meshcore 2.3.7__tar.gz → 2.3.9__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.9/.github/funding.yml +2 -0
  2. {meshcore-2.3.7 → meshcore-2.3.9}/PKG-INFO +4 -2
  3. {meshcore-2.3.7 → meshcore-2.3.9}/README.md +2 -0
  4. {meshcore-2.3.7 → meshcore-2.3.9}/pyproject.toml +1 -1
  5. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/ble_cx.py +48 -1
  6. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/commands/base.py +112 -15
  7. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/commands/device.py +17 -0
  8. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/commands/messaging.py +58 -21
  9. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/events.py +2 -0
  10. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/packets.py +17 -0
  11. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/reader.py +84 -6
  12. meshcore-2.3.9/tests/unit/test_error_handling.py +716 -0
  13. meshcore-2.3.9/tests/unit/test_protocol_surface_gaps.py +725 -0
  14. meshcore-2.3.7/tests/unit/test_error_handling.py +0 -236
  15. meshcore-2.3.7/tests/unit/test_protocol_surface_gaps.py +0 -364
  16. {meshcore-2.3.7 → meshcore-2.3.9}/.github/python-test.yml +0 -0
  17. {meshcore-2.3.7 → meshcore-2.3.9}/.gitignore +0 -0
  18. {meshcore-2.3.7 → meshcore-2.3.9}/LICENSE +0 -0
  19. {meshcore-2.3.7 → meshcore-2.3.9}/examples/.pubsub_example.py.swp +0 -0
  20. {meshcore-2.3.7 → meshcore-2.3.9}/examples/ble_chat.py +0 -0
  21. {meshcore-2.3.7 → meshcore-2.3.9}/examples/ble_pin_pairing_example.py +0 -0
  22. {meshcore-2.3.7 → meshcore-2.3.9}/examples/ble_private_key_export.py +0 -0
  23. {meshcore-2.3.7 → meshcore-2.3.9}/examples/ble_sign_example.py +0 -0
  24. {meshcore-2.3.7 → meshcore-2.3.9}/examples/ble_stats.py +0 -0
  25. {meshcore-2.3.7 → meshcore-2.3.9}/examples/ble_t1000_chan_msg.py +0 -0
  26. {meshcore-2.3.7 → meshcore-2.3.9}/examples/ble_t1000_custom_vars.py +0 -0
  27. {meshcore-2.3.7 → meshcore-2.3.9}/examples/ble_t1000_infos.py +0 -0
  28. {meshcore-2.3.7 → meshcore-2.3.9}/examples/ble_t1000_msg.py +0 -0
  29. {meshcore-2.3.7 → meshcore-2.3.9}/examples/ble_t1000_msg_retries.py +0 -0
  30. {meshcore-2.3.7 → meshcore-2.3.9}/examples/ble_t1000_set_cv.py +0 -0
  31. {meshcore-2.3.7 → meshcore-2.3.9}/examples/chan_recv_with_path.py +0 -0
  32. {meshcore-2.3.7 → meshcore-2.3.9}/examples/connection_events_example.py +0 -0
  33. {meshcore-2.3.7 → meshcore-2.3.9}/examples/mepo_mc_gps.py +0 -0
  34. {meshcore-2.3.7 → meshcore-2.3.9}/examples/pubsub_example.py +0 -0
  35. {meshcore-2.3.7 → meshcore-2.3.9}/examples/rf_packet_monitor.py +0 -0
  36. {meshcore-2.3.7 → meshcore-2.3.9}/examples/serial_battery_monitor.py +0 -0
  37. {meshcore-2.3.7 → meshcore-2.3.9}/examples/serial_channel_manager.py +0 -0
  38. {meshcore-2.3.7 → meshcore-2.3.9}/examples/serial_chat.py +0 -0
  39. {meshcore-2.3.7 → meshcore-2.3.9}/examples/serial_contacts.py +0 -0
  40. {meshcore-2.3.7 → meshcore-2.3.9}/examples/serial_infos.py +0 -0
  41. {meshcore-2.3.7 → meshcore-2.3.9}/examples/serial_meshcore_ollama.py +0 -0
  42. {meshcore-2.3.7 → meshcore-2.3.9}/examples/serial_msg.py +0 -0
  43. {meshcore-2.3.7 → meshcore-2.3.9}/examples/serial_pingbot.py +0 -0
  44. {meshcore-2.3.7 → meshcore-2.3.9}/examples/serial_repeater_status.py +0 -0
  45. {meshcore-2.3.7 → meshcore-2.3.9}/examples/serial_repeater_telemetry.py +0 -0
  46. {meshcore-2.3.7 → meshcore-2.3.9}/examples/serial_rss_bot.py +0 -0
  47. {meshcore-2.3.7 → meshcore-2.3.9}/examples/serial_trace.py +0 -0
  48. {meshcore-2.3.7 → meshcore-2.3.9}/examples/tcp_chat.py +0 -0
  49. {meshcore-2.3.7 → meshcore-2.3.9}/examples/tcp_login_status.py +0 -0
  50. {meshcore-2.3.7 → meshcore-2.3.9}/examples/tcp_mchome_contacts.py +0 -0
  51. {meshcore-2.3.7 → meshcore-2.3.9}/examples/tcp_mchome_infos.py +0 -0
  52. {meshcore-2.3.7 → meshcore-2.3.9}/examples/tcp_mchome_msg.py +0 -0
  53. {meshcore-2.3.7 → meshcore-2.3.9}/examples/tcp_mchome_readmsgs.py +0 -0
  54. {meshcore-2.3.7 → meshcore-2.3.9}/pytest.ini +0 -0
  55. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/__init__.py +0 -0
  56. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/commands/__init__.py +0 -0
  57. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/commands/binary.py +0 -0
  58. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/commands/contact.py +0 -0
  59. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/commands/control_data.py +0 -0
  60. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/connection_manager.py +0 -0
  61. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/lpp_json_encoder.py +0 -0
  62. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/meshcore.py +0 -0
  63. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/meshcore_parser.py +0 -0
  64. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/parsing.py +0 -0
  65. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/serial_cx.py +0 -0
  66. {meshcore-2.3.7 → meshcore-2.3.9}/src/meshcore/tcp_cx.py +0 -0
  67. {meshcore-2.3.7 → meshcore-2.3.9}/tests/README.md +0 -0
  68. {meshcore-2.3.7 → meshcore-2.3.9}/tests/test_ble_connection.py +0 -0
  69. {meshcore-2.3.7 → meshcore-2.3.9}/tests/test_ble_pin_pairing.py +0 -0
  70. {meshcore-2.3.7 → meshcore-2.3.9}/tests/test_meshcore_ble_pin.py +0 -0
  71. {meshcore-2.3.7 → meshcore-2.3.9}/tests/unit/test_asyncio_lifecycle.py +0 -0
  72. {meshcore-2.3.7 → meshcore-2.3.9}/tests/unit/test_commands.py +0 -0
  73. {meshcore-2.3.7 → meshcore-2.3.9}/tests/unit/test_connection_manager.py +0 -0
  74. {meshcore-2.3.7 → meshcore-2.3.9}/tests/unit/test_events.py +0 -0
  75. {meshcore-2.3.7 → meshcore-2.3.9}/tests/unit/test_lpp_parsing.py +0 -0
  76. {meshcore-2.3.7 → meshcore-2.3.9}/tests/unit/test_parse_status.py +0 -0
  77. {meshcore-2.3.7 → meshcore-2.3.9}/tests/unit/test_path_discovery_response.py +0 -0
  78. {meshcore-2.3.7 → meshcore-2.3.9}/tests/unit/test_private_key_export.py +0 -0
  79. {meshcore-2.3.7 → meshcore-2.3.9}/tests/unit/test_reader.py +0 -0
  80. {meshcore-2.3.7 → meshcore-2.3.9}/tests/unit/test_serial_connection.py +0 -0
  81. {meshcore-2.3.7 → meshcore-2.3.9}/tests/unit/test_standalone_fixes.py +0 -0
  82. {meshcore-2.3.7 → meshcore-2.3.9}/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
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: meshcore
3
- Version: 2.3.7
3
+ Version: 2.3.9
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
@@ -541,6 +541,8 @@ All commands are async methods that return `Event` objects. Commands are organiz
541
541
  | **Device Actions** ||||
542
542
  | `send_advert(flood=False)` | `flood: bool` | `OK` | Send advertisement (optionally flood network) |
543
543
  | `reboot()` | None | None | Reboot device (no response expected) |
544
+ | `run_cli_command()` | `cmd: str` | `CLI_REPLY/ERROR` | Send a cli command to device |
545
+ | `send_raw_packet()` | `data: str/bytes, priority=0` | `OK/ERROR` | Send a raw packet through device, can be an hex string or bytes |
544
546
  | **Security** ||||
545
547
  | `export_private_key()` | None | `PRIVATE_KEY/DISABLED` | Export device private key (requires PIN auth & enabled firmware) |
546
548
  | `import_private_key(key)` | `key: bytes` | `OK` | Import private key to device |
@@ -518,6 +518,8 @@ All commands are async methods that return `Event` objects. Commands are organiz
518
518
  | **Device Actions** ||||
519
519
  | `send_advert(flood=False)` | `flood: bool` | `OK` | Send advertisement (optionally flood network) |
520
520
  | `reboot()` | None | None | Reboot device (no response expected) |
521
+ | `run_cli_command()` | `cmd: str` | `CLI_REPLY/ERROR` | Send a cli command to device |
522
+ | `send_raw_packet()` | `data: str/bytes, priority=0` | `OK/ERROR` | Send a raw packet through device, can be an hex string or bytes |
521
523
  | **Security** ||||
522
524
  | `export_private_key()` | None | `PRIVATE_KEY/DISABLED` | Export device private key (requires PIN auth & enabled firmware) |
523
525
  | `import_private_key(key)` | `key: bytes` | `OK` | Import private key to device |
@@ -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.9"
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)
@@ -20,6 +20,23 @@ class DeviceCommands(CommandHandlerBase):
20
20
  logger.debug("Sending device query command")
21
21
  return await self.send(b"\x16\x03", [EventType.DEVICE_INFO, EventType.ERROR])
22
22
 
23
+ async def run_cli_command(self, cmd: str) -> Event:
24
+ logger.debug(f"Sending cli command: {cmd}")
25
+ cmd_data = bytearray([CommandType.RUN_CLI_COMMAND.value])
26
+ cmd_data.extend(cmd.encode("utf-8"))
27
+ return await self.send(cmd_data, [EventType.CLI_REPLY, EventType.ERROR])
28
+
29
+ async def send_raw_packet(self, data, priority=0):
30
+ if isinstance(data, str): # got a string (assuming hex), change it to bytes
31
+ data=bytes.fromhex(data)
32
+
33
+ cmd_data = bytearray([CommandType.SEND_RAW_PACKET.value])
34
+ cmd_data.append(priority)
35
+ cmd_data.extend(data)
36
+
37
+ logger.debug(f"Sending raw packet {data.hex()}")
38
+ return await self.send(cmd_data, [EventType.OK, EventType.ERROR])
39
+
23
40
  async def send_advert(self, flood: bool = False) -> Event:
24
41
  logger.debug(f"Sending advertisement command (flood={flood})")
25
42
  if flood:
@@ -4,7 +4,7 @@ from typing import Optional, Union
4
4
  from hashlib import sha256
5
5
 
6
6
  from ..events import Event, EventType
7
- from ..packets import CommandType
7
+ from ..packets import CommandType, TxtType, AdvType
8
8
  from .base import CommandHandlerBase, DestinationType, _validate_destination
9
9
 
10
10
  logger = logging.getLogger("meshcore")
@@ -61,23 +61,32 @@ class MessagingCommands(CommandHandlerBase):
61
61
  return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
62
62
 
63
63
  async def send_cmd(
64
- self, dst: DestinationType, cmd: str, timestamp: Optional[int] = None
64
+ self, dst: DestinationType, cmd: str, timestamp: Optional[int] = None,
65
+ dst_type = None # if None, will have to get a contact to know this
65
66
  ) -> Event:
66
67
  dst_bytes = _validate_destination(dst)
67
68
  logger.debug(f"Sending command to {dst_bytes.hex()}: {cmd}")
68
69
 
69
70
  if timestamp is None:
70
71
  import time
71
-
72
72
  timestamp = int(time.time())
73
73
 
74
- data = (
75
- b"\x02\x01\x00"
76
- + timestamp.to_bytes(4, "little")
77
- + dst_bytes
78
- + cmd.encode("utf-8")
79
- )
80
- return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
74
+ if dst_type is None:
75
+ dst_type = dst["type"]
76
+ else: # assume destination is a repeater
77
+ logger.warning("Can't determine destination type, please ensure contacts first or specify dst_type when calling `send_cmd`. Assuming it's a repeater.")
78
+ dst_type = AdvType.REPEATER.value
79
+
80
+ cmd_data = bytearray([CommandType.SEND_TXT_MSG.value])
81
+ if dst_type == AdvType.REPEATER.value :
82
+ cmd_data.append(TxtType.CLI_DATA.value)
83
+ else:
84
+ cmd_data.append(TxtType.CLI_CMD.value)
85
+ cmd_data.append(0) # first and only attempt
86
+ cmd_data.extend(timestamp.to_bytes(4, "little"))
87
+ cmd_data.extend(dst_bytes)
88
+ cmd_data.extend(cmd.encode("utf-8"))
89
+ return await self.send(cmd_data, [EventType.MSG_SENT, EventType.ERROR])
81
90
 
82
91
  async def send_msg(
83
92
  self, dst: DestinationType, msg: str, timestamp: Optional[int] = None,
@@ -307,30 +316,41 @@ class MessagingCommands(CommandHandlerBase):
307
316
 
308
317
  return await self.send(cmd_data, [EventType.MSG_SENT, EventType.ERROR])
309
318
 
310
- async def send_raw_data(self, payload: bytes) -> Event:
319
+ async def send_raw_data(self, payload: bytes, path: bytes = b"") -> Event:
311
320
  """N09: Send raw data via CMD_SEND_RAW_DATA (25).
312
321
 
313
- Sends an arbitrary payload through the mesh network.
322
+ Sends an arbitrary raw-data payload directly (no flood support yet).
323
+
324
+ Command format:
325
+ 0x19 | path_len(1) | path(path_len bytes) | payload(>=4 bytes)
314
326
 
315
327
  Args:
316
- payload: Raw bytes to send.
328
+ payload: Raw bytes to send (minimum 4 bytes).
329
+ path: Optional path bytes for intermediate hops (default: empty = zero-hop direct).
317
330
 
318
331
  Returns:
319
- Event with MSG_SENT or ERROR.
332
+ Event with OK or ERROR.
320
333
  """
321
334
  if not isinstance(payload, (bytes, bytearray)):
322
335
  raise TypeError("payload must be bytes-like")
323
- data = b"\x19" + bytes(payload)
324
- return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
336
+ if len(payload) < 4:
337
+ raise ValueError("payload must be at least 4 bytes")
338
+ path = bytes(path)
339
+ data = bytes([0x19, len(path)]) + path + bytes(payload)
340
+ return await self.send(data, [EventType.OK, EventType.ERROR])
325
341
 
326
- async def set_flood_scope(self, scope):
342
+ async def set_flood_scope(self, scope, force_unscoped=False):
327
343
  if scope is None:
328
344
  logger.debug(f"Resetting scope")
329
345
  scope_key = b"\0"*16
330
346
  elif isinstance (scope, str):
331
- if scope == "0" or scope == "None" or scope == "*" or scope == "": # disable
347
+ if scope == "0" or scope == "None" or scope == "": # revert to default
332
348
  logger.debug(f"Resetting scope")
333
349
  scope_key = b"\0"*16
350
+ logger.debug("revert to default_scope")
351
+ elif scope == "*":
352
+ force_unscoped = True
353
+ logger.debug("forcing unscoped msgs")
334
354
  else:
335
355
  logger.debug(f"Setting scope from string {scope}")
336
356
  if scope[0] != "#": # no hashtag as first char
@@ -342,14 +362,28 @@ class MessagingCommands(CommandHandlerBase):
342
362
  else:
343
363
  raise TypeError(f"set_flood_scope: unsupported scope type {type(scope).__name__}")
344
364
 
345
- logger.debug(f"Setting scope to {scope_key.hex()}")
365
+ if force_unscoped:
366
+ logger.debug("Forcing unscoped messages")
367
+ elif scope_key is None:
368
+ logger.debug(f"Resetting scope")
369
+ else:
370
+ logger.debug(f"Setting scope to {scope_key.hex()}")
346
371
 
347
372
  cmd_data = bytearray([CommandType.SET_FLOOD_SCOPE.value])
348
- cmd_data.extend(b"\0")
349
- cmd_data.extend(scope_key)
373
+ if force_unscoped:
374
+ cmd_data.append(0x01)
375
+ else:
376
+ cmd_data.extend(b"\0")
377
+ cmd_data.extend(scope_key)
350
378
 
351
379
  return await self.send(cmd_data, [EventType.OK, EventType.ERROR])
352
380
 
381
+ async def reset_flood_scope(self):
382
+ return await self.set_flood_scope(b"")
383
+
384
+ async def force_unscoped(self):
385
+ return await self.set_flood_scope(b"", force_unscoped=True)
386
+
353
387
  async def set_default_flood_scope(self, scope):
354
388
  if scope is None:
355
389
  logger.debug(f"Resetting default scope")
@@ -378,6 +412,9 @@ class MessagingCommands(CommandHandlerBase):
378
412
 
379
413
  return await self.send(cmd_data, [EventType.OK, EventType.ERROR])
380
414
 
415
+ async def reset_default_flood_scope(self):
416
+ return await self.set_default_flood_scope(None)
417
+
381
418
  async def get_default_flood_scope(self):
382
419
  logger.debug(f"Getting default flood scope")
383
420
  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"
@@ -24,6 +25,7 @@ class EventType(Enum):
24
25
  NEXT_CONTACT = "next_contact"
25
26
  AUTOADD_CONFIG = "autoadd_config"
26
27
  ADVERT_PATH = "advert_path"
28
+ CLI_REPLY = "cli_reply"
27
29
 
28
30
  # Push notifications
29
31
  ADVERTISEMENT = "advertisement"
@@ -1,5 +1,18 @@
1
1
  from enum import Enum
2
2
 
3
+ class AdvType(Enum):
4
+ NONE = 0x00
5
+ CHAT = 0x01
6
+ REPEATER = 0x02
7
+ ROOM = 0x03
8
+ SENSOR = 0x04
9
+
10
+ class TxtType(Enum):
11
+ PLAIN = 0x00
12
+ CLI_DATA = 0x01
13
+ SIGNED_PLAIN = 0x02
14
+ CLI_CMD = 0x03
15
+
3
16
  class AnonReqType(Enum):
4
17
  REGIONS = 0x01
5
18
  OWNER = 0x02
@@ -75,6 +88,8 @@ class CommandType(Enum):
75
88
  SET_PATH_HASH_MODE = 61
76
89
  SET_DEFAULT_FLOOD_SCOPE = 63
77
90
  GET_DEFAULT_FLOOD_SCOPE = 64
91
+ SEND_RAW_PACKET = 65
92
+ RUN_CLI_COMMAND = 66
78
93
 
79
94
  # Packet prefixes for the protocol
80
95
  class PacketType(Enum):
@@ -105,7 +120,9 @@ class PacketType(Enum):
105
120
  STATS = 24
106
121
  AUTOADD_CONFIG = 25
107
122
  ALLOWED_REPEAT_FREQ = 26
123
+ CHANNEL_DATA_RECV = 27
108
124
  DEFAULT_FLOOD_SCOPE = 28
125
+ CLI_REPLY = 29
109
126
 
110
127
  # Push notifications
111
128
  ADVERTISEMENT = 0x80
@@ -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)
@@ -979,6 +1052,11 @@ class MessageReader:
979
1052
  res = {"rx_delay": rx_delay, "airtime_factor": airtime_factor}
980
1053
  await self.dispatcher.dispatch(Event(EventType.TUNING_PARAMS, res))
981
1054
 
1055
+ elif packet_type_value == PacketType.CLI_REPLY.value:
1056
+ res = {}
1057
+ res["text"] = data[1:].decode("utf-8", "ignore")
1058
+ await self.dispatcher.dispatch(Event(EventType.CLI_REPLY, res))
1059
+
982
1060
  else:
983
1061
  logger.debug(f"Unhandled data received {data}")
984
1062
  logger.debug(f"Unhandled packet type: {packet_type_value}")