meshcore 2.3.6__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.6 → meshcore-2.3.8}/PKG-INFO +1 -1
- {meshcore-2.3.6 → meshcore-2.3.8}/pyproject.toml +1 -1
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/ble_cx.py +83 -6
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/commands/base.py +170 -32
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/commands/binary.py +0 -4
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/commands/contact.py +30 -6
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/commands/device.py +101 -13
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/commands/messaging.py +96 -7
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/connection_manager.py +56 -39
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/events.py +47 -3
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/meshcore.py +20 -4
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/meshcore_parser.py +61 -36
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/packets.py +8 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/parsing.py +7 -1
- meshcore-2.3.8/src/meshcore/reader.py +1065 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/serial_cx.py +27 -8
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/tcp_cx.py +22 -9
- {meshcore-2.3.6 → meshcore-2.3.8}/tests/test_ble_pin_pairing.py +9 -10
- meshcore-2.3.8/tests/unit/test_asyncio_lifecycle.py +235 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/tests/unit/test_commands.py +11 -0
- meshcore-2.3.8/tests/unit/test_connection_manager.py +294 -0
- meshcore-2.3.8/tests/unit/test_error_handling.py +716 -0
- meshcore-2.3.8/tests/unit/test_parse_status.py +146 -0
- meshcore-2.3.8/tests/unit/test_protocol_surface_gaps.py +725 -0
- meshcore-2.3.8/tests/unit/test_reader.py +280 -0
- meshcore-2.3.8/tests/unit/test_standalone_fixes.py +227 -0
- meshcore-2.3.8/tests/unit/test_transport_symmetry.py +238 -0
- meshcore-2.3.6/src/meshcore/reader.py +0 -921
- meshcore-2.3.6/tests/unit/test_reader.py +0 -90
- {meshcore-2.3.6 → meshcore-2.3.8}/.github/python-test.yml +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/.gitignore +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/LICENSE +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/README.md +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/.pubsub_example.py.swp +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_chat.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_pin_pairing_example.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_private_key_export.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_sign_example.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_stats.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_t1000_chan_msg.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_t1000_custom_vars.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_t1000_infos.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_t1000_msg.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_t1000_msg_retries.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_t1000_set_cv.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/chan_recv_with_path.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/connection_events_example.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/mepo_mc_gps.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/pubsub_example.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/rf_packet_monitor.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_battery_monitor.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_channel_manager.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_chat.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_contacts.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_infos.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_meshcore_ollama.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_msg.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_pingbot.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_repeater_status.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_repeater_telemetry.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_rss_bot.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_trace.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/tcp_chat.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/tcp_login_status.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/tcp_mchome_contacts.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/tcp_mchome_infos.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/tcp_mchome_msg.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/examples/tcp_mchome_readmsgs.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/pytest.ini +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/__init__.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/commands/__init__.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/commands/control_data.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/lpp_json_encoder.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/tests/README.md +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/tests/test_ble_connection.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/tests/test_meshcore_ble_pin.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/tests/unit/test_events.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/tests/unit/test_lpp_parsing.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/tests/unit/test_path_discovery_response.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/tests/unit/test_private_key_export.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.8}/tests/unit/test_serial_connection.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.
|
|
@@ -51,6 +57,35 @@ class BLEConnection:
|
|
|
51
57
|
self.pin = pin
|
|
52
58
|
self.rx_char = None
|
|
53
59
|
self._disconnect_callback = None
|
|
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
|
|
82
|
+
|
|
83
|
+
def _spawn_background(self, coro) -> asyncio.Task:
|
|
84
|
+
"""Create a tracked background task (prevents GC of fire-and-forget tasks)."""
|
|
85
|
+
task = asyncio.create_task(coro)
|
|
86
|
+
self._background_tasks.add(task)
|
|
87
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
88
|
+
return task
|
|
54
89
|
|
|
55
90
|
async def connect(self):
|
|
56
91
|
"""
|
|
@@ -116,9 +151,12 @@ class BLEConnection:
|
|
|
116
151
|
await self.client.pair()
|
|
117
152
|
logger.info("BLE pairing successful")
|
|
118
153
|
except Exception as e:
|
|
119
|
-
logger.
|
|
120
|
-
#
|
|
121
|
-
#
|
|
154
|
+
logger.error(f"BLE pairing failed: {e}")
|
|
155
|
+
# A failed pairing leaves the transport in a half-usable
|
|
156
|
+
# state — re-raise so the caller gets a clean failure
|
|
157
|
+
# instead of a silently degraded connection.
|
|
158
|
+
await self.client.disconnect()
|
|
159
|
+
raise
|
|
122
160
|
|
|
123
161
|
except BleakDeviceNotFoundError:
|
|
124
162
|
return None
|
|
@@ -154,8 +192,19 @@ class BLEConnection:
|
|
|
154
192
|
self.client = self._user_provided_client
|
|
155
193
|
self.device = self._user_provided_device
|
|
156
194
|
|
|
195
|
+
# Re-register disconnect callback on the reset client so subsequent
|
|
196
|
+
# disconnects after a reconnect cycle are still detected.
|
|
197
|
+
if self.client is not None and hasattr(self.client, 'set_disconnected_callback'):
|
|
198
|
+
try:
|
|
199
|
+
self.client.set_disconnected_callback(self.handle_disconnect)
|
|
200
|
+
except Exception:
|
|
201
|
+
# set_disconnected_callback may not be available on all bleak
|
|
202
|
+
# versions; the next connect() call will re-create the client
|
|
203
|
+
# with the callback anyway.
|
|
204
|
+
pass
|
|
205
|
+
|
|
157
206
|
if self._disconnect_callback:
|
|
158
|
-
|
|
207
|
+
self._spawn_background(self._disconnect_callback("ble_disconnect"))
|
|
159
208
|
|
|
160
209
|
def set_disconnect_callback(self, callback):
|
|
161
210
|
"""Set callback to handle disconnections."""
|
|
@@ -166,16 +215,44 @@ class BLEConnection:
|
|
|
166
215
|
|
|
167
216
|
def handle_rx(self, _: BleakGATTCharacteristic, data: bytearray):
|
|
168
217
|
if self.reader is not None:
|
|
169
|
-
|
|
218
|
+
self._spawn_background(self.reader.handle_rx(data))
|
|
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)
|
|
170
223
|
|
|
171
224
|
async def send(self, data):
|
|
172
225
|
if not self.client:
|
|
173
226
|
logger.error("Client is not connected")
|
|
227
|
+
if self._disconnect_callback:
|
|
228
|
+
await self._disconnect_callback("ble_transport_lost")
|
|
174
229
|
return False
|
|
175
230
|
if not self.rx_char:
|
|
176
231
|
logger.error("RX characteristic not found")
|
|
177
232
|
return False
|
|
178
|
-
|
|
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.
|
|
240
|
+
try:
|
|
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
|
|
251
|
+
except Exception as exc:
|
|
252
|
+
logger.warning(f"BLE write failed: {exc}")
|
|
253
|
+
if self._disconnect_callback:
|
|
254
|
+
await self._disconnect_callback(f"ble_write_failed: {exc}")
|
|
255
|
+
return False
|
|
179
256
|
|
|
180
257
|
async def disconnect(self):
|
|
181
258
|
"""Disconnect from the BLE device."""
|
|
@@ -57,18 +57,91 @@ 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.
|
|
120
|
+
|
|
121
|
+
.. note::
|
|
122
|
+
The internal ``asyncio.Lock`` is created lazily on first access
|
|
123
|
+
so that it binds to the correct running event loop (required for
|
|
124
|
+
Python 3.9/3.10 compatibility).
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
DEFAULT_TIMEOUT = 15.0
|
|
62
128
|
|
|
63
129
|
def __init__(self, default_timeout: Optional[float] = None):
|
|
64
130
|
self._sender_func: Optional[Callable[[bytes], Coroutine[Any, Any, None]]] = None
|
|
65
131
|
self._reader: Optional[MessageReader] = None
|
|
66
132
|
self.dispatcher: Optional[EventDispatcher] = None
|
|
67
|
-
self.
|
|
133
|
+
self.__mesh_request_lock: Optional[asyncio.Lock] = None
|
|
68
134
|
self.default_timeout = (
|
|
69
135
|
default_timeout if default_timeout is not None else self.DEFAULT_TIMEOUT
|
|
70
136
|
)
|
|
71
137
|
|
|
138
|
+
@property
|
|
139
|
+
def _mesh_request_lock(self) -> asyncio.Lock:
|
|
140
|
+
"""Lazy-init lock so it binds to the running loop, not import-time."""
|
|
141
|
+
if self.__mesh_request_lock is None:
|
|
142
|
+
self.__mesh_request_lock = asyncio.Lock()
|
|
143
|
+
return self.__mesh_request_lock
|
|
144
|
+
|
|
72
145
|
def set_connection(self, connection: Any) -> None:
|
|
73
146
|
async def sender(data: bytes) -> None:
|
|
74
147
|
await connection.send(data)
|
|
@@ -90,6 +163,14 @@ class CommandHandlerBase:
|
|
|
90
163
|
expected_events: Optional[Union[EventType, List[EventType]]] = None,
|
|
91
164
|
timeout: Optional[float] = None,
|
|
92
165
|
) -> Event:
|
|
166
|
+
"""Wait for the first of *expected_events* to arrive.
|
|
167
|
+
|
|
168
|
+
Returns the first matched ``Event``. When ``EventType.ERROR`` is
|
|
169
|
+
among the expected types, the caller **must** check
|
|
170
|
+
``result.is_error()`` before accessing command-specific payload
|
|
171
|
+
keys — an ERROR payload is ``{"reason": "..."}`` and will
|
|
172
|
+
``KeyError`` on any other key.
|
|
173
|
+
"""
|
|
93
174
|
try:
|
|
94
175
|
# Convert single event to list if needed
|
|
95
176
|
if not isinstance(expected_events, list):
|
|
@@ -129,9 +210,6 @@ class CommandHandlerBase:
|
|
|
129
210
|
logger.debug(f"Command error: {e}")
|
|
130
211
|
return Event(EventType.ERROR, {"error": str(e)})
|
|
131
212
|
|
|
132
|
-
return Event(EventType.ERROR, {})
|
|
133
|
-
|
|
134
|
-
|
|
135
213
|
async def send(
|
|
136
214
|
self,
|
|
137
215
|
data: bytes,
|
|
@@ -151,7 +229,14 @@ class CommandHandlerBase:
|
|
|
151
229
|
timeout: Timeout in seconds, or None to use default_timeout
|
|
152
230
|
|
|
153
231
|
Returns:
|
|
154
|
-
Event: The full event object that was received in response to
|
|
232
|
+
Event: The full event object that was received in response to
|
|
233
|
+
the command.
|
|
234
|
+
|
|
235
|
+
Important:
|
|
236
|
+
When ``EventType.ERROR`` is included in *expected_events*, the
|
|
237
|
+
returned event may be an error response. Callers **must**
|
|
238
|
+
check ``result.is_error()`` before accessing command-specific
|
|
239
|
+
payload keys to avoid ``KeyError``.
|
|
155
240
|
"""
|
|
156
241
|
if not self.dispatcher:
|
|
157
242
|
raise RuntimeError("Dispatcher not set, cannot send commands")
|
|
@@ -170,7 +255,7 @@ class CommandHandlerBase:
|
|
|
170
255
|
futures: List[asyncio.Future] = []
|
|
171
256
|
subscriptions = []
|
|
172
257
|
|
|
173
|
-
loop = asyncio.
|
|
258
|
+
loop = asyncio.get_running_loop()
|
|
174
259
|
for event_type in expected_events:
|
|
175
260
|
future = loop.create_future()
|
|
176
261
|
|
|
@@ -243,49 +328,102 @@ class CommandHandlerBase:
|
|
|
243
328
|
logger.debug(f"Binary request to {dst_bytes.hex()}")
|
|
244
329
|
data = b"\x32" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + (data if data else b"")
|
|
245
330
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
#
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
# Use provided timeout or fallback to suggested timeout (with 5s default)
|
|
255
|
-
actual_timeout = timeout if timeout is not None and timeout > 0 else result.payload.get("suggested_timeout", 4000) / 800.0
|
|
331
|
+
# Pre-register a placeholder binary request before send() to close the race
|
|
332
|
+
# window where a BINARY_RESPONSE could arrive between send() returning and
|
|
333
|
+
# registration. The placeholder tag is patched to the real tag once MSG_SENT
|
|
334
|
+
# returns. If send() fails, the placeholder is cleaned up.
|
|
335
|
+
placeholder_tag = None
|
|
336
|
+
if self._reader is not None and request_type is not None:
|
|
337
|
+
placeholder_tag = f"_pending_{id(data)}"
|
|
338
|
+
actual_timeout = timeout if timeout is not None and timeout > 0 else self.default_timeout
|
|
256
339
|
actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout
|
|
257
|
-
self._reader.register_binary_request(pubkey_prefix.hex(),
|
|
340
|
+
self._reader.register_binary_request(pubkey_prefix.hex(), placeholder_tag, request_type, actual_timeout, context=context)
|
|
341
|
+
|
|
342
|
+
result = await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
|
|
343
|
+
|
|
344
|
+
# Patch the placeholder tag with the real tag from MSG_SENT, or clean up on failure
|
|
345
|
+
if placeholder_tag is not None and self._reader is not None:
|
|
346
|
+
# Remove the placeholder entry
|
|
347
|
+
self._reader.pending_binary_requests.pop(placeholder_tag, None)
|
|
348
|
+
if (result.type == EventType.MSG_SENT and
|
|
349
|
+
request_type is not None):
|
|
350
|
+
exp_tag = result.payload["expected_ack"].hex()
|
|
351
|
+
# Use suggested_timeout from the result if available
|
|
352
|
+
actual_timeout = timeout if timeout is not None and timeout > 0 else result.payload.get("suggested_timeout", 4000) / 800.0
|
|
353
|
+
actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout
|
|
354
|
+
# Register with the real tag
|
|
355
|
+
self._reader.register_binary_request(pubkey_prefix.hex(), exp_tag, request_type, actual_timeout, context=context)
|
|
258
356
|
|
|
259
357
|
return result
|
|
260
358
|
|
|
261
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
|
+
"""
|
|
262
369
|
dst_bytes = _validate_destination(dst, prefix_length=32)
|
|
263
370
|
pubkey_prefix = _validate_destination(dst, prefix_length=6)
|
|
264
371
|
logger.debug(f"Anon Binary request to {dst_bytes.hex()}")
|
|
265
372
|
|
|
266
|
-
contact
|
|
267
|
-
|
|
268
|
-
|
|
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())
|
|
269
381
|
|
|
270
382
|
zero_hop = False
|
|
271
|
-
if contact
|
|
272
|
-
logger.
|
|
273
|
-
|
|
274
|
-
|
|
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
|
+
)
|
|
275
413
|
|
|
276
|
-
data =
|
|
277
|
-
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
|
|
278
415
|
|
|
279
416
|
result = await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
|
|
280
|
-
|
|
417
|
+
|
|
281
418
|
# Register the request with the reader if we have both reader and request_type
|
|
282
|
-
if (result.type == EventType.MSG_SENT and
|
|
283
|
-
self._reader is not None and
|
|
419
|
+
if (result.type == EventType.MSG_SENT and
|
|
420
|
+
self._reader is not None and
|
|
284
421
|
request_type is not None):
|
|
285
|
-
|
|
422
|
+
|
|
286
423
|
exp_tag = result.payload["expected_ack"].hex()
|
|
287
424
|
# Use provided timeout or fallback to suggested timeout (with 5s default)
|
|
288
|
-
|
|
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
|
|
289
427
|
actual_timeout = timeout if timeout is not None and timeout > 0 else result.payload.get("suggested_timeout", 4000) / 800.0
|
|
290
428
|
actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout
|
|
291
429
|
self._reader.register_binary_request(pubkey_prefix.hex(), exp_tag, request_type, actual_timeout, context=context, is_anon=True)
|
|
@@ -73,10 +73,6 @@ class BinaryCommandHandler(CommandHandlerBase):
|
|
|
73
73
|
|
|
74
74
|
return telem_event.payload["lpp"] if telem_event else None
|
|
75
75
|
|
|
76
|
-
async def req_mma(self, contact, timeout=0, min_timeout=0):
|
|
77
|
-
logger.error("*** please consider using req_mma_sync instead of req_mma")
|
|
78
|
-
return await self.req_mma_sync(contact, start, end, timeout,min_timeout)
|
|
79
|
-
|
|
80
76
|
async def req_mma_sync(self, contact, start, end, timeout=0,min_timeout=0):
|
|
81
77
|
async with self._mesh_request_lock:
|
|
82
78
|
req = (
|
|
@@ -43,13 +43,17 @@ class ContactCommands(CommandHandlerBase):
|
|
|
43
43
|
logger.debug("Timeout while getting contacts")
|
|
44
44
|
for future in pending: # cancel all futures
|
|
45
45
|
future.cancel()
|
|
46
|
-
return
|
|
46
|
+
return Event(EventType.ERROR, {"reason": "timeout waiting for contacts"})
|
|
47
47
|
|
|
48
48
|
for future in done:
|
|
49
49
|
event = await future
|
|
50
|
-
if event is None
|
|
51
|
-
for
|
|
52
|
-
|
|
50
|
+
if event is None:
|
|
51
|
+
for f in pending:
|
|
52
|
+
f.cancel()
|
|
53
|
+
return Event(EventType.ERROR, {"reason": "no event received during contacts retrieval"})
|
|
54
|
+
if event.type != EventType.NEXT_CONTACT:
|
|
55
|
+
for f in pending:
|
|
56
|
+
f.cancel()
|
|
53
57
|
return event
|
|
54
58
|
|
|
55
59
|
futures = []
|
|
@@ -64,7 +68,7 @@ class ContactCommands(CommandHandlerBase):
|
|
|
64
68
|
|
|
65
69
|
except asyncio.TimeoutError:
|
|
66
70
|
logger.debug(f"Timeout receiving contacts")
|
|
67
|
-
return
|
|
71
|
+
return Event(EventType.ERROR, {"reason": "asyncio timeout receiving contacts"})
|
|
68
72
|
except Exception as e:
|
|
69
73
|
logger.debug(f"Command error: {e}")
|
|
70
74
|
return Event(EventType.ERROR, {"error": str(e)})
|
|
@@ -116,7 +120,9 @@ class ContactCommands(CommandHandlerBase):
|
|
|
116
120
|
path_hash_mode = int(path.split(":")[1])
|
|
117
121
|
path = path.split(":")[0].replace(":","")
|
|
118
122
|
else: # use device one by default
|
|
119
|
-
|
|
123
|
+
# out_path_len is pre-masked (& 0x3F) in reader.py, so high bits are always 0;
|
|
124
|
+
# the actual path_hash_mode is fetched from the device query below.
|
|
125
|
+
path_hash_mode = 0
|
|
120
126
|
res = await self.send_device_query()
|
|
121
127
|
if not res is None and res.type != EventType.ERROR:
|
|
122
128
|
if "path_hash_mode" in res.payload:
|
|
@@ -185,6 +191,24 @@ class ContactCommands(CommandHandlerBase):
|
|
|
185
191
|
data = b"\x3B"
|
|
186
192
|
return await self.send(data, [EventType.AUTOADD_CONFIG, EventType.ERROR])
|
|
187
193
|
|
|
194
|
+
async def get_contact_by_key(self, pubkey: bytes) -> Event:
|
|
195
|
+
"""N09: Retrieve a single contact by its public key (CMD 30).
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
pubkey: 32-byte public key of the contact.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Event with the contact data (same format as CONTACT/NEXT_CONTACT),
|
|
202
|
+
or ERROR if not found.
|
|
203
|
+
"""
|
|
204
|
+
if not isinstance(pubkey, (bytes, bytearray)):
|
|
205
|
+
raise TypeError("pubkey must be bytes-like")
|
|
206
|
+
# Truncate or pad to 32 bytes
|
|
207
|
+
key_bytes = bytes(pubkey[:32])
|
|
208
|
+
logger.debug(f"Getting contact by key: {key_bytes.hex()}")
|
|
209
|
+
data = b"\x1e" + key_bytes
|
|
210
|
+
return await self.send(data, [EventType.NEXT_CONTACT, EventType.ERROR])
|
|
211
|
+
|
|
188
212
|
async def get_advert_path(self, key: DestinationType) -> Event:
|
|
189
213
|
key_bytes = _validate_destination(key, prefix_length=32)
|
|
190
214
|
logger.debug(f"getting advert path for: {key} {key_bytes.hex()}")
|