meshcore 2.3.7__tar.gz → 2.3.8__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 (82) hide show
  1. meshcore-2.3.8/.github/funding.yml +2 -0
  2. {meshcore-2.3.7 → meshcore-2.3.8}/PKG-INFO +1 -1
  3. {meshcore-2.3.7 → meshcore-2.3.8}/pyproject.toml +1 -1
  4. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/ble_cx.py +48 -1
  5. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/commands/base.py +112 -15
  6. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/commands/messaging.py +39 -11
  7. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/events.py +1 -0
  8. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/packets.py +1 -0
  9. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/reader.py +79 -6
  10. meshcore-2.3.8/tests/unit/test_error_handling.py +716 -0
  11. meshcore-2.3.8/tests/unit/test_protocol_surface_gaps.py +725 -0
  12. meshcore-2.3.7/tests/unit/test_error_handling.py +0 -236
  13. meshcore-2.3.7/tests/unit/test_protocol_surface_gaps.py +0 -364
  14. {meshcore-2.3.7 → meshcore-2.3.8}/.github/python-test.yml +0 -0
  15. {meshcore-2.3.7 → meshcore-2.3.8}/.gitignore +0 -0
  16. {meshcore-2.3.7 → meshcore-2.3.8}/LICENSE +0 -0
  17. {meshcore-2.3.7 → meshcore-2.3.8}/README.md +0 -0
  18. {meshcore-2.3.7 → meshcore-2.3.8}/examples/.pubsub_example.py.swp +0 -0
  19. {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_chat.py +0 -0
  20. {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_pin_pairing_example.py +0 -0
  21. {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_private_key_export.py +0 -0
  22. {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_sign_example.py +0 -0
  23. {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_stats.py +0 -0
  24. {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_t1000_chan_msg.py +0 -0
  25. {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_t1000_custom_vars.py +0 -0
  26. {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_t1000_infos.py +0 -0
  27. {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_t1000_msg.py +0 -0
  28. {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_t1000_msg_retries.py +0 -0
  29. {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_t1000_set_cv.py +0 -0
  30. {meshcore-2.3.7 → meshcore-2.3.8}/examples/chan_recv_with_path.py +0 -0
  31. {meshcore-2.3.7 → meshcore-2.3.8}/examples/connection_events_example.py +0 -0
  32. {meshcore-2.3.7 → meshcore-2.3.8}/examples/mepo_mc_gps.py +0 -0
  33. {meshcore-2.3.7 → meshcore-2.3.8}/examples/pubsub_example.py +0 -0
  34. {meshcore-2.3.7 → meshcore-2.3.8}/examples/rf_packet_monitor.py +0 -0
  35. {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_battery_monitor.py +0 -0
  36. {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_channel_manager.py +0 -0
  37. {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_chat.py +0 -0
  38. {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_contacts.py +0 -0
  39. {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_infos.py +0 -0
  40. {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_meshcore_ollama.py +0 -0
  41. {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_msg.py +0 -0
  42. {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_pingbot.py +0 -0
  43. {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_repeater_status.py +0 -0
  44. {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_repeater_telemetry.py +0 -0
  45. {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_rss_bot.py +0 -0
  46. {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_trace.py +0 -0
  47. {meshcore-2.3.7 → meshcore-2.3.8}/examples/tcp_chat.py +0 -0
  48. {meshcore-2.3.7 → meshcore-2.3.8}/examples/tcp_login_status.py +0 -0
  49. {meshcore-2.3.7 → meshcore-2.3.8}/examples/tcp_mchome_contacts.py +0 -0
  50. {meshcore-2.3.7 → meshcore-2.3.8}/examples/tcp_mchome_infos.py +0 -0
  51. {meshcore-2.3.7 → meshcore-2.3.8}/examples/tcp_mchome_msg.py +0 -0
  52. {meshcore-2.3.7 → meshcore-2.3.8}/examples/tcp_mchome_readmsgs.py +0 -0
  53. {meshcore-2.3.7 → meshcore-2.3.8}/pytest.ini +0 -0
  54. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/__init__.py +0 -0
  55. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/commands/__init__.py +0 -0
  56. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/commands/binary.py +0 -0
  57. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/commands/contact.py +0 -0
  58. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/commands/control_data.py +0 -0
  59. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/commands/device.py +0 -0
  60. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/connection_manager.py +0 -0
  61. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/lpp_json_encoder.py +0 -0
  62. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/meshcore.py +0 -0
  63. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/meshcore_parser.py +0 -0
  64. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/parsing.py +0 -0
  65. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/serial_cx.py +0 -0
  66. {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/tcp_cx.py +0 -0
  67. {meshcore-2.3.7 → meshcore-2.3.8}/tests/README.md +0 -0
  68. {meshcore-2.3.7 → meshcore-2.3.8}/tests/test_ble_connection.py +0 -0
  69. {meshcore-2.3.7 → meshcore-2.3.8}/tests/test_ble_pin_pairing.py +0 -0
  70. {meshcore-2.3.7 → meshcore-2.3.8}/tests/test_meshcore_ble_pin.py +0 -0
  71. {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_asyncio_lifecycle.py +0 -0
  72. {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_commands.py +0 -0
  73. {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_connection_manager.py +0 -0
  74. {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_events.py +0 -0
  75. {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_lpp_parsing.py +0 -0
  76. {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_parse_status.py +0 -0
  77. {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_path_discovery_response.py +0 -0
  78. {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_private_key_export.py +0 -0
  79. {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_reader.py +0 -0
  80. {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_serial_connection.py +0 -0
  81. {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_standalone_fixes.py +0 -0
  82. {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_transport_symmetry.py +0 -0
@@ -0,0 +1,2 @@
1
+ buy_me_a_coffee: fdlamotte
2
+ github: meshcore-dev
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcore
3
- Version: 2.3.7
3
+ Version: 2.3.8
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.7"
7
+ version = "2.3.8"
8
8
  authors = [
9
9
  { name="Florent de Lamotte", email="florent@frizoncorrea.fr" },
10
10
  { name="Alex Wolden", email="awolden@gmail.com" },
@@ -4,6 +4,7 @@ mccli.py : CLI interface to MeschCore BLE companion app
4
4
 
5
5
  import asyncio
6
6
  import logging
7
+ from typing import Optional
7
8
 
8
9
 
9
10
  # Make bleak optional - only fail if BLE operations are attempted
@@ -27,6 +28,11 @@ UART_RX_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"
27
28
  UART_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
28
29
 
29
30
  class BLEConnection:
31
+ # Upper bound on a single write (lock acquisition included). Healthy writes
32
+ # measured 0.06-0.17s against real hardware; observed stalls ran 20s to
33
+ # minutes, so this preempts them rather than waiting for CoreBluetooth.
34
+ WRITE_TIMEOUT = 10.0
35
+
30
36
  def __init__(self, address=None, device=None, client=None, pin=None):
31
37
  """
32
38
  Constructor: specify address or an existing BleakClient.
@@ -52,6 +58,27 @@ class BLEConnection:
52
58
  self.rx_char = None
53
59
  self._disconnect_callback = None
54
60
  self._background_tasks: set[asyncio.Task] = set()
61
+ self._write_lock_obj: Optional[asyncio.Lock] = None
62
+
63
+ @property
64
+ def _write_lock(self) -> asyncio.Lock:
65
+ """Serialises write_gatt_char().
66
+
67
+ Two overlapping writes to the same characteristic drop the link outright
68
+ (observed on macOS/CoreBluetooth: "BLE write failed: 19", connection
69
+ gone). Nothing above this layer guarantees callers are sequential --
70
+ schedulers, health checks and user commands all issue independently --
71
+ so the transport has to enforce it.
72
+
73
+ Lazily created so it binds to the running loop, mirroring the
74
+ _mesh_request_lock property in commands/base.py. Read through getattr so
75
+ an instance built without __init__ still works.
76
+ """
77
+ lock = getattr(self, "_write_lock_obj", None)
78
+ if lock is None:
79
+ lock = asyncio.Lock()
80
+ self._write_lock_obj = lock
81
+ return lock
55
82
 
56
83
  def _spawn_background(self, coro) -> asyncio.Task:
57
84
  """Create a tracked background task (prevents GC of fire-and-forget tasks)."""
@@ -190,6 +217,10 @@ class BLEConnection:
190
217
  if self.reader is not None:
191
218
  self._spawn_background(self.reader.handle_rx(data))
192
219
 
220
+ async def _write_locked(self, data):
221
+ async with self._write_lock:
222
+ await self.client.write_gatt_char(self.rx_char, bytes(data), response=True)
223
+
193
224
  async def send(self, data):
194
225
  if not self.client:
195
226
  logger.error("Client is not connected")
@@ -199,8 +230,24 @@ class BLEConnection:
199
230
  if not self.rx_char:
200
231
  logger.error("RX characteristic not found")
201
232
  return False
233
+ # Bound the whole acquire-plus-write. A stalled write has been seen to
234
+ # hang for minutes, and CommandHandler's own timeout does not cover this
235
+ # -- it starts only after _sender_func returns -- so without a bound the
236
+ # serialising lock would queue every other command behind the stall
237
+ # indefinitely, with nothing logged and no disconnect raised. Turning one
238
+ # hung command into a silent whole-client stall would be worse than the
239
+ # overlap the lock exists to prevent.
202
240
  try:
203
- await self.client.write_gatt_char(self.rx_char, bytes(data), response=True)
241
+ await asyncio.wait_for(self._write_locked(data), timeout=self.WRITE_TIMEOUT)
242
+ except asyncio.TimeoutError:
243
+ # Do not simply release and carry on: the underlying write may still
244
+ # be in flight, and a second write racing it re-creates the exact
245
+ # overlap that kills the link. Tear the connection down so the
246
+ # reconnect path takes over -- bounded and self-healing.
247
+ logger.warning(f"BLE write timed out after {self.WRITE_TIMEOUT}s")
248
+ if self._disconnect_callback:
249
+ await self._disconnect_callback("ble_write_timeout")
250
+ return False
204
251
  except Exception as exc:
205
252
  logger.warning(f"BLE write failed: {exc}")
206
253
  if self._disconnect_callback:
@@ -57,6 +57,64 @@ def _validate_destination(dst: DestinationType, prefix_length: int = 6) -> bytes
57
57
  )
58
58
 
59
59
 
60
+ # Size of the server-side reply_path buffer (uint8_t reply_path[64] in
61
+ # simple_repeater/MyMesh.h). It is memcpy'd into without a length check.
62
+ MAX_REPLY_PATH_BYTES = 64
63
+ # reply_path_len is the low 6 bits of the header byte.
64
+ MAX_REPLY_PATH_HOPS = 63
65
+
66
+
67
+ def encode_reply_path(out_path_len: int, out_path_hex: str, out_path_hash_mode: int) -> bytes:
68
+ """Encode the reply path a server should use when answering us.
69
+
70
+ The leading byte packs two fields, which the server unpacks as:
71
+
72
+ reply_path_len = byte & 63
73
+ reply_path_hash_size = (byte >> 6) + 1
74
+
75
+ so the hash mode has to travel in the top two bits. Omitting it makes the
76
+ server read a hash size of 1 regardless of the real mode, take the wrong
77
+ number of bytes per hop, and route its reply to hops that do not exist.
78
+
79
+ The path itself is reversed by *hop*, not by byte: a return path visits the
80
+ same hops in the opposite order, and each hop's multi-byte hash must stay
81
+ intact. (For single-byte hops the two are indistinguishable, which is most
82
+ of why this went unnoticed - mode 0 is the default.)
83
+ """
84
+ hash_mode = max(out_path_hash_mode, 0) # -1 means "flood", i.e. no path
85
+ if hash_mode > 2:
86
+ # The server computes hash_size = mode + 1, and Packet::isValidPathLen
87
+ # rejects 4-byte hops outright, so such a path is unusable on the wire.
88
+ logger.warning(
89
+ f"Unsupported out_path_hash_mode {out_path_hash_mode}; "
90
+ "requesting a zero-hop reply path instead"
91
+ )
92
+ return b"\x00"
93
+ hash_size = hash_mode + 1
94
+ # Saturate rather than mask: `& 63` would silently wrap a 64-hop path to
95
+ # zero hops, i.e. a zero-hop reply for a distant node.
96
+ hops = min(max(out_path_len, 0), MAX_REPLY_PATH_HOPS)
97
+
98
+ raw = bytes.fromhex(out_path_hex or "")
99
+ # Never read past what the contact actually carries; a truncated or padded
100
+ # field would otherwise yield short trailing hops.
101
+ hops = min(hops, len(raw) // hash_size)
102
+ # The server memcpys into a fixed 64-byte reply_path with no bounds check
103
+ # (simple_repeater/MyMesh.cpp), so never describe more than fits.
104
+ max_hops = min(MAX_REPLY_PATH_HOPS, MAX_REPLY_PATH_BYTES // hash_size)
105
+ if hops > max_hops:
106
+ logger.warning(
107
+ f"Reply path of {hops} hops x {hash_size}B exceeds the "
108
+ f"{MAX_REPLY_PATH_BYTES}B the server can hold; truncating to {max_hops}"
109
+ )
110
+ hops = max_hops
111
+
112
+ path = b"".join(
113
+ raw[i * hash_size:(i + 1) * hash_size] for i in range(hops - 1, -1, -1)
114
+ )
115
+ return bytes([hops | (hash_mode << 6)]) + path
116
+
117
+
60
118
  class CommandHandlerBase:
61
119
  """Base class for command handlers.
62
120
 
@@ -299,34 +357,73 @@ class CommandHandlerBase:
299
357
  return result
300
358
 
301
359
  async def send_anon_req(self, dst: DestinationType, request_type: AnonReqType, data: Optional[bytes] = None, context={}, timeout=None, min_timeout=0) -> Event:
360
+ """Send an anonymous request to *dst*.
361
+
362
+ *dst* need not be a known contact. When it is, that contact's out path is
363
+ used as the reply path; otherwise a zero-hop direct reply path is
364
+ requested (see the comment below).
365
+
366
+ Note: *data* is currently ignored -- the request body is the reply path,
367
+ which is derived here rather than supplied by the caller.
368
+ """
302
369
  dst_bytes = _validate_destination(dst, prefix_length=32)
303
370
  pubkey_prefix = _validate_destination(dst, prefix_length=6)
304
371
  logger.debug(f"Anon Binary request to {dst_bytes.hex()}")
305
372
 
306
- contact = self._get_contact_by_prefix(dst_bytes.hex()) # need a contact for return path
307
- if contact is None:
308
- logger.error("No contact found")
309
- return Event(EventType.ERROR, {"reason": "contact_not_found"})
373
+ # The contact is consulted only to build the reply path appended to the
374
+ # request; it is not required to send one. Companion firmware from
375
+ # FIRMWARE_VER_CODE 13 synthesises a transient anon contact for an unknown
376
+ # pubkey (out_path_len = 0, zero-hop direct), so an unknown destination is
377
+ # reachable as long as we ask it to reply zero-hop. Refusing here would
378
+ # block probing any node the client has not already added - for instance
379
+ # asking a freshly discovered neighbour for its regions.
380
+ contact = self._get_contact_by_prefix(dst_bytes.hex())
310
381
 
311
382
  zero_hop = False
312
- if contact["out_path_len"] == -1:
313
- logger.info("No path set trying zero hop")
314
- zero_hop = True
315
- await self.change_contact_path(contact, "")
383
+ if contact is None:
384
+ logger.debug("No contact found, requesting a zero-hop direct reply path")
385
+ out_path_len = 0
386
+ reply_path = encode_reply_path(0, "", 0)
387
+ else:
388
+ if contact["out_path_len"] == -1:
389
+ logger.info("No path set trying zero hop")
390
+ zero_hop = True
391
+ path_res = await self.change_contact_path(contact, "")
392
+ if path_res is not None and path_res.type == EventType.ERROR:
393
+ # The device still has this contact as flood, so sendAnonReq
394
+ # will flood the request -- and the server gates REGIONS,
395
+ # OWNER and BASIC behind isRouteDirect(), silently dropping
396
+ # it. Better to fail here than to wait out a full timeout
397
+ # for a reply that cannot come.
398
+ logger.error("Could not set zero-hop path, aborting anon request")
399
+ return Event(EventType.ERROR, {"reason": "path_reset_failed"})
400
+ # update_contact() normally reflects the change back onto the dict, so
401
+ # out_path_len reads 0 here. Clamp anyway: if that call failed (e.g. the
402
+ # device query inside it errored) the dict is still -1, and the unsigned
403
+ # to_bytes below would raise OverflowError -- which would skip the
404
+ # reset_path at the end of this method and leave the contact pinned to
405
+ # zero-hop on the device. Zero is the right value to send regardless,
406
+ # since zero-hop is exactly what we just asked for.
407
+ out_path_len = max(contact["out_path_len"], 0)
408
+ reply_path = encode_reply_path(
409
+ out_path_len,
410
+ contact["out_path"],
411
+ contact.get("out_path_hash_mode", 0),
412
+ )
316
413
 
317
- data = contact["out_path_len"].to_bytes(1, "little") + bytes.fromhex(contact["out_path"])[::-1]
318
- data = b"\x39" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + (data if data else b"")
414
+ data = b"\x39" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + reply_path
319
415
 
320
416
  result = await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
321
-
417
+
322
418
  # Register the request with the reader if we have both reader and request_type
323
- if (result.type == EventType.MSG_SENT and
324
- self._reader is not None and
419
+ if (result.type == EventType.MSG_SENT and
420
+ self._reader is not None and
325
421
  request_type is not None):
326
-
422
+
327
423
  exp_tag = result.payload["expected_ack"].hex()
328
424
  # Use provided timeout or fallback to suggested timeout (with 5s default)
329
- result.payload["suggested_timeout"] = result.payload.get("suggested_timeout", 4000) * (contact["out_path_len"] + 1) # update timeout from path_len
425
+ emitted_hops = reply_path[0] & 63 # what actually went on the wire
426
+ result.payload["suggested_timeout"] = result.payload.get("suggested_timeout", 4000) * (emitted_hops + 1) # update timeout from path_len
330
427
  actual_timeout = timeout if timeout is not None and timeout > 0 else result.payload.get("suggested_timeout", 4000) / 800.0
331
428
  actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout
332
429
  self._reader.register_binary_request(pubkey_prefix.hex(), exp_tag, request_type, actual_timeout, context=context, is_anon=True)
@@ -307,30 +307,41 @@ class MessagingCommands(CommandHandlerBase):
307
307
 
308
308
  return await self.send(cmd_data, [EventType.MSG_SENT, EventType.ERROR])
309
309
 
310
- async def send_raw_data(self, payload: bytes) -> Event:
310
+ async def send_raw_data(self, payload: bytes, path: bytes = b"") -> Event:
311
311
  """N09: Send raw data via CMD_SEND_RAW_DATA (25).
312
312
 
313
- Sends an arbitrary payload through the mesh network.
313
+ Sends an arbitrary raw-data payload directly (no flood support yet).
314
+
315
+ Command format:
316
+ 0x19 | path_len(1) | path(path_len bytes) | payload(>=4 bytes)
314
317
 
315
318
  Args:
316
- payload: Raw bytes to send.
319
+ payload: Raw bytes to send (minimum 4 bytes).
320
+ path: Optional path bytes for intermediate hops (default: empty = zero-hop direct).
317
321
 
318
322
  Returns:
319
- Event with MSG_SENT or ERROR.
323
+ Event with OK or ERROR.
320
324
  """
321
325
  if not isinstance(payload, (bytes, bytearray)):
322
326
  raise TypeError("payload must be bytes-like")
323
- data = b"\x19" + bytes(payload)
324
- return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
327
+ if len(payload) < 4:
328
+ raise ValueError("payload must be at least 4 bytes")
329
+ path = bytes(path)
330
+ data = bytes([0x19, len(path)]) + path + bytes(payload)
331
+ return await self.send(data, [EventType.OK, EventType.ERROR])
325
332
 
326
- async def set_flood_scope(self, scope):
333
+ async def set_flood_scope(self, scope, force_unscoped=False):
327
334
  if scope is None:
328
335
  logger.debug(f"Resetting scope")
329
336
  scope_key = b"\0"*16
330
337
  elif isinstance (scope, str):
331
- if scope == "0" or scope == "None" or scope == "*" or scope == "": # disable
338
+ if scope == "0" or scope == "None" or scope == "": # revert to default
332
339
  logger.debug(f"Resetting scope")
333
340
  scope_key = b"\0"*16
341
+ logger.debug("revert to default_scope")
342
+ elif scope == "*":
343
+ force_unscoped = True
344
+ logger.debug("forcing unscoped msgs")
334
345
  else:
335
346
  logger.debug(f"Setting scope from string {scope}")
336
347
  if scope[0] != "#": # no hashtag as first char
@@ -342,14 +353,28 @@ class MessagingCommands(CommandHandlerBase):
342
353
  else:
343
354
  raise TypeError(f"set_flood_scope: unsupported scope type {type(scope).__name__}")
344
355
 
345
- logger.debug(f"Setting scope to {scope_key.hex()}")
356
+ if force_unscoped:
357
+ logger.debug("Forcing unscoped messages")
358
+ elif scope_key is None:
359
+ logger.debug(f"Resetting scope")
360
+ else:
361
+ logger.debug(f"Setting scope to {scope_key.hex()}")
346
362
 
347
363
  cmd_data = bytearray([CommandType.SET_FLOOD_SCOPE.value])
348
- cmd_data.extend(b"\0")
349
- cmd_data.extend(scope_key)
364
+ if force_unscoped:
365
+ cmd_data.append(0x01)
366
+ else:
367
+ cmd_data.extend(b"\0")
368
+ cmd_data.extend(scope_key)
350
369
 
351
370
  return await self.send(cmd_data, [EventType.OK, EventType.ERROR])
352
371
 
372
+ async def reset_flood_scope(self):
373
+ return await self.set_flood_scope(b"")
374
+
375
+ async def force_unscoped(self):
376
+ return await self.set_flood_scope(b"", force_unscoped=True)
377
+
353
378
  async def set_default_flood_scope(self, scope):
354
379
  if scope is None:
355
380
  logger.debug(f"Resetting default scope")
@@ -378,6 +403,9 @@ class MessagingCommands(CommandHandlerBase):
378
403
 
379
404
  return await self.send(cmd_data, [EventType.OK, EventType.ERROR])
380
405
 
406
+ async def reset_default_flood_scope(self):
407
+ return await self.set_default_flood_scope(None)
408
+
381
409
  async def get_default_flood_scope(self):
382
410
  logger.debug(f"Getting default flood scope")
383
411
  cmd_data = bytearray([CommandType.GET_DEFAULT_FLOOD_SCOPE.value])
@@ -14,6 +14,7 @@ class EventType(Enum):
14
14
  SELF_INFO = "self_info"
15
15
  CONTACT_MSG_RECV = "contact_message"
16
16
  CHANNEL_MSG_RECV = "channel_message"
17
+ CHANNEL_DATA_RECV = "channel_data"
17
18
  CURRENT_TIME = "time_update"
18
19
  NO_MORE_MSGS = "no_more_messages"
19
20
  CONTACT_URI = "contact_uri"
@@ -105,6 +105,7 @@ class PacketType(Enum):
105
105
  STATS = 24
106
106
  AUTOADD_CONFIG = 25
107
107
  ALLOWED_REPEAT_FREQ = 26
108
+ CHANNEL_DATA_RECV = 27
108
109
  DEFAULT_FLOOD_SCOPE = 28
109
110
 
110
111
  # Push notifications
@@ -112,7 +112,17 @@ class MessageReader:
112
112
  else:
113
113
  c["out_path_hash_mode"] = plen >> 6
114
114
  c["out_path_len"] = plen & 0x3F # 6 LSB
115
- c["out_path"] = dbuf.read(64).replace(b"\0", b"").hex()
115
+ # The field is a fixed 64 bytes, NUL-padded past the real path.
116
+ # Take exactly the bytes the path occupies rather than stripping
117
+ # NULs: a hop hash may legitimately contain 0x00, and dropping
118
+ # those shortens the path and shifts every hop after it.
119
+ # (PATH_DISCOVERY_RESPONSE below already reads opl*opl_hlen.)
120
+ path_bytes = dbuf.read(64)
121
+ if c["out_path_len"] > 0:
122
+ used = c["out_path_len"] * (c["out_path_hash_mode"] + 1)
123
+ c["out_path"] = path_bytes[:used].hex()
124
+ else:
125
+ c["out_path"] = ""
116
126
  c["adv_name"] = dbuf.read(32).decode("utf-8", "ignore").replace("\0", "")
117
127
  c["last_advert"] = int.from_bytes(dbuf.read(4), byteorder="little")
118
128
  c["adv_lat"] = (
@@ -184,8 +194,13 @@ class MessageReader:
184
194
 
185
195
  elif packet_type_value == PacketType.DEFAULT_FLOOD_SCOPE.value:
186
196
  res = {}
187
- res["scope_name"] = dbuf.read(31).decode("utf-8", "ignore").replace("\0", "")
188
- res["scope_key"] = dbuf.read(16).hex()
197
+ # Firmware emits a 48-byte frame when a scope is configured,
198
+ # or a 1-byte sentinel frame (just the response code) when no
199
+ # scope is set. Gate the 31+16 byte read so the sentinel
200
+ # dispatches an empty payload instead of empty-string fields.
201
+ if len(data) >= 48:
202
+ res["scope_name"] = dbuf.read(31).decode("utf-8", "ignore").replace("\0", "")
203
+ res["scope_key"] = dbuf.read(16).hex()
189
204
  await self.dispatcher.dispatch(Event(EventType.DEFAULT_FLOOD_SCOPE, res))
190
205
 
191
206
  elif packet_type_value == PacketType.MSG_SENT.value:
@@ -332,6 +347,41 @@ class MessageReader:
332
347
  Event(EventType.CHANNEL_MSG_RECV, res, attributes)
333
348
  )
334
349
 
350
+ elif packet_type_value == PacketType.CHANNEL_DATA_RECV.value:
351
+ # Group-channel binary data (PAYLOAD_TYPE_GRP_DATA), companion-v1.15.0+.
352
+ # Fixed 9-byte header (including the code byte) + variable payload:
353
+ # code(1) + snr(1) + reserved(2) + channel_idx(1)
354
+ # + path_len(1) + data_type(2) + data_len(1) = 9 bytes
355
+ # The first six post-code bytes share CHANNEL_MSG_RECV_V3's framing;
356
+ # data_type is a 16-bit little-endian field (widened from uint8 in
357
+ # firmware, so the high byte may be non-zero).
358
+ if len(data) < 9:
359
+ logger.debug(f"CHANNEL_DATA_RECV frame too short ({len(data)} bytes < 9), skipping parse")
360
+ return
361
+ res = {}
362
+ res["SNR"] = int.from_bytes(dbuf.read(1), byteorder="little", signed=True) / 4
363
+ dbuf.read(2) # reserved
364
+ res["channel_idx"] = dbuf.read(1)[0]
365
+ plen = dbuf.read(1)[0]
366
+ if plen == 255: # direct message
367
+ res["path_hash_mode"] = -1
368
+ res["path_len"] = plen
369
+ else:
370
+ res["path_hash_mode"] = plen >> 6
371
+ res["path_len"] = plen & 0x3F
372
+ res["data_type"] = int.from_bytes(dbuf.read(2), byteorder="little")
373
+ res["data_len"] = dbuf.read(1)[0]
374
+ res["payload"] = dbuf.read(res["data_len"]).hex()
375
+
376
+ attributes = {
377
+ "channel_idx": res["channel_idx"],
378
+ "data_type": res["data_type"],
379
+ }
380
+
381
+ await self.dispatcher.dispatch(
382
+ Event(EventType.CHANNEL_DATA_RECV, res, attributes)
383
+ )
384
+
335
385
  elif packet_type_value == PacketType.CURRENT_TIME.value:
336
386
  time_value = int.from_bytes(dbuf.read(4), byteorder="little")
337
387
  result = {"time": time_value}
@@ -491,7 +541,12 @@ class MessageReader:
491
541
 
492
542
  res = {}
493
543
  res["config"] = dbuf.read(1)[0]
494
- await self.dispatcher.dispatch(Event(EventType.AUTOADD_CONFIG, res, res))
544
+ # `max_hops` trailing byte added in companion-v1.14.0
545
+ # (firmware commit 00566741). Pre-v1.14.0 firmware emits a
546
+ # 1-byte response; read defensively to remain compatible.
547
+ if len(data) >= 3:
548
+ res["max_hops"] = dbuf.read(1)[0]
549
+ await self.dispatcher.dispatch(Event(EventType.AUTOADD_CONFIG, res, res))
495
550
 
496
551
  elif packet_type_value == PacketType.CHANNEL_INFO.value:
497
552
  logger.debug(f"received channel info response: {data.hex()}")
@@ -533,6 +588,12 @@ class MessageReader:
533
588
 
534
589
  if len(data) >= 5:
535
590
  ack_data["code"] = dbuf.read(4).hex()
591
+ # `trip_time` (round-trip latency in ms) has been on the wire
592
+ # since companion-v1.0.0a (firmware commit d9dc76f1, Jan 2025).
593
+ # Read defensively so legacy frames without this field still
594
+ # dispatch correctly.
595
+ if len(data) >= 9:
596
+ ack_data["trip_time"] = int.from_bytes(dbuf.read(4), "little")
536
597
 
537
598
  attributes = {"code": ack_data.get("code", "")}
538
599
 
@@ -546,7 +607,8 @@ class MessageReader:
546
607
  res = {}
547
608
  res["SNR"] = int.from_bytes(dbuf.read(1), byteorder="little", signed=True) / 4
548
609
  res["RSSI"] = int.from_bytes(dbuf.read(1), byteorder="little", signed=True)
549
- res["payload"] = dbuf.read(4).hex()
610
+ dbuf.read(1) # skip reserved byte (0xFF, possibly path_len in future)
611
+ res["payload"] = dbuf.read().hex() # read all remaining payload bytes
550
612
  logger.debug("Received raw data")
551
613
  logger.debug(res)
552
614
  await self.dispatcher.dispatch(Event(EventType.RAW_DATA, res))
@@ -560,8 +622,19 @@ class MessageReader:
560
622
  res["is_admin"] = (perms & 1) == 1 # Check if admin bit is set
561
623
  if len(data) > 7:
562
624
  res["pubkey_prefix"] = dbuf.read(6).hex()
563
-
625
+
564
626
  attributes = {"pubkey_prefix": res.get("pubkey_prefix")}
627
+ # The following trailing fields are emitted only by the new-
628
+ # style RESP_SERVER_LOGIN_OK path. server_timestamp landed in
629
+ # firmware commit 0e90b731 (companion-v1.10.0); acl_permissions
630
+ # in 7947e8a2; fw_ver_level in 418ae08b (also companion-v1.10.0).
631
+ # Per-field length gates handle every firmware-version tier.
632
+ if len(data) >= 12:
633
+ res["server_timestamp"] = int.from_bytes(dbuf.read(4), "little")
634
+ if len(data) >= 13:
635
+ res["acl_permissions"] = dbuf.read(1)[0]
636
+ if len(data) >= 14:
637
+ res["fw_ver_level"] = dbuf.read(1)[0]
565
638
 
566
639
  await self.dispatcher.dispatch(
567
640
  Event(EventType.LOGIN_SUCCESS, res, attributes)