Skip to content

Commit fda191d

Browse files
authored
Merge branch 'main' into fix/standalone-bugs-and-cleanup
2 parents af88646 + 5032f81 commit fda191d

21 files changed

Lines changed: 2892 additions & 934 deletions

src/meshcore/ble_cx.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ def __init__(self, address=None, device=None, client=None, pin=None):
5151
self.pin = pin
5252
self.rx_char = None
5353
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
5462

5563
async def connect(self):
5664
"""
@@ -116,9 +124,12 @@ def match_meshcore_device(d: BLEDevice, adv: AdvertisementData):
116124
await self.client.pair()
117125
logger.info("BLE pairing successful")
118126
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
122133

123134
except BleakDeviceNotFoundError:
124135
return None
@@ -154,8 +165,19 @@ def handle_disconnect(self, client: BleakClient):
154165
self.client = self._user_provided_client
155166
self.device = self._user_provided_device
156167

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+
157179
if self._disconnect_callback:
158-
asyncio.create_task(self._disconnect_callback("ble_disconnect"))
180+
self._spawn_background(self._disconnect_callback("ble_disconnect"))
159181

160182
def set_disconnect_callback(self, callback):
161183
"""Set callback to handle disconnections."""
@@ -166,16 +188,24 @@ def set_reader(self, reader):
166188

167189
def handle_rx(self, _: BleakGATTCharacteristic, data: bytearray):
168190
if self.reader is not None:
169-
asyncio.create_task(self.reader.handle_rx(data))
191+
self._spawn_background(self.reader.handle_rx(data))
170192

171193
async def send(self, data):
172194
if not self.client:
173195
logger.error("Client is not connected")
196+
if self._disconnect_callback:
197+
await self._disconnect_callback("ble_transport_lost")
174198
return False
175199
if not self.rx_char:
176200
logger.error("RX characteristic not found")
177201
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
179209

180210
async def disconnect(self):
181211
"""Disconnect from the BLE device."""

src/meshcore/commands/base.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,17 +58,32 @@ def _validate_destination(dst: DestinationType, prefix_length: int = 6) -> bytes
5858

5959

6060
class CommandHandlerBase:
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+
6169
DEFAULT_TIMEOUT = 15.0
6270

6371
def __init__(self, default_timeout: Optional[float] = None):
6472
self._sender_func: Optional[Callable[[bytes], Coroutine[Any, Any, None]]] = None
6573
self._reader: Optional[MessageReader] = None
6674
self.dispatcher: Optional[EventDispatcher] = None
67-
self._mesh_request_lock = asyncio.Lock()
75+
self.__mesh_request_lock: Optional[asyncio.Lock] = None
6876
self.default_timeout = (
6977
default_timeout if default_timeout is not None else self.DEFAULT_TIMEOUT
7078
)
7179

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+
7287
def set_connection(self, connection: Any) -> None:
7388
async def sender(data: bytes) -> None:
7489
await connection.send(data)
@@ -90,6 +105,14 @@ async def wait_for_events(
90105
expected_events: Optional[Union[EventType, List[EventType]]] = None,
91106
timeout: Optional[float] = None,
92107
) -> 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+
"""
93116
try:
94117
# Convert single event to list if needed
95118
if not isinstance(expected_events, list):
@@ -129,9 +152,6 @@ async def wait_for_events(
129152
logger.debug(f"Command error: {e}")
130153
return Event(EventType.ERROR, {"error": str(e)})
131154

132-
return Event(EventType.ERROR, {})
133-
134-
135155
async def send(
136156
self,
137157
data: bytes,
@@ -151,7 +171,14 @@ async def send(
151171
timeout: Timeout in seconds, or None to use default_timeout
152172
153173
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``.
155182
"""
156183
if not self.dispatcher:
157184
raise RuntimeError("Dispatcher not set, cannot send commands")
@@ -170,7 +197,7 @@ async def send(
170197
futures: List[asyncio.Future] = []
171198
subscriptions = []
172199

173-
loop = asyncio.get_event_loop()
200+
loop = asyncio.get_running_loop()
174201
for event_type in expected_events:
175202
future = loop.create_future()
176203

@@ -279,6 +306,7 @@ async def send_anon_req(self, dst: DestinationType, request_type: AnonReqType, d
279306
contact = self._get_contact_by_prefix(dst_bytes.hex()) # need a contact for return path
280307
if contact is None:
281308
logger.error("No contact found")
309+
return Event(EventType.ERROR, {"reason": "contact_not_found"})
282310

283311
zero_hop = False
284312
if contact["out_path_len"] == -1:

src/meshcore/commands/contact.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,24 @@ async def get_autoadd_config(self) -> Event:
191191
data = b"\x3B"
192192
return await self.send(data, [EventType.AUTOADD_CONFIG, EventType.ERROR])
193193

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+
194212
async def get_advert_path(self, key: DestinationType) -> Event:
195213
key_bytes = _validate_destination(key, prefix_length=32)
196214
logger.debug(f"getting advert path for: {key} {key_bytes.hex()}")

src/meshcore/commands/device.py

Lines changed: 101 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Optional
55

66
from ..events import Event, EventType
7+
from ..packets import CommandType
78
from .base import CommandHandlerBase, DestinationType, _validate_destination
89

910
logger = logging.getLogger("meshcore")
@@ -13,7 +14,7 @@ class DeviceCommands(CommandHandlerBase):
1314
async def send_appstart(self) -> Event:
1415
logger.debug("Sending appstart command")
1516
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])
1718

1819
async def send_device_query(self) -> Event:
1920
logger.debug("Sending device query command")
@@ -129,32 +130,50 @@ async def set_other_params_from_infos(self, infos) -> Event:
129130
return await self.send(data, [EventType.OK, EventType.ERROR])
130131

131132
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
133137
infos["telemetry_mode_base"] = telemetry_mode_base
134138
return await self.set_other_params_from_infos(infos)
135139

136140
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
138145
infos["telemetry_mode_loc"] = telemetry_mode_loc
139146
return await self.set_other_params_from_infos(infos)
140147

141148
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
143153
infos["telemetry_mode_env"] = telemetry_mode_env
144154
return await self.set_other_params_from_infos(infos)
145155

146156
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
148161
infos["manual_add_contacts"] = manual_add_contacts
149162
return await self.set_other_params_from_infos(infos)
150163

151164
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
153169
infos["adv_loc_policy"] = advert_loc_policy
154170
return await self.set_other_params_from_infos(infos)
155171

156172
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
158177
infos["multi_acks"] = multi_acks
159178
return await self.set_other_params_from_infos(infos)
160179

@@ -273,20 +292,89 @@ async def sign(self, data: bytes, chunk_size: int = 120, timeout: Optional[float
273292

274293
return await self.sign_finish(timeout=timeout, data_size=len(data))
275294

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+
276361
async def get_stats_core(self) -> Event:
277362
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])
280366

281367
async def get_stats_radio(self) -> Event:
282368
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])
285372

286373
async def get_stats_packets(self) -> Event:
287374
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])
290378

291379
async def get_allowed_repeat_freq(self) -> Event:
292380
logger.debug("Getting allowed repeat freqs")

0 commit comments

Comments
 (0)