diff --git a/.gitignore b/.gitignore index 4f7ed26..2555f92 100644 --- a/.gitignore +++ b/.gitignore @@ -124,3 +124,9 @@ dmypy.json # OS .DS_Store Thumbs.db + +# Session tracking (git-excluded by convention) +RULES.md +PROBLEM_STATEMENT.md +PR_DESCRIPTION.md +REPEATER_CHANGES.md diff --git a/src/pymc_core/hardware/gpio_manager.py b/src/pymc_core/hardware/gpio_manager.py index 1dc3c0e..2f2c9fe 100644 --- a/src/pymc_core/hardware/gpio_manager.py +++ b/src/pymc_core/hardware/gpio_manager.py @@ -254,7 +254,7 @@ def setup_output_pin(self, pin_number: int, initial_value: bool = False) -> bool print(f"\nDebug: sudo lsof /dev/gpiochip* | grep {pin_number}") print("\nThe system cannot function without GPIO access.") print("━" * 60) - sys.exit(1) + raise RuntimeError(f"GPIO pin {pin_number} is already in use by another process") from e elif "permission denied" in error_msg: logger.error(f"Permission denied for GPIO pin {pin_number}: {e}") print(f"\nFATAL: Permission denied for GPIO pin {pin_number}") @@ -263,7 +263,7 @@ def setup_output_pin(self, pin_number: int, initial_value: bool = False) -> bool print(" • Add user to gpio group: sudo usermod -a -G gpio $USER") print(" • Then logout and login again") print("━" * 60) - sys.exit(1) + raise RuntimeError(f"Permission denied for GPIO pin {pin_number}") from e else: logger.error( f"Failed to setup output pin {pin_number} on {self._gpio_chip} " @@ -277,7 +277,7 @@ def setup_output_pin(self, pin_number: int, initial_value: bool = False) -> bool print(f"Error: {e}") print("\nThe system cannot function without GPIO access.") print("━" * 60) - sys.exit(1) + raise RuntimeError(f"Cannot setup GPIO output pin {pin_number}: {e}") from e def setup_input_pin( self, @@ -340,7 +340,7 @@ def setup_input_pin( print(f"\nDebug: sudo lsof /dev/gpiochip* | grep {pin_number}") print("\nThe system cannot function without GPIO access.") print("━" * 60) - sys.exit(1) + raise RuntimeError(f"GPIO pin {pin_number} is already in use by another process") from e elif "permission denied" in error_msg: logger.error(f"Permission denied for GPIO pin {pin_number}: {e}") print(f"\nFATAL: Permission denied for GPIO pin {pin_number}") @@ -349,7 +349,7 @@ def setup_input_pin( print(" • Add user to gpio group: sudo usermod -a -G gpio $USER") print(" • Then logout and login again") print("━" * 60) - sys.exit(1) + raise RuntimeError(f"Permission denied for GPIO pin {pin_number}") from e else: logger.error( f"Failed to setup input pin {pin_number} on {self._gpio_chip} " @@ -363,7 +363,7 @@ def setup_input_pin( print(f"Error: {e}") print("\nThe system cannot function without GPIO access.") print("━" * 60) - sys.exit(1) + raise RuntimeError(f"Cannot setup GPIO input pin {pin_number}: {e}") from e def setup_interrupt_pin( self, @@ -423,7 +423,7 @@ def setup_interrupt_pin( print(f"\nDebug: sudo lsof /dev/gpiochip* | grep {pin_number}") print("\nThe system cannot function without GPIO access.") print("━" * 60) - sys.exit(1) + raise RuntimeError(f"GPIO pin {pin_number} is already in use by another process") from e elif "permission denied" in error_msg: print(f"\nFATAL: Permission denied for GPIO pin {pin_number}") print("━" * 60) @@ -431,7 +431,7 @@ def setup_interrupt_pin( print(" • Add user to gpio group: sudo usermod -a -G gpio $USER") print(" • Then logout and login again") print("━" * 60) - sys.exit(1) + raise RuntimeError(f"Permission denied for GPIO pin {pin_number}") from e else: logger.error( f"Failed to setup interrupt pin {pin_number} on {self._gpio_chip} " @@ -445,7 +445,7 @@ def setup_interrupt_pin( print(f"Error: {e}") print("\nThe system cannot function without GPIO access.") print("━" * 60) - sys.exit(1) + raise RuntimeError(f"Cannot setup GPIO interrupt pin {pin_number}: {e}") from e def _start_edge_detection(self, pin_number: int) -> None: """Start hardware edge detection thread""" @@ -526,7 +526,7 @@ def _monitor_edge_events(self, pin_number: int, stop_event: threading.Event) -> while not stop_event.is_set() and pin_number in self._pins: try: # Wait for edge event (kernel blocks until interrupt) - if gpio.poll(30.0) and not stop_event.is_set(): + if gpio.poll(1.0) and not stop_event.is_set(): # Consume event from kernel queue to prevent repeated triggers event: EdgeEvent = gpio.read_event() diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py index 17180c1..1b34c21 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -291,6 +291,7 @@ def __init__( # Event loop for thread-safe async callback invocation self._event_loop: Optional[asyncio.AbstractEventLoop] = None + # When no event loop is set, run callback in a worker so RX thread never blocks self._callback_executor: Optional[ThreadPoolExecutor] = None @@ -433,6 +434,9 @@ def disconnect(self): logger.info(f"KISS modem disconnected from {self.port}") + def cleanup(self) -> None: + self.disconnect() + def _write_frame(self, frame: bytes) -> bool: """ Write a complete KISS frame to the serial port. @@ -1440,7 +1444,7 @@ def _process_received_frame(self): def _rx_worker(self): """Background thread for receiving data""" - while not self.stop_event.is_set() and self.is_connected: + while not self.stop_event.is_set(): try: if self.serial_conn and self.serial_conn.in_waiting > 0: data = self.serial_conn.read(self.serial_conn.in_waiting) @@ -1452,13 +1456,14 @@ def _rx_worker(self): threading.Event().wait(0.01) except Exception as e: - if self.is_connected: - logger.error(f"RX worker error: {e}") + logger.error(f"RX worker error: {e}") + self.is_connected = False + self.stop_event.set() break def _tx_worker(self): """Background thread for sending data""" - while not self.stop_event.is_set() and self.is_connected: + while not self.stop_event.is_set(): try: if self.tx_buffer: frame = self.tx_buffer.popleft() @@ -1475,8 +1480,9 @@ def _tx_worker(self): threading.Event().wait(0.01) except Exception as e: - if self.is_connected: - logger.error(f"TX worker error: {e}") + logger.error(f"TX worker error: {e}") + self.is_connected = False + self.stop_event.set() break def __enter__(self): diff --git a/src/pymc_core/hardware/kiss_serial_wrapper.py b/src/pymc_core/hardware/kiss_serial_wrapper.py index 5ff2f6c..d411c86 100644 --- a/src/pymc_core/hardware/kiss_serial_wrapper.py +++ b/src/pymc_core/hardware/kiss_serial_wrapper.py @@ -103,6 +103,7 @@ def __init__( # Callbacks self.on_frame_received = on_frame_received + # KISS Configuration self.config = { "txdelay": 30, # TX delay (units of 10ms) @@ -183,6 +184,9 @@ def disconnect(self): logger.info(f"KISS serial disconnected from {self.port}") + def cleanup(self) -> None: + self.disconnect() + def send_frame(self, data: bytes) -> bool: """ Send a data frame via KISS protocol @@ -649,7 +653,7 @@ def _process_received_frame(self): def _rx_worker(self): """Background thread for receiving data""" - while not self.stop_event.is_set() and self.is_connected: + while not self.stop_event.is_set(): try: if self.serial_conn and self.serial_conn.in_waiting > 0: # Read available bytes @@ -664,13 +668,14 @@ def _rx_worker(self): threading.Event().wait(0.01) except Exception as e: - if self.is_connected: # Only log if we expect to be connected - logger.error(f"RX worker error: {e}") + logger.error(f"RX worker error: {e}") + self.is_connected = False + self.stop_event.set() break def _tx_worker(self): """Background thread for sending data""" - while not self.stop_event.is_set() and self.is_connected: + while not self.stop_event.is_set(): try: if self.tx_buffer: # Get frame from buffer @@ -690,8 +695,9 @@ def _tx_worker(self): threading.Event().wait(0.01) except Exception as e: - if self.is_connected: # Only log if we expect to be connected - logger.error(f"TX worker error: {e}") + logger.error(f"TX worker error: {e}") + self.is_connected = False + self.stop_event.set() break def __enter__(self): diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index 7abbb27..bbe690e 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -164,6 +164,7 @@ def __init__( # Track event loop for thread-safe interrupt handling self._event_loop = None + # Store CAD results from interrupt handler self._last_cad_detected = False self._last_cad_irq_status = 0 @@ -541,7 +542,7 @@ async def _rx_irq_background_task(self): except Exception as e: logger.error(f"[RX Task] Unexpected error: {e}") - await asyncio.sleep(1.0) # Wait and continue + await asyncio.sleep(1.0) logger.warning("[RX] RX IRQ background task exiting") @@ -831,7 +832,7 @@ def begin(self) -> bool: except Exception as e: logger.error(f"Failed to initialize SX1262 radio: '{e}'") self._initialized = False - # Hard fail immediately - no retries + self.cleanup() raise RuntimeError(f"Failed to initialize SX1262 radio: {e}") from e def _calculate_tx_timeout(self, packet_length: int) -> tuple[int, int]: @@ -1396,6 +1397,59 @@ def set_bw(): "set bandwidth", set_bw, f"Bandwidth set to {bw/1000:.0f} kHz" ) + def configure_radio( + self, + frequency: Optional[int] = None, + bandwidth: Optional[int] = None, + spreading_factor: Optional[int] = None, + coding_rate: Optional[int] = None, + ) -> bool: + if not self._initialized or self.lora is None: + logger.error("Cannot configure radio: not initialised") + return False + + freq = frequency if frequency is not None else self.frequency + bw = bandwidth if bandwidth is not None else self.bandwidth + sf = spreading_factor if spreading_factor is not None else self.spreading_factor + cr = coding_rate if coding_rate is not None else self.coding_rate + ldro = sf >= 11 and bw <= 125000 + + deadline = time.monotonic() + 10.0 + while self._tx_lock.locked(): + if time.monotonic() > deadline: + logger.error("configure_radio: TX did not complete within 10s") + return False + time.sleep(0.05) + + try: + self.lora.clearIrqStatus(0xFFFF) + self.lora.setStandby(self.lora.STANDBY_RC) + time.sleep(self._RADIO_TIMING_DELAY) + self.lora.setFrequency(freq) + self.lora.setLoRaModulation(sf, bw, cr, ldro) + self.frequency = freq + self.bandwidth = bw + self.spreading_factor = sf + self.coding_rate = cr + self._noise_floor = -99.0 + self._num_floor_samples = 0 + self._floor_sample_sum = 0.0 + rx_mask = self._get_rx_irq_mask() + self.lora.clearIrqStatus(0xFFFF) + self.lora.setDioIrqParams(rx_mask, rx_mask, self.lora.IRQ_NONE, self.lora.IRQ_NONE) + self.lora.request(self.lora.RX_CONTINUOUS) + time.sleep(self._RADIO_TIMING_DELAY) + self.lora.clearIrqStatus(0xFFFF) + self._control_tx_rx_pins(tx_mode=False) + logger.info( + "Radio reconfigured: %.3f MHz BW=%.1f kHz SF%d CR4/%d", + freq / 1e6, bw / 1000, sf, cr, + ) + return True + except Exception as e: + logger.error("Failed to configure radio: %s", e) + return False + def get_status(self) -> dict: """Get radio status information""" status = { @@ -1652,6 +1706,9 @@ async def perform_cad( def cleanup(self) -> None: """Clean up radio resources""" + if hasattr(self, "_rx_irq_task") and self._rx_irq_task and not self._rx_irq_task.done(): + self._rx_irq_task.cancel() + if hasattr(self, "lora") and self.lora: try: self.lora.end() @@ -1659,6 +1716,11 @@ def cleanup(self) -> None: logger.error(f"Error during cleanup: {e}") if hasattr(self, "_gpio_manager"): + for pin in [self.txen_pin, self.rxen_pin]: + if pin != -1: + self._gpio_manager.set_pin_low(pin) + for pin in self.en_pins: + self._gpio_manager.set_pin_low(pin) self._gpio_manager.cleanup_all() self._interrupt_setup = False