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.
- meshcore-2.3.8/.github/funding.yml +2 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/PKG-INFO +1 -1
- {meshcore-2.3.7 → meshcore-2.3.8}/pyproject.toml +1 -1
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/ble_cx.py +48 -1
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/commands/base.py +112 -15
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/commands/messaging.py +39 -11
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/events.py +1 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/packets.py +1 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/reader.py +79 -6
- meshcore-2.3.8/tests/unit/test_error_handling.py +716 -0
- meshcore-2.3.8/tests/unit/test_protocol_surface_gaps.py +725 -0
- meshcore-2.3.7/tests/unit/test_error_handling.py +0 -236
- meshcore-2.3.7/tests/unit/test_protocol_surface_gaps.py +0 -364
- {meshcore-2.3.7 → meshcore-2.3.8}/.github/python-test.yml +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/.gitignore +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/LICENSE +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/README.md +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/.pubsub_example.py.swp +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_chat.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_pin_pairing_example.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_private_key_export.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_sign_example.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_stats.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_t1000_chan_msg.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_t1000_custom_vars.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_t1000_infos.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_t1000_msg.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_t1000_msg_retries.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/ble_t1000_set_cv.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/chan_recv_with_path.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/connection_events_example.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/mepo_mc_gps.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/pubsub_example.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/rf_packet_monitor.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_battery_monitor.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_channel_manager.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_chat.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_contacts.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_infos.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_meshcore_ollama.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_msg.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_pingbot.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_repeater_status.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_repeater_telemetry.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_rss_bot.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/serial_trace.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/tcp_chat.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/tcp_login_status.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/tcp_mchome_contacts.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/tcp_mchome_infos.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/tcp_mchome_msg.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/examples/tcp_mchome_readmsgs.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/pytest.ini +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/__init__.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/commands/__init__.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/commands/binary.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/commands/contact.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/commands/control_data.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/commands/device.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/connection_manager.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/lpp_json_encoder.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/meshcore.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/meshcore_parser.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/parsing.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/serial_cx.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/src/meshcore/tcp_cx.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/README.md +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/test_ble_connection.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/test_ble_pin_pairing.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/test_meshcore_ble_pin.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_asyncio_lifecycle.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_commands.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_connection_manager.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_events.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_lpp_parsing.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_parse_status.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_path_discovery_response.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_private_key_export.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_reader.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_serial_connection.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_standalone_fixes.py +0 -0
- {meshcore-2.3.7 → meshcore-2.3.8}/tests/unit/test_transport_symmetry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meshcore
|
|
3
|
-
Version: 2.3.
|
|
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,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
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
313
|
-
logger.
|
|
314
|
-
|
|
315
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
324
|
-
|
|
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 == "
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|