Rebuild TUI as 4-tab layout with 10 new widgets

Replace sidebar + 5-screen layout with horizontal tab bar (F1-F4)
and persistent StatusStrip. Consolidate Position + Scan screens into
Signal (F3) with Monitor/Sweep/Sky Map sub-modes via ModeBar.

Screens:
  - F1 Dashboard: system health, tracking panel, quick actions, presets
  - F2 Control: motor tuning, compass rose, preset management
  - F3 Signal: RSSI monitor, 1D sweep (SweepPlot), 2D sky map (heatmap)
  - F4 System: NVS editor with regex filter, EEPROM, firmware info
  - F5 Console: push/pop overlay (no longer a tab)

New widgets: StatusStrip, ModeBar, SweepPlot, QuickActions, PresetList,
ReceiverInfo, MotorTuning, NvsFilter, SystemHealth, TrackingPanel.

Removed: PositionScreen, ScanScreen, DeviceStatusBar (functionality
absorbed into new screens and StatusStrip).

App-level position poll feeds StatusStrip and active screen at ~2 Hz.
Fix shared threading.Event across instances (class-level mutable default).
This commit is contained in:
Ryan Malloy 2026-02-14 18:04:50 -07:00
parent e7e71c47d7
commit 145763fcfb
23 changed files with 2796 additions and 865 deletions

View file

@ -1,21 +1,39 @@
"""Custom widgets for the Birdcage TUI."""
from birdcage_tui.widgets.compass_rose import CompassRose
from birdcage_tui.widgets.device_status_bar import DeviceStatusBar
from birdcage_tui.widgets.mode_bar import ModeBar
from birdcage_tui.widgets.motor_status import MotorStatus
from birdcage_tui.widgets.motor_tuning import MotorTuning
from birdcage_tui.widgets.nvs_filter import NvsFilter
from birdcage_tui.widgets.nvs_table import NvsTable
from birdcage_tui.widgets.preset_list import PresetList
from birdcage_tui.widgets.quick_actions import QuickActions
from birdcage_tui.widgets.receiver_info import ReceiverInfo
from birdcage_tui.widgets.serial_log import SerialLog
from birdcage_tui.widgets.signal_gauge import SignalGauge
from birdcage_tui.widgets.sky_heatmap import SkyHeatmap
from birdcage_tui.widgets.sparkline_widget import SparklineWidget
from birdcage_tui.widgets.status_strip import StatusStrip
from birdcage_tui.widgets.sweep_plot import SweepPlot
from birdcage_tui.widgets.system_health import SystemHealthPanel
from birdcage_tui.widgets.tracking_panel import TrackingPanel
__all__ = [
"CompassRose",
"DeviceStatusBar",
"ModeBar",
"MotorStatus",
"MotorTuning",
"NvsFilter",
"NvsTable",
"PresetList",
"QuickActions",
"ReceiverInfo",
"SerialLog",
"SignalGauge",
"SkyHeatmap",
"SparklineWidget",
"StatusStrip",
"SweepPlot",
"SystemHealthPanel",
"TrackingPanel",
]

View file

@ -1,92 +0,0 @@
"""Device status bar widget — sidebar display of connection state and firmware info."""
from rich.text import Text
from textual.widgets import Static
class DeviceStatusBar(Static):
"""Sidebar status display showing connection state and firmware info."""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._connected: bool = False
self._demo: bool = False
self._firmware: str = "---"
self._submenu: str = "---"
self._port: str = "---"
def set_device(self, device: object) -> None:
"""Accept a device reference and update the status display."""
is_demo = hasattr(device, "demo_mode") or type(device).__name__ == "DemoDevice"
fw = getattr(device, "firmware_id", "02.02.48") if device else "---"
port = getattr(self.app, "serial_port", "/dev/ttyUSB0") if self.app else "---"
submenu = getattr(device, "current_menu", "TRK>") if device else "---"
connected = device is not None and not is_demo
self.update_status(
connected=connected,
demo=is_demo,
firmware=str(fw),
submenu=str(submenu),
port=str(port),
)
def update_status(
self,
connected: bool,
demo: bool,
firmware: str,
submenu: str,
port: str,
) -> None:
"""Update all status fields and refresh the display."""
self._connected = connected
self._demo = demo
self._firmware = firmware
self._submenu = submenu
self._port = port
self.refresh()
def render(self) -> Text:
result = Text()
label_w = 8
# Status row
result.append("Status".ljust(label_w), style="#506878")
if self._connected:
result.append("Connected", style="#00e060 bold")
elif self._demo:
result.append("Demo", style="#e8a020 italic")
else:
result.append("Offline", style="#e04040")
result.append("\n")
# Port row
result.append("Port".ljust(label_w), style="#506878")
result.append(self._port, style="#c8d0d8")
result.append("\n")
# Firmware row
result.append("FW".ljust(label_w), style="#506878")
result.append(self._firmware, style="#c8d0d8")
result.append("\n")
# Menu row
result.append("Menu".ljust(label_w), style="#506878")
# Color the menu prompt with its matching prompt color
submenu_colors: dict[str, str] = {
"TRK>": "#00d4aa",
"MOT>": "#00e060",
"DVB>": "#2080d0",
"NVS>": "#e8a020",
"A3981>": "#00b8c8",
"STEP>": "#40c0a0",
"EE>": "#e8a020",
"OS>": "#8090a0",
"ADC>": "#00b8c8",
"GPIO>": "#40c0a0",
"PEAK>": "#e8c020",
}
menu_color = submenu_colors.get(self._submenu, "#c8d0d8")
result.append(self._submenu, style=f"{menu_color} bold")
return result

View file

@ -0,0 +1,72 @@
"""Mode bar widget -- button bar for ContentSwitcher sub-modes."""
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.message import Message
from textual.widgets import Button
class ModeBar(Horizontal):
"""Horizontal bar of toggle buttons that switch a ContentSwitcher.
Used inside screens to switch between sub-modes (e.g., Manual/Presets/Track
within the Control screen, or Monitor/Sweep/SkyMap within Signal).
Posts a ``ModeBar.ModeChanged`` message when the active mode changes.
The parent screen should watch for this and update its ContentSwitcher.
"""
class ModeChanged(Message):
"""Posted when the user selects a different mode."""
def __init__(self, mode: str) -> None:
super().__init__()
self.mode = mode
def __init__(
self,
modes: dict[str, str],
initial: str | None = None,
**kwargs,
) -> None:
"""Create a mode bar.
Args:
modes: Mapping of mode_key -> display label.
initial: Which mode to highlight initially. Defaults to the first key.
"""
super().__init__(**kwargs)
self._modes = modes
self._initial = initial or next(iter(modes))
def compose(self) -> ComposeResult:
for key, label in self._modes.items():
btn = Button(label, id=f"mode-{key}", classes="mode-btn")
if key == self._initial:
btn.add_class("active")
yield btn
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id or ""
if not button_id.startswith("mode-"):
return
mode = button_id.removeprefix("mode-")
if mode not in self._modes:
return
# Update button highlight
for btn in self.query(".mode-btn"):
btn.remove_class("active")
event.button.add_class("active")
self.post_message(self.ModeChanged(mode))
event.stop()
@property
def active_mode(self) -> str:
"""Return the currently active mode key."""
for btn in self.query(".mode-btn.active"):
btn_id = btn.id or ""
return btn_id.removeprefix("mode-")
return self._initial

View file

@ -0,0 +1,130 @@
"""Motor tuning widget -- PID gain editor for AZ and EL motor control loops."""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.message import Message
from textual.widgets import Button, Input, Static
class MotorTuning(Container):
"""PID gain editor for AZ and EL motor control loops.
Displays two rows of labeled inputs (Kp, Kv, Ki for each axis) and an
Apply button. The button uses ``variant="warning"`` because writing PID
gains takes effect immediately on the live motor control loop.
The parent screen should confirm the action before sending the values
to the firmware via ``mot pid <motor> <Kp> <Kv> <Ki>``.
"""
class ApplyRequested(Message):
"""Posted when the user clicks Apply PID.
The parent screen should validate and confirm before writing to
the firmware, since PID changes affect motor behavior immediately.
"""
def __init__(
self,
az_kp: float,
az_kv: float,
az_ki: float,
el_kp: float,
el_kv: float,
el_ki: float,
) -> None:
super().__init__()
self.az_kp = az_kp
self.az_kv = az_kv
self.az_ki = az_ki
self.el_kp = el_kp
self.el_kv = el_kv
self.el_ki = el_ki
def compose(self) -> ComposeResult:
yield Static("PID Tuning", classes="panel-title")
with Vertical():
# AZ row
with Horizontal(classes="pid-row"):
yield Static("AZ", classes="pid-axis-label")
yield Static("Kp:", classes="pid-gain-label")
yield Input(value="600", id="pid-az-kp", type="number")
yield Static("Kv:", classes="pid-gain-label")
yield Input(value="60", id="pid-az-kv", type="number")
yield Static("Ki:", classes="pid-gain-label")
yield Input(value="1", id="pid-az-ki", type="number")
# EL row
with Horizontal(classes="pid-row"):
yield Static("EL", classes="pid-axis-label")
yield Static("Kp:", classes="pid-gain-label")
yield Input(value="250", id="pid-el-kp", type="number")
yield Static("Kv:", classes="pid-gain-label")
yield Input(value="50", id="pid-el-kv", type="number")
yield Static("Ki:", classes="pid-gain-label")
yield Input(value="1", id="pid-el-ki", type="number")
# Button row
with Horizontal(classes="pid-button-row"):
yield Button(
"Apply PID",
id="pid-apply",
variant="warning",
)
def load_gains(
self,
az_kp: float,
az_kv: float,
az_ki: float,
el_kp: float,
el_kv: float,
el_ki: float,
) -> None:
"""Populate all input fields from device-reported PID gains.
Args:
az_kp: Azimuth proportional gain.
az_kv: Azimuth velocity gain.
az_ki: Azimuth integral gain.
el_kp: Elevation proportional gain.
el_kv: Elevation velocity gain.
el_ki: Elevation integral gain.
"""
self.query_one("#pid-az-kp", Input).value = str(int(az_kp))
self.query_one("#pid-az-kv", Input).value = str(int(az_kv))
self.query_one("#pid-az-ki", Input).value = str(int(az_ki))
self.query_one("#pid-el-kp", Input).value = str(int(el_kp))
self.query_one("#pid-el-kv", Input).value = str(int(el_kv))
self.query_one("#pid-el-ki", Input).value = str(int(el_ki))
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle the Apply PID button press."""
if event.button.id != "pid-apply":
return
try:
az_kp = float(self.query_one("#pid-az-kp", Input).value)
az_kv = float(self.query_one("#pid-az-kv", Input).value)
az_ki = float(self.query_one("#pid-az-ki", Input).value)
el_kp = float(self.query_one("#pid-el-kp", Input).value)
el_kv = float(self.query_one("#pid-el-kv", Input).value)
el_ki = float(self.query_one("#pid-el-ki", Input).value)
except ValueError:
# Non-numeric input -- do not post the message.
# The Input widget's type="number" constraint should prevent this
# in normal usage, but guard against edge cases.
return
self.post_message(
self.ApplyRequested(
az_kp=az_kp,
az_kv=az_kv,
az_ki=az_ki,
el_kp=el_kp,
el_kv=el_kv,
el_ki=el_ki,
)
)
event.stop()

View file

@ -0,0 +1,72 @@
"""NVS filter widget -- text search and modified-only toggle for NVS table filtering."""
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.message import Message
from textual.widgets import Checkbox, Input, Static
class NvsFilter(Horizontal):
"""Filter bar for the NVS table: text search + show-modified-only toggle.
Composes a label, text input for searching NVS entries by name or index,
and a checkbox to restrict display to entries where current != default.
Posts ``NvsFilter.FilterChanged`` when either control changes, so the
parent screen can re-filter the NVS DataTable rows.
"""
class FilterChanged(Message):
"""Posted when the filter text or modified-only toggle changes."""
def __init__(self, text: str, modified_only: bool) -> None:
super().__init__()
self.text = text
self.modified_only = modified_only
def compose(self) -> ComposeResult:
yield Static("Filter: ", classes="label")
yield Input(
placeholder="search by name or index...",
id="nvs-filter-input",
)
yield Checkbox(
"Modified only",
id="nvs-filter-modified",
value=False,
)
def on_input_changed(self, event: Input.Changed) -> None:
"""Re-post filter state when search text changes."""
if event.input.id != "nvs-filter-input":
return
self._post_filter()
event.stop()
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
"""Re-post filter state when the modified-only toggle changes."""
if event.checkbox.id != "nvs-filter-modified":
return
self._post_filter()
event.stop()
def _post_filter(self) -> None:
"""Read current control values and post a FilterChanged message."""
text_input = self.query_one("#nvs-filter-input", Input)
checkbox = self.query_one("#nvs-filter-modified", Checkbox)
self.post_message(
self.FilterChanged(
text=text_input.value,
modified_only=checkbox.value,
)
)
@property
def filter_text(self) -> str:
"""Current text in the search input."""
return self.query_one("#nvs-filter-input", Input).value
@property
def modified_only(self) -> bool:
"""Whether the modified-only checkbox is checked."""
return self.query_one("#nvs-filter-modified", Checkbox).value

View file

@ -0,0 +1,216 @@
"""Preset list widget -- saved AZ/EL target presets backed by JSON file."""
import json
import logging
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Container, Horizontal
from textual.message import Message
from textual.widgets import Button, DataTable, Input
log = logging.getLogger(__name__)
PRESETS_PATH = Path.home() / ".config" / "birdcage" / "presets.json"
class PresetList(Container):
"""DataTable of saved AZ/EL presets with save/go/delete actions.
File format::
{
"targets": [
{"name": "Zenith", "az": null, "el": 65.0, "notes": "EL-only"},
{"name": "South", "az": 180.0, "el": 45.0, "notes": ""}
]
}
An ``az`` value of ``null`` means "don't move AZ" (EL-only targets).
"""
class GoToPreset(Message):
"""Posted when the user presses Go on a selected preset."""
def __init__(self, az: float | None, el: float) -> None:
super().__init__()
self.az = az
self.el = el
class SaveRequested(Message):
"""Posted when the user presses Save Current.
The parent screen should read the current position and call
``save_preset()`` with a name and the current AZ/EL.
"""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._presets: list[dict] = []
self._table_ready = False
def compose(self) -> ComposeResult:
yield DataTable(id="preset-table")
with Horizontal(classes="preset-controls"):
yield Input(placeholder="Preset name", id="preset-name-input")
yield Button("Save Current", id="btn-preset-save")
yield Button("Go", id="btn-preset-go")
yield Button("Delete", id="btn-preset-delete")
def on_mount(self) -> None:
"""Add columns and load presets when mounted."""
table = self.query_one("#preset-table", DataTable)
table.add_columns("Name", "AZ", "EL", "Notes")
table.cursor_type = "row"
self._table_ready = True
self.load_presets()
# ------------------------------------------------------------------
# Persistence
# ------------------------------------------------------------------
def load_presets(self) -> None:
"""Read presets from the JSON file and populate the table."""
self._presets = []
if PRESETS_PATH.exists():
try:
data = json.loads(PRESETS_PATH.read_text(encoding="utf-8"))
self._presets = data.get("targets", [])
except (json.JSONDecodeError, KeyError):
log.warning("Failed to parse presets file: %s", PRESETS_PATH)
self._rebuild_table()
def _write_presets(self) -> None:
"""Write the current presets list to the JSON file."""
PRESETS_PATH.parent.mkdir(parents=True, exist_ok=True)
data = {"targets": self._presets}
PRESETS_PATH.write_text(
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
def _rebuild_table(self) -> None:
"""Clear and repopulate the DataTable from the in-memory presets list."""
if not self._table_ready:
return
table = self.query_one("#preset-table", DataTable)
table.clear()
for idx, preset in enumerate(self._presets):
name = preset.get("name", f"preset-{idx}")
az = preset.get("az")
el = preset.get("el", 0.0)
notes = preset.get("notes", "")
az_str = f"{az:.1f}" if az is not None else "---"
el_str = f"{el:.1f}"
table.add_row(name, az_str, el_str, notes, key=f"preset-{idx}")
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def save_preset(
self,
name: str,
az: float | None,
el: float,
notes: str = "",
) -> None:
"""Append a new preset and persist to disk.
Args:
name: Human-readable label for this target.
az: Azimuth in degrees, or None for EL-only targets.
el: Elevation in degrees.
notes: Optional description.
"""
entry: dict = {
"name": name,
"az": az,
"el": el,
"notes": notes,
}
self._presets.append(entry)
self._write_presets()
self._rebuild_table()
def delete_selected(self) -> None:
"""Remove the currently highlighted preset row."""
table = self.query_one("#preset-table", DataTable)
if table.row_count == 0:
return
row_key = table.cursor_row
if row_key < 0 or row_key >= len(self._presets):
return
self._presets.pop(row_key)
self._write_presets()
self._rebuild_table()
def _get_selected_preset(self) -> dict | None:
"""Return the preset dict for the currently highlighted row."""
table = self.query_one("#preset-table", DataTable)
if table.row_count == 0:
return None
row_key = table.cursor_row
if row_key < 0 or row_key >= len(self._presets):
return None
return self._presets[row_key]
@property
def presets(self) -> list[dict]:
"""Access the current in-memory presets list."""
return list(self._presets)
# ------------------------------------------------------------------
# Button handlers
# ------------------------------------------------------------------
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id or ""
if button_id == "btn-preset-save":
self._handle_save()
elif button_id == "btn-preset-go":
self._handle_go()
elif button_id == "btn-preset-delete":
self._handle_delete()
def _handle_save(self) -> None:
"""Read the name input and post a SaveRequested message."""
name_input = self.query_one("#preset-name-input", Input)
name = name_input.value.strip()
if not name:
self.app.notify("Enter a preset name first", severity="warning")
return
self.post_message(self.SaveRequested())
def _handle_go(self) -> None:
"""Post a GoToPreset message for the selected row."""
preset = self._get_selected_preset()
if preset is None:
self.app.notify("No preset selected", severity="warning")
return
az = preset.get("az")
el = preset.get("el", 0.0)
self.post_message(self.GoToPreset(az=az, el=float(el)))
def _handle_delete(self) -> None:
"""Delete the selected preset."""
preset = self._get_selected_preset()
if preset is None:
self.app.notify("No preset selected", severity="warning")
return
name = preset.get("name", "?")
self.delete_selected()
self.app.notify(f"Deleted preset: {name}")

View file

@ -0,0 +1,48 @@
"""Quick actions widget -- grid of task-oriented action buttons for the Dashboard."""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal
from textual.message import Message
from textual.widgets import Button
class QuickActions(Container):
"""Grid of buttons navigating to relevant screens/modes.
Posts an ``ActionSelected`` message when a button is pressed.
The parent screen or app handles the actual navigation or confirmation
(e.g., stow requires user confirmation before moving).
"""
class ActionSelected(Message):
"""Posted when the user selects a quick action."""
def __init__(self, action: str) -> None:
super().__init__()
self.action = action
# Action definitions: (id_suffix, label, description)
_ACTIONS: list[tuple[str, str]] = [
("point", "Point Dish"),
("monitor", "Monitor Signal"),
("scan", "Scan Sky"),
("stow", "Stow"),
]
def compose(self) -> ComposeResult:
with Horizontal(classes="quick-action-row"):
for action_id, label in self._ACTIONS:
yield Button(
label,
id=f"qa-{action_id}",
classes="quick-action-btn",
)
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id or ""
if not button_id.startswith("qa-"):
return
action = button_id.removeprefix("qa-")
self.post_message(self.ActionSelected(action))
event.stop()

View file

@ -0,0 +1,156 @@
"""Receiver info widget -- parsed DVB/RF receiver parameters display."""
import re
from rich.text import Text
from textual.widgets import Static
class ReceiverInfo(Static):
"""Displays parsed DVB receiver parameters from bridge channel/config queries.
Call ``load_data(channel_params_text, dvb_config_text)`` to update.
Parses the firmware's ``dis`` and ``config`` command output into a
compact, color-coded summary of receiver state.
"""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._freq: str = "---"
self._symrate: str = "---"
self._lnb: str = "---"
self._lock: str = "NO"
self._bcm_id: str = "---"
self._bcm_rev: str = ""
self._bcm_fw: str = ""
def load_data(
self,
channel_params: str = "",
dvb_config: str = "",
) -> None:
"""Parse raw firmware responses and refresh the display.
Args:
channel_params: Raw output from DVB ``dis`` command (channel
parameter table with Parameter/Current columns).
dvb_config: Raw output from DVB ``config`` command (BCM
hardware/firmware identification).
"""
self._parse_channel_params(channel_params)
self._parse_dvb_config(dvb_config)
self.refresh()
def _parse_channel_params(self, text: str) -> None:
"""Extract frequency, symbol rate, LNB state, and lock from dis output.
The ``dis`` command returns a table-formatted output with
"Parameter" and "Current" columns. We extract key-value pairs
by matching known parameter names.
"""
if not text:
return
# Frequency (kHz)
freq_match = re.search(
r"(?:freq|frequency)\s*[:\|]?\s*(\d+)", text, re.IGNORECASE
)
if freq_match:
self._freq = f"{freq_match.group(1)} kHz"
# Symbol rate
sym_match = re.search(
r"(?:sym(?:bol)?[\s_]*rate|ksps)\s*[:\|]?\s*(\S+)", text, re.IGNORECASE
)
if sym_match:
val = sym_match.group(1)
# May be "blind" for blind scan mode, or a numeric value
if val.lower() in ("blind", "blind_scan", "auto"):
self._symrate = "blind scan"
else:
self._symrate = f"{val} ksps"
# LNB voltage / polarity
lnb_match = re.search(
r"(?:lnb|polarity|lnbdc)\s*[:\|]?\s*(.+?)(?:\r?\n|$)", text, re.IGNORECASE
)
if lnb_match:
raw = lnb_match.group(1).strip()
# Interpret voltage as polarity
if "13" in raw:
self._lnb = "ODU 13V (V-pol)"
elif "18" in raw:
self._lnb = "ODU 18V (H-pol)"
elif raw:
self._lnb = raw
# Lock status
lock_match = re.search(r"lock\s*[:\|]?\s*(\S+)", text, re.IGNORECASE)
if lock_match:
val = lock_match.group(1).upper()
if val in ("1", "YES", "TRUE", "LOCKED"):
self._lock = "YES"
else:
self._lock = "NO"
def _parse_dvb_config(self, text: str) -> None:
"""Extract BCM chip ID, revision, and firmware version from config output.
The ``config`` command returns lines like:
BCM4515 ID 0x4515 Rev B0
FW v113.37
"""
if not text:
return
# BCM chip ID (e.g., "0x4515")
id_match = re.search(r"(?:ID|BCM)\s*(0x[0-9a-fA-F]+)", text)
if id_match:
self._bcm_id = id_match.group(1)
# Revision (e.g., "Rev B0")
rev_match = re.search(r"Rev\s+(\S+)", text, re.IGNORECASE)
if rev_match:
self._bcm_rev = rev_match.group(1)
# Firmware version (e.g., "FW v113.37" or "v113.37")
fw_match = re.search(r"(?:FW\s+)?v(\d+\.\d+)", text)
if fw_match:
self._bcm_fw = f"v{fw_match.group(1)}"
def render(self) -> Text:
result = Text()
label_w = 10
# Frequency
result.append("Freq".ljust(label_w), style="#506878")
result.append(self._freq, style="#c8d0d8")
result.append("\n")
# Symbol rate
result.append("SymRate".ljust(label_w), style="#506878")
result.append(self._symrate, style="#c8d0d8")
result.append("\n")
# LNB state
result.append("LNB".ljust(label_w), style="#506878")
result.append(self._lnb, style="#c8d0d8")
result.append("\n")
# Lock status
result.append("Lock".ljust(label_w), style="#506878")
if self._lock == "YES":
result.append("YES", style="#00e060 bold")
else:
result.append("NO", style="#e04040")
result.append("\n")
# BCM identification
result.append("BCM".ljust(label_w), style="#506878")
result.append(self._bcm_id, style="#00d4aa")
if self._bcm_rev:
result.append(f" Rev {self._bcm_rev}", style="#c8d0d8")
if self._bcm_fw:
result.append(f" FW {self._bcm_fw}", style="#c8d0d8")
return result

View file

@ -0,0 +1,111 @@
"""Status strip -- persistent 1-row connection/position/signal bar."""
from rich.text import Text
from textual.reactive import reactive
from textual.widgets import Static
# Firmware prompt → display color mapping.
_MENU_COLORS: dict[str, str] = {
"TRK>": "#00d4aa",
"MOT>": "#00e060",
"DVB>": "#2080d0",
"NVS>": "#e8a020",
"A3981>": "#00b8c8",
"STEP>": "#40c0a0",
"EE>": "#e8a020",
"OS>": "#8090a0",
"ADC>": "#00b8c8",
"GPIO>": "#40c0a0",
"PEAK>": "#e8c020",
}
class StatusStrip(Static):
"""Persistent status bar showing connection, position, signal, and motor state.
Docked below the header on every screen. Updated by the app-level
position poll and signal monitors.
"""
connected: reactive[bool] = reactive(False)
demo: reactive[bool] = reactive(False)
port: reactive[str] = reactive("/dev/ttyUSB0")
azimuth: reactive[float] = reactive(0.0)
elevation: reactive[float] = reactive(0.0)
rssi: reactive[int] = reactive(-1) # -1 means no data
motor_state: reactive[str] = reactive("IDLE")
fw_menu: reactive[str] = reactive("TRK>")
def render(self) -> Text:
result = Text()
sep = Text(" \u2502 ", style="#1a2a38")
# Connection status
if self.demo:
result.append(" DEMO", style="#e8a020 bold italic")
elif self.connected:
result.append(" CONNECTED", style="#00e060 bold")
result.append(f" {self.port}", style="#506878")
else:
result.append(" OFFLINE", style="#e04040 bold")
result.append_text(sep)
# Position
result.append("AZ ", style="#506878")
result.append(f"{self.azimuth:7.2f}", style="#00d4aa bold")
result.append(" EL ", style="#506878")
result.append(f"{self.elevation:6.2f}", style="#00d4aa bold")
result.append_text(sep)
# Signal (RSSI)
if self.rssi >= 0:
result.append("RSSI ", style="#506878")
result.append(f"{self.rssi}", style="#00b8c8 bold")
else:
result.append("RSSI ", style="#384858")
result.append("---", style="#384858")
result.append_text(sep)
# Motor state
state = self.motor_state
if state == "MOVING":
result.append(state, style="#e8c020 bold")
elif state == "ENGAGED":
result.append(state, style="#00e060")
else:
result.append(state, style="#506878")
result.append_text(sep)
# Firmware context
menu_color = _MENU_COLORS.get(self.fw_menu, "#506878")
result.append(self.fw_menu, style=f"{menu_color}")
return result
def watch_connected(self, _value: bool) -> None:
self.refresh()
def watch_demo(self, _value: bool) -> None:
self.refresh()
def watch_port(self, _value: str) -> None:
self.refresh()
def watch_azimuth(self, _value: float) -> None:
self.refresh()
def watch_elevation(self, _value: float) -> None:
self.refresh()
def watch_rssi(self, _value: int) -> None:
self.refresh()
def watch_motor_state(self, _value: str) -> None:
self.refresh()
def watch_fw_menu(self, _value: str) -> None:
self.refresh()

View file

@ -0,0 +1,157 @@
"""Sweep plot widget -- 1D azimuth-vs-RSSI bar chart for signal sweep visualization."""
from collections import deque
from rich.text import Text
from textual.widgets import Static
# 8-level vertical block characters for bar rendering.
# Index 0 = lowest bar, index 7 = tallest bar.
_BLOCKS = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"
# RSSI color thresholds matching signal_gauge.py / sky_heatmap.py
_THRESHOLDS: list[tuple[float, str]] = [
(500.0, "#2080d0"), # cold -- noise floor
(1000.0, "#00b8c8"), # cool -- weak signal
(2000.0, "#00e060"), # mid -- usable
(3000.0, "#e8c020"), # warm -- strong
(4096.0, "#e04040"), # hot -- saturating
]
# Maximum display width in columns. AZ range is mapped to fit within this.
MAX_DISPLAY_WIDTH = 60
def _rssi_color(rssi: float) -> str:
"""Return the color string for a given RSSI value."""
if rssi <= 0:
return "#1a2a38"
for threshold, color in _THRESHOLDS:
if rssi <= threshold:
return color
return _THRESHOLDS[-1][1]
class SweepPlot(Static):
"""1D vertical bar chart showing RSSI at each AZ position.
X-axis = azimuth positions, Y-axis = RSSI intensity (8-level Unicode blocks).
Similar to a spectrum analyzer display but in angular domain.
Each column represents one azimuth measurement point, rendered as a stacked
block character whose height encodes RSSI strength and whose color encodes
the signal gradient (blue < cyan < green < yellow < red).
Methods:
clear: Reset all measurement data.
add_point: Add an AZ/RSSI measurement point.
set_active: Highlight the current sweep position.
"""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
# Ordered measurement data: (az, rssi) pairs
self._points: deque[tuple[float, float]] = deque(maxlen=MAX_DISPLAY_WIDTH)
self._active_az: float | None = None
def clear(self) -> None:
"""Reset all data and clear the active position."""
self._points.clear()
self._active_az = None
self.refresh()
def add_point(self, az: float, rssi: float) -> None:
"""Add a measurement point and refresh the display.
Args:
az: Azimuth angle in degrees.
rssi: Raw RSSI ADC count (0-4096).
"""
self._points.append((az, rssi))
self.refresh()
def set_active(self, az: float) -> None:
"""Highlight the current sweep position and refresh.
Args:
az: Azimuth angle in degrees of the active scan position.
"""
self._active_az = az
self.refresh()
def render(self) -> Text:
result = Text()
# Title line
result.append("AZ Sweep", style="#506878 bold")
if not self._points:
result.append("\n")
result.append(_BLOCKS[0] * MAX_DISPLAY_WIDTH, style="#1a2a38")
result.append("\n")
result.append("no data", style="#384858")
return result
points = list(self._points)
rssi_values = [rssi for _, rssi in points]
az_values = [az for az, _ in points]
# Find peak
peak_rssi = max(rssi_values)
peak_idx = rssi_values.index(peak_rssi)
peak_az = az_values[peak_idx]
# Normalize RSSI to 0-7 block index range
lo = min(rssi_values)
hi = max(rssi_values)
span = hi - lo
# Render the bar chart row
result.append("\n")
for az, rssi in points:
is_active = self._active_az is not None and abs(az - self._active_az) < 0.05
if is_active:
# Active position: bright white marker
result.append(_BLOCKS[7], style="#ffffff bold")
else:
# Compute block level from RSSI
if span <= 0:
idx = 3
else:
normalized = (rssi - lo) / span
idx = min(int(normalized * 7.999), 7)
color = _rssi_color(rssi)
result.append(_BLOCKS[idx], style=color)
# Pad remaining width with low blocks if fewer points than max width
remaining = MAX_DISPLAY_WIDTH - len(points)
if remaining > 0:
result.append(_BLOCKS[0] * remaining, style="#1a2a38")
# AZ axis labels
result.append("\n")
if len(az_values) >= 2:
az_lo = az_values[0]
az_hi = az_values[-1]
lo_label = f"{az_lo:.1f}\u00b0"
hi_label = f"{az_hi:.1f}\u00b0"
gap = MAX_DISPLAY_WIDTH - len(lo_label) - len(hi_label)
result.append(lo_label, style="#506878")
if gap > 0:
result.append(" " * gap)
result.append(hi_label, style="#506878")
elif len(az_values) == 1:
result.append(f"{az_values[0]:.1f}\u00b0", style="#506878")
# Peak indicator line
result.append("\n")
result.append("Peak at ", style="#506878")
result.append(f"AZ={peak_az:.1f}", style="#00d4aa bold")
result.append(" RSSI=", style="#506878")
result.append(f"{peak_rssi:.0f}", style=_rssi_color(peak_rssi))
# Point count
result.append(f" ({len(points)} pts)", style="#384858")
return result

View file

@ -0,0 +1,195 @@
"""System health panel widget -- compact multi-line diagnostics for the Dashboard."""
import re
from rich.text import Text
from textual.widgets import Static
class SystemHealthPanel(Static):
"""Multi-line styled text showing A3981 diag, firmware ID, motor life.
Populated by calling ``load_data()`` with raw firmware response strings.
Parses and formats hardware diagnostics into a compact, color-coded
summary suitable for the Dashboard overview.
"""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._diag: str = ""
self._torque: str = ""
self._fw_id: str = ""
self._motor_life: str = ""
self._el_limits: dict[str, float] = {"min": 0.0, "max": 0.0}
def load_data(
self,
diag: str = "",
torque: str = "",
fw_id: str = "",
motor_life: str = "",
el_limits: dict[str, float] | None = None,
) -> None:
"""Update all health fields from raw firmware responses and refresh.
Args:
diag: Raw A3981 ``diag`` response (e.g., "AZ DIAG: OK EL DIAG: OK").
torque: Raw A3981 ``st`` response (e.g., "AZ Torq:LOW EL Torq:LOW").
fw_id: Raw OS ``id`` response with NVS version, system ID, chip info.
motor_life: Raw MOT ``life`` response with usage statistics.
el_limits: Parsed EL limits dict with "min" and "max" keys (degrees).
"""
self._diag = diag
self._torque = torque
self._fw_id = fw_id
self._motor_life = motor_life
if el_limits is not None:
self._el_limits = el_limits
self.refresh()
def render(self) -> Text:
result = Text()
label_w = 10
# A3981 diagnostic row
result.append("A3981".ljust(label_w), style="#506878")
az_diag, el_diag = _parse_diag(self._diag)
result.append("AZ ", style="#506878")
result.append(az_diag, style=_diag_style(az_diag))
result.append(" EL ", style="#506878")
result.append(el_diag, style=_diag_style(el_diag))
result.append("\n")
# Torque row
result.append("Torque".ljust(label_w), style="#506878")
az_torque, el_torque = _parse_torque(self._torque)
result.append("AZ ", style="#506878")
result.append(az_torque, style=_torque_style(az_torque))
result.append(" EL ", style="#506878")
result.append(el_torque, style=_torque_style(el_torque))
result.append("\n")
# Firmware identification row
fw_ver, mcu, ant_id = _parse_fw_id(self._fw_id)
result.append("FW".ljust(label_w), style="#506878")
result.append(fw_ver, style="#c8d0d8")
if mcu:
result.append(" MCU: ", style="#506878")
result.append(mcu, style="#c8d0d8")
if ant_id:
result.append(" Ant: ", style="#506878")
result.append(ant_id, style="#c8d0d8")
result.append("\n")
# EL range + motor life row
result.append("EL Range".ljust(label_w), style="#506878")
el_min = self._el_limits.get("min", 0.0)
el_max = self._el_limits.get("max", 0.0)
result.append(f"{el_min:.1f}\u00b0", style="#c8d0d8")
result.append(" \u2013 ", style="#506878")
result.append(f"{el_max:.1f}\u00b0", style="#c8d0d8")
az_life, el_life = _parse_motor_life(self._motor_life)
if az_life or el_life:
result.append(" Life: ", style="#506878")
result.append(f"AZ {az_life}", style="#c8d0d8")
result.append(f" EL {el_life}", style="#c8d0d8")
return result
def _parse_diag(text: str) -> tuple[str, str]:
"""Extract AZ and EL diagnostic status from A3981 diag response."""
az = "---"
el = "---"
if not text:
return az, el
az_match = re.search(r"AZ\s+DIAG:\s*(\w+)", text, re.IGNORECASE)
el_match = re.search(r"EL\s+DIAG:\s*(\w+)", text, re.IGNORECASE)
if az_match:
az = az_match.group(1).upper()
if el_match:
el = el_match.group(1).upper()
return az, el
def _parse_torque(text: str) -> tuple[str, str]:
"""Extract AZ and EL torque levels from A3981 st response."""
az = "---"
el = "---"
if not text:
return az, el
az_match = re.search(r"AZ\s+Torq:(\w+)", text, re.IGNORECASE)
el_match = re.search(r"EL\s+Torq:(\w+)", text, re.IGNORECASE)
if az_match:
az = az_match.group(1).upper()
if el_match:
el = el_match.group(1).upper()
return az, el
def _parse_fw_id(text: str) -> tuple[str, str, str]:
"""Extract firmware version, MCU type, and antenna ID from OS id response.
The ``id`` command returns multi-line output including NVS version,
System ID, and chip details. We extract the most relevant fields.
"""
fw_ver = "---"
mcu = ""
ant_id = ""
if not text:
return fw_ver, mcu, ant_id
# Firmware / NVS version (e.g., "02.02.48" or "NVS Ver: 02.02.48")
ver_match = re.search(r"(\d{2}\.\d{2}\.\d{2,3})", text)
if ver_match:
fw_ver = ver_match.group(1)
# MCU identification (e.g., "K60" or "Kinetis")
if "K60" in text or "Kinetis" in text:
mcu = "K60 96MHz"
# Antenna ID (e.g., "12-IN G2" or "Ant ID")
ant_match = re.search(r"Ant\s+ID\s*[-:]\s*(.+?)(?:\r?\n|$)", text, re.IGNORECASE)
if ant_match:
ant_id = ant_match.group(1).strip()
return fw_ver, mcu, ant_id
def _parse_motor_life(text: str) -> tuple[str, str]:
"""Extract AZ and EL motor life counters from MOT life response."""
az = ""
el = ""
if not text:
return az, el
# Motor life output varies by firmware. Look for numeric counters
# associated with motor 0 (AZ) and motor 1 (EL).
az_match = re.search(r"(?:Motor\s*\[?0\]?|AZ)\D+(\d+)", text, re.IGNORECASE)
el_match = re.search(r"(?:Motor\s*\[?1\]?|EL)\D+(\d+)", text, re.IGNORECASE)
if az_match:
az = az_match.group(1)
if el_match:
el = el_match.group(1)
return az, el
def _diag_style(value: str) -> str:
"""Return Rich style string for a diagnostic status value."""
if value == "OK":
return "#00e060 bold"
if value == "FAULT":
return "#e04040 bold"
return "#506878"
def _torque_style(value: str) -> str:
"""Return Rich style string for a torque level value."""
if value == "HIGH":
return "#e8c020 bold"
if value == "LOW":
return "#c8d0d8"
return "#506878"

View file

@ -0,0 +1,164 @@
"""Tracking panel widget -- rotctld server lifecycle control for satellite tracking."""
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import Container, Horizontal
from textual.message import Message
from textual.reactive import reactive
from textual.widgets import Button, Input, Static
class TrackingPanel(Container):
"""Panel wrapping the RotctldServer lifecycle UI for satellite tracking.
Displays server status, bind address, client info, move statistics,
and leapfrog state. The actual rotctld server is started and managed
by the parent screen -- this widget is purely the control surface.
"""
class StartRequested(Message):
"""Posted when the user presses Start Server."""
def __init__(self, host: str, port: int, min_el: float) -> None:
super().__init__()
self.host = host
self.port = port
self.min_el = min_el
class StopRequested(Message):
"""Posted when the user presses Stop."""
def compose(self) -> ComposeResult:
yield TrackingStatus(id="tracking-status")
with Horizontal(classes="tracking-controls"):
yield Button("Start Server", id="btn-track-start", variant="primary")
yield Button("Stop", id="btn-track-stop")
yield Static(" Bind: ", classes="label")
yield Input(value="127.0.0.1", id="track-host-input")
yield Static(":", classes="label")
yield Input(value="4533", id="track-port-input", type="integer")
yield Static(" Min EL: ", classes="label")
yield Input(value="18.0", id="track-minel-input", type="number")
# ------------------------------------------------------------------
# Status updates (called by parent screen)
# ------------------------------------------------------------------
def set_status(
self,
state: str = "STOPPED",
client: str = "",
moves: int = 0,
rate: float = 0.0,
leapfrog: bool = True,
) -> None:
"""Update the tracking status display.
Args:
state: One of "STOPPED", "LISTENING", "CONNECTED".
client: Client identification string (e.g., "Gpredict").
moves: Total move commands received.
rate: Move command rate in commands per second.
leapfrog: Whether leapfrog predictive compensation is active.
"""
status = self.query_one("#tracking-status", TrackingStatus)
status.state = state
status.client = client
status.moves = moves
status.rate = rate
status.leapfrog = leapfrog
# ------------------------------------------------------------------
# Button handlers
# ------------------------------------------------------------------
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id or ""
if button_id == "btn-track-start":
self._handle_start()
elif button_id == "btn-track-stop":
self._handle_stop()
def _handle_start(self) -> None:
"""Read bind parameters and post a StartRequested message."""
host = self.query_one("#track-host-input", Input).value.strip()
if not host:
host = "127.0.0.1"
try:
port = int(self.query_one("#track-port-input", Input).value)
except ValueError:
self.app.notify("Invalid port number", severity="warning")
return
try:
min_el = float(self.query_one("#track-minel-input", Input).value)
except ValueError:
min_el = 18.0
self.post_message(self.StartRequested(host, port, min_el))
def _handle_stop(self) -> None:
"""Post a StopRequested message."""
self.post_message(self.StopRequested())
class TrackingStatus(Static):
"""Rich text display of rotctld server state and tracking statistics."""
state: reactive[str] = reactive("STOPPED")
client: reactive[str] = reactive("")
moves: reactive[int] = reactive(0)
rate: reactive[float] = reactive(0.0)
leapfrog: reactive[bool] = reactive(True)
def render(self) -> Text:
result = Text()
label_w = 10
# Status row
result.append("Status".ljust(label_w), style="#506878")
if self.state == "CONNECTED":
result.append("CONNECTED", style="#00e060 bold")
elif self.state == "LISTENING":
result.append("LISTENING", style="#e8a020 bold")
else:
result.append("STOPPED", style="#e04040")
result.append("\n")
# Bind address row (shown as part of status context)
result.append("Client".ljust(label_w), style="#506878")
if self.client:
result.append(self.client, style="#c8d0d8")
else:
result.append("(none)", style="#384858")
result.append("\n")
# Move statistics row
result.append("Moves".ljust(label_w), style="#506878")
result.append(f"{self.moves}", style="#c8d0d8")
result.append(" Rate: ", style="#506878")
result.append(f"{self.rate:.1f}/s", style="#c8d0d8")
result.append(" Leapfrog: ", style="#506878")
if self.leapfrog:
result.append("ON", style="#00e060 bold")
else:
result.append("OFF", style="#506878")
return result
def watch_state(self, _value: str) -> None:
self.refresh()
def watch_client(self, _value: str) -> None:
self.refresh()
def watch_moves(self, _value: int) -> None:
self.refresh()
def watch_rate(self, _value: float) -> None:
self.refresh()
def watch_leapfrog(self, _value: bool) -> None:
self.refresh()