Ryan Malloy ba8859cc31 Fix 300s executor shutdown with threading.Event
Replace time.sleep() with threading.Event.wait() in all poll
loops so worker threads exit immediately on shutdown instead of
blocking for up to 500ms per iteration. Fixes the on_unmount
crash (NoMatches from querying removed DOM nodes) by signaling
the event directly rather than iterating child widgets.

Three shutdown paths covered: q key (on_unmount), Ctrl+C
(try/finally in main), and Textual internal shutdown.
2026-02-14 10:21:42 -07:00

273 lines
11 KiB
Python

"""F3 Scan Screen -- AZ sweep heatmap, sky mapping with configurable parameters.
Grid-based sky scan: iterates over AZ/EL range, moves the dish to each point,
reads RSSI, and paints the result into a 2D heatmap. Supports CSV export of
raw (az, el, rssi) data for offline analysis.
"""
import csv
import logging
from pathlib import Path
from textual import work
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Button, Input, ProgressBar, Static
from textual.worker import get_current_worker
from birdcage_tui.widgets.sky_heatmap import SkyHeatmap
from birdcage_tui.widgets.sparkline_widget import SparklineWidget
log = logging.getLogger(__name__)
# Type alias -- SerialBridge and DemoDevice share the same duck-typed interface.
DeviceLike = object
class ScanScreen(Container):
"""F3: Sky scan and RF mapping."""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._device: DeviceLike | None = None
self._scanning = False
self._scan_data: list[tuple[float, float, float]] = []
def compose(self):
with Container(classes="screen-container"):
with Vertical(classes="panel"):
yield Static("Sky Scan", classes="panel-title")
yield SkyHeatmap(az_bins=40, el_bins=10, id="heatmap")
yield SparklineWidget(
max_points=80, label="Sweep RSSI", color="#00d4aa", id="sweep-spark"
)
with Horizontal(classes="scan-status"):
yield Static("Idle", id="scan-status-text")
yield ProgressBar(id="scan-progress", total=100, show_eta=False)
with Horizontal(classes="bottom-controls"):
yield Static("AZ ", classes="label")
yield Input(value="160", id="az-start", type="number")
yield Static("-", classes="label")
yield Input(value="220", id="az-end", type="number")
yield Static(" Step ", classes="label")
yield Input(value="1.5", id="az-step", type="number")
yield Static(" EL ", classes="label")
yield Input(value="18", id="el-start", type="number")
yield Static("-", classes="label")
yield Input(value="65", id="el-end", type="number")
yield Static(" Step ", classes="label")
yield Input(value="5.0", id="el-step", type="number")
with Horizontal(classes="bottom-controls"):
yield Static("Transponders ", classes="label")
yield Input(value="3", id="xponder-input", type="integer")
yield Button("Start Scan", id="btn-start-scan", variant="primary")
yield Button("Stop", id="btn-stop-scan")
yield Button("Export CSV", id="btn-export")
# ------------------------------------------------------------------
# Device wiring
# ------------------------------------------------------------------
def set_device(self, device: DeviceLike) -> None:
"""Store the device reference (SerialBridge or DemoDevice)."""
self._device = device
# ------------------------------------------------------------------
# Input helpers
# ------------------------------------------------------------------
def _read_float(self, widget_id: str, fallback: float) -> float:
"""Read a float from an Input widget, returning *fallback* on parse error."""
try:
return float(self.query_one(f"#{widget_id}", Input).value)
except (ValueError, TypeError):
return fallback
def _read_int(self, widget_id: str, fallback: int) -> int:
"""Read an int from an Input widget, returning *fallback* on parse error."""
try:
return int(self.query_one(f"#{widget_id}", Input).value)
except (ValueError, TypeError):
return fallback
# ------------------------------------------------------------------
# Scan worker
# ------------------------------------------------------------------
@work(thread=True)
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
# Read scan parameters (widget access must happen via call_from_thread
# for Input.value, but Textual Input.value is a reactive that is safe
# to read from threads as a string snapshot).
az_start = self._read_float("az-start", 160.0)
az_end = self._read_float("az-end", 220.0)
az_step = self._read_float("az-step", 1.5)
el_start = self._read_float("el-start", 18.0)
el_end = self._read_float("el-end", 65.0)
el_step = self._read_float("el-step", 5.0)
iterations = self._read_int("xponder-input", 3)
# Clamp step sizes to something sane.
if az_step <= 0:
az_step = 1.0
if el_step <= 0:
el_step = 1.0
# Build the grid point list.
el_values: list[float] = []
el = el_start
while el <= el_end + 1e-9:
el_values.append(round(el, 2))
el += el_step
az_values: list[float] = []
az = az_start
while az <= az_end + 1e-9:
az_values.append(round(az, 2))
az += az_step
total_points = len(el_values) * len(az_values)
if total_points == 0:
self.app.call_from_thread(
self._set_status, "No grid points -- check parameters"
)
return
heatmap = self.query_one("#heatmap", SkyHeatmap)
spark = self.query_one("#sweep-spark", SparklineWidget)
done = 0
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 or shutdown.is_set():
self.app.call_from_thread(self._set_status, "Scan stopped")
return
# Move dish.
try:
device.move_to(az_val, el_val)
except Exception:
log.exception("move_to failed at AZ=%.2f EL=%.2f", az_val, el_val)
msg = f"Move error at AZ={az_val:.1f} EL={el_val:.1f}"
self.app.call_from_thread(self._set_status, msg)
continue
# Settle time -- let the motor stop and vibrations damp.
shutdown.wait(0.3)
# Read signal.
try:
rssi_data = device.get_rssi(iterations)
rssi = float(rssi_data.get("average", 0))
except Exception:
log.exception("get_rssi failed at AZ=%.2f EL=%.2f", az_val, el_val)
rssi = 0.0
# Record raw data.
self._scan_data.append((az_val, el_val, rssi))
# Map to heatmap grid indices -- fit into fixed-size bins.
az_span = az_end - az_start + 1e-9
el_span = el_end - el_start + 1e-9
grid_az = min(
int((az_val - az_start) / az_span * heatmap.az_bins),
heatmap.az_bins - 1,
)
grid_el = min(
int((el_val - el_start) / el_span * heatmap.el_bins),
heatmap.el_bins - 1,
)
# Update widgets.
self.app.call_from_thread(heatmap.set_point, grid_az, grid_el, rssi)
self.app.call_from_thread(heatmap.set_active, grid_az, grid_el)
self.app.call_from_thread(spark.push, rssi)
done += 1
pct = int(done * 100 / total_points)
status_text = (
f"Scanning AZ={az_val:.1f} EL={el_val:.1f} "
f"RSSI={rssi:.0f} [{done}/{total_points}]"
)
self.app.call_from_thread(self._set_progress, pct, status_text)
msg = f"Scan complete -- {total_points} points"
self.app.call_from_thread(self._set_status, msg)
# ------------------------------------------------------------------
# Widget update helpers (called via call_from_thread)
# ------------------------------------------------------------------
def _set_status(self, text: str) -> None:
"""Update the scan status text label."""
self.query_one("#scan-status-text", Static).update(text)
def _set_progress(self, pct: int, status_text: str) -> None:
"""Update both progress bar and status text."""
self.query_one("#scan-progress", ProgressBar).update(progress=pct)
self.query_one("#scan-status-text", Static).update(status_text)
# ------------------------------------------------------------------
# Button handlers
# ------------------------------------------------------------------
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id or ""
if button_id == "btn-start-scan":
self._start_scan()
elif button_id == "btn-stop-scan":
self._stop_scan()
elif button_id == "btn-export":
self._export_csv()
def _start_scan(self) -> None:
"""Clear state and kick off the scan worker."""
if self._device is None:
self.app.notify("No device connected", severity="warning")
return
if self._scanning:
self.app.notify("Scan already in progress", severity="warning")
return
# Reset.
heatmap = self.query_one("#heatmap", SkyHeatmap)
heatmap.clear()
self._scan_data.clear()
self.query_one("#scan-progress", ProgressBar).update(progress=0)
self._set_status("Starting scan...")
self._scanning = True
self._do_scan()
def _stop_scan(self) -> None:
"""Signal the scan worker to stop."""
self._scanning = False
self._set_status("Stopping...")
def _export_csv(self) -> None:
"""Write scan data to /tmp/birdcage_scan.csv."""
if not self._scan_data:
self.app.notify("No scan data to export", severity="warning")
return
output = Path("/tmp/birdcage_scan.csv")
try:
with output.open("w", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(["az", "el", "rssi"])
for az, el, rssi in self._scan_data:
writer.writerow([f"{az:.2f}", f"{el:.2f}", f"{rssi:.1f}"])
self.app.notify(f"Exported {len(self._scan_data)} points to {output}")
except OSError as exc:
log.exception("CSV export failed")
self.app.notify(f"Export failed: {exc}", severity="error")