meshcore 2.3.14__tar.gz → 2.3.16__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {meshcore-2.3.14 → meshcore-2.3.16}/PKG-INFO +1 -1
  2. {meshcore-2.3.14 → meshcore-2.3.16}/pyproject.toml +1 -1
  3. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/commands/messaging.py +42 -2
  4. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/lpp_json_encoder.py +1 -1
  5. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/meshcore.py +10 -5
  6. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/packets.py +1 -0
  7. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/reader.py +1 -0
  8. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_asyncio_lifecycle.py +64 -0
  9. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_lpp_parsing.py +17 -0
  10. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_protocol_surface_gaps.py +116 -0
  11. {meshcore-2.3.14 → meshcore-2.3.16}/.github/funding.yml +0 -0
  12. {meshcore-2.3.14 → meshcore-2.3.16}/.github/python-test.yml +0 -0
  13. {meshcore-2.3.14 → meshcore-2.3.16}/.gitignore +0 -0
  14. {meshcore-2.3.14 → meshcore-2.3.16}/LICENSE +0 -0
  15. {meshcore-2.3.14 → meshcore-2.3.16}/README.md +0 -0
  16. {meshcore-2.3.14 → meshcore-2.3.16}/examples/.pubsub_example.py.swp +0 -0
  17. {meshcore-2.3.14 → meshcore-2.3.16}/examples/ble_chat.py +0 -0
  18. {meshcore-2.3.14 → meshcore-2.3.16}/examples/ble_pin_pairing_example.py +0 -0
  19. {meshcore-2.3.14 → meshcore-2.3.16}/examples/ble_private_key_export.py +0 -0
  20. {meshcore-2.3.14 → meshcore-2.3.16}/examples/ble_sign_example.py +0 -0
  21. {meshcore-2.3.14 → meshcore-2.3.16}/examples/ble_stats.py +0 -0
  22. {meshcore-2.3.14 → meshcore-2.3.16}/examples/ble_t1000_chan_msg.py +0 -0
  23. {meshcore-2.3.14 → meshcore-2.3.16}/examples/ble_t1000_custom_vars.py +0 -0
  24. {meshcore-2.3.14 → meshcore-2.3.16}/examples/ble_t1000_infos.py +0 -0
  25. {meshcore-2.3.14 → meshcore-2.3.16}/examples/ble_t1000_msg.py +0 -0
  26. {meshcore-2.3.14 → meshcore-2.3.16}/examples/ble_t1000_msg_retries.py +0 -0
  27. {meshcore-2.3.14 → meshcore-2.3.16}/examples/ble_t1000_set_cv.py +0 -0
  28. {meshcore-2.3.14 → meshcore-2.3.16}/examples/chan_recv_with_path.py +0 -0
  29. {meshcore-2.3.14 → meshcore-2.3.16}/examples/connection_events_example.py +0 -0
  30. {meshcore-2.3.14 → meshcore-2.3.16}/examples/mepo_mc_gps.py +0 -0
  31. {meshcore-2.3.14 → meshcore-2.3.16}/examples/pubsub_example.py +0 -0
  32. {meshcore-2.3.14 → meshcore-2.3.16}/examples/rf_packet_monitor.py +0 -0
  33. {meshcore-2.3.14 → meshcore-2.3.16}/examples/serial_battery_monitor.py +0 -0
  34. {meshcore-2.3.14 → meshcore-2.3.16}/examples/serial_channel_manager.py +0 -0
  35. {meshcore-2.3.14 → meshcore-2.3.16}/examples/serial_chat.py +0 -0
  36. {meshcore-2.3.14 → meshcore-2.3.16}/examples/serial_contacts.py +0 -0
  37. {meshcore-2.3.14 → meshcore-2.3.16}/examples/serial_infos.py +0 -0
  38. {meshcore-2.3.14 → meshcore-2.3.16}/examples/serial_meshcore_ollama.py +0 -0
  39. {meshcore-2.3.14 → meshcore-2.3.16}/examples/serial_msg.py +0 -0
  40. {meshcore-2.3.14 → meshcore-2.3.16}/examples/serial_pingbot.py +0 -0
  41. {meshcore-2.3.14 → meshcore-2.3.16}/examples/serial_repeater_status.py +0 -0
  42. {meshcore-2.3.14 → meshcore-2.3.16}/examples/serial_repeater_telemetry.py +0 -0
  43. {meshcore-2.3.14 → meshcore-2.3.16}/examples/serial_rss_bot.py +0 -0
  44. {meshcore-2.3.14 → meshcore-2.3.16}/examples/serial_trace.py +0 -0
  45. {meshcore-2.3.14 → meshcore-2.3.16}/examples/tcp_chat.py +0 -0
  46. {meshcore-2.3.14 → meshcore-2.3.16}/examples/tcp_login_status.py +0 -0
  47. {meshcore-2.3.14 → meshcore-2.3.16}/examples/tcp_mchome_contacts.py +0 -0
  48. {meshcore-2.3.14 → meshcore-2.3.16}/examples/tcp_mchome_infos.py +0 -0
  49. {meshcore-2.3.14 → meshcore-2.3.16}/examples/tcp_mchome_msg.py +0 -0
  50. {meshcore-2.3.14 → meshcore-2.3.16}/examples/tcp_mchome_readmsgs.py +0 -0
  51. {meshcore-2.3.14 → meshcore-2.3.16}/pytest.ini +0 -0
  52. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/__init__.py +0 -0
  53. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/ble_cx.py +0 -0
  54. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/commands/__init__.py +0 -0
  55. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/commands/base.py +0 -0
  56. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/commands/binary.py +0 -0
  57. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/commands/contact.py +0 -0
  58. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/commands/control_data.py +0 -0
  59. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/commands/device.py +0 -0
  60. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/connection_manager.py +0 -0
  61. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/events.py +0 -0
  62. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/meshcore_parser.py +0 -0
  63. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/parsing.py +0 -0
  64. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/serial_cx.py +0 -0
  65. {meshcore-2.3.14 → meshcore-2.3.16}/src/meshcore/tcp_cx.py +0 -0
  66. {meshcore-2.3.14 → meshcore-2.3.16}/tests/README.md +0 -0
  67. {meshcore-2.3.14 → meshcore-2.3.16}/tests/test_ble_connection.py +0 -0
  68. {meshcore-2.3.14 → meshcore-2.3.16}/tests/test_ble_pin_pairing.py +0 -0
  69. {meshcore-2.3.14 → meshcore-2.3.16}/tests/test_meshcore_ble_pin.py +0 -0
  70. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_ble_reconnect_cleanup.py +0 -0
  71. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_commands.py +0 -0
  72. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_connection_manager.py +0 -0
  73. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_error_handling.py +0 -0
  74. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_events.py +0 -0
  75. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_parse_status.py +0 -0
  76. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_path_discovery_response.py +0 -0
  77. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_private_key_export.py +0 -0
  78. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_reader.py +0 -0
  79. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_rxlog_keyerror_regression.py +0 -0
  80. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_send_msg_with_retry.py +0 -0
  81. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_serial_connection.py +0 -0
  82. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_standalone_fixes.py +0 -0
  83. {meshcore-2.3.14 → meshcore-2.3.16}/tests/unit/test_transport_symmetry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: meshcore
3
- Version: 2.3.14
3
+ Version: 2.3.16
4
4
  Summary: Base classes for communicating with meshcore companion radios
5
5
  Project-URL: Homepage, https://github.com/fdlamotte/meshcore_py
6
6
  Project-URL: Issues, https://github.com/fdlamotte/meshcore_py/issues
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meshcore"
7
- version = "2.3.14"
7
+ version = "2.3.16"
8
8
  authors = [
9
9
  { name="Florent de Lamotte", email="florent@frizoncorrea.fr" },
10
10
  { name="Alex Wolden", email="awolden@gmail.com" },
@@ -19,6 +19,7 @@ class MessagingCommands(CommandHandlerBase):
19
19
  [
20
20
  EventType.CONTACT_MSG_RECV,
21
21
  EventType.CHANNEL_MSG_RECV,
22
+ EventType.CHANNEL_DATA_RECV,
22
23
  EventType.ERROR,
23
24
  EventType.NO_MORE_MSGS,
24
25
  ],
@@ -327,7 +328,7 @@ class MessagingCommands(CommandHandlerBase):
327
328
  return Event(EventType.ERROR, {"reason": "unsupported_path_type"})
328
329
 
329
330
  # Prepare the command packet: CMD(1) + tag(4) + auth_code(4) + flags(1) + [path]
330
- cmd_data = bytearray([36]) # CMD_SEND_TRACE_PATH
331
+ cmd_data = bytearray([CommandType.SEND_TRACE_PATH.value]) # CMD_SEND_TRACE_PATH
331
332
  cmd_data.extend(tag.to_bytes(4, "little"))
332
333
  cmd_data.extend(auth_code.to_bytes(4, "little"))
333
334
  cmd_data.append(flags)
@@ -365,7 +366,46 @@ class MessagingCommands(CommandHandlerBase):
365
366
  if len(payload) < 4:
366
367
  raise ValueError("payload must be at least 4 bytes")
367
368
  path = bytes(path)
368
- data = bytes([0x19, len(path)]) + path + bytes(payload)
369
+ data = bytes([CommandType.SEND_RAW_DATA.value, len(path)]) + path + bytes(payload)
370
+ return await self.send(data, [EventType.OK, EventType.ERROR])
371
+
372
+ async def send_channel_data(
373
+ self, chan: int, data_type: int, payload: bytes,
374
+ path: Optional[bytes] = None, path_hash_mode: Optional[int] = 0
375
+ ) -> Event:
376
+ """Send a binary datagram to a channel via CMD_SEND_CHANNEL_DATA (0x3E).
377
+
378
+ Command format:
379
+ 0x3E | channel_idx(1) | path_len(1) | path(path_len bytes) | data_type(2, LE) | payload
380
+
381
+ Args:
382
+ chan: Channel index (0-255, must fit in one byte; the firmware rejects unknown channels).
383
+ data_type: Application identifier, 0x0001-0xFFFF (0xFFFF is the developer namespace).
384
+ payload: Binary payload, at most 163 bytes (MAX_CHANNEL_DATA_LENGTH).
385
+ path: Optional path bytes to send along (default None = flood, path_len 0xFF).
386
+ path_hash_mode: Optional path_hash_mode (size of the hash - 1)
387
+
388
+ Returns:
389
+ Event with OK or ERROR.
390
+ """
391
+ if not isinstance(payload, (bytes, bytearray)):
392
+ raise TypeError("payload must be bytes-like")
393
+ if not 0 <= chan <= 0xFF:
394
+ raise ValueError("chan must fit in one byte (0-255)")
395
+ if not 0x0001 <= data_type <= 0xFFFF:
396
+ raise ValueError("data_type must be between 0x0001 and 0xFFFF")
397
+ if len(payload) > 163:
398
+ raise ValueError("payload must be at most 163 bytes")
399
+ if path is None:
400
+ path_bytes = b"\xff"
401
+ else:
402
+ path = bytes(path)
403
+ if len(path) >= 0xFF:
404
+ raise ValueError("path must be shorter than 255 bytes")
405
+ path_len = int(len(path) / (path_hash_mode + 1))
406
+ path_bytes = bytes([path_len | (path_hash_mode << 6)]) + path
407
+ logger.debug(f"Sending raw data {payload.hex()} through path {"Flood" if path is None else path.hex()} of len {"-1" if path is None else path_len} with mode {path_hash_mode} (raw path: {path_bytes.hex()})")
408
+ data = bytes([CommandType.SEND_CHANNEL_DATA.value, chan]) + path_bytes + data_type.to_bytes(2, "little") + bytes(payload)
369
409
  return await self.send(data, [EventType.OK, EventType.ERROR])
370
410
 
371
411
  async def set_flood_scope(self, scope, force_unscoped=False):
@@ -25,7 +25,7 @@ my_lpp_types = {
25
25
  128: ("power", []),
26
26
  130: ("distance", []),
27
27
  131: ("energy", []),
28
- 132: ("direction", None),
28
+ 132: ("direction", []),
29
29
  133: ("time", []),
30
30
  134: ("gyrometer", None),
31
31
  135: ("colour", ["red", "green", "blue"]),
@@ -207,11 +207,16 @@ class MeshCore:
207
207
 
208
208
  async def connect(self):
209
209
  await self.dispatcher.start()
210
- result = await self.connection_manager.connect()
211
- if result is None:
212
- await self.dispatcher.stop()
213
- raise ConnectionError("Failed to connect to device")
214
- res = await self.commands.send_appstart(timeout=2)
210
+ try:
211
+ result = await self.connection_manager.connect()
212
+ if result is None:
213
+ raise ConnectionError("Failed to connect to device")
214
+ res = await self.commands.send_appstart(timeout=2)
215
+ except BaseException:
216
+ # The create_* factories drop this instance when connect() raises, so
217
+ # nothing else can stop the dispatcher task or close an opened transport.
218
+ await self.disconnect()
219
+ raise
215
220
  if res is None or res.type == EventType.ERROR:
216
221
  return None
217
222
  return res
@@ -86,6 +86,7 @@ class CommandType(Enum):
86
86
  GET_ALLOWED_REPEAT_FREQ = 60
87
87
  GET_STATS = 56 # R04: CMD_GET_STATS — used by get_stats_core/radio/packets
88
88
  SET_PATH_HASH_MODE = 61
89
+ SEND_CHANNEL_DATA = 62
89
90
  SET_DEFAULT_FLOOD_SCOPE = 63
90
91
  GET_DEFAULT_FLOOD_SCOPE = 64
91
92
  SEND_RAW_PACKET = 65
@@ -360,6 +360,7 @@ class MessageReader:
360
360
  logger.debug(f"CHANNEL_DATA_RECV frame too short ({len(data)} bytes < 9), skipping parse")
361
361
  return
362
362
  res = {}
363
+ res["type"] = "DATA"
363
364
  res["SNR"] = int.from_bytes(dbuf.read(1), byteorder="little", signed=True) / 4
364
365
  dbuf.read(2) # reserved
365
366
  res["channel_idx"] = dbuf.read(1)[0]
@@ -4,9 +4,11 @@ Verification tests for asyncio lifecycle fixes.
4
4
 
5
5
  import asyncio
6
6
  import gc
7
+ import socket
7
8
  import unittest
8
9
  from unittest.mock import AsyncMock, MagicMock, patch
9
10
 
11
+ from meshcore import MeshCore
10
12
  from meshcore.events import Event, EventDispatcher, EventType
11
13
  from meshcore.tcp_cx import TCPConnection
12
14
  from meshcore.serial_cx import SerialConnection
@@ -231,5 +233,67 @@ class TestGetRunningLoop(unittest.TestCase):
231
233
  asyncio.run(_run())
232
234
 
233
235
 
236
+ class TestConnectFailureCleanup(unittest.TestCase):
237
+ """A connect() that raises or is cancelled must not leave the dispatcher running."""
238
+
239
+ def setUp(self):
240
+ # Without collection, a leaked task stays visible instead of being destroyed
241
+ gc.disable()
242
+ self.addCleanup(gc.enable)
243
+
244
+ @staticmethod
245
+ def _dispatcher_tasks():
246
+ return [
247
+ task
248
+ for task in asyncio.all_tasks()
249
+ if not task.done()
250
+ and task.get_coro().__qualname__ == "EventDispatcher._process_events"
251
+ ]
252
+
253
+ def test_refused_create_tcp_leaves_no_dispatcher_task(self):
254
+ """create_tcp() raising on a refused connection stops the dispatcher."""
255
+ async def _run():
256
+ with socket.socket() as sock:
257
+ sock.bind(("127.0.0.1", 0))
258
+ port = sock.getsockname()[1]
259
+
260
+ with self.assertRaises(OSError):
261
+ await MeshCore.create_tcp("127.0.0.1", port)
262
+
263
+ assert self._dispatcher_tasks() == []
264
+
265
+ asyncio.run(_run())
266
+
267
+ def test_cancelled_connect_closes_transport_and_dispatcher(self):
268
+ """Cancelling connect() while it waits for appstart releases everything."""
269
+ async def _run():
270
+ peers = []
271
+ accepted = asyncio.Event()
272
+
273
+ async def accept(reader, _writer):
274
+ peers.append(reader)
275
+ accepted.set()
276
+
277
+ server = await asyncio.start_server(accept, "127.0.0.1", 0)
278
+ port = server.sockets[0].getsockname()[1]
279
+ try:
280
+ mc = MeshCore(TCPConnection("127.0.0.1", port))
281
+ attempt = asyncio.create_task(mc.connect())
282
+ await asyncio.wait_for(accepted.wait(), 1.0)
283
+ attempt.cancel()
284
+ with self.assertRaises(asyncio.CancelledError):
285
+ await attempt
286
+
287
+ assert self._dispatcher_tasks() == []
288
+ assert not mc.dispatcher.running
289
+ # EOF on the server side means the client transport was closed
290
+ await asyncio.wait_for(peers[0].read(), 1.0)
291
+ finally:
292
+ server.close()
293
+ await server.wait_closed()
294
+
295
+ asyncio.run(_run())
296
+
297
+
234
298
  if __name__ == "__main__":
235
299
  unittest.main()
@@ -37,3 +37,20 @@ class TestLppCurrentParsing:
37
37
  lpp = json.loads(json.dumps(LppFrame([lppdata]), default=lpp_json_encoder))
38
38
 
39
39
  assert lpp[0]['value'] == 0.5
40
+
41
+
42
+ class TestLppDirectionParsing:
43
+ """Tests to verify LPP direction values are encoded as scalars."""
44
+
45
+ def test_direction_value_is_scalar(self):
46
+ """A direction value should not be serialized as a one-item list."""
47
+ from meshcore.lpp_json_encoder import lpp_json_encoder
48
+
49
+ # Channel 2, Type 132 (direction), 90 degrees = 0x00 0x5A.
50
+ raw_bytes = bytes([2, 132, 0x00, 0x5A])
51
+ lppdata = LppData.from_bytes(raw_bytes)
52
+
53
+ lpp = json.loads(json.dumps(LppFrame([lppdata]), default=lpp_json_encoder))
54
+
55
+ assert lpp[0]['type'] == 'direction'
56
+ assert lpp[0]['value'] == 90.0
@@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
12
12
  from meshcore.events import Event, EventType, EventDispatcher
13
13
  from meshcore.reader import MessageReader
14
14
  from meshcore.packets import PacketType, CommandType
15
+ from meshcore.commands.messaging import MessagingCommands
15
16
 
16
17
 
17
18
  # ---------------------------------------------------------------------------
@@ -489,6 +490,121 @@ async def test_channel_data_recv_widened_data_type():
489
490
  assert evt.payload["payload"] == ""
490
491
 
491
492
 
493
+ @pytest.mark.asyncio
494
+ async def test_get_msg_returns_channel_data_recv():
495
+ """get_msg() resolves on CHANNEL_DATA_RECV instead of timing out.
496
+
497
+ The companion protocol lists PACKET_CHANNEL_DATA_RECV (0x1B) as an answer
498
+ to GET_MESSAGE (0x0A). When the next queued message is a channel datagram
499
+ the device answers with that frame, and get_msg() must return it rather
500
+ than an ERROR event with reason "timeout".
501
+ """
502
+ dispatcher = EventDispatcher()
503
+ await dispatcher.start()
504
+ reader = MessageReader(dispatcher)
505
+ commands = MessagingCommands()
506
+ commands.dispatcher = dispatcher
507
+
508
+ frame = bytes([
509
+ PacketType.CHANNEL_DATA_RECV.value,
510
+ 0x10, # SNR
511
+ 0x00, 0x00, # reserved
512
+ 0x01, # channel_idx
513
+ 0xFF, # path_len direct
514
+ 0x23, 0x01, # data_type = 0x0123 (little-endian)
515
+ 0x04, # data_len
516
+ 0xDE, 0xAD, 0xBE, 0xEF, # payload
517
+ ])
518
+
519
+ async def device(data):
520
+ # The device answers GET_MESSAGE with the queued channel datagram.
521
+ assert data == b"\x0a"
522
+ await reader.handle_rx(bytearray(frame))
523
+
524
+ commands._sender_func = device
525
+
526
+ try:
527
+ result = await commands.get_msg(timeout=2.0)
528
+ finally:
529
+ await dispatcher.stop()
530
+
531
+ assert result.type == EventType.CHANNEL_DATA_RECV
532
+ assert result.payload["channel_idx"] == 1
533
+ assert result.payload["data_type"] == 0x0123
534
+ assert result.payload["payload"] == "deadbeef"
535
+
536
+
537
+ # ---------------------------------------------------------------------------
538
+ # Command wrapper: send_channel_data (CMD_SEND_CHANNEL_DATA, 0x3E)
539
+ # ---------------------------------------------------------------------------
540
+
541
+ def _make_channel_data_commands():
542
+ """MessagingCommands whose send() records the written frame and expected events."""
543
+ from meshcore.commands.messaging import MessagingCommands
544
+
545
+ cmd = MessagingCommands.__new__(MessagingCommands)
546
+ sent = []
547
+
548
+ async def mock_send(data, expected_events, timeout=None):
549
+ sent.append((bytes(data), expected_events))
550
+ return Event(EventType.OK, {})
551
+
552
+ cmd.send = mock_send
553
+ return cmd, sent
554
+
555
+
556
+ @pytest.mark.asyncio
557
+ async def test_send_channel_data_flood_matches_protocol_example():
558
+ """Flood frame equals the companion protocol example: 3E 01 FF FF FF A1 B2 C3."""
559
+ cmd, sent = _make_channel_data_commands()
560
+
561
+ evt = await cmd.send_channel_data(1, 0xFFFF, b"\xA1\xB2\xC3")
562
+
563
+ assert evt.type == EventType.OK
564
+ assert sent == [
565
+ (bytes.fromhex("3E01FFFFFFA1B2C3"), [EventType.OK, EventType.ERROR]),
566
+ ]
567
+
568
+
569
+ @pytest.mark.asyncio
570
+ async def test_send_channel_data_with_path():
571
+ """A path is sent after its length byte; data_type is little-endian."""
572
+ cmd, sent = _make_channel_data_commands()
573
+
574
+ await cmd.send_channel_data(2, 0x0102, b"\x07", path=b"\xAA\xBB")
575
+
576
+ assert sent[0][0] == bytes.fromhex("3E02" "02" "AABB" "0201" "07")
577
+
578
+
579
+ @pytest.mark.asyncio
580
+ async def test_send_channel_data_max_payload_accepted():
581
+ """A 163-byte payload (MAX_CHANNEL_DATA_LENGTH) is sent."""
582
+ cmd, sent = _make_channel_data_commands()
583
+
584
+ await cmd.send_channel_data(0, 0x0100, b"\x00" * 163)
585
+
586
+ assert len(sent[0][0]) == 3 + 2 + 163
587
+
588
+
589
+ @pytest.mark.asyncio
590
+ async def test_send_channel_data_rejects_invalid_arguments():
591
+ """Reserved data_type, oversized payload, bad chan and non-bytes payload never reach send()."""
592
+ cmd, sent = _make_channel_data_commands()
593
+
594
+ with pytest.raises(ValueError):
595
+ await cmd.send_channel_data(1, 0x0000, b"\x01")
596
+ with pytest.raises(ValueError):
597
+ await cmd.send_channel_data(1, 0x10000, b"\x01")
598
+ with pytest.raises(ValueError):
599
+ await cmd.send_channel_data(1, 0xFFFF, b"\x00" * 164)
600
+ with pytest.raises(ValueError):
601
+ await cmd.send_channel_data(256, 0xFFFF, b"\x01")
602
+ with pytest.raises(TypeError):
603
+ await cmd.send_channel_data(1, 0xFFFF, "text")
604
+
605
+ assert sent == []
606
+
607
+
492
608
  # ---------------------------------------------------------------------------
493
609
  # Wire-format parity bundle: AUTOADD_CONFIG (max_hops trailing byte)
494
610
  # ---------------------------------------------------------------------------
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes