meshcore 2.3.6__tar.gz → 2.3.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {meshcore-2.3.6 → meshcore-2.3.7}/PKG-INFO +1 -1
  2. {meshcore-2.3.6 → meshcore-2.3.7}/pyproject.toml +1 -1
  3. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/ble_cx.py +36 -6
  4. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/commands/base.py +59 -18
  5. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/commands/binary.py +0 -4
  6. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/commands/contact.py +30 -6
  7. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/commands/device.py +101 -13
  8. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/commands/messaging.py +64 -3
  9. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/connection_manager.py +56 -39
  10. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/events.py +46 -3
  11. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/meshcore.py +20 -4
  12. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/meshcore_parser.py +61 -36
  13. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/packets.py +7 -0
  14. {meshcore-2.3.6 → 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.6 → meshcore-2.3.7}/src/meshcore/serial_cx.py +27 -8
  17. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/tcp_cx.py +22 -9
  18. {meshcore-2.3.6 → 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.6 → meshcore-2.3.7}/tests/unit/test_commands.py +11 -0
  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_parse_status.py +146 -0
  24. meshcore-2.3.7/tests/unit/test_protocol_surface_gaps.py +364 -0
  25. meshcore-2.3.7/tests/unit/test_reader.py +280 -0
  26. meshcore-2.3.7/tests/unit/test_standalone_fixes.py +227 -0
  27. meshcore-2.3.7/tests/unit/test_transport_symmetry.py +238 -0
  28. meshcore-2.3.6/src/meshcore/reader.py +0 -921
  29. meshcore-2.3.6/tests/unit/test_reader.py +0 -90
  30. {meshcore-2.3.6 → meshcore-2.3.7}/.github/python-test.yml +0 -0
  31. {meshcore-2.3.6 → meshcore-2.3.7}/.gitignore +0 -0
  32. {meshcore-2.3.6 → meshcore-2.3.7}/LICENSE +0 -0
  33. {meshcore-2.3.6 → meshcore-2.3.7}/README.md +0 -0
  34. {meshcore-2.3.6 → meshcore-2.3.7}/examples/.pubsub_example.py.swp +0 -0
  35. {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_chat.py +0 -0
  36. {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_pin_pairing_example.py +0 -0
  37. {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_private_key_export.py +0 -0
  38. {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_sign_example.py +0 -0
  39. {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_stats.py +0 -0
  40. {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_t1000_chan_msg.py +0 -0
  41. {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_t1000_custom_vars.py +0 -0
  42. {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_t1000_infos.py +0 -0
  43. {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_t1000_msg.py +0 -0
  44. {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_t1000_msg_retries.py +0 -0
  45. {meshcore-2.3.6 → meshcore-2.3.7}/examples/ble_t1000_set_cv.py +0 -0
  46. {meshcore-2.3.6 → meshcore-2.3.7}/examples/chan_recv_with_path.py +0 -0
  47. {meshcore-2.3.6 → meshcore-2.3.7}/examples/connection_events_example.py +0 -0
  48. {meshcore-2.3.6 → meshcore-2.3.7}/examples/mepo_mc_gps.py +0 -0
  49. {meshcore-2.3.6 → meshcore-2.3.7}/examples/pubsub_example.py +0 -0
  50. {meshcore-2.3.6 → meshcore-2.3.7}/examples/rf_packet_monitor.py +0 -0
  51. {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_battery_monitor.py +0 -0
  52. {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_channel_manager.py +0 -0
  53. {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_chat.py +0 -0
  54. {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_contacts.py +0 -0
  55. {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_infos.py +0 -0
  56. {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_meshcore_ollama.py +0 -0
  57. {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_msg.py +0 -0
  58. {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_pingbot.py +0 -0
  59. {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_repeater_status.py +0 -0
  60. {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_repeater_telemetry.py +0 -0
  61. {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_rss_bot.py +0 -0
  62. {meshcore-2.3.6 → meshcore-2.3.7}/examples/serial_trace.py +0 -0
  63. {meshcore-2.3.6 → meshcore-2.3.7}/examples/tcp_chat.py +0 -0
  64. {meshcore-2.3.6 → meshcore-2.3.7}/examples/tcp_login_status.py +0 -0
  65. {meshcore-2.3.6 → meshcore-2.3.7}/examples/tcp_mchome_contacts.py +0 -0
  66. {meshcore-2.3.6 → meshcore-2.3.7}/examples/tcp_mchome_infos.py +0 -0
  67. {meshcore-2.3.6 → meshcore-2.3.7}/examples/tcp_mchome_msg.py +0 -0
  68. {meshcore-2.3.6 → meshcore-2.3.7}/examples/tcp_mchome_readmsgs.py +0 -0
  69. {meshcore-2.3.6 → meshcore-2.3.7}/pytest.ini +0 -0
  70. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/__init__.py +0 -0
  71. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/commands/__init__.py +0 -0
  72. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/commands/control_data.py +0 -0
  73. {meshcore-2.3.6 → meshcore-2.3.7}/src/meshcore/lpp_json_encoder.py +0 -0
  74. {meshcore-2.3.6 → meshcore-2.3.7}/tests/README.md +0 -0
  75. {meshcore-2.3.6 → meshcore-2.3.7}/tests/test_ble_connection.py +0 -0
  76. {meshcore-2.3.6 → meshcore-2.3.7}/tests/test_meshcore_ble_pin.py +0 -0
  77. {meshcore-2.3.6 → meshcore-2.3.7}/tests/unit/test_events.py +0 -0
  78. {meshcore-2.3.6 → meshcore-2.3.7}/tests/unit/test_lpp_parsing.py +0 -0
  79. {meshcore-2.3.6 → meshcore-2.3.7}/tests/unit/test_path_discovery_response.py +0 -0
  80. {meshcore-2.3.6 → meshcore-2.3.7}/tests/unit/test_private_key_export.py +0 -0
  81. {meshcore-2.3.6 → meshcore-2.3.7}/tests/unit/test_serial_connection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcore
3
- Version: 2.3.6
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.6"
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,17 +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
67
- self._mesh_request_lock = asyncio.Lock()
75
+ self.__mesh_request_lock: Optional[asyncio.Lock] = None
68
76
  self.default_timeout = (
69
77
  default_timeout if default_timeout is not None else self.DEFAULT_TIMEOUT
70
78
  )
71
79
 
80
+ @property
81
+ def _mesh_request_lock(self) -> asyncio.Lock:
82
+ """Lazy-init lock so it binds to the running loop, not import-time."""
83
+ if self.__mesh_request_lock is None:
84
+ self.__mesh_request_lock = asyncio.Lock()
85
+ return self.__mesh_request_lock
86
+
72
87
  def set_connection(self, connection: Any) -> None:
73
88
  async def sender(data: bytes) -> None:
74
89
  await connection.send(data)
@@ -90,6 +105,14 @@ class CommandHandlerBase:
90
105
  expected_events: Optional[Union[EventType, List[EventType]]] = None,
91
106
  timeout: Optional[float] = None,
92
107
  ) -> Event:
108
+ """Wait for the first of *expected_events* to arrive.
109
+
110
+ Returns the first matched ``Event``. When ``EventType.ERROR`` is
111
+ among the expected types, the caller **must** check
112
+ ``result.is_error()`` before accessing command-specific payload
113
+ keys — an ERROR payload is ``{"reason": "..."}`` and will
114
+ ``KeyError`` on any other key.
115
+ """
93
116
  try:
94
117
  # Convert single event to list if needed
95
118
  if not isinstance(expected_events, list):
@@ -129,9 +152,6 @@ class CommandHandlerBase:
129
152
  logger.debug(f"Command error: {e}")
130
153
  return Event(EventType.ERROR, {"error": str(e)})
131
154
 
132
- return Event(EventType.ERROR, {})
133
-
134
-
135
155
  async def send(
136
156
  self,
137
157
  data: bytes,
@@ -151,7 +171,14 @@ class CommandHandlerBase:
151
171
  timeout: Timeout in seconds, or None to use default_timeout
152
172
 
153
173
  Returns:
154
- Event: The full event object that was received in response to 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``.
155
182
  """
156
183
  if not self.dispatcher:
157
184
  raise RuntimeError("Dispatcher not set, cannot send commands")
@@ -170,7 +197,7 @@ class CommandHandlerBase:
170
197
  futures: List[asyncio.Future] = []
171
198
  subscriptions = []
172
199
 
173
- loop = asyncio.get_event_loop()
200
+ loop = asyncio.get_running_loop()
174
201
  for event_type in expected_events:
175
202
  future = loop.create_future()
176
203
 
@@ -243,18 +270,31 @@ class CommandHandlerBase:
243
270
  logger.debug(f"Binary request to {dst_bytes.hex()}")
244
271
  data = b"\x32" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + (data if data else b"")
245
272
 
246
- 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
273
+ # Pre-register a placeholder binary request before send() to close the race
274
+ # window where a BINARY_RESPONSE could arrive between send() returning and
275
+ # registration. The placeholder tag is patched to the real tag once MSG_SENT
276
+ # returns. If send() fails, the placeholder is cleaned up.
277
+ placeholder_tag = None
278
+ if self._reader is not None and request_type is not None:
279
+ placeholder_tag = f"_pending_{id(data)}"
280
+ actual_timeout = timeout if timeout is not None and timeout > 0 else self.default_timeout
256
281
  actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout
257
- self._reader.register_binary_request(pubkey_prefix.hex(), 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)
258
298
 
259
299
  return result
260
300
 
@@ -266,6 +306,7 @@ class CommandHandlerBase:
266
306
  contact = self._get_contact_by_prefix(dst_bytes.hex()) # need a contact for return path
267
307
  if contact is None:
268
308
  logger.error("No contact found")
309
+ return Event(EventType.ERROR, {"reason": "contact_not_found"})
269
310
 
270
311
  zero_hop = False
271
312
  if contact["out_path_len"] == -1:
@@ -73,10 +73,6 @@ class BinaryCommandHandler(CommandHandlerBase):
73
73
 
74
74
  return telem_event.payload["lpp"] if telem_event else None
75
75
 
76
- async def req_mma(self, contact, timeout=0, min_timeout=0):
77
- logger.error("*** please consider using req_mma_sync instead of req_mma")
78
- return await self.req_mma_sync(contact, start, end, timeout,min_timeout)
79
-
80
76
  async def req_mma_sync(self, contact, start, end, timeout=0,min_timeout=0):
81
77
  async with self._mesh_request_lock:
82
78
  req = (
@@ -43,13 +43,17 @@ class ContactCommands(CommandHandlerBase):
43
43
  logger.debug("Timeout while getting contacts")
44
44
  for future in pending: # cancel all futures
45
45
  future.cancel()
46
- return 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()}")
@@ -4,6 +4,7 @@ from hashlib import sha256
4
4
  from typing import Optional
5
5
 
6
6
  from ..events import Event, EventType
7
+ from ..packets import CommandType
7
8
  from .base import CommandHandlerBase, DestinationType, _validate_destination
8
9
 
9
10
  logger = logging.getLogger("meshcore")
@@ -13,7 +14,7 @@ class DeviceCommands(CommandHandlerBase):
13
14
  async def send_appstart(self) -> Event:
14
15
  logger.debug("Sending appstart command")
15
16
  b1 = bytearray(b"\x01\x03 mccli")
16
- return await self.send(b1, [EventType.SELF_INFO])
17
+ return await self.send(b1, [EventType.SELF_INFO, EventType.ERROR])
17
18
 
18
19
  async def send_device_query(self) -> Event:
19
20
  logger.debug("Sending device query command")
@@ -129,32 +130,50 @@ class DeviceCommands(CommandHandlerBase):
129
130
  return await self.send(data, [EventType.OK, EventType.ERROR])
130
131
 
131
132
  async def set_telemetry_mode_base(self, telemetry_mode_base: int) -> Event:
132
- infos = (await self.send_appstart()).payload
133
+ result = await self.send_appstart()
134
+ if result.is_error():
135
+ return result
136
+ infos = result.payload
133
137
  infos["telemetry_mode_base"] = telemetry_mode_base
134
138
  return await self.set_other_params_from_infos(infos)
135
139
 
136
140
  async def set_telemetry_mode_loc(self, telemetry_mode_loc: int) -> Event:
137
- infos = (await self.send_appstart()).payload
141
+ result = await self.send_appstart()
142
+ if result.is_error():
143
+ return result
144
+ infos = result.payload
138
145
  infos["telemetry_mode_loc"] = telemetry_mode_loc
139
146
  return await self.set_other_params_from_infos(infos)
140
147
 
141
148
  async def set_telemetry_mode_env(self, telemetry_mode_env: int) -> Event:
142
- infos = (await self.send_appstart()).payload
149
+ result = await self.send_appstart()
150
+ if result.is_error():
151
+ return result
152
+ infos = result.payload
143
153
  infos["telemetry_mode_env"] = telemetry_mode_env
144
154
  return await self.set_other_params_from_infos(infos)
145
155
 
146
156
  async def set_manual_add_contacts(self, manual_add_contacts: bool) -> Event:
147
- infos = (await self.send_appstart()).payload
157
+ result = await self.send_appstart()
158
+ if result.is_error():
159
+ return result
160
+ infos = result.payload
148
161
  infos["manual_add_contacts"] = manual_add_contacts
149
162
  return await self.set_other_params_from_infos(infos)
150
163
 
151
164
  async def set_advert_loc_policy(self, advert_loc_policy: int) -> Event:
152
- infos = (await self.send_appstart()).payload
165
+ result = await self.send_appstart()
166
+ if result.is_error():
167
+ return result
168
+ infos = result.payload
153
169
  infos["adv_loc_policy"] = advert_loc_policy
154
170
  return await self.set_other_params_from_infos(infos)
155
171
 
156
172
  async def set_multi_acks(self, multi_acks: int) -> Event:
157
- infos = (await self.send_appstart()).payload
173
+ result = await self.send_appstart()
174
+ if result.is_error():
175
+ return result
176
+ infos = result.payload
158
177
  infos["multi_acks"] = multi_acks
159
178
  return await self.set_other_params_from_infos(infos)
160
179
 
@@ -273,20 +292,89 @@ class DeviceCommands(CommandHandlerBase):
273
292
 
274
293
  return await self.sign_finish(timeout=timeout, data_size=len(data))
275
294
 
295
+ async def has_connection(self) -> Event:
296
+ """N09: Check if the device has an active connection (CMD 28).
297
+
298
+ Returns:
299
+ Event with a 1-byte response indicating connection status,
300
+ or ERROR.
301
+ """
302
+ logger.debug("Checking device connection status")
303
+ return await self.send(b"\x1c", [EventType.OK, EventType.ERROR])
304
+
305
+ async def get_tuning(self) -> Event:
306
+ """N03/N09: Request current tuning parameters (CMD_GET_TUNING_PARAMS = 43).
307
+
308
+ Firmware responds with RESP_CODE_TUNING_PARAMS (23): 9 bytes containing
309
+ rx_delay (4 bytes LE) and airtime_factor (4 bytes LE).
310
+
311
+ Returns:
312
+ Event of type TUNING_PARAMS with rx_delay and airtime_factor,
313
+ or ERROR.
314
+ """
315
+ logger.debug("Getting tuning parameters")
316
+ return await self.send(b"\x2b", [EventType.TUNING_PARAMS, EventType.ERROR])
317
+
318
+ async def request_factory_reset(self) -> str:
319
+ """N09: Request a factory reset token (step 1 of 2).
320
+
321
+ This method returns a confirmation token string. Pass it to
322
+ ``confirm_factory_reset(token)`` to actually execute the reset.
323
+ The two-step pattern is a Python-side safety measure; the firmware
324
+ itself has no token verification.
325
+
326
+ Returns:
327
+ A confirmation token string to pass to confirm_factory_reset().
328
+ """
329
+ import secrets
330
+ token = secrets.token_hex(8)
331
+ logger.warning(
332
+ "Factory reset requested. Call confirm_factory_reset('%s') to proceed. "
333
+ "This will ERASE ALL DATA on the device.", token
334
+ )
335
+ # Store the token on the instance for validation
336
+ self._factory_reset_token = token
337
+ return token
338
+
339
+ async def confirm_factory_reset(self, token: str) -> Event:
340
+ """N09: Execute factory reset after token confirmation (step 2 of 2).
341
+
342
+ Args:
343
+ token: The token returned by request_factory_reset().
344
+
345
+ Returns:
346
+ Event with OK or ERROR.
347
+
348
+ Raises:
349
+ ValueError: If the token does not match.
350
+ """
351
+ expected = getattr(self, "_factory_reset_token", None)
352
+ if expected is None or token != expected:
353
+ raise ValueError(
354
+ "Invalid or expired factory reset token. "
355
+ "Call request_factory_reset() first."
356
+ )
357
+ self._factory_reset_token = None # Consume the token
358
+ logger.warning("Executing factory reset — all device data will be erased")
359
+ return await self.send(b"\x33", [EventType.OK, EventType.ERROR])
360
+
276
361
  async def get_stats_core(self) -> Event:
277
362
  logger.debug("Getting core statistics")
278
- # CMD_GET_STATS (56) + STATS_TYPE_CORE (0)
279
- return await self.send(b"\x38\x00", [EventType.STATS_CORE, EventType.ERROR])
363
+ # R04: Use CommandType enum instead of literal bytes
364
+ cmd = bytes([CommandType.GET_STATS.value, 0x00]) # GET_STATS + STATS_TYPE_CORE
365
+ return await self.send(cmd, [EventType.STATS_CORE, EventType.ERROR])
280
366
 
281
367
  async def get_stats_radio(self) -> Event:
282
368
  logger.debug("Getting radio statistics")
283
- # CMD_GET_STATS (56) + STATS_TYPE_RADIO (1)
284
- return await self.send(b"\x38\x01", [EventType.STATS_RADIO, EventType.ERROR])
369
+ # R04: Use CommandType enum instead of literal bytes
370
+ cmd = bytes([CommandType.GET_STATS.value, 0x01]) # GET_STATS + STATS_TYPE_RADIO
371
+ return await self.send(cmd, [EventType.STATS_RADIO, EventType.ERROR])
285
372
 
286
373
  async def get_stats_packets(self) -> Event:
287
374
  logger.debug("Getting packet statistics")
288
- # CMD_GET_STATS (56) + STATS_TYPE_PACKETS (2)
289
- return await self.send(b"\x38\x02", [EventType.STATS_PACKETS, EventType.ERROR])
375
+ # R04: Use CommandType enum instead of literal bytes
376
+ cmd = bytes([CommandType.GET_STATS.value, 0x02]) # GET_STATS + STATS_TYPE_PACKETS
377
+ return await self.send(cmd, [EventType.STATS_PACKETS, EventType.ERROR])
290
378
 
291
379
  async def get_allowed_repeat_freq(self) -> Event:
292
380
  logger.debug("Getting allowed repeat freqs")
@@ -144,8 +144,12 @@ class MessagingCommands(CommandHandlerBase):
144
144
  logger.info(f"Retry sending msg: {attempts + 1}")
145
145
 
146
146
  result = await self.send_msg(dst, msg, timestamp, attempt=attempts)
147
- if result.type == EventType.ERROR:
148
- logger.error(f"⚠️ Failed to send message: {result.payload}")
147
+ if result.is_error():
148
+ logger.error(f"Failed to send message: {result.payload}")
149
+ attempts += 1
150
+ if flood:
151
+ flood_attempts += 1
152
+ continue
149
153
 
150
154
  exp_ack = result.payload["expected_ack"].hex()
151
155
  timeout = result.payload["suggested_timeout"] / 1000 * 1.2 if timeout==0 else timeout
@@ -255,7 +259,7 @@ class MessagingCommands(CommandHandlerBase):
255
259
  elif path_hash_len == 8 :
256
260
  flags = 3
257
261
  else :
258
- logger.error(f"Invalid path format: {e}")
262
+ logger.error(f"Invalid path format: unknown path_hash_len {path_hash_len}")
259
263
  return Event(EventType.ERROR, {"reason": "invalid_path_format"})
260
264
  else:
261
265
  flags = 0
@@ -291,12 +295,34 @@ class MessagingCommands(CommandHandlerBase):
291
295
  cmd_data.append(flags)
292
296
  cmd_data.extend(path_bytes)
293
297
 
298
+ # N05: Firmware requires strict len > 10 (MyMesh.cpp:1620).
299
+ # When path is empty, cmd(1)+tag(4)+auth(4)+flags(1) = 10 bytes exactly,
300
+ # which is silently rejected. Pad with one zero byte to reach 11.
301
+ if len(cmd_data) <= 10:
302
+ cmd_data.append(0x00)
303
+
294
304
  logger.debug(
295
305
  f"Sending trace: tag={tag}, auth={auth_code}, flags={flags}, path={path_bytes.hex()}"
296
306
  )
297
307
 
298
308
  return await self.send(cmd_data, [EventType.MSG_SENT, EventType.ERROR])
299
309
 
310
+ async def send_raw_data(self, payload: bytes) -> Event:
311
+ """N09: Send raw data via CMD_SEND_RAW_DATA (25).
312
+
313
+ Sends an arbitrary payload through the mesh network.
314
+
315
+ Args:
316
+ payload: Raw bytes to send.
317
+
318
+ Returns:
319
+ Event with MSG_SENT or ERROR.
320
+ """
321
+ if not isinstance(payload, (bytes, bytearray)):
322
+ raise TypeError("payload must be bytes-like")
323
+ data = b"\x19" + bytes(payload)
324
+ return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
325
+
300
326
  async def set_flood_scope(self, scope):
301
327
  if scope is None:
302
328
  logger.debug(f"Resetting scope")
@@ -313,6 +339,8 @@ class MessagingCommands(CommandHandlerBase):
313
339
  elif isinstance (scope, bytes): # scope has been sent directly as byte
314
340
  logger.debug(f"Directly setting scope to {scope}")
315
341
  scope_key = scope
342
+ else:
343
+ raise TypeError(f"set_flood_scope: unsupported scope type {type(scope).__name__}")
316
344
 
317
345
  logger.debug(f"Setting scope to {scope_key.hex()}")
318
346
 
@@ -321,3 +349,36 @@ class MessagingCommands(CommandHandlerBase):
321
349
  cmd_data.extend(scope_key)
322
350
 
323
351
  return await self.send(cmd_data, [EventType.OK, EventType.ERROR])
352
+
353
+ async def set_default_flood_scope(self, scope):
354
+ if scope is None:
355
+ logger.debug(f"Resetting default scope")
356
+ scope_key = b"\0"*16
357
+ scope_name = ""
358
+ elif isinstance (scope, str):
359
+ if scope == "0" or scope == "None" or scope == "*" or scope == "": # disable
360
+ logger.debug ("Resetting default scope")
361
+ scope_key = b"\0"*16
362
+ scope_name = ""
363
+ else:
364
+ logger.debug (f"Setting scope to {scope}")
365
+ if scope[0] != "#":
366
+ scope = "#" + scope
367
+ scope_name = scope
368
+ scope_key = sha256(scope.encode("utf-8")).digest()[0:16]
369
+ else:
370
+ raise TypeError(f"set_flood_scope: unsupported scope type {type(scope).__name__}")
371
+
372
+ logger.debug(f"Setting scope key to {scope_key.hex()}")
373
+
374
+ cmd_data = bytearray([CommandType.SET_DEFAULT_FLOOD_SCOPE.value])
375
+ cmd_data.extend(scope_name.encode("utf-8"))
376
+ cmd_data.extend((31-len(scope))*b'\0')
377
+ cmd_data.extend(scope_key)
378
+
379
+ return await self.send(cmd_data, [EventType.OK, EventType.ERROR])
380
+
381
+ async def get_default_flood_scope(self):
382
+ logger.debug(f"Getting default flood scope")
383
+ cmd_data = bytearray([CommandType.GET_DEFAULT_FLOOD_SCOPE.value])
384
+ return await self.send(cmd_data, [EventType.DEFAULT_FLOOD_SCOPE, EventType.ERROR])