From 95fb024fce0f3a55644709a42627d9ef5da7df86 Mon Sep 17 00:00:00 2001 From: Will Dillon Date: Fri, 26 Sep 2025 08:23:07 -0700 Subject: [PATCH 01/12] Added meshadv mini radio profile and tested on hardware --- README.md | 1 + docs/docs/examples.md | 44 +++++++++++++++++++ docs/docs/node.md | 82 +++++++++++++++++++++++++++-------- examples/common.py | 22 ++++++++-- examples/send_flood_advert.py | 4 +- 5 files changed, 131 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 0f66f7c..78a7c31 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ For examples, see the [documentation](https://rightup.github.io/pyMC_core/exampl ### Supported Radios - **Waveshare SX1262 LoRaWAN/GNSS HAT** - Popular Raspberry Pi LoRa module - **HackerGadgets uConsole** - All-in-one extension board with LoRa support +- **FrequencyLabs meshadv-mini** - Raspberry Pi hat with E22-900M22S LoRa module ### Requirements - Raspberry Pi (or compatible SBC) diff --git a/docs/docs/examples.md b/docs/docs/examples.md index 2194742..2fd0e7f 100644 --- a/docs/docs/examples.md +++ b/docs/docs/examples.md @@ -84,6 +84,7 @@ python examples/send_text_message.py uconsole Each example script accepts an optional radio type parameter: - `waveshare` (default) - Waveshare SX1262 HAT - `uconsole` - HackerGadgets uConsole +- `meshadv-mini` - FrequencyLabs meshadv-mini You can also run examples directly with command-line arguments: @@ -101,11 +102,13 @@ Each example accepts an optional radio type parameter: - `waveshare` (default): Waveshare LoRaWAN/GNSS HAT configuration - `uconsole`: HackerGadgets uConsole configuration +- `meshadv-mini`: Frequency Labs Mesh Adv ```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 ``` ## Hardware Requirements @@ -131,6 +134,14 @@ pyMC_Core supports multiple SX1262-based LoRa radio modules: - **GPIO Pins**: CS=-1, Reset=25, Busy=24, IRQ=26 - **Additional Setup**: Requires SPI1 overlay and GPS/RTC configuration (see uConsole setup guide) +#### Frequency Labs meshadv-mini +- **Hardware**: FrequencyLabs meshadv-mini Hat +- **Platform**: Raspberry Pi (or compatible single-board computer) +- **Frequency**: 868MHz (EU) or 915MHz (US) +- **TX Power**: Up to 22dBm +- **SPI Bus**: SPI0 +- **GPIO Pins**: CS=8, Reset=24, Busy=20, IRQ=16 + ### Default Pin Configurations #### Waveshare HAT @@ -153,6 +164,16 @@ pyMC_Core supports multiple SX1262-based LoRa radio modules: - TX Enable: Not used (-1) - RX Enable: Not used (-1) +#### meshadv-mini (Frequency Labs) +- SPI Bus: 0 +- CS ID: 0 +- CS Pin: GPIO 8 +- Busy Pin: GPIO 20 +- Reset Pin: GPIO 24 +- IRQ Pin: GPIO 16 +- TX Enable: Not used (-1) +- RX Enable: GPIO 12 + ## 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: @@ -251,6 +272,22 @@ All examples use the SX1262 LoRa radio with the following default settings: - **TX Enable**: Not used (-1) - **RX Enable**: Not used (-1) +#### meshadv-mini (Frequency Labs) +- **Radio Type**: SX1262 direct hardware control +- **Frequency**: 869.525MHz (European standard) +- **TX Power**: 22dBm +- **Spreading Factor**: 11 +- **Bandwidth**: 250kHz +- **Coding Rate**: 4/5 +- **Preamble Length**: 17 symbols +- **SPI Bus**: 0 +- **CS Pin**: GPIO 8 +- **Reset Pin**: GPIO 24 +- **Busy Pin**: GPIO 20 +- **IRQ Pin**: GPIO 16 +- **TX Enable**: Not used (-1) +- **RX Enable**: GPIO 12 + The radio configuration is hardcoded in `common.py` for simplicity and reliability. ## Hardware Setup @@ -262,6 +299,13 @@ The radio configuration is hardcoded in `common.py` for simplicity and reliabili 4. Remove old GPIO library if present: `sudo apt remove python3-rpi.gpio` 5. The configuration is pre-set in `common.py` for the Waveshare HAT +### Raspberry Pi with Frequency Labs meshadv-mini +1. Connect Waveshare SX1262 HAT to Raspberry Pi 40PIN GPIO header +2. Enable SPI interface in Raspberry Pi configuration (raspi-config) +3. Install required GPIO library: `sudo apt install python3-rpi.lgpio` +4. Remove old GPIO library if present: `sudo apt remove python3-rpi.gpio` +5. The configuration is pre-set in `common.py` for the meshadv-mini + ### Clockwork uConsole 1. The uConsole has the SX1262 radio pre-integrated 2. Enable SPI1 in `/boot/firmware/config.txt`: diff --git a/docs/docs/node.md b/docs/docs/node.md index 69dc8fb..d188853 100644 --- a/docs/docs/node.md +++ b/docs/docs/node.md @@ -31,7 +31,7 @@ This guide references working examples from the `examples/` directory: - **`send_channel_message.py`**: Send messages to group channels - **`ping_repeater_trace.py`**: Network diagnostics using trace packets -All examples use the `common.py` utilities for shared setup and support both Waveshare HAT and uConsole radio configurations. +All examples use the `common.py` utilities for shared setup and support Waveshare HAT, uConsole radio, and meshadv-mini configurations. ### Running Examples @@ -45,6 +45,11 @@ python examples/send_text_message.py python examples/send_flood_advert.py uconsole python examples/send_direct_advert.py uconsole python examples/send_text_message.py uconsole + +# Run examples with meshadv-mini radio +python examples/send_flood_advert.py meshadv-mini +python examples/send_direct_advert.py meshadv-mini +python examples/send_text_message.py meshadv-mini ``` ## Radio Setup @@ -135,6 +140,49 @@ radio = SX1262Radio( radio.begin() ``` +### meshadv and meshadv-mini LoRaWAN/GNSS HAT + +The meshadv and meshadv-mini are available from FrequencyLabs, and are open source hardware. They're based on the E22-900M22S module. The Meshadv has the same pinout (more or less) as the waveshare module, but the meshadv mini is slightly different, so it needs its own definition file. The documentation for the meshadv and meshadv Mini are available on [github](https://github.com/chrismyers2000) + +**Hardware Setup:** +1. Connect the HAT to Raspberry Pi 40PIN GPIO header +2. Ensure SPI interface is enabled in Raspberry Pi configuration +3. Install required GPIO library: `sudo apt install python3-rpi.lgpio` + +**Pin Configuration (Raspberry Pi):** +- SPI Bus: SPI0 (MOSI, MISO, SCLK pins) +- CS: GPIO 21 +- Reset: GPIO 18 +- Busy: GPIO 20 +- IRQ (DIO1): GPIO 16 +- TXEN: GPIO 6 +- RXEN: Connected to DIO2 (not used directly) + +```python +from pymc_core.hardware.sx1262_wrapper import SX1262Radio + +# meshadv-mini HAT configuration (matches official pinout) +radio = SX1262Radio( + bus_id=0, # SPI bus 0 + cs_id=0, # SPI chip select 0 + cs_pin=8, # CS pin (GPIO 8) + reset_pin=24, # Reset pin (GPIO 24) + busy_pin=20, # Busy pin (GPIO 20) + irq_pin=16, # Interrupt pin (GPIO 16) + txen_pin=-1, # TX enable not connected + rxen_pin=12, # RX enable pin (GPIO 12) + frequency=910525000, # 910.525 MHz (US standard) + tx_power=22, # 22 dBm + spreading_factor=7, # Spreading factor + bandwidth=62500, # 250 kHz + coding_rate=5, # 4/5 coding rate + preamble_length=17 # Preamble length +) + +# Initialize the radio +radio.begin() +``` + ### Alternative Radio Configuration For custom hardware setups, you can customize the pin configuration: @@ -156,22 +204,22 @@ radio.begin() ### Radio Configuration Parameters -| Parameter | Description | Waveshare HAT | uConsole | -|-----------|-------------|---------------|----------| -| `bus_id` | SPI bus ID | 0 | 1 | -| `cs_id` | SPI chip select ID | 0 | 0 | -| `cs_pin` | Chip select GPIO pin | 21 | -1 | -| `reset_pin` | Reset GPIO pin | 18 | 25 | -| `busy_pin` | Busy GPIO pin | 20 | 24 | -| `irq_pin` | Interrupt GPIO pin | 16 | 26 | -| `txen_pin` | TX enable GPIO pin | 6 | -1 | -| `rxen_pin` | RX enable GPIO pin | -1 | -1 | -| `frequency` | Operating frequency in Hz | 869525000 (EU) | 915000000 (US) | -| `tx_power` | Transmit power in dBm | 22 | 22 | -| `spreading_factor` | LoRa spreading factor (7-12) | 11 | 11 | -| `bandwidth` | Bandwidth in Hz | 250000 | 250000 | -| `coding_rate` | Coding rate (5=4/5, 6=4/6, etc.) | 5 | 5 | -| `preamble_length` | Preamble length | 17 | 17 | +| Parameter | Description | Waveshare HAT | meshadv-mini | uConsole | +|--------------------|----------------------------------|----------------|----------------|----------------| +| `bus_id` | SPI bus ID | 0 | 0 | 1 | +| `cs_id` | SPI chip select ID | 0 | 0 | 0 | +| `cs_pin` | Chip select GPIO pin | 21 | 8 | -1 | +| `reset_pin` | Reset GPIO pin | 18 | 24 | 25 | +| `busy_pin` | Busy GPIO pin | 20 | 20 | 24 | +| `irq_pin` | Interrupt GPIO pin | 16 | 16 | 26 | +| `txen_pin` | TX enable GPIO pin | 6 | -1 | -1 | +| `rxen_pin` | RX enable GPIO pin | -1 | 12 | -1 | +| `frequency` | Operating frequency in Hz | 869525000 (EU) | 910525000 (US) | 869525000 (EU) | +| `tx_power` | Transmit power in dBm | 22 | 22 | 22 | +| `spreading_factor` | LoRa spreading factor (7-12) | 11 | 7 | 11 | +| `bandwidth` | Bandwidth in Hz | 250000 | 62500 | 250000 | +| `coding_rate` | Coding rate (5=4/5, 6=4/6, etc.) | 5 | 5 | 5 | +| `preamble_length` | Preamble length | 17 | 17 | 17 | **Note:** Adjust the `frequency` parameter based on your regional LoRa regulations (868MHz for EU, 915MHz for US, 433MHz for Asia). diff --git a/examples/common.py b/examples/common.py index 905750a..d081931 100644 --- a/examples/common.py +++ b/examples/common.py @@ -51,8 +51,8 @@ def create_radio(radio_type: str = "waveshare") -> LoRaRadio: "reset_pin": 18, "busy_pin": 20, "irq_pin": 16, - "txen_pin": 6, # GPIO 6 for TX enable - "rxen_pin": -1, + "txen_pin": 13, # GPIO 6 for TX enable + "rxen_pin": 12, "frequency": int(869.525 * 1000000), # EU: 869.525 MHz "tx_power": 22, "spreading_factor": 11, @@ -76,11 +76,27 @@ def create_radio(radio_type: str = "waveshare") -> LoRaRadio: "coding_rate": 5, "preamble_length": 17, }, + "meshadv-mini": { + "bus_id": 0, + "cs_id": 0, + "cs_pin": 8, + "reset_pin": 24, + "busy_pin": 20, + "irq_pin": 16, + "txen_pin": -1, + "rxen_pin": 12, + "frequency": int(910.525 * 1000000), # US: 910.525 MHz + "tx_power": 22, + "spreading_factor": 7, + "bandwidth": int(62.5 * 1000), + "coding_rate": 5, + "preamble_length": 17, + } } if radio_type not in configs: raise ValueError( - f"Unknown radio type: {radio_type}. Use 'waveshare' or 'uconsole'" + f"Unknown radio type: {radio_type}. Use 'waveshare' 'meshadv-mini' or 'uconsole'" ) radio_kwargs = configs[radio_type] diff --git a/examples/send_flood_advert.py b/examples/send_flood_advert.py index 08cc1de..4cc678d 100644 --- a/examples/send_flood_advert.py +++ b/examples/send_flood_advert.py @@ -61,8 +61,8 @@ def main(radio_type: str = "waveshare"): # Parse command line arguments radio_type = sys.argv[1] if len(sys.argv) > 1 else "waveshare" - if radio_type not in ["waveshare", "uconsole"]: - print("Usage: python send_flood_advert.py [waveshare|uconsole]") + 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) From 670be272fbe580ebb349c9865c13b3942aa532f2 Mon Sep 17 00:00:00 2001 From: David Huang Date: Sun, 5 Oct 2025 14:26:35 -0500 Subject: [PATCH 02/12] Add an explicit flag to the radio configuration for the Waveshare HAT, instead of assuming only Waveshare uses CS on GPIO21 --- examples/common.py | 1 + src/pymc_core/hardware/sx1262_wrapper.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/common.py b/examples/common.py index 905750a..7d15592 100644 --- a/examples/common.py +++ b/examples/common.py @@ -59,6 +59,7 @@ def create_radio(radio_type: str = "waveshare") -> LoRaRadio: "bandwidth": int(250 * 1000), "coding_rate": 5, "preamble_length": 17, + "is_waveshare": True, }, "uconsole": { "bus_id": 1, # SPI1 diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index c6c4f88..e81df48 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -126,6 +126,7 @@ def __init__( coding_rate: int = 5, preamble_length: int = 12, sync_word: int = 0x3444, + is_waveshare: bool = False, ): """ Initialize SX1262 radio @@ -146,6 +147,7 @@ def __init__( coding_rate: Coding rate (default: 5 for 4/5) preamble_length: Preamble length (default: 12) sync_word: Sync word (default: 0x3444 for public network) + is_waveshare: Use alternate initialization needed for Waveshare HAT """ # Check if there's already an active instance and clean it up if SX1262Radio._active_instance is not None: @@ -173,6 +175,7 @@ def __init__( self.coding_rate = coding_rate self.preamble_length = preamble_length self.sync_word = sync_word + self.is_waveshare = is_waveshare # State variables self.lora: Optional[SX126x] = None @@ -510,7 +513,7 @@ def begin(self) -> bool: logger.warning(f"Could not setup TXEN pin {self.txen_pin}") # Adaptive initialization based on board type - if self.cs_pin == 21: # Waveshare HAT - use minimal initialization + if self.is_waveshare: # Waveshare HAT - use minimal initialization # Basic radio setup if not self._basic_radio_setup(): return False From fdf8e5b2bdf74219804f5d128ebd3b203fac9876 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sun, 19 Oct 2025 22:15:06 +0100 Subject: [PATCH 03/12] feat: enhance text message handling and routing logic with improved ACK processing --- src/pymc_core/node/dispatcher.py | 2 + src/pymc_core/node/handlers/text.py | 90 +++++++++++++++-------- src/pymc_core/node/node.py | 2 + src/pymc_core/protocol/packet_builder.py | 60 ++++------------ src/pymc_core/protocol/packet_utils.py | 92 ++++++++++++++++++++++-- 5 files changed, 168 insertions(+), 78 deletions(-) diff --git a/src/pymc_core/node/dispatcher.py b/src/pymc_core/node/dispatcher.py index eff5e65..cc95e5b 100644 --- a/src/pymc_core/node/dispatcher.py +++ b/src/pymc_core/node/dispatcher.py @@ -148,6 +148,7 @@ def register_default_handlers( channel_db=None, event_service=None, node_name=None, + radio_config=None, ) -> None: """Quick setup for all the standard packet handlers.""" # Keep our identity handy for detecting our own packets @@ -171,6 +172,7 @@ def register_default_handlers( self._log, self.send_packet, event_service, + radio_config, ) # Keep a reference so the node can use it self.text_message_handler = text_message_handler diff --git a/src/pymc_core/node/handlers/text.py b/src/pymc_core/node/handlers/text.py index bbbed63..2d31dd1 100644 --- a/src/pymc_core/node/handlers/text.py +++ b/src/pymc_core/node/handlers/text.py @@ -1,7 +1,8 @@ import asyncio +import struct -from ...protocol import CryptoUtils, Identity, Packet, PacketBuilder -from ...protocol.constants import PAYLOAD_TYPE_TXT_MSG +from ...protocol import CryptoUtils, Identity, Packet, PacketBuilder, PacketTimingUtils +from ...protocol.constants import PAYLOAD_TYPE_TXT_MSG, PAYLOAD_TYPE_ACK from .base import BaseHandler @@ -17,6 +18,7 @@ def __init__( log_fn, send_packet_fn, event_service=None, + radio_config=None, ): self.local_identity = local_identity self.contacts = contacts @@ -24,26 +26,19 @@ def __init__( self.send_packet = send_packet_fn self.event_service = event_service # Event service for broadcasting self.command_response_callback = None # Callback for command responses + self.radio_config = radio_config or {} # Radio configuration for airtime calculations def set_command_response_callback(self, callback): """Set callback function for command responses.""" self.command_response_callback = callback async def __call__(self, packet: Packet) -> None: - self.log(" TEXT handler called: processing TXT_MSG packet") - self.log(f" Payload length: {len(packet.payload) if packet.payload else 0}") - if hasattr(packet, "_rssi"): - self.log( - f" RSSI: {packet._rssi}dBm, SNR: {getattr(packet, '_snr', 'N/A')}dB" - ) - if len(packet.payload) < 4: self.log("TXT_MSG payload too short to decrypt") return src_hash = packet.payload[1] matched_contact = None - self.log(f"FULL hex of payload: {packet.payload.hex()}") for contact in self.contacts.contacts: try: if bytes.fromhex(contact.public_key)[0] == src_hash: @@ -56,10 +51,6 @@ async def __call__(self, packet: Packet) -> None: self.log(f"No contact found for src hash: {src_hash:02X}") return - self.log( - f"Matched contact: {matched_contact.name} ({matched_contact.public_key[:8]}…)" - ) - peer_id = Identity(bytes.fromhex(matched_contact.public_key)) shared_secret = peer_id.calc_shared_secret( self.local_identity.get_private_key() @@ -83,23 +74,64 @@ async def __call__(self, packet: Packet) -> None: attempt = flags & 0x03 # Last 2 bits are the attempt number message_body = decrypted[5:] # Rest is the message content - # Strip null terminator for ACK calculation (like firmware) - message_text_for_ack = message_body.rstrip(b"\x00") - pubkey = bytes.fromhex(matched_contact.public_key) + timestamp_int = int.from_bytes(timestamp, "little") + + # Determine message routing type from packet header + route_type = packet.header & 0x03 # Route type is in bits 0-1 + is_flood = route_type == 1 # ROUTE_TYPE_FLOOD = 1 + + self.log(f"Processing message - route_type: {route_type}, is_flood: {is_flood}, timestamp: {timestamp_int}") + + # Create appropriate ACK response + if is_flood: + # FLOOD messages use PATH ACK responses with ACK hash in extra payload + text_bytes = message_body.rstrip(b"\x00") + + # Calculate ACK hash using standard method (same as DIRECT messages) + pack_data = PacketBuilder._pack_timestamp_data(timestamp_int, attempt, text_bytes) + ack_hash = CryptoUtils.sha256(pack_data + pubkey)[:4] + + # Create PATH ACK response + incoming_path = list(packet.path if hasattr(packet, 'path') else []) + + ack_packet = PacketBuilder.create_path_return( + dest_hash=PacketBuilder._hash_byte(pubkey), + src_hash=PacketBuilder._hash_byte(self.local_identity.get_public_key()), + secret=shared_secret, + path=incoming_path, + extra_type=PAYLOAD_TYPE_ACK, + extra=ack_hash + ) - ack_packet = PacketBuilder.create_ack( - pubkey=pubkey, - timestamp=int.from_bytes(timestamp, "little"), - attempt=attempt, - text=message_text_for_ack, # Use stripped version for ACK - ) + + ack_airtime = PacketTimingUtils.estimate_airtime_ms(len(ack_packet.write_to()), self.radio_config) + ack_timeout_ms = PacketTimingUtils.calc_flood_timeout_ms(ack_airtime) / 1000.0 # Convert to seconds + + else: + # DIRECT messages use discrete ACK packets + ack_packet = PacketBuilder.create_ack( + pubkey=pubkey, + timestamp=timestamp_int, + attempt=attempt, + text=message_body.rstrip(b"\x00"), + ) + + + ack_airtime = PacketTimingUtils.estimate_airtime_ms(len(ack_packet.write_to()), self.radio_config) + # For direct ACKs, path_len is typically 0 (direct response) + ack_timeout_ms = PacketTimingUtils.calc_direct_timeout_ms(ack_airtime, 0) / 1000.0 # Convert to seconds + + async def send_delayed_ack(): + await asyncio.sleep(ack_timeout_ms) + try: + await self.send_packet(ack_packet, wait_for_ack=False) + self.log(f"ACK packet sent successfully (delayed {ack_timeout_ms*1000:.1f}ms) for timestamp {timestamp_int}") + except Exception as ack_send_error: + self.log(f"Failed to send ACK packet: {ack_send_error}") - # Send ACK with logging - self.log("Sending ACK for message") - for _ in range(1): - await self.send_packet(ack_packet, wait_for_ack=False) - await asyncio.sleep(0.5) + # Schedule ACK to be sent after delay (non-blocking) + asyncio.create_task(send_delayed_ack()) decoded_msg = message_body.decode("utf-8", "replace") self.log(f"Received TXT_MSG: {decoded_msg}") @@ -118,7 +150,7 @@ async def __call__(self, packet: Packet) -> None: # Continue with normal message processing if callback fails # Save the incoming message by publishing event for app to handle - message_timestamp = int.from_bytes(timestamp, "little") + message_timestamp = timestamp_int # Create message event data for the app to handle storage and deduplication normalized_timestamp = (message_timestamp // 1000) * 1000 diff --git a/src/pymc_core/node/node.py b/src/pymc_core/node/node.py index 048c68e..266be07 100644 --- a/src/pymc_core/node/node.py +++ b/src/pymc_core/node/node.py @@ -60,6 +60,7 @@ def __init__( # Node name should be provided by app self.node_name = config.get("node", {}).get("name", "unknown") if config else "unknown" + self.radio_config = config.get("radio", {}) if config else {} self.logger = logger or logging.getLogger("MeshNode") self.log = self.logger @@ -77,6 +78,7 @@ def __init__( channel_db=self.channel_db, event_service=self.event_service, node_name=self.node_name, + radio_config=self.radio_config, ) # Store reference to text handler for command response callbacks self._text_handler = None diff --git a/src/pymc_core/protocol/packet_builder.py b/src/pymc_core/protocol/packet_builder.py index 8f05ede..09e6ff7 100644 --- a/src/pymc_core/protocol/packet_builder.py +++ b/src/pymc_core/protocol/packet_builder.py @@ -642,10 +642,11 @@ def create_path_return( raise ValueError("Combined path/extra too long") inner = bytes([len(path)]) + bytes(path) + bytes([extra_type]) + extra - cipher = PacketBuilder._encrypt_payload(CryptoUtils.sha256(secret), secret, inner) + aes_key = secret[:16] + cipher = PacketBuilder._encrypt_payload(aes_key, secret, inner) payload = bytearray([dest_hash, src_hash]) + cipher - header = PacketBuilder._create_header(PAYLOAD_TYPE_PATH) + header = PacketBuilder._create_header(PAYLOAD_TYPE_PATH, route_type="flood", has_routing_path=False) return PacketBuilder._create_packet(header, payload) @staticmethod @@ -727,51 +728,20 @@ def create_text_message( pkt.payload = bytearray(payload) pkt.payload_len = len(payload) + + # Enhanced debug logging with packet details + route_type_names = {0: "TRANSPORT_FLOOD", 1: "FLOOD", 2: "DIRECT", 3: "TRANSPORT_DIRECT"} + header_route_type = pkt.header & 0x03 + logger.debug(f"Created TXT_MSG packet:") + logger.debug(f" Header: 0x{pkt.header:02X} (route_type={header_route_type}={route_type_names.get(header_route_type, 'UNKNOWN')})") + logger.debug(f" Path: {list(pkt.path)} (len={pkt.path_len})") + logger.debug(f" Payload: {len(pkt.payload)} bytes, first 10: {list(pkt.payload[:10])}") + logger.debug(f" Message: '{message}', attempt={attempt}, timestamp={timestamp}") + logger.debug(f" CRC: 0x{ack_crc:08X}") + return pkt, ack_crc - attempt &= 0x03 - timestamp = PacketBuilder._get_timestamp() - - # Use timestamp+data packing - plaintext = PacketBuilder._pack_timestamp_data(timestamp, attempt, message, b"\x00") - - # Use encryption and payload creation - payload, shared_secret, aes_key = PacketBuilder._create_encrypted_payload( - contact, local_identity, plaintext - ) - - # Calculate CRC using centralized packing - crc_input = PacketBuilder._pack_timestamp_data(timestamp, attempt, message) - ack_crc = int.from_bytes( - CryptoUtils.sha256(crc_input + local_identity.get_public_key())[:4], - "little", - ) + - # Use path validation - routing_path = ( - out_path if out_path is not None else (contact.out_path if contact.out_path else []) - ) - routing_path = PacketBuilder._validate_routing_path(routing_path) - - # Create packet with validated path - pkt = Packet() - has_path = bool(routing_path and len(routing_path) > 0) - pkt.header = PacketBuilder._create_header(PAYLOAD_TYPE_TXT_MSG, message_type, has_path) - - if routing_path and len(routing_path) > 0: - if len(routing_path) > MAX_PATH_SIZE: - logger.warning( - f"Path length {len(routing_path)} exceeds maximum {MAX_PATH_SIZE}, truncating" - ) - routing_path = routing_path[:MAX_PATH_SIZE] - - pkt.path = bytearray(routing_path) - pkt.path_len = len(pkt.path) - else: - pkt.path_len, pkt.path = 0, bytearray() - - pkt.payload = bytearray(payload) - pkt.payload_len = len(payload) - return pkt, ack_crc @staticmethod def create_protocol_request( diff --git a/src/pymc_core/protocol/packet_utils.py b/src/pymc_core/protocol/packet_utils.py index 9fc87ca..64b25c9 100644 --- a/src/pymc_core/protocol/packet_utils.py +++ b/src/pymc_core/protocol/packet_utils.py @@ -251,8 +251,92 @@ class RouteTypeUtils: def get_route_type_value(route_type: str, has_routing_path: bool = False) -> int: """Get numeric route type value with optional transport prefix.""" if has_routing_path: - return RouteTypeUtils.ROUTE_MAP.get( - f"transport_{route_type}", ROUTE_TYPE_TRANSPORT_FLOOD - ) - else: + # When path is supplied, use DIRECT routing (don't use transport variants) return RouteTypeUtils.ROUTE_MAP.get(route_type, ROUTE_TYPE_DIRECT) + else: + # When no path supplied, use FLOOD to build path automatically + return RouteTypeUtils.ROUTE_MAP.get(route_type, ROUTE_TYPE_FLOOD) + + +class PacketTimingUtils: + """Utilities for packet transmission timing calculations.""" + + @staticmethod + def estimate_airtime_ms(packet_length_bytes: int, radio_config: dict = None) -> float: + """ + Estimate LoRa packet airtime in milliseconds based on packet size and radio parameters. + + Args: + packet_length_bytes: Total packet length including headers + radio_config: Radio configuration dict with spreading_factor, bandwidth, etc. + + Returns: + Estimated airtime in milliseconds + """ + if radio_config is None: + # Default MeshCore parameters + radio_config = { + 'spreading_factor': 10, + 'bandwidth': 250000, # 250kHz + 'coding_rate': 5, + 'preamble_length': 8, + } + + sf = radio_config.get('spreading_factor', 10) + bw = radio_config.get('bandwidth', 250000) # Hz + cr = radio_config.get('coding_rate', 5) + preamble = radio_config.get('preamble_length', 8) + + # Simplified LoRa airtime calculation + # This is an approximation - real LoRa chips have more complex calculations + symbol_time = (2 ** sf) / bw # seconds per symbol + + # Preamble time + preamble_time = preamble * symbol_time + + # Payload symbols (simplified) + payload_symbols = 8 + max(0, (packet_length_bytes * 8 - 4 * sf + 28) // (4 * (sf - 2))) * (cr + 4) + payload_time = payload_symbols * symbol_time + + total_time_ms = (preamble_time + payload_time) * 1000 + + # Add some overhead for processing and turnaround + return max(total_time_ms, 50.0) # Minimum 50ms + + @staticmethod + def calc_flood_timeout_ms(packet_airtime_ms: float) -> float: + """ + Calculate timeout for flood packets. + + Formula: 500ms + (16.0 × airtime) + + Args: + packet_airtime_ms: Estimated packet airtime in milliseconds + + Returns: + Timeout in milliseconds + """ + SEND_TIMEOUT_BASE_MILLIS = 500 + FLOOD_SEND_TIMEOUT_FACTOR = 16.0 + return SEND_TIMEOUT_BASE_MILLIS + (FLOOD_SEND_TIMEOUT_FACTOR * packet_airtime_ms) + + @staticmethod + def calc_direct_timeout_ms(packet_airtime_ms: float, path_len: int) -> float: + """ + Calculate timeout for direct packets. + + Formula: 500ms + ((airtime × 6 + 250ms) × (path_len + 1)) + + Args: + packet_airtime_ms: Estimated packet airtime in milliseconds + path_len: Number of hops in the path (0 for direct) + + Returns: + Timeout in milliseconds + """ + SEND_TIMEOUT_BASE_MILLIS = 500 + DIRECT_SEND_PERHOP_FACTOR = 6.0 + DIRECT_SEND_PERHOP_EXTRA_MILLIS = 250 + return SEND_TIMEOUT_BASE_MILLIS + ( + (packet_airtime_ms * DIRECT_SEND_PERHOP_FACTOR + DIRECT_SEND_PERHOP_EXTRA_MILLIS) * (path_len + 1) + ) From 30ca37504df6ff8bd9c2fd4f35658df42561ba0c Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sun, 19 Oct 2025 22:29:52 +0100 Subject: [PATCH 04/12] add PacketTimingUtils to imports --- src/pymc_core/protocol/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pymc_core/protocol/__init__.py b/src/pymc_core/protocol/__init__.py index 450564f..5294a47 100644 --- a/src/pymc_core/protocol/__init__.py +++ b/src/pymc_core/protocol/__init__.py @@ -69,6 +69,7 @@ PacketHeaderUtils, PacketValidationUtils, RouteTypeUtils, + PacketTimingUtils ) from .utils import decode_appdata, parse_advert_payload @@ -90,6 +91,7 @@ "PacketHeaderUtils", "PacketHashingUtils", "RouteTypeUtils", + "PacketTimingUtils", # Header constants "PH_ROUTE_MASK", "PH_TYPE_SHIFT", From a10e21b24e9e0bc7525a3be36570831e47d9bf8c Mon Sep 17 00:00:00 2001 From: Lloyd Date: Mon, 20 Oct 2025 09:12:14 +0100 Subject: [PATCH 05/12] Update docs/docs/node.md update to calc in kHz Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/docs/node.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/node.md b/docs/docs/node.md index d188853..cc1dc38 100644 --- a/docs/docs/node.md +++ b/docs/docs/node.md @@ -174,7 +174,7 @@ radio = SX1262Radio( frequency=910525000, # 910.525 MHz (US standard) tx_power=22, # 22 dBm spreading_factor=7, # Spreading factor - bandwidth=62500, # 250 kHz + bandwidth=62500, # 62.5 kHz coding_rate=5, # 4/5 coding rate preamble_length=17 # Preamble length ) From 1d98f9258330f33a45a01d74ea21402236a026a0 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Mon, 20 Oct 2025 09:12:48 +0100 Subject: [PATCH 06/12] Update examples/common.py Update to comment Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/common.py b/examples/common.py index d081931..063b1a1 100644 --- a/examples/common.py +++ b/examples/common.py @@ -51,7 +51,7 @@ def create_radio(radio_type: str = "waveshare") -> LoRaRadio: "reset_pin": 18, "busy_pin": 20, "irq_pin": 16, - "txen_pin": 13, # GPIO 6 for TX enable + "txen_pin": 13, # GPIO 13 for TX enable "rxen_pin": 12, "frequency": int(869.525 * 1000000), # EU: 869.525 MHz "tx_power": 22, From 55fb05d02670759207d366d05b4a7ad9421335af Mon Sep 17 00:00:00 2001 From: Lloyd Date: Mon, 20 Oct 2025 09:17:45 +0100 Subject: [PATCH 07/12] Update docs/docs/examples.md update The setup instructions incorrectly reference 'Waveshare SX1262 HAT' when it should refer to the 'Frequency Labs meshadv-mini HAT' to match the section heading. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/docs/examples.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/examples.md b/docs/docs/examples.md index 2fd0e7f..4c398b7 100644 --- a/docs/docs/examples.md +++ b/docs/docs/examples.md @@ -300,7 +300,7 @@ The radio configuration is hardcoded in `common.py` for simplicity and reliabili 5. The configuration is pre-set in `common.py` for the Waveshare HAT ### Raspberry Pi with Frequency Labs meshadv-mini -1. Connect Waveshare SX1262 HAT to Raspberry Pi 40PIN GPIO header +1. Connect Frequency Labs meshadv-mini HAT to Raspberry Pi 40PIN GPIO header 2. Enable SPI interface in Raspberry Pi configuration (raspi-config) 3. Install required GPIO library: `sudo apt install python3-rpi.lgpio` 4. Remove old GPIO library if present: `sudo apt remove python3-rpi.gpio` From 058f4e8eb31dd0674010cb1b89bdbfe327866621 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Thu, 23 Oct 2025 19:58:20 +0100 Subject: [PATCH 08/12] improve ACK timing logging and enhance airtime estimation in PacketTimingUtils --- src/pymc_core/node/handlers/text.py | 19 ++++++++++++------- src/pymc_core/protocol/packet_utils.py | 19 +++++++++++++------ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/pymc_core/node/handlers/text.py b/src/pymc_core/node/handlers/text.py index 2d31dd1..0688998 100644 --- a/src/pymc_core/node/handlers/text.py +++ b/src/pymc_core/node/handlers/text.py @@ -104,9 +104,12 @@ async def __call__(self, packet: Packet) -> None: extra=ack_hash ) - - ack_airtime = PacketTimingUtils.estimate_airtime_ms(len(ack_packet.write_to()), self.radio_config) - ack_timeout_ms = PacketTimingUtils.calc_flood_timeout_ms(ack_airtime) / 1000.0 # Convert to seconds + packet_len = len(ack_packet.write_to()) + ack_airtime = PacketTimingUtils.estimate_airtime_ms(packet_len, self.radio_config) + ack_timeout_ms = PacketTimingUtils.calc_flood_timeout_ms(ack_airtime) + + self.log(f"FLOOD ACK timing - packet:{packet_len}B, airtime:{ack_airtime:.1f}ms, delay:{ack_timeout_ms:.1f}ms") + ack_timeout_ms = ack_timeout_ms / 1000.0 # Convert to seconds else: # DIRECT messages use discrete ACK packets @@ -117,10 +120,12 @@ async def __call__(self, packet: Packet) -> None: text=message_body.rstrip(b"\x00"), ) - - ack_airtime = PacketTimingUtils.estimate_airtime_ms(len(ack_packet.write_to()), self.radio_config) - # For direct ACKs, path_len is typically 0 (direct response) - ack_timeout_ms = PacketTimingUtils.calc_direct_timeout_ms(ack_airtime, 0) / 1000.0 # Convert to seconds + packet_len = len(ack_packet.write_to()) + ack_airtime = PacketTimingUtils.estimate_airtime_ms(packet_len, self.radio_config) + ack_timeout_ms = PacketTimingUtils.calc_direct_timeout_ms(ack_airtime, 0) + + self.log(f"DIRECT ACK timing - packet:{packet_len}B, airtime:{ack_airtime:.1f}ms, delay:{ack_timeout_ms:.1f}ms, radio_config:{self.radio_config}") + ack_timeout_ms = ack_timeout_ms / 1000.0 # Convert to seconds async def send_delayed_ack(): await asyncio.sleep(ack_timeout_ms) diff --git a/src/pymc_core/protocol/packet_utils.py b/src/pymc_core/protocol/packet_utils.py index 64b25c9..040959c 100644 --- a/src/pymc_core/protocol/packet_utils.py +++ b/src/pymc_core/protocol/packet_utils.py @@ -156,7 +156,7 @@ def hash_bytes(dest_pubkey: bytes, src_pubkey: bytes) -> bytearray: @staticmethod def calculate_snr_db(raw_snr: int) -> float: """Convert raw SNR value to decibels.""" - return raw_snr / 4.0 if raw_snr is not None else 0.0 + return raw_snr if raw_snr is not None else 0.0 class PacketHeaderUtils: @@ -265,16 +265,17 @@ class PacketTimingUtils: def estimate_airtime_ms(packet_length_bytes: int, radio_config: dict = None) -> float: """ Estimate LoRa packet airtime in milliseconds based on packet size and radio parameters. - + Args: packet_length_bytes: Total packet length including headers radio_config: Radio configuration dict with spreading_factor, bandwidth, etc. + Can include 'measured_airtime_ms' for actual measured value Returns: Estimated airtime in milliseconds """ if radio_config is None: - # Default MeshCore parameters + radio_config = { 'spreading_factor': 10, 'bandwidth': 250000, # 250kHz @@ -282,13 +283,19 @@ def estimate_airtime_ms(packet_length_bytes: int, radio_config: dict = None) -> 'preamble_length': 8, } + + if 'measured_airtime_ms' in radio_config: + return radio_config['measured_airtime_ms'] + sf = radio_config.get('spreading_factor', 10) - bw = radio_config.get('bandwidth', 250000) # Hz + bw = radio_config.get('bandwidth', 250000) # Hz or kHz - convert to Hz if needed cr = radio_config.get('coding_rate', 5) preamble = radio_config.get('preamble_length', 8) - # Simplified LoRa airtime calculation - # This is an approximation - real LoRa chips have more complex calculations + # Convert bandwidth to Hz if it's in kHz (values < 1000 are assumed to be kHz) + if bw < 1000: + bw = bw * 1000 # Convert kHz to Hz + symbol_time = (2 ** sf) / bw # seconds per symbol # Preamble time From ed4d0d2daa901ae2f549b25e3ab9629a3a09f2e7 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 24 Oct 2025 21:50:54 +0100 Subject: [PATCH 09/12] bump version to 1.0.1 and optimize name byte handling in PacketBuilder --- src/pymc_core/__init__.py | 2 +- src/pymc_core/protocol/packet_builder.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pymc_core/__init__.py b/src/pymc_core/__init__.py index 5bcac9a..25ed2b5 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.0" +__version__ = "1.0.1" # Core mesh functionality from .node.node import MeshNode diff --git a/src/pymc_core/protocol/packet_builder.py b/src/pymc_core/protocol/packet_builder.py index 09e6ff7..f05e31b 100644 --- a/src/pymc_core/protocol/packet_builder.py +++ b/src/pymc_core/protocol/packet_builder.py @@ -169,7 +169,7 @@ def _encode_advert_data( # Add name if present if final_flags & ADVERT_FLAG_HAS_NAME: name_bytes = name.encode("utf-8")[:31] + b"\x00" - buf += name_bytes.ljust(32, b"\x00") + buf += name_bytes else: buf += bytes(32) From 1a41560c7c9a69349a655e791ca51828878afbc7 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 24 Oct 2025 22:39:40 +0100 Subject: [PATCH 10/12] update pre-commit configuration and README links; bump version to 1.0.1 --- .pre-commit-config.yaml | 2 +- README.md | 2 +- docs/docs/index.md | 4 +- examples/common.py | 6 +- examples/ping_repeater_trace.py | 4 +- examples/send_flood_advert.py | 2 +- examples/send_tracked_advert.py | 5 +- pyproject.toml | 2 +- src/pymc_core/hardware/lora/LoRaRF/SX126x.py | 40 +++------ src/pymc_core/hardware/lora/LoRaRF/SX127x.py | 40 +++------ src/pymc_core/node/dispatcher.py | 60 ++++---------- src/pymc_core/node/events/event_service.py | 12 +-- src/pymc_core/node/handlers/advert.py | 22 ++--- src/pymc_core/node/handlers/path.py | 12 +-- src/pymc_core/node/handlers/text.py | 50 ++++++----- src/pymc_core/protocol/__init__.py | 2 +- src/pymc_core/protocol/packet_builder.py | 19 +++-- src/pymc_core/protocol/packet_filter.py | 4 +- src/pymc_core/protocol/packet_utils.py | 87 +++++++++----------- tests/test_dispatcher.py | 38 ++------- tests/test_handlers.py | 20 ++--- tests/test_packet_builder.py | 4 +- tests/test_packet_utils.py | 4 +- 23 files changed, 151 insertions(+), 290 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8923285..d598134 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: # Strict but reasonable settings args: [ "--max-line-length=100", - "--extend-ignore=E203,W503" + "--extend-ignore=E203,E231,W503" ] # Exclude third-party code but check our core code exclude: '^(src/pymc_core/hardware/lora/|examples/)' diff --git a/README.md b/README.md index 78a7c31..82228dc 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - [Documentation](https://rightup.github.io/pyMC_core/) - [Issues](https://github.com/rightup/pyMC_Core/issues) - [Discussions](https://github.com/rightup/pyMC_Core/discussions) -- [Meshcore Discord](https://discord.com/channels/1343693475589263471/1343693475589263474) +- [Meshcore Discord](https://discord.gg/fThwBrRc3Q) --- diff --git a/docs/docs/index.md b/docs/docs/index.md index de24882..f227af6 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -134,9 +134,9 @@ await node.start() ## Acknowledgements -- Thanks to [MeshCore](https://github.com/meshcore-dev) for the original C++ implementation. +- Thanks to [MeshCore](https://github.com/meshcore-dev) for the original C++ implementation. - Appreciation to **@scott_33238**, **@liamcottle**, **@recrof**, and **@cheaporeps** on Discord - for their ongoing help and patience with my questions. + for their ongoing help and patience with my questions. - Waveshare LoRaRF library, modified to use modern `gpiozero` library (`DigitalInputDevice` and `DigitalOutputDevice`) for all GPIO operations, replacing legacy RPi.GPIO methods for compatibility across all recent Raspberry Pi models (Zero, 3, 4, 5) - Contributors and third-party libraries (see `pyproject.toml`) diff --git a/examples/common.py b/examples/common.py index e378dad..256616d 100644 --- a/examples/common.py +++ b/examples/common.py @@ -92,7 +92,7 @@ def create_radio(radio_type: str = "waveshare") -> LoRaRadio: "bandwidth": int(62.5 * 1000), "coding_rate": 5, "preamble_length": 17, - } + }, } if radio_type not in configs: @@ -136,9 +136,7 @@ def create_mesh_node( # Create a local identity (this generates a new keypair) logger.debug("Creating LocalIdentity...") identity = LocalIdentity() - logger.info( - f"Created identity with public key: {identity.get_public_key().hex()[:16]}..." - ) + logger.info(f"Created identity with public key: {identity.get_public_key().hex()[:16]}...") # Create the SX1262 radio logger.debug("Creating radio...") diff --git a/examples/ping_repeater_trace.py b/examples/ping_repeater_trace.py index 2264ac3..4f01f39 100644 --- a/examples/ping_repeater_trace.py +++ b/examples/ping_repeater_trace.py @@ -50,9 +50,7 @@ def on_trace_response(success: bool, response_text: str, response_data: dict): trace_handler = mesh_node.dispatcher.trace_handler if trace_handler: # Use the target repeater's hash for the callback - repeater_hash_hex = ( - "b5d8df576ee9ab9ba4e71dc3ef753c6383f1215306139b0cc3bb2c02136d7f65" - ) + repeater_hash_hex = "b5d8df576ee9ab9ba4e71dc3ef753c6383f1215306139b0cc3bb2c02136d7f65" repeater_pubkey_hash = bytes.fromhex(repeater_hash_hex) repeater_hash = repeater_pubkey_hash[0] diff --git a/examples/send_flood_advert.py b/examples/send_flood_advert.py index 4cc678d..1db234b 100644 --- a/examples/send_flood_advert.py +++ b/examples/send_flood_advert.py @@ -61,7 +61,7 @@ def main(radio_type: str = "waveshare"): # 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']: + if radio_type not in ["waveshare", "uconsole", "meshadv-mini"]: print("Usage: python send_flood_advert.py [waveshare|uconsole|meshadv-mini]") sys.exit(1) diff --git a/examples/send_tracked_advert.py b/examples/send_tracked_advert.py index e7aa5b5..d59f5d8 100644 --- a/examples/send_tracked_advert.py +++ b/examples/send_tracked_advert.py @@ -31,10 +31,7 @@ async def simple_repeat_counter(packet, raw_data=None): try: # Check if this is an advert packet - if ( - hasattr(packet, "get_payload_type") - and packet.get_payload_type() == PAYLOAD_TYPE_ADVERT - ): + if hasattr(packet, "get_payload_type") and packet.get_payload_type() == PAYLOAD_TYPE_ADVERT: repeat_count += 1 print(f"ADVERT REPEAT HEARD #{repeat_count}") except Exception as e: diff --git a/pyproject.toml b/pyproject.toml index 92b5457..84eddf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pymc_core" -version = "1.0.0" +version = "1.0.1" authors = [ {name = "Lloyd Newton", email = "lloyd@rightup.co.uk"}, ] diff --git a/src/pymc_core/hardware/lora/LoRaRF/SX126x.py b/src/pymc_core/hardware/lora/LoRaRF/SX126x.py index 8aab5ba..dd1a88b 100644 --- a/src/pymc_core/hardware/lora/LoRaRF/SX126x.py +++ b/src/pymc_core/hardware/lora/LoRaRF/SX126x.py @@ -114,12 +114,8 @@ class SX126x(BaseLoRa): # SetRxTxFallbackMode FALLBACK_FS = 0x40 # after Rx/Tx go to: FS mode - FALLBACK_STDBY_XOSC = ( - 0x30 # standby mode with crystal oscillator - ) - FALLBACK_STDBY_RC = ( - 0x20 # standby mode with RC oscillator (default) - ) + FALLBACK_STDBY_XOSC = 0x30 # standby mode with crystal oscillator + FALLBACK_STDBY_RC = 0x20 # standby mode with RC oscillator (default) # SetDioIrqParams IRQ_TX_DONE = 0x0001 # packet transmission completed @@ -233,9 +229,7 @@ class SX126x(BaseLoRa): PREAMBLE_DET_LEN_32 = 0x07 # 32-bit ADDR_COMP_OFF = 0x00 # FSK address filtering: off ADDR_COMP_NODE = 0x01 # filtering on node address - ADDR_COMP_ALL = ( - 0x02 # filtering on node and broadcast address - ) + ADDR_COMP_ALL = 0x02 # filtering on node and broadcast address PACKET_KNOWN = 0x00 # FSK packet type: the packet length known on both side PACKET_VARIABLE = 0x01 # the packet length on variable size CRC_0 = 0x01 # FSK CRC type: no CRC @@ -256,9 +250,7 @@ class SX126x(BaseLoRa): CAD_EXIT_RX = 0x01 # after CAD is done, exit to Rx mode if activity is detected # GetStatus - STATUS_DATA_AVAILABLE = ( - 0x04 # command status: packet received and data can be retrieved - ) + STATUS_DATA_AVAILABLE = 0x04 # command status: packet received and data can be retrieved STATUS_CMD_TIMEOUT = 0x06 # SPI command timed out STATUS_CMD_ERROR = 0x08 # invalid SPI command STATUS_CMD_FAILED = 0x0A # SPI command failed to execute @@ -715,9 +707,7 @@ def setLoRaPacket( else: invertIq = self.IQ_STANDARD - self.setPacketParamsLoRa( - preambleLength, headerType, payloadLength, crcType, invertIq - ) + self.setPacketParamsLoRa(preambleLength, headerType, payloadLength, crcType, invertIq) self._fixInvertedIq(invertIq) def setSpreadingFactor(self, sf: int): @@ -897,9 +887,7 @@ def request(self, timeout: int = RX_SINGLE) -> bool: if self.getMode() == self.STATUS_MODE_RX: return False # clear previous interrupt and set RX done, RX timeout, header error, and CRC error as interrupt source - self._irqSetup( - self.IRQ_RX_DONE | self.IRQ_TIMEOUT | self.IRQ_HEADER_ERR | self.IRQ_CRC_ERR - ) + self._irqSetup(self.IRQ_RX_DONE | self.IRQ_TIMEOUT | self.IRQ_HEADER_ERR | self.IRQ_CRC_ERR) # set status to RX wait or RX continuous wait self._statusWait = self.STATUS_RX_WAIT self._statusIrq = 0x0000 @@ -924,9 +912,7 @@ def listen(self, rxPeriod: int, sleepPeriod: int) -> bool: if self.getMode() == self.STATUS_MODE_RX: return False # clear previous interrupt and set RX done, RX timeout, header error, and CRC error as interrupt source - self._irqSetup( - self.IRQ_RX_DONE | self.IRQ_TIMEOUT | self.IRQ_HEADER_ERR | self.IRQ_CRC_ERR - ) + self._irqSetup(self.IRQ_RX_DONE | self.IRQ_TIMEOUT | self.IRQ_HEADER_ERR | self.IRQ_CRC_ERR) # set status to RX wait or RX continuous wait self._statusWait = self.STATUS_RX_WAIT self._statusIrq = 0x0000 @@ -1235,9 +1221,7 @@ def readBuffer(self, offset: int, nData: int) -> tuple: ### SX126X API: DIO AND IRQ CONTROL ### - def setDioIrqParams( - self, irqMask: int, dio1Mask: int, dio2Mask: int, dio3Mask: int - ): + def setDioIrqParams(self, irqMask: int, dio1Mask: int, dio2Mask: int, dio3Mask: int): buf = ( (irqMask >> 8) & 0xFF, irqMask & 0xFF, @@ -1296,9 +1280,7 @@ def setModulationParamsLoRa(self, sf: int, bw: int, cr: int, ldro: int): buf = (sf, bw, cr, ldro, 0, 0, 0, 0) self._writeBytes(0x8B, buf, 8) - def setModulationParamsFsk( - self, br: int, pulseShape: int, bandwidth: int, Fdev: int - ): + def setModulationParamsFsk(self, br: int, pulseShape: int, bandwidth: int, Fdev: int): buf = ( (br >> 16) & 0xFF, (br >> 8) & 0xFF, @@ -1471,9 +1453,7 @@ def _writeBytes(self, opCode: int, data: tuple, nBytes: int): time.sleep(0.000001) # 1µs hold time before CS release _get_output(self._cs_define).on() - def _readBytes( - self, opCode: int, nBytes: int, address: tuple = (), nAddress: int = 0 - ) -> tuple: + def _readBytes(self, opCode: int, nBytes: int, address: tuple = (), nAddress: int = 0) -> tuple: if self.busyCheck(): return () diff --git a/src/pymc_core/hardware/lora/LoRaRF/SX127x.py b/src/pymc_core/hardware/lora/LoRaRF/SX127x.py index 6d06fdb..790fe0a 100644 --- a/src/pymc_core/hardware/lora/LoRaRF/SX127x.py +++ b/src/pymc_core/hardware/lora/LoRaRF/SX127x.py @@ -90,12 +90,8 @@ class SX126x(BaseLoRa): # SetRxTxFallbackMode FALLBACK_FS = 0x40 # after Rx/Tx go to: FS mode - FALLBACK_STDBY_XOSC = ( - 0x30 # standby mode with crystal oscillator - ) - FALLBACK_STDBY_RC = ( - 0x20 # standby mode with RC oscillator (default) - ) + FALLBACK_STDBY_XOSC = 0x30 # standby mode with crystal oscillator + FALLBACK_STDBY_RC = 0x20 # standby mode with RC oscillator (default) # SetDioIrqParams IRQ_TX_DONE = 0x0001 # packet transmission completed @@ -209,9 +205,7 @@ class SX126x(BaseLoRa): PREAMBLE_DET_LEN_32 = 0x07 # 32-bit ADDR_COMP_OFF = 0x00 # FSK address filtering: off ADDR_COMP_NODE = 0x01 # filtering on node address - ADDR_COMP_ALL = ( - 0x02 # filtering on node and broadcast address - ) + ADDR_COMP_ALL = 0x02 # filtering on node and broadcast address PACKET_KNOWN = 0x00 # FSK packet type: the packet length known on both side PACKET_VARIABLE = 0x01 # the packet length on variable size CRC_0 = 0x01 # FSK CRC type: no CRC @@ -232,9 +226,7 @@ class SX126x(BaseLoRa): CAD_EXIT_RX = 0x01 # after CAD is done, exit to Rx mode if activity is detected # GetStatus - STATUS_DATA_AVAILABLE = ( - 0x04 # command status: packet received and data can be retrieved - ) + STATUS_DATA_AVAILABLE = 0x04 # command status: packet received and data can be retrieved STATUS_CMD_TIMEOUT = 0x06 # SPI command timed out STATUS_CMD_ERROR = 0x08 # invalid SPI command STATUS_CMD_FAILED = 0x0A # SPI command failed to execute @@ -652,9 +644,7 @@ def setLoRaPacket( else: invertIq = self.IQ_STANDARD - self.setPacketParamsLoRa( - preambleLength, headerType, payloadLength, crcType, invertIq - ) + self.setPacketParamsLoRa(preambleLength, headerType, payloadLength, crcType, invertIq) self._fixInvertedIq(invertIq) def setSpreadingFactor(self, sf: int): @@ -834,9 +824,7 @@ def request(self, timeout: int = RX_SINGLE) -> bool: if self.getMode() == self.STATUS_MODE_RX: return False # clear previous interrupt and set RX done, RX timeout, header error, and CRC error as interrupt source - self._irqSetup( - self.IRQ_RX_DONE | self.IRQ_TIMEOUT | self.IRQ_HEADER_ERR | self.IRQ_CRC_ERR - ) + self._irqSetup(self.IRQ_RX_DONE | self.IRQ_TIMEOUT | self.IRQ_HEADER_ERR | self.IRQ_CRC_ERR) # set status to RX wait or RX continuous wait self._statusWait = self.STATUS_RX_WAIT self._statusIrq = 0x0000 @@ -861,9 +849,7 @@ def listen(self, rxPeriod: int, sleepPeriod: int) -> bool: if self.getMode() == self.STATUS_MODE_RX: return False # clear previous interrupt and set RX done, RX timeout, header error, and CRC error as interrupt source - self._irqSetup( - self.IRQ_RX_DONE | self.IRQ_TIMEOUT | self.IRQ_HEADER_ERR | self.IRQ_CRC_ERR - ) + self._irqSetup(self.IRQ_RX_DONE | self.IRQ_TIMEOUT | self.IRQ_HEADER_ERR | self.IRQ_CRC_ERR) # set status to RX wait or RX continuous wait self._statusWait = self.STATUS_RX_WAIT self._statusIrq = 0x0000 @@ -1172,9 +1158,7 @@ def readBuffer(self, offset: int, nData: int) -> tuple: ### SX126X API: DIO AND IRQ CONTROL ### - def setDioIrqParams( - self, irqMask: int, dio1Mask: int, dio2Mask: int, dio3Mask: int - ): + def setDioIrqParams(self, irqMask: int, dio1Mask: int, dio2Mask: int, dio3Mask: int): buf = ( (irqMask >> 8) & 0xFF, irqMask & 0xFF, @@ -1233,9 +1217,7 @@ def setModulationParamsLoRa(self, sf: int, bw: int, cr: int, ldro: int): buf = (sf, bw, cr, ldro, 0, 0, 0, 0) self._writeBytes(0x8B, buf, 8) - def setModulationParamsFsk( - self, br: int, pulseShape: int, bandwidth: int, Fdev: int - ): + def setModulationParamsFsk(self, br: int, pulseShape: int, bandwidth: int, Fdev: int): buf = ( (br >> 16) & 0xFF, (br >> 8) & 0xFF, @@ -1397,9 +1379,7 @@ def _writeBytes(self, opCode: int, data: tuple, nBytes: int): time.sleep(0.000001) # 1µs hold time before CS release _get_output(self._cs_define).on() - def _readBytes( - self, opCode: int, nBytes: int, address: tuple = (), nAddress: int = 0 - ) -> tuple: + def _readBytes(self, opCode: int, nBytes: int, address: tuple = (), nAddress: int = 0) -> tuple: if self.busyCheck(): return () # Ensure CS starts high, then pull low with setup time diff --git a/src/pymc_core/node/dispatcher.py b/src/pymc_core/node/dispatcher.py index cc95e5b..f405d37 100644 --- a/src/pymc_core/node/dispatcher.py +++ b/src/pymc_core/node/dispatcher.py @@ -60,17 +60,11 @@ def __init__( self.tx_delay = tx_delay self.state: DispatcherState = DispatcherState.IDLE - self.packet_received_callback: Optional[ - Callable[[Packet], Awaitable[None] | None] - ] = None - self.packet_sent_callback: Optional[ - Callable[[Packet], Awaitable[None] | None] - ] = None + self.packet_received_callback: Optional[Callable[[Packet], Awaitable[None] | None]] = None + self.packet_sent_callback: Optional[Callable[[Packet], Awaitable[None] | None]] = None # Add raw packet callback for detailed logging - self.raw_packet_callback: Optional[ - Callable[[Packet, bytes], Awaitable[None] | None] - ] = None + self.raw_packet_callback: Optional[Callable[[Packet, bytes], Awaitable[None] | None]] = None self._handlers: dict[int, Any] = {} # Keep track of packet handlers self._handler_instances: dict[ @@ -194,16 +188,12 @@ def register_default_handlers( ), ) # Protocol response handler for encrypted responses (including telemetry) - protocol_response_handler = ProtocolResponseHandler( - self._log, local_identity, contacts - ) + protocol_response_handler = ProtocolResponseHandler(self._log, local_identity, contacts) # Keep a reference for the node self.protocol_response_handler = protocol_response_handler # Login response handler for PAYLOAD_TYPE_RESPONSE packets - login_response_handler = LoginResponseHandler( - local_identity, contacts, self._log - ) + login_response_handler = LoginResponseHandler(local_identity, contacts, self._log) # Connect protocol response handler for forwarding telemetry login_response_handler.set_protocol_response_handler(protocol_response_handler) # Keep references for backward compatibility @@ -272,9 +262,7 @@ def _is_own_packet(self, pkt: Packet) -> bool: is_own = src_hash == our_hash if is_own: - self._log( - f"Own packet detected: src_hash={src_hash:02X}, our_hash={our_hash:02X}" - ) + self._log(f"Own packet detected: src_hash={src_hash:02X}, our_hash={our_hash:02X}") return is_own @@ -308,9 +296,7 @@ def _on_packet_received(self, data: bytes) -> None: async def _process_received_packet(self, data: bytes) -> None: """Process a received packet from the radio callback.""" - self._log( - f"[RX DEBUG] Processing packet: {len(data)} bytes, data: {data.hex()[:32]}..." - ) + self._log(f"[RX DEBUG] Processing packet: {len(data)} bytes, data: {data.hex()[:32]}...") # Generate packet hash for deduplication and blacklist checking packet_hash = self.packet_filter.generate_hash(data) @@ -362,9 +348,7 @@ async def _process_received_packet(self, data: bytes) -> None: # Always call raw packet callback first for logging (regardless of source) if self.raw_packet_callback: - await self._invoke_enhanced_raw_callback( - self.raw_packet_callback, pkt, data, {} - ) + await self._invoke_enhanced_raw_callback(self.raw_packet_callback, pkt, data, {}) self._log("[RX DEBUG] Raw packet callback completed") # Check if this is our own packet before processing handlers @@ -375,9 +359,7 @@ async def _process_received_packet(self, data: bytes) -> None: self._log( " This suggests your packet was repeated by another node and came back to you!" ) - self._log( - f"Ignoring own packet (type={pkt.header >> 4:02X}) to prevent loops" - ) + self._log(f"Ignoring own packet (type={pkt.header >> 4:02X}) to prevent loops") return # Handle ACK matching for waiting senders @@ -425,12 +407,8 @@ async def send_packet( return False # Log what we sent type_name = PAYLOAD_TYPES.get(payload_type, f"UNKNOWN_{payload_type}") - route_name = ROUTE_TYPES.get( - packet.get_route_type(), f"UNKNOWN_{packet.get_route_type()}" - ) - self._log( - f"TX {packet.get_raw_length()} bytes (type={type_name}, route={route_name})" - ) + route_name = ROUTE_TYPES.get(packet.get_route_type(), f"UNKNOWN_{packet.get_route_type()}") + self._log(f"TX {packet.get_raw_length()} bytes (type={type_name}, route={route_name})") if self.packet_sent_callback: await self._invoke_callback(self.packet_sent_callback, packet) @@ -458,9 +436,7 @@ async def send_packet( try: # Wait for the ACK using the event-based system - ack_received = await self.wait_for_ack( - self._current_expected_crc, ACK_TIMEOUT - ) + ack_received = await self.wait_for_ack(self._current_expected_crc, ACK_TIMEOUT) if ack_received: self._log(f"[>>acK] received for CRC {self._current_expected_crc:08X}") return True @@ -505,13 +481,9 @@ async def _dispatch(self, pkt: Packet) -> None: type_name = PAYLOAD_TYPES.get(payload_type, f"UNKNOWN_{payload_type}") self._log(f"RX {type_name} ({payload_type})") - self._logger.debug( - f"Received packet type {type_name}, payload length: {pkt.payload_len}" - ) + self._logger.debug(f"Received packet type {type_name}, payload length: {pkt.payload_len}") if pkt.payload_len > 0: - self._logger.debug( - f"Payload preview: {pkt.payload[:min(10, pkt.payload_len)].hex()}" - ) + self._logger.debug(f"Payload preview: {pkt.payload[:min(10, pkt.payload_len)].hex()}") handler = self._get_handler(payload_type) if not handler: @@ -547,9 +519,7 @@ async def run_forever(self) -> None: while True: # Clean out old ACK CRCs (older than 5 seconds) now = asyncio.get_event_loop().time() - self._recent_acks = { - crc: ts for crc, ts in self._recent_acks.items() if now - ts < 5 - } + self._recent_acks = {crc: ts for crc, ts in self._recent_acks.items() if now - ts < 5} # Clean old packet hashes for deduplication self.packet_filter.cleanup_old_hashes() diff --git a/src/pymc_core/node/events/event_service.py b/src/pymc_core/node/events/event_service.py index 2afbd5d..b970f5b 100644 --- a/src/pymc_core/node/events/event_service.py +++ b/src/pymc_core/node/events/event_service.py @@ -46,9 +46,7 @@ def unsubscribe(self, event_type: str, subscriber: EventSubscriber) -> None: if event_type in self._subscribers: try: self._subscribers[event_type].remove(subscriber) - self.logger.debug( - f"Unsubscribed {subscriber.__class__.__name__} from {event_type}" - ) + self.logger.debug(f"Unsubscribed {subscriber.__class__.__name__} from {event_type}") except ValueError: pass @@ -56,9 +54,7 @@ def unsubscribe_all(self, subscriber: EventSubscriber) -> None: """Unsubscribe from all events.""" try: self._global_subscribers.remove(subscriber) - self.logger.debug( - f"Removed global subscriber {subscriber.__class__.__name__}" - ) + self.logger.debug(f"Removed global subscriber {subscriber.__class__.__name__}") except ValueError: pass @@ -72,9 +68,7 @@ async def publish(self, event_type: str, data: Dict[str, Any]) -> None: try: await subscriber.handle_event(event_type, data) except Exception as e: - self.logger.error( - f"Error in subscriber {subscriber.__class__.__name__}: {e}" - ) + self.logger.error(f"Error in subscriber {subscriber.__class__.__name__}: {e}") # Notify global subscribers for subscriber in self._global_subscribers: diff --git a/src/pymc_core/node/handlers/advert.py b/src/pymc_core/node/handlers/advert.py index 2a83bae..acc740c 100644 --- a/src/pymc_core/node/handlers/advert.py +++ b/src/pymc_core/node/handlers/advert.py @@ -1,11 +1,7 @@ import time from ...protocol import Packet, decode_appdata -from ...protocol.constants import ( - PAYLOAD_TYPE_ADVERT, - PUB_KEY_SIZE, - describe_advert_flags, -) +from ...protocol.constants import PAYLOAD_TYPE_ADVERT, PUB_KEY_SIZE, describe_advert_flags from ...protocol.utils import determine_contact_type_from_flags from .base import BaseHandler @@ -29,9 +25,7 @@ async def __call__(self, packet: Packet) -> None: if self.contacts is not None: self.log(f"Processing advert for pubkey: {pubkey_hex}") - contact = next( - (c for c in self.contacts.contacts if c.public_key == pubkey_hex), None - ) + contact = next((c for c in self.contacts.contacts if c.public_key == pubkey_hex), None) if contact: self.log(f"Peer identity already known: {contact.name}") contact.last_advert = int(time.time()) @@ -45,9 +39,7 @@ async def __call__(self, packet: Packet) -> None: # Require valid name - ignore packet if no name present if not name: - self.log( - f"Ignoring advert packet without name (pubkey={pubkey_hex[:8]}...)" - ) + self.log(f"Ignoring advert packet without name (pubkey={pubkey_hex[:8]}...)") return self.log(f"Processing contact with name: {name}") @@ -74,10 +66,6 @@ async def __call__(self, packet: Packet) -> None: try: from ..events import MeshEvents - self.event_service.publish_sync( - MeshEvents.NEW_CONTACT, new_contact_data - ) + self.event_service.publish_sync(MeshEvents.NEW_CONTACT, new_contact_data) except Exception as broadcast_error: - self.log( - f"Failed to publish new contact event: {broadcast_error}" - ) + self.log(f"Failed to publish new contact event: {broadcast_error}") diff --git a/src/pymc_core/node/handlers/path.py b/src/pymc_core/node/handlers/path.py index 255d995..98eddf1 100644 --- a/src/pymc_core/node/handlers/path.py +++ b/src/pymc_core/node/handlers/path.py @@ -67,9 +67,7 @@ async def __call__(self, pkt: Packet) -> None: self._dispatcher.packet_analysis_callback(pkt) self._log("PATH packet analysis delegated to app") else: - self._log( - "PATH packet received - hop analysis requires app-level analyzer" - ) + self._log("PATH packet received - hop analysis requires app-level analyzer") except Exception as e: self._log(f"PATH packet analysis failed: {e}") @@ -79,9 +77,7 @@ async def __call__(self, pkt: Packet) -> None: payload = pkt.get_payload() if len(payload) >= 2: hop_count = payload[1] - self._log( - f"PATH packet: hop_count={hop_count}, payload_len={len(payload)}" - ) + self._log(f"PATH packet: hop_count={hop_count}, payload_len={len(payload)}") self._log(f"Path contains {hop_count} hops") else: self._log("PATH packet received with minimal payload") @@ -96,9 +92,7 @@ async def __call__(self, pkt: Packet) -> None: # Extract route type from packet header if possible # This is a simplified version without full analysis - self._log( - "PATH packet routing analysis requires app-level analyzer" - ) + self._log("PATH packet routing analysis requires app-level analyzer") except ImportError: pass diff --git a/src/pymc_core/node/handlers/text.py b/src/pymc_core/node/handlers/text.py index 0688998..4e04373 100644 --- a/src/pymc_core/node/handlers/text.py +++ b/src/pymc_core/node/handlers/text.py @@ -1,8 +1,7 @@ import asyncio -import struct from ...protocol import CryptoUtils, Identity, Packet, PacketBuilder, PacketTimingUtils -from ...protocol.constants import PAYLOAD_TYPE_TXT_MSG, PAYLOAD_TYPE_ACK +from ...protocol.constants import PAYLOAD_TYPE_ACK, PAYLOAD_TYPE_TXT_MSG from .base import BaseHandler @@ -52,9 +51,7 @@ async def __call__(self, packet: Packet) -> None: return peer_id = Identity(bytes.fromhex(matched_contact.public_key)) - shared_secret = peer_id.calc_shared_secret( - self.local_identity.get_private_key() - ) + shared_secret = peer_id.calc_shared_secret(self.local_identity.get_private_key()) aes_key = shared_secret[:16] payload = packet.payload[2:] # Skip dest_hash and src_hash @@ -80,35 +77,41 @@ async def __call__(self, packet: Packet) -> None: # Determine message routing type from packet header route_type = packet.header & 0x03 # Route type is in bits 0-1 is_flood = route_type == 1 # ROUTE_TYPE_FLOOD = 1 - - self.log(f"Processing message - route_type: {route_type}, is_flood: {is_flood}, timestamp: {timestamp_int}") + + self.debug( + f"Processing message - route_type: {route_type}, is_flood: {is_flood}, " + f"timestamp: {timestamp_int}" + ) # Create appropriate ACK response if is_flood: # FLOOD messages use PATH ACK responses with ACK hash in extra payload text_bytes = message_body.rstrip(b"\x00") - + # Calculate ACK hash using standard method (same as DIRECT messages) pack_data = PacketBuilder._pack_timestamp_data(timestamp_int, attempt, text_bytes) ack_hash = CryptoUtils.sha256(pack_data + pubkey)[:4] - + # Create PATH ACK response - incoming_path = list(packet.path if hasattr(packet, 'path') else []) - + incoming_path = list(packet.path if hasattr(packet, "path") else []) + ack_packet = PacketBuilder.create_path_return( dest_hash=PacketBuilder._hash_byte(pubkey), src_hash=PacketBuilder._hash_byte(self.local_identity.get_public_key()), secret=shared_secret, path=incoming_path, extra_type=PAYLOAD_TYPE_ACK, - extra=ack_hash + extra=ack_hash, ) packet_len = len(ack_packet.write_to()) ack_airtime = PacketTimingUtils.estimate_airtime_ms(packet_len, self.radio_config) ack_timeout_ms = PacketTimingUtils.calc_flood_timeout_ms(ack_airtime) - - self.log(f"FLOOD ACK timing - packet:{packet_len}B, airtime:{ack_airtime:.1f}ms, delay:{ack_timeout_ms:.1f}ms") + + self.log( + f"FLOOD ACK timing - packet:{packet_len}B, airtime:{ack_airtime:.1f}ms, " + f"delay:{ack_timeout_ms:.1f}ms" + ) ack_timeout_ms = ack_timeout_ms / 1000.0 # Convert to seconds else: @@ -123,15 +126,21 @@ async def __call__(self, packet: Packet) -> None: packet_len = len(ack_packet.write_to()) ack_airtime = PacketTimingUtils.estimate_airtime_ms(packet_len, self.radio_config) ack_timeout_ms = PacketTimingUtils.calc_direct_timeout_ms(ack_airtime, 0) - - self.log(f"DIRECT ACK timing - packet:{packet_len}B, airtime:{ack_airtime:.1f}ms, delay:{ack_timeout_ms:.1f}ms, radio_config:{self.radio_config}") + + self.log( + f"DIRECT ACK timing - packet:{packet_len}B, airtime:{ack_airtime:.1f}ms, " + f"delay:{ack_timeout_ms:.1f}ms, radio_config:{self.radio_config}" + ) ack_timeout_ms = ack_timeout_ms / 1000.0 # Convert to seconds async def send_delayed_ack(): await asyncio.sleep(ack_timeout_ms) try: await self.send_packet(ack_packet, wait_for_ack=False) - self.log(f"ACK packet sent successfully (delayed {ack_timeout_ms*1000:.1f}ms) for timestamp {timestamp_int}") + self.log( + f"ACK packet sent successfully (delayed {ack_timeout_ms*1000:.1f}ms) " + f"for timestamp {timestamp_int}" + ) except Exception as ack_send_error: self.log(f"Failed to send ACK packet: {ack_send_error}") @@ -145,9 +154,7 @@ async def send_delayed_ack(): if self.command_response_callback: try: self.command_response_callback(decoded_msg, matched_contact) - self.log( - f"Command response captured from {matched_contact.name}: {decoded_msg}" - ) + self.log(f"Command response captured from {matched_contact.name}: {decoded_msg}") # Don't save command responses to regular message database return except Exception as e: @@ -160,8 +167,7 @@ async def send_delayed_ack(): # Create message event data for the app to handle storage and deduplication normalized_timestamp = (message_timestamp // 1000) * 1000 content_hash = ( - hash(f"{matched_contact.name}_{decoded_msg}_{normalized_timestamp}") - & 0xFFFFFFFF + hash(f"{matched_contact.name}_{decoded_msg}_{normalized_timestamp}") & 0xFFFFFFFF ) message_id = f"rx_{normalized_timestamp}_{content_hash:08x}" diff --git a/src/pymc_core/protocol/__init__.py b/src/pymc_core/protocol/__init__.py index 5294a47..1e23f07 100644 --- a/src/pymc_core/protocol/__init__.py +++ b/src/pymc_core/protocol/__init__.py @@ -67,9 +67,9 @@ PacketDataUtils, PacketHashingUtils, PacketHeaderUtils, + PacketTimingUtils, PacketValidationUtils, RouteTypeUtils, - PacketTimingUtils ) from .utils import decode_appdata, parse_advert_payload diff --git a/src/pymc_core/protocol/packet_builder.py b/src/pymc_core/protocol/packet_builder.py index f05e31b..d40bed1 100644 --- a/src/pymc_core/protocol/packet_builder.py +++ b/src/pymc_core/protocol/packet_builder.py @@ -509,7 +509,7 @@ def create_group_datagram( channel = next((ch for ch in channels_config if ch.get("name") == group_name), None) if not channel: - raise ValueError(f"Channel '{group_name}' not found in provided channels_config") + raise ValueError(f"Channel '{group_name}' not in provided channels_config") secret_bytes = ( bytes.fromhex(channel["secret"]) @@ -646,7 +646,9 @@ def create_path_return( cipher = PacketBuilder._encrypt_payload(aes_key, secret, inner) payload = bytearray([dest_hash, src_hash]) + cipher - header = PacketBuilder._create_header(PAYLOAD_TYPE_PATH, route_type="flood", has_routing_path=False) + header = PacketBuilder._create_header( + PAYLOAD_TYPE_PATH, route_type="flood", has_routing_path=False + ) return PacketBuilder._create_packet(header, payload) @staticmethod @@ -728,20 +730,21 @@ def create_text_message( pkt.payload = bytearray(payload) pkt.payload_len = len(payload) - + # Enhanced debug logging with packet details route_type_names = {0: "TRANSPORT_FLOOD", 1: "FLOOD", 2: "DIRECT", 3: "TRANSPORT_DIRECT"} header_route_type = pkt.header & 0x03 - logger.debug(f"Created TXT_MSG packet:") - logger.debug(f" Header: 0x{pkt.header:02X} (route_type={header_route_type}={route_type_names.get(header_route_type, 'UNKNOWN')})") + logger.debug("Created TXT_MSG packet:") + logger.debug( + f" Header: 0x{pkt.header:02X} (route_type={header_route_type}=" + f"{route_type_names.get(header_route_type, 'UNKNOWN')})" + ) logger.debug(f" Path: {list(pkt.path)} (len={pkt.path_len})") logger.debug(f" Payload: {len(pkt.payload)} bytes, first 10: {list(pkt.payload[:10])}") logger.debug(f" Message: '{message}', attempt={attempt}, timestamp={timestamp}") logger.debug(f" CRC: 0x{ack_crc:08X}") - - return pkt, ack_crc - + return pkt, ack_crc @staticmethod def create_protocol_request( diff --git a/src/pymc_core/protocol/packet_filter.py b/src/pymc_core/protocol/packet_filter.py index 1e0a350..1b91420 100644 --- a/src/pymc_core/protocol/packet_filter.py +++ b/src/pymc_core/protocol/packet_filter.py @@ -49,9 +49,7 @@ def cleanup_old_hashes(self) -> None: """Clean up old packet hashes beyond the deduplication window.""" current_time = time.time() old_hashes = [ - h - for h, ts in self._packet_hashes.items() - if current_time - ts > self.window_seconds + h for h, ts in self._packet_hashes.items() if current_time - ts > self.window_seconds ] for h in old_hashes: del self._packet_hashes[h] diff --git a/src/pymc_core/protocol/packet_utils.py b/src/pymc_core/protocol/packet_utils.py index 040959c..cd54ebe 100644 --- a/src/pymc_core/protocol/packet_utils.py +++ b/src/pymc_core/protocol/packet_utils.py @@ -46,9 +46,7 @@ def validate_routing_path(routing_path: List[Union[str, int, float]]) -> List[in raise ValueError(f"routing_path must be a list, got {type(routing_path)}") if len(routing_path) > MAX_PATH_SIZE: - raise ValueError( - f"Path length {len(routing_path)} exceeds maximum {MAX_PATH_SIZE}" - ) + raise ValueError(f"Path length {len(routing_path)} exceeds maximum {MAX_PATH_SIZE}") validated_path = [] for i, item in enumerate(routing_path): @@ -59,17 +57,13 @@ def validate_routing_path(routing_path: List[Union[str, int, float]]) -> List[in ) hex_part = item[:2] if not all(c in "0123456789abcdefABCDEF" for c in hex_part): - raise ValueError( - f"Path[{i}]: '{hex_part}' contains invalid hex characters" - ) + raise ValueError(f"Path[{i}]: '{hex_part}' contains invalid hex characters") byte_val = int(hex_part, 16) validated_path.append(byte_val) elif isinstance(item, (int, float)): byte_val = int(item) if not (0 <= byte_val <= 255): - raise ValueError( - f"Path[{i}]: value {byte_val} out of range (0-255)" - ) + raise ValueError(f"Path[{i}]: value {byte_val} out of range (0-255)") validated_path.append(byte_val) else: raise ValueError( @@ -78,9 +72,7 @@ def validate_routing_path(routing_path: List[Union[str, int, float]]) -> List[in return validated_path @staticmethod - def validate_packet_bounds( - idx: int, required: int, data_len: int, error_msg: str - ) -> None: + def validate_packet_bounds(idx: int, required: int, data_len: int, error_msg: str) -> None: """Check if we have enough data remaining.""" if idx + required > data_len: raise ValueError(error_msg) @@ -207,9 +199,7 @@ class PacketHashingUtils: """Centralized hashing utilities for packets.""" @staticmethod - def calculate_packet_hash( - payload_type: int, path_len: int, payload: bytes - ) -> bytes: + def calculate_packet_hash(payload_type: int, path_len: int, payload: bytes) -> bytes: """ Calculate packet hash compatible with C++ implementation. @@ -231,9 +221,7 @@ def calculate_packet_hash( @staticmethod def calculate_crc(payload_type: int, path_len: int, payload: bytes) -> int: """Calculate 4-byte CRC from packet hash.""" - hash_bytes = PacketHashingUtils.calculate_packet_hash( - payload_type, path_len, payload - ) + hash_bytes = PacketHashingUtils.calculate_packet_hash(payload_type, path_len, payload) return int.from_bytes(hash_bytes[:4], "little") @@ -254,7 +242,7 @@ def get_route_type_value(route_type: str, has_routing_path: bool = False) -> int # When path is supplied, use DIRECT routing (don't use transport variants) return RouteTypeUtils.ROUTE_MAP.get(route_type, ROUTE_TYPE_DIRECT) else: - # When no path supplied, use FLOOD to build path automatically + # When no path supplied, use FLOOD to build path automatically return RouteTypeUtils.ROUTE_MAP.get(route_type, ROUTE_TYPE_FLOOD) @@ -270,43 +258,43 @@ def estimate_airtime_ms(packet_length_bytes: int, radio_config: dict = None) -> packet_length_bytes: Total packet length including headers radio_config: Radio configuration dict with spreading_factor, bandwidth, etc. Can include 'measured_airtime_ms' for actual measured value - + Returns: Estimated airtime in milliseconds """ if radio_config is None: - radio_config = { - 'spreading_factor': 10, - 'bandwidth': 250000, # 250kHz - 'coding_rate': 5, - 'preamble_length': 8, + "spreading_factor": 10, + "bandwidth": 250000, # 250kHz + "coding_rate": 5, + "preamble_length": 8, } - - - if 'measured_airtime_ms' in radio_config: - return radio_config['measured_airtime_ms'] - - sf = radio_config.get('spreading_factor', 10) - bw = radio_config.get('bandwidth', 250000) # Hz or kHz - convert to Hz if needed - cr = radio_config.get('coding_rate', 5) - preamble = radio_config.get('preamble_length', 8) - + + if "measured_airtime_ms" in radio_config: + return radio_config["measured_airtime_ms"] + + sf = radio_config.get("spreading_factor", 10) + bw = radio_config.get("bandwidth", 250000) # Hz or kHz - convert to Hz if needed + cr = radio_config.get("coding_rate", 5) + preamble = radio_config.get("preamble_length", 8) + # Convert bandwidth to Hz if it's in kHz (values < 1000 are assumed to be kHz) if bw < 1000: bw = bw * 1000 # Convert kHz to Hz - - symbol_time = (2 ** sf) / bw # seconds per symbol - + + symbol_time = (2**sf) / bw # seconds per symbol + # Preamble time preamble_time = preamble * symbol_time - + # Payload symbols (simplified) - payload_symbols = 8 + max(0, (packet_length_bytes * 8 - 4 * sf + 28) // (4 * (sf - 2))) * (cr + 4) + payload_symbols = 8 + max(0, (packet_length_bytes * 8 - 4 * sf + 28) // (4 * (sf - 2))) * ( + cr + 4 + ) payload_time = payload_symbols * symbol_time - + total_time_ms = (preamble_time + payload_time) * 1000 - + # Add some overhead for processing and turnaround return max(total_time_ms, 50.0) # Minimum 50ms @@ -314,12 +302,12 @@ def estimate_airtime_ms(packet_length_bytes: int, radio_config: dict = None) -> def calc_flood_timeout_ms(packet_airtime_ms: float) -> float: """ Calculate timeout for flood packets. - + Formula: 500ms + (16.0 × airtime) - + Args: packet_airtime_ms: Estimated packet airtime in milliseconds - + Returns: Timeout in milliseconds """ @@ -331,13 +319,13 @@ def calc_flood_timeout_ms(packet_airtime_ms: float) -> float: def calc_direct_timeout_ms(packet_airtime_ms: float, path_len: int) -> float: """ Calculate timeout for direct packets. - + Formula: 500ms + ((airtime × 6 + 250ms) × (path_len + 1)) - + Args: packet_airtime_ms: Estimated packet airtime in milliseconds path_len: Number of hops in the path (0 for direct) - + Returns: Timeout in milliseconds """ @@ -345,5 +333,6 @@ def calc_direct_timeout_ms(packet_airtime_ms: float, path_len: int) -> float: DIRECT_SEND_PERHOP_FACTOR = 6.0 DIRECT_SEND_PERHOP_EXTRA_MILLIS = 250 return SEND_TIMEOUT_BASE_MILLIS + ( - (packet_airtime_ms * DIRECT_SEND_PERHOP_FACTOR + DIRECT_SEND_PERHOP_EXTRA_MILLIS) * (path_len + 1) + (packet_airtime_ms * DIRECT_SEND_PERHOP_FACTOR + DIRECT_SEND_PERHOP_EXTRA_MILLIS) + * (path_len + 1) ) diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index addd988..a805815 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -5,11 +5,7 @@ from pymc_core.node.dispatcher import Dispatcher, DispatcherState from pymc_core.protocol import Packet -from pymc_core.protocol.constants import ( - PAYLOAD_TYPE_ACK, - PAYLOAD_TYPE_ADVERT, - PAYLOAD_TYPE_TXT_MSG, -) +from pymc_core.protocol.constants import PAYLOAD_TYPE_ACK, PAYLOAD_TYPE_ADVERT, PAYLOAD_TYPE_TXT_MSG from pymc_core.protocol.packet_filter import PacketFilter @@ -20,9 +16,7 @@ def create_test_packet(payload_type: int, payload: bytes) -> bytes: # Ensure payload_type is valid (0-15) if payload_type > 15: payload_type = 15 # Max valid payload type - packet.header = (1 << 6) | ( - payload_type << 2 - ) # Version 0, route type 1, payload type + packet.header = (1 << 6) | (payload_type << 2) # Version 0, route type 1, payload type packet.payload = bytearray(payload) packet.payload_len = len(payload) packet.path_len = 0 # No path @@ -121,9 +115,7 @@ def mock_logger(): @pytest.fixture def dispatcher(mock_radio, mock_identity, mock_contact_book, mock_logger): packet_filter = PacketFilter() - dispatcher = Dispatcher( - radio=mock_radio, packet_filter=packet_filter, log_fn=mock_logger - ) + dispatcher = Dispatcher(radio=mock_radio, packet_filter=packet_filter, log_fn=mock_logger) # Set additional attributes that are normally set by the node dispatcher.local_identity = mock_identity dispatcher.contact_book = mock_contact_book @@ -133,14 +125,10 @@ def dispatcher(mock_radio, mock_identity, mock_contact_book, mock_logger): class TestDispatcherInitialization: """Test dispatcher initialization and setup.""" - def test_dispatcher_creation( - self, mock_radio, mock_identity, mock_contact_book, mock_logger - ): + def test_dispatcher_creation(self, mock_radio, mock_identity, mock_contact_book, mock_logger): """Test creating a dispatcher with valid parameters.""" packet_filter = PacketFilter() - dispatcher = Dispatcher( - radio=mock_radio, packet_filter=packet_filter, log_fn=mock_logger - ) + dispatcher = Dispatcher(radio=mock_radio, packet_filter=packet_filter, log_fn=mock_logger) # Set additional attributes that are normally set by the node dispatcher.local_identity = mock_identity dispatcher.contact_book = mock_contact_book @@ -278,9 +266,7 @@ async def test_ack_timeout_cleanup(self, dispatcher): # Simulate cleanup (this is what run_forever does) dispatcher._recent_acks = { - crc_key: ts - for crc_key, ts in dispatcher._recent_acks.items() - if now - ts < 5 + crc_key: ts for crc_key, ts in dispatcher._recent_acks.items() if now - ts < 5 } # Old ACK should be cleaned up @@ -381,9 +367,7 @@ async def test_send_packet_failure(self, dispatcher): packet.payload_len = len(packet.payload) packet.path_len = 0 - dispatcher.radio.transmit = AsyncMock( - side_effect=Exception("Radio transmit failed") - ) + dispatcher.radio.transmit = AsyncMock(side_effect=Exception("Radio transmit failed")) result = await dispatcher.send_packet(packet) @@ -530,9 +514,7 @@ async def test_radio_tx_error_handling(self, dispatcher): """Test handling radio transmit errors.""" # Create a proper Packet object packet = Packet() - packet.header = (1 << 6) | ( - PAYLOAD_TYPE_ADVERT << 2 - ) # ADVERT packets don't wait for ACK + packet.header = (1 << 6) | (PAYLOAD_TYPE_ADVERT << 2) # ADVERT packets don't wait for ACK packet.payload = bytearray(b"test_data") packet.payload_len = len(packet.payload) packet.path_len = 0 @@ -608,9 +590,7 @@ async def test_multiple_handlers(self, dispatcher): text_packet_data = create_test_packet(PAYLOAD_TYPE_TXT_MSG, b"text message") # Create and process ACK packet - ack_packet_data = create_test_packet( - PAYLOAD_TYPE_ACK, b"\x78\x56\x34\x12" - ) # CRC + ack_packet_data = create_test_packet(PAYLOAD_TYPE_ACK, b"\x78\x56\x34\x12") # CRC # Process both packets await dispatcher._process_received_packet(text_packet_data) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 6440aa5..cd3816a 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -193,9 +193,7 @@ def setup_method(self): self.log_fn = MagicMock() self.ack_handler = AckHandler(self.log_fn) self.protocol_response_handler = MagicMock() - self.handler = PathHandler( - self.log_fn, self.ack_handler, self.protocol_response_handler - ) + self.handler = PathHandler(self.log_fn, self.ack_handler, self.protocol_response_handler) def test_payload_type(self): """Test path handler payload type.""" @@ -241,9 +239,7 @@ def setup_method(self): self.log_fn = MagicMock() self.send_packet_fn = AsyncMock() self.local_identity = LocalIdentity() - self.handler = LoginResponseHandler( - self.local_identity, self.contacts, self.log_fn - ) + self.handler = LoginResponseHandler(self.local_identity, self.contacts, self.log_fn) def test_payload_type(self): """Test login response handler payload type.""" @@ -264,9 +260,7 @@ def setup_method(self): self.log_fn = MagicMock() self.send_packet_fn = AsyncMock() self.local_identity = LocalIdentity() - self.handler = ProtocolResponseHandler( - self.log_fn, self.local_identity, self.contacts - ) + self.handler = ProtocolResponseHandler(self.log_fn, self.local_identity, self.contacts) def test_payload_type(self): """Test protocol response handler payload type.""" @@ -338,9 +332,7 @@ async def test_handlers_can_be_called(): handlers = [ AckHandler(log_fn), - TextMessageHandler( - local_identity, contacts, log_fn, send_packet_fn, event_service - ), + TextMessageHandler(local_identity, contacts, log_fn, send_packet_fn, event_service), AdvertHandler(contacts, log_fn, local_identity, event_service), PathHandler(log_fn), GroupTextHandler(local_identity, contacts, log_fn, send_packet_fn), @@ -360,9 +352,7 @@ async def test_handlers_can_be_called(): except Exception as e: # Some handlers may raise exceptions due to incomplete setup, # but they should be callable - assert isinstance( - e, (ValueError, AttributeError, TypeError) - ) # Expected exceptions + assert isinstance(e, (ValueError, AttributeError, TypeError)) # Expected exceptions # AnonReqResponseHandler Tests (separate from LoginResponseHandler) diff --git a/tests/test_packet_builder.py b/tests/test_packet_builder.py index b4a0025..58f453f 100644 --- a/tests/test_packet_builder.py +++ b/tests/test_packet_builder.py @@ -11,9 +11,7 @@ def test_packet_builder_create_ack(): attempt = 1 text = "test_ack" - ack_packet = PacketBuilder.create_ack( - identity.get_public_key(), timestamp, attempt, text - ) + ack_packet = PacketBuilder.create_ack(identity.get_public_key(), timestamp, attempt, text) assert ack_packet is not None assert ack_packet.get_payload_type() == PAYLOAD_TYPE_ACK diff --git a/tests/test_packet_utils.py b/tests/test_packet_utils.py index dd9ca71..8ad59fc 100644 --- a/tests/test_packet_utils.py +++ b/tests/test_packet_utils.py @@ -86,9 +86,7 @@ def test_validate_buffer_lengths(self): PacketValidationUtils.validate_buffer_lengths(5, 6, 10, 10) # Invalid payload length - with pytest.raises( - ValueError, match="payload_len mismatch: expected 10, got 15" - ): + with pytest.raises(ValueError, match="payload_len mismatch: expected 10, got 15"): PacketValidationUtils.validate_buffer_lengths(5, 5, 10, 15) def test_validate_payload_size(self): From 59ecf1067b33b82bd9e745e6baa96d93fc5e931a Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 24 Oct 2025 22:48:25 +0100 Subject: [PATCH 11/12] update version in test to match 1.0.1 --- tests/test_basic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_basic.py b/tests/test_basic.py index ed760b9..f11740a 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -2,7 +2,7 @@ def test_version(): - assert __version__ == "1.0.0" + assert __version__ == "1.0.1" def test_import(): From 9cc29cae49c0448a62af2219bfdceee135a66555 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 24 Oct 2025 22:54:29 +0100 Subject: [PATCH 12/12] Update src/pymc_core/node/handlers/text.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pymc_core/node/handlers/text.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymc_core/node/handlers/text.py b/src/pymc_core/node/handlers/text.py index 4e04373..2482f34 100644 --- a/src/pymc_core/node/handlers/text.py +++ b/src/pymc_core/node/handlers/text.py @@ -78,7 +78,7 @@ async def __call__(self, packet: Packet) -> None: route_type = packet.header & 0x03 # Route type is in bits 0-1 is_flood = route_type == 1 # ROUTE_TYPE_FLOOD = 1 - self.debug( + self.log( f"Processing message - route_type: {route_type}, is_flood: {is_flood}, " f"timestamp: {timestamp_int}" )