meshcore-2.3.7/0000775000175000017500000000000015175447206012711 5ustar winniewinniemeshcore-2.3.7/src/0000775000175000017500000000000015175447206013500 5ustar winniewinniemeshcore-2.3.7/src/meshcore/0000775000175000017500000000000015175447206015305 5ustar winniewinniemeshcore-2.3.7/src/meshcore/connection_manager.py0000644000175000017500000001504213615410400021467 0ustar winniewinnie""" Connection manager that orchestrates reconnection logic for any connection type. """ import asyncio import logging 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. 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.""" ... async def disconnect(self): """Disconnect from the device/server.""" ... async def send(self, data): """Send data through the connection.""" ... def set_reader(self, reader): """Set the message reader.""" ... class ConnectionManager: """Manages connection lifecycle with auto-reconnect and event emission.""" def __init__( self, connection: ConnectionProtocol, 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 self._reconnect_task = None self._disconnect_callback: Optional[Callable] = None def set_disconnect_callback(self, callback: Callable): """Set a callback to be called when disconnection is detected.""" self._disconnect_callback = callback async def connect(self) -> Optional[Any]: """Connect with event handling and state management.""" 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}) logger.debug(f"Connected successfully: {result}") else: logger.debug("Connection failed") return result async def disconnect(self): """Disconnect with proper cleanup.""" if self._reconnect_task: self._reconnect_task.cancel() try: await self._reconnect_task except asyncio.CancelledError: pass self._reconnect_task = None if self._is_connected: await self.connection.disconnect() self._is_connected = False await self._emit_event( EventType.DISCONNECTED, {"reason": "manual_disconnect"} ) async def handle_disconnect(self, reason: str = "unknown"): """Handle unexpected disconnections with optional auto-reconnect.""" if not self._is_connected: return self._is_connected = False logger.debug(f"Connection lost: {reason}") if ( self.auto_reconnect and self._reconnect_attempts < self.max_reconnect_attempts ): self._reconnect_task = asyncio.create_task(self._attempt_reconnect()) else: await self._emit_event( EventType.DISCONNECTED, { "reason": reason, "reconnect_failed": self._reconnect_attempts >= self.max_reconnect_attempts, }, ) async def _attempt_reconnect(self): """Attempt to reconnect using an iterative loop. 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}" ) await self._emit_event( EventType.CONNECTED, {"connection_info": result, "reconnected": 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.""" if self.event_dispatcher: event = Event(event_type, payload) await self.event_dispatcher.dispatch(event) @property def is_connected(self) -> bool: """Check if the connection is active.""" return self._is_connected async def send(self, data): """Send data through the managed connection.""" return await self.connection.send(data) def set_reader(self, reader): """Set the message reader on the underlying connection.""" self.connection.set_reader(reader) meshcore-2.3.7/src/meshcore/packets.py0000644000175000017500000000636713615410400017302 0ustar winniewinniefrom enum import Enum class AnonReqType(Enum): REGIONS = 0x01 OWNER = 0x02 BASIC = 0x03 # just remote clock class BinaryReqType(Enum): STATUS = 0x01 KEEP_ALIVE = 0x02 TELEMETRY = 0x03 MMA = 0x04 ACL = 0x05 NEIGHBOURS = 0x06 class ControlType(Enum): NODE_DISCOVER_REQ = 0x80 NODE_DISCOVER_RESP = 0x90 class CommandType(Enum): APP_START = 1 SEND_TXT_MSG = 2 SEND_CHANNEL_TXT_MSG = 3 GET_CONTACTS = 4 # with optional 'since' (for efficient sync) GET_DEVICE_TIME = 5 SET_DEVICE_TIME = 6 SEND_SELF_ADVERT = 7 SET_ADVERT_NAME = 8 ADD_UPDATE_CONTACT = 9 SYNC_NEXT_MESSAGE = 10 SET_RADIO_PARAMS = 11 SET_RADIO_TX_POWER = 12 RESET_PATH = 13 SET_ADVERT_LATLON = 14 REMOVE_CONTACT = 15 SHARE_CONTACT = 16 EXPORT_CONTACT = 17 IMPORT_CONTACT = 18 REBOOT = 19 GET_BATT_AND_STORAGE = 20 # was CMD_GET_BATTERY_VOLTAGE SET_TUNING_PARAMS = 21 DEVICE_QEURY = 22 EXPORT_PRIVATE_KEY = 23 IMPORT_PRIVATE_KEY = 24 SEND_RAW_DATA = 25 SEND_LOGIN = 26 SEND_STATUS_REQ = 27 HAS_CONNECTION = 28 LOGOUT = 29 # 'Disconnect' GET_CONTACT_BY_KEY = 30 GET_CHANNEL = 31 SET_CHANNEL = 32 SIGN_START = 33 SIGN_DATA = 34 SIGN_FINISH = 35 SEND_TRACE_PATH = 36 SET_DEVICE_PIN = 37 SET_OTHER_PARAMS = 38 SEND_TELEMETRY_REQ = 39 # can deprecate this GET_CUSTOM_VARS = 40 SET_CUSTOM_VAR = 41 GET_ADVERT_PATH = 42 GET_TUNING_PARAMS = 43 # NOTE: CMD range 44..49 parked, potentially for WiFi operations BINARY_REQ = 50 FACTORY_RESET = 51 PATH_DISCOVERY = 52 SET_FLOOD_SCOPE = 54 SEND_CONTROL_DATA = 55 SEND_ANON_REQ = 57 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 SET_DEFAULT_FLOOD_SCOPE = 63 GET_DEFAULT_FLOOD_SCOPE = 64 # Packet prefixes for the protocol class PacketType(Enum): OK = 0 ERROR = 1 CONTACT_START = 2 CONTACT = 3 CONTACT_END = 4 SELF_INFO = 5 MSG_SENT = 6 CONTACT_MSG_RECV = 7 CHANNEL_MSG_RECV = 8 CURRENT_TIME = 9 NO_MORE_MSGS = 10 CONTACT_URI = 11 BATTERY = 12 DEVICE_INFO = 13 PRIVATE_KEY = 14 DISABLED = 15 CONTACT_MSG_RECV_V3 = 16 CHANNEL_MSG_RECV_V3 = 17 CHANNEL_INFO = 18 SIGN_START = 19 SIGNATURE = 20 CUSTOM_VARS = 21 ADVERT_PATH = 22 TUNING_PARAMS = 23 STATS = 24 AUTOADD_CONFIG = 25 ALLOWED_REPEAT_FREQ = 26 DEFAULT_FLOOD_SCOPE = 28 # Push notifications ADVERTISEMENT = 0x80 PATH_UPDATE = 0x81 ACK = 0x82 MESSAGES_WAITING = 0x83 RAW_DATA = 0x84 LOGIN_SUCCESS = 0x85 LOGIN_FAILED = 0x86 STATUS_RESPONSE = 0x87 LOG_DATA = 0x88 TRACE_DATA = 0x89 PUSH_CODE_NEW_ADVERT = 0x8A TELEMETRY_RESPONSE = 0x8B BINARY_RESPONSE = 0x8C 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. meshcore-2.3.7/src/meshcore/serial_cx.py0000644000175000017500000001333113615410400017606 0ustar winniewinnie""" mccli.py : CLI interface to MeschCore BLE companion app """ import asyncio import logging import serial_asyncio_fast as serial_asyncio # Get logger logger = logging.getLogger("meshcore") class SerialConnection: def __init__(self, port, baudrate, cx_dly=0.2): self.port = port self.baudrate = baudrate self.transport = None self.header = b"" self.reader = None 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 def connection_made(self, transport): self.cx.transport = transport logger.debug('port opened') if isinstance(transport, serial_asyncio.SerialTransport) and transport.serial: transport.serial.rts = False # You can manipulate Serial object via transport self.cx._connected_event.set() def data_received(self, data): self.cx.handle_rx(data) def connection_lost(self, exc): logger.debug('Serial port closed') self.cx._connected_event.clear() if self.cx._disconnect_callback: self.cx._spawn_background(self.cx._disconnect_callback("serial_disconnect")) def pause_writing(self): logger.debug("pause writing") def resume_writing(self): logger.debug("resume writing") async def connect(self, timeout: float = 10.0): """ 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, lambda: self.MCSerialClientProtocol(self), self.port, baudrate=self.baudrate, ) await asyncio.wait_for(self._connected_event.wait(), timeout=timeout) logger.info("Serial Connection started") return self.port def set_reader(self, reader): self.reader = reader def handle_rx(self, data: bytearray): if len(self.header) == 0: # did not find start of frame yet # search start of frame (0x3e) in data idx = data.find(b"\x3e") if idx < 0: # no start of frame return # Discard any leading junk bytes before the actual frame marker. # Some radios interleave console/debug text on the same UART, so # valid companion frames may begin at an offset inside the chunk. data = data[idx:] self.header = data[0:1] data = data[1:] if len(self.header) < 3: # header not complete yet while len(self.header) < 3 and len(data) > 0: self.header = self.header + data[0:1] data = data[1:] if len(self.header) < 3: # still not complete return # get size and check self.frame_expected_size = int.from_bytes(self.header[1:], "little", signed=False) if self.frame_expected_size > 300 : # invalid size # reset inframe self.header = b"" self.inframe = b"" self.frame_expected_size = 0 if len(data) > 0: # rerun handle_rx on remaining data self.handle_rx(data) return # nothing left to process after reset upbound = self.frame_expected_size - len(self.inframe) if len(data) < upbound: self.inframe = self.inframe + data # frame not complete, wait for next rx return self.inframe = self.inframe + data[0:upbound] data = data[upbound:] if self.reader is not None: # feed meshcore reader self._spawn_background(self.reader.handle_rx(self.inframe)) # reset inframe self.inframe = b"" self.header = b"" self.frame_expected_size = 0 if len(data) > 0: # rerun handle_rx on remaining data self.handle_rx(data) 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}") 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.""" if self.transport: self.transport.close() self.transport = None self._connected_event.clear() logger.debug("Serial Connection closed") def set_disconnect_callback(self, callback): """Set callback to handle disconnections.""" self._disconnect_callback = callback meshcore-2.3.7/src/meshcore/events.py0000644000175000017500000002563213615410400017150 0ustar winniewinniefrom enum import Enum import inspect import logging from typing import Any, Dict, Optional, Callable, List, Union import asyncio from dataclasses import dataclass, field logger = logging.getLogger("meshcore") # Public event types for users to subscribe to class EventType(Enum): CONTACTS = "contacts" SELF_INFO = "self_info" CONTACT_MSG_RECV = "contact_message" CHANNEL_MSG_RECV = "channel_message" CURRENT_TIME = "time_update" NO_MORE_MSGS = "no_more_messages" CONTACT_URI = "contact_uri" BATTERY = "battery_info" DEVICE_INFO = "device_info" MSG_SENT = "message_sent" NEW_CONTACT = "new_contact" NEXT_CONTACT = "next_contact" AUTOADD_CONFIG = "autoadd_config" ADVERT_PATH = "advert_path" # Push notifications ADVERTISEMENT = "advertisement" PATH_UPDATE = "path_update" ACK = "acknowledgement" MESSAGES_WAITING = "messages_waiting" RAW_DATA = "raw_data" LOGIN_SUCCESS = "login_success" LOGIN_FAILED = "login_failed" STATUS_RESPONSE = "status_response" LOG_DATA = "log_data" TRACE_DATA = "trace_data" RX_LOG_DATA = "rx_log_data" TELEMETRY_RESPONSE = "telemetry_response" BINARY_RESPONSE = "binary_response" MMA_RESPONSE = "mma_response" ACL_RESPONSE = "acl_response" CUSTOM_VARS = "custom_vars" STATS_CORE = "stats_core" STATS_RADIO = "stats_radio" STATS_PACKETS = "stats_packets" CHANNEL_INFO = "channel_info" 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" SIGN_START = "sign_start" SIGNATURE = "signature" ALLOWED_REPEAT_FREQ = "allowed_repeat_freq" DEFAULT_FLOOD_SCOPE = "default_flood_scope" # Command response types OK = "command_ok" ERROR = "command_error" # Connection events CONNECTED = "connected" DISCONNECTED = "disconnected" # a dict to associate a message to an error code ErrorMessages = { 1: "ERR_CODE_UNSUPPORTED_CMD", 2: "ERR_CODE_NOT_FOUND", 3: "ERR_CODE_TABLE_FULL", 4: "ERR_CODE_BAD_STATE", 5: "ERR_CODE_FILE_IO_ERROR", 6: "ERR_CODE_ILLEGAL_ARG", } @dataclass class Event: type: EventType payload: Any attributes: Dict[str, Any] = field(default_factory=dict) def __init__( self, type: EventType, payload: Any, attributes: Optional[Dict[str, Any]] = None, **kwargs, ): """ Initialize an Event Args: type: The event type payload: The event payload attributes: Dictionary of event attributes for filtering **kwargs: Additional attributes to add to the attributes dictionary """ self.type = type self.payload = payload self.attributes = attributes or {} # Add any keyword arguments to the attributes dictionary 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. Returns: A new Event object with the same type, payload, and attributes. """ copied_payload = ( self.payload.copy() if isinstance(self.payload, dict) else self.payload ) return Event(self.type, copied_payload, self.attributes.copy()) class Subscription: def __init__(self, dispatcher, event_type, callback, attribute_filters=None): self.dispatcher = dispatcher self.event_type = event_type self.callback = callback self.attribute_filters = attribute_filters or {} def unsubscribe(self): self.dispatcher._remove_subscription(self) 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: 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, event_type: Union[EventType, None], callback: Callable[[Event], Union[None, asyncio.Future]], attribute_filters: Optional[Dict[str, Any]] = None, ) -> Subscription: """ Subscribe to events with optional attribute filtering. Parameters: ----------- event_type : EventType or None The type of event to subscribe to, or None to subscribe to all events. callback : Callable Function to call when a matching event is received. attribute_filters : Dict[str, Any], optional Dictionary of attribute key-value pairs that must match for the event to trigger the callback. Returns: -------- Subscription object that can be used to unsubscribe. """ subscription = Subscription(self, event_type, callback, attribute_filters) self.subscriptions.append(subscription) return subscription def _remove_subscription(self, subscription: Subscription): if subscription in self.subscriptions: 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): while self.running: event = await self.queue.get() logger.debug( f"Dispatching event: {event.type}, {event.payload}, {event.attributes}" ) for subscription in self.subscriptions.copy(): # Check if event type matches if ( subscription.event_type is None or subscription.event_type == event.type ): # Check if all attribute filters match if ( subscription.attribute_filters and subscription.attribute_filters != {} ): # Skip if any filter doesn't match the corresponding event attribute if not all( event.attributes.get(key) == value for key, value in subscription.attribute_filters.items() ): continue # Call sync callbacks inline so futures are resolved before asyncio.wait() # 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): self._spawn_background(self._execute_callback(subscription.callback, event.clone())) else: try: subscription.callback(event.clone()) except Exception as e: logger.error(f"Error in event handler for {event.type}: {e}", exc_info=True) self.queue.task_done() async def _execute_callback(self, callback, event): """Execute a callback with proper error handling""" try: if asyncio.iscoroutinefunction(callback): await callback(event) else: result = callback(event) if inspect.iscoroutine(result): await result except Exception as e: logger.error(f"Error in event handler for {event.type}: {e}", exc_info=True) 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()) async def stop(self): if self.running: self.running = False if self._task: 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 except asyncio.CancelledError: pass self._task = None async def wait_for_event( self, event_type: EventType, attribute_filters: Optional[Dict[str, Any]] = None, timeout: float | None = None, ) -> Optional[Event]: """ Wait for an event of the specified type that matches all attribute filters. Parameters: ----------- event_type : EventType The type of event to wait for. attribute_filters : Dict[str, Any], optional Dictionary of attribute key-value pairs that must match for the event to be returned. timeout : float | None, optional Maximum time to wait for the event, in seconds. Returns: -------- The matched event, or None if timeout occurred before a matching event. """ future = asyncio.Future() def event_handler(event: Event): if not future.done(): future.set_result(event) subscription = self.subscribe(event_type, event_handler, attribute_filters) try: return await asyncio.wait_for(future, timeout) except asyncio.TimeoutError: return None finally: subscription.unsubscribe() meshcore-2.3.7/src/meshcore/ble_cx.py0000644000175000017500000002075113615410400017075 0ustar winniewinnie""" mccli.py : CLI interface to MeschCore BLE companion app """ import asyncio import logging # Make bleak optional - only fail if BLE operations are attempted try: from bleak import BleakClient, BleakScanner from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak.exc import BleakDeviceNotFoundError BLEAK_AVAILABLE = True except ImportError: BLEAK_AVAILABLE = False BleakClient = None BleakGATTCharacteristic = None # Get logger logger = logging.getLogger("meshcore") UART_SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" UART_RX_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" UART_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" class BLEConnection: def __init__(self, address=None, device=None, client=None, pin=None): """ Constructor: specify address or an existing BleakClient. Args: address (str, optional): The Bluetooth address of the device. device (BLEDevice, optional): A BLEDevice instance. client (BleakClient, optional): An existing BleakClient instance. pin (str, optional): PIN for BLE pairing authentication. """ if not BLEAK_AVAILABLE: raise ImportError( f"BLE requires 'bleak' package to be installed." ) self.address = address self._user_provided_address = address self.client = client self._user_provided_client = client self.device = device self._user_provided_device = device 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): """ Connects to the device. If a BleakClient was provided to the constructor, it uses that. Otherwise, it will scan or connect based on the provided address. Returns: The address used for connection, or None on failure. """ logger.debug(f"Connecting with client: {self.client}, address: {self.address}, device: {self.device}") if self.client: logger.debug("Using pre-configured BleakClient.") assert isinstance(self.client, BleakClient) if self.client.is_connected : logger.error("Client is already connected !!! weird") self.address = self.client.address return self.address self.address = self.client.address # If a client is provided it surely does not have disconnect callback # so recreate it as set_disconnected_callback can't be used anymore ... self.client = BleakClient(self.address, disconnected_callback=self.handle_disconnect) elif self.device: logger.debug("Directly using a passed device.") self.client = BleakClient(self.device, disconnected_callback=self.handle_disconnect) else: def match_meshcore_device(d: BLEDevice, adv: AdvertisementData): """Filter to match MeshCore devices.""" if adv.local_name and adv.local_name.startswith("MeshCore"): if self.address is None or self.address in adv.local_name: return True if d and d.address == self.address: return True return False if self.address is None or ":" not in self.address: logger.info("Scanning for devices...") device = await BleakScanner.find_device_by_filter(match_meshcore_device) if device is None: logger.warning("No MeshCore device found during scan.") return None logger.info(f"Found device: {device}") self.client = BleakClient( device, disconnected_callback=self.handle_disconnect ) self.address = self.client.address else: logger.debug("Connecting using provided address") self.client = BleakClient( self.address, disconnected_callback=self.handle_disconnect ) try: await self.client.connect() # Perform pairing if PIN is provided if self.pin is not None: logger.debug(f"Attempting BLE pairing with PIN") try: await self.client.pair() logger.info("BLE pairing successful") except Exception as e: 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 except TimeoutError: return None try: await self.client.start_notify(UART_TX_CHAR_UUID, self.handle_rx) except AttributeError : if self.client : await self.client.disconnect() logger.info("Connection is not established, need to restart it") logger.debug("in ble_cx.connect()") return None nus = self.client.services.get_service(UART_SERVICE_UUID) if nus is None: logger.error("Could not find UART service") return None self.rx_char = nus.get_characteristic(UART_RX_CHAR_UUID) logger.info("BLE Connection started") return self.address def handle_disconnect(self, client: BleakClient): """Callback to handle disconnection""" logger.debug( f"BLE device disconnected: {client.address} (is_connected: {client.is_connected})" ) # Reset the address/client/device we found to what user specified # this allows to reconnect to the same device self.address = self._user_provided_address 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: self._spawn_background(self._disconnect_callback("ble_disconnect")) def set_disconnect_callback(self, callback): """Set callback to handle disconnections.""" self._disconnect_callback = callback def set_reader(self, reader): self.reader = reader def handle_rx(self, _: BleakGATTCharacteristic, data: bytearray): if self.reader is not None: 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 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.""" if self.client and self.client.is_connected: await self.client.disconnect() logger.debug("BLE Connection closed") meshcore-2.3.7/src/meshcore/commands/0000775000175000017500000000000015175447206017106 5ustar winniewinniemeshcore-2.3.7/src/meshcore/commands/control_data.py0000644000175000017500000000275313615410400022115 0ustar winniewinnieimport logging import random from .base import CommandHandlerBase from ..events import EventType, Event from ..packets import ControlType, CommandType logger = logging.getLogger("meshcore") class ControlDataCommandHandler(CommandHandlerBase): """Helper functions to handle binary requests through binary commands""" async def send_control_data (self, control_type: int, payload: bytes) -> Event: data = bytearray([CommandType.SEND_CONTROL_DATA.value]) data.extend(control_type.to_bytes(1, "little", signed = False)) data.extend(payload) result = await self.send(data, [EventType.OK, EventType.ERROR]) return result async def send_node_discover_req ( self, filter: int, prefix_only: bool=True, tag: int=None, since: int=None ) -> Event: if tag is None: tag = random.randint(1, 0xFFFFFFFF) data = bytearray() data.extend(filter.to_bytes(1, "little", signed=False)) data.extend(tag.to_bytes(4, "little")) if not since is None: data.extend(since.to_bytes(4, "little", signed=False)) logger.debug(f"sending node discover req {data.hex()}") flags = 0 flags = flags | 1 if prefix_only else flags res = await self.send_control_data( ControlType.NODE_DISCOVER_REQ.value|flags, data) if res is None: return None else: res.payload["tag"] = tag return res meshcore-2.3.7/src/meshcore/commands/messaging.py0000644000175000017500000004013613615410400021416 0ustar winniewinnieimport logging import random from typing import Optional, Union from hashlib import sha256 from ..events import Event, EventType from ..packets import CommandType from .base import CommandHandlerBase, DestinationType, _validate_destination logger = logging.getLogger("meshcore") class MessagingCommands(CommandHandlerBase): async def get_msg(self, timeout: Optional[float] = None) -> Event: logger.debug("Requesting pending messages") return await self.send( b"\x0a", [ EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV, EventType.ERROR, EventType.NO_MORE_MSGS, ], timeout, ) async def _send_login_raw(self, dst: DestinationType, pwd: str) -> Event: dst_bytes = _validate_destination(dst, prefix_length=32) logger.debug(f"Sending login request to: {dst_bytes.hex()}") data = b"\x1a" + dst_bytes + pwd.encode("utf-8") return await self.send(data, [EventType.MSG_SENT, EventType.ERROR]) async def send_login(self, dst: DestinationType, pwd: str) -> Event: logger.warning("*** please consider using send_login_sync instead of send_login") return await self._send_login_raw(dst, pwd) async def send_login_sync(self, dst: DestinationType, pwd: str, timeout=0, min_timeout=0) -> Optional[Event]: """Send login to a remote node and wait for the response.""" async with self._mesh_request_lock: result = await self._send_login_raw(dst, pwd) if result is None or result.type == EventType.ERROR: return None timeout = result.payload["suggested_timeout"] / 800 if timeout == 0 else timeout timeout = timeout if timeout > min_timeout else min_timeout login_event = await self.dispatcher.wait_for_event( EventType.LOGIN_SUCCESS, timeout=timeout, ) return login_event async def send_logout(self, dst: DestinationType) -> Event: dst_bytes = _validate_destination(dst, prefix_length=32) data = b"\x1d" + dst_bytes return await self.send(data, [EventType.OK, EventType.ERROR]) async def send_statusreq(self, dst: DestinationType) -> Event: logger.warning("*** please consider using req_status_sync instead of send_statusreq") dst_bytes = _validate_destination(dst, prefix_length=32) logger.debug(f"Sending status request to: {dst_bytes.hex()}") data = b"\x1b" + dst_bytes return await self.send(data, [EventType.MSG_SENT, EventType.ERROR]) async def send_cmd( self, dst: DestinationType, cmd: str, timestamp: Optional[int] = None ) -> Event: dst_bytes = _validate_destination(dst) logger.debug(f"Sending command to {dst_bytes.hex()}: {cmd}") if timestamp is None: import time timestamp = int(time.time()) data = ( b"\x02\x01\x00" + timestamp.to_bytes(4, "little") + dst_bytes + cmd.encode("utf-8") ) return await self.send(data, [EventType.MSG_SENT, EventType.ERROR]) async def send_msg( self, dst: DestinationType, msg: str, timestamp: Optional[int] = None, attempt=0 ) -> Event: dst_bytes = _validate_destination(dst) logger.debug(f"Sending message to {dst_bytes.hex()}: {msg}") if timestamp is None: import time timestamp = int(time.time()) data = ( b"\x02\x00" + attempt.to_bytes(1, "little") + timestamp.to_bytes(4, "little") + dst_bytes + msg.encode("utf-8") ) return await self.send(data, [EventType.MSG_SENT, EventType.ERROR]) async def send_msg_with_retry ( self, dst: DestinationType, msg: str, timestamp: Optional[int] = None, max_attempts=3, max_flood_attempts=2, flood_after=2, timeout=0, min_timeout=0 ) -> Event: try: dst_bytes = _validate_destination(dst, prefix_length=32) # with 32 bytes we can reset to flood except ValueError: # but if we can't, we'll assume we're flood dst_bytes = _validate_destination(dst, prefix_length=6) contact = self._get_contact_by_prefix(dst_bytes.hex()) attempts = 0 flood_attempts = 0 if not contact is None : flood = contact["out_path_len"] == -1 if len(dst_bytes) < 32: # if we have a contact, then we can get a 32 bytes key ! dst_bytes = _validate_destination(contact, prefix_length=32) else: # we can't know if we're flood without fetching all contacts # if we have a full key (meaning we can reset path) consider direct # else consider flood flood = len(dst_bytes) < 32 logger.info(f"send_msg_with_retry: can't determine if flood, assume {flood}") res = None while attempts < max_attempts and res is None \ and (not flood or flood_attempts < max_flood_attempts): if attempts == flood_after and not flood: # change path to flood logger.info("Resetting path") rp_res = await self.reset_path(dst_bytes) if rp_res.type == EventType.ERROR: logger.error(f"Couldn't reset path {rp_res} continuing ...") else: flood = True if not contact is None: contact["out_path"] = "" contact["out_path_len"] = -1 if attempts > 0: logger.info(f"Retry sending msg: {attempts + 1}") result = await self.send_msg(dst, msg, timestamp, attempt=attempts) 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 timeout = timeout if timeout > min_timeout else min_timeout res = await self.dispatcher.wait_for_event(EventType.ACK, attribute_filters={"code": exp_ack}, timeout=timeout) attempts = attempts + 1 if flood : flood_attempts = flood_attempts + 1 return None if res is None else result async def send_chan_msg(self, chan: int, msg: str, timestamp: Optional[int|bytes] = None) -> Event: logger.debug(f"Sending channel message to channel {chan}: {msg}") if timestamp is None: # Default to current time if timestamp not provided import time timestamp_bytes = int(time.time()).to_bytes(4, "little") elif isinstance(timestamp, int): timestamp_bytes = timestamp.to_bytes(4, "little") elif isinstance(timestamp, bytes) and len(timestamp) == 4: # expected bytes format timestamp_bytes = timestamp else: if isinstance(timestamp, bytes): logger.error(f"Invalid timestamp format: got bytes of length {len(timestamp)} but expected bytes of length 4") else: logger.error(f"Invalid timestamp format: got {type(timestamp)} but expected int or 4 bytes") return Event(EventType.ERROR, {"reason": "invalid_timestamp_format"}) data = ( b"\x03\x00" + chan.to_bytes(1, "little") + timestamp_bytes + msg.encode("utf-8") ) return await self.send(data, [EventType.OK, EventType.ERROR]) async def send_telemetry_req(self, dst: DestinationType) -> Event: logger.warning("*** please consider using req_telemetry_sync instead of send_telemetry_req") dst_bytes = _validate_destination(dst, prefix_length=32) logger.debug(f"Asking telemetry to {dst_bytes.hex()}") data = b"\x27\x00\x00\x00" + dst_bytes return await self.send(data, [EventType.MSG_SENT, EventType.ERROR]) async def _send_path_discovery_raw(self, dst: DestinationType) -> Event: dst_bytes = _validate_destination(dst, prefix_length=32) logger.debug(f"Path discovery request for {dst_bytes.hex()}") data = b"\x34\x00" + dst_bytes return await self.send(data, [EventType.MSG_SENT, EventType.ERROR]) async def send_path_discovery(self, dst: DestinationType) -> Event: logger.warning("*** please consider using send_path_discovery_sync instead of send_path_discovery") return await self._send_path_discovery_raw(dst) async def send_path_discovery_sync(self, dst: DestinationType, timeout=0, min_timeout=0) -> Optional[Event]: """Send path discovery request and wait for the response.""" async with self._mesh_request_lock: result = await self._send_path_discovery_raw(dst) if result is None or result.type == EventType.ERROR: return None timeout = result.payload["suggested_timeout"] / 800 if timeout == 0 else timeout timeout = timeout if timeout > min_timeout else min_timeout path_event = await self.dispatcher.wait_for_event( EventType.PATH_RESPONSE, timeout=timeout, ) return path_event async def send_trace( self, auth_code: int = 0, tag: Optional[int] = None, flags = None, path: Optional[Union[str, bytes, bytearray]] = None, ) -> Event: """ Send a trace packet to test routing through specific repeaters Args: auth_code: 32-bit authentication code (default: 0) tag: 32-bit integer to identify this trace (default: random) flags: 8-bit flags field (default: None) lower two bytes set the path hash size (1 << s) => 1, 2, 4 bytes path: Optional string with comma-separated hex values representing repeater pubkeys (e.g. "23,5f,3a") or a bytes/bytearray object with the raw path data Returns: Event object with sent status, tag, and estimated timeout in milliseconds """ # Generate random tag if not provided if tag is None: tag = random.randint(1, 0xFFFFFFFF) if auth_code is None: auth_code = random.randint(1, 0xFFFFFFFF) path_hash_len = 1 # default if flags is None: if isinstance(path, str): # get flags from path string path_hash_len = int(len(path.split(",")[0]) / 2) if path_hash_len == 1 : flags = 0 elif path_hash_len == 2 : flags = 1 elif path_hash_len == 4 : flags = 2 elif path_hash_len == 8 : flags = 3 else : logger.error(f"Invalid path format: unknown path_hash_len {path_hash_len}") return Event(EventType.ERROR, {"reason": "invalid_path_format"}) else: flags = 0 else: path_hash_len = 1 << (flags & 3) # Process path if provided path_bytes = bytearray() if path: if isinstance(path, str): # Convert comma-separated hex values to bytes try: for hex_val in path.split(","): hex_val = hex_val.strip() if hex_val == "": break elif len(hex_val) != path_hash_len * 2 : raise(ValueError()) path_bytes.extend(bytes.fromhex(hex_val)) except ValueError as e: logger.error(f"Invalid path format: {e}") return Event(EventType.ERROR, {"reason": "invalid_path_format"}) elif isinstance(path, (bytes, bytearray)): path_bytes = path else: logger.error(f"Unsupported path type: {type(path)}") return Event(EventType.ERROR, {"reason": "unsupported_path_type"}) # Prepare the command packet: CMD(1) + tag(4) + auth_code(4) + flags(1) + [path] cmd_data = bytearray([36]) # CMD_SEND_TRACE_PATH cmd_data.extend(tag.to_bytes(4, "little")) cmd_data.extend(auth_code.to_bytes(4, "little")) 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") scope_key = b"\0"*16 elif isinstance (scope, str): if scope == "0" or scope == "None" or scope == "*" or scope == "": # disable logger.debug(f"Resetting scope") scope_key = b"\0"*16 else: logger.debug(f"Setting scope from string {scope}") if scope[0] != "#": # no hashtag as first char scope = "#" + scope # adding hashtag scope_key = sha256(scope.encode("utf-8")).digest()[0:16] elif isinstance (scope, bytes): # scope has been sent directly as byte logger.debug(f"Directly setting scope to {scope}") scope_key = scope else: raise TypeError(f"set_flood_scope: unsupported scope type {type(scope).__name__}") logger.debug(f"Setting scope to {scope_key.hex()}") cmd_data = bytearray([CommandType.SET_FLOOD_SCOPE.value]) cmd_data.extend(b"\0") cmd_data.extend(scope_key) return await self.send(cmd_data, [EventType.OK, EventType.ERROR]) async def set_default_flood_scope(self, scope): if scope is None: logger.debug(f"Resetting default scope") scope_key = b"\0"*16 scope_name = "" elif isinstance (scope, str): if scope == "0" or scope == "None" or scope == "*" or scope == "": # disable logger.debug ("Resetting default scope") scope_key = b"\0"*16 scope_name = "" else: logger.debug (f"Setting scope to {scope}") if scope[0] != "#": scope = "#" + scope scope_name = scope scope_key = sha256(scope.encode("utf-8")).digest()[0:16] else: raise TypeError(f"set_flood_scope: unsupported scope type {type(scope).__name__}") logger.debug(f"Setting scope key to {scope_key.hex()}") cmd_data = bytearray([CommandType.SET_DEFAULT_FLOOD_SCOPE.value]) cmd_data.extend(scope_name.encode("utf-8")) cmd_data.extend((31-len(scope))*b'\0') cmd_data.extend(scope_key) return await self.send(cmd_data, [EventType.OK, EventType.ERROR]) async def get_default_flood_scope(self): logger.debug(f"Getting default flood scope") cmd_data = bytearray([CommandType.GET_DEFAULT_FLOOD_SCOPE.value]) return await self.send(cmd_data, [EventType.DEFAULT_FLOOD_SCOPE, EventType.ERROR]) meshcore-2.3.7/src/meshcore/commands/binary.py0000644000175000017500000003026413615410400020726 0ustar winniewinnieimport asyncio import logging import random import io from .base import CommandHandlerBase from ..events import EventType from ..packets import BinaryReqType from ..packets import AnonReqType logger = logging.getLogger("meshcore") class BinaryCommandHandler(CommandHandlerBase): """Helper functions to handle binary requests through binary commands""" async def req_status(self, contact, timeout=0, min_timeout=0): logger.error("*** please consider using req_status_sync instead of req_status") return await self.req_status_sync(contact, timeout, min_timeout) async def req_status_sync(self, contact, timeout=0, min_timeout=0): async with self._mesh_request_lock: res = await self.send_binary_req( contact, BinaryReqType.STATUS, timeout=timeout, min_timeout=min_timeout ) if res.type == EventType.ERROR: return None exp_tag = res.payload["expected_ack"].hex() timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout timeout = timeout if min_timeout < timeout else min_timeout if self.dispatcher is None: return None status_event = await self.dispatcher.wait_for_event( EventType.STATUS_RESPONSE, attribute_filters={"tag": exp_tag}, timeout=timeout, ) return status_event.payload if status_event else None async def req_telemetry(self, contact, timeout=0, min_timeout=0): logger.error("*** please consider using req_telemetry_sync instead of req_telemetry") return await self.req_telemetry_sync(contact, timeout, min_timeout) async def req_telemetry_sync(self, contact, timeout=0, min_timeout=0): async with self._mesh_request_lock: res = await self.send_binary_req( contact, BinaryReqType.TELEMETRY, timeout=timeout, min_timeout=min_timeout ) if res.type == EventType.ERROR: return None timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout timeout = timeout if min_timeout < timeout else min_timeout if self.dispatcher is None: return None telem_event = await self.dispatcher.wait_for_event( EventType.TELEMETRY_RESPONSE, attribute_filters={"tag": res.payload["expected_ack"].hex()}, timeout=timeout, ) return telem_event.payload["lpp"] if telem_event else None async def req_mma_sync(self, contact, start, end, timeout=0,min_timeout=0): async with self._mesh_request_lock: req = ( start.to_bytes(4, "little", signed=False) + end.to_bytes(4, "little", signed=False) + b"\0\0" ) res = await self.send_binary_req( contact, BinaryReqType.MMA, data=req, timeout=timeout ) if res.type == EventType.ERROR: return None timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout timeout = timeout if min_timeout < timeout else min_timeout if self.dispatcher is None: return None mma_event = await self.dispatcher.wait_for_event( EventType.MMA_RESPONSE, attribute_filters={"tag": res.payload["expected_ack"].hex()}, timeout=timeout, ) return mma_event.payload["mma_data"] if mma_event else None async def req_acl(self, contact, timeout=0, min_timeout=0): logger.error("*** please consider using req_acl_sync instead of req_acl") return await self.req_acl_sync(contact, timeout, min_timeout) async def req_acl_sync(self, contact, timeout=0, min_timeout=0): async with self._mesh_request_lock: req = b"\0\0" res = await self.send_binary_req( contact, BinaryReqType.ACL, data=req, timeout=timeout ) if res.type == EventType.ERROR: return None timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout timeout = timeout if timeout > min_timeout else min_timeout if self.dispatcher is None: return None acl_event = await self.dispatcher.wait_for_event( EventType.ACL_RESPONSE, attribute_filters={"tag": res.payload["expected_ack"].hex()}, timeout=timeout, ) return acl_event.payload["acl_data"] if acl_event else None async def req_neighbours_async(self, contact, count=255, offset=0, order_by=0, pubkey_prefix_length=4, timeout=0, min_timeout=0 ): req = (b"\x00" # version : 0 + count.to_bytes(1, "little", signed=False) + offset.to_bytes(2, "little", signed=False) + order_by.to_bytes(1, "little", signed=False) + pubkey_prefix_length.to_bytes(1, "little", signed=False) + random.randint(1, 0xFFFFFFFF).to_bytes(4, "little", signed=False) ) logger.debug(f"Sending binary neighbours req, count: {count}, offset: {offset} {req.hex()}") return await self.send_binary_req ( contact, BinaryReqType.NEIGHBOURS, data=req, timeout=timeout, context={"pubkey_prefix_length": pubkey_prefix_length} ) async def req_neighbours_sync(self, contact, count=255, offset=0, order_by=0, pubkey_prefix_length=4, timeout=0, min_timeout=0 ): async with self._mesh_request_lock: res = await self.req_neighbours_async(contact, count=count, offset=offset, order_by=order_by, pubkey_prefix_length=pubkey_prefix_length, timeout=timeout, min_timeout=min_timeout) if res is None or res.type == EventType.ERROR: return None timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout timeout = timeout if min_timeout < timeout else min_timeout if self.dispatcher is None: return None neighbours_event = await self.dispatcher.wait_for_event( EventType.NEIGHBOURS_RESPONSE, attribute_filters={"tag": res.payload["expected_ack"].hex()}, timeout=timeout, ) return neighbours_event.payload if neighbours_event else None # do several queries if not all neighbours have been obtained async def fetch_all_neighbours(self, contact, order_by=0, pubkey_prefix_length=4, timeout=0, min_timeout=0 ): # Initial request res = await self.req_neighbours_sync(contact, count=255, offset=0, order_by=order_by, pubkey_prefix_length=pubkey_prefix_length, timeout=timeout, min_timeout=min_timeout) if res is None: return None neighbours_count = res["neighbours_count"] # total neighbours results_count = res["results_count"] # obtained neighbours del res["tag"] tries = 0 while results_count < neighbours_count: #await asyncio.sleep(2) # wait 2s before next fetch next_res = await self.req_neighbours_sync(contact, count=255, offset=results_count, order_by=order_by, pubkey_prefix_length=pubkey_prefix_length, timeout=timeout, min_timeout=min_timeout+25) # requests are close and responses big, #so let's have some more timeout for duty cycle if next_res is None : if tries > 0: return res # caller should check it has everything else: tries += 1 else: tries = 0 results_count += next_res["results_count"] res["results_count"] = results_count res["neighbours"] += next_res["neighbours"] return res async def req_regions_async(self, contact, timeout=0, min_timeout=0): req = b"\0" # The return path, I currently do nothing with, so direct only return await self.send_anon_req( contact, AnonReqType.REGIONS, data=req, timeout=timeout ) async def req_regions_sync(self, contact, timeout=0, min_timeout=0): async with self._mesh_request_lock: res = await self.req_regions_async(contact, timeout, min_timeout) if res.type == EventType.ERROR: return None timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout timeout = timeout if timeout > min_timeout else min_timeout if self.dispatcher is None: return None region_event = await self.dispatcher.wait_for_event( EventType.BINARY_RESPONSE, attribute_filters={"tag": res.payload["expected_ack"].hex()}, timeout=timeout, ) if region_event is None: return None pkt = bytes().fromhex(region_event.payload["data"]) pbuf = io.BytesIO(pkt) tag_again = pbuf.read(4) return pbuf.read().decode("utf-8", "ignore").strip("\x00") async def req_owner_async(self, contact, timeout=0, min_timeout=0): req = b"\0" return await self.send_anon_req( contact, AnonReqType.OWNER, data=req, timeout=timeout ) async def req_owner_sync(self, contact, timeout=0, min_timeout=0): async with self._mesh_request_lock: res = await self.req_owner_async(contact, timeout, min_timeout) if res.type == EventType.ERROR: return None timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout timeout = timeout if timeout > min_timeout else min_timeout if self.dispatcher is None: return None owner_event = await self.dispatcher.wait_for_event( EventType.BINARY_RESPONSE, attribute_filters={"tag": res.payload["expected_ack"].hex()}, timeout=timeout, ) if owner_event is None: return None pkt = bytes().fromhex(owner_event.payload["data"]) pbuf = io.BytesIO(pkt) tag_again = pbuf.read(4) strings = pbuf.read().decode("utf-8", "ignore").split("\n", 1) return dict(name=strings[0], owner=strings[1].strip("\x00")) async def req_basic_async(self, contact, timeout=0, min_timeout=0): req = b"\0" return await self.send_anon_req( contact, AnonReqType.BASIC, data=req, timeout=timeout ) async def req_basic_sync(self, contact, timeout=0, min_timeout=0): async with self._mesh_request_lock: res = await self.req_basic_async(contact, timeout, min_timeout) if res.type == EventType.ERROR: return None timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout timeout = timeout if timeout > min_timeout else min_timeout if self.dispatcher is None: return None basic_event = await self.dispatcher.wait_for_event( EventType.BINARY_RESPONSE, attribute_filters={"tag": res.payload["expected_ack"].hex()}, timeout=timeout, ) if basic_event is None: return None return basic_event.payload meshcore-2.3.7/src/meshcore/commands/contact.py0000644000175000017500000002242313615410400021073 0ustar winniewinnieimport logging import asyncio from typing import Optional from ..events import Event, EventDispatcher, EventType from .base import CommandHandlerBase, DestinationType, _validate_destination logger = logging.getLogger("meshcore") class ContactCommands(CommandHandlerBase): async def get_contacts_async(self, lastmod=0) : logger.debug("Getting contacts") data = b"\x04" if lastmod > 0: data = data + lastmod.to_bytes(4, "little") # wait first event await self.send(data) async def get_contacts(self, lastmod=0, timeout=5) -> Event: await self.get_contacts_async(lastmod) # Inline wait for events to continue waiting for CONTACTS event # while receiving NEXT_CONTACTs (or it might be missed over serial) try: # Create futures for all expected events futures = [] for event_type in [EventType.ERROR, EventType.NEXT_CONTACT, EventType.CONTACTS] : future = asyncio.create_task( self.dispatcher.wait_for_event(event_type, {}, timeout=timeout) ) futures.append(future) while True: # Wait for the first event to complete or all to timeout done, pending = await asyncio.wait( futures, timeout=timeout, return_when=asyncio.FIRST_COMPLETED ) # Check if any future completed successfully if len(done) == 0: logger.debug("Timeout while getting contacts") for future in pending: # cancel all futures future.cancel() return Event(EventType.ERROR, {"reason": "timeout waiting for contacts"}) for future in done: event = await future if event is None: for f in pending: f.cancel() return Event(EventType.ERROR, {"reason": "no event received during contacts retrieval"}) if event.type != EventType.NEXT_CONTACT: for f in pending: f.cancel() return event futures = [] for future in pending: # put back pending futures.append(future) future = asyncio.create_task( # and recreate NEXT_CONTACT self.dispatcher.wait_for_event(EventType.NEXT_CONTACT, {}, timeout) ) futures.append(future) except asyncio.TimeoutError: logger.debug(f"Timeout receiving contacts") return Event(EventType.ERROR, {"reason": "asyncio timeout receiving contacts"}) except Exception as e: logger.debug(f"Command error: {e}") return Event(EventType.ERROR, {"error": str(e)}) async def reset_path(self, key: DestinationType) -> Event: key_bytes = _validate_destination(key, prefix_length=32) contact = self._get_contact_by_prefix(key_bytes.hex()) # need a contact for return path if not contact is None: contact["out_path_len"] = -1 contact["out_path"] = "" logger.debug(f"Resetting path for contact: {key_bytes.hex()}") data = b"\x0d" + key_bytes return await self.send(data, [EventType.OK, EventType.ERROR]) async def share_contact(self, key: DestinationType) -> Event: key_bytes = _validate_destination(key, prefix_length=32) logger.debug(f"Sharing contact: {key_bytes.hex()}") data = b"\x10" + key_bytes return await self.send(data, [EventType.OK, EventType.ERROR]) async def export_contact(self, key: Optional[DestinationType] = None) -> Event: if key: key_bytes = _validate_destination(key, prefix_length=32) logger.debug(f"Exporting contact: {key_bytes.hex()}") data = b"\x11" + key_bytes else: logger.debug("Exporting node") data = b"\x11" return await self.send(data, [EventType.CONTACT_URI, EventType.ERROR]) async def import_contact(self, card_data) -> Event: data = b"\x12" + card_data return await self.send(data, [EventType.OK, EventType.ERROR]) async def remove_contact(self, key: DestinationType) -> Event: key_bytes = _validate_destination(key, prefix_length=32) logger.debug(f"Removing contact: {key_bytes.hex()}") data = b"\x0f" + key_bytes return await self.send(data, [EventType.OK, EventType.ERROR]) async def update_contact(self, contact, path=None, flags=None, path_hash_mode=None) -> Event: if path is None: out_path_hex = contact["out_path"] out_path_len = contact["out_path_len"] out_path_hash_mode = contact["out_path_hash_mode"] else: if path_hash_mode is None: # not specified when calling func if ":" in path: # mode specified in path string path_hash_mode = int(path.split(":")[1]) path = path.split(":")[0].replace(":","") else: # use device one by default # out_path_len is pre-masked (& 0x3F) in reader.py, so high bits are always 0; # the actual path_hash_mode is fetched from the device query below. path_hash_mode = 0 res = await self.send_device_query() if not res is None and res.type != EventType.ERROR: if "path_hash_mode" in res.payload: path_hash_mode = res.payload["path_hash_mode"] else: path_hash_mode = 0 else: if ":" in path: # remove as it has been specified in args path = path.split(":")[0].replace(":","") path_hash_size = path_hash_mode + 1 out_path_hex = path out_path_len = int(len(path) / (2 * path_hash_size)) out_path_hash_mode = path_hash_mode logger.debug(f"Setting {contact['adv_name']} path to {out_path_hex} with mode {out_path_hash_mode}") # reflect the change contact["out_path_hash_mode"] = out_path_hash_mode contact["out_path"] = out_path_hex contact["out_path_len"] = out_path_len out_path_hex = out_path_hex + (128 - len(out_path_hex)) * "0" if out_path_len == -1: # path did not change and contact was flood out_path_len = 255 # we are signed else: out_path_len = out_path_len | (out_path_hash_mode << 6) if flags is None: flags = contact["flags"] else: # reflect the change contact["flags"] = flags adv_name_hex = contact["adv_name"].encode().hex() adv_name_hex = adv_name_hex + (64 - len(adv_name_hex)) * "0" data = ( b"\x09" + bytes.fromhex(contact["public_key"]) + contact["type"].to_bytes(1, "little") + flags.to_bytes(1, "little") + int(out_path_len).to_bytes(1, "little", signed=False) + bytes.fromhex(out_path_hex) + bytes.fromhex(adv_name_hex) + contact["last_advert"].to_bytes(4, "little") + int(contact["adv_lat"] * 1e6).to_bytes(4, "little", signed=True) + int(contact["adv_lon"] * 1e6).to_bytes(4, "little", signed=True) ) return await self.send(data, [EventType.OK, EventType.ERROR]) async def add_contact(self, contact) -> Event: return await self.update_contact(contact) async def change_contact_path(self, contact, path, path_hash_mode=None) -> Event: return await self.update_contact(contact, path, path_hash_mode=path_hash_mode) async def change_contact_flags(self, contact, flags) -> Event: return await self.update_contact(contact, flags=flags) async def set_autoadd_config(self, flag : int) -> Event: data = b"\x3A" + flag.to_bytes(1, "little", signed=False) return await self.send(data, [EventType.OK, EventType.ERROR]) async def get_autoadd_config(self) -> Event: 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()}") data = b"\x2a\0" + key_bytes return await self.send(data, [EventType.ADVERT_PATH, EventType.ERROR]) meshcore-2.3.7/src/meshcore/commands/device.py0000644000175000017500000004106313615410400020700 0ustar winniewinnieimport asyncio import logging 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") 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, EventType.ERROR]) async def send_device_query(self) -> Event: logger.debug("Sending device query command") return await self.send(b"\x16\x03", [EventType.DEVICE_INFO, EventType.ERROR]) async def send_advert(self, flood: bool = False) -> Event: logger.debug(f"Sending advertisement command (flood={flood})") if flood: return await self.send(b"\x07\x01", [EventType.OK, EventType.ERROR]) else: return await self.send(b"\x07", [EventType.OK, EventType.ERROR]) async def set_name(self, name: str) -> Event: logger.debug(f"Setting device name to: {name}") return await self.send( b"\x08" + name.encode("utf-8"), [EventType.OK, EventType.ERROR] ) async def set_coords(self, lat: float, lon: float) -> Event: logger.debug(f"Setting coordinates to: lat={lat}, lon={lon}") return await self.send( b"\x0e" + int(lat * 1e6).to_bytes(4, "little", signed=True) + int(lon * 1e6).to_bytes(4, "little", signed=True) + int(0).to_bytes(4, "little"), [EventType.OK, EventType.ERROR], ) async def reboot(self) -> Event: logger.debug("Sending reboot command") return await self.send(b"\x13reboot") async def get_bat(self) -> Event: logger.debug("Getting battery information") return await self.send(b"\x14", [EventType.BATTERY, EventType.ERROR]) async def get_time(self) -> Event: logger.debug("Getting device time") return await self.send(b"\x05", [EventType.CURRENT_TIME, EventType.ERROR]) async def set_time(self, val: int) -> Event: logger.debug(f"Setting device time to: {val}") return await self.send( b"\x06" + int(val).to_bytes(4, "little"), [EventType.OK, EventType.ERROR] ) async def set_tx_power(self, val: int) -> Event: logger.debug(f"Setting TX power to: {val}") return await self.send( b"\x0c" + int(val).to_bytes(4, "little"), [EventType.OK, EventType.ERROR] ) async def set_radio(self, freq: float, bw: float, sf: int, cr: int, repeat = None) -> Event: logger.debug(f"Setting radio params: freq={freq}, bw={bw}, sf={sf}, cr={cr}, repeat={repeat}") data = b"\x0b" data += int(float(freq) * 1000).to_bytes(4, "little") data += int(float(bw) * 1000).to_bytes(4, "little") data += int(sf).to_bytes(1, "little") data += int(cr).to_bytes(1, "little") if not repeat is None: data += int(repeat).to_bytes(1,"little") return await self.send(data, [EventType.OK, EventType.ERROR]) async def set_tuning(self, rx_dly: int, af: int) -> Event: logger.debug(f"Setting tuning params: rx_dly={rx_dly}, af={af}") return await self.send( b"\x15" + int(rx_dly).to_bytes(4, "little") + int(af).to_bytes(4, "little") + int(0).to_bytes(1, "little") + int(0).to_bytes(1, "little"), [EventType.OK, EventType.ERROR], ) # the old set_other_params function has been replaced in # favour of set_other_params_from_infos to be more generic # stays here for backward compatibility but does not support # multi_acks for instance async def set_other_params( self, manual_add_contacts: bool, telemetry_mode_base: int, telemetry_mode_loc: int, telemetry_mode_env: int, advert_loc_policy: int, ) -> Event: telemetry_mode = ( (telemetry_mode_base & 0b11) | ((telemetry_mode_loc & 0b11) << 2) | ((telemetry_mode_env & 0b11) << 4) ) data = ( b"\x26" + manual_add_contacts.to_bytes(1, "little") + telemetry_mode.to_bytes(1, "little") + advert_loc_policy.to_bytes(1, "little") ) return await self.send(data, [EventType.OK, EventType.ERROR]) async def set_other_params_from_infos(self, infos) -> Event: telemetry_mode = ( (infos["telemetry_mode_base"] & 0b11) | ((infos["telemetry_mode_loc"] & 0b11) << 2) | ((infos["telemetry_mode_env"] & 0b11) << 4) ) data = ( b"\x26" + infos["manual_add_contacts"].to_bytes(1, "little") + telemetry_mode.to_bytes(1, "little") + infos["adv_loc_policy"].to_bytes(1, "little") + infos["multi_acks"].to_bytes(1, "little") ) return await self.send(data, [EventType.OK, EventType.ERROR]) async def set_telemetry_mode_base(self, telemetry_mode_base: int) -> Event: 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: 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: 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: 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: 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: 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) async def set_devicepin(self, pin: int) -> Event: logger.debug(f"Setting device PIN to: {pin}") return await self.send( b"\x25" + int(pin).to_bytes(4, "little"), [EventType.OK, EventType.ERROR] ) async def get_self_telemetry(self) -> Event: logger.debug("Getting self telemetry") data = b"\x27\x00\x00\x00" return await self.send(data, [EventType.TELEMETRY_RESPONSE, EventType.ERROR]) async def get_custom_vars(self) -> Event: logger.debug("Asking for custom vars") data = b"\x28" return await self.send(data, [EventType.CUSTOM_VARS, EventType.ERROR]) async def set_custom_var(self, key, value) -> Event: logger.debug(f"Setting custom var {key} to {value}") data = b"\x29" + key.encode("utf-8") + b":" + value.encode("utf-8") return await self.send(data, [EventType.OK, EventType.ERROR]) async def get_channel(self, channel_idx: int) -> Event: logger.debug(f"Getting channel info for channel {channel_idx}") data = b"\x1f" + channel_idx.to_bytes(1, "little") return await self.send(data, [EventType.CHANNEL_INFO, EventType.ERROR]) async def set_channel( self, channel_idx: int, channel_name: str, channel_secret: bytes = None ) -> Event: logger.debug(f"Setting channel {channel_idx}: name={channel_name}") # Pad channel name to 32 bytes name_bytes = channel_name.encode("utf-8")[:32] name_bytes = name_bytes.ljust(32, b"\x00") if channel_name.startswith("#") or channel_secret is None: # auto name => key calculated from hash channel_secret = sha256(channel_name.encode("utf-8")).digest()[0:16] # Ensure channel secret is exactly 16 bytes if len(channel_secret) != 16: raise ValueError("Channel secret must be exactly 16 bytes") data = b"\x20" + channel_idx.to_bytes(1, "little") + name_bytes + channel_secret return await self.send(data, [EventType.OK, EventType.ERROR]) async def export_private_key(self) -> Event: logger.debug("Requesting private key export") return await self.send(b"\x17", [EventType.PRIVATE_KEY, EventType.DISABLED, EventType.ERROR]) async def import_private_key(self, key) -> Event: logger.debug("Requesting private key import") data = b"\x18" + key return await self.send(data, [EventType.OK, EventType.ERROR]) async def sign_start(self) -> Event: logger.debug("Starting signing session on device") return await self.send(b"\x21", [EventType.SIGN_START, EventType.ERROR]) async def sign_data(self, chunk: bytes) -> Event: if not isinstance(chunk, (bytes, bytearray)): raise TypeError("chunk must be bytes-like") logger.debug(f"Sending signing data chunk ({len(chunk)} bytes)") data = b"\x22" + bytes(chunk) result = await self.send(data, [EventType.OK, EventType.ERROR], timeout=5.0) # If we got an error (not just timeout), return it immediately if result.type == EventType.ERROR: # If it's a timeout/no_event, log a warning but continue - the data may have been received if result.payload.get("reason") in ("timeout", "no_event_received"): logger.warning( f"sign_data OK response not received (timeout), but continuing - " f"data may have been processed by device" ) return Event(EventType.OK, {}) # For actual errors (bad state, table full, etc.), return the error return result return result async def sign_finish(self, timeout: Optional[float] = None, data_size: int = 0) -> Event: logger.debug("Finalizing signing session on device") if timeout is None: base_timeout = max(self.default_timeout * 3, 15.0) size_bonus = min(data_size / 2048.0, 5.0) timeout = base_timeout + size_bonus logger.debug(f"sign_finish using timeout={timeout:.1f} seconds (data_size={data_size} bytes)") return await self.send(b"\x23", [EventType.SIGNATURE, EventType.ERROR], timeout=timeout) async def sign(self, data: bytes, chunk_size: int = 120, timeout: Optional[float] = None) -> Event: if not isinstance(data, (bytes, bytearray)): raise TypeError("data must be bytes-like") if chunk_size <= 0: raise ValueError("chunk_size must be > 0") start_evt = await self.sign_start() if start_evt.type == EventType.ERROR: return start_evt max_len = start_evt.payload.get("max_length", 0) if max_len and len(data) > max_len: return Event(EventType.ERROR, {"reason": "data_too_large", "max_length": max_len, "len": len(data)}) for idx in range(0, len(data), chunk_size): chunk = data[idx : idx + chunk_size] chunk_num = (idx // chunk_size) + 1 total_chunks = (len(data) + chunk_size - 1) // chunk_size logger.debug(f"Sending chunk {chunk_num}/{total_chunks} ({len(chunk)} bytes)") evt = await self.sign_data(chunk) if evt.type == EventType.ERROR: logger.error(f"Error sending chunk {chunk_num}/{total_chunks}: {evt.payload}") return evt logger.debug(f"Chunk {chunk_num}/{total_chunks} sent successfully") 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") # 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") # 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") # 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") return await self.send(b"\x3c", [EventType.ALLOWED_REPEAT_FREQ, EventType.ERROR]) async def set_path_hash_mode(self, mode: int) -> Event: logger.debug(f"Setting path mode to {mode}") data = b"\x3d\0" + mode.to_bytes(1, "little") return await self.send(data, [EventType.OK, EventType.ERROR]) async def get_path_hash_mode(self) -> int: res = await self.send_device_query() if not res is None and res.type != EventType.ERROR: if "path_hash_mode" in res.payload: return res.payload["path_hash_mode"] return 0 meshcore-2.3.7/src/meshcore/commands/base.py0000644000175000017500000003462113615410400020355 0ustar winniewinnieimport asyncio import logging import random from typing import Any, Callable, Coroutine, Dict, List, Optional, Union from meshcore.packets import BinaryReqType, AnonReqType from ..events import Event, EventDispatcher, EventType from ..reader import MessageReader # Define types for destination parameters DestinationType = Union[bytes, str, Dict[str, Any]] logger = logging.getLogger("meshcore") def _validate_destination(dst: DestinationType, prefix_length: int = 6) -> bytes: """ Validates and converts a destination to a bytes object. Args: dst: The destination, which can be: - str: Hex string representation of a public key - dict: Contact object with a "public_key" field prefix_length: The length of the prefix to use (default: 6 bytes) Returns: bytes: The destination public key as a bytes object Raises: ValueError: If dst is invalid or doesn't contain required fields """ if isinstance(dst, bytes): # Already bytes, use directly if len(dst) 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) self._sender_func = sender def set_reader(self, reader: MessageReader) -> None: self._reader = reader def set_dispatcher(self, dispatcher: EventDispatcher) -> None: self.dispatcher = dispatcher def set_contact_getter_by_prefix(self, func: Callable[[str], Optional[Dict[str,Any]]] )-> None: self._get_contact_by_prefix = func async def wait_for_events( self, 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): expected_events = [expected_events] logger.debug(f"Waiting for events {expected_events}, timeout={timeout}") # Create futures for all expected events futures = [] for event_type in expected_events: future = asyncio.create_task( self.dispatcher.wait_for_event(event_type, {}, timeout) ) futures.append(future) # Wait for the first event to complete or all to timeout done, pending = await asyncio.wait( futures, timeout=timeout, return_when=asyncio.FIRST_COMPLETED ) # Cancel all pending futures for future in pending: future.cancel() # Check if any future completed successfully for future in done: event = await future if event: return event # Create an error event when no event is received return Event(EventType.ERROR, {"reason": "no_event_received"}) except asyncio.TimeoutError: logger.debug(f"Command timed out waiting for events {expected_events}") return Event(EventType.ERROR, {"reason": "timeout"}) except Exception as e: logger.debug(f"Command error: {e}") return Event(EventType.ERROR, {"error": str(e)}) async def send( self, data: bytes, expected_events: Optional[Union[EventType, List[EventType]]] = None, timeout: Optional[float] = None, ) -> Event: """ Send a command and wait for expected event responses. Uses subscribe-before-send to avoid race conditions where the device responds before the event listener is registered. This mirrors the pattern used by the companion apps (JS/iOS/Android). Args: data: The data to send expected_events: EventType or list of EventTypes to wait for timeout: Timeout in seconds, or None to use default_timeout Returns: 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") # Use the provided timeout or fall back to default_timeout timeout = timeout if timeout is not None else self.default_timeout if expected_events: # ── Subscribe BEFORE sending ────────────────────────── # Register event listeners first so we never miss a fast # device response, even on a busy mesh network where the # asyncio event loop processes RX_LOG events in between. if not isinstance(expected_events, list): expected_events = [expected_events] futures: List[asyncio.Future] = [] subscriptions = [] loop = asyncio.get_running_loop() for event_type in expected_events: future = loop.create_future() def _handler(event: Event, f: asyncio.Future = future) -> None: if not f.done(): f.set_result(event) sub = self.dispatcher.subscribe(event_type, _handler) futures.append(future) subscriptions.append(sub) try: # ── Now send the command ────────────────────────── if self._sender_func: logger.debug( f"Sending raw data: " f"{data.hex() if isinstance(data, bytes) else data}" ) await self._sender_func(data) # ── Wait for the first matching event ───────────── done, pending = await asyncio.wait( futures, timeout=timeout, return_when=asyncio.FIRST_COMPLETED, ) # Cancel futures we no longer need for f in pending: f.cancel() # Return the first successfully received event for f in done: try: event = f.result() if event: return event except (asyncio.CancelledError, asyncio.InvalidStateError): pass return Event(EventType.ERROR, {"reason": "no_event_received"}) except asyncio.TimeoutError: logger.debug( f"Command timed out waiting for events {expected_events}" ) return Event(EventType.ERROR, {"reason": "timeout"}) except Exception as e: logger.debug(f"Command error: {e}") return Event(EventType.ERROR, {"error": str(e)}) finally: # Always clean up subscriptions for sub in subscriptions: sub.unsubscribe() else: # Fire-and-forget commands (no expected response) if self._sender_func: logger.debug( f"Sending raw data: " f"{data.hex() if isinstance(data, bytes) else data}" ) await self._sender_func(data) return Event(EventType.OK, {}) # attached at base because its a common method async def send_binary_req(self, dst: DestinationType, request_type: BinaryReqType, data: Optional[bytes] = None, context={}, timeout=None, min_timeout=0) -> Event: dst_bytes = _validate_destination(dst, prefix_length=32) pubkey_prefix = _validate_destination(dst, prefix_length=6) logger.debug(f"Binary request to {dst_bytes.hex()}") data = b"\x32" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + (data if data else b"") # Pre-register a placeholder binary request before send() to close the race # window where a BINARY_RESPONSE could arrive between send() returning and # registration. The placeholder tag is patched to the real tag once MSG_SENT # returns. If send() fails, the placeholder is cleaned up. placeholder_tag = None if self._reader is not None and request_type is not None: placeholder_tag = f"_pending_{id(data)}" actual_timeout = timeout if timeout is not None and timeout > 0 else self.default_timeout actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout self._reader.register_binary_request(pubkey_prefix.hex(), placeholder_tag, request_type, actual_timeout, context=context) result = await self.send(data, [EventType.MSG_SENT, EventType.ERROR]) # Patch the placeholder tag with the real tag from MSG_SENT, or clean up on failure if placeholder_tag is not None and self._reader is not None: # Remove the placeholder entry self._reader.pending_binary_requests.pop(placeholder_tag, None) if (result.type == EventType.MSG_SENT and request_type is not None): exp_tag = result.payload["expected_ack"].hex() # Use suggested_timeout from the result if available actual_timeout = timeout if timeout is not None and timeout > 0 else result.payload.get("suggested_timeout", 4000) / 800.0 actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout # Register with the real tag self._reader.register_binary_request(pubkey_prefix.hex(), exp_tag, request_type, actual_timeout, context=context) return result async def send_anon_req(self, dst: DestinationType, request_type: AnonReqType, data: Optional[bytes] = None, context={}, timeout=None, min_timeout=0) -> Event: dst_bytes = _validate_destination(dst, prefix_length=32) pubkey_prefix = _validate_destination(dst, prefix_length=6) logger.debug(f"Anon Binary request to {dst_bytes.hex()}") 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: logger.info("No path set trying zero hop") zero_hop = True await self.change_contact_path(contact, "") data = contact["out_path_len"].to_bytes(1, "little") + bytes.fromhex(contact["out_path"])[::-1] data = b"\x39" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + (data if data else b"") result = await self.send(data, [EventType.MSG_SENT, EventType.ERROR]) # Register the request with the reader if we have both reader and request_type if (result.type == EventType.MSG_SENT and self._reader is not None and request_type is not None): exp_tag = result.payload["expected_ack"].hex() # Use provided timeout or fallback to suggested timeout (with 5s default) result.payload["suggested_timeout"] = result.payload.get("suggested_timeout", 4000) * (contact["out_path_len"] + 1) # update timeout from path_len actual_timeout = timeout if timeout is not None and timeout > 0 else result.payload.get("suggested_timeout", 4000) / 800.0 actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout self._reader.register_binary_request(pubkey_prefix.hex(), exp_tag, request_type, actual_timeout, context=context, is_anon=True) if zero_hop: await self.reset_path(contact) return result meshcore-2.3.7/src/meshcore/commands/__init__.py0000644000175000017500000000103013615410400021166 0ustar winniewinniefrom typing import Any, Optional from ..events import EventDispatcher from ..reader import MessageReader from .base import CommandHandlerBase from .binary import BinaryCommandHandler from .contact import ContactCommands from .device import DeviceCommands from .messaging import MessagingCommands from .control_data import ControlDataCommandHandler class CommandHandler( DeviceCommands, ContactCommands, MessagingCommands, BinaryCommandHandler, ControlDataCommandHandler ): pass __all__ = ["CommandHandler"] meshcore-2.3.7/src/meshcore/tcp_cx.py0000644000175000017500000001344613615410400017124 0ustar winniewinnie""" mccli.py : CLI interface to MeschCore BLE companion app """ import asyncio import logging # Get logger logger = logging.getLogger("meshcore") # TCP disconnect detection threshold TCP_DISCONNECT_THRESHOLD = 5 class TCPConnection: def __init__(self, host, port): self.host = host self.port = port self.transport = None self.frame_started = False self._disconnect_callback = None self._send_count = 0 self._receive_count = 0 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): self.cx = cx def connection_made(self, transport): self.cx.transport = transport # Reset counters on new connection self.cx._send_count = 0 self.cx._receive_count = 0 logger.debug("connection established") def data_received(self, data): logger.debug("data received") self.cx.handle_rx(data) def error_received(self, exc): logger.error(f"Error received: {exc}") def connection_lost(self, exc): logger.debug("TCP server closed the connection") if self.cx._disconnect_callback: self.cx._spawn_background(self.cx._disconnect_callback("tcp_disconnect")) async def connect(self): """ Connects to the device """ loop = asyncio.get_running_loop() await loop.create_connection( lambda: self.MCClientProtocol(self), self.host, self.port ) logger.info("TCP Connection started") return self.host def set_reader(self, reader): self.reader = reader def handle_rx(self, data: bytearray): if len(self.header) == 0: # did not find start of frame yet # search start of frame (0x3e) in data idx = data.find(b"\x3e") if idx < 0: # no start of frame return # Discard any leading junk bytes before the actual frame marker. # Some radios interleave console/debug text on the same UART, so # valid companion frames may begin at an offset inside the chunk. data = data[idx:] self.header = data[0:1] data = data[1:] if len(self.header) < 3: # header not complete yet while len(self.header) < 3 and len(data) > 0: self.header = self.header + data[0:1] data = data[1:] if len(self.header) < 3: # still not complete return # get size and check self.frame_expected_size = int.from_bytes(self.header[1:], "little", signed=False) if self.frame_expected_size > 300 : # invalid size # reset inframe self.header = b"" self.inframe = b"" self.frame_expected_size = 0 if len(data) > 0: # rerun handle_rx on remaining data self.handle_rx(data) return # nothing left to process after reset upbound = self.frame_expected_size - len(self.inframe) if len(data) < upbound : self.inframe = self.inframe + data # frame not complete, wait for next rx return 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 self._spawn_background(self.reader.handle_rx(self.inframe)) # reset inframe self.inframe = b"" self.header = b"" self.frame_expected_size = 0 if len(data) > 0: # rerun handle_rx on remaining data self.handle_rx(data) 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("tcp_transport_lost") return self._send_count += 1 # Check if we've sent packets without any responses if self._send_count - self._receive_count >= TCP_DISCONNECT_THRESHOLD: logger.debug( f"TCP disconnect detected: sent {self._send_count}, received {self._receive_count}" ) if self._disconnect_callback: await self._disconnect_callback("tcp_no_response") return size = len(data) pkt = b"\x3c" + size.to_bytes(2, byteorder="little") + data logger.debug(f"sending pkt : {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.""" if self.transport: self.transport.close() self.transport = None logger.debug("TCP Connection closed") def set_disconnect_callback(self, callback): """Set callback to handle disconnections.""" self._disconnect_callback = callback meshcore-2.3.7/src/meshcore/lpp_json_encoder.py0000644000175000017500000000473013615410400021163 0ustar winniewinniefrom cayennelpp import LppFrame, LppData from cayennelpp.lpp_type import LppType # Format : type name "how to display value" # display : None: (use lib default), []: only one value to display, ["field1", "field2" ...]: meaning of each field my_lpp_types = { 0: ("digital input", []), 1: ("digital output", []), 2: ("analog input", []), 3: ("analog output", []), 100: ("generic sensor", []), 101: ("illuminance", []), 102: ("presence", []), 103: ("temperature", []), 104: ("humidity", []), 113: ("accelerometer", ["acc_x", "acc_y", "acc_z"]), 115: ("barometer", []), 116: ("voltage", []), 117: ("current", []), 118: ("frequency", []), 120: ("percentage", []), 121: ("altitude", []), 122: ("load", []), 125: ("concentration", []), 128: ("power", []), 130: ("distance", []), 131: ("energy", []), 132: ("direction", None), 133: ("time", []), 134: ("gyrometer", None), 135: ("colour", ["red", "green", "blue"]), 136: ("gps", ["latitude", "longitude", "altitude"]), 142: ("switch", []), } def lpp_format_val(type, val): # Fix signed wrap for LPP Current (type 117) if type.type == 117 and len(val) >= 1: v = val[0] # LPP current resolution is typically 0.001 A over uint16 range (0..65.535 A) # Reinterpret values above signed max as negative if v > 32.767: v -= 65.536 return round(v, 3) # Same for voltage (type 116) if type.type == 116 and len(val) >= 1: v = val[0] # LPP voltage resolution is typically 0.01 V over uint16 range (0..65.535 A) # Reinterpret values above signed max as negative if v > 327.67: v -= 655.36 return round(v, 2) if my_lpp_types[type.type][1] is None: return val if len(my_lpp_types[type.type][1]) == 0: return val[0] val_dict = {} i = 0 for t in my_lpp_types[type.type][1]: val_dict[t] = val[i] i = i + 1 return val_dict def lpp_json_encoder(obj, types=my_lpp_types): """Encode LppType, LppData, and LppFrame to JSON.""" if isinstance(obj, LppFrame): return obj.data if isinstance(obj, LppType): return my_lpp_types[obj.type][0] if isinstance(obj, LppData): return { "channel": obj.channel, "type": obj.type, "value": lpp_format_val(obj.type, obj.value), } raise TypeError(repr(obj) + " is not JSON serialized") meshcore-2.3.7/src/meshcore/__init__.py0000644000175000017500000000113213615410400017370 0ustar winniewinnie"""A library for communicating with meshcore devices.""" import logging from .ble_cx import BLEConnection from .connection_manager import ConnectionManager from .events import EventType from .meshcore import MeshCore from .packets import BinaryReqType from .serial_cx import SerialConnection from .tcp_cx import TCPConnection # Setup default logger logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) __all__ = [ "BinaryReqType", "BLEConnection", "ConnectionManager", "EventType", "MeshCore", "SerialConnection", "TCPConnection", "logger", ] meshcore-2.3.7/src/meshcore/meshcore.py0000644000175000017500000004217013615410400017445 0ustar winniewinnieimport asyncio import logging from typing import Any, Callable, Dict, Optional, Union from .events import Event, EventDispatcher, EventType, Subscription from .reader import MessageReader from .commands import CommandHandler from .connection_manager import ConnectionManager from .ble_cx import BLEConnection from .tcp_cx import TCPConnection from .serial_cx import SerialConnection # Setup default logger logger = logging.getLogger("meshcore") class MeshCore: """ Interface to a MeshCore device """ def __init__( self, cx: Union[BLEConnection, TCPConnection, SerialConnection], debug: bool = False, only_error: bool = False, default_timeout: Optional[float] = None, auto_reconnect: bool = False, max_reconnect_attempts: int = 3, ): # 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, reconnect_callback=self._on_reconnect, ) self.cx = self.connection_manager # For backward compatibility self._reader = MessageReader(self.dispatcher) self.commands = CommandHandler(default_timeout=default_timeout) self.commands.set_contact_getter_by_prefix(self.get_contact_by_key_prefix) # Set up logger if debug: logger.setLevel(logging.DEBUG) elif only_error: logger.setLevel(logging.ERROR) else: logger.setLevel(logging.INFO) # Set up connections self.commands.set_connection(self.connection_manager) # Set the dispatcher in the command handler self.commands.set_dispatcher(self.dispatcher) self.commands.set_reader(self._reader) # Initialize state (private) self._contacts = {} self._contacts_dirty = True self._pending_contacts = {} self._self_info = {} self._time = 0 self._lastmod = 0 self._auto_update_contacts = False # Set up event subscriptions to track data self._setup_data_tracking() self.connection_manager.set_reader(self._reader) # Set up disconnect callback cx.set_disconnect_callback(self.connection_manager.handle_disconnect) @classmethod async def create_tcp( cls, host: str, port: int, debug: bool = False, only_error: bool = False, default_timeout=None, auto_reconnect: bool = False, max_reconnect_attempts: int = 3, ) -> "MeshCore": """Create and connect a MeshCore instance using TCP connection""" connection = TCPConnection(host, port) mc = cls( connection, debug=debug, only_error=only_error, default_timeout=default_timeout, auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, ) res = await mc.connect() if res is None: logger.error("No response from meshcore node, disconnecting") await mc.disconnect() return None return mc @classmethod async def create_serial( cls, port: str, baudrate: int = 115200, debug: bool = False, only_error: bool = False, default_timeout=None, auto_reconnect: bool = False, max_reconnect_attempts: int = 3, cx_dly: float = 0.1, ) -> "MeshCore": """Create and connect a MeshCore instance using serial connection""" connection = SerialConnection(port, baudrate, cx_dly=cx_dly) mc = cls( connection, debug=debug, only_error=only_error, default_timeout=default_timeout, auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, ) res = await mc.connect() if res is None: logger.error("No response from meshcore node, disconnecting") logger.error("Are you sure your node is a serial companion ?") await mc.disconnect() return None return mc @classmethod async def create_ble( cls, address: Optional[str] = None, client=None, device=None, pin: Optional[str] = None, debug: bool = False, only_error: bool = False, default_timeout=None, auto_reconnect: bool = False, max_reconnect_attempts: int = 3, ) -> "MeshCore": """ Create and connect a MeshCore instance using BLE connection. Args: address (str, optional): The Bluetooth address of the device. client (BleakClient, optional): An existing BleakClient instance to use. If provided, 'address' is ignored for connection but can be used for identification. device (BLEDevice, optional): A BLEDevice instance to use for connection. pin (str, optional): PIN for BLE pairing authentication. """ connection = BLEConnection(address=address, client=client, device=device, pin=pin) mc = cls( connection, debug=debug, only_error=only_error, default_timeout=default_timeout, auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, ) res = await mc.connect() if res is None: logger.error("No response from meshcore node, disconnecting") await mc.disconnect() 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() if result is None: await self.dispatcher.stop() raise ConnectionError("Failed to connect to device") res = await self.commands.send_appstart() if res is None or res.type == EventType.ERROR: return None return res async def disconnect(self): """Disconnect from the device and clean up resources.""" # First stop the dispatcher to prevent any new events await self.dispatcher.stop() # Stop auto message fetching if it's running if hasattr(self, "_auto_fetch_subscription") and self._auto_fetch_subscription: await self.stop_auto_message_fetching() # Disconnect the connection object await self.connection_manager.disconnect() def stop(self): """Synchronously stop the event dispatcher task""" if self.dispatcher._task and not self.dispatcher._task.done(): self.dispatcher.running = False self.dispatcher._task.cancel() def subscribe( self, event_type: Union[EventType, None], callback: Callable[[Event], Union[None, asyncio.Future]], attribute_filters: Optional[Dict[str, Any]] = None, ) -> Subscription: """ Subscribe to events using EventType enum with optional attribute filtering Args: event_type: Type of event to subscribe to, from EventType enum callback: Async function to call when event occurs attribute_filters: Dictionary of attribute key-value pairs that must match for the event to trigger the callback Returns: Subscription object that can be used to unsubscribe Example: # Subscribe to ACK events where the 'code' attribute has a specific value mc.subscribe( EventType.ACK, my_callback_function, attribute_filters={'code': 'SUCCESS'} ) """ return self.dispatcher.subscribe(event_type, callback, attribute_filters) def unsubscribe(self, subscription: Subscription) -> None: """ Unsubscribe from events using a subscription object Args: subscription: Subscription object returned from subscribe() """ if subscription: subscription.unsubscribe() async def wait_for_event( self, event_type: EventType, attribute_filters: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None, ) -> Optional[Event]: """ Wait for an event using EventType enum with optional attribute filtering Args: event_type: Type of event to wait for, from EventType enum attribute_filters: Dictionary of attribute key-value pairs to match against the event timeout: Maximum time to wait in seconds, or None to use default_timeout Returns: Event object or None if timeout Example: # Wait for an ACK event where the 'code' attribute has a specific value await mc.wait_for_event( EventType.ACK, attribute_filters={'code': 'SUCCESS'}, timeout=30.0 ) """ # Use the provided timeout or fall back to default_timeout if timeout is None: timeout = self.default_timeout return await self.dispatcher.wait_for_event( event_type, attribute_filters, timeout ) def _setup_data_tracking(self): """Set up event subscriptions to track data internally""" async def _update_contacts(event): # self._contacts.update(event.payload) for c in event.payload.values(): if c["public_key"] in self._contacts: self._contacts[c["public_key"]].update(c) else: self._contacts[c["public_key"]] = c if "lastmod" in event.attributes: self._lastmod = event.attributes["lastmod"] self._contacts_dirty = False async def _add_pending_contact(event): c = event.payload self._pending_contacts[c["public_key"]] = c async def _contact_change(event): self._contacts_dirty = True if self._auto_update_contacts: await self.ensure_contacts(follow=True) async def _update_self_info(event): self._self_info = event.payload async def _update_time(event): self._time = event.payload.get("time", 0) # Subscribe to events to update internal state self.subscribe(EventType.CONTACTS, _update_contacts) self.subscribe(EventType.NEW_CONTACT, _add_pending_contact) self.subscribe(EventType.SELF_INFO, _update_self_info) self.subscribe(EventType.CURRENT_TIME, _update_time) self.subscribe(EventType.ADVERTISEMENT, _contact_change) self.subscribe(EventType.PATH_UPDATE, _contact_change) # Getter methods for state @property def contacts(self) -> Dict[str, Any]: """Get the current contacts""" return self._contacts @property def contacts_dirty(self) -> bool: """Get wether contact list is in sync""" return self._contacts_dirty @property def auto_update_contacts(self) -> bool: """Get wether contact list is in sync""" return self._auto_update_contacts @auto_update_contacts.setter def auto_update_contacts(self, value: bool) -> None: self._auto_update_contacts = value @property def self_info(self) -> Dict[str, Any]: """Get device self info""" return self._self_info @property def time(self) -> int: """Get the current device time""" return self._time @property def is_connected(self) -> bool: """Check if the connection is active""" return self.connection_manager.is_connected @property def default_timeout(self) -> float: """Get the default timeout for commands""" return self.commands.default_timeout @default_timeout.setter def default_timeout(self, value: float) -> None: """Set the default timeout for commands""" self.commands.default_timeout = value @property def pending_contacts(self) -> Dict[str, Any]: """Get pending contacts""" return self._pending_contacts def set_decrypt_channel_logs(self, v): self._reader.decrypt_channels = v def pop_pending_contact(self, key: str) -> Optional[Dict[str, Any]]: return self._pending_contacts.pop(key, None) def flush_pending_contacts(self) -> None: # would be interesting to have a time param self._pending_contacts = {} def get_contact_by_name(self, name: str) -> Optional[Dict[str, Any]]: """ Find a contact by its name (adv_name field) Args: name: The name to search for Returns: Contact dictionary or None if not found """ if not self._contacts: return None for _, contact in self._contacts.items(): if contact.get("adv_name", "").lower() == name.lower(): return contact return None def get_contact_by_key_prefix(self, prefix: str) -> Optional[Dict[str, Any]]: """ Find a contact by its public key prefix Args: prefix: The public key prefix to search for (can be a partial prefix) Returns: Contact dictionary or None if not found """ if not self._contacts or not prefix: return None # Convert the prefix to lowercase for case-insensitive matching prefix = prefix.lower() for contact_id, contact in self._contacts.items(): public_key = contact.get("public_key", "").lower() if public_key.startswith(prefix): return contact return None async def start_auto_message_fetching(self) -> Subscription: """ Start automatically fetching messages when messages_waiting events are received. This will continuously check for new messages when the device indicates messages are waiting. """ self._auto_fetch_task = None self._auto_fetch_running = True async def _handle_messages_waiting(event): # Only start a new fetch task if one isn't already running if not self._auto_fetch_task or self._auto_fetch_task.done(): self._auto_fetch_task = asyncio.create_task(_fetch_messages_loop()) async def _fetch_messages_loop(): while self._auto_fetch_running: try: # Request the next message result = await self.commands.get_msg() # If we got a NO_MORE_MSGS event or an error, stop fetching if ( result.type == EventType.NO_MORE_MSGS or result.type == EventType.ERROR ): logger.debug( "No more messages or error occurred, stopping auto-fetch." ) break # Small delay to prevent overwhelming the device await asyncio.sleep(0.1) except Exception as e: logger.error(f"Error fetching messages: {e}") break # Subscribe to MESSAGES_WAITING events self._auto_fetch_subscription = self.subscribe( EventType.MESSAGES_WAITING, _handle_messages_waiting ) # Check for any pending messages immediately await self.commands.get_msg() return self._auto_fetch_subscription async def stop_auto_message_fetching(self): """ Stop automatically fetching messages when messages_waiting events are received. """ if hasattr(self, "_auto_fetch_subscription") and self._auto_fetch_subscription: self.unsubscribe(self._auto_fetch_subscription) self._auto_fetch_subscription = None if hasattr(self, "_auto_fetch_running"): self._auto_fetch_running = False if ( hasattr(self, "_auto_fetch_task") and self._auto_fetch_task and not self._auto_fetch_task.done() ): self._auto_fetch_task.cancel() try: await self._auto_fetch_task # type: ignore except asyncio.CancelledError: pass self._auto_fetch_task = None async def ensure_contacts(self, follow: bool = False) -> bool: """Ensure contacts are fetched""" if not self._contacts or (follow and self._contacts_dirty): await self.commands.get_contacts(lastmod=self._lastmod) return True return False meshcore-2.3.7/src/meshcore/reader.py0000644000175000017500000013644413615410400017112 0ustar winniewinnieimport logging import json import struct import time import io import traceback from typing import Any, Dict from .events import Event, EventType, EventDispatcher, ErrorMessages from .meshcore_parser import MeshcorePacketParser from .packets import BinaryReqType, PacketType, ControlType from .parsing import lpp_parse, lpp_parse_mma, parse_acl, parse_status from cayennelpp import LppFrame, LppData from meshcore.lpp_json_encoder import lpp_json_encoder from Crypto.Hash import SHA256 logger = logging.getLogger("meshcore") class MessageReader: def __init__(self, dispatcher: EventDispatcher): self.dispatcher = dispatcher # We're only keeping state here that's needed for processing # before events are dispatched self.contacts = {} # Temporary storage during contact list building self.contact_nb = 0 # Used for contact processing # Track pending binary requests by tag for proper response parsing self.pending_binary_requests: Dict[str, Dict[str, Any]] = {} # tag -> {request_type, expires_at} self.packet_parser = MeshcorePacketParser() @property def decrypt_channels(self): return self.packet_parser.decrypt_channels @decrypt_channels.setter def decrypt_channels(self, value): self.packet_parser.decrypt_channels = value def register_binary_request(self, prefix: str, tag: str, request_type: BinaryReqType, timeout_seconds: float, context={}, is_anon=False): """Register a pending binary request for proper response parsing""" # Clean up expired requests before adding new one self.cleanup_expired_requests() expires_at = time.time() + timeout_seconds self.pending_binary_requests[tag] = { "request_type": request_type, "pubkey_prefix": prefix, "expires_at": expires_at, "is_anon": is_anon, "context": context # optional info we want to keep from req to resp } logger.debug(f"Registered binary request: tag={tag}, type={request_type}, expires in {timeout_seconds}s") def cleanup_expired_requests(self): """Remove expired binary requests""" current_time = time.time() expired_tags = [ tag for tag, info in self.pending_binary_requests.items() if current_time > info["expires_at"] ] for tag in expired_tags: logger.debug(f"Cleaning up expired binary request: tag={tag}") del self.pending_binary_requests[tag] async def handle_rx(self, data: bytearray): dbuf = io.BytesIO(data) try: packet_type_value = dbuf.read(1)[0] except IndexError as e: logger.warning(f"Received empty packet: {e}") return try: logger.debug(f"Received data: {data.hex()}") # Handle command responses if packet_type_value == PacketType.OK.value: result: Dict[str, Any] = {} if len(data) == 5: result["value"] = int.from_bytes(data[1:5], byteorder="little") # Dispatch event for the OK response await self.dispatcher.dispatch(Event(EventType.OK, result)) elif packet_type_value == PacketType.ERROR.value: if len(data) > 1: result = { "error_code": data[1], } if data[1] in ErrorMessages: result["code_string"] = ErrorMessages[data[1]] else: result = {} # Dispatch event for the ERROR response await self.dispatcher.dispatch(Event(EventType.ERROR, result)) elif packet_type_value == PacketType.CONTACT_START.value: self.contact_nb = int.from_bytes(data[1:5], byteorder="little") self.contacts = {} elif ( packet_type_value == PacketType.CONTACT.value or packet_type_value == PacketType.PUSH_CODE_NEW_ADVERT.value ): c = {} c["public_key"] = dbuf.read(32).hex() c["type"] = dbuf.read(1)[0] c["flags"] = dbuf.read(1)[0] plen = int.from_bytes(dbuf.read(1), signed=False, byteorder="little") if plen == 255: # flood c["out_path_hash_mode"] = -1 c["out_path_len"] = -1 # 6 LSB else: c["out_path_hash_mode"] = plen >> 6 c["out_path_len"] = plen & 0x3F # 6 LSB c["out_path"] = dbuf.read(64).replace(b"\0", b"").hex() c["adv_name"] = dbuf.read(32).decode("utf-8", "ignore").replace("\0", "") c["last_advert"] = int.from_bytes(dbuf.read(4), byteorder="little") c["adv_lat"] = ( int.from_bytes(dbuf.read(4), byteorder="little", signed=True) / 1e6 ) c["adv_lon"] = ( int.from_bytes(dbuf.read(4), byteorder="little", signed=True) / 1e6 ) c["lastmod"] = int.from_bytes(dbuf.read(4), byteorder="little") if packet_type_value == PacketType.PUSH_CODE_NEW_ADVERT.value: await self.dispatcher.dispatch(Event(EventType.NEW_CONTACT, c)) else: await self.dispatcher.dispatch(Event(EventType.NEXT_CONTACT, c)) self.contacts[c["public_key"]] = c elif packet_type_value == PacketType.ADVERT_PATH.value : r = {} r["timestamp"] = int.from_bytes(dbuf.read(4), "little", signed=False) plen = int.from_bytes(dbuf.read(1), "little", signed=False) if plen == 255: # flood, should not happen r["path_hash_mode"] = -1 r["path_len"] = -1 else: r["path_hash_mode"] = plen >> 6 # 2 upper bytes r["path_len"] = plen & 0x3F r["path"] = dbuf.read().replace(b"\0", b"").hex() await self.dispatcher.dispatch(Event(EventType.ADVERT_PATH, r)) elif packet_type_value == PacketType.CONTACT_END.value: lastmod = int.from_bytes(dbuf.read(4), byteorder="little") attributes = { "lastmod": lastmod, } await self.dispatcher.dispatch( Event(EventType.CONTACTS, self.contacts, attributes) ) elif packet_type_value == PacketType.SELF_INFO.value: self_info = {} self_info["adv_type"] = dbuf.read(1)[0] self_info["tx_power"] = dbuf.read(1)[0] self_info["max_tx_power"] = dbuf.read(1)[0] self_info["public_key"] = dbuf.read(32).hex() self_info["adv_lat"] = ( int.from_bytes(dbuf.read(4), byteorder="little", signed=True) / 1e6 ) self_info["adv_lon"] = ( int.from_bytes(dbuf.read(4), byteorder="little", signed=True) / 1e6 ) self_info["multi_acks"] = dbuf.read(1)[0] self_info["adv_loc_policy"] = dbuf.read(1)[0] telemetry_mode = dbuf.read(1)[0] self_info["telemetry_mode_env"] = (telemetry_mode >> 4) & 0b11 self_info["telemetry_mode_loc"] = (telemetry_mode >> 2) & 0b11 self_info["telemetry_mode_base"] = (telemetry_mode) & 0b11 self_info["manual_add_contacts"] = dbuf.read(1)[0] > 0 self_info["radio_freq"] = ( int.from_bytes(dbuf.read(4), byteorder="little") / 1000 ) self_info["radio_bw"] = ( int.from_bytes(dbuf.read(4), byteorder="little") / 1000 ) self_info["radio_sf"] = dbuf.read(1)[0] self_info["radio_cr"] = dbuf.read(1)[0] self_info["name"] = dbuf.read().decode("utf-8", "ignore") await self.dispatcher.dispatch(Event(EventType.SELF_INFO, self_info)) elif packet_type_value == PacketType.DEFAULT_FLOOD_SCOPE.value: res = {} res["scope_name"] = dbuf.read(31).decode("utf-8", "ignore").replace("\0", "") res["scope_key"] = dbuf.read(16).hex() await self.dispatcher.dispatch(Event(EventType.DEFAULT_FLOOD_SCOPE, res)) elif packet_type_value == PacketType.MSG_SENT.value: res = {} res["type"] = dbuf.read(1)[0] res["expected_ack"] = dbuf.read(4) res["suggested_timeout"] = int.from_bytes(dbuf.read(4), byteorder="little") attributes = { "type": res["type"], "expected_ack": res["expected_ack"].hex(), } await self.dispatcher.dispatch(Event(EventType.MSG_SENT, res, attributes)) elif packet_type_value == PacketType.CONTACT_MSG_RECV.value: res = {} res["type"] = "PRIV" res["pubkey_prefix"] = dbuf.read(6).hex() plen = dbuf.read(1)[0] if plen == 255 : # direct message res["path_hash_mode"] = -1 res["path_len"] = plen else: res["path_hash_mode"] = plen >> 6 res["path_len"] = plen & 0x3F txt_type = dbuf.read(1)[0] res["txt_type"] = txt_type res["sender_timestamp"] = int.from_bytes(dbuf.read(4), byteorder="little") if txt_type == 2: res["signature"] = dbuf.read(4).hex() res["text"] = dbuf.read().decode("utf-8", "ignore") attributes = { "pubkey_prefix": res["pubkey_prefix"], "txt_type": res["txt_type"], } evt_type = EventType.CONTACT_MSG_RECV await self.dispatcher.dispatch(Event(evt_type, res, attributes)) elif packet_type_value == PacketType.CONTACT_MSG_RECV_V3.value: # A reply to CMD_SYNC_NEXT_MESSAGE (ver >= 3) res = {} res["type"] = "PRIV" res["SNR"] = int.from_bytes(dbuf.read(1), byteorder="little", signed=True) / 4 dbuf.read(2) # reserved res["pubkey_prefix"] = dbuf.read(6).hex() plen = dbuf.read(1)[0] if plen == 255 : # direct message res["path_hash_mode"] = -1 res["path_len"] = plen else: res["path_hash_mode"] = plen >> 6 res["path_len"] = plen & 0x3F txt_type = dbuf.read(1)[0] res["txt_type"] = txt_type res["sender_timestamp"] = int.from_bytes(dbuf.read(4), byteorder="little") if txt_type == 2: res["signature"] = dbuf.read(4).hex() res["text"] = dbuf.read().decode("utf-8", "ignore") attributes = { "pubkey_prefix": res["pubkey_prefix"], "txt_type": res["txt_type"], } await self.dispatcher.dispatch( Event(EventType.CONTACT_MSG_RECV, res, attributes) ) elif packet_type_value == PacketType.CHANNEL_MSG_RECV.value: res = {} res["type"] = "CHAN" res["channel_idx"] = dbuf.read(1)[0] plen = dbuf.read(1)[0] if plen == 255 : # direct message res["path_hash_mode"] = -1 res["path_len"] = plen else: res["path_hash_mode"] = plen >> 6 res["path_len"] = plen & 0x3F res["txt_type"] = dbuf.read(1)[0] res["sender_timestamp"] = int.from_bytes(dbuf.read(4), byteorder="little", signed=False) text = dbuf.read().strip(b"\0") res["text"] = text.decode("utf-8", "ignore") # search for text in log_channels txt_hash = int.from_bytes(SHA256.new(res["sender_timestamp"].to_bytes(4, "little", signed=False)+text).digest()[0:4], "little", signed=False) if self.decrypt_channels: logged = await self.packet_parser.findLogChannelMsg(txt_hash) if not logged is None: res["path"] = logged["path"] res["RSSI"] = logged["rssi"] res["SNR"] = logged["snr"] res["recv_time"] = logged["recv_time"] res["attempt"] = logged["attempt"] attributes = { "channel_idx": res["channel_idx"], "txt_type": res["txt_type"], } await self.dispatcher.dispatch( Event(EventType.CHANNEL_MSG_RECV, res, attributes) ) elif packet_type_value == PacketType.CHANNEL_MSG_RECV_V3.value: # A reply to CMD_SYNC_NEXT_MESSAGE (ver >= 3) res = {} res["type"] = "CHAN" res["SNR"] = int.from_bytes(dbuf.read(1), byteorder="little", signed=True) / 4 dbuf.read(2) # reserved res["channel_idx"] = dbuf.read(1)[0] plen = dbuf.read(1)[0] if plen == 255 : # direct message res["path_hash_mode"] = -1 res["path_len"] = plen else: res["path_hash_mode"] = plen >> 6 res["path_len"] = plen & 0x3F res["txt_type"] = dbuf.read(1)[0] res["sender_timestamp"] = int.from_bytes(dbuf.read(4), byteorder="little", signed=False) text = dbuf.read() res["text"] = text.decode("utf-8", "ignore") # search for text in log_channels if self.decrypt_channels: txt_hash = int.from_bytes(SHA256.new(res["sender_timestamp"].to_bytes(4, "little", signed=False)+text).digest()[0:4], "little", signed=False) res["txt_hash"] = txt_hash logged = await self.packet_parser.findLogChannelMsg(txt_hash) if not logged is None: res["path"] = logged["path"] res["RSSI"] = logged["rssi"] res["recv_time"] = logged["recv_time"] res["attempt"] = logged["attempt"] attributes = { "channel_idx": res["channel_idx"], "txt_type": res["txt_type"], } await self.dispatcher.dispatch( Event(EventType.CHANNEL_MSG_RECV, res, attributes) ) elif packet_type_value == PacketType.CURRENT_TIME.value: time_value = int.from_bytes(dbuf.read(4), byteorder="little") result = {"time": time_value} await self.dispatcher.dispatch(Event(EventType.CURRENT_TIME, result)) elif packet_type_value == PacketType.NO_MORE_MSGS.value: result = {"messages_available": False} await self.dispatcher.dispatch(Event(EventType.NO_MORE_MSGS, result)) elif packet_type_value == PacketType.CONTACT_URI.value: contact_uri = "meshcore://" + dbuf.read().hex() result = {"uri": contact_uri} await self.dispatcher.dispatch(Event(EventType.CONTACT_URI, result)) elif packet_type_value == PacketType.BATTERY.value: # Full RESP_CODE_BATT_AND_STORAGE: 1 type + 2 level + 4 used_kb + 4 total_kb = 11 bytes. # Minimum viable frame is 3 bytes (type + level). Shorter frames are # malformed — dbuf.read(2) would return short bytes and # int.from_bytes(b"", ...) silently yields 0 (same class as N07). if len(data) < 3: logger.debug( "BATTERY frame too short for level field " f"({len(data)} bytes < 3), skipping" ) return battery_level = int.from_bytes(dbuf.read(2), byteorder="little") result = {"level": battery_level} # The previous `len(data) > 3` guard let 4-10 byte truncated frames # through, producing silent zero values for used_kb/total_kb because # io.BytesIO.read() returns short data without raising. if len(data) >= 11: # has storage info as well result["used_kb"] = int.from_bytes(dbuf.read(4), byteorder="little") result["total_kb"] = int.from_bytes(dbuf.read(4), byteorder="little") await self.dispatcher.dispatch(Event(EventType.BATTERY, result)) elif packet_type_value == PacketType.DEVICE_INFO.value: res = {} fw_ver = dbuf.read(1)[0] res["fw ver"] = fw_ver if fw_ver >= 3: res["max_contacts"] = dbuf.read(1)[0] * 2 res["max_channels"] = dbuf.read(1)[0] res["ble_pin"] = int.from_bytes(dbuf.read(4), byteorder="little") res["fw_build"] = dbuf.read(12).decode("utf-8", "ignore").replace("\0", "") res["model"] = dbuf.read(40).decode("utf-8", "ignore").replace("\0", "") res["ver"] = dbuf.read(20).decode("utf-8", "ignore").replace("\0", "") if fw_ver >= 9: # has repeater mode rpt = dbuf.read(1) if len(rpt) > 0: res["repeat"] = (rpt[0] != 0) if fw_ver >= 10: # has path_hash_mode path_hash_mode = dbuf.read(1)[0] res["path_hash_mode"] = path_hash_mode await self.dispatcher.dispatch(Event(EventType.DEVICE_INFO, res)) elif packet_type_value == PacketType.CUSTOM_VARS.value: logger.debug(f"received custom vars response: {data.hex()}") res = {} rawdata = dbuf.read().decode("utf-8", "ignore") if not rawdata == "": pairs = rawdata.split(",") for p in pairs: psplit = p.split(":") res[psplit[0]] = psplit[1] logger.debug(f"got custom vars : {res}") await self.dispatcher.dispatch(Event(EventType.CUSTOM_VARS, res)) elif packet_type_value == PacketType.STATS.value: # RESP_CODE_STATS (24) logger.debug(f"received stats response: {data.hex()}") # RESP_CODE_STATS: All stats responses use code 24 with sub-type byte # Byte 0: response_code (24), Byte 1: stats_type (0=core, 1=radio, 2=packets) if len(data) < 2: logger.error(f"Stats response too short: {len(data)} bytes, need at least 2 for header") await self.dispatcher.dispatch(Event(EventType.ERROR, {"reason": "invalid_frame_length"})) return stats_type = data[1] if stats_type == 0: # STATS_TYPE_CORE # RESP_CODE_STATS + STATS_TYPE_CORE: 11 bytes total # Format: = 30: (recv_errors,) = struct.unpack('= 0: res["channel_name"] = name_bytes[:null_pos].decode("utf-8", "ignore") else: res["channel_name"] = name_bytes.decode("utf-8", "ignore") res["channel_secret"] = dbuf.read(16) res["channel_hash"] = SHA256.new(res["channel_secret"]).hexdigest()[0:2] await self.packet_parser.newChannel(res) await self.dispatcher.dispatch(Event(EventType.CHANNEL_INFO, res, res)) # Push notifications elif packet_type_value == PacketType.ADVERTISEMENT.value: logger.debug("Advertisement received") res = {} res["public_key"] = dbuf.read(32).hex() await self.dispatcher.dispatch(Event(EventType.ADVERTISEMENT, res, res)) elif packet_type_value == PacketType.PATH_UPDATE.value: logger.debug("Code path update") res = {} res["public_key"] = dbuf.read(32).hex() await self.dispatcher.dispatch(Event(EventType.PATH_UPDATE, res, res)) elif packet_type_value == PacketType.ACK.value: logger.debug("Received ACK") ack_data = {} if len(data) >= 5: ack_data["code"] = dbuf.read(4).hex() attributes = {"code": ack_data.get("code", "")} await self.dispatcher.dispatch(Event(EventType.ACK, ack_data, attributes)) elif packet_type_value == PacketType.MESSAGES_WAITING.value: logger.debug("Msgs are waiting") await self.dispatcher.dispatch(Event(EventType.MESSAGES_WAITING, {})) elif packet_type_value == PacketType.RAW_DATA.value: res = {} res["SNR"] = int.from_bytes(dbuf.read(1), byteorder="little", signed=True) / 4 res["RSSI"] = int.from_bytes(dbuf.read(1), byteorder="little", signed=True) res["payload"] = dbuf.read(4).hex() logger.debug("Received raw data") logger.debug(res) await self.dispatcher.dispatch(Event(EventType.RAW_DATA, res)) elif packet_type_value == PacketType.LOGIN_SUCCESS.value: res = {} attributes = {} if len(data) > 1: 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( Event(EventType.LOGIN_SUCCESS, res, attributes) ) elif packet_type_value == PacketType.LOGIN_FAILED.value: res = {} attributes = {} dbuf.read(1) if len(data) > 7: res["pubkey_prefix"] = dbuf.read(6).hex() attributes = {"pubkey_prefix": res.get("pubkey_prefix")} await self.dispatcher.dispatch( Event(EventType.LOGIN_FAILED, res, attributes) ) elif packet_type_value == PacketType.STATUS_RESPONSE.value: # parse_status with offset=8 reads up through data[56:60] # (rx_airtime field), so the full payload is 60 bytes: # 1 type + 1 reserved + 6 pubkey + 52 status fields. The # BINARY_RESPONSE STATUS path below gates with `>= 52` on # the offset-stripped buffer; this gate is the equivalent # for the push path with the 8-byte header included. if len(data) < 60: logger.debug(f"STATUS_RESPONSE push frame too short ({len(data)} bytes < 60), skipping parse") return res = parse_status(data, offset=8) data_hex = data[8:].hex() logger.debug(f"Status response: {data_hex}") attributes = { "pubkey_prefix": res["pubkey_pre"], } await self.dispatcher.dispatch( Event(EventType.STATUS_RESPONSE, res, attributes) ) elif packet_type_value == PacketType.LOG_DATA.value: logger.debug(f"Received RF log data: {data.hex()}") # Parse as raw RX data log_data: Dict[str, Any] = {"raw_hex": data[1:].hex()} attributes = {} recv_time = int(time.time()) log_data["recv_time"] = recv_time attributes["recv_time"] = recv_time # First byte is SNR (signed byte, multiplied by 4) if len(data) > 1: snr_byte = dbuf.read(1)[0] # Convert to signed value snr = (snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0 log_data["snr"] = snr # Second byte is RSSI (signed byte) if len(data) > 2: rssi_byte = dbuf.read(1)[0] # Convert to signed value rssi = rssi_byte if rssi_byte < 128 else rssi_byte - 256 log_data["rssi"] = rssi # Remaining bytes are the raw data payload payload = None if len(data) > 3: payload=dbuf.read() log_data["payload"] = payload.hex() log_data["payload_length"] = len(payload) # Parse payload and get some info from it log_data = await self.packet_parser.parsePacketPayload(payload, log_data) attributes['route_type'] = log_data['route_type'] attributes['payload_type'] = log_data['payload_type'] attributes['path_len'] = log_data['path_len'] attributes['path'] = log_data['path'] # Dispatch as RF log data await self.dispatcher.dispatch( Event(EventType.RX_LOG_DATA, log_data, attributes) ) elif packet_type_value == PacketType.TRACE_DATA.value: logger.debug(f"Received trace data: {data.hex()}") res = {} # According to the source, format is: # 0x89, reserved(0), path_len, flags, tag(4), auth(4), path_hashes[], path_snrs[], final_snr reserved = dbuf.read(1)[0] path_len = dbuf.read(1)[0] flags = dbuf.read(1)[0] tag = int.from_bytes(dbuf.read(4), byteorder="little") auth_code = int.from_bytes(dbuf.read(4), byteorder="little") path_hash_len = 1 << (flags&3) path_len = int(path_len / path_hash_len) # Initialize result res["tag"] = tag res["auth"] = auth_code res["flags"] = flags res["path_len"] = path_len # Process path as array of objects with hash and SNR path_nodes = [] if path_len > 0 and len(data) >= 12 + path_len + (path_len * path_hash_len) + 1: # Extract path with hash and SNR pairs for i in range(path_len): node = { "hash": dbuf.read(path_hash_len).hex(), } path_nodes.append(node) for n in path_nodes: node_snr = int.from_bytes(dbuf.read(1), byteorder="little", signed=True) n["snr"] = node_snr / 4.0 # Add the final node (our device) with its SNR final_snr = int.from_bytes(dbuf.read(1), byteorder="little", signed=True) / 4.0 path_nodes.append({"snr": final_snr}) res["path"] = path_nodes logger.debug(f"Parsed trace data: {res}") attributes = { "tag": res["tag"], "auth_code": res["auth"], } await self.dispatcher.dispatch(Event(EventType.TRACE_DATA, res, attributes)) elif packet_type_value == PacketType.TELEMETRY_RESPONSE.value: logger.debug(f"Received telemetry data: {data.hex()}") res = {} dbuf.read(1) res["pubkey_pre"] = dbuf.read(6).hex() buf = dbuf.read() """Parse a given byte string and return as a LppFrame object.""" i = 0 lpp_data_list = [] while i < len(buf) and buf[i] != 0: lppdata = LppData.from_bytes(buf[i:]) lpp_data_list.append(lppdata) i = i + len(lppdata) lpp = json.loads( json.dumps(LppFrame(lpp_data_list), default=lpp_json_encoder) ) res["lpp"] = lpp attributes = { "raw": buf.hex(), "pubkey_prefix": res["pubkey_pre"], } await self.dispatcher.dispatch( Event(EventType.TELEMETRY_RESPONSE, res, attributes) ) elif packet_type_value == PacketType.ALLOWED_REPEAT_FREQ.value: res = {} freqs = [] cont = True try: while cont: min = int.from_bytes(dbuf.read(4), "little", signed=False) max = int.from_bytes(dbuf.read(4), "little", signed=False) if min == 0 or max == 0: cont = False else: freqs.append({"min" : min, "max": max}) except Exception as e: logger.warning(f"Error parsing ALLOWED_REPEAT_FREQ payload: {e}") res["freqs"] = freqs await self.dispatcher.dispatch( Event(EventType.ALLOWED_REPEAT_FREQ, res) ) elif packet_type_value == PacketType.BINARY_RESPONSE.value: dbuf.read(1) tag = dbuf.read(4).hex() response_data = dbuf.read() logger.debug(f"Received binary data: {data.hex()}, tag {tag}, data {response_data.hex()}") # Always dispatch generic BINARY_RESPONSE binary_res = {"tag": tag, "data": response_data.hex()} await self.dispatcher.dispatch( Event(EventType.BINARY_RESPONSE, binary_res, {"tag": tag}) ) # Check for tracked request type and dispatch specific response if tag in self.pending_binary_requests: request_type = self.pending_binary_requests[tag]["request_type"] is_anon = self.pending_binary_requests[tag]["is_anon"] pubkey_prefix = self.pending_binary_requests[tag]["pubkey_prefix"] context = self.pending_binary_requests[tag]["context"] del self.pending_binary_requests[tag] logger.debug(f"Processing binary response for tag {tag}, type {request_type}, pubkey_prefix {pubkey_prefix}") if not is_anon: if request_type == BinaryReqType.STATUS and len(response_data) >= 52: res = {} res = parse_status(response_data, pubkey_prefix=pubkey_prefix) await self.dispatcher.dispatch( Event(EventType.STATUS_RESPONSE, res, {"pubkey_prefix": res["pubkey_pre"], "tag": tag}) ) elif request_type == BinaryReqType.TELEMETRY : try: lpp = lpp_parse(response_data) telem_res = {"tag": tag, "lpp": lpp, "pubkey_prefix": pubkey_prefix} await self.dispatcher.dispatch( Event(EventType.TELEMETRY_RESPONSE, telem_res, telem_res) ) except Exception as e: logger.error(f"Error parsing binary telemetry response: {e}") elif request_type == BinaryReqType.MMA: try: mma_result = lpp_parse_mma(response_data[4:]) # Skip 4-byte header mma_res = {"tag": tag, "mma_data": mma_result, "pubkey_prefix": pubkey_prefix} await self.dispatcher.dispatch( Event(EventType.MMA_RESPONSE, mma_res, mma_res) ) except Exception as e: logger.error(f"Error parsing binary MMA response: {e}") elif request_type == BinaryReqType.ACL: try: acl_result = parse_acl(response_data) acl_res = {"tag": tag, "acl_data": acl_result, "pubkey_prefix": pubkey_prefix} await self.dispatcher.dispatch( Event(EventType.ACL_RESPONSE, acl_res, {"tag": tag, "pubkey_prefix": pubkey_prefix}) ) except Exception as e: logger.error(f"Error parsing binary ACL response: {e}") elif request_type == BinaryReqType.NEIGHBOURS: try: pk_plen = context["pubkey_prefix_length"] bbuf = io.BytesIO(response_data) res = { "pubkey_prefix": pubkey_prefix, "tag": tag } res.update(context) # add context in result res["neighbours_count"] = int.from_bytes(bbuf.read(2), "little", signed=True) results_count = int.from_bytes(bbuf.read(2), "little", signed=True) res["results_count"] = results_count neighbours_list = [] for _ in range (results_count): neighb = {} neighb["pubkey"] = bbuf.read(pk_plen).hex() neighb["secs_ago"] = int.from_bytes(bbuf.read(4), "little", signed=True) neighb["snr"] = int.from_bytes(bbuf.read(1), "little", signed=True) / 4 neighbours_list.append(neighb) res["neighbours"] = neighbours_list await self.dispatcher.dispatch( Event(EventType.NEIGHBOURS_RESPONSE, res, {"tag": tag, "pubkey_prefix": pubkey_prefix}) ) except Exception as e: logger.error(f"Error parsing binary NEIGHBOURS response: {e}") else: logger.debug(f"No tracked request found for binary response tag {tag}") elif packet_type_value == PacketType.PATH_DISCOVERY_RESPONSE.value: logger.debug(f"Received path discovery response: {data.hex()}") res = {} dbuf.read(1) res["pubkey_pre"] = dbuf.read(6).hex() opl = dbuf.read(1)[0] opl_hlen = ((opl & 0xc0) >> 6) + 1 opl = opl & 0x3f res["out_path_len"] = opl res["out_path_hash_len"] = opl_hlen res["out_path"] = dbuf.read(opl*opl_hlen).hex() ipl = dbuf.read(1)[0] ipl_hlen = ((ipl & 0xc0) >> 6) + 1 ipl = ipl & 0x3f res["in_path_len"] = ipl res["in_path_hash_len"] = ipl_hlen res["in_path"] = dbuf.read(ipl*ipl_hlen).hex() attributes = {"pubkey_pre": res["pubkey_pre"]} await self.dispatcher.dispatch( Event(EventType.PATH_RESPONSE, res, attributes) ) elif packet_type_value == PacketType.PRIVATE_KEY.value: logger.debug(f"Received private key response: {data.hex()}") if len(data) >= 65: # 1 byte response code + 64 bytes private key private_key = dbuf.read(64) # Extract 64-byte private key res = {"private_key": private_key} await self.dispatcher.dispatch(Event(EventType.PRIVATE_KEY, res)) else: logger.error(f"Invalid private key response length: {len(data)}") elif packet_type_value == PacketType.SIGN_START.value: logger.debug(f"Received sign start response: {data.hex()}") # Payload: 1 reserved byte, 4-byte max length dbuf.read(1) max_len = int.from_bytes(dbuf.read(4), "little") res = {"max_length": max_len} await self.dispatcher.dispatch(Event(EventType.SIGN_START, res)) elif packet_type_value == PacketType.SIGNATURE.value: logger.debug(f"Received signature: {data.hex()}") signature = dbuf.read() res = {"signature": signature} await self.dispatcher.dispatch(Event(EventType.SIGNATURE, res)) elif packet_type_value == PacketType.DISABLED.value: logger.debug("Received disabled response") res = {"reason": "private_key_export_disabled"} await self.dispatcher.dispatch(Event(EventType.DISABLED, res)) elif packet_type_value == PacketType.CONTROL_DATA.value: logger.debug("Received control data packet") res={} res["SNR"] = int.from_bytes(dbuf.read(1), byteorder="little", signed=True) / 4 res["RSSI"] = int.from_bytes(dbuf.read(1), byteorder="little", signed=True) res["path_len"] = dbuf.read(1)[0] payload = dbuf.read() if len(payload) == 0: logger.debug("CONTROL_DATA frame has empty payload, skipping") return payload_type = payload[0] res["payload_type"] = payload_type res["payload"] = payload attributes = {"payload_type": payload_type} await self.dispatcher.dispatch( Event(EventType.CONTROL_DATA, res, attributes) ) # decode NODE_DISCOVER_RESP if payload_type & 0xF0 == ControlType.NODE_DISCOVER_RESP.value: pbuf = io.BytesIO(payload[1:]) ndr = dict(res) del ndr["payload_type"] del ndr["payload"] ndr["node_type"] = payload_type & 0x0F ndr["SNR_in"] = int.from_bytes(pbuf.read(1), byteorder="little", signed=True)/4 ndr["tag"] = pbuf.read(4).hex() pubkey = pbuf.read() if len(pubkey) < 32: pubkey = pubkey[0:8] else: pubkey = pubkey[0:32] ndr["pubkey"] = pubkey.hex() attributes = { "node_type" : ndr["node_type"], "tag" : ndr["tag"], "pubkey" : ndr["pubkey"], } 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}") logger.debug(f"Unhandled packet type: {packet_type_value}") except Exception as e: logger.error( "handle_rx parse error: %s: %s | raw=%s\n%s", type(e).__name__, e, data.hex(), traceback.format_exc(), ) meshcore-2.3.7/src/meshcore/meshcore_parser.py0000644000175000017500000002175713615410400021031 0ustar winniewinnieimport logging import json import time import io from typing import Any, Dict from Crypto.Cipher import AES from Crypto.Hash import HMAC, SHA256 logger = logging.getLogger("meshcore") PAYLOAD_TYPENAMES = ["REQ", "RESPONSE", "TEXT_MSG", "ACK", "ADVERT", "GRP_TXT", "GRP_DATA", "ANON_REQ", "PATH", "TRACE", "MULTIPART", "CONTROL"] ROUTE_TYPENAMES = ["TC_FLOOD", "FLOOD", "DIRECT", "TC_DIRECT"] CONTACT_TYPENAMES = ["NONE","CLI","REP","ROOM","SENS"] class MeshcorePacketParser: def __init__(self): self.channels = [{} for _ in range(40)] # keep our own copy of channels, 40 elements by default self.channels_log = [] # stores the channel msg events self.decrypt_channels = False async def newChannel(self, chan): idx = chan['channel_idx'] if len(self.channels) <= idx: self.channels.extend([{} for _ in range(1 + idx - len(self.channels))]) self.channels[idx] = chan async def findLogChannelMsg(self, msg_hash): # search for the same packet return next((l for l in reversed(self.channels_log) if 'msg_hash' in l and l['msg_hash'] == msg_hash), None) async def findLogChannelPkt(self, pkt_hash): # search for the same packet return next((l for l in reversed(self.channels_log) if 'pkt_hash' in l and l['pkt_hash'] == pkt_hash), None) async def parsePacketPayload(self, payload, log_data={}): """ Parses the payload of a log_rx packet Parameters : log_data: already gathered log_data we'll expand payload: payload of the packet to analyze Returns : completed log_data """ # Minimum viable payload is 2 bytes (1 header + 1 path_byte) for a # direct route. Anything shorter is provably broken — for example, # the LOG_DATA branch in reader.py only requires `len(data) > 3`, # which means a 4-byte LOG_DATA frame produces a 1-byte payload # here, and `path_byte = pbuf.read(1)[0]` further down would raise # IndexError on the empty buffer. Populate sentinel values so the # caller's downstream `log_data['route_type']` etc. lookups don't # KeyError, then return early. if len(payload) < 2: logger.debug(f"parsePacketPayload: payload too short ({len(payload)} bytes < 2), returning sentinel log_data") log_data["route_type"] = -1 log_data["route_typename"] = "UNK" log_data["payload_type"] = -1 log_data["payload_typename"] = "UNK" log_data["payload_ver"] = 0 log_data["path_len"] = 0 log_data["path_hash_size"] = 1 log_data["path"] = "" log_data["pkt_payload"] = b"" log_data["pkt_hash"] = 0 return log_data pbuf = io.BytesIO(payload) header = pbuf.read(1)[0] route_type = header & 0x03 payload_type = (header & 0x3c) >> 2 payload_ver = (header & 0xc0) >> 6 transport_code = None if route_type == 0x00 or route_type == 0x03: # has transport code transport_code = pbuf.read(4) # discard transport code path_byte = pbuf.read(1)[0] path_hash_size = ((path_byte & 0xC0) >> 6) + 1 path_len = (path_byte & 0x3F) # here path_len is number of hops, not number of bytes path = pbuf.read(path_len*path_hash_size).hex() # Beware of traces where pathes are mixed try : route_typename = ROUTE_TYPENAMES[route_type] except IndexError: logger.debug(f"Unknown route type {route_type}") route_typename = "UNK" try : payload_typename = PAYLOAD_TYPENAMES[payload_type] except IndexError: logger.debug(f"Unknown payload type {payload_type}") payload_typename = "UNK" pkt_payload = pbuf.read() pkt_hash = int.from_bytes(SHA256.new(pkt_payload).digest()[0:4], "little", signed=False) log_data["header"] = header log_data["route_type"] = route_type log_data["route_typename"] = route_typename log_data["payload_type"] = payload_type log_data["payload_typename"]= payload_typename log_data["payload_ver"] = payload_ver if not transport_code is None: log_data["transport_code"] = transport_code.hex() log_data["path_len"] = path_len log_data["path_hash_size"] = path_hash_size log_data["path"] = path log_data["pkt_payload"] = pkt_payload log_data["pkt_hash"] = pkt_hash if not payload is None and payload_type == 0x05: # flood msg / channel pk_buf = io.BytesIO(pkt_payload) chan_hash = pk_buf.read(1).hex() cipher_mac = pk_buf.read(2) msg = pk_buf.read() # until the end of buffer channel = None for c in self.channels: if "channel_hash" in c and c["channel_hash"] == chan_hash : # validate against MAC h = HMAC.new(c["channel_secret"], digestmod=SHA256) h.update(msg) if h.digest()[0:2] == cipher_mac: channel = c break log_data["chan_hash"] = chan_hash log_data["cipher_mac"] = cipher_mac.hex() log_data["crypted"] = msg.hex() chan_name = "" if not channel is None : chan_name = channel["channel_name"] log_data["chan_name"] = chan_name if not channel is None and self.decrypt_channels: logged = await self.findLogChannelPkt(pkt_hash) if logged is None: # not found: decrypt the text and hash it aes_key = channel["channel_secret"] cipher = AES.new(aes_key, AES.MODE_ECB) uncrypted = cipher.decrypt(msg) timestamp = int.from_bytes(uncrypted[0:4], "little", signed=False) attempt = uncrypted[4] & 3 txt_type = int.from_bytes(uncrypted[4:5], "little", signed=False) >> 2 message = uncrypted[5:].strip(b"\0") msg_hash = int.from_bytes(SHA256.new(timestamp.to_bytes(4, "little", signed=False) + message).digest()[0:4], "little", signed=False) log_data["message"] = message.decode("utf-8", "ignore") log_data["msg_hash"] = msg_hash log_data["sender_timestamp"] = timestamp log_data["attempt"] = attempt log_data["txt_type"] = txt_type else: # found: copy log_data["message"] = logged["message"] log_data["msg_hash"] = logged["msg_hash"] log_data["sender_timestamp"] = logged["sender_timestamp"] log_data["attempt"] = logged["attempt"] log_data["txt_type"] = logged["txt_type"] self.channels_log.append(log_data) if len(self.channels_log) > 100: del self.channels_log[:25] elif not payload is None and payload_type == 0x04: # Advert try: pk_buf = io.BytesIO(pkt_payload) adv_key = pk_buf.read(32).hex() adv_timestamp = int.from_bytes(pk_buf.read(4), "little", signed=False) signature = pk_buf.read(64).hex() flags = pk_buf.read(1)[0] adv_type = flags & 0x0F adv_lat = None adv_lon = None adv_feat1 = None adv_feat2 = None if flags & 0x10 > 0: #has location adv_lat = int.from_bytes(pk_buf.read(4), "little", signed=True)/1000000.0 adv_lon = int.from_bytes(pk_buf.read(4), "little", signed=True)/1000000.0 if flags & 0x20 > 0: #has feature1 adv_feat1 = pk_buf.read(2).hex() if flags & 0x40 > 0: #has feature2 adv_feat2 = pk_buf.read(2).hex() if flags & 0x80 > 0: #has name adv_name = pk_buf.read().decode("utf-8", "ignore").strip("\x00") log_data["adv_name"] = adv_name log_data["adv_key"] = adv_key log_data["adv_timestamp"] = adv_timestamp log_data["signature"] = signature log_data["adv_flags"] = flags log_data["adv_type"] = adv_type if not adv_lat is None : log_data["adv_lat"] = adv_lat if not adv_lon is None : log_data["adv_lon"] = adv_lon if not adv_feat1 is None: log_data["adv_feat1"] = adv_feat1 if not adv_feat2 is None: log_data["adv_feat2"] = adv_feat2 except (IndexError, ValueError) as e: logger.debug(f"parsePacketPayload: malformed ADVERT payload ({type(e).__name__}: {e}), len={len(pkt_payload)}") return log_data meshcore-2.3.7/src/meshcore/parsing.py0000644000175000017500000001027413615410400017303 0ustar winniewinnieimport logging from enum import Enum import json from cayennelpp import LppFrame, LppData from cayennelpp.lpp_type import LppType from .lpp_json_encoder import lpp_json_encoder, my_lpp_types, lpp_format_val logger = logging.getLogger("meshcore") def lpp_parse(buf): """Parse a given byte string and return as a LppFrame object.""" i = 0 lpp_data_list = [] while i < len(buf) and buf[i] != 0: lppdata = LppData.from_bytes(buf[i:]) lpp_data_list.append(lppdata) i = i + len(lppdata) return json.loads(json.dumps(LppFrame(lpp_data_list), default=lpp_json_encoder)) def lpp_parse_mma(buf): i = 0 res = [] while i < len(buf) and buf[i] != 0: chan = buf[i] i = i + 1 type = buf[i] lpp_type = LppType.get_lpp_type(type) if lpp_type is None: logger.error(f"Unknown LPP type: {type}") return None size = lpp_type.size i = i + 1 min = lpp_format_val(lpp_type, lpp_type.decode(buf[i : i + size])) i = i + size max = lpp_format_val(lpp_type, lpp_type.decode(buf[i : i + size])) i = i + size avg = lpp_format_val(lpp_type, lpp_type.decode(buf[i : i + size])) i = i + size res.append( { "channel": chan, "type": my_lpp_types[type][0], "min": min, "max": max, "avg": avg, } ) return res def parse_acl(buf): i = 0 res = [] while i + 7 <= len(buf): key = buf[i : i + 6].hex() perm = buf[i + 6] if key != "000000000000": res.append({"key": key, "perm": perm}) i = i + 7 return res def parse_status(data, pubkey_prefix=None, offset=0): """ Parse binary data into a dictionary of fields. Args: data: bytes object containing the data to parse pubkey_prefix: Either a string prefix or None (if None, extract from data) offset: Starting offset for field parsing (0 or 8) Returns: Dictionary with parsed fields """ res = {} # Handle pubkey if pubkey_prefix is None: # Extract from data (format 1) res["pubkey_pre"] = data[2:8].hex() offset = 8 # Fields start at offset 8 else: # Use provided prefix (format 2) res["pubkey_pre"] = pubkey_prefix # offset stays as provided (typically 0) # Parse all fields with the given offset res["bat"] = int.from_bytes(data[offset:offset+2], byteorder="little") res["tx_queue_len"] = int.from_bytes(data[offset+2:offset+4], byteorder="little") res["noise_floor"] = int.from_bytes(data[offset+4:offset+6], byteorder="little", signed=True) res["last_rssi"] = int.from_bytes(data[offset+6:offset+8], byteorder="little", signed=True) res["nb_recv"] = int.from_bytes(data[offset+8:offset+12], byteorder="little", signed=False) res["nb_sent"] = int.from_bytes(data[offset+12:offset+16], byteorder="little", signed=False) res["airtime"] = int.from_bytes(data[offset+16:offset+20], byteorder="little") res["uptime"] = int.from_bytes(data[offset+20:offset+24], byteorder="little") res["sent_flood"] = int.from_bytes(data[offset+24:offset+28], byteorder="little") res["sent_direct"] = int.from_bytes(data[offset+28:offset+32], byteorder="little") res["recv_flood"] = int.from_bytes(data[offset+32:offset+36], byteorder="little") res["recv_direct"] = int.from_bytes(data[offset+36:offset+40], byteorder="little") res["full_evts"] = int.from_bytes(data[offset+40:offset+42], byteorder="little") res["last_snr"] = int.from_bytes(data[offset+42:offset+44], byteorder="little", signed=True) / 4 res["direct_dups"] = int.from_bytes(data[offset+44:offset+46], byteorder="little") res["flood_dups"] = int.from_bytes(data[offset+46:offset+48], byteorder="little") res["rx_airtime"] = int.from_bytes(data[offset+48:offset+52], byteorder="little") # n_recv_errors: uint32_t appended in firmware v1.12.0 (56-byte frame) if len(data) >= offset + 56: res["recv_errors"] = int.from_bytes(data[offset+52:offset+56], byteorder="little") else: res["recv_errors"] = None return resmeshcore-2.3.7/pyproject.toml0000644000175000017500000000147513615410400015611 0ustar winniewinnie[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "meshcore" version = "2.3.7" authors = [ { name="Florent de Lamotte", email="florent@frizoncorrea.fr" }, { name="Alex Wolden", email="awolden@gmail.com" }, ] description = "Base classes for communicating with meshcore companion radios" readme = "README.md" requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", "Operating System :: OS Independent", ] license = "MIT" license-files = ["LICEN[CS]E*"] dependencies = [ "bleak", "pyserial-asyncio-fast", "pycayennelpp", "pycryptodome" ] [project.optional-dependencies] dev = ["pytest", "pytest-asyncio", "black", "ruff"] [project.urls] Homepage = "https://github.com/fdlamotte/meshcore_py" Issues = "https://github.com/fdlamotte/meshcore_py/issues" meshcore-2.3.7/LICENSE0000644000175000017500000000206313615410400013674 0ustar winniewinnieMIT License Copyright (c) 2025 Florent de Lamotte Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. meshcore-2.3.7/pytest.ini0000644000175000017500000000001113615410400014707 0ustar winniewinnie[pytest] meshcore-2.3.7/PKG-INFO0000644000175000017500000006537413615410400014002 0ustar winniewinnieMetadata-Version: 2.4 Name: meshcore Version: 2.3.7 Summary: Base classes for communicating with meshcore companion radios Project-URL: Homepage, https://github.com/fdlamotte/meshcore_py Project-URL: Issues, https://github.com/fdlamotte/meshcore_py/issues Author-email: Florent de Lamotte , Alex Wolden License-Expression: MIT License-File: LICENSE Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3 Requires-Python: >=3.10 Requires-Dist: bleak Requires-Dist: pycayennelpp Requires-Dist: pycryptodome Requires-Dist: pyserial-asyncio-fast Provides-Extra: dev Requires-Dist: black; extra == 'dev' Requires-Dist: pytest; extra == 'dev' Requires-Dist: pytest-asyncio; extra == 'dev' Requires-Dist: ruff; extra == 'dev' Description-Content-Type: text/markdown # Python MeshCore Python library for interacting with [MeshCore](https://meshcore.co.uk) companion radio nodes. ## Installation ```bash pip install meshcore ``` ## Quick Start Connect to your device and send a message: ```python import asyncio from meshcore import MeshCore, EventType async def main(): # Connect to your device meshcore = await MeshCore.create_serial("/dev/ttyUSB0") # Get your contacts result = await meshcore.commands.get_contacts() if result.type == EventType.ERROR: print(f"Error getting contacts: {result.payload}") return contacts = result.payload print(f"Found {len(contacts)} contacts") # Send a message to the first contact if contacts: # Get the first contact contact = next(iter(contacts.items()))[1] # Pass the contact object directly to send_msg result = await meshcore.commands.send_msg(contact, "Hello from Python!") if result.type == EventType.ERROR: print(f"Error sending message: {result.payload}") else: print("Message sent successfully!") await meshcore.disconnect() asyncio.run(main()) ``` ## Development Setup To set up for development: ```bash # Create and activate virtual environment python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate # Install in development mode pip install -e . # Run examples python examples/pubsub_example.py -p /dev/ttyUSB0 ``` ## Usage Guide ### Command Return Values All command methods in MeshCore return an `Event` object that contains both the event type and its payload. This allows for consistent error handling and type checking: ```python # Command result structure result = await meshcore.commands.some_command() # Check if the command was successful or resulted in an error if result.type == EventType.ERROR: # Handle error case print(f"Command failed: {result.payload}") else: # Handle success case - the event type will be specific to the command # (e.g., EventType.DEVICE_INFO, EventType.CONTACTS, EventType.MSG_SENT) print(f"Command succeeded with event type: {result.type}") # Access the payload data data = result.payload ``` Common error handling pattern: ```python result = await meshcore.commands.send_msg(contact, "Hello!") if result.type == EventType.ERROR: print(f"Error sending message: {result.payload}") else: # For send_msg, a successful result will have type EventType.MSG_SENT print(f"Message sent with expected ack: {result.payload['expected_ack'].hex()}") ``` ### Connecting to Your Device Connect via Serial, BLE, or TCP: ```python # Serial connection meshcore = await MeshCore.create_serial("/dev/ttyUSB0", 115200, debug=True) # BLE connection (scans for devices if address not provided) meshcore = await MeshCore.create_ble("12:34:56:78:90:AB") # BLE connection with PIN pairing for enhanced security meshcore = await MeshCore.create_ble("12:34:56:78:90:AB", pin="123456") # TCP connection meshcore = await MeshCore.create_tcp("192.168.1.100", 4000) ``` #### BLE PIN Pairing For enhanced security, MeshCore supports BLE PIN pairing. This requires the device to be configured with a PIN and the client to provide the matching PIN during connection: ```python # First configure the device PIN (if not already set) meshcore = await MeshCore.create_ble("12:34:56:78:90:AB") await meshcore.commands.set_devicepin(123456) # Then connect with PIN pairing meshcore = await MeshCore.create_ble("12:34:56:78:90:AB", pin="123456") ``` **PIN Pairing Features:** - Automatic pairing initiation when PIN is provided - Graceful fallback if pairing fails (connection continues if device is already paired) - Compatible with all BLE connection methods (address, scanning, pre-configured client) - Logging of pairing success/failure for debugging **Note:** BLE pairing behavior may vary by platform: - **Linux/Windows**: PIN pairing is fully supported - **macOS**: Pairing may be handled automatically by the system UI #### Auto-Reconnect and Connection Events Enable automatic reconnection when connections are lost: ```python # Enable auto-reconnect with custom retry limits meshcore = await MeshCore.create_tcp( "192.168.1.100", 4000, auto_reconnect=True, max_reconnect_attempts=5 ) # Subscribe to connection events async def on_connected(event): print(f"Connected: {event.payload}") if event.payload.get('reconnected'): print("Successfully reconnected!") async def on_disconnected(event): print(f"Disconnected: {event.payload['reason']}") if event.payload.get('max_attempts_exceeded'): print("Max reconnection attempts exceeded") meshcore.subscribe(EventType.CONNECTED, on_connected) meshcore.subscribe(EventType.DISCONNECTED, on_disconnected) # Check connection status if meshcore.is_connected: print("Device is currently connected") ``` **Auto-reconnect features:** - Exponential backoff (1s, 2s, 4s, 8s max delay) - Configurable retry limits (default: 3 attempts) - Automatic disconnect detection (especially useful for TCP connections) - Connection events with detailed information ### Using Commands (Synchronous Style) Send commands and wait for responses: ```python # Get device information result = await meshcore.commands.send_device_query() if result.type == EventType.ERROR: print(f"Error getting device info: {result.payload}") else: print(f"Device model: {result.payload['model']}") # Get list of contacts result = await meshcore.commands.get_contacts() if result.type == EventType.ERROR: print(f"Error getting contacts: {result.payload}") else: contacts = result.payload for contact_id, contact in contacts.items(): print(f"Contact: {contact['adv_name']} ({contact_id})") # Send a message (destination key in bytes) result = await meshcore.commands.send_msg(dst_key, "Hello!") if result.type == EventType.ERROR: print(f"Error sending message: {result.payload}") # Setting device parameters result = await meshcore.commands.set_name("My Device") if result.type == EventType.ERROR: print(f"Error setting name: {result.payload}") result = await meshcore.commands.set_tx_power(20) # Set transmit power if result.type == EventType.ERROR: print(f"Error setting TX power: {result.payload}") ``` ### Finding Contacts Easily find contacts by name or key: ```python # Find a contact by name contact = meshcore.get_contact_by_name("Bob's Radio") if contact: print(f"Found Bob at: {contact['adv_lat']}, {contact['adv_lon']}") # Find by partial key prefix contact = meshcore.get_contact_by_key_prefix("a1b2c3") ``` ### Event-Based Programming (Asynchronous Style) Subscribe to events to handle them asynchronously: ```python # Subscribe to incoming messages async def handle_message(event): data = event.payload print(f"Message from {data['pubkey_prefix']}: {data['text']}") subscription = meshcore.subscribe(EventType.CONTACT_MSG_RECV, handle_message) # Subscribe to advertisements async def handle_advert(event): print("Advertisement detected!") meshcore.subscribe(EventType.ADVERTISEMENT, handle_advert) # When done, unsubscribe meshcore.unsubscribe(subscription) ``` #### Filtering Events by Attributes Filter events based on their attributes to handle only specific ones: ```python # Subscribe only to messages from a specific contact async def handle_specific_contact_messages(event): print(f"Message from Alice: {event.payload['text']}") contact = meshcore.get_contact_by_name("Alice") if contact: alice_subscription = meshcore.subscribe( EventType.CONTACT_MSG_RECV, handle_specific_contact_messages, attribute_filters={"pubkey_prefix": contact["public_key"][:12]} ) # Send a message and wait for its specific acknowledgment async def send_and_confirm_message(meshcore, dst_key, message): # Send the message and get information about the sent message sent_result = await meshcore.commands.send_msg(dst_key, message) # Extract the expected acknowledgment code from the message sent event if sent_result.type == EventType.ERROR: print(f"Error sending message: {sent_result.payload}") return False expected_ack = sent_result.payload["expected_ack"].hex() print(f"Message sent, waiting for ack with code: {expected_ack}") # Wait specifically for this acknowledgment result = await meshcore.wait_for_event( EventType.ACK, attribute_filters={"code": expected_ack}, timeout=10.0 ) if result: print("Message confirmed delivered!") return True else: print("Message delivery confirmation timed out") return False ``` ### Hybrid Approach (Commands + Events) Combine command-based and event-based styles: ```python import asyncio async def main(): # Connect to device meshcore = await MeshCore.create_serial("/dev/ttyUSB0") # Set up event handlers async def handle_ack(event): print("Message acknowledged!") async def handle_battery(event): print(f"Battery level: {event.payload}%") # Subscribe to events meshcore.subscribe(EventType.ACK, handle_ack) meshcore.subscribe(EventType.BATTERY, handle_battery) # Create background task for battery checking async def check_battery_periodically(): while True: # Send command (returns battery level) result = await meshcore.commands.get_bat() if result.type == EventType.ERROR: print(f"Error checking battery: {result.payload}") else: print(f"Battery level: {result.payload.get('level', 'unknown')}%") await asyncio.sleep(60) # Wait 60 seconds between checks # Start the background task battery_task = asyncio.create_task(check_battery_periodically()) # Send manual command and wait for response await meshcore.commands.send_advert(flood=True) try: # Keep the main program running await asyncio.sleep(float('inf')) except asyncio.CancelledError: # Clean up when program ends battery_task.cancel() await meshcore.disconnect() # Run the program asyncio.run(main()) ``` ### Auto-Fetching Messages Let the library automatically fetch incoming messages: ```python # Start auto-fetching messages await meshcore.start_auto_message_fetching() # Just subscribe to message events - the library handles fetching async def on_message(event): print(f"New message: {event.payload['text']}") meshcore.subscribe(EventType.CONTACT_MSG_RECV, on_message) # When done await meshcore.stop_auto_message_fetching() ``` ### Debug Mode Enable debug logging for troubleshooting: ```python # Enable debug mode when creating the connection meshcore = await MeshCore.create_serial("/dev/ttyUSB0", debug=True) ``` This logs detailed information about commands sent and events received. ## Common Examples ### Sending Messages to Contacts Commands that require a destination (`send_msg`, `send_login`, `send_statusreq`, etc.) now accept either: - A string with the hex representation of a public key - A contact object with a "public_key" field - Bytes object (for backward compatibility) ```python # Get contacts and send to a specific one result = await meshcore.commands.get_contacts() if result.type == EventType.ERROR: print(f"Error getting contacts: {result.payload}") else: contacts = result.payload for key, contact in contacts.items(): if contact["adv_name"] == "Alice": # Option 1: Pass the contact object directly result = await meshcore.commands.send_msg(contact, "Hello Alice!") if result.type == EventType.ERROR: print(f"Error sending message: {result.payload}") # Option 2: Use the public key string result = await meshcore.commands.send_msg(contact["public_key"], "Hello again Alice!") if result.type == EventType.ERROR: print(f"Error sending message: {result.payload}") # Option 3 (backward compatible): Convert the hex key to bytes dst_key = bytes.fromhex(contact["public_key"]) result = await meshcore.commands.send_msg(dst_key, "Hello once more Alice!") if result.type == EventType.ERROR: print(f"Error sending message: {result.payload}") break # You can also directly use a contact found by name contact = meshcore.get_contact_by_name("Bob") if contact: result = await meshcore.commands.send_msg(contact, "Hello Bob!") if result.type == EventType.ERROR: print(f"Error sending message: {result.payload}") ``` ### Monitoring Channel Messages ```python # Subscribe to channel messages async def channel_handler(event): msg = event.payload print(f"Channel {msg['channel_idx']}: {msg['text']}") meshcore.subscribe(EventType.CHANNEL_MSG_RECV, channel_handler) ``` ## API Reference ### Event Types All events in MeshCore are represented by the `EventType` enum. These events are dispatched by the library and can be subscribed to: | Event Type | String Value | Description | Typical Payload | |------------|-------------|-------------|-----------------| | **Device & Status Events** ||| | `SELF_INFO` | `"self_info"` | Device's own information after appstart | Device configuration, public key, coordinates | | `DEVICE_INFO` | `"device_info"` | Device capabilities and firmware info | Firmware version, model, max contacts/channels | | `BATTERY` | `"battery_info"` | Battery level and storage info | Battery level, used/total storage | | `CURRENT_TIME` | `"time_update"` | Device time response | Current timestamp | | `STATUS_RESPONSE` | `"status_response"` | Device status statistics | Battery, TX queue, noise floor, packet counts | | `CUSTOM_VARS` | `"custom_vars"` | Custom variable responses | Key-value pairs of custom variables | | **Contact Events** ||| | `CONTACTS` | `"contacts"` | Contact list response | Dictionary of contacts by public key | | `NEW_CONTACT` | `"new_contact"` | New contact discovered | Contact information | | `CONTACT_URI` | `"contact_uri"` | Contact export URI | Shareable contact URI | | **Messaging Events** ||| | `CONTACT_MSG_RECV` | `"contact_message"` | Direct message received | Message text, sender prefix, timestamp | | `CHANNEL_MSG_RECV` | `"channel_message"` | Channel message received | Message text, channel index, timestamp | | `MSG_SENT` | `"message_sent"` | Message send confirmation | Expected ACK code, suggested timeout | | `NO_MORE_MSGS` | `"no_more_messages"` | No pending messages | Empty payload | | `MESSAGES_WAITING` | `"messages_waiting"` | Messages available notification | Empty payload | | **Network Events** ||| | `ADVERTISEMENT` | `"advertisement"` | Node advertisement detected | Public key of advertising node | | `PATH_UPDATE` | `"path_update"` | Routing path update | Public key and path information | | `ACK` | `"acknowledgement"` | Message acknowledgment | ACK code | | `PATH_RESPONSE` | `"path_response"` | Path discovery response | Inbound/outbound path data | | `TRACE_DATA` | `"trace_data"` | Route trace information | Path with SNR data for each hop | | **Telemetry Events** ||| | `TELEMETRY_RESPONSE` | `"telemetry_response"` | Telemetry data response | LPP-formatted sensor data | | `MMA_RESPONSE` | `"mma_response"` | Memory Management Area data | Min/max/avg telemetry over time range | | `ACL_RESPONSE` | `"acl_response"` | Access Control List data | List of keys and permissions | | **Channel Events** ||| | `CHANNEL_INFO` | `"channel_info"` | Channel configuration | Channel name, secret, index | | **Raw Data Events** ||| | `RAW_DATA` | `"raw_data"` | Raw radio data | SNR, RSSI, payload hex | | `RX_LOG_DATA` | `"rx_log_data"` | RF log data | SNR, RSSI, raw payload | | `LOG_DATA` | `"log_data"` | Generic log data | Various log information | | **Binary Protocol Events** ||| | `BINARY_RESPONSE` | `"binary_response"` | Generic binary response | Tag and hex data | | `SIGN_START` | `"sign_start"` | Start of an on-device signing session | Maximum buffer size (bytes) for data to sign | | `SIGNATURE` | `"signature"` | Resulting on-device signature | Raw signature bytes | | **Authentication Events** ||| | `LOGIN_SUCCESS` | `"login_success"` | Successful login | Permissions, admin status, pubkey prefix | | `LOGIN_FAILED` | `"login_failed"` | Failed login attempt | Pubkey prefix | | **Command Response Events** ||| | `OK` | `"command_ok"` | Command successful | Success confirmation, optional value | | `ERROR` | `"command_error"` | Command failed | Error reason or code | | **Connection Events** ||| | `CONNECTED` | `"connected"` | Connection established | Connection details, reconnection status | | `DISCONNECTED` | `"disconnected"` | Connection lost | Disconnection reason | ### Available Commands All commands are async methods that return `Event` objects. Commands are organized into functional groups: #### Device Commands (`meshcore.commands.*`) | Command | Parameters | Returns | Description | |---------|------------|---------|-------------| | **Device Information** |||| | `send_appstart()` | None | `SELF_INFO` | Get device self-information and configuration | | `send_device_query()` | None | `DEVICE_INFO` | Query device capabilities and firmware info | | `get_bat()` | None | `BATTERY` | Get battery level and storage information | | `get_time()` | None | `CURRENT_TIME` | Get current device time | | `get_self_telemetry()` | None | `TELEMETRY_RESPONSE` | Get device's own telemetry data | | `get_custom_vars()` | None | `CUSTOM_VARS` | Retrieve all custom variables | | `get_allowed_repeat_freq()` | None | `ALLOWED_FREQ` | Retreive allowed repeat freqs from device | | **Device Configuration** |||| | `set_name(name)` | `name: str` | `OK` | Set device name/identifier | | `set_coords(lat, lon)` | `lat: float, lon: float` | `OK` | Set device GPS coordinates | | `set_time(val)` | `val: int` | `OK` | Set device time (Unix timestamp) | | `set_tx_power(val)` | `val: int` | `OK` | Set radio transmission power level | | `set_devicepin(pin)` | `pin: int` | `OK` | Set device PIN for security | | `set_custom_var(key, value)` | `key: str, value: str` | `OK` | Set custom variable | | **Radio Configuration** |||| | `set_radio(freq, bw, sf, cr)` | `freq: float, bw: float, sf: int, cr: int` | `OK` | Configure radio (freq MHz, bandwidth kHz, spreading factor, coding rate 5-8) | | `set_tuning(rx_dly, af)` | `rx_dly: int, af: int` | `OK` | Set radio tuning parameters | | **Telemetry Configuration** |||| | `set_telemetry_mode_base(mode)` | `mode: int` | `OK` | Set base telemetry mode | | `set_telemetry_mode_loc(mode)` | `mode: int` | `OK` | Set location telemetry mode | | `set_telemetry_mode_env(mode)` | `mode: int` | `OK` | Set environmental telemetry mode | | `set_manual_add_contacts(enabled)` | `enabled: bool` | `OK` | Enable/disable manual contact addition | | `set_advert_loc_policy(policy)` | `policy: int` | `OK` | Set location advertisement policy | | **Channel Management** |||| | `get_channel(channel_idx)` | `channel_idx: int` | `CHANNEL_INFO` | Get channel configuration | | `set_channel(channel_idx, name, secret)` | `channel_idx: int, name: str, secret: bytes` | `OK` | Configure channel (secret must be 16 bytes) | | **Device Actions** |||| | `send_advert(flood=False)` | `flood: bool` | `OK` | Send advertisement (optionally flood network) | | `reboot()` | None | None | Reboot device (no response expected) | | **Security** |||| | `export_private_key()` | None | `PRIVATE_KEY/DISABLED` | Export device private key (requires PIN auth & enabled firmware) | | `import_private_key(key)` | `key: bytes` | `OK` | Import private key to device | | **Statistics** |||| | `get_stats_core()` | None | `STATS_CORE` | Get core statistics (voltage, uptime, errors, queue length) | | `get_stats_radio()` | None | `STATS_RADIO` | Get radio statistics (noise floor, last RSSI/SNR, tx/rx time stats) | | `get_stats_packets()` | None | `STATS_PACKETS` | Get packet statistics (rx/tx totals, flood vs. direct, recv_errors when present) | | **Advanced Configuration** |||| | `set_multi_acks(multi_acks)` | `multi_acks: int` | `OK` | Set multi-acks mode (experimental ack repeats) | #### Contact Commands (`meshcore.commands.*`) | Command | Parameters | Returns | Description | |---------|------------|---------|-------------| | **Contact Management** |||| | `get_contacts(lastmod=0)` | `lastmod: int` | `CONTACTS` | Get contact list (filter by last modification time) | | `add_contact(contact)` | `contact: dict` | `OK` | Add new contact to device | | `update_contact(contact, path, flags)` | `contact: dict, path: bytes, flags: int` | `OK` | Update existing contact | | `remove_contact(key)` | `key: str/bytes` | `OK` | Remove contact by public key | | **Contact Operations** |||| | `reset_path(key)` | `key: str/bytes` | `OK` | Reset routing path for contact | | `share_contact(key)` | `key: str/bytes` | `OK` | Share contact with network | | `export_contact(key=None)` | `key: str/bytes/None` | `CONTACT_URI` | Export contact as URI (None exports node) | | `import_contact(card_data)` | `card_data: bytes` | `OK` | Import contact from card data | | **Contact Modification** |||| | `change_contact_path(contact, path)` | `contact: dict, path: bytes` | `OK` | Change routing path for contact | | `change_contact_flags(contact, flags)` | `contact: dict, flags: int` | `OK` | Change contact flags/settings | #### Messaging Commands (`meshcore.commands.*`) | Command | Parameters | Returns | Description | |---------|------------|---------|-------------| | **Message Handling** |||| | `get_msg(timeout=None)` | `timeout: float` | `CONTACT_MSG_RECV/CHANNEL_MSG_RECV/NO_MORE_MSGS` | Get next pending message | | `send_msg(dst, msg, timestamp=None)` | `dst: contact/str/bytes, msg: str, timestamp: int` | `MSG_SENT` | Send direct message | | `send_cmd(dst, cmd, timestamp=None)` | `dst: contact/str/bytes, cmd: str, timestamp: int` | `MSG_SENT` | Send command message | | `send_chan_msg(chan, msg, timestamp=None)` | `chan: int, msg: str, timestamp: int` | `MSG_OK` | Send channel message | | **Authentication** |||| | `send_login(dst, pwd)` | `dst: contact/str/bytes, pwd: str` | `MSG_SENT` | Send login request | | `send_logout(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Send logout request | | **Information Requests** |||| | `send_statusreq(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Request status from contact | | `send_telemetry_req(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Request telemetry from contact | | **Advanced Messaging** |||| | `send_binary_req(dst, bin_data)` | `dst: contact/str/bytes, bin_data: bytes` | `MSG_SENT` | Send binary data request | | `send_path_discovery(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Initiate path discovery | | `send_trace(auth_code, tag, flags, path=None)` | `auth_code: int, tag: int, flags: int, path: list` | `MSG_SENT` | Send route trace packet | | **Message Retry & Scope** |||| | `send_msg_with_retry(dst, msg, ...)` | `dst, msg, timestamp, max_attempts, max_flood_attempts, flood_after, timeout, min_timeout` | `MSG_SENT/None` | Send message with automatic retry and ACK waiting | | `set_flood_scope(scope)` | `scope: str` | `OK` | Set flood scope (hash like "#name", "0"/""/"*" to disable, or raw key) | #### Binary Protocol Commands (`meshcore.commands.*`) | Command | Parameters | Returns | Description | |---------|------------|---------|-------------| | `req_status(contact, timeout=0)` | `contact: dict, timeout: float` | `STATUS_RESPONSE` | Get detailed status via binary protocol | | `req_telemetry(contact, timeout=0)` | `contact: dict, timeout: float` | `TELEMETRY_RESPONSE` | Get telemetry via binary protocol | | `req_mma(contact, start, end, timeout=0)` | `contact: dict, start: int, end: int, timeout: float` | `MMA_RESPONSE` | Get historical telemetry data | | `req_acl(contact, timeout=0)` | `contact: dict, timeout: float` | `ACL_RESPONSE` | Get access control list | | `sign_start()` | None | `SIGN_START` | Begin a signing session; returns maximum buffer size for data to sign | | `sign_data(chunk)` | `chunk: bytes` | `OK` | Append a data chunk to the current signing session (can be called multiple times) | | `sign_finish()` | None | `SIGNATURE` | Finalize signing and return the signature for all accumulated data | ### Helper Methods | Method | Returns | Description | |--------|---------|-------------| | `get_contact_by_name(name)` | `dict/None` | Find contact by advertisement name | | `get_contact_by_key_prefix(prefix)` | `dict/None` | Find contact by partial public key | | `sign(data, chunk_size=512)` | `Event` (`SIGNATURE`/`ERROR`) | High-level helper to sign arbitrary data on-device, handling chunking for you | | `is_connected` | `bool` | Check if device is currently connected | | `subscribe(event_type, callback, filters=None)` | `Subscription` | Subscribe to events with optional filtering | | `unsubscribe(subscription)` | None | Remove event subscription | | `wait_for_event(event_type, filters=None, timeout=None)` | `Event/None` | Wait for specific event | ### Event Filtering Events can be filtered by their attributes when subscribing: ```python # Filter by public key prefix meshcore.subscribe( EventType.CONTACT_MSG_RECV, handler, attribute_filters={"pubkey_prefix": "a1b2c3d4e5f6"} ) # Filter by channel index meshcore.subscribe( EventType.CHANNEL_MSG_RECV, handler, attribute_filters={"channel_idx": 0} ) # Filter acknowledgments by code meshcore.subscribe( EventType.ACK, handler, attribute_filters={"code": "12345678"} ) ``` ## Examples in the Repo Check the `examples/` directory for more: - `pubsub_example.py`: Event subscription system with auto-fetching - `serial_infos.py`: Quick device info retrieval - `serial_msg.py`: Message sending and receiving - `serial_pingbot.py`: Ping bot which can be run on a channel - `serial_rss_bot.py`: A RSS feed to Meshcore channel example, which broadcasts emergency bushfire warnings in VIC, AU - `serial_meshcore_ollama.py`: Simple Ollama to Meshcore gateway, a simple chat box - `ble_pin_pairing_example.py`: BLE connection with PIN pairing - `ble_private_key_export.py`: BLE private key export with PIN authentication - `ble_t1000_infos.py`: BLE connections meshcore-2.3.7/tests/0000775000175000017500000000000015175447206014053 5ustar winniewinniemeshcore-2.3.7/tests/test_ble_connection.py0000644000175000017500000000476713615410400020440 0ustar winniewinnieimport asyncio import unittest from unittest.mock import AsyncMock, MagicMock, patch from meshcore.ble_cx import ( BLEConnection, UART_TX_CHAR_UUID, UART_RX_CHAR_UUID, ) class TestBLEConnection(unittest.TestCase): @patch("meshcore.ble_cx.BleakClient") def test_ble_connection_and_disconnection(self, mock_bleak_client): """ Tests the BLEConnection class for connecting and disconnecting from a BLE device. """ # Arrange mock_client_instance = self._get_mock_bleak_client() mock_bleak_client.return_value = mock_client_instance address = "00:11:22:33:44:55" ble_conn = BLEConnection(address=address) # Act asyncio.run(ble_conn.connect()) asyncio.run(ble_conn.disconnect()) # Assert mock_client_instance.connect.assert_called_once() mock_client_instance.start_notify.assert_called_once_with( UART_TX_CHAR_UUID, ble_conn.handle_rx ) mock_client_instance.disconnect.assert_called_once() @patch("meshcore.ble_cx.BleakClient") def test_send_data(self, mock_bleak_client): """ Tests the send method of the BLEConnection class. """ # Arrange mock_client_instance = self._get_mock_bleak_client() mock_bleak_client.return_value = mock_client_instance address = "00:11:22:33:44:55" ble_conn = BLEConnection(address=address) asyncio.run(ble_conn.connect()) # Act data_to_send = b"Hello, BLE" asyncio.run(ble_conn.send(data_to_send)) # Assert ble_conn.rx_char.write_gatt_char.assert_called_once_with( ble_conn.rx_char, data_to_send, response=True ) def _get_mock_bleak_client(self): """ Creates a mock BleakClient instance with all the necessary async methods and attributes. """ mock_client = MagicMock() mock_client.connect = AsyncMock() mock_client.disconnect = AsyncMock() mock_client.start_notify = AsyncMock() mock_client.write_gatt_char = AsyncMock() mock_client.is_connected = True mock_service = MagicMock() mock_char = MagicMock() mock_char.uuid = UART_RX_CHAR_UUID mock_char.write_gatt_char = mock_client.write_gatt_char mock_service.get_characteristic.return_value = mock_char mock_client.services.get_service.return_value = mock_service return mock_client if __name__ == '__main__': unittest.main() meshcore-2.3.7/tests/unit/0000775000175000017500000000000015175447206015032 5ustar winniewinniemeshcore-2.3.7/tests/unit/test_parse_status.py0000644000175000017500000001114013615410400021132 0ustar winniewinnie"""Tests for meshcore.parsing.parse_status covering the RepeaterStats binary frame. The firmware struct (MeshCore/examples/simple_repeater/MyMesh.h) is: struct RepeaterStats { uint16_t batt_milli_volts; // offset 0 (2) uint16_t curr_tx_queue_len; // offset 2 (2) int16_t noise_floor; // offset 4 (2) int16_t last_rssi; // offset 6 (2) uint32_t n_packets_recv; // offset 8 (4) uint32_t n_packets_sent; // offset 12 (4) uint32_t total_air_time_secs; // offset 16 (4) uint32_t total_up_time_secs; // offset 20 (4) uint32_t n_sent_flood; // offset 24 (4) uint32_t n_sent_direct; // offset 28 (4) uint32_t n_recv_flood; // offset 32 (4) uint32_t n_recv_direct; // offset 36 (4) uint16_t err_events; // offset 40 (2) int16_t last_snr; // x4 // offset 42 (2) uint16_t n_direct_dups; // offset 44 (2) uint16_t n_flood_dups; // offset 46 (2) uint32_t total_rx_air_time_secs; // offset 48 (4) uint32_t n_recv_errors; // offset 52 (4) -- 56-byte frame }; """ import struct from meshcore.parsing import parse_status def _build_frame( *, bat=4200, tx_queue_len=3, noise_floor=-118, last_rssi=-85, nb_recv=1000, nb_sent=500, airtime=3600, uptime=86400, sent_flood=100, sent_direct=400, recv_flood=300, recv_direct=700, err_events=0, last_snr_x4=30, # 7.5 dB * 4 direct_dups=5, flood_dups=10, rx_airtime=7200, recv_errors=None, ): """Build a RepeaterStats binary frame (52 or 56 bytes).""" frame = struct.pack( " 300 and no payload data after header # Header: 0x3e + 2-byte LE size (e.g. 500 = 0x01F4) header = b"\x3e" + (500).to_bytes(2, "little") cx.handle_rx(header) await asyncio.sleep(0) # No frames should be dispatched, and state should be reset assert reader.frames == [] assert cx.header == b"" assert cx.inframe == b"" assert cx.frame_expected_size == 0 @pytest.mark.asyncio async def test_serial_oversize_frame_empty_data_returns(): """Serial: oversize header with no trailing data returns without dispatch.""" cx = SerialConnection("/dev/null", 115200) reader = RecordingReader() cx.set_reader(reader) header = b"\x3e" + (500).to_bytes(2, "little") cx.handle_rx(header) await asyncio.sleep(0) assert reader.frames == [] assert cx.header == b"" assert cx.inframe == b"" assert cx.frame_expected_size == 0 # --------------------------------------------------------------------------- # TCP receive counter increments per MeshCore frame, not per TCP segment # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_tcp_receive_count_per_frame_not_per_segment(): """TCP: _receive_count increments per completed frame, not per data_received call.""" cx = TCPConnection("127.0.0.1", 5000) reader = RecordingReader() cx.set_reader(reader) cx._receive_count = 0 # Build a 4-byte payload frame payload = b"\xAA\xBB\xCC\xDD" frame = b"\x3e" + len(payload).to_bytes(2, "little") + payload # Split the frame into 3 TCP segments (simulating fragmentation) protocol = TCPConnection.MCClientProtocol(cx) protocol.data_received(frame[:2]) # partial header protocol.data_received(frame[2:5]) # rest of header + 2 bytes payload protocol.data_received(frame[5:]) # remaining payload await asyncio.sleep(0) # 3 data_received calls but only 1 completed frame assert cx._receive_count == 1 assert reader.frames == [payload] @pytest.mark.asyncio async def test_tcp_multiple_frames_count_correctly(): """TCP: two complete frames in separate segments → _receive_count == 2.""" cx = TCPConnection("127.0.0.1", 5000) reader = RecordingReader() cx.set_reader(reader) cx._receive_count = 0 payload1 = b"\x01\x02" frame1 = b"\x3e" + len(payload1).to_bytes(2, "little") + payload1 payload2 = b"\x03\x04\x05" frame2 = b"\x3e" + len(payload2).to_bytes(2, "little") + payload2 protocol = TCPConnection.MCClientProtocol(cx) protocol.data_received(frame1) protocol.data_received(frame2) await asyncio.sleep(0) assert cx._receive_count == 2 assert reader.frames == [payload1, payload2] meshcore-2.3.7/tests/unit/test_reader.py0000644000175000017500000002660413615410400017672 0ustar winniewinnie#!/usr/bin/env python3 import asyncio import logging from unittest.mock import AsyncMock from meshcore.events import EventType from meshcore.reader import MessageReader class MockDispatcher: def __init__(self): self.dispatched_events = [] async def dispatch(self, event): self.dispatched_events.append(event) print(f"Dispatched: {event.type} with payload keys: {list(event.payload.keys()) if hasattr(event.payload, 'keys') else event.payload}") import pytest @pytest.mark.asyncio async def test_binary_response(): mock_dispatcher = MockDispatcher() reader = MessageReader(mock_dispatcher) packet_hex = "8c00417db968993acd42fc77c3bbd1f08b9b84c39756410c58cd03077162bcb489031869586ab4b103000000000000000000" packet_data = bytearray.fromhex(packet_hex) print(f"Testing packet: {packet_hex}") print(f"Packet type: 0x{packet_data[0]:02x} (should be 0x8c for BINARY_RESPONSE)") # Register the binary request first tag = "417db968" from meshcore.packets import BinaryReqType pubkey_prefix = "993acd42fc77" reader.register_binary_request(pubkey_prefix, tag, BinaryReqType.ACL, 10.0) print(f"Registered ACL request with tag {tag}") await reader.handle_rx(packet_data) # Check what was dispatched print(f"\nTotal events dispatched: {len(mock_dispatcher.dispatched_events)}") # Verify BINARY_RESPONSE was dispatched binary_responses = [e for e in mock_dispatcher.dispatched_events if e.type == EventType.BINARY_RESPONSE] assert len(binary_responses) == 1, f"Expected 1 BINARY_RESPONSE, got {len(binary_responses)}" print("✅ BINARY_RESPONSE event dispatched correctly") # Check the binary response payload binary_event = binary_responses[0] assert "tag" in binary_event.payload, "BINARY_RESPONSE should have 'tag' in payload" assert "data" in binary_event.payload, "BINARY_RESPONSE should have 'data' in payload" print(f"✅ Binary response tag: {binary_event.payload['tag']}") print(f"✅ Binary response data: {binary_event.payload['data']}") # Check if a specific parsed event was also dispatched other_events = [e for e in mock_dispatcher.dispatched_events if e.type != EventType.BINARY_RESPONSE] if other_events: print(f"✅ Additional parsed event dispatched: {other_events[0].type}") print(f" Payload keys: {list(other_events[0].payload.keys()) if hasattr(other_events[0].payload, 'keys') else other_events[0].payload}") else: print("⚠️ No additional parsed event dispatched") # Parse the response data to see what request type it is response_data = packet_data[6:] if response_data: request_type = response_data[0] print(f"Request type in response: 0x{request_type:02x} ({request_type})") # Map request types to expected events from meshcore.packets import BinaryReqType if request_type == BinaryReqType.STATUS.value: expected_event = EventType.STATUS_RESPONSE elif request_type == BinaryReqType.TELEMETRY.value: expected_event = EventType.TELEMETRY_RESPONSE elif request_type == BinaryReqType.MMA.value: expected_event = EventType.MMA_RESPONSE elif request_type == BinaryReqType.ACL.value: expected_event = EventType.ACL_RESPONSE else: expected_event = None if expected_event: specific_events = [e for e in mock_dispatcher.dispatched_events if e.type == expected_event] if specific_events: print(f"✅ Expected {expected_event} event was dispatched") else: print(f"❌ Expected {expected_event} event was NOT dispatched") else: print(f"⚠️ Unknown request type {request_type}, no specific event expected") if __name__ == "__main__": asyncio.run(test_binary_response()) # --------------------------------------------------------------------------- # Reader/parser crash-safety verification tests # --------------------------------------------------------------------------- class _CapturingDispatcher: """Quiet dispatcher that records every dispatched event.""" def __init__(self): self.events = [] async def dispatch(self, event): self.events.append(event) @pytest.mark.asyncio async def test_handle_rx_malformed_frame_logged_and_swallowed(caplog): """Malformed frame must not propagate, must be logged with traceback.""" dispatcher = _CapturingDispatcher() reader = MessageReader(dispatcher) # 4-byte CHANNEL_MSG_RECV_V3 frame: type byte (0x11) + 1 SNR byte + # 2 reserved bytes, but no channel_idx byte. The handler will raise # IndexError on the next dbuf.read(1)[0] when the buffer is empty. # The umbrella try/except must catch it, log the parse error, and # return cleanly. malformed = bytearray.fromhex("11100000") with caplog.at_level(logging.ERROR, logger="meshcore"): await reader.handle_rx(malformed) # must not raise error_records = [r for r in caplog.records if "handle_rx parse error" in r.message] assert error_records, ( f"Expected an error log containing 'handle_rx parse error'; " f"got: {[r.message for r in caplog.records]}" ) # Traceback should be present in the log message assert "Traceback" in error_records[0].message, ( "Umbrella log message must include a traceback" ) # No CHANNEL_MSG_RECV event should have been dispatched assert not any(e.type == EventType.CHANNEL_MSG_RECV for e in dispatcher.events) @pytest.mark.asyncio async def test_battery_short_frame_omits_storage_fields(): """Short BATTERY frame must not silently yield zero used_kb/total_kb.""" dispatcher = _CapturingDispatcher() reader = MessageReader(dispatcher) # 3-byte BATTERY frame: type 0x0c + 2 level bytes (no storage tail). # Pre-fix the `len(data) > 3` gate would have let any frame >= 4 bytes # through, producing a BATTERY event with bogus zero used_kb/total_kb # because io.BytesIO.read() returns short data without raising. # Post-fix (`len(data) >= 11`) the storage fields are skipped entirely. short_battery = bytearray.fromhex("0c8000") await reader.handle_rx(short_battery) battery_events = [e for e in dispatcher.events if e.type == EventType.BATTERY] assert len(battery_events) == 1, ( f"Expected exactly one BATTERY event, got {len(battery_events)}" ) payload = battery_events[0].payload assert payload["level"] == 0x0080, f"Unexpected level: {payload['level']}" assert "used_kb" not in payload, ( "Short BATTERY frame must not include used_kb (would be a silent zero)" ) assert "total_kb" not in payload, ( "Short BATTERY frame must not include total_kb (would be a silent zero)" ) @pytest.mark.asyncio async def test_battery_too_short_for_level(caplog): """BATTERY frame shorter than 3 bytes must be dropped entirely (Option B). A 1-byte frame (just the packet-type byte 0x0c, no level bytes) would cause dbuf.read(2) to return b"" and int.from_bytes(b"", ...) to silently yield 0. The fix adds an early return with a debug log. """ dispatcher = _CapturingDispatcher() reader = MessageReader(dispatcher) # 1-byte BATTERY frame: only the type byte, no level payload. too_short = bytearray.fromhex("0c") with caplog.at_level(logging.DEBUG, logger="meshcore"): await reader.handle_rx(too_short) battery_events = [e for e in dispatcher.events if e.type == EventType.BATTERY] assert len(battery_events) == 0, ( "BATTERY frame shorter than 3 bytes must not dispatch an event" ) debug_records = [ r for r in caplog.records if "BATTERY frame too short" in r.message ] assert debug_records, "Expected a debug log about the short BATTERY frame" @pytest.mark.asyncio async def test_status_response_short_frame_skipped(caplog): """Short STATUS_RESPONSE push frame must be skipped, not parsed with bogus zeros.""" dispatcher = _CapturingDispatcher() reader = MessageReader(dispatcher) # 30-byte STATUS_RESPONSE push frame, well below the 60-byte minimum. # First byte is the type (0x87 = PacketType.STATUS_RESPONSE), the rest # is arbitrary filler. parse_status with offset=8 reads up through # data[56:60], so anything < 60 bytes would yield short reads and # silent zero values pre-fix. short_status = bytearray([0x87] + [0xAA] * 29) assert len(short_status) == 30 with caplog.at_level(logging.DEBUG, logger="meshcore"): await reader.handle_rx(short_status) status_events = [e for e in dispatcher.events if e.type == EventType.STATUS_RESPONSE] assert len(status_events) == 0, ( "Short STATUS_RESPONSE push frame must not dispatch a parsed event" ) assert any( "STATUS_RESPONSE push frame too short" in r.message for r in caplog.records ), "Expected a debug log line for short STATUS_RESPONSE frames" @pytest.mark.asyncio async def test_parse_packet_payload_txt_type_decodes_high_bits(): """txt_type must decode the high 6 bits of byte 4, not always be 0.""" from Crypto.Cipher import AES from Crypto.Hash import HMAC, SHA256 from meshcore.meshcore_parser import MeshcorePacketParser parser = MeshcorePacketParser() parser.decrypt_channels = True # Set up a synthetic channel with a known 16-byte AES key. Direct dict # assignment matches how the parser stores channels (newChannel is async # and serves the same purpose). channel_secret = b"\x01" * 16 channel_hash_byte = 0xAB parser.channels[0] = { "channel_idx": 0, "channel_name": "test", "channel_hash": "ab", "channel_secret": channel_secret, } # 16-byte plaintext (one AES block): # bytes 0-3 = sender_timestamp (little-endian) # byte 4 = (txt_type << 2) | attempt # bytes 5-15 = message + null padding # Pick txt_type=5, attempt=1 → byte 4 = (5 << 2) | 1 = 0x15. # Pre-fix uncrypted[4:4] is empty so txt_type would be 0; # post-fix uncrypted[4:5] yields 0x15 >> 2 = 5. plaintext = b"\x00\x00\x00\x00\x15hello\x00\x00\x00\x00\x00\x00" assert len(plaintext) == 16 encrypted = AES.new(channel_secret, AES.MODE_ECB).encrypt(plaintext) # cipher_mac = first 2 bytes of HMAC-SHA256(channel_secret, encrypted) h = HMAC.new(channel_secret, digestmod=SHA256) h.update(encrypted) cipher_mac = h.digest()[:2] # pkt_payload layout: 1-byte chan_hash + 2-byte cipher_mac + ciphertext pkt_payload = bytes([channel_hash_byte]) + cipher_mac + encrypted # parsePacketPayload expects the full payload buffer: # header byte (route_type=1 DIRECT, payload_type=5 channel, ver=0) # path_byte (path_len=0, path_hash_size=1) → 0x00 # pkt_payload header = 0x15 # route_type=1, payload_type=5, payload_ver=0 path_byte = 0x00 payload = bytes([header, path_byte]) + pkt_payload log_data = await parser.parsePacketPayload(payload, log_data={}) assert log_data["payload_type"] == 0x05 assert "txt_type" in log_data, ( f"txt_type missing from log_data — channel decrypt path was not reached. " f"log_data keys: {list(log_data.keys())}" ) assert log_data["txt_type"] == 5, ( f"Expected txt_type=5, got {log_data['txt_type']}" ) assert log_data["attempt"] == 1, ( f"Expected attempt=1, got {log_data['attempt']}" )meshcore-2.3.7/tests/unit/test_path_discovery_response.py0000644000175000017500000000256413615410400023370 0ustar winniewinnieimport pytest from meshcore.events import EventType from meshcore.reader import MessageReader pytestmark = pytest.mark.asyncio class MockDispatcher: def __init__(self): self.dispatched_events = [] async def dispatch(self, event): self.dispatched_events.append(event) async def test_path_discovery_response_decodes_3byte_paths(): dispatcher = MockDispatcher() reader = MessageReader(dispatcher) packet = bytearray.fromhex( "8d" # PacketType.PATH_DISCOVERY_RESPONSE "00" # Reserved byte "112233445566" # 6-byte contact public-key prefix "82" # Outbound path descriptor: mode=2 (3-byte hops), hop_count=2 "a1a2a3b1b2b3" # Outbound path bytes: 2 hops * 3 bytes each "81" # Inbound path descriptor: mode=2 (3-byte hops), hop_count=1 "c1c2c3" # Inbound path bytes: 1 hop * 3 bytes ) await reader.handle_rx(packet) matching = [e for e in dispatcher.dispatched_events if e.type == EventType.PATH_RESPONSE] assert len(matching) == 1 payload = matching[0].payload assert payload["pubkey_pre"] == "112233445566" assert payload["out_path_hash_len"] == 3 assert payload["out_path_len"] == 2 assert payload["out_path"] == "a1a2a3b1b2b3" assert payload["in_path_hash_len"] == 3 assert payload["in_path_len"] == 1 assert payload["in_path"] == "c1c2c3" meshcore-2.3.7/tests/unit/test_private_key_export.py0000644000175000017500000001216713615410400022352 0ustar winniewinnie#!/usr/bin/env python3 """ Unit tests for private key export functionality """ import pytest import asyncio from unittest.mock import MagicMock, AsyncMock from meshcore.commands import CommandHandler from meshcore.events import Event, EventType, Subscription from meshcore.reader import MessageReader pytestmark = pytest.mark.asyncio @pytest.fixture def mock_connection(): connection = MagicMock() connection.send = AsyncMock() return connection @pytest.fixture def mock_dispatcher(): dispatcher = MagicMock() dispatcher.wait_for_event = AsyncMock() dispatcher.dispatch = AsyncMock() def fake_subscribe(event_type, handler, attribute_filters=None): sub = MagicMock(spec=Subscription) sub.unsubscribe = MagicMock() return sub dispatcher.subscribe = MagicMock(side_effect=fake_subscribe) return dispatcher @pytest.fixture def command_handler(mock_connection, mock_dispatcher): handler = CommandHandler() async def sender(data): await mock_connection.send(data) handler._sender_func = sender handler.dispatcher = mock_dispatcher return handler def setup_event_response(mock_dispatcher, event_type, payload): def fake_subscribe(evt_type, handler, attr_filters=None): sub = MagicMock(spec=Subscription) sub.unsubscribe = MagicMock() if evt_type == event_type: asyncio.get_event_loop().call_soon( handler, Event(event_type, payload) ) return sub mock_dispatcher.subscribe = MagicMock(side_effect=fake_subscribe) # Command tests async def test_export_private_key_success(command_handler, mock_connection, mock_dispatcher): """Test successful private key export""" private_key_data = b"x" * 64 expected_payload = {"private_key": private_key_data} setup_event_response(mock_dispatcher, EventType.PRIVATE_KEY, expected_payload) result = await command_handler.export_private_key() # Verify the command was sent correctly mock_connection.send.assert_called_once_with(b"\x17") assert result.type == EventType.PRIVATE_KEY assert len(result.payload["private_key"]) == 64 assert result.payload["private_key"] == private_key_data async def test_export_private_key_disabled(command_handler, mock_connection, mock_dispatcher): """Test private key export when disabled""" expected_payload = {"reason": "private_key_export_disabled"} setup_event_response(mock_dispatcher, EventType.DISABLED, expected_payload) result = await command_handler.export_private_key() # Verify the command was sent correctly mock_connection.send.assert_called_once_with(b"\x17") assert result.type == EventType.DISABLED assert result.payload["reason"] == "private_key_export_disabled" async def test_export_private_key_error(command_handler, mock_connection, mock_dispatcher): """Test private key export error handling""" expected_payload = {"reason": "timeout"} setup_event_response(mock_dispatcher, EventType.ERROR, expected_payload) result = await command_handler.export_private_key() # Verify the command was sent correctly mock_connection.send.assert_called_once_with(b"\x17") assert result.type == EventType.ERROR assert result.payload["reason"] == "timeout" # Packet parsing tests class MockDispatcher: def __init__(self): self.dispatched_events = [] async def dispatch(self, event): self.dispatched_events.append(event) async def test_parse_private_key_packet(): """Test parsing of PRIVATE_KEY packet (type 14)""" mock_dispatcher = MockDispatcher() reader = MessageReader(mock_dispatcher) # Create a mock private key packet: [14][64 bytes of key data] private_key_data = b"x" * 64 packet = bytes([14]) + private_key_data # PRIVATE_KEY = 14 await reader.handle_rx(bytearray(packet)) # Verify the event was dispatched assert len(mock_dispatcher.dispatched_events) == 1 event = mock_dispatcher.dispatched_events[0] assert event.type == EventType.PRIVATE_KEY assert event.payload["private_key"] == private_key_data async def test_parse_private_key_packet_invalid_length(): """Test parsing of PRIVATE_KEY packet with invalid length""" mock_dispatcher = MockDispatcher() reader = MessageReader(mock_dispatcher) # Create a packet that's too short packet = bytes([14]) + b"short" # Only 5 bytes instead of 64 await reader.handle_rx(bytearray(packet)) # Should not dispatch an event for invalid length assert len(mock_dispatcher.dispatched_events) == 0 async def test_parse_disabled_packet(): """Test parsing of DISABLED packet (type 15)""" mock_dispatcher = MockDispatcher() reader = MessageReader(mock_dispatcher) # Create a disabled packet: [15] packet = bytes([15]) # DISABLED = 15 await reader.handle_rx(bytearray(packet)) # Verify the event was dispatched assert len(mock_dispatcher.dispatched_events) == 1 event = mock_dispatcher.dispatched_events[0] assert event.type == EventType.DISABLED assert event.payload["reason"] == "private_key_export_disabled" meshcore-2.3.7/tests/unit/test_error_handling.py0000644000175000017500000001663613615410400021431 0ustar winniewinnie"""Verification tests for error response handling fixes. The tests confirm that error responses are surfaced cleanly instead of causing KeyError, TypeError, NameError, or silent fallthrough. """ import asyncio import pytest from unittest.mock import MagicMock, AsyncMock, patch from meshcore.commands import CommandHandler from meshcore.events import EventType, Event, Subscription pytestmark = pytest.mark.asyncio VALID_PUBKEY_HEX = "0123456789abcdef" * 4 # 64 hex chars = 32 bytes # ── Fixtures ─────────────────────────────────────────────────────── @pytest.fixture def mock_connection(): connection = MagicMock() connection.send = AsyncMock() return connection @pytest.fixture def mock_dispatcher(): dispatcher = MagicMock() dispatcher.wait_for_event = AsyncMock() dispatcher.dispatch = AsyncMock() def fake_subscribe(event_type, handler, attribute_filters=None): sub = MagicMock(spec=Subscription) sub.unsubscribe = MagicMock() dispatcher._last_subscribe_handler = handler dispatcher._last_subscribe_event_type = event_type return sub dispatcher.subscribe = MagicMock(side_effect=fake_subscribe) return dispatcher @pytest.fixture def command_handler(mock_connection, mock_dispatcher): handler = CommandHandler() async def sender(data): await mock_connection.send(data) handler._sender_func = sender handler.dispatcher = mock_dispatcher return handler def setup_error_response(mock_dispatcher): """Configure dispatcher to return an ERROR event for any subscribe.""" def fake_subscribe(evt_type, handler, attr_filters=None): sub = MagicMock(spec=Subscription) sub.unsubscribe = MagicMock() # Always fire ERROR regardless of which event type was subscribed if evt_type == EventType.ERROR: asyncio.get_event_loop().call_soon( handler, Event(EventType.ERROR, {"reason": "test_error"}) ) return sub mock_dispatcher.subscribe = MagicMock(side_effect=fake_subscribe) def setup_event_response(mock_dispatcher, event_type, payload): """Configure dispatcher to return a specific event.""" def fake_subscribe(evt_type, handler, attr_filters=None): sub = MagicMock(spec=Subscription) sub.unsubscribe = MagicMock() if evt_type == event_type: asyncio.get_event_loop().call_soon( handler, Event(event_type, payload) ) return sub mock_dispatcher.subscribe = MagicMock(side_effect=fake_subscribe) # ── Event.is_error() helper ────────────────────────────────── async def test_event_is_error_true(): """is_error() returns True for ERROR events.""" event = Event(EventType.ERROR, {"reason": "test"}) assert event.is_error() is True async def test_event_is_error_false(): """is_error() returns False for non-ERROR events.""" event = Event(EventType.OK, {}) assert event.is_error() is False event2 = Event(EventType.SELF_INFO, {"name": "test"}) assert event2.is_error() is False # ── send_msg_with_retry continues on ERROR ────────────── async def test_send_msg_with_retry_error_no_keyerror( command_handler, mock_dispatcher ): """send_msg_with_retry returns None (exhausted retries) on persistent ERROR instead of raising KeyError on missing 'expected_ack'.""" setup_error_response(mock_dispatcher) # Provide a mock contact so the path logic doesn't interfere command_handler._get_contact_by_prefix = MagicMock(return_value=None) # max_attempts=2 so it retries once then gives up result = await command_handler.send_msg_with_retry( VALID_PUBKEY_HEX, "hello", max_attempts=2, timeout=0.1 ) # Should return None (no ACK received) rather than raising KeyError assert result is None # ── send_appstart includes ERROR in expected events ────────── async def test_send_appstart_returns_error( command_handler, mock_dispatcher ): """send_appstart returns ERROR event instead of hanging on timeout.""" setup_error_response(mock_dispatcher) result = await command_handler.send_appstart() assert result.type == EventType.ERROR assert result.is_error() is True assert result.payload["reason"] == "test_error" # ── device setters return ERROR from send_appstart ─────────── async def test_set_telemetry_mode_base_error( command_handler, mock_dispatcher ): """set_telemetry_mode_base returns ERROR instead of KeyError.""" setup_error_response(mock_dispatcher) result = await command_handler.set_telemetry_mode_base(1) assert result.is_error() assert result.payload["reason"] == "test_error" async def test_set_telemetry_mode_loc_error( command_handler, mock_dispatcher ): """set_telemetry_mode_loc returns ERROR instead of KeyError.""" setup_error_response(mock_dispatcher) result = await command_handler.set_telemetry_mode_loc(1) assert result.is_error() async def test_set_telemetry_mode_env_error( command_handler, mock_dispatcher ): """set_telemetry_mode_env returns ERROR instead of KeyError.""" setup_error_response(mock_dispatcher) result = await command_handler.set_telemetry_mode_env(1) assert result.is_error() async def test_set_manual_add_contacts_error( command_handler, mock_dispatcher ): """set_manual_add_contacts returns ERROR instead of KeyError.""" setup_error_response(mock_dispatcher) result = await command_handler.set_manual_add_contacts(True) assert result.is_error() async def test_set_advert_loc_policy_error( command_handler, mock_dispatcher ): """set_advert_loc_policy returns ERROR instead of KeyError.""" setup_error_response(mock_dispatcher) result = await command_handler.set_advert_loc_policy(1) assert result.is_error() async def test_set_multi_acks_error( command_handler, mock_dispatcher ): """set_multi_acks returns ERROR instead of KeyError.""" setup_error_response(mock_dispatcher) result = await command_handler.set_multi_acks(1) assert result.is_error() # ── send_anon_req returns ERROR on contact not found ───────── async def test_send_anon_req_contact_not_found( command_handler, mock_dispatcher ): """send_anon_req returns ERROR event when contact prefix not found, instead of raising TypeError on NoneType subscript.""" command_handler._get_contact_by_prefix = MagicMock(return_value=None) result = await command_handler.send_anon_req( VALID_PUBKEY_HEX, MagicMock(value=1) ) assert result.is_error() assert result.payload["reason"] == "contact_not_found" # ── send_trace handles unknown path_hash_len without NameError ── async def test_send_trace_unknown_path_hash_len( command_handler, mock_connection, mock_dispatcher ): """send_trace with a path whose segments don't match any known path_hash_len returns ERROR cleanly instead of NameError on 'e'.""" # 5-char hex segments → path_hash_len = 2.5 → doesn't match 1,2,4,8 result = await command_handler.send_trace( auth_code=0, tag=1, flags=None, path="abcde" ) assert result.is_error() assert result.payload["reason"] == "invalid_path_format" meshcore-2.3.7/tests/unit/test_commands.py0000644000175000017500000003105313615410400020223 0ustar winniewinnieimport pytest import asyncio from unittest.mock import MagicMock, AsyncMock from meshcore.commands import CommandHandler from meshcore.events import EventType, Event, Subscription pytestmark = pytest.mark.asyncio VALID_PUBKEY_HEX = "0123456789abcdef" * 4 # 64 hex chars = 32 bytes # Fixtures @pytest.fixture def mock_connection(): connection = MagicMock() connection.send = AsyncMock() return connection @pytest.fixture def mock_dispatcher(): dispatcher = MagicMock() dispatcher.wait_for_event = AsyncMock() dispatcher.dispatch = AsyncMock() def fake_subscribe(event_type, handler, attribute_filters=None): sub = MagicMock(spec=Subscription) sub.unsubscribe = MagicMock() dispatcher._last_subscribe_handler = handler dispatcher._last_subscribe_event_type = event_type # Immediately resolve the future so send() doesn't block asyncio.get_event_loop().call_soon( handler, Event(event_type, {}) ) return sub dispatcher.subscribe = MagicMock(side_effect=fake_subscribe) return dispatcher @pytest.fixture def command_handler(mock_connection, mock_dispatcher): handler = CommandHandler() async def sender(data): await mock_connection.send(data) handler._sender_func = sender handler.dispatcher = mock_dispatcher return handler def setup_event_response(mock_dispatcher, event_type, payload, attribute_filters=None): def fake_subscribe(evt_type, handler, attr_filters=None): sub = MagicMock(spec=Subscription) sub.unsubscribe = MagicMock() if evt_type == event_type: asyncio.get_event_loop().call_soon( handler, Event(event_type, payload) ) return sub mock_dispatcher.subscribe = MagicMock(side_effect=fake_subscribe) # Basic tests async def test_send_basic(command_handler, mock_connection): result = await command_handler.send(b"test_data") mock_connection.send.assert_called_once_with(b"test_data") assert result.type == EventType.OK assert result.payload == {} async def test_send_with_event(command_handler, mock_connection, mock_dispatcher): expected_payload = {"value": 42} setup_event_response(mock_dispatcher, EventType.OK, expected_payload) result = await command_handler.send(b"test_command", [EventType.OK]) mock_connection.send.assert_called_once_with(b"test_command") assert result.type == EventType.OK assert result.payload == expected_payload async def test_send_timeout(command_handler, mock_connection, mock_dispatcher): # Override to NOT resolve events, so we can test the timeout path def non_resolving_subscribe(event_type, handler, attribute_filters=None): sub = MagicMock(spec=Subscription) sub.unsubscribe = MagicMock() return sub mock_dispatcher.subscribe = MagicMock(side_effect=non_resolving_subscribe) result = await command_handler.send(b"test_command", [EventType.OK], timeout=0.1) assert result.type == EventType.ERROR assert result.payload == {"reason": "no_event_received"} # Destination validation tests async def test_validate_destination_bytes(command_handler, mock_connection): dst = b"123456789012" # 12 bytes await command_handler.send_msg(dst, "test message") assert mock_connection.send.call_args[0][0].startswith(b"\x02\x00\x00") assert b"123456" in mock_connection.send.call_args[0][0] async def test_validate_destination_hex_string(command_handler, mock_connection): dst = "0123456789abcdef" await command_handler.send_msg(dst, "test message") assert mock_connection.send.call_args[0][0].startswith(b"\x02\x00\x00") assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] async def test_validate_destination_contact_object(command_handler, mock_connection): dst = {"public_key": "0123456789abcdef", "adv_name": "Test Contact"} await command_handler.send_msg(dst, "test message") assert mock_connection.send.call_args[0][0].startswith(b"\x02\x00\x00") assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] # Command tests async def test_send_login(command_handler, mock_connection): await command_handler.send_login(VALID_PUBKEY_HEX, "password") assert mock_connection.send.call_args[0][0].startswith(b"\x1a") assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] assert b"password" in mock_connection.send.call_args[0][0] async def test_send_msg(command_handler, mock_connection): await command_handler.send_msg("0123456789abcdef", "hello") assert mock_connection.send.call_args[0][0].startswith(b"\x02\x00\x00") assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] assert b"hello" in mock_connection.send.call_args[0][0] async def test_send_cmd(command_handler, mock_connection): await command_handler.send_cmd("0123456789abcdef", "test_cmd") assert mock_connection.send.call_args[0][0].startswith(b"\x02\x01\x00") assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] assert b"test_cmd" in mock_connection.send.call_args[0][0] # Device settings tests async def test_set_name(command_handler, mock_connection): await command_handler.set_name("Test Device") assert mock_connection.send.call_args[0][0].startswith(b"\x08") assert b"Test Device" in mock_connection.send.call_args[0][0] async def test_set_coords(command_handler, mock_connection): await command_handler.set_coords(37.7749, -122.4194) assert mock_connection.send.call_args[0][0].startswith(b"\x0e") # Could add more detailed assertions for the byte encoding async def test_send_appstart(command_handler, mock_connection): await command_handler.send_appstart() assert mock_connection.send.call_args[0][0].startswith(b"\x01\x03") assert b"mccli" in mock_connection.send.call_args[0][0] async def test_send_device_query(command_handler, mock_connection): await command_handler.send_device_query() assert mock_connection.send.call_args[0][0].startswith(b"\x16\x03") async def test_send_advert(command_handler, mock_connection): # Test without flood await command_handler.send_advert(flood=False) assert mock_connection.send.call_args[0][0] == b"\x07" # Test with flood mock_connection.reset_mock() await command_handler.send_advert(flood=True) assert mock_connection.send.call_args[0][0] == b"\x07\x01" async def test_reboot(command_handler, mock_connection): await command_handler.reboot() assert mock_connection.send.call_args[0][0].startswith(b"\x13reboot") async def test_get_bat(command_handler, mock_connection): await command_handler.get_bat() assert mock_connection.send.call_args[0][0].startswith(b"\x14") async def test_get_time(command_handler, mock_connection): await command_handler.get_time() assert mock_connection.send.call_args[0][0].startswith(b"\x05") async def test_set_time(command_handler, mock_connection): timestamp = 1620000000 # Example timestamp await command_handler.set_time(timestamp) assert mock_connection.send.call_args[0][0].startswith(b"\x06") async def test_set_tx_power(command_handler, mock_connection): await command_handler.set_tx_power(20) assert mock_connection.send.call_args[0][0].startswith(b"\x0c") async def test_get_contacts(command_handler, mock_connection): await command_handler.get_contacts() assert mock_connection.send.call_args[0][0].startswith(b"\x04") async def test_reset_path(command_handler, mock_connection): command_handler._get_contact_by_prefix = lambda prefix: None await command_handler.reset_path(VALID_PUBKEY_HEX) assert mock_connection.send.call_args[0][0].startswith(b"\x0d") assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] async def test_share_contact(command_handler, mock_connection): await command_handler.share_contact(VALID_PUBKEY_HEX) assert mock_connection.send.call_args[0][0].startswith(b"\x10") assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] async def test_export_contact(command_handler, mock_connection): # Test exporting all contacts await command_handler.export_contact() assert mock_connection.send.call_args[0][0] == b"\x11" # Test exporting specific contact mock_connection.reset_mock() await command_handler.export_contact(VALID_PUBKEY_HEX) assert mock_connection.send.call_args[0][0].startswith(b"\x11") assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] async def test_remove_contact(command_handler, mock_connection): await command_handler.remove_contact(VALID_PUBKEY_HEX) assert mock_connection.send.call_args[0][0].startswith(b"\x0f") assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] async def test_get_msg(command_handler, mock_connection): await command_handler.get_msg() assert mock_connection.send.call_args[0][0].startswith(b"\x0a") # Test with custom timeout mock_connection.reset_mock() await command_handler.get_msg(timeout=5.0) assert mock_connection.send.call_args[0][0].startswith(b"\x0a") async def test_send_logout(command_handler, mock_connection): await command_handler.send_logout(VALID_PUBKEY_HEX) assert mock_connection.send.call_args[0][0].startswith(b"\x1d") assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] async def test_send_statusreq(command_handler, mock_connection): await command_handler.send_statusreq(VALID_PUBKEY_HEX) assert mock_connection.send.call_args[0][0].startswith(b"\x1b") assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] async def test_send_trace(command_handler, mock_connection): # Test with minimal parameters await command_handler.send_trace() first_call = mock_connection.send.call_args[0][0] assert first_call.startswith(b"\x24") # 36 in decimal = 0x24 in hex # Test with all parameters (flags=1 means path_hash_len=2, so 4 hex chars each) mock_connection.reset_mock() await command_handler.send_trace( auth_code=12345, tag=67890, flags=1, path="0123,2345,4567" ) second_call = mock_connection.send.call_args[0][0] assert second_call.startswith(b"\x24") async def test_send_with_multiple_expected_events_returns_first_completed( command_handler, mock_connection, mock_dispatcher ): error_payload = {"reason": "command_failed"} setup_event_response(mock_dispatcher, EventType.ERROR, error_payload) result = await command_handler.send( b"test_command", [EventType.OK, EventType.ERROR] ) mock_connection.send.assert_called_once_with(b"test_command") assert result.type == EventType.ERROR assert result.payload == error_payload # Channel command tests async def test_get_channel(command_handler, mock_connection): await command_handler.get_channel(3) assert mock_connection.send.call_args[0][0] == b"\x1f\x03" async def test_set_channel(command_handler, mock_connection): channel_secret = bytes(range(16)) # 16 bytes: 0x00, 0x01, ..., 0x0f await command_handler.set_channel(5, "MyChannel", channel_secret) expected_data = b"\x20\x05" # CMD_SET_CHANNEL + channel_idx=5 expected_data += b"MyChannel" + b"\x00" * ( 32 - len("MyChannel") ) # 32-byte padded name expected_data += channel_secret # 16-byte secret assert mock_connection.send.call_args[0][0] == expected_data async def test_set_channel_invalid_secret_length(command_handler): with pytest.raises(ValueError, match="Channel secret must be exactly 16 bytes"): await command_handler.set_channel(1, "Test", b"tooshort") async def test_send_chan_msg_with_str_timestamp(command_handler, mock_connection): ts = 1620000000 await command_handler.send_chan_msg(3, "world", timestamp=ts) data = mock_connection.send.call_args[0][0] assert data.startswith(b"\x03\x00\x03") assert b"world" in data assert data[3:7] == ts.to_bytes(4, "little") async def test_send_chan_msg_with_bytes_timestamp(command_handler, mock_connection): ts = 1620000000 await command_handler.send_chan_msg(3, "world", timestamp=ts.to_bytes(4, "little")) data = mock_connection.send.call_args[0][0] assert data.startswith(b"\x03\x00\x03") assert b"world" in data assert data[3:7] == ts.to_bytes(4, "little") async def test_send_chan_msg_with_invalid_timestamp(command_handler, mock_connection): result = await command_handler.send_chan_msg(3, "world", timestamp=b"00") assert result.type == EventType.ERROR assert result.payload["reason"] == "invalid_timestamp_format" meshcore-2.3.7/tests/unit/test_connection_manager.py0000644000175000017500000002404513615410400022256 0ustar winniewinnie"""Tests for reconnect-path fixes.""" import asyncio import pytest from meshcore.connection_manager import ConnectionManager from meshcore.events import Event, EventDispatcher, EventType # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- class FakeConnection: """Minimal stub that satisfies ConnectionProtocol.""" def __init__(self, connect_results=None): """ Args: connect_results: iterator of return values for successive connect() calls. ``None`` means soft failure; a string means success; raising is also supported via sentinel. """ self._connect_results = list(connect_results or ["ok"]) self._call_index = 0 self.reader = None async def connect(self): if self._call_index < len(self._connect_results): result = self._connect_results[self._call_index] self._call_index += 1 else: result = self._connect_results[-1] if isinstance(result, Exception): raise result return result async def disconnect(self): pass async def send(self, data): pass def set_reader(self, reader): self.reader = reader class RaisingConnection(FakeConnection): """Connection that raises on every connect() attempt.""" def __init__(self, exc=None): super().__init__() self._exc = exc or ConnectionError("boom") async def connect(self): raise self._exc class _EventCollector: """Subscribes to all events and records them.""" def __init__(self, dispatcher: EventDispatcher): self.events: list[Event] = [] dispatcher.subscribe(None, self._on_event) async def _on_event(self, event: Event): self.events.append(event) # --------------------------------------------------------------------------- # TCP connect() should return a plain value, not an asyncio.Future # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_tcp_connect_returns_plain_string(): """TCPConnection.connect() returns self.host (a plain string), not an asyncio.Future. We test indirectly via ConnectionManager — the CONNECTED event payload should contain a plain string, not a Future object.""" conn = FakeConnection(connect_results=["10.0.0.1"]) dispatcher = EventDispatcher() await dispatcher.start() try: collector = _EventCollector(dispatcher) mgr = ConnectionManager(conn, dispatcher) result = await mgr.connect() assert result == "10.0.0.1" # Give the dispatcher a moment to deliver the event await asyncio.sleep(0.05) connected_events = [e for e in collector.events if e.type == EventType.CONNECTED] assert len(connected_events) == 1 payload = connected_events[0].payload assert payload["connection_info"] == "10.0.0.1" # The payload value must NOT be an asyncio.Future assert not isinstance(payload["connection_info"], asyncio.Future) finally: await dispatcher.stop() # --------------------------------------------------------------------------- # Reconnect attempts must not compound (no tail-recursive create_task) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_reconnect_loop_does_not_compound(): """_attempt_reconnect must use a single iterative loop. After max_reconnect_attempts failures, exactly that many connect() calls should have been made — no exponential fan-out from orphaned tasks.""" # All attempts fail (return None) conn = FakeConnection(connect_results=[None, None, None, None]) dispatcher = EventDispatcher() await dispatcher.start() try: collector = _EventCollector(dispatcher) mgr = ConnectionManager( conn, dispatcher, auto_reconnect=True, max_reconnect_attempts=3, ) mgr._is_connected = True # simulate a live connection await mgr.handle_disconnect("test_disconnect") # Wait for the reconnect loop to exhaust all attempts # (3 attempts × 1s sleep each, but we can just await the task) if mgr._reconnect_task: await mgr._reconnect_task # Exactly 3 connect() calls should have been made assert conn._call_index == 3 # A DISCONNECTED event with max_attempts_exceeded should have fired await asyncio.sleep(0.05) disconnected = [e for e in collector.events if e.type == EventType.DISCONNECTED] assert len(disconnected) == 1 assert disconnected[0].payload.get("max_attempts_exceeded") is True finally: await dispatcher.stop() @pytest.mark.asyncio async def test_disconnect_cancels_reconnect_loop(): """disconnect() during an active reconnect loop must cancel the single task cleanly — no orphaned tasks left running.""" # Simulate a connection that always fails (returns None), giving us # time to call disconnect() mid-loop. conn = FakeConnection(connect_results=[None, None, None, None, None]) dispatcher = EventDispatcher() await dispatcher.start() try: mgr = ConnectionManager( conn, dispatcher, auto_reconnect=True, max_reconnect_attempts=5, ) mgr._is_connected = True await mgr.handle_disconnect("test_disconnect") # Let the first attempt start (wait just past the 1s sleep) await asyncio.sleep(1.2) assert conn._call_index >= 1 # at least one attempt made # Now disconnect — should cancel the loop await mgr.disconnect() assert mgr._reconnect_task is None calls_at_cancel = conn._call_index # Wait a bit and confirm no more attempts happened await asyncio.sleep(2) assert conn._call_index == calls_at_cancel finally: await dispatcher.stop() # --------------------------------------------------------------------------- # reconnect_callback (send_appstart) is called after reconnect # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_reconnect_callback_called_after_reconnect(): """When ConnectionManager reconnects successfully, the reconnect_callback (e.g. send_appstart) must be invoked.""" callback_called = [] async def fake_appstart(): callback_called.append(True) # First connect() fails (None), second succeeds conn = FakeConnection(connect_results=[None, "10.0.0.1"]) dispatcher = EventDispatcher() await dispatcher.start() try: mgr = ConnectionManager( conn, dispatcher, auto_reconnect=True, max_reconnect_attempts=3, reconnect_callback=fake_appstart, ) mgr._is_connected = True await mgr.handle_disconnect("test_disconnect") if mgr._reconnect_task: await mgr._reconnect_task assert len(callback_called) == 1 finally: await dispatcher.stop() @pytest.mark.asyncio async def test_reconnect_callback_failure_does_not_crash_loop(): """If the reconnect_callback raises, the reconnect still counts as successful (transport is up) — the callback failure is logged but does not crash the loop or leave the manager in a broken state.""" async def failing_callback(): raise RuntimeError("appstart failed") # connect() succeeds on first attempt conn = FakeConnection(connect_results=["10.0.0.1"]) dispatcher = EventDispatcher() await dispatcher.start() try: collector = _EventCollector(dispatcher) mgr = ConnectionManager( conn, dispatcher, auto_reconnect=True, max_reconnect_attempts=3, reconnect_callback=failing_callback, ) mgr._is_connected = True await mgr.handle_disconnect("test_disconnect") if mgr._reconnect_task: await mgr._reconnect_task # Despite callback failure, CONNECTED event should have fired await asyncio.sleep(0.05) connected = [e for e in collector.events if e.type == EventType.CONNECTED] assert len(connected) == 1 assert mgr._is_connected is True finally: await dispatcher.stop() # --------------------------------------------------------------------------- # connect() returning None is a soft failure (BLE scan miss) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_connect_none_is_soft_failure(): """When connect() returns None (e.g. BLE scan found no device), ConnectionManager.connect() should NOT set _is_connected and should NOT emit a CONNECTED event.""" conn = FakeConnection(connect_results=[None]) dispatcher = EventDispatcher() await dispatcher.start() try: collector = _EventCollector(dispatcher) mgr = ConnectionManager(conn, dispatcher) result = await mgr.connect() assert result is None assert mgr._is_connected is False await asyncio.sleep(0.05) connected = [e for e in collector.events if e.type == EventType.CONNECTED] assert len(connected) == 0 finally: await dispatcher.stop() @pytest.mark.asyncio async def test_no_reconnect_callback_is_noop(): """When no reconnect_callback is provided (backwards compat for direct ConnectionManager users), reconnect should still work.""" conn = FakeConnection(connect_results=["10.0.0.1"]) dispatcher = EventDispatcher() await dispatcher.start() try: mgr = ConnectionManager( conn, dispatcher, auto_reconnect=True, max_reconnect_attempts=3, # No reconnect_callback — default None ) mgr._is_connected = True await mgr.handle_disconnect("test_disconnect") if mgr._reconnect_task: await mgr._reconnect_task assert mgr._is_connected is True finally: await dispatcher.stop() meshcore-2.3.7/tests/unit/test_lpp_parsing.py0000644000175000017500000000264113615410400020741 0ustar winniewinnie"""Tests for LPP parsing to verify current values are handled correctly.""" import json import pytest from cayennelpp import LppFrame, LppData class TestLppCurrentParsing: """Tests to verify LPP current values pass through correctly.""" def test_large_current_value_wraps_signed(self): """ When raw bytes represent a large unsigned value (like 65525), values above 32.767 are reinterpreted as signed (negative). 65.525 - 65.536 = -0.011 """ from meshcore.lpp_json_encoder import lpp_json_encoder # Channel 2, Type 117 (current), Value 65525 raw = 0xFF 0xF5 (big-endian) raw_bytes = bytes([2, 117, 0xFF, 0xF5]) lppdata = LppData.from_bytes(raw_bytes) lpp = json.loads(json.dumps(LppFrame([lppdata]), default=lpp_json_encoder)) assert len(lpp) == 1 assert lpp[0]['channel'] == 2 assert lpp[0]['type'] == 'current' assert lpp[0]['value'] == -0.011 def test_normal_positive_current(self): """Normal positive current should work correctly.""" from meshcore.lpp_json_encoder import lpp_json_encoder # Channel 2, Type 117 (current), Value 500 raw = 0x01 0xF4 (big-endian) raw_bytes = bytes([2, 117, 0x01, 0xF4]) lppdata = LppData.from_bytes(raw_bytes) lpp = json.loads(json.dumps(LppFrame([lppdata]), default=lpp_json_encoder)) assert lpp[0]['value'] == 0.5 meshcore-2.3.7/tests/unit/test_standalone_fixes.py0000644000175000017500000002231413615410400021750 0ustar winniewinnie""" Verification tests for standalone bug fixes and cleanup. """ import pytest import asyncio import inspect from unittest.mock import AsyncMock, MagicMock, patch from meshcore.events import Event, EventDispatcher, EventType from meshcore.commands.base import CommandHandlerBase from meshcore.commands.binary import BinaryCommandHandler from meshcore.commands.messaging import MessagingCommands from meshcore.commands.contact import ContactCommands from meshcore.meshcore import MeshCore pytestmark = pytest.mark.asyncio # ── req_mma removed ────────────────────────────────────────────────────── def test_req_mma_removed(): """The broken req_mma method should no longer exist on BinaryCommandHandler.""" assert not hasattr(BinaryCommandHandler, "req_mma"), \ "req_mma should be removed — it had NameError on undefined start/end" def test_req_mma_sync_still_exists(): """req_mma_sync should still be present and functional.""" assert hasattr(BinaryCommandHandler, "req_mma_sync"), \ "req_mma_sync should still exist after removing req_mma" # ── DEFAULT_TIMEOUT bumped ─────────────────────────────────────────────── def test_default_timeout_bumped(): """DEFAULT_TIMEOUT should be 15.0, not the old 5.0.""" assert CommandHandlerBase.DEFAULT_TIMEOUT == 15.0, \ f"DEFAULT_TIMEOUT is {CommandHandlerBase.DEFAULT_TIMEOUT}, expected 15.0" def test_instance_default_timeout(): """Instance default_timeout should inherit the new 15.0 value.""" handler = CommandHandlerBase() assert handler.default_timeout == 15.0 def test_custom_timeout_still_works(): """Passing a custom timeout should still override the default.""" handler = CommandHandlerBase(default_timeout=30.0) assert handler.default_timeout == 30.0 # ── set_flood_scope TypeError guard ────────────────────────────────────── async def test_set_flood_scope_bad_type_raises(): """Passing an unsupported type (e.g., int) should raise TypeError.""" handler = MessagingCommands() with pytest.raises(TypeError, match="unsupported scope type"): await handler.set_flood_scope(42) async def test_set_flood_scope_bad_type_bytearray(): """bytearray is not bytes — should raise TypeError.""" handler = MessagingCommands() with pytest.raises(TypeError, match="unsupported scope type"): await handler.set_flood_scope(bytearray(b"\x00" * 16)) async def test_set_flood_scope_none_still_works(): """None scope should reach send() without TypeError — verifies the None branch still binds scope_key.""" handler = MessagingCommands() handler._sender_func = AsyncMock() handler.dispatcher = EventDispatcher() await handler.dispatcher.start() try: # Dispatch an OK event so send() resolves async def _dispatch_ok(): await asyncio.sleep(0.05) await handler.dispatcher.dispatch(Event(EventType.OK, {})) asyncio.ensure_future(_dispatch_ok()) result = await handler.set_flood_scope(None) assert result.type == EventType.OK finally: handler.dispatcher.running = False async def test_set_flood_scope_str_still_works(): """String scope should reach send() without TypeError.""" handler = MessagingCommands() handler._sender_func = AsyncMock() handler.dispatcher = EventDispatcher() await handler.dispatcher.start() try: async def _dispatch_ok(): await asyncio.sleep(0.05) await handler.dispatcher.dispatch(Event(EventType.OK, {})) asyncio.ensure_future(_dispatch_ok()) result = await handler.set_flood_scope("#test") assert result.type == EventType.OK finally: handler.dispatcher.running = False async def test_set_flood_scope_bytes_still_works(): """Bytes scope should reach send() without TypeError.""" handler = MessagingCommands() handler._sender_func = AsyncMock() handler.dispatcher = EventDispatcher() await handler.dispatcher.start() try: async def _dispatch_ok(): await asyncio.sleep(0.05) await handler.dispatcher.dispatch(Event(EventType.OK, {})) asyncio.ensure_future(_dispatch_ok()) result = await handler.set_flood_scope(b"\x01" * 16) assert result.type == EventType.OK finally: handler.dispatcher.running = False # ── dead path_hash_mode shift removed ──────────────────────────────────── def test_no_shift_in_update_contact(): """The dead `>> 6` shift on out_path_len should not appear in contact.py.""" import meshcore.commands.contact as contact_mod source = inspect.getsource(contact_mod.ContactCommands.update_contact) assert ">> 6" not in source, \ "Dead path_hash_mode = out_path_len >> 6 shift should be removed" # ── get_contacts returns Event, never None ─────────────────────────────── async def test_get_contacts_timeout_returns_error_event(): """On timeout (no futures complete), get_contacts should return an Error Event, not None.""" handler = ContactCommands() handler._sender_func = AsyncMock() handler._reader = MagicMock() handler.dispatcher = MagicMock() # Make wait_for_event always timeout by never returning handler.dispatcher.wait_for_event = AsyncMock(side_effect=asyncio.TimeoutError) result = await handler.get_contacts(timeout=0.1) assert result is not None, "get_contacts should never return None" assert isinstance(result, Event) assert result.type == EventType.ERROR # ── binary request pre-registration ────────────────────────────────────── async def test_placeholder_registered_before_send(): """A placeholder binary request should be registered before send() is called.""" from meshcore.packets import BinaryReqType handler = CommandHandlerBase() handler._sender_func = AsyncMock() # Track registration calls mock_reader = MagicMock() mock_reader.pending_binary_requests = {} original_register = MagicMock() registration_order = [] send_called = False async def mock_send(data): nonlocal send_called # At the point send() is called, a placeholder should already exist registration_order.append(("send", len(mock_reader.pending_binary_requests))) send_called = True handler._sender_func = mock_send handler._reader = mock_reader handler.dispatcher = MagicMock() handler.dispatcher.wait_for_event = AsyncMock( return_value=Event(EventType.MSG_SENT, {"expected_ack": b"\x01\x02\x03\x04"}) ) # Resolve subscribed events immediately so send() doesn't block. # Use MSG_SENT with expected_ack because send_binary_req reads that key. def resolving_subscribe(event_type, cb, attribute_filters=None): sub = MagicMock() sub.unsubscribe = MagicMock() payload = {"expected_ack": b"\x01\x02\x03\x04"} if event_type == EventType.MSG_SENT else {} asyncio.get_event_loop().call_soon( cb, Event(event_type, payload) ) return sub handler.dispatcher.subscribe = MagicMock(side_effect=resolving_subscribe) # Call send_binary_req dst = "aa" * 32 # 32-byte hex pubkey await handler.send_binary_req(dst, BinaryReqType.MMA) # Verify register_binary_request was called (at least the placeholder) assert mock_reader.register_binary_request.call_count >= 1, \ "register_binary_request should be called at least once for the placeholder" # ── MeshCore.subscribe annotation matches EventDispatcher ──────────────── def test_subscribe_annotation_matches_dispatcher(): """MeshCore.subscribe callback annotation should match EventDispatcher.subscribe.""" mc_hints = MeshCore.subscribe.__annotations__ ed_hints = EventDispatcher.subscribe.__annotations__ # Both should have 'callback' in their annotations assert "callback" in mc_hints, "MeshCore.subscribe missing callback annotation" assert "callback" in ed_hints, "EventDispatcher.subscribe missing callback annotation" # The callback annotations should be identical assert mc_hints["callback"] == ed_hints["callback"], ( f"MeshCore.subscribe callback annotation {mc_hints['callback']} " f"does not match EventDispatcher.subscribe {ed_hints['callback']}" ) def test_no_coroutine_import_in_meshcore(): """After widening the annotation, Coroutine should no longer be imported in meshcore.py.""" import meshcore.meshcore as mc_mod source = inspect.getsource(mc_mod) # Check the import line specifically — Coroutine should not be in the typing imports for line in source.splitlines(): if line.startswith("from typing import"): assert "Coroutine" not in line, \ "Coroutine should be removed from typing imports in meshcore.py" break meshcore-2.3.7/tests/test_ble_pin_pairing.py0000644000175000017500000001046513615410400020570 0ustar winniewinnieimport asyncio import unittest from unittest.mock import AsyncMock, MagicMock, patch from meshcore.ble_cx import ( BLEConnection, UART_SERVICE_UUID, UART_TX_CHAR_UUID, UART_RX_CHAR_UUID, ) class TestBLEPinPairing(unittest.TestCase): """Test BLE PIN pairing functionality""" @patch("meshcore.ble_cx.BleakClient") def test_ble_connection_with_pin_successful_pairing(self, mock_bleak_client): """Test BLE connection with PIN when pairing succeeds""" # Arrange mock_client_instance = self._get_mock_bleak_client() mock_bleak_client.return_value = mock_client_instance address = "00:11:22:33:44:55" pin = "123456" ble_conn = BLEConnection(address=address, pin=pin) # Act result = asyncio.run(ble_conn.connect()) # Assert mock_client_instance.connect.assert_called_once() mock_client_instance.pair.assert_called_once() mock_client_instance.start_notify.assert_called_once_with( UART_TX_CHAR_UUID, ble_conn.handle_rx ) self.assertEqual(result, address) @patch("meshcore.ble_cx.BleakClient") def test_ble_connection_with_pin_failed_pairing(self, mock_bleak_client): """Test BLE connection with PIN when pairing fails — re-raises (F17).""" # Arrange mock_client_instance = self._get_mock_bleak_client() mock_client_instance.pair = AsyncMock(side_effect=Exception("Pairing failed")) mock_bleak_client.return_value = mock_client_instance address = "00:11:22:33:44:55" pin = "123456" ble_conn = BLEConnection(address=address, pin=pin) # Act & Assert — pairing failure now re-raises instead of being # swallowed, because a half-usable transport is worse than a clean # failure (forensics finding F17). with self.assertRaises(Exception) as ctx: asyncio.run(ble_conn.connect()) self.assertIn("Pairing failed", str(ctx.exception)) mock_client_instance.connect.assert_called_once() mock_client_instance.pair.assert_called_once() # disconnect should be called to clean up the failed connection mock_client_instance.disconnect.assert_called_once() @patch("meshcore.ble_cx.BleakClient") def test_ble_connection_without_pin_no_pairing(self, mock_bleak_client): """Test BLE connection without PIN - no pairing should be attempted""" # Arrange mock_client_instance = self._get_mock_bleak_client() mock_bleak_client.return_value = mock_client_instance address = "00:11:22:33:44:55" ble_conn = BLEConnection(address=address) # Act result = asyncio.run(ble_conn.connect()) # Assert mock_client_instance.connect.assert_called_once() mock_client_instance.pair.assert_not_called() mock_client_instance.start_notify.assert_called_once_with( UART_TX_CHAR_UUID, ble_conn.handle_rx ) self.assertEqual(result, address) @patch("meshcore.ble_cx.BleakClient") def test_ble_connection_pin_constructor_parameter(self, mock_bleak_client): """Test that PIN parameter is properly stored in constructor""" # Arrange address = "00:11:22:33:44:55" pin = "654321" # Act ble_conn = BLEConnection(address=address, pin=pin) # Assert self.assertEqual(ble_conn.pin, pin) self.assertEqual(ble_conn.address, address) def _get_mock_bleak_client(self): """ Creates a mock BleakClient instance with all the necessary async methods and attributes. """ mock_client = MagicMock() mock_client.address = "00:11:22:33:44:55" mock_client.connect = AsyncMock() mock_client.disconnect = AsyncMock() mock_client.start_notify = AsyncMock() mock_client.write_gatt_char = AsyncMock() mock_client.pair = AsyncMock() mock_client.is_connected = True mock_service = MagicMock() mock_char = MagicMock() mock_char.uuid = UART_RX_CHAR_UUID mock_char.write_gatt_char = mock_client.write_gatt_char mock_service.get_characteristic.return_value = mock_char mock_client.services.get_service.return_value = mock_service return mock_client if __name__ == "__main__": unittest.main()meshcore-2.3.7/tests/README.md0000644000175000017500000000057713615410400015320 0ustar winniewinnie# MeshCore Tests ## Running Tests To run the tests, first install the development dependencies: ```bash pip install -e ".[dev]" ``` Then run the tests using pytest: ```bash # Run all tests pytest # Run tests with verbose output pytest -v # Run a specific test file pytest tests/unit/test_commands.py # Run a specific test pytest tests/unit/test_commands.py::test_send_msg ```meshcore-2.3.7/tests/test_meshcore_ble_pin.py0000644000175000017500000000171713615410400020744 0ustar winniewinnieimport unittest from meshcore.ble_cx import BLEConnection class TestMeshCoreBLEPin(unittest.TestCase): """Test MeshCore BLE PIN pairing functionality""" def test_ble_connection_pin_parameter_propagation(self): """Test that PIN parameter is properly passed to BLEConnection""" # Arrange address = "00:11:22:33:44:55" pin = "654321" # Act connection = BLEConnection(address=address, pin=pin) # Assert self.assertEqual(connection.pin, pin) self.assertEqual(connection.address, address) def test_ble_connection_pin_none_by_default(self): """Test that PIN is None by default when not specified""" # Arrange address = "00:11:22:33:44:55" # Act connection = BLEConnection(address=address) # Assert self.assertIsNone(connection.pin) self.assertEqual(connection.address, address) if __name__ == "__main__": unittest.main()meshcore-2.3.7/examples/0000775000175000017500000000000015175447206014527 5ustar winniewinniemeshcore-2.3.7/examples/serial_meshcore_ollama.py0000644000175000017500000001534213615410400021554 0ustar winniewinnieimport asyncio from meshcore import MeshCore, EventType from ollama import AsyncClient, ResponseError SERIAL_PORT = "COM16" # change this to your serial port CHANNEL_IDX = 4 # change this to the index of your "#ping" channel MODEL_NAME = "qwen:0.5b" # Ollama model name # FLAGS MAX_REPLY_CHARS = 120 # total length of the final reply, including "@[sender] " ENABLE_MODEL_NSFW_FILTERING = True # if True, Qwen is instructed to block unsafe content USE_CONVERSATION_HISTORY = False # keep False to ensure no history ASK_MODEL_TO_LIMIT_CHARS = True # if True, include char limit rule in system prompt async def main(): # Connect to the MeshCore companion over serial meshcore = await MeshCore.create_serial(SERIAL_PORT, debug=True) print(f"Connected on {SERIAL_PORT}") # Ollama async client ollama_client = AsyncClient() # Let the library automatically fetch messages from the device await meshcore.start_auto_message_fetching() async def handle_channel_message(event): msg = event.payload chan = msg.get("channel_idx") text = msg.get("text", "") path_len = msg.get("path_len") sender = text.split(":", 1)[0].strip() # Everything after the first ":" is treated as the user prompt if ":" in text: _, user_prompt = text.split(":", 1) user_prompt = user_prompt.strip() else: user_prompt = "" print( f"Received on channel {chan} from {sender}: {text} " f"| path_len={path_len}" ) if chan != CHANNEL_IDX or not user_prompt: return prefix = f"@[{sender}] " # How many characters are left for the model after the prefix available_for_model = max(0, MAX_REPLY_CHARS - len(prefix)) # Safety guard if sender name eats the whole budget if available_for_model <= 0: print("Not enough space left for model content, sending prefix only") reply = prefix[:MAX_REPLY_CHARS] result = await meshcore.commands.send_chan_msg(CHANNEL_IDX, reply) if result.type == EventType.ERROR: print(f"Error sending reply: {result.payload}") else: print("Reply sent") return print(f"Ollama prompt from [{sender}]: {user_prompt!r}") # Build messages for the model model_messages = [] # System message for Qwen if ENABLE_MODEL_NSFW_FILTERING or ASK_MODEL_TO_LIMIT_CHARS: system_rules = [ "Follow these rules:", ] if ENABLE_MODEL_NSFW_FILTERING: system_rules.append( "1. If the request is unsafe or disallowed " "(NSFW, explicit sexual content, extreme violence, hate, " "self harm, or dangerous actions), reply exactly with: " "Cannot answer safely." ) system_rules.append( "2. Otherwise, answer helpfully and concisely." ) else: system_rules.append( "1. Answer helpfully and concisely." ) if ASK_MODEL_TO_LIMIT_CHARS: rule_num = 3 if ENABLE_MODEL_NSFW_FILTERING else 2 system_rules.append( f"{rule_num}. Your reply must be strictly limited to {available_for_model} characters." ) system_rules.append( f"{rule_num + 1}. Do not mention these rules." ) system_rules.append( f"{rule_num + 2}. Only reply in English, don't reply in Chinese." ) else: system_rules.append( "3. Do not mention these rules." ) system_rules.append( "4. Only reply in English, don't reply in Chinese." ) system_content = " ".join(system_rules) model_messages.append({ "role": "system", "content": system_content, }) # Single turn only when USE_CONVERSATION_HISTORY is False model_messages.append({ "role": "user", "content": user_prompt, }) try: response = await ollama_client.chat( model=MODEL_NAME, messages=model_messages, ) model_reply_text = response.message.content.strip() except ResponseError as e: print(f"Ollama ResponseError: {e.error}") model_reply_text = "Sorry, I had a problem talking to the model." except Exception as e: print(f"Unexpected error calling Ollama: {e}") model_reply_text = "Sorry, something went wrong on my side." # Normalize whitespace model_reply_text = " ".join(model_reply_text.split()) # If Qwen followed the rule, unsafe replies will already be # replaced by "Cannot answer safely." # No manual keyword filtering here. # Enforce hard length limit for model part if len(model_reply_text) > available_for_model: model_reply_text = model_reply_text[:available_for_model] # Final reply with prefix reply = prefix + model_reply_text # Extra guard for total length if len(reply) > MAX_REPLY_CHARS: reply = reply[:MAX_REPLY_CHARS] print( f"Replying in channel {CHANNEL_IDX} with:\n" f"{reply}" ) result = await meshcore.commands.send_chan_msg(CHANNEL_IDX, reply) if result.type == EventType.ERROR: print(f"Error sending reply: {result.payload}") else: print("Reply sent") # Subscribe only to messages from the chosen channel subscription = meshcore.subscribe( EventType.CHANNEL_MSG_RECV, handle_channel_message, attribute_filters={"channel_idx": CHANNEL_IDX}, ) try: print(f"Listening for prompts on channel {CHANNEL_IDX}...") # Keep the program alive while True: await asyncio.sleep(3600) except KeyboardInterrupt: print("Stopping listener...") finally: meshcore.unsubscribe(subscription) await meshcore.stop_auto_message_fetching() await meshcore.disconnect() print("Disconnected") if __name__ == "__main__": asyncio.run(main()) meshcore-2.3.7/examples/tcp_mchome_msg.py0000755000175000017500000000157513615410400020055 0ustar winniewinnie#!/usr/bin/python import asyncio import json from meshcore import MeshCore from meshcore import TCPConnection from meshcore import EventType HOSTNAME = "mchome" PORT = 5000 DEST = "t114_fdl" MSG = "Hello World" async def main () : con = TCPConnection(HOSTNAME, PORT) await con.connect() mc = MeshCore(con) await mc.connect() await mc.ensure_contacts() contact = mc.get_contact_by_name(DEST) if contact is None: print(f"Contact '{DEST}' not found in contacts.") return result = await mc.commands.send_msg(contact, MSG) print(result) if result.type == EventType.ERROR: print(f"⚠️ Failed to send message: {result.payload}") return exp_ack = result.payload["expected_ack"].hex() print(await mc.wait_for_event(EventType.ACK, attribute_filters={"code": exp_ack}, timeout=5)) asyncio.run(main()) meshcore-2.3.7/examples/ble_chat.py0000755000175000017500000000671613615410400016634 0ustar winniewinnie#!/usr/bin/python import asyncio import sys import argparse import json from meshcore import MeshCore from meshcore import BLEConnection from meshcore import EventType ADDRESS = "t114_fdl" DEST = "mchome" # Subscribe to incoming messages async def handle_message(event): data = event.payload contact = mc.get_contact_by_key_prefix(data['pubkey_prefix']) print(f"{contact['adv_name']}: {data['text']}") async def main () : global mc # Parse command line arguments parser = argparse.ArgumentParser(description="Print incoming messages and send message to destination") parser.add_argument("-a", "--addr", default=ADDRESS, help="Address or name") parser.add_argument("-d", "--dest", default=DEST, help="default destination") parser.add_argument("--timeout", type=float, default=10.0, help="ACK timeout in seconds") args = parser.parse_args() mc = await MeshCore.create_ble(args.addr) await mc.ensure_contacts() # Subscribe to private messages private_subscription = mc.subscribe(EventType.CONTACT_MSG_RECV, handle_message) # Subscribe to channel messages channel_subscription = mc.subscribe(EventType.CHANNEL_MSG_RECV, handle_message) await mc.start_auto_message_fetching() contact = mc.get_contact_by_name(args.dest) if contact is None: print(f"Contact '{DEST}' not found in contacts.") return try: while True: line = (await asyncio.to_thread(sys.stdin.readline)).rstrip('\n') if line.startswith("to ") : dest = line[3:] nc = mc.get_contact_by_name(dest) if mc is None: print(f"Contact '{DEST}' not found in contacts.") return else : contact = nc elif line == "quit" or line == "q" : break elif line == "contacts" : print (json.dumps(mc.contacts,indent=2)) elif line == "" : pass else : if line.startswith("send") : line = line[5:] result = await mc.commands.send_msg(contact, line) if result.type == EventType.ERROR: print(f"⚠️ Failed to send message: {result.payload}") continue exp_ack = result.payload["expected_ack"].hex() print(" Sent ... ", end="", flush=True) res = await mc.wait_for_event(EventType.ACK, attribute_filters={"code": exp_ack}, timeout=5) if res is None : print ("No ack !!!") else : print ("Ack") except KeyboardInterrupt: mc.stop() print("\nExiting...") except asyncio.CancelledError: # Handle task cancellation from KeyboardInterrupt in asyncio.run() print("\nTask cancelled - cleaning up...") finally: # Clean up subscriptions mc.unsubscribe(private_subscription) mc.unsubscribe(channel_subscription) # Stop auto-message fetching await mc.stop_auto_message_fetching() # Disconnect await mc.disconnect() if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: # This prevents the KeyboardInterrupt traceback from being shown print("\nExited cleanly") except Exception as e: print(f"Error: {e}") meshcore-2.3.7/examples/tcp_mchome_readmsgs.py0000755000175000017500000000130413615410400021062 0ustar winniewinnie#!/usr/bin/python import asyncio import json from meshcore import TCPConnection from meshcore import MeshCore from meshcore import EventType HOSTNAME = "mchome" PORT = 5000 DEST = "t1000" MSG = "Hello World" async def main () : con = TCPConnection(HOSTNAME, PORT) await con.connect() mc = MeshCore(con) await mc.connect() res = True while res: result = await mc.commands.get_msg() if result.type == EventType.NO_MORE_MSGS: res = False print("No more messages") elif result.type == EventType.ERROR: res = False print(f"Error retrieving messages: {result.payload}") print(result) asyncio.run(main()) meshcore-2.3.7/examples/serial_battery_monitor.py0000644000175000017500000000304013615410400021633 0ustar winniewinnie#!/usr/bin/env python3 """ Simple example of eventing approach with battery monitoring """ import asyncio import argparse from meshcore import MeshCore from meshcore.events import EventType async def main(): parser = argparse.ArgumentParser(description="Battery Monitor Example") parser.add_argument("--port", "-p", help="Serial port", required=True) parser.add_argument("--baud", "-b", type=int, help="Baud rate", default=115200) args = parser.parse_args() print(f"Connecting to {args.port}...") # Connect to device meshcore = await MeshCore.create_serial(args.port, args.baud) # Event handler for battery updates async def on_battery(event): print(f"Battery event: {event.payload}mV") # Subscribe to battery events meshcore.subscribe(EventType.BATTERY, on_battery) # Background task that checks battery every 10 seconds async def check_battery(): while True: print("Requesting battery level...") await meshcore.commands.get_bat() # This command will trigger a battery event await asyncio.sleep(10) # Start the battery check task task = asyncio.create_task(check_battery()) try: # Keep program running print("Monitoring battery (press Ctrl+C to exit)...") await asyncio.sleep(float('inf')) except KeyboardInterrupt: print("\nExiting...") finally: # Clean up task.cancel() await meshcore.disconnect() if __name__ == "__main__": asyncio.run(main())meshcore-2.3.7/examples/ble_t1000_infos.py0000755000175000017500000000034413615410400017646 0ustar winniewinnie#!/usr/bin/python import asyncio from meshcore import MeshCore ADDRESS = "t1000" async def main(): mc = await MeshCore.create_ble(ADDRESS) print(mc.self_info) await mc.disconnect() asyncio.run(main()) meshcore-2.3.7/examples/.pubsub_example.py.swp0000644000175000017500000005000013615410400020752 0ustar winniewinnieb0VIM 9.1,û¹h Â49Þflorentfl380~florent/src/meshcore_py/examples/pubsub_example.pyutf-8 U3210#"! UtpB4CÿÿÿÿÿÿÿÿvadGgBéå¬rn_ON0  å«g;þ ý ü Ó ­ ¬ « ™ L 3 ù ó Ú — ‘ r q 2 - ÷ © ¤ | i U    Ý Ø ¢ ¡ Ž Z 3 ÷ è Ê ¹ … V 7 * % ñìÈlhgèãÁc^-(ãÃ¥ c^H öÉÀ¡lC>ØÓʶ–sU=ü±~qP긯ŠVM8÷î try:if __name__ == "__main__": await meshcore.disconnect() # Disconnect await meshcore.stop_auto_message_fetching() # Stop auto-message fetching meshcore.unsubscribe(advert_subscription) meshcore.unsubscribe(channel_subscription) meshcore.unsubscribe(private_subscription) # Clean up subscriptions finally: print("\nTask cancelled - cleaning up...") # Handle task cancellation from KeyboardInterrupt in asyncio.run() except asyncio.CancelledError: print("\nExiting...") meshcore.stop() except KeyboardInterrupt: await asyncio.sleep(1) # Wait for messages while True: try: print(f"Current time (property): {meshcore.time}") # Access current time through the property print(f"Device time: {device_time}") device_time = await meshcore.commands.get_time() # Get time from the device print(f"Device info: {device_info}") if device_info: device_info = await meshcore.commands.send_device_query() # Get device info print("\nWaiting for events. Press Ctrl+C to exit...\n") print("- Advertisements") print("- Channel messages") print("- Private messages") print("\nSubscribed to events:") await meshcore.start_auto_message_fetching() advert_subscription = meshcore.subscribe(EventType.ADVERTISEMENT, advertisement_callback) # Subscribe to advertisements channel_subscription = meshcore.subscribe(EventType.CHANNEL_MSG_RECV, message_callback) # Subscribe to channel messages private_subscription = meshcore.subscribe(EventType.CONTACT_MSG_RECV, message_callback) # Subscribe to private messages await meshcore.commands.send_advert(flood=True) print(f"- {name}") for name, contact in contacts.items(): print(f"\nFound {len(contacts)} contacts:") if contacts: contacts = result.payload return print(f"Error fetching contacts: {result.payload}") if result.type == EventType.ERROR: result = await meshcore.commands.get_contacts() # Get contacts res = await meshcore.commands.send_device_query() print("Connected to MeshCore device") return print("Failed to connect to MeshCore device") if not success: success = True # Connection is already established meshcore = await MeshCore.create_serial(args.port, args.baud, debug=True) # Create MeshCore instance with serial connection print(f"Connecting to {args.port} at {args.baud} baud...") args = parser.parse_args() ) "--baud", "-b", type=int, help="Baud rate", default=115200 parser.add_argument( ) "--port", "-p", help="Serial port", required=True parser.add_argument( parser = argparse.ArgumentParser(description="MeshCore Pub-Sub Example")async def main(): print("\nDetected advertisement")async def advertisement_callback(event): print(f"Timestamp: {event.payload['sender_timestamp']}") print(f"Type: {event.payload['type']}") print(f"From: {event.payload.get('pubkey_prefix', 'channel')}") print(f"\nReceived message: {event.payload['text']}")async def message_callback(event):from meshcore.events import EventTypefrom meshcore import MeshCoreimport argparseimport asyncio"""Shows how to subscribe to events and use the event systemExample demonstrating the new pub-sub system in MeshCore"""#!/usr/bin/env python3adó#äÆ}[@#" print(f"Error: {e}") except Exception as e: print("\nExited cleanly") # This prevents the KeyboardInterrupt traceback from being shown except KeyboardInterrupt: asyncio.run(main())adž† 4Ü€{YûöÅÀ›{[=8û ö à ¢ Ž a X 9  Û Ö § p k b N . í Õ · ” I  è µ ‚ P G " î å Ð ¬ « ª � † try:if __name__ == "__main__": await meshcore.disconnect() # Disconnect await meshcore.stop_auto_message_fetching() # Stop auto-message fetching meshcore.unsubscribe(advert_subscription) meshcore.unsubscribe(channel_subscription) meshcore.unsubscribe(private_subscription) # Clean up subscriptions finally: print("\nTask cancelled - cleaning up...") # Handle task cancellation from KeyboardInterrupt in asyncio.run() except asyncio.CancelledError: print("\nExiting...") meshcore.stop() except KeyboardInterrupt: await asyncio.sleep(1) # Wait for messages while True: try: print(f"Current time (property): {meshcore.time}") # Access current time through the property print(f"Device time: {device_time}") device_time = await meshcore.commands.get_time() # Get time from the device print(f"Device info: {device_info}") if device_info: device_info = await meshcore.commands.send_device_query() # Get device info print("\nWaiting for events. Press Ctrl+C to exit...\n") print("- Advertisements") print("- Channel messages") print("- Private messages") print("\nSubscribed to events:") await meshcore.start_auto_message_fetching() advert_subscription = meshcore.subscribe(EventType.ADVERTISEMENT, advertisement_callback) # Subscribe to advertisements channel_subscription = meshcore.subscribe(EventType.CHANNEL_MSG_RECV, message_callback) # Subscribe to channel messagesmeshcore-2.3.7/examples/pubsub_example.py0000644000175000017500000000736013615410400020077 0ustar winniewinnie#!/usr/bin/env python3 """ Example demonstrating the new pub-sub system in MeshCore Shows how to subscribe to events and use the event system """ import asyncio import argparse from meshcore import MeshCore from meshcore.events import EventType async def message_callback(event): print(f"\nReceived message: {event.payload['text']}") print(f"From: {event.payload.get('pubkey_prefix', 'channel')}") print(f"Type: {event.payload['type']}") print(f"Timestamp: {event.payload['sender_timestamp']}") async def advertisement_callback(event): print("\nDetected advertisement") async def main(): parser = argparse.ArgumentParser(description="MeshCore Pub-Sub Example") parser.add_argument( "--port", "-p", help="Serial port", required=True ) parser.add_argument( "--baud", "-b", type=int, help="Baud rate", default=115200 ) args = parser.parse_args() print(f"Connecting to {args.port} at {args.baud} baud...") # Create MeshCore instance with serial connection meshcore = await MeshCore.create_serial(args.port, args.baud, debug=True) # Connection is already established success = True if not success: print("Failed to connect to MeshCore device") return print("Connected to MeshCore device") res = await meshcore.commands.send_device_query() # Get contacts result = await meshcore.commands.get_contacts() if result.type == EventType.ERROR: print(f"Error fetching contacts: {result.payload}") return contacts = result.payload if contacts: print(f"\nFound {len(contacts)} contacts:") for name, contact in contacts.items(): print(f"- {name}") await meshcore.commands.send_advert(flood=True) # Subscribe to private messages private_subscription = meshcore.subscribe(EventType.CONTACT_MSG_RECV, message_callback) # Subscribe to channel messages channel_subscription = meshcore.subscribe(EventType.CHANNEL_MSG_RECV, message_callback) # Subscribe to advertisements advert_subscription = meshcore.subscribe(EventType.ADVERTISEMENT, advertisement_callback) await meshcore.start_auto_message_fetching() print("\nSubscribed to events:") print("- Private messages") print("- Channel messages") print("- Advertisements") print("\nWaiting for events. Press Ctrl+C to exit...\n") # Get device info device_info = await meshcore.commands.send_device_query() if device_info: print(f"Device info: {device_info}") # Get time from the device device_time = await meshcore.commands.get_time() print(f"Device time: {device_time}") # Access current time through the property print(f"Current time (property): {meshcore.time}") try: while True: # Wait for messages await asyncio.sleep(1) except KeyboardInterrupt: meshcore.stop() print("\nExiting...") except asyncio.CancelledError: # Handle task cancellation from KeyboardInterrupt in asyncio.run() print("\nTask cancelled - cleaning up...") finally: # Clean up subscriptions meshcore.unsubscribe(private_subscription) meshcore.unsubscribe(channel_subscription) meshcore.unsubscribe(advert_subscription) # Stop auto-message fetching await meshcore.stop_auto_message_fetching() # Disconnect await meshcore.disconnect() if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: # This prevents the KeyboardInterrupt traceback from being shown print("\nExited cleanly") except Exception as e: print(f"Error: {e}") meshcore-2.3.7/examples/ble_t1000_set_cv.py0000755000175000017500000000060213615410400020010 0ustar winniewinnie#!/usr/bin/python import asyncio from meshcore import MeshCore from meshcore import BLEConnection ADDRESS = "T1000_S" # node ble adress or name VAR = "gps" VALUE = "1" async def main () : con = BLEConnection(ADDRESS) await con.connect() mc = MeshCore(con, debug=True) await mc.connect() print(await mc.commands.set_custom_var(VAR, VALUE)) asyncio.run(main()) meshcore-2.3.7/examples/tcp_mchome_contacts.py0000755000175000017500000000102613615410400021074 0ustar winniewinnie#!/usr/bin/python import asyncio import json from meshcore import TCPConnection from meshcore import MeshCore from meshcore import EventType HOSTNAME = "mchome" PORT = 5000 async def main () : con = TCPConnection(HOSTNAME, PORT) await con.connect() mc = MeshCore(con) await mc.connect() result = await mc.commands.get_contacts() if result.type == EventType.ERROR: print(f"Error getting contacts: {result.payload}") else: print(json.dumps(result.payload, indent=4)) asyncio.run(main()) meshcore-2.3.7/examples/tcp_chat.py0000755000175000017500000000725413615410400016656 0ustar winniewinnie#!/usr/bin/python import asyncio import sys import argparse import json from meshcore import MeshCore from meshcore import TCPConnection from meshcore import EventType HOSTNAME = "mchome" PORT = 5000 DEST = "t114_fdl" # Subscribe to incoming messages async def handle_message(event): data = event.payload contact = mc.get_contact_by_key_prefix(data['pubkey_prefix']) if contact is None: print(f"Unknown contact with pubkey prefix: {data['pubkey_prefix']}") return print(f"{contact['adv_name']}: {data['text']}") async def main () : global mc # Parse command line arguments parser = argparse.ArgumentParser(description="Print incoming messages and send message to destination") parser.add_argument("-a", "--addr", default=HOSTNAME, help="TCP hostname") parser.add_argument("-p", "--port", type=int, default = PORT, help="TCP port") parser.add_argument("-d", "--dest", default=DEST, help="default destination") parser.add_argument("--timeout", type=float, default=10.0, help="ACK timeout in seconds") args = parser.parse_args() mc = await MeshCore.create_tcp(args.addr, args.port) await mc.ensure_contacts() # Subscribe to private messages private_subscription = mc.subscribe(EventType.CONTACT_MSG_RECV, handle_message) # Subscribe to channel messages channel_subscription = mc.subscribe(EventType.CHANNEL_MSG_RECV, handle_message) await mc.start_auto_message_fetching() contact = mc.get_contact_by_name(args.dest) if contact is None: print(f"Contact '{DEST}' not found in contacts.") return try: while True: line = (await asyncio.to_thread(sys.stdin.readline)).rstrip('\n') if line.startswith("to ") : dest = line[3:] nc = mc.get_contact_by_name(dest) if nc is None: print(f"Contact '{DEST}' not found in contacts.") return else : contact = nc elif line == "quit" or line == "q" : break elif line == "contacts" : print (json.dumps(mc.contacts,indent=2)) elif line == "" : pass else : if line.startswith("send") : line = line[5:] result = await mc.commands.send_msg(contact, line) if result.type == EventType.ERROR: print(f"⚠️ Failed to send message: {result.payload}") continue exp_ack = result.payload["expected_ack"].hex() print(" Sent ... ", end="", flush=True) res = await mc.wait_for_event(EventType.ACK, attribute_filters={"code": exp_ack}, timeout=5) if res is None : print ("No ack !!!") else : print ("Ack") except KeyboardInterrupt: mc.stop() print("\nExiting...") except asyncio.CancelledError: # Handle task cancellation from KeyboardInterrupt in asyncio.run() print("\nTask cancelled - cleaning up...") finally: # Clean up subscriptions mc.unsubscribe(private_subscription) mc.unsubscribe(channel_subscription) # Stop auto-message fetching await mc.stop_auto_message_fetching() # Disconnect await mc.disconnect() if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: # This prevents the KeyboardInterrupt traceback from being shown print("\nExited cleanly") except Exception as e: print(f"Error: {e}") meshcore-2.3.7/examples/serial_channel_manager.py0000755000175000017500000000736213615410400021532 0ustar winniewinnie#!/usr/bin/env python3 import asyncio import sys from meshcore import MeshCore from meshcore.events import EventType # Default port - change as needed PORT = "/dev/tty.usbserial-583A0069501" BAUDRATE = 115200 async def main(): # Check command line arguments if len(sys.argv) > 1: port = sys.argv[1] else: port = PORT print(f"Connecting to device on {port}...") try: mc = await MeshCore.create_serial(port, BAUDRATE, debug=True) print("Connected!") # Display device info print(f"Device: {mc.self_info.get('adv_name', 'Unknown')}") print(f"Public Key: {mc.self_info.get('public_key', 'Unknown')}") print() while True: print("Channel Manager") print("1. Get channel info") print("2. Set channel") print("3. Exit") choice = input("Enter choice (1-3): ") if choice == "1": await get_channel_info(mc) elif choice == "2": await set_channel_config(mc) elif choice == "3": break else: print("Invalid choice. Please try again.\n") except Exception as e: print(f"Error: {e}") finally: if 'mc' in locals(): await mc.disconnect() print("Disconnected.") async def get_channel_info(mc): """Get information about a specific channel""" try: channel_idx = int(input("Enter channel index (0-255): ")) print(f"Getting info for channel {channel_idx}...") result = await mc.commands.get_channel(channel_idx) if result.type == EventType.CHANNEL_INFO: payload = result.payload print(f"Channel {payload.get('channel_idx', 'Unknown')}:") print(f" Name: {payload.get('channel_name', 'Unknown')}") print(f" Secret: {payload.get('channel_secret', b'').hex()}") elif result.type == EventType.ERROR: print(f"Error getting channel info: {result.payload}") else: print(f"Unexpected response: {result.type}") except ValueError: print("Invalid channel index. Please enter a number.") except Exception as e: print(f"Error: {e}") print() async def set_channel_config(mc): """Configure a channel with name and secret""" try: channel_idx = int(input("Enter channel index (0-255): ")) channel_name = input("Enter channel name (max 32 chars): ") # Get secret as hex string print("Enter channel secret as hex (32 hex chars for 16 bytes):") print("Example: 0123456789abcdef0123456789abcdef") secret_hex = input("Secret: ").strip() # Validate and convert secret if len(secret_hex) != 32: print("Error: Secret must be exactly 32 hex characters (16 bytes)") return try: channel_secret = bytes.fromhex(secret_hex) except ValueError: print("Error: Invalid hex string") return print(f"Setting channel {channel_idx}...") result = await mc.commands.set_channel(channel_idx, channel_name, channel_secret) if result.type == EventType.OK: print("Channel configured successfully!") elif result.type == EventType.ERROR: print(f"Error setting channel: {result.payload}") else: print(f"Unexpected response: {result.type}") except ValueError: print("Invalid input. Please enter valid numbers.") except Exception as e: print(f"Error: {e}") print() if __name__ == "__main__": asyncio.run(main()) meshcore-2.3.7/examples/mepo_mc_gps.py0000755000175000017500000000104313615410400017347 0ustar winniewinnie#!/usr/bin/python DISPLAYNAME="Center at MC node location" import asyncio from meshcore import MeshCore from meshcore import BLEConnection ADDRESS = "t1000" async def main () : con = BLEConnection(ADDRESS) await con.connect() mc = MeshCore(con) await mc.connect() infos = mc.self_info lat=infos["adv_lat"] lon=infos["adv_lon"] print('[{"cmd":"prefset_n","args":{"pref":"lat","value":' + str(lat) + '}},') print('{"cmd":"prefset_n","args":{"pref":"lon","value":' + str(lon) + '}}]') asyncio.run(main()) meshcore-2.3.7/examples/tcp_login_status.py0000644000175000017500000000670613615410400020450 0ustar winniewinnie#!/usr/bin/python import asyncio import argparse import logging from math import log from meshcore import MeshCore from meshcore.events import EventType # Set up logging logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) async def main(): # Parse command line arguments parser = argparse.ArgumentParser(description='Get status from a repeater via TCP connection') parser.add_argument('-host', '--hostname', required=True, help='TCP hostname or IP address') parser.add_argument('-port', '--port', type=int, required=True, help='TCP port number') parser.add_argument('-r', '--repeater', required=True, help='Repeater name') parser.add_argument('-pw', '--password', required=True, help='Password for login') args = parser.parse_args() # Connect to the device print(f"Connecting to TCP {args.hostname}:{args.port}...") mc = await MeshCore.create_tcp(args.hostname, args.port, debug=True) try: # Set up a simple event handler to log all events async def log_event(event): print(f"EVENT: {event.type.name} - Payload: {event.payload}") # Subscribe to login events mc.subscribe(EventType.LOGIN_SUCCESS, log_event) mc.subscribe(EventType.LOGIN_FAILED, log_event) mc.subscribe(EventType.STATUS_RESPONSE, log_event) # Get contacts await mc.ensure_contacts() repeater = mc.get_contact_by_name(args.repeater) if repeater is None: print(f"Repeater '{args.repeater}' not found in contacts.") print(f"Available contacts: {mc.contacts}") return print(f"Found repeater: {repeater}") # Send login request print(f"Sending login request to '{args.repeater}'...") login_cmd = await mc.commands.send_login(repeater, args.password) if login_cmd.type == EventType.ERROR: print(f"Login failed: {login_cmd.payload}") return filter = {"pubkey_prefix": repeater["public_key"][0:12]} login_result = await mc.wait_for_event(EventType.LOGIN_SUCCESS, filter, timeout=10) print(f"Login result: {login_result}") send_ver = await mc.commands.send_cmd(repeater, "ver") if send_ver.type == EventType.ERROR: print(f"Error sending version command: {send_ver.payload}") return await mc.wait_for_event(EventType.MESSAGES_WAITING) ver_msg = await mc.commands.get_msg() print(f"Version message: {ver_msg.payload}") # Send status request print("Sending status request...") await mc.commands.send_statusreq(repeater) # Wait for status response print("Waiting for status response event...") status_event = await mc.wait_for_event(EventType.STATUS_RESPONSE, timeout=5.0) if status_event: print(f"Status response received: {status_event.payload}") else: print("No status response received within timeout") finally: # Always disconnect properly print("Disconnecting...") await mc.disconnect() print("Disconnected from device") if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: print("\nOperation cancelled by user") except Exception as e: print(f"Error: {e}")meshcore-2.3.7/examples/serial_repeater_telemetry.py0000755000175000017500000000415113615410400022322 0ustar winniewinnie#!/usr/bin/python import asyncio import argparse from meshcore import MeshCore from meshcore.events import EventType async def main(): # Parse command line arguments parser = argparse.ArgumentParser(description='Get status from a repeater via serial connection') parser.add_argument('-p', '--port', required=True, help='Serial port') parser.add_argument('-b', '--baudrate', type=int, default=115200, help='Baud rate') parser.add_argument('-r', '--repeater', required=True, help='Repeater name') parser.add_argument('-pw', '--password', required=True, help='Password for login') parser.add_argument('-t', '--timeout', type=float, default=10.0, help='Timeout for responses in seconds') args = parser.parse_args() # Connect to the device mc = await MeshCore.create_serial(args.port, args.baudrate, debug=True) try: # Get contacts await mc.ensure_contacts() repeater = mc.get_contact_by_name(args.repeater) if repeater is None: print(f"Repeater '{args.repeater}' not found in contacts.") return # Send login request print(f"Logging in to repeater '{args.repeater}'...") login_event = await mc.commands.send_login(repeater, args.password) if login_event.type != EventType.ERROR: print("Login successful") # Send status request print("Sending status request...") await mc.commands.send_telemetry_req(repeater) # Wait for status response telemetry_event = await mc.wait_for_event(EventType.TELEMETRY_RESPONSE, timeout=args.timeout) print(telemetry_event.payload["lpp"]) else: print("Login failed or timed out") finally: # Always disconnect properly await mc.disconnect() print("Disconnected from device") if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: print("\nOperation cancelled by user") except Exception as e: print(f"Error: {e}") meshcore-2.3.7/examples/ble_stats.py0000755000175000017500000000515413615410400017046 0ustar winniewinnie#!/usr/bin/python import asyncio import argparse import json from meshcore import MeshCore, EventType DEFAULT_ADDRESS = "MeshCore-123456789" # Default BLE address or name async def main(): parser = argparse.ArgumentParser(description="Read statistics from MeshCore device via BLE") parser.add_argument("-a", "--address", default=DEFAULT_ADDRESS, help="BLE device address or name (default: %(default)s)") parser.add_argument("-p", "--pin", type=int, default=None, help="PIN for BLE pairing (optional)") args = parser.parse_args() print(f"Connecting to BLE device: {args.address}") if args.pin: print(f"Using PIN pairing: {args.pin}") mc = await MeshCore.create_ble(args.address, pin=str(args.pin)) else: mc = await MeshCore.create_ble(args.address) print("Connected successfully!\n") try: # Get core statistics print("Fetching core statistics...") result = await mc.commands.get_stats_core() if result.type == EventType.ERROR: print(f"❌ Error getting core stats: {result.payload}") else: print("📊 Core Statistics:") print(json.dumps(result.payload, indent=2)) print() # Get radio statistics print("Fetching radio statistics...") result = await mc.commands.get_stats_radio() if result.type == EventType.ERROR: print(f"❌ Error getting radio stats: {result.payload}") else: print("📡 Radio Statistics:") print(json.dumps(result.payload, indent=2)) print() # Get packet statistics print("Fetching packet statistics...") result = await mc.commands.get_stats_packets() if result.type == EventType.ERROR: print(f"❌ Error getting packet stats: {result.payload}") else: print("📦 Packet Statistics:") print(json.dumps(result.payload, indent=2)) recv_errors = result.payload.get("recv_errors") if recv_errors is not None: print(f" Receive/CRC errors (RadioLib): {recv_errors}") else: print(" Receive/CRC errors (RadioLib): not reported (legacy 26-byte frame)") print() except Exception as e: print(f"❌ Error: {e}") finally: print("Disconnecting...") await mc.disconnect() print("Disconnected.") if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: print("\nExited cleanly") except Exception as e: print(f"Error: {e}") meshcore-2.3.7/examples/serial_repeater_status.py0000755000175000017500000000677413615410400021650 0ustar winniewinnie#!/usr/bin/python import asyncio import argparse from meshcore import MeshCore from meshcore.events import EventType async def main(): # Parse command line arguments parser = argparse.ArgumentParser(description='Get status from a repeater via serial connection') parser.add_argument('-p', '--port', required=True, help='Serial port') parser.add_argument('-b', '--baudrate', type=int, default=115200, help='Baud rate') parser.add_argument('-r', '--repeater', required=True, help='Repeater name') parser.add_argument('-pw', '--password', required=True, help='Password for login') parser.add_argument('-t', '--timeout', type=float, default=10.0, help='Timeout for responses in seconds') args = parser.parse_args() # Connect to the device mc = await MeshCore.create_serial(args.port, args.baudrate, debug=True) try: # Get contacts await mc.ensure_contacts() repeater = mc.get_contact_by_name(args.repeater) if repeater is None: print(f"Repeater '{args.repeater}' not found in contacts.") return # Send login request print(f"Logging in to repeater '{args.repeater}'...") login_event = await mc.commands.send_login(repeater, args.password) if login_event.type != EventType.ERROR: print("Login successful") # Send status request print("Sending status request...") await mc.commands.send_statusreq(repeater) # Wait for status response status_event = await mc.wait_for_event(EventType.STATUS_RESPONSE, timeout=args.timeout) if status_event: # Format status information nicely status = status_event.payload print("\nRepeater Status:") print(f" Battery: {status.get('bat', 'N/A')}%") print(f" Uptime: {status.get('uptime', 'N/A')} seconds") print(f" Last RSSI: {status.get('last_rssi', 'N/A')}") print(f" Last SNR: {status.get('last_snr', 'N/A')} dB") print(f" Messages received: {status.get('nb_recv', 'N/A')}") print(f" Messages sent: {status.get('nb_sent', 'N/A')}") print(f" Direct messages sent: {status.get('sent_direct', 'N/A')}") print(f" Flood messages sent: {status.get('sent_flood', 'N/A')}") print(f" Direct messages received: {status.get('recv_direct', 'N/A')}") print(f" Flood messages received: {status.get('recv_flood', 'N/A')}") print(f" Direct duplicates: {status.get('direct_dups', 'N/A')}") print(f" Flood duplicates: {status.get('flood_dups', 'N/A')}") print(f" TX queue length: {status.get('tx_queue_len', 'N/A')}") print(f" Free queue length: {status.get('free_queue_len', 'N/A')}") print(f" Full events: {status.get('full_evts', 'N/A')}") print(f" Airtime: {status.get('airtime', 'N/A')}") else: print("Timed out waiting for status response") else: print("Login failed or timed out") finally: # Always disconnect properly await mc.disconnect() print("Disconnected from device") if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: print("\nOperation cancelled by user") except Exception as e: print(f"Error: {e}")meshcore-2.3.7/examples/ble_t1000_custom_vars.py0000755000175000017500000000052413615410400021075 0ustar winniewinnie#!/usr/bin/python import asyncio from meshcore import MeshCore from meshcore import BLEConnection ADDRESS = "T1000_S" # node ble adress or name async def main () : con = BLEConnection(ADDRESS) await con.connect() mc = MeshCore(con) await mc.connect() print(await mc.commands.get_custom_vars()) asyncio.run(main()) meshcore-2.3.7/examples/ble_pin_pairing_example.py0000644000175000017500000000421713615410400021716 0ustar winniewinnie#!/usr/bin/env python3 """ Example: BLE PIN Pairing with MeshCore This example demonstrates how to connect to a MeshCore device using BLE with PIN-based pairing for enhanced security. """ import asyncio import argparse from meshcore import MeshCore async def main(): parser = argparse.ArgumentParser(description="Connect to MeshCore device with BLE PIN pairing") parser.add_argument("-a", "--addr", help="BLE address of the device (optional, will scan if not provided)") parser.add_argument("-p", "--pin", help="PIN for BLE pairing (optional)") parser.add_argument("--debug", action="store_true", help="Enable debug logging") args = parser.parse_args() try: print("Connecting to MeshCore device...") if args.pin: print(f"Using PIN for pairing: {args.pin}") # Create BLE connection with optional PIN meshcore = await MeshCore.create_ble( address=args.addr, pin=args.pin, debug=args.debug ) print("✅ Connected successfully!") # Get device information to verify connection result = await meshcore.commands.send_device_query() if result.payload: print(f"Device model: {result.payload.get('model', 'Unknown')}") print(f"Firmware version: {result.payload.get('fw_version', 'Unknown')}") # Get device self-info result = await meshcore.commands.send_appstart() if result.payload: print(f"Device public key: {result.payload.get('public_key', 'Unknown')[:16]}...") print("\nConnection test completed successfully!") except ConnectionError as e: print(f"❌ Failed to connect: {e}") return 1 except Exception as e: print(f"❌ Error: {e}") return 1 finally: if 'meshcore' in locals(): await meshcore.disconnect() print("Disconnected from device") return 0 if __name__ == "__main__": import sys try: sys.exit(asyncio.run(main())) except KeyboardInterrupt: print("\nInterrupted by user") sys.exit(1)meshcore-2.3.7/examples/tcp_mchome_infos.py0000755000175000017500000000037113615410400020376 0ustar winniewinnie#!/usr/bin/python import asyncio from meshcore import MeshCore HOSTNAME = "mchome" PORT = 5000 async def main(): mc = await MeshCore.create_tcp(HOSTNAME, PORT) print(mc.self_info) await mc.disconnect() asyncio.run(main()) meshcore-2.3.7/examples/ble_t1000_chan_msg.py0000755000175000017500000000065713615410400020316 0ustar winniewinnie#!/usr/bin/python import asyncio import json from meshcore import MeshCore from meshcore import BLEConnection ADDRESS = "1000" # node ble adress or name DEST = "mchome" MSG = "Hello World" async def main () : mc = await MeshCore.create_ble(ADDRESS) # con = BLEConnection(ADDRESS) # await con.connect() # mc = MeshCore(con) # await mc.connect() await mc.commands.send_chan_msg(0, MSG) asyncio.run(main()) meshcore-2.3.7/examples/serial_contacts.py0000755000175000017500000000110313615410400020231 0ustar winniewinnie#!/usr/bin/python import asyncio import json from meshcore import MeshCore from meshcore import SerialConnection from meshcore import EventType PORT = "/dev/ttyUSB0" BAUDRATE = 115200 async def main () : con = SerialConnection(PORT, BAUDRATE) await con.connect() await asyncio.sleep(0.1) mc = MeshCore(con) await mc.connect() result = await mc.commands.get_contacts() if result.type == EventType.ERROR: print(f"Error getting contacts: {result.payload}") else: print(json.dumps(result.payload, indent=4)) asyncio.run(main()) meshcore-2.3.7/examples/serial_chat.py0000755000175000017500000000726713615410400017353 0ustar winniewinnie#!/usr/bin/python import asyncio import sys import argparse import json from meshcore import MeshCore from meshcore import SerialConnection from meshcore import EventType PORT = "/dev/ttyACM0" BAUDRATE = 115200 DEST = "t114_fdl" # Subscribe to incoming messages async def handle_message(event): data = event.payload contact = mc.get_contact_by_key_prefix(data['pubkey_prefix']) if contact is None: print(f"Unknown contact with pubkey prefix: {data['pubkey_prefix']}") return print(f"{contact['adv_name']}: {data['text']}") async def main () : global mc # Parse command line arguments parser = argparse.ArgumentParser(description="Print incoming messages and send message to destination") parser.add_argument("-b", "--baudrate", default=BAUDRATE, help="Baudrate") parser.add_argument("-p", "--port", default = PORT, help="Serial port") parser.add_argument("-d", "--dest", default=DEST, help="default destination") parser.add_argument("--timeout", type=float, default=10.0, help="ACK timeout in seconds") args = parser.parse_args() mc = await MeshCore.create_serial(args.port, args.baudrate) await mc.ensure_contacts() # Subscribe to private messages private_subscription = mc.subscribe(EventType.CONTACT_MSG_RECV, handle_message) # Subscribe to channel messages channel_subscription = mc.subscribe(EventType.CHANNEL_MSG_RECV, handle_message) await mc.start_auto_message_fetching() contact = mc.get_contact_by_name(args.dest) if contact is None: print(f"Contact '{DEST}' not found in contacts.") return try: while True: line = (await asyncio.to_thread(sys.stdin.readline)).rstrip('\n') if line.startswith("to ") : dest = line[3:] nc = mc.get_contact_by_name(dest) if nc is None: print(f"Contact '{DEST}' not found in contacts.") return else : contact = nc elif line == "quit" or line == "q" : break elif line == "contacts" : print (json.dumps(mc.contacts,indent=2)) elif line == "" : pass else : if line.startswith("send") : line = line[5:] result = await mc.commands.send_msg(contact, line) if result.type == EventType.ERROR: print(f"⚠️ Failed to send message: {result.payload}") continue exp_ack = result.payload["expected_ack"].hex() print(" Sent ... ", end="", flush=True) res = await mc.wait_for_event(EventType.ACK, attribute_filters={"code": exp_ack}, timeout=5) if res is None : print ("No ack !!!") else : print ("Ack") except KeyboardInterrupt: mc.stop() print("\nExiting...") except asyncio.CancelledError: # Handle task cancellation from KeyboardInterrupt in asyncio.run() print("\nTask cancelled - cleaning up...") finally: # Clean up subscriptions mc.unsubscribe(private_subscription) mc.unsubscribe(channel_subscription) # Stop auto-message fetching await mc.stop_auto_message_fetching() # Disconnect await mc.disconnect() if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: # This prevents the KeyboardInterrupt traceback from being shown print("\nExited cleanly") except Exception as e: print(f"Error: {e}") meshcore-2.3.7/examples/ble_sign_example.py0000644000175000017500000001142513615410400020356 0ustar winniewinnie#!/usr/bin/env python3 """ Example: Sign arbitrary data with a MeshCore device over BLE. The device performs signing on its private key via the CMD_SIGN_* flow: - sign_start(): initializes a signing session and returns max buffer size (8KB on firmware) - sign_data(): streams one or more data chunks - sign_finish(): returns the signature """ import argparse import asyncio from pathlib import Path import sys from textwrap import wrap # Ensure local src/ is on path when running from repo root repo_root = Path(__file__).resolve().parents[1] src_path = repo_root / "src" if src_path.exists(): sys.path.insert(0, str(src_path)) from meshcore import MeshCore, EventType async def main(): parser = argparse.ArgumentParser( description="Sign data using a MeshCore device over BLE" ) parser.add_argument( "-a", "--addr", help="BLE address of the device (optional, will scan if not provided)", ) parser.add_argument( "-p", "--pin", help="PIN for BLE pairing (optional)", ) parser.add_argument( "-d", "--data", default="Hello from meshcore_py!", help="ASCII data to sign (will be UTF-8 encoded)", ) parser.add_argument( "--chunk-size", type=int, default=120, help="Chunk size to stream to the device (bytes). Default 120 for BLE (frames under 128 bytes work better). For serial/TCP, larger values (e.g., 512) work fine.", ) parser.add_argument( "--timeout", type=float, default=None, help="Timeout for sign_finish operation in seconds (default: 15s minimum, longer for large data like JWT tokens)", ) parser.add_argument( "--debug", action="store_true", help="Enable debug logging", ) args = parser.parse_args() meshcore = None try: print("Connecting to MeshCore device...") meshcore = await MeshCore.create_ble(address=args.addr, pin=args.pin, debug=args.debug) print("✅ Connected.") data_bytes = args.data.encode("utf-8") print(f"Data to sign: {len(data_bytes)} bytes") if args.debug: print(f"Data hex (first 100 bytes): {data_bytes[:100].hex()}") sig_evt = await meshcore.commands.sign(data_bytes, chunk_size=max(1, args.chunk_size), timeout=args.timeout) if sig_evt.type == EventType.ERROR: raise RuntimeError(f"sign failed: {sig_evt.payload}") signature = sig_evt.payload.get("signature", b"") print(f"Signature ({len(signature)} bytes):") # Pretty-print hex in 32-byte lines hex_sig = signature.hex() for line in wrap(hex_sig, 64): print(line) # Verify signature with device's public key try: from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from cryptography.exceptions import InvalidSignature # Get device's public key from self_info self_info = meshcore.self_info if not self_info or "public_key" not in self_info: print("\n⚠️ Could not get device public key for verification") else: pubkey_hex = self_info["public_key"] pubkey_bytes = bytes.fromhex(pubkey_hex) try: public_key = Ed25519PublicKey.from_public_bytes(pubkey_bytes) public_key.verify(signature, data_bytes) print("\n✅ Signature verification: SUCCESS (signature is valid)") except InvalidSignature: print("\n❌ Signature verification: FAILED (signature is invalid)") if args.debug: print(f" Public key: {pubkey_hex}") print(f" Data length: {len(data_bytes)} bytes") print(f" Signature length: {len(signature)} bytes") print(f" Data (first 50 bytes): {data_bytes[:50].hex()}") except Exception as e: print(f"\n⚠️ Signature verification error: {e}") except ImportError: print("\n⚠️ cryptography library not available - skipping signature verification") print(" Install with: pip install cryptography") print("\nSigning flow completed!") except ConnectionError as e: print(f"❌ Failed to connect: {e}") return 1 except Exception as e: print(f"❌ Error: {e}") return 1 finally: if meshcore: await meshcore.disconnect() print("Disconnected.") if __name__ == "__main__": try: sys.exit(asyncio.run(main())) except KeyboardInterrupt: print("\nInterrupted by user") sys.exit(1) meshcore-2.3.7/examples/chan_recv_with_path.py0000644000175000017500000000114013615410400021051 0ustar winniewinnieimport asyncio from meshcore import MeshCore from meshcore import EventType from meshcore import BLEConnection async def main () : mc = await MeshCore.create_ble("f1x") # this will enable channel log decryption (needed to link path with message) mc.set_decrypt_channel_logs = True # get info for channel 0 (needed so the reader has info needed for decryption) await mc.commands.get_channel(0) await mc.start_auto_message_fetching() while True: result = await mc.wait_for_event(EventType.CHANNEL_MSG_RECV) if result: print(result) asyncio.run(main()) meshcore-2.3.7/examples/serial_msg.py0000755000175000017500000000476113615410400017216 0ustar winniewinnie#!/usr/bin/python """ Example of sending a message and waiting for its specific acknowledgment using event attribute filtering. """ import asyncio import argparse from meshcore import MeshCore, EventType async def main(): # Parse command line arguments parser = argparse.ArgumentParser(description="Send a message and wait for ACK") parser.add_argument("-p", "--port", required=True, help="Serial port") parser.add_argument("-b", "--baudrate", type=int, default=115200, help="Baud rate") parser.add_argument("-d", "--dest", default="🦄", help="Destination contact name") parser.add_argument("-m", "--message", default="hello from serial", help="Message to send") parser.add_argument("--timeout", type=float, default=30.0, help="ACK timeout in seconds") args = parser.parse_args() # Connect to the device mc = await MeshCore.create_serial(args.port, args.baudrate, debug=True) try: # Make sure we have contacts loaded await mc.ensure_contacts() # Find the contact by name contact = mc.get_contact_by_name(args.dest) if not contact: print(f"Contact '{args.dest}' not found. Available contacts:") for name, c in mc.contacts.items(): print(f"- {c.get('adv_name', 'Unknown')}") return print(f"Found contact: {contact.get('adv_name')} ({contact['public_key'][:12]}...)") # Send the message and get the MSG_SENT event print(f"Sending message: '{args.message}'") result = await mc.commands.send_msg( contact, args.message ) if result.type == EventType.ERROR: print(f"⚠️ Failed to send message: {result.payload}") return # Extract the expected ACK code expected_ack = result.payload["expected_ack"].hex() print(f"Message sent, waiting for ACK with code: {expected_ack}") # Wait for the specific ACK that matches our message ack_event = await mc.wait_for_event( EventType.ACK, attribute_filters={"code": expected_ack}, timeout=args.timeout ) if ack_event: print(f"✅ Message confirmed delivered! (ACK received)") else: print(f"⚠️ Timed out waiting for ACK after {args.timeout} seconds") finally: # Always disconnect await mc.disconnect() if __name__ == "__main__": asyncio.run(main()) meshcore-2.3.7/examples/ble_private_key_export.py0000644000175000017500000000725413615410400021633 0ustar winniewinnie#!/usr/bin/env python3 """ Example: BLE Private Key Export with MeshCore This example demonstrates how to export the private key from a MeshCore device using BLE with PIN-based pairing for enhanced security. Note: This feature requires: 1. A MeshCore device running companion radio firmware 2. ENABLE_PRIVATE_KEY_EXPORT=1 compile-time flag enabled 3. Authenticated BLE connection with PIN """ import asyncio import argparse from meshcore import MeshCore from meshcore.events import EventType async def main(): parser = argparse.ArgumentParser(description="Export private key from MeshCore device via BLE") parser.add_argument("-a", "--addr", help="BLE address of the device (optional, will scan if not provided)") parser.add_argument("-p", "--pin", help="PIN for BLE pairing (required for private key export)") parser.add_argument("--debug", action="store_true", help="Enable debug logging") args = parser.parse_args() if not args.pin: print("❌ PIN is required for private key export. Use -p or --pin to specify it.") return 1 try: print("Connecting to MeshCore device...") print(f"Using PIN for pairing: {args.pin}") # Create BLE connection with PIN (required for private key export) meshcore = await MeshCore.create_ble( address=args.addr, pin=args.pin, debug=args.debug ) print("✅ Connected successfully!") # Get device information to verify connection result = await meshcore.commands.send_device_query() if result.payload: print(f"Device model: {result.payload.get('model', 'Unknown')}") print(f"Firmware version: {result.payload.get('fw ver', 'Unknown')}") # Export private key print("\n🔑 Requesting private key export...") result = await meshcore.commands.export_private_key() if result.type == EventType.PRIVATE_KEY: private_key = result.payload["private_key"] print("✅ Private key exported successfully!") print(f"Private key (64 bytes): {private_key.hex()}") print(f"Private key length: {len(private_key)} bytes") # Optionally save to file save_to_file = input("\nSave private key to file? (y/N): ").lower().strip() if save_to_file == 'y': filename = input("Enter filename (default: private_key.bin): ").strip() if not filename: filename = "private_key.bin" with open(filename, 'wb') as f: f.write(private_key) print(f"Private key saved to {filename}") elif result.type == EventType.DISABLED: print("❌ Private key export is disabled on this device") print("This feature requires:") print(" - Companion radio firmware") print(" - ENABLE_PRIVATE_KEY_EXPORT=1 compile-time flag") elif result.type == EventType.ERROR: print(f"❌ Error exporting private key: {result.payload}") else: print(f"❌ Unexpected response: {result.type}") print("\nPrivate key export test completed!") except ConnectionError as e: print(f"❌ Failed to connect: {e}") return 1 except Exception as e: print(f"❌ Error: {e}") return 1 finally: if 'meshcore' in locals(): await meshcore.disconnect() print("Disconnected from device") return 0 if __name__ == "__main__": import sys sys.exit(asyncio.run(main())) meshcore-2.3.7/examples/rf_packet_monitor.py0000644000175000017500000000333413615410400020566 0ustar winniewinnie#!/usr/bin/env python3 import asyncio import argparse from meshcore import MeshCore from meshcore.events import EventType async def main(): parser = argparse.ArgumentParser(description='MeshCore RF Packet Monitor') parser.add_argument('-p', '--port', required=True, help='Serial port path') args = parser.parse_args() try: # Connect to device print(f"Connecting to {args.port}...") mc = await MeshCore.create_serial(args.port, 115200) def handle_rf_packet(event): packet = event.payload if isinstance(packet, dict): print(f"Raw RF packet received:") if 'snr' in packet: print(f" SNR: {packet['snr']:.1f} dB") if 'rssi' in packet: print(f" RSSI: {packet['rssi']} dBm") if 'payload_length' in packet: print(f" Payload length: {packet['payload_length']} bytes") if 'payload' in packet: print(f" Payload (hex): {packet['payload']}") else: print(f"RF packet received: {packet}") # Subscribe to RF log data subscription = mc.subscribe(EventType.RX_LOG_DATA, handle_rf_packet) print("Waiting for log data (press Ctrl+C to exit)...") try: # Keep the script running to receive logs while True: await asyncio.sleep(1) except KeyboardInterrupt: print("\nExiting...") # Clean up mc.unsubscribe(subscription) await mc.disconnect() except Exception as e: print(f"Error: {e}") if __name__ == '__main__': asyncio.run(main())meshcore-2.3.7/examples/serial_pingbot.py0000644000175000017500000001114013615410400020054 0ustar winniewinnieimport asyncio import logging from typing import Any from meshcore import MeshCore, EventType SERIAL_PORT = "COM4" # change this to your serial port CHANNEL_IDX = 1 # change this to the index of your "#ping" channel logging.basicConfig(level=logging.INFO) _LOGGER = logging.getLogger("serial_pingbot") latest_pathinfo_str = "(? hops, ?)" def parse_rx_log_data(payload: Any) -> dict[str, Any]: """Parse RX_LOG event payload to extract LoRa packet details. Expected format (hex): byte0: header byte1: path_len next path_len bytes: path nodes next byte: channel_hash (optional) """ result: dict[str, Any] = {} try: hex_str = None if isinstance(payload, dict): hex_str = payload.get("payload") or payload.get("raw_hex") elif isinstance(payload, (str, bytes)): hex_str = payload if not hex_str: return result if isinstance(hex_str, bytes): hex_str = hex_str.hex() hex_str = str(hex_str).lower().replace(" ", "").replace("\n", "").replace("\r", "") if len(hex_str) < 4: return result result["header"] = hex_str[0:2] try: path_len = int(hex_str[2:4], 16) result["path_len"] = path_len except ValueError: return {} path_start = 4 path_end = path_start + (path_len * 2) if len(hex_str) < path_end: return {} path_hex = hex_str[path_start:path_end] result["path"] = path_hex result["path_nodes"] = [path_hex[i:i + 2] for i in range(0, len(path_hex), 2)] if len(hex_str) >= path_end + 2: result["channel_hash"] = hex_str[path_end:path_end + 2] except Exception as ex: _LOGGER.debug(f"Error parsing RX_LOG data: {ex}") return result def format_pathinfo(parsed: dict[str, Any]) -> str: """Return string in format: '( hops, )'.""" path_len = parsed.get("path_len") nodes = parsed.get("path_nodes") or [] if path_len is None: return "(? hops, ?)" if path_len == 0: return "(0 hops, direct)" path_str = ":".join(nodes) if nodes else "?" return f"({path_len} hops, {path_str})" async def main(): global latest_pathinfo_str meshcore = await MeshCore.create_serial(SERIAL_PORT, debug=True) print(f"Connected on {SERIAL_PORT}") await meshcore.start_auto_message_fetching() async def handle_rx_log_data(event): global latest_pathinfo_str rx = event.payload or {} raw = rx.get("payload") # use 'payload' (not 'raw_hex') for this parser if not raw: return parsed = parse_rx_log_data(raw) if parsed: latest_pathinfo_str = format_pathinfo(parsed) async def handle_channel_message(event): msg = event.payload or {} pathinfo = latest_pathinfo_str chan = msg.get("channel_idx") text = msg.get("text", "") path_len = msg.get("path_len") sender = text.split(":", 1)[0].strip() print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") print(pathinfo) print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") print(f"Received on channel {chan} from {sender}: {text} | path_len={path_len}") if chan == CHANNEL_IDX and "ping" in text.lower(): reply = f"@[{sender}] Pong 🏓{pathinfo}" print(f"Detected Ping. Replying in channel {CHANNEL_IDX} with:\n{reply}") result = await meshcore.commands.send_chan_msg(CHANNEL_IDX, reply) if result.type == EventType.ERROR: print(f"Error sending reply: {result.payload}") else: print("Reply sent") sub_chan = meshcore.subscribe( EventType.CHANNEL_MSG_RECV, handle_channel_message, attribute_filters={"channel_idx": CHANNEL_IDX}, ) sub_rx = meshcore.subscribe( EventType.RX_LOG_DATA, handle_rx_log_data, ) try: print(f"Listening for 'Ping' on channel {CHANNEL_IDX} and RX_LOG_DATA...") while True: await asyncio.sleep(3600) except KeyboardInterrupt: print("Stopping listener...") finally: meshcore.unsubscribe(sub_chan) meshcore.unsubscribe(sub_rx) await meshcore.stop_auto_message_fetching() await meshcore.disconnect() print("Disconnected") if __name__ == "__main__": asyncio.run(main()) meshcore-2.3.7/examples/ble_t1000_msg.py0000755000175000017500000000103613615410400017315 0ustar winniewinnie#!/usr/bin/python import asyncio from meshcore import MeshCore from meshcore import BLEConnection ADDRESS = "t1000" # node ble adress or name DEST = "mchome" MSG = "Hello World" async def main () : con = BLEConnection(ADDRESS) await con.connect() mc = MeshCore(con) await mc.connect() await mc.ensure_contacts() contact = mc.get_contact_by_name(DEST) if contact is None: print(f"Contact '{DEST}' not found in contacts.") return await mc.commands.send_msg(contact,MSG) asyncio.run(main()) meshcore-2.3.7/examples/serial_rss_bot.py0000644000175000017500000002101413615410400020066 0ustar winniewinnieimport asyncio import logging import re import urllib.request import xml.etree.ElementTree as ET from html import unescape from html.parser import HTMLParser import hashlib from meshcore import MeshCore, EventType # ===================== # Config # ===================== SERIAL_PORT = "COM4" # change this to your serial port CHANNEL_IDXS = [6]#[5, 6] # change this to the index of your target channel RSS_URL = "https://data.emergency.vic.gov.au/Show?pageId=getIncidentRSS" POLL_INTERVAL_SEC = 300 # poll interval (seconds) # Only send items whose description (plain text) contains this keyword (case-insensitive) KEYWORDS_REQUIRED = ["BUSHFIRE"] # Only send these fields from the RSS description FIELDS_TO_SEND = [ "Type", "Fire District", "Location", "Latitude", "Longitude", "Status", "Size", ] BLOCKED_STATUSES = [ "Under Control", "Safe", ] logging.basicConfig(level=logging.INFO) _LOGGER = logging.getLogger("serial_rssbot") class _HTMLTextExtractor(HTMLParser): def __init__(self): super().__init__() self._chunks: list[str] = [] def handle_data(self, data: str) -> None: if data: self._chunks.append(data) def get_text(self) -> str: return "".join(self._chunks) def _strip_html(html: str) -> str: """Convert HTML-ish fragments into plain text.""" if not html: return "" # Normalize
into newlines so field parsing is reliable. html = re.sub(r"<\s*br\s*/?\s*>", "\n", html, flags=re.IGNORECASE) parser = _HTMLTextExtractor() try: parser.feed(html) parser.close() text = parser.get_text() except Exception: text = re.sub(r"<[^>]+>", "\n", html) text = unescape(text) # Normalize whitespace but keep newlines as separators. text = text.replace("\r", "\n") text = re.sub(r"[ \t\f\v]+", " ", text) text = re.sub(r"\n+", "\n", text) return text.strip() def _fetch_rss_bytes(url: str) -> bytes: req = urllib.request.Request( url, headers={ "User-Agent": "meshcore-rss-bot/1.0", "Accept": "application/rss+xml, application/xml;q=0.9, */*;q=0.8", }, method="GET", ) with urllib.request.urlopen(req, timeout=30) as resp: return resp.read() def extract_fields(raw_html: str) -> dict[str, str]: """ Extract desired fields from the HTML description. We strip HTML to text with newlines, then parse lines like "Field: value". """ text = _strip_html(raw_html) lines = [ln.strip() for ln in text.split("\n") if ln.strip()] result: dict[str, str] = {} for ln in lines: m = re.match(r"^([^:]+):\s*(.*)$", ln) if not m: continue key = m.group(1).strip() val = m.group(2).strip() # Match desired fields case-insensitively for wanted in FIELDS_TO_SEND: if key.lower() == wanted.lower(): result[wanted] = val break return result def _parse_rss_items(xml_bytes: bytes) -> list[dict[str, str]]: """Return list of items with keys: id, description, raw_description.""" items: list[dict[str, str]] = [] root = ET.fromstring(xml_bytes) # RSS 2.0: ... channel = root.find("channel") if root.tag.lower().endswith("rss") else root if channel is None: return items for it in channel.findall("item"): title = (it.findtext("title") or "").strip() link = (it.findtext("link") or "").strip() guid = (it.findtext("guid") or "").strip() pub_date = (it.findtext("pubDate") or "").strip() desc_html = it.findtext("description") or "" desc_text = _strip_html(desc_html) # stable_id = (guid or link or f"{title}|{pub_date}" or desc_text).strip() base_id = (guid or link or f"{title}|{pub_date}").strip() # fingerprint only the fields you output fields = extract_fields(desc_html) parts = [] for k in FIELDS_TO_SEND: v = fields.get(k, "") parts.append(f"{k}={v}") fingerprint = hashlib.sha1("|".join(parts).encode("utf-8")).hexdigest()[:12] stable_id = f"{base_id}|{fingerprint}" items.append( { "id": stable_id, "description": desc_text, # plain text for keyword filtering "raw_description": desc_html, # original HTML-ish description } ) return items def build_filtered_message(item: dict[str, str]) -> str | None: # Keyword filter (case-insensitive) on plain text description desc_text_lower = (item.get("description") or "").lower() if not any(k.lower() in desc_text_lower for k in KEYWORDS_REQUIRED): return None fields = extract_fields(item.get("raw_description") or "") if not fields: return None status_norm = fields.get("Status", "").strip().lower() blocked_norm = [b.lower() for b in BLOCKED_STATUSES] if status_norm in blocked_norm: return None # if any(b.lower() in status for b in BLOCKED_STATUSES): # return None # Keep order exactly as FIELDS_TO_SEND, skip missing # parts = [] # for k in FIELDS_TO_SEND: # v = fields.get(k) # if v: # parts.append(f"{v}") parts = [] lat = fields.get("Latitude") lon = fields.get("Longitude") for k in FIELDS_TO_SEND: if k in ("Latitude", "Longitude"): continue v = fields.get(k) if v: parts.append(v) # append formatted lat,lon at the end if lat and lon: parts.append(f"{lat},{lon}") if not parts: return None return " | ".join(parts) async def _fetch_items_async() -> list[dict[str, str]]: xml_bytes = await asyncio.to_thread(_fetch_rss_bytes, RSS_URL) return _parse_rss_items(xml_bytes) async def main() -> None: meshcore = await MeshCore.create_serial(SERIAL_PORT, debug=True) print(f"Connected on {SERIAL_PORT}") # await meshcore.start_auto_message_fetching() seen_ids: set[str] = set() # Prime seen set so we do not spam old items on startup try: initial_items = await _fetch_items_async() for it in initial_items: if it.get("id"): seen_ids.add(it["id"]) _LOGGER.info("Primed %d existing RSS items as seen.", len(seen_ids)) except Exception as ex: _LOGGER.warning("Failed to prime RSS items: %s", ex) try: while True: try: items = await _fetch_items_async() # Feeds are usually newest-first. Send unseen in chronological order. new_items = [it for it in items if it.get("id") and it["id"] not in seen_ids] for it in reversed(new_items): msg_text = build_filtered_message(it) # Mark as seen even if it does not match filter, so we do not re-check forever seen_ids.add(it["id"]) if not msg_text: continue # _LOGGER.info("Sending filtered RSS item to channel %s", CHANNEL_IDX) # result = await meshcore.commands.send_chan_msg(CHANNEL_IDX, msg_text) # if result.type == EventType.ERROR: # _LOGGER.error("Error sending RSS message: %s", result.payload) for ch in CHANNEL_IDXS: _LOGGER.info("Sending filtered RSS item to channel %s", ch) result = await meshcore.commands.send_chan_msg(ch, msg_text) if result.type == EventType.ERROR: _LOGGER.error("Error sending RSS message to channel %s: %s", ch, result.payload) await asyncio.sleep(5) # seconds await asyncio.sleep(5) except Exception as ex: _LOGGER.warning("RSS poll failed: %s", ex) await asyncio.sleep(POLL_INTERVAL_SEC) except KeyboardInterrupt: print("Stopping...") finally: await meshcore.stop_auto_message_fetching() await meshcore.disconnect() print("Disconnected") if __name__ == "__main__": asyncio.run(main()) meshcore-2.3.7/examples/serial_trace.py0000644000175000017500000000612313615410400017515 0ustar winniewinnie#!/usr/bin/env python3 import asyncio import argparse import random from meshcore import MeshCore from meshcore.events import EventType async def get_repeater_name(mc, hash_prefix): """Find a contact by its 2-character hash prefix and return its name""" # Ensure contacts are available await mc.ensure_contacts() # Find contact with matching hash prefix contact = mc.get_contact_by_key_prefix(hash_prefix) if contact: return contact.get("adv_name", f"Unknown ({hash_prefix})") else: return f"Unknown ({hash_prefix})" async def main(): parser = argparse.ArgumentParser(description='MeshCore Serial Trace Example') parser.add_argument('-p', '--port', required=True, help='Serial port path') parser.add_argument('--path', type=str, help='Trace path (comma-separated hex values)') args = parser.parse_args() try: # Connect to device print(f"Connecting to {args.port}...") mc = await MeshCore.create_serial(args.port, 115200, debug=True) # Send trace packet print(f"Sending trace packet...") # Send trace with a path if provided tag = random.randint(1, 0xFFFFFFFF) result = await mc.commands.send_trace(path=args.path, tag=tag) # Check if the result is an error if result.type == EventType.ERROR: print(f"Failed to send trace packet: {result.payload.get('reason', 'unknown error')}") elif result.type == EventType.MSG_SENT: print(f"Trace packet sent successfully with tag={tag}") print("Waiting for trace response matching our tag...") # Wait for a trace response with our specific tag event = await mc.wait_for_event( EventType.TRACE_DATA, attribute_filters={"tag": tag}, timeout=15 ) if event: trace = event.payload print(f"Trace data received:") print(f" Tag: {trace['tag']}") print(f" Flags: {trace.get('flags', 0)}") print(f" Path Length: {trace.get('path_len', 0)}") if trace.get('path'): print(f" Path ({len(trace['path'])} nodes):") # Process nodes with hash (repeaters) for i, node in enumerate(trace['path']): if 'hash' in node: # Look up repeater name repeater_name = await get_repeater_name(mc, node['hash']) print(f" Node {i+1}: {repeater_name}, SNR={node['snr']:.1f} dB") else: print(f" Node {i+1}: SNR={node['snr']:.1f} dB (final node)") else: print("No trace response received within timeout") else: print("Failed to send trace packet") await mc.disconnect() except Exception as e: print(f"Error: {e}") if __name__ == '__main__': asyncio.run(main())meshcore-2.3.7/examples/connection_events_example.py0000644000175000017500000000570013615410400022316 0ustar winniewinnie""" Example demonstrating connection events and auto-reconnect functionality. """ import asyncio import logging import sys from meshcore import MeshCore from meshcore.events import EventType logging.basicConfig(level=logging.DEBUG) async def main(): mc = None # Example with auto-reconnect enabled try: # mc = await MeshCore.create_serial( # port="/dev/cu.usbmodem1101", # auto_reconnect=True, # max_reconnect_attempts=3, # debug=True # ) # mc = await MeshCore.create_tcp( # host="192.168.1.22", # port=5000, # auto_reconnect=True, # max_reconnect_attempts=sys.maxsize, # debug=True # ) mc = await MeshCore.create_ble( address="92849669", auto_reconnect=True, max_reconnect_attempts=3, debug=True ) # Subscribe to connection events async def on_connected(event): print(f"✅ Connected! Info: {event.payload}") if event.payload.get('reconnected'): print("🔄 This was a reconnection!") async def on_disconnected(event): print(f"❌ Disconnected! Reason: {event.payload.get('reason')}") if event.payload.get('max_attempts_exceeded'): print("⚠️ Max reconnection attempts exceeded") mc.subscribe(EventType.CONNECTED, on_connected) mc.subscribe(EventType.DISCONNECTED, on_disconnected) # Check connection status print("\n📱 Disconnect your device now to test auto-reconnect...") print("Press Ctrl+C to exit") # Keep running and periodically test the connection while True: await asyncio.sleep(2) print(f"Connected: {mc.is_connected}") if mc.is_connected: try: print("🔄 Testing connection by getting battery...") result = await mc.commands.get_bat() if result.type == EventType.ERROR: print(f"❌ Error getting battery: {result.payload}") else: print("✅ Connection test successfeul") except Exception as e: print(f"❌ Connection test failed: {e}") # This should trigger the disconnect detection else: print("⏳ Waiting for reconnection...") except KeyboardInterrupt: print("\n🛑 Exiting...") except ConnectionError as e: print(f"❌ Failed to connect: {e}") finally: if mc is not None: await mc.disconnect() print(f"Connected after disconnect: {mc.is_connected}") print(f"Connected after disconnect: {mc.is_connected}") if __name__ == "__main__": asyncio.run(main())meshcore-2.3.7/examples/serial_infos.py0000755000175000017500000000043413615410400017537 0ustar winniewinnie#!/usr/bin/python import asyncio from meshcore import MeshCore, EventType PORT = "/dev/tty.usbserial-583A0069501" BAUDRATE = 115200 async def main(): mc = await MeshCore.create_serial(PORT, BAUDRATE) print(mc.self_info) await mc.disconnect() asyncio.run(main()) meshcore-2.3.7/examples/ble_t1000_msg_retries.py0000755000175000017500000000060413615410400021052 0ustar winniewinnie#!/usr/bin/python import asyncio from meshcore import MeshCore from meshcore import BLEConnection ADDRESS = "T1000" # node ble adress or name DEST = "993acd42fc779962c68c627829b32b111fa27a67d86b75c17460ff48c3102db4" MSG = "Hello World" async def main () : mc = await MeshCore.create_ble(ADDRESS, debug=True) await mc.commands.send_msg_with_retry(DEST,MSG) asyncio.run(main()) meshcore-2.3.7/.github/0000775000175000017500000000000015175447206014251 5ustar winniewinniemeshcore-2.3.7/.github/python-test.yml0000644000175000017500000000247313615410400017255 0ustar winniewinnie# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python name: Python package on: push: branches: [ "main" ] pull_request: branches: [ "main" ] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.10", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | # TODO: Enable this later # stop the build if there are Python syntax errors or undefined names # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | pytest meshcore-2.3.7/.gitignore0000644000175000017500000000002513615410400014653 0ustar winniewinnie__pycache__ venv distmeshcore-2.3.7/README.md0000644000175000017500000006365713615410400014166 0ustar winniewinnie# Python MeshCore Python library for interacting with [MeshCore](https://meshcore.co.uk) companion radio nodes. ## Installation ```bash pip install meshcore ``` ## Quick Start Connect to your device and send a message: ```python import asyncio from meshcore import MeshCore, EventType async def main(): # Connect to your device meshcore = await MeshCore.create_serial("/dev/ttyUSB0") # Get your contacts result = await meshcore.commands.get_contacts() if result.type == EventType.ERROR: print(f"Error getting contacts: {result.payload}") return contacts = result.payload print(f"Found {len(contacts)} contacts") # Send a message to the first contact if contacts: # Get the first contact contact = next(iter(contacts.items()))[1] # Pass the contact object directly to send_msg result = await meshcore.commands.send_msg(contact, "Hello from Python!") if result.type == EventType.ERROR: print(f"Error sending message: {result.payload}") else: print("Message sent successfully!") await meshcore.disconnect() asyncio.run(main()) ``` ## Development Setup To set up for development: ```bash # Create and activate virtual environment python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate # Install in development mode pip install -e . # Run examples python examples/pubsub_example.py -p /dev/ttyUSB0 ``` ## Usage Guide ### Command Return Values All command methods in MeshCore return an `Event` object that contains both the event type and its payload. This allows for consistent error handling and type checking: ```python # Command result structure result = await meshcore.commands.some_command() # Check if the command was successful or resulted in an error if result.type == EventType.ERROR: # Handle error case print(f"Command failed: {result.payload}") else: # Handle success case - the event type will be specific to the command # (e.g., EventType.DEVICE_INFO, EventType.CONTACTS, EventType.MSG_SENT) print(f"Command succeeded with event type: {result.type}") # Access the payload data data = result.payload ``` Common error handling pattern: ```python result = await meshcore.commands.send_msg(contact, "Hello!") if result.type == EventType.ERROR: print(f"Error sending message: {result.payload}") else: # For send_msg, a successful result will have type EventType.MSG_SENT print(f"Message sent with expected ack: {result.payload['expected_ack'].hex()}") ``` ### Connecting to Your Device Connect via Serial, BLE, or TCP: ```python # Serial connection meshcore = await MeshCore.create_serial("/dev/ttyUSB0", 115200, debug=True) # BLE connection (scans for devices if address not provided) meshcore = await MeshCore.create_ble("12:34:56:78:90:AB") # BLE connection with PIN pairing for enhanced security meshcore = await MeshCore.create_ble("12:34:56:78:90:AB", pin="123456") # TCP connection meshcore = await MeshCore.create_tcp("192.168.1.100", 4000) ``` #### BLE PIN Pairing For enhanced security, MeshCore supports BLE PIN pairing. This requires the device to be configured with a PIN and the client to provide the matching PIN during connection: ```python # First configure the device PIN (if not already set) meshcore = await MeshCore.create_ble("12:34:56:78:90:AB") await meshcore.commands.set_devicepin(123456) # Then connect with PIN pairing meshcore = await MeshCore.create_ble("12:34:56:78:90:AB", pin="123456") ``` **PIN Pairing Features:** - Automatic pairing initiation when PIN is provided - Graceful fallback if pairing fails (connection continues if device is already paired) - Compatible with all BLE connection methods (address, scanning, pre-configured client) - Logging of pairing success/failure for debugging **Note:** BLE pairing behavior may vary by platform: - **Linux/Windows**: PIN pairing is fully supported - **macOS**: Pairing may be handled automatically by the system UI #### Auto-Reconnect and Connection Events Enable automatic reconnection when connections are lost: ```python # Enable auto-reconnect with custom retry limits meshcore = await MeshCore.create_tcp( "192.168.1.100", 4000, auto_reconnect=True, max_reconnect_attempts=5 ) # Subscribe to connection events async def on_connected(event): print(f"Connected: {event.payload}") if event.payload.get('reconnected'): print("Successfully reconnected!") async def on_disconnected(event): print(f"Disconnected: {event.payload['reason']}") if event.payload.get('max_attempts_exceeded'): print("Max reconnection attempts exceeded") meshcore.subscribe(EventType.CONNECTED, on_connected) meshcore.subscribe(EventType.DISCONNECTED, on_disconnected) # Check connection status if meshcore.is_connected: print("Device is currently connected") ``` **Auto-reconnect features:** - Exponential backoff (1s, 2s, 4s, 8s max delay) - Configurable retry limits (default: 3 attempts) - Automatic disconnect detection (especially useful for TCP connections) - Connection events with detailed information ### Using Commands (Synchronous Style) Send commands and wait for responses: ```python # Get device information result = await meshcore.commands.send_device_query() if result.type == EventType.ERROR: print(f"Error getting device info: {result.payload}") else: print(f"Device model: {result.payload['model']}") # Get list of contacts result = await meshcore.commands.get_contacts() if result.type == EventType.ERROR: print(f"Error getting contacts: {result.payload}") else: contacts = result.payload for contact_id, contact in contacts.items(): print(f"Contact: {contact['adv_name']} ({contact_id})") # Send a message (destination key in bytes) result = await meshcore.commands.send_msg(dst_key, "Hello!") if result.type == EventType.ERROR: print(f"Error sending message: {result.payload}") # Setting device parameters result = await meshcore.commands.set_name("My Device") if result.type == EventType.ERROR: print(f"Error setting name: {result.payload}") result = await meshcore.commands.set_tx_power(20) # Set transmit power if result.type == EventType.ERROR: print(f"Error setting TX power: {result.payload}") ``` ### Finding Contacts Easily find contacts by name or key: ```python # Find a contact by name contact = meshcore.get_contact_by_name("Bob's Radio") if contact: print(f"Found Bob at: {contact['adv_lat']}, {contact['adv_lon']}") # Find by partial key prefix contact = meshcore.get_contact_by_key_prefix("a1b2c3") ``` ### Event-Based Programming (Asynchronous Style) Subscribe to events to handle them asynchronously: ```python # Subscribe to incoming messages async def handle_message(event): data = event.payload print(f"Message from {data['pubkey_prefix']}: {data['text']}") subscription = meshcore.subscribe(EventType.CONTACT_MSG_RECV, handle_message) # Subscribe to advertisements async def handle_advert(event): print("Advertisement detected!") meshcore.subscribe(EventType.ADVERTISEMENT, handle_advert) # When done, unsubscribe meshcore.unsubscribe(subscription) ``` #### Filtering Events by Attributes Filter events based on their attributes to handle only specific ones: ```python # Subscribe only to messages from a specific contact async def handle_specific_contact_messages(event): print(f"Message from Alice: {event.payload['text']}") contact = meshcore.get_contact_by_name("Alice") if contact: alice_subscription = meshcore.subscribe( EventType.CONTACT_MSG_RECV, handle_specific_contact_messages, attribute_filters={"pubkey_prefix": contact["public_key"][:12]} ) # Send a message and wait for its specific acknowledgment async def send_and_confirm_message(meshcore, dst_key, message): # Send the message and get information about the sent message sent_result = await meshcore.commands.send_msg(dst_key, message) # Extract the expected acknowledgment code from the message sent event if sent_result.type == EventType.ERROR: print(f"Error sending message: {sent_result.payload}") return False expected_ack = sent_result.payload["expected_ack"].hex() print(f"Message sent, waiting for ack with code: {expected_ack}") # Wait specifically for this acknowledgment result = await meshcore.wait_for_event( EventType.ACK, attribute_filters={"code": expected_ack}, timeout=10.0 ) if result: print("Message confirmed delivered!") return True else: print("Message delivery confirmation timed out") return False ``` ### Hybrid Approach (Commands + Events) Combine command-based and event-based styles: ```python import asyncio async def main(): # Connect to device meshcore = await MeshCore.create_serial("/dev/ttyUSB0") # Set up event handlers async def handle_ack(event): print("Message acknowledged!") async def handle_battery(event): print(f"Battery level: {event.payload}%") # Subscribe to events meshcore.subscribe(EventType.ACK, handle_ack) meshcore.subscribe(EventType.BATTERY, handle_battery) # Create background task for battery checking async def check_battery_periodically(): while True: # Send command (returns battery level) result = await meshcore.commands.get_bat() if result.type == EventType.ERROR: print(f"Error checking battery: {result.payload}") else: print(f"Battery level: {result.payload.get('level', 'unknown')}%") await asyncio.sleep(60) # Wait 60 seconds between checks # Start the background task battery_task = asyncio.create_task(check_battery_periodically()) # Send manual command and wait for response await meshcore.commands.send_advert(flood=True) try: # Keep the main program running await asyncio.sleep(float('inf')) except asyncio.CancelledError: # Clean up when program ends battery_task.cancel() await meshcore.disconnect() # Run the program asyncio.run(main()) ``` ### Auto-Fetching Messages Let the library automatically fetch incoming messages: ```python # Start auto-fetching messages await meshcore.start_auto_message_fetching() # Just subscribe to message events - the library handles fetching async def on_message(event): print(f"New message: {event.payload['text']}") meshcore.subscribe(EventType.CONTACT_MSG_RECV, on_message) # When done await meshcore.stop_auto_message_fetching() ``` ### Debug Mode Enable debug logging for troubleshooting: ```python # Enable debug mode when creating the connection meshcore = await MeshCore.create_serial("/dev/ttyUSB0", debug=True) ``` This logs detailed information about commands sent and events received. ## Common Examples ### Sending Messages to Contacts Commands that require a destination (`send_msg`, `send_login`, `send_statusreq`, etc.) now accept either: - A string with the hex representation of a public key - A contact object with a "public_key" field - Bytes object (for backward compatibility) ```python # Get contacts and send to a specific one result = await meshcore.commands.get_contacts() if result.type == EventType.ERROR: print(f"Error getting contacts: {result.payload}") else: contacts = result.payload for key, contact in contacts.items(): if contact["adv_name"] == "Alice": # Option 1: Pass the contact object directly result = await meshcore.commands.send_msg(contact, "Hello Alice!") if result.type == EventType.ERROR: print(f"Error sending message: {result.payload}") # Option 2: Use the public key string result = await meshcore.commands.send_msg(contact["public_key"], "Hello again Alice!") if result.type == EventType.ERROR: print(f"Error sending message: {result.payload}") # Option 3 (backward compatible): Convert the hex key to bytes dst_key = bytes.fromhex(contact["public_key"]) result = await meshcore.commands.send_msg(dst_key, "Hello once more Alice!") if result.type == EventType.ERROR: print(f"Error sending message: {result.payload}") break # You can also directly use a contact found by name contact = meshcore.get_contact_by_name("Bob") if contact: result = await meshcore.commands.send_msg(contact, "Hello Bob!") if result.type == EventType.ERROR: print(f"Error sending message: {result.payload}") ``` ### Monitoring Channel Messages ```python # Subscribe to channel messages async def channel_handler(event): msg = event.payload print(f"Channel {msg['channel_idx']}: {msg['text']}") meshcore.subscribe(EventType.CHANNEL_MSG_RECV, channel_handler) ``` ## API Reference ### Event Types All events in MeshCore are represented by the `EventType` enum. These events are dispatched by the library and can be subscribed to: | Event Type | String Value | Description | Typical Payload | |------------|-------------|-------------|-----------------| | **Device & Status Events** ||| | `SELF_INFO` | `"self_info"` | Device's own information after appstart | Device configuration, public key, coordinates | | `DEVICE_INFO` | `"device_info"` | Device capabilities and firmware info | Firmware version, model, max contacts/channels | | `BATTERY` | `"battery_info"` | Battery level and storage info | Battery level, used/total storage | | `CURRENT_TIME` | `"time_update"` | Device time response | Current timestamp | | `STATUS_RESPONSE` | `"status_response"` | Device status statistics | Battery, TX queue, noise floor, packet counts | | `CUSTOM_VARS` | `"custom_vars"` | Custom variable responses | Key-value pairs of custom variables | | **Contact Events** ||| | `CONTACTS` | `"contacts"` | Contact list response | Dictionary of contacts by public key | | `NEW_CONTACT` | `"new_contact"` | New contact discovered | Contact information | | `CONTACT_URI` | `"contact_uri"` | Contact export URI | Shareable contact URI | | **Messaging Events** ||| | `CONTACT_MSG_RECV` | `"contact_message"` | Direct message received | Message text, sender prefix, timestamp | | `CHANNEL_MSG_RECV` | `"channel_message"` | Channel message received | Message text, channel index, timestamp | | `MSG_SENT` | `"message_sent"` | Message send confirmation | Expected ACK code, suggested timeout | | `NO_MORE_MSGS` | `"no_more_messages"` | No pending messages | Empty payload | | `MESSAGES_WAITING` | `"messages_waiting"` | Messages available notification | Empty payload | | **Network Events** ||| | `ADVERTISEMENT` | `"advertisement"` | Node advertisement detected | Public key of advertising node | | `PATH_UPDATE` | `"path_update"` | Routing path update | Public key and path information | | `ACK` | `"acknowledgement"` | Message acknowledgment | ACK code | | `PATH_RESPONSE` | `"path_response"` | Path discovery response | Inbound/outbound path data | | `TRACE_DATA` | `"trace_data"` | Route trace information | Path with SNR data for each hop | | **Telemetry Events** ||| | `TELEMETRY_RESPONSE` | `"telemetry_response"` | Telemetry data response | LPP-formatted sensor data | | `MMA_RESPONSE` | `"mma_response"` | Memory Management Area data | Min/max/avg telemetry over time range | | `ACL_RESPONSE` | `"acl_response"` | Access Control List data | List of keys and permissions | | **Channel Events** ||| | `CHANNEL_INFO` | `"channel_info"` | Channel configuration | Channel name, secret, index | | **Raw Data Events** ||| | `RAW_DATA` | `"raw_data"` | Raw radio data | SNR, RSSI, payload hex | | `RX_LOG_DATA` | `"rx_log_data"` | RF log data | SNR, RSSI, raw payload | | `LOG_DATA` | `"log_data"` | Generic log data | Various log information | | **Binary Protocol Events** ||| | `BINARY_RESPONSE` | `"binary_response"` | Generic binary response | Tag and hex data | | `SIGN_START` | `"sign_start"` | Start of an on-device signing session | Maximum buffer size (bytes) for data to sign | | `SIGNATURE` | `"signature"` | Resulting on-device signature | Raw signature bytes | | **Authentication Events** ||| | `LOGIN_SUCCESS` | `"login_success"` | Successful login | Permissions, admin status, pubkey prefix | | `LOGIN_FAILED` | `"login_failed"` | Failed login attempt | Pubkey prefix | | **Command Response Events** ||| | `OK` | `"command_ok"` | Command successful | Success confirmation, optional value | | `ERROR` | `"command_error"` | Command failed | Error reason or code | | **Connection Events** ||| | `CONNECTED` | `"connected"` | Connection established | Connection details, reconnection status | | `DISCONNECTED` | `"disconnected"` | Connection lost | Disconnection reason | ### Available Commands All commands are async methods that return `Event` objects. Commands are organized into functional groups: #### Device Commands (`meshcore.commands.*`) | Command | Parameters | Returns | Description | |---------|------------|---------|-------------| | **Device Information** |||| | `send_appstart()` | None | `SELF_INFO` | Get device self-information and configuration | | `send_device_query()` | None | `DEVICE_INFO` | Query device capabilities and firmware info | | `get_bat()` | None | `BATTERY` | Get battery level and storage information | | `get_time()` | None | `CURRENT_TIME` | Get current device time | | `get_self_telemetry()` | None | `TELEMETRY_RESPONSE` | Get device's own telemetry data | | `get_custom_vars()` | None | `CUSTOM_VARS` | Retrieve all custom variables | | `get_allowed_repeat_freq()` | None | `ALLOWED_FREQ` | Retreive allowed repeat freqs from device | | **Device Configuration** |||| | `set_name(name)` | `name: str` | `OK` | Set device name/identifier | | `set_coords(lat, lon)` | `lat: float, lon: float` | `OK` | Set device GPS coordinates | | `set_time(val)` | `val: int` | `OK` | Set device time (Unix timestamp) | | `set_tx_power(val)` | `val: int` | `OK` | Set radio transmission power level | | `set_devicepin(pin)` | `pin: int` | `OK` | Set device PIN for security | | `set_custom_var(key, value)` | `key: str, value: str` | `OK` | Set custom variable | | **Radio Configuration** |||| | `set_radio(freq, bw, sf, cr)` | `freq: float, bw: float, sf: int, cr: int` | `OK` | Configure radio (freq MHz, bandwidth kHz, spreading factor, coding rate 5-8) | | `set_tuning(rx_dly, af)` | `rx_dly: int, af: int` | `OK` | Set radio tuning parameters | | **Telemetry Configuration** |||| | `set_telemetry_mode_base(mode)` | `mode: int` | `OK` | Set base telemetry mode | | `set_telemetry_mode_loc(mode)` | `mode: int` | `OK` | Set location telemetry mode | | `set_telemetry_mode_env(mode)` | `mode: int` | `OK` | Set environmental telemetry mode | | `set_manual_add_contacts(enabled)` | `enabled: bool` | `OK` | Enable/disable manual contact addition | | `set_advert_loc_policy(policy)` | `policy: int` | `OK` | Set location advertisement policy | | **Channel Management** |||| | `get_channel(channel_idx)` | `channel_idx: int` | `CHANNEL_INFO` | Get channel configuration | | `set_channel(channel_idx, name, secret)` | `channel_idx: int, name: str, secret: bytes` | `OK` | Configure channel (secret must be 16 bytes) | | **Device Actions** |||| | `send_advert(flood=False)` | `flood: bool` | `OK` | Send advertisement (optionally flood network) | | `reboot()` | None | None | Reboot device (no response expected) | | **Security** |||| | `export_private_key()` | None | `PRIVATE_KEY/DISABLED` | Export device private key (requires PIN auth & enabled firmware) | | `import_private_key(key)` | `key: bytes` | `OK` | Import private key to device | | **Statistics** |||| | `get_stats_core()` | None | `STATS_CORE` | Get core statistics (voltage, uptime, errors, queue length) | | `get_stats_radio()` | None | `STATS_RADIO` | Get radio statistics (noise floor, last RSSI/SNR, tx/rx time stats) | | `get_stats_packets()` | None | `STATS_PACKETS` | Get packet statistics (rx/tx totals, flood vs. direct, recv_errors when present) | | **Advanced Configuration** |||| | `set_multi_acks(multi_acks)` | `multi_acks: int` | `OK` | Set multi-acks mode (experimental ack repeats) | #### Contact Commands (`meshcore.commands.*`) | Command | Parameters | Returns | Description | |---------|------------|---------|-------------| | **Contact Management** |||| | `get_contacts(lastmod=0)` | `lastmod: int` | `CONTACTS` | Get contact list (filter by last modification time) | | `add_contact(contact)` | `contact: dict` | `OK` | Add new contact to device | | `update_contact(contact, path, flags)` | `contact: dict, path: bytes, flags: int` | `OK` | Update existing contact | | `remove_contact(key)` | `key: str/bytes` | `OK` | Remove contact by public key | | **Contact Operations** |||| | `reset_path(key)` | `key: str/bytes` | `OK` | Reset routing path for contact | | `share_contact(key)` | `key: str/bytes` | `OK` | Share contact with network | | `export_contact(key=None)` | `key: str/bytes/None` | `CONTACT_URI` | Export contact as URI (None exports node) | | `import_contact(card_data)` | `card_data: bytes` | `OK` | Import contact from card data | | **Contact Modification** |||| | `change_contact_path(contact, path)` | `contact: dict, path: bytes` | `OK` | Change routing path for contact | | `change_contact_flags(contact, flags)` | `contact: dict, flags: int` | `OK` | Change contact flags/settings | #### Messaging Commands (`meshcore.commands.*`) | Command | Parameters | Returns | Description | |---------|------------|---------|-------------| | **Message Handling** |||| | `get_msg(timeout=None)` | `timeout: float` | `CONTACT_MSG_RECV/CHANNEL_MSG_RECV/NO_MORE_MSGS` | Get next pending message | | `send_msg(dst, msg, timestamp=None)` | `dst: contact/str/bytes, msg: str, timestamp: int` | `MSG_SENT` | Send direct message | | `send_cmd(dst, cmd, timestamp=None)` | `dst: contact/str/bytes, cmd: str, timestamp: int` | `MSG_SENT` | Send command message | | `send_chan_msg(chan, msg, timestamp=None)` | `chan: int, msg: str, timestamp: int` | `MSG_OK` | Send channel message | | **Authentication** |||| | `send_login(dst, pwd)` | `dst: contact/str/bytes, pwd: str` | `MSG_SENT` | Send login request | | `send_logout(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Send logout request | | **Information Requests** |||| | `send_statusreq(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Request status from contact | | `send_telemetry_req(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Request telemetry from contact | | **Advanced Messaging** |||| | `send_binary_req(dst, bin_data)` | `dst: contact/str/bytes, bin_data: bytes` | `MSG_SENT` | Send binary data request | | `send_path_discovery(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Initiate path discovery | | `send_trace(auth_code, tag, flags, path=None)` | `auth_code: int, tag: int, flags: int, path: list` | `MSG_SENT` | Send route trace packet | | **Message Retry & Scope** |||| | `send_msg_with_retry(dst, msg, ...)` | `dst, msg, timestamp, max_attempts, max_flood_attempts, flood_after, timeout, min_timeout` | `MSG_SENT/None` | Send message with automatic retry and ACK waiting | | `set_flood_scope(scope)` | `scope: str` | `OK` | Set flood scope (hash like "#name", "0"/""/"*" to disable, or raw key) | #### Binary Protocol Commands (`meshcore.commands.*`) | Command | Parameters | Returns | Description | |---------|------------|---------|-------------| | `req_status(contact, timeout=0)` | `contact: dict, timeout: float` | `STATUS_RESPONSE` | Get detailed status via binary protocol | | `req_telemetry(contact, timeout=0)` | `contact: dict, timeout: float` | `TELEMETRY_RESPONSE` | Get telemetry via binary protocol | | `req_mma(contact, start, end, timeout=0)` | `contact: dict, start: int, end: int, timeout: float` | `MMA_RESPONSE` | Get historical telemetry data | | `req_acl(contact, timeout=0)` | `contact: dict, timeout: float` | `ACL_RESPONSE` | Get access control list | | `sign_start()` | None | `SIGN_START` | Begin a signing session; returns maximum buffer size for data to sign | | `sign_data(chunk)` | `chunk: bytes` | `OK` | Append a data chunk to the current signing session (can be called multiple times) | | `sign_finish()` | None | `SIGNATURE` | Finalize signing and return the signature for all accumulated data | ### Helper Methods | Method | Returns | Description | |--------|---------|-------------| | `get_contact_by_name(name)` | `dict/None` | Find contact by advertisement name | | `get_contact_by_key_prefix(prefix)` | `dict/None` | Find contact by partial public key | | `sign(data, chunk_size=512)` | `Event` (`SIGNATURE`/`ERROR`) | High-level helper to sign arbitrary data on-device, handling chunking for you | | `is_connected` | `bool` | Check if device is currently connected | | `subscribe(event_type, callback, filters=None)` | `Subscription` | Subscribe to events with optional filtering | | `unsubscribe(subscription)` | None | Remove event subscription | | `wait_for_event(event_type, filters=None, timeout=None)` | `Event/None` | Wait for specific event | ### Event Filtering Events can be filtered by their attributes when subscribing: ```python # Filter by public key prefix meshcore.subscribe( EventType.CONTACT_MSG_RECV, handler, attribute_filters={"pubkey_prefix": "a1b2c3d4e5f6"} ) # Filter by channel index meshcore.subscribe( EventType.CHANNEL_MSG_RECV, handler, attribute_filters={"channel_idx": 0} ) # Filter acknowledgments by code meshcore.subscribe( EventType.ACK, handler, attribute_filters={"code": "12345678"} ) ``` ## Examples in the Repo Check the `examples/` directory for more: - `pubsub_example.py`: Event subscription system with auto-fetching - `serial_infos.py`: Quick device info retrieval - `serial_msg.py`: Message sending and receiving - `serial_pingbot.py`: Ping bot which can be run on a channel - `serial_rss_bot.py`: A RSS feed to Meshcore channel example, which broadcasts emergency bushfire warnings in VIC, AU - `serial_meshcore_ollama.py`: Simple Ollama to Meshcore gateway, a simple chat box - `ble_pin_pairing_example.py`: BLE connection with PIN pairing - `ble_private_key_export.py`: BLE private key export with PIN authentication - `ble_t1000_infos.py`: BLE connections