"""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")