Skip to content

Protocol

spec v0.5.0
ParameterDefaultNotes
Band864–868 MHzNZ ISM band (matches EU868 hardware)
Spreading factorSF9Balance of range and airtime
Bandwidth125 kHzStandard sub-GHz LoRa setting
Coding rate4/5Default
TX power+14 dBmUp to +20 dBm legal in NZ if needed
Preamble8 symbolsDefault
CRCOnLoRa hardware CRC
Sync wordPrivate0x12 (or per-deployment, see crypto.md)

All parameters are configurable per deployment via the provisioning wizard.

All frames share a common envelope. Payload size is small (≤ 32 bytes plaintext in normal operation) to keep airtime low. Fixed overhead is 16 bytes per frame (12-byte clear header + 4-byte MIC).

+--------+--------+--------+--------+--------+---------------+--------+
| ver(1) | type(1)| src(4) | dst(4) | seq(2) | ciphertext(N) | MIC(4) |
+--------+--------+--------+--------+--------+---------------+--------+
\______________ clear header, 12 B _________/ \___ AES-CCM payload ___/
\__________________ authenticated as AAD ___/
  • ver: protocol version (start at 0x01).
  • type: message type (see table below).
  • src: 32-bit source node ID.
  • dst: 32-bit destination node ID. 0xFFFFFFFF for broadcast.
  • seq: 16-bit monotonic per-source sequence number.
  • ciphertext: AES-128-CCM encrypted payload under K_group. Plaintext is type-specific (see message types).
  • MIC: 4-byte authentication tag from AES-CCM.

The 12-byte clear header is transmitted in the clear and fed to AES-CCM as additional authenticated data (AAD), so it cannot be modified in flight even though it is readable. This lets routers route frames without holding the destination’s key, and lets passive observers see that traffic exists (acceptable threat model).

The AES-CCM nonce is not transmitted. It is derived at both ends as src(4) || seq(2) || dir(1), where dir = 0 for uplink (toward hub) and dir = 1 for downlink — dir follows from the message type’s direction (see the table below). Every input is already in the clear header or implied by the type, so sending the nonce would waste 7 bytes of airtime. Nonce uniqueness is guaranteed by the monotonic per-source seq; dir keeps the two directions from ever colliding. See crypto.md for the full construction.

CodeNameDirectionPurpose
0x01STATUSendpoint → routerRoutine check-in or trigger event
0x02STATUS_ACKrouter → endpointACK + optional piggybacked commands
0x03JOINendpoint → routerFirst TX after reset / boot
0x04JOIN_ACKrouter → endpointWelcomes node, may carry BLE-wake cmd
0x05ANNOUNCEendpoint → routerFull node info (location, etc.)
0x06WHO_ARE_YOUrouter → endpointRequest full ANNOUNCE
0x07COMMANDrouter → endpointPush config / action (signed w/ K_admin)
0x08COMMAND_ACKendpoint → routerConfirm command applied
0x10ROUTING_BEACONrouter → routerDistance-vector neighbour advertisement
0x11ROUTER_UPLINKrouter → router/hubAggregated child traffic
0x12ROUTER_DOWNLINKhub/router → routerCommands destined for a child
0x20KEY_ROLLOVERhub → allNew K_group activation (signed K_admin)
0x21HELPendpoint → anyOrphaned-endpoint escalation broadcast

Routine check-in. Plaintext payload, ~10 bytes:

+--------+--------+--------+--------+--------+
| flags(1) | batt_mV(2) | uptime_h(2) | trigger_age_s(2) |
| last_ack_rssi(1) | last_ack_snr(1) | rsvd(1) |
+--------+--------+--------+--------+--------+
  • flags:
    • bit 0: trap closed
    • bit 1: triggered since last check-in
    • bit 2: low battery
    • bit 3: tamper detect
    • bit 4: ack-requested (router must reply with STATUS_ACK)
    • bit 5: help mode (escalation)
    • bits 6–7: reserved
  • batt_mV: raw battery millivolts. The hub interprets SoC / SoH.
  • uptime_h: hours since last reset (saturates at 65535 = ~7.5 years).
  • trigger_age_s: seconds since trap last triggered, 0 if never.
  • last_ack_rssi: signed int8, RSSI of the last received STATUS_ACK from the current primary router. The hub uses this to maintain its per-link quality matrix and decide preference-list reorderings. 0x7F = no recent ACK (e.g. first transmission after boot).
  • last_ack_snr: signed int8, SNR of the same packet. 0x7F = unknown.
  • rsvd: reserved, zero.

Location is not carried in STATUS. The hub holds the canonical location from provisioning. To re-fetch, the hub sends WHO_ARE_YOU and the node replies with ANNOUNCE.

Router → endpoint reply, sent only when the matching STATUS had ack-requested = 1. Plaintext payload, 7 bytes:

+--------+--------+--------+
| flags(1) | hub_time(4) | config_version(2) |
+--------+--------+--------+
  • flags:
    • bit 0: config_pending — router has queued COMMANDs for this endpoint. Endpoint MUST extend its RX window to 30 seconds (default) and accept the incoming COMMAND frames before sleeping.
    • bit 1: time_validhub_time carries a valid wall-clock time the endpoint may use to set its RTC.
    • bit 2: rekey_pending — a KEY_ROLLOVER COMMAND will be delivered in the extended RX window (advisory; not load-bearing).
    • bits 3–7: reserved.
  • hub_time: unsigned 32-bit unix timestamp in seconds. The hub is the authoritative time source for the deployment.
  • config_version: hub’s known config version for this endpoint. If it does not match the endpoint’s config_version, the hub will issue COMMANDs to reconcile.
  1. Verify K_group MIC. Drop on failure.
  2. If time_valid, set RTC to hub_time.
  3. Reset missed_ack_count.
  4. If config_pending:
    • Open a 30 second RX window immediately.
    • Process any COMMAND frames received (K_admin verification required for privileged commands — see crypto.md).
    • Increment config_version and update config_updated_at on every applied COMMAND that mutates config.
    • Reply with COMMAND_ACK for each applied command.
  5. Sleep until next scheduled TX.

Every endpoint persists in flash:

  • config_version (uint16) — incremented on every applied COMMAND.
  • config_updated_at (uint32) — unix timestamp of the most recent applied COMMAND.
  • last_key_rotation_at (uint32) — unix timestamp of the most recent successful KEY_ROLLOVER.

These three fields are returned in every ANNOUNCE response so the hub can audit deployment-wide drift.

A node’s first uplink after boot/reset, before it begins the STATUS loop. Plaintext payload, 6 bytes:

+----------+--------+--------+--------+--------+
| proto_role(1) | hw_rev(1) | fw_ver(2) | flags(1) | rsvd(1) |
+----------+--------+--------+--------+--------+
  • proto_role: 1 = endpoint, 2 = router, 3 = tech (see architecture.md node roles).
  • hw_rev: hardware revision of the board.
  • fw_ver: firmware version, encoded major × 256 + minor.
  • flags: bit 0 ble_wake_request (the node asks the hub to grant a BLE wake in JOIN_ACK); bits 1–7 reserved.
  • rsvd: reserved, zero.

Hub → endpoint reply welcoming the node. Mirrors the STATUS_ACK shape so the endpoint’s RX path is uniform. Plaintext payload, 7 bytes:

+--------+-------------+-------------------+
| flags(1) | hub_time(4) | config_version(2) |
+--------+-------------+-------------------+
  • flags: bit 0 accepted (clear = node rejected); bit 1 config_pending (hub has COMMANDs queued — endpoint extends its RX window, as for STATUS_ACK); bit 2 ble_wake_granted (hub granted the requested BLE wake); bits 3–7 reserved.
  • hub_time: unsigned 32-bit unix timestamp; the hub is the authoritative clock.
  • config_version: hub’s known config version for this endpoint.

On the current bench there is no router tier yet (Phase 3), so JOIN / JOIN_ACK / ANNOUNCE run endpoint ↔ hub directly; the router becomes a pass-through (as drawn in the JOIN sequence below) when that tier lands.

Full node descriptor. Sent on join and on demand:

+----------+----------+----------+----------+----------+----------+
| lat_e7(4) | lon_e7(4) | alt_m(2) | hw_rev(1) | fw_ver(2) | role(1) |
+----------+----------+----------+----------+----------+----------+
| router_list_len(1) | router_id_1(4) | router_id_2(4) | ... up to N |
+----------+----------+----------+----------+
| config_version(2) | config_updated_at(4) | last_key_rotation_at(4) |
+----------+----------+----------+
| autonomous_reorder(1) | rsvd(1) |
+----------+----------+
| name_len(1) | name(...) |
+----------+----------+
  • lat_e7 / lon_e7: degrees × 10⁷, signed int32. (Standard “E7” encoding.)
  • alt_m: altitude in metres, signed int16.
  • router_list_len: number of routers in this endpoint’s preference list. Valid range 1–8, default 4. Order is preference order (router_id_1 is the current primary).
  • router_id_N: 4 bytes each. List is exactly router_list_len long.
  • config_version / config_updated_at: see “Endpoint audit state” below.
  • last_key_rotation_at: unix timestamp of last successful KEY_ROLLOVER.
  • autonomous_reorder: 0 = hub-curated only (default), 1 = endpoint may reorder its preference list based on observed signal quality, with self-recovery fallback to last hub-pushed order.
  • name: short UTF-8 string (≤ 16 bytes recommended).
  1. After each STATUS TX, open a 1-second RX window for STATUS_ACK or COMMAND.
  2. Every Nth STATUS sets the ack-requested bit (default N = 4, i.e. once a day).
  3. If ack-requested is set but no STATUS_ACK arrives in the RX window, increment missed_ack_count.
  4. If missed_ack_count ≥ 3, switch to help mode: TX every 30 minutes with ack-requested = 1 and help = 1, until an ACK arrives or a timeout fires.
  5. In help mode, also try the secondary router after missed_ack_count ≥ 6.

Trigger events are the one frame that must not be lost. Send the same (src, seq) three times with randomised backoff:

  • TX1: immediately on trigger
  • TX2: 8 ± 2 s later
  • TX3: 25 ± 5 s later

The router dedups by (src, seq).

Each router maintains a ring buffer (32 entries) of recent (src, seq) pairs. Duplicates are silently dropped after the first frame has been forwarded.

  • 16-bit monotonic, per source.
  • Persisted to flash every 16 increments (to bound flash wear). On boot, jump forward by 16 to guarantee monotonicity across resets.
  • Wrap is handled by the receiver as “newer if (new - old) mod 65536 < 32768”.

The JOIN/JOIN_ACK exchange is the only authenticated way to enable BLE on a node.

endpoint router hub
───────── ─────── ──────
reset
JOIN(seq, role, fw_ver) ────►
cache, forward ─────────────► JOIN
check pending
cmds for src
◄────────────────── JOIN_ACK
(+ wake_ble?)
◄────────────────── JOIN_ACK
apply commands (wake BLE if requested)
ANNOUNCE ─────────► forward ───────────────────► ANNOUNCE

If JOIN_ACK is not received within a configurable timeout (default 60 s):

  1. Retry against the primary router up to 3 times.
  2. Retry against the secondary router up to 3 times.
  3. Enter help mode and broadcast HELP frames.
  4. BLE is never enabled without a valid JOIN_ACK carrying the wake command.

Delivered in the extended RX window after a config_pending STATUS_ACK. Each COMMAND is a single, self-contained instruction:

+--------+--------+--------+--------+
| cmd_type(1) | cmd_seq(2) | cmd_payload(...) | admin_mic(8) |
+--------+--------+--------+--------+
  • cmd_type: identifies which command (table below).
  • cmd_seq: monotonic counter from the hub. Endpoint rejects cmd_seq ≤ last_applied_cmd_seq (replay protection at the command layer).
  • cmd_payload: command-specific bytes.
  • admin_mic: 8-byte AES-CMAC over src||dst||cmd_type||cmd_seq||cmd_payload using K_admin. Required for privileged commands (see crypto.md).
cmd_typeNamePrivilegedPayload
0x01set_router_listyeslist_len(1), router_id(4) × N
0x02add_router_to_listyesrouter_id(4), position(1)
0x03remove_router_from_listyesrouter_id(4)
0x04reorder_router_listyeslist_len(1), router_id(4) × N (new order)
0x05set_check_in_intervalyesseconds(4)
0x06set_ack_intervalyesevery_n_tx(2)
0x07wake_bleyesminutes(1)
0x08rotate_keyyesnew_K_group(16), activate_epoch(4)
0x09request_announceno(empty)
0x0Afactory_reset_remoteyesconfirmation_nonce(4)
0x0Bset_low_batt_thresholdyesmillivolts(2)
0x0Cset_autonomous_reorderyesenabled(1)

The router-list commands (0x01–0x04) supersede the older set_primary_router / set_secondary_router pair. position in add_router_to_list is 0-indexed; using 0xFF appends at the bottom of the preference list (the typical hub behaviour when a new nearby router is registered).

Endpoints enforce 1 ≤ list_len ≤ 8. A set_router_list command with list_len = 1 is accepted but the endpoint may log a warning to the hub on next STATUS (via a future flags bit, reserved for now).

The first applied COMMAND in a config-pull session increments config_version. The last successful COMMAND’s wall-clock time is recorded as config_updated_at.

After applying any COMMAND that mutates config:

  1. Write the updated config to flash atomically (e.g. dual-bank).
  2. Increment config_version; record config_updated_at = hub_time.
  3. Send COMMAND_ACK with cmd_seq and the new config_version.
  4. If the COMMAND was a router reassignment, the next STATUS will be sent to the new primary; the secondary is updated independently.
+--------+--------+--------+
| cmd_seq(2) | result(1) | new_config_version(2) |
+--------+--------+--------+
  • result: 0x00 success, 0x01 bad MIC, 0x02 replay, 0x03 unknown cmd_type, 0x04 payload malformed, 0x05 apply failed.

Sent when an endpoint has exhausted retries against both primary and secondary. Broadcast dst = 0xFFFFFFFF. Any router that hears it forwards to the hub. The hub may adopt the orphan by issuing a COMMAND that updates its primary/secondary to reachable routers.

Router-tier distance-vector advertisement, broadcast among routers:

+----------+----------+----------+----------+
| hops_to_hub(1) | link_cost(1) | parent_id(4) | seq(2) |
+----------+----------+----------+----------+
  • hops_to_hub: 0 at the hub, up to 15.
  • link_cost: cumulative path cost (RSSI-derived, 0–255).
  • parent_id: the router’s currently selected parent (for split-horizon).

Beacon interval: 30–60 minutes, jittered ±20%.

Routers may bundle multiple child STATUS frames into a single ROUTER_UPLINK to save airtime. The aggregate frame carries a count and a list of (src, seq, type, payload) tuples, all under the same outer AES-CCM envelope.

Routers should aggregate within a small window (e.g. 5 s) if hub-direction airtime is constrained, but for v1 it’s acceptable to forward each frame individually.

  • 0x00: reserved (invalid)
  • 0x01–0x0F: endpoint/router app traffic
  • 0x10–0x1F: routing and aggregation
  • 0x20–0x2F: key management
  • 0x30–0xFE: reserved for future use
  • 0xFF: reserved (invalid)