Ryan Malloy 567bf4d9e0 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).
2026-02-14 16:08:58 -07:00

112 lines
4.1 KiB
Python

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