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 0f66f7c..82228dc 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) @@ -179,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/examples.md b/docs/docs/examples.md index 2194742..4c398b7 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 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` +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/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/docs/docs/node.md b/docs/docs/node.md index 69dc8fb..cc1dc38 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, # 62.5 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..256616d 100644 --- a/examples/common.py +++ b/examples/common.py @@ -51,14 +51,15 @@ 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 13 for TX enable + "rxen_pin": 12, "frequency": int(869.525 * 1000000), # EU: 869.525 MHz "tx_power": 22, "spreading_factor": 11, "bandwidth": int(250 * 1000), "coding_rate": 5, "preamble_length": 17, + "is_waveshare": True, }, "uconsole": { "bus_id": 1, # SPI1 @@ -76,11 +77,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] @@ -119,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 08cc1de..1db234b 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) 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/__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/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/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 diff --git a/src/pymc_core/node/dispatcher.py b/src/pymc_core/node/dispatcher.py index eff5e65..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[ @@ -148,6 +142,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 +166,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 @@ -192,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 @@ -270,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 @@ -306,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) @@ -360,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 @@ -373,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 @@ -423,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) @@ -456,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 @@ -503,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: @@ -545,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 bbbed63..2482f34 100644 --- a/src/pymc_core/node/handlers/text.py +++ b/src/pymc_core/node/handlers/text.py @@ -1,7 +1,7 @@ import asyncio -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_ACK, PAYLOAD_TYPE_TXT_MSG from .base import BaseHandler @@ -17,6 +17,7 @@ def __init__( log_fn, send_packet_fn, event_service=None, + radio_config=None, ): self.local_identity = local_identity self.contacts = contacts @@ -24,26 +25,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,14 +50,8 @@ 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() - ) + 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 @@ -83,23 +71,81 @@ 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 - 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 + self.log( + f"Processing message - route_type: {route_type}, is_flood: {is_flood}, " + f"timestamp: {timestamp_int}" ) - # 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) + # 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, + ) + + 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, " + f"delay:{ack_timeout_ms:.1f}ms" + ) + ack_timeout_ms = ack_timeout_ms / 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"), + ) + + 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, " + 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) " + f"for timestamp {timestamp_int}" + ) + except Exception as ack_send_error: + self.log(f"Failed to send ACK packet: {ack_send_error}") + + # 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}") @@ -108,9 +154,7 @@ async def __call__(self, packet: Packet) -> None: 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: @@ -118,13 +162,12 @@ 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 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/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/__init__.py b/src/pymc_core/protocol/__init__.py index 450564f..1e23f07 100644 --- a/src/pymc_core/protocol/__init__.py +++ b/src/pymc_core/protocol/__init__.py @@ -67,6 +67,7 @@ PacketDataUtils, PacketHashingUtils, PacketHeaderUtils, + PacketTimingUtils, PacketValidationUtils, RouteTypeUtils, ) @@ -90,6 +91,7 @@ "PacketHeaderUtils", "PacketHashingUtils", "RouteTypeUtils", + "PacketTimingUtils", # Header constants "PH_ROUTE_MASK", "PH_TYPE_SHIFT", diff --git a/src/pymc_core/protocol/packet_builder.py b/src/pymc_core/protocol/packet_builder.py index 8f05ede..d40bed1 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) @@ -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"]) @@ -642,10 +642,13 @@ 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,50 +730,20 @@ def create_text_message( pkt.payload = bytearray(payload) pkt.payload_len = len(payload) - 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 []) + # 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("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')})" ) - routing_path = PacketBuilder._validate_routing_path(routing_path) + 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}") - # 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 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 9fc87ca..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) @@ -156,7 +148,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: @@ -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") @@ -251,8 +239,100 @@ 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. + 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, + } + + 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 + + # 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) + ) 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(): 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):