From ca10f1bef30435275a053caf4c268c8353e24f0f Mon Sep 17 00:00:00 2001 From: Lloyd Date: Wed, 29 Oct 2025 16:29:54 +0000 Subject: [PATCH 01/21] FEAT: Added KISS TNC support Added a new KissSerialWrapper class to manage KISS protocol communication over serial, including frame encoding/decoding and configuration commands. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Examples Introduced support for KISS TNC devices via a new --serial-port argument. Updated example scripts : ping_repeater_trace.py, send_channel_message.py, send_direct_advert.py, send_flood_advert.py, send_text_message.py, send_simple_tracked_advert.py — to accept the --serial-port option for configuring KISS TNC connections. Updated README.md detailing example usage, supported radio hardware, configuration options, and troubleshooting guidance. --- docs/docs/examples.md | 243 ++++-- examples/README.md | 187 +++++ examples/common.py | 79 +- examples/ping_repeater_trace.py | 35 +- examples/send_channel_message.py | 35 +- examples/send_direct_advert.py | 35 +- examples/send_flood_advert.py | 39 +- examples/send_text_message.py | 35 +- examples/send_tracked_advert.py | 35 +- pyproject.toml | 1 + src/pymc_core/hardware/kiss_serial_wrapper.py | 746 ++++++++++++++++++ 11 files changed, 1340 insertions(+), 130 deletions(-) create mode 100644 examples/README.md create mode 100644 src/pymc_core/hardware/kiss_serial_wrapper.py diff --git a/docs/docs/examples.md b/docs/docs/examples.md index 4c398b7..7071cfe 100644 --- a/docs/docs/examples.md +++ b/docs/docs/examples.md @@ -6,27 +6,40 @@ This section contains practical examples of using pyMC_Core for mesh communicati This directory contains examples for using PyMC Core functionality. More examples will be added over time. -## Files +## Available Examples -- `common.py`: Shared utilities and mock implementations used by all examples -- `send_flood_advert.py`: Flood advertisement example -- `send_direct_advert.py`: Direct advertisement example -- `send_tracked_advert.py`: Tracked advertisement example -- `ping_repeater_trace.py`: Trace ping example for repeater diagnostics +All examples support multiple radio types via `--radio-type` argument: + +- `send_tracked_advert.py`: Send location-tracked advertisements +- `send_direct_advert.py`: Send direct advertisements without mesh routing +- `send_flood_advert.py`: Send flood advertisements that propagate through mesh +- `send_text_message.py`: Send text messages to mesh nodes +- `send_channel_message.py`: Send messages to specific channels +- `ping_repeater_trace.py`: Test mesh routing and trace packet paths +- `common.py`: Shared utilities for radio setup and mesh node creation + +## Radio Hardware Support + +### SX1262 Direct Radio +- **waveshare**: Waveshare SX1262 HAT for Raspberry Pi +- **uconsole**: ClockworkPi uConsole LoRa module +- **meshadv-mini**: MeshAdviser Mini board + +### KISS TNC +- **kiss-tnc**: Serial KISS TNC devices (MeshTNC) ## Shared Components (`common.py`) -### `MockLoRaRadio` -Mock radio implementation for testing and demonstration: -- Simulates LoRa hardware without requiring actual hardware -- Logs transmission operations -- Returns realistic RSSI/SNR values -- Implements the `LoRaRadio` interface +### `create_radio(radio_type, serial_port)` +Creates radio instances for different hardware types: +- **SX1262 Radios**: Direct hardware control via SPI/GPIO +- **KISS TNC**: Serial protocol wrapper for TNC devices +- Supports waveshare, uconsole, meshadv-mini, and kiss-tnc types -### `create_mesh_node(node_name)` +### `create_mesh_node(name, radio_type, serial_port)` Helper function that creates a mesh node setup: - Generates a new `LocalIdentity` with cryptographic keypair -- Creates and initializes a `MockLoRaRadio` +- Creates and configures the specified radio type - Returns configured `MeshNode` and `LocalIdentity` ### `print_packet_info(packet, description)` @@ -60,62 +73,63 @@ Example showing how to ping a repeater using trace packets for network diagnosti ## Running the Examples -All examples use SX1262 LoRa radio hardware with support for multiple radio types. +All examples support multiple radio hardware types via unified command-line arguments. -### Direct Execution (Recommended) +### Command Line Interface -Run the example scripts directly with optional radio type selection: +Each example uses argparse with consistent options: ```bash -# Run examples with default Waveshare radio -python examples/send_flood_advert.py -python examples/send_direct_advert.py -python examples/send_text_message.py -python examples/send_channel_message.py -python examples/ping_repeater_trace.py -python examples/send_tracked_advert.py - -# Run examples with uConsole radio -python examples/send_flood_advert.py uconsole -python examples/send_direct_advert.py uconsole -python examples/send_text_message.py uconsole +# Show help for any example +python examples/send_tracked_advert.py --help ``` -Each example script accepts an optional radio type parameter: -- `waveshare` (default) - Waveshare SX1262 HAT -- `uconsole` - HackerGadgets uConsole -- `meshadv-mini` - FrequencyLabs meshadv-mini +**Arguments:** +- `--radio-type`: Choose hardware type (waveshare, uconsole, meshadv-mini, kiss-tnc) +- `--serial-port`: Serial port for KISS TNC (default: /dev/ttyUSB0) -You can also run examples directly with command-line arguments: +### SX1262 Direct Radio Examples ```bash -# Default Waveshare HAT configuration -python examples/send_flood_advert.py +# Send tracked advert with Waveshare HAT (default) +python examples/send_tracked_advert.py -# uConsole configuration -python examples/send_flood_advert.py uconsole -``` +# Send text message with uConsole +python examples/send_text_message.py --radio-type uconsole -### Command Line Options +# Send direct advert with MeshAdv Mini +python examples/send_direct_advert.py --radio-type meshadv-mini -Each example accepts an optional radio type parameter: +# Ping test with Waveshare +python examples/ping_repeater_trace.py --radio-type waveshare +``` -- `waveshare` (default): Waveshare LoRaWAN/GNSS HAT configuration -- `uconsole`: HackerGadgets uConsole configuration -- `meshadv-mini`: Frequency Labs Mesh Adv +### KISS TNC Examples ```bash -# Examples with explicit radio type -python examples/send_flood_advert.py waveshare -python examples/send_flood_advert.py uconsole -python examples/send_flood_advert.py meshadv-mini +# Send tracked advert via KISS TNC +python examples/send_tracked_advert.py --radio-type kiss-tnc --serial-port /dev/cu.usbserial-0001 + +# Send text message via KISS TNC +python examples/send_text_message.py --radio-type kiss-tnc --serial-port /dev/ttyUSB0 + +# Send flood advert via KISS TNC +python examples/send_flood_advert.py --radio-type kiss-tnc --serial-port /dev/cu.usbserial-0001 + +# Send channel message via KISS TNC +python examples/send_channel_message.py --radio-type kiss-tnc --serial-port /dev/ttyUSB0 + +# Ping test via KISS TNC +python examples/ping_repeater_trace.py --radio-type kiss-tnc --serial-port /dev/cu.usbserial-0001 ``` ## Hardware Requirements -### Supported SX1262 Radio Hardware +### Supported Radio Hardware + +pyMC_Core supports both direct SX1262 radio control and KISS TNC devices: -pyMC_Core supports multiple SX1262-based LoRa radio modules: +### SX1262 Direct Radio Hardware #### Waveshare LoRaWAN/GNSS HAT - **Hardware**: Waveshare SX1262 LoRa HAT @@ -174,6 +188,23 @@ pyMC_Core supports multiple SX1262-based LoRa radio modules: - TX Enable: Not used (-1) - RX Enable: GPIO 12 +### KISS TNC Hardware + +#### KISS TNC Devices +- **Hardware**: Any KISS-compatible TNC device (MeshTNC, etc.) +- **Interface**: Serial/USB connection +- **Protocol**: KISS Serial Protocol +- **Configuration**: Radio settings handled by TNC firmware +- **Connection**: USB, RS-232, or TTL serial +- **Baud Rate**: 115200 (default, configurable) +- **Advantages**: No GPIO/SPI setup required, plug-and-play operation + +**Supported TNC Devices:** +- MeshTNC boards +- OpenTracker+ with KISS firmware +- Mobilinkd TNC devices +- Custom Arduino/ESP32 KISS TNCs + ## Dependencies > **Important**: On modern Python installations (Ubuntu 22.04+, Debian 12+), you may encounter `externally-managed-environment` errors when installing packages system-wide. Create a virtual environment first: @@ -194,13 +225,20 @@ pyMC_Core supports multiple SX1262-based LoRa radio modules: pip install pymc_core ``` -### Hardware Dependencies (for SX1262 radio) +### Hardware Dependencies + +**For SX1262 Direct Radio:** ```bash pip install pymc_core[hardware] # or manually: pip install gpiozero lgpio ``` +**For KISS TNC:** +```bash +pip install pyserial +``` + ### All Dependencies ```bash pip install pymc_core[all] @@ -208,9 +246,19 @@ pip install pymc_core[all] ## Hardware Setup +### SX1262 Direct Radio Setup + 1. Connect SX1262 module to Raspberry Pi GPIO pins according to the pin configuration -2. Install required Python packages -3. Run any example to test the setup +2. Enable SPI interface: `sudo raspi-config` → Interface Options → SPI +3. Install required Python packages +4. Run any example to test the setup + +### KISS TNC Setup + +1. Connect KISS TNC device via USB or serial +2. Install pyserial: `pip install pyserial` +3. Identify serial port: `ls /dev/tty*` or `ls /dev/cu.*` (macOS) +4. Run examples with `--radio-type kiss-tnc --serial-port /dev/ttyUSB0` The examples will automatically initialize the radio with the default configuration and send packets. @@ -288,7 +336,23 @@ All examples use the SX1262 LoRa radio with the following default settings: - **TX Enable**: Not used (-1) - **RX Enable**: GPIO 12 -The radio configuration is hardcoded in `common.py` for simplicity and reliability. +### KISS TNC Configuration +- **Radio Type**: KISS Serial Protocol over TNC device +- **Frequency**: 869.525MHz (EU standard, configurable) +- **TX Power**: 22dBm (configurable) +- **Spreading Factor**: 11 (configurable) +- **Bandwidth**: 250kHz (configurable) +- **Coding Rate**: 4/5 (configurable) +- **Serial Port**: /dev/ttyUSB0 (Linux), /dev/cu.usbserial-* (macOS) +- **Baud Rate**: 115200 (default) +- **Protocol**: KISS frames with radio configuration commands +- **Auto Configure**: Automatically configures TNC and enters KISS mode + +All radio configurations use Hz-based frequency and bandwidth values for consistency: +- **Frequency**: `int(869.525 * 1000000)` (869.525 MHz in Hz) +- **Bandwidth**: `int(250 * 1000)` (250 kHz in Hz) + +The radio configurations are defined in `common.py` for each hardware type. ## Hardware Setup @@ -431,3 +495,74 @@ custom_packet = Packet( await node.send_packet(custom_packet) ``` + +## Troubleshooting + +### SX1262 Radio Issues + +**SPI Communication Problems:** +```bash +# Enable SPI interface +sudo raspi-config # → Interface Options → SPI + +# Check SPI devices +ls /dev/spi* + +# Verify GPIO permissions +sudo usermod -a -G gpio $USER +``` + +**GPIO Access Errors:** +```bash +# Install modern GPIO library +sudo apt install python3-rpi.lgpio + +# Remove old GPIO library if present +sudo apt remove python3-rpi.gpio +``` + +### KISS TNC Issues + +**Serial Port Problems:** +```bash +# Find available serial ports +ls /dev/tty* # Linux +ls /dev/cu.* # macOS + +# Check port permissions +sudo chmod 666 /dev/ttyUSB0 + +# Test serial connection +screen /dev/ttyUSB0 115200 +``` + +**KISS Protocol Issues:** +- Verify TNC supports KISS mode +- Check baud rate (default: 115200) +- Ensure no other programs using port +- Try different serial port if multiple devices + +**Configuration Problems:** +- All examples use Hz-based frequency values +- KISS TNC automatically configures radio +- Check TNC firmware supports configuration commands + +### Import Errors + +**Module Not Found:** +```bash +# Install in development mode +cd pyMC_core +pip install -e . + +# Or install from PyPI +pip install pymc_core +``` + +**Virtual Environment Issues:** +```bash +# Create fresh virtual environment +python3 -m venv pymc_env +source pymc_env/bin/activate # Linux/Mac +pip install pymc_core +``` diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..61b93b8 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,187 @@ +# PyMC Core Examples + +This directory contains examples demonstrating how to use PyMC Core with different radio hardware configurations. + +## Available Examples + +All examples support multiple radio types via `--radio-type` argument: + +- **`send_tracked_advert.py`**: Send location-tracked advertisements +- **`send_direct_advert.py`**: Send direct advertisements without mesh routing +- **`send_text_message.py`**: Send text messages to mesh nodes +- **`send_channel_message.py`**: Send messages to specific channels +- **`ping_repeater_trace.py`**: Test mesh routing and trace packet paths + +## Radio Hardware Support + +### Direct Radio (SX1262) +- **waveshare**: Waveshare SX1262 HAT for Raspberry Pi +- **uconsole**: ClockworkPi uConsole LoRa module +- **meshadv-mini**: MeshAdviser Mini board + +### KISS TNC +- **kiss-tnc**: Serial KISS TNC devices (MeshTNC) + +## Configuration + +All configurations use Hz-based frequency and bandwidth values for consistency. + +### SX1262 Direct Radio Configurations + +**Waveshare HAT (EU 869 MHz):** +```python +waveshare_config = { + "bus_id": 0, # SPI bus + "cs_id": 0, # SPI chip select + "cs_pin": 21, # Waveshare HAT CS pin + "reset_pin": 18, # Reset GPIO pin + "busy_pin": 20, # Busy GPIO pin + "irq_pin": 16, # IRQ GPIO pin + "txen_pin": 13, # TX enable GPIO + "rxen_pin": 12, # RX enable GPIO + "frequency": int(869.525 * 1000000), # 869.525 MHz in Hz + "tx_power": 22, # TX power (dBm) + "spreading_factor": 11, # LoRa SF11 + "bandwidth": int(250 * 1000), # 250 kHz in Hz + "coding_rate": 5, # LoRa CR 4/5 + "preamble_length": 17, # Preamble length + "is_waveshare": True, # Waveshare-specific flag +} +``` + +**uConsole (EU 869 MHz):** +```python +uconsole_config = { + "bus_id": 1, # SPI1 bus + "cs_id": 0, # SPI chip select + "cs_pin": -1, # Use hardware CS + "reset_pin": 25, # Reset GPIO pin + "busy_pin": 24, # Busy GPIO pin + "irq_pin": 26, # IRQ GPIO pin + "txen_pin": -1, # No TX enable pin + "rxen_pin": -1, # No RX enable pin + "frequency": int(869.525 * 1000000), # 869.525 MHz in Hz + "tx_power": 22, # TX power (dBm) + "spreading_factor": 11, # LoRa SF11 + "bandwidth": int(250 * 1000), # 250 kHz in Hz + "coding_rate": 5, # LoRa CR 4/5 + "preamble_length": 17, # Preamble length +} +``` + +**MeshAdv Mini (US 915 MHz):** +```python +meshadv_config = { + "bus_id": 0, # SPI bus + "cs_id": 0, # SPI chip select + "cs_pin": 8, # CS GPIO pin + "reset_pin": 24, # Reset GPIO pin + "busy_pin": 20, # Busy GPIO pin + "irq_pin": 16, # IRQ GPIO pin + "txen_pin": -1, # No TX enable pin + "rxen_pin": 12, # RX enable GPIO + "frequency": int(910.525 * 1000000), # 910.525 MHz in Hz + "tx_power": 22, # TX power (dBm) + "spreading_factor": 7, # LoRa SF7 + "bandwidth": int(62.5 * 1000), # 62.5 kHz in Hz + "coding_rate": 5, # LoRa CR 4/5 + "preamble_length": 17, # Preamble length +} +``` + +### KISS TNC Configuration + +**KISS TNC (EU 869 MHz):** +```python +kiss_config = { + 'frequency': int(869.525 * 1000000), # 869.525 MHz in Hz + 'bandwidth': int(250 * 1000), # 250 kHz in Hz + 'spreading_factor': 11, # LoRa SF11 + 'coding_rate': 5, # LoRa CR 4/5 + 'sync_word': 0x12, # Sync word + 'power': 22 # TX power (dBm) +} +``` + +## Usage Examples + +### SX1262 Direct Radio +```bash +# Send tracked advert with Waveshare HAT (default) +python3 send_tracked_advert.py + +# Send text message with uConsole +python3 send_text_message.py --radio-type uconsole + +# Ping test with MeshAdv Mini +python3 ping_repeater_trace.py --radio-type meshadv-mini +``` + +### KISS TNC +```bash +# Send tracked advert via KISS TNC +python3 send_tracked_advert.py --radio-type kiss-tnc --serial-port /dev/cu.usbserial-0001 + +# Send text message via KISS TNC +python3 send_text_message.py --radio-type kiss-tnc --serial-port /dev/ttyUSB0 + +# Send direct advert via KISS TNC +python3 send_direct_advert.py --radio-type kiss-tnc --serial-port /dev/cu.usbserial-0001 + +# Send flood advert via KISS TNC +python3 send_flood_advert.py --radio-type kiss-tnc --serial-port /dev/ttyUSB0 + +# Send channel message via KISS TNC +python3 send_channel_message.py --radio-type kiss-tnc --serial-port /dev/cu.usbserial-0001 + +# Ping test via KISS TNC +python3 ping_repeater_trace.py --radio-type kiss-tnc --serial-port /dev/cu.usbserial-0001 +``` + +## Common Module (`common.py`) + +Provides shared utilities for examples: + +- `create_radio(radio_type, serial_port)`: Create radio instances +- `create_mesh_node(name, radio_type, serial_port)`: Create mesh nodes +- `print_packet_info(packet, description)`: Debug packet information + +**Supported Radio Types:** +- `waveshare`: Waveshare SX1262 HAT +- `uconsole`: ClockworkPi uConsole LoRa +- `meshadv-mini`: MeshAdviser Mini board +- `kiss-tnc`: KISS TNC devices + +## Requirements + +### For SX1262 Direct Radio: +- SX1262 hardware (Waveshare HAT, uConsole, MeshAdv Mini) +- SPI interface enabled on Raspberry Pi +- GPIO access for control pins +- Python SPI libraries (`pip install spidev RPi.GPIO`) + +### For KISS TNC: +- KISS-compatible TNC device (MeshTNC, etc.) +- Serial/USB connection +- pyserial library (`pip install pyserial`) + +## Troubleshooting + +### SX1262 Radio Issues: +1. Enable SPI: `sudo raspi-config` → Interface Options → SPI +2. Check GPIO permissions: `sudo usermod -a -G gpio $USER` +3. Verify wiring matches pin configuration in `common.py` +4. Test SPI communication: `ls /dev/spi*` + +### KISS TNC Issues: +1. Check device connection: `ls /dev/tty*` or `ls /dev/cu.*` +2. Verify permissions: `sudo chmod 666 /dev/ttyUSB0` +3. Ensure no other programs using port +4. Test with terminal: `screen /dev/ttyUSB0 115200` + +### Import Errors: +Make sure pymc_core is properly installed: +```bash +cd ../ +pip install -e . +``` \ No newline at end of file diff --git a/examples/common.py b/examples/common.py index 256616d..e6f7f6d 100644 --- a/examples/common.py +++ b/examples/common.py @@ -12,7 +12,7 @@ # Set up logging logging.basicConfig( - level=logging.WARNING, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" + level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) @@ -25,21 +25,48 @@ from pymc_core.node.node import MeshNode -def create_radio(radio_type: str = "waveshare") -> LoRaRadio: - """Create an SX1262 radio instance with configuration for specified hardware. +def create_radio(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0") -> LoRaRadio: + """Create a radio instance with configuration for specified hardware. Args: - radio_type: Type of radio hardware ("waveshare" or "uconsole") + 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) Returns: - SX1262Radio instance configured for the specified hardware + Radio instance configured for the specified hardware """ - logger.info(f"Creating SX1262 radio for {radio_type}...") + logger.info(f"Creating radio for {radio_type}...") try: - # Direct SX1262 radio + # Check if this is a KISS TNC configuration + if radio_type == "kiss-tnc": + from pymc_core.hardware.kiss_serial_wrapper import KissSerialWrapper + logger.debug("Using KISS Serial Wrapper") + + # KISS TNC configuration + kiss_config = { + 'frequency': int(869.525 * 1000000), # EU: 869.525 MHz + 'bandwidth': int(250 * 1000), # 250 kHz + 'spreading_factor': 11, # LoRa SF11 + 'coding_rate': 5, # LoRa CR 4/5 + 'sync_word': 0x12, # Sync word + 'power': 22 # TX power + } + + # Create KISS wrapper with specified port + kiss_wrapper = KissSerialWrapper( + port=serial_port, + baudrate=115200, + radio_config=kiss_config, + auto_configure=True + ) + + logger.info("Created KISS Serial Wrapper") + logger.info(f"Frequency: {kiss_config['frequency']/1000000:.3f}MHz, TX Power: {kiss_config['power']}dBm") + return kiss_wrapper + + # Direct SX1262 radio for other types from pymc_core.hardware.sx1262_wrapper import SX1262Radio - logger.debug("Imported SX1262Radio successfully") # Radio configurations for different hardware @@ -97,7 +124,7 @@ def create_radio(radio_type: str = "waveshare") -> LoRaRadio: if radio_type not in configs: raise ValueError( - f"Unknown radio type: {radio_type}. Use 'waveshare' 'meshadv-mini' or 'uconsole'" + f"Unknown radio type: {radio_type}. Use 'waveshare', 'meshadv-mini', 'uconsole', or 'kiss-tnc'" ) radio_kwargs = configs[radio_type] @@ -119,13 +146,14 @@ def create_radio(radio_type: str = "waveshare") -> LoRaRadio: def create_mesh_node( - node_name: str = "ExampleNode", radio_type: str = "waveshare" + node_name: str = "ExampleNode", radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0" ) -> tuple[MeshNode, LocalIdentity]: - """Create a mesh node with SX1262 radio. + """Create a mesh node with radio. Args: node_name: Name for the mesh node - radio_type: Type of radio hardware ("waveshare" or "uconsole") + 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) Returns: Tuple of (MeshNode, LocalIdentity) @@ -138,13 +166,28 @@ def create_mesh_node( identity = LocalIdentity() logger.info(f"Created identity with public key: {identity.get_public_key().hex()[:16]}...") - # Create the SX1262 radio + # Create the radio logger.debug("Creating radio...") - radio = create_radio(radio_type) - - logger.debug("Calling radio.begin()...") - radio.begin() - logger.info("Radio initialized successfully") + radio = create_radio(radio_type, serial_port) + + # Initialize radio (different methods for different types) + if radio_type == "kiss-tnc": + logger.debug("Connecting KISS radio...") + if radio.connect(): + logger.info("KISS radio connected successfully") + print(f"✓ KISS radio connected to {serial_port}") + if hasattr(radio, 'kiss_mode_active') and radio.kiss_mode_active: + print("✓ KISS mode is active") + else: + print("⚠ KISS mode may not be active") + else: + 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}") + else: + logger.debug("Calling radio.begin()...") + radio.begin() + logger.info("Radio initialized successfully") # Create a mesh node with the radio and identity config = {"node": {"name": node_name}} diff --git a/examples/ping_repeater_trace.py b/examples/ping_repeater_trace.py index 4f01f39..ff8c3db 100644 --- a/examples/ping_repeater_trace.py +++ b/examples/ping_repeater_trace.py @@ -24,12 +24,12 @@ from pymc_core.protocol.packet_utils import PacketDataUtils -async def ping_repeater(radio_type: str = "waveshare"): +async def ping_repeater(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0"): """ Ping a specific repeater using trace packets with callback response handling. This demonstrates the proper way to handle asynchronous trace responses. """ - mesh_node, identity = create_mesh_node("PingNode", radio_type) + mesh_node, identity = create_mesh_node("PingNode", radio_type, serial_port) # Create an event to signal when response is received response_received = asyncio.Event() @@ -79,14 +79,31 @@ def on_trace_response(success: bool, response_text: str, response_data: dict): print("Trace handler not available") -def main(radio_type: str = "waveshare"): +def main(): """Main function for running the example.""" - print(f"Using {radio_type} radio configuration") - asyncio.run(ping_repeater(radio_type)) + import argparse + + parser = argparse.ArgumentParser(description="Ping a repeater using trace packets") + parser.add_argument( + "--radio-type", + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + default="waveshare", + help="Radio hardware type (default: waveshare)" + ) + parser.add_argument( + "--serial-port", + default="/dev/ttyUSB0", + help="Serial port for KISS TNC (default: /dev/ttyUSB0)" + ) + + args = parser.parse_args() + + print(f"Using {args.radio_type} radio configuration") + if args.radio_type == "kiss-tnc": + print(f"Serial port: {args.serial_port}") + + asyncio.run(ping_repeater(args.radio_type, args.serial_port)) if __name__ == "__main__": - import sys - - radio_type = sys.argv[1] if len(sys.argv) > 1 else "waveshare" - main(radio_type) + main() diff --git a/examples/send_channel_message.py b/examples/send_channel_message.py index 9859e87..227e2ef 100644 --- a/examples/send_channel_message.py +++ b/examples/send_channel_message.py @@ -14,12 +14,12 @@ from pymc_core.protocol.packet_builder import PacketBuilder -async def send_channel_message(radio_type: str = "waveshare"): +async def send_channel_message(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0"): """Send a channel message to the Public channel.""" print("Starting channel message send example...") # Create mesh node - mesh_node, identity = create_mesh_node("ChannelSender", radio_type) + mesh_node, identity = create_mesh_node("ChannelSender", radio_type, serial_port) # Initialize packet variable packet = Packet() @@ -64,11 +64,31 @@ async def send_channel_message(radio_type: str = "waveshare"): return packet, mesh_node -def main(radio_type: str = "waveshare"): +def main(): """Main function for running the example.""" - print(f"Using {radio_type} radio configuration") + import argparse + + parser = argparse.ArgumentParser(description="Send a channel message to the Public channel") + parser.add_argument( + "--radio-type", + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + default="waveshare", + help="Radio hardware type (default: waveshare)" + ) + parser.add_argument( + "--serial-port", + default="/dev/ttyUSB0", + help="Serial port for KISS TNC (default: /dev/ttyUSB0)" + ) + + args = parser.parse_args() + + print(f"Using {args.radio_type} radio configuration") + if args.radio_type == "kiss-tnc": + print(f"Serial port: {args.serial_port}") + try: - packet, node = asyncio.run(send_channel_message(radio_type)) + packet, node = asyncio.run(send_channel_message(args.radio_type, args.serial_port)) print("Example completed") except KeyboardInterrupt: print("\nInterrupted by user") @@ -77,7 +97,4 @@ def main(radio_type: str = "waveshare"): if __name__ == "__main__": - import sys - - radio_type = sys.argv[1] if len(sys.argv) > 1 else "waveshare" - main(radio_type) + main() diff --git a/examples/send_direct_advert.py b/examples/send_direct_advert.py index d30f9db..4dcd977 100644 --- a/examples/send_direct_advert.py +++ b/examples/send_direct_advert.py @@ -16,9 +16,9 @@ from pymc_core.protocol.packet_builder import PacketBuilder -async def send_direct_advert(radio_type: str = "waveshare"): +async def send_direct_advert(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0"): # Create a mesh node with SX1262 radio - mesh_node, identity = create_mesh_node("MyNode", radio_type) + mesh_node, identity = create_mesh_node("MyNode", radio_type, serial_port) # Create a direct advertisement packet # Parameters: identity, node_name, lat, lon, feature1, feature2, flags @@ -44,14 +44,31 @@ async def send_direct_advert(radio_type: str = "waveshare"): return advert_packet -def main(radio_type: str = "waveshare"): +def main(): """Main function for running the example.""" - print(f"Using {radio_type} radio configuration") - asyncio.run(send_direct_advert(radio_type)) + import argparse + + parser = argparse.ArgumentParser(description="Send a direct advertisement packet") + parser.add_argument( + "--radio-type", + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + default="waveshare", + help="Radio hardware type (default: waveshare)" + ) + parser.add_argument( + "--serial-port", + default="/dev/ttyUSB0", + help="Serial port for KISS TNC (default: /dev/ttyUSB0)" + ) + + args = parser.parse_args() + + print(f"Using {args.radio_type} radio configuration") + if args.radio_type == "kiss-tnc": + print(f"Serial port: {args.serial_port}") + + asyncio.run(send_direct_advert(args.radio_type, args.serial_port)) if __name__ == "__main__": - import sys - - radio_type = sys.argv[1] if len(sys.argv) > 1 else "waveshare" - main(radio_type) + main() diff --git a/examples/send_flood_advert.py b/examples/send_flood_advert.py index 1db234b..9a812fd 100644 --- a/examples/send_flood_advert.py +++ b/examples/send_flood_advert.py @@ -23,9 +23,9 @@ from pymc_core.protocol.packet_builder import PacketBuilder -async def send_flood_advert(radio_type: str = "waveshare"): +async def send_flood_advert(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0"): # Create a mesh node with SX1262 radio - mesh_node, identity = create_mesh_node("MyNode", radio_type) + mesh_node, identity = create_mesh_node("MyNode", radio_type, serial_port) # Create a flood advertisement packet # Parameters: identity, node_name, lat, lon, feature1, feature2, flags @@ -51,18 +51,31 @@ async def send_flood_advert(radio_type: str = "waveshare"): return advert_packet -def main(radio_type: str = "waveshare"): +def main(): """Main function for running the example.""" - print(f"Using {radio_type} radio configuration") - asyncio.run(send_flood_advert(radio_type)) + import argparse + + parser = argparse.ArgumentParser(description="Send a flood advertisement packet") + parser.add_argument( + "--radio-type", + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + default="waveshare", + help="Radio hardware type (default: waveshare)" + ) + parser.add_argument( + "--serial-port", + default="/dev/ttyUSB0", + help="Serial port for KISS TNC (default: /dev/ttyUSB0)" + ) + + args = parser.parse_args() + + print(f"Using {args.radio_type} radio configuration") + if args.radio_type == "kiss-tnc": + print(f"Serial port: {args.serial_port}") + + asyncio.run(send_flood_advert(args.radio_type, args.serial_port)) if __name__ == "__main__": - # Parse command line arguments - radio_type = sys.argv[1] if len(sys.argv) > 1 else "waveshare" - - if radio_type not in ["waveshare", "uconsole", "meshadv-mini"]: - print("Usage: python send_flood_advert.py [waveshare|uconsole|meshadv-mini]") - sys.exit(1) - - main(radio_type) + main() diff --git a/examples/send_text_message.py b/examples/send_text_message.py index fd899ab..9b86adb 100644 --- a/examples/send_text_message.py +++ b/examples/send_text_message.py @@ -14,12 +14,12 @@ from pymc_core.protocol.packet_builder import PacketBuilder -async def send_text_message(radio_type: str = "waveshare"): +async def send_text_message(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0"): """Send a text message with CRC validation.""" print("Starting text message send example...") # Create mesh node - mesh_node, identity = create_mesh_node("MessageSender", radio_type) + mesh_node, identity = create_mesh_node("MessageSender", radio_type, serial_port) # Initialize packet variable packet = Packet() @@ -71,11 +71,31 @@ def __init__(self, name, pubkey_hex): return packet, mesh_node -def main(radio_type: str = "waveshare"): +def main(): """Main function for running the example.""" - print(f"Using {radio_type} radio configuration") + import argparse + + parser = argparse.ArgumentParser(description="Send a text message to the mesh network") + parser.add_argument( + "--radio-type", + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + default="waveshare", + help="Radio hardware type (default: waveshare)" + ) + parser.add_argument( + "--serial-port", + default="/dev/ttyUSB0", + help="Serial port for KISS TNC (default: /dev/ttyUSB0)" + ) + + args = parser.parse_args() + + print(f"Using {args.radio_type} radio configuration") + if args.radio_type == "kiss-tnc": + print(f"Serial port: {args.serial_port}") + try: - packet, node = asyncio.run(send_text_message(radio_type)) + packet, node = asyncio.run(send_text_message(args.radio_type, args.serial_port)) print("Example completed") except KeyboardInterrupt: print("\nInterrupted by user") @@ -84,7 +104,4 @@ def main(radio_type: str = "waveshare"): if __name__ == "__main__": - import sys - - radio_type = sys.argv[1] if len(sys.argv) > 1 else "waveshare" - main(radio_type) + main() diff --git a/examples/send_tracked_advert.py b/examples/send_tracked_advert.py index d59f5d8..2ff6712 100644 --- a/examples/send_tracked_advert.py +++ b/examples/send_tracked_advert.py @@ -38,12 +38,12 @@ async def simple_repeat_counter(packet, raw_data=None): print(f"Error processing packet: {e}") -async def send_simple_tracked_advert(radio_type: str = "waveshare"): +async def send_simple_tracked_advert(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0"): """Send a tracked advert and count responses.""" global repeat_count # Create mesh node - mesh_node, identity = create_mesh_node("SimpleTracker", radio_type) + mesh_node, identity = create_mesh_node("SimpleTracker", radio_type, serial_port) # Create advert packet advert_packet = PacketBuilder.create_advert( @@ -83,11 +83,31 @@ async def send_simple_tracked_advert(radio_type: str = "waveshare"): return advert_packet, mesh_node -def main(radio_type: str = "waveshare"): +def main(): """Main function for running the example.""" - print(f"Using {radio_type} radio configuration") + import argparse + + parser = argparse.ArgumentParser(description="Send a location-tracked advertisement") + parser.add_argument( + "--radio-type", + choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], + default="waveshare", + help="Radio hardware type (default: waveshare)" + ) + parser.add_argument( + "--serial-port", + default="/dev/ttyUSB0", + help="Serial port for KISS TNC (default: /dev/ttyUSB0)" + ) + + args = parser.parse_args() + + print(f"Using {args.radio_type} radio configuration") + if args.radio_type == "kiss-tnc": + print(f"Serial port: {args.serial_port}") + try: - packet, node = asyncio.run(send_simple_tracked_advert(radio_type)) + packet, node = asyncio.run(send_simple_tracked_advert(args.radio_type, args.serial_port)) print("Example completed") except KeyboardInterrupt: print("\nInterrupted by user") @@ -96,7 +116,4 @@ def main(radio_type: str = "waveshare"): if __name__ == "__main__": - import sys - - radio_type = sys.argv[1] if len(sys.argv) > 1 else "waveshare" - main(radio_type) + main() diff --git a/pyproject.toml b/pyproject.toml index 84eddf2..bd82bb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ hardware = [ "gpiozero>=2.0.0", "lgpio>=0.2.0", "spidev>=3.5", + "pyserial>=3.5", ] websocket = [ "websockets>=11.0.0", diff --git a/src/pymc_core/hardware/kiss_serial_wrapper.py b/src/pymc_core/hardware/kiss_serial_wrapper.py new file mode 100644 index 0000000..4132342 --- /dev/null +++ b/src/pymc_core/hardware/kiss_serial_wrapper.py @@ -0,0 +1,746 @@ +""" +KISS Serial Protocol Wrapper + +""" + +import asyncio +import logging +import serial +import threading +from collections import deque +from typing import Callable, Optional, List, Dict, Any + +from .base import LoRaRadio + +# KISS Protocol Constants +KISS_FEND = 0xC0 # Frame End +KISS_FESC = 0xDB # Frame Escape +KISS_TFEND = 0xDC # Transposed Frame End +KISS_TFESC = 0xDD # Transposed Frame Escape + +# KISS Command Masks +KISS_MASK_PORT = 0xF0 +KISS_MASK_CMD = 0x0F + +# KISS Commands +KISS_CMD_DATA = 0x00 +KISS_CMD_TXDELAY = 0x01 +KISS_CMD_PERSIST = 0x02 +KISS_CMD_SLOTTIME = 0x03 +KISS_CMD_TXTAIL = 0x04 +KISS_CMD_FULLDUP = 0x05 +KISS_CMD_VENDOR = 0x06 +KISS_CMD_RETURN = 0xFF + +# Buffer and timing constants +MAX_FRAME_SIZE = 512 +RX_BUFFER_SIZE = 1024 +TX_BUFFER_SIZE = 1024 +DEFAULT_BAUDRATE = 115200 +DEFAULT_TIMEOUT = 1.0 + +logger = logging.getLogger("KissSerialWrapper") + + +class KissSerialWrapper(LoRaRadio): + """ + KISS Serial Protocol Interface + + Provides full-duplex KISS protocol communication over serial port. + Handles frame encoding/decoding, buffering, and configuration commands. + Implements the LoRaRadio interface for PyMC Core compatibility. + """ + + def __init__( + self, + port: str, + baudrate: int = DEFAULT_BAUDRATE, + timeout: float = DEFAULT_TIMEOUT, + kiss_port: int = 0, + on_frame_received: Optional[Callable[[bytes], None]] = None, + radio_config: Optional[Dict[str, Any]] = None, + auto_configure: bool = True, + ): + """ + Initialize KISS Serial Wrapper + + Args: + port: Serial port device path (e.g., '/dev/ttyUSB0, /dev/cu.usbserial-0001, comm1 etc') + baudrate: Serial communication baud rate (default: 115200) + timeout: Serial read timeout in seconds (default: 1.0) + kiss_port: KISS port number (0-15, default: 0) + on_frame_received: Callback for received HDLC frames + radio_config: Optional radio configuration dict with keys: + frequency, bandwidth, sf, cr, sync_word, power, etc. + auto_configure: If True, automatically configure radio and enter KISS mode + """ + self.port = port + self.baudrate = baudrate + self.timeout = timeout + self.kiss_port = kiss_port & 0x0F # Ensure 4-bit port number + self.auto_configure = auto_configure + + + self.radio_config = radio_config or {} + self.is_configured = False + self.kiss_mode_active = 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 + + # KISS Configuration + self.config = { + 'txdelay': 30, # TX delay (units of 10ms) + 'persist': 64, # P parameter (0-255) + 'slottime': 10, # Slot time (units of 10ms) + 'txtail': 1, # TX tail time (units of 10ms) + 'fulldup': False, # Full duplex mode + } + + self.stats = { + 'frames_sent': 0, + 'frames_received': 0, + 'bytes_sent': 0, + 'bytes_received': 0, + 'frame_errors': 0, + 'buffer_overruns': 0, + 'last_rssi': None, + 'last_snr': None, + 'noise_floor': 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 serial connected to {self.port} at {self.baudrate} baud") + + # Auto-configure if requested + if self.auto_configure: + if not self.configure_radio_and_enter_kiss(): + logger.warning("Auto-configuration failed, KISS mode not active") + return False + + 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 serial disconnected from {self.port}") + + def send_frame(self, data: bytes) -> bool: + """ + Send a data frame via KISS protocol + + Args: + data: Raw frame data to send + + Returns: + True if frame queued successfully, False otherwise + """ + if not self.is_connected or len(data) > MAX_FRAME_SIZE: + return False + + try: + # Create KISS frame + kiss_frame = self._encode_kiss_frame(KISS_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_config_command(self, cmd: int, value: int) -> bool: + """ + Send KISS configuration command + + Args: + cmd: KISS command type (KISS_CMD_*) + value: Command parameter value + + Returns: + True if command sent successfully, False otherwise + """ + if not self.is_connected: + return False + + try: + # Update local config + if cmd == KISS_CMD_TXDELAY: + self.config['txdelay'] = value + elif cmd == KISS_CMD_PERSIST: + self.config['persist'] = value + elif cmd == KISS_CMD_SLOTTIME: + self.config['slottime'] = value + elif cmd == KISS_CMD_TXTAIL: + self.config['txtail'] = value + elif cmd == KISS_CMD_FULLDUP: + self.config['fulldup'] = bool(value) + + # Create and send KISS command frame + kiss_frame = self._encode_kiss_frame(cmd, bytes([value])) + + if len(self.tx_buffer) < TX_BUFFER_SIZE: + self.tx_buffer.append(kiss_frame) + return True + else: + self.stats['buffer_overruns'] += 1 + return False + + except Exception as e: + logger.error(f"Failed to send config command: {e}") + return False + + def get_stats(self) -> Dict[str, Any]: + """Get interface statistics""" + return self.stats.copy() + + def get_config(self) -> Dict[str, Any]: + """Get current KISS configuration""" + return self.config.copy() + + def configure_radio_and_enter_kiss(self) -> bool: + """ + Configure radio settings and enter KISS mode + + Returns: + True if configuration successful, False otherwise + """ + if not self.is_connected: + logger.error("Cannot configure radio: not connected") + return False + + try: + + if self.radio_config: + if not self._configure_radio(): + logger.error("Radio configuration failed") + return False + + if not self._enter_kiss_mode(): + logger.error("Failed to enter KISS mode") + return False + + self.kiss_mode_active = True + logger.info("Successfully configured radio and entered KISS mode") + return True + + except Exception as e: + logger.error(f"Configuration failed: {e}") + return False + + def _configure_radio(self) -> bool: + """ + Send radio configuration commands + + Returns: + True if configuration successful, False otherwise + """ + if not self.serial_conn or not self.serial_conn.is_open: + return False + + try: + # Build radio configuration command + # Format: "set radio ,,,," + config_parts = [] + + # Extract configuration parameters with defaults + frequency_hz = self.radio_config.get('frequency', int(916.75 * 1000000)) + bandwidth_hz = self.radio_config.get('bandwidth', int(500.0 * 1000)) + sf = self.radio_config.get('spreading_factor', 5) + cr = self.radio_config.get('coding_rate', 5) + sync_word = self.radio_config.get('sync_word', 0x12) + power = self.radio_config.get('power', 20) # Keep for future use + + # Convert Hz values to MHz/kHz for KISS command + frequency = frequency_hz / 1000000.0 # Convert Hz to MHz + bandwidth = bandwidth_hz / 1000.0 # Convert Hz to kHz + + # Format sync_word as hex if it's an integer + if isinstance(sync_word, int): + sync_word_str = f"0x{sync_word:02X}" + else: + sync_word_str = str(sync_word) + + # Build command string: set radio ,,,, + # Note: power parameter kept in config but not used in current command format + radio_cmd = f"set radio {frequency},{bandwidth},{sf},{cr},{sync_word_str}\r\n" + + # Send command + self.serial_conn.write(radio_cmd.encode('ascii')) + self.serial_conn.flush() + + # Wait for response + threading.Event().wait(0.5) + + # Read any response + response = "" + if self.serial_conn.in_waiting > 0: + response = self.serial_conn.read(self.serial_conn.in_waiting).decode('ascii', errors='ignore') + + logger.info(f"Radio config sent: {radio_cmd.strip()}") + if response: + logger.debug(f"Radio config response: {response.strip()}") + + self.is_configured = True + return True + + except Exception as e: + logger.error(f"Radio configuration error: {e}") + return False + + def _enter_kiss_mode(self) -> bool: + """ + Enter KISS serial mode + + Returns: + True if KISS mode entered successfully, False otherwise + """ + if not self.serial_conn or not self.serial_conn.is_open: + return False + + try: + # Send command to enter KISS mode + kiss_cmd = "serial mode kiss\r\n" + self.serial_conn.write(kiss_cmd.encode('ascii')) + self.serial_conn.flush() + + # Wait for mode switch + threading.Event().wait(1.0) + + # Read any response + response = "" + if self.serial_conn.in_waiting > 0: + response = self.serial_conn.read(self.serial_conn.in_waiting).decode('ascii', errors='ignore') + + logger.info("Entered KISS mode") + if response: + logger.debug(f"KISS mode response: {response.strip()}") + + return True + + except Exception as e: + logger.error(f"KISS mode entry error: {e}") + return False + + def exit_kiss_mode(self) -> bool: + """ + Exit KISS mode and return to CLI mode + + Returns: + True if successfully exited KISS mode, False otherwise + """ + if not self.is_connected or not self.kiss_mode_active: + return False + + try: + # Send KISS return command to exit mode + return_frame = self._encode_kiss_frame(KISS_CMD_RETURN, b'') + + if self.serial_conn and self.serial_conn.is_open: + self.serial_conn.write(return_frame) + self.serial_conn.flush() + + # Wait for mode switch + threading.Event().wait(1.0) + + self.kiss_mode_active = False + logger.info("Exited KISS mode") + return True + + except Exception as e: + logger.error(f"Failed to exit KISS mode: {e}") + + return False + + def send_cli_command(self, command: str) -> Optional[str]: + """ + Send a CLI command (only works when not in KISS mode) + + Args: + command: CLI command to send + + Returns: + Response string if available, None otherwise + """ + if not self.is_connected or self.kiss_mode_active or not self.serial_conn: + logger.error("Cannot send CLI command: not connected or in KISS mode") + return None + + try: + # Send command + cmd_line = f"{command}\r\n" + self.serial_conn.write(cmd_line.encode('ascii')) + self.serial_conn.flush() + + # Wait for response + threading.Event().wait(0.5) + + # Read response + response = "" + if self.serial_conn.in_waiting > 0: + response = self.serial_conn.read(self.serial_conn.in_waiting).decode('ascii', errors='ignore') + + logger.debug(f"CLI command: {command.strip()} -> {response.strip()}") + return response.strip() if response else None + + except Exception as e: + logger.error(f"CLI command error: {e}") + return None + + def set_rx_callback(self, callback: Callable[[bytes], None]): + """ + Set the RX callback function + + Args: + callback: Function to call when a frame is received + """ + self.on_frame_received = callback + logger.debug("RX callback set") + + def begin(self): + """ + Initialize the radio + """ + success = self.connect() + if not success: + raise Exception("Failed to initialize KISS radio") + + async def send(self, data: bytes) -> None: + """ + Send data via KISS TNC + + Args: + data: Data to send + + Raises: + Exception: If send fails + """ + success = self.send_frame(data) + if not success: + raise Exception("Failed to send frame via KISS TNC") + + async def wait_for_rx(self) -> bytes: + """ + Wait for a packet to be received asynchronously + + Returns: + Received packet data + """ + # Create a future to wait for the next received frame + future = asyncio.Future() + + # Store the original callback + original_callback = self.on_frame_received + + # Set a temporary callback that completes the future + def temp_callback(data: bytes): + if not future.done(): + future.set_result(data) + # Restore original callback if it exists + 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: + # Wait for the next frame + data = await future + return data + finally: + # Restore original callback + self.on_frame_received = original_callback + + def sleep(self): + """ + Put the radio into low-power mode + + Note: KISS TNCs typically don't have software sleep control + """ + logger.debug("Sleep mode not supported for KISS TNC") + pass + + def get_last_rssi(self) -> int: + """ + Return last received RSSI in dBm + + Returns: + Last RSSI value or -999 if not available + """ + return self.stats.get('last_rssi', -999) + + def get_last_snr(self) -> float: + """ + Return last received SNR in dB + + Returns: + Last SNR value or -999.0 if not available + """ + return self.stats.get('last_snr', -999.0) + + def _encode_kiss_frame(self, cmd: int, data: bytes) -> bytes: + """ + Encode data into KISS frame format + + Args: + cmd: KISS command byte + data: Raw data to encode + + Returns: + Encoded KISS frame + """ + # Create command byte with port number + cmd_byte = ((self.kiss_port << 4) & KISS_MASK_PORT) | (cmd & KISS_MASK_CMD) + + # Start with FEND and command + frame = bytearray([KISS_FEND, cmd_byte]) + + # 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) > 1: + # 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_byte = self.rx_frame_buffer[0] + port = (cmd_byte & KISS_MASK_PORT) >> 4 + cmd = cmd_byte & KISS_MASK_CMD + + # Check if frame is for our port + if port != self.kiss_port: + return + + # Extract data payload + data = bytes(self.rx_frame_buffer[1:]) + + if cmd == KISS_CMD_DATA: + # Data frame - emit to callback + if self.on_frame_received and len(data) > 0: + self.stats['frames_received'] += 1 + self.stats['bytes_received'] += len(data) + try: + self.on_frame_received(data) + except Exception as e: + logger.error(f"Error in frame received callback: {e}") + else: + # Configuration command response + logger.debug(f"Received KISS config command: cmd=0x{cmd:02X}, data={data.hex()}") + + 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: + # Read available bytes + data = self.serial_conn.read(self.serial_conn.in_waiting) + + # Process each byte through KISS decoder + for byte in data: + self._decode_kiss_byte(byte) + + else: + # Short sleep when no data available + threading.Event().wait(0.01) + + except Exception as e: + if self.is_connected: # Only log if we expect to be 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: + # Get frame from buffer + frame = self.tx_buffer.popleft() + + # Send via serial + 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: + # Short sleep when no data to send + threading.Event().wait(0.01) + + except Exception as e: + if self.is_connected: # Only log if we expect to be 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 # Ignore errors during destruction + + +if __name__ == "__main__": + # Example usage + import time + + def on_frame_received(data): + print(f"Received frame: {data.hex()}") + + # Radio configuration example + radio_config = { + 'frequency': int(916.75 * 1000000), # US: 916.75 MHz + 'bandwidth': int(500.0 * 1000), # 500 kHz + 'spreading_factor': 5, # LoRa SF5 + 'coding_rate': 5, # LoRa CR 4/5 + 'sync_word': 0x16, # Sync word + 'power': 20 # TX power + } + + # Initialize with auto-configuration + kiss = KissSerialWrapper( + port='/dev/ttyUSB0', + baudrate=115200, + radio_config=radio_config, + on_frame_received=on_frame_received + ) + + try: + if kiss.connect(): + print("Connected and configured successfully") + print(f"Configuration: {kiss.get_config()}") + print(f"Statistics: {kiss.get_stats()}") + + # Send a test frame + kiss.send_frame(b"Hello KISS World!") + + # Keep running for a bit + time.sleep(5) + else: + print("Failed to connect") + + except KeyboardInterrupt: + print("Interrupted by user") + finally: + kiss.disconnect() \ No newline at end of file From 8bd404d88cfc3931f48937fbd4fafb7b5be3db3f Mon Sep 17 00:00:00 2001 From: Lloyd Date: Wed, 29 Oct 2025 21:54:52 +0000 Subject: [PATCH 02/21] feat: Add KISS-TNC support --- docs/docs/examples.md | 6 +- examples/README.md | 14 +- examples/common.py | 43 +-- examples/ping_repeater_trace.py | 16 +- examples/send_channel_message.py | 16 +- examples/send_direct_advert.py | 16 +- examples/send_flood_advert.py | 16 +- examples/send_text_message.py | 16 +- examples/send_tracked_advert.py | 39 +- src/pymc_core/hardware/kiss_serial_wrapper.py | 348 +++++++++--------- src/pymc_core/hardware/sx1262_wrapper.py | 25 +- 11 files changed, 289 insertions(+), 266 deletions(-) diff --git a/docs/docs/examples.md b/docs/docs/examples.md index 7071cfe..9748475 100644 --- a/docs/docs/examples.md +++ b/docs/docs/examples.md @@ -10,7 +10,7 @@ This directory contains examples for using PyMC Core functionality. More example All examples support multiple radio types via `--radio-type` argument: -- `send_tracked_advert.py`: Send location-tracked advertisements +- `send_tracked_advert.py`: Send location-tracked advertisements - `send_direct_advert.py`: Send direct advertisements without mesh routing - `send_flood_advert.py`: Send flood advertisements that propagate through mesh - `send_text_message.py`: Send text messages to mesh nodes @@ -22,7 +22,7 @@ All examples support multiple radio types via `--radio-type` argument: ### SX1262 Direct Radio - **waveshare**: Waveshare SX1262 HAT for Raspberry Pi -- **uconsole**: ClockworkPi uConsole LoRa module +- **uconsole**: ClockworkPi uConsole LoRa module - **meshadv-mini**: MeshAdviser Mini board ### KISS TNC @@ -113,7 +113,7 @@ python examples/send_tracked_advert.py --radio-type kiss-tnc --serial-port /dev/ # Send text message via KISS TNC python examples/send_text_message.py --radio-type kiss-tnc --serial-port /dev/ttyUSB0 -# Send flood advert via KISS TNC +# Send flood advert via KISS TNC python examples/send_flood_advert.py --radio-type kiss-tnc --serial-port /dev/cu.usbserial-0001 # Send channel message via KISS TNC diff --git a/examples/README.md b/examples/README.md index 61b93b8..e24cb3f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,7 +6,7 @@ This directory contains examples demonstrating how to use PyMC Core with differe All examples support multiple radio types via `--radio-type` argument: -- **`send_tracked_advert.py`**: Send location-tracked advertisements +- **`send_tracked_advert.py`**: Send location-tracked advertisements - **`send_direct_advert.py`**: Send direct advertisements without mesh routing - **`send_text_message.py`**: Send text messages to mesh nodes - **`send_channel_message.py`**: Send messages to specific channels @@ -16,7 +16,7 @@ All examples support multiple radio types via `--radio-type` argument: ### Direct Radio (SX1262) - **waveshare**: Waveshare SX1262 HAT for Raspberry Pi -- **uconsole**: ClockworkPi uConsole LoRa module +- **uconsole**: ClockworkPi uConsole LoRa module - **meshadv-mini**: MeshAdviser Mini board ### KISS TNC @@ -95,7 +95,7 @@ meshadv_config = { ```python kiss_config = { 'frequency': int(869.525 * 1000000), # 869.525 MHz in Hz - 'bandwidth': int(250 * 1000), # 250 kHz in Hz + 'bandwidth': int(250 * 1000), # 250 kHz in Hz 'spreading_factor': 11, # LoRa SF11 'coding_rate': 5, # LoRa CR 4/5 'sync_word': 0x12, # Sync word @@ -128,7 +128,7 @@ python3 send_text_message.py --radio-type kiss-tnc --serial-port /dev/ttyUSB0 # Send direct advert via KISS TNC python3 send_direct_advert.py --radio-type kiss-tnc --serial-port /dev/cu.usbserial-0001 -# Send flood advert via KISS TNC +# Send flood advert via KISS TNC python3 send_flood_advert.py --radio-type kiss-tnc --serial-port /dev/ttyUSB0 # Send channel message via KISS TNC @@ -143,11 +143,11 @@ python3 ping_repeater_trace.py --radio-type kiss-tnc --serial-port /dev/cu.usbse Provides shared utilities for examples: - `create_radio(radio_type, serial_port)`: Create radio instances -- `create_mesh_node(name, radio_type, serial_port)`: Create mesh nodes +- `create_mesh_node(name, radio_type, serial_port)`: Create mesh nodes - `print_packet_info(packet, description)`: Debug packet information **Supported Radio Types:** -- `waveshare`: Waveshare SX1262 HAT +- `waveshare`: Waveshare SX1262 HAT - `uconsole`: ClockworkPi uConsole LoRa - `meshadv-mini`: MeshAdviser Mini board - `kiss-tnc`: KISS TNC devices @@ -184,4 +184,4 @@ Make sure pymc_core is properly installed: ```bash cd ../ pip install -e . -``` \ No newline at end of file +``` diff --git a/examples/common.py b/examples/common.py index e6f7f6d..8c796c6 100644 --- a/examples/common.py +++ b/examples/common.py @@ -12,7 +12,7 @@ # Set up logging logging.basicConfig( - level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) @@ -41,32 +41,33 @@ def create_radio(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0 # Check if this is a KISS TNC configuration if radio_type == "kiss-tnc": from pymc_core.hardware.kiss_serial_wrapper import KissSerialWrapper + logger.debug("Using KISS Serial Wrapper") - + # KISS TNC configuration kiss_config = { - 'frequency': int(869.525 * 1000000), # EU: 869.525 MHz - 'bandwidth': int(250 * 1000), # 250 kHz - 'spreading_factor': 11, # LoRa SF11 - 'coding_rate': 5, # LoRa CR 4/5 - 'sync_word': 0x12, # Sync word - 'power': 22 # TX power + "frequency": int(869.618 * 1000000), # EU: 869.525 MHz + "bandwidth": int(62.5 * 1000), # 250 kHz + "spreading_factor": 8, # LoRa SF11 + "coding_rate": 8, # LoRa CR 4/5 + "sync_word": 0x12, # Sync word + "power": 22, # TX power } - + # Create KISS wrapper with specified port kiss_wrapper = KissSerialWrapper( - port=serial_port, - baudrate=115200, - radio_config=kiss_config, - auto_configure=True + port=serial_port, baudrate=115200, radio_config=kiss_config, auto_configure=True ) - + logger.info("Created KISS Serial Wrapper") - logger.info(f"Frequency: {kiss_config['frequency']/1000000:.3f}MHz, TX Power: {kiss_config['power']}dBm") + logger.info( + f"Frequency: {kiss_config['frequency']/1000000:.3f}MHz, TX Power: {kiss_config['power']}dBm" + ) return kiss_wrapper - + # Direct SX1262 radio for other types from pymc_core.hardware.sx1262_wrapper import SX1262Radio + logger.debug("Imported SX1262Radio successfully") # Radio configurations for different hardware @@ -175,14 +176,14 @@ def create_mesh_node( logger.debug("Connecting KISS radio...") if radio.connect(): logger.info("KISS radio connected successfully") - print(f"✓ KISS radio connected to {serial_port}") - if hasattr(radio, 'kiss_mode_active') and radio.kiss_mode_active: - print("✓ KISS mode is active") + print(f"KISS radio connected to {serial_port}") + if hasattr(radio, "kiss_mode_active") and radio.kiss_mode_active: + print("KISS mode is active") else: - print("⚠ KISS mode may not be active") + print("Warning: KISS mode may not be active") else: logger.error("Failed to connect KISS radio") - print(f"✗ Failed to connect to KISS radio on {serial_port}") + print(f"Failed to connect to KISS radio on {serial_port}") raise Exception(f"KISS radio connection failed on {serial_port}") else: logger.debug("Calling radio.begin()...") diff --git a/examples/ping_repeater_trace.py b/examples/ping_repeater_trace.py index ff8c3db..340864f 100644 --- a/examples/ping_repeater_trace.py +++ b/examples/ping_repeater_trace.py @@ -82,26 +82,26 @@ def on_trace_response(success: bool, response_text: str, response_data: dict): def main(): """Main function for running the example.""" import argparse - + parser = argparse.ArgumentParser(description="Ping a repeater using trace packets") parser.add_argument( - "--radio-type", + "--radio-type", choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], default="waveshare", - help="Radio hardware type (default: waveshare)" + help="Radio hardware type (default: waveshare)", ) parser.add_argument( "--serial-port", - default="/dev/ttyUSB0", - help="Serial port for KISS TNC (default: /dev/ttyUSB0)" + default="/dev/ttyUSB0", + help="Serial port for KISS TNC (default: /dev/ttyUSB0)", ) - + args = parser.parse_args() - + print(f"Using {args.radio_type} radio configuration") if args.radio_type == "kiss-tnc": print(f"Serial port: {args.serial_port}") - + asyncio.run(ping_repeater(args.radio_type, args.serial_port)) diff --git a/examples/send_channel_message.py b/examples/send_channel_message.py index 227e2ef..0280506 100644 --- a/examples/send_channel_message.py +++ b/examples/send_channel_message.py @@ -67,26 +67,26 @@ async def send_channel_message(radio_type: str = "waveshare", serial_port: str = def main(): """Main function for running the example.""" import argparse - + parser = argparse.ArgumentParser(description="Send a channel message to the Public channel") parser.add_argument( - "--radio-type", + "--radio-type", choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], default="waveshare", - help="Radio hardware type (default: waveshare)" + help="Radio hardware type (default: waveshare)", ) parser.add_argument( "--serial-port", - default="/dev/ttyUSB0", - help="Serial port for KISS TNC (default: /dev/ttyUSB0)" + default="/dev/ttyUSB0", + help="Serial port for KISS TNC (default: /dev/ttyUSB0)", ) - + args = parser.parse_args() - + print(f"Using {args.radio_type} radio configuration") if args.radio_type == "kiss-tnc": print(f"Serial port: {args.serial_port}") - + try: packet, node = asyncio.run(send_channel_message(args.radio_type, args.serial_port)) print("Example completed") diff --git a/examples/send_direct_advert.py b/examples/send_direct_advert.py index 4dcd977..2953fc0 100644 --- a/examples/send_direct_advert.py +++ b/examples/send_direct_advert.py @@ -47,26 +47,26 @@ async def send_direct_advert(radio_type: str = "waveshare", serial_port: str = " def main(): """Main function for running the example.""" import argparse - + parser = argparse.ArgumentParser(description="Send a direct advertisement packet") parser.add_argument( - "--radio-type", + "--radio-type", choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], default="waveshare", - help="Radio hardware type (default: waveshare)" + help="Radio hardware type (default: waveshare)", ) parser.add_argument( "--serial-port", - default="/dev/ttyUSB0", - help="Serial port for KISS TNC (default: /dev/ttyUSB0)" + default="/dev/ttyUSB0", + help="Serial port for KISS TNC (default: /dev/ttyUSB0)", ) - + args = parser.parse_args() - + print(f"Using {args.radio_type} radio configuration") if args.radio_type == "kiss-tnc": print(f"Serial port: {args.serial_port}") - + asyncio.run(send_direct_advert(args.radio_type, args.serial_port)) diff --git a/examples/send_flood_advert.py b/examples/send_flood_advert.py index 9a812fd..d6288ef 100644 --- a/examples/send_flood_advert.py +++ b/examples/send_flood_advert.py @@ -54,26 +54,26 @@ async def send_flood_advert(radio_type: str = "waveshare", serial_port: str = "/ def main(): """Main function for running the example.""" import argparse - + parser = argparse.ArgumentParser(description="Send a flood advertisement packet") parser.add_argument( - "--radio-type", + "--radio-type", choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], default="waveshare", - help="Radio hardware type (default: waveshare)" + help="Radio hardware type (default: waveshare)", ) parser.add_argument( "--serial-port", - default="/dev/ttyUSB0", - help="Serial port for KISS TNC (default: /dev/ttyUSB0)" + default="/dev/ttyUSB0", + help="Serial port for KISS TNC (default: /dev/ttyUSB0)", ) - + args = parser.parse_args() - + print(f"Using {args.radio_type} radio configuration") if args.radio_type == "kiss-tnc": print(f"Serial port: {args.serial_port}") - + asyncio.run(send_flood_advert(args.radio_type, args.serial_port)) diff --git a/examples/send_text_message.py b/examples/send_text_message.py index 9b86adb..01bfcf1 100644 --- a/examples/send_text_message.py +++ b/examples/send_text_message.py @@ -74,26 +74,26 @@ def __init__(self, name, pubkey_hex): def main(): """Main function for running the example.""" import argparse - + parser = argparse.ArgumentParser(description="Send a text message to the mesh network") parser.add_argument( - "--radio-type", + "--radio-type", choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], default="waveshare", - help="Radio hardware type (default: waveshare)" + help="Radio hardware type (default: waveshare)", ) parser.add_argument( "--serial-port", - default="/dev/ttyUSB0", - help="Serial port for KISS TNC (default: /dev/ttyUSB0)" + default="/dev/ttyUSB0", + help="Serial port for KISS TNC (default: /dev/ttyUSB0)", ) - + args = parser.parse_args() - + print(f"Using {args.radio_type} radio configuration") if args.radio_type == "kiss-tnc": print(f"Serial port: {args.serial_port}") - + try: packet, node = asyncio.run(send_text_message(args.radio_type, args.serial_port)) print("Example completed") diff --git a/examples/send_tracked_advert.py b/examples/send_tracked_advert.py index 2ff6712..079fa24 100644 --- a/examples/send_tracked_advert.py +++ b/examples/send_tracked_advert.py @@ -25,20 +25,22 @@ repeat_count = 0 -async def simple_repeat_counter(packet, raw_data=None): - """Simple handler that just counts advert repeats.""" +def simple_repeat_counter(raw_data: bytes): + """Simple handler that just counts packet repeats.""" global repeat_count try: - # Check if this is an advert packet - if hasattr(packet, "get_payload_type") and packet.get_payload_type() == PAYLOAD_TYPE_ADVERT: - repeat_count += 1 - print(f"ADVERT REPEAT HEARD #{repeat_count}") + # Simple check - just count any received packet as a potential repeat + # I have kept it simple but you would want to check if its actaully a advert etc. + repeat_count += 1 + print(f"PACKET REPEAT HEARD #{repeat_count} ({len(raw_data)} bytes)") except Exception as e: print(f"Error processing packet: {e}") -async def send_simple_tracked_advert(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0"): +async def send_simple_tracked_advert( + radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0" +): """Send a tracked advert and count responses.""" global repeat_count @@ -57,8 +59,9 @@ async def send_simple_tracked_advert(radio_type: str = "waveshare", serial_port: ) print_packet_info(advert_packet, "Created advert packet") - print("Sending advert...") + # Send the packet + print("\nSending advert...") success = await mesh_node.dispatcher.send_packet(advert_packet, wait_for_ack=False) if success: @@ -66,8 +69,8 @@ async def send_simple_tracked_advert(radio_type: str = "waveshare", serial_port: print("Listening for repeats... (Ctrl+C to stop)") print("-" * 40) - # Set up simple repeat counter - mesh_node.dispatcher.set_packet_received_callback(simple_repeat_counter) + # Set up simple repeat counter directly on the radio + mesh_node.radio.set_rx_callback(simple_repeat_counter) # Listen continuously try: @@ -86,26 +89,26 @@ async def send_simple_tracked_advert(radio_type: str = "waveshare", serial_port: def main(): """Main function for running the example.""" import argparse - + parser = argparse.ArgumentParser(description="Send a location-tracked advertisement") parser.add_argument( - "--radio-type", + "--radio-type", choices=["waveshare", "uconsole", "meshadv-mini", "kiss-tnc"], default="waveshare", - help="Radio hardware type (default: waveshare)" + help="Radio hardware type (default: waveshare)", ) parser.add_argument( "--serial-port", - default="/dev/ttyUSB0", - help="Serial port for KISS TNC (default: /dev/ttyUSB0)" + default="/dev/ttyUSB0", + help="Serial port for KISS TNC (default: /dev/ttyUSB0)", ) - + args = parser.parse_args() - + print(f"Using {args.radio_type} radio configuration") if args.radio_type == "kiss-tnc": print(f"Serial port: {args.serial_port}") - + try: packet, node = asyncio.run(send_simple_tracked_advert(args.radio_type, args.serial_port)) print("Example completed") diff --git a/src/pymc_core/hardware/kiss_serial_wrapper.py b/src/pymc_core/hardware/kiss_serial_wrapper.py index 4132342..7701cd9 100644 --- a/src/pymc_core/hardware/kiss_serial_wrapper.py +++ b/src/pymc_core/hardware/kiss_serial_wrapper.py @@ -5,18 +5,19 @@ import asyncio import logging -import serial import threading from collections import deque -from typing import Callable, Optional, List, Dict, Any +from typing import Any, Callable, Dict, Optional + +import serial from .base import LoRaRadio # KISS Protocol Constants -KISS_FEND = 0xC0 # Frame End -KISS_FESC = 0xDB # Frame Escape -KISS_TFEND = 0xDC # Transposed Frame End -KISS_TFESC = 0xDD # Transposed Frame Escape +KISS_FEND = 0xC0 # Frame End +KISS_FESC = 0xDB # Frame Escape +KISS_TFEND = 0xDC # Transposed Frame End +KISS_TFESC = 0xDD # Transposed Frame Escape # KISS Command Masks KISS_MASK_PORT = 0xF0 @@ -45,7 +46,7 @@ class KissSerialWrapper(LoRaRadio): """ KISS Serial Protocol Interface - + Provides full-duplex KISS protocol communication over serial port. Handles frame encoding/decoding, buffering, and configuration commands. Implements the LoRaRadio interface for PyMC Core compatibility. @@ -63,7 +64,7 @@ def __init__( ): """ Initialize KISS Serial Wrapper - + Args: port: Serial port device path (e.g., '/dev/ttyUSB0, /dev/cu.usbserial-0001, comm1 etc') baudrate: Serial communication baud rate (default: 115200) @@ -79,54 +80,53 @@ def __init__( self.timeout = timeout self.kiss_port = kiss_port & 0x0F # Ensure 4-bit port number self.auto_configure = auto_configure - self.radio_config = radio_config or {} self.is_configured = False self.kiss_mode_active = 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 - + # KISS Configuration self.config = { - 'txdelay': 30, # TX delay (units of 10ms) - 'persist': 64, # P parameter (0-255) - 'slottime': 10, # Slot time (units of 10ms) - 'txtail': 1, # TX tail time (units of 10ms) - 'fulldup': False, # Full duplex mode + "txdelay": 30, # TX delay (units of 10ms) + "persist": 64, # P parameter (0-255) + "slottime": 10, # Slot time (units of 10ms) + "txtail": 1, # TX tail time (units of 10ms) + "fulldup": False, # Full duplex mode } - + self.stats = { - 'frames_sent': 0, - 'frames_received': 0, - 'bytes_sent': 0, - 'bytes_received': 0, - 'frame_errors': 0, - 'buffer_overruns': 0, - 'last_rssi': None, - 'last_snr': None, - 'noise_floor': None, + "frames_sent": 0, + "frames_received": 0, + "bytes_sent": 0, + "bytes_received": 0, + "frame_errors": 0, + "buffer_overruns": 0, + "last_rssi": None, + "last_snr": None, + "noise_floor": None, } def connect(self) -> bool: """ Connect to serial port and start communication threads - + Returns: True if connection successful, False otherwise """ @@ -139,27 +139,27 @@ def connect(self) -> bool: 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 serial connected to {self.port} at {self.baudrate} baud") - + # Auto-configure if requested if self.auto_configure: if not self.configure_radio_and_enter_kiss(): logger.warning("Auto-configuration failed, KISS mode not active") return False - + return True - + except Exception as e: logger.error(f"Failed to connect to {self.port}: {e}") self.is_connected = False @@ -169,45 +169,49 @@ 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 serial disconnected from {self.port}") def send_frame(self, data: bytes) -> bool: """ Send a data frame via KISS protocol - + Args: data: Raw frame data to send - + Returns: True if frame queued successfully, False otherwise """ if not self.is_connected or len(data) > MAX_FRAME_SIZE: + logger.warning( + f"Cannot send frame - connected: {self.is_connected}, " + f"size: {len(data)}/{MAX_FRAME_SIZE}" + ) return False - + try: # Create KISS frame kiss_frame = self._encode_kiss_frame(KISS_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 + 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 @@ -215,40 +219,40 @@ def send_frame(self, data: bytes) -> bool: def send_config_command(self, cmd: int, value: int) -> bool: """ Send KISS configuration command - + Args: cmd: KISS command type (KISS_CMD_*) value: Command parameter value - + Returns: True if command sent successfully, False otherwise """ if not self.is_connected: return False - + try: # Update local config if cmd == KISS_CMD_TXDELAY: - self.config['txdelay'] = value + self.config["txdelay"] = value elif cmd == KISS_CMD_PERSIST: - self.config['persist'] = value + self.config["persist"] = value elif cmd == KISS_CMD_SLOTTIME: - self.config['slottime'] = value + self.config["slottime"] = value elif cmd == KISS_CMD_TXTAIL: - self.config['txtail'] = value + self.config["txtail"] = value elif cmd == KISS_CMD_FULLDUP: - self.config['fulldup'] = bool(value) - + self.config["fulldup"] = bool(value) + # Create and send KISS command frame kiss_frame = self._encode_kiss_frame(cmd, bytes([value])) - + if len(self.tx_buffer) < TX_BUFFER_SIZE: self.tx_buffer.append(kiss_frame) return True else: - self.stats['buffer_overruns'] += 1 + self.stats["buffer_overruns"] += 1 return False - + except Exception as e: logger.error(f"Failed to send config command: {e}") return False @@ -264,29 +268,28 @@ def get_config(self) -> Dict[str, Any]: def configure_radio_and_enter_kiss(self) -> bool: """ Configure radio settings and enter KISS mode - + Returns: True if configuration successful, False otherwise """ if not self.is_connected: logger.error("Cannot configure radio: not connected") return False - - try: + try: if self.radio_config: if not self._configure_radio(): logger.error("Radio configuration failed") return False - + if not self._enter_kiss_mode(): logger.error("Failed to enter KISS mode") return False - + self.kiss_mode_active = True logger.info("Successfully configured radio and entered KISS mode") return True - + except Exception as e: logger.error(f"Configuration failed: {e}") return False @@ -294,59 +297,58 @@ def configure_radio_and_enter_kiss(self) -> bool: def _configure_radio(self) -> bool: """ Send radio configuration commands - + Returns: True if configuration successful, False otherwise """ if not self.serial_conn or not self.serial_conn.is_open: return False - - try: - # Build radio configuration command - # Format: "set radio ,,,," - config_parts = [] + try: # Extract configuration parameters with defaults - frequency_hz = self.radio_config.get('frequency', int(916.75 * 1000000)) - bandwidth_hz = self.radio_config.get('bandwidth', int(500.0 * 1000)) - sf = self.radio_config.get('spreading_factor', 5) - cr = self.radio_config.get('coding_rate', 5) - sync_word = self.radio_config.get('sync_word', 0x12) - power = self.radio_config.get('power', 20) # Keep for future use - + frequency_hz = self.radio_config.get("frequency", int(916.75 * 1000000)) + bandwidth_hz = self.radio_config.get("bandwidth", int(500.0 * 1000)) + sf = self.radio_config.get("spreading_factor", 5) + cr = self.radio_config.get("coding_rate", 5) + sync_word = self.radio_config.get("sync_word", 0x12) + power = self.radio_config.get("power", 20) # noqa: F841 - kept for future use + # Convert Hz values to MHz/kHz for KISS command frequency = frequency_hz / 1000000.0 # Convert Hz to MHz - bandwidth = bandwidth_hz / 1000.0 # Convert Hz to kHz - + bandwidth = bandwidth_hz / 1000.0 # Convert Hz to kHz + # Format sync_word as hex if it's an integer if isinstance(sync_word, int): sync_word_str = f"0x{sync_word:02X}" else: sync_word_str = str(sync_word) - + # Build command string: set radio ,,,, # Note: power parameter kept in config but not used in current command format radio_cmd = f"set radio {frequency},{bandwidth},{sf},{cr},{sync_word_str}\r\n" - + logger.info(radio_cmd) + # Send command - self.serial_conn.write(radio_cmd.encode('ascii')) + self.serial_conn.write(radio_cmd.encode("ascii")) self.serial_conn.flush() - + # Wait for response threading.Event().wait(0.5) - + # Read any response response = "" if self.serial_conn.in_waiting > 0: - response = self.serial_conn.read(self.serial_conn.in_waiting).decode('ascii', errors='ignore') - + response = self.serial_conn.read(self.serial_conn.in_waiting).decode( + "ascii", errors="ignore" + ) + logger.info(f"Radio config sent: {radio_cmd.strip()}") if response: logger.debug(f"Radio config response: {response.strip()}") - + self.is_configured = True return True - + except Exception as e: logger.error(f"Radio configuration error: {e}") return False @@ -354,33 +356,35 @@ def _configure_radio(self) -> bool: def _enter_kiss_mode(self) -> bool: """ Enter KISS serial mode - + Returns: True if KISS mode entered successfully, False otherwise """ if not self.serial_conn or not self.serial_conn.is_open: return False - + try: # Send command to enter KISS mode kiss_cmd = "serial mode kiss\r\n" - self.serial_conn.write(kiss_cmd.encode('ascii')) + self.serial_conn.write(kiss_cmd.encode("ascii")) self.serial_conn.flush() - + # Wait for mode switch threading.Event().wait(1.0) - + # Read any response response = "" if self.serial_conn.in_waiting > 0: - response = self.serial_conn.read(self.serial_conn.in_waiting).decode('ascii', errors='ignore') - + response = self.serial_conn.read(self.serial_conn.in_waiting).decode( + "ascii", errors="ignore" + ) + logger.info("Entered KISS mode") if response: logger.debug(f"KISS mode response: {response.strip()}") - + return True - + except Exception as e: logger.error(f"KISS mode entry error: {e}") return False @@ -388,64 +392,66 @@ def _enter_kiss_mode(self) -> bool: def exit_kiss_mode(self) -> bool: """ Exit KISS mode and return to CLI mode - + Returns: True if successfully exited KISS mode, False otherwise """ if not self.is_connected or not self.kiss_mode_active: return False - + try: # Send KISS return command to exit mode - return_frame = self._encode_kiss_frame(KISS_CMD_RETURN, b'') - + return_frame = self._encode_kiss_frame(KISS_CMD_RETURN, b"") + if self.serial_conn and self.serial_conn.is_open: self.serial_conn.write(return_frame) self.serial_conn.flush() - + # Wait for mode switch threading.Event().wait(1.0) - + self.kiss_mode_active = False logger.info("Exited KISS mode") return True - + except Exception as e: logger.error(f"Failed to exit KISS mode: {e}") - + return False def send_cli_command(self, command: str) -> Optional[str]: """ Send a CLI command (only works when not in KISS mode) - + Args: command: CLI command to send - + Returns: Response string if available, None otherwise """ if not self.is_connected or self.kiss_mode_active or not self.serial_conn: logger.error("Cannot send CLI command: not connected or in KISS mode") return None - + try: # Send command cmd_line = f"{command}\r\n" - self.serial_conn.write(cmd_line.encode('ascii')) + self.serial_conn.write(cmd_line.encode("ascii")) self.serial_conn.flush() - + # Wait for response threading.Event().wait(0.5) - + # Read response response = "" if self.serial_conn.in_waiting > 0: - response = self.serial_conn.read(self.serial_conn.in_waiting).decode('ascii', errors='ignore') - + response = self.serial_conn.read(self.serial_conn.in_waiting).decode( + "ascii", errors="ignore" + ) + logger.debug(f"CLI command: {command.strip()} -> {response.strip()}") return response.strip() if response else None - + except Exception as e: logger.error(f"CLI command error: {e}") return None @@ -453,7 +459,7 @@ def send_cli_command(self, command: str) -> Optional[str]: def set_rx_callback(self, callback: Callable[[bytes], None]): """ Set the RX callback function - + Args: callback: Function to call when a frame is received """ @@ -471,10 +477,10 @@ def begin(self): async def send(self, data: bytes) -> None: """ Send data via KISS TNC - + Args: data: Data to send - + Raises: Exception: If send fails """ @@ -485,16 +491,16 @@ async def send(self, data: bytes) -> None: async def wait_for_rx(self) -> bytes: """ Wait for a packet to be received asynchronously - + Returns: Received packet data """ # Create a future to wait for the next received frame future = asyncio.Future() - + # Store the original callback original_callback = self.on_frame_received - + # Set a temporary callback that completes the future def temp_callback(data: bytes): if not future.done(): @@ -505,9 +511,9 @@ def temp_callback(data: bytes): original_callback(data) except Exception as e: logger.error(f"Error in original callback: {e}") - + self.on_frame_received = temp_callback - + try: # Wait for the next frame data = await future @@ -519,7 +525,7 @@ def temp_callback(data: bytes): def sleep(self): """ Put the radio into low-power mode - + Note: KISS TNCs typically don't have software sleep control """ logger.debug("Sleep mode not supported for KISS TNC") @@ -528,38 +534,38 @@ def sleep(self): def get_last_rssi(self) -> int: """ Return last received RSSI in dBm - + Returns: Last RSSI value or -999 if not available """ - return self.stats.get('last_rssi', -999) + return self.stats.get("last_rssi", -999) def get_last_snr(self) -> float: """ Return last received SNR in dB - + Returns: - Last SNR value or -999.0 if not available + Last SNR value or -999.0 if not available """ - return self.stats.get('last_snr', -999.0) + return self.stats.get("last_snr", -999.0) def _encode_kiss_frame(self, cmd: int, data: bytes) -> bytes: """ Encode data into KISS frame format - + Args: cmd: KISS command byte data: Raw data to encode - + Returns: Encoded KISS frame """ # Create command byte with port number cmd_byte = ((self.kiss_port << 4) & KISS_MASK_PORT) | (cmd & KISS_MASK_CMD) - + # Start with FEND and command frame = bytearray([KISS_FEND, cmd_byte]) - + # Escape and add data for byte in data: if byte == KISS_FEND: @@ -568,16 +574,16 @@ def _encode_kiss_frame(self, cmd: int, data: bytes) -> bytes: 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 """ @@ -589,11 +595,11 @@ def _decode_kiss_byte(self, byte: int): 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) @@ -601,10 +607,10 @@ def _decode_kiss_byte(self, byte: int): self.rx_frame_buffer.append(KISS_FESC) else: # Invalid escape sequence - self.stats['frame_errors'] += 1 + 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) @@ -613,24 +619,24 @@ def _process_received_frame(self): """Process a complete received KISS frame""" if len(self.rx_frame_buffer) < 1: return - + # Extract command byte cmd_byte = self.rx_frame_buffer[0] port = (cmd_byte & KISS_MASK_PORT) >> 4 cmd = cmd_byte & KISS_MASK_CMD - + # Check if frame is for our port if port != self.kiss_port: return - + # Extract data payload data = bytes(self.rx_frame_buffer[1:]) - + if cmd == KISS_CMD_DATA: # Data frame - emit to callback if self.on_frame_received and len(data) > 0: - self.stats['frames_received'] += 1 - self.stats['bytes_received'] += len(data) + self.stats["frames_received"] += 1 + self.stats["bytes_received"] += len(data) try: self.on_frame_received(data) except Exception as e: @@ -646,15 +652,15 @@ def _rx_worker(self): if self.serial_conn and self.serial_conn.in_waiting > 0: # Read available bytes data = self.serial_conn.read(self.serial_conn.in_waiting) - + # Process each byte through KISS decoder for byte in data: self._decode_kiss_byte(byte) - + else: # Short sleep when no data available threading.Event().wait(0.01) - + except Exception as e: if self.is_connected: # Only log if we expect to be connected logger.error(f"RX worker error: {e}") @@ -667,18 +673,20 @@ def _tx_worker(self): if self.tx_buffer: # Get frame from buffer frame = self.tx_buffer.popleft() - + # Send via serial 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) + + self.stats["frames_sent"] += 1 + self.stats["bytes_sent"] += len(frame) + else: + logger.warning("Serial connection not open or not available") else: # Short sleep when no data to send threading.Event().wait(0.01) - + except Exception as e: if self.is_connected: # Only log if we expect to be connected logger.error(f"TX worker error: {e}") @@ -704,43 +712,43 @@ def __del__(self): if __name__ == "__main__": # Example usage import time - + def on_frame_received(data): print(f"Received frame: {data.hex()}") - + # Radio configuration example radio_config = { - 'frequency': int(916.75 * 1000000), # US: 916.75 MHz - 'bandwidth': int(500.0 * 1000), # 500 kHz - 'spreading_factor': 5, # LoRa SF5 - 'coding_rate': 5, # LoRa CR 4/5 - 'sync_word': 0x16, # Sync word - 'power': 20 # TX power + "frequency": int(916.75 * 1000000), # US: 916.75 MHz + "bandwidth": int(500.0 * 1000), # 500 kHz + "spreading_factor": 5, # LoRa SF5 + "coding_rate": 5, # LoRa CR 4/5 + "sync_word": 0x16, # Sync word + "power": 20, # TX power } - + # Initialize with auto-configuration kiss = KissSerialWrapper( - port='/dev/ttyUSB0', + port="/dev/ttyUSB0", baudrate=115200, radio_config=radio_config, - on_frame_received=on_frame_received + on_frame_received=on_frame_received, ) - + try: if kiss.connect(): print("Connected and configured successfully") print(f"Configuration: {kiss.get_config()}") print(f"Statistics: {kiss.get_stats()}") - + # Send a test frame kiss.send_frame(b"Hello KISS World!") - + # Keep running for a bit time.sleep(5) else: print("Failed to connect") - + except KeyboardInterrupt: print("Interrupted by user") finally: - kiss.disconnect() \ No newline at end of file + kiss.disconnect() diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index 527087e..0486ac0 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -195,7 +195,8 @@ def __init__( logger.info( f"SX1262Radio configured: freq={frequency/1e6:.1f}MHz, " - f"power={tx_power}dBm, sf={spreading_factor}, bw={bandwidth/1000:.1f}kHz, pre={preamble_length}" + f"power={tx_power}dBm, sf={spreading_factor}, " + f"bw={bandwidth/1000:.1f}kHz, pre={preamble_length}" ) # Register this instance as the active radio for IRQ callback access SX1262Radio._active_instance = self @@ -526,10 +527,15 @@ def begin(self) -> bool: self.lora.setBufferBaseAddress(0x00, 0x80) # TX=0x00, RX=0x80 # Enable LDRO if symbol duration > 16ms (SF11/62.5kHz = 32.768ms) - symbol_duration_ms = (2 ** self.spreading_factor) / (self.bandwidth / 1000) + symbol_duration_ms = (2**self.spreading_factor) / (self.bandwidth / 1000) ldro = symbol_duration_ms > 16.0 - logger.info(f"LDRO {'enabled' if ldro else 'disabled'} (symbol duration: {symbol_duration_ms:.3f}ms)") - self.lora.setLoRaModulation(self.spreading_factor, self.bandwidth, self.coding_rate, ldro) + logger.info( + f"LDRO {'enabled' if ldro else 'disabled'} " + f"(symbol duration: {symbol_duration_ms:.3f}ms)" + ) + self.lora.setLoRaModulation( + self.spreading_factor, self.bandwidth, self.coding_rate, ldro + ) self.lora.setLoRaPacket( self.lora.HEADER_EXPLICIT, @@ -569,10 +575,15 @@ def begin(self) -> bool: # Configure modulation and packet parameters # Enable LDRO if symbol duration > 16ms (SF11/62.5kHz = 32.768ms) - symbol_duration_ms = (2 ** self.spreading_factor) / (self.bandwidth / 1000) + symbol_duration_ms = (2**self.spreading_factor) / (self.bandwidth / 1000) ldro = symbol_duration_ms > 16.0 - logger.info(f"LDRO {'enabled' if ldro else 'disabled'} (symbol duration: {symbol_duration_ms:.3f}ms)") - self.lora.setLoRaModulation(self.spreading_factor, self.bandwidth, self.coding_rate, ldro) + logger.info( + f"LDRO {'enabled' if ldro else 'disabled'} " + f"(symbol duration: {symbol_duration_ms:.3f}ms)" + ) + self.lora.setLoRaModulation( + self.spreading_factor, self.bandwidth, self.coding_rate, ldro + ) self.lora.setPacketParamsLoRa( self.preamble_length, self.lora.HEADER_EXPLICIT, From 6f8637c7aa20b7c40c11b1ed093ab04225471d5e Mon Sep 17 00:00:00 2001 From: Lloyd Date: Thu, 30 Oct 2025 12:18:51 +0000 Subject: [PATCH 03/21] feat: Implement Channel Activity Detection (CAD) functionality in SX1262Radio --- src/pymc_core/hardware/lora/LoRaRF/SX126x.py | 15 ++ src/pymc_core/hardware/sx1262_wrapper.py | 137 +++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/src/pymc_core/hardware/lora/LoRaRF/SX126x.py b/src/pymc_core/hardware/lora/LoRaRF/SX126x.py index dd1a88b..d015956 100644 --- a/src/pymc_core/hardware/lora/LoRaRF/SX126x.py +++ b/src/pymc_core/hardware/lora/LoRaRF/SX126x.py @@ -1483,3 +1483,18 @@ def _readBytes(self, opCode: int, nBytes: int, address: tuple = (), nAddress: in _get_output(self._cs_define).on() return tuple(feedback[nAddress + 1 :]) + + + def start_cad(self, det_peak: int, det_min: int): + """Start CAD with given thresholds.""" + self.clearIrqStatus(0xFFFF) + self.setCadParams( + self.CAD_ON_8_SYMB, + det_peak, + det_min, + self.CAD_EXIT_STDBY, + 0xFFFFFF, + ) + mask = self.IRQ_CAD_DONE | self.IRQ_CAD_DETECTED + self.setDioIrqParams(mask, mask, self.IRQ_NONE, self.IRQ_NONE) + self.setCad() diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index 0486ac0..8cd2e07 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -192,6 +192,7 @@ def __init__( self._tx_done_event = asyncio.Event() self._rx_done_event = asyncio.Event() + self._cad_event = asyncio.Event() logger.info( f"SX1262Radio configured: freq={frequency/1e6:.1f}MHz, " @@ -271,6 +272,14 @@ def _handle_interrupt(self): logger.debug("[TX] TX_DONE interrupt (0x{:04X})".format(self.lora.IRQ_TX_DONE)) self._tx_done_event.set() + # Check for CAD interrupts + cad_detected_flag = getattr(self.lora, 'IRQ_CAD_DETECTED', 0x4000) + cad_done_flag = getattr(self.lora, 'IRQ_CAD_DONE', 0x8000) + if irqStat & (cad_detected_flag | cad_done_flag): + logger.debug(f"[CAD] CAD interrupt detected (0x{irqStat:04X})") + if hasattr(self, '_cad_event'): + self._cad_event.set() + # Check each RX interrupt type separately for better debugging rx_interrupts = self._get_rx_irq_mask() if irqStat & self.lora.IRQ_RX_DONE: @@ -1018,6 +1027,134 @@ def get_status(self) -> dict: status["hardware_ready"] = False return status + + + + + def _get_thresholds_for_current_settings(self) -> tuple[int, int]: + """Fetch CAD thresholds for the current spreading factor. + Returns (cadDetPeak, cadDetMin). + """ + + # Default CAD thresholds by SF (based on Semtech TR013 recommendations) + DEFAULT_CAD_THRESHOLDS = { + 7: (22, 10), + 8: (22, 10), + 9: (24, 10), + 10: (25, 10), + 11: (26, 10), + 12: (30, 10), + } + + # Fall back to SF7 values if unknown + return DEFAULT_CAD_THRESHOLDS.get(self.spreading_factor, (22, 10)) + + + + + async def perform_cad( + self, + det_peak: int | None = None, + det_min: int | None = None, + timeout: float = 1.0, + calibration: bool = False, + ) -> bool | dict: + """ + Perform Channel Activity Detection (CAD). + If calibration=True, uses provided thresholds and returns detailed info. + If calibration=False, uses pre-calibrated/default thresholds. + + Returns: + bool: Channel activity detected (when calibration=False) + dict: Calibration data (when calibration=True) + """ + if not self._initialized: + raise RuntimeError("Radio not initialized") + + if not self.lora: + raise RuntimeError("LoRa radio object not available") + + # Choose thresholds + if det_peak is None or det_min is None: + det_peak, det_min = self._get_thresholds_for_current_settings() + + try: + # Clear any existing interrupt flags + existing_irq = self.lora.getIrqStatus() + if existing_irq != 0: + self.lora.clearIrqStatus(existing_irq) + + # Clear CAD event before starting + self._cad_event.clear() + + # Start CAD operation using the driver's start_cad method + self.lora.start_cad(det_peak, det_min) + + # Wait for CAD completion + try: + await asyncio.wait_for(self._cad_event.wait(), timeout=timeout) + self._cad_event.clear() + + # Read interrupt status + irq = self.lora.getIrqStatus() + self.lora.clearIrqStatus(irq) + + # Check for CAD detection + cad_detected_flag = getattr(self.lora, 'IRQ_CAD_DETECTED', 0x4000) + detected = bool(irq & cad_detected_flag) + + if calibration: + return { + "sf": self.spreading_factor, + "bw": self.bandwidth, + "det_peak": det_peak, + "det_min": det_min, + "detected": detected, + "timestamp": time.time(), + "irq_status": irq + } + else: + return detected + + except asyncio.TimeoutError: + logger.debug("CAD operation timed out") + if calibration: + return { + "sf": self.spreading_factor, + "bw": self.bandwidth, + "det_peak": det_peak, + "det_min": det_min, + "detected": False, + "timestamp": time.time(), + "timeout": True + } + else: + return False + + except Exception as e: + logger.error(f"CAD operation failed: {e}") + if calibration: + return { + "sf": self.spreading_factor, + "bw": self.bandwidth, + "det_peak": det_peak, + "det_min": det_min, + "detected": False, + "timestamp": time.time(), + "error": str(e) + } + else: + return False + finally: + # Restore RX mode after CAD + try: + rx_mask = self._get_rx_irq_mask() + self.lora.setDioIrqParams(rx_mask, rx_mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE) + self.lora.setRx(self.lora.RX_CONTINUOUS) + except Exception as e: + logger.warning(f"Failed to restore RX mode after CAD: {e}") + + def cleanup(self) -> None: """Clean up radio resources""" From 57f6a54614dc140906d97e76bd5662d049742237 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Thu, 30 Oct 2025 12:35:07 +0000 Subject: [PATCH 04/21] feat: Add CAD calibration example for SX1262 radios --- examples/calibrate_cad.py | 272 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 examples/calibrate_cad.py diff --git a/examples/calibrate_cad.py b/examples/calibrate_cad.py new file mode 100644 index 0000000..e018ed5 --- /dev/null +++ b/examples/calibrate_cad.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +""" +CAD Calibration Example: Calibrate Channel Activity Detection thresholds. + +This example helps calibrate CAD (Channel Activity Detection) thresholds +for optimal performance. It performs multiple CAD operations with different +threshold values and provides recommendations. + +Note: This example only works with SX1262 radios, not KISS TNC devices. +""" + +import asyncio +import statistics +import time +from typing import List, Dict, Any + +from common import create_radio + +# CAD calibration results storage +cad_results: List[Dict[str, Any]] = [] + + +async def perform_cad_sweep(radio, det_peak_range: range, det_min_range: range, samples_per_config: int = 5) -> List[Dict[str, Any]]: + """Perform a CAD sweep across threshold ranges.""" + results = [] + total_configs = len(det_peak_range) * len(det_min_range) + current_config = 0 + + print(f"Starting CAD sweep: {len(det_peak_range)} peak × {len(det_min_range)} min = {total_configs} configurations") + print(f"Taking {samples_per_config} samples per configuration...") + print("-" * 60) + + for det_peak in det_peak_range: + for det_min in det_min_range: + current_config += 1 + print(f"[{current_config:2d}/{total_configs}] Testing det_peak={det_peak:2d}, det_min={det_min:2d}", end=" ") + + # Take multiple samples for this configuration + sample_results = [] + detected_count = 0 + + for sample in range(samples_per_config): + try: + # Perform CAD with calibration data + result = await radio.perform_cad( + det_peak=det_peak, + det_min=det_min, + timeout=2.0, + calibration=True + ) + + sample_results.append(result) + if result.get('detected', False): + detected_count += 1 + + # Small delay between samples + await asyncio.sleep(0.1) + + except Exception as e: + print(f"\n Error in sample {sample + 1}: {e}") + continue + + # Calculate statistics for this configuration + detection_rate = (detected_count / samples_per_config) * 100 if samples_per_config > 0 else 0 + + config_result = { + 'det_peak': det_peak, + 'det_min': det_min, + 'samples': samples_per_config, + 'detections': detected_count, + 'detection_rate': detection_rate, + 'sample_results': sample_results, + 'timestamp': time.time() + } + + results.append(config_result) + print(f"→ {detected_count}/{samples_per_config} detected ({detection_rate:4.1f}%)") + + return results + + +def analyze_cad_results(results: List[Dict[str, Any]]) -> Dict[str, Any]: + """Analyze CAD calibration results and provide recommendations.""" + if not results: + return {"error": "No results to analyze"} + + # Group results by detection rate + no_detection = [r for r in results if r['detection_rate'] == 0] + low_detection = [r for r in results if 0 < r['detection_rate'] <= 25] + medium_detection = [r for r in results if 25 < r['detection_rate'] <= 75] + high_detection = [r for r in results if r['detection_rate'] > 75] + + # Find optimal thresholds (low false positive rate but still sensitive) + optimal_configs = [r for r in results if 0 < r['detection_rate'] <= 10] # 0-10% detection rate + if not optimal_configs: + optimal_configs = no_detection # Fall back to no detection if no low-detection configs + + # Sort by sensitivity (lower thresholds = more sensitive) + optimal_configs.sort(key=lambda x: (x['det_peak'], x['det_min'])) + + analysis = { + 'total_configurations': len(results), + 'no_detection_count': len(no_detection), + 'low_detection_count': len(low_detection), + 'medium_detection_count': len(medium_detection), + 'high_detection_count': len(high_detection), + 'detection_rates': [r['detection_rate'] for r in results], + 'recommended_config': optimal_configs[0] if optimal_configs else None, + 'most_sensitive': min(results, key=lambda x: (x['det_peak'], x['det_min'])), + 'least_sensitive': max(results, key=lambda x: (x['det_peak'], x['det_min'])), + } + + return analysis + + +def print_calibration_summary(analysis: Dict[str, Any], radio_config: Dict[str, Any]): + """Print a comprehensive calibration summary.""" + print("\n" + "=" * 60) + print("CAD CALIBRATION SUMMARY") + print("=" * 60) + + # Radio configuration + print(f"Radio Configuration:") + print(f" Frequency: {radio_config.get('frequency', 0)/1000000:.3f} MHz") + print(f" Bandwidth: {radio_config.get('bandwidth', 0)/1000:.1f} kHz") + print(f" Spreading Factor: {radio_config.get('spreading_factor', 0)}") + print(f" TX Power: {radio_config.get('tx_power', 0)} dBm") + + # Results overview + print(f"\nCalibration Results:") + print(f" Total configurations tested: {analysis['total_configurations']}") + print(f" No detection (0%): {analysis['no_detection_count']} configs") + print(f" Low detection (1-25%): {analysis['low_detection_count']} configs") + print(f" Medium detection (26-75%): {analysis['medium_detection_count']} configs") + print(f" High detection (>75%): {analysis['high_detection_count']} configs") + + if analysis['detection_rates']: + avg_detection = statistics.mean(analysis['detection_rates']) + print(f" Average detection rate: {avg_detection:.1f}%") + + # Recommendations + print(f"\nRecommendations:") + if analysis['recommended_config']: + rec = analysis['recommended_config'] + print(f" RECOMMENDED: det_peak={rec['det_peak']}, det_min={rec['det_min']}") + print(f" Detection rate: {rec['detection_rate']:.1f}% (good balance)") + else: + print(" No optimal configuration found in tested range") + + # Sensitivity range + most_sens = analysis['most_sensitive'] + least_sens = analysis['least_sensitive'] + print(f" Most sensitive: det_peak={most_sens['det_peak']}, det_min={most_sens['det_min']} ({most_sens['detection_rate']:.1f}%)") + print(f" Least sensitive: det_peak={least_sens['det_peak']}, det_min={least_sens['det_min']} ({least_sens['detection_rate']:.1f}%)") + + print("\nNote: Lower detection rates indicate better noise rejection.") + print("Choose thresholds based on your environment and requirements.") + + +async def calibrate_cad(radio_type: str = "waveshare"): + """Main CAD calibration function.""" + # Validate radio type + if radio_type == "kiss-tnc": + print("ERROR: CAD calibration is not supported with KISS TNC radios.") + print("CAD (Channel Activity Detection) is only available on SX1262 hardware.") + print("Please use --radio-type with 'waveshare', 'uconsole', or 'meshadv-mini'") + return None + + print(f"Starting CAD calibration for {radio_type} radio...") + print("This will test various CAD threshold combinations to find optimal settings.") + print() + + radio = None + try: + # Create radio (this will be an SX1262Radio instance) + radio = create_radio(radio_type) + + # Verify we have an SX1262 radio (CAD is only available on SX1262) + from pymc_core.hardware.sx1262_wrapper import SX1262Radio + if not isinstance(radio, SX1262Radio): + print(f"ERROR: Expected SX1262Radio, got {type(radio).__name__}") + print("CAD calibration requires SX1262 hardware.") + return None + + # Initialize radio + print("Initializing radio...") + radio.begin() + print("Radio initialized successfully!") + + # Get radio configuration for reporting + radio_config = radio.get_status() + print(f"Radio config: {radio_config['frequency']/1000000:.3f}MHz, SF{radio_config['spreading_factor']}, {radio_config['bandwidth']/1000:.1f}kHz") + + # Wait for radio to settle + print("Waiting for radio to settle...") + await asyncio.sleep(2.0) + + # Define calibration ranges + # These ranges cover typical CAD threshold values + det_peak_range = range(20, 31, 2) # 20, 22, 24, 26, 28, 30 + det_min_range = range(8, 13, 1) # 8, 9, 10, 11, 12 + samples_per_config = 3 # Number of samples per threshold combination + + print(f"Threshold ranges:") + print(f" det_peak: {list(det_peak_range)}") + print(f" det_min: {list(det_min_range)}") + print(f" samples per config: {samples_per_config}") + print() + + # Perform calibration sweep + results = await perform_cad_sweep(radio, det_peak_range, det_min_range, samples_per_config) + + # Analyze results + analysis = analyze_cad_results(results) + + # Print summary + print_calibration_summary(analysis, radio_config) + + # Store results globally for potential further analysis + global cad_results + cad_results = results + + return results, analysis + + except Exception as e: + print(f"Calibration failed: {e}") + import traceback + traceback.print_exc() + return None + finally: + # Clean up radio + try: + if radio is not None and hasattr(radio, 'cleanup'): + radio.cleanup() # type: ignore + except: + pass + + +def main(): + """Main function for running the CAD calibration.""" + import argparse + + parser = argparse.ArgumentParser(description="Calibrate CAD thresholds for SX1262 radio") + parser.add_argument( + "--radio-type", + choices=["waveshare", "uconsole", "meshadv-mini"], # Note: kiss-tnc excluded + default="waveshare", + help="SX1262 radio hardware type (default: waveshare)" + ) + + args = parser.parse_args() + + print("CAD Calibration Tool") + print("===================") + print(f"Using {args.radio_type} radio configuration") + print() + + try: + result = asyncio.run(calibrate_cad(args.radio_type)) + if result: + print("\nCalibration completed successfully!") + print("Use the recommended thresholds in your CAD operations.") + else: + print("\nCalibration failed!") + except KeyboardInterrupt: + print("\nCalibration interrupted by user") + except Exception as e: + print(f"Error: {e}") + + +if __name__ == "__main__": + main() \ No newline at end of file From bf10f2589591a272eb2fe177ba9d5c0f9e67c9a5 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Thu, 30 Oct 2025 12:51:57 +0000 Subject: [PATCH 05/21] Update SX1262 radio configuration parameters for UK MC Defaults narrow --- examples/common.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/common.py b/examples/common.py index 8c796c6..826b542 100644 --- a/examples/common.py +++ b/examples/common.py @@ -81,11 +81,11 @@ def create_radio(radio_type: str = "waveshare", serial_port: str = "/dev/ttyUSB0 "irq_pin": 16, "txen_pin": 13, # GPIO 13 for TX enable "rxen_pin": 12, - "frequency": int(869.525 * 1000000), # EU: 869.525 MHz + "frequency": int(869.618 * 1000000), # EU: 869.618 MHz "tx_power": 22, - "spreading_factor": 11, - "bandwidth": int(250 * 1000), - "coding_rate": 5, + "spreading_factor": 8, + "bandwidth": int(62.5 * 1000), + "coding_rate": 8, "preamble_length": 17, "is_waveshare": True, }, From 2be72878a810341f16107fe1fbd183bde46ec114 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Thu, 30 Oct 2025 14:24:32 +0000 Subject: [PATCH 06/21] feat: Enhance CAD calibration tool with analysis and CSV export functionality --- examples/calibrate_cad.py | 363 +++++++++++++++-------- src/pymc_core/hardware/sx1262_wrapper.py | 38 ++- 2 files changed, 272 insertions(+), 129 deletions(-) diff --git a/examples/calibrate_cad.py b/examples/calibrate_cad.py index e018ed5..41748a5 100644 --- a/examples/calibrate_cad.py +++ b/examples/calibrate_cad.py @@ -10,30 +10,167 @@ """ import asyncio +import csv +import logging import statistics import time -from typing import List, Dict, Any +from pathlib import Path +from typing import List, Dict, Any, Optional, Tuple from common import create_radio +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + # CAD calibration results storage cad_results: List[Dict[str, Any]] = [] -async def perform_cad_sweep(radio, det_peak_range: range, det_min_range: range, samples_per_config: int = 5) -> List[Dict[str, Any]]: +class CadCalibrationAnalyzer: + """Analyzes CAD calibration results and provides recommendations.""" + + def __init__(self, results: List[Dict[str, Any]]): + """Initialize analyzer with calibration results.""" + self.results = results + self.analysis = self._analyze_results() + + def _analyze_results(self) -> Dict[str, Any]: + """Analyze CAD calibration results and provide recommendations.""" + if not self.results: + return {"error": "No results to analyze"} + + # Group results by detection rate + no_detection = [r for r in self.results if r['detection_rate'] == 0] + low_detection = [r for r in self.results if 0 < r['detection_rate'] <= 25] + medium_detection = [r for r in self.results if 25 < r['detection_rate'] <= 75] + high_detection = [r for r in self.results if r['detection_rate'] > 75] + + # Find optimal thresholds (low false positive rate but still sensitive) + optimal_configs = [r for r in self.results if 0 < r['detection_rate'] <= 10] # 0-10% detection rate + if not optimal_configs: + optimal_configs = no_detection # Fall back to no detection if no low-detection configs + + # Sort by sensitivity (lower thresholds = more sensitive) + optimal_configs.sort(key=lambda x: (x['det_peak'], x['det_min'])) + + analysis = { + 'total_configurations': len(self.results), + 'no_detection_count': len(no_detection), + 'low_detection_count': len(low_detection), + 'medium_detection_count': len(medium_detection), + 'high_detection_count': len(high_detection), + 'detection_rates': [r['detection_rate'] for r in self.results], + 'recommended_config': optimal_configs[0] if optimal_configs else None, + 'most_sensitive': min(self.results, key=lambda x: (x['det_peak'], x['det_min'])), + 'least_sensitive': max(self.results, key=lambda x: (x['det_peak'], x['det_min'])), + } + + return analysis + + def print_summary(self, radio_config: Dict[str, Any]) -> None: + """Print a comprehensive calibration summary.""" + logger.info("=" * 60) + logger.info("CAD CALIBRATION SUMMARY") + logger.info("=" * 60) + + # Radio configuration + logger.info("Radio Configuration:") + logger.info(f" Frequency: {radio_config.get('frequency', 0)/1000000:.3f} MHz") + logger.info(f" Bandwidth: {radio_config.get('bandwidth', 0)/1000:.1f} kHz") + logger.info(f" Spreading Factor: {radio_config.get('spreading_factor', 0)}") + logger.info(f" TX Power: {radio_config.get('tx_power', 0)} dBm") + + # Results overview + logger.info("Calibration Results:") + logger.info(f" Total configurations tested: {self.analysis['total_configurations']}") + logger.info(f" No detection (0%%): {self.analysis['no_detection_count']} configs") + logger.info(f" Low detection (1-25%%): {self.analysis['low_detection_count']} configs") + logger.info(f" Medium detection (26-75%%): {self.analysis['medium_detection_count']} configs") + logger.info(f" High detection (>75%%): {self.analysis['high_detection_count']} configs") + + if self.analysis['detection_rates']: + avg_detection = statistics.mean(self.analysis['detection_rates']) + logger.info(f" Average detection rate: {avg_detection:.1f}%%") + + # Recommendations + logger.info("Recommendations:") + if self.analysis['recommended_config']: + rec = self.analysis['recommended_config'] + logger.info(f" RECOMMENDED: det_peak={rec['det_peak']}, det_min={rec['det_min']}") + logger.info(f" Detection rate: {rec['detection_rate']:.1f}%% (good balance)") + else: + logger.warning(" No optimal configuration found in tested range") + + # Sensitivity range + most_sens = self.analysis['most_sensitive'] + least_sens = self.analysis['least_sensitive'] + logger.info(f" Most sensitive: det_peak={most_sens['det_peak']}, det_min={most_sens['det_min']} ({most_sens['detection_rate']:.1f}%%)") + logger.info(f" Least sensitive: det_peak={least_sens['det_peak']}, det_min={least_sens['det_min']} ({least_sens['detection_rate']:.1f}%%)") + + logger.info("Note: Lower detection rates indicate better noise rejection.") + logger.info("Choose thresholds based on your environment and requirements.") + + def export_csv(self, filename: str) -> None: + """Export calibration results to CSV file.""" + try: + with open(filename, 'w', newline='') as csvfile: + fieldnames = [ + 'det_peak', 'det_min', 'samples', 'detections', + 'detection_rate', 'timestamp' + ] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + + for result in self.results: + row = {k: result.get(k, '') for k in fieldnames} + writer.writerow(row) + + logger.info(f"Results exported to {filename}") + except Exception as e: + logger.error(f"Failed to export CSV: {e}") + + +def get_sf_based_thresholds(spreading_factor: int) -> Tuple[range, range]: + """Get CAD threshold ranges based on spreading factor.""" + # SF-based threshold ranges (based on Semtech recommendations) + sf_thresholds = { + 7: (range(18, 27, 2), range(8, 13, 1)), # det_peak: 18-26, det_min: 8-12 + 8: (range(18, 27, 2), range(8, 13, 1)), # det_peak: 18-26, det_min: 8-12 + 9: (range(20, 29, 2), range(9, 14, 1)), # det_peak: 20-28, det_min: 9-13 + 10: (range(22, 31, 2), range(9, 14, 1)), # det_peak: 22-30, det_min: 9-13 + 11: (range(24, 33, 2), range(10, 15, 1)), # det_peak: 24-32, det_min: 10-14 + 12: (range(26, 35, 2), range(10, 15, 1)), # det_peak: 26-34, det_min: 10-14 + } + + # Default to SF7 ranges if unknown SF + return sf_thresholds.get(spreading_factor, sf_thresholds[7]) + + + + + +async def perform_cad_sweep( + radio, + det_peak_range: range, + det_min_range: range, + samples_per_config: int = 3 +) -> List[Dict[str, Any]]: """Perform a CAD sweep across threshold ranges.""" results = [] total_configs = len(det_peak_range) * len(det_min_range) current_config = 0 - print(f"Starting CAD sweep: {len(det_peak_range)} peak × {len(det_min_range)} min = {total_configs} configurations") - print(f"Taking {samples_per_config} samples per configuration...") - print("-" * 60) + logger.info(f"Starting CAD sweep: {len(det_peak_range)} peak × {len(det_min_range)} min = {total_configs} configurations") + logger.info(f"Taking {samples_per_config} samples per configuration...") + logger.info("-" * 60) for det_peak in det_peak_range: for det_min in det_min_range: current_config += 1 - print(f"[{current_config:2d}/{total_configs}] Testing det_peak={det_peak:2d}, det_min={det_min:2d}", end=" ") # Take multiple samples for this configuration sample_results = [] @@ -57,7 +194,7 @@ async def perform_cad_sweep(radio, det_peak_range: range, det_min_range: range, await asyncio.sleep(0.1) except Exception as e: - print(f"\n Error in sample {sample + 1}: {e}") + logger.warning(f"Error in config {current_config}, sample {sample + 1}: {e}") continue # Calculate statistics for this configuration @@ -74,101 +211,25 @@ async def perform_cad_sweep(radio, det_peak_range: range, det_min_range: range, } results.append(config_result) - print(f"→ {detected_count}/{samples_per_config} detected ({detection_rate:4.1f}%)") + logger.info(f"[{current_config:2d}/{total_configs}] det_peak={det_peak:2d}, det_min={det_min:2d} → {detected_count}/{samples_per_config} detected ({detection_rate:4.1f}%%)") return results -def analyze_cad_results(results: List[Dict[str, Any]]) -> Dict[str, Any]: - """Analyze CAD calibration results and provide recommendations.""" - if not results: - return {"error": "No results to analyze"} - - # Group results by detection rate - no_detection = [r for r in results if r['detection_rate'] == 0] - low_detection = [r for r in results if 0 < r['detection_rate'] <= 25] - medium_detection = [r for r in results if 25 < r['detection_rate'] <= 75] - high_detection = [r for r in results if r['detection_rate'] > 75] - - # Find optimal thresholds (low false positive rate but still sensitive) - optimal_configs = [r for r in results if 0 < r['detection_rate'] <= 10] # 0-10% detection rate - if not optimal_configs: - optimal_configs = no_detection # Fall back to no detection if no low-detection configs - - # Sort by sensitivity (lower thresholds = more sensitive) - optimal_configs.sort(key=lambda x: (x['det_peak'], x['det_min'])) - - analysis = { - 'total_configurations': len(results), - 'no_detection_count': len(no_detection), - 'low_detection_count': len(low_detection), - 'medium_detection_count': len(medium_detection), - 'high_detection_count': len(high_detection), - 'detection_rates': [r['detection_rate'] for r in results], - 'recommended_config': optimal_configs[0] if optimal_configs else None, - 'most_sensitive': min(results, key=lambda x: (x['det_peak'], x['det_min'])), - 'least_sensitive': max(results, key=lambda x: (x['det_peak'], x['det_min'])), - } - - return analysis - - -def print_calibration_summary(analysis: Dict[str, Any], radio_config: Dict[str, Any]): - """Print a comprehensive calibration summary.""" - print("\n" + "=" * 60) - print("CAD CALIBRATION SUMMARY") - print("=" * 60) - - # Radio configuration - print(f"Radio Configuration:") - print(f" Frequency: {radio_config.get('frequency', 0)/1000000:.3f} MHz") - print(f" Bandwidth: {radio_config.get('bandwidth', 0)/1000:.1f} kHz") - print(f" Spreading Factor: {radio_config.get('spreading_factor', 0)}") - print(f" TX Power: {radio_config.get('tx_power', 0)} dBm") - - # Results overview - print(f"\nCalibration Results:") - print(f" Total configurations tested: {analysis['total_configurations']}") - print(f" No detection (0%): {analysis['no_detection_count']} configs") - print(f" Low detection (1-25%): {analysis['low_detection_count']} configs") - print(f" Medium detection (26-75%): {analysis['medium_detection_count']} configs") - print(f" High detection (>75%): {analysis['high_detection_count']} configs") - - if analysis['detection_rates']: - avg_detection = statistics.mean(analysis['detection_rates']) - print(f" Average detection rate: {avg_detection:.1f}%") - - # Recommendations - print(f"\nRecommendations:") - if analysis['recommended_config']: - rec = analysis['recommended_config'] - print(f" RECOMMENDED: det_peak={rec['det_peak']}, det_min={rec['det_min']}") - print(f" Detection rate: {rec['detection_rate']:.1f}% (good balance)") - else: - print(" No optimal configuration found in tested range") - - # Sensitivity range - most_sens = analysis['most_sensitive'] - least_sens = analysis['least_sensitive'] - print(f" Most sensitive: det_peak={most_sens['det_peak']}, det_min={most_sens['det_min']} ({most_sens['detection_rate']:.1f}%)") - print(f" Least sensitive: det_peak={least_sens['det_peak']}, det_min={least_sens['det_min']} ({least_sens['detection_rate']:.1f}%)") - - print("\nNote: Lower detection rates indicate better noise rejection.") - print("Choose thresholds based on your environment and requirements.") - - -async def calibrate_cad(radio_type: str = "waveshare"): +async def calibrate_cad( + radio_type: str = "waveshare", + export_csv: Optional[str] = None +) -> Optional[Tuple[List[Dict[str, Any]], Dict[str, Any]]]: """Main CAD calibration function.""" # Validate radio type if radio_type == "kiss-tnc": - print("ERROR: CAD calibration is not supported with KISS TNC radios.") - print("CAD (Channel Activity Detection) is only available on SX1262 hardware.") - print("Please use --radio-type with 'waveshare', 'uconsole', or 'meshadv-mini'") + logger.error("CAD calibration is not supported with KISS TNC radios.") + logger.error("CAD (Channel Activity Detection) is only available on SX1262 hardware.") + logger.error("Please use --radio-type with 'waveshare', 'uconsole', or 'meshadv-mini'") return None - print(f"Starting CAD calibration for {radio_type} radio...") - print("This will test various CAD threshold combinations to find optimal settings.") - print() + logger.info(f"Starting CAD calibration for {radio_type} radio...") + logger.info("This will test various CAD threshold combinations to find optimal settings.") radio = None try: @@ -178,62 +239,71 @@ async def calibrate_cad(radio_type: str = "waveshare"): # Verify we have an SX1262 radio (CAD is only available on SX1262) from pymc_core.hardware.sx1262_wrapper import SX1262Radio if not isinstance(radio, SX1262Radio): - print(f"ERROR: Expected SX1262Radio, got {type(radio).__name__}") - print("CAD calibration requires SX1262 hardware.") + logger.error(f"Expected SX1262Radio, got {type(radio).__name__}") + logger.error("CAD calibration requires SX1262 hardware.") return None # Initialize radio - print("Initializing radio...") + logger.info("Initializing radio...") radio.begin() - print("Radio initialized successfully!") + logger.info("Radio initialized successfully!") # Get radio configuration for reporting radio_config = radio.get_status() - print(f"Radio config: {radio_config['frequency']/1000000:.3f}MHz, SF{radio_config['spreading_factor']}, {radio_config['bandwidth']/1000:.1f}kHz") + sf = radio_config.get('spreading_factor', 11) + logger.info(f"Radio config: {radio_config['frequency']/1000000:.3f}MHz, SF{sf}, {radio_config['bandwidth']/1000:.1f}kHz") # Wait for radio to settle - print("Waiting for radio to settle...") + logger.info("Waiting for radio to settle...") await asyncio.sleep(2.0) - # Define calibration ranges - # These ranges cover typical CAD threshold values - det_peak_range = range(20, 31, 2) # 20, 22, 24, 26, 28, 30 - det_min_range = range(8, 13, 1) # 8, 9, 10, 11, 12 + # Get SF-based threshold ranges + det_peak_range, det_min_range = get_sf_based_thresholds(sf) samples_per_config = 3 # Number of samples per threshold combination - print(f"Threshold ranges:") - print(f" det_peak: {list(det_peak_range)}") - print(f" det_min: {list(det_min_range)}") - print(f" samples per config: {samples_per_config}") - print() + logger.info(f"SF{sf}-optimized threshold ranges:") + logger.info(f" det_peak: {list(det_peak_range)}") + logger.info(f" det_min: {list(det_min_range)}") + logger.info(f" samples per config: {samples_per_config}") # Perform calibration sweep - results = await perform_cad_sweep(radio, det_peak_range, det_min_range, samples_per_config) + results = await perform_cad_sweep( + radio, + det_peak_range, + det_min_range, + samples_per_config + ) # Analyze results - analysis = analyze_cad_results(results) + analyzer = CadCalibrationAnalyzer(results) # Print summary - print_calibration_summary(analysis, radio_config) + analyzer.print_summary(radio_config) + + # Export CSV if requested + if export_csv: + analyzer.export_csv(export_csv) # Store results globally for potential further analysis global cad_results cad_results = results - return results, analysis + return results, analyzer.analysis except Exception as e: - print(f"Calibration failed: {e}") + logger.error(f"Calibration failed: {e}") import traceback traceback.print_exc() return None finally: # Clean up radio - try: - if radio is not None and hasattr(radio, 'cleanup'): - radio.cleanup() # type: ignore - except: - pass + if radio is not None: + try: + if hasattr(radio, 'cleanup'): + radio.cleanup() # type: ignore + logger.info("Radio cleanup completed") + except Exception as e: + logger.warning(f"Error during radio cleanup: {e}") def main(): @@ -247,25 +317,62 @@ def main(): default="waveshare", help="SX1262 radio hardware type (default: waveshare)" ) + parser.add_argument( + "--export-csv", + type=str, + help="Export results to CSV file (e.g., cad_results.csv)" + ) + + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Enable verbose logging" + ) args = parser.parse_args() - print("CAD Calibration Tool") - print("===================") - print(f"Using {args.radio_type} radio configuration") - print() + # Set logging level + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + logger.debug("Verbose logging enabled") + + logger.info("CAD Calibration Tool") + logger.info("===================") + logger.info(f"Using {args.radio_type} radio configuration") + + # Validate export path + if args.export_csv: + export_path = Path(args.export_csv) + if export_path.exists(): + logger.warning(f"CSV file {args.export_csv} already exists and will be overwritten") + logger.info(f"Results will be exported to: {args.export_csv}") + + logger.info("") + logger.info("CAD Calibration Notes:") + logger.info("- 0% detection = quiet RF environment (good for mesh networking)") + logger.info("- Higher detection rates may indicate RF noise or interference") + logger.info("- Recommended thresholds balance sensitivity vs false positives") + logger.info("") try: - result = asyncio.run(calibrate_cad(args.radio_type)) + result = asyncio.run(calibrate_cad( + radio_type=args.radio_type, + export_csv=args.export_csv + )) + if result: - print("\nCalibration completed successfully!") - print("Use the recommended thresholds in your CAD operations.") + logger.info("Calibration completed successfully!") + logger.info("Use the recommended thresholds in your CAD operations.") else: - print("\nCalibration failed!") + logger.error("Calibration failed!") + exit(1) + except KeyboardInterrupt: - print("\nCalibration interrupted by user") + logger.warning("Calibration interrupted by user") + exit(130) except Exception as e: - print(f"Error: {e}") + logger.error(f"Unexpected error: {e}") + exit(1) if __name__ == "__main__": diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index 8cd2e07..20503ea 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -953,16 +953,52 @@ def get_noise_floor(self) -> Optional[float]: """ if not self._initialized or self.lora is None: return None + + # Skip noise floor reading if we're currently transmitting + if hasattr(self, '_tx_lock') and self._tx_lock.locked(): + return None + try: raw_rssi = self.lora.getRssiInst() if raw_rssi is not None: noise_floor_dbm = -(float(raw_rssi) / 2) - return noise_floor_dbm + # Validate reading - reject obviously invalid values + if -150.0 <= noise_floor_dbm <= -50.0: + return noise_floor_dbm + else: + # Invalid reading detected - trigger radio state reset + logger.debug(f"Invalid noise floor reading: {noise_floor_dbm:.1f}dBm - resetting radio") + self._reset_radio_state() + return None return None except Exception as e: logger.debug(f"Failed to read noise floor: {e}") return None + def _reset_radio_state(self) -> None: + """Reset radio state to recover from invalid RSSI readings""" + if not self._initialized or self.lora is None: + return + + try: + # Force radio back to standby then RX mode + self.lora.setStandby(self.lora.STANDBY_RC) + time.sleep(0.05) # Let radio settle + + # Clear interrupt flags + irq_status = self.lora.getIrqStatus() + if irq_status != 0: + self.lora.clearIrqStatus(irq_status) + + # Restore RX mode + rx_mask = self._get_rx_irq_mask() + self.lora.setDioIrqParams(rx_mask, rx_mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE) + self.lora.setRx(self.lora.RX_CONTINUOUS) + + logger.debug("Radio state reset completed") + except Exception as e: + logger.warning(f"Failed to reset radio state: {e}") + def set_frequency(self, frequency: int) -> bool: """Set operating frequency""" From 677063ad99bf8861f7ee33a62b260d796607c0ed Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sat, 1 Nov 2025 22:30:15 +0000 Subject: [PATCH 07/21] Enhance SX1262 radio with custom CAD threshold support and improved CAD handling --- .pre-commit-config.yaml | 38 +- examples/calibrate_cad.py | 598 +++++++++---------- src/pymc_core/hardware/lora/LoRaRF/SX126x.py | 1 - src/pymc_core/hardware/sx1262_wrapper.py | 185 ++++-- 4 files changed, 439 insertions(+), 383 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d598134..eccfa7b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,22 +36,22 @@ repos: exclude: '^(src/pymc_core/hardware/lora/|examples/)' # Run pytest to ensure all tests pass - - repo: local - hooks: - - id: pytest - name: pytest - entry: pytest - language: system - pass_filenames: false - # Only run if Python files in src/ or tests/ have changed - files: ^(src/|tests/|pyproject\.toml|setup\.py).*$ - args: ["-v", "--tb=short"] - - id: pytest-fast - name: pytest-fast (quick smoke test) - entry: pytest - language: system - pass_filenames: false - # Run a quick subset of tests for faster feedback - files: ^(src/|tests/).*\.py$ - args: ["-v", "--tb=short", "-x", "--maxfail=3", "tests/test_basic.py", "tests/test_crypto.py"] - stages: [manual] + # - repo: local + # hooks: + # - id: pytest + # name: pytest + # entry: pytest + # language: system + # pass_filenames: false + # # Only run if Python files in src/ or tests/ have changed + # files: ^(src/|tests/|pyproject\.toml|setup\.py).*$ + # args: ["-v", "--tb=short"] + # - id: pytest-fast + # name: pytest-fast (quick smoke test) + # entry: pytest + # language: system + # pass_filenames: false + # # Run a quick subset of tests for faster feedback + # files: ^(src/|tests/).*\.py$ + # args: ["-v", "--tb=short", "-x", "--maxfail=3", "tests/test_basic.py", "tests/test_crypto.py"] + # stages: [manual] diff --git a/examples/calibrate_cad.py b/examples/calibrate_cad.py index 41748a5..0e3e8b4 100644 --- a/examples/calibrate_cad.py +++ b/examples/calibrate_cad.py @@ -1,379 +1,337 @@ #!/usr/bin/env python3 """ -CAD Calibration Example: Calibrate Channel Activity Detection thresholds. - -This example helps calibrate CAD (Channel Activity Detection) thresholds -for optimal performance. It performs multiple CAD operations with different -threshold values and provides recommendations. - -Note: This example only works with SX1262 radios, not KISS TNC devices. +CAD Calibration Tool - Improved staged calibration workflow """ import asyncio -import csv import logging import statistics import time -from pathlib import Path -from typing import List, Dict, Any, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from common import create_radio -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) -# CAD calibration results storage -cad_results: List[Dict[str, Any]] = [] - - -class CadCalibrationAnalyzer: - """Analyzes CAD calibration results and provides recommendations.""" - - def __init__(self, results: List[Dict[str, Any]]): - """Initialize analyzer with calibration results.""" - self.results = results - self.analysis = self._analyze_results() - - def _analyze_results(self) -> Dict[str, Any]: - """Analyze CAD calibration results and provide recommendations.""" - if not self.results: - return {"error": "No results to analyze"} - - # Group results by detection rate - no_detection = [r for r in self.results if r['detection_rate'] == 0] - low_detection = [r for r in self.results if 0 < r['detection_rate'] <= 25] - medium_detection = [r for r in self.results if 25 < r['detection_rate'] <= 75] - high_detection = [r for r in self.results if r['detection_rate'] > 75] - - # Find optimal thresholds (low false positive rate but still sensitive) - optimal_configs = [r for r in self.results if 0 < r['detection_rate'] <= 10] # 0-10% detection rate - if not optimal_configs: - optimal_configs = no_detection # Fall back to no detection if no low-detection configs - - # Sort by sensitivity (lower thresholds = more sensitive) - optimal_configs.sort(key=lambda x: (x['det_peak'], x['det_min'])) - - analysis = { - 'total_configurations': len(self.results), - 'no_detection_count': len(no_detection), - 'low_detection_count': len(low_detection), - 'medium_detection_count': len(medium_detection), - 'high_detection_count': len(high_detection), - 'detection_rates': [r['detection_rate'] for r in self.results], - 'recommended_config': optimal_configs[0] if optimal_configs else None, - 'most_sensitive': min(self.results, key=lambda x: (x['det_peak'], x['det_min'])), - 'least_sensitive': max(self.results, key=lambda x: (x['det_peak'], x['det_min'])), - } - - return analysis - - def print_summary(self, radio_config: Dict[str, Any]) -> None: - """Print a comprehensive calibration summary.""" - logger.info("=" * 60) - logger.info("CAD CALIBRATION SUMMARY") - logger.info("=" * 60) - - # Radio configuration - logger.info("Radio Configuration:") - logger.info(f" Frequency: {radio_config.get('frequency', 0)/1000000:.3f} MHz") - logger.info(f" Bandwidth: {radio_config.get('bandwidth', 0)/1000:.1f} kHz") - logger.info(f" Spreading Factor: {radio_config.get('spreading_factor', 0)}") - logger.info(f" TX Power: {radio_config.get('tx_power', 0)} dBm") - - # Results overview - logger.info("Calibration Results:") - logger.info(f" Total configurations tested: {self.analysis['total_configurations']}") - logger.info(f" No detection (0%%): {self.analysis['no_detection_count']} configs") - logger.info(f" Low detection (1-25%%): {self.analysis['low_detection_count']} configs") - logger.info(f" Medium detection (26-75%%): {self.analysis['medium_detection_count']} configs") - logger.info(f" High detection (>75%%): {self.analysis['high_detection_count']} configs") - - if self.analysis['detection_rates']: - avg_detection = statistics.mean(self.analysis['detection_rates']) - logger.info(f" Average detection rate: {avg_detection:.1f}%%") - - # Recommendations - logger.info("Recommendations:") - if self.analysis['recommended_config']: - rec = self.analysis['recommended_config'] - logger.info(f" RECOMMENDED: det_peak={rec['det_peak']}, det_min={rec['det_min']}") - logger.info(f" Detection rate: {rec['detection_rate']:.1f}%% (good balance)") - else: - logger.warning(" No optimal configuration found in tested range") - - # Sensitivity range - most_sens = self.analysis['most_sensitive'] - least_sens = self.analysis['least_sensitive'] - logger.info(f" Most sensitive: det_peak={most_sens['det_peak']}, det_min={most_sens['det_min']} ({most_sens['detection_rate']:.1f}%%)") - logger.info(f" Least sensitive: det_peak={least_sens['det_peak']}, det_min={least_sens['det_min']} ({least_sens['detection_rate']:.1f}%%)") - - logger.info("Note: Lower detection rates indicate better noise rejection.") - logger.info("Choose thresholds based on your environment and requirements.") - - def export_csv(self, filename: str) -> None: - """Export calibration results to CSV file.""" + +def get_test_ranges(spreading_factor: int) -> Tuple[range, range]: + """Get CAD test ranges based on spreading factor""" + sf_ranges = { + 7: (range(16, 29, 1), range(6, 15, 1)), + 8: (range(16, 29, 1), range(6, 15, 1)), + 9: (range(18, 31, 1), range(7, 16, 1)), + 10: (range(20, 33, 1), range(8, 16, 1)), + 11: (range(22, 35, 1), range(9, 17, 1)), + 12: (range(24, 37, 1), range(10, 18, 1)), + } + return sf_ranges.get(spreading_factor, sf_ranges[8]) + + +def get_status_text(detection_rate: float) -> str: + """Get status text based on detection rate""" + if detection_rate == 0: + return "QUIET" + elif detection_rate < 10: + return "LOW" + elif detection_rate < 30: + return "MED" + else: + return "HIGH" + + +async def test_cad_config(radio, det_peak: int, det_min: int, samples: int = 8) -> Dict[str, Any]: + """Test a single CAD configuration with multiple samples""" + detections = 0 + for _ in range(samples): try: - with open(filename, 'w', newline='') as csvfile: - fieldnames = [ - 'det_peak', 'det_min', 'samples', 'detections', - 'detection_rate', 'timestamp' - ] - writer = csv.DictWriter(csvfile, fieldnames=fieldnames) - writer.writeheader() - - for result in self.results: - row = {k: result.get(k, '') for k in fieldnames} - writer.writerow(row) - - logger.info(f"Results exported to {filename}") - except Exception as e: - logger.error(f"Failed to export CSV: {e}") - - -def get_sf_based_thresholds(spreading_factor: int) -> Tuple[range, range]: - """Get CAD threshold ranges based on spreading factor.""" - # SF-based threshold ranges (based on Semtech recommendations) - sf_thresholds = { - 7: (range(18, 27, 2), range(8, 13, 1)), # det_peak: 18-26, det_min: 8-12 - 8: (range(18, 27, 2), range(8, 13, 1)), # det_peak: 18-26, det_min: 8-12 - 9: (range(20, 29, 2), range(9, 14, 1)), # det_peak: 20-28, det_min: 9-13 - 10: (range(22, 31, 2), range(9, 14, 1)), # det_peak: 22-30, det_min: 9-13 - 11: (range(24, 33, 2), range(10, 15, 1)), # det_peak: 24-32, det_min: 10-14 - 12: (range(26, 35, 2), range(10, 15, 1)), # det_peak: 26-34, det_min: 10-14 + result = await radio.perform_cad(det_peak=det_peak, det_min=det_min, timeout=0.6) + if result: + detections += 1 + except Exception: + pass + await asyncio.sleep(0.03) + + return { + "det_peak": det_peak, + "det_min": det_min, + "samples": samples, + "detections": detections, + "detection_rate": (detections / samples) * 100, } - - # Default to SF7 ranges if unknown SF - return sf_thresholds.get(spreading_factor, sf_thresholds[7]) +async def stage1_broad_scan(radio, peak_range: range, min_range: range) -> List[Dict[str, Any]]: + """Stage 1: Broad scan - 8 samples, stop after 10 consecutive quiet configs""" + logger.info("Stage 1: Broad scan (8 samples each)") + results = [] + consecutive_quiet = 0 + total = len(peak_range) * len(min_range) + current = 0 + for det_peak in peak_range: + for det_min in min_range: + current += 1 + result = await test_cad_config(radio, det_peak, det_min, 8) + results.append(result) + rate = result["detection_rate"] + status = get_status_text(rate) + logger.info( + f"[{current:3d}/{total}] peak={det_peak:2d} min={det_min:2d} -> {result['detections']:2d}/8 ({rate:5.1f}%) {status}" + ) -async def perform_cad_sweep( - radio, - det_peak_range: range, - det_min_range: range, - samples_per_config: int = 3 -) -> List[Dict[str, Any]]: - """Perform a CAD sweep across threshold ranges.""" + # Track consecutive quiet configs + if rate == 0: + consecutive_quiet += 1 + if consecutive_quiet >= 10: + logger.info(f"Found 10 consecutive quiet configs, stopping broad scan early") + break + else: + consecutive_quiet = 0 + + if consecutive_quiet >= 10: + break + + return results + + +async def stage2_focused_scan(radio, candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Stage 2: Focused scan - 16 samples on configs with 0-20% detection""" + good_candidates = [c for c in candidates if c["detection_rate"] <= 20] + if len(good_candidates) > 20: + good_candidates = sorted( + good_candidates, key=lambda x: (x["detection_rate"], x["det_peak"]) + )[:20] + + logger.info(f"Stage 2: Focused scan on {len(good_candidates)} candidates (16 samples each)") results = [] - total_configs = len(det_peak_range) * len(det_min_range) - current_config = 0 - - logger.info(f"Starting CAD sweep: {len(det_peak_range)} peak × {len(det_min_range)} min = {total_configs} configurations") - logger.info(f"Taking {samples_per_config} samples per configuration...") - logger.info("-" * 60) - - for det_peak in det_peak_range: - for det_min in det_min_range: - current_config += 1 - - # Take multiple samples for this configuration - sample_results = [] - detected_count = 0 - - for sample in range(samples_per_config): - try: - # Perform CAD with calibration data - result = await radio.perform_cad( - det_peak=det_peak, - det_min=det_min, - timeout=2.0, - calibration=True - ) - - sample_results.append(result) - if result.get('detected', False): - detected_count += 1 - - # Small delay between samples - await asyncio.sleep(0.1) - - except Exception as e: - logger.warning(f"Error in config {current_config}, sample {sample + 1}: {e}") - continue - - # Calculate statistics for this configuration - detection_rate = (detected_count / samples_per_config) * 100 if samples_per_config > 0 else 0 - - config_result = { - 'det_peak': det_peak, - 'det_min': det_min, - 'samples': samples_per_config, - 'detections': detected_count, - 'detection_rate': detection_rate, - 'sample_results': sample_results, - 'timestamp': time.time() - } - - results.append(config_result) - logger.info(f"[{current_config:2d}/{total_configs}] det_peak={det_peak:2d}, det_min={det_min:2d} → {detected_count}/{samples_per_config} detected ({detection_rate:4.1f}%%)") - + + for i, candidate in enumerate(good_candidates, 1): + result = await test_cad_config(radio, candidate["det_peak"], candidate["det_min"], 16) + results.append(result) + + rate = result["detection_rate"] + status = get_status_text(rate) + logger.info( + f"[{i:2d}/{len(good_candidates)}] peak={result['det_peak']:2d} min={result['det_min']:2d} -> {result['detections']:2d}/16 ({rate:5.1f}%) {status}" + ) + + # Stop if we have 5 excellent configs + excellent = [r for r in results if r["detection_rate"] <= 5] + if len(excellent) >= 5: + logger.info(f"Found 5 excellent configs (<=5% detection), moving to next stage") + break + return results -async def calibrate_cad( - radio_type: str = "waveshare", - export_csv: Optional[str] = None -) -> Optional[Tuple[List[Dict[str, Any]], Dict[str, Any]]]: - """Main CAD calibration function.""" - # Validate radio type +async def stage3_fine_tuning(radio, candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Stage 3: Fine tuning - 32 samples on top 5 quietest configs""" + top5 = sorted(candidates, key=lambda x: (x["detection_rate"], x["det_peak"]))[:5] + + logger.info(f"Stage 3: Fine tuning on top {len(top5)} configs (32 samples each)") + results = [] + + for i, candidate in enumerate(top5, 1): + result = await test_cad_config(radio, candidate["det_peak"], candidate["det_min"], 32) + results.append(result) + + rate = result["detection_rate"] + status = get_status_text(rate) + logger.info( + f"[{i}/{len(top5)}] peak={result['det_peak']:2d} min={result['det_min']:2d} -> {result['detections']:2d}/32 ({rate:5.1f}%) {status}" + ) + + return results + + +async def stage4_validation(radio, candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Stage 4: Validation - 64 samples on best 1-2 configs, 3 consecutive runs""" + best_configs = sorted(candidates, key=lambda x: (x["detection_rate"], x["det_peak"]))[:2] + + logger.info(f"Stage 4: Validation on best {len(best_configs)} config(s) (64 samples x 3 runs)") + final_results = [] + + for i, candidate in enumerate(best_configs, 1): + logger.info( + f"Validating config {i}: peak={candidate['det_peak']}, min={candidate['det_min']}" + ) + + runs = [] + stable = True + + for run in range(3): + result = await test_cad_config(radio, candidate["det_peak"], candidate["det_min"], 64) + runs.append(result) + + rate = result["detection_rate"] + logger.info(f" Run {run+1}/3: {result['detections']:2d}/64 ({rate:4.1f}%)") + + # Check stability (within ±5% of first run) + if run > 0: + diff = abs(result["detection_rate"] - runs[0]["detection_rate"]) + if diff > 5.0: + stable = False + + # Average the runs + avg_detections = sum(r["detections"] for r in runs) / len(runs) + avg_rate = (avg_detections / 64) * 100 + + final_result = { + "det_peak": candidate["det_peak"], + "det_min": candidate["det_min"], + "samples": 64 * 3, + "detections": int(avg_detections * 3), + "detection_rate": avg_rate, + "stable": stable, + "runs": runs, + } + + status = "STABLE" if stable else "UNSTABLE" + logger.info(f" Average: {final_result['detections']:3d}/192 ({avg_rate:4.1f}%) {status}") + + final_results.append(final_result) + + return final_results + + +async def perform_staged_calibration( + radio, peak_range: range, min_range: range +) -> List[Dict[str, Any]]: + """Perform the complete 4-stage calibration process""" + # Stage 1: Broad scan + stage1_results = await stage1_broad_scan(radio, peak_range, min_range) + + # Stage 2: Focused scan + stage2_results = await stage2_focused_scan(radio, stage1_results) + + # Stage 3: Fine tuning + stage3_results = await stage3_fine_tuning(radio, stage2_results) + + # Stage 4: Validation + stage4_results = await stage4_validation(radio, stage3_results) + + return stage4_results + + +async def calibrate_cad(radio_type: str = "waveshare", staged: bool = True): + """Main CAD calibration function with staged workflow""" if radio_type == "kiss-tnc": - logger.error("CAD calibration is not supported with KISS TNC radios.") - logger.error("CAD (Channel Activity Detection) is only available on SX1262 hardware.") - logger.error("Please use --radio-type with 'waveshare', 'uconsole', or 'meshadv-mini'") + logger.error("CAD not supported on KISS-TNC. Use SX1262 radios only.") return None - - logger.info(f"Starting CAD calibration for {radio_type} radio...") - logger.info("This will test various CAD threshold combinations to find optimal settings.") - + + logger.info(f"CAD Calibration: {radio_type} radio") + if staged: + logger.info("Using 4-stage calibration workflow") + radio = None try: - # Create radio (this will be an SX1262Radio instance) + # Create and verify radio radio = create_radio(radio_type) - - # Verify we have an SX1262 radio (CAD is only available on SX1262) from pymc_core.hardware.sx1262_wrapper import SX1262Radio + if not isinstance(radio, SX1262Radio): - logger.error(f"Expected SX1262Radio, got {type(radio).__name__}") - logger.error("CAD calibration requires SX1262 hardware.") + logger.error(f"Need SX1262Radio, got {type(radio).__name__}") return None - - # Initialize radio - logger.info("Initializing radio...") + + # Initialize radio.begin() - logger.info("Radio initialized successfully!") - - # Get radio configuration for reporting - radio_config = radio.get_status() - sf = radio_config.get('spreading_factor', 11) - logger.info(f"Radio config: {radio_config['frequency']/1000000:.3f}MHz, SF{sf}, {radio_config['bandwidth']/1000:.1f}kHz") - - # Wait for radio to settle - logger.info("Waiting for radio to settle...") - await asyncio.sleep(2.0) - - # Get SF-based threshold ranges - det_peak_range, det_min_range = get_sf_based_thresholds(sf) - samples_per_config = 3 # Number of samples per threshold combination - - logger.info(f"SF{sf}-optimized threshold ranges:") - logger.info(f" det_peak: {list(det_peak_range)}") - logger.info(f" det_min: {list(det_min_range)}") - logger.info(f" samples per config: {samples_per_config}") - - # Perform calibration sweep - results = await perform_cad_sweep( - radio, - det_peak_range, - det_min_range, - samples_per_config + config = radio.get_status() + sf = config.get("spreading_factor", 8) + logger.info( + f"Radio: {config['frequency']/1e6:.1f}MHz, SF{sf}, {config['bandwidth']/1000:.1f}kHz" ) - - # Analyze results - analyzer = CadCalibrationAnalyzer(results) - - # Print summary - analyzer.print_summary(radio_config) - - # Export CSV if requested - if export_csv: - analyzer.export_csv(export_csv) - - # Store results globally for potential further analysis - global cad_results - cad_results = results - - return results, analyzer.analysis - + + await asyncio.sleep(1.0) # Radio settle time + + # Get test ranges + peak_range, min_range = get_test_ranges(sf) + logger.info( + f"Testing peak {peak_range.start}-{peak_range.stop-1}, min {min_range.start}-{min_range.stop-1}" + ) + + # Perform calibration + if staged: + results = await perform_staged_calibration(radio, peak_range, min_range) + + logger.info("=" * 60) + logger.info("FINAL CALIBRATION RESULTS") + logger.info("=" * 60) + + for i, result in enumerate(results, 1): + stable_text = "STABLE" if result.get("stable", False) else "UNSTABLE" + logger.info( + f"Config {i}: peak={result['det_peak']:2d}, min={result['det_min']:2d} -> " + f"{result['detections']:3d}/192 ({result['detection_rate']:4.1f}%) {stable_text}" + ) + + if results: + best = min(results, key=lambda x: (x["detection_rate"], x["det_peak"])) + logger.info( + f"\nRECOMMENDED: peak={best['det_peak']}, min={best['det_min']} " + f"({best['detection_rate']:.1f}% detection)" + ) + else: + # Simple sweep fallback + results = [] + total = len(peak_range) * len(min_range) + current = 0 + for det_peak in peak_range: + for det_min in min_range: + current += 1 + result = await test_cad_config(radio, det_peak, det_min, 8) + results.append(result) + + rate = result["detection_rate"] + status = get_status_text(rate) + logger.info( + f"[{current:3d}/{total}] peak={det_peak:2d} min={det_min:2d} -> {result['detections']:2d}/8 ({rate:5.1f}%) {status}" + ) + + return results + except Exception as e: logger.error(f"Calibration failed: {e}") - import traceback - traceback.print_exc() return None finally: - # Clean up radio - if radio is not None: - try: - if hasattr(radio, 'cleanup'): - radio.cleanup() # type: ignore - logger.info("Radio cleanup completed") - except Exception as e: - logger.warning(f"Error during radio cleanup: {e}") + if radio: + radio.cleanup() + logger.info("Cleanup complete") def main(): - """Main function for running the CAD calibration.""" import argparse - - parser = argparse.ArgumentParser(description="Calibrate CAD thresholds for SX1262 radio") + + parser = argparse.ArgumentParser(description="CAD Calibration Tool with Staged Workflow") parser.add_argument( - "--radio-type", - choices=["waveshare", "uconsole", "meshadv-mini"], # Note: kiss-tnc excluded + "--radio", + choices=["waveshare", "uconsole", "meshadv-mini"], default="waveshare", - help="SX1262 radio hardware type (default: waveshare)" + help="Radio type", ) parser.add_argument( - "--export-csv", - type=str, - help="Export results to CSV file (e.g., cad_results.csv)" + "--simple", action="store_true", help="Use simple sweep instead of staged workflow" ) - - parser.add_argument( - "--verbose", "-v", - action="store_true", - help="Enable verbose logging" - ) - args = parser.parse_args() - - # Set logging level - if args.verbose: - logging.getLogger().setLevel(logging.DEBUG) - logger.debug("Verbose logging enabled") - + logger.info("CAD Calibration Tool") - logger.info("===================") - logger.info(f"Using {args.radio_type} radio configuration") - - # Validate export path - if args.export_csv: - export_path = Path(args.export_csv) - if export_path.exists(): - logger.warning(f"CSV file {args.export_csv} already exists and will be overwritten") - logger.info(f"Results will be exported to: {args.export_csv}") - - logger.info("") - logger.info("CAD Calibration Notes:") - logger.info("- 0% detection = quiet RF environment (good for mesh networking)") - logger.info("- Higher detection rates may indicate RF noise or interference") - logger.info("- Recommended thresholds balance sensitivity vs false positives") - logger.info("") - + if not args.simple: + logger.info("4-Stage Workflow: Broad->Focused->Fine->Validation") + logger.info("Lower detection % = better for mesh networking") + try: - result = asyncio.run(calibrate_cad( - radio_type=args.radio_type, - export_csv=args.export_csv - )) - + result = asyncio.run(calibrate_cad(args.radio, staged=not args.simple)) if result: - logger.info("Calibration completed successfully!") - logger.info("Use the recommended thresholds in your CAD operations.") + logger.info("Calibration complete!") else: - logger.error("Calibration failed!") exit(1) - except KeyboardInterrupt: - logger.warning("Calibration interrupted by user") - exit(130) + logger.info("Stopped by user") except Exception as e: - logger.error(f"Unexpected error: {e}") + logger.error(f"Error: {e}") exit(1) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/pymc_core/hardware/lora/LoRaRF/SX126x.py b/src/pymc_core/hardware/lora/LoRaRF/SX126x.py index d015956..421bbd5 100644 --- a/src/pymc_core/hardware/lora/LoRaRF/SX126x.py +++ b/src/pymc_core/hardware/lora/LoRaRF/SX126x.py @@ -1484,7 +1484,6 @@ def _readBytes(self, opCode: int, nBytes: int, address: tuple = (), nAddress: in return tuple(feedback[nAddress + 1 :]) - def start_cad(self, det_peak: int, det_min: int): """Start CAD with given thresholds.""" self.clearIrqStatus(0xFFFF) diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index 20503ea..b8c50a1 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -1,6 +1,11 @@ """ SX1262 LoRa Radio Driver for Raspberry Pi Implements the LoRaRadio interface using the SX126x library + + +I have made some expermental changes to the cad section that I need to revist. + + """ import asyncio @@ -194,6 +199,10 @@ def __init__( self._rx_done_event = asyncio.Event() self._cad_event = asyncio.Event() + # Custom CAD thresholds (None means use defaults) + self._custom_cad_peak = None + self._custom_cad_min = None + logger.info( f"SX1262Radio configured: freq={frequency/1e6:.1f}MHz, " f"power={tx_power}dBm, sf={spreading_factor}, " @@ -273,11 +282,14 @@ def _handle_interrupt(self): self._tx_done_event.set() # Check for CAD interrupts - cad_detected_flag = getattr(self.lora, 'IRQ_CAD_DETECTED', 0x4000) - cad_done_flag = getattr(self.lora, 'IRQ_CAD_DONE', 0x8000) - if irqStat & (cad_detected_flag | cad_done_flag): - logger.debug(f"[CAD] CAD interrupt detected (0x{irqStat:04X})") - if hasattr(self, '_cad_event'): + if irqStat & (self.lora.IRQ_CAD_DETECTED | self.lora.IRQ_CAD_DONE): + cad_detected = bool(irqStat & self.lora.IRQ_CAD_DETECTED) + cad_done = bool(irqStat & self.lora.IRQ_CAD_DONE) + logger.debug( + f"[CAD] interrupt detected: {cad_detected}, done: {cad_done} (0x{irqStat:04X})" + ) + if hasattr(self, "_cad_event"): + # WAKEUP CODE self._cad_event.set() # Check each RX interrupt type separately for better debugging @@ -606,6 +618,24 @@ def begin(self) -> bool: self.lora.setDioIrqParams(rx_mask, rx_mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE) self.lora.clearIrqStatus(0xFFFF) + # Program custom CAD thresholds to chip hardware if available + if self._custom_cad_peak is not None and self._custom_cad_min is not None: + logger.info( + f"Settting CAD thresholds to chip: peak={self._custom_cad_peak},", + f"min={self._custom_cad_min}", + ) + try: + self.lora.setCadParams( + self.lora.CAD_ON_2_SYMB, # 2 symbols for detection + self._custom_cad_peak, + self._custom_cad_min, + self.lora.CAD_EXIT_STDBY, # exit to standby + 0, # no timeout + ) + logger.debug("Custom CAD thresholds written") + except Exception as e: + logger.warning(f"Failed to write CAD thresholds: {e}") + # Set to RX continuous mode for initial operation self.lora.setRx(self.lora.RX_CONTINUOUS) self._initialized = True @@ -710,6 +740,36 @@ async def _prepare_radio_for_tx(self) -> bool: await asyncio.sleep(0.01) busy_wait += 1 + # Listen Before Talk (LBT) - Check for channel activity using CAD + lbt_attempts = 0 + max_lbt_attempts = 5 + while lbt_attempts < max_lbt_attempts: + try: + # Perform CAD with your custom thresholds + channel_busy = await self.perform_cad(timeout=0.5) + if not channel_busy: + logger.debug(f"Channel clear after {lbt_attempts + 1} CAD checks") + break + else: + lbt_attempts += 1 + if lbt_attempts < max_lbt_attempts: + # Channel busy, wait random backoff before trying again + # this may confilict with dispatcher will need testing. + backoff_ms = 50 + (lbt_attempts * 20) # 50ms, 70ms, 90ms, etc. + logger.debug( + f"Channel busy (CAD detected activity), backing off {backoff_ms}ms", + f">>>>>>> attempt {lbt_attempts} <<<<<<<", + ) + await asyncio.sleep(backoff_ms / 1000.0) + else: + logger.warning( + f"Channel still busy after {max_lbt_attempts} CAD attempts", + "transmitting anyway", + ) + except Exception as e: + logger.debug(f"CAD check failed: {e}, proceeding with transmission") + break + # Set TXEN/RXEN pins for TX mode self._control_tx_rx_pins(tx_mode=True) @@ -890,21 +950,18 @@ async def send(self, data: bytes) -> None: # Prepare packet for transmission self._prepare_packet_transmission(data_list, length) - # Setup TX interrupts - self._setup_tx_interrupts() - - # Small delay to ensure IRQ configuration is applied - await asyncio.sleep(self.RADIO_TIMING_DELAY) - logger.debug( f"Setting TX timeout: {final_timeout_ms}ms " f"(tOut={driver_timeout}) for {length} bytes" ) - # Prepare radio hardware for transmission if not await self._prepare_radio_for_tx(): return + # Setup TX interrupts AFTER CAD checks (CAD changes interrupt config) + self._setup_tx_interrupts() + await asyncio.sleep(self.RADIO_TIMING_DELAY) + # Execute the transmission if not await self._execute_transmission(driver_timeout): return @@ -953,11 +1010,11 @@ def get_noise_floor(self) -> Optional[float]: """ if not self._initialized or self.lora is None: return None - + # Skip noise floor reading if we're currently transmitting - if hasattr(self, '_tx_lock') and self._tx_lock.locked(): + if hasattr(self, "_tx_lock") and self._tx_lock.locked(): return None - + try: raw_rssi = self.lora.getRssiInst() if raw_rssi is not None: @@ -967,7 +1024,9 @@ def get_noise_floor(self) -> Optional[float]: return noise_floor_dbm else: # Invalid reading detected - trigger radio state reset - logger.debug(f"Invalid noise floor reading: {noise_floor_dbm:.1f}dBm - resetting radio") + logger.debug( + f"Invalid noise floor reading: {noise_floor_dbm:.1f}dBm - resetting radio" + ) self._reset_radio_state() return None return None @@ -979,22 +1038,22 @@ def _reset_radio_state(self) -> None: """Reset radio state to recover from invalid RSSI readings""" if not self._initialized or self.lora is None: return - + try: # Force radio back to standby then RX mode self.lora.setStandby(self.lora.STANDBY_RC) time.sleep(0.05) # Let radio settle - + # Clear interrupt flags irq_status = self.lora.getIrqStatus() if irq_status != 0: self.lora.clearIrqStatus(irq_status) - + # Restore RX mode rx_mask = self._get_rx_irq_mask() self.lora.setDioIrqParams(rx_mask, rx_mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE) self.lora.setRx(self.lora.RX_CONTINUOUS) - + logger.debug("Radio state reset completed") except Exception as e: logger.warning(f"Failed to reset radio state: {e}") @@ -1063,14 +1122,34 @@ def get_status(self) -> dict: status["hardware_ready"] = False return status - + def set_custom_cad_thresholds(self, peak: int, min_val: int) -> None: + """Set custom CAD thresholds that override the defaults. + + Args: + peak: CAD detection peak threshold (0-31) + min_val: CAD detection minimum threshold (0-31) + """ + if not (0 <= peak <= 31) or not (0 <= min_val <= 31): + raise ValueError("CAD thresholds must be between 0 and 31") + + self._custom_cad_peak = peak + self._custom_cad_min = min_val + logger.info(f"Custom CAD thresholds set: peak={peak}, min={min_val}") + def clear_custom_cad_thresholds(self) -> None: + """Clear custom CAD thresholds and revert to defaults.""" + self._custom_cad_peak = None + self._custom_cad_min = None + logger.info("Custom CAD thresholds cleared, reverting to defaults") def _get_thresholds_for_current_settings(self) -> tuple[int, int]: """Fetch CAD thresholds for the current spreading factor. Returns (cadDetPeak, cadDetMin). """ + # Use custom thresholds if set + if self._custom_cad_peak is not None and self._custom_cad_min is not None: + return (self._custom_cad_peak, self._custom_cad_min) # Default CAD thresholds by SF (based on Semtech TR013 recommendations) DEFAULT_CAD_THRESHOLDS = { @@ -1085,9 +1164,6 @@ def _get_thresholds_for_current_settings(self) -> tuple[int, int]: # Fall back to SF7 values if unknown return DEFAULT_CAD_THRESHOLDS.get(self.spreading_factor, (22, 10)) - - - async def perform_cad( self, det_peak: int | None = None, @@ -1097,47 +1173,65 @@ async def perform_cad( ) -> bool | dict: """ Perform Channel Activity Detection (CAD). - If calibration=True, uses provided thresholds and returns detailed info. + If calibration=True, uses provided thresholds and returns info. If calibration=False, uses pre-calibrated/default thresholds. - + Returns: bool: Channel activity detected (when calibration=False) dict: Calibration data (when calibration=True) """ if not self._initialized: raise RuntimeError("Radio not initialized") - + if not self.lora: raise RuntimeError("LoRa radio object not available") # Choose thresholds if det_peak is None or det_min is None: det_peak, det_min = self._get_thresholds_for_current_settings() - + try: + # Put radio in standby mode before CAD configuration + self.lora.setStandby(self.lora.STANDBY_RC) + # Clear any existing interrupt flags existing_irq = self.lora.getIrqStatus() if existing_irq != 0: self.lora.clearIrqStatus(existing_irq) - + + # Configure CAD interrupts + cad_mask = self.lora.IRQ_CAD_DONE | self.lora.IRQ_CAD_DETECTED + self.lora.setDioIrqParams(cad_mask, cad_mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE) + + self.lora.setCadParams( + self.lora.CAD_ON_2_SYMB, # 2 symbols + det_peak, + det_min, + self.lora.CAD_EXIT_STDBY, # exit to standby + 0, # no timeout + ) + # Clear CAD event before starting self._cad_event.clear() - - # Start CAD operation using the driver's start_cad method - self.lora.start_cad(det_peak, det_min) + + # Start CAD operation + self.lora.setCad() + + logger.debug(f"CAD started with peak={det_peak}, min={det_min}") # Wait for CAD completion try: await asyncio.wait_for(self._cad_event.wait(), timeout=timeout) self._cad_event.clear() - - # Read interrupt status + irq = self.lora.getIrqStatus() + logger.debug(f"CAD completed with IRQ status: 0x{irq:04X}") self.lora.clearIrqStatus(irq) + detected = bool(irq & self.lora.IRQ_CAD_DETECTED) + cad_done = bool(irq & self.lora.IRQ_CAD_DONE) - # Check for CAD detection - cad_detected_flag = getattr(self.lora, 'IRQ_CAD_DETECTED', 0x4000) - detected = bool(irq & cad_detected_flag) + if not cad_done: + logger.warning("CAD interrupt received but CAD_DONE flag not set") if calibration: return { @@ -1146,14 +1240,21 @@ async def perform_cad( "det_peak": det_peak, "det_min": det_min, "detected": detected, + "cad_done": cad_done, "timestamp": time.time(), - "irq_status": irq + "irq_status": irq, } else: return detected except asyncio.TimeoutError: logger.debug("CAD operation timed out") + # Check if there were any interrupt flags set anyway + irq = self.lora.getIrqStatus() + if irq != 0: + logger.debug(f"CAD timeout but IRQ status: 0x{irq:04X}") + self.lora.clearIrqStatus(irq) + if calibration: return { "sf": self.spreading_factor, @@ -1162,11 +1263,11 @@ async def perform_cad( "det_min": det_min, "detected": False, "timestamp": time.time(), - "timeout": True + "timeout": True, } else: return False - + except Exception as e: logger.error(f"CAD operation failed: {e}") if calibration: @@ -1177,7 +1278,7 @@ async def perform_cad( "det_min": det_min, "detected": False, "timestamp": time.time(), - "error": str(e) + "error": str(e), } else: return False @@ -1190,8 +1291,6 @@ async def perform_cad( except Exception as e: logger.warning(f"Failed to restore RX mode after CAD: {e}") - - def cleanup(self) -> None: """Clean up radio resources""" if hasattr(self, "lora") and self.lora: From 5732274509d562ae06046bdfacda69f432a94f4e Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sat, 1 Nov 2025 23:00:30 +0000 Subject: [PATCH 08/21] Improve backoff strategy for CAD handling in SX1262Radio --- src/pymc_core/hardware/sx1262_wrapper.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index b8c50a1..2767181 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -755,16 +755,21 @@ async def _prepare_radio_for_tx(self) -> bool: if lbt_attempts < max_lbt_attempts: # Channel busy, wait random backoff before trying again # this may confilict with dispatcher will need testing. - backoff_ms = 50 + (lbt_attempts * 20) # 50ms, 70ms, 90ms, etc. + # Channel busy, wait backoff before trying again (MeshCore-inspired) + import random + + base_delay = random.randint(120, 240) + backoff_ms = base_delay + ( + lbt_attempts * 50 + ) # Progressive: 120-290ms, 170-340ms, etc. logger.debug( - f"Channel busy (CAD detected activity), backing off {backoff_ms}ms", - f">>>>>>> attempt {lbt_attempts} <<<<<<<", + f"Channel busy (CAD detected activity), backing off {backoff_ms}ms" + f" - >>>>>>> attempt {lbt_attempts} <<<<<<<", ) await asyncio.sleep(backoff_ms / 1000.0) else: logger.warning( - f"Channel still busy after {max_lbt_attempts} CAD attempts", - "transmitting anyway", + f"Channel still busy after {max_lbt_attempts} CAD attempts - tx anyway" ) except Exception as e: logger.debug(f"CAD check failed: {e}, proceeding with transmission") From e51f18077234ca6e68e3df31483843e1dff7701f Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sun, 2 Nov 2025 20:28:33 +0000 Subject: [PATCH 09/21] feat: Add Wireshark streaming example for mesh packets via UDP --- examples/wireshark_stream.py | 60 ++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 examples/wireshark_stream.py diff --git a/examples/wireshark_stream.py b/examples/wireshark_stream.py new file mode 100644 index 0000000..8a71b79 --- /dev/null +++ b/examples/wireshark_stream.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +import argparse +import asyncio +import socket +import struct +import sys +import time + +from common import create_mesh_node + +LINKTYPE_USER0 = 147 + + +def setup_wireshark_stream(ip, port): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + dest = (ip, port) + global_hdr = struct.pack(" Date: Sun, 2 Nov 2025 20:37:32 +0000 Subject: [PATCH 10/21] refactor: Simplify packet handling by encapsulating logic in WiresharkHandler class --- examples/wireshark_stream.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/examples/wireshark_stream.py b/examples/wireshark_stream.py index 8a71b79..3a55c83 100644 --- a/examples/wireshark_stream.py +++ b/examples/wireshark_stream.py @@ -19,28 +19,38 @@ def setup_wireshark_stream(ip, port): return sock, dest -def create_packet_handler(sock, dest): - def packet_handler(packet): +class WiresharkHandler: + def __init__(self, sock, dest): + self.sock = sock + self.dest = dest + + @staticmethod + def payload_type() -> int: + return 0xFF # Special marker for fallback handler + + async def __call__(self, packet, metadata=None): try: raw_data = packet.get_raw_data() ts = time.time() ts_sec, ts_usec = int(ts), int((ts % 1) * 1_000_000) pkt_hdr = struct.pack(" Date: Sun, 2 Nov 2025 20:43:03 +0000 Subject: [PATCH 11/21] refactor: Streamline Wireshark streaming setup by integrating radio and dispatcher initialization --- examples/wireshark_stream.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/examples/wireshark_stream.py b/examples/wireshark_stream.py index 3a55c83..fd7db75 100644 --- a/examples/wireshark_stream.py +++ b/examples/wireshark_stream.py @@ -45,17 +45,25 @@ async def main(ip, port): sock, dest = setup_wireshark_stream(ip, port) print("Sent PCAP global header") - mesh_node, identity = create_mesh_node("WiresharkStreamer") - print("Mesh node created") + # Create radio and dispatcher directly (no default handlers) + from common import create_radio + + from pymc_core.node.dispatcher import Dispatcher + + radio = create_radio("waveshare") + radio.begin() + print("Radio initialized") + + dispatcher = Dispatcher(radio) + print("Dispatcher created (no default handlers)") wireshark_handler = WiresharkHandler(sock, dest) - mesh_node.dispatcher.register_fallback_handler(wireshark_handler) + dispatcher.register_fallback_handler(wireshark_handler) print("Fallback handler registered") print("Listening for packets...") try: - while True: - await asyncio.sleep(1) + await dispatcher.run_forever() except KeyboardInterrupt: print("Stopping...") From 00adba66798b35d21fdf7ae8d75c09571717e963 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sun, 2 Nov 2025 21:02:10 +0000 Subject: [PATCH 12/21] fix: Correct method call to retrieve raw packet data in WiresharkHandler --- examples/wireshark_stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/wireshark_stream.py b/examples/wireshark_stream.py index fd7db75..584df61 100644 --- a/examples/wireshark_stream.py +++ b/examples/wireshark_stream.py @@ -30,7 +30,7 @@ def payload_type() -> int: async def __call__(self, packet, metadata=None): try: - raw_data = packet.get_raw_data() + raw_data = packet.write_to() ts = time.time() ts_sec, ts_usec = int(ts), int((ts % 1) * 1_000_000) pkt_hdr = struct.pack(" Date: Sun, 2 Nov 2025 21:39:49 +0000 Subject: [PATCH 13/21] chore: Bump version to 1.0.3 in pyproject.toml and __init__.py --- pyproject.toml | 2 +- src/pymc_core/__init__.py | 2 +- tests/test_basic.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7c3e5eb..f03279c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pymc_core" -version = "1.0.2" +version = "1.0.3" authors = [ {name = "Lloyd Newton", email = "lloyd@rightup.co.uk"}, ] diff --git a/src/pymc_core/__init__.py b/src/pymc_core/__init__.py index a4882a5..46fece3 100644 --- a/src/pymc_core/__init__.py +++ b/src/pymc_core/__init__.py @@ -3,7 +3,7 @@ Clean, simple API for building mesh network applications. """ -__version__ = "1.0.2" +__version__ = "1.0.3" # Core mesh functionality from .node.node import MeshNode diff --git a/tests/test_basic.py b/tests/test_basic.py index 17e1406..7645b47 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -2,7 +2,7 @@ def test_version(): - assert __version__ == "1.0.2" + assert __version__ == "1.0.3" def test_import(): From cf220b06e502eeca1a63f5c2a876a08b11487285 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sun, 2 Nov 2025 13:58:33 -0800 Subject: [PATCH 14/21] Update sx1262_wrapper.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pymc_core/hardware/sx1262_wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index 2767181..eac4dd3 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -3,7 +3,7 @@ Implements the LoRaRadio interface using the SX126x library -I have made some expermental changes to the cad section that I need to revist. +I have made some experimental changes to the cad section that I need to revisit. """ From 654c19d49fee152880a8ad02102d65971d705384 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sun, 2 Nov 2025 13:58:47 -0800 Subject: [PATCH 15/21] Update sx1262_wrapper.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pymc_core/hardware/sx1262_wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index eac4dd3..0ac80bf 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -621,7 +621,7 @@ def begin(self) -> bool: # Program custom CAD thresholds to chip hardware if available if self._custom_cad_peak is not None and self._custom_cad_min is not None: logger.info( - f"Settting CAD thresholds to chip: peak={self._custom_cad_peak},", + f"Setting CAD thresholds to chip: peak={self._custom_cad_peak},", f"min={self._custom_cad_min}", ) try: From ea3e6019a3d7764262b1336af82788ecabdb2344 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sun, 2 Nov 2025 13:58:58 -0800 Subject: [PATCH 16/21] Update sx1262_wrapper.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pymc_core/hardware/sx1262_wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index 0ac80bf..47287c5 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -754,7 +754,7 @@ async def _prepare_radio_for_tx(self) -> bool: lbt_attempts += 1 if lbt_attempts < max_lbt_attempts: # Channel busy, wait random backoff before trying again - # this may confilict with dispatcher will need testing. + # this may conflict with dispatcher will need testing. # Channel busy, wait backoff before trying again (MeshCore-inspired) import random From 6dfd6f5e37082856c0b88a934529956022b6479e Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sun, 2 Nov 2025 14:00:36 -0800 Subject: [PATCH 17/21] Update send_tracked_advert.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/send_tracked_advert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/send_tracked_advert.py b/examples/send_tracked_advert.py index 079fa24..d7c6591 100644 --- a/examples/send_tracked_advert.py +++ b/examples/send_tracked_advert.py @@ -31,7 +31,7 @@ def simple_repeat_counter(raw_data: bytes): try: # Simple check - just count any received packet as a potential repeat - # I have kept it simple but you would want to check if its actaully a advert etc. + # I have kept it simple but you would want to check if it's actually an advert etc. repeat_count += 1 print(f"PACKET REPEAT HEARD #{repeat_count} ({len(raw_data)} bytes)") except Exception as e: From e85bc45bf34634657702555ff2e3511f754edbfc Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sun, 2 Nov 2025 14:01:07 -0800 Subject: [PATCH 18/21] Update kiss_serial_wrapper.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pymc_core/hardware/kiss_serial_wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymc_core/hardware/kiss_serial_wrapper.py b/src/pymc_core/hardware/kiss_serial_wrapper.py index 7701cd9..c41f37e 100644 --- a/src/pymc_core/hardware/kiss_serial_wrapper.py +++ b/src/pymc_core/hardware/kiss_serial_wrapper.py @@ -66,7 +66,7 @@ def __init__( Initialize KISS Serial Wrapper Args: - port: Serial port device path (e.g., '/dev/ttyUSB0, /dev/cu.usbserial-0001, comm1 etc') + port: Serial port device path (e.g., '/dev/ttyUSB0', '/dev/cu.usbserial-0001', 'comm1', etc.) baudrate: Serial communication baud rate (default: 115200) timeout: Serial read timeout in seconds (default: 1.0) kiss_port: KISS port number (0-15, default: 0) From c01c09233daa05b857294bb0638e90dddfa0092f Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sun, 2 Nov 2025 14:02:23 -0800 Subject: [PATCH 19/21] Update calibrate_cad.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/calibrate_cad.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/calibrate_cad.py b/examples/calibrate_cad.py index 0e3e8b4..18e74ad 100644 --- a/examples/calibrate_cad.py +++ b/examples/calibrate_cad.py @@ -5,7 +5,6 @@ import asyncio import logging -import statistics import time from typing import Any, Dict, List, Optional, Tuple From 0190fb976fe8b50e77c7df8e979c93dc7b656cf1 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sun, 2 Nov 2025 14:02:35 -0800 Subject: [PATCH 20/21] Update calibrate_cad.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/calibrate_cad.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/calibrate_cad.py b/examples/calibrate_cad.py index 18e74ad..96799a5 100644 --- a/examples/calibrate_cad.py +++ b/examples/calibrate_cad.py @@ -5,7 +5,6 @@ import asyncio import logging -import time from typing import Any, Dict, List, Optional, Tuple from common import create_radio From 9b45dd2597193a9674c4b574c23712cfa11d7461 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sun, 2 Nov 2025 14:02:54 -0800 Subject: [PATCH 21/21] Update wireshark_stream.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/wireshark_stream.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/wireshark_stream.py b/examples/wireshark_stream.py index 584df61..f0ff3f8 100644 --- a/examples/wireshark_stream.py +++ b/examples/wireshark_stream.py @@ -3,7 +3,6 @@ import asyncio import socket import struct -import sys import time from common import create_mesh_node