Merge branch 'main' into fix/reader-parser-crash-safety

This commit is contained in:
fdlamotte
2026-04-25 15:17:48 +02:00
committed by GitHub
19 changed files with 1808 additions and 100 deletions

View File

@@ -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."""

View File

@@ -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:

View File

@@ -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()}")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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."""

View File

@@ -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

View File

@@ -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()

View File

@@ -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.

View File

@@ -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(),
)
)

View File

@@ -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."""

View File

@@ -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."""