skywalker-1/tui/src/skywalker_tui/screens/config.py
Ryan Malloy bbdcb243dc 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.
2026-02-20 10:55:50 -07:00

742 lines
24 KiB
Python

"""Config screen — LNB power, DiSEqC switching, and modulation/FEC setup.
Manages the hardware configuration layer: LNB voltage/tone control, DiSEqC
port switching (committed commands, tone burst, raw hex), and the tuning
parameter set (modulation, FEC, symbol rate, frequency). Bottom status bar
shows live config register state from the device.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, Select
from textual import work
from textual.worker import Worker
from skywalker_lib import MODULATIONS, FEC_RATES, MOD_FEC_GROUP, format_config_bits
def _fec_options_for_mod(mod_key: str) -> list[tuple[str, str]]:
"""Return (label, value) tuples for the FEC Select dropdown."""
group = MOD_FEC_GROUP.get(mod_key, "dvbs")
rates = FEC_RATES.get(group, {})
return [(rate_name, rate_name) for rate_name in rates]
def _mod_options() -> list[tuple[str, str]]:
"""Return (label, value) tuples for the Modulation Select dropdown."""
return [(desc, key) for key, (_idx, desc) in MODULATIONS.items()]
class ConfigScreen(Container):
"""LNB, DiSEqC, and modulation/FEC configuration panel."""
DEFAULT_CSS = """
ConfigScreen {
layout: vertical;
}
ConfigScreen #cfg-main {
height: 1fr;
layout: horizontal;
padding: 1 2;
}
/* --- Three column panels --- */
ConfigScreen .cfg-panel {
width: 1fr;
background: #0e1420;
border: round #1a2a3a;
padding: 1 2;
margin: 0 1 0 0;
layout: vertical;
}
ConfigScreen .cfg-panel:last-of-type {
margin: 0;
}
ConfigScreen .cfg-panel-title {
color: #00d4aa;
text-style: bold;
margin: 0 0 1 0;
height: 1;
}
/* --- Buttons within panels --- */
ConfigScreen .cfg-btn-row {
height: auto;
layout: horizontal;
margin: 0 0 1 0;
}
ConfigScreen .cfg-btn-row Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
min-width: 12;
}
ConfigScreen .cfg-btn-row Button {
margin: 0 1 0 0;
background: #1a2a40;
color: #c8d0d8;
border: round #1a3050;
}
ConfigScreen .cfg-btn-row Button:hover {
background: #00d4aa;
color: #0a0a12;
}
ConfigScreen .cfg-btn-row Button.-active-setting {
background: #0a3a3a;
color: #00d4aa;
border: round #00d4aa;
text-style: bold;
}
/* --- Warning label --- */
ConfigScreen .cfg-warning {
color: #e8a020;
margin: 1 0 0 0;
text-style: italic;
height: auto;
}
/* --- DiSEqC port buttons --- */
ConfigScreen #cfg-diseqc-ports {
height: auto;
layout: horizontal;
margin: 0 0 1 0;
}
ConfigScreen #cfg-diseqc-ports Button {
margin: 0 1 0 0;
min-width: 10;
background: #1a2a40;
color: #c8d0d8;
border: round #1a3050;
}
ConfigScreen #cfg-diseqc-ports Button:hover {
background: #00d4aa;
color: #0a0a12;
}
ConfigScreen #cfg-diseqc-ports Button.-active-setting {
background: #0a3a3a;
color: #00d4aa;
border: round #00d4aa;
text-style: bold;
}
/* --- Raw DiSEqC input row --- */
ConfigScreen #cfg-diseqc-raw {
height: auto;
layout: horizontal;
margin: 1 0 0 0;
}
ConfigScreen #cfg-diseqc-raw Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
ConfigScreen #cfg-diseqc-raw Input {
width: 1fr;
margin: 0 1 0 0;
background: #121c2a;
border: round #1a3050;
color: #c8d0d8;
}
ConfigScreen #cfg-diseqc-raw Input:focus {
border: round #00d4aa;
}
ConfigScreen #cfg-diseqc-raw Button {
margin: 0;
background: #1a2a40;
color: #c8d0d8;
border: round #1a3050;
}
/* --- Modulation/FEC panel --- */
ConfigScreen .cfg-field-row {
height: auto;
layout: horizontal;
margin: 0 0 1 0;
}
ConfigScreen .cfg-field-row Label {
width: auto;
min-width: 12;
margin: 1 1 0 0;
color: #506878;
}
ConfigScreen .cfg-field-row Select {
width: 1fr;
}
ConfigScreen .cfg-field-row Input {
width: 1fr;
background: #121c2a;
border: round #1a3050;
color: #c8d0d8;
}
ConfigScreen .cfg-field-row Input:focus {
border: round #00d4aa;
}
ConfigScreen #cfg-tune-btn {
margin: 1 0 0 0;
width: 100%;
}
/* --- Bottom status bar --- */
ConfigScreen #cfg-status-bar {
height: auto;
min-height: 3;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
}
ConfigScreen #cfg-result {
height: auto;
min-height: 2;
padding: 0 2;
background: #0e1018;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._refresh_worker: Worker | None = None
self._active_port: int | None = None
self._lnb_power = False
self._lnb_voltage_high = False
self._tone_22khz = False
self._extra_volt = False
def compose(self) -> ComposeResult:
with Horizontal(id="cfg-main"):
# --- LNB Control ---
with Vertical(classes="cfg-panel"):
yield Static("[#00d4aa bold]LNB Control[/]", classes="cfg-panel-title")
with Horizontal(classes="cfg-btn-row"):
yield Label("Power:")
yield Button("On", id="cfg-lnb-on", variant="success")
yield Button("Off", id="cfg-lnb-off", variant="error")
with Horizontal(classes="cfg-btn-row"):
yield Label("Voltage:")
yield Button("13V", id="cfg-volt-13")
yield Button("18V", id="cfg-volt-18")
with Horizontal(classes="cfg-btn-row"):
yield Label("22kHz Tone:")
yield Button("On", id="cfg-tone-on")
yield Button("Off", id="cfg-tone-off")
with Horizontal(classes="cfg-btn-row"):
yield Label("Extra +1V:")
yield Button("On", id="cfg-extra-on")
yield Button("Off", id="cfg-extra-off")
yield Static(
"[#e8a020]Max 450mA continuous load[/]",
classes="cfg-warning",
)
# --- DiSEqC Switch ---
with Vertical(classes="cfg-panel"):
yield Static("[#00d4aa bold]DiSEqC Switch[/]", classes="cfg-panel-title")
yield Label("Committed Port:", classes="cfg-section-label")
with Horizontal(id="cfg-diseqc-ports"):
yield Button("Port 1", id="cfg-port-1")
yield Button("Port 2", id="cfg-port-2")
yield Button("Port 3", id="cfg-port-3")
yield Button("Port 4", id="cfg-port-4")
with Horizontal(classes="cfg-btn-row"):
yield Label("Tone Burst:")
yield Button("A", id="cfg-burst-a")
yield Button("B", id="cfg-burst-b")
with Horizontal(id="cfg-diseqc-raw"):
yield Label("Raw Hex:")
yield Input(
placeholder="E0 10 38 F0",
id="cfg-diseqc-hex",
)
yield Button("Send", id="cfg-diseqc-send")
# --- Modulation / FEC ---
with Vertical(classes="cfg-panel"):
yield Static(
"[#00d4aa bold]Modulation / FEC[/]",
classes="cfg-panel-title",
)
with Horizontal(classes="cfg-field-row"):
yield Label("Modulation:")
yield Select(
_mod_options(),
value="qpsk",
id="cfg-mod-select",
allow_blank=False,
)
with Horizontal(classes="cfg-field-row"):
yield Label("FEC Rate:")
yield Select(
_fec_options_for_mod("qpsk"),
value="auto",
id="cfg-fec-select",
allow_blank=False,
)
with Horizontal(classes="cfg-field-row"):
yield Label("Symbol Rate:")
yield Input("20000", id="cfg-sr-input")
yield Label("ksps")
with Horizontal(classes="cfg-field-row"):
yield Label("Frequency:")
yield Input("1200", id="cfg-freq-input")
yield Label("MHz")
yield Button(
"Tune",
id="cfg-tune-btn",
variant="success",
)
yield Static("", id="cfg-result")
yield Static("[#506878]Config: reading...[/]", id="cfg-status-bar")
# ── Lifecycle ──
def on_show(self) -> None:
self._refresh_config()
def on_hide(self) -> None:
if self._refresh_worker is not None:
self._refresh_worker.cancel()
self._refresh_worker = None
# ── Event handlers ──
def on_button_pressed(self, event: Button.Pressed) -> None:
btn = event.button.id
if btn is None:
return
# LNB control
if btn == "cfg-lnb-on":
self._do_lnb_power(True)
elif btn == "cfg-lnb-off":
self._do_lnb_power(False)
elif btn == "cfg-volt-13":
self._do_lnb_voltage(high=False)
elif btn == "cfg-volt-18":
self._do_lnb_voltage(high=True)
elif btn == "cfg-tone-on":
self._do_22khz_tone(on=True)
elif btn == "cfg-tone-off":
self._do_22khz_tone(on=False)
elif btn == "cfg-extra-on":
self._do_extra_voltage(on=True)
elif btn == "cfg-extra-off":
self._do_extra_voltage(on=False)
# DiSEqC ports
elif btn == "cfg-port-1":
self._do_diseqc_port(1)
elif btn == "cfg-port-2":
self._do_diseqc_port(2)
elif btn == "cfg-port-3":
self._do_diseqc_port(3)
elif btn == "cfg-port-4":
self._do_diseqc_port(4)
# Tone burst
elif btn == "cfg-burst-a":
self._do_tone_burst(0)
elif btn == "cfg-burst-b":
self._do_tone_burst(1)
# Raw DiSEqC
elif btn == "cfg-diseqc-send":
self._do_diseqc_raw()
# Tune
elif btn == "cfg-tune-btn":
self._do_tune()
def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id == "cfg-mod-select":
mod_key = str(event.value)
self._update_fec_options(mod_key)
# ── LNB operations ──
@work(thread=True)
def _do_lnb_power(self, on: bool) -> None:
try:
self._bridge.start_intersil(on)
self._lnb_power = on
label = "[#00d4aa]ON[/]" if on else "[#e04040]OFF[/]"
self.app.call_from_thread(
self._show_result,
f"[#c8d0d8]LNB power:[/] {label}",
)
self.app.call_from_thread(self._highlight_lnb_power, on)
except Exception as e:
self.app.call_from_thread(
self._show_result,
f"[#e04040]LNB power error: {e}[/]",
)
self.app.call_from_thread(self._refresh_config)
@work(thread=True)
def _do_lnb_voltage(self, high: bool) -> None:
try:
self._bridge.set_lnb_voltage(high)
self._lnb_voltage_high = high
volts = "18V" if high else "13V"
self.app.call_from_thread(
self._show_result,
f"[#c8d0d8]LNB voltage set to[/] [#00d4aa]{volts}[/]",
)
self.app.call_from_thread(self._highlight_voltage, high)
except Exception as e:
self.app.call_from_thread(
self._show_result,
f"[#e04040]Voltage error: {e}[/]",
)
self.app.call_from_thread(self._refresh_config)
@work(thread=True)
def _do_22khz_tone(self, on: bool) -> None:
try:
self._bridge.set_22khz_tone(on)
self._tone_22khz = on
label = "[#00d4aa]ON[/]" if on else "[#e04040]OFF[/]"
self.app.call_from_thread(
self._show_result,
f"[#c8d0d8]22kHz tone:[/] {label}",
)
self.app.call_from_thread(self._highlight_tone, on)
except Exception as e:
self.app.call_from_thread(
self._show_result,
f"[#e04040]22kHz tone error: {e}[/]",
)
self.app.call_from_thread(self._refresh_config)
@work(thread=True)
def _do_extra_voltage(self, on: bool) -> None:
try:
self._bridge.set_extra_voltage(on)
self._extra_volt = on
label = "[#00d4aa]ON[/]" if on else "[#e04040]OFF[/]"
self.app.call_from_thread(
self._show_result,
f"[#c8d0d8]Extra +1V:[/] {label}",
)
self.app.call_from_thread(self._highlight_extra, on)
except Exception as e:
self.app.call_from_thread(
self._show_result,
f"[#e04040]Extra voltage error: {e}[/]",
)
self.app.call_from_thread(self._refresh_config)
# ── DiSEqC operations ──
_DISEQC_PORT_CMDS = {
1: bytes([0xE0, 0x10, 0x38, 0xF0]),
2: bytes([0xE0, 0x10, 0x38, 0xF4]),
3: bytes([0xE0, 0x10, 0x38, 0xF8]),
4: bytes([0xE0, 0x10, 0x38, 0xFC]),
}
@work(thread=True)
def _do_diseqc_port(self, port: int) -> None:
cmd = self._DISEQC_PORT_CMDS[port]
try:
self._bridge.send_diseqc_message(cmd)
self._active_port = port
self.app.call_from_thread(
self._show_result,
f"[#c8d0d8]DiSEqC port[/] [#00d4aa]{port}[/]"
f" [#506878]({cmd.hex(' ')})[/]",
)
self.app.call_from_thread(self._highlight_port, port)
except Exception as e:
self.app.call_from_thread(
self._show_result,
f"[#e04040]DiSEqC port {port} error: {e}[/]",
)
@work(thread=True)
def _do_tone_burst(self, mini_cmd: int) -> None:
label = "A" if mini_cmd == 0 else "B"
try:
self._bridge.send_diseqc_tone_burst(mini_cmd)
self.app.call_from_thread(
self._show_result,
f"[#c8d0d8]Tone burst[/] [#00d4aa]{label}[/]",
)
except Exception as e:
self.app.call_from_thread(
self._show_result,
f"[#e04040]Tone burst error: {e}[/]",
)
@work(thread=True)
def _do_diseqc_raw(self) -> None:
try:
hex_input = self.app.call_from_thread(self._get_diseqc_hex)
except Exception:
return
if not hex_input:
self.app.call_from_thread(
self._show_result,
"[#e8a020]Enter hex bytes (e.g. E0 10 38 F0)[/]",
)
return
try:
raw = bytes.fromhex(hex_input.replace(",", " "))
except ValueError:
self.app.call_from_thread(
self._show_result,
f"[#e04040]Invalid hex: {hex_input}[/]",
)
return
if len(raw) < 3 or len(raw) > 6:
self.app.call_from_thread(
self._show_result,
f"[#e04040]DiSEqC message must be 3-6 bytes, got {len(raw)}[/]",
)
return
try:
self._bridge.send_diseqc_message(raw)
self.app.call_from_thread(
self._show_result,
f"[#c8d0d8]DiSEqC sent:[/] [#00d4aa]{raw.hex(' ')}[/]",
)
except Exception as e:
self.app.call_from_thread(
self._show_result,
f"[#e04040]DiSEqC send error: {e}[/]",
)
def _get_diseqc_hex(self) -> str:
"""Read the raw hex input value (must be called from UI thread)."""
return self.query_one("#cfg-diseqc-hex", Input).value.strip()
# ── Tune operation ──
@work(thread=True)
def _do_tune(self) -> None:
# Read inputs from UI thread
try:
params = self.app.call_from_thread(self._read_tune_params)
except Exception:
return
if params is None:
return
mod_key, fec_key, sr_ksps, freq_mhz = params
# Validate
if not (256 <= sr_ksps <= 30000):
self.app.call_from_thread(
self._show_result,
"[#e04040]Symbol rate out of range (256-30000 ksps)[/]",
)
return
if not (950 <= freq_mhz <= 2150):
self.app.call_from_thread(
self._show_result,
"[#e04040]Frequency out of range (950-2150 MHz)[/]",
)
return
mod_index = MODULATIONS[mod_key][0]
fec_group = MOD_FEC_GROUP.get(mod_key, "dvbs")
fec_rates = FEC_RATES.get(fec_group, {})
fec_index = fec_rates.get(fec_key, 0)
sr_sps = sr_ksps * 1000
freq_khz = int(freq_mhz * 1000)
try:
self._bridge.ensure_booted()
self._bridge.tune(sr_sps, freq_khz, mod_index, fec_index)
mod_desc = MODULATIONS[mod_key][1]
self.app.call_from_thread(
self._show_result,
f"[#00d4aa]Tuned:[/] [#c8d0d8]{freq_mhz:.1f} MHz "
f"{sr_ksps} ksps {mod_desc} FEC {fec_key}[/]",
)
except Exception as e:
self.app.call_from_thread(
self._show_result,
f"[#e04040]Tune error: {e}[/]",
)
self.app.call_from_thread(self._refresh_config)
def _read_tune_params(self) -> tuple | None:
"""Read tune parameters from UI widgets (must be called from UI thread)."""
mod_select = self.query_one("#cfg-mod-select", Select)
fec_select = self.query_one("#cfg-fec-select", Select)
sr_input = self.query_one("#cfg-sr-input", Input)
freq_input = self.query_one("#cfg-freq-input", Input)
mod_key = str(mod_select.value) if mod_select.value is not None else "qpsk"
fec_key = str(fec_select.value) if fec_select.value is not None else "auto"
try:
sr_ksps = int(float(sr_input.value or "20000"))
except ValueError:
self._show_result("[#e04040]Invalid symbol rate[/]")
return None
try:
freq_mhz = float(freq_input.value or "1200")
except ValueError:
self._show_result("[#e04040]Invalid frequency[/]")
return None
return (mod_key, fec_key, sr_ksps, freq_mhz)
# ── FEC dropdown update ──
def _update_fec_options(self, mod_key: str) -> None:
"""Rebuild the FEC dropdown when modulation changes."""
fec_select = self.query_one("#cfg-fec-select", Select)
options = _fec_options_for_mod(mod_key)
fec_select.set_options(options)
# Default to "auto" if available, otherwise first option
auto_keys = [v for (_l, v) in options if v == "auto"]
if auto_keys:
fec_select.value = "auto"
elif options:
fec_select.value = options[0][1]
# ── Config status display ──
@work(thread=True)
def _refresh_config(self) -> None:
"""Read config register and update the status bar."""
try:
status = self._bridge.get_config()
bits = format_config_bits(status)
self.app.call_from_thread(self._display_config, status, bits)
except Exception as e:
self.app.call_from_thread(
self._display_config_error, str(e),
)
def _display_config(self, status: int, bits: list) -> None:
"""Render config bits in the status bar (UI thread)."""
if not self.is_mounted:
return
parts = []
for name, is_set in bits:
if is_set:
parts.append(f"[#00d4aa]{name}[/]")
else:
parts.append(f"[#303840]{name}[/]")
text = (
f"[#506878]Config 0x{status:02X}:[/] "
+ " ".join(parts)
)
self.query_one("#cfg-status-bar", Static).update(text)
# Sync button highlights from config bits
self._lnb_power = bool(status & 0x04)
self._lnb_voltage_high = bool(status & 0x20)
self._tone_22khz = bool(status & 0x10)
self._highlight_lnb_power(self._lnb_power)
self._highlight_voltage(self._lnb_voltage_high)
self._highlight_tone(self._tone_22khz)
def _display_config_error(self, error: str) -> None:
if not self.is_mounted:
return
self.query_one("#cfg-status-bar", Static).update(
f"[#e04040]Config read error: {error}[/]"
)
# ── UI highlight helpers ──
def _show_result(self, markup: str) -> None:
"""Display operation result text."""
if not self.is_mounted:
return
self.query_one("#cfg-result", Static).update(markup)
def _highlight_lnb_power(self, on: bool) -> None:
if not self.is_mounted:
return
btn_on = self.query_one("#cfg-lnb-on", Button)
btn_off = self.query_one("#cfg-lnb-off", Button)
if on:
btn_on.add_class("-active-setting")
btn_off.remove_class("-active-setting")
else:
btn_off.add_class("-active-setting")
btn_on.remove_class("-active-setting")
def _highlight_voltage(self, high: bool) -> None:
if not self.is_mounted:
return
btn_13 = self.query_one("#cfg-volt-13", Button)
btn_18 = self.query_one("#cfg-volt-18", Button)
if high:
btn_18.add_class("-active-setting")
btn_13.remove_class("-active-setting")
else:
btn_13.add_class("-active-setting")
btn_18.remove_class("-active-setting")
def _highlight_tone(self, on: bool) -> None:
if not self.is_mounted:
return
btn_on = self.query_one("#cfg-tone-on", Button)
btn_off = self.query_one("#cfg-tone-off", Button)
if on:
btn_on.add_class("-active-setting")
btn_off.remove_class("-active-setting")
else:
btn_off.add_class("-active-setting")
btn_on.remove_class("-active-setting")
def _highlight_extra(self, on: bool) -> None:
if not self.is_mounted:
return
btn_on = self.query_one("#cfg-extra-on", Button)
btn_off = self.query_one("#cfg-extra-off", Button)
if on:
btn_on.add_class("-active-setting")
btn_off.remove_class("-active-setting")
else:
btn_off.add_class("-active-setting")
btn_on.remove_class("-active-setting")
def _highlight_port(self, port: int) -> None:
if not self.is_mounted:
return
for p in range(1, 5):
btn = self.query_one(f"#cfg-port-{p}", Button)
if p == port:
btn.add_class("-active-setting")
else:
btn.remove_class("-active-setting")