fix: decode trailing fields in AUTOADD_CONFIG/LOGIN_SUCCESS/ACK

Wire-format parity fixes in reader.py for trailing fields the
companion-radio firmware emits but the SDK decoder was dropping, plus an
over-read guard on DEFAULT_FLOOD_SCOPE. Each fix uses the existing
BATT_AND_STORAGE defensive-read pattern (up-front minimum-length check
plus per-field cumulative-length gates), so older firmware that doesn't
emit the trailing fields keeps decoding without raising. Every fix ships
a legacy-frame and modern-frame unit test pair.

AUTOADD_CONFIG (PacketType 25): companion-v1.14.0 firmware emits a
trailing max_hops byte (firmware commit 00566741); the SDK dropped it.
Adds a defensive `if len(data) >= 3` read. Pre-v1.14.0 frames decode to
`{"config": ...}` with no max_hops key.

LOGIN_SUCCESS (PacketType 0x85): the new-style RESP_SERVER_LOGIN_OK path
emits three trailing fields the SDK was dropping — server_timestamp (4B,
firmware commit 0e90b731), acl_permissions (1B, 7947e8a2), and
fw_ver_level (1B, 418ae08b), all first shipped in companion-v1.10.0.
Adds per-field length gates (>=12, >=13, >=14). Legacy 8-byte "OK"-path
frames decode unchanged.

ACK (PacketType 0x82): firmware emits a trailing 4-byte trip_time
(round-trip latency in ms) since companion-v1.0.0a (firmware commit
d9dc76f1, Jan 2025) — on the wire ~16 months but never surfaced by the
SDK. Adds an `if len(data) >= 9` read. Legacy 5-byte ACK frames decode
unchanged.

DEFAULT_FLOOD_SCOPE (PacketType 28): firmware emits a 48-byte populated
frame or a 1-byte sentinel when no scope is set. The SDK unconditionally
read 31+16 bytes, over-reading 47 bytes past the end of the sentinel
frame and dispatching `{"scope_name": "", "scope_key": ""}`. Adds an
`if len(data) >= 48` guard so the sentinel dispatches `{}`. Consumers
detecting "no scope" via `payload["scope_name"] == ""` should switch to
a key-presence check.

RAW_DATA: the payload-framing fix this branch originally carried landed
upstream first in PR #86 (commit 44b21be), with identical decode logic
(discard the reserved 0xFF byte, then read the remaining variable-length
payload). That redundant change is dropped here; this commit retains only
its regression test (test_raw_data_realistic_frame), which exercises the
upstream fix end-to-end — the upstream change shipped without a test.

CHANGELOG:
  - Decode max_hops trailing byte in AUTOADD_CONFIG (companion-v1.14.0+).
  - Decode server_timestamp / acl_permissions / fw_ver_level trailing
    fields in LOGIN_SUCCESS (companion-v1.10.0+).
  - Decode trip_time trailing field in ACK (companion-v1.0.0a+).
  - DEFAULT_FLOOD_SCOPE dispatches `{}` on the 1-byte sentinel frame
    instead of `{scope_name: "", scope_key: ""}`. Detect "no scope" via
    key presence.

Tests: 10 tests in tests/unit/test_protocol_surface_gaps.py (legacy +
modern frame pair per finding, including a RAW_DATA regression test for
the upstream fix); all 10 pass on the rebased upstream base.

Why: companion-radio firmware has been emitting these fields on the wire
for between 2 and 16 months; SDK consumers (integrations, bots,
dashboards) lose access to data that is on the wire today.
This commit is contained in:
Matthew Wolter
2026-05-28 10:42:00 -07:00
parent 28ee333352
commit 46288e4bef
2 changed files with 267 additions and 4 deletions

View File

@@ -184,8 +184,13 @@ class MessageReader:
elif packet_type_value == PacketType.DEFAULT_FLOOD_SCOPE.value:
res = {}
res["scope_name"] = dbuf.read(31).decode("utf-8", "ignore").replace("\0", "")
res["scope_key"] = dbuf.read(16).hex()
# Firmware emits a 48-byte frame when a scope is configured,
# or a 1-byte sentinel frame (just the response code) when no
# scope is set. Gate the 31+16 byte read so the sentinel
# dispatches an empty payload instead of empty-string fields.
if len(data) >= 48:
res["scope_name"] = dbuf.read(31).decode("utf-8", "ignore").replace("\0", "")
res["scope_key"] = dbuf.read(16).hex()
await self.dispatcher.dispatch(Event(EventType.DEFAULT_FLOOD_SCOPE, res))
elif packet_type_value == PacketType.MSG_SENT.value:
@@ -526,7 +531,12 @@ class MessageReader:
res = {}
res["config"] = dbuf.read(1)[0]
await self.dispatcher.dispatch(Event(EventType.AUTOADD_CONFIG, res, res))
# `max_hops` trailing byte added in companion-v1.14.0
# (firmware commit 00566741). Pre-v1.14.0 firmware emits a
# 1-byte response; read defensively to remain compatible.
if len(data) >= 3:
res["max_hops"] = dbuf.read(1)[0]
await self.dispatcher.dispatch(Event(EventType.AUTOADD_CONFIG, res, res))
elif packet_type_value == PacketType.CHANNEL_INFO.value:
logger.debug(f"received channel info response: {data.hex()}")
@@ -568,6 +578,12 @@ class MessageReader:
if len(data) >= 5:
ack_data["code"] = dbuf.read(4).hex()
# `trip_time` (round-trip latency in ms) has been on the wire
# since companion-v1.0.0a (firmware commit d9dc76f1, Jan 2025).
# Read defensively so legacy frames without this field still
# dispatch correctly.
if len(data) >= 9:
ack_data["trip_time"] = int.from_bytes(dbuf.read(4), "little")
attributes = {"code": ack_data.get("code", "")}
@@ -596,8 +612,19 @@ class MessageReader:
res["is_admin"] = (perms & 1) == 1 # Check if admin bit is set
if len(data) > 7:
res["pubkey_prefix"] = dbuf.read(6).hex()
attributes = {"pubkey_prefix": res.get("pubkey_prefix")}
# The following trailing fields are emitted only by the new-
# style RESP_SERVER_LOGIN_OK path. server_timestamp landed in
# firmware commit 0e90b731 (companion-v1.10.0); acl_permissions
# in 7947e8a2; fw_ver_level in 418ae08b (also companion-v1.10.0).
# Per-field length gates handle every firmware-version tier.
if len(data) >= 12:
res["server_timestamp"] = int.from_bytes(dbuf.read(4), "little")
if len(data) >= 13:
res["acl_permissions"] = dbuf.read(1)[0]
if len(data) >= 14:
res["fw_ver_level"] = dbuf.read(1)[0]
await self.dispatcher.dispatch(
Event(EventType.LOGIN_SUCCESS, res, attributes)