HerdTag

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

Tag: flashing the RAK4631

Untested. The sketch has only been syntax-checked against stub headers; arduino-cli wasn't available here. Expect a bring-up pass to fix library version mismatches.

  1. Arduino IDE → Settings → Additional boards manager URLs: https://raw.githubusercontent.com/RAKWireless/RAKwireless-Arduino-BSP-Index/main/package_rakwireless_index.json Then Boards Manager → install RAKwireless nRF Boards → select WisBlock RAK4631. (This is the Adafruit-nRF52-based BSP, not RUI3.)
  2. 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
  3. cp firmware/tag/secrets.example.h firmware/tag/secrets.h and fill in DevEUI, JoinEUI and AppKey from The Things Stack or ChirpStack. secrets.h is gitignored.
  4. pins.h already holds the verified map from hardware/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

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.