Add Textual TUI for SkyWalker-1 RF tool

Separate entry point (skywalker-tui) that reuses skywalker_lib.py
unchanged. Five RF modes: spectrum, scan, monitor, lband, track —
each with threaded USB bridge workers for non-blocking I/O.

Includes --demo mode with synthetic signal generation (Gaussian
peaks, noise floor, AGC simulation) for development without hardware.

Custom widgets: spectrum bar chart, rolling waterfall, signal gauge,
sparkline history, transponder table, device status bar.
This commit is contained in:
Ryan Malloy 2026-02-13 04:39:55 -07:00
parent c4bfe33d61
commit 64c33985a3
21 changed files with 2762 additions and 0 deletions

View file

@ -0,0 +1,193 @@
"""Spectrum screen — sweep analyzer across the IF range.
Displays a bar chart of power vs. frequency, optionally with a rolling
waterfall beneath it. Uses threaded workers for the blocking USB sweep.
"""
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import Screen
from textual.widgets import Label, Input, Button, Static, ProgressBar, Checkbox
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.spectrum_plot import SpectrumPlot
from skywalker_tui.widgets.waterfall import WaterfallDisplay
class SpectrumScreen(Screen):
"""Spectrum analyzer with bar chart and optional waterfall."""
DEFAULT_CSS = """
SpectrumScreen {
layout: vertical;
}
SpectrumScreen #spec-main {
height: 1fr;
layout: vertical;
}
SpectrumScreen #spec-progress-row {
height: 3;
layout: horizontal;
padding: 0 2;
background: #0e1018;
}
SpectrumScreen #spec-progress-row Static {
width: auto;
margin: 1 1 0 0;
}
SpectrumScreen #spec-progress-row ProgressBar {
width: 1fr;
margin: 1 1 0 0;
}
SpectrumScreen #spec-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
SpectrumScreen #spec-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
SpectrumScreen #spec-controls Input {
width: 10;
margin: 0 1;
}
SpectrumScreen #spec-controls Button {
margin: 0 1;
}
SpectrumScreen #spec-controls Checkbox {
margin: 1 1 0 0;
}
"""
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 Vertical(id="spec-main"):
yield SpectrumPlot(title="Spectrum Analyzer", id="spec-plot")
yield WaterfallDisplay(title="Waterfall", id="spec-waterfall")
with Horizontal(id="spec-progress-row"):
yield Static("[#506878]Ready[/]", id="spec-status")
yield ProgressBar(total=100, show_eta=False, id="spec-pbar")
with Horizontal(id="spec-controls"):
yield Label("Start:")
yield Input("950", id="spec-start")
yield Label("Stop:")
yield Input("2150", id="spec-stop")
yield Label("Step:")
yield Input("5", id="spec-step")
yield Label("Dwell:")
yield Input("10", id="spec-dwell")
yield Label("LNB LO:")
yield Input("0", id="spec-lnb")
yield Checkbox("Continuous", id="spec-continuous")
yield Button("Sweep", id="spec-sweep-btn", variant="success")
yield Button("Stop", id="spec-stop-btn", variant="error")
def on_mount(self) -> None:
if self._bridge.is_demo:
self._start_sweep()
def on_unmount(self) -> None:
self._stop_sweep()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "spec-sweep-btn":
self._start_sweep()
elif event.button.id == "spec-stop-btn":
self._stop_sweep()
def _start_sweep(self) -> None:
if self._sweeping:
return
self._sweeping = True
start = float(self.query_one("#spec-start", Input).value or "950")
stop = float(self.query_one("#spec-stop", Input).value or "2150")
step = float(self.query_one("#spec-step", Input).value or "5")
dwell = int(self.query_one("#spec-dwell", Input).value or "10")
lnb_lo = float(self.query_one("#spec-lnb", Input).value or "0")
continuous = self.query_one("#spec-continuous", Checkbox).value
self._sweep_worker = self._do_sweep(start, stop, step, dwell, lnb_lo, continuous)
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, lnb_lo: float, continuous: bool) -> None:
"""Background sweep worker."""
import time
try:
self._bridge.ensure_booted()
except Exception:
pass
sweep_num = 0
while self._sweeping:
sweep_num += 1
total_steps = max(1, int((stop - start) / step) + 1)
step_count = [0]
def progress_cb(freq, step_num, total, result):
step_count[0] = step_num + 1
pct = (step_num + 1) / total * 100
self.app.call_from_thread(self._update_progress, pct, freq, sweep_num)
self.app.call_from_thread(self._update_status, f"Sweeping #{sweep_num}...")
freqs, powers, results = self._bridge.sweep_spectrum(
start, stop, step, dwell, sr_ksps=20000, callback=progress_cb,
)
self.app.call_from_thread(self._update_plot, freqs, powers, results, lnb_lo)
self.app.call_from_thread(self._update_waterfall, powers)
if not continuous:
break
time.sleep(0.1)
self._sweeping = False
self.app.call_from_thread(self._update_status, "Complete")
def _update_progress(self, pct: float, freq: float, sweep_num: int) -> None:
if not self.is_mounted:
return
self.query_one("#spec-pbar", ProgressBar).update(progress=pct)
self.query_one("#spec-status", Static).update(
f"[#00d4aa]Sweep #{sweep_num}[/] [#506878]{freq:.0f} MHz[/]"
)
def _update_status(self, msg: str) -> None:
if not self.is_mounted:
return
self.query_one("#spec-status", Static).update(f"[#506878]{msg}[/]")
self.query_one("#spec-pbar", ProgressBar).update(progress=0)
def _update_plot(self, freqs, powers, results, lnb_lo) -> None:
if not self.is_mounted:
return
self.query_one("#spec-plot", SpectrumPlot).update_data(
freqs, powers, results, lnb_lo=lnb_lo,
)
def _update_waterfall(self, powers) -> None:
if not self.is_mounted:
return
self.query_one("#spec-waterfall", WaterfallDisplay).add_sweep(powers)