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.
Files changed (82) hide show
  1. {meshcore-2.3.5 → meshcore-2.3.7}/PKG-INFO +1 -1
  2. {meshcore-2.3.5 → meshcore-2.3.7}/pyproject.toml +1 -1
  3. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/ble_cx.py +36 -6
  4. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/commands/base.py +59 -17
  5. meshcore-2.3.7/src/meshcore/commands/binary.py +353 -0
  6. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/commands/contact.py +30 -6
  7. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/commands/device.py +101 -13
  8. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/commands/messaging.py +112 -13
  9. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/connection_manager.py +56 -39
  10. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/events.py +46 -3
  11. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/meshcore.py +20 -4
  12. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/meshcore_parser.py +61 -36
  13. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/packets.py +7 -0
  14. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/parsing.py +7 -1
  15. meshcore-2.3.7/src/meshcore/reader.py +992 -0
  16. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/serial_cx.py +27 -8
  17. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/tcp_cx.py +22 -9
  18. {meshcore-2.3.5 → meshcore-2.3.7}/tests/test_ble_pin_pairing.py +9 -10
  19. meshcore-2.3.7/tests/unit/test_asyncio_lifecycle.py +235 -0
  20. {meshcore-2.3.5 → meshcore-2.3.7}/tests/unit/test_commands.py +43 -42
  21. meshcore-2.3.7/tests/unit/test_connection_manager.py +294 -0
  22. meshcore-2.3.7/tests/unit/test_error_handling.py +236 -0
  23. meshcore-2.3.7/tests/unit/test_lpp_parsing.py +39 -0
  24. meshcore-2.3.7/tests/unit/test_parse_status.py +146 -0
  25. {meshcore-2.3.5 → meshcore-2.3.7}/tests/unit/test_private_key_export.py +18 -9
  26. meshcore-2.3.7/tests/unit/test_protocol_surface_gaps.py +364 -0
  27. meshcore-2.3.7/tests/unit/test_reader.py +280 -0
  28. meshcore-2.3.7/tests/unit/test_standalone_fixes.py +227 -0
  29. meshcore-2.3.7/tests/unit/test_transport_symmetry.py +238 -0
  30. meshcore-2.3.5/src/meshcore/commands/binary.py +0 -356
  31. meshcore-2.3.5/src/meshcore/reader.py +0 -921
  32. meshcore-2.3.5/tests/unit/test_reader.py +0 -89
  33. {meshcore-2.3.5 → meshcore-2.3.7}/.github/python-test.yml +0 -0
  34. {meshcore-2.3.5 → meshcore-2.3.7}/.gitignore +0 -0
  35. {meshcore-2.3.5 → meshcore-2.3.7}/LICENSE +0 -0
  36. {meshcore-2.3.5 → meshcore-2.3.7}/README.md +0 -0
  37. {meshcore-2.3.5 → meshcore-2.3.7}/examples/.pubsub_example.py.swp +0 -0
  38. {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_chat.py +0 -0
  39. {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_pin_pairing_example.py +0 -0
  40. {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_private_key_export.py +0 -0
  41. {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_sign_example.py +0 -0
  42. {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_stats.py +0 -0
  43. {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_t1000_chan_msg.py +0 -0
  44. {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_t1000_custom_vars.py +0 -0
  45. {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_t1000_infos.py +0 -0
  46. {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_t1000_msg.py +0 -0
  47. {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_t1000_msg_retries.py +0 -0
  48. {meshcore-2.3.5 → meshcore-2.3.7}/examples/ble_t1000_set_cv.py +0 -0
  49. {meshcore-2.3.5 → meshcore-2.3.7}/examples/chan_recv_with_path.py +0 -0
  50. {meshcore-2.3.5 → meshcore-2.3.7}/examples/connection_events_example.py +0 -0
  51. {meshcore-2.3.5 → meshcore-2.3.7}/examples/mepo_mc_gps.py +0 -0
  52. {meshcore-2.3.5 → meshcore-2.3.7}/examples/pubsub_example.py +0 -0
  53. {meshcore-2.3.5 → meshcore-2.3.7}/examples/rf_packet_monitor.py +0 -0
  54. {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_battery_monitor.py +0 -0
  55. {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_channel_manager.py +0 -0
  56. {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_chat.py +0 -0
  57. {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_contacts.py +0 -0
  58. {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_infos.py +0 -0
  59. {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_meshcore_ollama.py +0 -0
  60. {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_msg.py +0 -0
  61. {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_pingbot.py +0 -0
  62. {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_repeater_status.py +0 -0
  63. {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_repeater_telemetry.py +0 -0
  64. {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_rss_bot.py +0 -0
  65. {meshcore-2.3.5 → meshcore-2.3.7}/examples/serial_trace.py +0 -0
  66. {meshcore-2.3.5 → meshcore-2.3.7}/examples/tcp_chat.py +0 -0
  67. {meshcore-2.3.5 → meshcore-2.3.7}/examples/tcp_login_status.py +0 -0
  68. {meshcore-2.3.5 → meshcore-2.3.7}/examples/tcp_mchome_contacts.py +0 -0
  69. {meshcore-2.3.5 → meshcore-2.3.7}/examples/tcp_mchome_infos.py +0 -0
  70. {meshcore-2.3.5 → meshcore-2.3.7}/examples/tcp_mchome_msg.py +0 -0
  71. {meshcore-2.3.5 → meshcore-2.3.7}/examples/tcp_mchome_readmsgs.py +0 -0
  72. {meshcore-2.3.5 → meshcore-2.3.7}/pytest.ini +0 -0
  73. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/__init__.py +0 -0
  74. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/commands/__init__.py +0 -0
  75. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/commands/control_data.py +0 -0
  76. {meshcore-2.3.5 → meshcore-2.3.7}/src/meshcore/lpp_json_encoder.py +0 -0
  77. {meshcore-2.3.5 → meshcore-2.3.7}/tests/README.md +0 -0
  78. {meshcore-2.3.5 → meshcore-2.3.7}/tests/test_ble_connection.py +0 -0
  79. {meshcore-2.3.5 → meshcore-2.3.7}/tests/test_meshcore_ble_pin.py +0 -0
  80. {meshcore-2.3.5 → meshcore-2.3.7}/tests/unit/test_events.py +0 -0
  81. {meshcore-2.3.5 → meshcore-2.3.7}/tests/unit/test_path_discovery_response.py +0 -0
  82. {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.5
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meshcore"
7
- version = "2.3.5"
7
+ version = "2.3.7"
8
8
  authors = [
9
9
  { name="Florent de Lamotte", email="florent@frizoncorrea.fr" },
10
10
  { name="Alex Wolden", email="awolden@gmail.com" },
@@ -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.warning(f"BLE pairing failed: {e}")
120
- # Don't fail the connection if pairing fails, as the device
121
- # might already be paired or not require pairing
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
- asyncio.create_task(self._disconnect_callback("ble_disconnect"))
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
- asyncio.create_task(self.reader.handle_rx(data))
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
- await self.client.write_gatt_char(self.rx_char, bytes(data), response=True)
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
- DEFAULT_TIMEOUT = 5.0
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 the command
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.get_event_loop()
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
- result = await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
246
-
247
- # Register the request with the reader if we have both reader and request_type
248
- if (result.type == EventType.MSG_SENT and
249
- self._reader is not None and
250
- request_type is not None):
251
-
252
- exp_tag = result.payload["expected_ack"].hex()
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(), exp_tag, request_type, actual_timeout, context=context)
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 None
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 or event.type != EventType.NEXT_CONTACT:
51
- for future in pending:
52
- future.cancel()
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 None
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
- path_hash_mode = contact["out_path_len"] >> 6 # would fallback to previous val
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()}")