Contract exposed by the SAO (Meshtastic LemSaoModule, UART slave) to any host reading it
over the connector's two signal pins. Deliberately host-agnostic: a host discovers a
L.E.M SAO via WHO_AM_I and doesn't need to know anything else about the host firmware
that's polling it. The reference host implementation is badge-2026 (lib/core/hardware/lem_sao.*).
The SAO connector's two signal pins carry UART instead of I2C (SAO side: GPIO1=TX,
GPIO2=RX; direction convention on the host side is documented in the host's own pinout).
100kHz-I2C-equivalent target throughput, 115200 baud in practice — a full read-only block
(~80 bytes + frame overhead) takes ~7-8ms, well under a 3Hz host poll rate.
Registers are 8-bit, addressed explicitly per frame (no auto-increment pointer):
Request (host -> SAO): [0xAA START] [CMD] [REG] [LEN] [PAYLOAD if CMD=WRITE] [CRC8]
Response (SAO -> host):
READ: [0x55 START] [STATUS] [LEN] [DATA] [CRC8]
WRITE: [0x55 START] [STATUS] [0x00 LEN] [CRC8]
CMD:0x01= READ,0x02= WRITE.REG: starting register (same address space as the table below).LEN: number of bytes to read/write (max useful value:0x50, the full read-only block;0x41= 65 for a fullTX_LEN+TX_BUFwrite).CRC8: CRC-8/CCITT (poly0x07) over every preceding byte in the frame.STATUS(response):0x00= OK, non-zero = error.- Distinct start bytes (
0xAArequest /0x55response) let either side resync by scanning the stream if a previous frame was misread. - Expected robustness on the host side: a strict per-read timeout, RX buffer purge before each new request, and a retry budget with a consecutive-failure counter before declaring the SAO unreachable (see "Disconnect behavior" below).
Read-only:
0x00 WHO_AM_I = 0x4C
0x01 FW_VERSION major<<4 | minor
0x02 STATUS bit0 mesh joined / bit1 RX pending / bit2 TX busy / bit3 radio fault /
bit4 RX_IS_DM (v0.4) / bit5 BLE_PAIRING (v0.6)
0x03 NODE_COUNT
0x04 PKT_RELAYED_L u16 LE, wraps
0x05 PKT_RELAYED_H
0x06 LAST_RSSI i8 dBm -- updated on every promiscuous packet
0x07 LAST_SNR i8 dB x4 -- updated on every promiscuous packet
0x08 RX_LEN text payload length -- updated only if RX_PORTNUM = TEXT_MESSAGE_APP
0x09-0x0C RX_FROM u32 LE, full NodeNum -- updated on every promiscuous packet (v0.2)
0x0D RX_ROLE u8 -- raw meshtastic_Config_DeviceConfig_Role, 0xFF = not yet resolved (v0.2)
0x0E RX_PORTNUM u8 -- raw meshtastic_PortNum, saturates to 0xFF if > 255 (v0.2)
0x0F HOPS_AWAY u8 -- hop_start - hop_limit, 0xFF = indeterminate (v0.3)
0x10-0x4F RX_BUF 64 bytes UTF-8, text only, filled if RX_PORTNUM = TEXT_MESSAGE_APP
Write:
0x50 TX_LEN 1-64
0x51-0x90 TX_BUF
0x91 TX_COMMIT write 0x01 = broadcast (ignored if TX busy)
0x92-0x9F OWN_LONG_NAME 14 bytes, read-only, UTF-8, not NUL-terminated on the wire --
truncated copy of this SAO's own long name (v0.7)
0xA0-0xA3 DOCK_NODENUM u32 LE -- target NodeNum for NFC out-of-band key injection (v0.5)
0xA4-0xC3 DOCK_PUBKEY 32 bytes -- target X25519 public key (v0.5)
0xC4 DOCK_COMMIT write 0x01 = stage async NodeDB commit + favorite (v0.5, see below)
0xC5-0xC8 OWN_NODENUM u32 LE, read-only -- this SAO's own NodeNum (v0.5)
0xC9-0xE8 OWN_PUBKEY 32 bytes, read-only -- this SAO's own X25519 public key (v0.5)
0xE9-0xED OWN_SHORT_NAME 5 bytes, read-only, UTF-8 -- this SAO's own short name (v0.7)
0xF0 CTRL bit0 clear counters
0xF1-0xF4 BLE_PIN u32 LE, read-only -- Bluetooth pairing passkey, valid only while
STATUS.BLE_PAIRING is set (v0.6)
0xF5-0xF6 OWN_BLE_SUFFIX 2 bytes, read-only -- last 2 MAC bytes, same suffix used in this
SAO's advertised Bluetooth device name (v0.8)
0x00-0x0F is the identity/status header block, shared convention for any SAO in this
project family (a host discovers capability via WHO_AM_I). No unallocated bytes remain in
that block as of v0.4.
OWN_NODENUM/OWN_PUBKEY (0xC5-0xE8) are read-only despite sitting address-wise inside
the 0xA0+ block alongside the write-only DOCK_* registers -- listed in address order rather
than moved up next to the other read-only block to keep the docking-related registers
grouped together. A host needs to read these before a docking exchange to know what identity
to hand over on its own behalf (previously an unexposed gap, see Version history v0.5).
0xA4-0xC3 (DOCK_PUBKEY) holds an X25519 public key -- Meshtastic's PKC direct-message
encryption uses X25519 (Diffie-Hellman key exchange), not Ed25519 (signing). Do not
confuse the two when implementing either side.
LAST_RSSI / LAST_SNR / RX_FROM / RX_ROLE / RX_PORTNUM / HOPS_AWAY all update
together on every promiscuous packet, so a single host read always sees a coherent tuple from
one packet. RX_LEN / RX_BUF stay text-only (only updated when RX_PORTNUM = TEXT_MESSAGE_APP). STATUS.RX_IS_DM (v0.4) joins the text-only group: recomputed (set or
cleared) on every promiscuous packet, but only meaningful when RX_PORTNUM = TEXT_MESSAGE_APP.
Known limitation, accepted as best-effort: LAST_RSSI only updates when the packet
carries a radio metric (mp.has_rx_rssi). A locally-generated/looped packet (e.g. a
ROUTING_APP ACK the SAO itself originates) still advances RX_FROM/RX_ROLE/RX_PORTNUM/
HOPS_AWAY, but leaves LAST_RSSI stale from the previous radio packet. The same packet
type also reports HOPS_AWAY as indeterminate. No fix planned -- treat as sampling noise, the
same category as the 3Hz poll rate itself.
No per-device addressing. WHO_AM_I is a fixed byte on every L.E.M SAO -- the protocol
assumes exactly one SAO on the bus. Two SAOs sharing the same physical signal pins is not
supported (see the host's own hardening notes for what that requires).
Writing 0x01 to DOCK_COMMIT (0xC4) does not perform the NodeDB injection
synchronously within that write transaction. It stages DOCK_NODENUM/DOCK_PUBKEY and sets
an internal flag, drained on the SAO's own next background tick rather than inline in the
UART responder path.
Why: the actual injection (NodeDB::commitRemoteKey() + set_favorite() +
saveToDisk()) ends in a synchronous, blocking flash write that acquires the SPI bus lock --
the same lock the LoRa radio driver uses for its own SPI transactions. Performing that write
inline inside the time-critical UART request/response handler risks starving the radio's SPI
access and blowing the UART side's own per-frame timeout budget. This is not a deadlock risk
(single lock, no circular wait), but a latency/starvation one -- worth stating precisely so a
future contributor doesn't chase the wrong failure mode.
Host implication: a DOCK_COMMIT write acknowledges once staged, not once applied. A host
that needs to confirm the contact was actually committed should re-read relevant state after a
short delay rather than assume success is immediate.
STATUS.RX_PENDING (bit1) has no clear/ack mechanism -- it only ever gets set, never cleared,
by the SAO. A host that needs edge-detection (e.g. "new message since last poll") has to track
(RX_FROM, RX_LEN) itself between polls rather than relying on this bit. CTRL (0xF0) only
exposes a "clear counters" bit, not a way to acknowledge pending-message state.
A host driving this register map over UART should treat the SAO as hot-pluggable and design for these failure modes:
- Per-frame timeout: a bounded wait for the response start byte (tens of ms, well above the expected round-trip at 115200 baud) so a disconnect mid-transaction degrades to a clean timeout, not a hang.
- Immediate retry budget: a few retries within one read/write call absorb an isolated dropped/corrupted frame (CRC mismatch, stale response from a previous timed-out request) without escalating to "SAO gone."
- Consecutive-failure threshold across polls: only declare the SAO unreachable after several consecutive failed polls, not on the first miss -- avoids flapping on a transient glitch while still detecting a real unplug promptly.
- Bounded bus lock: if the transport is shared with another peripheral/protocol on the same physical pins (as on the reference host, see its own docs), guard bus acquisition with a bounded wait rather than blocking forever, so one stuck caller can't freeze every other consumer of the bus.
The reference host implementation (badge-2026) follows exactly this shape and additionally
surfaces the transition visually (LED + status screen) once the failure threshold is crossed,
then backs off to a slower re-probe interval until WHO_AM_I answers again.
The L.E.M SAO has no screen of its own, so Meshtastic's default-config logic
(NodeDB.cpp, hasScreen ? RANDOM_PIN : FIXED_PIN) would otherwise leave Bluetooth pairing on
the static default PIN forever. LemSaoModule now forces config.bluetooth.mode = RANDOM_PIN
in memory on every boot (not persisted), and relays the actual pairing passkey via BLE_PIN +
STATUS.BLE_PAIRING so a host can display it during pairing -- the same job a Meshtastic
device's own screen does via screen->startAlert() (see NimbleBluetooth.cpp
onPassKeyNotify()). BLE_PIN sits outside the 0x00-0x4F block WHO_AM_I-block reads cover
in one transaction, so a host needs a dedicated read for it (same situation as OWN_NODENUM/
OWN_PUBKEY, v0.5).
OWN_LONG_NAME/OWN_SHORT_NAME expose this SAO's own Meshtastic display name (owner.long_name/
owner.short_name) so a host can label which physical SAO is docked -- useful once more than one
SAO exists. Refreshed every tick like OWN_NODENUM/OWN_PUBKEY (v0.5), no STATUS validity bit
needed (static identity, not a transient state). OWN_LONG_NAME is a truncated 14-byte copy
of owner.long_name (buffer is 40 bytes, Meshtastic's own UI convention already caps it at 24
UTF-8 bytes) -- there was no single free contiguous register-map gap large enough for the full
field, and 14 characters is enough for the small e-ink line this drives. OWN_SHORT_NAME is a
raw 5-byte copy (already short by Meshtastic's own design, no truncation needed).
OWN_BLE_SUFFIX exposes the same last-2-MAC-bytes suffix getDeviceName() (main.cpp) appends
to this SAO's advertised Bluetooth device name (<short_name>_xxxx, or Meshtastic_xxxx if
short_name is unset). This is not derivable from OWN_NODENUM or OWN_LONG_NAME/
OWN_SHORT_NAME -- confirmed against real hardware (2026-08-18) that the BLE name suffix comes
from the MAC address, not the Meshtastic NodeNum, which is a distinct value. Exists specifically
so a host can build an identifier that matches what a user actually sees when scanning for this
SAO over Bluetooth -- useful as a fallback when OWN_SHORT_NAME itself isn't displayable (e.g.
a non-ASCII short_name a host's font can't render).
- v0.1: initial block,
WHO_AM_I..RX_BUF, single-byteRX_FROM. - v0.2:
RX_FROMwidened to 4 bytes;RX_ROLE/RX_PORTNUMadded; RSSI/SNR/FROM/ROLE/ PORTNUM synchronization rule introduced (fixes a desync where a staleRX_FROMcould pair with an unrelated, more recentLAST_RSSI). - v0.3:
HOPS_AWAYadded. - v0.4:
STATUSbit4 (RX_IS_DM) added for private-message detection. - v0.5:
DOCK_NODENUM/DOCK_PUBKEY/DOCK_COMMITadded for NFC out-of-band contact/key exchange ("Orbital Docking") -- replaces the earlierCHANNEL_*channel-exchange draft, which is dropped in favor of per-node PKC key injection (no channel switch needed, the SAO stays on Primary). Also addsOWN_NODENUM/OWN_PUBKEY(read-only) -- caught during implementation prep: nothing let a host read its own SAO's identity to hand over during a docking exchange, a gap that predates this feature (previously tracked as a deferred idea, "a real per-SAO ID," inplan-dev.md) but that Orbital Docking makes load-bearing rather than optional. - v0.6:
BLE_PIN+STATUSbit5 (BLE_PAIRING) added to relay the Bluetooth pairing passkey to a host, now thatLemSaoModuleforcesRANDOM_PINpairing mode every boot instead of inheriting the screen-less default (FIXED_PIN). - v0.7:
OWN_LONG_NAME/OWN_SHORT_NAMEadded so a host can identify which physical SAO is docked by its configured Meshtastic display name. - v0.8:
OWN_BLE_SUFFIXadded -- the BLE device name suffix isn't derivable fromOWN_NODENUMor v0.7's name registers (it comes from the MAC), and a host needs it to build a fallback identifier that matches what's seen when scanning for this SAO over Bluetooth.