Add DiSEqC motor control, QO-100 DATV reception, and carrier survey

Firmware v3.03.0: DiSEqC Manchester encoder (cmd 0x8D extended),
parameterized spectrum sweep (0xBA), adaptive blind scan (0xBB),
error code reporting (0xBC). All new function locals moved to XDATA
to fit within FX2LP 256-byte internal RAM constraint.

Motor control: DiSEqC 1.2 positioner with USALS GotoX, stored
positions, interactive keyboard jog, 30-second safety auto-halt.

QO-100 DATV: Es'hail-2 wideband transponder tools — LNB IF
calculator, narrowband scan, tune, and TS-to-video pipe (ffplay/mpv).

Carrier survey: six-stage pipeline (coarse sweep → peak detection →
fine sweep → blind scan → TS sample → catalog). JSON catalog with
differential analysis, QO-100 optimized mode, CSV/text export.

TUI: F9 Motor screen (3-column layout with signal gauge), F10 Survey
screen (Full Band + QO-100 tabs). Bridge, demo, and theme updated.

Docs: motor.mdx, survey.mdx, qo100-datv.mdx guide, tui.mdx updated
for 10 screens. Site builds 41 pages, all links valid.
This commit is contained in:
Ryan Malloy 2026-02-15 17:01:11 -07:00
parent 0f4ba4766f
commit cc3a0707a1
20 changed files with 5645 additions and 84 deletions

View file

@ -27,6 +27,8 @@ 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 = {
@ -38,6 +40,8 @@ MODES = {
"device": ("F6 Device", DeviceScreen),
"stream": ("F7 Stream", StreamScreen),
"config": ("F8 Config", ConfigScreen),
"motor": ("F9 Motor", MotorScreen),
"survey": ("F10 Survey", SurveyScreen),
}
@ -57,6 +61,8 @@ class SkyWalkerApp(App):
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),

View file

@ -200,3 +200,45 @@ class USBBridge:
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()

View file

@ -26,6 +26,14 @@ _TRANSPONDERS = [
(1950, -22.0, 13000), # weaker TP
]
# QO-100 transponders (IF MHz for 9361 LO: RF 10491-10499 -> IF 1130-1138)
_QO100_TRANSPONDERS = [
(1130.5, -20.0, 1500), # BATC beacon
(1131.0, -24.0, 1000), # DATV station
(1132.0, -28.0, 500), # low-power DATV
(1133.0, -30.0, 333), # minimum viable DVB-S
]
_NOISE_FLOOR = -35.0
_LOCK_THRESHOLD_DB = 3.5
@ -73,6 +81,17 @@ class DemoDevice:
self._sample_count = 0
self._eeprom = _build_demo_eeprom()
self._cc_counters: dict[int, int] = {pid: 0 for pid in _DEMO_PIDS}
# Motor simulation state
self._motor_position_deg = 0.0
self._motor_target_deg = 0.0
self._motor_moving = False
self._motor_direction = 0 # -1=west, 0=stopped, 1=east
self._motor_speed_dps = 1.5 # degrees per second
self._motor_last_update = time.monotonic()
self._motor_stored: dict[int, float] = {}
self._motor_east_limit = 75.0
self._motor_west_limit = -75.0
self._last_error = 0x00
def open(self):
pass
@ -89,10 +108,10 @@ class DemoDevice:
def get_fw_version(self) -> dict:
return {
"major": 3,
"minor": 2,
"minor": 3,
"patch": 0,
"version": "3.02.0",
"date": "2025-02-10",
"version": "3.03.0",
"date": "2026-02-15",
}
def get_config(self) -> int:
@ -416,6 +435,129 @@ class DemoDevice:
def send_diseqc_message(self, msg: bytes) -> None:
time.sleep(0.05)
# --- Motor simulation ---
def _motor_tick(self):
"""Update simulated motor position based on elapsed time."""
now = time.monotonic()
dt = now - self._motor_last_update
self._motor_last_update = now
if self._motor_direction != 0:
delta = self._motor_speed_dps * dt * self._motor_direction
self._motor_position_deg += delta
# Clamp to limits
self._motor_position_deg = max(
self._motor_west_limit,
min(self._motor_east_limit, self._motor_position_deg)
)
# Stop at limits
if (self._motor_position_deg >= self._motor_east_limit or
self._motor_position_deg <= self._motor_west_limit):
self._motor_direction = 0
self._motor_moving = False
# Slew to target (for goto commands)
if self._motor_moving and self._motor_direction == 0:
diff = self._motor_target_deg - self._motor_position_deg
if abs(diff) < 0.1:
self._motor_position_deg = self._motor_target_deg
self._motor_moving = False
else:
step = min(abs(diff), self._motor_speed_dps * dt)
self._motor_position_deg += step if diff > 0 else -step
def motor_halt(self) -> None:
self._motor_tick()
self._motor_direction = 0
self._motor_moving = False
time.sleep(0.02)
def motor_drive_east(self, steps: int = 0) -> None:
self._motor_tick()
if steps > 0:
self._motor_target_deg = self._motor_position_deg + steps * 0.1
self._motor_target_deg = min(self._motor_target_deg, self._motor_east_limit)
self._motor_moving = True
self._motor_direction = 0
else:
self._motor_direction = 1
self._motor_moving = True
time.sleep(0.02)
def motor_drive_west(self, steps: int = 0) -> None:
self._motor_tick()
if steps > 0:
self._motor_target_deg = self._motor_position_deg - steps * 0.1
self._motor_target_deg = max(self._motor_target_deg, self._motor_west_limit)
self._motor_moving = True
self._motor_direction = 0
else:
self._motor_direction = -1
self._motor_moving = True
time.sleep(0.02)
def motor_store_position(self, slot: int) -> None:
self._motor_tick()
self._motor_stored[slot] = self._motor_position_deg
time.sleep(0.02)
def motor_goto_position(self, slot: int) -> None:
self._motor_tick()
if slot == 0:
self._motor_target_deg = 0.0
elif slot in self._motor_stored:
self._motor_target_deg = self._motor_stored[slot]
else:
return
self._motor_moving = True
self._motor_direction = 0
time.sleep(0.02)
def motor_goto_x(self, observer_lon: float, sat_lon: float) -> None:
# Simplified USALS angle calculation
angle = math.degrees(math.atan2(
math.sin(math.radians(sat_lon - observer_lon)),
math.cos(math.radians(sat_lon - observer_lon)) - 6378.0 / (6378.0 + 35786.0)
))
self._motor_tick()
self._motor_target_deg = angle
self._motor_moving = True
self._motor_direction = 0
time.sleep(0.02)
def motor_set_limit(self, direction: str) -> None:
self._motor_tick()
if direction.lower() == "east":
self._motor_east_limit = self._motor_position_deg
else:
self._motor_west_limit = self._motor_position_deg
time.sleep(0.02)
def motor_disable_limits(self) -> None:
self._motor_east_limit = 75.0
self._motor_west_limit = -75.0
time.sleep(0.02)
def get_last_error(self) -> int:
return self._last_error
def get_last_error_str(self) -> str:
names = {0: "OK", 1: "I2C timeout", 2: "I2C NAK",
3: "I2C arb lost", 4: "BCM not ready", 5: "BCM timeout"}
return names.get(self._last_error, f"Unknown (0x{self._last_error:02X})")
@property
def motor_position(self) -> float:
"""Current simulated motor position in degrees."""
self._motor_tick()
return self._motor_position_deg
@property
def motor_is_moving(self) -> bool:
self._motor_tick()
return self._motor_moving or self._motor_direction != 0
def get_signal_lock(self) -> bool:
sig = self.signal_monitor()
return sig["locked"]
@ -442,14 +584,25 @@ class DemoDevice:
power = _NOISE_FLOOR + random.gauss(0, 0.5)
# Add Gaussian peaks for each simulated transponder
for tp_freq, tp_peak, _sr in _TRANSPONDERS:
# Bandwidth ~15 MHz sigma
all_tps = list(_TRANSPONDERS) + list(_QO100_TRANSPONDERS)
for tp_freq, tp_peak, _sr in all_tps:
# Bandwidth ~15 MHz sigma for broadcast, ~3 MHz for QO-100
sigma = 3.0 if tp_freq > 1100 and tp_freq < 1145 else 12.0
dist = (freq_mhz - tp_freq)
gauss = math.exp(-(dist ** 2) / (2 * 12.0 ** 2))
gauss = math.exp(-(dist ** 2) / (2 * sigma ** 2))
# Slow atmospheric drift: +-2 dB over 30s period
drift = 2.0 * math.sin(elapsed / 30.0 * 2 * math.pi + tp_freq / 100.0)
power += (tp_peak - _NOISE_FLOOR + drift) * gauss
# Motor position affects signal strength (simulates dish alignment)
# Peak signal at position 0 (reference), degrades with offset
if hasattr(self, '_motor_position_deg'):
self._motor_tick()
offset = abs(self._motor_position_deg)
if offset > 2.0:
# Signal drops ~3 dB per degree off-axis beyond ±2°
power -= (offset - 2.0) * 3.0
return power
@staticmethod

View file

@ -0,0 +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 #motor-usals-inputs {
height: auto;
}
MotorScreen #motor-usals-inputs Label {
color: #506878;
width: auto;
margin: 0 1 0 0;
}
MotorScreen #motor-usals-inputs 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(id="motor-usals-inputs"):
yield Label("Observer Lon:")
yield Input("-97.5", id="usals-obs-lon")
with Horizontal(id="motor-usals-inputs"):
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

@ -0,0 +1,593 @@
"""Survey screen — carrier survey + QO-100 DATV reception (tabbed).
Two tabs within the Survey screen:
- Full Band: automated carrier discovery across the entire IF range
- QO-100: focused on the Es'hail-2 wideband DATV transponder
Both share a spectrum visualization + results table pattern.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import (
Label, Input, Button, Static, ProgressBar,
TabbedContent, TabPane,
)
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
# QO-100 IF range for common LNB LOs
_QO100_WB_RF_START = 10491.0
_QO100_WB_RF_STOP = 10499.0
class SurveyScreen(Container):
"""Carrier survey with full-band and QO-100 tabs."""
DEFAULT_CSS = """
SurveyScreen {
layout: vertical;
}
SurveyScreen TabbedContent {
height: 1fr;
}
SurveyScreen .survey-tab {
layout: vertical;
height: 1fr;
}
SurveyScreen .survey-upper {
height: 1fr;
layout: horizontal;
}
SurveyScreen .survey-spectrum-col {
width: 1fr;
}
SurveyScreen .survey-results-col {
width: 1fr;
}
SurveyScreen .survey-progress {
height: auto;
padding: 1 2;
background: #0e1018;
layout: vertical;
}
SurveyScreen .survey-progress Static {
width: auto;
margin: 0 1 0 0;
}
SurveyScreen .survey-progress-row {
height: 3;
layout: horizontal;
}
SurveyScreen .survey-progress ProgressBar {
width: 1fr;
margin: 0 1;
}
SurveyScreen .survey-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
SurveyScreen .survey-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
SurveyScreen .survey-controls Input {
width: 10;
margin: 0 1;
}
SurveyScreen .survey-controls Button {
margin: 0 1;
}
SurveyScreen .qo100-info {
height: auto;
padding: 1;
background: #0e1420;
border: round #1a2a3a;
margin: 0 0 1 0;
}
SurveyScreen .qo100-info-title {
color: #00d4aa;
text-style: bold;
margin: 0 0 1 0;
}
SurveyScreen .qo100-station {
color: #c8d0d8;
}
SurveyScreen .qo100-detectable {
color: #00e060;
}
SurveyScreen .qo100-not-lockable {
color: #e8a020;
}
"""
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 TabbedContent():
with TabPane("Full Band", id="tab-fullband"):
yield self._compose_fullband()
with TabPane("QO-100 DATV", id="tab-qo100"):
yield self._compose_qo100()
def _compose_fullband(self) -> Container:
c = Vertical(classes="survey-tab")
c._nodes = []
return _FullBandTab(self._bridge, id="fullband-tab")
def _compose_qo100(self) -> Container:
return _QO100Tab(self._bridge, id="qo100-tab")
def on_hide(self) -> None:
self._stop_scan()
def _stop_scan(self) -> None:
self._scanning = False
if self._scan_worker:
self._scan_worker.cancel()
self._scan_worker = None
class _FullBandTab(Container):
"""Full IF band carrier survey."""
DEFAULT_CSS = """
_FullBandTab {
layout: vertical;
height: 1fr;
}
"""
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 Horizontal(classes="survey-upper"):
with Vertical(classes="survey-spectrum-col"):
yield SpectrumPlot(title="Survey Sweep", id="survey-spectrum")
with Vertical(classes="survey-results-col"):
yield Static("[#00d4aa bold]Carriers Found[/]")
yield FrequencyTable(id="survey-table")
with Vertical(classes="survey-progress"):
yield Static("[#506878]Ready[/]", id="survey-phase")
with Horizontal(classes="survey-progress-row"):
yield ProgressBar(total=100, show_eta=False, id="survey-pbar")
with Horizontal(classes="survey-controls"):
yield Label("Start IF:")
yield Input("950", id="survey-start")
yield Label("Stop IF:")
yield Input("2150", id="survey-stop")
yield Label("LNB LO:")
yield Input("9750", id="survey-lnb")
yield Label("Step:")
yield Input("5", id="survey-step")
yield Button("Full Scan", id="survey-full", variant="success")
yield Button("Quick Scan", id="survey-quick", variant="primary")
yield Button("Stop", id="survey-stop-btn", variant="error")
def on_button_pressed(self, event: Button.Pressed) -> None:
btn = event.button.id or ""
if btn == "survey-full":
self._start_full_scan()
elif btn == "survey-quick":
self._start_quick_scan()
elif btn == "survey-stop-btn":
self._stop()
def _stop(self) -> None:
self._scanning = False
if self._scan_worker:
self._scan_worker.cancel()
self._scan_worker = None
def _start_full_scan(self) -> None:
if self._scanning:
return
self._scanning = True
start = float(self.query_one("#survey-start", Input).value or "950")
stop = float(self.query_one("#survey-stop", Input).value or "2150")
lnb_lo = float(self.query_one("#survey-lnb", Input).value or "9750")
step = float(self.query_one("#survey-step", Input).value or "5")
self.query_one("#survey-table", FrequencyTable).clear_table()
self._scan_worker = self._do_full_scan(start, stop, lnb_lo, step)
def _start_quick_scan(self) -> None:
if self._scanning:
return
self._scanning = True
start = float(self.query_one("#survey-start", Input).value or "950")
stop = float(self.query_one("#survey-stop", Input).value or "2150")
lnb_lo = float(self.query_one("#survey-lnb", Input).value or "9750")
step = float(self.query_one("#survey-step", Input).value or "5")
self._scan_worker = self._do_quick_scan(start, stop, lnb_lo, step)
@work(thread=True)
def _do_full_scan(self, start: float, stop: float,
lnb_lo: float, step: float) -> None:
"""Six-stage carrier survey in background thread."""
import sys, 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
# Stage 1: Coarse sweep
self.app.call_from_thread(self._set_phase, "Stage 1/6: Coarse sweep", 0)
def sweep_cb(freq, step_num, total, result):
pct = (step_num + 1) / total * 15
self.app.call_from_thread(self._set_progress, pct)
freqs, powers, results = self._bridge.sweep_spectrum(
start, stop, step, dwell_ms=15, callback=sweep_cb,
)
if not self._scanning:
return
self.app.call_from_thread(self._update_spectrum, freqs, powers, results, lnb_lo)
# Stage 2: Peak detection
self.app.call_from_thread(self._set_phase, "Stage 2/6: Peak detection", 15)
peaks = detect_peaks(freqs, powers, threshold_db=5.0)
if not peaks:
self.app.call_from_thread(self._set_phase, "No carriers detected", 100)
self._scanning = False
return
# Stage 3: Fine sweep
self.app.call_from_thread(
self._set_phase,
f"Stage 3/6: Fine sweep ({len(peaks)} peaks)", 25,
)
refined = []
for i, (freq, pwr, idx) in enumerate(peaks):
if not self._scanning:
return
fine_start = max(start, freq - 10)
fine_stop = min(stop, freq + 10)
fine_freqs, fine_powers, fine_results = self._bridge.sweep_spectrum(
fine_start, fine_stop, step_mhz=1.0, dwell_ms=20,
)
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 = 25 + (i + 1) / len(peaks) * 20
self.app.call_from_thread(self._set_progress, pct)
# Stage 4: Blind scan
self.app.call_from_thread(
self._set_phase,
f"Stage 4/6: Blind scan ({len(refined)} candidates)", 45,
)
locked_carriers = []
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, 1000000, 30000000, 500000)
if bs_result and bs_result.get("locked"):
carrier = {
"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,
}
locked_carriers.append(carrier)
self.app.call_from_thread(self._add_carrier, carrier)
pct = 45 + (i + 1) / len(refined) * 25
self.app.call_from_thread(self._set_progress, pct)
# Stage 5: TS sample (simplified — just report locked carriers)
self.app.call_from_thread(
self._set_phase,
f"Stage 5/6: TS sampling ({len(locked_carriers)} locked)", 70,
)
# In a full implementation, we'd tune to each carrier, arm_transfer,
# capture 3s of TS, and parse PAT/PMT/SDT for service names.
# For the TUI demo, the locked carrier list is sufficient.
# Stage 6: Catalog
self.app.call_from_thread(self._set_phase, "Stage 6/6: Catalog assembly", 90)
self.app.call_from_thread(self._set_progress, 95)
total = len(locked_carriers)
self.app.call_from_thread(
self._set_phase,
f"Survey complete: {total} carrier{'s' if total != 1 else ''} cataloged",
100,
)
self._scanning = False
@work(thread=True)
def _do_quick_scan(self, start: float, stop: float,
lnb_lo: float, step: float) -> None:
"""Quick sweep + peak detection only."""
import sys, 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
self.app.call_from_thread(self._set_phase, "Quick scan: sweeping", 0)
def cb(freq, step_num, total, result):
pct = (step_num + 1) / total * 80
self.app.call_from_thread(self._set_progress, pct)
freqs, powers, results = self._bridge.sweep_spectrum(
start, stop, step, dwell_ms=10, callback=cb,
)
if not self._scanning:
return
self.app.call_from_thread(self._update_spectrum, freqs, powers, results, lnb_lo)
self.app.call_from_thread(self._set_phase, "Quick scan: detecting peaks", 80)
peaks = detect_peaks(freqs, powers, threshold_db=5.0)
for freq, pwr, idx in peaks:
carrier = {
"if_mhz": freq,
"rf_mhz": if_to_rf(freq, lnb_lo),
"sr_ksps": 0,
"power_db": pwr,
"locked": False,
}
self.app.call_from_thread(self._add_carrier, carrier)
self.app.call_from_thread(
self._set_phase,
f"Quick scan: {len(peaks)} peaks found", 100,
)
self._scanning = False
def _set_phase(self, text: str, progress: float) -> None:
if not self.is_mounted:
return
self.query_one("#survey-phase", Static).update(f"[#00d4aa]{text}[/]")
self.query_one("#survey-pbar", ProgressBar).update(progress=progress)
def _set_progress(self, pct: float) -> None:
if not self.is_mounted:
return
self.query_one("#survey-pbar", ProgressBar).update(progress=pct)
def _update_spectrum(self, freqs, powers, results, lnb_lo) -> None:
if not self.is_mounted:
return
self.query_one("#survey-spectrum", SpectrumPlot).update_data(
freqs, powers, results, lnb_lo=lnb_lo,
)
def _add_carrier(self, carrier: dict) -> None:
if not self.is_mounted:
return
self.query_one("#survey-table", FrequencyTable).add_transponder(carrier)
class _QO100Tab(Container):
"""QO-100 DATV focused scan."""
DEFAULT_CSS = """
_QO100Tab {
layout: vertical;
height: 1fr;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._scanning = False
self._scan_worker: Worker | None = None
def compose(self) -> ComposeResult:
# Info panel with known stations
with Vertical(classes="qo100-info"):
yield Label("QO-100 Wideband Transponder (Es'hail-2, 25.9E)",
classes="qo100-info-title")
yield Static(
"[#506878]RF range:[/] [#00d4aa]10491-10499 MHz[/] "
"[#506878]BCM4500 min SR:[/] [#e8a020]256 ksps[/]"
)
yield Static(
"[#506878]Known stations:[/]\n"
" [#00e060]BATC beacon 10491.5 MHz SR 1500 ksps QPSK 3/4[/]\n"
" [#00e060]DATV 10492.0 MHz SR 1000 ksps QPSK 1/2[/]\n"
" [#e8a020]Low-power 10493.0 MHz SR 500 ksps QPSK 1/2[/]\n"
" [#e8a020]Minimum 10494.0 MHz SR 333 ksps QPSK 1/2[/]\n"
" [#506878]Beacon 10489.75 MHz CW (not lockable)[/]"
)
with Horizontal(classes="survey-upper"):
with Vertical(classes="survey-spectrum-col"):
yield SpectrumPlot(title="QO-100 Sweep", id="qo100-spectrum")
with Vertical(classes="survey-results-col"):
yield Static("[#00d4aa bold]QO-100 Carriers[/]")
yield FrequencyTable(id="qo100-table")
with Vertical(classes="survey-progress"):
yield Static("[#506878]Ready — enter LNB LO frequency[/]", id="qo100-phase")
with Horizontal(classes="survey-progress-row"):
yield ProgressBar(total=100, show_eta=False, id="qo100-pbar")
with Horizontal(classes="survey-controls"):
yield Label("LNB LO (MHz):")
yield Input("9361", id="qo100-lnb")
yield Label("Step (kHz):")
yield Input("500", id="qo100-step")
yield Label("Dwell (ms):")
yield Input("50", id="qo100-dwell")
yield Button("Scan QO-100", id="qo100-scan", variant="success")
yield Button("Stop", id="qo100-stop", variant="error")
def on_button_pressed(self, event: Button.Pressed) -> None:
btn = event.button.id or ""
if btn == "qo100-scan":
self._start_scan()
elif btn == "qo100-stop":
self._stop()
def _stop(self) -> None:
self._scanning = False
if self._scan_worker:
self._scan_worker.cancel()
self._scan_worker = None
def _start_scan(self) -> None:
if self._scanning:
return
self._scanning = True
lnb_lo = float(self.query_one("#qo100-lnb", Input).value or "9361")
step_khz = float(self.query_one("#qo100-step", Input).value or "500")
dwell_ms = int(self.query_one("#qo100-dwell", Input).value or "50")
self.query_one("#qo100-table", FrequencyTable).clear_table()
self._scan_worker = self._do_qo100_scan(lnb_lo, step_khz, dwell_ms)
@work(thread=True)
def _do_qo100_scan(self, lnb_lo: float, step_khz: float,
dwell_ms: int) -> None:
"""Scan QO-100 wideband transponder with optimized parameters."""
import sys, 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
# Calculate IF range from LO
if_start = _QO100_WB_RF_START - lnb_lo
if_stop = _QO100_WB_RF_STOP - lnb_lo
step_mhz = step_khz / 1000.0
# Validate IF range is within receiver's capabilities
if if_start < 950 or if_stop > 2150:
self.app.call_from_thread(
self._set_phase,
f"IF range {if_start:.0f}-{if_stop:.0f} MHz out of receiver range (950-2150)",
0,
)
self._scanning = False
return
self.app.call_from_thread(
self._set_phase,
f"Scanning IF {if_start:.0f}-{if_stop:.0f} MHz (LO {lnb_lo:.0f})", 0,
)
# Sweep with low SR for better sensitivity to narrow signals
def cb(freq, step_num, total, result):
pct = (step_num + 1) / total * 60
self.app.call_from_thread(self._set_progress, pct)
freqs, powers, results = self._bridge.sweep_spectrum(
if_start, if_stop, step_mhz, dwell_ms=dwell_ms,
sr_ksps=1000, # Lower SR for QO-100 sensitivity
callback=cb,
)
if not self._scanning:
return
self.app.call_from_thread(
self._update_spectrum, freqs, powers, results, lnb_lo,
)
# Peak detection with lower threshold for weak DATV signals
self.app.call_from_thread(self._set_phase, "Detecting QO-100 carriers", 60)
peaks = detect_peaks(freqs, powers, threshold_db=3.0)
# Try blind scan on each peak
for i, (freq, pwr, idx) in enumerate(peaks):
if not self._scanning:
return
freq_khz = int(freq * 1000)
rf_mhz = if_to_rf(freq, lnb_lo)
# Try common QO-100 SRs: 333, 500, 1000, 1500, 2000 ksps
locked = False
locked_sr = 0
for sr_ksps in [1500, 1000, 500, 333, 2000]:
sr_sps = sr_ksps * 1000
bs = self._bridge.blind_scan(freq_khz, sr_sps, sr_sps, 1)
if bs and bs.get("locked"):
locked = True
locked_sr = sr_ksps
break
carrier = {
"if_mhz": freq,
"rf_mhz": rf_mhz,
"sr_ksps": locked_sr,
"power_db": pwr,
"locked": locked,
}
self.app.call_from_thread(self._add_carrier, carrier)
pct = 60 + (i + 1) / len(peaks) * 35
self.app.call_from_thread(self._set_progress, pct)
total = len(peaks)
locked_count = sum(1 for f, p, i in peaks) # simplified
self.app.call_from_thread(
self._set_phase,
f"QO-100 scan complete: {total} carrier{'s' if total != 1 else ''} detected",
100,
)
self._scanning = False
def _set_phase(self, text: str, progress: float) -> None:
if not self.is_mounted:
return
self.query_one("#qo100-phase", Static).update(f"[#00d4aa]{text}[/]")
self.query_one("#qo100-pbar", ProgressBar).update(progress=progress)
def _set_progress(self, pct: float) -> None:
if not self.is_mounted:
return
self.query_one("#qo100-pbar", ProgressBar).update(progress=pct)
def _update_spectrum(self, freqs, powers, results, lnb_lo) -> None:
if not self.is_mounted:
return
self.query_one("#qo100-spectrum", SpectrumPlot).update_data(
freqs, powers, results, lnb_lo=lnb_lo,
)
def _add_carrier(self, carrier: dict) -> None:
if not self.is_mounted:
return
self.query_one("#qo100-table", FrequencyTable).add_transponder(carrier)

View file

@ -382,3 +382,43 @@ StarWarsScreen #sw-container {
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;
}