meshcore 2.3.5__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.5 → meshcore-2.3.7}/PKG-INFO +1 -1
- {meshcore-2.3.5 → meshcore-2.3.7}/pyproject.toml +1 -1
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/ble_cx.py +36 -6
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/commands/base.py +59 -17
- meshcore-2.3.7/src/meshcore/commands/binary.py +353 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/commands/contact.py +30 -6
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/commands/device.py +101 -13
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/commands/messaging.py +112 -13
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/connection_manager.py +56 -39
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/events.py +46 -3
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/meshcore.py +20 -4
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/meshcore_parser.py +61 -36
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/packets.py +7 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/parsing.py +7 -1
- meshcore-2.3.7/src/meshcore/reader.py +992 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/serial_cx.py +27 -8
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/tcp_cx.py +22 -9
- {meshcore-2.3.5 → 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.5 → meshcore-2.3.7}/tests/unit/test_commands.py +43 -42
- 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_lpp_parsing.py +39 -0
- meshcore-2.3.7/tests/unit/test_parse_status.py +146 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/tests/unit/test_private_key_export.py +18 -9
- 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.5/src/meshcore/commands/binary.py +0 -356
- meshcore-2.3.5/src/meshcore/reader.py +0 -921
- meshcore-2.3.5/tests/unit/test_reader.py +0 -89
- {meshcore-2.3.5 → meshcore-2.3.7}/.github/python-test.yml +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/.gitignore +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/LICENSE +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/README.md +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/.pubsub_example.py.swp +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_chat.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_pin_pairing_example.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_private_key_export.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_sign_example.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_stats.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_t1000_chan_msg.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_t1000_custom_vars.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_t1000_infos.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_t1000_msg.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_t1000_msg_retries.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_t1000_set_cv.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/chan_recv_with_path.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/connection_events_example.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/mepo_mc_gps.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/pubsub_example.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/rf_packet_monitor.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_battery_monitor.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_channel_manager.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_chat.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_contacts.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_infos.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_meshcore_ollama.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_msg.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_pingbot.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_repeater_status.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_repeater_telemetry.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_rss_bot.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_trace.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/tcp_chat.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/tcp_login_status.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/tcp_mchome_contacts.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/tcp_mchome_infos.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/tcp_mchome_msg.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/examples/tcp_mchome_readmsgs.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/pytest.ini +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/__init__.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/commands/__init__.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/commands/control_data.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/lpp_json_encoder.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/tests/README.md +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/tests/test_ble_connection.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/tests/test_meshcore_ble_pin.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/tests/unit/test_events.py +0 -0
- {meshcore-2.3.5 → meshcore-2.3.7}/tests/unit/test_path_discovery_response.py +0 -0
- {meshcore-2.3.5 → 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,16 +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
|
|
75
|
+
self.__mesh_request_lock: Optional[asyncio.Lock] = None
|
|
67
76
|
self.default_timeout = (
|
|
68
77
|
default_timeout if default_timeout is not None else self.DEFAULT_TIMEOUT
|
|
69
78
|
)
|
|
70
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
|
+
|
|
71
87
|
def set_connection(self, connection: Any) -> None:
|
|
72
88
|
async def sender(data: bytes) -> None:
|
|
73
89
|
await connection.send(data)
|
|
@@ -89,6 +105,14 @@ class CommandHandlerBase:
|
|
|
89
105
|
expected_events: Optional[Union[EventType, List[EventType]]] = None,
|
|
90
106
|
timeout: Optional[float] = None,
|
|
91
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
|
+
"""
|
|
92
116
|
try:
|
|
93
117
|
# Convert single event to list if needed
|
|
94
118
|
if not isinstance(expected_events, list):
|
|
@@ -128,9 +152,6 @@ class CommandHandlerBase:
|
|
|
128
152
|
logger.debug(f"Command error: {e}")
|
|
129
153
|
return Event(EventType.ERROR, {"error": str(e)})
|
|
130
154
|
|
|
131
|
-
return Event(EventType.ERROR, {})
|
|
132
|
-
|
|
133
|
-
|
|
134
155
|
async def send(
|
|
135
156
|
self,
|
|
136
157
|
data: bytes,
|
|
@@ -150,7 +171,14 @@ class CommandHandlerBase:
|
|
|
150
171
|
timeout: Timeout in seconds, or None to use default_timeout
|
|
151
172
|
|
|
152
173
|
Returns:
|
|
153
|
-
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``.
|
|
154
182
|
"""
|
|
155
183
|
if not self.dispatcher:
|
|
156
184
|
raise RuntimeError("Dispatcher not set, cannot send commands")
|
|
@@ -169,7 +197,7 @@ class CommandHandlerBase:
|
|
|
169
197
|
futures: List[asyncio.Future] = []
|
|
170
198
|
subscriptions = []
|
|
171
199
|
|
|
172
|
-
loop = asyncio.
|
|
200
|
+
loop = asyncio.get_running_loop()
|
|
173
201
|
for event_type in expected_events:
|
|
174
202
|
future = loop.create_future()
|
|
175
203
|
|
|
@@ -242,18 +270,31 @@ class CommandHandlerBase:
|
|
|
242
270
|
logger.debug(f"Binary request to {dst_bytes.hex()}")
|
|
243
271
|
data = b"\x32" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + (data if data else b"")
|
|
244
272
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
#
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
# Use provided timeout or fallback to suggested timeout (with 5s default)
|
|
254
|
-
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
|
|
255
281
|
actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout
|
|
256
|
-
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)
|
|
257
298
|
|
|
258
299
|
return result
|
|
259
300
|
|
|
@@ -265,6 +306,7 @@ class CommandHandlerBase:
|
|
|
265
306
|
contact = self._get_contact_by_prefix(dst_bytes.hex()) # need a contact for return path
|
|
266
307
|
if contact is None:
|
|
267
308
|
logger.error("No contact found")
|
|
309
|
+
return Event(EventType.ERROR, {"reason": "contact_not_found"})
|
|
268
310
|
|
|
269
311
|
zero_hop = False
|
|
270
312
|
if contact["out_path_len"] == -1:
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import random
|
|
4
|
+
import io
|
|
5
|
+
|
|
6
|
+
from .base import CommandHandlerBase
|
|
7
|
+
from ..events import EventType
|
|
8
|
+
from ..packets import BinaryReqType
|
|
9
|
+
from ..packets import AnonReqType
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("meshcore")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BinaryCommandHandler(CommandHandlerBase):
|
|
15
|
+
"""Helper functions to handle binary requests through binary commands"""
|
|
16
|
+
|
|
17
|
+
async def req_status(self, contact, timeout=0, min_timeout=0):
|
|
18
|
+
logger.error("*** please consider using req_status_sync instead of req_status")
|
|
19
|
+
return await self.req_status_sync(contact, timeout, min_timeout)
|
|
20
|
+
|
|
21
|
+
async def req_status_sync(self, contact, timeout=0, min_timeout=0):
|
|
22
|
+
async with self._mesh_request_lock:
|
|
23
|
+
res = await self.send_binary_req(
|
|
24
|
+
contact,
|
|
25
|
+
BinaryReqType.STATUS,
|
|
26
|
+
timeout=timeout,
|
|
27
|
+
min_timeout=min_timeout
|
|
28
|
+
)
|
|
29
|
+
if res.type == EventType.ERROR:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
exp_tag = res.payload["expected_ack"].hex()
|
|
33
|
+
timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout
|
|
34
|
+
timeout = timeout if min_timeout < timeout else min_timeout
|
|
35
|
+
|
|
36
|
+
if self.dispatcher is None:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
status_event = await self.dispatcher.wait_for_event(
|
|
40
|
+
EventType.STATUS_RESPONSE,
|
|
41
|
+
attribute_filters={"tag": exp_tag},
|
|
42
|
+
timeout=timeout,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return status_event.payload if status_event else None
|
|
46
|
+
|
|
47
|
+
async def req_telemetry(self, contact, timeout=0, min_timeout=0):
|
|
48
|
+
logger.error("*** please consider using req_telemetry_sync instead of req_telemetry")
|
|
49
|
+
return await self.req_telemetry_sync(contact, timeout, min_timeout)
|
|
50
|
+
|
|
51
|
+
async def req_telemetry_sync(self, contact, timeout=0, min_timeout=0):
|
|
52
|
+
async with self._mesh_request_lock:
|
|
53
|
+
res = await self.send_binary_req(
|
|
54
|
+
contact,
|
|
55
|
+
BinaryReqType.TELEMETRY,
|
|
56
|
+
timeout=timeout,
|
|
57
|
+
min_timeout=min_timeout
|
|
58
|
+
)
|
|
59
|
+
if res.type == EventType.ERROR:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout
|
|
63
|
+
timeout = timeout if min_timeout < timeout else min_timeout
|
|
64
|
+
|
|
65
|
+
if self.dispatcher is None:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
telem_event = await self.dispatcher.wait_for_event(
|
|
69
|
+
EventType.TELEMETRY_RESPONSE,
|
|
70
|
+
attribute_filters={"tag": res.payload["expected_ack"].hex()},
|
|
71
|
+
timeout=timeout,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return telem_event.payload["lpp"] if telem_event else None
|
|
75
|
+
|
|
76
|
+
async def req_mma_sync(self, contact, start, end, timeout=0,min_timeout=0):
|
|
77
|
+
async with self._mesh_request_lock:
|
|
78
|
+
req = (
|
|
79
|
+
start.to_bytes(4, "little", signed=False)
|
|
80
|
+
+ end.to_bytes(4, "little", signed=False)
|
|
81
|
+
+ b"\0\0"
|
|
82
|
+
)
|
|
83
|
+
res = await self.send_binary_req(
|
|
84
|
+
contact,
|
|
85
|
+
BinaryReqType.MMA,
|
|
86
|
+
data=req,
|
|
87
|
+
timeout=timeout
|
|
88
|
+
)
|
|
89
|
+
if res.type == EventType.ERROR:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout
|
|
93
|
+
timeout = timeout if min_timeout < timeout else min_timeout
|
|
94
|
+
|
|
95
|
+
if self.dispatcher is None:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
mma_event = await self.dispatcher.wait_for_event(
|
|
99
|
+
EventType.MMA_RESPONSE,
|
|
100
|
+
attribute_filters={"tag": res.payload["expected_ack"].hex()},
|
|
101
|
+
timeout=timeout,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return mma_event.payload["mma_data"] if mma_event else None
|
|
105
|
+
|
|
106
|
+
async def req_acl(self, contact, timeout=0, min_timeout=0):
|
|
107
|
+
logger.error("*** please consider using req_acl_sync instead of req_acl")
|
|
108
|
+
return await self.req_acl_sync(contact, timeout, min_timeout)
|
|
109
|
+
|
|
110
|
+
async def req_acl_sync(self, contact, timeout=0, min_timeout=0):
|
|
111
|
+
async with self._mesh_request_lock:
|
|
112
|
+
req = b"\0\0"
|
|
113
|
+
res = await self.send_binary_req(
|
|
114
|
+
contact,
|
|
115
|
+
BinaryReqType.ACL,
|
|
116
|
+
data=req,
|
|
117
|
+
timeout=timeout
|
|
118
|
+
)
|
|
119
|
+
if res.type == EventType.ERROR:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout
|
|
123
|
+
timeout = timeout if timeout > min_timeout else min_timeout
|
|
124
|
+
|
|
125
|
+
if self.dispatcher is None:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
acl_event = await self.dispatcher.wait_for_event(
|
|
129
|
+
EventType.ACL_RESPONSE,
|
|
130
|
+
attribute_filters={"tag": res.payload["expected_ack"].hex()},
|
|
131
|
+
timeout=timeout,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return acl_event.payload["acl_data"] if acl_event else None
|
|
135
|
+
|
|
136
|
+
async def req_neighbours_async(self,
|
|
137
|
+
contact,
|
|
138
|
+
count=255,
|
|
139
|
+
offset=0,
|
|
140
|
+
order_by=0,
|
|
141
|
+
pubkey_prefix_length=4,
|
|
142
|
+
timeout=0,
|
|
143
|
+
min_timeout=0
|
|
144
|
+
):
|
|
145
|
+
req = (b"\x00" # version : 0
|
|
146
|
+
+ count.to_bytes(1, "little", signed=False)
|
|
147
|
+
+ offset.to_bytes(2, "little", signed=False)
|
|
148
|
+
+ order_by.to_bytes(1, "little", signed=False)
|
|
149
|
+
+ pubkey_prefix_length.to_bytes(1, "little", signed=False)
|
|
150
|
+
+ random.randint(1, 0xFFFFFFFF).to_bytes(4, "little", signed=False)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
logger.debug(f"Sending binary neighbours req, count: {count}, offset: {offset} {req.hex()}")
|
|
154
|
+
|
|
155
|
+
return await self.send_binary_req (
|
|
156
|
+
contact,
|
|
157
|
+
BinaryReqType.NEIGHBOURS,
|
|
158
|
+
data=req,
|
|
159
|
+
timeout=timeout,
|
|
160
|
+
context={"pubkey_prefix_length": pubkey_prefix_length}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async def req_neighbours_sync(self,
|
|
164
|
+
contact,
|
|
165
|
+
count=255,
|
|
166
|
+
offset=0,
|
|
167
|
+
order_by=0,
|
|
168
|
+
pubkey_prefix_length=4,
|
|
169
|
+
timeout=0,
|
|
170
|
+
min_timeout=0
|
|
171
|
+
):
|
|
172
|
+
async with self._mesh_request_lock:
|
|
173
|
+
res = await self.req_neighbours_async(contact,
|
|
174
|
+
count=count,
|
|
175
|
+
offset=offset,
|
|
176
|
+
order_by=order_by,
|
|
177
|
+
pubkey_prefix_length=pubkey_prefix_length,
|
|
178
|
+
timeout=timeout,
|
|
179
|
+
min_timeout=min_timeout)
|
|
180
|
+
|
|
181
|
+
if res is None or res.type == EventType.ERROR:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout
|
|
185
|
+
timeout = timeout if min_timeout < timeout else min_timeout
|
|
186
|
+
|
|
187
|
+
if self.dispatcher is None:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
neighbours_event = await self.dispatcher.wait_for_event(
|
|
191
|
+
EventType.NEIGHBOURS_RESPONSE,
|
|
192
|
+
attribute_filters={"tag": res.payload["expected_ack"].hex()},
|
|
193
|
+
timeout=timeout,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return neighbours_event.payload if neighbours_event else None
|
|
197
|
+
|
|
198
|
+
# do several queries if not all neighbours have been obtained
|
|
199
|
+
async def fetch_all_neighbours(self,
|
|
200
|
+
contact,
|
|
201
|
+
order_by=0,
|
|
202
|
+
pubkey_prefix_length=4,
|
|
203
|
+
timeout=0,
|
|
204
|
+
min_timeout=0
|
|
205
|
+
):
|
|
206
|
+
|
|
207
|
+
# Initial request
|
|
208
|
+
res = await self.req_neighbours_sync(contact,
|
|
209
|
+
count=255,
|
|
210
|
+
offset=0,
|
|
211
|
+
order_by=order_by,
|
|
212
|
+
pubkey_prefix_length=pubkey_prefix_length,
|
|
213
|
+
timeout=timeout,
|
|
214
|
+
min_timeout=min_timeout)
|
|
215
|
+
|
|
216
|
+
if res is None:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
neighbours_count = res["neighbours_count"] # total neighbours
|
|
220
|
+
results_count = res["results_count"] # obtained neighbours
|
|
221
|
+
|
|
222
|
+
del res["tag"]
|
|
223
|
+
|
|
224
|
+
tries = 0
|
|
225
|
+
while results_count < neighbours_count:
|
|
226
|
+
#await asyncio.sleep(2) # wait 2s before next fetch
|
|
227
|
+
next_res = await self.req_neighbours_sync(contact,
|
|
228
|
+
count=255,
|
|
229
|
+
offset=results_count,
|
|
230
|
+
order_by=order_by,
|
|
231
|
+
pubkey_prefix_length=pubkey_prefix_length,
|
|
232
|
+
timeout=timeout,
|
|
233
|
+
min_timeout=min_timeout+25) # requests are close and responses big,
|
|
234
|
+
#so let's have some more timeout for duty cycle
|
|
235
|
+
if next_res is None :
|
|
236
|
+
if tries > 0:
|
|
237
|
+
return res # caller should check it has everything
|
|
238
|
+
else:
|
|
239
|
+
tries += 1
|
|
240
|
+
else:
|
|
241
|
+
tries = 0
|
|
242
|
+
results_count += next_res["results_count"]
|
|
243
|
+
res["results_count"] = results_count
|
|
244
|
+
res["neighbours"] += next_res["neighbours"]
|
|
245
|
+
|
|
246
|
+
return res
|
|
247
|
+
|
|
248
|
+
async def req_regions_async(self, contact, timeout=0, min_timeout=0):
|
|
249
|
+
req = b"\0" # The return path, I currently do nothing with, so direct only
|
|
250
|
+
return await self.send_anon_req(
|
|
251
|
+
contact,
|
|
252
|
+
AnonReqType.REGIONS,
|
|
253
|
+
data=req,
|
|
254
|
+
timeout=timeout
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def req_regions_sync(self, contact, timeout=0, min_timeout=0):
|
|
258
|
+
async with self._mesh_request_lock:
|
|
259
|
+
res = await self.req_regions_async(contact, timeout, min_timeout)
|
|
260
|
+
|
|
261
|
+
if res.type == EventType.ERROR:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout
|
|
265
|
+
timeout = timeout if timeout > min_timeout else min_timeout
|
|
266
|
+
|
|
267
|
+
if self.dispatcher is None:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
region_event = await self.dispatcher.wait_for_event(
|
|
271
|
+
EventType.BINARY_RESPONSE,
|
|
272
|
+
attribute_filters={"tag": res.payload["expected_ack"].hex()},
|
|
273
|
+
timeout=timeout,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if region_event is None:
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
pkt = bytes().fromhex(region_event.payload["data"])
|
|
280
|
+
pbuf = io.BytesIO(pkt)
|
|
281
|
+
tag_again = pbuf.read(4)
|
|
282
|
+
return pbuf.read().decode("utf-8", "ignore").strip("\x00")
|
|
283
|
+
|
|
284
|
+
async def req_owner_async(self, contact, timeout=0, min_timeout=0):
|
|
285
|
+
req = b"\0"
|
|
286
|
+
return await self.send_anon_req(
|
|
287
|
+
contact,
|
|
288
|
+
AnonReqType.OWNER,
|
|
289
|
+
data=req,
|
|
290
|
+
timeout=timeout
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
async def req_owner_sync(self, contact, timeout=0, min_timeout=0):
|
|
294
|
+
async with self._mesh_request_lock:
|
|
295
|
+
res = await self.req_owner_async(contact, timeout, min_timeout)
|
|
296
|
+
|
|
297
|
+
if res.type == EventType.ERROR:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout
|
|
301
|
+
timeout = timeout if timeout > min_timeout else min_timeout
|
|
302
|
+
|
|
303
|
+
if self.dispatcher is None:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
owner_event = await self.dispatcher.wait_for_event(
|
|
307
|
+
EventType.BINARY_RESPONSE,
|
|
308
|
+
attribute_filters={"tag": res.payload["expected_ack"].hex()},
|
|
309
|
+
timeout=timeout,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if owner_event is None:
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
pkt = bytes().fromhex(owner_event.payload["data"])
|
|
316
|
+
pbuf = io.BytesIO(pkt)
|
|
317
|
+
tag_again = pbuf.read(4)
|
|
318
|
+
strings = pbuf.read().decode("utf-8", "ignore").split("\n", 1)
|
|
319
|
+
|
|
320
|
+
return dict(name=strings[0], owner=strings[1].strip("\x00"))
|
|
321
|
+
|
|
322
|
+
async def req_basic_async(self, contact, timeout=0, min_timeout=0):
|
|
323
|
+
req = b"\0"
|
|
324
|
+
return await self.send_anon_req(
|
|
325
|
+
contact,
|
|
326
|
+
AnonReqType.BASIC,
|
|
327
|
+
data=req,
|
|
328
|
+
timeout=timeout
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
async def req_basic_sync(self, contact, timeout=0, min_timeout=0):
|
|
332
|
+
async with self._mesh_request_lock:
|
|
333
|
+
res = await self.req_basic_async(contact, timeout, min_timeout)
|
|
334
|
+
|
|
335
|
+
if res.type == EventType.ERROR:
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout
|
|
339
|
+
timeout = timeout if timeout > min_timeout else min_timeout
|
|
340
|
+
|
|
341
|
+
if self.dispatcher is None:
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
basic_event = await self.dispatcher.wait_for_event(
|
|
345
|
+
EventType.BINARY_RESPONSE,
|
|
346
|
+
attribute_filters={"tag": res.payload["expected_ack"].hex()},
|
|
347
|
+
timeout=timeout,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if basic_event is None:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
return basic_event.payload
|
|
@@ -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()}")
|