HerdTag

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.

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

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

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)

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.