Source: firmware/README.md
herdtag firmware
firmware/
core/ portable C99 port of the Python controller, geo and protocol code (the logic that matters)
ht_core.h/.c
test/ golden-vector replay; vectors.h is GENERATED from Python
Makefile
tag/ Arduino sketch for RAK4631 (UNTESTED until hardware arrives)
tag.ino pins.h secrets.example.h
gnss_hal.h gnss_hal_rak12500.cpp GNSS behind a 4-call interface (see below)
ht_core.{c,h} copies of core/
Core: build and test on a laptop
cd ~/herdtag
make -C firmware/core test # float + double builds, plus a check that tag/ copies are current
make -C firmware/core vectors # regenerate test/vectors.h from Python (runs on its own when the .py files change)
make -C firmware/core sync # copy core into the sketch after editing it
The test replays 11 scenarios (about 550 controller steps, 3,630 checks) generated by
scripts/gen_vectors.py from the Python reference. Each step checks state, cue,
next-fix interval, distance, approach rate and events. The scenarios: approach with
cue, walk away, degraded fix, breach and return, breach through a degraded fix,
episode cap, hourly cap, time-to-zone sampling, motion gate, mid-run fence swap, and
a 400-step noisy random walk over the non-convex default pasture. On top of that:
uplink bytes, fragmented fence downlinks delivered out of order plus a corrupt-CRC
case, and frame conversion.
Rule: Python is the spec. Change src/herdtag/*.py first, regenerate the
vectors, then make C match.
Where C differs from Python on purpose
- Bounded buffers. Trend history is a ring of 16, so if more than 16 fixes land
in one 30 s window the oldest is dropped. That can't happen with the default
5 s minimum interval (at most 7). Burst history holds 64, which must stay at or
above
max_bursts_per_hour. Fences hold at most 32 vertices and 32 fragments. - Types. Geometry is
floatby default. Time (ht_time) is alwaysdouble, so the tag doesn't lose timestamp precision over months. - lat/lon to local x/y subtracts in integer 1e-7 degrees before converting, to keep float precision. That matches Python to within 5 cm.
- Events are bit flags rather than a list of strings, so the same event can't appear twice in one decision. Python never emits a duplicate either.
Tag: flashing the RAK4631
Untested. The sketch has only been syntax-checked against stub headers;
arduino-cliwasn't available here. Expect a bring-up pass to fix library version mismatches.
- Arduino IDE → Settings → Additional boards manager URLs:
https://raw.githubusercontent.com/RAKWireless/RAKwireless-Arduino-BSP-Index/main/package_rakwireless_index.jsonThen Boards Manager → install RAKwireless nRF Boards → select WisBlock RAK4631. (This is the Adafruit-nRF52-based BSP, not RUI3.) - Library Manager, install: - SX126x-Arduino (beegee-tokyo) for LoRaWAN - SparkFun u-blox GNSS Arduino Library (v2.x) for the RAK12500 - SparkFun LIS3DH Arduino Library for the RAK1904 - LittleFS / InternalFileSystem come with the BSP
cp firmware/tag/secrets.example.h firmware/tag/secrets.hand fill in DevEUI, JoinEUI and AppKey from The Things Stack or ChirpStack.secrets.his gitignored.pins.halready holds the verified map fromhardware/BUILD.md→ Firmware pin map:
| Signal | Pin | Arduino # | Header | Notes |
|---|---|---|---|---|
PIN_CUE |
WB_A1 | 31 | J11 "AIN1" | PWM to AO3400A gate → PUI AT-1442-TT-WP-R piezo |
PIN_VIBE |
WB_IO1 | 17 | J11 "IO1" | optional; verify with RAK Pin Mapper that slot-A GNSS doesn't use IO1 |
PIN_GNSS_POWER |
WB_IO2 | 34 | — | 3V3_S switched-rail enable |
| GNSS | I2C SDA 13 / SCL 14 | — | slot A | RAK12500, addr 0x42 |
PIN_ACCEL_INT |
WB_IO3 | 21 | slot C | RAK1904 INT1; I2C addr 0x18 or 0x19 (unconfirmed) |
PIN_VBAT |
WB_A0 | 5 | — | ×1.73, then set VBAT_CAL per board against a meter |
At boot the sketch scans the I2C bus, logs every address it finds, and
uses whichever of 0x18 or 0x19 answers for the LIS3DH. Check the log on
first power-up. You should see 0x42 (GNSS) plus one of those two.
5. Open firmware/tag/tag.ino, upload, and open Serial at 115200. You should see
joined, then one line per fix.
If lmh_callback_t fails to compile, your SX126x-Arduino version added the
unconf_finished and conf_finished callbacks. Append two handlers, as the
library's RAK4630 OTAA example does.
What the tag does
- Joins over OTAA on US915, sub-band 2 (channels 8–15 plus 65, which TTN and most gateways use). Device class A. ADR is on, starting at DR0.
- Sleeps (FreeRTOS semaphore plus SoftwareTimer) until the earlier of the next fix or the next uplink.
- Takes a GNSS fix through
gnss_hal.hwith a 45 s timeout, and passes the receiver's horizontal accuracy estimate to the controller ash_acc. With no fix it passesh_acc = 999, so the controller goes DEGRADED and never cues. - On
cueit plays a fixed-volume 3 kHz pattern (250 ms on, 150 ms off) forcue_burst_s. - Uplinks every 15 min, immediately on
breachorcue_ignored, immediately to ack a new fence, and every 30 s while a fence download is incomplete. Class A only receives right after it transmits, so it has to keep "pulling" fragments. - Saves the active fence to internal flash, so a reboot doesn't lose the boundary.
- The accelerometer is only read when
use_motion_gateis enabled, and it's off.
GNSS module swap (RAK12500 is being discontinued)
The sketch only talks to the GNSS through gnss_hal.h: gnss_begin,
gnss_get_fix, gnss_sleep and gnss_wake. The fallback module, RAK12501,
uses a different GNSS chip and protocol. To support it, write
gnss_hal_rak12501.cpp with the same four functions, guard it with
#if GNSS_RAK12501, and swap #define GNSS_RAK12500 1 in pins.h for
GNSS_RAK12501. It must still return a horizontal accuracy in metres. If the
new chip doesn't report one, derive it conservatively, for example from HDOP
× about 5 m. Underestimating it would let the tag cue on bad fixes. Re-check
the slot and pins with RAK Pin Mapper, since they may differ.
Known gaps: GNSS stays powered between fixes: gnss_sleep() and gnss_wake() are no-ops until the
PPK2 measurement session settles the rail-cycling vs. hot-start trade-off. The battery percentage uses
a crude linear LiPo curve. Solar charge state isn't reported.
Over-the-air formats (same as src/herdtag/protocol.py)
Uplink, FPort 1, 11 bytes, big-endian. This fits US915 DR0.
| bytes | field | encoding |
|---|---|---|
| 0–3 | lat | int32, 1e-7 deg |
| 4–7 | lon | int32, 1e-7 deg |
| 8 | battery | uint8 % (0–100) |
| 9 | state / flags | high nibble = state (0 inside, 1 near, 2 warning, 3 breach, 4 degraded); low nibble = flags: bit0 cue active, bit1 GPS degraded, bit2 cue ignored, bit3 fence ack |
| 10 | fence_id | uint8, the fence the tag is enforcing |
Fence downlink, FPort 2, fragmented. Each fragment is
fence_id u8 | frag_idx u8 | frag_count u8 | body chunk. Reassembled body:
origin_lat int32 1e-7 | origin_lon int32 1e-7 | n_vertices u8 |
n × (x int16 m, y int16 m) ; metres east/north of origin, ±32 km
crc16 u16 ; CRC-16/CCITT-FALSE (init 0xFFFF, poly 0x1021) over everything before it
Fragments can arrive in any order. A fragment with a different fence_id or
frag_count restarts reassembly. A CRC failure discards the whole fence, and the
tag keeps enforcing the old one. Limits on the tag: 3–32 vertices, at most 32
fragments, at most 61 body bytes per fragment. Use the Python
encode_fence(..., max_frag=51) for US915 RX windows at DR8 or above, or
max_frag=11 if you're stuck at the lowest data rates.