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:
parent
e7e71c47d7
commit
145763fcfb
23 changed files with 2796 additions and 865 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
72
tui/src/birdcage_tui/widgets/mode_bar.py
Normal file
72
tui/src/birdcage_tui/widgets/mode_bar.py
Normal 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
|
||||
130
tui/src/birdcage_tui/widgets/motor_tuning.py
Normal file
130
tui/src/birdcage_tui/widgets/motor_tuning.py
Normal 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()
|
||||
72
tui/src/birdcage_tui/widgets/nvs_filter.py
Normal file
72
tui/src/birdcage_tui/widgets/nvs_filter.py
Normal 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
|
||||
216
tui/src/birdcage_tui/widgets/preset_list.py
Normal file
216
tui/src/birdcage_tui/widgets/preset_list.py
Normal 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}")
|
||||
48
tui/src/birdcage_tui/widgets/quick_actions.py
Normal file
48
tui/src/birdcage_tui/widgets/quick_actions.py
Normal 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()
|
||||
156
tui/src/birdcage_tui/widgets/receiver_info.py
Normal file
156
tui/src/birdcage_tui/widgets/receiver_info.py
Normal 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
|
||||
111
tui/src/birdcage_tui/widgets/status_strip.py
Normal file
111
tui/src/birdcage_tui/widgets/status_strip.py
Normal 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()
|
||||
157
tui/src/birdcage_tui/widgets/sweep_plot.py
Normal file
157
tui/src/birdcage_tui/widgets/sweep_plot.py
Normal 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
|
||||
195
tui/src/birdcage_tui/widgets/system_health.py
Normal file
195
tui/src/birdcage_tui/widgets/system_health.py
Normal 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"
|
||||
164
tui/src/birdcage_tui/widgets/tracking_panel.py
Normal file
164
tui/src/birdcage_tui/widgets/tracking_panel.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue