meshcore 2.3.6__tar.gz → 2.3.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. meshcore-2.3.8/.github/funding.yml +2 -0
  2. {meshcore-2.3.6 → meshcore-2.3.8}/PKG-INFO +1 -1
  3. {meshcore-2.3.6 → meshcore-2.3.8}/pyproject.toml +1 -1
  4. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/ble_cx.py +83 -6
  5. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/commands/base.py +170 -32
  6. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/commands/binary.py +0 -4
  7. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/commands/contact.py +30 -6
  8. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/commands/device.py +101 -13
  9. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/commands/messaging.py +96 -7
  10. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/connection_manager.py +56 -39
  11. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/events.py +47 -3
  12. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/meshcore.py +20 -4
  13. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/meshcore_parser.py +61 -36
  14. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/packets.py +8 -0
  15. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/parsing.py +7 -1
  16. meshcore-2.3.8/src/meshcore/reader.py +1065 -0
  17. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/serial_cx.py +27 -8
  18. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/tcp_cx.py +22 -9
  19. {meshcore-2.3.6 → meshcore-2.3.8}/tests/test_ble_pin_pairing.py +9 -10
  20. meshcore-2.3.8/tests/unit/test_asyncio_lifecycle.py +235 -0
  21. {meshcore-2.3.6 → meshcore-2.3.8}/tests/unit/test_commands.py +11 -0
  22. meshcore-2.3.8/tests/unit/test_connection_manager.py +294 -0
  23. meshcore-2.3.8/tests/unit/test_error_handling.py +716 -0
  24. meshcore-2.3.8/tests/unit/test_parse_status.py +146 -0
  25. meshcore-2.3.8/tests/unit/test_protocol_surface_gaps.py +725 -0
  26. meshcore-2.3.8/tests/unit/test_reader.py +280 -0
  27. meshcore-2.3.8/tests/unit/test_standalone_fixes.py +227 -0
  28. meshcore-2.3.8/tests/unit/test_transport_symmetry.py +238 -0
  29. meshcore-2.3.6/src/meshcore/reader.py +0 -921
  30. meshcore-2.3.6/tests/unit/test_reader.py +0 -90
  31. {meshcore-2.3.6 → meshcore-2.3.8}/.github/python-test.yml +0 -0
  32. {meshcore-2.3.6 → meshcore-2.3.8}/.gitignore +0 -0
  33. {meshcore-2.3.6 → meshcore-2.3.8}/LICENSE +0 -0
  34. {meshcore-2.3.6 → meshcore-2.3.8}/README.md +0 -0
  35. {meshcore-2.3.6 → meshcore-2.3.8}/examples/.pubsub_example.py.swp +0 -0
  36. {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_chat.py +0 -0
  37. {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_pin_pairing_example.py +0 -0
  38. {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_private_key_export.py +0 -0
  39. {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_sign_example.py +0 -0
  40. {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_stats.py +0 -0
  41. {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_t1000_chan_msg.py +0 -0
  42. {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_t1000_custom_vars.py +0 -0
  43. {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_t1000_infos.py +0 -0
  44. {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_t1000_msg.py +0 -0
  45. {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_t1000_msg_retries.py +0 -0
  46. {meshcore-2.3.6 → meshcore-2.3.8}/examples/ble_t1000_set_cv.py +0 -0
  47. {meshcore-2.3.6 → meshcore-2.3.8}/examples/chan_recv_with_path.py +0 -0
  48. {meshcore-2.3.6 → meshcore-2.3.8}/examples/connection_events_example.py +0 -0
  49. {meshcore-2.3.6 → meshcore-2.3.8}/examples/mepo_mc_gps.py +0 -0
  50. {meshcore-2.3.6 → meshcore-2.3.8}/examples/pubsub_example.py +0 -0
  51. {meshcore-2.3.6 → meshcore-2.3.8}/examples/rf_packet_monitor.py +0 -0
  52. {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_battery_monitor.py +0 -0
  53. {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_channel_manager.py +0 -0
  54. {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_chat.py +0 -0
  55. {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_contacts.py +0 -0
  56. {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_infos.py +0 -0
  57. {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_meshcore_ollama.py +0 -0
  58. {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_msg.py +0 -0
  59. {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_pingbot.py +0 -0
  60. {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_repeater_status.py +0 -0
  61. {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_repeater_telemetry.py +0 -0
  62. {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_rss_bot.py +0 -0
  63. {meshcore-2.3.6 → meshcore-2.3.8}/examples/serial_trace.py +0 -0
  64. {meshcore-2.3.6 → meshcore-2.3.8}/examples/tcp_chat.py +0 -0
  65. {meshcore-2.3.6 → meshcore-2.3.8}/examples/tcp_login_status.py +0 -0
  66. {meshcore-2.3.6 → meshcore-2.3.8}/examples/tcp_mchome_contacts.py +0 -0
  67. {meshcore-2.3.6 → meshcore-2.3.8}/examples/tcp_mchome_infos.py +0 -0
  68. {meshcore-2.3.6 → meshcore-2.3.8}/examples/tcp_mchome_msg.py +0 -0
  69. {meshcore-2.3.6 → meshcore-2.3.8}/examples/tcp_mchome_readmsgs.py +0 -0
  70. {meshcore-2.3.6 → meshcore-2.3.8}/pytest.ini +0 -0
  71. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/__init__.py +0 -0
  72. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/commands/__init__.py +0 -0
  73. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/commands/control_data.py +0 -0
  74. {meshcore-2.3.6 → meshcore-2.3.8}/src/meshcore/lpp_json_encoder.py +0 -0
  75. {meshcore-2.3.6 → meshcore-2.3.8}/tests/README.md +0 -0
  76. {meshcore-2.3.6 → meshcore-2.3.8}/tests/test_ble_connection.py +0 -0
  77. {meshcore-2.3.6 → meshcore-2.3.8}/tests/test_meshcore_ble_pin.py +0 -0
  78. {meshcore-2.3.6 → meshcore-2.3.8}/tests/unit/test_events.py +0 -0
  79. {meshcore-2.3.6 → meshcore-2.3.8}/tests/unit/test_lpp_parsing.py +0 -0
  80. {meshcore-2.3.6 → meshcore-2.3.8}/tests/unit/test_path_discovery_response.py +0 -0
  81. {meshcore-2.3.6 → meshcore-2.3.8}/tests/unit/test_private_key_export.py +0 -0
  82. {meshcore-2.3.6 → meshcore-2.3.8}/tests/unit/test_serial_connection.py +0 -0
@@ -0,0 +1,2 @@
1
+ buy_me_a_coffee: fdlamotte
2
+ github: meshcore-dev
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcore
3
- Version: 2.3.6
3
+ Version: 2.3.8
4
4
  Summary: Base classes for communicating with meshcore companion radios
5
5
  Project-URL: Homepage, https://github.com/fdlamotte/meshcore_py
6
6
  Project-URL: Issues, https://github.com/fdlamotte/meshcore_py/issues
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meshcore"
7
- version = "2.3.6"
7
+ version = "2.3.8"
8
8
  authors = [
9
9
  { name="Florent de Lamotte", email="florent@frizoncorrea.fr" },
10
10
  { name="Alex Wolden", email="awolden@gmail.com" },
@@ -4,6 +4,7 @@ mccli.py : CLI interface to MeschCore BLE companion app
4
4
 
5
5
  import asyncio
6
6
  import logging
7
+ from typing import Optional
7
8
 
8
9
 
9
10
  # Make bleak optional - only fail if BLE operations are attempted
@@ -27,6 +28,11 @@ UART_RX_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"
27
28
  UART_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
28
29
 
29
30
  class BLEConnection:
31
+ # Upper bound on a single write (lock acquisition included). Healthy writes
32
+ # measured 0.06-0.17s against real hardware; observed stalls ran 20s to
33
+ # minutes, so this preempts them rather than waiting for CoreBluetooth.
34
+ WRITE_TIMEOUT = 10.0
35
+
30
36
  def __init__(self, address=None, device=None, client=None, pin=None):
31
37
  """
32
38
  Constructor: specify address or an existing BleakClient.
@@ -51,6 +57,35 @@ class BLEConnection:
51
57
  self.pin = pin
52
58
  self.rx_char = None
53
59
  self._disconnect_callback = None
60
+ self._background_tasks: set[asyncio.Task] = set()
61
+ self._write_lock_obj: Optional[asyncio.Lock] = None
62
+
63
+ @property
64
+ def _write_lock(self) -> asyncio.Lock:
65
+ """Serialises write_gatt_char().
66
+
67
+ Two overlapping writes to the same characteristic drop the link outright
68
+ (observed on macOS/CoreBluetooth: "BLE write failed: 19", connection
69
+ gone). Nothing above this layer guarantees callers are sequential --
70
+ schedulers, health checks and user commands all issue independently --
71
+ so the transport has to enforce it.
72
+
73
+ Lazily created so it binds to the running loop, mirroring the
74
+ _mesh_request_lock property in commands/base.py. Read through getattr so
75
+ an instance built without __init__ still works.
76
+ """
77
+ lock = getattr(self, "_write_lock_obj", None)
78
+ if lock is None:
79
+ lock = asyncio.Lock()
80
+ self._write_lock_obj = lock
81
+ return lock
82
+
83
+ def _spawn_background(self, coro) -> asyncio.Task:
84
+ """Create a tracked background task (prevents GC of fire-and-forget tasks)."""
85
+ task = asyncio.create_task(coro)
86
+ self._background_tasks.add(task)
87
+ task.add_done_callback(self._background_tasks.discard)
88
+ return task
54
89
 
55
90
  async def connect(self):
56
91
  """
@@ -116,9 +151,12 @@ class BLEConnection:
116
151
  await self.client.pair()
117
152
  logger.info("BLE pairing successful")
118
153
  except Exception as e:
119
- logger.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
154
+ logger.error(f"BLE pairing failed: {e}")
155
+ # A failed pairing leaves the transport in a half-usable
156
+ # state — re-raise so the caller gets a clean failure
157
+ # instead of a silently degraded connection.
158
+ await self.client.disconnect()
159
+ raise
122
160
 
123
161
  except BleakDeviceNotFoundError:
124
162
  return None
@@ -154,8 +192,19 @@ class BLEConnection:
154
192
  self.client = self._user_provided_client
155
193
  self.device = self._user_provided_device
156
194
 
195
+ # Re-register disconnect callback on the reset client so subsequent
196
+ # disconnects after a reconnect cycle are still detected.
197
+ if self.client is not None and hasattr(self.client, 'set_disconnected_callback'):
198
+ try:
199
+ self.client.set_disconnected_callback(self.handle_disconnect)
200
+ except Exception:
201
+ # set_disconnected_callback may not be available on all bleak
202
+ # versions; the next connect() call will re-create the client
203
+ # with the callback anyway.
204
+ pass
205
+
157
206
  if self._disconnect_callback:
158
- asyncio.create_task(self._disconnect_callback("ble_disconnect"))
207
+ self._spawn_background(self._disconnect_callback("ble_disconnect"))
159
208
 
160
209
  def set_disconnect_callback(self, callback):
161
210
  """Set callback to handle disconnections."""
@@ -166,16 +215,44 @@ class BLEConnection:
166
215
 
167
216
  def handle_rx(self, _: BleakGATTCharacteristic, data: bytearray):
168
217
  if self.reader is not None:
169
- asyncio.create_task(self.reader.handle_rx(data))
218
+ self._spawn_background(self.reader.handle_rx(data))
219
+
220
+ async def _write_locked(self, data):
221
+ async with self._write_lock:
222
+ await self.client.write_gatt_char(self.rx_char, bytes(data), response=True)
170
223
 
171
224
  async def send(self, data):
172
225
  if not self.client:
173
226
  logger.error("Client is not connected")
227
+ if self._disconnect_callback:
228
+ await self._disconnect_callback("ble_transport_lost")
174
229
  return False
175
230
  if not self.rx_char:
176
231
  logger.error("RX characteristic not found")
177
232
  return False
178
- await self.client.write_gatt_char(self.rx_char, bytes(data), response=True)
233
+ # Bound the whole acquire-plus-write. A stalled write has been seen to
234
+ # hang for minutes, and CommandHandler's own timeout does not cover this
235
+ # -- it starts only after _sender_func returns -- so without a bound the
236
+ # serialising lock would queue every other command behind the stall
237
+ # indefinitely, with nothing logged and no disconnect raised. Turning one
238
+ # hung command into a silent whole-client stall would be worse than the
239
+ # overlap the lock exists to prevent.
240
+ try:
241
+ await asyncio.wait_for(self._write_locked(data), timeout=self.WRITE_TIMEOUT)
242
+ except asyncio.TimeoutError:
243
+ # Do not simply release and carry on: the underlying write may still
244
+ # be in flight, and a second write racing it re-creates the exact
245
+ # overlap that kills the link. Tear the connection down so the
246
+ # reconnect path takes over -- bounded and self-healing.
247
+ logger.warning(f"BLE write timed out after {self.WRITE_TIMEOUT}s")
248
+ if self._disconnect_callback:
249
+ await self._disconnect_callback("ble_write_timeout")
250
+ return False
251
+ except Exception as exc:
252
+ logger.warning(f"BLE write failed: {exc}")
253
+ if self._disconnect_callback:
254
+ await self._disconnect_callback(f"ble_write_failed: {exc}")
255
+ return False
179
256
 
180
257
  async def disconnect(self):
181
258
  """Disconnect from the BLE device."""
@@ -57,18 +57,91 @@ def _validate_destination(dst: DestinationType, prefix_length: int = 6) -> bytes
57
57
  )
58
58
 
59
59
 
60
+ # Size of the server-side reply_path buffer (uint8_t reply_path[64] in
61
+ # simple_repeater/MyMesh.h). It is memcpy'd into without a length check.
62
+ MAX_REPLY_PATH_BYTES = 64
63
+ # reply_path_len is the low 6 bits of the header byte.
64
+ MAX_REPLY_PATH_HOPS = 63
65
+
66
+
67
+ def encode_reply_path(out_path_len: int, out_path_hex: str, out_path_hash_mode: int) -> bytes:
68
+ """Encode the reply path a server should use when answering us.
69
+
70
+ The leading byte packs two fields, which the server unpacks as:
71
+
72
+ reply_path_len = byte & 63
73
+ reply_path_hash_size = (byte >> 6) + 1
74
+
75
+ so the hash mode has to travel in the top two bits. Omitting it makes the
76
+ server read a hash size of 1 regardless of the real mode, take the wrong
77
+ number of bytes per hop, and route its reply to hops that do not exist.
78
+
79
+ The path itself is reversed by *hop*, not by byte: a return path visits the
80
+ same hops in the opposite order, and each hop's multi-byte hash must stay
81
+ intact. (For single-byte hops the two are indistinguishable, which is most
82
+ of why this went unnoticed - mode 0 is the default.)
83
+ """
84
+ hash_mode = max(out_path_hash_mode, 0) # -1 means "flood", i.e. no path
85
+ if hash_mode > 2:
86
+ # The server computes hash_size = mode + 1, and Packet::isValidPathLen
87
+ # rejects 4-byte hops outright, so such a path is unusable on the wire.
88
+ logger.warning(
89
+ f"Unsupported out_path_hash_mode {out_path_hash_mode}; "
90
+ "requesting a zero-hop reply path instead"
91
+ )
92
+ return b"\x00"
93
+ hash_size = hash_mode + 1
94
+ # Saturate rather than mask: `& 63` would silently wrap a 64-hop path to
95
+ # zero hops, i.e. a zero-hop reply for a distant node.
96
+ hops = min(max(out_path_len, 0), MAX_REPLY_PATH_HOPS)
97
+
98
+ raw = bytes.fromhex(out_path_hex or "")
99
+ # Never read past what the contact actually carries; a truncated or padded
100
+ # field would otherwise yield short trailing hops.
101
+ hops = min(hops, len(raw) // hash_size)
102
+ # The server memcpys into a fixed 64-byte reply_path with no bounds check
103
+ # (simple_repeater/MyMesh.cpp), so never describe more than fits.
104
+ max_hops = min(MAX_REPLY_PATH_HOPS, MAX_REPLY_PATH_BYTES // hash_size)
105
+ if hops > max_hops:
106
+ logger.warning(
107
+ f"Reply path of {hops} hops x {hash_size}B exceeds the "
108
+ f"{MAX_REPLY_PATH_BYTES}B the server can hold; truncating to {max_hops}"
109
+ )
110
+ hops = max_hops
111
+
112
+ path = b"".join(
113
+ raw[i * hash_size:(i + 1) * hash_size] for i in range(hops - 1, -1, -1)
114
+ )
115
+ return bytes([hops | (hash_mode << 6)]) + path
116
+
117
+
60
118
  class CommandHandlerBase:
61
- DEFAULT_TIMEOUT = 5.0
119
+ """Base class for command handlers.
120
+
121
+ .. note::
122
+ The internal ``asyncio.Lock`` is created lazily on first access
123
+ so that it binds to the correct running event loop (required for
124
+ Python 3.9/3.10 compatibility).
125
+ """
126
+
127
+ DEFAULT_TIMEOUT = 15.0
62
128
 
63
129
  def __init__(self, default_timeout: Optional[float] = None):
64
130
  self._sender_func: Optional[Callable[[bytes], Coroutine[Any, Any, None]]] = None
65
131
  self._reader: Optional[MessageReader] = None
66
132
  self.dispatcher: Optional[EventDispatcher] = None
67
- self._mesh_request_lock = asyncio.Lock()
133
+ self.__mesh_request_lock: Optional[asyncio.Lock] = None
68
134
  self.default_timeout = (
69
135
  default_timeout if default_timeout is not None else self.DEFAULT_TIMEOUT
70
136
  )
71
137
 
138
+ @property
139
+ def _mesh_request_lock(self) -> asyncio.Lock:
140
+ """Lazy-init lock so it binds to the running loop, not import-time."""
141
+ if self.__mesh_request_lock is None:
142
+ self.__mesh_request_lock = asyncio.Lock()
143
+ return self.__mesh_request_lock
144
+
72
145
  def set_connection(self, connection: Any) -> None:
73
146
  async def sender(data: bytes) -> None:
74
147
  await connection.send(data)
@@ -90,6 +163,14 @@ class CommandHandlerBase:
90
163
  expected_events: Optional[Union[EventType, List[EventType]]] = None,
91
164
  timeout: Optional[float] = None,
92
165
  ) -> Event:
166
+ """Wait for the first of *expected_events* to arrive.
167
+
168
+ Returns the first matched ``Event``. When ``EventType.ERROR`` is
169
+ among the expected types, the caller **must** check
170
+ ``result.is_error()`` before accessing command-specific payload
171
+ keys — an ERROR payload is ``{"reason": "..."}`` and will
172
+ ``KeyError`` on any other key.
173
+ """
93
174
  try:
94
175
  # Convert single event to list if needed
95
176
  if not isinstance(expected_events, list):
@@ -129,9 +210,6 @@ class CommandHandlerBase:
129
210
  logger.debug(f"Command error: {e}")
130
211
  return Event(EventType.ERROR, {"error": str(e)})
131
212
 
132
- return Event(EventType.ERROR, {})
133
-
134
-
135
213
  async def send(
136
214
  self,
137
215
  data: bytes,
@@ -151,7 +229,14 @@ class CommandHandlerBase:
151
229
  timeout: Timeout in seconds, or None to use default_timeout
152
230
 
153
231
  Returns:
154
- Event: The full event object that was received in response to the command
232
+ Event: The full event object that was received in response to
233
+ the command.
234
+
235
+ Important:
236
+ When ``EventType.ERROR`` is included in *expected_events*, the
237
+ returned event may be an error response. Callers **must**
238
+ check ``result.is_error()`` before accessing command-specific
239
+ payload keys to avoid ``KeyError``.
155
240
  """
156
241
  if not self.dispatcher:
157
242
  raise RuntimeError("Dispatcher not set, cannot send commands")
@@ -170,7 +255,7 @@ class CommandHandlerBase:
170
255
  futures: List[asyncio.Future] = []
171
256
  subscriptions = []
172
257
 
173
- loop = asyncio.get_event_loop()
258
+ loop = asyncio.get_running_loop()
174
259
  for event_type in expected_events:
175
260
  future = loop.create_future()
176
261
 
@@ -243,49 +328,102 @@ class CommandHandlerBase:
243
328
  logger.debug(f"Binary request to {dst_bytes.hex()}")
244
329
  data = b"\x32" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + (data if data else b"")
245
330
 
246
- result = await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
247
-
248
- # Register the request with the reader if we have both reader and request_type
249
- if (result.type == EventType.MSG_SENT and
250
- self._reader is not None and
251
- request_type is not None):
252
-
253
- exp_tag = result.payload["expected_ack"].hex()
254
- # Use provided timeout or fallback to suggested timeout (with 5s default)
255
- actual_timeout = timeout if timeout is not None and timeout > 0 else result.payload.get("suggested_timeout", 4000) / 800.0
331
+ # Pre-register a placeholder binary request before send() to close the race
332
+ # window where a BINARY_RESPONSE could arrive between send() returning and
333
+ # registration. The placeholder tag is patched to the real tag once MSG_SENT
334
+ # returns. If send() fails, the placeholder is cleaned up.
335
+ placeholder_tag = None
336
+ if self._reader is not None and request_type is not None:
337
+ placeholder_tag = f"_pending_{id(data)}"
338
+ actual_timeout = timeout if timeout is not None and timeout > 0 else self.default_timeout
256
339
  actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout
257
- self._reader.register_binary_request(pubkey_prefix.hex(), exp_tag, request_type, actual_timeout, context=context)
340
+ self._reader.register_binary_request(pubkey_prefix.hex(), placeholder_tag, request_type, actual_timeout, context=context)
341
+
342
+ result = await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
343
+
344
+ # Patch the placeholder tag with the real tag from MSG_SENT, or clean up on failure
345
+ if placeholder_tag is not None and self._reader is not None:
346
+ # Remove the placeholder entry
347
+ self._reader.pending_binary_requests.pop(placeholder_tag, None)
348
+ if (result.type == EventType.MSG_SENT and
349
+ request_type is not None):
350
+ exp_tag = result.payload["expected_ack"].hex()
351
+ # Use suggested_timeout from the result if available
352
+ actual_timeout = timeout if timeout is not None and timeout > 0 else result.payload.get("suggested_timeout", 4000) / 800.0
353
+ actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout
354
+ # Register with the real tag
355
+ self._reader.register_binary_request(pubkey_prefix.hex(), exp_tag, request_type, actual_timeout, context=context)
258
356
 
259
357
  return result
260
358
 
261
359
  async def send_anon_req(self, dst: DestinationType, request_type: AnonReqType, data: Optional[bytes] = None, context={}, timeout=None, min_timeout=0) -> Event:
360
+ """Send an anonymous request to *dst*.
361
+
362
+ *dst* need not be a known contact. When it is, that contact's out path is
363
+ used as the reply path; otherwise a zero-hop direct reply path is
364
+ requested (see the comment below).
365
+
366
+ Note: *data* is currently ignored -- the request body is the reply path,
367
+ which is derived here rather than supplied by the caller.
368
+ """
262
369
  dst_bytes = _validate_destination(dst, prefix_length=32)
263
370
  pubkey_prefix = _validate_destination(dst, prefix_length=6)
264
371
  logger.debug(f"Anon Binary request to {dst_bytes.hex()}")
265
372
 
266
- contact = self._get_contact_by_prefix(dst_bytes.hex()) # need a contact for return path
267
- if contact is None:
268
- logger.error("No contact found")
373
+ # The contact is consulted only to build the reply path appended to the
374
+ # request; it is not required to send one. Companion firmware from
375
+ # FIRMWARE_VER_CODE 13 synthesises a transient anon contact for an unknown
376
+ # pubkey (out_path_len = 0, zero-hop direct), so an unknown destination is
377
+ # reachable as long as we ask it to reply zero-hop. Refusing here would
378
+ # block probing any node the client has not already added - for instance
379
+ # asking a freshly discovered neighbour for its regions.
380
+ contact = self._get_contact_by_prefix(dst_bytes.hex())
269
381
 
270
382
  zero_hop = False
271
- if contact["out_path_len"] == -1:
272
- logger.info("No path set trying zero hop")
273
- zero_hop = True
274
- await self.change_contact_path(contact, "")
383
+ if contact is None:
384
+ logger.debug("No contact found, requesting a zero-hop direct reply path")
385
+ out_path_len = 0
386
+ reply_path = encode_reply_path(0, "", 0)
387
+ else:
388
+ if contact["out_path_len"] == -1:
389
+ logger.info("No path set trying zero hop")
390
+ zero_hop = True
391
+ path_res = await self.change_contact_path(contact, "")
392
+ if path_res is not None and path_res.type == EventType.ERROR:
393
+ # The device still has this contact as flood, so sendAnonReq
394
+ # will flood the request -- and the server gates REGIONS,
395
+ # OWNER and BASIC behind isRouteDirect(), silently dropping
396
+ # it. Better to fail here than to wait out a full timeout
397
+ # for a reply that cannot come.
398
+ logger.error("Could not set zero-hop path, aborting anon request")
399
+ return Event(EventType.ERROR, {"reason": "path_reset_failed"})
400
+ # update_contact() normally reflects the change back onto the dict, so
401
+ # out_path_len reads 0 here. Clamp anyway: if that call failed (e.g. the
402
+ # device query inside it errored) the dict is still -1, and the unsigned
403
+ # to_bytes below would raise OverflowError -- which would skip the
404
+ # reset_path at the end of this method and leave the contact pinned to
405
+ # zero-hop on the device. Zero is the right value to send regardless,
406
+ # since zero-hop is exactly what we just asked for.
407
+ out_path_len = max(contact["out_path_len"], 0)
408
+ reply_path = encode_reply_path(
409
+ out_path_len,
410
+ contact["out_path"],
411
+ contact.get("out_path_hash_mode", 0),
412
+ )
275
413
 
276
- data = contact["out_path_len"].to_bytes(1, "little") + bytes.fromhex(contact["out_path"])[::-1]
277
- data = b"\x39" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + (data if data else b"")
414
+ data = b"\x39" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + reply_path
278
415
 
279
416
  result = await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
280
-
417
+
281
418
  # Register the request with the reader if we have both reader and request_type
282
- if (result.type == EventType.MSG_SENT and
283
- self._reader is not None and
419
+ if (result.type == EventType.MSG_SENT and
420
+ self._reader is not None and
284
421
  request_type is not None):
285
-
422
+
286
423
  exp_tag = result.payload["expected_ack"].hex()
287
424
  # Use provided timeout or fallback to suggested timeout (with 5s default)
288
- result.payload["suggested_timeout"] = result.payload.get("suggested_timeout", 4000) * (contact["out_path_len"] + 1) # update timeout from path_len
425
+ emitted_hops = reply_path[0] & 63 # what actually went on the wire
426
+ result.payload["suggested_timeout"] = result.payload.get("suggested_timeout", 4000) * (emitted_hops + 1) # update timeout from path_len
289
427
  actual_timeout = timeout if timeout is not None and timeout > 0 else result.payload.get("suggested_timeout", 4000) / 800.0
290
428
  actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout
291
429
  self._reader.register_binary_request(pubkey_prefix.hex(), exp_tag, request_type, actual_timeout, context=context, is_anon=True)
@@ -73,10 +73,6 @@ class BinaryCommandHandler(CommandHandlerBase):
73
73
 
74
74
  return telem_event.payload["lpp"] if telem_event else None
75
75
 
76
- async def req_mma(self, contact, timeout=0, min_timeout=0):
77
- logger.error("*** please consider using req_mma_sync instead of req_mma")
78
- return await self.req_mma_sync(contact, start, end, timeout,min_timeout)
79
-
80
76
  async def req_mma_sync(self, contact, start, end, timeout=0,min_timeout=0):
81
77
  async with self._mesh_request_lock:
82
78
  req = (
@@ -43,13 +43,17 @@ class ContactCommands(CommandHandlerBase):
43
43
  logger.debug("Timeout while getting contacts")
44
44
  for future in pending: # cancel all futures
45
45
  future.cancel()
46
- return 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()}")