From 8e91aba318825202da848ba40d926d61700fe46f Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 3 Feb 2026 09:47:28 -0800 Subject: [PATCH 01/12] Add support for 'kiss-modem' radio type across multiple examples and update related documentation - Updated radio type choices in various example scripts to include 'kiss-modem'. - Enhanced the create_radio function to support 'kiss-modem' with specific configuration and identity handling. - Added conditional imports for KissSerialWrapper and KissModemWrapper in the hardware module. - Updated documentation to reflect changes in radio type options and identity creation logic. --- examples/calibrate_cad.py | 2 +- examples/common.py | 83 +- examples/discover_nodes.py | 2 +- examples/login_server.py | 2 +- examples/ping_repeater_trace.py | 2 +- examples/respond_to_discovery.py | 2 +- examples/send_channel_message.py | 2 +- examples/send_direct_advert.py | 2 +- examples/send_flood_advert.py | 2 +- examples/send_text_message.py | 2 +- examples/send_tracked_advert.py | 2 +- src/pymc_core/hardware/__init__.py | 27 +- src/pymc_core/hardware/kiss_modem_wrapper.py | 916 +++++++++++++++++++ src/pymc_core/protocol/__init__.py | 2 + src/pymc_core/protocol/modem_identity.py | 270 ++++++ tests/test_kiss_modem_wrapper.py | 568 ++++++++++++ tests/test_modem_identity.py | 266 ++++++ 17 files changed, 2128 insertions(+), 24 deletions(-) create mode 100644 src/pymc_core/hardware/kiss_modem_wrapper.py create mode 100644 src/pymc_core/protocol/modem_identity.py create mode 100644 tests/test_kiss_modem_wrapper.py create mode 100644 tests/test_modem_identity.py diff --git a/examples/calibrate_cad.py b/examples/calibrate_cad.py index 96799a5..bcb09bc 100644 --- a/examples/calibrate_cad.py +++ b/examples/calibrate_cad.py @@ -304,7 +304,7 @@ def main(): parser = argparse.ArgumentParser(description="CAD Calibration Tool with Staged Workflow") parser.add_argument( "--radio", - choices=["waveshare", "uconsole", "meshadv-mini"], + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc", "kiss-modem"], default="waveshare", help="Radio type", ) diff --git a/examples/common.py b/examples/common.py index 826b542..85a03fc 100644 --- a/examples/common.py +++ b/examples/common.py @@ -29,8 +29,9 @@ def create_radio(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0 """Create a radio instance with configuration for specified hardware. Args: - radio_type: Type of radio hardware ("waveshare", "uconsole", "meshadv-mini", or "kiss-tnc") - serial_port: Serial port for KISS TNC (only used with "kiss-tnc" type) + radio_type: Type of radio hardware ("waveshare", "uconsole", "meshadv-mini", + "kiss-tnc", or "kiss-modem") + serial_port: Serial port for KISS devices (only used with "kiss-tnc" or "kiss-modem") Returns: Radio instance configured for the specified hardware @@ -65,6 +66,33 @@ def create_radio(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0 ) return kiss_wrapper + # Check if this is a MeshCore KISS Modem configuration + if radio_type == "kiss-modem": + from pymc_core.hardware.kiss_modem_wrapper import KissModemWrapper + + logger.debug("Using MeshCore KISS Modem Wrapper") + + # MeshCore KISS Modem configuration + # Note: Sync word is configured at firmware build time + modem_config = { + "frequency": int(869.618 * 1000000), # EU: 869.618 MHz + "bandwidth": int(62.5 * 1000), # 62.5 kHz + "spreading_factor": 8, # LoRa SF8 + "coding_rate": 8, # LoRa CR 4/8 + "power": 22, # TX power + } + + # Create KISS modem wrapper with specified port + modem_wrapper = KissModemWrapper( + port=serial_port, baudrate=115200, radio_config=modem_config, auto_configure=True + ) + + logger.info("Created MeshCore KISS Modem Wrapper") + logger.info( + f"Frequency: {modem_config['frequency']/1000000:.3f}MHz, TX Power: {modem_config['power']}dBm" + ) + return modem_wrapper + # Direct SX1262 radio for other types from pymc_core.hardware.sx1262_wrapper import SX1262Radio @@ -125,7 +153,8 @@ def create_radio(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0 if radio_type not in configs: raise ValueError( - f"Unknown radio type: {radio_type}. Use 'waveshare', 'meshadv-mini', 'uconsole', or 'kiss-tnc'" + f"Unknown radio type: {radio_type}. " + "Use 'waveshare', 'meshadv-mini', 'uconsole', 'kiss-tnc', or 'kiss-modem'" ) radio_kwargs = configs[radio_type] @@ -147,27 +176,29 @@ def create_radio(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0 def create_mesh_node( - node_name: str = "ExampleNode", radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0" + node_name: str = "ExampleNode", + radio_type: str = "waveshare", + serial_port: str = "/dev/ttyUSB0", + use_modem_identity: bool = False, ) -> tuple[MeshNode, LocalIdentity]: """Create a mesh node with radio. Args: node_name: Name for the mesh node - radio_type: Type of radio hardware ("waveshare", "uconsole", "meshadv-mini", or "kiss-tnc") - serial_port: Serial port for KISS TNC (only used with "kiss-tnc" type) + radio_type: Type of radio hardware ("waveshare", "uconsole", "meshadv-mini", + "kiss-tnc", or "kiss-modem") + serial_port: Serial port for KISS devices (only used with "kiss-tnc" or "kiss-modem") + use_modem_identity: If True and radio_type is "kiss-modem", use the modem's + cryptographic identity instead of generating a local one. + This keeps the private key secure on the modem hardware. Returns: - Tuple of (MeshNode, LocalIdentity) + Tuple of (MeshNode, Identity) - Identity may be LocalIdentity or ModemIdentity """ logger.info(f"Creating mesh node with name: {node_name} using {radio_type} radio") try: - # Create a local identity (this generates a new keypair) - logger.debug("Creating LocalIdentity...") - identity = LocalIdentity() - logger.info(f"Created identity with public key: {identity.get_public_key().hex()[:16]}...") - - # Create the radio + # Create the radio first (needed for modem identity) logger.debug("Creating radio...") radio = create_radio(radio_type, serial_port) @@ -185,11 +216,37 @@ def create_mesh_node( logger.error("Failed to connect KISS radio") print(f"Failed to connect to KISS radio on {serial_port}") raise Exception(f"KISS radio connection failed on {serial_port}") + elif radio_type == "kiss-modem": + logger.debug("Connecting MeshCore KISS modem...") + if radio.connect(): + logger.info("KISS modem connected successfully") + print(f"KISS modem connected to {serial_port}") + if hasattr(radio, "modem_version") and radio.modem_version: + print(f"Modem version: {radio.modem_version}") + if hasattr(radio, "modem_identity") and radio.modem_identity: + print(f"Modem identity: {radio.modem_identity.hex()[:16]}...") + else: + logger.error("Failed to connect KISS modem") + print(f"Failed to connect to KISS modem on {serial_port}") + raise Exception(f"KISS modem connection failed on {serial_port}") else: logger.debug("Calling radio.begin()...") radio.begin() logger.info("Radio initialized successfully") + # Create identity - use modem identity if requested and available + if use_modem_identity and radio_type == "kiss-modem": + from pymc_core.protocol.modem_identity import ModemIdentity + + logger.debug("Creating ModemIdentity from KISS modem...") + identity = ModemIdentity(radio) + logger.info(f"Using modem identity: {identity.get_public_key().hex()[:16]}...") + print(f"Using modem identity (private key secured on modem)") + else: + logger.debug("Creating LocalIdentity...") + identity = LocalIdentity() + logger.info(f"Created local identity: {identity.get_public_key().hex()[:16]}...") + # Create a mesh node with the radio and identity config = {"node": {"name": node_name}} logger.debug(f"Creating MeshNode with config: {config}") diff --git a/examples/discover_nodes.py b/examples/discover_nodes.py index 097be79..5e69845 100644 --- a/examples/discover_nodes.py +++ b/examples/discover_nodes.py @@ -149,7 +149,7 @@ def main(): parser = argparse.ArgumentParser(description="Discover nearby mesh nodes") parser.add_argument( "--radio-type", - choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc", "kiss-modem"], default="waveshare", help="Radio hardware type (default: waveshare)", ) diff --git a/examples/login_server.py b/examples/login_server.py index f3dbb61..368f400 100644 --- a/examples/login_server.py +++ b/examples/login_server.py @@ -381,7 +381,7 @@ def main(): ) parser.add_argument( "--radio-type", - choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc", "kiss-modem"], default="waveshare", help="Radio hardware type (default: waveshare)", ) diff --git a/examples/ping_repeater_trace.py b/examples/ping_repeater_trace.py index 340864f..5451bad 100644 --- a/examples/ping_repeater_trace.py +++ b/examples/ping_repeater_trace.py @@ -86,7 +86,7 @@ def main(): parser = argparse.ArgumentParser(description="Ping a repeater using trace packets") parser.add_argument( "--radio-type", - choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc", "kiss-modem"], default="waveshare", help="Radio hardware type (default: waveshare)", ) diff --git a/examples/respond_to_discovery.py b/examples/respond_to_discovery.py index bf8fb93..f0fad58 100644 --- a/examples/respond_to_discovery.py +++ b/examples/respond_to_discovery.py @@ -121,7 +121,7 @@ def main(): parser = argparse.ArgumentParser(description="Respond to mesh node discovery requests") parser.add_argument( "--radio-type", - choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc", "kiss-modem"], default="waveshare", help="Radio hardware type (default: waveshare)", ) diff --git a/examples/send_channel_message.py b/examples/send_channel_message.py index 0280506..8d48e2c 100644 --- a/examples/send_channel_message.py +++ b/examples/send_channel_message.py @@ -71,7 +71,7 @@ def main(): parser = argparse.ArgumentParser(description="Send a channel message to the Public channel") parser.add_argument( "--radio-type", - choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc", "kiss-modem"], default="waveshare", help="Radio hardware type (default: waveshare)", ) diff --git a/examples/send_direct_advert.py b/examples/send_direct_advert.py index 2953fc0..9d9046e 100644 --- a/examples/send_direct_advert.py +++ b/examples/send_direct_advert.py @@ -51,7 +51,7 @@ def main(): parser = argparse.ArgumentParser(description="Send a direct advertisement packet") parser.add_argument( "--radio-type", - choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc", "kiss-modem"], default="waveshare", help="Radio hardware type (default: waveshare)", ) diff --git a/examples/send_flood_advert.py b/examples/send_flood_advert.py index d6288ef..ecf51e2 100644 --- a/examples/send_flood_advert.py +++ b/examples/send_flood_advert.py @@ -58,7 +58,7 @@ def main(): parser = argparse.ArgumentParser(description="Send a flood advertisement packet") parser.add_argument( "--radio-type", - choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc", "kiss-modem"], default="waveshare", help="Radio hardware type (default: waveshare)", ) diff --git a/examples/send_text_message.py b/examples/send_text_message.py index 01bfcf1..fa77f2a 100644 --- a/examples/send_text_message.py +++ b/examples/send_text_message.py @@ -78,7 +78,7 @@ def main(): parser = argparse.ArgumentParser(description="Send a text message to the mesh network") parser.add_argument( "--radio-type", - choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc", "kiss-modem"], default="waveshare", help="Radio hardware type (default: waveshare)", ) diff --git a/examples/send_tracked_advert.py b/examples/send_tracked_advert.py index d7c6591..f6c0397 100644 --- a/examples/send_tracked_advert.py +++ b/examples/send_tracked_advert.py @@ -93,7 +93,7 @@ def main(): parser = argparse.ArgumentParser(description="Send a location-tracked advertisement") parser.add_argument( "--radio-type", - choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc", "kiss-modem"], default="waveshare", help="Radio hardware type (default: waveshare)", ) diff --git a/src/pymc_core/hardware/__init__.py b/src/pymc_core/hardware/__init__.py index 2802c28..c865506 100644 --- a/src/pymc_core/hardware/__init__.py +++ b/src/pymc_core/hardware/__init__.py @@ -22,6 +22,24 @@ _SX1262_AVAILABLE = False SX1262Radio = None +# Conditional import for KissSerialWrapper (requires pyserial) +try: + from .kiss_serial_wrapper import KissSerialWrapper + + _KISS_SERIAL_AVAILABLE = True +except ImportError: + _KISS_SERIAL_AVAILABLE = False + KissSerialWrapper = None + +# Conditional import for KissModemWrapper (requires pyserial) +try: + from .kiss_modem_wrapper import KissModemWrapper + + _KISS_MODEM_AVAILABLE = True +except ImportError: + _KISS_MODEM_AVAILABLE = False + KissModemWrapper = None + __all__ = ["LoRaRadio"] # Add WsRadio to exports if available @@ -31,4 +49,11 @@ # Add SX1262Radio to exports if available if _SX1262_AVAILABLE: __all__.append("SX1262Radio") - __all__.append("SX1262Radio") + +# Add KissSerialWrapper to exports if available +if _KISS_SERIAL_AVAILABLE: + __all__.append("KissSerialWrapper") + +# Add KissModemWrapper to exports if available +if _KISS_MODEM_AVAILABLE: + __all__.append("KissModemWrapper") diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py new file mode 100644 index 0000000..cea3572 --- /dev/null +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -0,0 +1,916 @@ +""" +MeshCore KISS Modem Protocol Wrapper + +Implements the MeshCore KISS modem protocol for sending/receiving +MeshCore packets over LoRa and cryptographic operations. + +Protocol reference: https://github.com/meshcore-dev/MeshCore +""" + +import asyncio +import logging +import struct +import threading +from collections import deque +from typing import Any, Callable, Dict, Optional + +import serial + +from .base import LoRaRadio + +# KISS Protocol Constants (shared with standard KISS) +KISS_FEND = 0xC0 # Frame End +KISS_FESC = 0xDB # Frame Escape +KISS_TFEND = 0xDC # Transposed Frame End +KISS_TFESC = 0xDD # Transposed Frame Escape + +# MeshCore KISS Modem Request Commands (Host -> Modem) +# Based on actual KissModem.cpp implementation +CMD_DATA = 0x00 +CMD_GET_IDENTITY = 0x01 +CMD_GET_RANDOM = 0x02 +CMD_VERIFY_SIGNATURE = 0x03 +CMD_SIGN_DATA = 0x04 +CMD_ENCRYPT_DATA = 0x05 +CMD_DECRYPT_DATA = 0x06 +CMD_KEY_EXCHANGE = 0x07 +CMD_HASH = 0x08 +CMD_SET_RADIO = 0x09 +CMD_SET_TX_POWER = 0x0A +CMD_GET_RADIO = 0x0B +CMD_GET_TX_POWER = 0x0C +CMD_GET_VERSION = 0x0D +CMD_GET_CURRENT_RSSI = 0x0E +CMD_IS_CHANNEL_BUSY = 0x0F +CMD_GET_AIRTIME = 0x10 +CMD_GET_NOISE_FLOOR = 0x11 +CMD_GET_STATS = 0x12 +CMD_GET_BATTERY = 0x13 +CMD_PING = 0x14 +CMD_GET_SENSORS = 0x15 + +# MeshCore KISS Modem Response Commands (Modem -> Host) +RESP_IDENTITY = 0x21 +RESP_RANDOM = 0x22 +RESP_VERIFY = 0x23 +RESP_SIGNATURE = 0x24 +RESP_ENCRYPTED = 0x25 +RESP_DECRYPTED = 0x26 +RESP_SHARED_SECRET = 0x27 +RESP_HASH = 0x28 +RESP_OK = 0x29 +RESP_RADIO = 0x2A +RESP_TX_POWER = 0x2B +RESP_VERSION = 0x2C +RESP_ERROR = 0x2D +RESP_TX_DONE = 0x2E +RESP_CURRENT_RSSI = 0x2F +RESP_CHANNEL_BUSY = 0x30 +RESP_AIRTIME = 0x31 +RESP_NOISE_FLOOR = 0x32 +RESP_STATS = 0x33 +RESP_BATTERY = 0x34 +RESP_PONG = 0x35 +RESP_SENSORS = 0x36 + +# Error Codes +ERR_INVALID_LENGTH = 0x01 +ERR_INVALID_PARAM = 0x02 +ERR_NO_CALLBACK = 0x03 +ERR_MAC_FAILED = 0x04 +ERR_UNKNOWN_CMD = 0x05 +ERR_ENCRYPT_FAILED = 0x06 +ERR_TX_PENDING = 0x07 + +# Buffer and timing constants +MAX_FRAME_SIZE = 512 +RX_BUFFER_SIZE = 1024 +TX_BUFFER_SIZE = 1024 +DEFAULT_BAUDRATE = 115200 +DEFAULT_TIMEOUT = 1.0 +RESPONSE_TIMEOUT = 5.0 # Timeout for command responses + +logger = logging.getLogger("KissModemWrapper") + + +class KissModemWrapper(LoRaRadio): + """ + MeshCore KISS Modem Protocol Interface + + Provides full-duplex KISS protocol communication with MeshCore modem firmware. + Supports packet transmission/reception, radio configuration, and cryptographic + operations via the modem's identity. + + Implements the LoRaRadio interface for PyMC Core compatibility. + """ + + def __init__( + self, + port: str, + baudrate: int = DEFAULT_BAUDRATE, + timeout: float = DEFAULT_TIMEOUT, + on_frame_received: Optional[Callable[[bytes], None]] = None, + radio_config: Optional[Dict[str, Any]] = None, + auto_configure: bool = True, + ): + """ + Initialize MeshCore KISS Modem Wrapper + + Args: + port: Serial port device path (e.g., '/dev/ttyUSB0', '/dev/ttyACM0') + baudrate: Serial communication baud rate (default: 115200) + timeout: Serial read timeout in seconds (default: 1.0) + on_frame_received: Callback for received data packets + radio_config: Optional radio configuration dict with keys: + frequency, bandwidth, spreading_factor, coding_rate, power + auto_configure: If True, automatically configure radio on connect + """ + self.port = port + self.baudrate = baudrate + self.timeout = timeout + self.auto_configure = auto_configure + + self.radio_config = radio_config or {} + self.is_configured = False + + self.serial_conn: Optional[serial.Serial] = None + self.is_connected = False + + self.rx_buffer = deque(maxlen=RX_BUFFER_SIZE) + self.tx_buffer = deque(maxlen=TX_BUFFER_SIZE) + + self.rx_frame_buffer = bytearray() + self.in_frame = False + self.escaped = False + + self.rx_thread: Optional[threading.Thread] = None + self.tx_thread: Optional[threading.Thread] = None + self.stop_event = threading.Event() + + # Callbacks + self.on_frame_received = on_frame_received + + # Response handling + self._response_event = threading.Event() + self._pending_response: Optional[tuple[int, bytes]] = None + self._response_lock = threading.Lock() + + # TX completion tracking + self._tx_done_event = threading.Event() + self._tx_done_result: Optional[bool] = None + + self.stats = { + "frames_sent": 0, + "frames_received": 0, + "bytes_sent": 0, + "bytes_received": 0, + "frame_errors": 0, + "buffer_overruns": 0, + "rx_packets": 0, + "tx_packets": 0, + "errors": 0, + "last_rssi": -999, + "last_snr": -999.0, + "noise_floor": None, + } + + # Modem info + self.modem_version: Optional[int] = None + self.modem_identity: Optional[bytes] = None + + def connect(self) -> bool: + """ + Connect to serial port and start communication threads + + Returns: + True if connection successful, False otherwise + """ + try: + self.serial_conn = serial.Serial( + port=self.port, + baudrate=self.baudrate, + timeout=self.timeout, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + ) + + self.is_connected = True + self.stop_event.clear() + + # Start communication threads + self.rx_thread = threading.Thread(target=self._rx_worker, daemon=True) + self.tx_thread = threading.Thread(target=self._tx_worker, daemon=True) + + self.rx_thread.start() + self.tx_thread.start() + + logger.info(f"KISS modem connected to {self.port} at {self.baudrate} baud") + + # Auto-configure if requested + if self.auto_configure and self.radio_config: + if not self.configure_radio(): + logger.warning("Auto-configuration failed") + return False + + # Query modem info + self._query_modem_info() + + return True + + except Exception as e: + logger.error(f"Failed to connect to {self.port}: {e}") + self.is_connected = False + return False + + def disconnect(self): + """Disconnect from serial port and stop threads""" + self.is_connected = False + self.stop_event.set() + + # Wait for threads to finish + if self.rx_thread and self.rx_thread.is_alive(): + self.rx_thread.join(timeout=2.0) + if self.tx_thread and self.tx_thread.is_alive(): + self.tx_thread.join(timeout=2.0) + + # Close serial connection + if self.serial_conn and self.serial_conn.is_open: + self.serial_conn.close() + + logger.info(f"KISS modem disconnected from {self.port}") + + def _query_modem_info(self): + """Query modem version and identity""" + try: + # Get version + version_resp = self._send_command(CMD_GET_VERSION) + if version_resp and version_resp[0] == RESP_VERSION and len(version_resp[1]) >= 1: + self.modem_version = version_resp[1][0] + logger.info(f"Modem version: {self.modem_version}") + + # Get identity (public key) + identity_resp = self._send_command(CMD_GET_IDENTITY) + if identity_resp and identity_resp[0] == RESP_IDENTITY and len(identity_resp[1]) == 32: + self.modem_identity = identity_resp[1] + logger.info(f"Modem identity: {self.modem_identity.hex()[:16]}...") + + except Exception as e: + logger.warning(f"Failed to query modem info: {e}") + + def configure_radio(self) -> bool: + """ + Configure radio parameters + + Returns: + True if configuration successful, False otherwise + """ + if not self.is_connected: + logger.error("Cannot configure radio: not connected") + return False + + try: + # Extract configuration parameters with defaults + frequency_hz = self.radio_config.get("frequency", int(869.618 * 1000000)) + bandwidth_hz = self.radio_config.get("bandwidth", int(62500)) + sf = self.radio_config.get("spreading_factor", 8) + cr = self.radio_config.get("coding_rate", 8) + power = self.radio_config.get("power", 22) + + # Set radio parameters (frequency, bandwidth, SF, CR) + # Format: Freq (4) + BW (4) + SF (1) + CR (1) - all little-endian + radio_data = struct.pack(" bool: + """ + Send a data frame via KISS modem + + Args: + data: Raw packet data to send (2-255 bytes) + + Returns: + True if frame queued successfully, False otherwise + """ + if not self.is_connected: + logger.warning("Cannot send frame: not connected") + return False + + if len(data) < 2 or len(data) > 255: + logger.warning(f"Invalid frame size: {len(data)} (must be 2-255 bytes)") + return False + + try: + # Create KISS frame with CMD_DATA command + kiss_frame = self._encode_kiss_frame(CMD_DATA, data) + + # Add to TX buffer + if len(self.tx_buffer) < TX_BUFFER_SIZE: + self.tx_buffer.append(kiss_frame) + return True + else: + self.stats["buffer_overruns"] += 1 + logger.warning("TX buffer overrun") + return False + + except Exception as e: + logger.error(f"Failed to send frame: {e}") + return False + + def send_frame_and_wait(self, data: bytes, timeout: float = RESPONSE_TIMEOUT) -> bool: + """ + Send a data frame and wait for TX_DONE response + + Args: + data: Raw packet data to send + timeout: Timeout in seconds to wait for TX_DONE + + Returns: + True if transmission successful, False otherwise + """ + self._tx_done_event.clear() + self._tx_done_result = None + + if not self.send_frame(data): + return False + + # Wait for TX_DONE response + if self._tx_done_event.wait(timeout): + return self._tx_done_result or False + else: + logger.warning("TX_DONE timeout") + return False + + def _send_command( + self, cmd: int, data: bytes = b"", timeout: float = RESPONSE_TIMEOUT + ) -> Optional[tuple[int, bytes]]: + """ + Send a command and wait for response + + Args: + cmd: Command byte + data: Command data + timeout: Response timeout in seconds + + Returns: + Tuple of (response_cmd, response_data) or None on timeout + """ + with self._response_lock: + self._response_event.clear() + self._pending_response = None + + # Create and send KISS frame + kiss_frame = self._encode_kiss_frame(cmd, data) + + if self.serial_conn and self.serial_conn.is_open: + self.serial_conn.write(kiss_frame) + self.serial_conn.flush() + + # Wait for response + if self._response_event.wait(timeout): + with self._response_lock: + return self._pending_response + else: + logger.warning(f"Command 0x{cmd:02X} timeout") + return None + + def get_radio_config(self) -> Optional[Dict[str, Any]]: + """ + Get current radio configuration from modem + + Returns: + Dict with frequency, bandwidth, sf, cr, or None on error + """ + resp = self._send_command(CMD_GET_RADIO) + if resp and resp[0] == RESP_RADIO and len(resp[1]) >= 10: + freq, bw, sf, cr = struct.unpack(" Optional[int]: + """Get current TX power in dBm""" + resp = self._send_command(CMD_GET_TX_POWER) + if resp and resp[0] == RESP_TX_POWER and len(resp[1]) >= 1: + return resp[1][0] + return None + + def get_current_rssi(self) -> int: + """Get current RSSI from modem""" + resp = self._send_command(CMD_GET_CURRENT_RSSI) + if resp and resp[0] == RESP_CURRENT_RSSI and len(resp[1]) >= 1: + # RSSI is signed byte + rssi = resp[1][0] + if rssi > 127: + rssi -= 256 + return rssi + return -999 + + def is_channel_busy(self) -> bool: + """Check if channel is busy""" + resp = self._send_command(CMD_IS_CHANNEL_BUSY) + if resp and resp[0] == RESP_CHANNEL_BUSY and len(resp[1]) >= 1: + return resp[1][0] == 0x01 + return False + + def get_airtime(self, packet_length: int) -> Optional[int]: + """ + Get estimated airtime for a packet + + Args: + packet_length: Length of packet in bytes + + Returns: + Airtime in milliseconds or None on error + """ + resp = self._send_command(CMD_GET_AIRTIME, bytes([packet_length])) + if resp and resp[0] == RESP_AIRTIME and len(resp[1]) >= 4: + return struct.unpack(" Optional[int]: + """Get noise floor in dBm""" + resp = self._send_command(CMD_GET_NOISE_FLOOR) + if resp and resp[0] == RESP_NOISE_FLOOR and len(resp[1]) >= 2: + # Noise floor is signed 16-bit + noise = struct.unpack(" Optional[Dict[str, int]]: + """ + Get modem statistics + + Returns: + Dict with rx, tx, errors counts or None on error + """ + resp = self._send_command(CMD_GET_STATS) + if resp and resp[0] == RESP_STATS and len(resp[1]) >= 12: + rx, tx, errors = struct.unpack(" Optional[int]: + """Get battery voltage in millivolts""" + resp = self._send_command(CMD_GET_BATTERY) + if resp and resp[0] == RESP_BATTERY and len(resp[1]) >= 2: + return struct.unpack(" bool: + """Ping the modem to check connectivity""" + resp = self._send_command(CMD_PING) + return resp is not None and resp[0] == RESP_PONG + + def get_sensors(self, permissions: int = 0x07) -> Optional[bytes]: + """ + Get sensor data in CayenneLPP format + + Args: + permissions: Bitmask of sensors to query + 0x01 = battery, 0x02 = GPS, 0x04 = environment + + Returns: + CayenneLPP encoded sensor data or None + """ + resp = self._send_command(CMD_GET_SENSORS, bytes([permissions])) + if resp and resp[0] == RESP_SENSORS: + return resp[1] + return None + + # Cryptographic operations using modem's identity + + def get_identity(self) -> Optional[bytes]: + """Get modem's public key (32 bytes)""" + resp = self._send_command(CMD_GET_IDENTITY) + if resp and resp[0] == RESP_IDENTITY and len(resp[1]) == 32: + self.modem_identity = resp[1] + return resp[1] + return None + + def get_random(self, length: int) -> Optional[bytes]: + """ + Get random bytes from modem + + Args: + length: Number of random bytes (1-64) + + Returns: + Random bytes or None on error + """ + if length < 1 or length > 64: + logger.error("Random length must be 1-64") + return None + resp = self._send_command(CMD_GET_RANDOM, bytes([length])) + if resp and resp[0] == RESP_RANDOM: + return resp[1] + return None + + def sign_data(self, data: bytes) -> Optional[bytes]: + """ + Sign data with modem's private key + + Args: + data: Data to sign + + Returns: + 64-byte signature or None on error + """ + resp = self._send_command(CMD_SIGN_DATA, data) + if resp and resp[0] == RESP_SIGNATURE and len(resp[1]) == 64: + return resp[1] + return None + + def verify_signature(self, pubkey: bytes, signature: bytes, data: bytes) -> Optional[bool]: + """ + Verify a signature + + Args: + pubkey: 32-byte public key + signature: 64-byte signature + data: Original data + + Returns: + True if valid, False if invalid, None on error + """ + if len(pubkey) != 32 or len(signature) != 64: + logger.error("Invalid pubkey or signature length") + return None + payload = pubkey + signature + data + resp = self._send_command(CMD_VERIFY_SIGNATURE, payload) + if resp and resp[0] == RESP_VERIFY and len(resp[1]) >= 1: + return resp[1][0] == 0x01 + return None + + def encrypt_data(self, key: bytes, plaintext: bytes) -> Optional[tuple[bytes, bytes]]: + """ + Encrypt data using a shared key + + Args: + key: 32-byte encryption key + plaintext: Data to encrypt + + Returns: + Tuple of (mac, ciphertext) or None on error + """ + if len(key) != 32: + logger.error("Key must be 32 bytes") + return None + payload = key + plaintext + resp = self._send_command(CMD_ENCRYPT_DATA, payload) + if resp and resp[0] == RESP_ENCRYPTED and len(resp[1]) >= 2: + mac = resp[1][:2] + ciphertext = resp[1][2:] + return (mac, ciphertext) + return None + + def decrypt_data(self, key: bytes, mac: bytes, ciphertext: bytes) -> Optional[bytes]: + """ + Decrypt data using a shared key + + Args: + key: 32-byte decryption key + mac: 2-byte MAC + ciphertext: Encrypted data + + Returns: + Plaintext or None on error (includes MAC failure) + """ + if len(key) != 32 or len(mac) != 2: + logger.error("Invalid key or MAC length") + return None + payload = key + mac + ciphertext + resp = self._send_command(CMD_DECRYPT_DATA, payload) + if resp and resp[0] == RESP_DECRYPTED: + return resp[1] + return None + + def key_exchange(self, remote_pubkey: bytes) -> Optional[bytes]: + """ + Perform key exchange with remote public key + + Args: + remote_pubkey: 32-byte remote public key + + Returns: + 32-byte shared secret or None on error + """ + if len(remote_pubkey) != 32: + logger.error("Remote public key must be 32 bytes") + return None + resp = self._send_command(CMD_KEY_EXCHANGE, remote_pubkey) + if resp and resp[0] == RESP_SHARED_SECRET and len(resp[1]) == 32: + return resp[1] + return None + + def hash_data(self, data: bytes) -> Optional[bytes]: + """ + Compute SHA-256 hash of data + + Args: + data: Data to hash + + Returns: + 32-byte hash or None on error + """ + resp = self._send_command(CMD_HASH, data) + if resp and resp[0] == RESP_HASH and len(resp[1]) == 32: + return resp[1] + return None + + # LoRaRadio interface implementation + + def set_rx_callback(self, callback: Callable[[bytes], None]): + """ + Set the RX callback function + + Args: + callback: Function to call when a packet is received + """ + self.on_frame_received = callback + logger.debug("RX callback set") + + def begin(self): + """Initialize the modem (LoRaRadio interface)""" + success = self.connect() + if not success: + raise Exception("Failed to initialize KISS modem") + + async def send(self, data: bytes) -> Optional[Dict[str, Any]]: + """ + Send data via KISS modem (LoRaRadio interface) + + Args: + data: Data to send + + Returns: + Transmission metadata dict or None + + Raises: + Exception: If send fails + """ + success = self.send_frame(data) + if not success: + raise Exception("Failed to send frame via KISS modem") + + # Return metadata if available + airtime = self.get_airtime(len(data)) + if airtime: + return {"airtime_ms": airtime} + return None + + async def wait_for_rx(self) -> bytes: + """ + Wait for a packet to be received asynchronously (LoRaRadio interface) + + Returns: + Received packet data + """ + future = asyncio.Future() + + original_callback = self.on_frame_received + + def temp_callback(data: bytes): + if not future.done(): + future.set_result(data) + if original_callback: + try: + original_callback(data) + except Exception as e: + logger.error(f"Error in original callback: {e}") + + self.on_frame_received = temp_callback + + try: + data = await future + return data + finally: + self.on_frame_received = original_callback + + def sleep(self): + """Put the modem into low-power mode (LoRaRadio interface)""" + logger.debug("Sleep mode not directly supported for KISS modem") + pass + + def get_last_rssi(self) -> int: + """Return last received RSSI in dBm (LoRaRadio interface)""" + return self.stats.get("last_rssi", -999) + + def get_last_snr(self) -> float: + """Return last received SNR in dB (LoRaRadio interface)""" + return self.stats.get("last_snr", -999.0) + + def get_stats(self) -> Dict[str, Any]: + """Get interface statistics""" + return self.stats.copy() + + # KISS frame encoding/decoding + + def _encode_kiss_frame(self, cmd: int, data: bytes) -> bytes: + """ + Encode data into KISS frame format + + Args: + cmd: Command byte + data: Raw data to encode + + Returns: + Encoded KISS frame + """ + # Start with FEND and command + frame = bytearray([KISS_FEND, cmd]) + + # Escape and add data + for byte in data: + if byte == KISS_FEND: + frame.extend([KISS_FESC, KISS_TFEND]) + elif byte == KISS_FESC: + frame.extend([KISS_FESC, KISS_TFESC]) + else: + frame.append(byte) + + # End with FEND + frame.append(KISS_FEND) + + return bytes(frame) + + def _decode_kiss_byte(self, byte: int): + """ + Process received byte for KISS frame decoding + + Args: + byte: Received byte + """ + if byte == KISS_FEND: + if self.in_frame and len(self.rx_frame_buffer) > 0: + # Complete frame received + self._process_received_frame() + # Start new frame + self.rx_frame_buffer.clear() + self.in_frame = True + self.escaped = False + + elif byte == KISS_FESC: + if self.in_frame: + self.escaped = True + + elif self.escaped: + if byte == KISS_TFEND: + self.rx_frame_buffer.append(KISS_FEND) + elif byte == KISS_TFESC: + self.rx_frame_buffer.append(KISS_FESC) + else: + # Invalid escape sequence + self.stats["frame_errors"] += 1 + logger.warning(f"Invalid KISS escape sequence: 0x{byte:02X}") + self.escaped = False + + else: + if self.in_frame: + self.rx_frame_buffer.append(byte) + + def _process_received_frame(self): + """Process a complete received KISS frame""" + if len(self.rx_frame_buffer) < 1: + return + + # Extract command byte + cmd = self.rx_frame_buffer[0] + data = bytes(self.rx_frame_buffer[1:]) + + self.stats["frames_received"] += 1 + self.stats["bytes_received"] += len(data) + + if cmd == CMD_DATA: + # Data packet received - extract RSSI/SNR and payload + if len(data) >= 2: + # First byte is SNR * 4 (signed), second byte is RSSI (signed) + snr_raw = data[0] + rssi_raw = data[1] + + # Convert to signed values + if snr_raw > 127: + snr_raw -= 256 + if rssi_raw > 127: + rssi_raw -= 256 + + self.stats["last_snr"] = snr_raw / 4.0 # SNR in 0.25 dB steps + self.stats["last_rssi"] = rssi_raw + self.stats["rx_packets"] += 1 + + # Extract packet payload (skip SNR and RSSI bytes) + packet_data = data[2:] + + if self.on_frame_received and len(packet_data) > 0: + try: + self.on_frame_received(packet_data) + except Exception as e: + logger.error(f"Error in frame received callback: {e}") + + elif cmd == RESP_TX_DONE: + # TX completion response + if len(data) >= 1: + self._tx_done_result = data[0] == 0x01 + self.stats["tx_packets"] += 1 + self._tx_done_event.set() + + elif cmd == RESP_ERROR: + # Error response + if len(data) >= 1: + error_code = data[0] + self.stats["errors"] += 1 + logger.warning(f"Modem error: 0x{error_code:02X}") + # Signal response for command waiting + with self._response_lock: + self._pending_response = (cmd, data) + self._response_event.set() + + else: + # Response to a command + with self._response_lock: + self._pending_response = (cmd, data) + self._response_event.set() + + def _rx_worker(self): + """Background thread for receiving data""" + while not self.stop_event.is_set() and self.is_connected: + try: + if self.serial_conn and self.serial_conn.in_waiting > 0: + data = self.serial_conn.read(self.serial_conn.in_waiting) + + for byte in data: + self._decode_kiss_byte(byte) + + else: + threading.Event().wait(0.01) + + except Exception as e: + if self.is_connected: + logger.error(f"RX worker error: {e}") + break + + def _tx_worker(self): + """Background thread for sending data""" + while not self.stop_event.is_set() and self.is_connected: + try: + if self.tx_buffer: + frame = self.tx_buffer.popleft() + + if self.serial_conn and self.serial_conn.is_open: + self.serial_conn.write(frame) + self.serial_conn.flush() + + self.stats["frames_sent"] += 1 + self.stats["bytes_sent"] += len(frame) + else: + logger.warning("Serial connection not open") + else: + threading.Event().wait(0.01) + + except Exception as e: + if self.is_connected: + logger.error(f"TX worker error: {e}") + break + + def __enter__(self): + """Context manager entry""" + self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit""" + self.disconnect() + + def __del__(self): + """Destructor to ensure cleanup""" + try: + self.disconnect() + except Exception: + pass diff --git a/src/pymc_core/protocol/__init__.py b/src/pymc_core/protocol/__init__.py index 62ffd46..75cdddf 100644 --- a/src/pymc_core/protocol/__init__.py +++ b/src/pymc_core/protocol/__init__.py @@ -58,6 +58,7 @@ # Import identity classes after other imports to avoid circular dependencies from .identity import Identity, LocalIdentity +from .modem_identity import ModemIdentity from .packet import Packet # PacketBuilder imports from other protocol modules so import it last @@ -81,6 +82,7 @@ "PacketFilter", "CryptoUtils", "LocalIdentity", + "ModemIdentity", "Identity", # Utility functions "parse_advert_payload", diff --git a/src/pymc_core/protocol/modem_identity.py b/src/pymc_core/protocol/modem_identity.py new file mode 100644 index 0000000..1a4a896 --- /dev/null +++ b/src/pymc_core/protocol/modem_identity.py @@ -0,0 +1,270 @@ +""" +Modem-based Identity for MeshCore KISS Modem + +Provides an Identity implementation that delegates cryptographic operations +to the KISS modem hardware, keeping the private key secure on the device. +""" + +from typing import TYPE_CHECKING + +from nacl.public import PublicKey +from nacl.signing import VerifyKey + +from . import CryptoUtils + +if TYPE_CHECKING: + from pymc_core.hardware.kiss_modem_wrapper import KissModemWrapper + + +class ModemIdentity: + """ + Identity implementation using the KISS modem's cryptographic capabilities. + + Delegates signing, verification, and key exchange to the modem hardware, + ensuring the private key never leaves the secure modem environment. + + Implements the same interface as LocalIdentity for compatibility with + the rest of the pyMC_core stack. + """ + + def __init__(self, modem: "KissModemWrapper"): + """ + Initialize ModemIdentity with a connected KISS modem. + + Args: + modem: A connected KissModemWrapper instance + + Raises: + ValueError: If modem is not connected or identity cannot be retrieved + """ + if not modem.is_connected: + raise ValueError("Modem must be connected before creating ModemIdentity") + + self._modem = modem + + # Get the modem's public key + pubkey = modem.get_identity() + if pubkey is None or len(pubkey) != 32: + raise ValueError("Failed to retrieve modem identity") + + self._ed25519_pubkey = pubkey + self.verify_key = VerifyKey(pubkey) + + # Derive X25519 public key for ECDH + x25519_pubkey = CryptoUtils.ed25519_pk_to_x25519(pubkey) + self.x25519_pubkey = PublicKey(x25519_pubkey) + + # Cache the X25519 public key bytes + self._x25519_public = x25519_pubkey + + def get_public_key(self) -> bytes: + """ + Get the Ed25519 public key for this identity. + + Returns: + The 32-byte Ed25519 public key. + """ + return self._ed25519_pubkey + + def get_address_bytes(self) -> bytes: + """ + Get the address bytes derived from the public key. + + Returns: + The first byte of SHA256 hash of the public key, used as address. + """ + return CryptoUtils.sha256(self._ed25519_pubkey)[:1] + + def get_shared_public_key(self) -> bytes: + """ + Get the X25519 public key for ECDH operations. + + Returns: + The 32-byte X25519 public key. + """ + return self._x25519_public + + def sign(self, message: bytes) -> bytes: + """ + Sign a message using the modem's private key. + + Args: + message: The message to sign. + + Returns: + The 64-byte Ed25519 signature. + + Raises: + RuntimeError: If signing fails + """ + signature = self._modem.sign_data(message) + if signature is None: + raise RuntimeError("Modem signing failed") + return signature + + def verify(self, message: bytes, signature: bytes) -> bool: + """ + Verify a signature against a message. + + Note: This uses PyNaCl locally since verification only needs + the public key and is not security-sensitive. + + Args: + message: The original message bytes. + signature: The signature to verify. + + Returns: + True if the signature is valid, False otherwise. + """ + result = self._modem.verify_signature(self._ed25519_pubkey, signature, message) + if result is None: + # Fall back to local verification if modem fails + try: + self.verify_key.verify(message, signature) + return True + except Exception: + return False + return result + + def calc_shared_secret(self, remote_x25519_pubkey: bytes) -> bytes: + """ + Compute the ECDH shared secret with a remote public key. + + Uses the modem's key_exchange command for secure computation. + + Args: + remote_x25519_pubkey: The remote party's X25519 public key. + + Returns: + The 32-byte shared secret for encryption. + + Raises: + RuntimeError: If key exchange fails + """ + # The modem expects an Ed25519 public key for key exchange, + # but we have an X25519 key. We need to use the modem's + # key_exchange which internally handles the conversion. + # + # However, the MeshCore protocol typically passes Ed25519 pubkeys + # and converts internally. If we're given an X25519 key directly, + # we may need to handle this differently. + # + # For now, assume we're given the remote's Ed25519 pubkey and + # the modem will handle the X25519 conversion internally. + shared_secret = self._modem.key_exchange(remote_x25519_pubkey) + if shared_secret is None: + raise RuntimeError("Modem key exchange failed") + return shared_secret + + def get_private_key(self) -> bytes: + """ + Get the X25519 private key for ECDH operations. + + Note: ModemIdentity does NOT expose the private key since it + remains secure on the modem. This method raises an error. + + Raises: + RuntimeError: Always, as private key is not accessible + """ + raise RuntimeError( + "ModemIdentity does not expose private keys. " + "Use calc_shared_secret() for ECDH operations." + ) + + def get_signing_key_bytes(self) -> bytes: + """ + Get the signing key bytes for this identity. + + Note: ModemIdentity does NOT expose the signing key since it + remains secure on the modem. This method raises an error. + + Raises: + RuntimeError: Always, as signing key is not accessible + """ + raise RuntimeError( + "ModemIdentity does not expose signing keys. " + "Use sign() for signing operations." + ) + + # Additional modem-specific methods + + def hash_data(self, data: bytes) -> bytes: + """ + Compute SHA-256 hash using the modem. + + Args: + data: Data to hash. + + Returns: + The 32-byte SHA-256 hash. + + Raises: + RuntimeError: If hashing fails + """ + result = self._modem.hash_data(data) + if result is None: + # Fall back to local hashing + return CryptoUtils.sha256(data) + return result + + def get_random(self, length: int) -> bytes: + """ + Get random bytes from the modem's hardware RNG. + + Args: + length: Number of random bytes (1-64). + + Returns: + Random bytes from the modem. + + Raises: + RuntimeError: If random generation fails + """ + result = self._modem.get_random(length) + if result is None: + raise RuntimeError("Modem random generation failed") + return result + + def encrypt(self, key: bytes, plaintext: bytes) -> tuple[bytes, bytes]: + """ + Encrypt data using the modem. + + Args: + key: 32-byte encryption key. + plaintext: Data to encrypt. + + Returns: + Tuple of (2-byte MAC, ciphertext). + + Raises: + RuntimeError: If encryption fails + """ + result = self._modem.encrypt_data(key, plaintext) + if result is None: + raise RuntimeError("Modem encryption failed") + return result + + def decrypt(self, key: bytes, mac: bytes, ciphertext: bytes) -> bytes: + """ + Decrypt data using the modem. + + Args: + key: 32-byte decryption key. + mac: 2-byte MAC. + ciphertext: Encrypted data. + + Returns: + Decrypted plaintext. + + Raises: + RuntimeError: If decryption fails (includes MAC verification failure) + """ + result = self._modem.decrypt_data(key, mac, ciphertext) + if result is None: + raise RuntimeError("Modem decryption failed (MAC verification may have failed)") + return result + + @property + def modem(self) -> "KissModemWrapper": + """Get the underlying modem instance.""" + return self._modem diff --git a/tests/test_kiss_modem_wrapper.py b/tests/test_kiss_modem_wrapper.py new file mode 100644 index 0000000..58a9e82 --- /dev/null +++ b/tests/test_kiss_modem_wrapper.py @@ -0,0 +1,568 @@ +""" +Tests for MeshCore KISS Modem Wrapper + +Tests the KISS frame encoding/decoding, command/response handling, +and LoRaRadio interface implementation. +""" + +import struct +import threading +from unittest.mock import MagicMock, patch + +import pytest + +from pymc_core.hardware.kiss_modem_wrapper import ( + CMD_DATA, + CMD_ENCRYPT_DATA, + CMD_GET_AIRTIME, + CMD_GET_BATTERY, + CMD_GET_IDENTITY, + CMD_GET_NOISE_FLOOR, + CMD_GET_RADIO, + CMD_GET_RANDOM, + CMD_GET_STATS, + CMD_GET_TX_POWER, + CMD_GET_VERSION, + CMD_HASH, + CMD_KEY_EXCHANGE, + CMD_PING, + CMD_SET_RADIO, + CMD_SET_TX_POWER, + CMD_SIGN_DATA, + CMD_VERIFY_SIGNATURE, + KISS_FEND, + KISS_FESC, + KISS_TFEND, + KISS_TFESC, + RESP_AIRTIME, + RESP_BATTERY, + RESP_ENCRYPTED, + RESP_ERROR, + RESP_HASH, + RESP_IDENTITY, + RESP_NOISE_FLOOR, + RESP_OK, + RESP_PONG, + RESP_RADIO, + RESP_RANDOM, + RESP_SHARED_SECRET, + RESP_SIGNATURE, + RESP_STATS, + RESP_TX_DONE, + RESP_TX_POWER, + RESP_VERIFY, + RESP_VERSION, + KissModemWrapper, +) + + +class TestKissFrameEncoding: + """Test KISS frame encoding/decoding""" + + def test_encode_simple_frame(self): + """Test encoding a simple frame without special characters""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + frame = modem._encode_kiss_frame(CMD_DATA, b"\x01\x02\x03") + + # Should be: FEND + CMD + data + FEND + assert frame[0] == KISS_FEND + assert frame[1] == CMD_DATA + assert frame[2:5] == b"\x01\x02\x03" + assert frame[5] == KISS_FEND + + def test_encode_frame_with_fend_escape(self): + """Test encoding a frame containing FEND byte""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + frame = modem._encode_kiss_frame(CMD_DATA, bytes([0xC0])) # FEND + + # FEND in data should be escaped as FESC + TFEND + assert frame[0] == KISS_FEND + assert frame[1] == CMD_DATA + assert frame[2] == KISS_FESC + assert frame[3] == KISS_TFEND + assert frame[4] == KISS_FEND + + def test_encode_frame_with_fesc_escape(self): + """Test encoding a frame containing FESC byte""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + frame = modem._encode_kiss_frame(CMD_DATA, bytes([0xDB])) # FESC + + # FESC in data should be escaped as FESC + TFESC + assert frame[0] == KISS_FEND + assert frame[1] == CMD_DATA + assert frame[2] == KISS_FESC + assert frame[3] == KISS_TFESC + assert frame[4] == KISS_FEND + + def test_encode_frame_with_multiple_escapes(self): + """Test encoding a frame with multiple special characters""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + frame = modem._encode_kiss_frame(CMD_DATA, bytes([0xC0, 0xDB, 0xC0])) + + expected = bytes( + [ + KISS_FEND, + CMD_DATA, + KISS_FESC, + KISS_TFEND, # escaped 0xC0 + KISS_FESC, + KISS_TFESC, # escaped 0xDB + KISS_FESC, + KISS_TFEND, # escaped 0xC0 + KISS_FEND, + ] + ) + assert frame == expected + + def test_decode_simple_frame(self): + """Test decoding a simple frame""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + modem.is_connected = True + + received_frames = [] + modem.on_frame_received = lambda data: received_frames.append(data) + + # Simulate receiving: FEND + CMD_DATA + SNR + RSSI + payload + FEND + raw_bytes = bytes([KISS_FEND, CMD_DATA, 0x10, 0xB0, 0x01, 0x02, 0x03, KISS_FEND]) + + for byte in raw_bytes: + modem._decode_kiss_byte(byte) + + assert len(received_frames) == 1 + assert received_frames[0] == b"\x01\x02\x03" # payload without SNR/RSSI + + def test_decode_frame_with_escapes(self): + """Test decoding a frame with escaped characters""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + modem.is_connected = True + + received_frames = [] + modem.on_frame_received = lambda data: received_frames.append(data) + + # Frame with escaped FEND (0xC0) in payload + # SNR=0x10, RSSI=0xB0, payload contains 0xC0 escaped + raw_bytes = bytes( + [ + KISS_FEND, + CMD_DATA, + 0x10, + 0xB0, # SNR, RSSI + KISS_FESC, + KISS_TFEND, # escaped 0xC0 + KISS_FEND, + ] + ) + + for byte in raw_bytes: + modem._decode_kiss_byte(byte) + + assert len(received_frames) == 1 + assert received_frames[0] == bytes([0xC0]) + + def test_decode_extracts_rssi_snr(self): + """Test that RSSI and SNR are extracted from received frames""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + modem.is_connected = True + + # SNR = 0x10 (4.0 dB when divided by 4) + # RSSI = 0xB0 (-80 dBm as signed byte) + raw_bytes = bytes([KISS_FEND, CMD_DATA, 0x10, 0xB0, 0xAA, 0xBB, KISS_FEND]) + + for byte in raw_bytes: + modem._decode_kiss_byte(byte) + + assert modem.stats["last_snr"] == pytest.approx(4.0) + assert modem.stats["last_rssi"] == -80 + + +class TestCommandResponses: + """Test command sending and response parsing""" + + def test_send_command_encodes_correctly(self): + """Test that _send_command creates correct KISS frame""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + + # Mock serial connection + mock_serial = MagicMock() + mock_serial.is_open = True + modem.serial_conn = mock_serial + modem.is_connected = True + + # Send command with short timeout (will timeout since no response) + modem._send_command(CMD_GET_VERSION, timeout=0.1) + + # Verify frame was written + assert mock_serial.write.called + written_frame = mock_serial.write.call_args[0][0] + + assert written_frame[0] == KISS_FEND + assert written_frame[1] == CMD_GET_VERSION + assert written_frame[-1] == KISS_FEND + + def test_response_parsing_identity(self): + """Test parsing identity response""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + modem.is_connected = True + + # Simulate response: RESP_IDENTITY + 32 bytes pubkey + pubkey = bytes(range(32)) + raw_bytes = bytes([KISS_FEND, RESP_IDENTITY]) + pubkey + bytes([KISS_FEND]) + + # Set up response capture + modem._response_event = threading.Event() + modem._pending_response = None + + for byte in raw_bytes: + modem._decode_kiss_byte(byte) + + assert modem._pending_response is not None + assert modem._pending_response[0] == RESP_IDENTITY + assert modem._pending_response[1] == pubkey + + def test_response_parsing_error(self): + """Test parsing error response""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + modem.is_connected = True + + # Simulate error response + raw_bytes = bytes([KISS_FEND, RESP_ERROR, 0x05, KISS_FEND]) # ERR_UNKNOWN_CMD + + modem._response_event = threading.Event() + modem._pending_response = None + + for byte in raw_bytes: + modem._decode_kiss_byte(byte) + + assert modem._pending_response is not None + assert modem._pending_response[0] == RESP_ERROR + assert modem._pending_response[1][0] == 0x05 + + def test_tx_done_response(self): + """Test TX done response sets event""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + modem.is_connected = True + modem._tx_done_event = threading.Event() + + # Simulate TX done success + raw_bytes = bytes([KISS_FEND, RESP_TX_DONE, 0x01, KISS_FEND]) + + for byte in raw_bytes: + modem._decode_kiss_byte(byte) + + assert modem._tx_done_event.is_set() + assert modem._tx_done_result is True + + +class TestRadioConfiguration: + """Test radio configuration encoding""" + + def test_radio_config_struct_format(self): + """Test that radio config is packed correctly""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + + freq_hz = 869618000 + bw_hz = 62500 + sf = 8 + cr = 8 + + # This is what configure_radio should pack + expected = struct.pack(" 255 bytes) + assert modem.send_frame(bytes(256)) is False + + def test_send_frame_requires_connection(self): + """Test send_frame requires connection""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + modem.is_connected = False + + assert modem.send_frame(b"\x00\x01") is False + + def test_send_frame_queues_to_buffer(self): + """Test send_frame adds to TX buffer""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + modem.is_connected = True + + assert len(modem.tx_buffer) == 0 + + result = modem.send_frame(b"\x01\x02\x03") + + assert result is True + assert len(modem.tx_buffer) == 1 + + # Verify frame is properly encoded + frame = modem.tx_buffer[0] + assert frame[0] == KISS_FEND + assert frame[1] == CMD_DATA + assert frame[-1] == KISS_FEND + + +class TestQueryMethods: + """Test modem query methods""" + + def test_get_radio_config_parses_response(self): + """Test get_radio_config parses response correctly""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + + freq = 869618000 + bw = 62500 + sf = 8 + cr = 8 + response_data = struct.pack(" Date: Tue, 3 Feb 2026 10:11:24 -0800 Subject: [PATCH 02/12] Enhance RX callback functionality in KissModemWrapper to support per-packet metrics - Updated the RX callback to accept both (data) and (data, rssi, snr) signatures for improved flexibility. - Implemented a helper function to invoke the callback with the appropriate number of arguments. - Modified the dispatcher to handle per-packet RSSI and SNR values, ensuring accurate metrics without race conditions. - Added a test to verify that the RX callback correctly receives per-packet RSSI and SNR values. --- src/pymc_core/hardware/kiss_modem_wrapper.py | 50 ++++++++++++++++---- src/pymc_core/node/dispatcher.py | 28 +++++++---- tests/test_kiss_modem_wrapper.py | 25 ++++++++++ 3 files changed, 84 insertions(+), 19 deletions(-) diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py index cea3572..03839d2 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -8,11 +8,36 @@ """ import asyncio +import inspect import logging import struct import threading from collections import deque -from typing import Any, Callable, Dict, Optional +from typing import Any, Callable, Dict, Optional, Union + +# RX callback: (data) for backward compat, or (data, rssi, snr) for per-packet metrics +RxCallback = Union[ + Callable[[bytes], None], + Callable[[bytes, Optional[int], Optional[float]], None], +] + + +def _invoke_rx_callback( + callback: RxCallback, + data: bytes, + rssi: int, + snr: float, +) -> None: + """Invoke RX callback with 1 or 3 args depending on what it accepts.""" + try: + sig = inspect.signature(callback) + nparams = len([p for p in sig.parameters if p != "self"]) + except (ValueError, TypeError): + nparams = 1 + if nparams >= 3: + callback(data, rssi, snr) + else: + callback(data) import serial @@ -109,7 +134,7 @@ def __init__( port: str, baudrate: int = DEFAULT_BAUDRATE, timeout: float = DEFAULT_TIMEOUT, - on_frame_received: Optional[Callable[[bytes], None]] = None, + on_frame_received: Optional[RxCallback] = None, radio_config: Optional[Dict[str, Any]] = None, auto_configure: bool = True, ): @@ -647,12 +672,13 @@ def hash_data(self, data: bytes) -> Optional[bytes]: # LoRaRadio interface implementation - def set_rx_callback(self, callback: Callable[[bytes], None]): + def set_rx_callback(self, callback: RxCallback): """ - Set the RX callback function + Set the RX callback function. - Args: - callback: Function to call when a packet is received + The callback may be (data: bytes) or (data, rssi, snr). When invoked + by this wrapper it is always called with (data, rssi, snr) so each + packet gets correct per-packet metrics without race conditions. """ self.on_frame_received = callback logger.debug("RX callback set") @@ -697,12 +723,14 @@ async def wait_for_rx(self) -> bytes: original_callback = self.on_frame_received - def temp_callback(data: bytes): + def temp_callback(data: bytes, rssi: Optional[int] = None, snr: Optional[float] = None): if not future.done(): future.set_result(data) if original_callback: try: - original_callback(data) + rssi_val = rssi if rssi is not None else -999 + snr_val = snr if snr is not None else -999.0 + _invoke_rx_callback(original_callback, data, rssi_val, snr_val) except Exception as e: logger.error(f"Error in original callback: {e}") @@ -830,7 +858,11 @@ def _process_received_frame(self): if self.on_frame_received and len(packet_data) > 0: try: - self.on_frame_received(packet_data) + # Pass per-packet rssi/snr to avoid race with get_last_rssi/get_last_snr + snr_db = snr_raw / 4.0 # SNR in 0.25 dB steps + _invoke_rx_callback( + self.on_frame_received, packet_data, rssi_raw, snr_db + ) except Exception as e: logger.error(f"Error in frame received callback: {e}") diff --git a/src/pymc_core/node/dispatcher.py b/src/pymc_core/node/dispatcher.py index 5a0a2ea..a6e31c0 100644 --- a/src/pymc_core/node/dispatcher.py +++ b/src/pymc_core/node/dispatcher.py @@ -297,18 +297,26 @@ def set_raw_packet_callback( """Set callback for raw packet data (includes both parsed packet and raw bytes).""" self.raw_packet_callback = callback - def _on_packet_received(self, data: bytes) -> None: - """Called by the radio when a packet comes in.""" - # Schedule the packet processing in the event loop + def _on_packet_received( + self, + data: bytes, + rssi: Optional[int] = None, + snr: Optional[float] = None, + ) -> None: + """Called by the radio when a packet comes in. rssi/snr are per-packet when provided.""" try: loop = asyncio.get_running_loop() - loop.create_task(self._process_received_packet(data)) + loop.create_task(self._process_received_packet(data, rssi, snr)) except RuntimeError: - # No event loop running, can't process packet self._log("No event loop running, cannot process received packet") - async def _process_received_packet(self, data: bytes) -> None: - """Process a received packet from the radio callback.""" + async def _process_received_packet( + self, + data: bytes, + rssi: Optional[int] = None, + snr: Optional[float] = None, + ) -> None: + """Process a received packet from the radio callback. rssi/snr are per-packet when provided.""" self._log(f"[RX DEBUG] Processing packet: {len(data)} bytes, data: {data.hex()[:32]}...") # Generate packet hash for deduplication and blacklist checking @@ -342,9 +350,9 @@ async def _process_received_packet(self, data: bytes) -> None: self._log(f"[RX DEBUG] Packet type: {ptype:02X}") - # Add signal strength information to packet from radio - pkt._rssi = self.radio.get_last_rssi() - pkt._snr = self.radio.get_last_snr() + # Use per-packet rssi/snr when provided (avoids race); else fall back to radio last values + pkt._rssi = rssi if rssi is not None else self.radio.get_last_rssi() + pkt._snr = snr if snr is not None else self.radio.get_last_snr() # Let the node know about this packet for analysis (statistics, caching, etc.) if self.packet_analysis_callback: diff --git a/tests/test_kiss_modem_wrapper.py b/tests/test_kiss_modem_wrapper.py index 58a9e82..89d82f1 100644 --- a/tests/test_kiss_modem_wrapper.py +++ b/tests/test_kiss_modem_wrapper.py @@ -174,6 +174,31 @@ def test_decode_extracts_rssi_snr(self): assert modem.stats["last_snr"] == pytest.approx(4.0) assert modem.stats["last_rssi"] == -80 + def test_rx_callback_receives_per_packet_rssi_snr(self): + """Test that a 3-arg callback receives (data, rssi, snr) per packet without race""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + modem.is_connected = True + + received = [] + def capture(data, rssi, snr): + received.append((data, rssi, snr)) + + modem.on_frame_received = capture + + # First frame: SNR=0x10 (4.0 dB), RSSI=0xB0 (-80) + raw1 = bytes([KISS_FEND, CMD_DATA, 0x10, 0xB0, 0x01, 0x02, KISS_FEND]) + for byte in raw1: + modem._decode_kiss_byte(byte) + + # Second frame: different metrics + raw2 = bytes([KISS_FEND, CMD_DATA, 0x08, 0x9C, 0x03, 0x04, KISS_FEND]) + for byte in raw2: + modem._decode_kiss_byte(byte) + + assert len(received) == 2 + assert received[0] == (b"\x01\x02", -80, 4.0) + assert received[1] == (b"\x03\x04", -100, 2.0) # 0x9C signed = -100, 0x08/4 = 2.0 + class TestCommandResponses: """Test command sending and response parsing""" From dfb180c2a3cd86612e01f8e9a25f60614b573a6e Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 3 Feb 2026 10:23:20 -0800 Subject: [PATCH 03/12] Enhance KissModemWrapper with event loop support for thread-safe RX callbacks - Introduced a method to set an asyncio event loop for safe callback invocation from background threads. - Updated the RX callback dispatcher to utilize the event loop when set, ensuring proper async integration. - Modified the constructor and documentation to reflect changes in callback handling and configuration options. - Added tests to verify event loop functionality and compatibility with radio configuration keys. --- src/pymc_core/hardware/kiss_modem_wrapper.py | 73 ++++++++++- tests/test_kiss_modem_wrapper.py | 122 +++++++++++++++++++ 2 files changed, 189 insertions(+), 6 deletions(-) diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py index 03839d2..33ab2f4 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -127,6 +127,20 @@ class KissModemWrapper(LoRaRadio): operations via the modem's identity. Implements the LoRaRadio interface for PyMC Core compatibility. + + Threading Model: + This wrapper uses background threads for serial RX/TX. The RX callback + (on_frame_received) is invoked from the RX thread by default. For async + applications, call set_event_loop() to have callbacks scheduled onto + the event loop via call_soon_threadsafe(). + + RX Callback Signature: + The callback may accept either: + - (data: bytes) - backward compatible, single argument + - (data: bytes, rssi: int, snr: float) - per-packet signal metrics + + When using the 3-argument form, rssi and snr are the values for that + specific packet, avoiding race conditions with get_last_rssi/get_last_snr. """ def __init__( @@ -145,9 +159,11 @@ def __init__( port: Serial port device path (e.g., '/dev/ttyUSB0', '/dev/ttyACM0') baudrate: Serial communication baud rate (default: 115200) timeout: Serial read timeout in seconds (default: 1.0) - on_frame_received: Callback for received data packets + on_frame_received: Callback for received data packets. May be invoked + from a background thread unless set_event_loop() is used. radio_config: Optional radio configuration dict with keys: - frequency, bandwidth, spreading_factor, coding_rate, power + frequency, bandwidth, spreading_factor, coding_rate, + power (or tx_power) auto_configure: If True, automatically configure radio on connect """ self.port = port @@ -175,6 +191,9 @@ def __init__( # Callbacks self.on_frame_received = on_frame_received + # Event loop for thread-safe async callback invocation + self._event_loop: Optional[asyncio.AbstractEventLoop] = None + # Response handling self._response_event = threading.Event() self._pending_response: Optional[tuple[int, bytes]] = None @@ -203,6 +222,20 @@ def __init__( self.modem_version: Optional[int] = None self.modem_identity: Optional[bytes] = None + def set_event_loop(self, loop: asyncio.AbstractEventLoop) -> None: + """ + Set the event loop for thread-safe async callback invocation. + + When set, RX callbacks are scheduled onto the event loop via + call_soon_threadsafe() instead of being invoked directly from + the RX thread. This is required for proper async integration. + + Args: + loop: The asyncio event loop to use for callbacks + """ + self._event_loop = loop + logger.debug("Event loop set for thread-safe callbacks") + def connect(self) -> bool: """ Connect to serial port and start communication threads @@ -296,11 +329,12 @@ def configure_radio(self) -> bool: try: # Extract configuration parameters with defaults + # Support both "power" and "tx_power" for compatibility with different config styles frequency_hz = self.radio_config.get("frequency", int(869.618 * 1000000)) bandwidth_hz = self.radio_config.get("bandwidth", int(62500)) sf = self.radio_config.get("spreading_factor", 8) cr = self.radio_config.get("coding_rate", 8) - power = self.radio_config.get("power", 22) + power = self.radio_config.get("power", self.radio_config.get("tx_power", 22)) # Set radio parameters (frequency, bandwidth, SF, CR) # Format: Freq (4) + BW (4) + SF (1) + CR (1) - all little-endian @@ -824,6 +858,35 @@ def _decode_kiss_byte(self, byte: int): if self.in_frame: self.rx_frame_buffer.append(byte) + def _dispatch_rx_callback(self, data: bytes, rssi: int, snr: float) -> None: + """ + Dispatch RX callback, optionally via event loop for thread safety. + + If an event loop is set via set_event_loop(), the callback is scheduled + onto that loop using call_soon_threadsafe(). Otherwise, the callback + is invoked directly from the RX thread. + + Args: + data: Received packet data + rssi: RSSI in dBm + snr: SNR in dB + """ + if self.on_frame_received is None: + return + + if self._event_loop is not None: + # Schedule callback on event loop for thread-safe async + try: + self._event_loop.call_soon_threadsafe( + lambda: _invoke_rx_callback(self.on_frame_received, data, rssi, snr) + ) + except RuntimeError as e: + # Event loop may be closed + logger.warning(f"Failed to schedule RX callback on event loop: {e}") + else: + # Direct invocation from RX thread + _invoke_rx_callback(self.on_frame_received, data, rssi, snr) + def _process_received_frame(self): """Process a complete received KISS frame""" if len(self.rx_frame_buffer) < 1: @@ -860,9 +923,7 @@ def _process_received_frame(self): try: # Pass per-packet rssi/snr to avoid race with get_last_rssi/get_last_snr snr_db = snr_raw / 4.0 # SNR in 0.25 dB steps - _invoke_rx_callback( - self.on_frame_received, packet_data, rssi_raw, snr_db - ) + self._dispatch_rx_callback(packet_data, rssi_raw, snr_db) except Exception as e: logger.error(f"Error in frame received callback: {e}") diff --git a/tests/test_kiss_modem_wrapper.py b/tests/test_kiss_modem_wrapper.py index 89d82f1..0726c96 100644 --- a/tests/test_kiss_modem_wrapper.py +++ b/tests/test_kiss_modem_wrapper.py @@ -579,6 +579,128 @@ def mock_send_command(cmd, data=b"", timeout=5.0): assert modem.ping() is False +class TestEventLoop: + """Test event loop integration for thread-safe async""" + + def test_set_event_loop(self): + """Test setting event loop""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + loop = MagicMock() + + modem.set_event_loop(loop) + + assert modem._event_loop is loop + + def test_dispatch_uses_event_loop_when_set(self): + """Test that dispatch uses call_soon_threadsafe when loop is set""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + modem.is_connected = True + + loop = MagicMock() + modem.set_event_loop(loop) + + callback = MagicMock() + modem.on_frame_received = callback + + modem._dispatch_rx_callback(b"test", -80, 4.0) + + # Should have called call_soon_threadsafe + loop.call_soon_threadsafe.assert_called_once() + + def test_dispatch_direct_when_no_event_loop(self): + """Test that dispatch invokes callback directly when no loop set""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + modem.is_connected = True + + received = [] + + def callback(data, rssi, snr): + received.append((data, rssi, snr)) + + modem.on_frame_received = callback + + modem._dispatch_rx_callback(b"test", -80, 4.0) + + assert len(received) == 1 + assert received[0] == (b"test", -80, 4.0) + + +class TestRadioConfigCompatibility: + """Test radio config key compatibility""" + + def test_power_key(self): + """Test that 'power' key is used""" + modem = KissModemWrapper( + port="/dev/null", + auto_configure=False, + radio_config={"power": 15}, + ) + + sent_commands = [] + + def mock_send_command(cmd, data=b"", timeout=5.0): + sent_commands.append((cmd, data)) + return (RESP_OK, b"") + + modem._send_command = mock_send_command + modem.is_connected = True + + modem.configure_radio() + + # Find SET_TX_POWER command + tx_power_cmd = next((c for c in sent_commands if c[0] == CMD_SET_TX_POWER), None) + assert tx_power_cmd is not None + assert tx_power_cmd[1] == bytes([15]) + + def test_tx_power_key_fallback(self): + """Test that 'tx_power' key is used when 'power' is not present""" + modem = KissModemWrapper( + port="/dev/null", + auto_configure=False, + radio_config={"tx_power": 20}, + ) + + sent_commands = [] + + def mock_send_command(cmd, data=b"", timeout=5.0): + sent_commands.append((cmd, data)) + return (RESP_OK, b"") + + modem._send_command = mock_send_command + modem.is_connected = True + + modem.configure_radio() + + # Find SET_TX_POWER command + tx_power_cmd = next((c for c in sent_commands if c[0] == CMD_SET_TX_POWER), None) + assert tx_power_cmd is not None + assert tx_power_cmd[1] == bytes([20]) + + def test_power_takes_precedence_over_tx_power(self): + """Test that 'power' takes precedence over 'tx_power'""" + modem = KissModemWrapper( + port="/dev/null", + auto_configure=False, + radio_config={"power": 10, "tx_power": 20}, + ) + + sent_commands = [] + + def mock_send_command(cmd, data=b"", timeout=5.0): + sent_commands.append((cmd, data)) + return (RESP_OK, b"") + + modem._send_command = mock_send_command + modem.is_connected = True + + modem.configure_radio() + + # Find SET_TX_POWER command - should use 'power' value + tx_power_cmd = next((c for c in sent_commands if c[0] == CMD_SET_TX_POWER), None) + assert tx_power_cmd is not None + assert tx_power_cmd[1] == bytes([10]) + + class TestContextManager: """Test context manager functionality""" From 5ba450f715fbf84f61e8a83f9b15f5d64e139a95 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 3 Feb 2026 14:57:52 -0800 Subject: [PATCH 04/12] Update ModemIdentity to use Ed25519 public key for shared secret calculation - Changed method signature of calc_shared_secret to accept a 32-byte Ed25519 public key instead of X25519. - Updated documentation to clarify the internal conversion from Ed25519 to X25519 by the modem. - Added validation to ensure the provided public key is the correct length. - Enhanced comments for better understanding of the key exchange process. --- scripts/test_modem_crypto.py | 239 +++++++++++++++++++++++ src/pymc_core/protocol/modem_identity.py | 34 ++-- 2 files changed, 259 insertions(+), 14 deletions(-) create mode 100644 scripts/test_modem_crypto.py diff --git a/scripts/test_modem_crypto.py b/scripts/test_modem_crypto.py new file mode 100644 index 0000000..cb4bbf4 --- /dev/null +++ b/scripts/test_modem_crypto.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +Test script to verify modem cryptographic operations match Python implementation. + +Compares: +- Key exchange / shared secret computation +- Signing and verification +- Hashing +- Encryption/decryption +""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from pymc_core.hardware.kiss_modem_wrapper import KissModemWrapper +from pymc_core.protocol.identity import Identity, LocalIdentity +from pymc_core.protocol.crypto import CryptoUtils + + +def test_modem_crypto(port: str = "/dev/cu.usbmodem1101"): + """Run cryptographic comparison tests between modem and Python.""" + + print(f"Connecting to modem on {port}...") + modem = KissModemWrapper(port=port, auto_configure=False) + + if not modem.connect(): + print("ERROR: Failed to connect to modem") + return False + + print(f"Connected! Modem version: {modem.modem_version}") + print(f"Modem identity: {modem.modem_identity.hex()}") + print() + + all_passed = True + + # ========================================================================== + # Test 1: Hash comparison + # ========================================================================== + print("=" * 60) + print("Test 1: SHA-256 Hash") + print("=" * 60) + + test_data = b"Hello, MeshCore!" + + modem_hash = modem.hash_data(test_data) + python_hash = CryptoUtils.sha256(test_data) + + print(f"Test data: {test_data}") + print(f"Modem hash: {modem_hash.hex() if modem_hash else 'FAILED'}") + print(f"Python hash: {python_hash.hex()}") + + if modem_hash == python_hash: + print("PASS: Hashes match!") + else: + print("FAIL: Hashes do not match!") + all_passed = False + print() + + # ========================================================================== + # Test 2: Signature verification + # ========================================================================== + print("=" * 60) + print("Test 2: Sign and Verify") + print("=" * 60) + + message = b"Test message for signing" + + # Get modem to sign + modem_signature = modem.sign_data(message) + print(f"Message: {message}") + print(f"Modem signature: {modem_signature.hex() if modem_signature else 'FAILED'}") + + if modem_signature: + # Verify with modem + modem_verify = modem.verify_signature(modem.modem_identity, modem_signature, message) + print(f"Modem self-verify: {modem_verify}") + + # Verify with Python using modem's public key + modem_identity_obj = Identity(modem.modem_identity) + python_verify = modem_identity_obj.verify(message, modem_signature) + print(f"Python verify of modem signature: {python_verify}") + + if modem_verify and python_verify: + print("PASS: Signature verified by both!") + else: + print("FAIL: Signature verification mismatch!") + all_passed = False + else: + print("FAIL: Modem signing failed!") + all_passed = False + print() + + # ========================================================================== + # Test 3: Key Exchange / Shared Secret + # ========================================================================== + print("=" * 60) + print("Test 3: Key Exchange / Shared Secret") + print("=" * 60) + + # Create a local identity to exchange keys with + local_identity = LocalIdentity() + print(f"Local identity pubkey: {local_identity.get_public_key().hex()}") + print(f"Modem identity pubkey: {modem.modem_identity.hex()}") + + # Modem computes shared secret with local's Ed25519 pubkey + modem_shared = modem.key_exchange(local_identity.get_public_key()) + print(f"Modem shared secret: {modem_shared.hex() if modem_shared else 'FAILED'}") + + if modem_shared: + # Python computes shared secret: create Identity from modem's pubkey, + # then call calc_shared_secret with local's private key + modem_as_peer = Identity(modem.modem_identity) + python_shared = modem_as_peer.calc_shared_secret(local_identity.get_private_key()) + print(f"Python shared secret: {python_shared.hex()}") + + if modem_shared == python_shared: + print("PASS: Shared secrets match!") + else: + print("FAIL: Shared secrets do not match!") + all_passed = False + else: + print("FAIL: Modem key exchange failed!") + all_passed = False + print() + + # ========================================================================== + # Test 4: Encryption/Decryption round-trip + # ========================================================================== + print("=" * 60) + print("Test 4: Encryption/Decryption Round-trip") + print("=" * 60) + + if modem_shared: + plaintext = b"Secret message for encryption test!" + key = modem_shared # Use the shared secret as encryption key + + print(f"Plaintext: {plaintext}") + print(f"Key: {key.hex()[:32]}...") + + # Encrypt with modem + encrypt_result = modem.encrypt_data(key, plaintext) + if encrypt_result: + mac, ciphertext = encrypt_result + print(f"Modem encrypted - MAC: {mac.hex()}, Ciphertext: {ciphertext.hex()}") + + # Decrypt with modem + modem_decrypted = modem.decrypt_data(key, mac, ciphertext) + if modem_decrypted: + # Trim padding (modem pads to block size) + modem_decrypted = modem_decrypted[:len(plaintext)] + print(f"Modem decrypted: {modem_decrypted}") + + if modem_decrypted == plaintext: + print("PASS: Modem encrypt/decrypt round-trip works!") + else: + print("FAIL: Decrypted data doesn't match!") + all_passed = False + else: + print("FAIL: Modem decryption failed!") + all_passed = False + else: + print("FAIL: Modem encryption failed!") + all_passed = False + + # Now test Python encrypt -> Modem decrypt + print() + print("Cross-implementation test (Python encrypt -> Modem decrypt):") + + # Python encryption uses AES key (first 16 bytes) and shared secret for MAC + aes_key = key[:16] + python_encrypted = CryptoUtils.encrypt_then_mac(aes_key, key, plaintext) + python_mac = python_encrypted[:2] + python_ciphertext = python_encrypted[2:] + print(f"Python encrypted - MAC: {python_mac.hex()}, Ciphertext: {python_ciphertext.hex()}") + + # Decrypt with modem + modem_decrypted2 = modem.decrypt_data(key, python_mac, python_ciphertext) + if modem_decrypted2: + modem_decrypted2 = modem_decrypted2[:len(plaintext)] + print(f"Modem decrypted Python ciphertext: {modem_decrypted2}") + + if modem_decrypted2 == plaintext: + print("PASS: Cross-implementation encryption works!") + else: + print("FAIL: Cross-implementation decryption mismatch!") + all_passed = False + else: + print("FAIL: Modem failed to decrypt Python ciphertext!") + all_passed = False + print() + + # ========================================================================== + # Test 5: Random number generation + # ========================================================================== + print("=" * 60) + print("Test 5: Random Number Generation") + print("=" * 60) + + random1 = modem.get_random(32) + random2 = modem.get_random(32) + + print(f"Random 1: {random1.hex() if random1 else 'FAILED'}") + print(f"Random 2: {random2.hex() if random2 else 'FAILED'}") + + if random1 and random2: + if random1 != random2: + print("PASS: Random values are different (as expected)") + else: + print("FAIL: Random values are identical (suspicious!)") + all_passed = False + else: + print("FAIL: Random generation failed!") + all_passed = False + print() + + # ========================================================================== + # Summary + # ========================================================================== + print("=" * 60) + print("SUMMARY") + print("=" * 60) + + if all_passed: + print("ALL TESTS PASSED!") + else: + print("SOME TESTS FAILED!") + + modem.disconnect() + print("Modem disconnected.") + + return all_passed + + +if __name__ == "__main__": + port = sys.argv[1] if len(sys.argv) > 1 else "/dev/cu.usbmodem1101" + success = test_modem_crypto(port) + sys.exit(0 if success else 1) diff --git a/src/pymc_core/protocol/modem_identity.py b/src/pymc_core/protocol/modem_identity.py index 1a4a896..e8551ee 100644 --- a/src/pymc_core/protocol/modem_identity.py +++ b/src/pymc_core/protocol/modem_identity.py @@ -126,14 +126,27 @@ def verify(self, message: bytes, signature: bytes) -> bool: return False return result - def calc_shared_secret(self, remote_x25519_pubkey: bytes) -> bytes: + def calc_shared_secret(self, remote_ed25519_pubkey: bytes) -> bytes: """ - Compute the ECDH shared secret with a remote public key. + Compute the ECDH shared secret with a remote party's public key. Uses the modem's key_exchange command for secure computation. + The modem internally converts the Ed25519 public key to X25519 + and performs the ECDH computation. + + Note: This method signature differs from Identity.calc_shared_secret() + which takes a local private key. ModemIdentity.calc_shared_secret() + takes the remote's Ed25519 public key because the modem holds the + local private key internally. + + For use in pyMC_core handlers, which call calc_shared_secret on the + *peer's* Identity object (not on LocalIdentity/ModemIdentity), this + method is provided for cases where you want to compute a shared + secret directly using the modem's identity. Args: - remote_x25519_pubkey: The remote party's X25519 public key. + remote_ed25519_pubkey: The remote party's 32-byte Ed25519 public key. + The modem converts this to X25519 internally. Returns: The 32-byte shared secret for encryption. @@ -141,17 +154,10 @@ def calc_shared_secret(self, remote_x25519_pubkey: bytes) -> bytes: Raises: RuntimeError: If key exchange fails """ - # The modem expects an Ed25519 public key for key exchange, - # but we have an X25519 key. We need to use the modem's - # key_exchange which internally handles the conversion. - # - # However, the MeshCore protocol typically passes Ed25519 pubkeys - # and converts internally. If we're given an X25519 key directly, - # we may need to handle this differently. - # - # For now, assume we're given the remote's Ed25519 pubkey and - # the modem will handle the X25519 conversion internally. - shared_secret = self._modem.key_exchange(remote_x25519_pubkey) + if len(remote_ed25519_pubkey) != 32: + raise ValueError("Remote public key must be 32 bytes (Ed25519)") + + shared_secret = self._modem.key_exchange(remote_ed25519_pubkey) if shared_secret is None: raise RuntimeError("Modem key exchange failed") return shared_secret From 3e4c6a11bbddceb4a6e322dbc1f2cc8b571d5a88 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 3 Feb 2026 16:56:26 -0800 Subject: [PATCH 05/12] Update KISS Modem command constants to align with firmware specifications - Adjusted command constants for KISS Modem to reflect the correct values as per firmware documentation. - Renumbered CMD and RESP constants to ensure consistency and avoid conflicts with firmware commands. - Enhanced comments for clarity regarding the changes made to the command structure. --- src/pymc_core/hardware/kiss_modem_wrapper.py | 34 ++++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py index 33ab2f4..51729e1 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -50,7 +50,7 @@ def _invoke_rx_callback( KISS_TFESC = 0xDD # Transposed Frame Escape # MeshCore KISS Modem Request Commands (Host -> Modem) -# Based on actual KissModem.cpp implementation +# Aligned with firmware: 0x0B/0x0E not in firmware CMD_DATA = 0x00 CMD_GET_IDENTITY = 0x01 CMD_GET_RANDOM = 0x02 @@ -62,17 +62,17 @@ def _invoke_rx_callback( CMD_HASH = 0x08 CMD_SET_RADIO = 0x09 CMD_SET_TX_POWER = 0x0A -CMD_GET_RADIO = 0x0B -CMD_GET_TX_POWER = 0x0C -CMD_GET_VERSION = 0x0D -CMD_GET_CURRENT_RSSI = 0x0E -CMD_IS_CHANNEL_BUSY = 0x0F -CMD_GET_AIRTIME = 0x10 -CMD_GET_NOISE_FLOOR = 0x11 -CMD_GET_STATS = 0x12 -CMD_GET_BATTERY = 0x13 -CMD_PING = 0x14 -CMD_GET_SENSORS = 0x15 +CMD_GET_RADIO = 0x0C +CMD_GET_TX_POWER = 0x0D +CMD_GET_VERSION = 0x0F +CMD_GET_CURRENT_RSSI = 0x10 +CMD_IS_CHANNEL_BUSY = 0x11 +CMD_GET_AIRTIME = 0x12 +CMD_GET_NOISE_FLOOR = 0x13 +CMD_GET_STATS = 0x14 +CMD_GET_BATTERY = 0x15 +CMD_PING = 0x16 +CMD_GET_SENSORS = 0x17 # MeshCore KISS Modem Response Commands (Modem -> Host) RESP_IDENTITY = 0x21 @@ -92,11 +92,11 @@ def _invoke_rx_callback( RESP_CURRENT_RSSI = 0x2F RESP_CHANNEL_BUSY = 0x30 RESP_AIRTIME = 0x31 -RESP_NOISE_FLOOR = 0x32 -RESP_STATS = 0x33 -RESP_BATTERY = 0x34 -RESP_PONG = 0x35 -RESP_SENSORS = 0x36 +RESP_NOISE_FLOOR = 0x33 +RESP_STATS = 0x34 +RESP_BATTERY = 0x35 +RESP_PONG = 0x36 +RESP_SENSORS = 0x37 # Error Codes ERR_INVALID_LENGTH = 0x01 From 91b97e6e18fb44bdd758d3157a52c49d405a074c Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 5 Feb 2026 21:08:32 -0800 Subject: [PATCH 06/12] Add Listen-Before-Talk (LBT) functionality to KISS modem wrapper - Implemented LBT mechanism to check channel availability before transmission, aligning with MeshCore firmware specifications. - Introduced LBT retry delays and maximum wait time to manage channel busy states. - Updated send method to return detailed transmission metadata, including LBT metrics. - Refactored timestamp retrieval in Dispatcher to use asyncio.get_running_loop for better compatibility. --- src/pymc_core/hardware/kiss_modem_wrapper.py | 67 ++++++++++++++++++-- src/pymc_core/node/dispatcher.py | 4 +- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py index 51729e1..819e52b 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -10,6 +10,7 @@ import asyncio import inspect import logging +import random import struct import threading from collections import deque @@ -723,28 +724,84 @@ def begin(self): if not success: raise Exception("Failed to initialize KISS modem") + # LBT parameters aligned with MeshCore firmware (CAD fail retry / max duration) + LBT_RETRY_DELAYS_MS = (120, 240, 360) # random(1, 4) * 120 + LBT_MAX_WAIT_MS = 4000 + + async def _prepare_for_tx_lbt(self) -> tuple[bool, list[float]]: + """ + Listen-Before-Talk: query modem channel busy until clear or max wait. + Modeled on MeshCore firmware: retry delay one of 120, 240, 360 ms; give up + after 4 s total wait and transmit anyway. Returns (success, lbt_backoff_delays_ms). + """ + lbt_backoff_delays: list[float] = [] + total_wait_ms = 0.0 + + while total_wait_ms < self.LBT_MAX_WAIT_MS: + try: + channel_busy = await asyncio.to_thread(self.is_channel_busy) + if not channel_busy: + logger.debug( + "Channel busy check clear - channel available after " + f"{len(lbt_backoff_delays) + 1} check(s)" + ) + break + + logger.debug("Channel busy check still busy - activity detected") + remaining_ms = self.LBT_MAX_WAIT_MS - total_wait_ms + retry_delay_ms = random.choice(self.LBT_RETRY_DELAYS_MS) + backoff_ms = min(retry_delay_ms, remaining_ms) + lbt_backoff_delays.append(float(backoff_ms)) + total_wait_ms += backoff_ms + + logger.debug( + f"LBT backoff - waiting {backoff_ms}ms before retry " + f"(total wait {total_wait_ms:.0f}ms / {self.LBT_MAX_WAIT_MS}ms)" + ) + await asyncio.sleep(backoff_ms / 1000.0) + + if total_wait_ms >= self.LBT_MAX_WAIT_MS: + logger.warning( + f"LBT max duration reached ({self.LBT_MAX_WAIT_MS}ms) - " + "channel still busy, transmitting anyway" + ) + except Exception as e: + logger.warning(f"Channel busy check failed: {e}, proceeding with transmission") + break + + return True, lbt_backoff_delays + async def send(self, data: bytes) -> Optional[Dict[str, Any]]: """ Send data via KISS modem (LoRaRadio interface) + Runs Listen-Before-Talk (query channel busy until clear), then sends + the frame. Returns metadata including LBT metrics for parity with + SX1262 wrapper. + Args: data: Data to send Returns: - Transmission metadata dict or None + Transmission metadata dict (airtime_ms, lbt_attempts, + lbt_backoff_delays_ms, lbt_channel_busy) Raises: Exception: If send fails """ + _, lbt_backoff_delays = await self._prepare_for_tx_lbt() + success = self.send_frame(data) if not success: raise Exception("Failed to send frame via KISS modem") - # Return metadata if available airtime = self.get_airtime(len(data)) - if airtime: - return {"airtime_ms": airtime} - return None + return { + "airtime_ms": airtime if airtime is not None else 0, + "lbt_attempts": len(lbt_backoff_delays), + "lbt_backoff_delays_ms": lbt_backoff_delays, + "lbt_channel_busy": len(lbt_backoff_delays) > 0, + } async def wait_for_rx(self) -> bytes: """ diff --git a/src/pymc_core/node/dispatcher.py b/src/pymc_core/node/dispatcher.py index a6e31c0..a0f7d45 100644 --- a/src/pymc_core/node/dispatcher.py +++ b/src/pymc_core/node/dispatcher.py @@ -536,7 +536,7 @@ async def _dispatch(self, pkt: Packet) -> None: def _register_ack_received(self, crc: int) -> None: """Record that an ACK with the given CRC was received.""" - ts = asyncio.get_event_loop().time() + ts = asyncio.get_running_loop().time() self._recent_acks[crc] = ts # Notify waiting sender if this CRC matches @@ -549,7 +549,7 @@ async def run_forever(self) -> None: health_check_counter = 0 while True: # Clean out old ACK CRCs (older than 5 seconds) - now = asyncio.get_event_loop().time() + now = asyncio.get_running_loop().time() self._recent_acks = {crc: ts for crc, ts in self._recent_acks.items() if now - ts < 5} # Clean old packet hashes for deduplication From 3704a80f1a7d993bebdfccdc10d73a4f89d17281 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 5 Feb 2026 21:44:38 -0800 Subject: [PATCH 07/12] Added missing methods to KISS modem wrapper - Added a cleanup method to release modem resources by disconnecting the serial and stopping threads. - Added a check_radio_health method to verify modem connectivity and respond to pings, enhancing reliability. - Added a get_status method to retrieve comprehensive radio status, including configuration and statistics. --- src/pymc_core/hardware/kiss_modem_wrapper.py | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py index 819e52b..5ee9857 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -299,6 +299,10 @@ def disconnect(self): logger.info(f"KISS modem disconnected from {self.port}") + def cleanup(self) -> None: + """Release modem resources (disconnect serial and stop threads).""" + self.disconnect() + def _query_modem_info(self): """Query modem version and identity""" try: @@ -724,6 +728,16 @@ def begin(self): if not success: raise Exception("Failed to initialize KISS modem") + def check_radio_health(self) -> bool: + """Check modem connectivity. Returns True if connected and modem responds to ping.""" + if not self.is_connected: + return False + try: + return self.ping() + except Exception as e: + logger.debug(f"KISS modem health check failed: {e}") + return False + # LBT parameters aligned with MeshCore firmware (CAD fail retry / max duration) LBT_RETRY_DELAYS_MS = (120, 240, 360) # random(1, 4) * 120 LBT_MAX_WAIT_MS = 4000 @@ -850,6 +864,24 @@ def get_stats(self) -> Dict[str, Any]: """Get interface statistics""" return self.stats.copy() + def get_status(self) -> Dict[str, Any]: + """Get radio status. Uses cached config/stats where possible.""" + cfg = self.get_radio_config() + tx_power = self.get_tx_power() + status: Dict[str, Any] = { + "initialized": self.is_connected, + "frequency": cfg["frequency"] if cfg else self.radio_config.get("frequency", 0), + "tx_power": tx_power if tx_power is not None else self.radio_config.get("tx_power", self.radio_config.get("power", 0)), + "spreading_factor": cfg["spreading_factor"] if cfg else self.radio_config.get("spreading_factor", 0), + "bandwidth": cfg["bandwidth"] if cfg else self.radio_config.get("bandwidth", 0), + "coding_rate": cfg["coding_rate"] if cfg else self.radio_config.get("coding_rate", 0), + "last_rssi": self.stats.get("last_rssi", -999), + "last_snr": self.stats.get("last_snr", -999.0), + "last_signal_rssi": self.stats.get("last_rssi", -999), + "hardware_ready": self.is_connected, + } + return status + # KISS frame encoding/decoding def _encode_kiss_frame(self, cmd: int, data: bytes) -> bytes: From f62e4c9379e11804bf5ac577224bce797b883503 Mon Sep 17 00:00:00 2001 From: Adam Gessaman Date: Fri, 6 Feb 2026 10:42:13 -0800 Subject: [PATCH 08/12] Refactor KISS Modem command structure for spec compliance - Updated command and response constants to align with the new SetHardware protocol. - Introduced new sub-commands and responses for hardware interactions. - Enhanced error handling and validation for frame sizes. - Improved test cases to cover new command structures and ensure correct frame decoding. --- src/pymc_core/hardware/kiss_modem_wrapper.py | 370 +++++++++++++------ tests/test_kiss_modem_wrapper.py | 204 +++++++--- 2 files changed, 406 insertions(+), 168 deletions(-) diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py index 51729e1..fa04a54 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -49,66 +49,130 @@ def _invoke_rx_callback( KISS_TFEND = 0xDC # Transposed Frame End KISS_TFESC = 0xDD # Transposed Frame Escape -# MeshCore KISS Modem Request Commands (Host -> Modem) -# Aligned with firmware: 0x0B/0x0E not in firmware -CMD_DATA = 0x00 -CMD_GET_IDENTITY = 0x01 -CMD_GET_RANDOM = 0x02 -CMD_VERIFY_SIGNATURE = 0x03 -CMD_SIGN_DATA = 0x04 -CMD_ENCRYPT_DATA = 0x05 -CMD_DECRYPT_DATA = 0x06 -CMD_KEY_EXCHANGE = 0x07 -CMD_HASH = 0x08 -CMD_SET_RADIO = 0x09 -CMD_SET_TX_POWER = 0x0A -CMD_GET_RADIO = 0x0C -CMD_GET_TX_POWER = 0x0D -CMD_GET_VERSION = 0x0F -CMD_GET_CURRENT_RSSI = 0x10 -CMD_IS_CHANNEL_BUSY = 0x11 -CMD_GET_AIRTIME = 0x12 -CMD_GET_NOISE_FLOOR = 0x13 -CMD_GET_STATS = 0x14 -CMD_GET_BATTERY = 0x15 -CMD_PING = 0x16 -CMD_GET_SENSORS = 0x17 - -# MeshCore KISS Modem Response Commands (Modem -> Host) -RESP_IDENTITY = 0x21 -RESP_RANDOM = 0x22 -RESP_VERIFY = 0x23 -RESP_SIGNATURE = 0x24 -RESP_ENCRYPTED = 0x25 -RESP_DECRYPTED = 0x26 -RESP_SHARED_SECRET = 0x27 -RESP_HASH = 0x28 -RESP_OK = 0x29 -RESP_RADIO = 0x2A -RESP_TX_POWER = 0x2B -RESP_VERSION = 0x2C -RESP_ERROR = 0x2D -RESP_TX_DONE = 0x2E -RESP_CURRENT_RSSI = 0x2F -RESP_CHANNEL_BUSY = 0x30 -RESP_AIRTIME = 0x31 -RESP_NOISE_FLOOR = 0x33 -RESP_STATS = 0x34 -RESP_BATTERY = 0x35 -RESP_PONG = 0x36 -RESP_SENSORS = 0x37 - -# Error Codes -ERR_INVALID_LENGTH = 0x01 -ERR_INVALID_PARAM = 0x02 -ERR_NO_CALLBACK = 0x03 -ERR_MAC_FAILED = 0x04 -ERR_UNKNOWN_CMD = 0x05 -ERR_ENCRYPT_FAILED = 0x06 -ERR_TX_PENDING = 0x07 +# Standard KISS type bytes (port in bits 7-4, command in bits 3-0) +CMD_DATA = 0x00 # Data frame (raw packet) +KISS_CMD_SETHARDWARE = 0x06 # SetHardware: first payload byte is sub-command +KISS_CMD_RETURN = 0xFF # Exit KISS mode (no-op) + +# SetHardware request sub-commands (Host -> TNC, first data byte inside 0x06) +HW_CMD_GET_IDENTITY = 0x01 +HW_CMD_GET_RANDOM = 0x02 +HW_CMD_VERIFY_SIGNATURE = 0x03 +HW_CMD_SIGN_DATA = 0x04 +HW_CMD_ENCRYPT_DATA = 0x05 +HW_CMD_DECRYPT_DATA = 0x06 +HW_CMD_KEY_EXCHANGE = 0x07 +HW_CMD_HASH = 0x08 +HW_CMD_SET_RADIO = 0x09 +HW_CMD_SET_TX_POWER = 0x0A +HW_CMD_GET_RADIO = 0x0B +HW_CMD_GET_TX_POWER = 0x0C +HW_CMD_GET_CURRENT_RSSI = 0x0D +HW_CMD_IS_CHANNEL_BUSY = 0x0E +HW_CMD_GET_AIRTIME = 0x0F +HW_CMD_GET_NOISE_FLOOR = 0x10 +HW_CMD_GET_VERSION = 0x11 +HW_CMD_GET_STATS = 0x12 +HW_CMD_GET_BATTERY = 0x13 +HW_CMD_GET_MCU_TEMP = 0x14 +HW_CMD_GET_SENSORS = 0x15 +HW_CMD_GET_DEVICE_NAME = 0x16 +HW_CMD_PING = 0x17 +HW_CMD_REBOOT = 0x18 + +# SetHardware response sub-commands (TNC -> Host) +HW_RESP_IDENTITY = 0x21 +HW_RESP_RANDOM = 0x22 +HW_RESP_VERIFY = 0x23 +HW_RESP_SIGNATURE = 0x24 +HW_RESP_ENCRYPTED = 0x25 +HW_RESP_DECRYPTED = 0x26 +HW_RESP_SHARED_SECRET = 0x27 +HW_RESP_HASH = 0x28 +HW_RESP_OK = 0x29 +HW_RESP_ERROR = 0x2A +HW_RESP_RADIO = 0x2B +HW_RESP_TX_POWER = 0x2C +HW_RESP_CURRENT_RSSI = 0x2D +HW_RESP_CHANNEL_BUSY = 0x2E +HW_RESP_AIRTIME = 0x2F +HW_RESP_NOISE_FLOOR = 0x30 +HW_RESP_VERSION = 0x31 +HW_RESP_STATS = 0x32 +HW_RESP_BATTERY = 0x33 +HW_RESP_MCU_TEMP = 0x34 +HW_RESP_SENSORS = 0x35 +HW_RESP_DEVICE_NAME = 0x36 +HW_RESP_PONG = 0x37 +HW_RESP_TX_DONE = 0x38 +HW_RESP_RX_META = 0x39 + +# Backward-compatible aliases (same values as HW_*) +CMD_GET_IDENTITY = HW_CMD_GET_IDENTITY +CMD_GET_RANDOM = HW_CMD_GET_RANDOM +CMD_VERIFY_SIGNATURE = HW_CMD_VERIFY_SIGNATURE +CMD_SIGN_DATA = HW_CMD_SIGN_DATA +CMD_ENCRYPT_DATA = HW_CMD_ENCRYPT_DATA +CMD_DECRYPT_DATA = HW_CMD_DECRYPT_DATA +CMD_KEY_EXCHANGE = HW_CMD_KEY_EXCHANGE +CMD_HASH = HW_CMD_HASH +CMD_SET_RADIO = HW_CMD_SET_RADIO +CMD_SET_TX_POWER = HW_CMD_SET_TX_POWER +CMD_GET_RADIO = HW_CMD_GET_RADIO +CMD_GET_TX_POWER = HW_CMD_GET_TX_POWER +CMD_GET_CURRENT_RSSI = HW_CMD_GET_CURRENT_RSSI +CMD_IS_CHANNEL_BUSY = HW_CMD_IS_CHANNEL_BUSY +CMD_GET_AIRTIME = HW_CMD_GET_AIRTIME +CMD_GET_NOISE_FLOOR = HW_CMD_GET_NOISE_FLOOR +CMD_GET_VERSION = HW_CMD_GET_VERSION +CMD_GET_STATS = HW_CMD_GET_STATS +CMD_GET_BATTERY = HW_CMD_GET_BATTERY +CMD_GET_SENSORS = HW_CMD_GET_SENSORS +CMD_PING = HW_CMD_PING + +RESP_IDENTITY = HW_RESP_IDENTITY +RESP_RANDOM = HW_RESP_RANDOM +RESP_VERIFY = HW_RESP_VERIFY +RESP_SIGNATURE = HW_RESP_SIGNATURE +RESP_ENCRYPTED = HW_RESP_ENCRYPTED +RESP_DECRYPTED = HW_RESP_DECRYPTED +RESP_SHARED_SECRET = HW_RESP_SHARED_SECRET +RESP_HASH = HW_RESP_HASH +RESP_OK = HW_RESP_OK +RESP_RADIO = HW_RESP_RADIO +RESP_TX_POWER = HW_RESP_TX_POWER +RESP_VERSION = HW_RESP_VERSION +RESP_ERROR = HW_RESP_ERROR +RESP_TX_DONE = HW_RESP_TX_DONE +RESP_CURRENT_RSSI = HW_RESP_CURRENT_RSSI +RESP_CHANNEL_BUSY = HW_RESP_CHANNEL_BUSY +RESP_AIRTIME = HW_RESP_AIRTIME +RESP_NOISE_FLOOR = HW_RESP_NOISE_FLOOR +RESP_STATS = HW_RESP_STATS +RESP_BATTERY = HW_RESP_BATTERY +RESP_PONG = HW_RESP_PONG +RESP_SENSORS = HW_RESP_SENSORS + +# Error codes (SetHardware Error response payload) +HW_ERR_INVALID_LENGTH = 0x01 +HW_ERR_INVALID_PARAM = 0x02 +HW_ERR_NO_CALLBACK = 0x03 +HW_ERR_MAC_FAILED = 0x04 +HW_ERR_UNKNOWN_CMD = 0x05 +HW_ERR_ENCRYPT_FAILED = 0x06 + +ERR_INVALID_LENGTH = HW_ERR_INVALID_LENGTH +ERR_INVALID_PARAM = HW_ERR_INVALID_PARAM +ERR_NO_CALLBACK = HW_ERR_NO_CALLBACK +ERR_MAC_FAILED = HW_ERR_MAC_FAILED +ERR_UNKNOWN_CMD = HW_ERR_UNKNOWN_CMD +ERR_ENCRYPT_FAILED = HW_ERR_ENCRYPT_FAILED # Buffer and timing constants MAX_FRAME_SIZE = 512 +# Data payload ≤255 bytes (MeshCore MAX_TRANS_UNIT); queue bounds unpaired Data frames +KISS_MAX_PACKET_SIZE = 255 +MAX_PENDING_RX_FRAMES = 64 # max Data frames queued awaiting RxMeta; each payload ≤255 bytes RX_BUFFER_SIZE = 1024 TX_BUFFER_SIZE = 1024 DEFAULT_BAUDRATE = 115200 @@ -203,6 +267,9 @@ def __init__( self._tx_done_event = threading.Event() self._tx_done_result: Optional[bool] = None + # Pending RX data payloads (Data frame) waiting for RxMeta frame + self._pending_rx_queue: deque = deque() + self.stats = { "frames_sent": 0, "frames_received": 0, @@ -377,8 +444,10 @@ def send_frame(self, data: bytes) -> bool: logger.warning("Cannot send frame: not connected") return False - if len(data) < 2 or len(data) > 255: - logger.warning(f"Invalid frame size: {len(data)} (must be 2-255 bytes)") + if len(data) < 2 or len(data) > KISS_MAX_PACKET_SIZE: + logger.warning( + f"Invalid frame size: {len(data)} (must be 2-{KISS_MAX_PACKET_SIZE} bytes)" + ) return False try: @@ -423,25 +492,29 @@ def send_frame_and_wait(self, data: bytes, timeout: float = RESPONSE_TIMEOUT) -> return False def _send_command( - self, cmd: int, data: bytes = b"", timeout: float = RESPONSE_TIMEOUT + self, sub_cmd: int, data: bytes = b"", timeout: float = RESPONSE_TIMEOUT ) -> Optional[tuple[int, bytes]]: """ - Send a command and wait for response + Send a SetHardware command and wait for response. + + Encodes as KISS frame: FEND + 0x06 (SetHardware) + sub_cmd + data + FEND. Args: - cmd: Command byte - data: Command data + sub_cmd: SetHardware sub-command byte (e.g. HW_CMD_GET_IDENTITY) + data: Sub-command payload timeout: Response timeout in seconds Returns: - Tuple of (response_cmd, response_data) or None on timeout + Tuple of (response_sub_cmd, response_data) or None on timeout """ with self._response_lock: self._response_event.clear() self._pending_response = None - # Create and send KISS frame - kiss_frame = self._encode_kiss_frame(cmd, data) + # SetHardware frame: type 0x06, payload = sub_cmd (1 byte) + data + kiss_frame = self._encode_kiss_frame( + KISS_CMD_SETHARDWARE, bytes([sub_cmd]) + data + ) if self.serial_conn and self.serial_conn.is_open: self.serial_conn.write(kiss_frame) @@ -452,7 +525,7 @@ def _send_command( with self._response_lock: return self._pending_response else: - logger.warning(f"Command 0x{cmd:02X} timeout") + logger.warning(f"SetHardware sub_cmd 0x{sub_cmd:02X} timeout") return None def get_radio_config(self) -> Optional[Dict[str, Any]]: @@ -564,6 +637,44 @@ def get_sensors(self, permissions: int = 0x07) -> Optional[bytes]: return resp[1] return None + def get_mcu_temp(self) -> Optional[float]: + """ + Get MCU temperature in degrees Celsius. + + Returns: + Temperature in °C, or None if unsupported or error. + """ + resp = self._send_command(HW_CMD_GET_MCU_TEMP) + if resp and resp[0] == HW_RESP_MCU_TEMP and len(resp[1]) >= 2: + temp_tenths = struct.unpack("= 1: + if resp[1][0] == HW_ERR_NO_CALLBACK: + return None + return None + + def get_device_name(self) -> Optional[str]: + """ + Get device/manufacturer name (UTF-8 string). + + Returns: + Device name string or None on error. + """ + resp = self._send_command(HW_CMD_GET_DEVICE_NAME) + if resp and resp[0] == HW_RESP_DEVICE_NAME: + try: + return resp[1].decode("utf-8") + except UnicodeDecodeError: + return None + return None + + def reboot(self) -> None: + """ + Request modem reboot. Sends Reboot (0x18), expects OK then connection drop. + Does not wait for disconnect. + """ + self._send_command(HW_CMD_REBOOT, timeout=1.0) + # Cryptographic operations using modem's identity def get_identity(self) -> Optional[bytes]: @@ -888,68 +999,85 @@ def _dispatch_rx_callback(self, data: bytes, rssi: int, snr: float) -> None: _invoke_rx_callback(self.on_frame_received, data, rssi, snr) def _process_received_frame(self): - """Process a complete received KISS frame""" + """Process a complete received KISS frame (spec: type byte = port | cmd).""" if len(self.rx_frame_buffer) < 1: return - # Extract command byte - cmd = self.rx_frame_buffer[0] - data = bytes(self.rx_frame_buffer[1:]) + type_byte = self.rx_frame_buffer[0] + port = (type_byte >> 4) & 0x0F + cmd = type_byte & 0x0F + + # Only process port 0 (single-port TNC) + if port != 0: + return self.stats["frames_received"] += 1 - self.stats["bytes_received"] += len(data) + self.stats["bytes_received"] += len(self.rx_frame_buffer) - 1 if cmd == CMD_DATA: - # Data packet received - extract RSSI/SNR and payload - if len(data) >= 2: - # First byte is SNR * 4 (signed), second byte is RSSI (signed) - snr_raw = data[0] - rssi_raw = data[1] - - # Convert to signed values - if snr_raw > 127: - snr_raw -= 256 - if rssi_raw > 127: - rssi_raw -= 256 - - self.stats["last_snr"] = snr_raw / 4.0 # SNR in 0.25 dB steps - self.stats["last_rssi"] = rssi_raw - self.stats["rx_packets"] += 1 - - # Extract packet payload (skip SNR and RSSI bytes) - packet_data = data[2:] - - if self.on_frame_received and len(packet_data) > 0: - try: - # Pass per-packet rssi/snr to avoid race with get_last_rssi/get_last_snr - snr_db = snr_raw / 4.0 # SNR in 0.25 dB steps - self._dispatch_rx_callback(packet_data, rssi_raw, snr_db) - except Exception as e: - logger.error(f"Error in frame received callback: {e}") - - elif cmd == RESP_TX_DONE: - # TX completion response - if len(data) >= 1: - self._tx_done_result = data[0] == 0x01 - self.stats["tx_packets"] += 1 - self._tx_done_event.set() - - elif cmd == RESP_ERROR: - # Error response - if len(data) >= 1: - error_code = data[0] - self.stats["errors"] += 1 - logger.warning(f"Modem error: 0x{error_code:02X}") - # Signal response for command waiting - with self._response_lock: - self._pending_response = (cmd, data) - self._response_event.set() + # Data frame: raw packet only (≤255 bytes per spec); queue until RxMeta arrives + payload = bytes(self.rx_frame_buffer[1:]) + if len(self._pending_rx_queue) >= MAX_PENDING_RX_FRAMES: + self.stats["frame_errors"] += 1 + logger.warning( + "Pending RX queue full (max %d), dropping Data frame", + MAX_PENDING_RX_FRAMES, + ) + else: + self._pending_rx_queue.append(payload) + + elif cmd == KISS_CMD_SETHARDWARE: + # SetHardware: first byte is sub_cmd, rest is payload + if len(self.rx_frame_buffer) < 2: + return + sub_cmd = self.rx_frame_buffer[1] + payload = bytes(self.rx_frame_buffer[2:]) + + if sub_cmd == HW_RESP_RX_META: + # RxMeta follows a Data frame: SNR (1), RSSI (1); deliver queued data + rssi_raw = -999 + snr_db = -999.0 + if len(payload) >= 2: + snr_raw = payload[0] + rssi_raw = payload[1] + if snr_raw > 127: + snr_raw -= 256 + if rssi_raw > 127: + rssi_raw -= 256 + snr_db = snr_raw / 4.0 # 0.25 dB steps + self.stats["last_snr"] = snr_db + self.stats["last_rssi"] = rssi_raw + self.stats["rx_packets"] += 1 + if self._pending_rx_queue: + packet_data = self._pending_rx_queue.popleft() + if self.on_frame_received: + try: + self._dispatch_rx_callback(packet_data, rssi_raw, snr_db) + except Exception as e: + logger.error(f"Error in frame received callback: {e}") + else: + logger.warning("RxMeta received with no pending Data frame") + + elif sub_cmd == HW_RESP_TX_DONE: + if len(payload) >= 1: + self._tx_done_result = payload[0] == 0x01 + self.stats["tx_packets"] += 1 + self._tx_done_event.set() + + elif sub_cmd == HW_RESP_ERROR: + if len(payload) >= 1: + self.stats["errors"] += 1 + logger.warning(f"Modem error: 0x{payload[0]:02X}") + with self._response_lock: + self._pending_response = (sub_cmd, payload) + self._response_event.set() - else: - # Response to a command - with self._response_lock: - self._pending_response = (cmd, data) - self._response_event.set() + else: + # Other response sub-commands (Identity, Radio, OK, etc.) + with self._response_lock: + self._pending_response = (sub_cmd, payload) + self._response_event.set() + # cmd 0xFF (Return) has port=15 so is already discarded above def _rx_worker(self): """Background thread for receiving data""" diff --git a/tests/test_kiss_modem_wrapper.py b/tests/test_kiss_modem_wrapper.py index 0726c96..75f2a68 100644 --- a/tests/test_kiss_modem_wrapper.py +++ b/tests/test_kiss_modem_wrapper.py @@ -30,6 +30,14 @@ CMD_SET_TX_POWER, CMD_SIGN_DATA, CMD_VERIFY_SIGNATURE, + HW_CMD_GET_DEVICE_NAME, + HW_CMD_GET_MCU_TEMP, + HW_CMD_GET_VERSION, + HW_CMD_REBOOT, + HW_RESP_DEVICE_NAME, + HW_RESP_MCU_TEMP, + HW_RESP_OK, + KISS_CMD_SETHARDWARE, KISS_FEND, KISS_FESC, KISS_TFEND, @@ -115,67 +123,67 @@ def test_encode_frame_with_multiple_escapes(self): assert frame == expected def test_decode_simple_frame(self): - """Test decoding a simple frame""" + """Test decoding Data frame then RxMeta (spec: data and metadata separate)""" modem = KissModemWrapper(port="/dev/null", auto_configure=False) modem.is_connected = True received_frames = [] modem.on_frame_received = lambda data: received_frames.append(data) - # Simulate receiving: FEND + CMD_DATA + SNR + RSSI + payload + FEND - raw_bytes = bytes([KISS_FEND, CMD_DATA, 0x10, 0xB0, 0x01, 0x02, 0x03, KISS_FEND]) + # Data frame: FEND + 0x00 + raw_packet + FEND (no in-frame metadata) + data_frame = bytes([KISS_FEND, CMD_DATA, 0x01, 0x02, 0x03, KISS_FEND]) + # RxMeta: FEND + 0x06 + 0x39 + SNR + RSSI + FEND (sent immediately after Data) + rx_meta_frame = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, 0x39, 0x10, 0xB0, KISS_FEND]) - for byte in raw_bytes: + for byte in data_frame: + modem._decode_kiss_byte(byte) + for byte in rx_meta_frame: modem._decode_kiss_byte(byte) assert len(received_frames) == 1 - assert received_frames[0] == b"\x01\x02\x03" # payload without SNR/RSSI + assert received_frames[0] == b"\x01\x02\x03" def test_decode_frame_with_escapes(self): - """Test decoding a frame with escaped characters""" + """Test decoding Data frame with escaped FEND, then RxMeta""" modem = KissModemWrapper(port="/dev/null", auto_configure=False) modem.is_connected = True received_frames = [] modem.on_frame_received = lambda data: received_frames.append(data) - # Frame with escaped FEND (0xC0) in payload - # SNR=0x10, RSSI=0xB0, payload contains 0xC0 escaped - raw_bytes = bytes( - [ - KISS_FEND, - CMD_DATA, - 0x10, - 0xB0, # SNR, RSSI - KISS_FESC, - KISS_TFEND, # escaped 0xC0 - KISS_FEND, - ] + # Data frame: payload is escaped 0xC0 (FESC + TFEND) + data_frame = bytes( + [KISS_FEND, CMD_DATA, KISS_FESC, KISS_TFEND, KISS_FEND] ) + rx_meta_frame = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, 0x39, 0x10, 0xB0, KISS_FEND]) - for byte in raw_bytes: + for byte in data_frame: + modem._decode_kiss_byte(byte) + for byte in rx_meta_frame: modem._decode_kiss_byte(byte) assert len(received_frames) == 1 assert received_frames[0] == bytes([0xC0]) def test_decode_extracts_rssi_snr(self): - """Test that RSSI and SNR are extracted from received frames""" + """Test that RSSI and SNR are extracted from RxMeta frame""" modem = KissModemWrapper(port="/dev/null", auto_configure=False) modem.is_connected = True - # SNR = 0x10 (4.0 dB when divided by 4) - # RSSI = 0xB0 (-80 dBm as signed byte) - raw_bytes = bytes([KISS_FEND, CMD_DATA, 0x10, 0xB0, 0xAA, 0xBB, KISS_FEND]) + data_frame = bytes([KISS_FEND, CMD_DATA, 0xAA, 0xBB, KISS_FEND]) + # RxMeta: SNR=0x10 (4.0 dB), RSSI=0xB0 (-80) + rx_meta_frame = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, 0x39, 0x10, 0xB0, KISS_FEND]) - for byte in raw_bytes: + for byte in data_frame: + modem._decode_kiss_byte(byte) + for byte in rx_meta_frame: modem._decode_kiss_byte(byte) assert modem.stats["last_snr"] == pytest.approx(4.0) assert modem.stats["last_rssi"] == -80 def test_rx_callback_receives_per_packet_rssi_snr(self): - """Test that a 3-arg callback receives (data, rssi, snr) per packet without race""" + """Test that a 3-arg callback receives (data, rssi, snr) per Data+RxMeta pair""" modem = KissModemWrapper(port="/dev/null", auto_configure=False) modem.is_connected = True @@ -185,55 +193,92 @@ def capture(data, rssi, snr): modem.on_frame_received = capture - # First frame: SNR=0x10 (4.0 dB), RSSI=0xB0 (-80) - raw1 = bytes([KISS_FEND, CMD_DATA, 0x10, 0xB0, 0x01, 0x02, KISS_FEND]) - for byte in raw1: + # First packet: Data then RxMeta (SNR=4.0 dB, RSSI=-80) + data1 = bytes([KISS_FEND, CMD_DATA, 0x01, 0x02, KISS_FEND]) + meta1 = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, 0x39, 0x10, 0xB0, KISS_FEND]) + for byte in data1: + modem._decode_kiss_byte(byte) + for byte in meta1: modem._decode_kiss_byte(byte) - # Second frame: different metrics - raw2 = bytes([KISS_FEND, CMD_DATA, 0x08, 0x9C, 0x03, 0x04, KISS_FEND]) - for byte in raw2: + # Second packet: Data then RxMeta (SNR=2.0 dB, RSSI=-100) + data2 = bytes([KISS_FEND, CMD_DATA, 0x03, 0x04, KISS_FEND]) + meta2 = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, 0x39, 0x08, 0x9C, KISS_FEND]) + for byte in data2: + modem._decode_kiss_byte(byte) + for byte in meta2: modem._decode_kiss_byte(byte) assert len(received) == 2 assert received[0] == (b"\x01\x02", -80, 4.0) - assert received[1] == (b"\x03\x04", -100, 2.0) # 0x9C signed = -100, 0x08/4 = 2.0 + assert received[1] == (b"\x03\x04", -100, 2.0) + + def test_data_frame_without_rx_meta_does_not_call_callback(self): + """Spec: Data frame queues payload; callback only on following RxMeta""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + modem.is_connected = True + + received = [] + modem.on_frame_received = lambda data: received.append(data) + + # Only Data frame, no RxMeta + data_frame = bytes([KISS_FEND, CMD_DATA, 0x01, 0x02, 0x03, KISS_FEND]) + for byte in data_frame: + modem._decode_kiss_byte(byte) + + assert len(received) == 0 + assert len(modem._pending_rx_queue) == 1 + assert modem._pending_rx_queue[0] == b"\x01\x02\x03" + + def test_port_non_zero_discarded(self): + """Frames with port != 0 are ignored (type byte 0x10 = port 1, cmd 0)""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + modem.is_connected = True + + received = [] + modem.on_frame_received = lambda data: received.append(data) + + # Type 0x10: port=1, cmd=0 (Data on port 1) - should be discarded + frame = bytes([KISS_FEND, 0x10, 0x01, 0x02, 0x03, KISS_FEND]) + for byte in frame: + modem._decode_kiss_byte(byte) + + assert len(received) == 0 + assert len(modem._pending_rx_queue) == 0 class TestCommandResponses: """Test command sending and response parsing""" def test_send_command_encodes_correctly(self): - """Test that _send_command creates correct KISS frame""" + """Test that _send_command sends SetHardware frame (FEND + 0x06 + sub_cmd + data + FEND)""" modem = KissModemWrapper(port="/dev/null", auto_configure=False) - # Mock serial connection mock_serial = MagicMock() mock_serial.is_open = True modem.serial_conn = mock_serial modem.is_connected = True - # Send command with short timeout (will timeout since no response) modem._send_command(CMD_GET_VERSION, timeout=0.1) - # Verify frame was written assert mock_serial.write.called written_frame = mock_serial.write.call_args[0][0] assert written_frame[0] == KISS_FEND - assert written_frame[1] == CMD_GET_VERSION + assert written_frame[1] == KISS_CMD_SETHARDWARE # type SetHardware + assert written_frame[2] == HW_CMD_GET_VERSION # sub_cmd GetVersion assert written_frame[-1] == KISS_FEND def test_response_parsing_identity(self): - """Test parsing identity response""" + """Test parsing SetHardware Identity response (FEND + 0x06 + 0x21 + pubkey + FEND)""" modem = KissModemWrapper(port="/dev/null", auto_configure=False) modem.is_connected = True - # Simulate response: RESP_IDENTITY + 32 bytes pubkey pubkey = bytes(range(32)) - raw_bytes = bytes([KISS_FEND, RESP_IDENTITY]) + pubkey + bytes([KISS_FEND]) + raw_bytes = ( + bytes([KISS_FEND, KISS_CMD_SETHARDWARE, RESP_IDENTITY]) + pubkey + bytes([KISS_FEND]) + ) - # Set up response capture modem._response_event = threading.Event() modem._pending_response = None @@ -245,12 +290,11 @@ def test_response_parsing_identity(self): assert modem._pending_response[1] == pubkey def test_response_parsing_error(self): - """Test parsing error response""" + """Test parsing SetHardware Error response (FEND + 0x06 + 0x2A + code + FEND)""" modem = KissModemWrapper(port="/dev/null", auto_configure=False) modem.is_connected = True - # Simulate error response - raw_bytes = bytes([KISS_FEND, RESP_ERROR, 0x05, KISS_FEND]) # ERR_UNKNOWN_CMD + raw_bytes = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, RESP_ERROR, 0x05, KISS_FEND]) modem._response_event = threading.Event() modem._pending_response = None @@ -263,13 +307,12 @@ def test_response_parsing_error(self): assert modem._pending_response[1][0] == 0x05 def test_tx_done_response(self): - """Test TX done response sets event""" + """Test SetHardware TxDone (0x38) response sets event""" modem = KissModemWrapper(port="/dev/null", auto_configure=False) modem.is_connected = True modem._tx_done_event = threading.Event() - # Simulate TX done success - raw_bytes = bytes([KISS_FEND, RESP_TX_DONE, 0x01, KISS_FEND]) + raw_bytes = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, RESP_TX_DONE, 0x01, KISS_FEND]) for byte in raw_bytes: modem._decode_kiss_byte(byte) @@ -578,6 +621,73 @@ def mock_send_command(cmd, data=b"", timeout=5.0): assert modem.ping() is False + def test_get_mcu_temp_parses_response(self): + """Test get_mcu_temp parses signed int16 tenths of °C""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + + # 253 tenths = 25.3 °C + response_data = struct.pack(" Date: Fri, 6 Feb 2026 16:47:03 -0800 Subject: [PATCH 09/12] Enhance KISS Modem Wrapper for improved frame handling and callback execution - Introduced a new method `_write_frame` to ensure complete KISS frame writes to the serial port, enhancing reliability. - Implemented a `ThreadPoolExecutor` for non-blocking RX callback execution, preventing dropped packets during heavy processing. - Improved error handling for frame size and invalid escape sequences, ensuring better synchronization and robustness. - Updated documentation to reflect changes in protocol specifications and callback mechanisms. --- src/pymc_core/hardware/kiss_modem_wrapper.py | 89 ++++++++++++++++---- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py index fa04a54..eba79d8 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -4,7 +4,8 @@ Implements the MeshCore KISS modem protocol for sending/receiving MeshCore packets over LoRa and cryptographic operations. -Protocol reference: https://github.com/meshcore-dev/MeshCore +Protocol spec (frame format, SetHardware sub-commands, Data + RxMeta ordering): + https://github.com/ViezeVingertjes/MeshCore/blob/kiss-modem-spec-compliance/docs/kiss_modem_protocol.md """ import asyncio @@ -13,6 +14,7 @@ import struct import threading from collections import deque +from concurrent.futures import ThreadPoolExecutor from typing import Any, Callable, Dict, Optional, Union # RX callback: (data) for backward compat, or (data, rssi, snr) for per-packet metrics @@ -257,6 +259,8 @@ def __init__( # Event loop for thread-safe async callback invocation self._event_loop: Optional[asyncio.AbstractEventLoop] = None + # When no event loop is set, run callback in a worker so RX thread never blocks + self._callback_executor: Optional[ThreadPoolExecutor] = None # Response handling self._response_event = threading.Event() @@ -359,12 +363,46 @@ def disconnect(self): if self.tx_thread and self.tx_thread.is_alive(): self.tx_thread.join(timeout=2.0) + if self._callback_executor is not None: + self._callback_executor.shutdown(wait=False) + self._callback_executor = None + # Close serial connection if self.serial_conn and self.serial_conn.is_open: self.serial_conn.close() logger.info(f"KISS modem disconnected from {self.port}") + def _write_frame(self, frame: bytes) -> bool: + """ + Write a complete KISS frame to the serial port. + + Ensures the entire frame (including trailing FEND) is written; retries + on partial write so we never send a truncated frame. + + Returns: + True if all bytes written, False on error or incomplete write. + """ + if not self.serial_conn or not self.serial_conn.is_open: + return False + offset = 0 + while offset < len(frame): + try: + n = self.serial_conn.write(frame[offset:]) + if n is None or n <= 0: + logger.error("Serial write returned %s", n) + return False + offset += n + except Exception as e: + logger.error("Serial write error: %s", e) + return False + try: + self.serial_conn.flush() + except Exception as e: + logger.error("Serial flush error: %s", e) + return False + return True + def _query_modem_info(self): """Query modem version and identity""" try: @@ -516,9 +554,9 @@ def _send_command( KISS_CMD_SETHARDWARE, bytes([sub_cmd]) + data ) - if self.serial_conn and self.serial_conn.is_open: - self.serial_conn.write(kiss_frame) - self.serial_conn.flush() + if not self._write_frame(kiss_frame): + logger.warning("SetHardware frame write failed") + return None # Wait for response if self._response_event.wait(timeout): @@ -960,22 +998,34 @@ def _decode_kiss_byte(self, byte: int): elif byte == KISS_TFESC: self.rx_frame_buffer.append(KISS_FESC) else: - # Invalid escape sequence + # Invalid escape sequence; reset so we resync at next FEND self.stats["frame_errors"] += 1 logger.warning(f"Invalid KISS escape sequence: 0x{byte:02X}") + self.rx_frame_buffer.clear() + self.in_frame = False self.escaped = False else: if self.in_frame: - self.rx_frame_buffer.append(byte) + if len(self.rx_frame_buffer) >= MAX_FRAME_SIZE: + # Frame too long (e.g. lost FEND); reset and resync at next FEND + self.stats["frame_errors"] += 1 + logger.warning( + "KISS frame exceeded max size (%d), resyncing", MAX_FRAME_SIZE + ) + self.rx_frame_buffer.clear() + self.in_frame = False + else: + self.rx_frame_buffer.append(byte) def _dispatch_rx_callback(self, data: bytes, rssi: int, snr: float) -> None: """ - Dispatch RX callback, optionally via event loop for thread safety. + Dispatch RX callback without blocking the RX thread. If an event loop is set via set_event_loop(), the callback is scheduled - onto that loop using call_soon_threadsafe(). Otherwise, the callback - is invoked directly from the RX thread. + onto that loop. Otherwise, the callback is run in a single-worker thread + pool so the RX thread can keep reading serial data (avoids dropped + packets when the callback does I/O or heavy work). Args: data: Received packet data @@ -986,16 +1036,21 @@ def _dispatch_rx_callback(self, data: bytes, rssi: int, snr: float) -> None: return if self._event_loop is not None: - # Schedule callback on event loop for thread-safe async try: self._event_loop.call_soon_threadsafe( lambda: _invoke_rx_callback(self.on_frame_received, data, rssi, snr) ) except RuntimeError as e: - # Event loop may be closed logger.warning(f"Failed to schedule RX callback on event loop: {e}") + elif self.rx_thread is not None and threading.current_thread() is self.rx_thread: + # We're in the RX thread; run callback in executor so we don't block reading + if self._callback_executor is None: + self._callback_executor = ThreadPoolExecutor(max_workers=1) + self._callback_executor.submit( + _invoke_rx_callback, self.on_frame_received, data, rssi, snr + ) else: - # Direct invocation from RX thread + # Called from main thread (e.g. unit test); invoke directly _invoke_rx_callback(self.on_frame_received, data, rssi, snr) def _process_received_frame(self): @@ -1105,11 +1160,11 @@ def _tx_worker(self): frame = self.tx_buffer.popleft() if self.serial_conn and self.serial_conn.is_open: - self.serial_conn.write(frame) - self.serial_conn.flush() - - self.stats["frames_sent"] += 1 - self.stats["bytes_sent"] += len(frame) + if self._write_frame(frame): + self.stats["frames_sent"] += 1 + self.stats["bytes_sent"] += len(frame) + else: + logger.warning("TX frame write failed, dropping frame") else: logger.warning("Serial connection not open") else: From cfeb41bdafc30c090e8728b825e8b7ea1adb06d1 Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 9 Feb 2026 15:36:53 -0800 Subject: [PATCH 10/12] Update KISS Modem Wrapper command and response constants for signal reporting - Added new command constants for setting and getting signal reports. - Renumbered response constants to comply with updated protocol specifications. - Updated test cases to reflect changes in response handling for RxMeta frames. --- src/pymc_core/hardware/kiss_modem_wrapper.py | 54 +++++++++++--------- tests/test_kiss_modem_wrapper.py | 15 +++--- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py index eba79d8..c61497b 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -81,33 +81,37 @@ def _invoke_rx_callback( HW_CMD_GET_DEVICE_NAME = 0x16 HW_CMD_PING = 0x17 HW_CMD_REBOOT = 0x18 +HW_CMD_SET_SIGNAL_REPORT = 0x19 +HW_CMD_GET_SIGNAL_REPORT = 0x1A # SetHardware response sub-commands (TNC -> Host) -HW_RESP_IDENTITY = 0x21 -HW_RESP_RANDOM = 0x22 -HW_RESP_VERIFY = 0x23 -HW_RESP_SIGNATURE = 0x24 -HW_RESP_ENCRYPTED = 0x25 -HW_RESP_DECRYPTED = 0x26 -HW_RESP_SHARED_SECRET = 0x27 -HW_RESP_HASH = 0x28 -HW_RESP_OK = 0x29 -HW_RESP_ERROR = 0x2A -HW_RESP_RADIO = 0x2B -HW_RESP_TX_POWER = 0x2C -HW_RESP_CURRENT_RSSI = 0x2D -HW_RESP_CHANNEL_BUSY = 0x2E -HW_RESP_AIRTIME = 0x2F -HW_RESP_NOISE_FLOOR = 0x30 -HW_RESP_VERSION = 0x31 -HW_RESP_STATS = 0x32 -HW_RESP_BATTERY = 0x33 -HW_RESP_MCU_TEMP = 0x34 -HW_RESP_SENSORS = 0x35 -HW_RESP_DEVICE_NAME = 0x36 -HW_RESP_PONG = 0x37 -HW_RESP_TX_DONE = 0x38 -HW_RESP_RX_META = 0x39 +# Spec: response = command | 0x80 for command responses; 0xF0+ for generic/unsolicited +HW_RESP_IDENTITY = 0x81 # HW_CMD_GET_IDENTITY | 0x80 +HW_RESP_RANDOM = 0x82 +HW_RESP_VERIFY = 0x83 +HW_RESP_SIGNATURE = 0x84 +HW_RESP_ENCRYPTED = 0x85 +HW_RESP_DECRYPTED = 0x86 +HW_RESP_SHARED_SECRET = 0x87 +HW_RESP_HASH = 0x88 +HW_RESP_RADIO = 0x8B # HW_CMD_GET_RADIO | 0x80 +HW_RESP_TX_POWER = 0x8C +HW_RESP_CURRENT_RSSI = 0x8D +HW_RESP_CHANNEL_BUSY = 0x8E +HW_RESP_AIRTIME = 0x8F +HW_RESP_NOISE_FLOOR = 0x90 +HW_RESP_VERSION = 0x91 +HW_RESP_STATS = 0x92 +HW_RESP_BATTERY = 0x93 +HW_RESP_MCU_TEMP = 0x94 +HW_RESP_SENSORS = 0x95 +HW_RESP_DEVICE_NAME = 0x96 +HW_RESP_PONG = 0x97 # HW_CMD_PING | 0x80 +HW_RESP_OK = 0xF0 +HW_RESP_ERROR = 0xF1 +HW_RESP_TX_DONE = 0xF8 # Unsolicited +HW_RESP_RX_META = 0xF9 # Unsolicited +HW_RESP_SIGNAL_REPORT = 0x9A # HW_CMD_GET_SIGNAL_REPORT | 0x80 # Backward-compatible aliases (same values as HW_*) CMD_GET_IDENTITY = HW_CMD_GET_IDENTITY diff --git a/tests/test_kiss_modem_wrapper.py b/tests/test_kiss_modem_wrapper.py index 75f2a68..262d4b9 100644 --- a/tests/test_kiss_modem_wrapper.py +++ b/tests/test_kiss_modem_wrapper.py @@ -60,6 +60,7 @@ RESP_TX_POWER, RESP_VERIFY, RESP_VERSION, + HW_RESP_RX_META, KissModemWrapper, ) @@ -132,8 +133,8 @@ def test_decode_simple_frame(self): # Data frame: FEND + 0x00 + raw_packet + FEND (no in-frame metadata) data_frame = bytes([KISS_FEND, CMD_DATA, 0x01, 0x02, 0x03, KISS_FEND]) - # RxMeta: FEND + 0x06 + 0x39 + SNR + RSSI + FEND (sent immediately after Data) - rx_meta_frame = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, 0x39, 0x10, 0xB0, KISS_FEND]) + # RxMeta: FEND + 0x06 + 0xF9 + SNR + RSSI + FEND (sent immediately after Data) + rx_meta_frame = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_RX_META, 0x10, 0xB0, KISS_FEND]) for byte in data_frame: modem._decode_kiss_byte(byte) @@ -155,7 +156,7 @@ def test_decode_frame_with_escapes(self): data_frame = bytes( [KISS_FEND, CMD_DATA, KISS_FESC, KISS_TFEND, KISS_FEND] ) - rx_meta_frame = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, 0x39, 0x10, 0xB0, KISS_FEND]) + rx_meta_frame = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_RX_META, 0x10, 0xB0, KISS_FEND]) for byte in data_frame: modem._decode_kiss_byte(byte) @@ -172,7 +173,7 @@ def test_decode_extracts_rssi_snr(self): data_frame = bytes([KISS_FEND, CMD_DATA, 0xAA, 0xBB, KISS_FEND]) # RxMeta: SNR=0x10 (4.0 dB), RSSI=0xB0 (-80) - rx_meta_frame = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, 0x39, 0x10, 0xB0, KISS_FEND]) + rx_meta_frame = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_RX_META, 0x10, 0xB0, KISS_FEND]) for byte in data_frame: modem._decode_kiss_byte(byte) @@ -195,7 +196,7 @@ def capture(data, rssi, snr): # First packet: Data then RxMeta (SNR=4.0 dB, RSSI=-80) data1 = bytes([KISS_FEND, CMD_DATA, 0x01, 0x02, KISS_FEND]) - meta1 = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, 0x39, 0x10, 0xB0, KISS_FEND]) + meta1 = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_RX_META, 0x10, 0xB0, KISS_FEND]) for byte in data1: modem._decode_kiss_byte(byte) for byte in meta1: @@ -203,7 +204,7 @@ def capture(data, rssi, snr): # Second packet: Data then RxMeta (SNR=2.0 dB, RSSI=-100) data2 = bytes([KISS_FEND, CMD_DATA, 0x03, 0x04, KISS_FEND]) - meta2 = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, 0x39, 0x08, 0x9C, KISS_FEND]) + meta2 = bytes([KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_RX_META, 0x08, 0x9C, KISS_FEND]) for byte in data2: modem._decode_kiss_byte(byte) for byte in meta2: @@ -307,7 +308,7 @@ def test_response_parsing_error(self): assert modem._pending_response[1][0] == 0x05 def test_tx_done_response(self): - """Test SetHardware TxDone (0x38) response sets event""" + """Test SetHardware TxDone (0xF8) response sets event""" modem = KissModemWrapper(port="/dev/null", auto_configure=False) modem.is_connected = True modem._tx_done_event = threading.Event() From 0bfc1294652ef4dfa67188c6d019fea4a1220e55 Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 9 Feb 2026 15:42:48 -0800 Subject: [PATCH 11/12] Update KISS Modem Wrapper documentation link for protocol compliance --- src/pymc_core/hardware/kiss_modem_wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py index c61497b..b7ec88c 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -5,7 +5,7 @@ MeshCore packets over LoRa and cryptographic operations. Protocol spec (frame format, SetHardware sub-commands, Data + RxMeta ordering): - https://github.com/ViezeVingertjes/MeshCore/blob/kiss-modem-spec-compliance/docs/kiss_modem_protocol.md + https://github.com/meshcore-dev/MeshCore/blob/dev/docs/kiss_modem_protocol.md """ import asyncio From 38ed93bbabfd5cb404efca668934d78393020b0e Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 9 Feb 2026 16:34:01 -0800 Subject: [PATCH 12/12] Update KISS Modem Wrapper with new configuration options and methods - Added methods for setting KISS parameters: persistence, slottime, txtail, and full duplex. - Updated host-side Listen-Before-Talk (LBT) functionality to default to off, with related methods for enabling/disabling. - Updated the create_radio function to include additional parameters for serial port configuration. - Enhanced documentation and test cases to cover new features and ensure compliance with protocol specifications. --- examples/common.py | 14 +- src/pymc_core/hardware/kiss_modem_wrapper.py | 166 +++++++++++++++- src/pymc_core/node/dispatcher.py | 4 + tests/test_kiss_modem_wrapper.py | 187 +++++++++++++++++++ 4 files changed, 359 insertions(+), 12 deletions(-) diff --git a/examples/common.py b/examples/common.py index 4d90e1f..9db8ff5 100644 --- a/examples/common.py +++ b/examples/common.py @@ -32,7 +32,10 @@ from pymc_core.node.node import MeshNode -def create_radio(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0") -> LoRaRadio: +def create_radio( + radio_type: str = "waveshare", + serial_port: str = "/dev/ttyUSB0", +) -> LoRaRadio: """Create a radio instance with configuration for specified hardware. Args: @@ -89,9 +92,14 @@ def create_radio(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0 "power": 22, # TX power } - # Create KISS modem wrapper with specified port + # Create KISS modem wrapper with specified port. + # To enable host-side LBT (e.g. full-duplex on half-duplex link), call + # modem_wrapper.set_lbt_enabled(True) after creation. modem_wrapper = KissModemWrapper( - port=serial_port, baudrate=115200, radio_config=modem_config, auto_configure=True + port=serial_port, + baudrate=115200, + radio_config=modem_config, + auto_configure=True, ) logger.info("Created MeshCore KISS Modem Wrapper") diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py index 0f4cf69..d93e187 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -54,6 +54,11 @@ def _invoke_rx_callback( # Standard KISS type bytes (port in bits 7-4, command in bits 3-0) CMD_DATA = 0x00 # Data frame (raw packet) +KISS_CMD_TXDELAY = 0x01 # Transmitter keyup delay in 10ms units (firmware default 50 = 500ms) +KISS_CMD_PERSISTENCE = 0x02 # CSMA persistence 0-255 (firmware default 63) +KISS_CMD_SLOTTIME = 0x03 # CSMA slot interval in 10ms units (firmware default 10 = 100ms) +KISS_CMD_TXTAIL = 0x04 # Post-TX hold time in 10ms units (default: 0) +KISS_CMD_FULLDUPLEX = 0x05 # 0 = half duplex, nonzero = full duplex (default: 0) KISS_CMD_SETHARDWARE = 0x06 # SetHardware: first payload byte is sub-command KISS_CMD_RETURN = 0xFF # Exit KISS mode (no-op) @@ -222,6 +227,7 @@ def __init__( on_frame_received: Optional[RxCallback] = None, radio_config: Optional[Dict[str, Any]] = None, auto_configure: bool = True, + lbt_enabled: bool = False, ): """ Initialize MeshCore KISS Modem Wrapper @@ -234,13 +240,23 @@ def __init__( from a background thread unless set_event_loop() is used. radio_config: Optional radio configuration dict with keys: frequency, bandwidth, spreading_factor, coding_rate, - power (or tx_power) + power (or tx_power), tx_delay_ms (KISS key-up delay in ms; + default 50), kiss_persistence (0-255), kiss_slottime_ms, + kiss_txtail_ms (post-TX hold), kiss_full_duplex (bool), + and SetHardware options as needed auto_configure: If True, automatically configure radio on connect + lbt_enabled: If True, run Listen-Before-Talk before each send (default False). + For standard half-duplex the modem firmware performs p-persistent + CSMA; host-side LBT is redundant. Only enable for the marginal case + of full-duplex modem on a physically half-duplex link, where a + host "is channel busy?" check can delay submitting the next frame + to avoid collisions. """ self.port = port self.baudrate = baudrate self.timeout = timeout self.auto_configure = auto_configure + self.lbt_enabled = lbt_enabled self.radio_config = radio_config or {} self.is_configured = False @@ -312,6 +328,21 @@ def set_event_loop(self, loop: asyncio.AbstractEventLoop) -> None: self._event_loop = loop logger.debug("Event loop set for thread-safe callbacks") + def set_lbt_enabled(self, enabled: bool) -> None: + """ + Enable or disable host-side Listen-Before-Talk before each send. + + When enabled, send() checks is_channel_busy() and backs off (120/240/360 ms) + until clear or 4 s. For standard half-duplex the modem already does CSMA; + enable only for full-duplex modem on a physically half-duplex link. + """ + self.lbt_enabled = enabled + logger.debug("Software LBT %s", "enabled" if enabled else "disabled") + + def get_lbt_enabled(self) -> bool: + """Return whether host-side Listen-Before-Talk is enabled.""" + return self.lbt_enabled + def connect(self) -> bool: """ Connect to serial port and start communication threads @@ -350,6 +381,19 @@ def connect(self) -> bool: # Query modem info self._query_modem_info() + # Set KISS TXDELAY so key-up delay is not the firmware default 500ms (reduces + # round-trip latency for repeaters). Value in 10ms units; default 50ms. + tx_delay_ms = self.radio_config.get("tx_delay_ms", 50) + self._set_kiss_tx_delay(tx_delay_ms) + if "kiss_persistence" in self.radio_config: + self.set_kiss_persistence(self.radio_config["kiss_persistence"]) + if "kiss_slottime_ms" in self.radio_config: + self.set_kiss_slottime(self.radio_config["kiss_slottime_ms"]) + if "kiss_txtail_ms" in self.radio_config: + self.set_kiss_txtail(self.radio_config["kiss_txtail_ms"]) + if "kiss_full_duplex" in self.radio_config: + self.set_kiss_full_duplex(bool(self.radio_config["kiss_full_duplex"])) + return True except Exception as e: @@ -408,6 +452,108 @@ def _write_frame(self, frame: bytes) -> bool: return False return True + def _set_kiss_tx_delay(self, delay_ms: int) -> None: + """ + Send KISS TXDELAY command so modem key-up delay is not the default 500ms. + Value is in 10ms units; firmware default is 50 (= 500ms). Typical for + repeaters: 50ms (value 5). + """ + value = max(1, min(255, delay_ms // 10)) + frame = self._encode_kiss_frame(KISS_CMD_TXDELAY, bytes([value])) + if self._write_frame(frame): + logger.debug("KISS TXDELAY set to %dms (value %d)", value * 10, value) + else: + logger.warning("Failed to set KISS TXDELAY") + + def set_kiss_persistence(self, value: int) -> bool: + """ + Set KISS CSMA persistence parameter (0-255). Lower values defer longer + when channel is busy; firmware default is 63. + + Returns: + True if the command was written successfully. + """ + val = max(0, min(255, value)) + frame = self._encode_kiss_frame(KISS_CMD_PERSISTENCE, bytes([val])) + ok = self._write_frame(frame) + if ok: + logger.debug("KISS PERSISTENCE set to %d", val) + return ok + + def set_kiss_slottime(self, slottime_ms: int) -> bool: + """ + Set KISS CSMA slot time in milliseconds (sent as 10ms units to modem). + Firmware default is 100ms (value 10). Lower values reduce backoff delay + when channel is busy at the cost of more collisions under load. + + Returns: + True if the command was written successfully. + """ + value = max(0, min(255, slottime_ms // 10)) + frame = self._encode_kiss_frame(KISS_CMD_SLOTTIME, bytes([value])) + ok = self._write_frame(frame) + if ok: + logger.debug("KISS SLOTTIME set to %dms (value %d)", value * 10, value) + return ok + + def set_kiss_txtail(self, txtail_ms: int) -> bool: + """ + Set KISS post-TX hold time (TXtail) in milliseconds (sent as 10ms units). + Firmware default is 0. Some radios need a short hold after TX. + + Returns: + True if the command was written successfully. + """ + value = max(0, min(255, txtail_ms // 10)) + frame = self._encode_kiss_frame(KISS_CMD_TXTAIL, bytes([value])) + ok = self._write_frame(frame) + if ok: + logger.debug("KISS TXTAIL set to %dms (value %d)", value * 10, value) + return ok + + def set_kiss_full_duplex(self, full_duplex: bool) -> bool: + """ + Set KISS full-duplex mode. When False (default), modem uses p-persistent + CSMA. When True, CSMA is bypassed and packets transmit after TXDELAY only. + + Returns: + True if the command was written successfully. + """ + value = 0x01 if full_duplex else 0x00 + frame = self._encode_kiss_frame(KISS_CMD_FULLDUPLEX, bytes([value])) + ok = self._write_frame(frame) + if ok: + logger.debug("KISS FullDuplex set to %s", full_duplex) + return ok + + def set_signal_report(self, enabled: bool) -> bool: + """ + Enable or disable RxMeta frames (SNR + RSSI after each Data frame). + Enabled by default. When disabled, the modem does not send SetHardware + RxMeta (0xF9) after received packets. + + Returns: + True if the command was sent and a valid response was received. + """ + payload = bytes([0x01 if enabled else 0x00]) + resp = self._send_command(HW_CMD_SET_SIGNAL_REPORT, payload) + if resp and resp[0] in (HW_RESP_SIGNAL_REPORT, HW_RESP_OK): + return True + return False + + def get_signal_report(self) -> Optional[bool]: + """ + Query whether RxMeta (signal report) is enabled. When enabled, the modem + sends an RxMeta frame after each received Data frame. + + Returns: + True if enabled, False if disabled, None on error. + """ + resp = self._send_command(HW_CMD_GET_SIGNAL_REPORT) + if resp and resp[0] == HW_RESP_SIGNAL_REPORT and len(resp[1]) >= 1: + return resp[1][0] != 0x00 + return None + def _query_modem_info(self): """Query modem version and identity""" try: @@ -887,15 +1033,15 @@ def check_radio_health(self) -> bool: logger.debug(f"KISS modem health check failed: {e}") return False - # LBT parameters aligned with MeshCore firmware (CAD fail retry / max duration) - LBT_RETRY_DELAYS_MS = (120, 240, 360) # random(1, 4) * 120 + # Optional host-side LBT (only when lbt_enabled, e.g. full-duplex on half-duplex link) + LBT_RETRY_DELAYS_MS = (120, 240, 360) LBT_MAX_WAIT_MS = 4000 async def _prepare_for_tx_lbt(self) -> tuple[bool, list[float]]: """ Listen-Before-Talk: query modem channel busy until clear or max wait. - Modeled on MeshCore firmware: retry delay one of 120, 240, 360 ms; give up - after 4 s total wait and transmit anyway. Returns (success, lbt_backoff_delays_ms). + Used only when lbt_enabled (marginal case: full-duplex modem on physically + half-duplex link). Returns (success, lbt_backoff_delays_ms). """ lbt_backoff_delays: list[float] = [] total_wait_ms = 0.0 @@ -938,9 +1084,9 @@ async def send(self, data: bytes) -> Optional[Dict[str, Any]]: """ Send data via KISS modem (LoRaRadio interface) - Runs Listen-Before-Talk (query channel busy until clear), then sends - the frame. Returns metadata including LBT metrics for parity with - SX1262 wrapper. + For standard half-duplex, relies on the modem's p-persistent CSMA; no + host-side LBT. When lbt_enabled is True (full-duplex on half-duplex link), + runs a channel-busy check before submitting the frame. Args: data: Data to send @@ -952,7 +1098,9 @@ async def send(self, data: bytes) -> Optional[Dict[str, Any]]: Raises: Exception: If send fails """ - _, lbt_backoff_delays = await self._prepare_for_tx_lbt() + lbt_backoff_delays: list[float] = [] + if self.lbt_enabled: + _, lbt_backoff_delays = await self._prepare_for_tx_lbt() success = self.send_frame(data) if not success: diff --git a/src/pymc_core/node/dispatcher.py b/src/pymc_core/node/dispatcher.py index a0f7d45..9cd5a3e 100644 --- a/src/pymc_core/node/dispatcher.py +++ b/src/pymc_core/node/dispatcher.py @@ -57,6 +57,10 @@ def __init__( log_fn: Optional[Callable[[str], None]] = None, packet_filter: Optional[Any] = None, ) -> None: + # tx_delay: seconds to wait after TX before starting ACK wait (only when wait_for_ack). + # Round-trip latency can also be increased by: modem CSMA (TXDELAY/SlotTime in + # firmware), handler response delays (e.g. login_server 300 ms), and serial/ + # event-loop scheduling. KISS wrapper relies on modem CSMA by default (no host LBT). self.radio = radio self.tx_delay = tx_delay self.state: DispatcherState = DispatcherState.IDLE diff --git a/tests/test_kiss_modem_wrapper.py b/tests/test_kiss_modem_wrapper.py index 262d4b9..77c8e72 100644 --- a/tests/test_kiss_modem_wrapper.py +++ b/tests/test_kiss_modem_wrapper.py @@ -32,12 +32,19 @@ CMD_VERIFY_SIGNATURE, HW_CMD_GET_DEVICE_NAME, HW_CMD_GET_MCU_TEMP, + HW_CMD_GET_SIGNAL_REPORT, HW_CMD_GET_VERSION, HW_CMD_REBOOT, + HW_CMD_SET_SIGNAL_REPORT, HW_RESP_DEVICE_NAME, HW_RESP_MCU_TEMP, HW_RESP_OK, + HW_RESP_SIGNAL_REPORT, + KISS_CMD_FULLDUPLEX, + KISS_CMD_PERSISTENCE, KISS_CMD_SETHARDWARE, + KISS_CMD_SLOTTIME, + KISS_CMD_TXTAIL, KISS_FEND, KISS_FESC, KISS_TFEND, @@ -812,6 +819,186 @@ def mock_send_command(cmd, data=b"", timeout=5.0): assert tx_power_cmd[1] == bytes([10]) +class TestKissTuningMethods: + """Test KISS config commands: persistence, slottime, txtail, full_duplex, signal report""" + + def test_set_kiss_persistence_sends_correct_frame(self): + """Test set_kiss_persistence sends KISS_CMD_PERSISTENCE with value 0-255""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + written = [] + + def capture_write(frame): + written.append(bytes(frame)) + return True + + modem._write_frame = capture_write + modem.is_connected = True + + result = modem.set_kiss_persistence(63) + assert result is True + assert len(written) == 1 + # FEND + 0x02 + 0x3F + FEND + assert written[0][0] == KISS_FEND + assert written[0][1] == KISS_CMD_PERSISTENCE + assert written[0][2] == 63 + assert written[0][3] == KISS_FEND + + def test_set_kiss_persistence_clamps_value(self): + """Test set_kiss_persistence clamps to 0-255""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + written = [] + + def capture_write(frame): + written.append(bytes(frame)) + return True + + modem._write_frame = capture_write + modem.is_connected = True + + modem.set_kiss_persistence(300) + assert written[0][2] == 255 + written.clear() + modem.set_kiss_persistence(-1) + assert written[0][2] == 0 + + def test_set_kiss_slottime_sends_correct_frame(self): + """Test set_kiss_slottime sends KISS_CMD_SLOTTIME with value in 10ms units""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + written = [] + + def capture_write(frame): + written.append(bytes(frame)) + return True + + modem._write_frame = capture_write + modem.is_connected = True + + result = modem.set_kiss_slottime(100) + assert result is True + assert len(written) == 1 + assert written[0][0] == KISS_FEND + assert written[0][1] == KISS_CMD_SLOTTIME + assert written[0][2] == 10 # 100ms / 10 + assert written[0][3] == KISS_FEND + + def test_set_kiss_txtail_sends_correct_frame(self): + """Test set_kiss_txtail sends KISS_CMD_TXTAIL with value in 10ms units""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + written = [] + + def capture_write(frame): + written.append(bytes(frame)) + return True + + modem._write_frame = capture_write + modem.is_connected = True + + result = modem.set_kiss_txtail(50) + assert result is True + assert written[0][1] == KISS_CMD_TXTAIL + assert written[0][2] == 5 # 50ms / 10 + + def test_set_kiss_full_duplex_sends_correct_frame(self): + """Test set_kiss_full_duplex sends KISS_CMD_FULLDUPLEX 0x01 or 0x00""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + written = [] + + def capture_write(frame): + written.append(bytes(frame)) + return True + + modem._write_frame = capture_write + modem.is_connected = True + + modem.set_kiss_full_duplex(True) + assert written[0][1] == KISS_CMD_FULLDUPLEX + assert written[0][2] == 0x01 + written.clear() + modem.set_kiss_full_duplex(False) + assert written[0][2] == 0x00 + + def test_set_signal_report_returns_true_on_ok_response(self): + """Test set_signal_report returns True when modem responds OK or SignalReport""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + + def mock_send_command(cmd, data=b"", timeout=5.0): + if cmd == HW_CMD_SET_SIGNAL_REPORT: + return (HW_RESP_SIGNAL_REPORT, bytes([0x01])) + return None + + modem._send_command = mock_send_command + modem.is_connected = True + + assert modem.set_signal_report(True) is True + assert modem.set_signal_report(False) is True + + def test_set_signal_report_returns_true_on_ok(self): + """Test set_signal_report returns True when modem responds HW_RESP_OK""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + + def mock_send_command(cmd, data=b"", timeout=5.0): + if cmd == HW_CMD_SET_SIGNAL_REPORT: + return (HW_RESP_OK, b"") + return None + + modem._send_command = mock_send_command + modem.is_connected = True + + assert modem.set_signal_report(True) is True + + def test_set_signal_report_returns_false_on_error_or_timeout(self): + """Test set_signal_report returns False on error or timeout""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + + def mock_send_command(cmd, data=b"", timeout=5.0): + return None + + modem._send_command = mock_send_command + modem.is_connected = True + + assert modem.set_signal_report(True) is False + + def test_get_signal_report_returns_true_when_enabled(self): + """Test get_signal_report returns True when modem reports enabled""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + + def mock_send_command(cmd, data=b"", timeout=5.0): + if cmd == HW_CMD_GET_SIGNAL_REPORT: + return (HW_RESP_SIGNAL_REPORT, bytes([0x01])) + return None + + modem._send_command = mock_send_command + modem.is_connected = True + + assert modem.get_signal_report() is True + + def test_get_signal_report_returns_false_when_disabled(self): + """Test get_signal_report returns False when modem reports disabled""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + + def mock_send_command(cmd, data=b"", timeout=5.0): + if cmd == HW_CMD_GET_SIGNAL_REPORT: + return (HW_RESP_SIGNAL_REPORT, bytes([0x00])) + return None + + modem._send_command = mock_send_command + modem.is_connected = True + + assert modem.get_signal_report() is False + + def test_get_signal_report_returns_none_on_timeout(self): + """Test get_signal_report returns None on timeout or error""" + modem = KissModemWrapper(port="/dev/null", auto_configure=False) + + def mock_send_command(cmd, data=b"", timeout=5.0): + return None + + modem._send_command = mock_send_command + modem.is_connected = True + + assert modem.get_signal_report() is None + + class TestContextManager: """Test context manager functionality"""