Apply .gitattributes normalization to convert all CRLF line endings inherited from Windows-origin source files to Unix LF. 175 files, zero content changes.
206 lines
6.9 KiB
Python
206 lines
6.9 KiB
Python
"""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 Container, Horizontal, Vertical
|
||
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(Container):
|
||
"""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_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 == "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
|
||
|
||
# Validate inputs
|
||
try:
|
||
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(float(self.query_one("#spec-dwell", Input).value or "10"))
|
||
lnb_lo = float(self.query_one("#spec-lnb", Input).value or "0")
|
||
except ValueError:
|
||
self._update_status("Invalid input — check numeric fields")
|
||
return
|
||
|
||
if not (950 <= start <= 2150) or not (950 <= stop <= 2150):
|
||
self._update_status("Frequency out of range (950–2150 MHz)")
|
||
return
|
||
if start >= stop:
|
||
self._update_status("Start must be less than Stop")
|
||
return
|
||
if not (0.1 <= step <= 500):
|
||
self._update_status("Step out of range (0.1–500 MHz)")
|
||
return
|
||
|
||
continuous = self.query_one("#spec-continuous", Checkbox).value
|
||
self._sweeping = True
|
||
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
|
||
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)
|