Add Device, Stream, and Config screens to TUI (F6-F8)
Expand from 5 to 8 mode screens. F6 Device provides firmware management, EEPROM flash with full safety state machine (C2 validation, auto-backup, 3s countdown, page write, byte verify), and diagnostics (boot test, I2C scan, register dump). F7 Stream does live TS capture with PID distribution and PAT/PMT tree. F8 Config manages LNB power, DiSEqC switching, and modulation/FEC. Foundation: 12 new SkyWalker1 methods (device info, FX2 RAM, EEPROM I2C, diagnostics), matching DemoDevice synthetics with realistic C2 image and TS packets, 20 bridge wrappers (RLock).
This commit is contained in:
parent
5d9dfa7794
commit
567bf4d9e0
@ -480,6 +480,123 @@ class SkyWalker1:
|
||||
index=count, length=count)
|
||||
return bytes(data)
|
||||
|
||||
# -- Device info (extended) --
|
||||
|
||||
def get_serial_number(self) -> bytes:
|
||||
"""Read 8-byte serial number from device."""
|
||||
return self._vendor_in(CMD_GET_SERIAL_NUMBER, length=8)
|
||||
|
||||
def get_usb_speed(self) -> int:
|
||||
"""Read USB connection speed. 0=unknown, 1=Full, 2=High."""
|
||||
data = self._vendor_in(0x07, length=1)
|
||||
return data[0]
|
||||
|
||||
def get_vendor_string(self) -> str:
|
||||
"""Read vendor string descriptor from FX2."""
|
||||
data = self._vendor_in(0x0C, length=64)
|
||||
return bytes(data).rstrip(b'\x00').decode('ascii', errors='replace')
|
||||
|
||||
def get_product_string(self) -> str:
|
||||
"""Read product string descriptor from FX2."""
|
||||
data = self._vendor_in(0x0D, length=64)
|
||||
return bytes(data).rstrip(b'\x00').decode('ascii', errors='replace')
|
||||
|
||||
# -- FX2 RAM access (standard Cypress A0 vendor request) --
|
||||
|
||||
def fx2_ram_read(self, addr: int, length: int) -> bytes:
|
||||
"""Read FX2 internal RAM via A0 vendor request. Non-destructive."""
|
||||
length = max(1, min(64, length))
|
||||
data = self.dev.ctrl_transfer(
|
||||
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN,
|
||||
0xA0, addr, 0, length, 2000
|
||||
)
|
||||
if self.verbose:
|
||||
raw = bytes(data).hex(' ')
|
||||
print(f" RAM IN addr=0x{addr:04X} len={length} -> {raw}")
|
||||
return bytes(data)
|
||||
|
||||
def fx2_ram_write(self, addr: int, data: bytes) -> int:
|
||||
"""Write FX2 internal RAM via A0 vendor request. Reverts on power cycle."""
|
||||
if self.verbose:
|
||||
raw = data.hex(' ')
|
||||
print(f" RAM OUT addr=0x{addr:04X} len={len(data)} data={raw}")
|
||||
return self.dev.ctrl_transfer(
|
||||
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_OUT,
|
||||
0xA0, addr, 0, data, 2000
|
||||
)
|
||||
|
||||
def fx2_cpu_halt(self) -> None:
|
||||
"""Halt FX2 CPU by writing 1 to CPUCS register (0xE600)."""
|
||||
self.fx2_ram_write(0xE600, b'\x01')
|
||||
|
||||
def fx2_cpu_start(self) -> None:
|
||||
"""Release FX2 CPU by writing 0 to CPUCS register (0xE600)."""
|
||||
self.fx2_ram_write(0xE600, b'\x00')
|
||||
|
||||
# -- EEPROM access (via I2C proxy commands) --
|
||||
|
||||
EEPROM_SLAVE = 0x51
|
||||
EEPROM_PAGE_SIZE = 16
|
||||
EEPROM_WRITE_CYCLE_MS = 10
|
||||
|
||||
def eeprom_read(self, offset: int, length: int = 64) -> bytes:
|
||||
"""Read from boot EEPROM at given offset via I2C."""
|
||||
return self._vendor_in(CMD_I2C_READ, value=self.EEPROM_SLAVE,
|
||||
index=offset, length=length)
|
||||
|
||||
def eeprom_write_page(self, offset: int, data: bytes) -> int:
|
||||
"""Write a page (up to 16 bytes) to EEPROM. Caller handles alignment."""
|
||||
return self._vendor_out(CMD_I2C_WRITE, value=self.EEPROM_SLAVE,
|
||||
index=offset, data=data)
|
||||
|
||||
def eeprom_read_all(self, size: int = 16384) -> bytes:
|
||||
"""Read entire EEPROM contents up to size bytes."""
|
||||
chunk_size = 64
|
||||
result = bytearray()
|
||||
for offset in range(0, size, chunk_size):
|
||||
remaining = min(chunk_size, size - offset)
|
||||
chunk = self.eeprom_read(offset, remaining)
|
||||
result.extend(chunk)
|
||||
return bytes(result)
|
||||
|
||||
# -- Diagnostics --
|
||||
|
||||
def boot_debug(self, mode: int) -> dict:
|
||||
"""
|
||||
Run boot diagnostic with specified mode byte.
|
||||
|
||||
Modes: 0x80=no-op, 0x81=GPIO init, 0x82=I2C probe,
|
||||
0x83=BCM4500 reset, 0x84=FW load, 0x85=full boot.
|
||||
Returns 3-byte status: {stage, result, detail}.
|
||||
"""
|
||||
data = self._vendor_in(CMD_BOOT_8PSK, value=mode, length=3)
|
||||
return {
|
||||
"stage": data[0],
|
||||
"result": data[1],
|
||||
"detail": data[2],
|
||||
}
|
||||
|
||||
def i2c_bus_scan(self) -> list[int]:
|
||||
"""
|
||||
Scan I2C bus for responding devices.
|
||||
|
||||
Returns list of 7-bit slave addresses that ACK'd.
|
||||
The firmware returns a 16-byte bitmap (128 bits for addresses 0-127).
|
||||
"""
|
||||
data = self._vendor_in(CMD_I2C_BUS_SCAN, length=16)
|
||||
addresses = []
|
||||
for byte_idx in range(16):
|
||||
for bit_idx in range(8):
|
||||
if data[byte_idx] & (1 << bit_idx):
|
||||
addresses.append(byte_idx * 8 + bit_idx)
|
||||
return addresses
|
||||
|
||||
def i2c_raw_read(self, slave: int, reg: int) -> int:
|
||||
"""Read a single register from an I2C device."""
|
||||
data = self._vendor_in(CMD_I2C_RAW_READ, value=slave,
|
||||
index=reg, length=1)
|
||||
return data[0]
|
||||
|
||||
# -- High-level sweep helpers --
|
||||
|
||||
def sweep_spectrum(self, start_mhz: float, stop_mhz: float,
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
"""SkyWalker-1 TUI — main application.
|
||||
|
||||
Provides mode switching between 5 RF operating modes via a sidebar and F-key
|
||||
shortcuts. Each mode is a Screen subclass that manages its own workers.
|
||||
Provides mode switching between 8 operating modes via a sidebar and F-key
|
||||
shortcuts. Each mode is a Container subclass that manages its own workers.
|
||||
|
||||
Note: We use "rf_mode" terminology for our 5 operating modes to avoid colliding
|
||||
Note: We use "rf_mode" terminology for our operating modes to avoid colliding
|
||||
with Textual's built-in App.mode / _current_mode / _screen_stacks system.
|
||||
"""
|
||||
|
||||
@ -24,6 +24,9 @@ from skywalker_tui.screens.scan import ScanScreen
|
||||
from skywalker_tui.screens.monitor import MonitorScreen
|
||||
from skywalker_tui.screens.lband import LBandScreen
|
||||
from skywalker_tui.screens.track import TrackScreen
|
||||
from skywalker_tui.screens.device import DeviceScreen
|
||||
from skywalker_tui.screens.stream import StreamScreen
|
||||
from skywalker_tui.screens.config import ConfigScreen
|
||||
|
||||
|
||||
MODES = {
|
||||
@ -32,6 +35,9 @@ MODES = {
|
||||
"monitor": ("F3 Monitor", MonitorScreen),
|
||||
"lband": ("F4 L-Band", LBandScreen),
|
||||
"track": ("F5 Track", TrackScreen),
|
||||
"device": ("F6 Device", DeviceScreen),
|
||||
"stream": ("F7 Stream", StreamScreen),
|
||||
"config": ("F8 Config", ConfigScreen),
|
||||
}
|
||||
|
||||
|
||||
@ -48,6 +54,9 @@ class SkyWalkerApp(App):
|
||||
Binding("f3", "rf_mode('monitor')", "Monitor", show=True),
|
||||
Binding("f4", "rf_mode('lband')", "L-Band", show=True),
|
||||
Binding("f5", "rf_mode('track')", "Track", show=True),
|
||||
Binding("f6", "rf_mode('device')", "Device", show=True),
|
||||
Binding("f7", "rf_mode('stream')", "Stream", show=True),
|
||||
Binding("f8", "rf_mode('config')", "Config", show=True),
|
||||
Binding("q", "quit", "Quit", show=True),
|
||||
Binding("d", "toggle_dark", "Theme", show=True),
|
||||
Binding("ctrl+w", "starwars", "Star Wars", show=False),
|
||||
@ -100,7 +109,7 @@ class SkyWalkerApp(App):
|
||||
self.call_later(self._init_mode_screens)
|
||||
|
||||
def _init_mode_screens(self) -> None:
|
||||
"""Mount all 5 mode screens into the content switcher."""
|
||||
"""Mount all 8 mode screens into the content switcher."""
|
||||
switcher = self.query_one("#content-area", ContentSwitcher)
|
||||
for mode_key, (_label, cls) in MODES.items():
|
||||
screen = cls(self._bridge, id=f"screen-{mode_key}")
|
||||
|
||||
@ -17,7 +17,7 @@ class USBBridge:
|
||||
|
||||
def __init__(self, device):
|
||||
self._dev = device
|
||||
self._lock = threading.Lock()
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@property
|
||||
def is_demo(self) -> bool:
|
||||
@ -92,3 +92,111 @@ class USBBridge:
|
||||
if hasattr(self._dev, "blind_scan"):
|
||||
return self._dev.blind_scan(freq_khz, sr_min, sr_max, sr_step)
|
||||
return None
|
||||
|
||||
# -- Device info (extended) --
|
||||
|
||||
def get_serial_number(self) -> bytes:
|
||||
with self._lock:
|
||||
return self._dev.get_serial_number()
|
||||
|
||||
def get_usb_speed(self) -> int:
|
||||
with self._lock:
|
||||
return self._dev.get_usb_speed()
|
||||
|
||||
def get_vendor_string(self) -> str:
|
||||
with self._lock:
|
||||
return self._dev.get_vendor_string()
|
||||
|
||||
def get_product_string(self) -> str:
|
||||
with self._lock:
|
||||
return self._dev.get_product_string()
|
||||
|
||||
# -- FX2 RAM --
|
||||
|
||||
def fx2_ram_read(self, addr: int, length: int) -> bytes:
|
||||
with self._lock:
|
||||
return self._dev.fx2_ram_read(addr, length)
|
||||
|
||||
def fx2_ram_write(self, addr: int, data: bytes) -> int:
|
||||
with self._lock:
|
||||
return self._dev.fx2_ram_write(addr, data)
|
||||
|
||||
def fx2_cpu_halt(self) -> None:
|
||||
with self._lock:
|
||||
self._dev.fx2_cpu_halt()
|
||||
|
||||
def fx2_cpu_start(self) -> None:
|
||||
with self._lock:
|
||||
self._dev.fx2_cpu_start()
|
||||
|
||||
# -- EEPROM --
|
||||
|
||||
def eeprom_read(self, offset: int, length: int = 64) -> bytes:
|
||||
with self._lock:
|
||||
return self._dev.eeprom_read(offset, length)
|
||||
|
||||
def eeprom_write_page(self, offset: int, data: bytes) -> int:
|
||||
with self._lock:
|
||||
return self._dev.eeprom_write_page(offset, data)
|
||||
|
||||
def eeprom_read_all(self, size: int = 16384) -> bytes:
|
||||
with self._lock:
|
||||
return self._dev.eeprom_read_all(size)
|
||||
|
||||
# -- Diagnostics --
|
||||
|
||||
def boot_debug(self, mode: int) -> dict:
|
||||
with self._lock:
|
||||
return self._dev.boot_debug(mode)
|
||||
|
||||
def i2c_bus_scan(self) -> list[int]:
|
||||
with self._lock:
|
||||
return self._dev.i2c_bus_scan()
|
||||
|
||||
def i2c_raw_read(self, slave: int, reg: int) -> int:
|
||||
with self._lock:
|
||||
return self._dev.i2c_raw_read(slave, reg)
|
||||
|
||||
# -- Streaming --
|
||||
|
||||
def arm_transfer(self, on: bool) -> None:
|
||||
with self._lock:
|
||||
self._dev.arm_transfer(on)
|
||||
|
||||
def read_stream(self, size: int = 8192, timeout: int = 1000) -> bytes:
|
||||
with self._lock:
|
||||
return self._dev.read_stream(size, timeout)
|
||||
|
||||
# -- Config --
|
||||
|
||||
def send_diseqc_message(self, msg: bytes) -> None:
|
||||
with self._lock:
|
||||
self._dev.send_diseqc_message(msg)
|
||||
|
||||
def send_diseqc_tone_burst(self, mini_cmd: int) -> None:
|
||||
with self._lock:
|
||||
self._dev.send_diseqc_tone_burst(mini_cmd)
|
||||
|
||||
def start_intersil(self, on: bool = True) -> int:
|
||||
with self._lock:
|
||||
return self._dev.start_intersil(on)
|
||||
|
||||
def set_extra_voltage(self, on: bool) -> None:
|
||||
with self._lock:
|
||||
self._dev.set_extra_voltage(on)
|
||||
|
||||
def boot(self, on: bool = True) -> int:
|
||||
with self._lock:
|
||||
return self._dev.boot(on)
|
||||
|
||||
def get_signal_lock(self) -> bool:
|
||||
with self._lock:
|
||||
return self._dev.get_signal_lock()
|
||||
|
||||
def get_signal_strength(self) -> dict:
|
||||
with self._lock:
|
||||
return self._dev.get_signal_strength()
|
||||
|
||||
def multi_reg_read(self, start_reg: int, count: int) -> bytes:
|
||||
with self._lock:
|
||||
return self._dev.multi_reg_read(start_reg, count)
|
||||
|
||||
@ -13,6 +13,7 @@ This enables full TUI development and testing without hardware.
|
||||
|
||||
import math
|
||||
import random
|
||||
import struct
|
||||
import time
|
||||
|
||||
|
||||
@ -28,6 +29,31 @@ _TRANSPONDERS = [
|
||||
_NOISE_FLOOR = -35.0
|
||||
_LOCK_THRESHOLD_DB = 3.5
|
||||
|
||||
# Simulated PIDs for synthetic TS packets
|
||||
_DEMO_PIDS = [0x0000, 0x0100, 0x0101, 0x0102, 0x1FFF]
|
||||
_DEMO_PID_WEIGHTS = [1, 5, 40, 20, 34] # rough % distribution
|
||||
|
||||
|
||||
def _build_demo_eeprom() -> bytearray:
|
||||
"""Build a valid C2 boot EEPROM image for demo mode."""
|
||||
img = bytearray()
|
||||
# C2 header: magic, VID_L, VID_H, PID_L, PID_H, DID_L, DID_H, CONFIG
|
||||
img.append(0xC2)
|
||||
img.extend(struct.pack('<HHH', 0x09C0, 0x0203, 0x0001))
|
||||
img.append(0x44) # config byte: 400kHz I2C + disconnect
|
||||
|
||||
# One code segment: 512 bytes loaded to address 0x0000
|
||||
code_len = 512
|
||||
img.extend(struct.pack('>HH', code_len, 0x0000))
|
||||
img.extend(bytes(range(256)) * 2) # repeating pattern
|
||||
|
||||
# END marker: 0x8001 + entry point 0x0000
|
||||
img.extend(struct.pack('>HH', 0x8001, 0x0000))
|
||||
|
||||
# Pad to 16KB with 0xFF (like a real blank EEPROM region)
|
||||
img.extend(b'\xFF' * (16384 - len(img)))
|
||||
return img
|
||||
|
||||
|
||||
class DemoDevice:
|
||||
"""Drop-in replacement for SkyWalker1 that generates synthetic data."""
|
||||
@ -39,10 +65,14 @@ class DemoDevice:
|
||||
self._lnb_on = False
|
||||
self._lnb_voltage_high = False
|
||||
self._tone_22khz = False
|
||||
self._extra_voltage = False
|
||||
self._tuned_freq_khz = 0
|
||||
self._tuned_sr_sps = 0
|
||||
self._armed = False
|
||||
self._start_time = time.monotonic()
|
||||
self._sample_count = 0
|
||||
self._eeprom = _build_demo_eeprom()
|
||||
self._cc_counters: dict[int, int] = {pid: 0 for pid in _DEMO_PIDS}
|
||||
|
||||
def open(self):
|
||||
pass
|
||||
@ -109,6 +139,9 @@ class DemoDevice:
|
||||
def start_intersil(self, on: bool = True):
|
||||
self._lnb_on = on
|
||||
|
||||
def set_extra_voltage(self, on: bool):
|
||||
self._extra_voltage = on
|
||||
|
||||
def tune(self, symbol_rate_sps: int, freq_khz: int,
|
||||
mod_index: int, fec_index: int):
|
||||
self._tuned_freq_khz = freq_khz
|
||||
@ -211,6 +244,196 @@ class DemoDevice:
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Device info (extended) ---
|
||||
|
||||
def get_serial_number(self) -> bytes:
|
||||
time.sleep(0.005)
|
||||
return b'DEMO0001'
|
||||
|
||||
def get_usb_speed(self) -> int:
|
||||
time.sleep(0.002)
|
||||
return 2 # High speed
|
||||
|
||||
def get_vendor_string(self) -> str:
|
||||
time.sleep(0.005)
|
||||
return "Genpix Electronics"
|
||||
|
||||
def get_product_string(self) -> str:
|
||||
time.sleep(0.005)
|
||||
return "SkyWalker-1 DVB-S"
|
||||
|
||||
# --- FX2 RAM access ---
|
||||
|
||||
def fx2_ram_read(self, addr: int, length: int) -> bytes:
|
||||
time.sleep(0.003)
|
||||
# Return pseudo-random but deterministic bytes based on address
|
||||
rng = random.Random(addr)
|
||||
return bytes(rng.randint(0, 255) for _ in range(length))
|
||||
|
||||
def fx2_ram_write(self, addr: int, data: bytes) -> int:
|
||||
time.sleep(0.003)
|
||||
return len(data)
|
||||
|
||||
def fx2_cpu_halt(self) -> None:
|
||||
time.sleep(0.005)
|
||||
|
||||
def fx2_cpu_start(self) -> None:
|
||||
time.sleep(0.005)
|
||||
|
||||
# --- EEPROM access ---
|
||||
|
||||
def eeprom_read(self, offset: int, length: int = 64) -> bytes:
|
||||
time.sleep(0.005)
|
||||
end = min(offset + length, len(self._eeprom))
|
||||
return bytes(self._eeprom[offset:end])
|
||||
|
||||
def eeprom_write_page(self, offset: int, data: bytes) -> int:
|
||||
time.sleep(0.012) # simulated write cycle
|
||||
for i, b in enumerate(data):
|
||||
if offset + i < len(self._eeprom):
|
||||
self._eeprom[offset + i] = b
|
||||
return len(data)
|
||||
|
||||
def eeprom_read_all(self, size: int = 16384) -> bytes:
|
||||
chunk_size = 64
|
||||
result = bytearray()
|
||||
for offset in range(0, size, chunk_size):
|
||||
remaining = min(chunk_size, size - offset)
|
||||
result.extend(self.eeprom_read(offset, remaining))
|
||||
return bytes(result)
|
||||
|
||||
# --- Diagnostics ---
|
||||
|
||||
_BOOT_STAGES = {
|
||||
0x80: {"stage": 0x00, "result": 0x00, "detail": 0x00}, # no-op
|
||||
0x81: {"stage": 0x01, "result": 0x01, "detail": 0x00}, # GPIO OK
|
||||
0x82: {"stage": 0x02, "result": 0x01, "detail": 0x02}, # I2C probe OK
|
||||
0x83: {"stage": 0x03, "result": 0x01, "detail": 0x00}, # BCM4500 reset OK
|
||||
0x84: {"stage": 0x04, "result": 0x01, "detail": 0x00}, # FW load OK
|
||||
0x85: {"stage": 0x05, "result": 0x01, "detail": 0x00}, # full boot OK
|
||||
}
|
||||
|
||||
def boot_debug(self, mode: int) -> dict:
|
||||
time.sleep(0.05)
|
||||
return dict(self._BOOT_STAGES.get(mode, {
|
||||
"stage": mode & 0x0F, "result": 0x00, "detail": 0xFF,
|
||||
}))
|
||||
|
||||
def i2c_bus_scan(self) -> list[int]:
|
||||
time.sleep(0.1)
|
||||
return [0x08, 0x51] # BCM4500 at 0x08, EEPROM at 0x51
|
||||
|
||||
def i2c_raw_read(self, slave: int, reg: int) -> int:
|
||||
time.sleep(0.01)
|
||||
return random.randint(0, 255)
|
||||
|
||||
# --- Streaming ---
|
||||
|
||||
def arm_transfer(self, on: bool) -> None:
|
||||
self._armed = on
|
||||
time.sleep(0.01)
|
||||
|
||||
def read_stream(self, size: int = 8192, timeout: int = 1000) -> bytes:
|
||||
"""Generate synthetic TS packets with proper structure."""
|
||||
if not self._armed:
|
||||
return b''
|
||||
time.sleep(0.02)
|
||||
|
||||
packets = bytearray()
|
||||
num_packets = size // 188
|
||||
|
||||
for _ in range(num_packets):
|
||||
# Pick a PID based on weights
|
||||
pid = random.choices(_DEMO_PIDS, weights=_DEMO_PID_WEIGHTS, k=1)[0]
|
||||
cc = self._cc_counters[pid]
|
||||
self._cc_counters[pid] = (cc + 1) & 0x0F
|
||||
|
||||
# Occasionally inject a CC error (~0.1%)
|
||||
if random.random() < 0.001 and pid != 0x1FFF:
|
||||
self._cc_counters[pid] = (self._cc_counters[pid] + 1) & 0x0F
|
||||
|
||||
# Build 188-byte TS packet
|
||||
pkt = bytearray(188)
|
||||
pkt[0] = 0x47 # sync byte
|
||||
pkt[1] = (pid >> 8) & 0x1F
|
||||
pkt[2] = pid & 0xFF
|
||||
pkt[3] = 0x10 | (cc & 0x0F) # payload only + CC
|
||||
|
||||
if pid == 0x0000:
|
||||
# PAT packet with PUSI
|
||||
pkt[1] |= 0x40 # PUSI
|
||||
pkt[4] = 0x00 # pointer field
|
||||
# PAT section: table_id=0x00, one program
|
||||
pat = bytearray([
|
||||
0x00, # table_id
|
||||
0xB0, 0x0D, # section_syntax + length=13
|
||||
0x00, 0x01, # transport_stream_id
|
||||
0xC1, # version=0, current
|
||||
0x00, 0x00, # section_number, last_section
|
||||
0x00, 0x01, # program_number=1
|
||||
0xE1, 0x00, # PMT PID=0x0100
|
||||
# CRC32 placeholder
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
])
|
||||
pkt[5:5 + len(pat)] = pat
|
||||
|
||||
elif pid == 0x0100:
|
||||
# PMT packet with PUSI
|
||||
pkt[1] |= 0x40
|
||||
pkt[4] = 0x00
|
||||
pmt = bytearray([
|
||||
0x02, # table_id
|
||||
0xB0, 0x17, # section_syntax + length=23
|
||||
0x00, 0x01, # program_number=1
|
||||
0xC1, # version=0
|
||||
0x00, 0x00, # section_number
|
||||
0xE1, 0x01, # PCR PID=0x0101
|
||||
0xF0, 0x00, # program info length=0
|
||||
# Stream 1: MPEG-2 Video on PID 0x0101
|
||||
0x02, 0xE1, 0x01, 0xF0, 0x00,
|
||||
# Stream 2: MPEG-1 Audio on PID 0x0102
|
||||
0x03, 0xE1, 0x02, 0xF0, 0x00,
|
||||
# CRC32 placeholder
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
])
|
||||
pkt[5:5 + len(pmt)] = pmt
|
||||
|
||||
else:
|
||||
# Fill payload with pseudo-random data
|
||||
for i in range(4, 188):
|
||||
pkt[i] = random.randint(0, 255)
|
||||
pkt[3] = 0x10 | (cc & 0x0F)
|
||||
|
||||
packets.extend(pkt)
|
||||
|
||||
return bytes(packets)
|
||||
|
||||
# --- DiSEqC ---
|
||||
|
||||
def send_diseqc_tone_burst(self, mini_cmd: int) -> None:
|
||||
time.sleep(0.05)
|
||||
|
||||
def send_diseqc_message(self, msg: bytes) -> None:
|
||||
time.sleep(0.05)
|
||||
|
||||
def get_signal_lock(self) -> bool:
|
||||
sig = self.signal_monitor()
|
||||
return sig["locked"]
|
||||
|
||||
def get_signal_strength(self) -> dict:
|
||||
sig = self.signal_monitor()
|
||||
return {
|
||||
"snr_raw": sig["snr_raw"],
|
||||
"snr_db": sig["snr_db"],
|
||||
"snr_pct": sig["snr_pct"],
|
||||
"raw_bytes": "00 00 00 00 00 00",
|
||||
}
|
||||
|
||||
def multi_reg_read(self, start_reg: int, count: int) -> bytes:
|
||||
time.sleep(0.01)
|
||||
rng = random.Random(start_reg)
|
||||
return bytes(rng.randint(0, 255) for _ in range(count))
|
||||
|
||||
# --- Internal signal model ---
|
||||
|
||||
def _power_at(self, freq_mhz: float, elapsed: float) -> float:
|
||||
|
||||
742
tui/src/skywalker_tui/screens/config.py
Normal file
742
tui/src/skywalker_tui/screens/config.py
Normal file
@ -0,0 +1,742 @@
|
||||
"""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")
|
||||
1236
tui/src/skywalker_tui/screens/device.py
Normal file
1236
tui/src/skywalker_tui/screens/device.py
Normal file
File diff suppressed because it is too large
Load Diff
401
tui/src/skywalker_tui/screens/stream.py
Normal file
401
tui/src/skywalker_tui/screens/stream.py
Normal file
@ -0,0 +1,401 @@
|
||||
"""Stream screen -- live MPEG-2 transport stream capture and analysis.
|
||||
|
||||
Reads raw TS data from the SkyWalker-1 bulk endpoint, parses 188-byte
|
||||
packets in real time, and displays PID distribution statistics alongside
|
||||
a hierarchical PSI (PAT/PMT) program structure tree.
|
||||
|
||||
Supports file capture mode for saving raw .ts files to disk.
|
||||
|
||||
arm_transfer(on) is always paired in try/finally to guarantee the USB
|
||||
bulk endpoint is disarmed when monitoring stops.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.widgets import Label, Input, Button, Static
|
||||
from textual import work
|
||||
from textual.worker import Worker
|
||||
|
||||
from ts_analyze import (
|
||||
TSPacket, PSIParser, parse_pat, parse_pmt,
|
||||
KNOWN_PIDS, TS_PACKET_SIZE,
|
||||
)
|
||||
|
||||
from skywalker_tui.widgets.pid_table import PidTable
|
||||
from skywalker_tui.widgets.psi_tree import PsiTree
|
||||
|
||||
|
||||
class StreamScreen(Container):
|
||||
"""Live MPEG-2 TS monitor with PID analysis and PSI tree."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
StreamScreen {
|
||||
layout: vertical;
|
||||
}
|
||||
StreamScreen #stream-main {
|
||||
height: 1fr;
|
||||
layout: horizontal;
|
||||
}
|
||||
StreamScreen #stream-pid-col {
|
||||
width: 3fr;
|
||||
height: 1fr;
|
||||
padding: 1;
|
||||
}
|
||||
StreamScreen #stream-psi-col {
|
||||
width: 2fr;
|
||||
height: 1fr;
|
||||
padding: 1;
|
||||
}
|
||||
StreamScreen #stream-controls {
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
background: #0e1018;
|
||||
border-top: solid #1a2a3a;
|
||||
layout: horizontal;
|
||||
}
|
||||
StreamScreen #stream-controls Label {
|
||||
width: auto;
|
||||
margin: 1 1 0 0;
|
||||
color: #506878;
|
||||
}
|
||||
StreamScreen #stream-controls Input {
|
||||
width: 14;
|
||||
margin: 0 1;
|
||||
}
|
||||
StreamScreen #stream-controls Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
StreamScreen #stream-stats {
|
||||
height: 3;
|
||||
layout: horizontal;
|
||||
padding: 0 2;
|
||||
}
|
||||
StreamScreen #stream-stats Static {
|
||||
width: 1fr;
|
||||
height: 3;
|
||||
content-align: center middle;
|
||||
background: #121c2a;
|
||||
border: round #1a3050;
|
||||
margin: 0 1 0 0;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, bridge, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._bridge = bridge
|
||||
self._monitoring = False
|
||||
self._capturing = False
|
||||
self._capture_file = None
|
||||
self._monitor_worker: Worker | None = None
|
||||
|
||||
# Accumulated stats (written by worker thread, read by UI thread)
|
||||
self._total_packets = 0
|
||||
self._total_bytes = 0
|
||||
self._tei_count = 0
|
||||
self._pid_counts: dict[int, int] = {}
|
||||
self._cc_last: dict[int, int] = {}
|
||||
self._cc_errors: dict[int, int] = {}
|
||||
self._start_time = 0.0
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(id="stream-main"):
|
||||
with Vertical(id="stream-pid-col"):
|
||||
yield Static(
|
||||
"[bold #00d4aa]PID Distribution[/]", classes="panel-title"
|
||||
)
|
||||
yield PidTable(id="stream-pid-table")
|
||||
with Vertical(id="stream-psi-col"):
|
||||
yield Static(
|
||||
"[bold #00d4aa]Program Structure[/]", classes="panel-title"
|
||||
)
|
||||
yield PsiTree(id="stream-psi-tree")
|
||||
with Horizontal(id="stream-stats"):
|
||||
yield Static("[#506878]Packets:[/] [#00d4aa]0[/]", id="stat-packets")
|
||||
yield Static("[#506878]Bytes:[/] [#00d4aa]0[/]", id="stat-bytes")
|
||||
yield Static("[#506878]PIDs:[/] [#00d4aa]0[/]", id="stat-pids")
|
||||
yield Static("[#506878]CC Errors:[/] [#00d4aa]0[/]", id="stat-cc")
|
||||
yield Static(
|
||||
"[#506878]Duration:[/] [#00d4aa]0.0s[/]", id="stat-duration"
|
||||
)
|
||||
with Horizontal(id="stream-controls"):
|
||||
yield Button("Start Monitor", id="stream-start", variant="success")
|
||||
yield Button("Stop", id="stream-stop", variant="error")
|
||||
yield Label("Capture:")
|
||||
yield Input("capture.ts", id="stream-capture-file")
|
||||
yield Button("Capture", id="stream-capture", variant="warning")
|
||||
yield Static("[#506878]Idle[/]", id="stream-status")
|
||||
|
||||
def on_show(self) -> None:
|
||||
"""Auto-start monitoring in demo mode when this screen becomes visible."""
|
||||
if self._bridge.is_demo and not self._monitoring:
|
||||
self._start_monitor()
|
||||
|
||||
def on_hide(self) -> None:
|
||||
"""Stop monitoring when navigating away from this screen."""
|
||||
self._stop_monitor()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "stream-start":
|
||||
self._start_monitor()
|
||||
elif event.button.id == "stream-stop":
|
||||
self._stop_monitor()
|
||||
elif event.button.id == "stream-capture":
|
||||
self._toggle_capture()
|
||||
|
||||
# -- Monitor lifecycle --
|
||||
|
||||
def _start_monitor(self) -> None:
|
||||
"""Begin streaming from the device bulk endpoint."""
|
||||
if self._monitoring:
|
||||
return
|
||||
self._monitoring = True
|
||||
self._reset_stats()
|
||||
self._start_time = time.monotonic()
|
||||
try:
|
||||
self.query_one("#stream-status", Static).update(
|
||||
"[bold #00d4aa]Monitoring[/]"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._monitor_worker = self._do_monitor()
|
||||
|
||||
def _stop_monitor(self) -> None:
|
||||
"""Stop the monitor worker and clean up capture state."""
|
||||
self._monitoring = False
|
||||
self._capturing = False
|
||||
# Cancel worker BEFORE closing file — worker may still be writing
|
||||
if self._monitor_worker is not None:
|
||||
self._monitor_worker.cancel()
|
||||
self._monitor_worker = None
|
||||
if self._capture_file is not None:
|
||||
self._capture_file.close()
|
||||
self._capture_file = None
|
||||
try:
|
||||
self.query_one("#stream-status", Static).update(
|
||||
"[#506878]Stopped[/]"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _toggle_capture(self) -> None:
|
||||
"""Toggle file capture on/off. Starts monitor if not already running."""
|
||||
if self._capturing:
|
||||
self._capturing = False
|
||||
if self._capture_file is not None:
|
||||
self._capture_file.close()
|
||||
self._capture_file = None
|
||||
try:
|
||||
self.query_one("#stream-capture", Button).label = "Capture"
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Start capture
|
||||
if not self._monitoring:
|
||||
self._start_monitor()
|
||||
filename = self.query_one("#stream-capture-file", Input).value or "capture.ts"
|
||||
try:
|
||||
self._capture_file = open(filename, "wb")
|
||||
self._capturing = True
|
||||
self.query_one("#stream-capture", Button).label = "Stop Capture"
|
||||
except OSError as e:
|
||||
self.app.notify(f"Cannot open {filename}: {e}", severity="error")
|
||||
|
||||
def _reset_stats(self) -> None:
|
||||
"""Zero all counters and clear display widgets."""
|
||||
self._total_packets = 0
|
||||
self._total_bytes = 0
|
||||
self._tei_count = 0
|
||||
self._pid_counts.clear()
|
||||
self._cc_last.clear()
|
||||
self._cc_errors.clear()
|
||||
try:
|
||||
self.query_one("#stream-pid-table", PidTable).clear_table()
|
||||
self.query_one("#stream-psi-tree", PsiTree).clear_tree()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- Background worker --
|
||||
|
||||
@work(thread=True)
|
||||
def _do_monitor(self) -> None:
|
||||
"""Background worker: read TS stream, parse packets, post UI updates.
|
||||
|
||||
Runs in a dedicated thread via Textual's @work(thread=True) decorator.
|
||||
Calls arm_transfer(True) on entry and arm_transfer(False) in finally
|
||||
to guarantee the bulk endpoint is always disarmed on exit.
|
||||
"""
|
||||
psi_pat = PSIParser()
|
||||
psi_pmt = PSIParser()
|
||||
pat = None
|
||||
pmt_pids: set[int] = set()
|
||||
last_ui_update = 0.0
|
||||
|
||||
try:
|
||||
self._bridge.ensure_booted()
|
||||
self._bridge.arm_transfer(True)
|
||||
|
||||
while self._monitoring:
|
||||
data = self._bridge.read_stream(8192, timeout=1000)
|
||||
if not data:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
|
||||
self._total_bytes += len(data)
|
||||
|
||||
# Write raw data to capture file if active
|
||||
if self._capturing and self._capture_file is not None:
|
||||
try:
|
||||
self._capture_file.write(data)
|
||||
except OSError:
|
||||
self._capturing = False
|
||||
|
||||
# Parse 188-byte TS packets from the chunk
|
||||
offset = 0
|
||||
while offset + TS_PACKET_SIZE <= len(data):
|
||||
if data[offset] != 0x47:
|
||||
# Lost sync, scan forward for next sync byte
|
||||
offset += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
pkt = TSPacket(data[offset:offset + TS_PACKET_SIZE])
|
||||
except (ValueError, IndexError):
|
||||
offset += 1
|
||||
continue
|
||||
|
||||
offset += TS_PACKET_SIZE
|
||||
self._total_packets += 1
|
||||
pid = pkt.pid
|
||||
|
||||
# PID counting
|
||||
self._pid_counts[pid] = self._pid_counts.get(pid, 0) + 1
|
||||
|
||||
# TEI (Transport Error Indicator) check
|
||||
if pkt.tei:
|
||||
self._tei_count += 1
|
||||
|
||||
# Continuity counter check (payload-bearing, non-null PIDs)
|
||||
if pkt.adaptation & 0x01 and pid != 0x1FFF:
|
||||
if pid in self._cc_last:
|
||||
expected = (self._cc_last[pid] + 1) & 0x0F
|
||||
if (pkt.continuity != expected
|
||||
and pkt.continuity != self._cc_last[pid]):
|
||||
self._cc_errors[pid] = (
|
||||
self._cc_errors.get(pid, 0) + 1
|
||||
)
|
||||
self._cc_last[pid] = pkt.continuity
|
||||
|
||||
# PAT parsing (PID 0x0000)
|
||||
if pid == 0x0000:
|
||||
section = psi_pat.feed(pkt)
|
||||
if section is not None:
|
||||
parsed = parse_pat(section)
|
||||
if parsed is not None:
|
||||
pat = parsed
|
||||
for prog_num, pmt_pid in pat.get(
|
||||
"programs", {}
|
||||
).items():
|
||||
if prog_num != 0:
|
||||
pmt_pids.add(pmt_pid)
|
||||
|
||||
# PMT parsing (PIDs discovered from PAT)
|
||||
if pid in pmt_pids:
|
||||
section = psi_pmt.feed(pkt)
|
||||
if section is not None:
|
||||
parsed = parse_pmt(section)
|
||||
if parsed is not None:
|
||||
self.app.call_from_thread(
|
||||
self._update_pmt, pid, parsed
|
||||
)
|
||||
|
||||
# Batch UI updates every 500ms to avoid flooding the event loop
|
||||
now = time.monotonic()
|
||||
if now - last_ui_update >= 0.5:
|
||||
last_ui_update = now
|
||||
self.app.call_from_thread(
|
||||
self._update_ui,
|
||||
pat,
|
||||
dict(self._pid_counts),
|
||||
dict(self._cc_errors),
|
||||
self._total_packets,
|
||||
self._total_bytes,
|
||||
self._tei_count,
|
||||
)
|
||||
|
||||
finally:
|
||||
for _attempt in range(2):
|
||||
try:
|
||||
self._bridge.arm_transfer(False)
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.1)
|
||||
|
||||
# -- UI update methods (called from main thread) --
|
||||
|
||||
def _update_ui(
|
||||
self,
|
||||
pat: dict | None,
|
||||
pid_counts: dict[int, int],
|
||||
cc_errors: dict[int, int],
|
||||
total_pkts: int,
|
||||
total_bytes: int,
|
||||
tei_count: int,
|
||||
) -> None:
|
||||
"""Push accumulated stats to display widgets."""
|
||||
if not self.is_mounted:
|
||||
return
|
||||
|
||||
# Build known PID names by merging standard table with PAT-discovered PMTs
|
||||
known = dict(KNOWN_PIDS)
|
||||
if pat:
|
||||
for prog_num, pmt_pid in pat.get("programs", {}).items():
|
||||
if prog_num == 0:
|
||||
known[pmt_pid] = "NIT"
|
||||
else:
|
||||
known[pmt_pid] = f"PMT (prog {prog_num})"
|
||||
|
||||
self.query_one("#stream-pid-table", PidTable).update_pids(
|
||||
pid_counts, cc_errors, total_pkts, known
|
||||
)
|
||||
|
||||
if pat:
|
||||
self.query_one("#stream-psi-tree", PsiTree).update_pat(pat)
|
||||
|
||||
total_cc = sum(cc_errors.values())
|
||||
duration = time.monotonic() - self._start_time
|
||||
|
||||
self.query_one("#stat-packets", Static).update(
|
||||
f"[#506878]Packets:[/] [#00d4aa]{total_pkts:,}[/]"
|
||||
)
|
||||
|
||||
if total_bytes >= 1_000_000:
|
||||
bytes_str = f"{total_bytes / 1_000_000:.1f} MB"
|
||||
elif total_bytes >= 1_000:
|
||||
bytes_str = f"{total_bytes / 1_000:.1f} KB"
|
||||
else:
|
||||
bytes_str = str(total_bytes)
|
||||
self.query_one("#stat-bytes", Static).update(
|
||||
f"[#506878]Bytes:[/] [#00d4aa]{bytes_str}[/]"
|
||||
)
|
||||
|
||||
self.query_one("#stat-pids", Static).update(
|
||||
f"[#506878]PIDs:[/] [#00d4aa]{len(pid_counts)}[/]"
|
||||
)
|
||||
|
||||
cc_color = "#e04040" if total_cc > 0 else "#00d4aa"
|
||||
self.query_one("#stat-cc", Static).update(
|
||||
f"[#506878]CC Errors:[/] [{cc_color}]{total_cc}[/]"
|
||||
)
|
||||
|
||||
self.query_one("#stat-duration", Static).update(
|
||||
f"[#506878]Duration:[/] [#00d4aa]{duration:.1f}s[/]"
|
||||
)
|
||||
|
||||
def _update_pmt(self, pmt_pid: int, pmt: dict) -> None:
|
||||
"""Push a newly parsed PMT to the PSI tree widget."""
|
||||
if not self.is_mounted:
|
||||
return
|
||||
self.query_one("#stream-psi-tree", PsiTree).update_pmt(pmt_pid, pmt)
|
||||
@ -339,6 +339,36 @@ SplashScreen #splash-image {
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
/* ─── Hex view ─── */
|
||||
|
||||
HexView {
|
||||
min-height: 6;
|
||||
}
|
||||
|
||||
/* ─── PID table ─── */
|
||||
|
||||
PidTable {
|
||||
min-height: 8;
|
||||
}
|
||||
|
||||
/* ─── PSI tree ─── */
|
||||
|
||||
PsiTree {
|
||||
min-height: 8;
|
||||
}
|
||||
|
||||
/* ─── Countdown timer ─── */
|
||||
|
||||
CountdownTimer {
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
/* ─── Config bits display ─── */
|
||||
|
||||
ConfigBitsDisplay {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* ─── Star Wars overlay ─── */
|
||||
|
||||
StarWarsScreen {
|
||||
|
||||
46
tui/src/skywalker_tui/widgets/config_bits.py
Normal file
46
tui/src/skywalker_tui/widgets/config_bits.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""Config byte flag display with colored indicators.
|
||||
|
||||
Renders the 8PSK config byte as a horizontal row of labeled flags,
|
||||
each shown as a filled (set) or hollow (clear) circle with color coding.
|
||||
"""
|
||||
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
from textual.app import ComposeResult
|
||||
|
||||
from skywalker_lib import CONFIG_BITS
|
||||
|
||||
|
||||
class ConfigBitsDisplay(Widget):
|
||||
"""Renders the 8PSK config byte as labeled flags with colored indicators."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ConfigBitsDisplay {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._config = 0
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("", id="config-bits-content")
|
||||
|
||||
def update_config(self, config: int) -> None:
|
||||
"""Update the displayed config byte value."""
|
||||
self._config = config
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self) -> None:
|
||||
if not self.is_mounted:
|
||||
return
|
||||
parts = []
|
||||
for bit_mask, (name, _field) in CONFIG_BITS.items():
|
||||
is_set = bool(self._config & bit_mask)
|
||||
if is_set:
|
||||
parts.append(f"[bold #00e060]\u25cf[/] [#c8d0d8]{name}[/]")
|
||||
else:
|
||||
parts.append(f"[#3a3a3a]\u25cb[/] [#506878]{name}[/]")
|
||||
self.query_one("#config-bits-content", Static).update(" ".join(parts))
|
||||
112
tui/src/skywalker_tui/widgets/countdown_timer.py
Normal file
112
tui/src/skywalker_tui/widgets/countdown_timer.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""Countdown timer widget with ABORT button for safety-critical operations.
|
||||
|
||||
Used before EEPROM write to give the operator 3 seconds to abort.
|
||||
The ABORT button is auto-focused on mount so a single Enter/Space press cancels.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Button, Static, ProgressBar
|
||||
from textual.app import ComposeResult
|
||||
from textual.message import Message
|
||||
from textual import work
|
||||
|
||||
|
||||
class CountdownTimer(Widget):
|
||||
"""3-second countdown with prominent ABORT button.
|
||||
|
||||
Posts CountdownTimer.Completed when the countdown finishes, or
|
||||
CountdownTimer.Aborted if the operator presses ABORT.
|
||||
"""
|
||||
|
||||
class Completed(Message):
|
||||
"""Fired when countdown finishes without abort."""
|
||||
pass
|
||||
|
||||
class Aborted(Message):
|
||||
"""Fired when user presses ABORT."""
|
||||
pass
|
||||
|
||||
DEFAULT_CSS = """
|
||||
CountdownTimer {
|
||||
height: auto;
|
||||
background: #1a0a0a;
|
||||
border: round #e04040;
|
||||
padding: 1 2;
|
||||
}
|
||||
CountdownTimer #countdown-label {
|
||||
text-align: center;
|
||||
color: #e8a020;
|
||||
text-style: bold;
|
||||
margin: 0 0 1 0;
|
||||
}
|
||||
CountdownTimer #countdown-bar {
|
||||
margin: 0 0 1 0;
|
||||
}
|
||||
CountdownTimer #countdown-abort {
|
||||
width: 100%;
|
||||
min-height: 3;
|
||||
background: #e04040;
|
||||
color: #ffffff;
|
||||
text-style: bold;
|
||||
border: round #ff6060;
|
||||
}
|
||||
CountdownTimer #countdown-abort:hover {
|
||||
background: #ff4040;
|
||||
}
|
||||
CountdownTimer #countdown-abort:focus {
|
||||
background: #ff2020;
|
||||
border: round #ffffff;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._running = False
|
||||
self._aborted = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("EEPROM WRITE in 3...", id="countdown-label")
|
||||
yield ProgressBar(
|
||||
total=30, show_eta=False, show_percentage=False, id="countdown-bar"
|
||||
)
|
||||
yield Button("ABORT", id="countdown-abort", variant="error")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#countdown-abort", Button).focus()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Begin the 3-second countdown. Call after mounting."""
|
||||
self._running = True
|
||||
self._aborted = False
|
||||
self._do_countdown()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "countdown-abort":
|
||||
self._aborted = True
|
||||
self._running = False
|
||||
self.post_message(self.Aborted())
|
||||
|
||||
@work(thread=True)
|
||||
def _do_countdown(self) -> None:
|
||||
"""Tick at 100ms intervals for smooth progress bar animation."""
|
||||
for tick in range(30, -1, -1):
|
||||
if not self._running or self._aborted:
|
||||
return
|
||||
secs = tick / 10
|
||||
self.app.call_from_thread(self._update_display, secs, 30 - tick)
|
||||
time.sleep(0.1)
|
||||
if self._running and not self._aborted:
|
||||
self.app.call_from_thread(self._fire_completed)
|
||||
|
||||
def _update_display(self, secs: float, progress: int) -> None:
|
||||
if not self.is_mounted:
|
||||
return
|
||||
label = self.query_one("#countdown-label", Static)
|
||||
label.update(f"EEPROM WRITE in {secs:.1f}s \u2014 press ABORT to cancel")
|
||||
self.query_one("#countdown-bar", ProgressBar).update(progress=progress)
|
||||
|
||||
def _fire_completed(self) -> None:
|
||||
if self.is_mounted and not self._aborted:
|
||||
self.post_message(self.Completed())
|
||||
90
tui/src/skywalker_tui/widgets/hex_view.py
Normal file
90
tui/src/skywalker_tui/widgets/hex_view.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""Scrollable hex dump widget with diff byte highlighting."""
|
||||
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import VerticalScroll
|
||||
|
||||
|
||||
class HexView(Widget):
|
||||
"""Displays a hex dump of binary data with optional diff highlighting.
|
||||
|
||||
Set data via set_data(), optionally passing a set of byte offsets to
|
||||
highlight in red (for verify mismatches). Each row shows 16 bytes in
|
||||
traditional offset : hex : ASCII layout.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
HexView {
|
||||
height: 1fr;
|
||||
min-height: 6;
|
||||
background: #0e1420;
|
||||
border: round #1a2a3a;
|
||||
}
|
||||
HexView #hex-scroll {
|
||||
height: 1fr;
|
||||
padding: 0 1;
|
||||
}
|
||||
HexView #hex-content {
|
||||
width: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._data = b''
|
||||
self._diff_offsets: set[int] = set()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with VerticalScroll(id="hex-scroll"):
|
||||
yield Static("", id="hex-content")
|
||||
|
||||
def set_data(self, data: bytes, diff_offsets: set[int] | None = None) -> None:
|
||||
"""Set data to display. diff_offsets highlights those bytes in red."""
|
||||
self._data = data
|
||||
self._diff_offsets = diff_offsets or set()
|
||||
self._refresh_display()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the hex display."""
|
||||
self._data = b''
|
||||
self._diff_offsets.clear()
|
||||
if self.is_mounted:
|
||||
self.query_one("#hex-content", Static).update("")
|
||||
|
||||
def _refresh_display(self) -> None:
|
||||
if not self.is_mounted:
|
||||
return
|
||||
lines = []
|
||||
for row_off in range(0, len(self._data), 16):
|
||||
row = self._data[row_off:row_off + 16]
|
||||
# Offset column
|
||||
line = f"[#506878]{row_off:04X}:[/] "
|
||||
# Hex bytes
|
||||
hex_parts = []
|
||||
for i, b in enumerate(row):
|
||||
abs_off = row_off + i
|
||||
if abs_off in self._diff_offsets:
|
||||
hex_parts.append(f"[bold #e04040]{b:02X}[/]")
|
||||
else:
|
||||
hex_parts.append(f"[#7090a8]{b:02X}[/]")
|
||||
line += " ".join(hex_parts)
|
||||
# Pad if short row
|
||||
if len(row) < 16:
|
||||
line += " " * (16 - len(row))
|
||||
# ASCII column
|
||||
line += " "
|
||||
ascii_parts = []
|
||||
for i, b in enumerate(row):
|
||||
abs_off = row_off + i
|
||||
ch = chr(b) if 0x20 <= b < 0x7F else "."
|
||||
if abs_off in self._diff_offsets:
|
||||
ascii_parts.append(f"[bold #e04040]{ch}[/]")
|
||||
else:
|
||||
ascii_parts.append(f"[#506878]{ch}[/]")
|
||||
line += "".join(ascii_parts)
|
||||
lines.append(line)
|
||||
|
||||
self.query_one("#hex-content", Static).update(
|
||||
"\n".join(lines) if lines else "[#506878]No data[/]"
|
||||
)
|
||||
74
tui/src/skywalker_tui/widgets/pid_table.py
Normal file
74
tui/src/skywalker_tui/widgets/pid_table.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""DataTable wrapper for MPEG-2 TS PID distribution statistics.
|
||||
|
||||
Displays per-PID packet counts, percentage share, continuity counter errors,
|
||||
and well-known PID names. Table is rebuilt on each update to keep the sort
|
||||
order stable (by PID number ascending).
|
||||
"""
|
||||
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import DataTable
|
||||
from textual.app import ComposeResult
|
||||
|
||||
|
||||
class PidTable(Widget):
|
||||
"""Sortable PID statistics table for transport stream analysis."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
PidTable {
|
||||
height: 1fr;
|
||||
min-height: 8;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._pid_data: dict[int, dict] = {}
|
||||
self._total_packets = 0
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
table = DataTable(id="pid-stats-table")
|
||||
table.cursor_type = "row"
|
||||
yield table
|
||||
|
||||
def on_mount(self) -> None:
|
||||
table = self.query_one("#pid-stats-table", DataTable)
|
||||
for col in ["PID", "Count", "%", "CC Errors", "Name"]:
|
||||
table.add_column(col, key=col)
|
||||
|
||||
def update_pids(
|
||||
self,
|
||||
pid_counts: dict[int, int],
|
||||
cc_errors: dict[int, int],
|
||||
total: int,
|
||||
known_pids: dict[int, str],
|
||||
) -> None:
|
||||
"""Rebuild the table from accumulated stats.
|
||||
|
||||
Args:
|
||||
pid_counts: Mapping of PID number to total packet count.
|
||||
cc_errors: Mapping of PID number to continuity counter error count.
|
||||
total: Total packet count across all PIDs.
|
||||
known_pids: Mapping of PID number to human-readable name.
|
||||
"""
|
||||
self._total_packets = total
|
||||
table = self.query_one("#pid-stats-table", DataTable)
|
||||
table.clear()
|
||||
|
||||
for pid in sorted(pid_counts.keys()):
|
||||
count = pid_counts[pid]
|
||||
pct = (count / total * 100) if total > 0 else 0.0
|
||||
cc_err = cc_errors.get(pid, 0)
|
||||
name = known_pids.get(pid, "")
|
||||
table.add_row(
|
||||
f"0x{pid:04X}",
|
||||
f"{count:,}",
|
||||
f"{pct:.1f}%",
|
||||
str(cc_err) if cc_err > 0 else "-",
|
||||
name,
|
||||
)
|
||||
|
||||
def clear_table(self) -> None:
|
||||
"""Remove all rows and reset internal state."""
|
||||
self._pid_data.clear()
|
||||
self._total_packets = 0
|
||||
self.query_one("#pid-stats-table", DataTable).clear()
|
||||
111
tui/src/skywalker_tui/widgets/psi_tree.py
Normal file
111
tui/src/skywalker_tui/widgets/psi_tree.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""Tree-style display of MPEG-2 PSI structure (PAT/PMT).
|
||||
|
||||
Renders a hierarchical view of the Program Association Table and its
|
||||
child Program Map Tables using Rich markup inside a Static widget.
|
||||
Shows transport stream ID, program numbers, PMT PIDs, PCR PIDs,
|
||||
and elementary stream types with their PIDs.
|
||||
"""
|
||||
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
from textual.app import ComposeResult
|
||||
|
||||
|
||||
class PsiTree(Widget):
|
||||
"""Hierarchical PAT/PMT display for transport stream program structure."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
PsiTree {
|
||||
height: 1fr;
|
||||
min-height: 8;
|
||||
background: #0e1420;
|
||||
border: round #1a2a3a;
|
||||
padding: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._pat: dict | None = None
|
||||
self._pmts: dict[int, dict] = {}
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(
|
||||
"[#506878]Waiting for PSI data...[/]", id="psi-content"
|
||||
)
|
||||
|
||||
def update_pat(self, pat: dict) -> None:
|
||||
"""Update the Program Association Table and redraw."""
|
||||
self._pat = pat
|
||||
self._refresh()
|
||||
|
||||
def update_pmt(self, pmt_pid: int, pmt: dict) -> None:
|
||||
"""Update a Program Map Table and redraw."""
|
||||
self._pmts[pmt_pid] = pmt
|
||||
self._refresh()
|
||||
|
||||
def clear_tree(self) -> None:
|
||||
"""Reset all PSI state and show placeholder."""
|
||||
self._pat = None
|
||||
self._pmts.clear()
|
||||
if self.is_mounted:
|
||||
self.query_one("#psi-content", Static).update(
|
||||
"[#506878]Waiting for PSI data...[/]"
|
||||
)
|
||||
|
||||
def _refresh(self) -> None:
|
||||
"""Rebuild the tree markup from current PAT/PMT data."""
|
||||
if not self.is_mounted:
|
||||
return
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
if self._pat:
|
||||
tsid = self._pat.get("transport_stream_id", 0)
|
||||
ver = self._pat.get("version", 0)
|
||||
lines.append(
|
||||
f"[bold #00d4aa]PAT[/] [#506878]TSID=0x{tsid:04X} v{ver}[/]"
|
||||
)
|
||||
|
||||
programs = self._pat.get("programs", {})
|
||||
# Separate NIT (program 0) from real programs
|
||||
real_progs = {k: v for k, v in programs.items() if k != 0}
|
||||
|
||||
for prog_num, pmt_pid in sorted(programs.items()):
|
||||
if prog_num == 0:
|
||||
lines.append(
|
||||
f" [#7090a8]\u251c\u2500[/] [#506878]NIT[/] "
|
||||
f"PID=0x{pmt_pid:04X}"
|
||||
)
|
||||
else:
|
||||
is_last = prog_num == max(real_progs.keys())
|
||||
prefix = "\u2514\u2500" if is_last else "\u251c\u2500"
|
||||
lines.append(
|
||||
f" [#7090a8]{prefix}[/] "
|
||||
f"[bold #c8d0d8]Program {prog_num}[/] "
|
||||
f"PMT=0x{pmt_pid:04X}"
|
||||
)
|
||||
# Expand PMT details if available
|
||||
if pmt_pid in self._pmts:
|
||||
pmt = self._pmts[pmt_pid]
|
||||
pcr_pid = pmt.get("pcr_pid", 0)
|
||||
indent = " " if is_last else "\u2502 "
|
||||
lines.append(
|
||||
f" {indent} [#506878]PCR PID=0x{pcr_pid:04X}[/]"
|
||||
)
|
||||
streams = pmt.get("streams", [])
|
||||
for j, s in enumerate(streams):
|
||||
s_last = j == len(streams) - 1
|
||||
s_prefix = "\u2514\u2500" if s_last else "\u251c\u2500"
|
||||
type_name = s.get("type_name", "Unknown")
|
||||
epid = s.get("elementary_pid", 0)
|
||||
lines.append(
|
||||
f" {indent} [#7090a8]{s_prefix}[/] "
|
||||
f"[#c8d0d8]{type_name}[/] "
|
||||
f"PID=0x{epid:04X}"
|
||||
)
|
||||
else:
|
||||
lines.append("[#506878]No PAT received yet[/]")
|
||||
|
||||
self.query_one("#psi-content", Static).update("\n".join(lines))
|
||||
Loading…
x
Reference in New Issue
Block a user