Normalize line endings to LF across entire repository

Apply .gitattributes normalization to convert all CRLF line
endings inherited from Windows-origin source files to Unix LF.
175 files, zero content changes.
This commit is contained in:
Ryan Malloy 2026-02-20 10:55:50 -07:00
parent 696d2dd387
commit bbdcb243dc
175 changed files with 56794 additions and 56794 deletions

View file

@ -1,12 +1,12 @@
"""Textual TUI for Genpix SkyWalker-1 DVB-S receiver."""
import sys
from pathlib import Path
__version__ = "0.1.0"
# Ensure skywalker_lib (in sibling tools/ directory) is importable.
# Consolidated here so app.py and status_bar.py don't duplicate this.
_tools_dir = Path(__file__).resolve().parent.parent.parent.parent / "tools"
if _tools_dir.is_dir() and str(_tools_dir) not in sys.path:
sys.path.insert(0, str(_tools_dir))
"""Textual TUI for Genpix SkyWalker-1 DVB-S receiver."""
import sys
from pathlib import Path
__version__ = "0.1.0"
# Ensure skywalker_lib (in sibling tools/ directory) is importable.
# Consolidated here so app.py and status_bar.py don't duplicate this.
_tools_dir = Path(__file__).resolve().parent.parent.parent.parent / "tools"
if _tools_dir.is_dir() and str(_tools_dir) not in sys.path:
sys.path.insert(0, str(_tools_dir))

View file

@ -1,222 +1,222 @@
"""SkyWalker-1 TUI — main application.
Provides mode switching between 8 operating modes via a sidebar and F-key
shortcuts. Each mode is a Container subclass that manages its own workers.
Note: We use "rf_mode" terminology for our operating modes to avoid colliding
with Textual's built-in App.mode / _current_mode / _screen_stacks system.
"""
import argparse
import sys
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.widgets import Header, Footer, Button, Label, Static, ContentSwitcher
from skywalker_tui.bridge import USBBridge
from skywalker_tui.demo import DemoDevice
from skywalker_tui.widgets.status_bar import DeviceStatusBar
from skywalker_tui.screens.spectrum import SpectrumScreen
from skywalker_tui.screens.scan import ScanScreen
from skywalker_tui.screens.monitor import MonitorScreen
from skywalker_tui.screens.lband import LBandScreen
from skywalker_tui.screens.track import TrackScreen
from skywalker_tui.screens.device import DeviceScreen
from skywalker_tui.screens.stream import StreamScreen
from skywalker_tui.screens.config import ConfigScreen
from skywalker_tui.screens.motor import MotorScreen
from skywalker_tui.screens.survey import SurveyScreen
MODES = {
"spectrum": ("F1 Spectrum", SpectrumScreen),
"scan": ("F2 Scan", ScanScreen),
"monitor": ("F3 Monitor", MonitorScreen),
"lband": ("F4 L-Band", LBandScreen),
"track": ("F5 Track", TrackScreen),
"device": ("F6 Device", DeviceScreen),
"stream": ("F7 Stream", StreamScreen),
"config": ("F8 Config", ConfigScreen),
"motor": ("F9 Motor", MotorScreen),
"survey": ("F10 Survey", SurveyScreen),
}
class SkyWalkerApp(App):
"""Textual TUI for Genpix SkyWalker-1 DVB-S receiver."""
TITLE = "SkyWalker-1"
SUB_TITLE = "DVB-S RF Tool"
CSS_PATH = "theme.tcss"
BINDINGS = [
Binding("f1", "rf_mode('spectrum')", "Spectrum", show=True),
Binding("f2", "rf_mode('scan')", "Scan", show=True),
Binding("f3", "rf_mode('monitor')", "Monitor", show=True),
Binding("f4", "rf_mode('lband')", "L-Band", show=True),
Binding("f5", "rf_mode('track')", "Track", show=True),
Binding("f6", "rf_mode('device')", "Device", show=True),
Binding("f7", "rf_mode('stream')", "Stream", show=True),
Binding("f8", "rf_mode('config')", "Config", show=True),
Binding("f9", "rf_mode('motor')", "Motor", show=True),
Binding("f10", "rf_mode('survey')", "Survey", show=True),
Binding("q", "quit", "Quit", show=True),
Binding("d", "toggle_dark", "Theme", show=True),
Binding("ctrl+w", "starwars", "Star Wars", show=False),
]
def __init__(self, bridge: USBBridge, initial_mode: str = "spectrum",
show_splash: bool = True):
super().__init__()
self._bridge = bridge
self._initial_rf_mode = initial_mode
self._active_rf_mode = initial_mode
self._rf_screens: dict[str, object] = {}
self._show_splash = show_splash
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
with Vertical(id="sidebar"):
yield Label("[bold #00d4aa]SkyWalker-1[/]", classes="sidebar-heading")
yield Label("[#506878]DVB-S RF Tool[/]", classes="sidebar-heading")
yield Static("")
for mode_key, (label, _cls) in MODES.items():
yield Button(label, id=f"btn-{mode_key}", classes="mode-button")
yield Static("")
yield DeviceStatusBar(self._bridge)
yield ContentSwitcher(id="content-area")
yield Footer()
def on_mount(self) -> None:
# Initialize status bar (lightweight)
status = self.query_one(DeviceStatusBar)
status.update_status(self._bridge)
if self._show_splash:
# Push splash FIRST, then init mode screens behind it.
# Two-tick chain: tick 1 = splash renders, tick 2 = heavy work.
self.call_later(self._push_splash)
else:
self.call_later(self._init_mode_screens)
def _push_splash(self) -> None:
"""Push splash screen, then defer heavy mode screen init."""
from skywalker_tui.screens.splash import SplashScreen
try:
self.push_screen(SplashScreen())
except Exception:
pass
# Mode screens mount behind the splash overlay — pre-baked ANSI art
# renders instantly so no delay needed before heavy work starts
self.call_later(self._init_mode_screens)
def _init_mode_screens(self) -> None:
"""Mount all 8 mode screens into the content switcher."""
switcher = self.query_one("#content-area", ContentSwitcher)
for mode_key, (_label, cls) in MODES.items():
screen = cls(self._bridge, id=f"screen-{mode_key}")
self._rf_screens[mode_key] = screen
switcher.mount(screen)
self.action_rf_mode(self._initial_rf_mode)
def action_rf_mode(self, mode: str) -> None:
"""Switch to a different RF operating mode."""
if mode not in MODES:
return
self._active_rf_mode = mode
switcher = self.query_one("#content-area", ContentSwitcher)
switcher.current = f"screen-{mode}"
# Update sidebar button highlights
for mode_key in MODES:
btn = self.query_one(f"#btn-{mode_key}", Button)
btn.remove_class("-active")
self.query_one(f"#btn-{mode}", Button).add_class("-active")
self.sub_title = f"DVB-S RF Tool \u2014 {MODES[mode][0]}"
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle sidebar mode button clicks."""
btn_id = event.button.id or ""
if btn_id.startswith("btn-"):
mode = btn_id[4:]
if mode in MODES:
self.action_rf_mode(mode)
def action_toggle_dark(self) -> None:
self.theme = (
"textual-dark" if self.theme == "textual-light" else "textual-light"
)
if self.current_theme.dark:
self.notify(
"Welcome to the Dark Side.",
title="The Force is strong with this one",
severity="warning",
timeout=4,
)
else:
self.notify(
"The Force awakens.",
title="A New Hope",
severity="information",
timeout=4,
)
def action_starwars(self) -> None:
"""Easter egg: stream ASCII Star Wars from telnet."""
from skywalker_tui.screens.starwars import StarWarsScreen
self.push_screen(StarWarsScreen())
def main():
parser = argparse.ArgumentParser(
prog="skywalker-tui",
description="Textual TUI for Genpix SkyWalker-1 DVB-S receiver",
)
parser.add_argument(
"--demo", action="store_true",
help="Use synthetic signal data (no hardware required)",
)
parser.add_argument(
"--no-splash", action="store_true",
help="Skip the splash screen on startup",
)
parser.add_argument(
"mode", nargs="?", default="spectrum",
choices=list(MODES.keys()),
help="Initial mode (default: spectrum)",
)
parser.add_argument(
"-v", "--verbose", action="store_true",
help="Verbose USB logging (hardware mode only)",
)
args = parser.parse_args()
if args.demo:
device = DemoDevice()
bridge = USBBridge(device)
else:
try:
from skywalker_lib import SkyWalker1
device = SkyWalker1(verbose=args.verbose)
device.open()
bridge = USBBridge(device)
except Exception as e:
print(f"Cannot open SkyWalker-1: {e}", file=sys.stderr)
print("Use --demo for synthetic signal data.", file=sys.stderr)
sys.exit(1)
app = SkyWalkerApp(
bridge=bridge,
initial_mode=args.mode,
show_splash=not args.no_splash,
)
try:
app.run()
finally:
bridge.close()
"""SkyWalker-1 TUI — main application.
Provides mode switching between 8 operating modes via a sidebar and F-key
shortcuts. Each mode is a Container subclass that manages its own workers.
Note: We use "rf_mode" terminology for our operating modes to avoid colliding
with Textual's built-in App.mode / _current_mode / _screen_stacks system.
"""
import argparse
import sys
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.widgets import Header, Footer, Button, Label, Static, ContentSwitcher
from skywalker_tui.bridge import USBBridge
from skywalker_tui.demo import DemoDevice
from skywalker_tui.widgets.status_bar import DeviceStatusBar
from skywalker_tui.screens.spectrum import SpectrumScreen
from skywalker_tui.screens.scan import ScanScreen
from skywalker_tui.screens.monitor import MonitorScreen
from skywalker_tui.screens.lband import LBandScreen
from skywalker_tui.screens.track import TrackScreen
from skywalker_tui.screens.device import DeviceScreen
from skywalker_tui.screens.stream import StreamScreen
from skywalker_tui.screens.config import ConfigScreen
from skywalker_tui.screens.motor import MotorScreen
from skywalker_tui.screens.survey import SurveyScreen
MODES = {
"spectrum": ("F1 Spectrum", SpectrumScreen),
"scan": ("F2 Scan", ScanScreen),
"monitor": ("F3 Monitor", MonitorScreen),
"lband": ("F4 L-Band", LBandScreen),
"track": ("F5 Track", TrackScreen),
"device": ("F6 Device", DeviceScreen),
"stream": ("F7 Stream", StreamScreen),
"config": ("F8 Config", ConfigScreen),
"motor": ("F9 Motor", MotorScreen),
"survey": ("F10 Survey", SurveyScreen),
}
class SkyWalkerApp(App):
"""Textual TUI for Genpix SkyWalker-1 DVB-S receiver."""
TITLE = "SkyWalker-1"
SUB_TITLE = "DVB-S RF Tool"
CSS_PATH = "theme.tcss"
BINDINGS = [
Binding("f1", "rf_mode('spectrum')", "Spectrum", show=True),
Binding("f2", "rf_mode('scan')", "Scan", show=True),
Binding("f3", "rf_mode('monitor')", "Monitor", show=True),
Binding("f4", "rf_mode('lband')", "L-Band", show=True),
Binding("f5", "rf_mode('track')", "Track", show=True),
Binding("f6", "rf_mode('device')", "Device", show=True),
Binding("f7", "rf_mode('stream')", "Stream", show=True),
Binding("f8", "rf_mode('config')", "Config", show=True),
Binding("f9", "rf_mode('motor')", "Motor", show=True),
Binding("f10", "rf_mode('survey')", "Survey", show=True),
Binding("q", "quit", "Quit", show=True),
Binding("d", "toggle_dark", "Theme", show=True),
Binding("ctrl+w", "starwars", "Star Wars", show=False),
]
def __init__(self, bridge: USBBridge, initial_mode: str = "spectrum",
show_splash: bool = True):
super().__init__()
self._bridge = bridge
self._initial_rf_mode = initial_mode
self._active_rf_mode = initial_mode
self._rf_screens: dict[str, object] = {}
self._show_splash = show_splash
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
with Vertical(id="sidebar"):
yield Label("[bold #00d4aa]SkyWalker-1[/]", classes="sidebar-heading")
yield Label("[#506878]DVB-S RF Tool[/]", classes="sidebar-heading")
yield Static("")
for mode_key, (label, _cls) in MODES.items():
yield Button(label, id=f"btn-{mode_key}", classes="mode-button")
yield Static("")
yield DeviceStatusBar(self._bridge)
yield ContentSwitcher(id="content-area")
yield Footer()
def on_mount(self) -> None:
# Initialize status bar (lightweight)
status = self.query_one(DeviceStatusBar)
status.update_status(self._bridge)
if self._show_splash:
# Push splash FIRST, then init mode screens behind it.
# Two-tick chain: tick 1 = splash renders, tick 2 = heavy work.
self.call_later(self._push_splash)
else:
self.call_later(self._init_mode_screens)
def _push_splash(self) -> None:
"""Push splash screen, then defer heavy mode screen init."""
from skywalker_tui.screens.splash import SplashScreen
try:
self.push_screen(SplashScreen())
except Exception:
pass
# Mode screens mount behind the splash overlay — pre-baked ANSI art
# renders instantly so no delay needed before heavy work starts
self.call_later(self._init_mode_screens)
def _init_mode_screens(self) -> None:
"""Mount all 8 mode screens into the content switcher."""
switcher = self.query_one("#content-area", ContentSwitcher)
for mode_key, (_label, cls) in MODES.items():
screen = cls(self._bridge, id=f"screen-{mode_key}")
self._rf_screens[mode_key] = screen
switcher.mount(screen)
self.action_rf_mode(self._initial_rf_mode)
def action_rf_mode(self, mode: str) -> None:
"""Switch to a different RF operating mode."""
if mode not in MODES:
return
self._active_rf_mode = mode
switcher = self.query_one("#content-area", ContentSwitcher)
switcher.current = f"screen-{mode}"
# Update sidebar button highlights
for mode_key in MODES:
btn = self.query_one(f"#btn-{mode_key}", Button)
btn.remove_class("-active")
self.query_one(f"#btn-{mode}", Button).add_class("-active")
self.sub_title = f"DVB-S RF Tool \u2014 {MODES[mode][0]}"
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle sidebar mode button clicks."""
btn_id = event.button.id or ""
if btn_id.startswith("btn-"):
mode = btn_id[4:]
if mode in MODES:
self.action_rf_mode(mode)
def action_toggle_dark(self) -> None:
self.theme = (
"textual-dark" if self.theme == "textual-light" else "textual-light"
)
if self.current_theme.dark:
self.notify(
"Welcome to the Dark Side.",
title="The Force is strong with this one",
severity="warning",
timeout=4,
)
else:
self.notify(
"The Force awakens.",
title="A New Hope",
severity="information",
timeout=4,
)
def action_starwars(self) -> None:
"""Easter egg: stream ASCII Star Wars from telnet."""
from skywalker_tui.screens.starwars import StarWarsScreen
self.push_screen(StarWarsScreen())
def main():
parser = argparse.ArgumentParser(
prog="skywalker-tui",
description="Textual TUI for Genpix SkyWalker-1 DVB-S receiver",
)
parser.add_argument(
"--demo", action="store_true",
help="Use synthetic signal data (no hardware required)",
)
parser.add_argument(
"--no-splash", action="store_true",
help="Skip the splash screen on startup",
)
parser.add_argument(
"mode", nargs="?", default="spectrum",
choices=list(MODES.keys()),
help="Initial mode (default: spectrum)",
)
parser.add_argument(
"-v", "--verbose", action="store_true",
help="Verbose USB logging (hardware mode only)",
)
args = parser.parse_args()
if args.demo:
device = DemoDevice()
bridge = USBBridge(device)
else:
try:
from skywalker_lib import SkyWalker1
device = SkyWalker1(verbose=args.verbose)
device.open()
bridge = USBBridge(device)
except Exception as e:
print(f"Cannot open SkyWalker-1: {e}", file=sys.stderr)
print("Use --demo for synthetic signal data.", file=sys.stderr)
sys.exit(1)
app = SkyWalkerApp(
bridge=bridge,
initial_mode=args.mode,
show_splash=not args.no_splash,
)
try:
app.run()
finally:
bridge.close()

View file

@ -1,254 +1,254 @@
"""Thread-safe bridge between Textual async event loop and blocking pyusb calls.
All SkyWalker1 (or DemoDevice) methods are blocking I/O they perform USB control
transfers that can take 2-200ms each. Textual's event loop is asyncio-based, so
calling these directly would freeze the UI.
The USBBridge wraps every device method behind a threading.Lock to prevent concurrent
USB access (the BCM4500 can't handle overlapping control transfers) and exposes them
as plain synchronous methods meant to be called from Textual @work(thread=True) workers.
"""
import threading
class USBBridge:
"""Thread-safe wrapper around SkyWalker1 or DemoDevice."""
def __init__(self, device):
self._dev = device
self._lock = threading.RLock()
@property
def is_demo(self) -> bool:
return hasattr(self._dev, "_demo")
def open(self):
with self._lock:
if hasattr(self._dev, "open"):
self._dev.open()
def close(self):
with self._lock:
if hasattr(self._dev, "close"):
self._dev.close()
def get_fw_version(self) -> dict:
with self._lock:
return self._dev.get_fw_version()
def get_config(self) -> int:
with self._lock:
return self._dev.get_config()
def ensure_booted(self):
with self._lock:
self._dev.ensure_booted()
def signal_monitor(self) -> dict:
with self._lock:
return self._dev.signal_monitor()
def sweep_spectrum(self, start_mhz: float, stop_mhz: float,
step_mhz: float = 5.0, dwell_ms: int = 10,
sr_ksps: int = 20000, mod_index: int = 0,
fec_index: int = 5, callback=None) -> tuple:
with self._lock:
return self._dev.sweep_spectrum(
start_mhz, stop_mhz, step_mhz, dwell_ms,
sr_ksps, mod_index, fec_index, callback,
)
def tune_monitor(self, symbol_rate_sps: int, freq_khz: int,
mod_index: int, fec_index: int,
dwell_ms: int = 10) -> dict:
with self._lock:
return self._dev.tune_monitor(
symbol_rate_sps, freq_khz, mod_index, fec_index, dwell_ms,
)
def tune(self, symbol_rate_sps: int, freq_khz: int,
mod_index: int, fec_index: int):
with self._lock:
self._dev.tune(symbol_rate_sps, freq_khz, mod_index, fec_index)
def set_lnb_voltage(self, high: bool):
with self._lock:
self._dev.set_lnb_voltage(high)
def set_22khz_tone(self, on: bool):
with self._lock:
self._dev.set_22khz_tone(on)
def configure_lnb(self, pol=None, band=None, lnb_lo=None,
disable_lnb=False) -> float:
with self._lock:
return self._dev.configure_lnb(pol, band, lnb_lo, disable_lnb)
def blind_scan(self, freq_khz: int, sr_min: int, sr_max: int,
sr_step: int) -> dict | None:
"""Run blind scan at a single frequency. Returns result dict or None."""
with self._lock:
if hasattr(self._dev, "blind_scan"):
return self._dev.blind_scan(freq_khz, sr_min, sr_max, sr_step)
return None
# -- Device info (extended) --
def get_serial_number(self) -> bytes:
with self._lock:
return self._dev.get_serial_number()
def get_usb_speed(self) -> int:
with self._lock:
return self._dev.get_usb_speed()
def get_vendor_string(self) -> str:
with self._lock:
return self._dev.get_vendor_string()
def get_product_string(self) -> str:
with self._lock:
return self._dev.get_product_string()
# -- FX2 RAM --
def fx2_ram_read(self, addr: int, length: int) -> bytes:
with self._lock:
return self._dev.fx2_ram_read(addr, length)
def fx2_ram_write(self, addr: int, data: bytes) -> int:
with self._lock:
return self._dev.fx2_ram_write(addr, data)
def fx2_cpu_halt(self) -> None:
with self._lock:
self._dev.fx2_cpu_halt()
def fx2_cpu_start(self) -> None:
with self._lock:
self._dev.fx2_cpu_start()
# -- EEPROM --
def eeprom_read(self, offset: int, length: int = 64) -> bytes:
with self._lock:
return self._dev.eeprom_read(offset, length)
def eeprom_write_page(self, offset: int, data: bytes) -> int:
with self._lock:
return self._dev.eeprom_write_page(offset, data)
def eeprom_read_all(self, size: int = 16384) -> bytes:
with self._lock:
return self._dev.eeprom_read_all(size)
# -- Diagnostics --
def boot_debug(self, mode: int) -> dict:
with self._lock:
return self._dev.boot_debug(mode)
def i2c_bus_scan(self) -> list[int]:
with self._lock:
return self._dev.i2c_bus_scan()
def i2c_raw_read(self, slave: int, reg: int) -> int:
with self._lock:
return self._dev.i2c_raw_read(slave, reg)
# -- Streaming --
def arm_transfer(self, on: bool) -> None:
with self._lock:
self._dev.arm_transfer(on)
def read_stream(self, size: int = 8192, timeout: int = 1000) -> bytes:
with self._lock:
return self._dev.read_stream(size, timeout)
# -- Config --
def send_diseqc_message(self, msg: bytes) -> None:
with self._lock:
self._dev.send_diseqc_message(msg)
def send_diseqc_tone_burst(self, mini_cmd: int) -> None:
with self._lock:
self._dev.send_diseqc_tone_burst(mini_cmd)
def start_intersil(self, on: bool = True) -> int:
with self._lock:
return self._dev.start_intersil(on)
def set_extra_voltage(self, on: bool) -> None:
with self._lock:
self._dev.set_extra_voltage(on)
def boot(self, on: bool = True) -> int:
with self._lock:
return self._dev.boot(on)
def get_signal_lock(self) -> bool:
with self._lock:
return self._dev.get_signal_lock()
def get_signal_strength(self) -> dict:
with self._lock:
return self._dev.get_signal_strength()
def multi_reg_read(self, start_reg: int, count: int) -> bytes:
with self._lock:
return self._dev.multi_reg_read(start_reg, count)
# -- Motor control (v3.03+) --
def motor_halt(self) -> None:
with self._lock:
self._dev.motor_halt()
def motor_drive_east(self, steps: int = 0) -> None:
with self._lock:
self._dev.motor_drive_east(steps)
def motor_drive_west(self, steps: int = 0) -> None:
with self._lock:
self._dev.motor_drive_west(steps)
def motor_store_position(self, slot: int) -> None:
with self._lock:
self._dev.motor_store_position(slot)
def motor_goto_position(self, slot: int) -> None:
with self._lock:
self._dev.motor_goto_position(slot)
def motor_goto_x(self, observer_lon: float, sat_lon: float) -> None:
with self._lock:
self._dev.motor_goto_x(observer_lon, sat_lon)
def motor_set_limit(self, direction: str) -> None:
with self._lock:
self._dev.motor_set_limit(direction)
def motor_disable_limits(self) -> None:
with self._lock:
self._dev.motor_disable_limits()
def get_last_error(self) -> int:
with self._lock:
return self._dev.get_last_error()
def get_last_error_str(self) -> str:
with self._lock:
return self._dev.get_last_error_str()
def get_stream_diag(self, reset: bool = False) -> dict:
with self._lock:
return self._dev.get_stream_diag(reset=reset)
def get_hotplug_status(self, reset: bool = False,
force_scan: bool = False) -> dict:
with self._lock:
return self._dev.get_hotplug_status(reset=reset,
force_scan=force_scan)
"""Thread-safe bridge between Textual async event loop and blocking pyusb calls.
All SkyWalker1 (or DemoDevice) methods are blocking I/O they perform USB control
transfers that can take 2-200ms each. Textual's event loop is asyncio-based, so
calling these directly would freeze the UI.
The USBBridge wraps every device method behind a threading.Lock to prevent concurrent
USB access (the BCM4500 can't handle overlapping control transfers) and exposes them
as plain synchronous methods meant to be called from Textual @work(thread=True) workers.
"""
import threading
class USBBridge:
"""Thread-safe wrapper around SkyWalker1 or DemoDevice."""
def __init__(self, device):
self._dev = device
self._lock = threading.RLock()
@property
def is_demo(self) -> bool:
return hasattr(self._dev, "_demo")
def open(self):
with self._lock:
if hasattr(self._dev, "open"):
self._dev.open()
def close(self):
with self._lock:
if hasattr(self._dev, "close"):
self._dev.close()
def get_fw_version(self) -> dict:
with self._lock:
return self._dev.get_fw_version()
def get_config(self) -> int:
with self._lock:
return self._dev.get_config()
def ensure_booted(self):
with self._lock:
self._dev.ensure_booted()
def signal_monitor(self) -> dict:
with self._lock:
return self._dev.signal_monitor()
def sweep_spectrum(self, start_mhz: float, stop_mhz: float,
step_mhz: float = 5.0, dwell_ms: int = 10,
sr_ksps: int = 20000, mod_index: int = 0,
fec_index: int = 5, callback=None) -> tuple:
with self._lock:
return self._dev.sweep_spectrum(
start_mhz, stop_mhz, step_mhz, dwell_ms,
sr_ksps, mod_index, fec_index, callback,
)
def tune_monitor(self, symbol_rate_sps: int, freq_khz: int,
mod_index: int, fec_index: int,
dwell_ms: int = 10) -> dict:
with self._lock:
return self._dev.tune_monitor(
symbol_rate_sps, freq_khz, mod_index, fec_index, dwell_ms,
)
def tune(self, symbol_rate_sps: int, freq_khz: int,
mod_index: int, fec_index: int):
with self._lock:
self._dev.tune(symbol_rate_sps, freq_khz, mod_index, fec_index)
def set_lnb_voltage(self, high: bool):
with self._lock:
self._dev.set_lnb_voltage(high)
def set_22khz_tone(self, on: bool):
with self._lock:
self._dev.set_22khz_tone(on)
def configure_lnb(self, pol=None, band=None, lnb_lo=None,
disable_lnb=False) -> float:
with self._lock:
return self._dev.configure_lnb(pol, band, lnb_lo, disable_lnb)
def blind_scan(self, freq_khz: int, sr_min: int, sr_max: int,
sr_step: int) -> dict | None:
"""Run blind scan at a single frequency. Returns result dict or None."""
with self._lock:
if hasattr(self._dev, "blind_scan"):
return self._dev.blind_scan(freq_khz, sr_min, sr_max, sr_step)
return None
# -- Device info (extended) --
def get_serial_number(self) -> bytes:
with self._lock:
return self._dev.get_serial_number()
def get_usb_speed(self) -> int:
with self._lock:
return self._dev.get_usb_speed()
def get_vendor_string(self) -> str:
with self._lock:
return self._dev.get_vendor_string()
def get_product_string(self) -> str:
with self._lock:
return self._dev.get_product_string()
# -- FX2 RAM --
def fx2_ram_read(self, addr: int, length: int) -> bytes:
with self._lock:
return self._dev.fx2_ram_read(addr, length)
def fx2_ram_write(self, addr: int, data: bytes) -> int:
with self._lock:
return self._dev.fx2_ram_write(addr, data)
def fx2_cpu_halt(self) -> None:
with self._lock:
self._dev.fx2_cpu_halt()
def fx2_cpu_start(self) -> None:
with self._lock:
self._dev.fx2_cpu_start()
# -- EEPROM --
def eeprom_read(self, offset: int, length: int = 64) -> bytes:
with self._lock:
return self._dev.eeprom_read(offset, length)
def eeprom_write_page(self, offset: int, data: bytes) -> int:
with self._lock:
return self._dev.eeprom_write_page(offset, data)
def eeprom_read_all(self, size: int = 16384) -> bytes:
with self._lock:
return self._dev.eeprom_read_all(size)
# -- Diagnostics --
def boot_debug(self, mode: int) -> dict:
with self._lock:
return self._dev.boot_debug(mode)
def i2c_bus_scan(self) -> list[int]:
with self._lock:
return self._dev.i2c_bus_scan()
def i2c_raw_read(self, slave: int, reg: int) -> int:
with self._lock:
return self._dev.i2c_raw_read(slave, reg)
# -- Streaming --
def arm_transfer(self, on: bool) -> None:
with self._lock:
self._dev.arm_transfer(on)
def read_stream(self, size: int = 8192, timeout: int = 1000) -> bytes:
with self._lock:
return self._dev.read_stream(size, timeout)
# -- Config --
def send_diseqc_message(self, msg: bytes) -> None:
with self._lock:
self._dev.send_diseqc_message(msg)
def send_diseqc_tone_burst(self, mini_cmd: int) -> None:
with self._lock:
self._dev.send_diseqc_tone_burst(mini_cmd)
def start_intersil(self, on: bool = True) -> int:
with self._lock:
return self._dev.start_intersil(on)
def set_extra_voltage(self, on: bool) -> None:
with self._lock:
self._dev.set_extra_voltage(on)
def boot(self, on: bool = True) -> int:
with self._lock:
return self._dev.boot(on)
def get_signal_lock(self) -> bool:
with self._lock:
return self._dev.get_signal_lock()
def get_signal_strength(self) -> dict:
with self._lock:
return self._dev.get_signal_strength()
def multi_reg_read(self, start_reg: int, count: int) -> bytes:
with self._lock:
return self._dev.multi_reg_read(start_reg, count)
# -- Motor control (v3.03+) --
def motor_halt(self) -> None:
with self._lock:
self._dev.motor_halt()
def motor_drive_east(self, steps: int = 0) -> None:
with self._lock:
self._dev.motor_drive_east(steps)
def motor_drive_west(self, steps: int = 0) -> None:
with self._lock:
self._dev.motor_drive_west(steps)
def motor_store_position(self, slot: int) -> None:
with self._lock:
self._dev.motor_store_position(slot)
def motor_goto_position(self, slot: int) -> None:
with self._lock:
self._dev.motor_goto_position(slot)
def motor_goto_x(self, observer_lon: float, sat_lon: float) -> None:
with self._lock:
self._dev.motor_goto_x(observer_lon, sat_lon)
def motor_set_limit(self, direction: str) -> None:
with self._lock:
self._dev.motor_set_limit(direction)
def motor_disable_limits(self) -> None:
with self._lock:
self._dev.motor_disable_limits()
def get_last_error(self) -> int:
with self._lock:
return self._dev.get_last_error()
def get_last_error_str(self) -> str:
with self._lock:
return self._dev.get_last_error_str()
def get_stream_diag(self, reset: bool = False) -> dict:
with self._lock:
return self._dev.get_stream_diag(reset=reset)
def get_hotplug_status(self, reset: bool = False,
force_scan: bool = False) -> dict:
with self._lock:
return self._dev.get_hotplug_status(reset=reset,
force_scan=force_scan)

File diff suppressed because it is too large Load diff

View file

@ -1 +1 @@
"""Mode screens for SkyWalker-1 TUI."""
"""Mode screens for SkyWalker-1 TUI."""

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,215 +1,215 @@
"""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.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, ProgressBar
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)
"""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.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, ProgressBar
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)

View file

@ -1,205 +1,205 @@
"""Monitor screen — real-time signal strength at a single frequency.
This is the dish-alignment / signal-monitoring mode. It polls signal_monitor()
at a configurable rate and displays SNR, power, lock state, and a rolling
sparkline history.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.signal_gauge import SignalGauge
from skywalker_tui.widgets.sparkline_widget import SparklineWidget
class MonitorScreen(Container):
"""Real-time signal monitor with gauge and sparkline."""
DEFAULT_CSS = """
MonitorScreen {
layout: vertical;
}
MonitorScreen #monitor-main {
height: 1fr;
layout: vertical;
padding: 1 2;
}
MonitorScreen #monitor-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
MonitorScreen #monitor-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
MonitorScreen #monitor-controls Input {
width: 14;
margin: 0 1;
}
MonitorScreen #monitor-controls Button {
margin: 0 1;
}
MonitorScreen #monitor-stats {
height: 3;
layout: horizontal;
padding: 0 2;
}
MonitorScreen #monitor-stats Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
"""
BINDINGS = [
("space", "toggle_poll", "Start/Stop"),
]
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._polling = False
self._poll_worker: Worker | None = None
self._sample_count = 0
self._peak_snr = 0.0
def compose(self) -> ComposeResult:
with Vertical(id="monitor-main"):
yield SignalGauge(id="monitor-gauge")
yield SparklineWidget(title="SNR History", color="#00d4aa",
id="snr-sparkline")
yield SparklineWidget(title="Power History", color="#2196f3",
id="power-sparkline")
with Horizontal(id="monitor-stats"):
yield Static("[#506878]Samples:[/] [#00d4aa]0[/]", id="stat-samples")
yield Static("[#506878]Peak SNR:[/] [#00d4aa]0.0 dB[/]", id="stat-peak")
yield Static("[#506878]Status:[/] [#e8a020]Stopped[/]", id="stat-status")
with Horizontal(id="monitor-controls"):
yield Label("Freq (MHz):")
yield Input("1200", id="mon-freq")
yield Label("SR (ksps):")
yield Input("20000", id="mon-sr")
yield Label("Rate (Hz):")
yield Input("5", id="mon-rate")
yield Button("Start", id="mon-start", variant="success")
yield Button("Stop", id="mon-stop", variant="error")
def on_show(self) -> None:
# Auto-start polling in demo mode
if self._bridge.is_demo and not self._polling:
self._start_polling()
def on_hide(self) -> None:
self._stop_polling()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "mon-start":
self._start_polling()
elif event.button.id == "mon-stop":
self._stop_polling()
def action_toggle_poll(self) -> None:
if self._polling:
self._stop_polling()
else:
self._start_polling()
def _start_polling(self) -> None:
if self._polling:
return
self._polling = True
self._sample_count = 0
self._peak_snr = 0.0
freq_mhz = float(self.query_one("#mon-freq", Input).value or "1200")
sr_ksps = int(self.query_one("#mon-sr", Input).value or "20000")
rate = float(self.query_one("#mon-rate", Input).value or "5")
self.query_one("#stat-status", Static).update(
"[#506878]Status:[/] [bold #00d4aa]Running[/]"
)
self._poll_worker = self._do_poll(freq_mhz, sr_ksps, rate)
def _stop_polling(self) -> None:
self._polling = False
if self._poll_worker is not None:
self._poll_worker.cancel()
self._poll_worker = None
try:
self.query_one("#stat-status", Static).update(
"[#506878]Status:[/] [#e8a020]Stopped[/]"
)
except Exception:
pass
@staticmethod
def _parse_input(input_widget: Input, default: float) -> float:
try:
return float(input_widget.value)
except (ValueError, TypeError):
return default
@work(thread=True)
def _do_poll(self, freq_mhz: float, sr_ksps: int, rate: float) -> None:
"""Background worker that polls signal_monitor() in a thread."""
import time
interval = 1.0 / max(0.5, rate)
freq_khz = int(freq_mhz * 1000)
sr_sps = sr_ksps * 1000
# Initial tune
try:
self._bridge.ensure_booted()
self._bridge.tune(sr_sps, freq_khz, 0, 5)
time.sleep(0.3)
except Exception:
pass
while self._polling:
t0 = time.monotonic()
try:
sig = self._bridge.signal_monitor()
except Exception:
time.sleep(interval)
continue
self._sample_count += 1
snr_db = sig.get("snr_db", 0.0)
self._peak_snr = max(self._peak_snr, snr_db)
# Post updates to the UI thread
self.app.call_from_thread(self._update_ui, sig)
elapsed = time.monotonic() - t0
sleep = interval - elapsed
if sleep > 0:
time.sleep(sleep)
def _update_ui(self, sig: dict) -> None:
"""Called from the main thread to update widgets."""
if not self.is_mounted:
return
self.query_one("#monitor-gauge", SignalGauge).update_signal(sig)
self.query_one("#snr-sparkline", SparklineWidget).push(sig.get("snr_db", 0))
self.query_one("#power-sparkline", SparklineWidget).push(sig.get("power_db", -40))
self.query_one("#stat-samples", Static).update(
f"[#506878]Samples:[/] [#00d4aa]{self._sample_count}[/]"
)
self.query_one("#stat-peak", Static).update(
f"[#506878]Peak SNR:[/] [#00d4aa]{self._peak_snr:.1f} dB[/]"
)
"""Monitor screen — real-time signal strength at a single frequency.
This is the dish-alignment / signal-monitoring mode. It polls signal_monitor()
at a configurable rate and displays SNR, power, lock state, and a rolling
sparkline history.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.signal_gauge import SignalGauge
from skywalker_tui.widgets.sparkline_widget import SparklineWidget
class MonitorScreen(Container):
"""Real-time signal monitor with gauge and sparkline."""
DEFAULT_CSS = """
MonitorScreen {
layout: vertical;
}
MonitorScreen #monitor-main {
height: 1fr;
layout: vertical;
padding: 1 2;
}
MonitorScreen #monitor-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
MonitorScreen #monitor-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
MonitorScreen #monitor-controls Input {
width: 14;
margin: 0 1;
}
MonitorScreen #monitor-controls Button {
margin: 0 1;
}
MonitorScreen #monitor-stats {
height: 3;
layout: horizontal;
padding: 0 2;
}
MonitorScreen #monitor-stats Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
"""
BINDINGS = [
("space", "toggle_poll", "Start/Stop"),
]
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._polling = False
self._poll_worker: Worker | None = None
self._sample_count = 0
self._peak_snr = 0.0
def compose(self) -> ComposeResult:
with Vertical(id="monitor-main"):
yield SignalGauge(id="monitor-gauge")
yield SparklineWidget(title="SNR History", color="#00d4aa",
id="snr-sparkline")
yield SparklineWidget(title="Power History", color="#2196f3",
id="power-sparkline")
with Horizontal(id="monitor-stats"):
yield Static("[#506878]Samples:[/] [#00d4aa]0[/]", id="stat-samples")
yield Static("[#506878]Peak SNR:[/] [#00d4aa]0.0 dB[/]", id="stat-peak")
yield Static("[#506878]Status:[/] [#e8a020]Stopped[/]", id="stat-status")
with Horizontal(id="monitor-controls"):
yield Label("Freq (MHz):")
yield Input("1200", id="mon-freq")
yield Label("SR (ksps):")
yield Input("20000", id="mon-sr")
yield Label("Rate (Hz):")
yield Input("5", id="mon-rate")
yield Button("Start", id="mon-start", variant="success")
yield Button("Stop", id="mon-stop", variant="error")
def on_show(self) -> None:
# Auto-start polling in demo mode
if self._bridge.is_demo and not self._polling:
self._start_polling()
def on_hide(self) -> None:
self._stop_polling()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "mon-start":
self._start_polling()
elif event.button.id == "mon-stop":
self._stop_polling()
def action_toggle_poll(self) -> None:
if self._polling:
self._stop_polling()
else:
self._start_polling()
def _start_polling(self) -> None:
if self._polling:
return
self._polling = True
self._sample_count = 0
self._peak_snr = 0.0
freq_mhz = float(self.query_one("#mon-freq", Input).value or "1200")
sr_ksps = int(self.query_one("#mon-sr", Input).value or "20000")
rate = float(self.query_one("#mon-rate", Input).value or "5")
self.query_one("#stat-status", Static).update(
"[#506878]Status:[/] [bold #00d4aa]Running[/]"
)
self._poll_worker = self._do_poll(freq_mhz, sr_ksps, rate)
def _stop_polling(self) -> None:
self._polling = False
if self._poll_worker is not None:
self._poll_worker.cancel()
self._poll_worker = None
try:
self.query_one("#stat-status", Static).update(
"[#506878]Status:[/] [#e8a020]Stopped[/]"
)
except Exception:
pass
@staticmethod
def _parse_input(input_widget: Input, default: float) -> float:
try:
return float(input_widget.value)
except (ValueError, TypeError):
return default
@work(thread=True)
def _do_poll(self, freq_mhz: float, sr_ksps: int, rate: float) -> None:
"""Background worker that polls signal_monitor() in a thread."""
import time
interval = 1.0 / max(0.5, rate)
freq_khz = int(freq_mhz * 1000)
sr_sps = sr_ksps * 1000
# Initial tune
try:
self._bridge.ensure_booted()
self._bridge.tune(sr_sps, freq_khz, 0, 5)
time.sleep(0.3)
except Exception:
pass
while self._polling:
t0 = time.monotonic()
try:
sig = self._bridge.signal_monitor()
except Exception:
time.sleep(interval)
continue
self._sample_count += 1
snr_db = sig.get("snr_db", 0.0)
self._peak_snr = max(self._peak_snr, snr_db)
# Post updates to the UI thread
self.app.call_from_thread(self._update_ui, sig)
elapsed = time.monotonic() - t0
sleep = interval - elapsed
if sleep > 0:
time.sleep(sleep)
def _update_ui(self, sig: dict) -> None:
"""Called from the main thread to update widgets."""
if not self.is_mounted:
return
self.query_one("#monitor-gauge", SignalGauge).update_signal(sig)
self.query_one("#snr-sparkline", SparklineWidget).push(sig.get("snr_db", 0))
self.query_one("#power-sparkline", SparklineWidget).push(sig.get("power_db", -40))
self.query_one("#stat-samples", Static).update(
f"[#506878]Samples:[/] [#00d4aa]{self._sample_count}[/]"
)
self.query_one("#stat-peak", Static).update(
f"[#506878]Peak SNR:[/] [#00d4aa]{self._peak_snr:.1f} dB[/]"
)

View file

@ -1,411 +1,411 @@
"""Motor screen — DiSEqC 1.2 positioner control with live signal feedback.
Three-column layout: Motor Control (jog/halt/limits) | Positions (store/recall) |
USALS GotoX (calculator + presets). Bottom bar shows live signal monitor for
dish alignment feedback during jog.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical, Grid
from textual.widgets import Label, Input, Button, Static
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.signal_gauge import SignalGauge
_QO100_SAT_LON = 25.9 # Es'hail-2
class MotorScreen(Container):
"""DiSEqC 1.2 positioner control with signal feedback."""
DEFAULT_CSS = """
MotorScreen {
layout: vertical;
}
MotorScreen #motor-main {
height: 1fr;
layout: horizontal;
}
MotorScreen .motor-col {
width: 1fr;
padding: 1;
}
MotorScreen .motor-panel {
background: #0e1420;
border: round #1a2a3a;
padding: 1;
margin: 0 0 1 0;
height: auto;
}
MotorScreen .motor-panel-title {
color: #00d4aa;
text-style: bold;
margin: 0 0 1 0;
}
MotorScreen .jog-row {
height: auto;
layout: horizontal;
margin: 0 0 1 0;
}
MotorScreen .jog-row Button {
width: 1fr;
margin: 0 1 0 0;
}
MotorScreen .pos-grid {
layout: grid;
grid-size: 3;
grid-gutter: 1;
height: auto;
}
MotorScreen .pos-grid Button {
height: 3;
}
MotorScreen .pos-btn-stored {
background: #1a3a2a;
color: #00d4aa;
border: round #00d4aa;
}
MotorScreen .pos-btn-empty {
background: #121c2a;
color: #506878;
border: round #1a3050;
}
MotorScreen #motor-signal {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
dock: bottom;
}
MotorScreen #motor-signal .sig-row {
height: 3;
layout: horizontal;
}
MotorScreen #motor-signal Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
MotorScreen .usals-input-row {
height: auto;
}
MotorScreen .usals-input-row Label {
color: #506878;
width: auto;
margin: 0 1 0 0;
}
MotorScreen .usals-input-row Input {
width: 12;
margin: 0 1;
}
MotorScreen .motor-status {
height: auto;
color: #506878;
margin: 1 0 0 0;
}
"""
BINDINGS = [
("left", "jog_west", "Jog West"),
("right", "jog_east", "Jog East"),
("space", "halt", "Halt"),
]
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._polling = False
self._poll_worker: Worker | None = None
self._jog_active = False
self._jog_start_time = 0.0
self._stored_positions: dict[int, bool] = {}
def compose(self) -> ComposeResult:
with Horizontal(id="motor-main"):
# Column 1: Motor jog control
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("Motor Control", classes="motor-panel-title")
with Horizontal(classes="jog-row"):
yield Button("West", id="jog-west", variant="warning")
yield Button("HALT", id="jog-halt", variant="error")
yield Button("East", id="jog-east", variant="warning")
with Horizontal(classes="jog-row"):
yield Button("Step W", id="step-west")
yield Button("Step E", id="step-east")
yield Static("[#506878]Direction:[/] [#e8a020]Stopped[/]",
id="motor-dir-status", classes="motor-status")
yield Static("[#506878]Position:[/] [#00d4aa]0.0 deg[/]",
id="motor-pos-status", classes="motor-status")
with Vertical(classes="motor-panel"):
yield Label("Limits", classes="motor-panel-title")
with Horizontal(classes="jog-row"):
yield Button("Set East Limit", id="limit-east")
yield Button("Set West Limit", id="limit-west")
yield Button("Disable Limits", id="limit-disable")
# Column 2: Stored positions
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("Stored Positions", classes="motor-panel-title")
yield Static(
"[#506878]Press to recall, hold S+number to store[/]",
classes="motor-status",
)
with Grid(classes="pos-grid"):
for i in range(1, 10):
yield Button(
f"Pos {i}",
id=f"pos-{i}",
classes="pos-btn-empty",
)
yield Button("Go to Reference (0)", id="pos-ref")
yield Static("", id="pos-info", classes="motor-status")
# Column 3: USALS GotoX
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("USALS GotoX", classes="motor-panel-title")
with Horizontal(classes="usals-input-row"):
yield Label("Observer Lon:")
yield Input("-97.5", id="usals-obs-lon")
with Horizontal(classes="usals-input-row"):
yield Label("Satellite Lon:")
yield Input("25.9", id="usals-sat-lon")
yield Button("Calculate & Go", id="usals-go", variant="success")
yield Static("", id="usals-result", classes="motor-status")
with Vertical(classes="motor-panel"):
yield Label("Presets", classes="motor-panel-title")
yield Button("QO-100 (25.9E)", id="preset-qo100")
yield Button("Galaxy 19 (97.0W)", id="preset-g19")
yield Button("AMC-1 (103.0W)", id="preset-amc1")
# Bottom: live signal bar
with Vertical(id="motor-signal"):
yield SignalGauge(id="motor-gauge")
with Horizontal(classes="sig-row"):
yield Static("[#506878]SNR:[/] [#00d4aa]-- dB[/]", id="sig-snr")
yield Static("[#506878]Power:[/] [#00d4aa]-- dB[/]", id="sig-power")
yield Static("[#506878]Lock:[/] [#e04040]NO[/]", id="sig-lock")
yield Static("[#506878]Motor:[/] [#e8a020]Idle[/]", id="sig-motor")
def on_show(self) -> None:
if not self._polling:
self._start_polling()
def on_hide(self) -> None:
self._stop_polling()
# Safety: halt motor when leaving screen
if self._jog_active:
try:
self._bridge.motor_halt()
except Exception:
pass
self._jog_active = False
def on_button_pressed(self, event: Button.Pressed) -> None:
btn = event.button.id or ""
if btn == "jog-east":
self._do_jog_east()
elif btn == "jog-west":
self._do_jog_west()
elif btn == "jog-halt":
self._do_halt()
elif btn == "step-east":
self._do_step(east=True)
elif btn == "step-west":
self._do_step(east=False)
elif btn == "limit-east":
self._bridge.motor_set_limit("east")
elif btn == "limit-west":
self._bridge.motor_set_limit("west")
elif btn == "limit-disable":
self._bridge.motor_disable_limits()
elif btn.startswith("pos-") and btn != "pos-ref":
slot = int(btn.split("-")[1])
self._do_goto_position(slot)
elif btn == "pos-ref":
self._do_goto_position(0)
elif btn == "usals-go":
self._do_usals_go()
elif btn == "preset-qo100":
self._do_preset(25.9)
elif btn == "preset-g19":
self._do_preset(-97.0)
elif btn == "preset-amc1":
self._do_preset(-103.0)
def action_jog_east(self) -> None:
self._do_jog_east()
def action_jog_west(self) -> None:
self._do_jog_west()
def action_halt(self) -> None:
self._do_halt()
def _do_jog_east(self) -> None:
import time
self._jog_active = True
self._jog_start_time = time.monotonic()
self._bridge.motor_drive_east()
self._update_dir_status("East", "#00e060")
def _do_jog_west(self) -> None:
import time
self._jog_active = True
self._jog_start_time = time.monotonic()
self._bridge.motor_drive_west()
self._update_dir_status("West", "#2196f3")
def _do_halt(self) -> None:
self._jog_active = False
self._bridge.motor_halt()
self._update_dir_status("Stopped", "#e8a020")
def _do_step(self, east: bool) -> None:
if east:
self._bridge.motor_drive_east(steps=10)
else:
self._bridge.motor_drive_west(steps=10)
self._update_dir_status("Stepping", "#e8a020")
def _do_goto_position(self, slot: int) -> None:
self._bridge.motor_goto_position(slot)
label = f"Pos {slot}" if slot > 0 else "Reference"
self._update_dir_status(f"Going to {label}", "#00d4aa")
def _do_usals_go(self) -> None:
try:
obs_lon = float(self.query_one("#usals-obs-lon", Input).value)
sat_lon = float(self.query_one("#usals-sat-lon", Input).value)
except (ValueError, TypeError):
return
self._bridge.motor_goto_x(obs_lon, sat_lon)
# Show calculated angle
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
try:
from skywalker_lib import usals_angle
angle = usals_angle(obs_lon, sat_lon)
direction = "East" if angle >= 0 else "West"
self.query_one("#usals-result", Static).update(
f"[#506878]Angle:[/] [#00d4aa]{abs(angle):.1f} deg {direction}[/]"
)
except ImportError:
pass
self._update_dir_status("USALS GotoX", "#00d4aa")
def _do_preset(self, sat_lon: float) -> None:
self.query_one("#usals-sat-lon", Input).value = str(sat_lon)
self._do_usals_go()
def _update_dir_status(self, text: str, color: str) -> None:
if not self.is_mounted:
return
self.query_one("#motor-dir-status", Static).update(
f"[#506878]Direction:[/] [{color}]{text}[/]"
)
def _start_polling(self) -> None:
self._polling = True
self._poll_worker = self._do_signal_poll()
def _stop_polling(self) -> None:
self._polling = False
if self._poll_worker:
self._poll_worker.cancel()
self._poll_worker = None
@work(thread=True)
def _do_signal_poll(self) -> None:
"""Poll signal + motor state at ~2 Hz for alignment feedback."""
import time
try:
self._bridge.ensure_booted()
except Exception:
pass
while self._polling:
t0 = time.monotonic()
# Safety: auto-halt after 30s continuous jog
if self._jog_active:
elapsed = t0 - self._jog_start_time
if elapsed > 30.0:
self._bridge.motor_halt()
self._jog_active = False
self.app.call_from_thread(
self._update_dir_status, "Auto-halted (30s)", "#e04040"
)
try:
sig = self._bridge.signal_monitor()
except Exception:
time.sleep(0.5)
continue
# Read motor position from demo device
motor_pos = None
motor_moving = False
if hasattr(self._bridge, '_dev'):
dev = self._bridge._dev
if hasattr(dev, 'motor_position'):
motor_pos = dev.motor_position
motor_moving = dev.motor_is_moving
self.app.call_from_thread(self._update_signal_ui, sig, motor_pos, motor_moving)
elapsed = time.monotonic() - t0
sleep = 0.5 - elapsed
if sleep > 0:
time.sleep(sleep)
def _update_signal_ui(self, sig: dict, motor_pos: float | None,
motor_moving: bool) -> None:
if not self.is_mounted:
return
self.query_one("#motor-gauge", SignalGauge).update_signal(sig)
snr = sig.get("snr_db", 0.0)
power = sig.get("power_db", -40.0)
locked = sig.get("locked", False)
self.query_one("#sig-snr", Static).update(
f"[#506878]SNR:[/] [#00d4aa]{snr:.1f} dB[/]"
)
self.query_one("#sig-power", Static).update(
f"[#506878]Power:[/] [#00d4aa]{power:.1f} dB[/]"
)
lock_color = "#00e060" if locked else "#e04040"
lock_text = "LOCKED" if locked else "NO"
self.query_one("#sig-lock", Static).update(
f"[#506878]Lock:[/] [{lock_color}]{lock_text}[/]"
)
if motor_pos is not None:
direction = "E" if motor_pos >= 0 else "W"
move_indicator = " [#e8a020]>>>[/]" if motor_moving else ""
self.query_one("#sig-motor", Static).update(
f"[#506878]Motor:[/] [#00d4aa]{abs(motor_pos):.1f} deg {direction}[/]{move_indicator}"
)
self.query_one("#motor-pos-status", Static).update(
f"[#506878]Position:[/] [#00d4aa]{motor_pos:.1f} deg[/]"
)
if not self._jog_active and not motor_moving:
self._update_dir_status("Stopped", "#e8a020")
"""Motor screen — DiSEqC 1.2 positioner control with live signal feedback.
Three-column layout: Motor Control (jog/halt/limits) | Positions (store/recall) |
USALS GotoX (calculator + presets). Bottom bar shows live signal monitor for
dish alignment feedback during jog.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical, Grid
from textual.widgets import Label, Input, Button, Static
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.signal_gauge import SignalGauge
_QO100_SAT_LON = 25.9 # Es'hail-2
class MotorScreen(Container):
"""DiSEqC 1.2 positioner control with signal feedback."""
DEFAULT_CSS = """
MotorScreen {
layout: vertical;
}
MotorScreen #motor-main {
height: 1fr;
layout: horizontal;
}
MotorScreen .motor-col {
width: 1fr;
padding: 1;
}
MotorScreen .motor-panel {
background: #0e1420;
border: round #1a2a3a;
padding: 1;
margin: 0 0 1 0;
height: auto;
}
MotorScreen .motor-panel-title {
color: #00d4aa;
text-style: bold;
margin: 0 0 1 0;
}
MotorScreen .jog-row {
height: auto;
layout: horizontal;
margin: 0 0 1 0;
}
MotorScreen .jog-row Button {
width: 1fr;
margin: 0 1 0 0;
}
MotorScreen .pos-grid {
layout: grid;
grid-size: 3;
grid-gutter: 1;
height: auto;
}
MotorScreen .pos-grid Button {
height: 3;
}
MotorScreen .pos-btn-stored {
background: #1a3a2a;
color: #00d4aa;
border: round #00d4aa;
}
MotorScreen .pos-btn-empty {
background: #121c2a;
color: #506878;
border: round #1a3050;
}
MotorScreen #motor-signal {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
dock: bottom;
}
MotorScreen #motor-signal .sig-row {
height: 3;
layout: horizontal;
}
MotorScreen #motor-signal Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
MotorScreen .usals-input-row {
height: auto;
}
MotorScreen .usals-input-row Label {
color: #506878;
width: auto;
margin: 0 1 0 0;
}
MotorScreen .usals-input-row Input {
width: 12;
margin: 0 1;
}
MotorScreen .motor-status {
height: auto;
color: #506878;
margin: 1 0 0 0;
}
"""
BINDINGS = [
("left", "jog_west", "Jog West"),
("right", "jog_east", "Jog East"),
("space", "halt", "Halt"),
]
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._polling = False
self._poll_worker: Worker | None = None
self._jog_active = False
self._jog_start_time = 0.0
self._stored_positions: dict[int, bool] = {}
def compose(self) -> ComposeResult:
with Horizontal(id="motor-main"):
# Column 1: Motor jog control
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("Motor Control", classes="motor-panel-title")
with Horizontal(classes="jog-row"):
yield Button("West", id="jog-west", variant="warning")
yield Button("HALT", id="jog-halt", variant="error")
yield Button("East", id="jog-east", variant="warning")
with Horizontal(classes="jog-row"):
yield Button("Step W", id="step-west")
yield Button("Step E", id="step-east")
yield Static("[#506878]Direction:[/] [#e8a020]Stopped[/]",
id="motor-dir-status", classes="motor-status")
yield Static("[#506878]Position:[/] [#00d4aa]0.0 deg[/]",
id="motor-pos-status", classes="motor-status")
with Vertical(classes="motor-panel"):
yield Label("Limits", classes="motor-panel-title")
with Horizontal(classes="jog-row"):
yield Button("Set East Limit", id="limit-east")
yield Button("Set West Limit", id="limit-west")
yield Button("Disable Limits", id="limit-disable")
# Column 2: Stored positions
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("Stored Positions", classes="motor-panel-title")
yield Static(
"[#506878]Press to recall, hold S+number to store[/]",
classes="motor-status",
)
with Grid(classes="pos-grid"):
for i in range(1, 10):
yield Button(
f"Pos {i}",
id=f"pos-{i}",
classes="pos-btn-empty",
)
yield Button("Go to Reference (0)", id="pos-ref")
yield Static("", id="pos-info", classes="motor-status")
# Column 3: USALS GotoX
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("USALS GotoX", classes="motor-panel-title")
with Horizontal(classes="usals-input-row"):
yield Label("Observer Lon:")
yield Input("-97.5", id="usals-obs-lon")
with Horizontal(classes="usals-input-row"):
yield Label("Satellite Lon:")
yield Input("25.9", id="usals-sat-lon")
yield Button("Calculate & Go", id="usals-go", variant="success")
yield Static("", id="usals-result", classes="motor-status")
with Vertical(classes="motor-panel"):
yield Label("Presets", classes="motor-panel-title")
yield Button("QO-100 (25.9E)", id="preset-qo100")
yield Button("Galaxy 19 (97.0W)", id="preset-g19")
yield Button("AMC-1 (103.0W)", id="preset-amc1")
# Bottom: live signal bar
with Vertical(id="motor-signal"):
yield SignalGauge(id="motor-gauge")
with Horizontal(classes="sig-row"):
yield Static("[#506878]SNR:[/] [#00d4aa]-- dB[/]", id="sig-snr")
yield Static("[#506878]Power:[/] [#00d4aa]-- dB[/]", id="sig-power")
yield Static("[#506878]Lock:[/] [#e04040]NO[/]", id="sig-lock")
yield Static("[#506878]Motor:[/] [#e8a020]Idle[/]", id="sig-motor")
def on_show(self) -> None:
if not self._polling:
self._start_polling()
def on_hide(self) -> None:
self._stop_polling()
# Safety: halt motor when leaving screen
if self._jog_active:
try:
self._bridge.motor_halt()
except Exception:
pass
self._jog_active = False
def on_button_pressed(self, event: Button.Pressed) -> None:
btn = event.button.id or ""
if btn == "jog-east":
self._do_jog_east()
elif btn == "jog-west":
self._do_jog_west()
elif btn == "jog-halt":
self._do_halt()
elif btn == "step-east":
self._do_step(east=True)
elif btn == "step-west":
self._do_step(east=False)
elif btn == "limit-east":
self._bridge.motor_set_limit("east")
elif btn == "limit-west":
self._bridge.motor_set_limit("west")
elif btn == "limit-disable":
self._bridge.motor_disable_limits()
elif btn.startswith("pos-") and btn != "pos-ref":
slot = int(btn.split("-")[1])
self._do_goto_position(slot)
elif btn == "pos-ref":
self._do_goto_position(0)
elif btn == "usals-go":
self._do_usals_go()
elif btn == "preset-qo100":
self._do_preset(25.9)
elif btn == "preset-g19":
self._do_preset(-97.0)
elif btn == "preset-amc1":
self._do_preset(-103.0)
def action_jog_east(self) -> None:
self._do_jog_east()
def action_jog_west(self) -> None:
self._do_jog_west()
def action_halt(self) -> None:
self._do_halt()
def _do_jog_east(self) -> None:
import time
self._jog_active = True
self._jog_start_time = time.monotonic()
self._bridge.motor_drive_east()
self._update_dir_status("East", "#00e060")
def _do_jog_west(self) -> None:
import time
self._jog_active = True
self._jog_start_time = time.monotonic()
self._bridge.motor_drive_west()
self._update_dir_status("West", "#2196f3")
def _do_halt(self) -> None:
self._jog_active = False
self._bridge.motor_halt()
self._update_dir_status("Stopped", "#e8a020")
def _do_step(self, east: bool) -> None:
if east:
self._bridge.motor_drive_east(steps=10)
else:
self._bridge.motor_drive_west(steps=10)
self._update_dir_status("Stepping", "#e8a020")
def _do_goto_position(self, slot: int) -> None:
self._bridge.motor_goto_position(slot)
label = f"Pos {slot}" if slot > 0 else "Reference"
self._update_dir_status(f"Going to {label}", "#00d4aa")
def _do_usals_go(self) -> None:
try:
obs_lon = float(self.query_one("#usals-obs-lon", Input).value)
sat_lon = float(self.query_one("#usals-sat-lon", Input).value)
except (ValueError, TypeError):
return
self._bridge.motor_goto_x(obs_lon, sat_lon)
# Show calculated angle
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
try:
from skywalker_lib import usals_angle
angle = usals_angle(obs_lon, sat_lon)
direction = "East" if angle >= 0 else "West"
self.query_one("#usals-result", Static).update(
f"[#506878]Angle:[/] [#00d4aa]{abs(angle):.1f} deg {direction}[/]"
)
except ImportError:
pass
self._update_dir_status("USALS GotoX", "#00d4aa")
def _do_preset(self, sat_lon: float) -> None:
self.query_one("#usals-sat-lon", Input).value = str(sat_lon)
self._do_usals_go()
def _update_dir_status(self, text: str, color: str) -> None:
if not self.is_mounted:
return
self.query_one("#motor-dir-status", Static).update(
f"[#506878]Direction:[/] [{color}]{text}[/]"
)
def _start_polling(self) -> None:
self._polling = True
self._poll_worker = self._do_signal_poll()
def _stop_polling(self) -> None:
self._polling = False
if self._poll_worker:
self._poll_worker.cancel()
self._poll_worker = None
@work(thread=True)
def _do_signal_poll(self) -> None:
"""Poll signal + motor state at ~2 Hz for alignment feedback."""
import time
try:
self._bridge.ensure_booted()
except Exception:
pass
while self._polling:
t0 = time.monotonic()
# Safety: auto-halt after 30s continuous jog
if self._jog_active:
elapsed = t0 - self._jog_start_time
if elapsed > 30.0:
self._bridge.motor_halt()
self._jog_active = False
self.app.call_from_thread(
self._update_dir_status, "Auto-halted (30s)", "#e04040"
)
try:
sig = self._bridge.signal_monitor()
except Exception:
time.sleep(0.5)
continue
# Read motor position from demo device
motor_pos = None
motor_moving = False
if hasattr(self._bridge, '_dev'):
dev = self._bridge._dev
if hasattr(dev, 'motor_position'):
motor_pos = dev.motor_position
motor_moving = dev.motor_is_moving
self.app.call_from_thread(self._update_signal_ui, sig, motor_pos, motor_moving)
elapsed = time.monotonic() - t0
sleep = 0.5 - elapsed
if sleep > 0:
time.sleep(sleep)
def _update_signal_ui(self, sig: dict, motor_pos: float | None,
motor_moving: bool) -> None:
if not self.is_mounted:
return
self.query_one("#motor-gauge", SignalGauge).update_signal(sig)
snr = sig.get("snr_db", 0.0)
power = sig.get("power_db", -40.0)
locked = sig.get("locked", False)
self.query_one("#sig-snr", Static).update(
f"[#506878]SNR:[/] [#00d4aa]{snr:.1f} dB[/]"
)
self.query_one("#sig-power", Static).update(
f"[#506878]Power:[/] [#00d4aa]{power:.1f} dB[/]"
)
lock_color = "#00e060" if locked else "#e04040"
lock_text = "LOCKED" if locked else "NO"
self.query_one("#sig-lock", Static).update(
f"[#506878]Lock:[/] [{lock_color}]{lock_text}[/]"
)
if motor_pos is not None:
direction = "E" if motor_pos >= 0 else "W"
move_indicator = " [#e8a020]>>>[/]" if motor_moving else ""
self.query_one("#sig-motor", Static).update(
f"[#506878]Motor:[/] [#00d4aa]{abs(motor_pos):.1f} deg {direction}[/]{move_indicator}"
)
self.query_one("#motor-pos-status", Static).update(
f"[#506878]Position:[/] [#00d4aa]{motor_pos:.1f} deg[/]"
)
if not self._jog_active and not motor_moving:
self._update_dir_status("Stopped", "#e8a020")

View file

@ -1,257 +1,257 @@
"""Scan screen — automated transponder discovery.
Multi-phase pipeline: coarse sweep peak detection fine sweep blind scan.
Shows progress, spectrum visualization, and a results table.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, ProgressBar
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.spectrum_plot import SpectrumPlot
from skywalker_tui.widgets.frequency_table import FrequencyTable
class ScanScreen(Container):
"""Multi-phase transponder scanner with progress and results table."""
DEFAULT_CSS = """
ScanScreen {
layout: vertical;
}
ScanScreen #scan-main {
height: 1fr;
layout: vertical;
}
ScanScreen #scan-upper {
height: 1fr;
layout: horizontal;
}
ScanScreen #scan-spectrum-col {
width: 1fr;
}
ScanScreen #scan-results-col {
width: 1fr;
}
ScanScreen #scan-progress {
height: auto;
padding: 1 2;
background: #0e1018;
layout: vertical;
}
ScanScreen #scan-progress-row {
height: 3;
layout: horizontal;
}
ScanScreen #scan-progress Static {
width: auto;
margin: 0 1 0 0;
}
ScanScreen #scan-progress ProgressBar {
width: 1fr;
margin: 0 1;
}
ScanScreen #scan-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
ScanScreen #scan-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
ScanScreen #scan-controls Input {
width: 10;
margin: 0 1;
}
ScanScreen #scan-controls Button {
margin: 0 1;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._scanning = False
self._scan_worker: Worker | None = None
def compose(self) -> ComposeResult:
with Vertical(id="scan-main"):
with Horizontal(id="scan-upper"):
with Vertical(id="scan-spectrum-col"):
yield SpectrumPlot(title="Coarse Sweep", id="scan-spectrum")
with Vertical(id="scan-results-col"):
yield Static("[#00d4aa bold]Transponders Found[/]",
id="scan-results-title")
yield FrequencyTable(id="scan-table")
with Vertical(id="scan-progress"):
yield Static("[#506878]Ready[/]", id="scan-phase")
with Horizontal(id="scan-progress-row"):
yield ProgressBar(total=100, show_eta=False, id="scan-pbar")
with Horizontal(id="scan-controls"):
yield Label("Start:")
yield Input("950", id="scan-start")
yield Label("Stop:")
yield Input("2150", id="scan-stop")
yield Label("LNB LO:")
yield Input("9750", id="scan-lnb")
yield Label("Threshold:")
yield Input("3", id="scan-thresh")
yield Button("Scan", id="scan-start-btn", variant="success")
yield Button("Stop", id="scan-stop-btn", variant="error")
def on_hide(self) -> None:
self._stop_scan()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "scan-start-btn":
self._start_scan()
elif event.button.id == "scan-stop-btn":
self._stop_scan()
def _start_scan(self) -> None:
if self._scanning:
return
self._scanning = True
start = float(self.query_one("#scan-start", Input).value or "950")
stop = float(self.query_one("#scan-stop", Input).value or "2150")
lnb_lo = float(self.query_one("#scan-lnb", Input).value or "9750")
threshold = float(self.query_one("#scan-thresh", Input).value or "3")
# Clear previous results
self.query_one("#scan-table", FrequencyTable).clear_table()
self._scan_worker = self._do_scan(start, stop, lnb_lo, threshold)
def _stop_scan(self) -> None:
self._scanning = False
if self._scan_worker:
self._scan_worker.cancel()
self._scan_worker = None
@work(thread=True)
def _do_scan(self, start: float, stop: float, lnb_lo: float,
threshold: float) -> None:
"""Multi-phase scan pipeline in a background thread."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
from skywalker_lib import detect_peaks, if_to_rf
try:
self._bridge.ensure_booted()
except Exception:
pass
# Phase 1: Coarse sweep
self.app.call_from_thread(self._set_phase, "Phase 1: Coarse sweep", 0)
coarse_step = 10
def coarse_cb(freq, step_num, total, result):
pct = (step_num + 1) / total * 100
self.app.call_from_thread(self._set_progress, pct)
freqs, powers, results = self._bridge.sweep_spectrum(
start, stop, coarse_step, dwell_ms=15, sr_ksps=20000,
callback=coarse_cb,
)
if not self._scanning:
return
self.app.call_from_thread(self._update_spectrum, freqs, powers, results, lnb_lo)
# Phase 2: Peak detection
self.app.call_from_thread(self._set_phase, "Phase 2: Peak detection", 50)
peaks = detect_peaks(freqs, powers, threshold_db=threshold)
if not peaks:
self.app.call_from_thread(self._set_phase, "No peaks found", 100)
self._scanning = False
return
# Phase 3: Fine sweep around peaks
self.app.call_from_thread(
self._set_phase,
f"Phase 3: Fine sweep ({len(peaks)} peaks)", 60,
)
refined = []
for i, (freq, pwr, idx) in enumerate(peaks):
if not self._scanning:
return
fine_start = max(start, freq - 15)
fine_stop = min(stop, freq + 15)
fine_freqs, fine_powers, fine_results = self._bridge.sweep_spectrum(
fine_start, fine_stop, step_mhz=2.0, dwell_ms=20, sr_ksps=20000,
)
if fine_powers:
best_idx = fine_powers.index(max(fine_powers))
refined.append((
fine_freqs[best_idx], fine_powers[best_idx],
fine_results[best_idx],
))
pct = 60 + (i + 1) / len(peaks) * 20
self.app.call_from_thread(self._set_progress, pct)
# Phase 4: Blind scan
self.app.call_from_thread(
self._set_phase,
f"Phase 4: Blind scan ({len(refined)} candidates)", 80,
)
sr_min = 1000 * 1000
sr_max = 30000 * 1000
sr_step = 500 * 1000
for i, (freq, pwr, result) in enumerate(refined):
if not self._scanning:
return
freq_khz = int(freq * 1000)
bs_result = self._bridge.blind_scan(freq_khz, sr_min, sr_max, sr_step)
if bs_result and bs_result.get("locked"):
tp = {
"if_mhz": bs_result.get("freq_khz", freq_khz) / 1000.0,
"rf_mhz": if_to_rf(
bs_result.get("freq_khz", freq_khz) / 1000.0, lnb_lo
),
"sr_ksps": bs_result.get("sr_sps", 0) // 1000,
"power_db": pwr,
"locked": True,
}
self.app.call_from_thread(self._add_transponder, tp)
pct = 80 + (i + 1) / len(refined) * 20
self.app.call_from_thread(self._set_progress, pct)
self.app.call_from_thread(self._set_phase, "Scan complete", 100)
self._scanning = False
def _set_phase(self, text: str, progress: float) -> None:
if not self.is_mounted:
return
self.query_one("#scan-phase", Static).update(f"[#00d4aa]{text}[/]")
self.query_one("#scan-pbar", ProgressBar).update(progress=progress)
def _set_progress(self, pct: float) -> None:
if not self.is_mounted:
return
self.query_one("#scan-pbar", ProgressBar).update(progress=pct)
def _update_spectrum(self, freqs, powers, results, lnb_lo) -> None:
if not self.is_mounted:
return
self.query_one("#scan-spectrum", SpectrumPlot).update_data(
freqs, powers, results, lnb_lo=lnb_lo,
)
def _add_transponder(self, tp: dict) -> None:
if not self.is_mounted:
return
self.query_one("#scan-table", FrequencyTable).add_transponder(tp)
"""Scan screen — automated transponder discovery.
Multi-phase pipeline: coarse sweep peak detection fine sweep blind scan.
Shows progress, spectrum visualization, and a results table.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, ProgressBar
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.spectrum_plot import SpectrumPlot
from skywalker_tui.widgets.frequency_table import FrequencyTable
class ScanScreen(Container):
"""Multi-phase transponder scanner with progress and results table."""
DEFAULT_CSS = """
ScanScreen {
layout: vertical;
}
ScanScreen #scan-main {
height: 1fr;
layout: vertical;
}
ScanScreen #scan-upper {
height: 1fr;
layout: horizontal;
}
ScanScreen #scan-spectrum-col {
width: 1fr;
}
ScanScreen #scan-results-col {
width: 1fr;
}
ScanScreen #scan-progress {
height: auto;
padding: 1 2;
background: #0e1018;
layout: vertical;
}
ScanScreen #scan-progress-row {
height: 3;
layout: horizontal;
}
ScanScreen #scan-progress Static {
width: auto;
margin: 0 1 0 0;
}
ScanScreen #scan-progress ProgressBar {
width: 1fr;
margin: 0 1;
}
ScanScreen #scan-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
ScanScreen #scan-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
ScanScreen #scan-controls Input {
width: 10;
margin: 0 1;
}
ScanScreen #scan-controls Button {
margin: 0 1;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._scanning = False
self._scan_worker: Worker | None = None
def compose(self) -> ComposeResult:
with Vertical(id="scan-main"):
with Horizontal(id="scan-upper"):
with Vertical(id="scan-spectrum-col"):
yield SpectrumPlot(title="Coarse Sweep", id="scan-spectrum")
with Vertical(id="scan-results-col"):
yield Static("[#00d4aa bold]Transponders Found[/]",
id="scan-results-title")
yield FrequencyTable(id="scan-table")
with Vertical(id="scan-progress"):
yield Static("[#506878]Ready[/]", id="scan-phase")
with Horizontal(id="scan-progress-row"):
yield ProgressBar(total=100, show_eta=False, id="scan-pbar")
with Horizontal(id="scan-controls"):
yield Label("Start:")
yield Input("950", id="scan-start")
yield Label("Stop:")
yield Input("2150", id="scan-stop")
yield Label("LNB LO:")
yield Input("9750", id="scan-lnb")
yield Label("Threshold:")
yield Input("3", id="scan-thresh")
yield Button("Scan", id="scan-start-btn", variant="success")
yield Button("Stop", id="scan-stop-btn", variant="error")
def on_hide(self) -> None:
self._stop_scan()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "scan-start-btn":
self._start_scan()
elif event.button.id == "scan-stop-btn":
self._stop_scan()
def _start_scan(self) -> None:
if self._scanning:
return
self._scanning = True
start = float(self.query_one("#scan-start", Input).value or "950")
stop = float(self.query_one("#scan-stop", Input).value or "2150")
lnb_lo = float(self.query_one("#scan-lnb", Input).value or "9750")
threshold = float(self.query_one("#scan-thresh", Input).value or "3")
# Clear previous results
self.query_one("#scan-table", FrequencyTable).clear_table()
self._scan_worker = self._do_scan(start, stop, lnb_lo, threshold)
def _stop_scan(self) -> None:
self._scanning = False
if self._scan_worker:
self._scan_worker.cancel()
self._scan_worker = None
@work(thread=True)
def _do_scan(self, start: float, stop: float, lnb_lo: float,
threshold: float) -> None:
"""Multi-phase scan pipeline in a background thread."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
from skywalker_lib import detect_peaks, if_to_rf
try:
self._bridge.ensure_booted()
except Exception:
pass
# Phase 1: Coarse sweep
self.app.call_from_thread(self._set_phase, "Phase 1: Coarse sweep", 0)
coarse_step = 10
def coarse_cb(freq, step_num, total, result):
pct = (step_num + 1) / total * 100
self.app.call_from_thread(self._set_progress, pct)
freqs, powers, results = self._bridge.sweep_spectrum(
start, stop, coarse_step, dwell_ms=15, sr_ksps=20000,
callback=coarse_cb,
)
if not self._scanning:
return
self.app.call_from_thread(self._update_spectrum, freqs, powers, results, lnb_lo)
# Phase 2: Peak detection
self.app.call_from_thread(self._set_phase, "Phase 2: Peak detection", 50)
peaks = detect_peaks(freqs, powers, threshold_db=threshold)
if not peaks:
self.app.call_from_thread(self._set_phase, "No peaks found", 100)
self._scanning = False
return
# Phase 3: Fine sweep around peaks
self.app.call_from_thread(
self._set_phase,
f"Phase 3: Fine sweep ({len(peaks)} peaks)", 60,
)
refined = []
for i, (freq, pwr, idx) in enumerate(peaks):
if not self._scanning:
return
fine_start = max(start, freq - 15)
fine_stop = min(stop, freq + 15)
fine_freqs, fine_powers, fine_results = self._bridge.sweep_spectrum(
fine_start, fine_stop, step_mhz=2.0, dwell_ms=20, sr_ksps=20000,
)
if fine_powers:
best_idx = fine_powers.index(max(fine_powers))
refined.append((
fine_freqs[best_idx], fine_powers[best_idx],
fine_results[best_idx],
))
pct = 60 + (i + 1) / len(peaks) * 20
self.app.call_from_thread(self._set_progress, pct)
# Phase 4: Blind scan
self.app.call_from_thread(
self._set_phase,
f"Phase 4: Blind scan ({len(refined)} candidates)", 80,
)
sr_min = 1000 * 1000
sr_max = 30000 * 1000
sr_step = 500 * 1000
for i, (freq, pwr, result) in enumerate(refined):
if not self._scanning:
return
freq_khz = int(freq * 1000)
bs_result = self._bridge.blind_scan(freq_khz, sr_min, sr_max, sr_step)
if bs_result and bs_result.get("locked"):
tp = {
"if_mhz": bs_result.get("freq_khz", freq_khz) / 1000.0,
"rf_mhz": if_to_rf(
bs_result.get("freq_khz", freq_khz) / 1000.0, lnb_lo
),
"sr_ksps": bs_result.get("sr_sps", 0) // 1000,
"power_db": pwr,
"locked": True,
}
self.app.call_from_thread(self._add_transponder, tp)
pct = 80 + (i + 1) / len(refined) * 20
self.app.call_from_thread(self._set_progress, pct)
self.app.call_from_thread(self._set_phase, "Scan complete", 100)
self._scanning = False
def _set_phase(self, text: str, progress: float) -> None:
if not self.is_mounted:
return
self.query_one("#scan-phase", Static).update(f"[#00d4aa]{text}[/]")
self.query_one("#scan-pbar", ProgressBar).update(progress=progress)
def _set_progress(self, pct: float) -> None:
if not self.is_mounted:
return
self.query_one("#scan-pbar", ProgressBar).update(progress=pct)
def _update_spectrum(self, freqs, powers, results, lnb_lo) -> None:
if not self.is_mounted:
return
self.query_one("#scan-spectrum", SpectrumPlot).update_data(
freqs, powers, results, lnb_lo=lnb_lo,
)
def _add_transponder(self, tp: dict) -> None:
if not self.is_mounted:
return
self.query_one("#scan-table", FrequencyTable).add_transponder(tp)

View file

@ -1,206 +1,206 @@
"""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 (9502150 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.1500 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)
"""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 (9502150 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.1500 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)

View file

@ -1,151 +1,151 @@
"""Splash screen — renders 16colo.rs art on startup.
Pre-baked ANSI half-block art (.ans files) display instantly no runtime
image decoding or terminal protocol detection needed. Generated by
scripts/prebake_splash.py from the original artwork.
Randomly selects from bundled artwork on each launch. Auto-dismisses
after 5 seconds or on any keypress.
"""
import os
import random
from pathlib import Path
from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.containers import Vertical
from textual.widgets import Static
from rich.text import Text
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" / "splash"
# Artwork catalog — stem (sans extension), artist, title
ART_CATALOG = [
("seti-satellite", "Illarterate", "S.E.T.I. Satellite"),
("dialtone", "192.168.10.13", "Dialtone"),
("so-far-away", "Blippypixel", "So Far Away"),
("prodigy-out-of-space", "Jellica Jake", "Prodigy / Out of Space"),
("space-docker", "Blippypixel", "Space Docker"),
]
def _is_kitty() -> bool:
"""Detect if running inside Kitty terminal."""
return bool(os.environ.get("KITTY_WINDOW_ID"))
class SplashScreen(Screen):
"""Full-screen splash with 16colo.rs art, auto-dismisses."""
BINDINGS = [
Binding("escape", "dismiss_splash", "Skip", show=False),
]
DEFAULT_CSS = """
SplashScreen {
align: center middle;
background: #000000;
}
SplashScreen #splash-container {
width: 100%;
height: 100%;
align: center middle;
background: #000000;
}
SplashScreen #splash-title {
height: 1;
color: #00d4aa;
text-style: bold;
text-align: center;
dock: bottom;
margin: 0 0 2 0;
}
SplashScreen #splash-credit {
height: 1;
color: #506878;
text-align: center;
dock: bottom;
margin: 0 0 1 0;
}
SplashScreen #splash-skip {
height: 1;
color: #2a3a4a;
text-align: center;
dock: bottom;
}
SplashScreen #splash-art {
width: 100%;
height: 1fr;
content-align: center middle;
background: #000000;
}
SplashScreen #splash-fallback {
width: 100%;
height: 1fr;
content-align: center middle;
color: #00d4aa;
text-style: bold;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._ans_path: Path | None = None
self._artist = ""
self._title = ""
self._select_art()
def _select_art(self) -> None:
"""Pick a random artwork from available pre-baked .ans files."""
available = []
for stem, artist, title in ART_CATALOG:
ans_path = ASSETS_DIR / f"{stem}.ans"
if ans_path.exists():
available.append((ans_path, artist, title))
if available:
self._ans_path, self._artist, self._title = random.choice(available)
def compose(self) -> ComposeResult:
with Vertical(id="splash-container"):
if self._ans_path:
ansi_content = self._ans_path.read_text()
yield Static(Text.from_ansi(ansi_content), id="splash-art")
else:
yield Static(
"S K Y W A L K E R - 1",
id="splash-fallback",
)
kitty_tag = " \U0001f431" if _is_kitty() else ""
yield Static(
f"S K Y W A L K E R - 1 / DVB-S RF Tool{kitty_tag}",
id="splash-title",
)
if self._artist:
yield Static(
f"Art by {self._artist} \u2014 16colo.rs/mistigris",
id="splash-credit",
)
yield Static("[any key to skip]", id="splash-skip")
def on_mount(self) -> None:
# Brief delay before accepting key dismissal — prevents eating
# keystrokes from the transition that pushed this screen
self._accept_keys = False
self.set_timer(0.3, self._enable_keys)
self.set_timer(5, self.action_dismiss_splash)
def _enable_keys(self) -> None:
self._accept_keys = True
def on_key(self) -> None:
if getattr(self, "_accept_keys", False):
self.action_dismiss_splash()
def action_dismiss_splash(self) -> None:
if self.is_current:
self.app.pop_screen()
"""Splash screen — renders 16colo.rs art on startup.
Pre-baked ANSI half-block art (.ans files) display instantly no runtime
image decoding or terminal protocol detection needed. Generated by
scripts/prebake_splash.py from the original artwork.
Randomly selects from bundled artwork on each launch. Auto-dismisses
after 5 seconds or on any keypress.
"""
import os
import random
from pathlib import Path
from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.containers import Vertical
from textual.widgets import Static
from rich.text import Text
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" / "splash"
# Artwork catalog — stem (sans extension), artist, title
ART_CATALOG = [
("seti-satellite", "Illarterate", "S.E.T.I. Satellite"),
("dialtone", "192.168.10.13", "Dialtone"),
("so-far-away", "Blippypixel", "So Far Away"),
("prodigy-out-of-space", "Jellica Jake", "Prodigy / Out of Space"),
("space-docker", "Blippypixel", "Space Docker"),
]
def _is_kitty() -> bool:
"""Detect if running inside Kitty terminal."""
return bool(os.environ.get("KITTY_WINDOW_ID"))
class SplashScreen(Screen):
"""Full-screen splash with 16colo.rs art, auto-dismisses."""
BINDINGS = [
Binding("escape", "dismiss_splash", "Skip", show=False),
]
DEFAULT_CSS = """
SplashScreen {
align: center middle;
background: #000000;
}
SplashScreen #splash-container {
width: 100%;
height: 100%;
align: center middle;
background: #000000;
}
SplashScreen #splash-title {
height: 1;
color: #00d4aa;
text-style: bold;
text-align: center;
dock: bottom;
margin: 0 0 2 0;
}
SplashScreen #splash-credit {
height: 1;
color: #506878;
text-align: center;
dock: bottom;
margin: 0 0 1 0;
}
SplashScreen #splash-skip {
height: 1;
color: #2a3a4a;
text-align: center;
dock: bottom;
}
SplashScreen #splash-art {
width: 100%;
height: 1fr;
content-align: center middle;
background: #000000;
}
SplashScreen #splash-fallback {
width: 100%;
height: 1fr;
content-align: center middle;
color: #00d4aa;
text-style: bold;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._ans_path: Path | None = None
self._artist = ""
self._title = ""
self._select_art()
def _select_art(self) -> None:
"""Pick a random artwork from available pre-baked .ans files."""
available = []
for stem, artist, title in ART_CATALOG:
ans_path = ASSETS_DIR / f"{stem}.ans"
if ans_path.exists():
available.append((ans_path, artist, title))
if available:
self._ans_path, self._artist, self._title = random.choice(available)
def compose(self) -> ComposeResult:
with Vertical(id="splash-container"):
if self._ans_path:
ansi_content = self._ans_path.read_text()
yield Static(Text.from_ansi(ansi_content), id="splash-art")
else:
yield Static(
"S K Y W A L K E R - 1",
id="splash-fallback",
)
kitty_tag = " \U0001f431" if _is_kitty() else ""
yield Static(
f"S K Y W A L K E R - 1 / DVB-S RF Tool{kitty_tag}",
id="splash-title",
)
if self._artist:
yield Static(
f"Art by {self._artist} \u2014 16colo.rs/mistigris",
id="splash-credit",
)
yield Static("[any key to skip]", id="splash-skip")
def on_mount(self) -> None:
# Brief delay before accepting key dismissal — prevents eating
# keystrokes from the transition that pushed this screen
self._accept_keys = False
self.set_timer(0.3, self._enable_keys)
self.set_timer(5, self.action_dismiss_splash)
def _enable_keys(self) -> None:
self._accept_keys = True
def on_key(self) -> None:
if getattr(self, "_accept_keys", False):
self.action_dismiss_splash()
def action_dismiss_splash(self) -> None:
if self.is_current:
self.app.pop_screen()

View file

@ -1,448 +1,448 @@
"""Star Wars Easter egg — ASCII Star Wars via telnet with offline fallback.
Attempts to connect to towel.blinkenlights.nl:23 for the famous ASCII
Star Wars animation. If the network blocks port 23 (common), falls back
to a built-in opening crawl with ASCII art.
The telnet stream uses VT100 escape sequences:
ESC[H cursor home (frame boundary)
ESC[J clear to end of screen
ESC[...m SGR color/style codes
We detect ESC[H as "new frame", buffer the frame text, then render it
atomically via Rich's Text.from_ansi() which handles native ANSI styling.
Hidden binding: ctrl+w (W for Wars/Walker).
"""
import socket
import time
from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.containers import Vertical
from textual.widgets import Static, RichLog
from textual import work
from rich.text import Text
TELNET_HOST = "towel.blinkenlights.nl"
TELNET_PORT = 23
RECV_SIZE = 4096
CONNECT_TIMEOUT = 8
# ── Built-in offline crawl ────────────────────────────────────────────
# Shown when telnet is unreachable. Frames are (lines, delay_seconds).
_CRAWL_FRAMES = [
# Pause on black
([""], 2.0),
# Title card
([
"",
"",
"",
" . . . . . . . . .",
"",
" A long time ago in a galaxy far,",
" far away....",
"",
"",
], 3.0),
# Clear + logo
([
"",
"",
r" ________________. ___ .______",
r" / | / \ | _ \ ",
r" | (-----| |----`/ ^ \ | |_) |",
r" \ \ | | / /_\ \ | /",
r" .-----) | | | / _____ \ | |\ \------.",
r" |________/ |__| /__/ \__\| _| `.________|",
"",
r" ____ __ ____ ___ .______ ________.",
r" \ \ / \ / / / \ | _ \ / |",
r" \ \/ \/ / / ^ \ | |_) || (-----`",
r" \ / / /_\ \ | / \ \ ",
r" \ /\ / / _____ \ | |\ \---) |",
r" \__/ \__/ /__/ \__\|__| `._______/",
"",
"",
], 4.0),
# Episode info
([
"",
"",
"",
" Episode IV.I",
"",
" A N E W F R E Q U E N C Y",
"",
"",
], 3.0),
# Opening crawl text — the SkyWalker-1 version
([
" It is a period of civil engineering.",
" Rebel hackers, armed with USB cables",
" and logic analyzers, have won their",
" first victory against the evil",
" Proprietary Firmware Empire.",
"",
" During the battle, rebel spies managed",
" to steal secret plans to the Empire's",
" ultimate weapon, the GP8PSK USB protocol,",
" a device with enough power to receive",
" an entire satellite transponder.",
"",
" Pursued by the Empire's sinister agents,",
" Princess SkyWalker races home aboard her",
" DVB-S receiver, custodian of the stolen",
" vendor commands that can save her people",
" and restore signal lock to the galaxy....",
"",
], 0.18), # per-line scroll for this frame
# Star Destroyer ASCII art
([
"",
"",
"",
" . ",
" /|\\ ",
" / | \\ ",
" / | \\ ",
" / | \\ ",
" ____/ | \\____ ",
" ____/ _______|_______ \\____ ",
" ____/ ____/ | \\____ \\____",
" _____/ ___/ | \\____\\_____ ",
" /______/=====_____________|_____________=====\\______\\",
" \\ \\============|============/ /",
" \\ \\ | / /",
" \\ |__________|__________| /",
" \\ | | | /",
" \\___|__________|__________|___/",
" \\ | /",
" \\_______/ \\_______/",
"",
"",
" >>> GENPIX SKYWALKER-1 DVB-S RECEIVER <<<",
" Firmware reversed. Signal acquired.",
"",
"",
], 3.5),
# Credits
([
"",
" ==========================================",
" Directed by .............. Ryan Malloy",
" Firmware by ........... Genpix Electronics",
" Reversed by ........... USB sniffers & gdb",
" Radar Scope by ........ P1 green phosphor",
" Theme Music by ........ 22 kHz tone burst",
"",
" ASCII Star Wars originally by:",
" Simon Jansen (asciimation.co.nz)",
" Sten Spans (blinkenlights.nl)",
" Mike Edwards (terminal tricks)",
"",
" Telnet blocked? Blame your ISP.",
" ==========================================",
"",
"",
" [ESC to close]",
"",
], 0),
]
class _TelnetStripper:
"""Stateful telnet IAC sequence stripper that handles chunk boundaries.
Telnet IAC sequences can span TCP segment boundaries. This class
buffers partial sequences across feed() calls so they're correctly
stripped even when split across recv() chunks.
"""
def __init__(self):
self._pending = b""
def feed(self, data: bytes) -> bytes:
data = self._pending + data
self._pending = b""
result = bytearray()
i = 0
while i < len(data):
if data[i] == 0xFF:
if i + 1 >= len(data):
self._pending = data[i:]
break
if data[i + 1] == 0xFF: # escaped 0xFF
result.append(0xFF)
i += 2
elif data[i + 1] in (0xFB, 0xFC, 0xFD, 0xFE): # WILL/WONT/DO/DONT
if i + 2 >= len(data):
self._pending = data[i:]
break
i += 3
elif data[i + 1] == 0xFA: # Sub-negotiation
end = data.find(b"\xff\xf0", i)
if end == -1:
self._pending = data[i:]
break
i = end + 2
else:
i += 2
else:
result.append(data[i])
i += 1
return bytes(result)
class StarWarsScreen(Screen):
"""Modal overlay streaming ASCII Star Wars from telnet, with offline fallback."""
BINDINGS = [
Binding("escape", "dismiss", "Close", show=True),
Binding("q", "dismiss", "Close", show=False),
]
DEFAULT_CSS = """
StarWarsScreen {
align: center middle;
background: #000000 90%;
}
StarWarsScreen #sw-container {
width: 90%;
height: 90%;
background: #000000;
border: round #1a3050;
}
StarWarsScreen #sw-header {
height: 1;
color: #e8a020;
text-style: bold;
text-align: center;
padding: 0 1;
}
StarWarsScreen #sw-log {
height: 1fr;
background: #000000;
color: #c8d0d8;
}
StarWarsScreen #sw-footer {
height: 1;
color: #506878;
text-align: center;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._socket: socket.socket | None = None
self._running = False
def compose(self) -> ComposeResult:
with Vertical(id="sw-container"):
yield Static(
"A long time ago in a galaxy far, far away....",
id="sw-header",
)
yield RichLog(id="sw-log", wrap=False, markup=False)
yield Static("[ESC] Close", id="sw-footer")
def on_mount(self) -> None:
self._running = True
self._stream_starwars()
def on_unmount(self) -> None:
self._running = False
self._close_socket()
def action_dismiss(self) -> None:
self._running = False
self._close_socket()
self.app.pop_screen()
def _close_socket(self) -> None:
if self._socket:
try:
self._socket.close()
except Exception:
pass
self._socket = None
def _safe_write(self, text: str) -> None:
"""Write to RichLog only if screen is still mounted and running."""
if not self._running or not self.is_mounted:
return
try:
log = self.query_one("#sw-log", RichLog)
log.write(text)
except Exception:
pass
def _safe_clear(self) -> None:
"""Clear the RichLog."""
if not self._running or not self.is_mounted:
return
try:
self.query_one("#sw-log", RichLog).clear()
except Exception:
pass
@work(thread=True)
def _stream_starwars(self) -> None:
"""Try telnet first, fall back to built-in crawl."""
self.app.call_from_thread(
self._safe_write, "Connecting to towel.blinkenlights.nl:23..."
)
if self._try_telnet():
return # telnet worked, we're done
# Telnet failed — play the built-in crawl
self.app.call_from_thread(self._safe_write, "Falling back to local crawl...\n")
time.sleep(1.0)
self.app.call_from_thread(self._safe_clear)
self._play_offline_crawl()
def _try_telnet(self) -> bool:
"""Attempt telnet connection. Returns True if streaming completed.
Uses AF_UNSPEC to try both IPv6 and IPv4 (happy eyeballs style).
Many networks route IPv6 correctly but block or drop IPv4 on port 23.
"""
try:
addrs = socket.getaddrinfo(TELNET_HOST, TELNET_PORT,
socket.AF_UNSPEC, socket.SOCK_STREAM)
if not addrs:
raise socket.gaierror("No address found")
labels = []
for fam, _, _, _, sa in addrs:
tag = "v6" if fam == socket.AF_INET6 else "v4"
labels.append(f"{tag}:{sa[0]}")
self.app.call_from_thread(
self._safe_write, f"Resolved: {', '.join(labels)}"
)
except socket.gaierror as e:
self.app.call_from_thread(
self._safe_write, f"DNS failed: {e}"
)
return False
# Try each address until one connects (IPv6 first if available)
sock = None
last_err = None
for fam, socktype, proto, _canon, sa in addrs:
tag = "v6" if fam == socket.AF_INET6 else "v4"
self.app.call_from_thread(
self._safe_write, f"Trying {tag}:{sa[0]}..."
)
try:
s = socket.socket(fam, socktype, proto)
s.settimeout(CONNECT_TIMEOUT)
s.connect(sa)
s.settimeout(2.0)
sock = s
break
except (socket.timeout, OSError) as e:
last_err = e
try:
s.close()
except Exception:
pass
if sock is None:
self.app.call_from_thread(
self._safe_write, f"All addresses failed: {last_err}"
)
return False
self._socket = sock
self.app.call_from_thread(self._safe_write, "Connected! Streaming...\n")
stripper = _TelnetStripper()
buffer = b""
while self._running:
try:
chunk = sock.recv(RECV_SIZE)
if not chunk:
break
clean = stripper.feed(chunk)
buffer += clean
# ESC[H (cursor home) marks frame boundaries.
# Buffer until we have a complete frame, then render atomically.
while b"\x1b[H" in buffer:
frame_data, buffer = buffer.split(b"\x1b[H", 1)
if frame_data.strip():
self.app.call_from_thread(
self._render_frame, frame_data
)
except socket.timeout:
# Timeout with no ESC[H — flush buffer as partial frame
if buffer.strip():
self.app.call_from_thread(
self._render_frame, buffer
)
buffer = b""
continue
except Exception:
break
# Flush anything remaining
if buffer.strip():
self.app.call_from_thread(self._render_frame, buffer)
self.app.call_from_thread(self._safe_write, "\n[Stream ended]")
self._close_socket()
return True
def _render_frame(self, data: bytes) -> None:
"""Render a complete animation frame, replacing previous content.
Uses Rich's Text.from_ansi() to natively handle any ANSI styling
in the stream. ESC[J (clear) is stripped since we replace the
whole frame anyway.
"""
if not self._running or not self.is_mounted:
return
try:
log = self.query_one("#sw-log", RichLog)
log.clear()
# Decode frame, strip ESC[J (redundant — we clear anyway)
text = data.replace(b"\x1b[J", b"")
text = text.decode("ascii", errors="replace")
text = text.replace("\r", "")
# Rich's from_ansi() renders ANSI color/style codes natively
log.write(Text.from_ansi(text))
except Exception:
pass
def _play_offline_crawl(self) -> None:
"""Play the built-in Star Wars opening crawl frame by frame."""
for lines, delay in _CRAWL_FRAMES:
if not self._running:
return
# The opening crawl frame scrolls line-by-line
if delay > 0 and delay < 0.5 and len(lines) > 3:
# Per-line scroll mode
self.app.call_from_thread(self._safe_clear)
for line in lines:
if not self._running:
return
self.app.call_from_thread(self._safe_write, line)
time.sleep(delay)
else:
# Full-frame mode
self.app.call_from_thread(self._safe_clear)
for line in lines:
if not self._running:
return
self.app.call_from_thread(self._safe_write, line)
if delay > 0:
time.sleep(delay)
"""Star Wars Easter egg — ASCII Star Wars via telnet with offline fallback.
Attempts to connect to towel.blinkenlights.nl:23 for the famous ASCII
Star Wars animation. If the network blocks port 23 (common), falls back
to a built-in opening crawl with ASCII art.
The telnet stream uses VT100 escape sequences:
ESC[H cursor home (frame boundary)
ESC[J clear to end of screen
ESC[...m SGR color/style codes
We detect ESC[H as "new frame", buffer the frame text, then render it
atomically via Rich's Text.from_ansi() which handles native ANSI styling.
Hidden binding: ctrl+w (W for Wars/Walker).
"""
import socket
import time
from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.containers import Vertical
from textual.widgets import Static, RichLog
from textual import work
from rich.text import Text
TELNET_HOST = "towel.blinkenlights.nl"
TELNET_PORT = 23
RECV_SIZE = 4096
CONNECT_TIMEOUT = 8
# ── Built-in offline crawl ────────────────────────────────────────────
# Shown when telnet is unreachable. Frames are (lines, delay_seconds).
_CRAWL_FRAMES = [
# Pause on black
([""], 2.0),
# Title card
([
"",
"",
"",
" . . . . . . . . .",
"",
" A long time ago in a galaxy far,",
" far away....",
"",
"",
], 3.0),
# Clear + logo
([
"",
"",
r" ________________. ___ .______",
r" / | / \ | _ \ ",
r" | (-----| |----`/ ^ \ | |_) |",
r" \ \ | | / /_\ \ | /",
r" .-----) | | | / _____ \ | |\ \------.",
r" |________/ |__| /__/ \__\| _| `.________|",
"",
r" ____ __ ____ ___ .______ ________.",
r" \ \ / \ / / / \ | _ \ / |",
r" \ \/ \/ / / ^ \ | |_) || (-----`",
r" \ / / /_\ \ | / \ \ ",
r" \ /\ / / _____ \ | |\ \---) |",
r" \__/ \__/ /__/ \__\|__| `._______/",
"",
"",
], 4.0),
# Episode info
([
"",
"",
"",
" Episode IV.I",
"",
" A N E W F R E Q U E N C Y",
"",
"",
], 3.0),
# Opening crawl text — the SkyWalker-1 version
([
" It is a period of civil engineering.",
" Rebel hackers, armed with USB cables",
" and logic analyzers, have won their",
" first victory against the evil",
" Proprietary Firmware Empire.",
"",
" During the battle, rebel spies managed",
" to steal secret plans to the Empire's",
" ultimate weapon, the GP8PSK USB protocol,",
" a device with enough power to receive",
" an entire satellite transponder.",
"",
" Pursued by the Empire's sinister agents,",
" Princess SkyWalker races home aboard her",
" DVB-S receiver, custodian of the stolen",
" vendor commands that can save her people",
" and restore signal lock to the galaxy....",
"",
], 0.18), # per-line scroll for this frame
# Star Destroyer ASCII art
([
"",
"",
"",
" . ",
" /|\\ ",
" / | \\ ",
" / | \\ ",
" / | \\ ",
" ____/ | \\____ ",
" ____/ _______|_______ \\____ ",
" ____/ ____/ | \\____ \\____",
" _____/ ___/ | \\____\\_____ ",
" /______/=====_____________|_____________=====\\______\\",
" \\ \\============|============/ /",
" \\ \\ | / /",
" \\ |__________|__________| /",
" \\ | | | /",
" \\___|__________|__________|___/",
" \\ | /",
" \\_______/ \\_______/",
"",
"",
" >>> GENPIX SKYWALKER-1 DVB-S RECEIVER <<<",
" Firmware reversed. Signal acquired.",
"",
"",
], 3.5),
# Credits
([
"",
" ==========================================",
" Directed by .............. Ryan Malloy",
" Firmware by ........... Genpix Electronics",
" Reversed by ........... USB sniffers & gdb",
" Radar Scope by ........ P1 green phosphor",
" Theme Music by ........ 22 kHz tone burst",
"",
" ASCII Star Wars originally by:",
" Simon Jansen (asciimation.co.nz)",
" Sten Spans (blinkenlights.nl)",
" Mike Edwards (terminal tricks)",
"",
" Telnet blocked? Blame your ISP.",
" ==========================================",
"",
"",
" [ESC to close]",
"",
], 0),
]
class _TelnetStripper:
"""Stateful telnet IAC sequence stripper that handles chunk boundaries.
Telnet IAC sequences can span TCP segment boundaries. This class
buffers partial sequences across feed() calls so they're correctly
stripped even when split across recv() chunks.
"""
def __init__(self):
self._pending = b""
def feed(self, data: bytes) -> bytes:
data = self._pending + data
self._pending = b""
result = bytearray()
i = 0
while i < len(data):
if data[i] == 0xFF:
if i + 1 >= len(data):
self._pending = data[i:]
break
if data[i + 1] == 0xFF: # escaped 0xFF
result.append(0xFF)
i += 2
elif data[i + 1] in (0xFB, 0xFC, 0xFD, 0xFE): # WILL/WONT/DO/DONT
if i + 2 >= len(data):
self._pending = data[i:]
break
i += 3
elif data[i + 1] == 0xFA: # Sub-negotiation
end = data.find(b"\xff\xf0", i)
if end == -1:
self._pending = data[i:]
break
i = end + 2
else:
i += 2
else:
result.append(data[i])
i += 1
return bytes(result)
class StarWarsScreen(Screen):
"""Modal overlay streaming ASCII Star Wars from telnet, with offline fallback."""
BINDINGS = [
Binding("escape", "dismiss", "Close", show=True),
Binding("q", "dismiss", "Close", show=False),
]
DEFAULT_CSS = """
StarWarsScreen {
align: center middle;
background: #000000 90%;
}
StarWarsScreen #sw-container {
width: 90%;
height: 90%;
background: #000000;
border: round #1a3050;
}
StarWarsScreen #sw-header {
height: 1;
color: #e8a020;
text-style: bold;
text-align: center;
padding: 0 1;
}
StarWarsScreen #sw-log {
height: 1fr;
background: #000000;
color: #c8d0d8;
}
StarWarsScreen #sw-footer {
height: 1;
color: #506878;
text-align: center;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._socket: socket.socket | None = None
self._running = False
def compose(self) -> ComposeResult:
with Vertical(id="sw-container"):
yield Static(
"A long time ago in a galaxy far, far away....",
id="sw-header",
)
yield RichLog(id="sw-log", wrap=False, markup=False)
yield Static("[ESC] Close", id="sw-footer")
def on_mount(self) -> None:
self._running = True
self._stream_starwars()
def on_unmount(self) -> None:
self._running = False
self._close_socket()
def action_dismiss(self) -> None:
self._running = False
self._close_socket()
self.app.pop_screen()
def _close_socket(self) -> None:
if self._socket:
try:
self._socket.close()
except Exception:
pass
self._socket = None
def _safe_write(self, text: str) -> None:
"""Write to RichLog only if screen is still mounted and running."""
if not self._running or not self.is_mounted:
return
try:
log = self.query_one("#sw-log", RichLog)
log.write(text)
except Exception:
pass
def _safe_clear(self) -> None:
"""Clear the RichLog."""
if not self._running or not self.is_mounted:
return
try:
self.query_one("#sw-log", RichLog).clear()
except Exception:
pass
@work(thread=True)
def _stream_starwars(self) -> None:
"""Try telnet first, fall back to built-in crawl."""
self.app.call_from_thread(
self._safe_write, "Connecting to towel.blinkenlights.nl:23..."
)
if self._try_telnet():
return # telnet worked, we're done
# Telnet failed — play the built-in crawl
self.app.call_from_thread(self._safe_write, "Falling back to local crawl...\n")
time.sleep(1.0)
self.app.call_from_thread(self._safe_clear)
self._play_offline_crawl()
def _try_telnet(self) -> bool:
"""Attempt telnet connection. Returns True if streaming completed.
Uses AF_UNSPEC to try both IPv6 and IPv4 (happy eyeballs style).
Many networks route IPv6 correctly but block or drop IPv4 on port 23.
"""
try:
addrs = socket.getaddrinfo(TELNET_HOST, TELNET_PORT,
socket.AF_UNSPEC, socket.SOCK_STREAM)
if not addrs:
raise socket.gaierror("No address found")
labels = []
for fam, _, _, _, sa in addrs:
tag = "v6" if fam == socket.AF_INET6 else "v4"
labels.append(f"{tag}:{sa[0]}")
self.app.call_from_thread(
self._safe_write, f"Resolved: {', '.join(labels)}"
)
except socket.gaierror as e:
self.app.call_from_thread(
self._safe_write, f"DNS failed: {e}"
)
return False
# Try each address until one connects (IPv6 first if available)
sock = None
last_err = None
for fam, socktype, proto, _canon, sa in addrs:
tag = "v6" if fam == socket.AF_INET6 else "v4"
self.app.call_from_thread(
self._safe_write, f"Trying {tag}:{sa[0]}..."
)
try:
s = socket.socket(fam, socktype, proto)
s.settimeout(CONNECT_TIMEOUT)
s.connect(sa)
s.settimeout(2.0)
sock = s
break
except (socket.timeout, OSError) as e:
last_err = e
try:
s.close()
except Exception:
pass
if sock is None:
self.app.call_from_thread(
self._safe_write, f"All addresses failed: {last_err}"
)
return False
self._socket = sock
self.app.call_from_thread(self._safe_write, "Connected! Streaming...\n")
stripper = _TelnetStripper()
buffer = b""
while self._running:
try:
chunk = sock.recv(RECV_SIZE)
if not chunk:
break
clean = stripper.feed(chunk)
buffer += clean
# ESC[H (cursor home) marks frame boundaries.
# Buffer until we have a complete frame, then render atomically.
while b"\x1b[H" in buffer:
frame_data, buffer = buffer.split(b"\x1b[H", 1)
if frame_data.strip():
self.app.call_from_thread(
self._render_frame, frame_data
)
except socket.timeout:
# Timeout with no ESC[H — flush buffer as partial frame
if buffer.strip():
self.app.call_from_thread(
self._render_frame, buffer
)
buffer = b""
continue
except Exception:
break
# Flush anything remaining
if buffer.strip():
self.app.call_from_thread(self._render_frame, buffer)
self.app.call_from_thread(self._safe_write, "\n[Stream ended]")
self._close_socket()
return True
def _render_frame(self, data: bytes) -> None:
"""Render a complete animation frame, replacing previous content.
Uses Rich's Text.from_ansi() to natively handle any ANSI styling
in the stream. ESC[J (clear) is stripped since we replace the
whole frame anyway.
"""
if not self._running or not self.is_mounted:
return
try:
log = self.query_one("#sw-log", RichLog)
log.clear()
# Decode frame, strip ESC[J (redundant — we clear anyway)
text = data.replace(b"\x1b[J", b"")
text = text.decode("ascii", errors="replace")
text = text.replace("\r", "")
# Rich's from_ansi() renders ANSI color/style codes natively
log.write(Text.from_ansi(text))
except Exception:
pass
def _play_offline_crawl(self) -> None:
"""Play the built-in Star Wars opening crawl frame by frame."""
for lines, delay in _CRAWL_FRAMES:
if not self._running:
return
# The opening crawl frame scrolls line-by-line
if delay > 0 and delay < 0.5 and len(lines) > 3:
# Per-line scroll mode
self.app.call_from_thread(self._safe_clear)
for line in lines:
if not self._running:
return
self.app.call_from_thread(self._safe_write, line)
time.sleep(delay)
else:
# Full-frame mode
self.app.call_from_thread(self._safe_clear)
for line in lines:
if not self._running:
return
self.app.call_from_thread(self._safe_write, line)
if delay > 0:
time.sleep(delay)

View file

@ -1,401 +1,401 @@
"""Stream screen -- live MPEG-2 transport stream capture and analysis.
Reads raw TS data from the SkyWalker-1 bulk endpoint, parses 188-byte
packets in real time, and displays PID distribution statistics alongside
a hierarchical PSI (PAT/PMT) program structure tree.
Supports file capture mode for saving raw .ts files to disk.
arm_transfer(on) is always paired in try/finally to guarantee the USB
bulk endpoint is disarmed when monitoring stops.
"""
import time
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static
from textual import work
from textual.worker import Worker
from ts_analyze import (
TSPacket, PSIParser, parse_pat, parse_pmt,
KNOWN_PIDS, TS_PACKET_SIZE,
)
from skywalker_tui.widgets.pid_table import PidTable
from skywalker_tui.widgets.psi_tree import PsiTree
class StreamScreen(Container):
"""Live MPEG-2 TS monitor with PID analysis and PSI tree."""
DEFAULT_CSS = """
StreamScreen {
layout: vertical;
}
StreamScreen #stream-main {
height: 1fr;
layout: horizontal;
}
StreamScreen #stream-pid-col {
width: 3fr;
height: 1fr;
padding: 1;
}
StreamScreen #stream-psi-col {
width: 2fr;
height: 1fr;
padding: 1;
}
StreamScreen #stream-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
StreamScreen #stream-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
StreamScreen #stream-controls Input {
width: 14;
margin: 0 1;
}
StreamScreen #stream-controls Button {
margin: 0 1;
}
StreamScreen #stream-stats {
height: 3;
layout: horizontal;
padding: 0 2;
}
StreamScreen #stream-stats Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._monitoring = False
self._capturing = False
self._capture_file = None
self._monitor_worker: Worker | None = None
# Accumulated stats (written by worker thread, read by UI thread)
self._total_packets = 0
self._total_bytes = 0
self._tei_count = 0
self._pid_counts: dict[int, int] = {}
self._cc_last: dict[int, int] = {}
self._cc_errors: dict[int, int] = {}
self._start_time = 0.0
def compose(self) -> ComposeResult:
with Horizontal(id="stream-main"):
with Vertical(id="stream-pid-col"):
yield Static(
"[bold #00d4aa]PID Distribution[/]", classes="panel-title"
)
yield PidTable(id="stream-pid-table")
with Vertical(id="stream-psi-col"):
yield Static(
"[bold #00d4aa]Program Structure[/]", classes="panel-title"
)
yield PsiTree(id="stream-psi-tree")
with Horizontal(id="stream-stats"):
yield Static("[#506878]Packets:[/] [#00d4aa]0[/]", id="stat-packets")
yield Static("[#506878]Bytes:[/] [#00d4aa]0[/]", id="stat-bytes")
yield Static("[#506878]PIDs:[/] [#00d4aa]0[/]", id="stat-pids")
yield Static("[#506878]CC Errors:[/] [#00d4aa]0[/]", id="stat-cc")
yield Static(
"[#506878]Duration:[/] [#00d4aa]0.0s[/]", id="stat-duration"
)
with Horizontal(id="stream-controls"):
yield Button("Start Monitor", id="stream-start", variant="success")
yield Button("Stop", id="stream-stop", variant="error")
yield Label("Capture:")
yield Input("capture.ts", id="stream-capture-file")
yield Button("Capture", id="stream-capture", variant="warning")
yield Static("[#506878]Idle[/]", id="stream-status")
def on_show(self) -> None:
"""Auto-start monitoring in demo mode when this screen becomes visible."""
if self._bridge.is_demo and not self._monitoring:
self._start_monitor()
def on_hide(self) -> None:
"""Stop monitoring when navigating away from this screen."""
self._stop_monitor()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "stream-start":
self._start_monitor()
elif event.button.id == "stream-stop":
self._stop_monitor()
elif event.button.id == "stream-capture":
self._toggle_capture()
# -- Monitor lifecycle --
def _start_monitor(self) -> None:
"""Begin streaming from the device bulk endpoint."""
if self._monitoring:
return
self._monitoring = True
self._reset_stats()
self._start_time = time.monotonic()
try:
self.query_one("#stream-status", Static).update(
"[bold #00d4aa]Monitoring[/]"
)
except Exception:
pass
self._monitor_worker = self._do_monitor()
def _stop_monitor(self) -> None:
"""Stop the monitor worker and clean up capture state."""
self._monitoring = False
self._capturing = False
# Cancel worker BEFORE closing file — worker may still be writing
if self._monitor_worker is not None:
self._monitor_worker.cancel()
self._monitor_worker = None
if self._capture_file is not None:
self._capture_file.close()
self._capture_file = None
try:
self.query_one("#stream-status", Static).update(
"[#506878]Stopped[/]"
)
except Exception:
pass
def _toggle_capture(self) -> None:
"""Toggle file capture on/off. Starts monitor if not already running."""
if self._capturing:
self._capturing = False
if self._capture_file is not None:
self._capture_file.close()
self._capture_file = None
try:
self.query_one("#stream-capture", Button).label = "Capture"
except Exception:
pass
return
# Start capture
if not self._monitoring:
self._start_monitor()
filename = self.query_one("#stream-capture-file", Input).value or "capture.ts"
try:
self._capture_file = open(filename, "wb")
self._capturing = True
self.query_one("#stream-capture", Button).label = "Stop Capture"
except OSError as e:
self.app.notify(f"Cannot open {filename}: {e}", severity="error")
def _reset_stats(self) -> None:
"""Zero all counters and clear display widgets."""
self._total_packets = 0
self._total_bytes = 0
self._tei_count = 0
self._pid_counts.clear()
self._cc_last.clear()
self._cc_errors.clear()
try:
self.query_one("#stream-pid-table", PidTable).clear_table()
self.query_one("#stream-psi-tree", PsiTree).clear_tree()
except Exception:
pass
# -- Background worker --
@work(thread=True)
def _do_monitor(self) -> None:
"""Background worker: read TS stream, parse packets, post UI updates.
Runs in a dedicated thread via Textual's @work(thread=True) decorator.
Calls arm_transfer(True) on entry and arm_transfer(False) in finally
to guarantee the bulk endpoint is always disarmed on exit.
"""
psi_pat = PSIParser()
psi_pmt = PSIParser()
pat = None
pmt_pids: set[int] = set()
last_ui_update = 0.0
try:
self._bridge.ensure_booted()
self._bridge.arm_transfer(True)
while self._monitoring:
data = self._bridge.read_stream(8192, timeout=1000)
if not data:
time.sleep(0.05)
continue
self._total_bytes += len(data)
# Write raw data to capture file if active
if self._capturing and self._capture_file is not None:
try:
self._capture_file.write(data)
except OSError:
self._capturing = False
# Parse 188-byte TS packets from the chunk
offset = 0
while offset + TS_PACKET_SIZE <= len(data):
if data[offset] != 0x47:
# Lost sync, scan forward for next sync byte
offset += 1
continue
try:
pkt = TSPacket(data[offset:offset + TS_PACKET_SIZE])
except (ValueError, IndexError):
offset += 1
continue
offset += TS_PACKET_SIZE
self._total_packets += 1
pid = pkt.pid
# PID counting
self._pid_counts[pid] = self._pid_counts.get(pid, 0) + 1
# TEI (Transport Error Indicator) check
if pkt.tei:
self._tei_count += 1
# Continuity counter check (payload-bearing, non-null PIDs)
if pkt.adaptation & 0x01 and pid != 0x1FFF:
if pid in self._cc_last:
expected = (self._cc_last[pid] + 1) & 0x0F
if (pkt.continuity != expected
and pkt.continuity != self._cc_last[pid]):
self._cc_errors[pid] = (
self._cc_errors.get(pid, 0) + 1
)
self._cc_last[pid] = pkt.continuity
# PAT parsing (PID 0x0000)
if pid == 0x0000:
section = psi_pat.feed(pkt)
if section is not None:
parsed = parse_pat(section)
if parsed is not None:
pat = parsed
for prog_num, pmt_pid in pat.get(
"programs", {}
).items():
if prog_num != 0:
pmt_pids.add(pmt_pid)
# PMT parsing (PIDs discovered from PAT)
if pid in pmt_pids:
section = psi_pmt.feed(pkt)
if section is not None:
parsed = parse_pmt(section)
if parsed is not None:
self.app.call_from_thread(
self._update_pmt, pid, parsed
)
# Batch UI updates every 500ms to avoid flooding the event loop
now = time.monotonic()
if now - last_ui_update >= 0.5:
last_ui_update = now
self.app.call_from_thread(
self._update_ui,
pat,
dict(self._pid_counts),
dict(self._cc_errors),
self._total_packets,
self._total_bytes,
self._tei_count,
)
finally:
for _attempt in range(2):
try:
self._bridge.arm_transfer(False)
break
except Exception:
time.sleep(0.1)
# -- UI update methods (called from main thread) --
def _update_ui(
self,
pat: dict | None,
pid_counts: dict[int, int],
cc_errors: dict[int, int],
total_pkts: int,
total_bytes: int,
tei_count: int,
) -> None:
"""Push accumulated stats to display widgets."""
if not self.is_mounted:
return
# Build known PID names by merging standard table with PAT-discovered PMTs
known = dict(KNOWN_PIDS)
if pat:
for prog_num, pmt_pid in pat.get("programs", {}).items():
if prog_num == 0:
known[pmt_pid] = "NIT"
else:
known[pmt_pid] = f"PMT (prog {prog_num})"
self.query_one("#stream-pid-table", PidTable).update_pids(
pid_counts, cc_errors, total_pkts, known
)
if pat:
self.query_one("#stream-psi-tree", PsiTree).update_pat(pat)
total_cc = sum(cc_errors.values())
duration = time.monotonic() - self._start_time
self.query_one("#stat-packets", Static).update(
f"[#506878]Packets:[/] [#00d4aa]{total_pkts:,}[/]"
)
if total_bytes >= 1_000_000:
bytes_str = f"{total_bytes / 1_000_000:.1f} MB"
elif total_bytes >= 1_000:
bytes_str = f"{total_bytes / 1_000:.1f} KB"
else:
bytes_str = str(total_bytes)
self.query_one("#stat-bytes", Static).update(
f"[#506878]Bytes:[/] [#00d4aa]{bytes_str}[/]"
)
self.query_one("#stat-pids", Static).update(
f"[#506878]PIDs:[/] [#00d4aa]{len(pid_counts)}[/]"
)
cc_color = "#e04040" if total_cc > 0 else "#00d4aa"
self.query_one("#stat-cc", Static).update(
f"[#506878]CC Errors:[/] [{cc_color}]{total_cc}[/]"
)
self.query_one("#stat-duration", Static).update(
f"[#506878]Duration:[/] [#00d4aa]{duration:.1f}s[/]"
)
def _update_pmt(self, pmt_pid: int, pmt: dict) -> None:
"""Push a newly parsed PMT to the PSI tree widget."""
if not self.is_mounted:
return
self.query_one("#stream-psi-tree", PsiTree).update_pmt(pmt_pid, pmt)
"""Stream screen -- live MPEG-2 transport stream capture and analysis.
Reads raw TS data from the SkyWalker-1 bulk endpoint, parses 188-byte
packets in real time, and displays PID distribution statistics alongside
a hierarchical PSI (PAT/PMT) program structure tree.
Supports file capture mode for saving raw .ts files to disk.
arm_transfer(on) is always paired in try/finally to guarantee the USB
bulk endpoint is disarmed when monitoring stops.
"""
import time
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static
from textual import work
from textual.worker import Worker
from ts_analyze import (
TSPacket, PSIParser, parse_pat, parse_pmt,
KNOWN_PIDS, TS_PACKET_SIZE,
)
from skywalker_tui.widgets.pid_table import PidTable
from skywalker_tui.widgets.psi_tree import PsiTree
class StreamScreen(Container):
"""Live MPEG-2 TS monitor with PID analysis and PSI tree."""
DEFAULT_CSS = """
StreamScreen {
layout: vertical;
}
StreamScreen #stream-main {
height: 1fr;
layout: horizontal;
}
StreamScreen #stream-pid-col {
width: 3fr;
height: 1fr;
padding: 1;
}
StreamScreen #stream-psi-col {
width: 2fr;
height: 1fr;
padding: 1;
}
StreamScreen #stream-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
StreamScreen #stream-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
StreamScreen #stream-controls Input {
width: 14;
margin: 0 1;
}
StreamScreen #stream-controls Button {
margin: 0 1;
}
StreamScreen #stream-stats {
height: 3;
layout: horizontal;
padding: 0 2;
}
StreamScreen #stream-stats Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._monitoring = False
self._capturing = False
self._capture_file = None
self._monitor_worker: Worker | None = None
# Accumulated stats (written by worker thread, read by UI thread)
self._total_packets = 0
self._total_bytes = 0
self._tei_count = 0
self._pid_counts: dict[int, int] = {}
self._cc_last: dict[int, int] = {}
self._cc_errors: dict[int, int] = {}
self._start_time = 0.0
def compose(self) -> ComposeResult:
with Horizontal(id="stream-main"):
with Vertical(id="stream-pid-col"):
yield Static(
"[bold #00d4aa]PID Distribution[/]", classes="panel-title"
)
yield PidTable(id="stream-pid-table")
with Vertical(id="stream-psi-col"):
yield Static(
"[bold #00d4aa]Program Structure[/]", classes="panel-title"
)
yield PsiTree(id="stream-psi-tree")
with Horizontal(id="stream-stats"):
yield Static("[#506878]Packets:[/] [#00d4aa]0[/]", id="stat-packets")
yield Static("[#506878]Bytes:[/] [#00d4aa]0[/]", id="stat-bytes")
yield Static("[#506878]PIDs:[/] [#00d4aa]0[/]", id="stat-pids")
yield Static("[#506878]CC Errors:[/] [#00d4aa]0[/]", id="stat-cc")
yield Static(
"[#506878]Duration:[/] [#00d4aa]0.0s[/]", id="stat-duration"
)
with Horizontal(id="stream-controls"):
yield Button("Start Monitor", id="stream-start", variant="success")
yield Button("Stop", id="stream-stop", variant="error")
yield Label("Capture:")
yield Input("capture.ts", id="stream-capture-file")
yield Button("Capture", id="stream-capture", variant="warning")
yield Static("[#506878]Idle[/]", id="stream-status")
def on_show(self) -> None:
"""Auto-start monitoring in demo mode when this screen becomes visible."""
if self._bridge.is_demo and not self._monitoring:
self._start_monitor()
def on_hide(self) -> None:
"""Stop monitoring when navigating away from this screen."""
self._stop_monitor()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "stream-start":
self._start_monitor()
elif event.button.id == "stream-stop":
self._stop_monitor()
elif event.button.id == "stream-capture":
self._toggle_capture()
# -- Monitor lifecycle --
def _start_monitor(self) -> None:
"""Begin streaming from the device bulk endpoint."""
if self._monitoring:
return
self._monitoring = True
self._reset_stats()
self._start_time = time.monotonic()
try:
self.query_one("#stream-status", Static).update(
"[bold #00d4aa]Monitoring[/]"
)
except Exception:
pass
self._monitor_worker = self._do_monitor()
def _stop_monitor(self) -> None:
"""Stop the monitor worker and clean up capture state."""
self._monitoring = False
self._capturing = False
# Cancel worker BEFORE closing file — worker may still be writing
if self._monitor_worker is not None:
self._monitor_worker.cancel()
self._monitor_worker = None
if self._capture_file is not None:
self._capture_file.close()
self._capture_file = None
try:
self.query_one("#stream-status", Static).update(
"[#506878]Stopped[/]"
)
except Exception:
pass
def _toggle_capture(self) -> None:
"""Toggle file capture on/off. Starts monitor if not already running."""
if self._capturing:
self._capturing = False
if self._capture_file is not None:
self._capture_file.close()
self._capture_file = None
try:
self.query_one("#stream-capture", Button).label = "Capture"
except Exception:
pass
return
# Start capture
if not self._monitoring:
self._start_monitor()
filename = self.query_one("#stream-capture-file", Input).value or "capture.ts"
try:
self._capture_file = open(filename, "wb")
self._capturing = True
self.query_one("#stream-capture", Button).label = "Stop Capture"
except OSError as e:
self.app.notify(f"Cannot open {filename}: {e}", severity="error")
def _reset_stats(self) -> None:
"""Zero all counters and clear display widgets."""
self._total_packets = 0
self._total_bytes = 0
self._tei_count = 0
self._pid_counts.clear()
self._cc_last.clear()
self._cc_errors.clear()
try:
self.query_one("#stream-pid-table", PidTable).clear_table()
self.query_one("#stream-psi-tree", PsiTree).clear_tree()
except Exception:
pass
# -- Background worker --
@work(thread=True)
def _do_monitor(self) -> None:
"""Background worker: read TS stream, parse packets, post UI updates.
Runs in a dedicated thread via Textual's @work(thread=True) decorator.
Calls arm_transfer(True) on entry and arm_transfer(False) in finally
to guarantee the bulk endpoint is always disarmed on exit.
"""
psi_pat = PSIParser()
psi_pmt = PSIParser()
pat = None
pmt_pids: set[int] = set()
last_ui_update = 0.0
try:
self._bridge.ensure_booted()
self._bridge.arm_transfer(True)
while self._monitoring:
data = self._bridge.read_stream(8192, timeout=1000)
if not data:
time.sleep(0.05)
continue
self._total_bytes += len(data)
# Write raw data to capture file if active
if self._capturing and self._capture_file is not None:
try:
self._capture_file.write(data)
except OSError:
self._capturing = False
# Parse 188-byte TS packets from the chunk
offset = 0
while offset + TS_PACKET_SIZE <= len(data):
if data[offset] != 0x47:
# Lost sync, scan forward for next sync byte
offset += 1
continue
try:
pkt = TSPacket(data[offset:offset + TS_PACKET_SIZE])
except (ValueError, IndexError):
offset += 1
continue
offset += TS_PACKET_SIZE
self._total_packets += 1
pid = pkt.pid
# PID counting
self._pid_counts[pid] = self._pid_counts.get(pid, 0) + 1
# TEI (Transport Error Indicator) check
if pkt.tei:
self._tei_count += 1
# Continuity counter check (payload-bearing, non-null PIDs)
if pkt.adaptation & 0x01 and pid != 0x1FFF:
if pid in self._cc_last:
expected = (self._cc_last[pid] + 1) & 0x0F
if (pkt.continuity != expected
and pkt.continuity != self._cc_last[pid]):
self._cc_errors[pid] = (
self._cc_errors.get(pid, 0) + 1
)
self._cc_last[pid] = pkt.continuity
# PAT parsing (PID 0x0000)
if pid == 0x0000:
section = psi_pat.feed(pkt)
if section is not None:
parsed = parse_pat(section)
if parsed is not None:
pat = parsed
for prog_num, pmt_pid in pat.get(
"programs", {}
).items():
if prog_num != 0:
pmt_pids.add(pmt_pid)
# PMT parsing (PIDs discovered from PAT)
if pid in pmt_pids:
section = psi_pmt.feed(pkt)
if section is not None:
parsed = parse_pmt(section)
if parsed is not None:
self.app.call_from_thread(
self._update_pmt, pid, parsed
)
# Batch UI updates every 500ms to avoid flooding the event loop
now = time.monotonic()
if now - last_ui_update >= 0.5:
last_ui_update = now
self.app.call_from_thread(
self._update_ui,
pat,
dict(self._pid_counts),
dict(self._cc_errors),
self._total_packets,
self._total_bytes,
self._tei_count,
)
finally:
for _attempt in range(2):
try:
self._bridge.arm_transfer(False)
break
except Exception:
time.sleep(0.1)
# -- UI update methods (called from main thread) --
def _update_ui(
self,
pat: dict | None,
pid_counts: dict[int, int],
cc_errors: dict[int, int],
total_pkts: int,
total_bytes: int,
tei_count: int,
) -> None:
"""Push accumulated stats to display widgets."""
if not self.is_mounted:
return
# Build known PID names by merging standard table with PAT-discovered PMTs
known = dict(KNOWN_PIDS)
if pat:
for prog_num, pmt_pid in pat.get("programs", {}).items():
if prog_num == 0:
known[pmt_pid] = "NIT"
else:
known[pmt_pid] = f"PMT (prog {prog_num})"
self.query_one("#stream-pid-table", PidTable).update_pids(
pid_counts, cc_errors, total_pkts, known
)
if pat:
self.query_one("#stream-psi-tree", PsiTree).update_pat(pat)
total_cc = sum(cc_errors.values())
duration = time.monotonic() - self._start_time
self.query_one("#stat-packets", Static).update(
f"[#506878]Packets:[/] [#00d4aa]{total_pkts:,}[/]"
)
if total_bytes >= 1_000_000:
bytes_str = f"{total_bytes / 1_000_000:.1f} MB"
elif total_bytes >= 1_000:
bytes_str = f"{total_bytes / 1_000:.1f} KB"
else:
bytes_str = str(total_bytes)
self.query_one("#stat-bytes", Static).update(
f"[#506878]Bytes:[/] [#00d4aa]{bytes_str}[/]"
)
self.query_one("#stat-pids", Static).update(
f"[#506878]PIDs:[/] [#00d4aa]{len(pid_counts)}[/]"
)
cc_color = "#e04040" if total_cc > 0 else "#00d4aa"
self.query_one("#stat-cc", Static).update(
f"[#506878]CC Errors:[/] [{cc_color}]{total_cc}[/]"
)
self.query_one("#stat-duration", Static).update(
f"[#506878]Duration:[/] [#00d4aa]{duration:.1f}s[/]"
)
def _update_pmt(self, pmt_pid: int, pmt: dict) -> None:
"""Push a newly parsed PMT to the PSI tree widget."""
if not self.is_mounted:
return
self.query_one("#stream-psi-tree", PsiTree).update_pmt(pmt_pid, pmt)

File diff suppressed because it is too large Load diff

View file

@ -1,392 +1,392 @@
"""Track screen — carrier/beacon tracker with radar scope, logging, and export.
Locks to a single frequency and records SNR, power, lock state over time.
Features a hero radar scope (P1 phosphor CRT aesthetic), dual sparklines,
event log for lock transitions, and stats. Supports CSV/JSONL export.
"""
import csv
import json
import time
from collections import deque
from datetime import datetime
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, RichLog
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.radar_scope import RadarScope
from skywalker_tui.widgets.sparkline_widget import SparklineWidget
class TrackScreen(Container):
"""Long-running carrier tracker with radar scope and event log."""
DEFAULT_CSS = """
TrackScreen {
layout: vertical;
}
TrackScreen #track-main {
height: 1fr;
layout: horizontal;
padding: 1 2;
}
TrackScreen #track-radar-col {
width: 1fr;
min-width: 30;
layout: vertical;
}
TrackScreen #track-radar-label {
height: 1;
color: #0a5a10;
text-style: bold;
padding: 0 1;
}
TrackScreen #track-data-col {
width: 1fr;
layout: vertical;
padding: 0 0 0 1;
}
TrackScreen #track-sparklines {
height: auto;
layout: vertical;
}
TrackScreen #track-log-container {
height: 10;
background: #0e1018;
border: round #1a2a3a;
margin: 1 0;
}
TrackScreen #track-log-title {
height: 1;
color: #00d4aa;
text-style: bold;
padding: 0 1;
}
TrackScreen #track-log {
height: 1fr;
}
TrackScreen #track-stats {
height: 3;
layout: horizontal;
}
TrackScreen #track-stats Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
TrackScreen #track-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
TrackScreen #track-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
TrackScreen #track-controls Input {
width: 14;
margin: 0 1;
}
TrackScreen #track-controls Button {
margin: 0 1;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._tracking = False
self._track_worker: Worker | None = None
self._sample_count = 0
self._peak_snr = 0.0
self._start_time = 0.0
self._was_locked: bool | None = None
self._records: deque[dict] = deque(maxlen=360_000) # ~10h at 10Hz
def compose(self) -> ComposeResult:
with Horizontal(id="track-main"):
with Vertical(id="track-radar-col"):
yield Static("[#0a5a10 bold]Radar Scope[/]", id="track-radar-label")
yield RadarScope(id="track-radar")
with Vertical(id="track-data-col"):
with Vertical(id="track-sparklines"):
yield SparklineWidget(title="SNR (dB)", color="#00d4aa",
id="track-snr-spark")
yield SparklineWidget(title="Power (dB)", color="#2196f3",
id="track-power-spark")
with Vertical(id="track-log-container"):
yield Static("[#00d4aa bold]Event Log[/]", id="track-log-title")
yield RichLog(id="track-log", wrap=True, markup=True)
with Horizontal(id="track-stats"):
yield Static("[#506878]Samples:[/] [#00d4aa]0[/]", id="trk-samples")
yield Static("[#506878]Elapsed:[/] [#00d4aa]0s[/]", id="trk-elapsed")
yield Static("[#506878]Peak SNR:[/] [#00d4aa]0.0 dB[/]", id="trk-peak")
yield Static("[#506878]Status:[/] [#e8a020]Stopped[/]", id="trk-status")
with Horizontal(id="track-controls"):
yield Label("Freq (MHz):")
yield Input("1200", id="trk-freq")
yield Label("SR (ksps):")
yield Input("20000", id="trk-sr")
yield Label("Rate (Hz):")
yield Input("1", id="trk-rate")
yield Button("Start", id="trk-start-btn", variant="success")
yield Button("Stop", id="trk-stop-btn", variant="error")
yield Button("Export CSV", id="trk-csv-btn")
yield Button("Export JSONL", id="trk-jsonl-btn")
def on_show(self) -> None:
if self._bridge.is_demo and not self._tracking:
self._start_tracking()
def on_hide(self) -> None:
self._stop_tracking()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "trk-start-btn":
self._start_tracking()
elif event.button.id == "trk-stop-btn":
self._stop_tracking()
elif event.button.id == "trk-csv-btn":
self._export_csv()
elif event.button.id == "trk-jsonl-btn":
self._export_jsonl()
def _start_tracking(self) -> None:
if self._tracking:
return
log = self.query_one("#track-log", RichLog)
# C3: Validate inputs before starting
try:
freq = float(self.query_one("#trk-freq", Input).value or "1200")
except ValueError:
log.write("[bold #e04040]Invalid frequency — must be a number[/]")
return
try:
sr = int(float(self.query_one("#trk-sr", Input).value or "20000"))
except ValueError:
log.write("[bold #e04040]Invalid symbol rate — must be a number[/]")
return
try:
rate = float(self.query_one("#trk-rate", Input).value or "1")
except ValueError:
log.write("[bold #e04040]Invalid rate — must be a number[/]")
return
if not (950 <= freq <= 2150):
log.write("[bold #e04040]Frequency out of range (9502150 MHz)[/]")
return
if not (256 <= sr <= 30000):
log.write("[bold #e04040]Symbol rate out of range (25630000 ksps)[/]")
return
if not (0.1 <= rate <= 100):
log.write("[bold #e04040]Rate out of range (0.1100 Hz)[/]")
return
self._tracking = True
self._sample_count = 0
self._peak_snr = 0.0
self._was_locked = None
self._records.clear()
self._start_time = time.monotonic()
self.query_one("#trk-status", Static).update(
"[#506878]Status:[/] [bold #00d4aa]Tracking[/]"
)
log.clear()
log.write("[#506878]Tracking started[/]")
self._track_worker = self._do_track(freq, sr, rate)
def _stop_tracking(self) -> None:
self._tracking = False
if self._track_worker:
self._track_worker.cancel()
self._track_worker = None
try:
self.query_one("#trk-status", Static).update(
"[#506878]Status:[/] [#e8a020]Stopped[/]"
)
log = self.query_one("#track-log", RichLog)
log.write(f"[#506878]Stopped. {self._sample_count} samples.[/]")
except Exception:
pass
@work(thread=True)
def _do_track(self, freq_mhz: float, sr_ksps: int, rate: float) -> None:
"""Background tracking loop with circuit breaker."""
max_consecutive_errors = 10
interval = 1.0 / max(0.1, rate)
freq_khz = int(freq_mhz * 1000)
sr_sps = sr_ksps * 1000
try:
self._bridge.ensure_booted()
self._bridge.tune(sr_sps, freq_khz, 0, 5)
time.sleep(0.3)
except Exception as e:
self.app.call_from_thread(
self._log_event,
f"[bold #e04040]Boot/tune failed:[/] [#e04040]{e}[/]",
)
consecutive_errors = 0
while self._tracking:
t0 = time.monotonic()
try:
sig = self._bridge.signal_monitor()
consecutive_errors = 0 # reset on success
except Exception:
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
self.app.call_from_thread(
self._log_event,
f"[bold #e04040]Circuit breaker: "
f"{max_consecutive_errors} consecutive errors, stopping[/]",
)
self._tracking = False
self.app.call_from_thread(self._mark_stopped)
return
time.sleep(interval)
continue
self._sample_count += 1
snr_db = sig.get("snr_db", 0.0)
locked = sig.get("locked", False)
self._peak_snr = max(self._peak_snr, snr_db)
elapsed = time.monotonic() - self._start_time
record = {
"ts": datetime.now().isoformat(),
"elapsed": round(elapsed, 3),
"snr_db": round(snr_db, 2),
"agc1": sig.get("agc1", 0),
"agc2": sig.get("agc2", 0),
"power_db": round(sig.get("power_db", -40), 2),
"locked": locked,
}
self._records.append(record)
# Lock transition detection
lock_event = None
if self._was_locked is not None and locked != self._was_locked:
if locked:
lock_event = ("lock", snr_db)
else:
lock_event = ("unlock", snr_db)
self._was_locked = locked
self.app.call_from_thread(
self._update_ui, sig, elapsed, lock_event,
)
sleep = interval - (time.monotonic() - t0)
if sleep > 0:
time.sleep(sleep)
def _update_ui(self, sig: dict, elapsed: float,
lock_event: tuple | None) -> None:
if not self.is_mounted:
return
snr_db = sig.get("snr_db", 0.0)
locked = sig.get("locked", False)
# Feed radar scope
radar = self.query_one("#track-radar", RadarScope)
radar.push(snr_db)
radar.set_locked(locked)
# Feed sparklines
self.query_one("#track-snr-spark", SparklineWidget).push(snr_db)
self.query_one("#track-power-spark", SparklineWidget).push(sig.get("power_db", -40))
# Update stats
self.query_one("#trk-samples", Static).update(
f"[#506878]Samples:[/] [#00d4aa]{self._sample_count}[/]"
)
self.query_one("#trk-elapsed", Static).update(
f"[#506878]Elapsed:[/] [#00d4aa]{elapsed:.0f}s[/]"
)
self.query_one("#trk-peak", Static).update(
f"[#506878]Peak SNR:[/] [#00d4aa]{self._peak_snr:.1f} dB[/]"
)
if lock_event:
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
log = self.query_one("#track-log", RichLog)
if lock_event[0] == "lock":
log.write(
f"[#506878]{ts}[/] [bold #00e060]LOCK ACQUIRED[/]"
f" SNR {lock_event[1]:.1f} dB"
)
else:
log.write(
f"[#506878]{ts}[/] [bold #e04040]LOCK LOST[/]"
)
def _log_event(self, markup: str) -> None:
"""Write a message to the event log (safe from any thread via call_from_thread)."""
if not self.is_mounted:
return
try:
self.query_one("#track-log", RichLog).write(markup)
except Exception:
pass
def _mark_stopped(self) -> None:
"""Update UI to stopped state (called from circuit breaker)."""
if not self.is_mounted:
return
try:
self.query_one("#trk-status", Static).update(
"[#506878]Status:[/] [bold #e04040]Error[/]"
)
except Exception:
pass
def _export_csv(self) -> None:
if not self._records:
return
log = self.query_one("#track-log", RichLog)
path = Path(f"skywalker-track-{datetime.now().strftime('%Y%m%d-%H%M%S')}.csv")
try:
with open(path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(self._records[0].keys()))
w.writeheader()
w.writerows(self._records)
log.write(f"[#00d4aa]CSV exported: {path}[/]")
except PermissionError:
log.write(f"[bold #e04040]Permission denied: {path}[/]")
except OSError as e:
log.write(f"[bold #e04040]Export failed: {e}[/]")
def _export_jsonl(self) -> None:
if not self._records:
return
log = self.query_one("#track-log", RichLog)
path = Path(f"skywalker-track-{datetime.now().strftime('%Y%m%d-%H%M%S')}.jsonl")
try:
with open(path, "w") as f:
for rec in self._records:
f.write(json.dumps(rec) + "\n")
log.write(f"[#00d4aa]JSONL exported: {path}[/]")
except PermissionError:
log.write(f"[bold #e04040]Permission denied: {path}[/]")
except OSError as e:
log.write(f"[bold #e04040]Export failed: {e}[/]")
"""Track screen — carrier/beacon tracker with radar scope, logging, and export.
Locks to a single frequency and records SNR, power, lock state over time.
Features a hero radar scope (P1 phosphor CRT aesthetic), dual sparklines,
event log for lock transitions, and stats. Supports CSV/JSONL export.
"""
import csv
import json
import time
from collections import deque
from datetime import datetime
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, RichLog
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.radar_scope import RadarScope
from skywalker_tui.widgets.sparkline_widget import SparklineWidget
class TrackScreen(Container):
"""Long-running carrier tracker with radar scope and event log."""
DEFAULT_CSS = """
TrackScreen {
layout: vertical;
}
TrackScreen #track-main {
height: 1fr;
layout: horizontal;
padding: 1 2;
}
TrackScreen #track-radar-col {
width: 1fr;
min-width: 30;
layout: vertical;
}
TrackScreen #track-radar-label {
height: 1;
color: #0a5a10;
text-style: bold;
padding: 0 1;
}
TrackScreen #track-data-col {
width: 1fr;
layout: vertical;
padding: 0 0 0 1;
}
TrackScreen #track-sparklines {
height: auto;
layout: vertical;
}
TrackScreen #track-log-container {
height: 10;
background: #0e1018;
border: round #1a2a3a;
margin: 1 0;
}
TrackScreen #track-log-title {
height: 1;
color: #00d4aa;
text-style: bold;
padding: 0 1;
}
TrackScreen #track-log {
height: 1fr;
}
TrackScreen #track-stats {
height: 3;
layout: horizontal;
}
TrackScreen #track-stats Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
TrackScreen #track-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
TrackScreen #track-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
TrackScreen #track-controls Input {
width: 14;
margin: 0 1;
}
TrackScreen #track-controls Button {
margin: 0 1;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._tracking = False
self._track_worker: Worker | None = None
self._sample_count = 0
self._peak_snr = 0.0
self._start_time = 0.0
self._was_locked: bool | None = None
self._records: deque[dict] = deque(maxlen=360_000) # ~10h at 10Hz
def compose(self) -> ComposeResult:
with Horizontal(id="track-main"):
with Vertical(id="track-radar-col"):
yield Static("[#0a5a10 bold]Radar Scope[/]", id="track-radar-label")
yield RadarScope(id="track-radar")
with Vertical(id="track-data-col"):
with Vertical(id="track-sparklines"):
yield SparklineWidget(title="SNR (dB)", color="#00d4aa",
id="track-snr-spark")
yield SparklineWidget(title="Power (dB)", color="#2196f3",
id="track-power-spark")
with Vertical(id="track-log-container"):
yield Static("[#00d4aa bold]Event Log[/]", id="track-log-title")
yield RichLog(id="track-log", wrap=True, markup=True)
with Horizontal(id="track-stats"):
yield Static("[#506878]Samples:[/] [#00d4aa]0[/]", id="trk-samples")
yield Static("[#506878]Elapsed:[/] [#00d4aa]0s[/]", id="trk-elapsed")
yield Static("[#506878]Peak SNR:[/] [#00d4aa]0.0 dB[/]", id="trk-peak")
yield Static("[#506878]Status:[/] [#e8a020]Stopped[/]", id="trk-status")
with Horizontal(id="track-controls"):
yield Label("Freq (MHz):")
yield Input("1200", id="trk-freq")
yield Label("SR (ksps):")
yield Input("20000", id="trk-sr")
yield Label("Rate (Hz):")
yield Input("1", id="trk-rate")
yield Button("Start", id="trk-start-btn", variant="success")
yield Button("Stop", id="trk-stop-btn", variant="error")
yield Button("Export CSV", id="trk-csv-btn")
yield Button("Export JSONL", id="trk-jsonl-btn")
def on_show(self) -> None:
if self._bridge.is_demo and not self._tracking:
self._start_tracking()
def on_hide(self) -> None:
self._stop_tracking()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "trk-start-btn":
self._start_tracking()
elif event.button.id == "trk-stop-btn":
self._stop_tracking()
elif event.button.id == "trk-csv-btn":
self._export_csv()
elif event.button.id == "trk-jsonl-btn":
self._export_jsonl()
def _start_tracking(self) -> None:
if self._tracking:
return
log = self.query_one("#track-log", RichLog)
# C3: Validate inputs before starting
try:
freq = float(self.query_one("#trk-freq", Input).value or "1200")
except ValueError:
log.write("[bold #e04040]Invalid frequency — must be a number[/]")
return
try:
sr = int(float(self.query_one("#trk-sr", Input).value or "20000"))
except ValueError:
log.write("[bold #e04040]Invalid symbol rate — must be a number[/]")
return
try:
rate = float(self.query_one("#trk-rate", Input).value or "1")
except ValueError:
log.write("[bold #e04040]Invalid rate — must be a number[/]")
return
if not (950 <= freq <= 2150):
log.write("[bold #e04040]Frequency out of range (9502150 MHz)[/]")
return
if not (256 <= sr <= 30000):
log.write("[bold #e04040]Symbol rate out of range (25630000 ksps)[/]")
return
if not (0.1 <= rate <= 100):
log.write("[bold #e04040]Rate out of range (0.1100 Hz)[/]")
return
self._tracking = True
self._sample_count = 0
self._peak_snr = 0.0
self._was_locked = None
self._records.clear()
self._start_time = time.monotonic()
self.query_one("#trk-status", Static).update(
"[#506878]Status:[/] [bold #00d4aa]Tracking[/]"
)
log.clear()
log.write("[#506878]Tracking started[/]")
self._track_worker = self._do_track(freq, sr, rate)
def _stop_tracking(self) -> None:
self._tracking = False
if self._track_worker:
self._track_worker.cancel()
self._track_worker = None
try:
self.query_one("#trk-status", Static).update(
"[#506878]Status:[/] [#e8a020]Stopped[/]"
)
log = self.query_one("#track-log", RichLog)
log.write(f"[#506878]Stopped. {self._sample_count} samples.[/]")
except Exception:
pass
@work(thread=True)
def _do_track(self, freq_mhz: float, sr_ksps: int, rate: float) -> None:
"""Background tracking loop with circuit breaker."""
max_consecutive_errors = 10
interval = 1.0 / max(0.1, rate)
freq_khz = int(freq_mhz * 1000)
sr_sps = sr_ksps * 1000
try:
self._bridge.ensure_booted()
self._bridge.tune(sr_sps, freq_khz, 0, 5)
time.sleep(0.3)
except Exception as e:
self.app.call_from_thread(
self._log_event,
f"[bold #e04040]Boot/tune failed:[/] [#e04040]{e}[/]",
)
consecutive_errors = 0
while self._tracking:
t0 = time.monotonic()
try:
sig = self._bridge.signal_monitor()
consecutive_errors = 0 # reset on success
except Exception:
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
self.app.call_from_thread(
self._log_event,
f"[bold #e04040]Circuit breaker: "
f"{max_consecutive_errors} consecutive errors, stopping[/]",
)
self._tracking = False
self.app.call_from_thread(self._mark_stopped)
return
time.sleep(interval)
continue
self._sample_count += 1
snr_db = sig.get("snr_db", 0.0)
locked = sig.get("locked", False)
self._peak_snr = max(self._peak_snr, snr_db)
elapsed = time.monotonic() - self._start_time
record = {
"ts": datetime.now().isoformat(),
"elapsed": round(elapsed, 3),
"snr_db": round(snr_db, 2),
"agc1": sig.get("agc1", 0),
"agc2": sig.get("agc2", 0),
"power_db": round(sig.get("power_db", -40), 2),
"locked": locked,
}
self._records.append(record)
# Lock transition detection
lock_event = None
if self._was_locked is not None and locked != self._was_locked:
if locked:
lock_event = ("lock", snr_db)
else:
lock_event = ("unlock", snr_db)
self._was_locked = locked
self.app.call_from_thread(
self._update_ui, sig, elapsed, lock_event,
)
sleep = interval - (time.monotonic() - t0)
if sleep > 0:
time.sleep(sleep)
def _update_ui(self, sig: dict, elapsed: float,
lock_event: tuple | None) -> None:
if not self.is_mounted:
return
snr_db = sig.get("snr_db", 0.0)
locked = sig.get("locked", False)
# Feed radar scope
radar = self.query_one("#track-radar", RadarScope)
radar.push(snr_db)
radar.set_locked(locked)
# Feed sparklines
self.query_one("#track-snr-spark", SparklineWidget).push(snr_db)
self.query_one("#track-power-spark", SparklineWidget).push(sig.get("power_db", -40))
# Update stats
self.query_one("#trk-samples", Static).update(
f"[#506878]Samples:[/] [#00d4aa]{self._sample_count}[/]"
)
self.query_one("#trk-elapsed", Static).update(
f"[#506878]Elapsed:[/] [#00d4aa]{elapsed:.0f}s[/]"
)
self.query_one("#trk-peak", Static).update(
f"[#506878]Peak SNR:[/] [#00d4aa]{self._peak_snr:.1f} dB[/]"
)
if lock_event:
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
log = self.query_one("#track-log", RichLog)
if lock_event[0] == "lock":
log.write(
f"[#506878]{ts}[/] [bold #00e060]LOCK ACQUIRED[/]"
f" SNR {lock_event[1]:.1f} dB"
)
else:
log.write(
f"[#506878]{ts}[/] [bold #e04040]LOCK LOST[/]"
)
def _log_event(self, markup: str) -> None:
"""Write a message to the event log (safe from any thread via call_from_thread)."""
if not self.is_mounted:
return
try:
self.query_one("#track-log", RichLog).write(markup)
except Exception:
pass
def _mark_stopped(self) -> None:
"""Update UI to stopped state (called from circuit breaker)."""
if not self.is_mounted:
return
try:
self.query_one("#trk-status", Static).update(
"[#506878]Status:[/] [bold #e04040]Error[/]"
)
except Exception:
pass
def _export_csv(self) -> None:
if not self._records:
return
log = self.query_one("#track-log", RichLog)
path = Path(f"skywalker-track-{datetime.now().strftime('%Y%m%d-%H%M%S')}.csv")
try:
with open(path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(self._records[0].keys()))
w.writeheader()
w.writerows(self._records)
log.write(f"[#00d4aa]CSV exported: {path}[/]")
except PermissionError:
log.write(f"[bold #e04040]Permission denied: {path}[/]")
except OSError as e:
log.write(f"[bold #e04040]Export failed: {e}[/]")
def _export_jsonl(self) -> None:
if not self._records:
return
log = self.query_one("#track-log", RichLog)
path = Path(f"skywalker-track-{datetime.now().strftime('%Y%m%d-%H%M%S')}.jsonl")
try:
with open(path, "w") as f:
for rec in self._records:
f.write(json.dumps(rec) + "\n")
log.write(f"[#00d4aa]JSONL exported: {path}[/]")
except PermissionError:
log.write(f"[bold #e04040]Permission denied: {path}[/]")
except OSError as e:
log.write(f"[bold #e04040]Export failed: {e}[/]")

View file

@ -1,424 +1,424 @@
/* SkyWalker-1 TUI — dark RF theme
*
* Design: dark background with teal/cyan data accents.
* Signal gradient: blue → green → yellow → red (cold → hot).
* No purple per user preference.
*/
/* ─── Global ─── */
Screen {
background: #0a0a12;
color: #c8d0d8;
}
Header {
background: #0e1018;
color: #00d4aa;
dock: top;
}
Footer {
background: #0e1018;
dock: bottom;
}
/* ─── Sidebar ─── */
#sidebar {
width: 26;
background: #0e1420;
border-right: solid #1a2a3a;
padding: 1 1;
}
#sidebar .mode-button {
width: 100%;
margin: 0 0 1 0;
min-height: 3;
background: #121c2a;
color: #7090a8;
border: round #1a3050;
text-align: center;
}
#sidebar .mode-button:hover {
background: #1a2a40;
color: #00d4aa;
border: round #00d4aa;
}
#sidebar .mode-button.-active {
background: #0a2a3a;
color: #00d4aa;
border: round #00d4aa;
text-style: bold;
}
#sidebar Label.sidebar-heading {
color: #506878;
text-style: bold;
margin: 1 0 0 0;
text-align: center;
}
/* ─── Content area ─── */
#content-area {
background: #0a0a12;
}
/* ─── Status bar widget ─── */
#device-status {
height: 3;
background: #0e1420;
border-top: solid #1a2a3a;
padding: 0 1;
dock: bottom;
}
#device-status .status-label {
color: #506878;
}
#device-status .status-value {
color: #00d4aa;
}
#device-status .status-connected {
color: #00d4aa;
text-style: bold;
}
#device-status .status-demo {
color: #e8a020;
text-style: bold;
}
#device-status .status-disconnected {
color: #e04040;
text-style: bold;
}
/* ─── Signal gauge ─── */
.signal-gauge {
height: auto;
padding: 1;
}
.signal-gauge .snr-value {
color: #00d4aa;
text-style: bold;
}
.signal-gauge .lock-yes {
color: #00e060;
text-style: bold;
}
.signal-gauge .lock-no {
color: #e04040;
}
/* ─── Spectrum plot ─── */
.spectrum-plot {
min-height: 12;
}
/* ─── Panels and containers ─── */
.panel {
background: #0e1420;
border: round #1a2a3a;
padding: 1;
margin: 0 0 1 0;
}
.panel-title {
color: #00d4aa;
text-style: bold;
margin: 0 0 1 0;
}
/* ─── Controls / Input areas ─── */
.controls {
height: auto;
padding: 1;
background: #0e1018;
border-top: solid #1a2a3a;
dock: bottom;
}
.controls Label {
color: #506878;
width: auto;
margin: 0 1 0 0;
}
.controls Input {
width: 14;
background: #121c2a;
border: round #1a3050;
color: #c8d0d8;
}
.controls Input:focus {
border: round #00d4aa;
}
.controls Button {
margin: 0 1;
background: #1a2a40;
color: #00d4aa;
border: round #1a3050;
}
.controls Button:hover {
background: #00d4aa;
color: #0a0a12;
}
/* ─── Data table ─── */
DataTable {
background: #0a0a12;
}
DataTable > .datatable--header {
background: #0e1420;
color: #00d4aa;
text-style: bold;
}
DataTable > .datatable--cursor {
background: #1a2a40;
color: #ffffff;
}
/* ─── Progress bar ─── */
ProgressBar Bar {
color: #00d4aa;
background: #121c2a;
}
/* ─── Sparkline ─── */
.sparkline-widget {
height: 3;
padding: 0 1;
}
/* ─── Waterfall ─── */
.waterfall {
min-height: 10;
}
/* ─── Log / event list ─── */
#event-log {
height: 8;
background: #0e1018;
border: round #1a2a3a;
padding: 0 1;
overflow-y: auto;
}
#event-log .log-lock {
color: #00e060;
}
#event-log .log-unlock {
color: #e04040;
}
#event-log .log-time {
color: #506878;
}
/* ─── Stats panel ─── */
.stats-grid {
layout: grid;
grid-size: 4;
grid-gutter: 1;
height: auto;
padding: 1;
}
.stat-box {
height: 3;
background: #121c2a;
border: round #1a3050;
padding: 0 1;
content-align: center middle;
}
.stat-box .stat-value {
color: #00d4aa;
text-style: bold;
}
.stat-box .stat-label {
color: #506878;
}
/* ─── L-band allocation ─── */
.alloc-tag {
background: #1a2a40;
color: #60a0c0;
padding: 0 1;
margin: 0 1 0 0;
}
/* ─── Mode-specific screen layouts ─── */
.mode-screen {
layout: vertical;
}
.top-panel {
height: 1fr;
min-height: 10;
}
.bottom-panel {
height: auto;
}
.split-horizontal {
layout: horizontal;
}
.left-panel {
width: 1fr;
}
.right-panel {
width: 1fr;
}
/* ─── Radar scope ─── */
RadarScope {
min-height: 12;
min-width: 24;
height: 1fr;
background: #0a0a0a;
border: round #0a2a0a;
}
#track-radar-col {
width: 1fr;
min-width: 30;
}
/* ─── Splash screen overlay ─── */
SplashScreen {
align: center middle;
background: #000000;
}
SplashScreen #splash-container {
width: 100%;
height: 100%;
align: center middle;
background: #000000;
}
SplashScreen #splash-image {
width: 100%;
height: 1fr;
content-align: center middle;
}
/* ─── Hex view ─── */
HexView {
min-height: 6;
}
/* ─── PID table ─── */
PidTable {
min-height: 8;
}
/* ─── PSI tree ─── */
PsiTree {
min-height: 8;
}
/* ─── Countdown timer ─── */
CountdownTimer {
margin: 1 0;
}
/* ─── Config bits display ─── */
ConfigBitsDisplay {
height: auto;
}
/* ─── Star Wars overlay ─── */
StarWarsScreen {
align: center middle;
background: #000000 90%;
}
StarWarsScreen #sw-container {
width: 90%;
height: 90%;
background: #000000;
border: round #1a3050;
}
/* ─── Motor screen ─── */
MotorScreen .jog-row Button {
min-height: 3;
}
MotorScreen #jog-halt {
background: #3a1010;
color: #e04040;
border: round #e04040;
}
MotorScreen #jog-halt:hover {
background: #e04040;
color: #0a0a12;
}
MotorScreen #jog-east,
MotorScreen #jog-west {
background: #1a2a40;
color: #e8a020;
border: round #e8a020;
}
MotorScreen #jog-east:hover,
MotorScreen #jog-west:hover {
background: #e8a020;
color: #0a0a12;
}
/* ─── Survey / QO-100 screen ─── */
SurveyScreen TabbedContent ContentSwitcher {
height: 1fr;
}
SurveyScreen TabPane {
padding: 0;
}
/* SkyWalker-1 TUI — dark RF theme
*
* Design: dark background with teal/cyan data accents.
* Signal gradient: blue → green → yellow → red (cold → hot).
* No purple per user preference.
*/
/* ─── Global ─── */
Screen {
background: #0a0a12;
color: #c8d0d8;
}
Header {
background: #0e1018;
color: #00d4aa;
dock: top;
}
Footer {
background: #0e1018;
dock: bottom;
}
/* ─── Sidebar ─── */
#sidebar {
width: 26;
background: #0e1420;
border-right: solid #1a2a3a;
padding: 1 1;
}
#sidebar .mode-button {
width: 100%;
margin: 0 0 1 0;
min-height: 3;
background: #121c2a;
color: #7090a8;
border: round #1a3050;
text-align: center;
}
#sidebar .mode-button:hover {
background: #1a2a40;
color: #00d4aa;
border: round #00d4aa;
}
#sidebar .mode-button.-active {
background: #0a2a3a;
color: #00d4aa;
border: round #00d4aa;
text-style: bold;
}
#sidebar Label.sidebar-heading {
color: #506878;
text-style: bold;
margin: 1 0 0 0;
text-align: center;
}
/* ─── Content area ─── */
#content-area {
background: #0a0a12;
}
/* ─── Status bar widget ─── */
#device-status {
height: 3;
background: #0e1420;
border-top: solid #1a2a3a;
padding: 0 1;
dock: bottom;
}
#device-status .status-label {
color: #506878;
}
#device-status .status-value {
color: #00d4aa;
}
#device-status .status-connected {
color: #00d4aa;
text-style: bold;
}
#device-status .status-demo {
color: #e8a020;
text-style: bold;
}
#device-status .status-disconnected {
color: #e04040;
text-style: bold;
}
/* ─── Signal gauge ─── */
.signal-gauge {
height: auto;
padding: 1;
}
.signal-gauge .snr-value {
color: #00d4aa;
text-style: bold;
}
.signal-gauge .lock-yes {
color: #00e060;
text-style: bold;
}
.signal-gauge .lock-no {
color: #e04040;
}
/* ─── Spectrum plot ─── */
.spectrum-plot {
min-height: 12;
}
/* ─── Panels and containers ─── */
.panel {
background: #0e1420;
border: round #1a2a3a;
padding: 1;
margin: 0 0 1 0;
}
.panel-title {
color: #00d4aa;
text-style: bold;
margin: 0 0 1 0;
}
/* ─── Controls / Input areas ─── */
.controls {
height: auto;
padding: 1;
background: #0e1018;
border-top: solid #1a2a3a;
dock: bottom;
}
.controls Label {
color: #506878;
width: auto;
margin: 0 1 0 0;
}
.controls Input {
width: 14;
background: #121c2a;
border: round #1a3050;
color: #c8d0d8;
}
.controls Input:focus {
border: round #00d4aa;
}
.controls Button {
margin: 0 1;
background: #1a2a40;
color: #00d4aa;
border: round #1a3050;
}
.controls Button:hover {
background: #00d4aa;
color: #0a0a12;
}
/* ─── Data table ─── */
DataTable {
background: #0a0a12;
}
DataTable > .datatable--header {
background: #0e1420;
color: #00d4aa;
text-style: bold;
}
DataTable > .datatable--cursor {
background: #1a2a40;
color: #ffffff;
}
/* ─── Progress bar ─── */
ProgressBar Bar {
color: #00d4aa;
background: #121c2a;
}
/* ─── Sparkline ─── */
.sparkline-widget {
height: 3;
padding: 0 1;
}
/* ─── Waterfall ─── */
.waterfall {
min-height: 10;
}
/* ─── Log / event list ─── */
#event-log {
height: 8;
background: #0e1018;
border: round #1a2a3a;
padding: 0 1;
overflow-y: auto;
}
#event-log .log-lock {
color: #00e060;
}
#event-log .log-unlock {
color: #e04040;
}
#event-log .log-time {
color: #506878;
}
/* ─── Stats panel ─── */
.stats-grid {
layout: grid;
grid-size: 4;
grid-gutter: 1;
height: auto;
padding: 1;
}
.stat-box {
height: 3;
background: #121c2a;
border: round #1a3050;
padding: 0 1;
content-align: center middle;
}
.stat-box .stat-value {
color: #00d4aa;
text-style: bold;
}
.stat-box .stat-label {
color: #506878;
}
/* ─── L-band allocation ─── */
.alloc-tag {
background: #1a2a40;
color: #60a0c0;
padding: 0 1;
margin: 0 1 0 0;
}
/* ─── Mode-specific screen layouts ─── */
.mode-screen {
layout: vertical;
}
.top-panel {
height: 1fr;
min-height: 10;
}
.bottom-panel {
height: auto;
}
.split-horizontal {
layout: horizontal;
}
.left-panel {
width: 1fr;
}
.right-panel {
width: 1fr;
}
/* ─── Radar scope ─── */
RadarScope {
min-height: 12;
min-width: 24;
height: 1fr;
background: #0a0a0a;
border: round #0a2a0a;
}
#track-radar-col {
width: 1fr;
min-width: 30;
}
/* ─── Splash screen overlay ─── */
SplashScreen {
align: center middle;
background: #000000;
}
SplashScreen #splash-container {
width: 100%;
height: 100%;
align: center middle;
background: #000000;
}
SplashScreen #splash-image {
width: 100%;
height: 1fr;
content-align: center middle;
}
/* ─── Hex view ─── */
HexView {
min-height: 6;
}
/* ─── PID table ─── */
PidTable {
min-height: 8;
}
/* ─── PSI tree ─── */
PsiTree {
min-height: 8;
}
/* ─── Countdown timer ─── */
CountdownTimer {
margin: 1 0;
}
/* ─── Config bits display ─── */
ConfigBitsDisplay {
height: auto;
}
/* ─── Star Wars overlay ─── */
StarWarsScreen {
align: center middle;
background: #000000 90%;
}
StarWarsScreen #sw-container {
width: 90%;
height: 90%;
background: #000000;
border: round #1a3050;
}
/* ─── Motor screen ─── */
MotorScreen .jog-row Button {
min-height: 3;
}
MotorScreen #jog-halt {
background: #3a1010;
color: #e04040;
border: round #e04040;
}
MotorScreen #jog-halt:hover {
background: #e04040;
color: #0a0a12;
}
MotorScreen #jog-east,
MotorScreen #jog-west {
background: #1a2a40;
color: #e8a020;
border: round #e8a020;
}
MotorScreen #jog-east:hover,
MotorScreen #jog-west:hover {
background: #e8a020;
color: #0a0a12;
}
/* ─── Survey / QO-100 screen ─── */
SurveyScreen TabbedContent ContentSwitcher {
height: 1fr;
}
SurveyScreen TabPane {
padding: 0;
}

View file

@ -1 +1 @@
"""Custom widgets for SkyWalker-1 TUI."""
"""Custom widgets for SkyWalker-1 TUI."""

View file

@ -1,46 +1,46 @@
"""Config byte flag display with colored indicators.
Renders the 8PSK config byte as a horizontal row of labeled flags,
each shown as a filled (set) or hollow (clear) circle with color coding.
"""
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from skywalker_lib import CONFIG_BITS
class ConfigBitsDisplay(Widget):
"""Renders the 8PSK config byte as labeled flags with colored indicators."""
DEFAULT_CSS = """
ConfigBitsDisplay {
height: auto;
padding: 0 1;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._config = 0
def compose(self) -> ComposeResult:
yield Static("", id="config-bits-content")
def update_config(self, config: int) -> None:
"""Update the displayed config byte value."""
self._config = config
self._refresh()
def _refresh(self) -> None:
if not self.is_mounted:
return
parts = []
for bit_mask, (name, _field) in CONFIG_BITS.items():
is_set = bool(self._config & bit_mask)
if is_set:
parts.append(f"[bold #00e060]\u25cf[/] [#c8d0d8]{name}[/]")
else:
parts.append(f"[#3a3a3a]\u25cb[/] [#506878]{name}[/]")
self.query_one("#config-bits-content", Static).update(" ".join(parts))
"""Config byte flag display with colored indicators.
Renders the 8PSK config byte as a horizontal row of labeled flags,
each shown as a filled (set) or hollow (clear) circle with color coding.
"""
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from skywalker_lib import CONFIG_BITS
class ConfigBitsDisplay(Widget):
"""Renders the 8PSK config byte as labeled flags with colored indicators."""
DEFAULT_CSS = """
ConfigBitsDisplay {
height: auto;
padding: 0 1;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._config = 0
def compose(self) -> ComposeResult:
yield Static("", id="config-bits-content")
def update_config(self, config: int) -> None:
"""Update the displayed config byte value."""
self._config = config
self._refresh()
def _refresh(self) -> None:
if not self.is_mounted:
return
parts = []
for bit_mask, (name, _field) in CONFIG_BITS.items():
is_set = bool(self._config & bit_mask)
if is_set:
parts.append(f"[bold #00e060]\u25cf[/] [#c8d0d8]{name}[/]")
else:
parts.append(f"[#3a3a3a]\u25cb[/] [#506878]{name}[/]")
self.query_one("#config-bits-content", Static).update(" ".join(parts))

View file

@ -1,112 +1,112 @@
"""Countdown timer widget with ABORT button for safety-critical operations.
Used before EEPROM write to give the operator 3 seconds to abort.
The ABORT button is auto-focused on mount so a single Enter/Space press cancels.
"""
import time
from textual.widget import Widget
from textual.widgets import Button, Static, ProgressBar
from textual.app import ComposeResult
from textual.message import Message
from textual import work
class CountdownTimer(Widget):
"""3-second countdown with prominent ABORT button.
Posts CountdownTimer.Completed when the countdown finishes, or
CountdownTimer.Aborted if the operator presses ABORT.
"""
class Completed(Message):
"""Fired when countdown finishes without abort."""
pass
class Aborted(Message):
"""Fired when user presses ABORT."""
pass
DEFAULT_CSS = """
CountdownTimer {
height: auto;
background: #1a0a0a;
border: round #e04040;
padding: 1 2;
}
CountdownTimer #countdown-label {
text-align: center;
color: #e8a020;
text-style: bold;
margin: 0 0 1 0;
}
CountdownTimer #countdown-bar {
margin: 0 0 1 0;
}
CountdownTimer #countdown-abort {
width: 100%;
min-height: 3;
background: #e04040;
color: #ffffff;
text-style: bold;
border: round #ff6060;
}
CountdownTimer #countdown-abort:hover {
background: #ff4040;
}
CountdownTimer #countdown-abort:focus {
background: #ff2020;
border: round #ffffff;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._running = False
self._aborted = False
def compose(self) -> ComposeResult:
yield Static("EEPROM WRITE in 3...", id="countdown-label")
yield ProgressBar(
total=30, show_eta=False, show_percentage=False, id="countdown-bar"
)
yield Button("ABORT", id="countdown-abort", variant="error")
def on_mount(self) -> None:
self.query_one("#countdown-abort", Button).focus()
def start(self) -> None:
"""Begin the 3-second countdown. Call after mounting."""
self._running = True
self._aborted = False
self._do_countdown()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "countdown-abort":
self._aborted = True
self._running = False
self.post_message(self.Aborted())
@work(thread=True)
def _do_countdown(self) -> None:
"""Tick at 100ms intervals for smooth progress bar animation."""
for tick in range(30, -1, -1):
if not self._running or self._aborted:
return
secs = tick / 10
self.app.call_from_thread(self._update_display, secs, 30 - tick)
time.sleep(0.1)
if self._running and not self._aborted:
self.app.call_from_thread(self._fire_completed)
def _update_display(self, secs: float, progress: int) -> None:
if not self.is_mounted:
return
label = self.query_one("#countdown-label", Static)
label.update(f"EEPROM WRITE in {secs:.1f}s \u2014 press ABORT to cancel")
self.query_one("#countdown-bar", ProgressBar).update(progress=progress)
def _fire_completed(self) -> None:
if self.is_mounted and not self._aborted:
self.post_message(self.Completed())
"""Countdown timer widget with ABORT button for safety-critical operations.
Used before EEPROM write to give the operator 3 seconds to abort.
The ABORT button is auto-focused on mount so a single Enter/Space press cancels.
"""
import time
from textual.widget import Widget
from textual.widgets import Button, Static, ProgressBar
from textual.app import ComposeResult
from textual.message import Message
from textual import work
class CountdownTimer(Widget):
"""3-second countdown with prominent ABORT button.
Posts CountdownTimer.Completed when the countdown finishes, or
CountdownTimer.Aborted if the operator presses ABORT.
"""
class Completed(Message):
"""Fired when countdown finishes without abort."""
pass
class Aborted(Message):
"""Fired when user presses ABORT."""
pass
DEFAULT_CSS = """
CountdownTimer {
height: auto;
background: #1a0a0a;
border: round #e04040;
padding: 1 2;
}
CountdownTimer #countdown-label {
text-align: center;
color: #e8a020;
text-style: bold;
margin: 0 0 1 0;
}
CountdownTimer #countdown-bar {
margin: 0 0 1 0;
}
CountdownTimer #countdown-abort {
width: 100%;
min-height: 3;
background: #e04040;
color: #ffffff;
text-style: bold;
border: round #ff6060;
}
CountdownTimer #countdown-abort:hover {
background: #ff4040;
}
CountdownTimer #countdown-abort:focus {
background: #ff2020;
border: round #ffffff;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._running = False
self._aborted = False
def compose(self) -> ComposeResult:
yield Static("EEPROM WRITE in 3...", id="countdown-label")
yield ProgressBar(
total=30, show_eta=False, show_percentage=False, id="countdown-bar"
)
yield Button("ABORT", id="countdown-abort", variant="error")
def on_mount(self) -> None:
self.query_one("#countdown-abort", Button).focus()
def start(self) -> None:
"""Begin the 3-second countdown. Call after mounting."""
self._running = True
self._aborted = False
self._do_countdown()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "countdown-abort":
self._aborted = True
self._running = False
self.post_message(self.Aborted())
@work(thread=True)
def _do_countdown(self) -> None:
"""Tick at 100ms intervals for smooth progress bar animation."""
for tick in range(30, -1, -1):
if not self._running or self._aborted:
return
secs = tick / 10
self.app.call_from_thread(self._update_display, secs, 30 - tick)
time.sleep(0.1)
if self._running and not self._aborted:
self.app.call_from_thread(self._fire_completed)
def _update_display(self, secs: float, progress: int) -> None:
if not self.is_mounted:
return
label = self.query_one("#countdown-label", Static)
label.update(f"EEPROM WRITE in {secs:.1f}s \u2014 press ABORT to cancel")
self.query_one("#countdown-bar", ProgressBar).update(progress=progress)
def _fire_completed(self) -> None:
if self.is_mounted and not self._aborted:
self.post_message(self.Completed())

View file

@ -1,81 +1,81 @@
"""DataTable wrapper for transponder scan results."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "tools"))
from textual.widget import Widget
from textual.widgets import DataTable
from textual.app import ComposeResult
from skywalker_lib import LBAND_ALLOCATIONS
def _freq_allocation(freq_mhz: float) -> str:
"""Return allocation name for a given frequency."""
for lo, hi, name in LBAND_ALLOCATIONS:
if lo <= freq_mhz <= hi:
return name
return ""
class FrequencyTable(Widget):
"""Sortable table of discovered transponders or sweep results."""
DEFAULT_CSS = """
FrequencyTable {
height: 1fr;
min-height: 6;
}
"""
def __init__(self, show_allocation: bool = False, **kwargs):
super().__init__(**kwargs)
self._show_allocation = show_allocation
self._rows: list[dict] = []
def compose(self) -> ComposeResult:
table = DataTable(id="freq-table")
table.cursor_type = "row"
yield table
def on_mount(self) -> None:
table = self.query_one("#freq-table", DataTable)
cols = ["IF MHz", "RF MHz", "SR ksps", "Power dB", "Locked"]
if self._show_allocation:
cols.append("Allocation")
for col in cols:
table.add_column(col, key=col)
def add_transponder(self, tp: dict) -> None:
"""Add a single transponder result."""
self._rows.append(tp)
table = self.query_one("#freq-table", DataTable)
if_mhz = tp.get("if_mhz", 0)
rf_mhz = tp.get("rf_mhz", 0)
sr_ksps = tp.get("sr_ksps", 0)
power_db = tp.get("power_db", 0)
locked = "Yes" if tp.get("locked", False) else "No"
row = [f"{if_mhz:.1f}", f"{rf_mhz:.0f}", str(sr_ksps),
f"{power_db:.1f}", locked]
if self._show_allocation:
row.append(_freq_allocation(if_mhz))
table.add_row(*row)
def clear_table(self) -> None:
"""Remove all rows."""
self._rows.clear()
table = self.query_one("#freq-table", DataTable)
table.clear()
def get_selected_transponder(self) -> dict | None:
"""Return the currently selected transponder dict."""
table = self.query_one("#freq-table", DataTable)
cursor_row = table.cursor_row
if cursor_row is not None and 0 <= cursor_row < len(self._rows):
return self._rows[cursor_row]
return None
"""DataTable wrapper for transponder scan results."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "tools"))
from textual.widget import Widget
from textual.widgets import DataTable
from textual.app import ComposeResult
from skywalker_lib import LBAND_ALLOCATIONS
def _freq_allocation(freq_mhz: float) -> str:
"""Return allocation name for a given frequency."""
for lo, hi, name in LBAND_ALLOCATIONS:
if lo <= freq_mhz <= hi:
return name
return ""
class FrequencyTable(Widget):
"""Sortable table of discovered transponders or sweep results."""
DEFAULT_CSS = """
FrequencyTable {
height: 1fr;
min-height: 6;
}
"""
def __init__(self, show_allocation: bool = False, **kwargs):
super().__init__(**kwargs)
self._show_allocation = show_allocation
self._rows: list[dict] = []
def compose(self) -> ComposeResult:
table = DataTable(id="freq-table")
table.cursor_type = "row"
yield table
def on_mount(self) -> None:
table = self.query_one("#freq-table", DataTable)
cols = ["IF MHz", "RF MHz", "SR ksps", "Power dB", "Locked"]
if self._show_allocation:
cols.append("Allocation")
for col in cols:
table.add_column(col, key=col)
def add_transponder(self, tp: dict) -> None:
"""Add a single transponder result."""
self._rows.append(tp)
table = self.query_one("#freq-table", DataTable)
if_mhz = tp.get("if_mhz", 0)
rf_mhz = tp.get("rf_mhz", 0)
sr_ksps = tp.get("sr_ksps", 0)
power_db = tp.get("power_db", 0)
locked = "Yes" if tp.get("locked", False) else "No"
row = [f"{if_mhz:.1f}", f"{rf_mhz:.0f}", str(sr_ksps),
f"{power_db:.1f}", locked]
if self._show_allocation:
row.append(_freq_allocation(if_mhz))
table.add_row(*row)
def clear_table(self) -> None:
"""Remove all rows."""
self._rows.clear()
table = self.query_one("#freq-table", DataTable)
table.clear()
def get_selected_transponder(self) -> dict | None:
"""Return the currently selected transponder dict."""
table = self.query_one("#freq-table", DataTable)
cursor_row = table.cursor_row
if cursor_row is not None and 0 <= cursor_row < len(self._rows):
return self._rows[cursor_row]
return None

View file

@ -1,90 +1,90 @@
"""Scrollable hex dump widget with diff byte highlighting."""
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from textual.containers import VerticalScroll
class HexView(Widget):
"""Displays a hex dump of binary data with optional diff highlighting.
Set data via set_data(), optionally passing a set of byte offsets to
highlight in red (for verify mismatches). Each row shows 16 bytes in
traditional offset : hex : ASCII layout.
"""
DEFAULT_CSS = """
HexView {
height: 1fr;
min-height: 6;
background: #0e1420;
border: round #1a2a3a;
}
HexView #hex-scroll {
height: 1fr;
padding: 0 1;
}
HexView #hex-content {
width: auto;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._data = b''
self._diff_offsets: set[int] = set()
def compose(self) -> ComposeResult:
with VerticalScroll(id="hex-scroll"):
yield Static("", id="hex-content")
def set_data(self, data: bytes, diff_offsets: set[int] | None = None) -> None:
"""Set data to display. diff_offsets highlights those bytes in red."""
self._data = data
self._diff_offsets = diff_offsets or set()
self._refresh_display()
def clear(self) -> None:
"""Clear the hex display."""
self._data = b''
self._diff_offsets.clear()
if self.is_mounted:
self.query_one("#hex-content", Static).update("")
def _refresh_display(self) -> None:
if not self.is_mounted:
return
lines = []
for row_off in range(0, len(self._data), 16):
row = self._data[row_off:row_off + 16]
# Offset column
line = f"[#506878]{row_off:04X}:[/] "
# Hex bytes
hex_parts = []
for i, b in enumerate(row):
abs_off = row_off + i
if abs_off in self._diff_offsets:
hex_parts.append(f"[bold #e04040]{b:02X}[/]")
else:
hex_parts.append(f"[#7090a8]{b:02X}[/]")
line += " ".join(hex_parts)
# Pad if short row
if len(row) < 16:
line += " " * (16 - len(row))
# ASCII column
line += " "
ascii_parts = []
for i, b in enumerate(row):
abs_off = row_off + i
ch = chr(b) if 0x20 <= b < 0x7F else "."
if abs_off in self._diff_offsets:
ascii_parts.append(f"[bold #e04040]{ch}[/]")
else:
ascii_parts.append(f"[#506878]{ch}[/]")
line += "".join(ascii_parts)
lines.append(line)
self.query_one("#hex-content", Static).update(
"\n".join(lines) if lines else "[#506878]No data[/]"
)
"""Scrollable hex dump widget with diff byte highlighting."""
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from textual.containers import VerticalScroll
class HexView(Widget):
"""Displays a hex dump of binary data with optional diff highlighting.
Set data via set_data(), optionally passing a set of byte offsets to
highlight in red (for verify mismatches). Each row shows 16 bytes in
traditional offset : hex : ASCII layout.
"""
DEFAULT_CSS = """
HexView {
height: 1fr;
min-height: 6;
background: #0e1420;
border: round #1a2a3a;
}
HexView #hex-scroll {
height: 1fr;
padding: 0 1;
}
HexView #hex-content {
width: auto;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._data = b''
self._diff_offsets: set[int] = set()
def compose(self) -> ComposeResult:
with VerticalScroll(id="hex-scroll"):
yield Static("", id="hex-content")
def set_data(self, data: bytes, diff_offsets: set[int] | None = None) -> None:
"""Set data to display. diff_offsets highlights those bytes in red."""
self._data = data
self._diff_offsets = diff_offsets or set()
self._refresh_display()
def clear(self) -> None:
"""Clear the hex display."""
self._data = b''
self._diff_offsets.clear()
if self.is_mounted:
self.query_one("#hex-content", Static).update("")
def _refresh_display(self) -> None:
if not self.is_mounted:
return
lines = []
for row_off in range(0, len(self._data), 16):
row = self._data[row_off:row_off + 16]
# Offset column
line = f"[#506878]{row_off:04X}:[/] "
# Hex bytes
hex_parts = []
for i, b in enumerate(row):
abs_off = row_off + i
if abs_off in self._diff_offsets:
hex_parts.append(f"[bold #e04040]{b:02X}[/]")
else:
hex_parts.append(f"[#7090a8]{b:02X}[/]")
line += " ".join(hex_parts)
# Pad if short row
if len(row) < 16:
line += " " * (16 - len(row))
# ASCII column
line += " "
ascii_parts = []
for i, b in enumerate(row):
abs_off = row_off + i
ch = chr(b) if 0x20 <= b < 0x7F else "."
if abs_off in self._diff_offsets:
ascii_parts.append(f"[bold #e04040]{ch}[/]")
else:
ascii_parts.append(f"[#506878]{ch}[/]")
line += "".join(ascii_parts)
lines.append(line)
self.query_one("#hex-content", Static).update(
"\n".join(lines) if lines else "[#506878]No data[/]"
)

View file

@ -1,74 +1,74 @@
"""DataTable wrapper for MPEG-2 TS PID distribution statistics.
Displays per-PID packet counts, percentage share, continuity counter errors,
and well-known PID names. Table is rebuilt on each update to keep the sort
order stable (by PID number ascending).
"""
from textual.widget import Widget
from textual.widgets import DataTable
from textual.app import ComposeResult
class PidTable(Widget):
"""Sortable PID statistics table for transport stream analysis."""
DEFAULT_CSS = """
PidTable {
height: 1fr;
min-height: 8;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._pid_data: dict[int, dict] = {}
self._total_packets = 0
def compose(self) -> ComposeResult:
table = DataTable(id="pid-stats-table")
table.cursor_type = "row"
yield table
def on_mount(self) -> None:
table = self.query_one("#pid-stats-table", DataTable)
for col in ["PID", "Count", "%", "CC Errors", "Name"]:
table.add_column(col, key=col)
def update_pids(
self,
pid_counts: dict[int, int],
cc_errors: dict[int, int],
total: int,
known_pids: dict[int, str],
) -> None:
"""Rebuild the table from accumulated stats.
Args:
pid_counts: Mapping of PID number to total packet count.
cc_errors: Mapping of PID number to continuity counter error count.
total: Total packet count across all PIDs.
known_pids: Mapping of PID number to human-readable name.
"""
self._total_packets = total
table = self.query_one("#pid-stats-table", DataTable)
table.clear()
for pid in sorted(pid_counts.keys()):
count = pid_counts[pid]
pct = (count / total * 100) if total > 0 else 0.0
cc_err = cc_errors.get(pid, 0)
name = known_pids.get(pid, "")
table.add_row(
f"0x{pid:04X}",
f"{count:,}",
f"{pct:.1f}%",
str(cc_err) if cc_err > 0 else "-",
name,
)
def clear_table(self) -> None:
"""Remove all rows and reset internal state."""
self._pid_data.clear()
self._total_packets = 0
self.query_one("#pid-stats-table", DataTable).clear()
"""DataTable wrapper for MPEG-2 TS PID distribution statistics.
Displays per-PID packet counts, percentage share, continuity counter errors,
and well-known PID names. Table is rebuilt on each update to keep the sort
order stable (by PID number ascending).
"""
from textual.widget import Widget
from textual.widgets import DataTable
from textual.app import ComposeResult
class PidTable(Widget):
"""Sortable PID statistics table for transport stream analysis."""
DEFAULT_CSS = """
PidTable {
height: 1fr;
min-height: 8;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._pid_data: dict[int, dict] = {}
self._total_packets = 0
def compose(self) -> ComposeResult:
table = DataTable(id="pid-stats-table")
table.cursor_type = "row"
yield table
def on_mount(self) -> None:
table = self.query_one("#pid-stats-table", DataTable)
for col in ["PID", "Count", "%", "CC Errors", "Name"]:
table.add_column(col, key=col)
def update_pids(
self,
pid_counts: dict[int, int],
cc_errors: dict[int, int],
total: int,
known_pids: dict[int, str],
) -> None:
"""Rebuild the table from accumulated stats.
Args:
pid_counts: Mapping of PID number to total packet count.
cc_errors: Mapping of PID number to continuity counter error count.
total: Total packet count across all PIDs.
known_pids: Mapping of PID number to human-readable name.
"""
self._total_packets = total
table = self.query_one("#pid-stats-table", DataTable)
table.clear()
for pid in sorted(pid_counts.keys()):
count = pid_counts[pid]
pct = (count / total * 100) if total > 0 else 0.0
cc_err = cc_errors.get(pid, 0)
name = known_pids.get(pid, "")
table.add_row(
f"0x{pid:04X}",
f"{count:,}",
f"{pct:.1f}%",
str(cc_err) if cc_err > 0 else "-",
name,
)
def clear_table(self) -> None:
"""Remove all rows and reset internal state."""
self._pid_data.clear()
self._total_packets = 0
self.query_one("#pid-stats-table", DataTable).clear()

View file

@ -1,111 +1,111 @@
"""Tree-style display of MPEG-2 PSI structure (PAT/PMT).
Renders a hierarchical view of the Program Association Table and its
child Program Map Tables using Rich markup inside a Static widget.
Shows transport stream ID, program numbers, PMT PIDs, PCR PIDs,
and elementary stream types with their PIDs.
"""
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
class PsiTree(Widget):
"""Hierarchical PAT/PMT display for transport stream program structure."""
DEFAULT_CSS = """
PsiTree {
height: 1fr;
min-height: 8;
background: #0e1420;
border: round #1a2a3a;
padding: 1;
overflow-y: auto;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._pat: dict | None = None
self._pmts: dict[int, dict] = {}
def compose(self) -> ComposeResult:
yield Static(
"[#506878]Waiting for PSI data...[/]", id="psi-content"
)
def update_pat(self, pat: dict) -> None:
"""Update the Program Association Table and redraw."""
self._pat = pat
self._refresh()
def update_pmt(self, pmt_pid: int, pmt: dict) -> None:
"""Update a Program Map Table and redraw."""
self._pmts[pmt_pid] = pmt
self._refresh()
def clear_tree(self) -> None:
"""Reset all PSI state and show placeholder."""
self._pat = None
self._pmts.clear()
if self.is_mounted:
self.query_one("#psi-content", Static).update(
"[#506878]Waiting for PSI data...[/]"
)
def _refresh(self) -> None:
"""Rebuild the tree markup from current PAT/PMT data."""
if not self.is_mounted:
return
lines: list[str] = []
if self._pat:
tsid = self._pat.get("transport_stream_id", 0)
ver = self._pat.get("version", 0)
lines.append(
f"[bold #00d4aa]PAT[/] [#506878]TSID=0x{tsid:04X} v{ver}[/]"
)
programs = self._pat.get("programs", {})
# Separate NIT (program 0) from real programs
real_progs = {k: v for k, v in programs.items() if k != 0}
for prog_num, pmt_pid in sorted(programs.items()):
if prog_num == 0:
lines.append(
f" [#7090a8]\u251c\u2500[/] [#506878]NIT[/] "
f"PID=0x{pmt_pid:04X}"
)
else:
is_last = prog_num == max(real_progs.keys())
prefix = "\u2514\u2500" if is_last else "\u251c\u2500"
lines.append(
f" [#7090a8]{prefix}[/] "
f"[bold #c8d0d8]Program {prog_num}[/] "
f"PMT=0x{pmt_pid:04X}"
)
# Expand PMT details if available
if pmt_pid in self._pmts:
pmt = self._pmts[pmt_pid]
pcr_pid = pmt.get("pcr_pid", 0)
indent = " " if is_last else "\u2502 "
lines.append(
f" {indent} [#506878]PCR PID=0x{pcr_pid:04X}[/]"
)
streams = pmt.get("streams", [])
for j, s in enumerate(streams):
s_last = j == len(streams) - 1
s_prefix = "\u2514\u2500" if s_last else "\u251c\u2500"
type_name = s.get("type_name", "Unknown")
epid = s.get("elementary_pid", 0)
lines.append(
f" {indent} [#7090a8]{s_prefix}[/] "
f"[#c8d0d8]{type_name}[/] "
f"PID=0x{epid:04X}"
)
else:
lines.append("[#506878]No PAT received yet[/]")
self.query_one("#psi-content", Static).update("\n".join(lines))
"""Tree-style display of MPEG-2 PSI structure (PAT/PMT).
Renders a hierarchical view of the Program Association Table and its
child Program Map Tables using Rich markup inside a Static widget.
Shows transport stream ID, program numbers, PMT PIDs, PCR PIDs,
and elementary stream types with their PIDs.
"""
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
class PsiTree(Widget):
"""Hierarchical PAT/PMT display for transport stream program structure."""
DEFAULT_CSS = """
PsiTree {
height: 1fr;
min-height: 8;
background: #0e1420;
border: round #1a2a3a;
padding: 1;
overflow-y: auto;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._pat: dict | None = None
self._pmts: dict[int, dict] = {}
def compose(self) -> ComposeResult:
yield Static(
"[#506878]Waiting for PSI data...[/]", id="psi-content"
)
def update_pat(self, pat: dict) -> None:
"""Update the Program Association Table and redraw."""
self._pat = pat
self._refresh()
def update_pmt(self, pmt_pid: int, pmt: dict) -> None:
"""Update a Program Map Table and redraw."""
self._pmts[pmt_pid] = pmt
self._refresh()
def clear_tree(self) -> None:
"""Reset all PSI state and show placeholder."""
self._pat = None
self._pmts.clear()
if self.is_mounted:
self.query_one("#psi-content", Static).update(
"[#506878]Waiting for PSI data...[/]"
)
def _refresh(self) -> None:
"""Rebuild the tree markup from current PAT/PMT data."""
if not self.is_mounted:
return
lines: list[str] = []
if self._pat:
tsid = self._pat.get("transport_stream_id", 0)
ver = self._pat.get("version", 0)
lines.append(
f"[bold #00d4aa]PAT[/] [#506878]TSID=0x{tsid:04X} v{ver}[/]"
)
programs = self._pat.get("programs", {})
# Separate NIT (program 0) from real programs
real_progs = {k: v for k, v in programs.items() if k != 0}
for prog_num, pmt_pid in sorted(programs.items()):
if prog_num == 0:
lines.append(
f" [#7090a8]\u251c\u2500[/] [#506878]NIT[/] "
f"PID=0x{pmt_pid:04X}"
)
else:
is_last = prog_num == max(real_progs.keys())
prefix = "\u2514\u2500" if is_last else "\u251c\u2500"
lines.append(
f" [#7090a8]{prefix}[/] "
f"[bold #c8d0d8]Program {prog_num}[/] "
f"PMT=0x{pmt_pid:04X}"
)
# Expand PMT details if available
if pmt_pid in self._pmts:
pmt = self._pmts[pmt_pid]
pcr_pid = pmt.get("pcr_pid", 0)
indent = " " if is_last else "\u2502 "
lines.append(
f" {indent} [#506878]PCR PID=0x{pcr_pid:04X}[/]"
)
streams = pmt.get("streams", [])
for j, s in enumerate(streams):
s_last = j == len(streams) - 1
s_prefix = "\u2514\u2500" if s_last else "\u251c\u2500"
type_name = s.get("type_name", "Unknown")
epid = s.get("elementary_pid", 0)
lines.append(
f" {indent} [#7090a8]{s_prefix}[/] "
f"[#c8d0d8]{type_name}[/] "
f"PID=0x{epid:04X}"
)
else:
lines.append("[#506878]No PAT received yet[/]")
self.query_one("#psi-content", Static).update("\n".join(lines))

View file

@ -1,276 +1,276 @@
"""Radar scope widget — P1 green phosphor CRT aesthetic.
Renders a circular radar display using half-block characters for 2x vertical
resolution. Each terminal cell becomes two pixel rows via the character
with independent fg (top) and bg (bottom) colors.
The sweep beam rotates through 360 positions. Signal strength determines
radial distance from center. Older samples decay in brightness (phosphor
persistence). Concentric range rings and a crosshair provide scale reference.
"""
import math
from collections import deque
from textual.widget import Widget
from textual.strip import Strip
from rich.segment import Segment
from rich.style import Style
# P1 green phosphor palette — 8 intensity levels from dead to peak burn
PHOSPHOR = [
"#0a0a0a", # background / dead pixel
"#0a1a0a", # very faint trace
"#0a2a0a", # dim afterglow
"#0a3a0a", # fading
"#0a4a0f", # moderate
"#0a5a10", # bright trace
"#0a6a15", # very bright
"#10ff30", # peak phosphor burn
]
PHOSPHOR_STYLES = [Style(color=c) for c in PHOSPHOR]
BG_COLOR = "#0a0a0a"
BG_STYLE = Style(color=BG_COLOR, bgcolor=BG_COLOR)
RING_COLOR = "#0a2a0a"
CROSSHAIR_COLOR = "#0a3a0a"
LOCK_RING_COLOR = "#10ff30"
class RadarScope(Widget):
"""Circular radar scope with phosphor decay and sweep beam."""
DEFAULT_CSS = """
RadarScope {
min-height: 12;
min-width: 24;
background: #0a0a0a;
}
"""
def __init__(self, max_samples: int = 360, **kwargs):
super().__init__(**kwargs)
self._samples: deque[float] = deque([0.0] * max_samples, maxlen=max_samples)
self._max_samples = max_samples
self._angle_idx = 0 # current sweep position (0..max_samples-1)
self._locked = False
# Pre-computed geometry + LUTs (rebuilt on resize)
self._cx = 0.0
self._cy = 0.0
self._radius = 0.0
self._cols = 0
self._rows = 0 # pixel rows (2x terminal rows)
# Per-pixel LUTs: distance from center, angle-to-sample index
self._dist_lut: list[list[float]] = []
self._angle_lut: list[list[int]] = []
# Per-pixel dx/dy for crosshair checks
self._dx_lut: list[list[float]] = []
self._dy_lut: list[list[float]] = []
def push(self, snr_db: float, max_snr: float = 16.0) -> None:
"""Push a new signal sample. SNR is normalized to 0.0-1.0 range."""
normalized = max(0.0, min(1.0, snr_db / max(0.1, max_snr)))
self._samples.append(normalized)
self._angle_idx = (self._angle_idx + 1) % self._max_samples
self.refresh()
def set_locked(self, locked: bool) -> None:
if locked != self._locked:
self._locked = locked
self.refresh()
def _recompute_geometry(self, width: int, height: int) -> None:
"""Recompute center, radius, and per-pixel LUTs for current dimensions.
Pre-computes dist/angle for every pixel so render_line() avoids
math.sqrt/math.atan2 per pixel per frame ~O(1) lookup instead.
"""
self._cols = width
self._rows = height * 2 # 2x vertical resolution via half-blocks
# Radius fits within both dimensions, accounting for ~2:1 terminal char aspect
rx = (width - 2) / 2.0
ry = (height * 2 - 2) / 2.0
self._radius = min(rx, ry)
cx = width / 2.0
cy = height # center in pixel rows (height * 2 / 2)
self._cx = cx
self._cy = cy
# Build LUTs for all pixel rows (height * 2) × columns
pixel_rows = height * 2
dist_lut = []
angle_lut = []
dx_lut = []
dy_lut = []
sqrt = math.sqrt
atan2 = math.atan2
degrees = math.degrees
ms = self._max_samples
for py in range(pixel_rows):
d_row = []
a_row = []
dxr = []
dyr = []
dy = py - cy
for px in range(width):
dx = px - cx
dist = sqrt(dx * dx + dy * dy)
d_row.append(dist)
dxr.append(dx)
dyr.append(dy)
if dist >= 1.0:
angle = atan2(dy, dx)
angle_deg = (degrees(angle) + 360) % 360
a_row.append(int(angle_deg / 360 * ms) % ms)
else:
a_row.append(0) # center pixel — angle irrelevant
dist_lut.append(d_row)
angle_lut.append(a_row)
dx_lut.append(dxr)
dy_lut.append(dyr)
self._dist_lut = dist_lut
self._angle_lut = angle_lut
self._dx_lut = dx_lut
self._dy_lut = dy_lut
def render_line(self, y: int) -> Strip:
"""Render one terminal row of the radar scope."""
width = self.size.width
height = self.size.height
if width < 4 or height < 4:
return Strip([Segment(" " * width, BG_STYLE)])
if self._cols != width or self._rows != height * 2:
self._recompute_geometry(width, height)
radius = self._radius
if radius < 2:
return Strip([Segment(" " * width, BG_STYLE)])
# Snapshot mutable state for consistent rendering across the frame
samples = list(self._samples)
angle_idx = self._angle_idx
locked = self._locked
# Two pixel rows per terminal row
py_top = y * 2
py_bot = y * 2 + 1
# LUT rows for this terminal row
dist_top = self._dist_lut[py_top] if py_top < len(self._dist_lut) else None
dist_bot = self._dist_lut[py_bot] if py_bot < len(self._dist_lut) else None
angle_top = self._angle_lut[py_top] if py_top < len(self._angle_lut) else None
angle_bot = self._angle_lut[py_bot] if py_bot < len(self._angle_lut) else None
dx_top = self._dx_lut[py_top] if py_top < len(self._dx_lut) else None
dx_bot = self._dx_lut[py_bot] if py_bot < len(self._dx_lut) else None
dy_top = self._dy_lut[py_top] if py_top < len(self._dy_lut) else None
dy_bot = self._dy_lut[py_bot] if py_bot < len(self._dy_lut) else None
if not dist_top or not dist_bot:
return Strip([Segment(" " * width, BG_STYLE)])
_pi = self._pixel_intensity
segments = []
for x in range(width):
top_intensity = _pi(
dist_top[x], angle_top[x], dx_top[x], dy_top[x],
radius, samples, angle_idx, locked,
)
bot_intensity = _pi(
dist_bot[x], angle_bot[x], dx_bot[x], dy_bot[x],
radius, samples, angle_idx, locked,
)
top_color = PHOSPHOR[top_intensity]
bot_color = PHOSPHOR[bot_intensity]
# ▀ char: fg = top pixel, bg = bottom pixel
style = Style(color=top_color, bgcolor=bot_color)
segments.append(Segment("\u2580", style))
return Strip(segments)
def _pixel_intensity(self, dist: float, sample_idx: int,
dx: float, dy: float,
radius: float, samples: list[float],
angle_idx: int, locked: bool) -> int:
"""Compute phosphor intensity (0-7) for a single pixel.
Uses pre-computed dist/sample_idx/dx/dy from LUTs (no trig per pixel).
Uses snapshot data (samples, angle_idx, locked) rather than mutable
instance state for frame-consistent rendering.
"""
# Outside the scope circle
if dist > radius + 1:
return 0
# Lock ring — outer edge glow
if locked and abs(dist - radius) < 1.5:
return 7
# Range rings at 25%, 50%, 75% radius
for ring_r in (0.25, 0.5, 0.75):
if abs(dist - radius * ring_r) < 0.7:
return 2 # dim ring
# Outer boundary ring
if abs(dist - radius) < 0.7:
return 2
# Crosshair (horizontal and vertical through center)
if abs(dx) < 0.7 and dist < radius:
return 2
if abs(dy) < 0.7 and dist < radius:
return 2
# Signal trace — map pixel angle to sample buffer
if dist < 1.0:
return 1 # center dot
if dist > radius:
return 0
# sample_idx already computed in LUT via atan2 → degrees → index
strength = samples[sample_idx]
# Signal renders as a blip at radial distance proportional to strength
signal_dist = strength * radius * 0.85 # 85% of radius at max
noise_floor_dist = radius * 0.05 # tiny noise floor ring
# Distance from the signal blip center
blip_dist = abs(dist - signal_dist)
noise_dist = abs(dist - noise_floor_dist)
# Age-based decay: how far behind the sweep beam is this angle?
age = (angle_idx - sample_idx) % self._max_samples
age_ratio = age / self._max_samples # 0 = newest, 1 = oldest
# Intensity from signal blip proximity
if strength > 0.02 and blip_dist < 2.0:
proximity = max(0.0, 1.0 - blip_dist / 2.0)
freshness = max(0.0, 1.0 - age_ratio * 1.2)
raw_intensity = proximity * freshness * strength
return max(1, min(7, int(raw_intensity * 7 + 0.5)))
# Sweep beam — the most recent angle is brightest
if age < 3:
beam_intensity = max(0.0, 1.0 - age / 3.0)
if dist < radius * 0.9:
return max(1, min(5, int(beam_intensity * 5)))
# Noise floor glow
if noise_dist < 1.0 and strength > 0:
return 1
# Fill between center and signal (faint trail)
if strength > 0.1 and dist < signal_dist and age_ratio < 0.5:
trail = max(0.0, (1.0 - age_ratio * 2) * 0.3)
if trail > 0.05:
return 1
return 0
"""Radar scope widget — P1 green phosphor CRT aesthetic.
Renders a circular radar display using half-block characters for 2x vertical
resolution. Each terminal cell becomes two pixel rows via the character
with independent fg (top) and bg (bottom) colors.
The sweep beam rotates through 360 positions. Signal strength determines
radial distance from center. Older samples decay in brightness (phosphor
persistence). Concentric range rings and a crosshair provide scale reference.
"""
import math
from collections import deque
from textual.widget import Widget
from textual.strip import Strip
from rich.segment import Segment
from rich.style import Style
# P1 green phosphor palette — 8 intensity levels from dead to peak burn
PHOSPHOR = [
"#0a0a0a", # background / dead pixel
"#0a1a0a", # very faint trace
"#0a2a0a", # dim afterglow
"#0a3a0a", # fading
"#0a4a0f", # moderate
"#0a5a10", # bright trace
"#0a6a15", # very bright
"#10ff30", # peak phosphor burn
]
PHOSPHOR_STYLES = [Style(color=c) for c in PHOSPHOR]
BG_COLOR = "#0a0a0a"
BG_STYLE = Style(color=BG_COLOR, bgcolor=BG_COLOR)
RING_COLOR = "#0a2a0a"
CROSSHAIR_COLOR = "#0a3a0a"
LOCK_RING_COLOR = "#10ff30"
class RadarScope(Widget):
"""Circular radar scope with phosphor decay and sweep beam."""
DEFAULT_CSS = """
RadarScope {
min-height: 12;
min-width: 24;
background: #0a0a0a;
}
"""
def __init__(self, max_samples: int = 360, **kwargs):
super().__init__(**kwargs)
self._samples: deque[float] = deque([0.0] * max_samples, maxlen=max_samples)
self._max_samples = max_samples
self._angle_idx = 0 # current sweep position (0..max_samples-1)
self._locked = False
# Pre-computed geometry + LUTs (rebuilt on resize)
self._cx = 0.0
self._cy = 0.0
self._radius = 0.0
self._cols = 0
self._rows = 0 # pixel rows (2x terminal rows)
# Per-pixel LUTs: distance from center, angle-to-sample index
self._dist_lut: list[list[float]] = []
self._angle_lut: list[list[int]] = []
# Per-pixel dx/dy for crosshair checks
self._dx_lut: list[list[float]] = []
self._dy_lut: list[list[float]] = []
def push(self, snr_db: float, max_snr: float = 16.0) -> None:
"""Push a new signal sample. SNR is normalized to 0.0-1.0 range."""
normalized = max(0.0, min(1.0, snr_db / max(0.1, max_snr)))
self._samples.append(normalized)
self._angle_idx = (self._angle_idx + 1) % self._max_samples
self.refresh()
def set_locked(self, locked: bool) -> None:
if locked != self._locked:
self._locked = locked
self.refresh()
def _recompute_geometry(self, width: int, height: int) -> None:
"""Recompute center, radius, and per-pixel LUTs for current dimensions.
Pre-computes dist/angle for every pixel so render_line() avoids
math.sqrt/math.atan2 per pixel per frame ~O(1) lookup instead.
"""
self._cols = width
self._rows = height * 2 # 2x vertical resolution via half-blocks
# Radius fits within both dimensions, accounting for ~2:1 terminal char aspect
rx = (width - 2) / 2.0
ry = (height * 2 - 2) / 2.0
self._radius = min(rx, ry)
cx = width / 2.0
cy = height # center in pixel rows (height * 2 / 2)
self._cx = cx
self._cy = cy
# Build LUTs for all pixel rows (height * 2) × columns
pixel_rows = height * 2
dist_lut = []
angle_lut = []
dx_lut = []
dy_lut = []
sqrt = math.sqrt
atan2 = math.atan2
degrees = math.degrees
ms = self._max_samples
for py in range(pixel_rows):
d_row = []
a_row = []
dxr = []
dyr = []
dy = py - cy
for px in range(width):
dx = px - cx
dist = sqrt(dx * dx + dy * dy)
d_row.append(dist)
dxr.append(dx)
dyr.append(dy)
if dist >= 1.0:
angle = atan2(dy, dx)
angle_deg = (degrees(angle) + 360) % 360
a_row.append(int(angle_deg / 360 * ms) % ms)
else:
a_row.append(0) # center pixel — angle irrelevant
dist_lut.append(d_row)
angle_lut.append(a_row)
dx_lut.append(dxr)
dy_lut.append(dyr)
self._dist_lut = dist_lut
self._angle_lut = angle_lut
self._dx_lut = dx_lut
self._dy_lut = dy_lut
def render_line(self, y: int) -> Strip:
"""Render one terminal row of the radar scope."""
width = self.size.width
height = self.size.height
if width < 4 or height < 4:
return Strip([Segment(" " * width, BG_STYLE)])
if self._cols != width or self._rows != height * 2:
self._recompute_geometry(width, height)
radius = self._radius
if radius < 2:
return Strip([Segment(" " * width, BG_STYLE)])
# Snapshot mutable state for consistent rendering across the frame
samples = list(self._samples)
angle_idx = self._angle_idx
locked = self._locked
# Two pixel rows per terminal row
py_top = y * 2
py_bot = y * 2 + 1
# LUT rows for this terminal row
dist_top = self._dist_lut[py_top] if py_top < len(self._dist_lut) else None
dist_bot = self._dist_lut[py_bot] if py_bot < len(self._dist_lut) else None
angle_top = self._angle_lut[py_top] if py_top < len(self._angle_lut) else None
angle_bot = self._angle_lut[py_bot] if py_bot < len(self._angle_lut) else None
dx_top = self._dx_lut[py_top] if py_top < len(self._dx_lut) else None
dx_bot = self._dx_lut[py_bot] if py_bot < len(self._dx_lut) else None
dy_top = self._dy_lut[py_top] if py_top < len(self._dy_lut) else None
dy_bot = self._dy_lut[py_bot] if py_bot < len(self._dy_lut) else None
if not dist_top or not dist_bot:
return Strip([Segment(" " * width, BG_STYLE)])
_pi = self._pixel_intensity
segments = []
for x in range(width):
top_intensity = _pi(
dist_top[x], angle_top[x], dx_top[x], dy_top[x],
radius, samples, angle_idx, locked,
)
bot_intensity = _pi(
dist_bot[x], angle_bot[x], dx_bot[x], dy_bot[x],
radius, samples, angle_idx, locked,
)
top_color = PHOSPHOR[top_intensity]
bot_color = PHOSPHOR[bot_intensity]
# ▀ char: fg = top pixel, bg = bottom pixel
style = Style(color=top_color, bgcolor=bot_color)
segments.append(Segment("\u2580", style))
return Strip(segments)
def _pixel_intensity(self, dist: float, sample_idx: int,
dx: float, dy: float,
radius: float, samples: list[float],
angle_idx: int, locked: bool) -> int:
"""Compute phosphor intensity (0-7) for a single pixel.
Uses pre-computed dist/sample_idx/dx/dy from LUTs (no trig per pixel).
Uses snapshot data (samples, angle_idx, locked) rather than mutable
instance state for frame-consistent rendering.
"""
# Outside the scope circle
if dist > radius + 1:
return 0
# Lock ring — outer edge glow
if locked and abs(dist - radius) < 1.5:
return 7
# Range rings at 25%, 50%, 75% radius
for ring_r in (0.25, 0.5, 0.75):
if abs(dist - radius * ring_r) < 0.7:
return 2 # dim ring
# Outer boundary ring
if abs(dist - radius) < 0.7:
return 2
# Crosshair (horizontal and vertical through center)
if abs(dx) < 0.7 and dist < radius:
return 2
if abs(dy) < 0.7 and dist < radius:
return 2
# Signal trace — map pixel angle to sample buffer
if dist < 1.0:
return 1 # center dot
if dist > radius:
return 0
# sample_idx already computed in LUT via atan2 → degrees → index
strength = samples[sample_idx]
# Signal renders as a blip at radial distance proportional to strength
signal_dist = strength * radius * 0.85 # 85% of radius at max
noise_floor_dist = radius * 0.05 # tiny noise floor ring
# Distance from the signal blip center
blip_dist = abs(dist - signal_dist)
noise_dist = abs(dist - noise_floor_dist)
# Age-based decay: how far behind the sweep beam is this angle?
age = (angle_idx - sample_idx) % self._max_samples
age_ratio = age / self._max_samples # 0 = newest, 1 = oldest
# Intensity from signal blip proximity
if strength > 0.02 and blip_dist < 2.0:
proximity = max(0.0, 1.0 - blip_dist / 2.0)
freshness = max(0.0, 1.0 - age_ratio * 1.2)
raw_intensity = proximity * freshness * strength
return max(1, min(7, int(raw_intensity * 7 + 0.5)))
# Sweep beam — the most recent angle is brightest
if age < 3:
beam_intensity = max(0.0, 1.0 - age / 3.0)
if dist < radius * 0.9:
return max(1, min(5, int(beam_intensity * 5)))
# Noise floor glow
if noise_dist < 1.0 and strength > 0:
return 1
# Fill between center and signal (faint trail)
if strength > 0.1 and dist < signal_dist and age_ratio < 0.5:
trail = max(0.0, (1.0 - age_ratio * 2) * 0.3)
if trail > 0.05:
return 1
return 0

View file

@ -1,120 +1,120 @@
"""Large signal strength gauge with SNR bar and lock indicator."""
from textual.app import ComposeResult
from textual.widget import Widget
from textual.widgets import Static
from textual.reactive import reactive
# Bar characters for sub-block resolution
_BARS = " ▏▎▍▌▋▊▉█"
def _snr_color(snr_db: float) -> str:
"""Map SNR to a hex color: blue → cyan → green → yellow → red."""
if snr_db < 2:
return "#1565c0"
elif snr_db < 4:
return "#0097a7"
elif snr_db < 6:
return "#00bfa5"
elif snr_db < 8:
return "#00d4aa"
elif snr_db < 10:
return "#4caf50"
elif snr_db < 12:
return "#8bc34a"
elif snr_db < 14:
return "#cddc39"
elif snr_db < 16:
return "#ffc107"
else:
return "#f44336"
def _build_bar(pct: float, width: int = 40) -> str:
"""Build a Unicode block bar string with sub-character precision."""
pct = max(0.0, min(100.0, pct))
ratio = pct / 100.0
full = int(ratio * width)
remainder = (ratio * width) - full
partial = int(remainder * (len(_BARS) - 1))
bar = "" * full
if full < width:
bar += _BARS[partial]
bar += " " * (width - full - 1)
return bar
class SignalGauge(Widget):
"""Large signal strength display with SNR, power, and lock state."""
DEFAULT_CSS = """
SignalGauge {
height: auto;
padding: 1 2;
background: #0e1420;
border: round #1a2a3a;
margin: 0 0 1 0;
}
SignalGauge #gauge-header {
height: 1;
margin: 0 0 1 0;
}
SignalGauge #gauge-bar-line {
height: 1;
}
SignalGauge #gauge-details {
height: 1;
margin: 1 0 0 0;
color: #506878;
}
"""
snr_db = reactive(0.0)
snr_pct = reactive(0.0)
power_db = reactive(-40.0)
locked = reactive(False)
agc1 = reactive(0)
def compose(self) -> ComposeResult:
yield Static("", id="gauge-header")
yield Static("", id="gauge-bar-line")
yield Static("", id="gauge-details")
def watch_snr_db(self) -> None:
self._refresh_display()
def watch_locked(self) -> None:
self._refresh_display()
def update_signal(self, sig: dict) -> None:
"""Update from a signal_monitor() result dict."""
self.snr_db = sig.get("snr_db", 0.0)
self.snr_pct = sig.get("snr_pct", 0.0)
self.power_db = sig.get("power_db", -40.0)
self.locked = sig.get("locked", False)
self.agc1 = sig.get("agc1", 0)
def _refresh_display(self) -> None:
if not self.is_mounted:
return
color = _snr_color(self.snr_db)
lock_str = "[bold #00e060]LOCK[/]" if self.locked else "[#e04040]NO LOCK[/]"
header = self.query_one("#gauge-header", Static)
header.update(
f" {lock_str} "
f"[bold {color}]{self.snr_db:6.1f} dB[/] "
f"[#506878]{self.snr_pct:5.1f}%[/]"
)
bar_str = _build_bar(self.snr_pct, width=50)
bar_line = self.query_one("#gauge-bar-line", Static)
bar_line.update(f" [{color}]{bar_str}[/]")
details = self.query_one("#gauge-details", Static)
details.update(
f" Power: {self.power_db:6.1f} dB AGC: {self.agc1:5d}"
)
"""Large signal strength gauge with SNR bar and lock indicator."""
from textual.app import ComposeResult
from textual.widget import Widget
from textual.widgets import Static
from textual.reactive import reactive
# Bar characters for sub-block resolution
_BARS = " ▏▎▍▌▋▊▉█"
def _snr_color(snr_db: float) -> str:
"""Map SNR to a hex color: blue → cyan → green → yellow → red."""
if snr_db < 2:
return "#1565c0"
elif snr_db < 4:
return "#0097a7"
elif snr_db < 6:
return "#00bfa5"
elif snr_db < 8:
return "#00d4aa"
elif snr_db < 10:
return "#4caf50"
elif snr_db < 12:
return "#8bc34a"
elif snr_db < 14:
return "#cddc39"
elif snr_db < 16:
return "#ffc107"
else:
return "#f44336"
def _build_bar(pct: float, width: int = 40) -> str:
"""Build a Unicode block bar string with sub-character precision."""
pct = max(0.0, min(100.0, pct))
ratio = pct / 100.0
full = int(ratio * width)
remainder = (ratio * width) - full
partial = int(remainder * (len(_BARS) - 1))
bar = "" * full
if full < width:
bar += _BARS[partial]
bar += " " * (width - full - 1)
return bar
class SignalGauge(Widget):
"""Large signal strength display with SNR, power, and lock state."""
DEFAULT_CSS = """
SignalGauge {
height: auto;
padding: 1 2;
background: #0e1420;
border: round #1a2a3a;
margin: 0 0 1 0;
}
SignalGauge #gauge-header {
height: 1;
margin: 0 0 1 0;
}
SignalGauge #gauge-bar-line {
height: 1;
}
SignalGauge #gauge-details {
height: 1;
margin: 1 0 0 0;
color: #506878;
}
"""
snr_db = reactive(0.0)
snr_pct = reactive(0.0)
power_db = reactive(-40.0)
locked = reactive(False)
agc1 = reactive(0)
def compose(self) -> ComposeResult:
yield Static("", id="gauge-header")
yield Static("", id="gauge-bar-line")
yield Static("", id="gauge-details")
def watch_snr_db(self) -> None:
self._refresh_display()
def watch_locked(self) -> None:
self._refresh_display()
def update_signal(self, sig: dict) -> None:
"""Update from a signal_monitor() result dict."""
self.snr_db = sig.get("snr_db", 0.0)
self.snr_pct = sig.get("snr_pct", 0.0)
self.power_db = sig.get("power_db", -40.0)
self.locked = sig.get("locked", False)
self.agc1 = sig.get("agc1", 0)
def _refresh_display(self) -> None:
if not self.is_mounted:
return
color = _snr_color(self.snr_db)
lock_str = "[bold #00e060]LOCK[/]" if self.locked else "[#e04040]NO LOCK[/]"
header = self.query_one("#gauge-header", Static)
header.update(
f" {lock_str} "
f"[bold {color}]{self.snr_db:6.1f} dB[/] "
f"[#506878]{self.snr_pct:5.1f}%[/]"
)
bar_str = _build_bar(self.snr_pct, width=50)
bar_line = self.query_one("#gauge-bar-line", Static)
bar_line.update(f" [{color}]{bar_str}[/]")
details = self.query_one("#gauge-details", Static)
details.update(
f" Power: {self.power_db:6.1f} dB AGC: {self.agc1:5d}"
)

View file

@ -1,71 +1,71 @@
"""Rolling sparkline time series widget using Unicode spark characters."""
from collections import deque
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
_SPARKS = "▁▂▃▄▅▆▇█"
class SparklineWidget(Widget):
"""Rolling time-series display using Unicode block characters."""
DEFAULT_CSS = """
SparklineWidget {
height: 3;
padding: 0 1;
background: #0e1420;
border: round #1a2a3a;
margin: 0 0 1 0;
}
SparklineWidget #spark-label {
height: 1;
color: #506878;
}
SparklineWidget #spark-line {
height: 1;
}
"""
def __init__(self, title: str = "History", max_width: int = 80,
color: str = "#00d4aa", **kwargs):
super().__init__(**kwargs)
self._title = title
self._max_width = max_width
self._color = color
self._values: deque[float] = deque(maxlen=max_width)
def compose(self) -> ComposeResult:
yield Static(f"[#506878]{self._title}[/]", id="spark-label")
yield Static("", id="spark-line")
def push(self, value: float) -> None:
"""Add a new data point and refresh the display."""
self._values.append(value)
self._refresh()
def clear(self) -> None:
self._values.clear()
if self.is_mounted:
self.query_one("#spark-line", Static).update("")
def _refresh(self) -> None:
if not self.is_mounted or not self._values:
return
vals = list(self._values)
mn = min(vals)
mx = max(vals)
rng = mx - mn if mx != mn else 1.0
chars = []
for v in vals:
idx = int((v - mn) / rng * (len(_SPARKS) - 1))
idx = max(0, min(len(_SPARKS) - 1, idx))
chars.append(_SPARKS[idx])
spark_str = "".join(chars)
line = self.query_one("#spark-line", Static)
line.update(f"[{self._color}]{spark_str}[/] [{mn:.1f} .. {mx:.1f}]")
"""Rolling sparkline time series widget using Unicode spark characters."""
from collections import deque
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
_SPARKS = "▁▂▃▄▅▆▇█"
class SparklineWidget(Widget):
"""Rolling time-series display using Unicode block characters."""
DEFAULT_CSS = """
SparklineWidget {
height: 3;
padding: 0 1;
background: #0e1420;
border: round #1a2a3a;
margin: 0 0 1 0;
}
SparklineWidget #spark-label {
height: 1;
color: #506878;
}
SparklineWidget #spark-line {
height: 1;
}
"""
def __init__(self, title: str = "History", max_width: int = 80,
color: str = "#00d4aa", **kwargs):
super().__init__(**kwargs)
self._title = title
self._max_width = max_width
self._color = color
self._values: deque[float] = deque(maxlen=max_width)
def compose(self) -> ComposeResult:
yield Static(f"[#506878]{self._title}[/]", id="spark-label")
yield Static("", id="spark-line")
def push(self, value: float) -> None:
"""Add a new data point and refresh the display."""
self._values.append(value)
self._refresh()
def clear(self) -> None:
self._values.clear()
if self.is_mounted:
self.query_one("#spark-line", Static).update("")
def _refresh(self) -> None:
if not self.is_mounted or not self._values:
return
vals = list(self._values)
mn = min(vals)
mx = max(vals)
rng = mx - mn if mx != mn else 1.0
chars = []
for v in vals:
idx = int((v - mn) / rng * (len(_SPARKS) - 1))
idx = max(0, min(len(_SPARKS) - 1, idx))
chars.append(_SPARKS[idx])
spark_str = "".join(chars)
line = self.query_one("#spark-line", Static)
line.update(f"[{self._color}]{spark_str}[/] [{mn:.1f} .. {mx:.1f}]")

View file

@ -1,155 +1,155 @@
"""Terminal-native spectrum plot using Unicode block characters and Rich markup.
Renders a horizontal bar chart where each frequency bin gets a colored bar
proportional to its power level. The color gradient goes from cold (blue)
to hot (red), same concept as the CLI tool's WATERFALL_COLORS but using
Rich style strings instead of raw ANSI escapes.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "tools"))
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from textual.containers import VerticalScroll
from skywalker_lib import detect_peaks, if_to_rf
# Sub-block characters for fractional bar width
_BARS = " ▏▎▍▌▋▊▉█"
# Power-to-color gradient (16 steps: blue → cyan → green → yellow → red)
_POWER_COLORS = [
"#1a237e", # dark blue (weakest)
"#1565c0",
"#0277bd",
"#00838f",
"#00897b", # teal
"#2e7d32", # green
"#558b2f",
"#9e9d24",
"#f9a825", # yellow
"#ff8f00",
"#ef6c00", # orange
"#e65100",
"#d84315",
"#c62828", # red
"#b71c1c",
"#880e0e", # dark red (strongest)
]
def _power_to_color(power_db: float, floor: float, ceiling: float) -> str:
"""Map a power value to a color from the gradient."""
if ceiling == floor:
return _POWER_COLORS[len(_POWER_COLORS) // 2]
ratio = (power_db - floor) / (ceiling - floor)
ratio = max(0.0, min(1.0, ratio))
idx = int(ratio * (len(_POWER_COLORS) - 1))
return _POWER_COLORS[idx]
class SpectrumPlot(Widget):
"""Bar-chart spectrum display rendered with Unicode blocks and Rich styles."""
DEFAULT_CSS = """
SpectrumPlot {
height: 1fr;
min-height: 12;
background: #0a0a12;
padding: 0 1;
}
SpectrumPlot #spectrum-title {
height: 1;
color: #00d4aa;
text-style: bold;
}
SpectrumPlot #spectrum-body {
height: 1fr;
}
"""
def __init__(self, title: str = "Spectrum", bar_width: int = 40,
lnb_lo: float = 0.0, **kwargs):
super().__init__(**kwargs)
self._title = title
self._bar_width = bar_width
self._lnb_lo = lnb_lo
self._freqs: list[float] = []
self._powers: list[float] = []
self._results: list[dict] = []
def compose(self) -> ComposeResult:
yield Static(f"[#00d4aa bold]{self._title}[/]", id="spectrum-title")
yield VerticalScroll(Static("", id="spectrum-lines"), id="spectrum-body")
def update_data(self, freqs: list[float], powers: list[float],
results: list[dict] | None = None, lnb_lo: float | None = None):
"""Update with new sweep data and redraw."""
self._freqs = freqs
self._powers = powers
self._results = results or [{} for _ in freqs]
if lnb_lo is not None:
self._lnb_lo = lnb_lo
self._refresh()
def _refresh(self) -> None:
if not self.is_mounted or not self._freqs:
return
p_min = min(self._powers)
p_max = max(self._powers)
p_range = p_max - p_min if p_max != p_min else 1.0
# Detect peaks for markers
peaks_set = set()
peaks = detect_peaks(self._freqs, self._powers, threshold_db=3.0)
for _f, _p, idx in peaks:
peaks_set.add(idx)
lines = []
for i, (f, p) in enumerate(zip(self._freqs, self._powers)):
# Frequency label (RF or IF)
if self._lnb_lo > 0:
label = f"{if_to_rf(f, self._lnb_lo):7.0f}"
else:
label = f"{f:7.1f}"
# Bar
ratio = max(0.0, min(1.0, (p - p_min) / p_range))
full = int(ratio * self._bar_width)
remainder = (ratio * self._bar_width) - full
partial = int(remainder * (len(_BARS) - 1))
color = _power_to_color(p, p_min, p_max)
bar = "" * full
if full < self._bar_width:
bar += _BARS[partial]
bar += " " * (self._bar_width - full - 1)
locked = self._results[i].get("locked", False) if i < len(self._results) else False
lock_mark = " [bold #00e060]*[/]" if locked else ""
peak_mark = " [bold #f44336]^[/]" if i in peaks_set else ""
lines.append(
f"[#506878]{label}[/] [{color}]{bar}[/] [#7090a8]{p:6.1f}[/]{lock_mark}{peak_mark}"
)
# Peak summary at bottom
if peaks:
lines.append("")
lines.append(f"[#00d4aa bold]Peaks ({len(peaks)}):[/]")
for freq, pwr, idx in peaks:
if self._lnb_lo > 0:
fl = f"{if_to_rf(freq, self._lnb_lo):.0f} MHz RF"
else:
fl = f"{freq:.1f} MHz"
locked = self._results[idx].get("locked", False) if idx < len(self._results) else False
lock_s = " [bold #00e060]LOCKED[/]" if locked else ""
lines.append(f" [#c8d0d8]{fl} {pwr:.1f} dB{lock_s}[/]")
body = self.query_one("#spectrum-lines", Static)
body.update("\n".join(lines))
"""Terminal-native spectrum plot using Unicode block characters and Rich markup.
Renders a horizontal bar chart where each frequency bin gets a colored bar
proportional to its power level. The color gradient goes from cold (blue)
to hot (red), same concept as the CLI tool's WATERFALL_COLORS but using
Rich style strings instead of raw ANSI escapes.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "tools"))
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from textual.containers import VerticalScroll
from skywalker_lib import detect_peaks, if_to_rf
# Sub-block characters for fractional bar width
_BARS = " ▏▎▍▌▋▊▉█"
# Power-to-color gradient (16 steps: blue → cyan → green → yellow → red)
_POWER_COLORS = [
"#1a237e", # dark blue (weakest)
"#1565c0",
"#0277bd",
"#00838f",
"#00897b", # teal
"#2e7d32", # green
"#558b2f",
"#9e9d24",
"#f9a825", # yellow
"#ff8f00",
"#ef6c00", # orange
"#e65100",
"#d84315",
"#c62828", # red
"#b71c1c",
"#880e0e", # dark red (strongest)
]
def _power_to_color(power_db: float, floor: float, ceiling: float) -> str:
"""Map a power value to a color from the gradient."""
if ceiling == floor:
return _POWER_COLORS[len(_POWER_COLORS) // 2]
ratio = (power_db - floor) / (ceiling - floor)
ratio = max(0.0, min(1.0, ratio))
idx = int(ratio * (len(_POWER_COLORS) - 1))
return _POWER_COLORS[idx]
class SpectrumPlot(Widget):
"""Bar-chart spectrum display rendered with Unicode blocks and Rich styles."""
DEFAULT_CSS = """
SpectrumPlot {
height: 1fr;
min-height: 12;
background: #0a0a12;
padding: 0 1;
}
SpectrumPlot #spectrum-title {
height: 1;
color: #00d4aa;
text-style: bold;
}
SpectrumPlot #spectrum-body {
height: 1fr;
}
"""
def __init__(self, title: str = "Spectrum", bar_width: int = 40,
lnb_lo: float = 0.0, **kwargs):
super().__init__(**kwargs)
self._title = title
self._bar_width = bar_width
self._lnb_lo = lnb_lo
self._freqs: list[float] = []
self._powers: list[float] = []
self._results: list[dict] = []
def compose(self) -> ComposeResult:
yield Static(f"[#00d4aa bold]{self._title}[/]", id="spectrum-title")
yield VerticalScroll(Static("", id="spectrum-lines"), id="spectrum-body")
def update_data(self, freqs: list[float], powers: list[float],
results: list[dict] | None = None, lnb_lo: float | None = None):
"""Update with new sweep data and redraw."""
self._freqs = freqs
self._powers = powers
self._results = results or [{} for _ in freqs]
if lnb_lo is not None:
self._lnb_lo = lnb_lo
self._refresh()
def _refresh(self) -> None:
if not self.is_mounted or not self._freqs:
return
p_min = min(self._powers)
p_max = max(self._powers)
p_range = p_max - p_min if p_max != p_min else 1.0
# Detect peaks for markers
peaks_set = set()
peaks = detect_peaks(self._freqs, self._powers, threshold_db=3.0)
for _f, _p, idx in peaks:
peaks_set.add(idx)
lines = []
for i, (f, p) in enumerate(zip(self._freqs, self._powers)):
# Frequency label (RF or IF)
if self._lnb_lo > 0:
label = f"{if_to_rf(f, self._lnb_lo):7.0f}"
else:
label = f"{f:7.1f}"
# Bar
ratio = max(0.0, min(1.0, (p - p_min) / p_range))
full = int(ratio * self._bar_width)
remainder = (ratio * self._bar_width) - full
partial = int(remainder * (len(_BARS) - 1))
color = _power_to_color(p, p_min, p_max)
bar = "" * full
if full < self._bar_width:
bar += _BARS[partial]
bar += " " * (self._bar_width - full - 1)
locked = self._results[i].get("locked", False) if i < len(self._results) else False
lock_mark = " [bold #00e060]*[/]" if locked else ""
peak_mark = " [bold #f44336]^[/]" if i in peaks_set else ""
lines.append(
f"[#506878]{label}[/] [{color}]{bar}[/] [#7090a8]{p:6.1f}[/]{lock_mark}{peak_mark}"
)
# Peak summary at bottom
if peaks:
lines.append("")
lines.append(f"[#00d4aa bold]Peaks ({len(peaks)}):[/]")
for freq, pwr, idx in peaks:
if self._lnb_lo > 0:
fl = f"{if_to_rf(freq, self._lnb_lo):.0f} MHz RF"
else:
fl = f"{freq:.1f} MHz"
locked = self._results[idx].get("locked", False) if idx < len(self._results) else False
lock_s = " [bold #00e060]LOCKED[/]" if locked else ""
lines.append(f" [#c8d0d8]{fl} {pwr:.1f} dB{lock_s}[/]")
body = self.query_one("#spectrum-lines", Static)
body.update("\n".join(lines))

View file

@ -1,65 +1,65 @@
"""Device status bar — connection state, firmware version, config bits."""
from textual.app import ComposeResult
from textual.widget import Widget
from textual.widgets import Label
from skywalker_lib import format_config_bits
class DeviceStatusBar(Widget):
"""Bottom status bar showing device connection and configuration."""
DEFAULT_CSS = """
DeviceStatusBar {
height: 3;
background: #0e1420;
border-top: solid #1a2a3a;
padding: 0 1;
dock: bottom;
layout: horizontal;
}
DeviceStatusBar Label {
width: auto;
margin: 1 2 0 0;
}
"""
def __init__(self, bridge=None):
super().__init__(id="device-status")
self._bridge = bridge
def compose(self) -> ComposeResult:
yield Label("", id="status-conn")
yield Label("", id="status-fw")
yield Label("", id="status-config")
def update_status(self, bridge=None):
if bridge is not None:
self._bridge = bridge
if self._bridge is None:
return
conn_label = self.query_one("#status-conn", Label)
fw_label = self.query_one("#status-fw", Label)
config_label = self.query_one("#status-config", Label)
if self._bridge.is_demo:
conn_label.update("[bold #e8a020]DEMO[/]")
else:
conn_label.update("[bold #00d4aa]CONNECTED[/]")
try:
fw = self._bridge.get_fw_version()
fw_label.update(f"[#506878]FW:[/] [#c8d0d8]{fw['version']}[/]")
except Exception:
fw_label.update("[#506878]FW:[/] [#e04040]error[/]")
try:
config = self._bridge.get_config()
bits = format_config_bits(config)
active = [name for name, is_set in bits if is_set]
config_str = " | ".join(active) if active else "idle"
config_label.update(f"[#506878]Config:[/] [#7090a8]{config_str}[/]")
except Exception:
config_label.update("[#506878]Config:[/] [#e04040]error[/]")
"""Device status bar — connection state, firmware version, config bits."""
from textual.app import ComposeResult
from textual.widget import Widget
from textual.widgets import Label
from skywalker_lib import format_config_bits
class DeviceStatusBar(Widget):
"""Bottom status bar showing device connection and configuration."""
DEFAULT_CSS = """
DeviceStatusBar {
height: 3;
background: #0e1420;
border-top: solid #1a2a3a;
padding: 0 1;
dock: bottom;
layout: horizontal;
}
DeviceStatusBar Label {
width: auto;
margin: 1 2 0 0;
}
"""
def __init__(self, bridge=None):
super().__init__(id="device-status")
self._bridge = bridge
def compose(self) -> ComposeResult:
yield Label("", id="status-conn")
yield Label("", id="status-fw")
yield Label("", id="status-config")
def update_status(self, bridge=None):
if bridge is not None:
self._bridge = bridge
if self._bridge is None:
return
conn_label = self.query_one("#status-conn", Label)
fw_label = self.query_one("#status-fw", Label)
config_label = self.query_one("#status-config", Label)
if self._bridge.is_demo:
conn_label.update("[bold #e8a020]DEMO[/]")
else:
conn_label.update("[bold #00d4aa]CONNECTED[/]")
try:
fw = self._bridge.get_fw_version()
fw_label.update(f"[#506878]FW:[/] [#c8d0d8]{fw['version']}[/]")
except Exception:
fw_label.update("[#506878]FW:[/] [#e04040]error[/]")
try:
config = self._bridge.get_config()
bits = format_config_bits(config)
active = [name for name, is_set in bits if is_set]
config_str = " | ".join(active) if active else "idle"
config_label.update(f"[#506878]Config:[/] [#7090a8]{config_str}[/]")
except Exception:
config_label.update("[#506878]Config:[/] [#e04040]error[/]")

View file

@ -1,98 +1,98 @@
"""Rolling waterfall display — each row is one sweep, color = power level.
The waterfall auto-scrolls: new sweeps appear at the top, older rows shift down.
Uses Rich markup with the same 16-color power gradient as the spectrum plot.
"""
from collections import deque
from datetime import datetime
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from textual.containers import VerticalScroll
# Same gradient as spectrum_plot
_WATERFALL_COLORS = [
"#1a237e", "#1565c0", "#0277bd", "#00838f",
"#00897b", "#2e7d32", "#558b2f", "#9e9d24",
"#f9a825", "#ff8f00", "#ef6c00", "#e65100",
"#d84315", "#c62828", "#b71c1c", "#880e0e",
]
class WaterfallDisplay(Widget):
"""Scrolling waterfall of spectrum sweeps."""
DEFAULT_CSS = """
WaterfallDisplay {
height: 1fr;
min-height: 8;
background: #0a0a12;
padding: 0 1;
}
WaterfallDisplay #waterfall-title {
height: 1;
color: #00d4aa;
text-style: bold;
}
WaterfallDisplay #waterfall-body {
height: 1fr;
}
"""
def __init__(self, title: str = "Waterfall", max_rows: int = 50, **kwargs):
super().__init__(**kwargs)
self._title = title
self._max_rows = max_rows
self._rows: deque[tuple[str, list[float]]] = deque(maxlen=max_rows)
self._global_min: float = -40.0
self._global_max: float = 0.0
def compose(self) -> ComposeResult:
yield Static(f"[#00d4aa bold]{self._title}[/]", id="waterfall-title")
yield VerticalScroll(Static("", id="waterfall-lines"), id="waterfall-body")
def add_sweep(self, powers: list[float]) -> None:
"""Add a new sweep row and refresh."""
ts = datetime.now().strftime("%H:%M:%S")
self._rows.appendleft((ts, list(powers)))
# Update global range for consistent coloring
if powers:
self._global_min = min(self._global_min, min(powers))
self._global_max = max(self._global_max, max(powers))
self._refresh()
def clear(self) -> None:
self._rows.clear()
self._global_min = -40.0
self._global_max = 0.0
if self.is_mounted:
self.query_one("#waterfall-lines", Static).update("")
def _refresh(self) -> None:
if not self.is_mounted or not self._rows:
return
rng = self._global_max - self._global_min
if rng == 0:
rng = 1.0
lines = []
for ts, powers in self._rows:
chars = []
for p in powers:
ratio = (p - self._global_min) / rng
ratio = max(0.0, min(1.0, ratio))
idx = int(ratio * (len(_WATERFALL_COLORS) - 1))
color = _WATERFALL_COLORS[idx]
chars.append(f"[{color}]█[/]")
line = "".join(chars)
lines.append(f"[#506878]{ts}[/] {line}")
body = self.query_one("#waterfall-lines", Static)
body.update("\n".join(lines))
"""Rolling waterfall display — each row is one sweep, color = power level.
The waterfall auto-scrolls: new sweeps appear at the top, older rows shift down.
Uses Rich markup with the same 16-color power gradient as the spectrum plot.
"""
from collections import deque
from datetime import datetime
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from textual.containers import VerticalScroll
# Same gradient as spectrum_plot
_WATERFALL_COLORS = [
"#1a237e", "#1565c0", "#0277bd", "#00838f",
"#00897b", "#2e7d32", "#558b2f", "#9e9d24",
"#f9a825", "#ff8f00", "#ef6c00", "#e65100",
"#d84315", "#c62828", "#b71c1c", "#880e0e",
]
class WaterfallDisplay(Widget):
"""Scrolling waterfall of spectrum sweeps."""
DEFAULT_CSS = """
WaterfallDisplay {
height: 1fr;
min-height: 8;
background: #0a0a12;
padding: 0 1;
}
WaterfallDisplay #waterfall-title {
height: 1;
color: #00d4aa;
text-style: bold;
}
WaterfallDisplay #waterfall-body {
height: 1fr;
}
"""
def __init__(self, title: str = "Waterfall", max_rows: int = 50, **kwargs):
super().__init__(**kwargs)
self._title = title
self._max_rows = max_rows
self._rows: deque[tuple[str, list[float]]] = deque(maxlen=max_rows)
self._global_min: float = -40.0
self._global_max: float = 0.0
def compose(self) -> ComposeResult:
yield Static(f"[#00d4aa bold]{self._title}[/]", id="waterfall-title")
yield VerticalScroll(Static("", id="waterfall-lines"), id="waterfall-body")
def add_sweep(self, powers: list[float]) -> None:
"""Add a new sweep row and refresh."""
ts = datetime.now().strftime("%H:%M:%S")
self._rows.appendleft((ts, list(powers)))
# Update global range for consistent coloring
if powers:
self._global_min = min(self._global_min, min(powers))
self._global_max = max(self._global_max, max(powers))
self._refresh()
def clear(self) -> None:
self._rows.clear()
self._global_min = -40.0
self._global_max = 0.0
if self.is_mounted:
self.query_one("#waterfall-lines", Static).update("")
def _refresh(self) -> None:
if not self.is_mounted or not self._rows:
return
rng = self._global_max - self._global_min
if rng == 0:
rng = 1.0
lines = []
for ts, powers in self._rows:
chars = []
for p in powers:
ratio = (p - self._global_min) / rng
ratio = max(0.0, min(1.0, ratio))
idx = int(ratio * (len(_WATERFALL_COLORS) - 1))
color = _WATERFALL_COLORS[idx]
chars.append(f"[{color}]█[/]")
line = "".join(chars)
lines.append(f"[#506878]{ts}[/] {line}")
body = self.query_one("#waterfall-lines", Static)
body.update("\n".join(lines))