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:
parent
0f4ba4766f
commit
cc3a0707a1
20 changed files with 5645 additions and 84 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
411
tui/src/skywalker_tui/screens/motor.py
Normal file
411
tui/src/skywalker_tui/screens/motor.py
Normal 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")
|
||||
593
tui/src/skywalker_tui/screens/survey.py
Normal file
593
tui/src/skywalker_tui/screens/survey.py
Normal 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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue