From 6a806d348660c598e5a05dd2aa0857112eb072b6 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Thu, 13 Nov 2025 23:42:23 +0000 Subject: [PATCH 1/9] Refactor interrupt handling and TX/RX pin control for improved reliability and clarity --- src/pymc_core/hardware/sx1262_wrapper.py | 96 ++++++++++++------------ 1 file changed, 46 insertions(+), 50 deletions(-) diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index d71659c..7e3be7f 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -10,10 +10,11 @@ import asyncio import logging -import time import math import random +import time from typing import Callable, Optional + from gpiozero import Button, Device, OutputDevice # Force gpiozero to use LGPIOFactory - no RPi.GPIO fallback @@ -356,11 +357,9 @@ async def _rx_irq_background_task(self): 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 @@ -429,19 +428,12 @@ 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) + 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}") @@ -673,23 +665,25 @@ def begin(self) -> bool: def _calculate_tx_timeout(self, packet_length: int) -> tuple[int, int]: """Calculate transmission timeout using C++ MeshCore formula - simple and accurate""" - + 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 +696,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: @@ -758,14 +752,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)" @@ -797,23 +790,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: @@ -917,28 +907,34 @@ def _finalize_transmission(self) -> None: self._control_tx_rx_pins(tx_mode=False) async def _restore_rx_mode(self) -> None: - """Restore radio to RX continuous mode after transmission""" + """Restore radio to RX continuous mode after transmission with clean state""" try: if self.lora: - # Add a small delay to ensure radio is ready to transition to RX - await asyncio.sleep(0.01) + # Force radio to standby to ensure clean transition + self.lora.setStandby(self.lora.STANDBY_RC) + await asyncio.sleep(0.015) + + # Clear ALL interrupt flags from TX operation + self.lora.clearIrqStatus(0xFFFF) - # Reconfigure RX interrupts before setting RX mode + # Configure RX interrupts rx_mask = self._get_rx_irq_mask() self.lora.setDioIrqParams(rx_mask, rx_mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE) + # Enter RX continuous mode self.lora.setRx(self.lora.RX_CONTINUOUS) - # Verify the radio actually entered RX mode - await asyncio.sleep(0.01) + # Allow radio to stabilize in RX mode + await asyncio.sleep(0.020) - # Clear any pending interrupt flags to ensure clean RX state - irqStat = self.lora.getIrqStatus() - if irqStat != 0: - self.lora.clearIrqStatus(irqStat) + # Final cleanup: clear any startup artifacts + startup_irq = self.lora.getIrqStatus() + if startup_irq != 0: + logger.debug(f"Clearing RX startup IRQ: 0x{startup_irq:04X}") + self.lora.clearIrqStatus(startup_irq) except Exception as e: - logger.warning(f"Failed to set RX mode after TX: {e}") + logger.warning(f"Failed to restore RX mode after TX: {e}") async def send(self, data: bytes) -> None: """Send a packet asynchronously""" From 95c09b8b268cf24c3e2d043fdfded9acd3193643 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 14 Nov 2025 07:32:39 +0000 Subject: [PATCH 2/9] Enhance noise floor reading stability --- src/pymc_core/hardware/sx1262_wrapper.py | 138 ++++++++++++----------- 1 file changed, 73 insertions(+), 65 deletions(-) diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index 7e3be7f..3873933 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -205,6 +205,12 @@ def __init__( self._custom_cad_peak = None self._custom_cad_min = None + # Last known good noise floor reading + self._last_good_noise_floor = None + + # Track last transmission time for RSSI stabilization + self._last_tx_time = 0 + logger.info( f"SX1262Radio configured: freq={frequency/1e6:.1f}MHz, " f"power={tx_power}dBm, sf={spreading_factor}, " @@ -404,13 +410,6 @@ async def _rx_irq_background_task(self): 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 " @@ -466,18 +465,16 @@ async def _rx_irq_background_task(self): # 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) + if rx_check_count % 1000 == 0: + noise_floor_dbm = self.get_noise_floor() + if noise_floor_dbm is not None: 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: + else: logger.debug(f"[RX Task] Status check #{rx_check_count}") else: await asyncio.sleep(0.1) # Longer delay when interrupts not set up @@ -906,33 +903,21 @@ def _finalize_transmission(self) -> None: # Reset TX/RX enable pins after transmission self._control_tx_rx_pins(tx_mode=False) + # Record transmission completion time for RSSI stabilization + self._last_tx_time = time.time() + async def _restore_rx_mode(self) -> None: - """Restore radio to RX continuous mode after transmission with clean state""" + """Restore radio to RX continuous mode after transmission""" try: if self.lora: - # Force radio to standby to ensure clean transition - self.lora.setStandby(self.lora.STANDBY_RC) - await asyncio.sleep(0.015) - - # Clear ALL interrupt flags from TX operation + # Clear any TX interrupt flags and restore RX mode self.lora.clearIrqStatus(0xFFFF) - # Configure RX interrupts + # Configure RX interrupts and return to RX continuous mode rx_mask = self._get_rx_irq_mask() self.lora.setDioIrqParams(rx_mask, rx_mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE) - - # Enter RX continuous mode self.lora.setRx(self.lora.RX_CONTINUOUS) - # Allow radio to stabilize in RX mode - await asyncio.sleep(0.020) - - # Final cleanup: clear any startup artifacts - startup_irq = self.lora.getIrqStatus() - if startup_irq != 0: - logger.debug(f"Clearing RX startup IRQ: 0x{startup_irq:04X}") - self.lora.clearIrqStatus(startup_irq) - except Exception as e: logger.warning(f"Failed to restore RX mode after TX: {e}") @@ -1011,57 +996,80 @@ def get_last_snr(self) -> float: 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. """ if not self._initialized or self.lora is None: - return None + return self._last_good_noise_floor # Skip noise floor reading if we're currently transmitting if hasattr(self, "_tx_lock") and self._tx_lock.locked(): - return None + return self._last_good_noise_floor + + # Check if radio is in RX mode FIRST - don't even attempt RSSI reading if not + try: + current_mode = self.lora.getMode() + if current_mode != self.lora.STATUS_MODE_RX: + # Radio not in RX mode - don't attempt RSSI_INST reading at all + logger.debug( + f"Radio not in RX mode (mode=0x{current_mode:02X}) - " + f"using last good noise floor" + ) + return self._last_good_noise_floor + except Exception as e: + logger.debug(f"Failed to read radio mode: {e}") + return self._last_good_noise_floor + + # Even if radio reports RX mode, RSSI_INST may need time to stabilize + # Check if we have a recent transmission that might affect readings + current_time = time.time() + if hasattr(self, "_last_tx_time") and (current_time - self._last_tx_time) < 0.1: + # Less than 100ms since last TX - RSSI_INST might still be unstable + tx_elapsed_ms = (current_time - self._last_tx_time) * 1000 + logger.debug( + f"Recent TX detected ({tx_elapsed_ms:.0f}ms ago) - " f"using last good noise floor" + ) + return self._last_good_noise_floor + # Radio is in RX mode and stable - safe to read RSSI_INST 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: + + # Aggressive corruption detection for SX1262 RSSI_INST instability + # The -76/-77dBm readings are a specific corruption pattern after TX + if -78.0 <= noise_floor_dbm <= -74.0: + # This is likely the characteristic SX1262 corruption pattern + logger.debug( + f"Detected SX1262 RSSI corruption pattern: " + f"{noise_floor_dbm:.1f}dBm - using last good: " + f"{self._last_good_noise_floor}" + ) + return self._last_good_noise_floor + + # Validate reading - accept reasonable range + if -150.0 <= noise_floor_dbm <= -30.0: + # Valid reading - cache it and return + self._last_good_noise_floor = noise_floor_dbm + return noise_floor_dbm + elif -30.0 < noise_floor_dbm <= 10.0: + # Strong signal range - cache and return but log it + logger.debug(f"Strong signal detected: {noise_floor_dbm:.1f}dBm") + self._last_good_noise_floor = noise_floor_dbm return noise_floor_dbm else: - # Invalid reading detected - trigger radio state reset + # Invalid reading - log but return last known good logger.debug( - f"Invalid noise floor reading: {noise_floor_dbm:.1f}dBm - resetting radio" + f"Invalid RSSI reading: {noise_floor_dbm:.1f}dBm - " + f"using last good: {self._last_good_noise_floor}" ) - self._reset_radio_state() - return None - return None - except Exception as e: - logger.debug(f"Failed to read noise floor: {e}") - return None - - def _reset_radio_state(self) -> None: - """Reset radio state to recover from invalid RSSI readings""" - if not self._initialized or self.lora is None: - return - - try: - # Force radio back to standby then RX mode - self.lora.setStandby(self.lora.STANDBY_RC) - time.sleep(0.05) # Let radio settle - - # Clear interrupt flags - irq_status = self.lora.getIrqStatus() - if irq_status != 0: - self.lora.clearIrqStatus(irq_status) + return self._last_good_noise_floor - # 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) + # No reading available - return last known good + return self._last_good_noise_floor - logger.debug("Radio state reset completed") except Exception as e: - logger.warning(f"Failed to reset radio state: {e}") + logger.debug(f"Failed to read noise floor: {e}") + return self._last_good_noise_floor def set_frequency(self, frequency: int) -> bool: """Set operating frequency""" From dc8c72cb7423485010e7e35ede7e8aa4fc1389bb Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 14 Nov 2025 13:36:06 +0000 Subject: [PATCH 3/9] Add DIO3 TCXO control and voltage configuration to SX1262Radio --- src/pymc_core/hardware/sx1262_wrapper.py | 137 +++++++++++++---------- 1 file changed, 79 insertions(+), 58 deletions(-) diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index 3873933..34ed621 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -134,6 +134,8 @@ def __init__( preamble_length: int = 12, sync_word: int = 0x3444, is_waveshare: bool = False, + use_dio3_tcxo: bool = True, + dio3_tcxo_voltage: float = 1.8, ): """ Initialize SX1262 radio @@ -155,6 +157,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: @@ -183,6 +187,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 @@ -205,9 +211,6 @@ def __init__( self._custom_cad_peak = None self._custom_cad_min = None - # Last known good noise floor reading - self._last_good_noise_floor = None - # Track last transmission time for RSSI stabilization self._last_tx_time = 0 @@ -570,7 +573,35 @@ def begin(self) -> bool: 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, + } + # Find closest voltage match + voltage_constant = voltage_map.get(self.dio3_tcxo_voltage) + if voltage_constant is None: + # Find closest match + 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 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() @@ -996,80 +1027,43 @@ def get_last_snr(self) -> float: def get_noise_floor(self) -> Optional[float]: """ Get current noise floor (instantaneous RSSI) in dBm. + Simple KISS approach: return 0 if not available, highlight issues clearly. """ if not self._initialized or self.lora is None: - return self._last_good_noise_floor + return 0.0 - # Skip noise floor reading if we're currently transmitting + # If currently transmitting, return 0 (clear indicator) if hasattr(self, "_tx_lock") and self._tx_lock.locked(): - return self._last_good_noise_floor + return 0.0 - # Check if radio is in RX mode FIRST - don't even attempt RSSI reading if not + # Check if radio is in RX mode try: current_mode = self.lora.getMode() if current_mode != self.lora.STATUS_MODE_RX: - # Radio not in RX mode - don't attempt RSSI_INST reading at all - logger.debug( - f"Radio not in RX mode (mode=0x{current_mode:02X}) - " - f"using last good noise floor" - ) - return self._last_good_noise_floor + logger.debug(f"Radio not in RX mode (mode=0x{current_mode:02X}) - returning 0") + return 0.0 except Exception as e: logger.debug(f"Failed to read radio mode: {e}") - return self._last_good_noise_floor - - # Even if radio reports RX mode, RSSI_INST may need time to stabilize - # Check if we have a recent transmission that might affect readings - current_time = time.time() - if hasattr(self, "_last_tx_time") and (current_time - self._last_tx_time) < 0.1: - # Less than 100ms since last TX - RSSI_INST might still be unstable - tx_elapsed_ms = (current_time - self._last_tx_time) * 1000 - logger.debug( - f"Recent TX detected ({tx_elapsed_ms:.0f}ms ago) - " f"using last good noise floor" - ) - return self._last_good_noise_floor + return 0.0 - # Radio is in RX mode and stable - safe to read RSSI_INST + # Try to read RSSI - simple approach try: raw_rssi = self.lora.getRssiInst() if raw_rssi is not None: noise_floor_dbm = -(float(raw_rssi) / 2) - - # Aggressive corruption detection for SX1262 RSSI_INST instability - # The -76/-77dBm readings are a specific corruption pattern after TX - if -78.0 <= noise_floor_dbm <= -74.0: - # This is likely the characteristic SX1262 corruption pattern - logger.debug( - f"Detected SX1262 RSSI corruption pattern: " - f"{noise_floor_dbm:.1f}dBm - using last good: " - f"{self._last_good_noise_floor}" - ) - return self._last_good_noise_floor - - # Validate reading - accept reasonable range - if -150.0 <= noise_floor_dbm <= -30.0: - # Valid reading - cache it and return - self._last_good_noise_floor = noise_floor_dbm - return noise_floor_dbm - elif -30.0 < noise_floor_dbm <= 10.0: - # Strong signal range - cache and return but log it - logger.debug(f"Strong signal detected: {noise_floor_dbm:.1f}dBm") - self._last_good_noise_floor = noise_floor_dbm + + # Only basic sanity check + if -150.0 <= noise_floor_dbm <= 10.0: return noise_floor_dbm else: - # Invalid reading - log but return last known good - logger.debug( - f"Invalid RSSI reading: {noise_floor_dbm:.1f}dBm - " - f"using last good: {self._last_good_noise_floor}" - ) - return self._last_good_noise_floor - - # No reading available - return last known good - return self._last_good_noise_floor + logger.debug(f"Invalid RSSI reading: {noise_floor_dbm:.1f}dBm - returning 0") + return 0.0 + else: + return 0.0 except Exception as e: logger.debug(f"Failed to read noise floor: {e}") - return self._last_good_noise_floor + return 0.0 def set_frequency(self, frequency: int) -> bool: """Set operating frequency""" @@ -1355,6 +1349,15 @@ def create_sx1262_radio(**kwargs) -> SX1262Radio: "bandwidth": 125000, "coding_rate": 5, }, + "eu868_e22": { + "frequency": 868000000, + "tx_power": 14, + "spreading_factor": 7, + "bandwidth": 125000, + "coding_rate": 5, + "use_dio3_tcxo": True, + + }, "us915": { "frequency": 915000000, "tx_power": 20, @@ -1362,6 +1365,15 @@ def create_sx1262_radio(**kwargs) -> SX1262Radio: "bandwidth": 125000, "coding_rate": 5, }, + "us915_e22": { + "frequency": 915000000, + "tx_power": 20, + "spreading_factor": 7, + "bandwidth": 125000, + "coding_rate": 5, + "use_dio3_tcxo": True, + + }, "as923": { "frequency": 923000000, "tx_power": 16, @@ -1369,4 +1381,13 @@ def create_sx1262_radio(**kwargs) -> SX1262Radio: "bandwidth": 125000, "coding_rate": 5, }, + "as923_e22": { + "frequency": 923000000, + "tx_power": 16, + "spreading_factor": 7, + "bandwidth": 125000, + "coding_rate": 5, + "use_dio3_tcxo": True, + + }, } From 4b9c3d413f040392cbfdebb2b7cea1726b1cd917 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 14 Nov 2025 16:49:39 +0000 Subject: [PATCH 4/9] Refactor RX handling in SX1262Radio for improved clarity and reliability --- src/pymc_core/hardware/sx1262_wrapper.py | 80 +++++++----------------- 1 file changed, 23 insertions(+), 57 deletions(-) diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index 34ed621..9d7eee8 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -11,8 +11,8 @@ import asyncio import logging import math -import random import time +import random from typing import Callable, Optional from gpiozero import Button, Device, OutputDevice @@ -350,8 +350,6 @@ 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: @@ -371,7 +369,6 @@ async def _rx_irq_background_task(self): self.lora.clearIrqStatus(irqStat) if irqStat & self.lora.IRQ_RX_DONE: - last_preamble_time = 0 # Reset preamble timer on successful RX ( payloadLengthRx, rxStartBufferPointer, @@ -406,9 +403,8 @@ 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 @@ -420,7 +416,6 @@ async def _rx_irq_background_task(self): ) except Exception: pass - last_preamble_time = time.time() # Record when preamble was detected elif irqStat & self.lora.IRQ_SYNC_WORD_VALID: pass # Sync word valid - receiving packet data... elif irqStat & self.lora.IRQ_HEADER_VALID: @@ -443,42 +438,10 @@ async def _rx_irq_background_task(self): # 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 - # Log every 500 checks (roughly every 5 seconds) to show RX task is alive if rx_check_count % 500 == 0: - if rx_check_count % 1000 == 0: - noise_floor_dbm = self.get_noise_floor() - if noise_floor_dbm is not None: - 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}") - else: - logger.debug(f"[RX Task] Status check #{rx_check_count}") + logger.debug(f"[RX Task] Status check #{rx_check_count}") + else: await asyncio.sleep(0.1) # Longer delay when interrupts not set up @@ -496,7 +459,7 @@ def begin(self) -> bool: # Try IRQ setup - this is REQUIRED, no polling fallback try: - self.irq_pin = Button(self.irq_pin_number, pull_up=False) + self.irq_pin = Button(self.irq_pin_number, pull_up=True) self.irq_pin.when_activated = self._handle_interrupt self._interrupt_setup = True logger.debug(f"[RX] IRQ setup successful on pin {self.irq_pin_number}") @@ -659,6 +622,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") @@ -941,13 +905,26 @@ async def _restore_rx_mode(self) -> None: """Restore radio to RX continuous mode after transmission""" try: if self.lora: - # Clear any TX interrupt flags and restore RX mode + logger.debug("[RX Restore] Starting post-TX recovery sequence") + + # Clear any TX interrupt flags self.lora.clearIrqStatus(0xFFFF) - # Configure RX interrupts and return to RX continuous mode + # Simple post-TX recovery sequence + # Step 1: Go to standby mode first + self.lora.setStandby(self.lora.STANDBY_RC) + logger.debug("[RX Restore] Set to standby mode") + + # Step 2: Brief delay to reset AGC calibration + await asyncio.sleep(0.1) + logger.debug("[RX Restore] Completed AGC reset delay") + + # Step 3: Configure RX interrupts and return to RX continuous mode rx_mask = self._get_rx_irq_mask() self.lora.setDioIrqParams(rx_mask, rx_mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE) self.lora.setRx(self.lora.RX_CONTINUOUS) + + logger.debug("[RX Restore] Successfully restored to RX mode") except Exception as e: logger.warning(f"Failed to restore RX mode after TX: {e}") @@ -1027,7 +1004,6 @@ def get_last_snr(self) -> float: def get_noise_floor(self) -> Optional[float]: """ Get current noise floor (instantaneous RSSI) in dBm. - Simple KISS approach: return 0 if not available, highlight issues clearly. """ if not self._initialized or self.lora is None: return 0.0 @@ -1036,23 +1012,13 @@ def get_noise_floor(self) -> Optional[float]: if hasattr(self, "_tx_lock") and self._tx_lock.locked(): return 0.0 - # Check if radio is in RX mode - try: - current_mode = self.lora.getMode() - if current_mode != self.lora.STATUS_MODE_RX: - logger.debug(f"Radio not in RX mode (mode=0x{current_mode:02X}) - returning 0") - return 0.0 - except Exception as e: - logger.debug(f"Failed to read radio mode: {e}") - return 0.0 - - # Try to read RSSI - simple approach + # Try to read RSSI try: raw_rssi = self.lora.getRssiInst() if raw_rssi is not None: noise_floor_dbm = -(float(raw_rssi) / 2) - # Only basic sanity check + # Basic sanity check if -150.0 <= noise_floor_dbm <= 10.0: return noise_floor_dbm else: From 49a82786faf9efa4744cc57b164d31d969653851 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 14 Nov 2025 23:19:47 +0000 Subject: [PATCH 5/9] Refactor GPIOPinManager to enhance functionality and add LED control features --- src/pymc_core/hardware/gpio_manager.py | 186 ++++++++++ src/pymc_core/hardware/sx1262_wrapper.py | 411 ++++++++++------------- 2 files changed, 363 insertions(+), 234 deletions(-) create mode 100644 src/pymc_core/hardware/gpio_manager.py diff --git a/src/pymc_core/hardware/gpio_manager.py b/src/pymc_core/hardware/gpio_manager.py new file mode 100644 index 0000000..01b92f0 --- /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.debug(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 9d7eee8..e34659d 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -11,102 +11,17 @@ import asyncio import logging import math -import time 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""" @@ -126,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, @@ -134,7 +51,7 @@ def __init__( preamble_length: int = 12, sync_word: int = 0x3444, is_waveshare: bool = False, - use_dio3_tcxo: bool = True, + use_dio3_tcxo: bool = False, dio3_tcxo_voltage: float = 1.8, ): """ @@ -149,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) @@ -177,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 @@ -201,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() @@ -211,8 +134,14 @@ def __init__( self._custom_cad_peak = None self._custom_cad_min = None - # Track last transmission time for RSSI stabilization - self._last_tx_time = 0 + # 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, " @@ -292,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) @@ -300,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}") @@ -350,15 +293,20 @@ 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 - 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() @@ -382,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) @@ -406,16 +357,7 @@ async def _rx_irq_background_task(self): elif irqStat & self.lora.IRQ_TIMEOUT: logger.warning("[RX] RX timeout detected") elif irqStat & self.lora.IRQ_PREAMBLE_DETECTED: - preamble_detect_count += 1 - # Log detailed preamble detection info - try: - 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 + pass elif irqStat & self.lora.IRQ_SYNC_WORD_VALID: pass # Sync word valid - receiving packet data... elif irqStat & self.lora.IRQ_HEADER_VALID: @@ -429,18 +371,28 @@ async def _rx_irq_background_task(self): # 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 + # 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: - 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 @@ -454,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=True) - 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) @@ -490,6 +440,21 @@ def begin(self) -> bool: else: logger.warning(f"Could not setup TXEN pin {self.txen_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 @@ -530,7 +495,7 @@ 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 @@ -540,7 +505,7 @@ def begin(self) -> bool: # 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.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, @@ -548,23 +513,27 @@ def begin(self) -> bool: 3.0: self.lora.DIO3_OUTPUT_3_0, 3.3: self.lora.DIO3_OUTPUT_3_3, } - # Find closest voltage match + voltage_constant = voltage_map.get(self.dio3_tcxo_voltage) if voltage_constant is None: - # Find closest match - closest_voltage = min(voltage_map.keys(), key=lambda x: abs(x - self.dio3_tcxo_voltage)) + 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 mapped to closest {closest_voltage}V") + 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() @@ -622,7 +591,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") @@ -656,7 +625,7 @@ 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 @@ -708,9 +677,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 @@ -728,7 +697,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 @@ -773,7 +742,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") @@ -806,7 +775,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(): @@ -898,36 +867,33 @@ def _finalize_transmission(self) -> None: # Reset TX/RX enable pins after transmission self._control_tx_rx_pins(tx_mode=False) - # Record transmission completion time for RSSI stabilization - self._last_tx_time = time.time() - 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: - logger.debug("[RX Restore] Starting post-TX recovery sequence") - - # Clear any TX interrupt flags + # Clear any interrupt flags and set standby self.lora.clearIrqStatus(0xFFFF) - - # Simple post-TX recovery sequence - # Step 1: Go to standby mode first self.lora.setStandby(self.lora.STANDBY_RC) - logger.debug("[RX Restore] Set to standby mode") - - # Step 2: Brief delay to reset AGC calibration - await asyncio.sleep(0.1) - logger.debug("[RX Restore] Completed AGC reset delay") - # Step 3: Configure RX interrupts and return to RX continuous mode - rx_mask = self._get_rx_irq_mask() + # 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) - - logger.debug("[RX Restore] Successfully restored to RX mode") + + # Final clear of any spurious flags and we're done + await asyncio.sleep(0.05) + self.lora.clearIrqStatus(0xFFFF) + + logger.debug("[TX->RX] RX mode restoration completed") except Exception as e: - logger.warning(f"Failed to restore 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""" @@ -971,6 +937,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 @@ -1001,9 +970,58 @@ def get_last_snr(self) -> float: """Return last received SNR in dB""" return self.last_snr + def _sample_noise_floor(self) -> None: + """Sample noise floor""" + if not self._initialized or self.lora is None: + return + + # Don't sample during TX operations or if recently received packet + if self._tx_lock.locked(): + return + + # Give 500ms quiet time after any packet activity + if time.time() - self._last_packet_activity < 0.5: + return + + # Don't sample if currently receiving a packet + if self._is_receiving_packet: + return + + # 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) + + # 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 + + except Exception as e: + logger.debug(f"Failed to sample noise floor: {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 (instantaneous RSSI) in dBm. + 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 @@ -1012,24 +1030,8 @@ def get_noise_floor(self) -> Optional[float]: if hasattr(self, "_tx_lock") and self._tx_lock.locked(): return 0.0 - # Try to read RSSI - try: - raw_rssi = self.lora.getRssiInst() - if raw_rssi is not None: - noise_floor_dbm = -(float(raw_rssi) / 2) - - # Basic sanity check - if -150.0 <= noise_floor_dbm <= 10.0: - return noise_floor_dbm - else: - logger.debug(f"Invalid RSSI reading: {noise_floor_dbm:.1f}dBm - returning 0") - return 0.0 - else: - return 0.0 - - except Exception as e: - logger.debug(f"Failed to read noise floor: {e}") - 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""" @@ -1275,12 +1277,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 @@ -1304,56 +1300,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, - }, - "eu868_e22": { - "frequency": 868000000, - "tx_power": 14, - "spreading_factor": 7, - "bandwidth": 125000, - "coding_rate": 5, - "use_dio3_tcxo": True, - - }, - "us915": { - "frequency": 915000000, - "tx_power": 20, - "spreading_factor": 7, - "bandwidth": 125000, - "coding_rate": 5, - }, - "us915_e22": { - "frequency": 915000000, - "tx_power": 20, - "spreading_factor": 7, - "bandwidth": 125000, - "coding_rate": 5, - "use_dio3_tcxo": True, - - }, - "as923": { - "frequency": 923000000, - "tx_power": 16, - "spreading_factor": 7, - "bandwidth": 125000, - "coding_rate": 5, - }, - "as923_e22": { - "frequency": 923000000, - "tx_power": 16, - "spreading_factor": 7, - "bandwidth": 125000, - "coding_rate": 5, - "use_dio3_tcxo": True, - - }, -} From 9f4c0856a6f35520828389ba7f9f9cd6831e9886 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 14 Nov 2025 23:28:54 +0000 Subject: [PATCH 6/9] Bump version to 1.0.5 --- pyproject.toml | 2 +- src/pymc_core/__init__.py | 2 +- tests/test_basic.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 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/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(): From 1dcff4bd0514e455c910e6c695934500e2c39693 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 14 Nov 2025 15:51:35 -0800 Subject: [PATCH 7/9] Update gpio_manager.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pymc_core/hardware/gpio_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymc_core/hardware/gpio_manager.py b/src/pymc_core/hardware/gpio_manager.py index 01b92f0..804db8c 100644 --- a/src/pymc_core/hardware/gpio_manager.py +++ b/src/pymc_core/hardware/gpio_manager.py @@ -180,7 +180,7 @@ def blink_led(self, pin_number: int, duration: float = 3.0) -> None: except RuntimeError: # No event loop running - just turn on LED (won't auto-turn off) - logger.debug(f"No event loop, LED pin {pin_number} turned on (manual off required)") + 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}") From 9c16af61bb5e0dce8c601368e72f3f659ddd15ef Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 14 Nov 2025 23:56:56 +0000 Subject: [PATCH 8/9] Add RXEN pin setup --- src/pymc_core/hardware/sx1262_wrapper.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index e34659d..8a8b887 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -440,6 +440,13 @@ def begin(self) -> bool: 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=True): + 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): From cb92e864f43ff2278f58ae56f4f1586ffbbcf74a Mon Sep 17 00:00:00 2001 From: Lloyd Date: Sat, 15 Nov 2025 00:01:39 +0000 Subject: [PATCH 9/9] Fix RXEN pin setup to initialize with low state and add debug logging --- src/pymc_core/hardware/sx1262_wrapper.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index 8a8b887..e5584c4 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -436,13 +436,14 @@ 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=True): + 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}")