meshcore 2.3.6__tar.gz → 2.3.7__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.6 → meshcore-2.3.7}/PKG-INFO +1 -1
- {meshcore-2.3.6 → meshcore-2.3.7}/pyproject.toml +1 -1
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/ble_cx.py +36 -6
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/commands/base.py +59 -18
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/commands/binary.py +0 -4
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/commands/contact.py +30 -6
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/commands/device.py +101 -13
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/commands/messaging.py +64 -3
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/connection_manager.py +56 -39
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/events.py +46 -3
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/meshcore.py +20 -4
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/meshcore_parser.py +61 -36
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/packets.py +7 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/parsing.py +7 -1
- meshcore-2.3.7/src/meshcore/reader.py +992 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/serial_cx.py +27 -8
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/tcp_cx.py +22 -9
- {meshcore-2.3.6 → meshcore-2.3.7}/tests/test_ble_pin_pairing.py +9 -10
- meshcore-2.3.7/tests/unit/test_asyncio_lifecycle.py +235 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/tests/unit/test_commands.py +11 -0
- meshcore-2.3.7/tests/unit/test_connection_manager.py +294 -0
- meshcore-2.3.7/tests/unit/test_error_handling.py +236 -0
- meshcore-2.3.7/tests/unit/test_parse_status.py +146 -0
- meshcore-2.3.7/tests/unit/test_protocol_surface_gaps.py +364 -0
- meshcore-2.3.7/tests/unit/test_reader.py +280 -0
- meshcore-2.3.7/tests/unit/test_standalone_fixes.py +227 -0
- meshcore-2.3.7/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.7}/.github/python-test.yml +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/.gitignore +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/LICENSE +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/README.md +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/.pubsub_example.py.swp +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_chat.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_pin_pairing_example.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_private_key_export.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_sign_example.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_stats.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_t1000_chan_msg.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_t1000_custom_vars.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_t1000_infos.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_t1000_msg.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_t1000_msg_retries.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_t1000_set_cv.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/chan_recv_with_path.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/connection_events_example.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/mepo_mc_gps.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/pubsub_example.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/rf_packet_monitor.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_battery_monitor.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_channel_manager.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_chat.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_contacts.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_infos.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_meshcore_ollama.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_msg.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_pingbot.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_repeater_status.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_repeater_telemetry.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_rss_bot.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_trace.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/tcp_chat.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/tcp_login_status.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/tcp_mchome_contacts.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/tcp_mchome_infos.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/tcp_mchome_msg.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/examples/tcp_mchome_readmsgs.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/pytest.ini +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/__init__.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/commands/__init__.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/commands/control_data.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/lpp_json_encoder.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/tests/README.md +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/tests/test_ble_connection.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/tests/test_meshcore_ble_pin.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/tests/unit/test_events.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/tests/unit/test_lpp_parsing.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/tests/unit/test_path_discovery_response.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/tests/unit/test_private_key_export.py +0 -0
- {meshcore-2.3.6 → meshcore-2.3.7}/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.7
|
|
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
|
|
@@ -51,6 +51,14 @@ class BLEConnection:
|
|
|
51
51
|
self.pin = pin
|
|
52
52
|
self.rx_char = None
|
|
53
53
|
self._disconnect_callback = None
|
|
54
|
+
self._background_tasks: set[asyncio.Task] = set()
|
|
55
|
+
|
|
56
|
+
def _spawn_background(self, coro) -> asyncio.Task:
|
|
57
|
+
"""Create a tracked background task (prevents GC of fire-and-forget tasks)."""
|
|
58
|
+
task = asyncio.create_task(coro)
|
|
59
|
+
self._background_tasks.add(task)
|
|
60
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
61
|
+
return task
|
|
54
62
|
|
|
55
63
|
async def connect(self):
|
|
56
64
|
"""
|
|
@@ -116,9 +124,12 @@ class BLEConnection:
|
|
|
116
124
|
await self.client.pair()
|
|
117
125
|
logger.info("BLE pairing successful")
|
|
118
126
|
except Exception as e:
|
|
119
|
-
logger.
|
|
120
|
-
#
|
|
121
|
-
#
|
|
127
|
+
logger.error(f"BLE pairing failed: {e}")
|
|
128
|
+
# A failed pairing leaves the transport in a half-usable
|
|
129
|
+
# state — re-raise so the caller gets a clean failure
|
|
130
|
+
# instead of a silently degraded connection.
|
|
131
|
+
await self.client.disconnect()
|
|
132
|
+
raise
|
|
122
133
|
|
|
123
134
|
except BleakDeviceNotFoundError:
|
|
124
135
|
return None
|
|
@@ -154,8 +165,19 @@ class BLEConnection:
|
|
|
154
165
|
self.client = self._user_provided_client
|
|
155
166
|
self.device = self._user_provided_device
|
|
156
167
|
|
|
168
|
+
# Re-register disconnect callback on the reset client so subsequent
|
|
169
|
+
# disconnects after a reconnect cycle are still detected.
|
|
170
|
+
if self.client is not None and hasattr(self.client, 'set_disconnected_callback'):
|
|
171
|
+
try:
|
|
172
|
+
self.client.set_disconnected_callback(self.handle_disconnect)
|
|
173
|
+
except Exception:
|
|
174
|
+
# set_disconnected_callback may not be available on all bleak
|
|
175
|
+
# versions; the next connect() call will re-create the client
|
|
176
|
+
# with the callback anyway.
|
|
177
|
+
pass
|
|
178
|
+
|
|
157
179
|
if self._disconnect_callback:
|
|
158
|
-
|
|
180
|
+
self._spawn_background(self._disconnect_callback("ble_disconnect"))
|
|
159
181
|
|
|
160
182
|
def set_disconnect_callback(self, callback):
|
|
161
183
|
"""Set callback to handle disconnections."""
|
|
@@ -166,16 +188,24 @@ class BLEConnection:
|
|
|
166
188
|
|
|
167
189
|
def handle_rx(self, _: BleakGATTCharacteristic, data: bytearray):
|
|
168
190
|
if self.reader is not None:
|
|
169
|
-
|
|
191
|
+
self._spawn_background(self.reader.handle_rx(data))
|
|
170
192
|
|
|
171
193
|
async def send(self, data):
|
|
172
194
|
if not self.client:
|
|
173
195
|
logger.error("Client is not connected")
|
|
196
|
+
if self._disconnect_callback:
|
|
197
|
+
await self._disconnect_callback("ble_transport_lost")
|
|
174
198
|
return False
|
|
175
199
|
if not self.rx_char:
|
|
176
200
|
logger.error("RX characteristic not found")
|
|
177
201
|
return False
|
|
178
|
-
|
|
202
|
+
try:
|
|
203
|
+
await self.client.write_gatt_char(self.rx_char, bytes(data), response=True)
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
logger.warning(f"BLE write failed: {exc}")
|
|
206
|
+
if self._disconnect_callback:
|
|
207
|
+
await self._disconnect_callback(f"ble_write_failed: {exc}")
|
|
208
|
+
return False
|
|
179
209
|
|
|
180
210
|
async def disconnect(self):
|
|
181
211
|
"""Disconnect from the BLE device."""
|
|
@@ -58,17 +58,32 @@ def _validate_destination(dst: DestinationType, prefix_length: int = 6) -> bytes
|
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
class CommandHandlerBase:
|
|
61
|
-
|
|
61
|
+
"""Base class for command handlers.
|
|
62
|
+
|
|
63
|
+
.. note::
|
|
64
|
+
The internal ``asyncio.Lock`` is created lazily on first access
|
|
65
|
+
so that it binds to the correct running event loop (required for
|
|
66
|
+
Python 3.9/3.10 compatibility).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
DEFAULT_TIMEOUT = 15.0
|
|
62
70
|
|
|
63
71
|
def __init__(self, default_timeout: Optional[float] = None):
|
|
64
72
|
self._sender_func: Optional[Callable[[bytes], Coroutine[Any, Any, None]]] = None
|
|
65
73
|
self._reader: Optional[MessageReader] = None
|
|
66
74
|
self.dispatcher: Optional[EventDispatcher] = None
|
|
67
|
-
self.
|
|
75
|
+
self.__mesh_request_lock: Optional[asyncio.Lock] = None
|
|
68
76
|
self.default_timeout = (
|
|
69
77
|
default_timeout if default_timeout is not None else self.DEFAULT_TIMEOUT
|
|
70
78
|
)
|
|
71
79
|
|
|
80
|
+
@property
|
|
81
|
+
def _mesh_request_lock(self) -> asyncio.Lock:
|
|
82
|
+
"""Lazy-init lock so it binds to the running loop, not import-time."""
|
|
83
|
+
if self.__mesh_request_lock is None:
|
|
84
|
+
self.__mesh_request_lock = asyncio.Lock()
|
|
85
|
+
return self.__mesh_request_lock
|
|
86
|
+
|
|
72
87
|
def set_connection(self, connection: Any) -> None:
|
|
73
88
|
async def sender(data: bytes) -> None:
|
|
74
89
|
await connection.send(data)
|
|
@@ -90,6 +105,14 @@ class CommandHandlerBase:
|
|
|
90
105
|
expected_events: Optional[Union[EventType, List[EventType]]] = None,
|
|
91
106
|
timeout: Optional[float] = None,
|
|
92
107
|
) -> Event:
|
|
108
|
+
"""Wait for the first of *expected_events* to arrive.
|
|
109
|
+
|
|
110
|
+
Returns the first matched ``Event``. When ``EventType.ERROR`` is
|
|
111
|
+
among the expected types, the caller **must** check
|
|
112
|
+
``result.is_error()`` before accessing command-specific payload
|
|
113
|
+
keys — an ERROR payload is ``{"reason": "..."}`` and will
|
|
114
|
+
``KeyError`` on any other key.
|
|
115
|
+
"""
|
|
93
116
|
try:
|
|
94
117
|
# Convert single event to list if needed
|
|
95
118
|
if not isinstance(expected_events, list):
|
|
@@ -129,9 +152,6 @@ class CommandHandlerBase:
|
|
|
129
152
|
logger.debug(f"Command error: {e}")
|
|
130
153
|
return Event(EventType.ERROR, {"error": str(e)})
|
|
131
154
|
|
|
132
|
-
return Event(EventType.ERROR, {})
|
|
133
|
-
|
|
134
|
-
|
|
135
155
|
async def send(
|
|
136
156
|
self,
|
|
137
157
|
data: bytes,
|
|
@@ -151,7 +171,14 @@ class CommandHandlerBase:
|
|
|
151
171
|
timeout: Timeout in seconds, or None to use default_timeout
|
|
152
172
|
|
|
153
173
|
Returns:
|
|
154
|
-
Event: The full event object that was received in response to
|
|
174
|
+
Event: The full event object that was received in response to
|
|
175
|
+
the command.
|
|
176
|
+
|
|
177
|
+
Important:
|
|
178
|
+
When ``EventType.ERROR`` is included in *expected_events*, the
|
|
179
|
+
returned event may be an error response. Callers **must**
|
|
180
|
+
check ``result.is_error()`` before accessing command-specific
|
|
181
|
+
payload keys to avoid ``KeyError``.
|
|
155
182
|
"""
|
|
156
183
|
if not self.dispatcher:
|
|
157
184
|
raise RuntimeError("Dispatcher not set, cannot send commands")
|
|
@@ -170,7 +197,7 @@ class CommandHandlerBase:
|
|
|
170
197
|
futures: List[asyncio.Future] = []
|
|
171
198
|
subscriptions = []
|
|
172
199
|
|
|
173
|
-
loop = asyncio.
|
|
200
|
+
loop = asyncio.get_running_loop()
|
|
174
201
|
for event_type in expected_events:
|
|
175
202
|
future = loop.create_future()
|
|
176
203
|
|
|
@@ -243,18 +270,31 @@ class CommandHandlerBase:
|
|
|
243
270
|
logger.debug(f"Binary request to {dst_bytes.hex()}")
|
|
244
271
|
data = b"\x32" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + (data if data else b"")
|
|
245
272
|
|
|
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
|
|
273
|
+
# Pre-register a placeholder binary request before send() to close the race
|
|
274
|
+
# window where a BINARY_RESPONSE could arrive between send() returning and
|
|
275
|
+
# registration. The placeholder tag is patched to the real tag once MSG_SENT
|
|
276
|
+
# returns. If send() fails, the placeholder is cleaned up.
|
|
277
|
+
placeholder_tag = None
|
|
278
|
+
if self._reader is not None and request_type is not None:
|
|
279
|
+
placeholder_tag = f"_pending_{id(data)}"
|
|
280
|
+
actual_timeout = timeout if timeout is not None and timeout > 0 else self.default_timeout
|
|
256
281
|
actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout
|
|
257
|
-
self._reader.register_binary_request(pubkey_prefix.hex(),
|
|
282
|
+
self._reader.register_binary_request(pubkey_prefix.hex(), placeholder_tag, request_type, actual_timeout, context=context)
|
|
283
|
+
|
|
284
|
+
result = await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
|
|
285
|
+
|
|
286
|
+
# Patch the placeholder tag with the real tag from MSG_SENT, or clean up on failure
|
|
287
|
+
if placeholder_tag is not None and self._reader is not None:
|
|
288
|
+
# Remove the placeholder entry
|
|
289
|
+
self._reader.pending_binary_requests.pop(placeholder_tag, None)
|
|
290
|
+
if (result.type == EventType.MSG_SENT and
|
|
291
|
+
request_type is not None):
|
|
292
|
+
exp_tag = result.payload["expected_ack"].hex()
|
|
293
|
+
# Use suggested_timeout from the result if available
|
|
294
|
+
actual_timeout = timeout if timeout is not None and timeout > 0 else result.payload.get("suggested_timeout", 4000) / 800.0
|
|
295
|
+
actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout
|
|
296
|
+
# Register with the real tag
|
|
297
|
+
self._reader.register_binary_request(pubkey_prefix.hex(), exp_tag, request_type, actual_timeout, context=context)
|
|
258
298
|
|
|
259
299
|
return result
|
|
260
300
|
|
|
@@ -266,6 +306,7 @@ class CommandHandlerBase:
|
|
|
266
306
|
contact = self._get_contact_by_prefix(dst_bytes.hex()) # need a contact for return path
|
|
267
307
|
if contact is None:
|
|
268
308
|
logger.error("No contact found")
|
|
309
|
+
return Event(EventType.ERROR, {"reason": "contact_not_found"})
|
|
269
310
|
|
|
270
311
|
zero_hop = False
|
|
271
312
|
if contact["out_path_len"] == -1:
|
|
@@ -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()}")
|
|
@@ -4,6 +4,7 @@ from hashlib import sha256
|
|
|
4
4
|
from typing import Optional
|
|
5
5
|
|
|
6
6
|
from ..events import Event, EventType
|
|
7
|
+
from ..packets import CommandType
|
|
7
8
|
from .base import CommandHandlerBase, DestinationType, _validate_destination
|
|
8
9
|
|
|
9
10
|
logger = logging.getLogger("meshcore")
|
|
@@ -13,7 +14,7 @@ class DeviceCommands(CommandHandlerBase):
|
|
|
13
14
|
async def send_appstart(self) -> Event:
|
|
14
15
|
logger.debug("Sending appstart command")
|
|
15
16
|
b1 = bytearray(b"\x01\x03 mccli")
|
|
16
|
-
return await self.send(b1, [EventType.SELF_INFO])
|
|
17
|
+
return await self.send(b1, [EventType.SELF_INFO, EventType.ERROR])
|
|
17
18
|
|
|
18
19
|
async def send_device_query(self) -> Event:
|
|
19
20
|
logger.debug("Sending device query command")
|
|
@@ -129,32 +130,50 @@ class DeviceCommands(CommandHandlerBase):
|
|
|
129
130
|
return await self.send(data, [EventType.OK, EventType.ERROR])
|
|
130
131
|
|
|
131
132
|
async def set_telemetry_mode_base(self, telemetry_mode_base: int) -> Event:
|
|
132
|
-
|
|
133
|
+
result = await self.send_appstart()
|
|
134
|
+
if result.is_error():
|
|
135
|
+
return result
|
|
136
|
+
infos = result.payload
|
|
133
137
|
infos["telemetry_mode_base"] = telemetry_mode_base
|
|
134
138
|
return await self.set_other_params_from_infos(infos)
|
|
135
139
|
|
|
136
140
|
async def set_telemetry_mode_loc(self, telemetry_mode_loc: int) -> Event:
|
|
137
|
-
|
|
141
|
+
result = await self.send_appstart()
|
|
142
|
+
if result.is_error():
|
|
143
|
+
return result
|
|
144
|
+
infos = result.payload
|
|
138
145
|
infos["telemetry_mode_loc"] = telemetry_mode_loc
|
|
139
146
|
return await self.set_other_params_from_infos(infos)
|
|
140
147
|
|
|
141
148
|
async def set_telemetry_mode_env(self, telemetry_mode_env: int) -> Event:
|
|
142
|
-
|
|
149
|
+
result = await self.send_appstart()
|
|
150
|
+
if result.is_error():
|
|
151
|
+
return result
|
|
152
|
+
infos = result.payload
|
|
143
153
|
infos["telemetry_mode_env"] = telemetry_mode_env
|
|
144
154
|
return await self.set_other_params_from_infos(infos)
|
|
145
155
|
|
|
146
156
|
async def set_manual_add_contacts(self, manual_add_contacts: bool) -> Event:
|
|
147
|
-
|
|
157
|
+
result = await self.send_appstart()
|
|
158
|
+
if result.is_error():
|
|
159
|
+
return result
|
|
160
|
+
infos = result.payload
|
|
148
161
|
infos["manual_add_contacts"] = manual_add_contacts
|
|
149
162
|
return await self.set_other_params_from_infos(infos)
|
|
150
163
|
|
|
151
164
|
async def set_advert_loc_policy(self, advert_loc_policy: int) -> Event:
|
|
152
|
-
|
|
165
|
+
result = await self.send_appstart()
|
|
166
|
+
if result.is_error():
|
|
167
|
+
return result
|
|
168
|
+
infos = result.payload
|
|
153
169
|
infos["adv_loc_policy"] = advert_loc_policy
|
|
154
170
|
return await self.set_other_params_from_infos(infos)
|
|
155
171
|
|
|
156
172
|
async def set_multi_acks(self, multi_acks: int) -> Event:
|
|
157
|
-
|
|
173
|
+
result = await self.send_appstart()
|
|
174
|
+
if result.is_error():
|
|
175
|
+
return result
|
|
176
|
+
infos = result.payload
|
|
158
177
|
infos["multi_acks"] = multi_acks
|
|
159
178
|
return await self.set_other_params_from_infos(infos)
|
|
160
179
|
|
|
@@ -273,20 +292,89 @@ class DeviceCommands(CommandHandlerBase):
|
|
|
273
292
|
|
|
274
293
|
return await self.sign_finish(timeout=timeout, data_size=len(data))
|
|
275
294
|
|
|
295
|
+
async def has_connection(self) -> Event:
|
|
296
|
+
"""N09: Check if the device has an active connection (CMD 28).
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Event with a 1-byte response indicating connection status,
|
|
300
|
+
or ERROR.
|
|
301
|
+
"""
|
|
302
|
+
logger.debug("Checking device connection status")
|
|
303
|
+
return await self.send(b"\x1c", [EventType.OK, EventType.ERROR])
|
|
304
|
+
|
|
305
|
+
async def get_tuning(self) -> Event:
|
|
306
|
+
"""N03/N09: Request current tuning parameters (CMD_GET_TUNING_PARAMS = 43).
|
|
307
|
+
|
|
308
|
+
Firmware responds with RESP_CODE_TUNING_PARAMS (23): 9 bytes containing
|
|
309
|
+
rx_delay (4 bytes LE) and airtime_factor (4 bytes LE).
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Event of type TUNING_PARAMS with rx_delay and airtime_factor,
|
|
313
|
+
or ERROR.
|
|
314
|
+
"""
|
|
315
|
+
logger.debug("Getting tuning parameters")
|
|
316
|
+
return await self.send(b"\x2b", [EventType.TUNING_PARAMS, EventType.ERROR])
|
|
317
|
+
|
|
318
|
+
async def request_factory_reset(self) -> str:
|
|
319
|
+
"""N09: Request a factory reset token (step 1 of 2).
|
|
320
|
+
|
|
321
|
+
This method returns a confirmation token string. Pass it to
|
|
322
|
+
``confirm_factory_reset(token)`` to actually execute the reset.
|
|
323
|
+
The two-step pattern is a Python-side safety measure; the firmware
|
|
324
|
+
itself has no token verification.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
A confirmation token string to pass to confirm_factory_reset().
|
|
328
|
+
"""
|
|
329
|
+
import secrets
|
|
330
|
+
token = secrets.token_hex(8)
|
|
331
|
+
logger.warning(
|
|
332
|
+
"Factory reset requested. Call confirm_factory_reset('%s') to proceed. "
|
|
333
|
+
"This will ERASE ALL DATA on the device.", token
|
|
334
|
+
)
|
|
335
|
+
# Store the token on the instance for validation
|
|
336
|
+
self._factory_reset_token = token
|
|
337
|
+
return token
|
|
338
|
+
|
|
339
|
+
async def confirm_factory_reset(self, token: str) -> Event:
|
|
340
|
+
"""N09: Execute factory reset after token confirmation (step 2 of 2).
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
token: The token returned by request_factory_reset().
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Event with OK or ERROR.
|
|
347
|
+
|
|
348
|
+
Raises:
|
|
349
|
+
ValueError: If the token does not match.
|
|
350
|
+
"""
|
|
351
|
+
expected = getattr(self, "_factory_reset_token", None)
|
|
352
|
+
if expected is None or token != expected:
|
|
353
|
+
raise ValueError(
|
|
354
|
+
"Invalid or expired factory reset token. "
|
|
355
|
+
"Call request_factory_reset() first."
|
|
356
|
+
)
|
|
357
|
+
self._factory_reset_token = None # Consume the token
|
|
358
|
+
logger.warning("Executing factory reset — all device data will be erased")
|
|
359
|
+
return await self.send(b"\x33", [EventType.OK, EventType.ERROR])
|
|
360
|
+
|
|
276
361
|
async def get_stats_core(self) -> Event:
|
|
277
362
|
logger.debug("Getting core statistics")
|
|
278
|
-
#
|
|
279
|
-
|
|
363
|
+
# R04: Use CommandType enum instead of literal bytes
|
|
364
|
+
cmd = bytes([CommandType.GET_STATS.value, 0x00]) # GET_STATS + STATS_TYPE_CORE
|
|
365
|
+
return await self.send(cmd, [EventType.STATS_CORE, EventType.ERROR])
|
|
280
366
|
|
|
281
367
|
async def get_stats_radio(self) -> Event:
|
|
282
368
|
logger.debug("Getting radio statistics")
|
|
283
|
-
#
|
|
284
|
-
|
|
369
|
+
# R04: Use CommandType enum instead of literal bytes
|
|
370
|
+
cmd = bytes([CommandType.GET_STATS.value, 0x01]) # GET_STATS + STATS_TYPE_RADIO
|
|
371
|
+
return await self.send(cmd, [EventType.STATS_RADIO, EventType.ERROR])
|
|
285
372
|
|
|
286
373
|
async def get_stats_packets(self) -> Event:
|
|
287
374
|
logger.debug("Getting packet statistics")
|
|
288
|
-
#
|
|
289
|
-
|
|
375
|
+
# R04: Use CommandType enum instead of literal bytes
|
|
376
|
+
cmd = bytes([CommandType.GET_STATS.value, 0x02]) # GET_STATS + STATS_TYPE_PACKETS
|
|
377
|
+
return await self.send(cmd, [EventType.STATS_PACKETS, EventType.ERROR])
|
|
290
378
|
|
|
291
379
|
async def get_allowed_repeat_freq(self) -> Event:
|
|
292
380
|
logger.debug("Getting allowed repeat freqs")
|
|
@@ -144,8 +144,12 @@ class MessagingCommands(CommandHandlerBase):
|
|
|
144
144
|
logger.info(f"Retry sending msg: {attempts + 1}")
|
|
145
145
|
|
|
146
146
|
result = await self.send_msg(dst, msg, timestamp, attempt=attempts)
|
|
147
|
-
if result.
|
|
148
|
-
logger.error(f"
|
|
147
|
+
if result.is_error():
|
|
148
|
+
logger.error(f"Failed to send message: {result.payload}")
|
|
149
|
+
attempts += 1
|
|
150
|
+
if flood:
|
|
151
|
+
flood_attempts += 1
|
|
152
|
+
continue
|
|
149
153
|
|
|
150
154
|
exp_ack = result.payload["expected_ack"].hex()
|
|
151
155
|
timeout = result.payload["suggested_timeout"] / 1000 * 1.2 if timeout==0 else timeout
|
|
@@ -255,7 +259,7 @@ class MessagingCommands(CommandHandlerBase):
|
|
|
255
259
|
elif path_hash_len == 8 :
|
|
256
260
|
flags = 3
|
|
257
261
|
else :
|
|
258
|
-
logger.error(f"Invalid path format: {
|
|
262
|
+
logger.error(f"Invalid path format: unknown path_hash_len {path_hash_len}")
|
|
259
263
|
return Event(EventType.ERROR, {"reason": "invalid_path_format"})
|
|
260
264
|
else:
|
|
261
265
|
flags = 0
|
|
@@ -291,12 +295,34 @@ class MessagingCommands(CommandHandlerBase):
|
|
|
291
295
|
cmd_data.append(flags)
|
|
292
296
|
cmd_data.extend(path_bytes)
|
|
293
297
|
|
|
298
|
+
# N05: Firmware requires strict len > 10 (MyMesh.cpp:1620).
|
|
299
|
+
# When path is empty, cmd(1)+tag(4)+auth(4)+flags(1) = 10 bytes exactly,
|
|
300
|
+
# which is silently rejected. Pad with one zero byte to reach 11.
|
|
301
|
+
if len(cmd_data) <= 10:
|
|
302
|
+
cmd_data.append(0x00)
|
|
303
|
+
|
|
294
304
|
logger.debug(
|
|
295
305
|
f"Sending trace: tag={tag}, auth={auth_code}, flags={flags}, path={path_bytes.hex()}"
|
|
296
306
|
)
|
|
297
307
|
|
|
298
308
|
return await self.send(cmd_data, [EventType.MSG_SENT, EventType.ERROR])
|
|
299
309
|
|
|
310
|
+
async def send_raw_data(self, payload: bytes) -> Event:
|
|
311
|
+
"""N09: Send raw data via CMD_SEND_RAW_DATA (25).
|
|
312
|
+
|
|
313
|
+
Sends an arbitrary payload through the mesh network.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
payload: Raw bytes to send.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Event with MSG_SENT or ERROR.
|
|
320
|
+
"""
|
|
321
|
+
if not isinstance(payload, (bytes, bytearray)):
|
|
322
|
+
raise TypeError("payload must be bytes-like")
|
|
323
|
+
data = b"\x19" + bytes(payload)
|
|
324
|
+
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
|
|
325
|
+
|
|
300
326
|
async def set_flood_scope(self, scope):
|
|
301
327
|
if scope is None:
|
|
302
328
|
logger.debug(f"Resetting scope")
|
|
@@ -313,6 +339,8 @@ class MessagingCommands(CommandHandlerBase):
|
|
|
313
339
|
elif isinstance (scope, bytes): # scope has been sent directly as byte
|
|
314
340
|
logger.debug(f"Directly setting scope to {scope}")
|
|
315
341
|
scope_key = scope
|
|
342
|
+
else:
|
|
343
|
+
raise TypeError(f"set_flood_scope: unsupported scope type {type(scope).__name__}")
|
|
316
344
|
|
|
317
345
|
logger.debug(f"Setting scope to {scope_key.hex()}")
|
|
318
346
|
|
|
@@ -321,3 +349,36 @@ class MessagingCommands(CommandHandlerBase):
|
|
|
321
349
|
cmd_data.extend(scope_key)
|
|
322
350
|
|
|
323
351
|
return await self.send(cmd_data, [EventType.OK, EventType.ERROR])
|
|
352
|
+
|
|
353
|
+
async def set_default_flood_scope(self, scope):
|
|
354
|
+
if scope is None:
|
|
355
|
+
logger.debug(f"Resetting default scope")
|
|
356
|
+
scope_key = b"\0"*16
|
|
357
|
+
scope_name = ""
|
|
358
|
+
elif isinstance (scope, str):
|
|
359
|
+
if scope == "0" or scope == "None" or scope == "*" or scope == "": # disable
|
|
360
|
+
logger.debug ("Resetting default scope")
|
|
361
|
+
scope_key = b"\0"*16
|
|
362
|
+
scope_name = ""
|
|
363
|
+
else:
|
|
364
|
+
logger.debug (f"Setting scope to {scope}")
|
|
365
|
+
if scope[0] != "#":
|
|
366
|
+
scope = "#" + scope
|
|
367
|
+
scope_name = scope
|
|
368
|
+
scope_key = sha256(scope.encode("utf-8")).digest()[0:16]
|
|
369
|
+
else:
|
|
370
|
+
raise TypeError(f"set_flood_scope: unsupported scope type {type(scope).__name__}")
|
|
371
|
+
|
|
372
|
+
logger.debug(f"Setting scope key to {scope_key.hex()}")
|
|
373
|
+
|
|
374
|
+
cmd_data = bytearray([CommandType.SET_DEFAULT_FLOOD_SCOPE.value])
|
|
375
|
+
cmd_data.extend(scope_name.encode("utf-8"))
|
|
376
|
+
cmd_data.extend((31-len(scope))*b'\0')
|
|
377
|
+
cmd_data.extend(scope_key)
|
|
378
|
+
|
|
379
|
+
return await self.send(cmd_data, [EventType.OK, EventType.ERROR])
|
|
380
|
+
|
|
381
|
+
async def get_default_flood_scope(self):
|
|
382
|
+
logger.debug(f"Getting default flood scope")
|
|
383
|
+
cmd_data = bytearray([CommandType.GET_DEFAULT_FLOOD_SCOPE.value])
|
|
384
|
+
return await self.send(cmd_data, [EventType.DEFAULT_FLOOD_SCOPE, EventType.ERROR])
|