From 8b08f0feb19f804ca659389cd904f0c2cc7632da Mon Sep 17 00:00:00 2001 From: Maxwell Burton Date: Fri, 2 Oct 2026 18:45:04 +0000 Subject: [PATCH] Add send_channel_data() for CMD_SEND_CHANNEL_DATA (0x3E) Sends a binary channel datagram: flood by default, or along a given path. Validates data_type (1..0xFFFF), payload length (<= 163) and channel index (one byte), like send_raw_data. --- src/meshcore/commands/messaging.py | 35 ++++++++++++ tests/unit/test_protocol_surface_gaps.py | 71 ++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/meshcore/commands/messaging.py b/src/meshcore/commands/messaging.py index 12cbf49..d383cae 100644 --- a/src/meshcore/commands/messaging.py +++ b/src/meshcore/commands/messaging.py @@ -368,6 +368,41 @@ class MessagingCommands(CommandHandlerBase): data = bytes([0x19, len(path)]) + path + bytes(payload) return await self.send(data, [EventType.OK, EventType.ERROR]) + async def send_channel_data( + self, chan: int, data_type: int, payload: bytes, path: Optional[bytes] = None + ) -> Event: + """Send a binary datagram to a channel via CMD_SEND_CHANNEL_DATA (0x3E). + + Command format: + 0x3E | channel_idx(1) | path_len(1) | path(path_len bytes) | data_type(2, LE) | payload + + Args: + chan: Channel index (0-255, must fit in one byte; the firmware rejects unknown channels). + data_type: Application identifier, 0x0001-0xFFFF (0xFFFF is the developer namespace). + payload: Binary payload, at most 163 bytes (MAX_CHANNEL_DATA_LENGTH). + path: Optional path bytes to send along (default None = flood, path_len 0xFF). + + Returns: + Event with OK or ERROR. + """ + if not isinstance(payload, (bytes, bytearray)): + raise TypeError("payload must be bytes-like") + if not 0 <= chan <= 0xFF: + raise ValueError("chan must fit in one byte (0-255)") + if not 0x0001 <= data_type <= 0xFFFF: + raise ValueError("data_type must be between 0x0001 and 0xFFFF") + if len(payload) > 163: + raise ValueError("payload must be at most 163 bytes") + if path is None: + path_bytes = b"\xff" + else: + path = bytes(path) + if len(path) >= 0xFF: + raise ValueError("path must be shorter than 255 bytes") + path_bytes = bytes([len(path)]) + path + data = bytes([0x3E, chan]) + path_bytes + data_type.to_bytes(2, "little") + bytes(payload) + return await self.send(data, [EventType.OK, EventType.ERROR]) + async def set_flood_scope(self, scope, force_unscoped=False): if scope is None: logger.debug(f"Resetting scope") diff --git a/tests/unit/test_protocol_surface_gaps.py b/tests/unit/test_protocol_surface_gaps.py index 4048e4a..ef718e8 100644 --- a/tests/unit/test_protocol_surface_gaps.py +++ b/tests/unit/test_protocol_surface_gaps.py @@ -489,6 +489,77 @@ async def test_channel_data_recv_widened_data_type(): assert evt.payload["payload"] == "" +# --------------------------------------------------------------------------- +# Command wrapper: send_channel_data (CMD_SEND_CHANNEL_DATA, 0x3E) +# --------------------------------------------------------------------------- + +def _make_channel_data_commands(): + """MessagingCommands whose send() records the written frame and expected events.""" + from meshcore.commands.messaging import MessagingCommands + + cmd = MessagingCommands.__new__(MessagingCommands) + sent = [] + + async def mock_send(data, expected_events, timeout=None): + sent.append((bytes(data), expected_events)) + return Event(EventType.OK, {}) + + cmd.send = mock_send + return cmd, sent + + +@pytest.mark.asyncio +async def test_send_channel_data_flood_matches_protocol_example(): + """Flood frame equals the companion protocol example: 3E 01 FF FF FF A1 B2 C3.""" + cmd, sent = _make_channel_data_commands() + + evt = await cmd.send_channel_data(1, 0xFFFF, b"\xA1\xB2\xC3") + + assert evt.type == EventType.OK + assert sent == [ + (bytes.fromhex("3E01FFFFFFA1B2C3"), [EventType.OK, EventType.ERROR]), + ] + + +@pytest.mark.asyncio +async def test_send_channel_data_with_path(): + """A path is sent after its length byte; data_type is little-endian.""" + cmd, sent = _make_channel_data_commands() + + await cmd.send_channel_data(2, 0x0102, b"\x07", path=b"\xAA\xBB") + + assert sent[0][0] == bytes.fromhex("3E02" "02" "AABB" "0201" "07") + + +@pytest.mark.asyncio +async def test_send_channel_data_max_payload_accepted(): + """A 163-byte payload (MAX_CHANNEL_DATA_LENGTH) is sent.""" + cmd, sent = _make_channel_data_commands() + + await cmd.send_channel_data(0, 0x0100, b"\x00" * 163) + + assert len(sent[0][0]) == 3 + 2 + 163 + + +@pytest.mark.asyncio +async def test_send_channel_data_rejects_invalid_arguments(): + """Reserved data_type, oversized payload, bad chan and non-bytes payload never reach send().""" + cmd, sent = _make_channel_data_commands() + + with pytest.raises(ValueError): + await cmd.send_channel_data(1, 0x0000, b"\x01") + with pytest.raises(ValueError): + await cmd.send_channel_data(1, 0x10000, b"\x01") + with pytest.raises(ValueError): + await cmd.send_channel_data(1, 0xFFFF, b"\x00" * 164) + with pytest.raises(ValueError): + await cmd.send_channel_data(256, 0xFFFF, b"\x01") + with pytest.raises(TypeError): + await cmd.send_channel_data(1, 0xFFFF, "text") + + assert sent == [] + + # --------------------------------------------------------------------------- # Wire-format parity bundle: AUTOADD_CONFIG (max_hops trailing byte) # ---------------------------------------------------------------------------