Ryan Malloy 8da486719a Fix mode panels: Screen → Container with on_show/on_hide lifecycle
Textual's ContentSwitcher expects regular Widget/Container children,
not Screen subclasses. Screen's on_mount fires for all children at
once regardless of visibility, so demo workers for all 5 modes
started simultaneously and competed for the bridge.

Container children get proper on_show/on_hide from ContentSwitcher's
visibility toggling — only the active panel's worker runs.
2026-02-13 04:42:59 -07:00

221 lines
7.7 KiB
Python

"""L-Band screen — direct input spectrum analyzer with allocation annotations.
Same sweep mechanics as the spectrum screen, but with LNB disabled (direct input)
and band allocation overlays showing what service each frequency range belongs to.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, ProgressBar, Checkbox
from textual import work
from textual.worker import Worker
from skywalker_lib import LBAND_ALLOCATIONS
from skywalker_tui.widgets.spectrum_plot import SpectrumPlot
from skywalker_tui.widgets.waterfall import WaterfallDisplay
def _alloc_table(start: float, stop: float) -> str:
"""Build a Rich-markup allocation reference for the visible range."""
lines = ["[#00d4aa bold]L-Band Allocations in range:[/]"]
colors = ["#60a0c0", "#80b060", "#c0a050", "#a06080", "#50a0a0", "#a08060", "#6080a0"]
for i, (lo, hi, name) in enumerate(LBAND_ALLOCATIONS):
if lo < stop and hi > start:
overlap_lo = max(lo, start)
overlap_hi = min(hi, stop)
c = colors[i % len(colors)]
lines.append(f" [{c}]{overlap_lo:.0f}-{overlap_hi:.0f} MHz {name}[/]")
if len(lines) == 1:
lines.append(" [#506878](none in range)[/]")
return "\n".join(lines)
class LBandScreen(Container):
"""L-band direct input analyzer with allocation annotations."""
DEFAULT_CSS = """
LBandScreen {
layout: vertical;
}
LBandScreen #lband-main {
height: 1fr;
layout: horizontal;
}
LBandScreen #lband-plot-col {
width: 2fr;
layout: vertical;
}
LBandScreen #lband-info-col {
width: 1fr;
padding: 1;
background: #0e1420;
border-left: solid #1a2a3a;
layout: vertical;
}
LBandScreen #lband-alloc-panel {
height: auto;
padding: 1;
}
LBandScreen #lband-progress {
height: 3;
layout: horizontal;
padding: 0 2;
background: #0e1018;
}
LBandScreen #lband-progress Static {
width: auto;
margin: 1 1 0 0;
}
LBandScreen #lband-progress ProgressBar {
width: 1fr;
margin: 1 1 0 0;
}
LBandScreen #lband-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
LBandScreen #lband-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
LBandScreen #lband-controls Input {
width: 10;
margin: 0 1;
}
LBandScreen #lband-controls Button {
margin: 0 1;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._sweeping = False
self._sweep_worker: Worker | None = None
def compose(self) -> ComposeResult:
with Horizontal(id="lband-main"):
with Vertical(id="lband-plot-col"):
yield SpectrumPlot(title="L-Band Spectrum (Direct Input)",
id="lband-plot")
yield WaterfallDisplay(title="Waterfall", id="lband-waterfall")
with Vertical(id="lband-info-col"):
yield Static(_alloc_table(950, 2150), id="lband-alloc-panel")
with Horizontal(id="lband-progress"):
yield Static("[#506878]Ready[/]", id="lband-status")
yield ProgressBar(total=100, show_eta=False, id="lband-pbar")
with Horizontal(id="lband-controls"):
yield Label("Start:")
yield Input("950", id="lband-start")
yield Label("Stop:")
yield Input("2150", id="lband-stop")
yield Label("Step:")
yield Input("2", id="lband-step")
yield Label("Dwell:")
yield Input("20", id="lband-dwell")
yield Button("23cm", id="lband-23cm-btn")
yield Button("Sweep", id="lband-sweep-btn", variant="success")
yield Button("Stop", id="lband-stop-btn", variant="error")
def on_show(self) -> None:
if self._bridge.is_demo and not self._sweeping:
self._start_sweep()
def on_hide(self) -> None:
self._stop_sweep()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "lband-sweep-btn":
self._start_sweep()
elif event.button.id == "lband-stop-btn":
self._stop_sweep()
elif event.button.id == "lband-23cm-btn":
self.query_one("#lband-start", Input).value = "1240"
self.query_one("#lband-stop", Input).value = "1300"
self.query_one("#lband-step", Input).value = "0.5"
# Update allocation display
self.query_one("#lband-alloc-panel", Static).update(
_alloc_table(1240, 1300)
)
def _start_sweep(self) -> None:
if self._sweeping:
return
self._sweeping = True
start = float(self.query_one("#lband-start", Input).value or "950")
stop = float(self.query_one("#lband-stop", Input).value or "2150")
step = float(self.query_one("#lband-step", Input).value or "2")
dwell = int(self.query_one("#lband-dwell", Input).value or "20")
# Update allocation panel for current range
self.query_one("#lband-alloc-panel", Static).update(
_alloc_table(start, stop)
)
self._sweep_worker = self._do_sweep(start, stop, step, dwell)
def _stop_sweep(self) -> None:
self._sweeping = False
if self._sweep_worker:
self._sweep_worker.cancel()
self._sweep_worker = None
@work(thread=True)
def _do_sweep(self, start: float, stop: float, step: float, dwell: int) -> None:
"""L-band sweep with LNB disabled."""
try:
self._bridge.ensure_booted()
# Disable LNB for direct input
self._bridge.configure_lnb(disable_lnb=True)
except Exception:
pass
def progress_cb(freq, step_num, total, result):
pct = (step_num + 1) / total * 100
self.app.call_from_thread(self._update_progress, pct, freq)
self.app.call_from_thread(self._set_status, "Sweeping...")
freqs, powers, results = self._bridge.sweep_spectrum(
start, stop, step, dwell, sr_ksps=20000, callback=progress_cb,
)
self.app.call_from_thread(self._show_results, freqs, powers, results)
self._sweeping = False
def _update_progress(self, pct: float, freq: float) -> None:
if not self.is_mounted:
return
self.query_one("#lband-pbar", ProgressBar).update(progress=pct)
self.query_one("#lband-status", Static).update(
f"[#00d4aa]{freq:.1f} MHz[/]"
)
def _set_status(self, msg: str) -> None:
if not self.is_mounted:
return
self.query_one("#lband-status", Static).update(f"[#506878]{msg}[/]")
def _show_results(self, freqs, powers, results) -> None:
if not self.is_mounted:
return
self.query_one("#lband-plot", SpectrumPlot).update_data(
freqs, powers, results, lnb_lo=0.0,
)
self.query_one("#lband-waterfall", WaterfallDisplay).add_sweep(powers)
self.query_one("#lband-status", Static).update("[#506878]Complete[/]")
self.query_one("#lband-pbar", ProgressBar).update(progress=100)