Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/meshcore/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/meshcore/packets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions src/meshcore/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,41 @@ async def handle_rx(self, data: bytearray):
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}
Expand Down
124 changes: 124 additions & 0 deletions tests/unit/test_protocol_surface_gaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,3 +362,127 @@ async def mock_send(data, expected_events, timeout=None):
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"] == ""