feat: add CHANNEL_DATA_RECV packet type and handler

Why: companion-radio firmware emits RESP_CODE_CHANNEL_DATA_RECV (27) for
group-channel binary data (PAYLOAD_TYPE_GRP_DATA), shipped in
companion-v1.15.0. The SDK had no PacketType value 27, no EventType, and
no reader handler, so these frames hit the unknown-packet-type
fallthrough and the payload was silently dropped.

This adds PacketType.CHANNEL_DATA_RECV = 27 (the previously-skipped enum
slot), EventType.CHANNEL_DATA_RECV, and a reader handler. The fixed
9-byte header (snr, reserved, channel_idx, path_len with the
sentinel/hash-mode encoding) reuses CHANNEL_MSG_RECV_V3's framing; the
typed tail decodes data_type (uint16 little-endian, widened from uint8
in firmware), data_len, and payload (hex string). An up-front length
gate mirrors the defensive-read pattern used by the other handlers.

data_type is exposed as an int (matching the txt_type convention) and
payload as a hex string (matching RAW_DATA's convention for binary data
of unknown encoding); attributes surface channel_idx and data_type so
subscribers can filter without unpacking the payload.

Tests: tests/unit/test_protocol_surface_gaps.py — 5 new tests (enum
slot present, direct-path frame, route-flood path_len bit-split,
under-minimum frame dropped, widened data_type round-trip). Full unit
suite: 146 passed.
This commit is contained in:
Matthew Wolter
2026-05-28 11:28:05 -07:00
parent 4d0be8788a
commit 3ed4ec3eab
4 changed files with 161 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ class EventType(Enum):
SELF_INFO = "self_info"
CONTACT_MSG_RECV = "contact_message"
CHANNEL_MSG_RECV = "channel_message"
CHANNEL_DATA_RECV = "channel_data"
CURRENT_TIME = "time_update"
NO_MORE_MSGS = "no_more_messages"
CONTACT_URI = "contact_uri"

View File

@@ -105,6 +105,7 @@ class PacketType(Enum):
STATS = 24
AUTOADD_CONFIG = 25
ALLOWED_REPEAT_FREQ = 26
CHANNEL_DATA_RECV = 27
DEFAULT_FLOOD_SCOPE = 28
# Push notifications

View File

@@ -332,6 +332,41 @@ class MessageReader:
Event(EventType.CHANNEL_MSG_RECV, res, attributes)
)
elif packet_type_value == PacketType.CHANNEL_DATA_RECV.value:
# Group-channel binary data (PAYLOAD_TYPE_GRP_DATA), companion-v1.15.0+.
# Fixed 9-byte header (including the code byte) + variable payload:
# code(1) + snr(1) + reserved(2) + channel_idx(1)
# + path_len(1) + data_type(2) + data_len(1) = 9 bytes
# The first six post-code bytes share CHANNEL_MSG_RECV_V3's framing;
# data_type is a 16-bit little-endian field (widened from uint8 in
# firmware, so the high byte may be non-zero).
if len(data) < 9:
logger.debug(f"CHANNEL_DATA_RECV frame too short ({len(data)} bytes < 9), skipping parse")
return
res = {}
res["SNR"] = int.from_bytes(dbuf.read(1), byteorder="little", signed=True) / 4
dbuf.read(2) # reserved
res["channel_idx"] = dbuf.read(1)[0]
plen = dbuf.read(1)[0]
if plen == 255: # direct message
res["path_hash_mode"] = -1
res["path_len"] = plen
else:
res["path_hash_mode"] = plen >> 6
res["path_len"] = plen & 0x3F
res["data_type"] = int.from_bytes(dbuf.read(2), byteorder="little")
res["data_len"] = dbuf.read(1)[0]
res["payload"] = dbuf.read(res["data_len"]).hex()
attributes = {
"channel_idx": res["channel_idx"],
"data_type": res["data_type"],
}
await self.dispatcher.dispatch(
Event(EventType.CHANNEL_DATA_RECV, res, attributes)
)
elif packet_type_value == PacketType.CURRENT_TIME.value:
time_value = int.from_bytes(dbuf.read(4), byteorder="little")
result = {"time": time_value}