diff --git a/pyproject.toml b/pyproject.toml index 75b5797..193fbf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pymc_core" -version = "1.0.4" +version = "1.0.5" authors = [ {name = "Lloyd Newton", email = "lloyd@rightup.co.uk"}, ] diff --git a/src/pymc_core/__init__.py b/src/pymc_core/__init__.py index 1acef86..b44ac9b 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.4" +__version__ = "1.0.5" # Core mesh functionality from .node.node import MeshNode diff --git a/src/pymc_core/hardware/gpio_manager.py b/src/pymc_core/hardware/gpio_manager.py new file mode 100644 index 0000000..804db8c --- /dev/null +++ b/src/pymc_core/hardware/gpio_manager.py @@ -0,0 +1,186 @@ +""" +GPIO Pin Manager for Raspberry Pi +Manages GPIO pins abstraction using gpiozero +""" + +import asyncio +import logging +from typing import Callable, Optional + +from gpiozero import Button, Device, OutputDevice + +# Force gpiozero to use LGPIOFactory - no RPi.GPIO fallback +from gpiozero.pins.lgpio import LGPIOFactory + +Device.pin_factory = LGPIOFactory() + +logger = logging.getLogger("GPIOPinManager") + + +class GPIOPinManager: + """Manages GPIO pins abstraction""" + + def __init__(self): + self._pins = {} + self._led_tasks = {} # Track active LED tasks + + def setup_output_pin(self, pin_number: int, initial_value: bool = False) -> bool: + """Setup an output pin with initial value""" + if pin_number == -1: + return False + + try: + if pin_number in self._pins: + self._pins[pin_number].close() + + self._pins[pin_number] = OutputDevice(pin_number, initial_value=initial_value) + return True + except Exception as e: + logger.warning(f"Failed to setup output pin {pin_number}: {e}") + return False + + def setup_input_pin( + self, + pin_number: int, + pull_up: bool = False, + callback: Optional[Callable] = None, + ) -> bool: + """Setup an input pin with optional interrupt callback""" + if pin_number == -1: + return False + + try: + if pin_number in self._pins: + self._pins[pin_number].close() + + self._pins[pin_number] = Button(pin_number, pull_up=pull_up) + if callback: + self._pins[pin_number].when_activated = callback + + return True + except Exception as e: + logger.warning(f"Failed to setup input pin {pin_number}: {e}") + return False + + def setup_interrupt_pin( + self, + pin_number: int, + pull_up: bool = False, + callback: Optional[Callable] = None, + ) -> Optional[Button]: + """Setup an interrupt pin and return the Button object for direct access""" + if pin_number == -1: + return None + + try: + if pin_number in self._pins: + self._pins[pin_number].close() + + button = Button(pin_number, pull_up=pull_up) + if callback: + button.when_activated = callback + + self._pins[pin_number] = button + return button + except Exception as e: + logger.warning(f"Failed to setup interrupt pin {pin_number}: {e}") + return None + + def set_pin_high(self, pin_number: int) -> bool: + """Set output pin to HIGH""" + if pin_number in self._pins and hasattr(self._pins[pin_number], "on"): + try: + self._pins[pin_number].on() + return True + except Exception as e: + logger.warning(f"Failed to set pin {pin_number} HIGH: {e}") + return False + + def set_pin_low(self, pin_number: int) -> bool: + """Set output pin to LOW""" + if pin_number in self._pins and hasattr(self._pins[pin_number], "off"): + try: + self._pins[pin_number].off() + return True + except Exception as e: + logger.warning(f"Failed to set pin {pin_number} LOW: {e}") + return False + + def cleanup_pin(self, pin_number: int) -> None: + """Clean up a specific pin""" + if pin_number in self._pins: + try: + self._pins[pin_number].close() + del self._pins[pin_number] + except Exception as e: + logger.warning(f"Failed to cleanup pin {pin_number}: {e}") + + def cleanup_all(self) -> None: + """Clean up all managed pins""" + # Cancel any running LED tasks + for task in self._led_tasks.values(): + if not task.done(): + task.cancel() + self._led_tasks.clear() + + # Clean up pins + for pin_number in list(self._pins.keys()): + self.cleanup_pin(pin_number) + + async def _led_blink_task(self, pin_number: int, duration: float = 3.0) -> None: + """Internal task to blink LED for specified duration""" + try: + # Turn LED on + self.set_pin_high(pin_number) + logger.debug(f"LED {pin_number} turned ON for {duration}s") + + # Wait for duration + await asyncio.sleep(duration) + + # Turn LED off + self.set_pin_low(pin_number) + logger.debug(f"LED {pin_number} turned OFF") + + except asyncio.CancelledError: + # Turn off LED if task was cancelled + self.set_pin_low(pin_number) + logger.debug(f"LED {pin_number} task cancelled, LED turned OFF") + except Exception as e: + logger.warning(f"LED {pin_number} task error: {e}") + finally: + # Remove from active tasks + if pin_number in self._led_tasks: + del self._led_tasks[pin_number] + + def blink_led(self, pin_number: int, duration: float = 3.0) -> None: + """ + Blink LED for specified duration (non-blocking) + + Args: + pin_number: GPIO pin number for LED + duration: How long to keep LED on (seconds, default: 3.0) + """ + if pin_number == -1: + return # LED disabled + + if pin_number not in self._pins: + logger.debug(f"LED pin {pin_number} not configured, skipping") + return + + try: + # Cancel any existing LED task for this pin + if pin_number in self._led_tasks and not self._led_tasks[pin_number].done(): + self._led_tasks[pin_number].cancel() + + # Start new LED task + loop = asyncio.get_running_loop() + self._led_tasks[pin_number] = loop.create_task( + self._led_blink_task(pin_number, duration) + ) + + except RuntimeError: + # No event loop running - just turn on LED (won't auto-turn off) + logger.warning(f"No event loop, LED pin {pin_number} turned on (manual off required)") + self.set_pin_high(pin_number) + except Exception as e: + logger.warning(f"Failed to start LED task for pin {pin_number}: {e}") diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index d71659c..e5584c4 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -10,102 +10,18 @@ import asyncio import logging -import time import math import random -from typing import Callable, Optional -from gpiozero import Button, Device, OutputDevice - -# Force gpiozero to use LGPIOFactory - no RPi.GPIO fallback -from gpiozero.pins.lgpio import LGPIOFactory +import time +from typing import Optional from .base import LoRaRadio +from .gpio_manager import GPIOPinManager from .lora.LoRaRF.SX126x import SX126x -Device.pin_factory = LGPIOFactory() - logger = logging.getLogger("SX1262_wrapper") -class GPIOPinManager: - """Manages GPIO pins abstraction""" - - def __init__(self): - self._pins = {} - - def setup_output_pin(self, pin_number: int, initial_value: bool = False) -> bool: - """Setup an output pin with initial value""" - if pin_number == -1: - return False - - try: - if pin_number in self._pins: - self._pins[pin_number].close() - - self._pins[pin_number] = OutputDevice(pin_number, initial_value=initial_value) - return True - except Exception as e: - logger.warning(f"Failed to setup output pin {pin_number}: {e}") - return False - - def setup_input_pin( - self, - pin_number: int, - pull_up: bool = False, - callback: Optional[Callable] = None, - ) -> bool: - """Setup an input pin with optional interrupt callback""" - if pin_number == -1: - return False - - try: - if pin_number in self._pins: - self._pins[pin_number].close() - - self._pins[pin_number] = Button(pin_number, pull_up=pull_up) - if callback: - self._pins[pin_number].when_activated = callback - - return True - except Exception as e: - logger.warning(f"Failed to setup input pin {pin_number}: {e}") - return False - - def set_pin_high(self, pin_number: int) -> bool: - """Set output pin to HIGH""" - if pin_number in self._pins and hasattr(self._pins[pin_number], "on"): - try: - self._pins[pin_number].on() - return True - except Exception as e: - logger.warning(f"Failed to set pin {pin_number} HIGH: {e}") - return False - - def set_pin_low(self, pin_number: int) -> bool: - """Set output pin to LOW""" - if pin_number in self._pins and hasattr(self._pins[pin_number], "off"): - try: - self._pins[pin_number].off() - return True - except Exception as e: - logger.warning(f"Failed to set pin {pin_number} LOW: {e}") - return False - - def cleanup_pin(self, pin_number: int) -> None: - """Clean up a specific pin""" - if pin_number in self._pins: - try: - self._pins[pin_number].close() - del self._pins[pin_number] - except Exception as e: - logger.warning(f"Failed to cleanup pin {pin_number}: {e}") - - def cleanup_all(self) -> None: - """Clean up all managed pins""" - for pin_number in list(self._pins.keys()): - self.cleanup_pin(pin_number) - - class SX1262Radio(LoRaRadio): """SX1262 LoRa Radio implementation for Raspberry Pi""" @@ -125,6 +41,8 @@ def __init__( irq_pin: int = 16, txen_pin: int = 6, rxen_pin: int = -1, + txled_pin: int = -1, + rxled_pin: int = -1, frequency: int = 868000000, tx_power: int = 22, spreading_factor: int = 7, @@ -133,6 +51,8 @@ def __init__( preamble_length: int = 12, sync_word: int = 0x3444, is_waveshare: bool = False, + use_dio3_tcxo: bool = False, + dio3_tcxo_voltage: float = 1.8, ): """ Initialize SX1262 radio @@ -146,6 +66,8 @@ def __init__( irq_pin: GPIO pin for interrupt (default: 16) txen_pin: GPIO pin for TX enable (default: 6) rxen_pin: GPIO pin for RX enable (default: -1 if not used) + txled_pin: GPIO pin for TX LED (default: -1 if not used) + rxled_pin: GPIO pin for RX LED (default: -1 if not used) frequency: Operating frequency in Hz (default: 868MHz) tx_power: TX power in dBm (default: 22) spreading_factor: LoRa spreading factor (default: 7) @@ -154,6 +76,8 @@ def __init__( preamble_length: Preamble length (default: 12) sync_word: Sync word (default: 0x3444 for public network) is_waveshare: Use alternate initialization needed for Waveshare HAT + use_dio3_tcxo: Enable DIO3 TCXO control (default: False) + dio3_tcxo_voltage: TCXO reference voltage in volts (default: 1.8) """ # Check if there's already an active instance and clean it up if SX1262Radio._active_instance is not None: @@ -172,6 +96,8 @@ def __init__( self.irq_pin_number = irq_pin # Store pin number self.txen_pin = txen_pin self.rxen_pin = rxen_pin + self.txled_pin = txled_pin + self.rxled_pin = rxled_pin # Radio configuration self.frequency = frequency @@ -182,6 +108,8 @@ def __init__( self.preamble_length = preamble_length self.sync_word = sync_word self.is_waveshare = is_waveshare + self.use_dio3_tcxo = use_dio3_tcxo + self.dio3_tcxo_voltage = dio3_tcxo_voltage # State variables self.lora: Optional[SX126x] = None @@ -194,7 +122,9 @@ def __init__( # GPIO management self._gpio_manager = GPIOPinManager() self._interrupt_setup = False - self._txen_pin_setup = False # Track if TXEN pin is set up + self._txen_pin_setup = False + self._txled_pin_setup = False + self._rxled_pin_setup = False self._tx_done_event = asyncio.Event() self._rx_done_event = asyncio.Event() @@ -204,6 +134,15 @@ def __init__( self._custom_cad_peak = None self._custom_cad_min = None + # Noise floor sampling + self._noise_floor = -99.0 + self._num_floor_samples = 0 + self._floor_sample_sum = 0.0 + self._last_packet_activity = 0.0 + self._is_receiving_packet = False + self.NUM_NOISE_FLOOR_SAMPLES = 20 + self.SAMPLING_THRESHOLD = 10 # Only sample if RSSI < noise_floor + threshold + logger.info( f"SX1262Radio configured: freq={frequency/1e6:.1f}MHz, " f"power={tx_power}dBm, sf={spreading_factor}, " @@ -282,7 +221,7 @@ def _handle_interrupt(self): logger.debug("[TX] TX_DONE interrupt (0x{:04X})".format(self.lora.IRQ_TX_DONE)) self._tx_done_event.set() - # Check for CAD interrupts + # Check for CAD interrupts (needed for LBT) if irqStat & (self.lora.IRQ_CAD_DETECTED | self.lora.IRQ_CAD_DONE): cad_detected = bool(irqStat & self.lora.IRQ_CAD_DETECTED) cad_done = bool(irqStat & self.lora.IRQ_CAD_DONE) @@ -290,23 +229,37 @@ def _handle_interrupt(self): f"[CAD] interrupt detected: {cad_detected}, done: {cad_done} (0x{irqStat:04X})" ) if hasattr(self, "_cad_event"): - # WAKEUP CODE self._cad_event.set() - # Check each RX interrupt type separately for better debugging + # Handle RX interrupts normally - no filtering needed since they're disabled during TX rx_interrupts = self._get_rx_irq_mask() if irqStat & self.lora.IRQ_RX_DONE: logger.debug("[RX] RX_DONE interrupt (0x{:04X})".format(self.lora.IRQ_RX_DONE)) - self._rx_done_event.set() + if not self._tx_lock.locked(): + self._rx_done_event.set() + else: + logger.debug("[RX] Ignoring RX_DONE during TX operation") elif irqStat & self.lora.IRQ_CRC_ERR: logger.debug("[RX] CRC_ERR interrupt (0x{:04X})".format(self.lora.IRQ_CRC_ERR)) - self._rx_done_event.set() + if not self._tx_lock.locked(): + self._rx_done_event.set() + else: + logger.debug("[RX] Ignoring CRC_ERR during TX operation") elif irqStat & self.lora.IRQ_TIMEOUT: logger.debug("[RX] TIMEOUT interrupt (0x{:04X})".format(self.lora.IRQ_TIMEOUT)) - self._rx_done_event.set() + if not self._tx_lock.locked(): + self._rx_done_event.set() + else: + logger.debug("[RX] Ignoring TIMEOUT during TX operation") elif irqStat & rx_interrupts: logger.debug(f"[RX] Other RX interrupt detected: 0x{irqStat & rx_interrupts:04X}") - self._rx_done_event.set() + if not self._tx_lock.locked(): + self._rx_done_event.set() + else: + logger.debug( + f"[RX] Ignoring spurious interrupt " + f"0x{irqStat & rx_interrupts:04X} during TX operation" + ) except Exception as e: logger.error(f"IRQ handler error: {e}") @@ -340,30 +293,30 @@ async def _rx_irq_background_task(self): """Background task: waits for RX_DONE IRQ and processes received packets automatically.""" logger.debug("[RX] Starting RX IRQ background task") rx_check_count = 0 - last_preamble_time = 0 - preamble_timeout = 5.0 # 5 seconds timeout for incomplete preamble detection - preamble_detect_count = 0 # Counter for preamble detections while self._initialized: if self._interrupt_setup: # Wait for RX_DONE event try: - await asyncio.wait_for(self._rx_done_event.wait(), timeout=0.01) + await asyncio.wait_for( + self._rx_done_event.wait(), timeout=self.RADIO_TIMING_DELAY + ) self._rx_done_event.clear() logger.debug("[RX] RX_DONE event triggered!") + # Mark that we're processing a packet (prevents noise floor sampling) + self._is_receiving_packet = True + self._last_packet_activity = time.time() + try: # Read and process the received packet irqStat = self.lora.getIrqStatus() logger.debug(f"[RX] IRQ Status: 0x{irqStat:04X}") - # Clear RX-related interrupt flags only - rx_flags = self._get_rx_irq_mask() - flags_to_clear = irqStat & rx_flags - if flags_to_clear: - self.lora.clearIrqStatus(flags_to_clear) + # Clear ALL interrupt flags immediately to prevent duplicate processing + if irqStat != 0: + self.lora.clearIrqStatus(irqStat) if irqStat & self.lora.IRQ_RX_DONE: - last_preamble_time = 0 # Reset preamble timer on successful RX ( payloadLengthRx, rxStartBufferPointer, @@ -377,6 +330,9 @@ async def _rx_irq_background_task(self): f"RSSI={self.last_rssi}dBm, SNR={self.last_snr}dB" ) + # Trigger RX LED + self._gpio_manager.blink_led(self.rxled_pin) + if payloadLengthRx > 0: buffer = self.lora.readBuffer(rxStartBufferPointer, payloadLengthRx) packet_data = bytes(buffer) @@ -398,28 +354,10 @@ async def _rx_irq_background_task(self): logger.warning("[RX] Empty packet received") elif irqStat & self.lora.IRQ_CRC_ERR: logger.warning("[RX] CRC error detected") - last_preamble_time = 0 # Reset preamble timer on CRC error elif irqStat & self.lora.IRQ_TIMEOUT: - last_preamble_time = 0 # Reset preamble timer on timeout + logger.warning("[RX] RX timeout detected") elif irqStat & self.lora.IRQ_PREAMBLE_DETECTED: - preamble_detect_count += 1 - # Log detailed preamble detection info - try: - raw_rssi = self.lora.getRssiInst() - # Calculate preamble RSSI for potential future use - # preamble_rssi_dbm = ( - # -(float(raw_rssi) / 2) - # if raw_rssi is not None - # else "N/A" - # ) - if preamble_detect_count % 10 == 0: - logger.warning( - f"[IRQ RX] {preamble_detect_count} preamble detections " - f"without valid packets - possible RF noise interference" - ) - except Exception: - pass - last_preamble_time = time.time() # Record when preamble was detected + pass elif irqStat & self.lora.IRQ_SYNC_WORD_VALID: pass # Sync word valid - receiving packet data... elif irqStat & self.lora.IRQ_HEADER_VALID: @@ -429,64 +367,33 @@ async def _rx_irq_background_task(self): else: pass # Other RX interrupt - # For preamble detection, don't put radio back to RX mode immediately - # Let the packet reception complete naturally - if not (irqStat & self.lora.IRQ_PREAMBLE_DETECTED): - # Always ensure radio stays in RX continuous mode after - # any RX interrupt (except preamble) - try: - self.lora.setRx(self.lora.RX_CONTINUOUS) - except Exception: - pass - else: - # Skipping RX mode reset during preamble detection - - # letting packet complete - pass + # Always restore RX continuous mode after processing any interrupt + # This ensures the radio stays ready for the next packet + try: + self.lora.setRx(self.lora.RX_CONTINUOUS) + await asyncio.sleep(self.RADIO_TIMING_DELAY) + except Exception as e: + logger.debug(f"Failed to restore RX mode: {e}") except Exception as e: logger.error(f"[IRQ RX] Error processing received packet: {e}") + finally: + # Clear packet processing flag + self._is_receiving_packet = False except asyncio.TimeoutError: # No RX event within timeout - normal operation rx_check_count += 1 - # Check for stalled preamble detection (preamble detected but no follow-up) - current_time = time.time() - if ( - last_preamble_time > 0 - and (current_time - last_preamble_time) > preamble_timeout - ): - logger.debug( - f"[RX Task] Preamble timeout detected - {preamble_timeout}s " - f"elapsed since preamble, resetting radio" - ) - try: - # Force radio back to RX mode to clear any stuck state - self.lora.setRx(self.lora.RX_CONTINUOUS) - # Clear any pending interrupt flags - irqStat = self.lora.getIrqStatus() - if irqStat != 0: - self.lora.clearIrqStatus(irqStat) - except Exception as e: - logger.error( - f"[RX Task] Failed to reset radio after preamble timeout: {e}" - ) - last_preamble_time = 0 # Reset preamble timer + # Sample noise floor during quiet periods + self._sample_noise_floor() # Log every 500 checks (roughly every 5 seconds) to show RX task is alive if rx_check_count % 500 == 0: - # Keep one minimal status message - try: - raw_rssi = self.lora.getRssiInst() - if raw_rssi is not None: - noise_floor_dbm = -(float(raw_rssi) / 2) - logger.debug( - f"[RX Task] Status check #{rx_check_count}, " - f"Noise: {noise_floor_dbm:.1f}dBm" - ) - else: - logger.debug(f"[RX Task] Status check #{rx_check_count}") - except Exception: - logger.debug(f"[RX Task] Status check #{rx_check_count}") + logger.debug( + f"[RX Task] Status check #{rx_check_count}, " + f"noise_floor={self._noise_floor:.1f}dBm" + ) + else: await asyncio.sleep(0.1) # Longer delay when interrupts not set up @@ -499,18 +406,16 @@ def begin(self) -> bool: try: logger.debug("Initializing SX1262 radio...") - # Create SX126x instance self.lora = SX126x() + self.irq_pin = self._gpio_manager.setup_interrupt_pin( + self.irq_pin_number, pull_up=False, callback=self._handle_interrupt + ) - # Try IRQ setup - this is REQUIRED, no polling fallback - try: - self.irq_pin = Button(self.irq_pin_number, pull_up=False) - self.irq_pin.when_activated = self._handle_interrupt + if self.irq_pin is not None: self._interrupt_setup = True - logger.debug(f"[RX] IRQ setup successful on pin {self.irq_pin_number}") - except Exception as e: - logger.error(f"IRQ setup failed: {e}") - raise RuntimeError(f"Failed to set up IRQ pin {self.irq_pin_number}: {e}") + else: + logger.error(f"Failed to setup interrupt pin {self.irq_pin_number}") + raise RuntimeError(f"Could not setup IRQ pin {self.irq_pin_number}") # SPI and GPIO Pins setting self.lora.setSpi(self.bus_id, self.cs_id) @@ -531,10 +436,33 @@ def begin(self) -> bool: # Setup TXEN pin if needed if self.txen_pin != -1 and not self._txen_pin_setup: if self._gpio_manager.setup_output_pin(self.txen_pin, initial_value=False): + logger.debug(f"TXEN pin {self.txen_pin} configured") self._txen_pin_setup = True else: logger.warning(f"Could not setup TXEN pin {self.txen_pin}") + # Setup RXEN pin if needed + if self.rxen_pin != -1: + if self._gpio_manager.setup_output_pin(self.rxen_pin, initial_value=False): + logger.debug(f"RXEN pin {self.rxen_pin} configured") + else: + logger.warning(f"Could not setup RXEN pin {self.rxen_pin}") + + # Setup LED pins if specified + if self.txled_pin != -1 and not self._txled_pin_setup: + if self._gpio_manager.setup_output_pin(self.txled_pin, initial_value=False): + self._txled_pin_setup = True + logger.debug(f"TX LED pin {self.txled_pin} configured") + else: + logger.warning(f"Could not setup TX LED pin {self.txled_pin}") + + if self.rxled_pin != -1 and not self._rxled_pin_setup: + if self._gpio_manager.setup_output_pin(self.rxled_pin, initial_value=False): + self._rxled_pin_setup = True + logger.debug(f"RX LED pin {self.rxled_pin} configured") + else: + logger.warning(f"Could not setup RX LED pin {self.rxled_pin}") + # Adaptive initialization based on board type if self.is_waveshare: # Waveshare HAT - use minimal initialization # Basic radio setup @@ -575,13 +503,45 @@ def begin(self) -> bool: self.lora.setDioIrqParams(rx_mask, rx_mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE) self.lora.clearIrqStatus(0xFFFF) - else: # ClockworkPi or other boards - use full initialization + else: # Use full initialization # Reset RF module and set to standby if not self._basic_radio_setup(use_busy_check=True): return False # Configure TCXO, regulator, calibration and RF switch - self.lora.setDio3TcxoCtrl(self.lora.DIO3_OUTPUT_1_8, self.lora.TCXO_DELAY_5) + if self.use_dio3_tcxo: + # Map voltage to DIO3 constants following Meshtastic pattern + voltage_map = { + 1.6: self.lora.DIO3_OUTPUT_1_6, + 1.7: self.lora.DIO3_OUTPUT_1_7, + 1.8: self.lora.DIO3_OUTPUT_1_8, + 2.2: self.lora.DIO3_OUTPUT_2_2, + 2.4: self.lora.DIO3_OUTPUT_2_4, + 2.7: self.lora.DIO3_OUTPUT_2_7, + 3.0: self.lora.DIO3_OUTPUT_3_0, + 3.3: self.lora.DIO3_OUTPUT_3_3, + } + + voltage_constant = voltage_map.get(self.dio3_tcxo_voltage) + if voltage_constant is None: + closest_voltage = min( + voltage_map.keys(), key=lambda x: abs(x - self.dio3_tcxo_voltage) + ) + voltage_constant = voltage_map[closest_voltage] + logger.debug( + f"DIO3 TCXO voltage {self.dio3_tcxo_voltage}V " + f"mapped to closest {closest_voltage}V" + ) + else: + logger.debug(f"DIO3 TCXO voltage {self.dio3_tcxo_voltage}V mapped exactly") + + # Set TCXO with 5ms delay (standard value) + self.lora.setDio3TcxoCtrl(voltage_constant, self.lora.TCXO_DELAY_5) + logger.info(f"DIO3 TCXO enabled: {self.dio3_tcxo_voltage}V, 5ms delay") + time.sleep(0.05) # Allow TCXO to stabilize + else: + logger.debug("DIO3 TCXO is not enabled") + self.lora.setRegulatorMode(self.lora.REGULATOR_DC_DC) self.lora.calibrate(0x7F) self.lora.setDio2RfSwitch() @@ -639,6 +599,7 @@ def begin(self) -> bool: # Set to RX continuous mode for initial operation self.lora.setRx(self.lora.RX_CONTINUOUS) + self._initialized = True logger.info("SX1262 radio initialized successfully") @@ -672,24 +633,26 @@ def begin(self) -> bool: raise RuntimeError(f"Failed to initialize SX1262 radio: {e}") from e def _calculate_tx_timeout(self, packet_length: int) -> tuple[int, int]: - """Calculate transmission timeout using C++ MeshCore formula - simple and accurate""" - + """Calculate transmission timeout using C++ MeshCore formula""" + symbol_time = float(1 << self.spreading_factor) / float(self.bandwidth) preamble_time = (self.preamble_length + 4.25) * symbol_time tmp = (8 * packet_length) - (4 * self.spreading_factor) + 28 + 16 - #CRC is enabled + # CRC is enabled tmp -= 16 - + if tmp > 0: - payload_symbols = 8.0 + math.ceil(float(tmp) / float(4 * self.spreading_factor)) * (self.coding_rate + 4) + payload_symbols = 8.0 + math.ceil(float(tmp) / float(4 * self.spreading_factor)) * ( + self.coding_rate + 4 + ) else: payload_symbols = 8.0 - + payload_time = payload_symbols * symbol_time air_time_ms = (preamble_time + payload_time) * 1000.0 timeout_ms = math.ceil(air_time_ms) + 1000 driver_timeout = timeout_ms * 64 - + logger.debug( f"TX timing SF{self.spreading_factor}/{self.bandwidth/1000:.1f}kHz " f"CR4/{self.coding_rate} {packet_length}B: " @@ -702,7 +665,7 @@ def _calculate_tx_timeout(self, packet_length: int) -> tuple[int, int]: f"timeout={timeout_ms}ms, " f"driver_timeout={driver_timeout}" ) - + return timeout_ms, driver_timeout def _prepare_packet_transmission(self, data_list: list, length: int) -> None: @@ -722,9 +685,9 @@ def _prepare_packet_transmission(self, data_list: list, length: int) -> None: self.lora.setPacketParamsLoRa(preambleLength, headerType, length, crcType, invertIq) def _setup_tx_interrupts(self) -> None: - """Configure interrupts for transmission""" - # Set up TX interrupt - mask = self._get_tx_irq_mask() + """Configure interrupts for transmission - TX and CAD only, disable RX interrupts""" + # Set up TX and CAD interrupts only - this prevents spurious RX interrupts during TX + mask = self._get_tx_irq_mask() | self.lora.IRQ_CAD_DONE | self.lora.IRQ_CAD_DETECTED self.lora.setDioIrqParams(mask, mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE) # Clear any existing interrupt flags before starting @@ -742,7 +705,7 @@ async def _prepare_radio_for_tx(self) -> bool: if self.lora.busyCheck(): busy_wait = 0 while self.lora.busyCheck() and busy_wait < 20: - await asyncio.sleep(0.01) + await asyncio.sleep(self.RADIO_TIMING_DELAY) busy_wait += 1 # Listen Before Talk (LBT) - Check for channel activity using CAD @@ -758,14 +721,13 @@ async def _prepare_radio_for_tx(self) -> bool: else: lbt_attempts += 1 if lbt_attempts < max_lbt_attempts: - # Jitter (50-200ms) base_delay = random.randint(50, 200) # Exponential backoff: base * 2^attempts backoff_ms = base_delay * (2 ** (lbt_attempts - 1)) # Cap at 5 seconds maximum backoff_ms = min(backoff_ms, 5000) - + logger.debug( f"Channel busy (CAD detected activity), backing off {backoff_ms}ms " f"- attempt {lbt_attempts}/{max_lbt_attempts} (exponential backoff)" @@ -788,7 +750,7 @@ async def _prepare_radio_for_tx(self) -> bool: # Wait for radio to become ready busy_timeout = 0 while self.lora.busyCheck() and busy_timeout < 100: - await asyncio.sleep(0.01) + await asyncio.sleep(self.RADIO_TIMING_DELAY) busy_timeout += 1 if self.lora.busyCheck(): logger.error("Radio stayed busy - cannot start transmission") @@ -797,23 +759,20 @@ async def _prepare_radio_for_tx(self) -> bool: return True def _control_tx_rx_pins(self, tx_mode: bool) -> None: - """Control TXEN/RXEN pins for TX or RX mode""" + """Control TXEN/RXEN pins for the E22 module (simple and deterministic).""" + + # TX: TXEN=HIGH, RXEN=LOW if tx_mode: - # Control TX mode pins - if self.txen_pin != -1 and self._txen_pin_setup: + if self.txen_pin != -1: self._gpio_manager.set_pin_high(self.txen_pin) if self.rxen_pin != -1: - if not hasattr(self, "_rxen_pin_setup") or not self._rxen_pin_setup: - if self._gpio_manager.setup_output_pin(self.rxen_pin, initial_value=True): - self._rxen_pin_setup = True - else: - logger.warning(f"Could not setup RXEN pin {self.rxen_pin}") self._gpio_manager.set_pin_low(self.rxen_pin) + + # RX or idle: TXEN=LOW, RXEN=HIGH else: - # Control RX mode pins - if self.txen_pin != -1 and self._txen_pin_setup: + if self.txen_pin != -1: self._gpio_manager.set_pin_low(self.txen_pin) - if self.rxen_pin != -1 and hasattr(self, "_rxen_pin_setup") and self._rxen_pin_setup: + if self.rxen_pin != -1: self._gpio_manager.set_pin_high(self.rxen_pin) async def _execute_transmission(self, driver_timeout: int) -> bool: @@ -824,7 +783,7 @@ async def _execute_transmission(self, driver_timeout: int) -> bool: # Check if radio accepted the TX command (wait for busy to clear) busy_timeout = 0 while self.lora.busyCheck() and busy_timeout < 50: # 500ms max wait - await asyncio.sleep(0.01) + await asyncio.sleep(self.RADIO_TIMING_DELAY) busy_timeout += 1 if self.lora.busyCheck(): @@ -918,27 +877,31 @@ def _finalize_transmission(self) -> None: async def _restore_rx_mode(self) -> None: """Restore radio to RX continuous mode after transmission""" + logger.debug("[TX->RX] Starting RX mode restoration after transmission") try: if self.lora: - # Add a small delay to ensure radio is ready to transition to RX - await asyncio.sleep(0.01) + # Clear any interrupt flags and set standby + self.lora.clearIrqStatus(0xFFFF) + self.lora.setStandby(self.lora.STANDBY_RC) - # Reconfigure RX interrupts before setting RX mode - rx_mask = self._get_rx_irq_mask() - self.lora.setDioIrqParams(rx_mask, rx_mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE) + # Brief delay for radio to settle + await asyncio.sleep(0.05) + # Configure full RX interrupts and set RX continuous mode + rx_mask = ( + self._get_rx_irq_mask() | self.lora.IRQ_CAD_DONE | self.lora.IRQ_CAD_DETECTED + ) + self.lora.setDioIrqParams(rx_mask, rx_mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE) self.lora.setRx(self.lora.RX_CONTINUOUS) - # Verify the radio actually entered RX mode - await asyncio.sleep(0.01) + # Final clear of any spurious flags and we're done + await asyncio.sleep(0.05) + self.lora.clearIrqStatus(0xFFFF) - # Clear any pending interrupt flags to ensure clean RX state - irqStat = self.lora.getIrqStatus() - if irqStat != 0: - self.lora.clearIrqStatus(irqStat) + logger.debug("[TX->RX] RX mode restoration completed") except Exception as e: - logger.warning(f"Failed to set RX mode after TX: {e}") + logger.warning(f"[TX->RX] Failed to restore RX mode after TX: {e}") async def send(self, data: bytes) -> None: """Send a packet asynchronously""" @@ -982,6 +945,9 @@ async def send(self, data: bytes) -> None: # Finalize transmission and log results self._finalize_transmission() + # Trigger TX LED + self._gpio_manager.blink_led(self.txled_pin) + except Exception as e: logger.error(f"Failed to send packet: {e}") return @@ -1012,60 +978,68 @@ def get_last_snr(self) -> float: """Return last received SNR in dB""" return self.last_snr - def get_noise_floor(self) -> Optional[float]: - """ - Get current noise floor (instantaneous RSSI) in dBm. - Returns None if radio is not initialized or if reading fails. - """ + def _sample_noise_floor(self) -> None: + """Sample noise floor""" if not self._initialized or self.lora is None: - return None + return - # Skip noise floor reading if we're currently transmitting - if hasattr(self, "_tx_lock") and self._tx_lock.locked(): - return None + # Don't sample during TX operations or if recently received packet + if self._tx_lock.locked(): + return - try: - raw_rssi = self.lora.getRssiInst() - if raw_rssi is not None: - noise_floor_dbm = -(float(raw_rssi) / 2) - # Validate reading - reject obviously invalid values - if -150.0 <= noise_floor_dbm <= -50.0: - return noise_floor_dbm - else: - # Invalid reading detected - trigger radio state reset - logger.debug( - f"Invalid noise floor reading: {noise_floor_dbm:.1f}dBm - resetting radio" - ) - self._reset_radio_state() - return None - return None - except Exception as e: - logger.debug(f"Failed to read noise floor: {e}") - return None + # Give 500ms quiet time after any packet activity + if time.time() - self._last_packet_activity < 0.5: + return - def _reset_radio_state(self) -> None: - """Reset radio state to recover from invalid RSSI readings""" - if not self._initialized or self.lora is None: + # Don't sample if currently receiving a packet + if self._is_receiving_packet: return - try: - # Force radio back to standby then RX mode - self.lora.setStandby(self.lora.STANDBY_RC) - time.sleep(0.05) # Let radio settle + # Sample RSSI during quiet periods only + if self._num_floor_samples < self.NUM_NOISE_FLOOR_SAMPLES: + try: + raw_rssi = self.lora.getRssiInst() + if raw_rssi is not None: + current_rssi = -(float(raw_rssi) / 2) - # Clear interrupt flags - irq_status = self.lora.getIrqStatus() - if irq_status != 0: - self.lora.clearIrqStatus(irq_status) + # This prevents packet RSSI from contaminating noise floor measurements + if current_rssi < (self._noise_floor + self.SAMPLING_THRESHOLD): + self._num_floor_samples += 1 + self._floor_sample_sum += current_rssi - # Restore RX mode - rx_mask = self._get_rx_irq_mask() - self.lora.setDioIrqParams(rx_mask, rx_mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE) - self.lora.setRx(self.lora.RX_CONTINUOUS) + except Exception as e: + logger.debug(f"Failed to sample noise floor: {e}") - logger.debug("Radio state reset completed") - except Exception as e: - logger.warning(f"Failed to reset radio state: {e}") + elif ( + self._num_floor_samples >= self.NUM_NOISE_FLOOR_SAMPLES and self._floor_sample_sum != 0 + ): + # Calculate new noise floor average + new_noise_floor = self._floor_sample_sum / self.NUM_NOISE_FLOOR_SAMPLES + + # Clamp to reasonable bounds (-150 to -50 dBm) + if new_noise_floor < -150: + new_noise_floor = -150 + elif new_noise_floor > -50: + new_noise_floor = -50 + + self._noise_floor = new_noise_floor + self._floor_sample_sum = 0.0 + self._num_floor_samples = 0 + + def get_noise_floor(self) -> Optional[float]: + """ + Get current noise floor in dBm. + Returns properly sampled noise floor from background measurements. + """ + if not self._initialized or self.lora is None: + return 0.0 + + # If currently transmitting, return 0 (clear indicator) + if hasattr(self, "_tx_lock") and self._tx_lock.locked(): + return 0.0 + + # Return the properly sampled and averaged noise floor + return self._noise_floor def set_frequency(self, frequency: int) -> bool: """Set operating frequency""" @@ -1311,12 +1285,6 @@ def cleanup(self) -> None: if hasattr(self, "_gpio_manager"): self._gpio_manager.cleanup_all() - if hasattr(self, "irq_pin") and self.irq_pin: - try: - self.irq_pin.close() - except Exception: - pass - self._interrupt_setup = False self._initialized = False @@ -1340,29 +1308,3 @@ def create_sx1262_radio(**kwargs) -> SX1262Radio: return radio else: raise RuntimeError("Failed to initialize SX1262 radio") - - -# Example configuration for common setups -CONFIGS = { - "eu868": { - "frequency": 868000000, - "tx_power": 14, - "spreading_factor": 7, - "bandwidth": 125000, - "coding_rate": 5, - }, - "us915": { - "frequency": 915000000, - "tx_power": 20, - "spreading_factor": 7, - "bandwidth": 125000, - "coding_rate": 5, - }, - "as923": { - "frequency": 923000000, - "tx_power": 16, - "spreading_factor": 7, - "bandwidth": 125000, - "coding_rate": 5, - }, -} diff --git a/tests/test_basic.py b/tests/test_basic.py index 6ebb24b..d953316 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -2,7 +2,7 @@ def test_version(): - assert __version__ == "1.0.4" + assert __version__ == "1.0.5" def test_import():