Protocol
PHY parameters
Section titled “PHY parameters”| Parameter | Default | Notes |
|---|---|---|
| Band | 864–868 MHz | NZ ISM band (matches EU868 hardware) |
| Spreading factor | SF9 | Balance of range and airtime |
| Bandwidth | 125 kHz | Standard sub-GHz LoRa setting |
| Coding rate | 4/5 | Default |
| TX power | +14 dBm | Up to +20 dBm legal in NZ if needed |
| Preamble | 8 symbols | Default |
| CRC | On | LoRa hardware CRC |
| Sync word | Private | 0x12 (or per-deployment, see crypto.md) |
All parameters are configurable per deployment via the provisioning wizard.
Frame format
Section titled “Frame format”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.
0xFFFFFFFFfor 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.
Message types
Section titled “Message types”| Code | Name | Direction | Purpose |
|---|---|---|---|
| 0x01 | STATUS | endpoint → router | Routine check-in or trigger event |
| 0x02 | STATUS_ACK | router → endpoint | ACK + optional piggybacked commands |
| 0x03 | JOIN | endpoint → router | First TX after reset / boot |
| 0x04 | JOIN_ACK | router → endpoint | Welcomes node, may carry BLE-wake cmd |
| 0x05 | ANNOUNCE | endpoint → router | Full node info (location, etc.) |
| 0x06 | WHO_ARE_YOU | router → endpoint | Request full ANNOUNCE |
| 0x07 | COMMAND | router → endpoint | Push config / action (signed w/ K_admin) |
| 0x08 | COMMAND_ACK | endpoint → router | Confirm command applied |
| 0x10 | ROUTING_BEACON | router → router | Distance-vector neighbour advertisement |
| 0x11 | ROUTER_UPLINK | router → router/hub | Aggregated child traffic |
| 0x12 | ROUTER_DOWNLINK | hub/router → router | Commands destined for a child |
| 0x20 | KEY_ROLLOVER | hub → all | New K_group activation (signed K_admin) |
| 0x21 | HELP | endpoint → any | Orphaned-endpoint escalation broadcast |
STATUS payload (type 0x01)
Section titled “STATUS payload (type 0x01)”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.
STATUS_ACK payload (type 0x02)
Section titled “STATUS_ACK payload (type 0x02)”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_valid—hub_timecarries 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.
- bit 0:
- 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.
Endpoint behaviour on STATUS_ACK
Section titled “Endpoint behaviour on STATUS_ACK”- Verify K_group MIC. Drop on failure.
- If
time_valid, set RTC tohub_time. - Reset
missed_ack_count. - 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_versionand updateconfig_updated_aton every applied COMMAND that mutates config. - Reply with COMMAND_ACK for each applied command.
- Sleep until next scheduled TX.
Endpoint audit state
Section titled “Endpoint audit state”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.
JOIN payload (type 0x03)
Section titled “JOIN payload (type 0x03)”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.
JOIN_ACK payload (type 0x04)
Section titled “JOIN_ACK payload (type 0x04)”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 1config_pending(hub has COMMANDs queued — endpoint extends its RX window, as for STATUS_ACK); bit 2ble_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.
ANNOUNCE payload (type 0x05)
Section titled “ANNOUNCE payload (type 0x05)”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_1is the current primary). - router_id_N: 4 bytes each. List is exactly
router_list_lenlong. - 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).
ACK and retry
Section titled “ACK and retry”Endpoint TX behaviour
Section titled “Endpoint TX behaviour”- After each STATUS TX, open a 1-second RX window for STATUS_ACK or COMMAND.
- Every Nth STATUS sets the ack-requested bit (default
N = 4, i.e. once a day). - If
ack-requestedis set but no STATUS_ACK arrives in the RX window, incrementmissed_ack_count. - If
missed_ack_count ≥ 3, switch to help mode: TX every 30 minutes withack-requested = 1andhelp = 1, until an ACK arrives or a timeout fires. - In help mode, also try the secondary router after
missed_ack_count ≥ 6.
Trigger events
Section titled “Trigger events”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).
Router dedup
Section titled “Router dedup”Each router maintains a ring buffer (32 entries) of recent (src, seq) pairs.
Duplicates are silently dropped after the first frame has been forwarded.
Sequence numbers
Section titled “Sequence numbers”- 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”.
JOIN sequence
Section titled “JOIN sequence”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 ───────────────────► ANNOUNCEIf JOIN_ACK is not received within a configurable timeout (default 60 s):
- Retry against the primary router up to 3 times.
- Retry against the secondary router up to 3 times.
- Enter help mode and broadcast HELP frames.
- BLE is never enabled without a valid JOIN_ACK carrying the wake command.
COMMAND payload (type 0x07)
Section titled “COMMAND payload (type 0x07)”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_payloadusing K_admin. Required for privileged commands (see crypto.md).
Defined commands
Section titled “Defined commands”| cmd_type | Name | Privileged | Payload |
|---|---|---|---|
| 0x01 | set_router_list | yes | list_len(1), router_id(4) × N |
| 0x02 | add_router_to_list | yes | router_id(4), position(1) |
| 0x03 | remove_router_from_list | yes | router_id(4) |
| 0x04 | reorder_router_list | yes | list_len(1), router_id(4) × N (new order) |
| 0x05 | set_check_in_interval | yes | seconds(4) |
| 0x06 | set_ack_interval | yes | every_n_tx(2) |
| 0x07 | wake_ble | yes | minutes(1) |
| 0x08 | rotate_key | yes | new_K_group(16), activate_epoch(4) |
| 0x09 | request_announce | no | (empty) |
| 0x0A | factory_reset_remote | yes | confirmation_nonce(4) |
| 0x0B | set_low_batt_threshold | yes | millivolts(2) |
| 0x0C | set_autonomous_reorder | yes | enabled(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.
Endpoint persistence
Section titled “Endpoint persistence”After applying any COMMAND that mutates config:
- Write the updated config to flash atomically (e.g. dual-bank).
- Increment
config_version; recordconfig_updated_at = hub_time. - Send
COMMAND_ACKwithcmd_seqand the newconfig_version. - If the COMMAND was a router reassignment, the next STATUS will be sent to the new primary; the secondary is updated independently.
COMMAND_ACK payload (type 0x08)
Section titled “COMMAND_ACK payload (type 0x08)”+--------+--------+--------+| cmd_seq(2) | result(1) | new_config_version(2) |+--------+--------+--------+- result:
0x00success,0x01bad MIC,0x02replay,0x03unknown cmd_type,0x04payload malformed,0x05apply failed.
HELP payload (type 0x21)
Section titled “HELP payload (type 0x21)”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.
ROUTING_BEACON payload (type 0x10)
Section titled “ROUTING_BEACON payload (type 0x10)”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%.
ROUTER_UPLINK aggregation
Section titled “ROUTER_UPLINK aggregation”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.
Reserved type space
Section titled “Reserved type space”0x00: reserved (invalid)0x01–0x0F: endpoint/router app traffic0x10–0x1F: routing and aggregation0x20–0x2F: key management0x30–0xFE: reserved for future use0xFF: reserved (invalid)