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)
|
index=count, length=count)
|
||||||
return bytes(data)
|
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 --
|
# -- High-level sweep helpers --
|
||||||
|
|
||||||
def sweep_spectrum(self, start_mhz: float, stop_mhz: float,
|
def sweep_spectrum(self, start_mhz: float, stop_mhz: float,
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
"""SkyWalker-1 TUI — main application.
|
"""SkyWalker-1 TUI — main application.
|
||||||
|
|
||||||
Provides mode switching between 5 RF operating modes via a sidebar and F-key
|
Provides mode switching between 8 operating modes via a sidebar and F-key
|
||||||
shortcuts. Each mode is a Screen subclass that manages its own workers.
|
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.
|
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.monitor import MonitorScreen
|
||||||
from skywalker_tui.screens.lband import LBandScreen
|
from skywalker_tui.screens.lband import LBandScreen
|
||||||
from skywalker_tui.screens.track import TrackScreen
|
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 = {
|
MODES = {
|
||||||
@ -32,6 +35,9 @@ MODES = {
|
|||||||
"monitor": ("F3 Monitor", MonitorScreen),
|
"monitor": ("F3 Monitor", MonitorScreen),
|
||||||
"lband": ("F4 L-Band", LBandScreen),
|
"lband": ("F4 L-Band", LBandScreen),
|
||||||
"track": ("F5 Track", TrackScreen),
|
"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("f3", "rf_mode('monitor')", "Monitor", show=True),
|
||||||
Binding("f4", "rf_mode('lband')", "L-Band", show=True),
|
Binding("f4", "rf_mode('lband')", "L-Band", show=True),
|
||||||
Binding("f5", "rf_mode('track')", "Track", 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("q", "quit", "Quit", show=True),
|
||||||
Binding("d", "toggle_dark", "Theme", show=True),
|
Binding("d", "toggle_dark", "Theme", show=True),
|
||||||
Binding("ctrl+w", "starwars", "Star Wars", show=False),
|
Binding("ctrl+w", "starwars", "Star Wars", show=False),
|
||||||
@ -100,7 +109,7 @@ class SkyWalkerApp(App):
|
|||||||
self.call_later(self._init_mode_screens)
|
self.call_later(self._init_mode_screens)
|
||||||
|
|
||||||
def _init_mode_screens(self) -> None:
|
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)
|
switcher = self.query_one("#content-area", ContentSwitcher)
|
||||||
for mode_key, (_label, cls) in MODES.items():
|
for mode_key, (_label, cls) in MODES.items():
|
||||||
screen = cls(self._bridge, id=f"screen-{mode_key}")
|
screen = cls(self._bridge, id=f"screen-{mode_key}")
|
||||||
|
|||||||
@ -17,7 +17,7 @@ class USBBridge:
|
|||||||
|
|
||||||
def __init__(self, device):
|
def __init__(self, device):
|
||||||
self._dev = device
|
self._dev = device
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_demo(self) -> bool:
|
def is_demo(self) -> bool:
|
||||||
@ -92,3 +92,111 @@ class USBBridge:
|
|||||||
if hasattr(self._dev, "blind_scan"):
|
if hasattr(self._dev, "blind_scan"):
|
||||||
return self._dev.blind_scan(freq_khz, sr_min, sr_max, sr_step)
|
return self._dev.blind_scan(freq_khz, sr_min, sr_max, sr_step)
|
||||||
return None
|
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 math
|
||||||
import random
|
import random
|
||||||
|
import struct
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
@ -28,6 +29,31 @@ _TRANSPONDERS = [
|
|||||||
_NOISE_FLOOR = -35.0
|
_NOISE_FLOOR = -35.0
|
||||||
_LOCK_THRESHOLD_DB = 3.5
|
_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:
|
class DemoDevice:
|
||||||
"""Drop-in replacement for SkyWalker1 that generates synthetic data."""
|
"""Drop-in replacement for SkyWalker1 that generates synthetic data."""
|
||||||
@ -39,10 +65,14 @@ class DemoDevice:
|
|||||||
self._lnb_on = False
|
self._lnb_on = False
|
||||||
self._lnb_voltage_high = False
|
self._lnb_voltage_high = False
|
||||||
self._tone_22khz = False
|
self._tone_22khz = False
|
||||||
|
self._extra_voltage = False
|
||||||
self._tuned_freq_khz = 0
|
self._tuned_freq_khz = 0
|
||||||
self._tuned_sr_sps = 0
|
self._tuned_sr_sps = 0
|
||||||
|
self._armed = False
|
||||||
self._start_time = time.monotonic()
|
self._start_time = time.monotonic()
|
||||||
self._sample_count = 0
|
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):
|
def open(self):
|
||||||
pass
|
pass
|
||||||
@ -109,6 +139,9 @@ class DemoDevice:
|
|||||||
def start_intersil(self, on: bool = True):
|
def start_intersil(self, on: bool = True):
|
||||||
self._lnb_on = on
|
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,
|
def tune(self, symbol_rate_sps: int, freq_khz: int,
|
||||||
mod_index: int, fec_index: int):
|
mod_index: int, fec_index: int):
|
||||||
self._tuned_freq_khz = freq_khz
|
self._tuned_freq_khz = freq_khz
|
||||||
@ -211,6 +244,196 @@ class DemoDevice:
|
|||||||
}
|
}
|
||||||
return None
|
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 ---
|
# --- Internal signal model ---
|
||||||
|
|
||||||
def _power_at(self, freq_mhz: float, elapsed: float) -> float:
|
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;
|
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 ─── */
|
/* ─── Star Wars overlay ─── */
|
||||||
|
|
||||||
StarWarsScreen {
|
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