Skip to content

frames.yaml

spec v0.5.0

The machine-readable contract. Source of truth: spec/frames.yaml.

spec_version: "0.5.0"
title: meshtrap over-the-air frame formats
status: "envelope ratified; STATUS, STATUS_ACK, COMMAND, COMMAND_ACK, JOIN, JOIN_ACK, ANNOUNCE payloads defined (Phase 2 bring-up)"
authoritative_for: wire format
companion_prose_doc: ../docs/protocol.md
# This file is the source of truth for byte-level LoRa frame layouts.
# Concrete field definitions are populated in lockstep with firmware
# bring-up. As of 0.2.0 the envelope and the STATUS (0x01) payload are
# ratified and implemented in firmware/field (Zephyr TX) and
# firmware/hub (ESP-IDF RX). Remaining payloads stay pending.
phy:
band_mhz_min: 864
band_mhz_max: 868
defaults:
spreading_factor: 9
bandwidth_khz: 125
coding_rate: "4/5"
tx_power_dbm: 14
preamble_symbols: 8
crc: on
sync_word: 0x12 # default; deployments override with a value derived from K_group
envelope:
# On-wire frame layout, in order:
#
# [ clear header, 12 B ] [ ciphertext, N B ] [ mic, 4 B ]
#
# The 12-byte clear header is transmitted in the clear AND fed
# verbatim to AES-CCM as additional authenticated data (AAD), so it
# cannot be tampered with even though it is readable. Fixed overhead
# per frame is 16 bytes (12 header + 4 MIC).
on_wire:
- {name: ver, bytes: 1, description: "protocol version, starts at 0x01"}
- {name: type, bytes: 1, description: "message type, see message_types"}
- {name: src, bytes: 4, description: "source node ID, 32 bits, network-scoped"}
- {name: dst, bytes: 4, description: "destination node ID, 0xFFFFFFFF = broadcast"}
- {name: seq, bytes: 2, description: "monotonic per-source sequence number"}
- {name: ciphertext, bytes: variable, description: "AES-CCM ciphertext of the type-specific payload"}
- {name: mic, bytes: 4, description: "AES-CCM authentication tag (truncated to 4 B)"}
header_clear_bytes: 12 # ver+type+src+dst+seq; this exact span is the AAD
crypto:
# See ../docs/crypto.md for the key hierarchy and rationale.
aead: AES-128-CCM
key: K_group # network-wide symmetric key; per-deployment
aad: clear_header # the 12-byte on-wire header, verbatim
mic_bytes: 4 # CCM tag length M = 4
nonce_bytes: 7 # CCM nonce length N = 7 (=> CCM L = 8)
# The nonce is DERIVED, never transmitted (it is fully reconstructible
# from fields already present in the clear header plus the frame's
# direction):
nonce_construction: "src(4) || seq(2) || dir(1)"
dir:
uplink: 0 # toward the hub
downlink: 1 # away from the hub
# dir is determined by the message type's direction (see
# ../docs/protocol.md message-type table); it is not a wire field.
nonce_uniqueness: "guaranteed by the monotonic per-source seq; dir separates up/down so the two directions never collide"
replay: "receiver rejects seq <= last_seen_seq[src] (mod-2^16 window, see protocol.md)"
message_types:
app:
range: [0x01, 0x0F]
defined:
0x01: STATUS
0x02: STATUS_ACK
0x03: JOIN
0x04: JOIN_ACK
0x05: ANNOUNCE
0x06: WHO_ARE_YOU
0x07: COMMAND
0x08: COMMAND_ACK
routing:
range: [0x10, 0x1F]
defined:
0x10: ROUTING_BEACON
0x11: ROUTER_UPLINK
0x12: ROUTER_DOWNLINK
key_management:
range: [0x20, 0x2F]
defined:
0x20: KEY_ROLLOVER
0x21: HELP
reserved:
range: [0x30, 0xFE]
invalid:
values: [0x00, 0xFF]
payloads:
# Concrete payload schemas land here as firmware implements them.
# These are the plaintext fed to / recovered from AES-CCM (the
# ciphertext on the wire is the encrypted form). Multi-byte integers
# are little-endian unless noted.
# STATUS (type 0x01) — routine endpoint check-in. 10 bytes plaintext.
# Ratified 0.2.0; implemented in firmware/field (TX) + firmware/hub (RX).
status:
status: ratified
direction: uplink # dir = 0
plaintext_bytes: 10
endianness: little
fields:
- {name: flags, bytes: 1, type: bitfield}
- {name: batt_mv, bytes: 2, type: uint16, description: "raw battery millivolts"}
- {name: uptime_h, bytes: 2, type: uint16, description: "hours since reset, saturates at 0xFFFF"}
- {name: trigger_age_s, bytes: 2, type: uint16, description: "seconds since last trap trigger, 0 if never / 0xFFFF saturates"}
- {name: last_ack_rssi, bytes: 1, type: int8, description: "RSSI dBm of last STATUS_ACK; 0x7F = none"}
- {name: last_ack_snr, bytes: 1, type: int8, description: "SNR dB of last STATUS_ACK; 0x7F = unknown"}
- {name: rsvd, bytes: 1, type: uint8, description: "reserved, zero"}
flags_bits:
0: trap_closed
1: triggered_since_last
2: low_battery
3: tamper_detect
4: ack_requested # router must reply with STATUS_ACK
5: help_mode
6: reserved
7: reserved
# STATUS_ACK (type 0x02) — router/hub reply to a STATUS that had
# ack_requested set. 7 bytes plaintext. Ratified 0.3.0.
status_ack:
status: ratified
direction: downlink # dir = 1
plaintext_bytes: 7
endianness: little
fields:
- {name: flags, bytes: 1, type: bitfield}
- {name: hub_time, bytes: 4, type: uint32, description: "unix seconds; hub is the authoritative clock"}
- {name: config_version, bytes: 2, type: uint16, description: "hub's known config version for this endpoint"}
flags_bits:
0: config_pending # endpoint should extend its RX window for COMMANDs
1: time_valid # hub_time carries a usable wall-clock time
2: rekey_pending # a KEY_ROLLOVER COMMAND will follow (advisory)
3: reserved
4: reserved
5: reserved
6: reserved
7: reserved
# JOIN (type 0x03) — endpoint's first uplink after boot/reset, before
# the STATUS loop. Announces the node's identity so the hub (via a
# router, once that tier exists) can welcome it and queue any pending
# config. 6 bytes plaintext. Ratified 0.5.0.
join:
status: ratified
direction: uplink # dir = 0
plaintext_bytes: 6
endianness: little
fields:
- {name: proto_role, bytes: 1, type: uint8, description: "node's role: 1=endpoint, 2=router, 3=tech (see docs/architecture.md node roles)"}
- {name: hw_rev, bytes: 1, type: uint8, description: "hardware revision of the board"}
- {name: fw_ver, bytes: 2, type: uint16, description: "firmware version, encoded major*256+minor"}
- {name: flags, bytes: 1, type: bitfield}
- {name: rsvd, bytes: 1, type: uint8, description: "reserved, zero"}
flags_bits:
0: ble_wake_request # node requests the hub grant a BLE wake in JOIN_ACK
1: reserved
2: reserved
3: reserved
4: reserved
5: reserved
6: reserved
7: reserved
# JOIN_ACK (type 0x04) — hub's reply welcoming the node. Mirrors the
# STATUS_ACK shape (flags + hub_time + config_version) so the endpoint
# RX path is uniform. 7 bytes plaintext. Ratified 0.5.0.
join_ack:
status: ratified
direction: downlink # dir = 1
plaintext_bytes: 7
endianness: little
fields:
- {name: flags, bytes: 1, type: bitfield}
- {name: hub_time, bytes: 4, type: uint32, description: "unix seconds; hub is the authoritative clock"}
- {name: config_version, bytes: 2, type: uint16, description: "hub's known config version for this endpoint"}
flags_bits:
0: accepted # the join is accepted; if clear, the node is rejected
1: config_pending # hub has COMMANDs queued (endpoint extends RX window)
2: ble_wake_granted # hub granted the requested BLE wake
3: reserved
4: reserved
5: reserved
6: reserved
7: reserved
# ANNOUNCE (type 0x05) — full node descriptor, sent right after a
# successful JOIN_ACK and on demand (WHO_ARE_YOU / request_announce).
# Variable length; bounded by the AEAD buffer. Ratified 0.5.0.
# Layout mirrors docs/protocol.md "ANNOUNCE payload".
announce:
status: ratified
direction: uplink # dir = 0
endianness: little
fields:
- {name: lat_e7, bytes: 4, type: int32, description: "latitude degrees x 1e7"}
- {name: lon_e7, bytes: 4, type: int32, description: "longitude degrees x 1e7"}
- {name: alt_m, bytes: 2, type: int16, description: "altitude in metres"}
- {name: hw_rev, bytes: 1, type: uint8, description: "hardware revision"}
- {name: fw_ver, bytes: 2, type: uint16, description: "firmware version, major*256+minor"}
- {name: role, bytes: 1, type: uint8, description: "node role (see join.proto_role)"}
- {name: router_list_len, bytes: 1, type: uint8, description: "number of router IDs that follow, 1-8 (default 4)"}
- {name: router_ids, bytes: variable, description: "router_list_len x uint32, preference order (router_id_1 = current primary)"}
- {name: config_version, bytes: 2, type: uint16, description: "endpoint's current config version"}
- {name: config_updated_at, bytes: 4, type: uint32, description: "unix seconds of the most recent applied COMMAND"}
- {name: last_key_rotation_at, bytes: 4, type: uint32, description: "unix seconds of the most recent successful KEY_ROLLOVER"}
- {name: autonomous_reorder, bytes: 1, type: uint8, description: "0=hub-curated only (default), 1=endpoint may reorder by signal"}
- {name: rsvd, bytes: 1, type: uint8, description: "reserved, zero"}
- {name: name_len, bytes: 1, type: uint8, description: "length of the name field that follows, <= 16 recommended"}
- {name: name, bytes: variable, description: "node name, UTF-8, name_len bytes"}
# COMMAND (type 0x07) — hub/tech downlink instruction, delivered in the
# extended RX window after a config_pending STATUS_ACK. Ratified 0.4.0.
#
# Two crypto layers: the whole frame is AES-128-CCM under K_group (the
# normal envelope), and the COMMAND plaintext carries an INNER
# admin_mic that proves hub/tech authority. The endpoint applies a
# command only if BOTH the outer K_group MIC and the inner admin_mic
# verify. This inner step is deliberately pluggable (see docs/crypto.md
# "Design constraint for v1 firmware"): v1 uses AES-CMAC; Option B/C
# swap it for K_admin_next / ECDSA without changing the frame.
command:
status: ratified
direction: downlink # dir = 1
endianness: little
# Plaintext layout (variable length): fixed head + payload + inner MIC.
fields:
- {name: cmd_type, bytes: 1, type: uint8, description: "see commands table"}
- {name: cmd_seq, bytes: 2, type: uint16, description: "monotonic per-hub command counter; endpoint rejects cmd_seq <= last_applied_cmd_seq"}
- {name: cmd_payload, bytes: variable, description: "command-specific, see commands table"}
- {name: admin_mic, bytes: 8, description: "AES-CMAC-128 truncated to 8 B over src(4)||dst(4)||cmd_type(1)||cmd_seq(2)||cmd_payload, keyed by K_admin or K_field per the command's privilege class"}
admin_mic:
algorithm: AES-CMAC-128
truncated_to_bytes: 8
covers: "src(4) || dst(4) || cmd_type(1) || cmd_seq(2) || cmd_payload"
key_selection: "K_admin for the most privileged commands; K_field for operational tuning. See docs/crypto.md K_admin/K_field split."
commands:
# privilege: which key signs the admin_mic (admin | field | none)
0x01: {name: set_router_list, privilege: admin, payload: "list_len(1), router_id(4) x N"}
0x02: {name: add_router_to_list, privilege: admin, payload: "router_id(4), position(1)"}
0x03: {name: remove_router_from_list, privilege: admin, payload: "router_id(4)"}
0x04: {name: reorder_router_list, privilege: admin, payload: "list_len(1), router_id(4) x N"}
0x05: {name: set_check_in_interval, privilege: field, payload: "seconds(4)"}
0x06: {name: set_ack_interval, privilege: field, payload: "every_n_tx(2)"}
0x07: {name: wake_ble, privilege: field, payload: "minutes(1)"}
0x08: {name: rotate_key, privilege: admin, payload: "new_K_group(16), activate_epoch(4)"}
0x09: {name: request_announce, privilege: none, payload: "(empty)"}
0x0A: {name: factory_reset_remote, privilege: admin, payload: "confirmation_nonce(4)"}
0x0B: {name: set_low_batt_threshold, privilege: admin, payload: "millivolts(2)"}
0x0C: {name: set_autonomous_reorder, privilege: admin, payload: "enabled(1)"}
# Firmware status: as of 0.4.0, set_ack_interval (0x06) is fully
# implemented end to end (decode, CMAC verify, apply, COMMAND_ACK).
# Other cmd_types decode + verify but return result=unknown_cmd_type
# until their apply handlers land.
# COMMAND_ACK (type 0x08) — endpoint reply to a COMMAND. 5 bytes
# plaintext, uplink. Ratified 0.4.0.
command_ack:
status: ratified
direction: uplink # dir = 0
plaintext_bytes: 5
endianness: little
fields:
- {name: cmd_seq, bytes: 2, type: uint16, description: "echoes the COMMAND's cmd_seq"}
- {name: result, bytes: 1, type: uint8, description: "see result codes"}
- {name: new_config_version, bytes: 2, type: uint16, description: "endpoint config_version after applying (unchanged on failure)"}
result_codes:
0x00: success
0x01: bad_mic # inner admin_mic failed
0x02: replay # cmd_seq <= last_applied_cmd_seq
0x03: unknown_cmd_type
0x04: payload_malformed
0x05: apply_failed
routing_beacon: {status: pending, see: ../docs/protocol.md}
help: {status: pending, see: ../docs/protocol.md}