Source: docs/protocol.md
Over-the-air protocol
Two message types travel over LoRaWAN. Both are defined in src/herdtag/protocol.py, and byte-identical in firmware/core/ht_core.c. All multi-byte integers are big-endian.
| Direction | FPort | Message | Size |
|---|---|---|---|
| Tag → backend (uplink) | 1 | Position report | exactly 11 bytes |
| Backend → tag (downlink) | 2 | Fence fragment | 4–51 bytes each, 1+ fragments per fence |
The backend ignores uplinks on any other port, or of any other length, and logs an unknown_payload event.
Uplink: position report (11 bytes)
| Bytes | Field | Type | Encoding |
|---|---|---|---|
| 0–3 | lat |
int32 | degrees × 10⁷ (about 1 cm resolution) |
| 4–7 | lon |
int32 | degrees × 10⁷ |
| 8 | battery_pct |
uint8 | 0–100 (clamped) |
| 9 | state / flags |
uint8 | high nibble = state code, low nibble = flags |
| 10 | fence_id |
uint8 | id of the fence the tag is enforcing (0 = none yet) |
State codes (high nibble, the same order as controller.State):
| Code | State |
|---|---|
| 0 | inside |
| 1 | near |
| 2 | warning |
| 3 | breach |
| 4 | degraded |
Flags (low nibble):
| Bit | Constant | Meaning |
|---|---|---|
| 0 (0x1) | FLAG_CUE |
At least one cue burst since the last uplink |
| 1 (0x2) | FLAG_DEGRADED |
Latest fix was degraded |
| 2 (0x4) | FLAG_IGNORED |
A cue episode hit its cap (cue_ignored) since the last uplink |
| 3 (0x8) | FLAG_FENCE_ACK |
The tag has just applied the fence named in fence_id |
Not carried: the turned and hourly_cap events, and cue counts. The backend can only see "at least one cue since the last report".
Worked example
Generated with the Python encoder:
from herdtag.protocol import Uplink, FLAG_CUE
from herdtag.controller import State
Uplink(38.4512345, -96.5498765, 87, State.WARNING, FLAG_CUE, 1).encode().hex(" ")
# '16 eb 31 59 c6 73 a8 73 57 21 01'
| Bytes | Value | Decoded |
|---|---|---|
16 eb 31 59 |
384512345 | lat 38.4512345 |
c6 73 a8 73 |
−965498765 | lon −96.5498765 |
57 |
87 | battery 87 % |
21 |
state 2, flags 0x1 | warning, cue active |
01 |
1 | enforcing fence 1 |
Downlink: fence (fragmented)
A fence is a polygon of 3–32 corners. It's sent as a body split into fragments:
body = origin_lat int32 (1e-7 deg)
| origin_lon int32 (1e-7 deg)
| n_vertices uint8
| n × ( x int16 metres east of origin, y int16 metres north of origin )
| crc16 uint16 ; CRC-16/CCITT-FALSE over everything before it
- Origin is the mean of the corner coordinates (
Fence.from_latlon). Corners are relative offsets, so they're stored to the nearest whole metre. Each corner can move by up to 0.5 m. That's well under GPS error, but worth knowing. - Offsets must fit int16, so the fence has to lie within ±32 km of its centre. The backend rejects anything larger.
- CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, no reflection, no final XOR. The check value for ASCII
"123456789"is0x29B1.
Each fragment is a 3-byte header plus a chunk of the body:
| Byte | Field |
|---|---|
| 0 | fence_id (1–255) |
| 1 | frag_idx (0-based) |
| 2 | frag_count |
| 3… | next max_frag − 3 bytes of the body |
Worked example
A four-corner fence about 350 × 330 m, as fence id 1:
from herdtag.geo import Fence
from herdtag.protocol import encode_fence
frame, fence = Fence.from_latlon([(38.4500, -96.5520), (38.4500, -96.5480),
(38.4530, -96.5480), (38.4530, -96.5520)])
[f.hex(" ") for f in encode_fence(1, frame, fence, max_frag=51)]
At max_frag=51 (the backend's setting) it fits in one 30-byte fragment:
01 00 01 | 16 eb 3b b8 c6 73 a3 a0 04 ff 52 ff 59 00 ae ff 59 00 ae 00 a7 ff 52 00 a7 20 e6
hdr origin lat origin lon n=4 (-174,-167) (174,-167) (174,167) (-174,167) crc
At max_frag=11 (for the lowest data rates) the same fence takes four fragments:
01 00 04 16 eb 3b b8 c6 73 a3 a0
01 01 04 04 ff 52 ff 59 00 ae ff
01 02 04 59 00 ae 00 a7 ff 52 00
01 03 04 a7 20 e6
Reassembly rules (tag side)
- Fragments can arrive in any order.
- A fragment whose
fence_idorfrag_countdiffers from the one being assembled restarts reassembly. - If the CRC fails, the whole fence is thrown away and the tag keeps enforcing the old one.
- On success the tag saves the fence to flash, swaps it in (which resets trend history and the cue episode), and sets
FLAG_FENCE_ACKon its next uplink, which it sends right away. - Tag limits (C): at most 32 vertices, at most 32 fragments, at most 61 body bytes per fragment.
Data-rate constraints (US915)
| Constraint | Value | Consequence |
|---|---|---|
| Max uplink payload at DR0 (SF10/125 kHz, longest range) | 11 bytes | The position report is exactly 11 bytes, so it always fits, even at the worst link |
| Max downlink payload in RX2 (DR8, SF12/500 kHz) | 53 bytes | The backend uses MAX_FRAG = 51, so one fragment fits in the default RX2 window |
| Class A receive windows | only after an uplink | One fragment per uplink. The tag uplinks every 30 s while a fence is incomplete |
| Sub-band | FSB2 (channels 8–15 + 65) | Must match the gateway and TTS. See the Glossary |
| Airtime | ~0.37 s for 11 bytes at SF10 | Used in power.py for the energy estimate |
Encode and decode helpers: Uplink.encode/decode, encode_fence(fence_id, frame, fence, max_frag) and decode_fence(frags) in Python. In C, see ht_core.h.