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}

View File

@@ -362,3 +362,127 @@ async def test_get_stats_core_uses_enum():
assert captured_data is not None
assert captured_data[0] == CommandType.GET_STATS.value # 0x38 = 56
assert captured_data[1] == 0x00
# ---------------------------------------------------------------------------
# CHANNEL_DATA_RECV handler + enum entry (group-channel binary data)
# ---------------------------------------------------------------------------
def test_channel_data_recv_enum_exists():
"""PacketType.CHANNEL_DATA_RECV == 27 (the previously-skipped enum slot)."""
assert PacketType.CHANNEL_DATA_RECV.value == 27
@pytest.mark.asyncio
async def test_channel_data_recv_direct_path_frame():
"""A realistic direct-path CHANNEL_DATA_RECV frame dispatches the typed payload.
Frame: code + snr(0x10) + reserved(00 00) + channel_idx(1)
+ path_len(0xFF direct) + data_type(0x0123 LE) + data_len(4)
+ payload(DEADBEEF).
"""
reader, dispatched = _make_reader()
frame = bytes([
PacketType.CHANNEL_DATA_RECV.value,
0x10, # SNR (signed int8) -> 16 / 4 = 4.0
0x00, 0x00, # reserved
0x01, # channel_idx
0xFF, # path_len sentinel -> direct message
0x23, 0x01, # data_type = 0x0123 (uint16 little-endian)
0x04, # data_len
0xDE, 0xAD, 0xBE, 0xEF, # payload
])
assert len(frame) == 13
await reader.handle_rx(bytearray(frame))
assert len(dispatched) == 1
evt = dispatched[0]
assert evt.type == EventType.CHANNEL_DATA_RECV
assert evt.payload["SNR"] == 4.0
assert evt.payload["channel_idx"] == 1
assert evt.payload["path_len"] == 255
assert evt.payload["path_hash_mode"] == -1
assert evt.payload["data_type"] == 0x0123
assert evt.payload["data_len"] == 4
assert evt.payload["payload"] == "deadbeef"
assert evt.attributes["channel_idx"] == 1
assert evt.attributes["data_type"] == 0x0123
@pytest.mark.asyncio
async def test_channel_data_recv_route_flood_path_len_bits():
"""A route-flood path_len byte splits into hash-mode (>>6) and length (&0x3F).
path_len = 0x42 = 0b01000010 -> hash_mode = 1, length = 2.
"""
reader, dispatched = _make_reader()
frame = bytes([
PacketType.CHANNEL_DATA_RECV.value,
0x10, # SNR
0x00, 0x00, # reserved
0x02, # channel_idx
0x42, # path_len -> hash_mode 1, length 2
0x00, 0x00, # data_type = 0
0x02, # data_len
0xAA, 0xBB, # payload
])
assert len(frame) == 11
await reader.handle_rx(bytearray(frame))
assert len(dispatched) == 1
evt = dispatched[0]
assert evt.type == EventType.CHANNEL_DATA_RECV
assert evt.payload["path_hash_mode"] == 1
assert evt.payload["path_len"] == 2
assert evt.payload["channel_idx"] == 2
assert evt.payload["data_type"] == 0
assert evt.payload["data_len"] == 2
assert evt.payload["payload"] == "aabb"
@pytest.mark.asyncio
async def test_channel_data_recv_under_minimum_frame_ignored():
"""A CHANNEL_DATA_RECV frame shorter than the 9-byte header is dropped."""
reader, dispatched = _make_reader()
# 8 bytes total (header needs 9) — missing the data_len byte.
frame = bytes([
PacketType.CHANNEL_DATA_RECV.value,
0x10, 0x00, 0x00, 0x01, 0xFF, 0x00, 0x00,
])
assert len(frame) == 8
await reader.handle_rx(bytearray(frame))
assert len(dispatched) == 0
@pytest.mark.asyncio
async def test_channel_data_recv_widened_data_type():
"""data_type is a 16-bit field: a value > 0xFF round-trips through the 2-byte read.
data_type = 0x0201 (513) confirms the high byte is not truncated. data_len = 0
exercises the empty-payload tail.
"""
reader, dispatched = _make_reader()
frame = bytes([
PacketType.CHANNEL_DATA_RECV.value,
0x10, # SNR
0x00, 0x00, # reserved
0x00, # channel_idx
0xFF, # path_len direct
0x01, 0x02, # data_type = 0x0201 (little-endian)
0x00, # data_len = 0
])
assert len(frame) == 9
await reader.handle_rx(bytearray(frame))
assert len(dispatched) == 1
evt = dispatched[0]
assert evt.type == EventType.CHANNEL_DATA_RECV
assert evt.payload["data_type"] == 0x0201
assert evt.payload["data_type"] > 0xFF
assert evt.payload["data_len"] == 0
assert evt.payload["payload"] == ""