From 422315d0ea246ee4cdb9dfbf694678148de10cc8 Mon Sep 17 00:00:00 2001 From: Joshua Mesilane Date: Fri, 15 May 2026 13:46:21 +1000 Subject: [PATCH 1/5] hardware: fix inconsistent radio failure signalling Replace sys.exit(1) with raise RuntimeError in gpio_manager so init failures are catchable by except Exception. Make is_connected a @property derived from thread liveness in KissModemWrapper and KissSerialWrapper so runtime failures are reflected accurately without manual state management. Fix SX1262 background task to set _initialized=False and break on unexpected error rather than sleeping and looping. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 6 +++++ src/pymc_core/hardware/gpio_manager.py | 18 +++++++-------- src/pymc_core/hardware/kiss_modem_wrapper.py | 22 ++++++++++--------- src/pymc_core/hardware/kiss_serial_wrapper.py | 22 ++++++++++--------- src/pymc_core/hardware/sx1262_wrapper.py | 4 +++- 5 files changed, 42 insertions(+), 30 deletions(-) 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..47e3277 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""" diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py index 17180c1..f8089fd 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -273,7 +273,6 @@ def __init__( self.preamble_length = self.radio_config.get("preamble_length", 17) self.serial_conn: Optional[serial.Serial] = None - self.is_connected = False self.rx_buffer = deque(maxlen=RX_BUFFER_SIZE) self.tx_buffer = deque(maxlen=TX_BUFFER_SIZE) @@ -291,6 +290,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 @@ -339,6 +339,13 @@ def set_event_loop(self, loop: asyncio.AbstractEventLoop) -> None: self._event_loop = loop logger.debug("Event loop set for thread-safe callbacks") + @property + def is_connected(self) -> bool: + return ( + self.rx_thread is not None and self.rx_thread.is_alive() + and self.tx_thread is not None and self.tx_thread.is_alive() + ) + def set_lbt_enabled(self, enabled: bool) -> None: """ Enable or disable host-side Listen-Before-Talk before each send. @@ -371,7 +378,6 @@ def connect(self) -> bool: stopbits=serial.STOPBITS_ONE, ) - self.is_connected = True self.stop_event.clear() # Start communication threads @@ -409,12 +415,10 @@ def connect(self) -> bool: except Exception as e: logger.error(f"Failed to connect to {self.port}: {e}") - self.is_connected = False return False def disconnect(self): """Disconnect from serial port and stop threads""" - self.is_connected = False self.stop_event.set() # Wait for threads to finish @@ -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,12 @@ 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}") 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 +1478,7 @@ 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}") 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..7ced764 100644 --- a/src/pymc_core/hardware/kiss_serial_wrapper.py +++ b/src/pymc_core/hardware/kiss_serial_wrapper.py @@ -87,7 +87,6 @@ def __init__( self.kiss_mode_active = False self.serial_conn: Optional[serial.Serial] = None - self.is_connected = False self.rx_buffer = deque(maxlen=RX_BUFFER_SIZE) self.tx_buffer = deque(maxlen=TX_BUFFER_SIZE) @@ -103,6 +102,7 @@ def __init__( # Callbacks self.on_frame_received = on_frame_received + # KISS Configuration self.config = { "txdelay": 30, # TX delay (units of 10ms) @@ -124,6 +124,13 @@ def __init__( "noise_floor": None, } + @property + def is_connected(self) -> bool: + return ( + self.rx_thread is not None and self.rx_thread.is_alive() + and self.tx_thread is not None and self.tx_thread.is_alive() + ) + def connect(self) -> bool: """ Connect to serial port and start communication threads @@ -141,7 +148,6 @@ def connect(self) -> bool: stopbits=serial.STOPBITS_ONE, ) - self.is_connected = True self.stop_event.clear() # Start communication threads @@ -163,12 +169,10 @@ def connect(self) -> bool: except Exception as e: logger.error(f"Failed to connect to {self.port}: {e}") - self.is_connected = False return False def disconnect(self): """Disconnect from serial port and stop threads""" - self.is_connected = False self.stop_event.set() # Wait for threads to finish @@ -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,12 @@ 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}") 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 +693,7 @@ 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}") break def __enter__(self): diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index 7abbb27..8dedd9c 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,8 @@ 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 + self._initialized = False + break logger.warning("[RX] RX IRQ background task exiting") From bd80e911961c2969a4425ee1186d3f72f2b653b5 Mon Sep 17 00:00:00 2001 From: Joshua Mesilane Date: Fri, 15 May 2026 16:16:33 +1000 Subject: [PATCH 2/5] hardware: fix cleanup lifecycle across radio types gpio_manager: reduce edge detection poll timeout from 30s to 1s so cleanup_all() join(2.0) is sufficient to stop the thread synchronously and GPIO lines are released before the caller returns. sx1262_wrapper: cancel _rx_irq_background_task before teardown; drive txen, rxen, and en_pins LOW before cleanup_all() so output pins are in a known safe state when the kernel GPIO claim is released. kiss_modem_wrapper, kiss_serial_wrapper: add cleanup() delegating to disconnect(), giving callers a consistent interface across all radio types and ensuring the serial fd is properly closed on teardown. Co-Authored-By: Claude Sonnet 4.6 --- src/pymc_core/hardware/gpio_manager.py | 2 +- src/pymc_core/hardware/kiss_modem_wrapper.py | 3 +++ src/pymc_core/hardware/kiss_serial_wrapper.py | 3 +++ src/pymc_core/hardware/sx1262_wrapper.py | 8 ++++++++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/pymc_core/hardware/gpio_manager.py b/src/pymc_core/hardware/gpio_manager.py index 47e3277..2f2c9fe 100644 --- a/src/pymc_core/hardware/gpio_manager.py +++ b/src/pymc_core/hardware/gpio_manager.py @@ -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 f8089fd..784051b 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -437,6 +437,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. diff --git a/src/pymc_core/hardware/kiss_serial_wrapper.py b/src/pymc_core/hardware/kiss_serial_wrapper.py index 7ced764..49070db 100644 --- a/src/pymc_core/hardware/kiss_serial_wrapper.py +++ b/src/pymc_core/hardware/kiss_serial_wrapper.py @@ -187,6 +187,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 diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index 8dedd9c..a29ce9a 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -1654,6 +1654,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() @@ -1661,6 +1664,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 From 8ca15c2004fb64d23b3135bdec47842235963e92 Mon Sep 17 00:00:00 2001 From: Joshua Mesilane Date: Fri, 15 May 2026 17:37:53 +1000 Subject: [PATCH 3/5] hardware: add configure_radio to SX1262Radio Individual setters (set_frequency, set_bandwidth, set_spreading_factor) leave the radio in standby after calling calibrateImage/setLoRaModulation, causing the noise floor sampler to read RSSI=255 (-127.5 dBm) and the radio to stop receiving. configure_radio() applies all params in one standby/restore cycle, resets the noise floor state, and returns the radio to RX_CONTINUOUS. Waits up to 10s for any in-progress TX to complete before reconfiguring. Co-Authored-By: Claude Sonnet 4.6 --- src/pymc_core/hardware/sx1262_wrapper.py | 53 ++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index a29ce9a..82d36da 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -1398,6 +1398,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 = { From b493a5b0f79bd3f6d0eafdd2cb93dc55bbd6b4f8 Mon Sep 17 00:00:00 2001 From: Joshua Mesilane Date: Fri, 15 May 2026 21:08:02 +1000 Subject: [PATCH 4/5] hardware: address maintainer feedback on radio lifecycle changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert is_connected to plain bool attribute on KissModemWrapper and KissSerialWrapper — property approach broke backward compatibility with existing callers and tests that assign the flag directly - Revert SX1262 RX task to sleep-and-continue on unexpected errors rather than marking uninitialized and exiting — transient faults are common in long-running deployments and check_radio_health() cannot revive the task once _initialized is False - Call cleanup() before re-raising RuntimeError in SX1262Radio.begin() so GPIO file descriptors and pin state are released regardless of whether the caller handles the exception Co-Authored-By: Claude Sonnet 4.6 --- src/pymc_core/hardware/kiss_modem_wrapper.py | 11 ++++------- src/pymc_core/hardware/kiss_serial_wrapper.py | 11 ++++------- src/pymc_core/hardware/sx1262_wrapper.py | 5 ++--- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py index 784051b..42ea225 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -273,6 +273,7 @@ def __init__( self.preamble_length = self.radio_config.get("preamble_length", 17) self.serial_conn: Optional[serial.Serial] = None + self.is_connected = False self.rx_buffer = deque(maxlen=RX_BUFFER_SIZE) self.tx_buffer = deque(maxlen=TX_BUFFER_SIZE) @@ -339,13 +340,6 @@ def set_event_loop(self, loop: asyncio.AbstractEventLoop) -> None: self._event_loop = loop logger.debug("Event loop set for thread-safe callbacks") - @property - def is_connected(self) -> bool: - return ( - self.rx_thread is not None and self.rx_thread.is_alive() - and self.tx_thread is not None and self.tx_thread.is_alive() - ) - def set_lbt_enabled(self, enabled: bool) -> None: """ Enable or disable host-side Listen-Before-Talk before each send. @@ -378,6 +372,7 @@ def connect(self) -> bool: stopbits=serial.STOPBITS_ONE, ) + self.is_connected = True self.stop_event.clear() # Start communication threads @@ -415,10 +410,12 @@ def connect(self) -> bool: except Exception as e: logger.error(f"Failed to connect to {self.port}: {e}") + self.is_connected = False return False def disconnect(self): """Disconnect from serial port and stop threads""" + self.is_connected = False self.stop_event.set() # Wait for threads to finish diff --git a/src/pymc_core/hardware/kiss_serial_wrapper.py b/src/pymc_core/hardware/kiss_serial_wrapper.py index 49070db..dc63149 100644 --- a/src/pymc_core/hardware/kiss_serial_wrapper.py +++ b/src/pymc_core/hardware/kiss_serial_wrapper.py @@ -87,6 +87,7 @@ def __init__( self.kiss_mode_active = False self.serial_conn: Optional[serial.Serial] = None + self.is_connected = False self.rx_buffer = deque(maxlen=RX_BUFFER_SIZE) self.tx_buffer = deque(maxlen=TX_BUFFER_SIZE) @@ -124,13 +125,6 @@ def __init__( "noise_floor": None, } - @property - def is_connected(self) -> bool: - return ( - self.rx_thread is not None and self.rx_thread.is_alive() - and self.tx_thread is not None and self.tx_thread.is_alive() - ) - def connect(self) -> bool: """ Connect to serial port and start communication threads @@ -148,6 +142,7 @@ def connect(self) -> bool: stopbits=serial.STOPBITS_ONE, ) + self.is_connected = True self.stop_event.clear() # Start communication threads @@ -169,10 +164,12 @@ def connect(self) -> bool: except Exception as e: logger.error(f"Failed to connect to {self.port}: {e}") + self.is_connected = False return False def disconnect(self): """Disconnect from serial port and stop threads""" + self.is_connected = False self.stop_event.set() # Wait for threads to finish diff --git a/src/pymc_core/hardware/sx1262_wrapper.py b/src/pymc_core/hardware/sx1262_wrapper.py index 82d36da..bbe690e 100644 --- a/src/pymc_core/hardware/sx1262_wrapper.py +++ b/src/pymc_core/hardware/sx1262_wrapper.py @@ -542,8 +542,7 @@ async def _rx_irq_background_task(self): except Exception as e: logger.error(f"[RX Task] Unexpected error: {e}") - self._initialized = False - break + await asyncio.sleep(1.0) logger.warning("[RX] RX IRQ background task exiting") @@ -833,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]: From 254d58622643848fc8fb805486532ac2e70e10e6 Mon Sep 17 00:00:00 2001 From: Joshua Mesilane Date: Fri, 15 May 2026 21:19:59 +1000 Subject: [PATCH 5/5] hardware: signal disconnection from KISS worker crash path When a worker thread exits due to an exception, set is_connected=False and stop_event so RadioManager's _wait_for_disconnect() polling loop detects the failure within 1s and triggers a reconnect attempt. Full disconnect() cannot be called from within a worker thread as it attempts to join the current thread, which raises RuntimeError. Co-Authored-By: Claude Sonnet 4.6 --- src/pymc_core/hardware/kiss_modem_wrapper.py | 4 ++++ src/pymc_core/hardware/kiss_serial_wrapper.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/pymc_core/hardware/kiss_modem_wrapper.py b/src/pymc_core/hardware/kiss_modem_wrapper.py index 42ea225..1b34c21 100644 --- a/src/pymc_core/hardware/kiss_modem_wrapper.py +++ b/src/pymc_core/hardware/kiss_modem_wrapper.py @@ -1457,6 +1457,8 @@ def _rx_worker(self): except Exception as e: logger.error(f"RX worker error: {e}") + self.is_connected = False + self.stop_event.set() break def _tx_worker(self): @@ -1479,6 +1481,8 @@ def _tx_worker(self): except Exception as 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 dc63149..d411c86 100644 --- a/src/pymc_core/hardware/kiss_serial_wrapper.py +++ b/src/pymc_core/hardware/kiss_serial_wrapper.py @@ -669,6 +669,8 @@ def _rx_worker(self): except Exception as e: logger.error(f"RX worker error: {e}") + self.is_connected = False + self.stop_event.set() break def _tx_worker(self): @@ -694,6 +696,8 @@ def _tx_worker(self): except Exception as e: logger.error(f"TX worker error: {e}") + self.is_connected = False + self.stop_event.set() break def __enter__(self):