mirror of
https://github.com/meshcore-dev/meshcore_py.git
synced 2026-07-26 18:08:12 +00:00
Merge branch 'main' into fix/reader-parser-crash-safety
This commit is contained in:
@@ -51,6 +51,14 @@ class BLEConnection:
|
||||
self.pin = pin
|
||||
self.rx_char = None
|
||||
self._disconnect_callback = None
|
||||
self._background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
def _spawn_background(self, coro) -> asyncio.Task:
|
||||
"""Create a tracked background task (prevents GC of fire-and-forget tasks)."""
|
||||
task = asyncio.create_task(coro)
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
return task
|
||||
|
||||
async def connect(self):
|
||||
"""
|
||||
@@ -116,9 +124,12 @@ class BLEConnection:
|
||||
await self.client.pair()
|
||||
logger.info("BLE pairing successful")
|
||||
except Exception as e:
|
||||
logger.warning(f"BLE pairing failed: {e}")
|
||||
# Don't fail the connection if pairing fails, as the device
|
||||
# might already be paired or not require pairing
|
||||
logger.error(f"BLE pairing failed: {e}")
|
||||
# A failed pairing leaves the transport in a half-usable
|
||||
# state — re-raise so the caller gets a clean failure
|
||||
# instead of a silently degraded connection.
|
||||
await self.client.disconnect()
|
||||
raise
|
||||
|
||||
except BleakDeviceNotFoundError:
|
||||
return None
|
||||
@@ -154,8 +165,19 @@ class BLEConnection:
|
||||
self.client = self._user_provided_client
|
||||
self.device = self._user_provided_device
|
||||
|
||||
# Re-register disconnect callback on the reset client so subsequent
|
||||
# disconnects after a reconnect cycle are still detected.
|
||||
if self.client is not None and hasattr(self.client, 'set_disconnected_callback'):
|
||||
try:
|
||||
self.client.set_disconnected_callback(self.handle_disconnect)
|
||||
except Exception:
|
||||
# set_disconnected_callback may not be available on all bleak
|
||||
# versions; the next connect() call will re-create the client
|
||||
# with the callback anyway.
|
||||
pass
|
||||
|
||||
if self._disconnect_callback:
|
||||
asyncio.create_task(self._disconnect_callback("ble_disconnect"))
|
||||
self._spawn_background(self._disconnect_callback("ble_disconnect"))
|
||||
|
||||
def set_disconnect_callback(self, callback):
|
||||
"""Set callback to handle disconnections."""
|
||||
@@ -166,16 +188,24 @@ class BLEConnection:
|
||||
|
||||
def handle_rx(self, _: BleakGATTCharacteristic, data: bytearray):
|
||||
if self.reader is not None:
|
||||
asyncio.create_task(self.reader.handle_rx(data))
|
||||
self._spawn_background(self.reader.handle_rx(data))
|
||||
|
||||
async def send(self, data):
|
||||
if not self.client:
|
||||
logger.error("Client is not connected")
|
||||
if self._disconnect_callback:
|
||||
await self._disconnect_callback("ble_transport_lost")
|
||||
return False
|
||||
if not self.rx_char:
|
||||
logger.error("RX characteristic not found")
|
||||
return False
|
||||
await self.client.write_gatt_char(self.rx_char, bytes(data), response=True)
|
||||
try:
|
||||
await self.client.write_gatt_char(self.rx_char, bytes(data), response=True)
|
||||
except Exception as exc:
|
||||
logger.warning(f"BLE write failed: {exc}")
|
||||
if self._disconnect_callback:
|
||||
await self._disconnect_callback(f"ble_write_failed: {exc}")
|
||||
return False
|
||||
|
||||
async def disconnect(self):
|
||||
"""Disconnect from the BLE device."""
|
||||
|
||||
@@ -58,17 +58,32 @@ def _validate_destination(dst: DestinationType, prefix_length: int = 6) -> bytes
|
||||
|
||||
|
||||
class CommandHandlerBase:
|
||||
"""Base class for command handlers.
|
||||
|
||||
.. note::
|
||||
The internal ``asyncio.Lock`` is created lazily on first access
|
||||
so that it binds to the correct running event loop (required for
|
||||
Python 3.9/3.10 compatibility).
|
||||
"""
|
||||
|
||||
DEFAULT_TIMEOUT = 5.0
|
||||
|
||||
def __init__(self, default_timeout: Optional[float] = None):
|
||||
self._sender_func: Optional[Callable[[bytes], Coroutine[Any, Any, None]]] = None
|
||||
self._reader: Optional[MessageReader] = None
|
||||
self.dispatcher: Optional[EventDispatcher] = None
|
||||
self._mesh_request_lock = asyncio.Lock()
|
||||
self.__mesh_request_lock: Optional[asyncio.Lock] = None
|
||||
self.default_timeout = (
|
||||
default_timeout if default_timeout is not None else self.DEFAULT_TIMEOUT
|
||||
)
|
||||
|
||||
@property
|
||||
def _mesh_request_lock(self) -> asyncio.Lock:
|
||||
"""Lazy-init lock so it binds to the running loop, not import-time."""
|
||||
if self.__mesh_request_lock is None:
|
||||
self.__mesh_request_lock = asyncio.Lock()
|
||||
return self.__mesh_request_lock
|
||||
|
||||
def set_connection(self, connection: Any) -> None:
|
||||
async def sender(data: bytes) -> None:
|
||||
await connection.send(data)
|
||||
@@ -90,6 +105,14 @@ class CommandHandlerBase:
|
||||
expected_events: Optional[Union[EventType, List[EventType]]] = None,
|
||||
timeout: Optional[float] = None,
|
||||
) -> Event:
|
||||
"""Wait for the first of *expected_events* to arrive.
|
||||
|
||||
Returns the first matched ``Event``. When ``EventType.ERROR`` is
|
||||
among the expected types, the caller **must** check
|
||||
``result.is_error()`` before accessing command-specific payload
|
||||
keys — an ERROR payload is ``{"reason": "..."}`` and will
|
||||
``KeyError`` on any other key.
|
||||
"""
|
||||
try:
|
||||
# Convert single event to list if needed
|
||||
if not isinstance(expected_events, list):
|
||||
@@ -129,9 +152,6 @@ class CommandHandlerBase:
|
||||
logger.debug(f"Command error: {e}")
|
||||
return Event(EventType.ERROR, {"error": str(e)})
|
||||
|
||||
return Event(EventType.ERROR, {})
|
||||
|
||||
|
||||
async def send(
|
||||
self,
|
||||
data: bytes,
|
||||
@@ -151,7 +171,14 @@ class CommandHandlerBase:
|
||||
timeout: Timeout in seconds, or None to use default_timeout
|
||||
|
||||
Returns:
|
||||
Event: The full event object that was received in response to the command
|
||||
Event: The full event object that was received in response to
|
||||
the command.
|
||||
|
||||
Important:
|
||||
When ``EventType.ERROR`` is included in *expected_events*, the
|
||||
returned event may be an error response. Callers **must**
|
||||
check ``result.is_error()`` before accessing command-specific
|
||||
payload keys to avoid ``KeyError``.
|
||||
"""
|
||||
if not self.dispatcher:
|
||||
raise RuntimeError("Dispatcher not set, cannot send commands")
|
||||
@@ -170,7 +197,7 @@ class CommandHandlerBase:
|
||||
futures: List[asyncio.Future] = []
|
||||
subscriptions = []
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
for event_type in expected_events:
|
||||
future = loop.create_future()
|
||||
|
||||
@@ -266,6 +293,7 @@ class CommandHandlerBase:
|
||||
contact = self._get_contact_by_prefix(dst_bytes.hex()) # need a contact for return path
|
||||
if contact is None:
|
||||
logger.error("No contact found")
|
||||
return Event(EventType.ERROR, {"reason": "contact_not_found"})
|
||||
|
||||
zero_hop = False
|
||||
if contact["out_path_len"] == -1:
|
||||
|
||||
@@ -185,6 +185,24 @@ class ContactCommands(CommandHandlerBase):
|
||||
data = b"\x3B"
|
||||
return await self.send(data, [EventType.AUTOADD_CONFIG, EventType.ERROR])
|
||||
|
||||
async def get_contact_by_key(self, pubkey: bytes) -> Event:
|
||||
"""N09: Retrieve a single contact by its public key (CMD 30).
|
||||
|
||||
Args:
|
||||
pubkey: 32-byte public key of the contact.
|
||||
|
||||
Returns:
|
||||
Event with the contact data (same format as CONTACT/NEXT_CONTACT),
|
||||
or ERROR if not found.
|
||||
"""
|
||||
if not isinstance(pubkey, (bytes, bytearray)):
|
||||
raise TypeError("pubkey must be bytes-like")
|
||||
# Truncate or pad to 32 bytes
|
||||
key_bytes = bytes(pubkey[:32])
|
||||
logger.debug(f"Getting contact by key: {key_bytes.hex()}")
|
||||
data = b"\x1e" + key_bytes
|
||||
return await self.send(data, [EventType.NEXT_CONTACT, EventType.ERROR])
|
||||
|
||||
async def get_advert_path(self, key: DestinationType) -> Event:
|
||||
key_bytes = _validate_destination(key, prefix_length=32)
|
||||
logger.debug(f"getting advert path for: {key} {key_bytes.hex()}")
|
||||
|
||||
@@ -4,6 +4,7 @@ from hashlib import sha256
|
||||
from typing import Optional
|
||||
|
||||
from ..events import Event, EventType
|
||||
from ..packets import CommandType
|
||||
from .base import CommandHandlerBase, DestinationType, _validate_destination
|
||||
|
||||
logger = logging.getLogger("meshcore")
|
||||
@@ -13,7 +14,7 @@ class DeviceCommands(CommandHandlerBase):
|
||||
async def send_appstart(self) -> Event:
|
||||
logger.debug("Sending appstart command")
|
||||
b1 = bytearray(b"\x01\x03 mccli")
|
||||
return await self.send(b1, [EventType.SELF_INFO])
|
||||
return await self.send(b1, [EventType.SELF_INFO, EventType.ERROR])
|
||||
|
||||
async def send_device_query(self) -> Event:
|
||||
logger.debug("Sending device query command")
|
||||
@@ -129,32 +130,50 @@ class DeviceCommands(CommandHandlerBase):
|
||||
return await self.send(data, [EventType.OK, EventType.ERROR])
|
||||
|
||||
async def set_telemetry_mode_base(self, telemetry_mode_base: int) -> Event:
|
||||
infos = (await self.send_appstart()).payload
|
||||
result = await self.send_appstart()
|
||||
if result.is_error():
|
||||
return result
|
||||
infos = result.payload
|
||||
infos["telemetry_mode_base"] = telemetry_mode_base
|
||||
return await self.set_other_params_from_infos(infos)
|
||||
|
||||
async def set_telemetry_mode_loc(self, telemetry_mode_loc: int) -> Event:
|
||||
infos = (await self.send_appstart()).payload
|
||||
result = await self.send_appstart()
|
||||
if result.is_error():
|
||||
return result
|
||||
infos = result.payload
|
||||
infos["telemetry_mode_loc"] = telemetry_mode_loc
|
||||
return await self.set_other_params_from_infos(infos)
|
||||
|
||||
async def set_telemetry_mode_env(self, telemetry_mode_env: int) -> Event:
|
||||
infos = (await self.send_appstart()).payload
|
||||
result = await self.send_appstart()
|
||||
if result.is_error():
|
||||
return result
|
||||
infos = result.payload
|
||||
infos["telemetry_mode_env"] = telemetry_mode_env
|
||||
return await self.set_other_params_from_infos(infos)
|
||||
|
||||
async def set_manual_add_contacts(self, manual_add_contacts: bool) -> Event:
|
||||
infos = (await self.send_appstart()).payload
|
||||
result = await self.send_appstart()
|
||||
if result.is_error():
|
||||
return result
|
||||
infos = result.payload
|
||||
infos["manual_add_contacts"] = manual_add_contacts
|
||||
return await self.set_other_params_from_infos(infos)
|
||||
|
||||
async def set_advert_loc_policy(self, advert_loc_policy: int) -> Event:
|
||||
infos = (await self.send_appstart()).payload
|
||||
result = await self.send_appstart()
|
||||
if result.is_error():
|
||||
return result
|
||||
infos = result.payload
|
||||
infos["adv_loc_policy"] = advert_loc_policy
|
||||
return await self.set_other_params_from_infos(infos)
|
||||
|
||||
async def set_multi_acks(self, multi_acks: int) -> Event:
|
||||
infos = (await self.send_appstart()).payload
|
||||
result = await self.send_appstart()
|
||||
if result.is_error():
|
||||
return result
|
||||
infos = result.payload
|
||||
infos["multi_acks"] = multi_acks
|
||||
return await self.set_other_params_from_infos(infos)
|
||||
|
||||
@@ -273,20 +292,89 @@ class DeviceCommands(CommandHandlerBase):
|
||||
|
||||
return await self.sign_finish(timeout=timeout, data_size=len(data))
|
||||
|
||||
async def has_connection(self) -> Event:
|
||||
"""N09: Check if the device has an active connection (CMD 28).
|
||||
|
||||
Returns:
|
||||
Event with a 1-byte response indicating connection status,
|
||||
or ERROR.
|
||||
"""
|
||||
logger.debug("Checking device connection status")
|
||||
return await self.send(b"\x1c", [EventType.OK, EventType.ERROR])
|
||||
|
||||
async def get_tuning(self) -> Event:
|
||||
"""N03/N09: Request current tuning parameters (CMD_GET_TUNING_PARAMS = 43).
|
||||
|
||||
Firmware responds with RESP_CODE_TUNING_PARAMS (23): 9 bytes containing
|
||||
rx_delay (4 bytes LE) and airtime_factor (4 bytes LE).
|
||||
|
||||
Returns:
|
||||
Event of type TUNING_PARAMS with rx_delay and airtime_factor,
|
||||
or ERROR.
|
||||
"""
|
||||
logger.debug("Getting tuning parameters")
|
||||
return await self.send(b"\x2b", [EventType.TUNING_PARAMS, EventType.ERROR])
|
||||
|
||||
async def request_factory_reset(self) -> str:
|
||||
"""N09: Request a factory reset token (step 1 of 2).
|
||||
|
||||
This method returns a confirmation token string. Pass it to
|
||||
``confirm_factory_reset(token)`` to actually execute the reset.
|
||||
The two-step pattern is a Python-side safety measure; the firmware
|
||||
itself has no token verification.
|
||||
|
||||
Returns:
|
||||
A confirmation token string to pass to confirm_factory_reset().
|
||||
"""
|
||||
import secrets
|
||||
token = secrets.token_hex(8)
|
||||
logger.warning(
|
||||
"Factory reset requested. Call confirm_factory_reset('%s') to proceed. "
|
||||
"This will ERASE ALL DATA on the device.", token
|
||||
)
|
||||
# Store the token on the instance for validation
|
||||
self._factory_reset_token = token
|
||||
return token
|
||||
|
||||
async def confirm_factory_reset(self, token: str) -> Event:
|
||||
"""N09: Execute factory reset after token confirmation (step 2 of 2).
|
||||
|
||||
Args:
|
||||
token: The token returned by request_factory_reset().
|
||||
|
||||
Returns:
|
||||
Event with OK or ERROR.
|
||||
|
||||
Raises:
|
||||
ValueError: If the token does not match.
|
||||
"""
|
||||
expected = getattr(self, "_factory_reset_token", None)
|
||||
if expected is None or token != expected:
|
||||
raise ValueError(
|
||||
"Invalid or expired factory reset token. "
|
||||
"Call request_factory_reset() first."
|
||||
)
|
||||
self._factory_reset_token = None # Consume the token
|
||||
logger.warning("Executing factory reset — all device data will be erased")
|
||||
return await self.send(b"\x33", [EventType.OK, EventType.ERROR])
|
||||
|
||||
async def get_stats_core(self) -> Event:
|
||||
logger.debug("Getting core statistics")
|
||||
# CMD_GET_STATS (56) + STATS_TYPE_CORE (0)
|
||||
return await self.send(b"\x38\x00", [EventType.STATS_CORE, EventType.ERROR])
|
||||
# R04: Use CommandType enum instead of literal bytes
|
||||
cmd = bytes([CommandType.GET_STATS.value, 0x00]) # GET_STATS + STATS_TYPE_CORE
|
||||
return await self.send(cmd, [EventType.STATS_CORE, EventType.ERROR])
|
||||
|
||||
async def get_stats_radio(self) -> Event:
|
||||
logger.debug("Getting radio statistics")
|
||||
# CMD_GET_STATS (56) + STATS_TYPE_RADIO (1)
|
||||
return await self.send(b"\x38\x01", [EventType.STATS_RADIO, EventType.ERROR])
|
||||
# R04: Use CommandType enum instead of literal bytes
|
||||
cmd = bytes([CommandType.GET_STATS.value, 0x01]) # GET_STATS + STATS_TYPE_RADIO
|
||||
return await self.send(cmd, [EventType.STATS_RADIO, EventType.ERROR])
|
||||
|
||||
async def get_stats_packets(self) -> Event:
|
||||
logger.debug("Getting packet statistics")
|
||||
# CMD_GET_STATS (56) + STATS_TYPE_PACKETS (2)
|
||||
return await self.send(b"\x38\x02", [EventType.STATS_PACKETS, EventType.ERROR])
|
||||
# R04: Use CommandType enum instead of literal bytes
|
||||
cmd = bytes([CommandType.GET_STATS.value, 0x02]) # GET_STATS + STATS_TYPE_PACKETS
|
||||
return await self.send(cmd, [EventType.STATS_PACKETS, EventType.ERROR])
|
||||
|
||||
async def get_allowed_repeat_freq(self) -> Event:
|
||||
logger.debug("Getting allowed repeat freqs")
|
||||
|
||||
@@ -144,8 +144,12 @@ class MessagingCommands(CommandHandlerBase):
|
||||
logger.info(f"Retry sending msg: {attempts + 1}")
|
||||
|
||||
result = await self.send_msg(dst, msg, timestamp, attempt=attempts)
|
||||
if result.type == EventType.ERROR:
|
||||
logger.error(f"⚠️ Failed to send message: {result.payload}")
|
||||
if result.is_error():
|
||||
logger.error(f"Failed to send message: {result.payload}")
|
||||
attempts += 1
|
||||
if flood:
|
||||
flood_attempts += 1
|
||||
continue
|
||||
|
||||
exp_ack = result.payload["expected_ack"].hex()
|
||||
timeout = result.payload["suggested_timeout"] / 1000 * 1.2 if timeout==0 else timeout
|
||||
@@ -255,7 +259,7 @@ class MessagingCommands(CommandHandlerBase):
|
||||
elif path_hash_len == 8 :
|
||||
flags = 3
|
||||
else :
|
||||
logger.error(f"Invalid path format: {e}")
|
||||
logger.error(f"Invalid path format: unknown path_hash_len {path_hash_len}")
|
||||
return Event(EventType.ERROR, {"reason": "invalid_path_format"})
|
||||
else:
|
||||
flags = 0
|
||||
@@ -291,12 +295,34 @@ class MessagingCommands(CommandHandlerBase):
|
||||
cmd_data.append(flags)
|
||||
cmd_data.extend(path_bytes)
|
||||
|
||||
# N05: Firmware requires strict len > 10 (MyMesh.cpp:1620).
|
||||
# When path is empty, cmd(1)+tag(4)+auth(4)+flags(1) = 10 bytes exactly,
|
||||
# which is silently rejected. Pad with one zero byte to reach 11.
|
||||
if len(cmd_data) <= 10:
|
||||
cmd_data.append(0x00)
|
||||
|
||||
logger.debug(
|
||||
f"Sending trace: tag={tag}, auth={auth_code}, flags={flags}, path={path_bytes.hex()}"
|
||||
)
|
||||
|
||||
return await self.send(cmd_data, [EventType.MSG_SENT, EventType.ERROR])
|
||||
|
||||
async def send_raw_data(self, payload: bytes) -> Event:
|
||||
"""N09: Send raw data via CMD_SEND_RAW_DATA (25).
|
||||
|
||||
Sends an arbitrary payload through the mesh network.
|
||||
|
||||
Args:
|
||||
payload: Raw bytes to send.
|
||||
|
||||
Returns:
|
||||
Event with MSG_SENT or ERROR.
|
||||
"""
|
||||
if not isinstance(payload, (bytes, bytearray)):
|
||||
raise TypeError("payload must be bytes-like")
|
||||
data = b"\x19" + bytes(payload)
|
||||
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
|
||||
|
||||
async def set_flood_scope(self, scope):
|
||||
if scope is None:
|
||||
logger.debug(f"Resetting scope")
|
||||
|
||||
@@ -4,14 +4,23 @@ Connection manager that orchestrates reconnection logic for any connection type.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, Any, Callable, Protocol
|
||||
from typing import Optional, Any, Awaitable, Callable, Protocol
|
||||
from .events import Event, EventType
|
||||
|
||||
logger = logging.getLogger("meshcore")
|
||||
|
||||
|
||||
class ConnectionProtocol(Protocol):
|
||||
"""Protocol defining the interface that connection classes must implement."""
|
||||
"""Protocol defining the interface that connection classes must implement.
|
||||
|
||||
Return contract for connect():
|
||||
- On success: return a truthy value (typically an address string)
|
||||
that identifies the connection. This value is included in the
|
||||
CONNECTED event payload as ``connection_info``.
|
||||
- On failure: return ``None`` (soft failure — triggers a retry in
|
||||
``_attempt_reconnect``) **or** raise an exception (hard failure —
|
||||
also triggers a retry, logged as an error).
|
||||
"""
|
||||
|
||||
async def connect(self) -> Optional[Any]:
|
||||
"""Connect and return connection info, or None if failed."""
|
||||
@@ -39,11 +48,13 @@ class ConnectionManager:
|
||||
event_dispatcher=None,
|
||||
auto_reconnect: bool = False,
|
||||
max_reconnect_attempts: int = 3,
|
||||
reconnect_callback: Optional[Callable[[], Awaitable[None]]] = None,
|
||||
):
|
||||
self.connection = connection
|
||||
self.event_dispatcher = event_dispatcher
|
||||
self.auto_reconnect = auto_reconnect
|
||||
self.max_reconnect_attempts = max_reconnect_attempts
|
||||
self._reconnect_callback = reconnect_callback
|
||||
|
||||
self._reconnect_attempts = 0
|
||||
self._is_connected = False
|
||||
@@ -109,45 +120,51 @@ class ConnectionManager:
|
||||
)
|
||||
|
||||
async def _attempt_reconnect(self):
|
||||
"""Attempt to reconnect with flat delay."""
|
||||
logger.debug(
|
||||
f"Attempting reconnection ({self._reconnect_attempts + 1}/{self.max_reconnect_attempts})"
|
||||
)
|
||||
self._reconnect_attempts += 1
|
||||
"""Attempt to reconnect using an iterative loop.
|
||||
|
||||
# Flat 1 second delay for all attempts
|
||||
await asyncio.sleep(1)
|
||||
Runs as a single persistent task for the entire reconnect session.
|
||||
Previous implementation used tail-recursion via create_task which
|
||||
orphaned the running task reference — disconnect() could only cancel
|
||||
the newest pointer, leaving earlier attempts in flight (F03).
|
||||
"""
|
||||
while self._reconnect_attempts < self.max_reconnect_attempts:
|
||||
self._reconnect_attempts += 1
|
||||
logger.debug(
|
||||
f"Attempting reconnection ({self._reconnect_attempts}/{self.max_reconnect_attempts})"
|
||||
)
|
||||
|
||||
# Flat 1 second delay for all attempts
|
||||
await asyncio.sleep(1)
|
||||
|
||||
try:
|
||||
result = await self.connection.connect()
|
||||
if result is not None:
|
||||
self._is_connected = True
|
||||
self._reconnect_attempts = 0
|
||||
|
||||
# Invoke reconnect callback (e.g. send_appstart) if provided
|
||||
if self._reconnect_callback is not None:
|
||||
try:
|
||||
await self._reconnect_callback()
|
||||
except Exception as cb_err:
|
||||
logger.warning(
|
||||
f"Reconnect callback failed: {cb_err}"
|
||||
)
|
||||
|
||||
try:
|
||||
result = await self.connection.connect()
|
||||
if result is not None:
|
||||
self._is_connected = True
|
||||
self._reconnect_attempts = 0
|
||||
await self._emit_event(
|
||||
EventType.CONNECTED,
|
||||
{"connection_info": result, "reconnected": True},
|
||||
)
|
||||
logger.debug("Reconnected successfully")
|
||||
else:
|
||||
# Reconnection failed, try again if we haven't exceeded max attempts
|
||||
if self._reconnect_attempts < self.max_reconnect_attempts:
|
||||
self._reconnect_task = asyncio.create_task(
|
||||
self._attempt_reconnect()
|
||||
)
|
||||
else:
|
||||
await self._emit_event(
|
||||
EventType.DISCONNECTED,
|
||||
{"reason": "reconnect_failed", "max_attempts_exceeded": True},
|
||||
EventType.CONNECTED,
|
||||
{"connection_info": result, "reconnected": True},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Reconnection attempt failed: {e}")
|
||||
if self._reconnect_attempts < self.max_reconnect_attempts:
|
||||
self._reconnect_task = asyncio.create_task(self._attempt_reconnect())
|
||||
else:
|
||||
await self._emit_event(
|
||||
EventType.DISCONNECTED,
|
||||
{"reason": f"reconnect_error: {e}", "max_attempts_exceeded": True},
|
||||
)
|
||||
logger.debug("Reconnected successfully")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"Reconnection attempt failed: {e}")
|
||||
|
||||
# All attempts exhausted
|
||||
await self._emit_event(
|
||||
EventType.DISCONNECTED,
|
||||
{"reason": "reconnect_failed", "max_attempts_exceeded": True},
|
||||
)
|
||||
|
||||
async def _emit_event(self, event_type: EventType, payload: dict):
|
||||
"""Emit connection events if dispatcher is available."""
|
||||
|
||||
@@ -49,6 +49,9 @@ class EventType(Enum):
|
||||
PATH_RESPONSE = "path_response"
|
||||
PRIVATE_KEY = "private_key"
|
||||
DISABLED = "disabled"
|
||||
CONTACT_DELETED = "contact_deleted"
|
||||
CONTACTS_FULL = "contacts_full"
|
||||
TUNING_PARAMS = "tuning_params"
|
||||
CONTROL_DATA = "control_data"
|
||||
DISCOVER_RESPONSE = "discover_response"
|
||||
NEIGHBOURS_RESPONSE = "neighbours_response"
|
||||
@@ -104,6 +107,17 @@ class Event:
|
||||
if kwargs:
|
||||
self.attributes.update(kwargs)
|
||||
|
||||
def is_error(self) -> bool:
|
||||
"""Return True if this event represents an error response.
|
||||
|
||||
Callers that include ``EventType.ERROR`` in their expected-events
|
||||
list **must** check ``result.is_error()`` (or ``result.type ==
|
||||
EventType.ERROR``) before accessing keyed payload fields, because
|
||||
an ERROR payload contains ``{"reason": "..."}`` — not the
|
||||
command-specific keys the caller expects on the happy path.
|
||||
"""
|
||||
return self.type == EventType.ERROR
|
||||
|
||||
def clone(self):
|
||||
"""
|
||||
Create a copy of the event.
|
||||
@@ -129,11 +143,28 @@ class Subscription:
|
||||
|
||||
|
||||
class EventDispatcher:
|
||||
"""Event dispatch engine.
|
||||
|
||||
.. note::
|
||||
``start()`` must be called before dispatching or processing events.
|
||||
The internal ``asyncio.Queue`` is created lazily inside ``start()``
|
||||
so that it binds to the correct running event loop (required for
|
||||
Python 3.9/3.10 compatibility).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.queue: asyncio.Queue[Event] = asyncio.Queue()
|
||||
self.queue: Optional[asyncio.Queue[Event]] = None
|
||||
self.subscriptions: List[Subscription] = []
|
||||
self.running = False
|
||||
self._task = None
|
||||
self._background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
def _spawn_background(self, coro) -> asyncio.Task:
|
||||
"""Create a tracked background task (prevents GC of fire-and-forget tasks)."""
|
||||
task = asyncio.create_task(coro)
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
return task
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
@@ -166,6 +197,10 @@ class EventDispatcher:
|
||||
self.subscriptions.remove(subscription)
|
||||
|
||||
async def dispatch(self, event: Event):
|
||||
if self.queue is None:
|
||||
raise RuntimeError(
|
||||
"EventDispatcher.start() must be called before dispatching events"
|
||||
)
|
||||
await self.queue.put(event)
|
||||
|
||||
async def _process_events(self):
|
||||
@@ -197,7 +232,7 @@ class EventDispatcher:
|
||||
# returns - avoids the race where create_task schedules the callback after
|
||||
# the waiter has already timed out with done=set().
|
||||
if asyncio.iscoroutinefunction(subscription.callback):
|
||||
asyncio.create_task(self._execute_callback(subscription.callback, event.clone()))
|
||||
self._spawn_background(self._execute_callback(subscription.callback, event.clone()))
|
||||
else:
|
||||
try:
|
||||
subscription.callback(event.clone())
|
||||
@@ -220,6 +255,8 @@ class EventDispatcher:
|
||||
|
||||
async def start(self):
|
||||
if not self.running:
|
||||
if self.queue is None:
|
||||
self.queue = asyncio.Queue()
|
||||
self.running = True
|
||||
self._task = asyncio.create_task(self._process_events())
|
||||
|
||||
@@ -227,7 +264,12 @@ class EventDispatcher:
|
||||
if self.running:
|
||||
self.running = False
|
||||
if self._task:
|
||||
await self.queue.join()
|
||||
if self.queue is not None:
|
||||
await self.queue.join()
|
||||
# Wait for any in-flight async callbacks to complete before
|
||||
# tearing down (F07: task_done fires before callbacks finish).
|
||||
if self._background_tasks:
|
||||
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
|
||||
@@ -28,10 +28,17 @@ class MeshCore:
|
||||
auto_reconnect: bool = False,
|
||||
max_reconnect_attempts: int = 3,
|
||||
):
|
||||
# Wrap connection with ConnectionManager
|
||||
# Wrap connection with ConnectionManager.
|
||||
# The reconnect callback ensures send_appstart() runs after every
|
||||
# transport-level reconnect, which is required by firmware to
|
||||
# initialize the session (F02).
|
||||
self.dispatcher = EventDispatcher()
|
||||
self.connection_manager = ConnectionManager(
|
||||
cx, self.dispatcher, auto_reconnect, max_reconnect_attempts
|
||||
cx,
|
||||
self.dispatcher,
|
||||
auto_reconnect,
|
||||
max_reconnect_attempts,
|
||||
reconnect_callback=self._on_reconnect,
|
||||
)
|
||||
self.cx = self.connection_manager # For backward compatibility
|
||||
|
||||
@@ -174,6 +181,15 @@ class MeshCore:
|
||||
return None
|
||||
return mc
|
||||
|
||||
async def _on_reconnect(self):
|
||||
"""Callback invoked by ConnectionManager after a successful reconnect.
|
||||
|
||||
Firmware requires CMD_APP_START after every transport-level connection
|
||||
to initialize the session. MeshCore.connect() does this on the initial
|
||||
connection; this callback ensures it also happens on reconnects (F02).
|
||||
"""
|
||||
await self.commands.send_appstart()
|
||||
|
||||
async def connect(self):
|
||||
await self.dispatcher.start()
|
||||
result = await self.connection_manager.connect()
|
||||
|
||||
@@ -71,6 +71,7 @@ class CommandType(Enum):
|
||||
SET_AUTOADD_CONFIG = 58
|
||||
GET_AUTOADD_CONFIG = 59
|
||||
GET_ALLOWED_REPEAT_FREQ = 60
|
||||
GET_STATS = 56 # R04: CMD_GET_STATS — used by get_stats_core/radio/packets
|
||||
SET_PATH_HASH_MODE = 61
|
||||
|
||||
# Packet prefixes for the protocol
|
||||
@@ -120,3 +121,6 @@ class PacketType(Enum):
|
||||
PATH_DISCOVERY_RESPONSE = 0x8D
|
||||
CONTROL_DATA = 0x8E
|
||||
CONTACT_DELETED = 0x8F
|
||||
CONTACTS_FULL = 0x90 # N02: MyMesh::onContactsFull() — 1-byte push, no payload
|
||||
# Note: 0x90 == ControlType.NODE_DISCOVER_RESP in a different namespace.
|
||||
# Not a literal conflict (PacketType vs ControlType), but a maintenance hazard.
|
||||
|
||||
@@ -552,9 +552,9 @@ class MessageReader:
|
||||
perms = dbuf.read(1)[0]
|
||||
res["permissions"] = perms
|
||||
res["is_admin"] = (perms & 1) == 1 # Check if admin bit is set
|
||||
|
||||
if len(data) > 7:
|
||||
res["pubkey_prefix"] = dbuf.read(6).hex()
|
||||
|
||||
|
||||
attributes = {"pubkey_prefix": res.get("pubkey_prefix")}
|
||||
|
||||
await self.dispatcher.dispatch(
|
||||
@@ -942,6 +942,36 @@ class MessageReader:
|
||||
await self.dispatcher.dispatch(
|
||||
Event(EventType.DISCOVER_RESPONSE, ndr, attributes)
|
||||
)
|
||||
elif packet_type_value == PacketType.CONTACT_DELETED.value:
|
||||
# N01: PUSH_CODE_CONTACT_DELETED (0x8F) — 1-byte code + 32-byte pubkey
|
||||
# Emitted by MyMesh::onContactOverwrite() (MyMesh.cpp:325-334)
|
||||
if len(data) < 33:
|
||||
logger.debug("CONTACT_DELETED frame too short (%d bytes, need 33)", len(data))
|
||||
return
|
||||
pubkey = data[1:33].hex()
|
||||
await self.dispatcher.dispatch(
|
||||
Event(EventType.CONTACT_DELETED, {"pubkey": pubkey}, {"pubkey": pubkey})
|
||||
)
|
||||
|
||||
elif packet_type_value == PacketType.CONTACTS_FULL.value:
|
||||
# N02: PUSH_CODE_CONTACTS_FULL (0x90) — 1-byte push, no payload
|
||||
# Emitted by MyMesh::onContactsFull() (MyMesh.cpp:336)
|
||||
await self.dispatcher.dispatch(Event(EventType.CONTACTS_FULL, {}))
|
||||
|
||||
elif packet_type_value == PacketType.TUNING_PARAMS.value:
|
||||
# N03: RESP_CODE_TUNING_PARAMS (23) — response to CMD_GET_TUNING_PARAMS (43)
|
||||
# Format: 1-byte code + 4-byte rx_delay (LE) + 4-byte airtime_factor (LE) = 9 bytes
|
||||
# Emitted by MyMesh.cpp:1307-1313
|
||||
if len(data) < 9:
|
||||
logger.debug("TUNING_PARAMS frame too short (%d bytes, need 9)", len(data))
|
||||
await self.dispatcher.dispatch(
|
||||
Event(EventType.ERROR, {"reason": "invalid_frame_length"})
|
||||
)
|
||||
return
|
||||
rx_delay = int.from_bytes(data[1:5], byteorder="little")
|
||||
airtime_factor = int.from_bytes(data[5:9], byteorder="little")
|
||||
res = {"rx_delay": rx_delay, "airtime_factor": airtime_factor}
|
||||
await self.dispatcher.dispatch(Event(EventType.TUNING_PARAMS, res))
|
||||
|
||||
else:
|
||||
logger.debug(f"Unhandled data received {data}")
|
||||
@@ -953,4 +983,4 @@ class MessageReader:
|
||||
e,
|
||||
data.hex(),
|
||||
traceback.format_exc(),
|
||||
)
|
||||
)
|
||||
@@ -20,11 +20,19 @@ class SerialConnection:
|
||||
self._disconnect_callback = None
|
||||
self.cx_dly = cx_dly
|
||||
self._connected_event = asyncio.Event()
|
||||
self._background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
self.frame_expected_size = 0
|
||||
self.inframe = b""
|
||||
self.header = b""
|
||||
|
||||
def _spawn_background(self, coro) -> asyncio.Task:
|
||||
"""Create a tracked background task (prevents GC of fire-and-forget tasks)."""
|
||||
task = asyncio.create_task(coro)
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
return task
|
||||
|
||||
class MCSerialClientProtocol(asyncio.Protocol):
|
||||
def __init__(self, cx):
|
||||
self.cx = cx
|
||||
@@ -44,7 +52,7 @@ class SerialConnection:
|
||||
self.cx._connected_event.clear()
|
||||
|
||||
if self.cx._disconnect_callback:
|
||||
asyncio.create_task(self.cx._disconnect_callback("serial_disconnect"))
|
||||
self.cx._spawn_background(self.cx._disconnect_callback("serial_disconnect"))
|
||||
|
||||
def pause_writing(self):
|
||||
logger.debug("pause writing")
|
||||
@@ -52,12 +60,16 @@ class SerialConnection:
|
||||
def resume_writing(self):
|
||||
logger.debug("resume writing")
|
||||
|
||||
async def connect(self):
|
||||
async def connect(self, timeout: float = 10.0):
|
||||
"""
|
||||
Connects to the device
|
||||
Connects to the device.
|
||||
|
||||
Args:
|
||||
timeout: Maximum seconds to wait for connection_made callback.
|
||||
Defaults to 10.0. Raises asyncio.TimeoutError on expiry.
|
||||
"""
|
||||
self._connected_event.clear()
|
||||
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
await serial_asyncio.create_serial_connection(
|
||||
loop,
|
||||
@@ -66,7 +78,7 @@ class SerialConnection:
|
||||
baudrate=self.baudrate,
|
||||
)
|
||||
|
||||
await self._connected_event.wait()
|
||||
await asyncio.wait_for(self._connected_event.wait(), timeout=timeout)
|
||||
logger.info("Serial Connection started")
|
||||
return self.port
|
||||
|
||||
@@ -102,7 +114,7 @@ class SerialConnection:
|
||||
self.frame_expected_size = 0
|
||||
if len(data) > 0: # rerun handle_rx on remaining data
|
||||
self.handle_rx(data)
|
||||
return
|
||||
return # nothing left to process after reset
|
||||
|
||||
upbound = self.frame_expected_size - len(self.inframe)
|
||||
if len(data) < upbound:
|
||||
@@ -114,7 +126,7 @@ class SerialConnection:
|
||||
data = data[upbound:]
|
||||
if self.reader is not None:
|
||||
# feed meshcore reader
|
||||
asyncio.create_task(self.reader.handle_rx(self.inframe))
|
||||
self._spawn_background(self.reader.handle_rx(self.inframe))
|
||||
# reset inframe
|
||||
self.inframe = b""
|
||||
self.header = b""
|
||||
@@ -125,11 +137,18 @@ class SerialConnection:
|
||||
async def send(self, data):
|
||||
if not self.transport:
|
||||
logger.error("Transport not connected, cannot send data")
|
||||
if self._disconnect_callback:
|
||||
await self._disconnect_callback("serial_transport_lost")
|
||||
return
|
||||
size = len(data)
|
||||
pkt = b"\x3c" + size.to_bytes(2, byteorder="little") + data
|
||||
logger.debug(f"sending pkt : {pkt}")
|
||||
self.transport.write(pkt)
|
||||
try:
|
||||
self.transport.write(pkt)
|
||||
except OSError as exc:
|
||||
logger.warning(f"Serial write failed: {exc}")
|
||||
if self._disconnect_callback:
|
||||
await self._disconnect_callback(f"serial_write_failed: {exc}")
|
||||
|
||||
async def disconnect(self):
|
||||
"""Close the serial connection."""
|
||||
|
||||
@@ -24,6 +24,14 @@ class TCPConnection:
|
||||
self.frame_expected_size = 0
|
||||
self.header = b""
|
||||
self.inframe = b""
|
||||
self._background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
def _spawn_background(self, coro) -> asyncio.Task:
|
||||
"""Create a tracked background task (prevents GC of fire-and-forget tasks)."""
|
||||
task = asyncio.create_task(coro)
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
return task
|
||||
|
||||
class MCClientProtocol(asyncio.Protocol):
|
||||
def __init__(self, cx):
|
||||
@@ -38,7 +46,6 @@ class TCPConnection:
|
||||
|
||||
def data_received(self, data):
|
||||
logger.debug("data received")
|
||||
self.cx._receive_count += 1
|
||||
self.cx.handle_rx(data)
|
||||
|
||||
def error_received(self, exc):
|
||||
@@ -47,7 +54,7 @@ class TCPConnection:
|
||||
def connection_lost(self, exc):
|
||||
logger.debug("TCP server closed the connection")
|
||||
if self.cx._disconnect_callback:
|
||||
asyncio.create_task(self.cx._disconnect_callback("tcp_disconnect"))
|
||||
self.cx._spawn_background(self.cx._disconnect_callback("tcp_disconnect"))
|
||||
|
||||
async def connect(self):
|
||||
"""
|
||||
@@ -59,10 +66,7 @@ class TCPConnection:
|
||||
)
|
||||
|
||||
logger.info("TCP Connection started")
|
||||
future = asyncio.Future()
|
||||
future.set_result(self.host)
|
||||
|
||||
return future
|
||||
return self.host
|
||||
|
||||
def set_reader(self, reader):
|
||||
self.reader = reader
|
||||
@@ -96,7 +100,7 @@ class TCPConnection:
|
||||
self.frame_expected_size = 0
|
||||
if len(data) > 0: # rerun handle_rx on remaining data
|
||||
self.handle_rx(data)
|
||||
return
|
||||
return # nothing left to process after reset
|
||||
|
||||
upbound = self.frame_expected_size - len(self.inframe)
|
||||
if len(data) < upbound :
|
||||
@@ -106,9 +110,13 @@ class TCPConnection:
|
||||
|
||||
self.inframe = self.inframe + data[0:upbound]
|
||||
data = data[upbound:]
|
||||
# Increment per completed MeshCore frame, not per TCP segment (N04).
|
||||
# The threshold heuristic in send() compares _send_count vs
|
||||
# _receive_count — counting per-segment skews it under fragmentation.
|
||||
self._receive_count += 1
|
||||
if self.reader is not None:
|
||||
# feed meshcore reader
|
||||
asyncio.create_task(self.reader.handle_rx(self.inframe))
|
||||
self._spawn_background(self.reader.handle_rx(self.inframe))
|
||||
# reset inframe
|
||||
self.inframe = b""
|
||||
self.header = b""
|
||||
@@ -137,7 +145,12 @@ class TCPConnection:
|
||||
size = len(data)
|
||||
pkt = b"\x3c" + size.to_bytes(2, byteorder="little") + data
|
||||
logger.debug(f"sending pkt : {pkt}")
|
||||
self.transport.write(pkt)
|
||||
try:
|
||||
self.transport.write(pkt)
|
||||
except (OSError, ConnectionResetError) as exc:
|
||||
logger.warning(f"TCP write failed: {exc}")
|
||||
if self._disconnect_callback:
|
||||
await self._disconnect_callback(f"tcp_write_failed: {exc}")
|
||||
|
||||
async def disconnect(self):
|
||||
"""Close the TCP connection."""
|
||||
|
||||
Reference in New Issue
Block a user