diff --git a/tui/src/birdcage_tui/app.py b/tui/src/birdcage_tui/app.py index c7990d1..87f03f8 100644 --- a/tui/src/birdcage_tui/app.py +++ b/tui/src/birdcage_tui/app.py @@ -6,6 +6,7 @@ device status bar, and five swappable screen panels. import argparse import logging +import threading from textual.app import App, ComposeResult from textual.binding import Binding @@ -52,6 +53,7 @@ class BirdcageApp(App): firmware_name: str = "g2" skip_init: bool = False device: object = None + shutdown_event: threading.Event = threading.Event() @property def SUB_TITLE(self) -> str: # noqa: N802 @@ -130,13 +132,8 @@ class BirdcageApp(App): screen.on_show() def on_unmount(self) -> None: - """Stop all polling threads and disconnect the device on shutdown.""" - for mode_key in MODES: - screen = self.query_one(f"#{mode_key}") - if hasattr(screen, "_polling"): - screen._polling = False - if hasattr(screen, "_monitoring"): - screen._monitoring = False + """Signal all worker threads to exit and disconnect the device.""" + self.shutdown_event.set() if self.device and hasattr(self.device, "disconnect"): self.device.disconnect() @@ -173,4 +170,11 @@ def main() -> None: app.serial_port = args.port app.firmware_name = args.firmware app.skip_init = args.skip_init - app.run() + try: + app.run() + except KeyboardInterrupt: + pass + finally: + app.shutdown_event.set() + if app.device and hasattr(app.device, "disconnect"): + app.device.disconnect() diff --git a/tui/src/birdcage_tui/screens/position.py b/tui/src/birdcage_tui/screens/position.py index 388b3c6..ca197b8 100644 --- a/tui/src/birdcage_tui/screens/position.py +++ b/tui/src/birdcage_tui/screens/position.py @@ -6,7 +6,6 @@ and AZ/EL sparklines. Bottom row provides manual move controls. """ import logging -import time from textual import work from textual.app import ComposeResult @@ -98,9 +97,10 @@ class PositionScreen(Container): @work(thread=True, exclusive=True, group="position-poll") def _do_position_poll(self) -> None: """Poll device at ~2 Hz for position and step data.""" - while self._polling: + shutdown = self.app.shutdown_event + while self._polling and not shutdown.is_set(): if self._device is None: - time.sleep(0.5) + shutdown.wait(0.5) continue try: @@ -135,7 +135,7 @@ class PositionScreen(Container): except Exception: log.debug("Torque poll failed", exc_info=True) - time.sleep(0.5) + shutdown.wait(0.5) # ------------------------------------------------------------------ # Thread-safe widget update callbacks diff --git a/tui/src/birdcage_tui/screens/scan.py b/tui/src/birdcage_tui/screens/scan.py index a194196..7be6fab 100644 --- a/tui/src/birdcage_tui/screens/scan.py +++ b/tui/src/birdcage_tui/screens/scan.py @@ -7,7 +7,6 @@ raw (az, el, rssi) data for offline analysis. import csv import logging -import time from pathlib import Path from textual import work @@ -98,6 +97,7 @@ class ScanScreen(Container): def _do_scan(self) -> None: """Execute the AZ/EL grid scan in a background thread.""" worker = get_current_worker() + shutdown = self.app.shutdown_event device = self._device if device is None: return @@ -146,7 +146,7 @@ class ScanScreen(Container): for _el_idx, el_val in enumerate(el_values): for _az_idx, az_val in enumerate(az_values): - if not self._scanning or worker.is_cancelled: + if not self._scanning or worker.is_cancelled or shutdown.is_set(): self.app.call_from_thread(self._set_status, "Scan stopped") return @@ -160,7 +160,7 @@ class ScanScreen(Container): continue # Settle time -- let the motor stop and vibrations damp. - time.sleep(0.3) + shutdown.wait(0.3) # Read signal. try: diff --git a/tui/src/birdcage_tui/screens/signal.py b/tui/src/birdcage_tui/screens/signal.py index 9b7c7e9..9a5ac27 100644 --- a/tui/src/birdcage_tui/screens/signal.py +++ b/tui/src/birdcage_tui/screens/signal.py @@ -7,7 +7,6 @@ sparklines for DVB and ADC RSSI, peak tracking, and LNA toggle. import logging import re -import time from textual import work from textual.app import ComposeResult @@ -82,9 +81,10 @@ class SignalScreen(Container): @work(thread=True, exclusive=True, group="signal-poll") def _do_signal_poll(self) -> None: """Poll RSSI at the configured rate while monitoring is active.""" - while self._monitoring: + shutdown = self.app.shutdown_event + while self._monitoring and not shutdown.is_set(): if self._device is None: - time.sleep(0.5) + shutdown.wait(0.5) continue # Read config from inputs (safe defaults on parse failure). @@ -141,7 +141,7 @@ class SignalScreen(Container): except Exception: log.debug("Lock status poll failed", exc_info=True) - time.sleep(1.0 / rate) + shutdown.wait(1.0 / rate) # ------------------------------------------------------------------ # Thread-safe widget update callbacks