Add Birdcage TUI: 5-screen Textual interface for Carryout G2
F1 Position (compass rose, motor control, sparklines), F2 Signal (RSSI gauge with sub-char precision, DVB/ADC sparklines, LNA toggle), F3 Scan (AZ/EL grid sweep with heatmap and CSV export), F4 System (NVS table, A3981 diagnostics, motor dynamics), F5 Console (raw serial terminal with prompt detection and safety gates). Includes SerialBridge (thread-safe protocol wrapper), DemoDevice (synthetic simulation for --demo mode), dark RF theme with rounded borders and teal accents, and send_raw() on CarryoutG2Protocol.
This commit is contained in:
parent
a70b9b0a29
commit
7271b53c63
23 changed files with 4160 additions and 0 deletions
21
tui/src/birdcage_tui/widgets/__init__.py
Normal file
21
tui/src/birdcage_tui/widgets/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""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.motor_status import MotorStatus
|
||||
from birdcage_tui.widgets.nvs_table import NvsTable
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
"CompassRose",
|
||||
"DeviceStatusBar",
|
||||
"MotorStatus",
|
||||
"NvsTable",
|
||||
"SerialLog",
|
||||
"SignalGauge",
|
||||
"SkyHeatmap",
|
||||
"SparklineWidget",
|
||||
]
|
||||
183
tui/src/birdcage_tui/widgets/compass_rose.py
Normal file
183
tui/src/birdcage_tui/widgets/compass_rose.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"""Compass rose widget — visual AZ/EL position display with Unicode compass dial."""
|
||||
|
||||
from rich.text import Text
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
# Compass grid layout: 11 columns x 7 rows.
|
||||
# Positions indexed [row][col] where (0,0) is top-left.
|
||||
# Cardinal/intercardinal markers are placed at fixed positions.
|
||||
# The pointer occupies one of 16 perimeter slots based on azimuth.
|
||||
|
||||
# 16-slot perimeter positions (clockwise from N=0):
|
||||
# Each entry is (row, col) on the 11x7 grid.
|
||||
_POINTER_SLOTS: list[tuple[int, int]] = [
|
||||
(0, 5), # 0: N
|
||||
(0, 7), # 1: NNE
|
||||
(1, 9), # 2: NE
|
||||
(2, 10), # 3: ENE
|
||||
(3, 10), # 4: E
|
||||
(4, 10), # 5: ESE
|
||||
(5, 9), # 6: SE
|
||||
(6, 7), # 7: SSE
|
||||
(6, 5), # 8: S
|
||||
(6, 3), # 9: SSW
|
||||
(5, 1), # 10: SW
|
||||
(4, 0), # 11: WSW
|
||||
(3, 0), # 12: W
|
||||
(2, 0), # 13: WNW
|
||||
(1, 1), # 14: NW
|
||||
(0, 3), # 15: NNW
|
||||
]
|
||||
|
||||
# Fixed cardinal/intercardinal label positions: (row, col, label)
|
||||
_LABELS: list[tuple[int, int, str]] = [
|
||||
(0, 5, "N"),
|
||||
(3, 10, "E"),
|
||||
(6, 5, "S"),
|
||||
(3, 0, "W"),
|
||||
]
|
||||
|
||||
# Ring structure characters for the compass dial.
|
||||
_RING_CHARS: dict[tuple[int, int], str] = {
|
||||
# Top arc
|
||||
(0, 3): ".",
|
||||
(0, 4): "\u2500",
|
||||
(0, 6): "\u2500",
|
||||
(0, 7): ".",
|
||||
# Upper sides
|
||||
(1, 1): "/",
|
||||
(1, 9): "\\",
|
||||
# Mid-upper sides
|
||||
(2, 0): "\u2502",
|
||||
(2, 10): "\u2502",
|
||||
# Center sides (cardinals placed separately)
|
||||
# (3, 0) and (3, 10) reserved for W/E labels
|
||||
# Lower-mid sides
|
||||
(4, 0): "\u2502",
|
||||
(4, 10): "\u2502",
|
||||
# Lower sides
|
||||
(5, 1): "\\",
|
||||
(5, 9): "/",
|
||||
# Bottom arc
|
||||
(6, 3): "'",
|
||||
(6, 4): "\u2500",
|
||||
(6, 6): "\u2500",
|
||||
(6, 7): "'",
|
||||
}
|
||||
|
||||
|
||||
def _azimuth_to_slot(az: float) -> int:
|
||||
"""Map azimuth (0-360, 0=N clockwise) to one of 16 perimeter slots."""
|
||||
normalized = az % 360.0
|
||||
slot = round(normalized / 22.5) % 16
|
||||
return slot
|
||||
|
||||
|
||||
class CompassRose(Static):
|
||||
"""Visual compass display showing azimuth/elevation position."""
|
||||
|
||||
azimuth: reactive[float] = reactive(180.0)
|
||||
elevation: reactive[float] = reactive(45.0)
|
||||
|
||||
def render(self) -> Text:
|
||||
result = Text()
|
||||
|
||||
# Large numeric readout
|
||||
az_label = Text("AZ ", style="#506878 bold")
|
||||
az_value = Text(f"{self.azimuth:7.2f}\u00b0", style="#00d4aa bold")
|
||||
el_label = Text(" EL ", style="#506878 bold")
|
||||
el_value = Text(f"{self.elevation:6.2f}\u00b0", style="#00d4aa bold")
|
||||
|
||||
result.append(az_label)
|
||||
result.append(az_value)
|
||||
result.append(el_label)
|
||||
result.append(el_value)
|
||||
result.append("\n\n")
|
||||
|
||||
# Build the 7x11 compass grid
|
||||
grid: list[list[tuple[str, str]]] = [
|
||||
[(" ", "#0e1420") for _ in range(11)] for _ in range(7)
|
||||
]
|
||||
|
||||
# Place ring structure
|
||||
for (r, c), ch in _RING_CHARS.items():
|
||||
grid[r][c] = (ch, "#c8d0d8")
|
||||
|
||||
# Place cardinal labels
|
||||
for r, c, label in _LABELS:
|
||||
grid[r][c] = (label, "#506878 bold")
|
||||
|
||||
# Center crosshair
|
||||
grid[3][5] = ("\u253c", "#1a2a38")
|
||||
grid[3][4] = ("\u2500", "#1a2a38")
|
||||
grid[3][6] = ("\u2500", "#1a2a38")
|
||||
grid[2][5] = ("\u2502", "#1a2a38")
|
||||
grid[4][5] = ("\u2502", "#1a2a38")
|
||||
|
||||
# Place pointer at azimuth position
|
||||
slot = _azimuth_to_slot(self.azimuth)
|
||||
pr, pc = _POINTER_SLOTS[slot]
|
||||
# Use a filled diamond for the pointer
|
||||
grid[pr][pc] = ("\u25c6", "#00d4aa bold")
|
||||
|
||||
# Compute a line from center toward the pointer direction for visual clarity
|
||||
# Place a dot at an intermediate position between center (3,5) and pointer
|
||||
cr, cc = 3, 5
|
||||
dr = pr - cr
|
||||
dc = pc - cc
|
||||
if abs(dr) > 1 or abs(dc) > 1:
|
||||
mr = cr + (1 if dr > 0 else (-1 if dr < 0 else 0))
|
||||
mc = cc + (1 if dc > 0 else (-1 if dc < 0 else 0))
|
||||
# Only place intermediate dot if it doesn't overwrite a label
|
||||
existing_ch = grid[mr][mc][0]
|
||||
if existing_ch in (" ", "\u2500", "\u2502", "\u253c"):
|
||||
grid[mr][mc] = ("\u2022", "#00d4aa")
|
||||
|
||||
# Render grid to text
|
||||
for row_idx, row in enumerate(grid):
|
||||
for _col_idx, (ch, style) in enumerate(row):
|
||||
result.append(ch, style=style)
|
||||
if row_idx < 6:
|
||||
result.append("\n")
|
||||
|
||||
# Bearing line below compass
|
||||
bearing = self.azimuth % 360.0
|
||||
if bearing < 0:
|
||||
bearing += 360.0
|
||||
cardinal = _bearing_to_cardinal(bearing)
|
||||
result.append("\n")
|
||||
result.append(f" {cardinal:>5s}", style="#506878")
|
||||
result.append(f" {bearing:05.1f}\u00b0", style="#c8d0d8")
|
||||
|
||||
return result
|
||||
|
||||
def watch_azimuth(self, _value: float) -> None:
|
||||
self.refresh()
|
||||
|
||||
def watch_elevation(self, _value: float) -> None:
|
||||
self.refresh()
|
||||
|
||||
|
||||
def _bearing_to_cardinal(bearing: float) -> str:
|
||||
"""Convert bearing in degrees to 16-point cardinal abbreviation."""
|
||||
directions = [
|
||||
"N",
|
||||
"NNE",
|
||||
"NE",
|
||||
"ENE",
|
||||
"E",
|
||||
"ESE",
|
||||
"SE",
|
||||
"SSE",
|
||||
"S",
|
||||
"SSW",
|
||||
"SW",
|
||||
"WSW",
|
||||
"W",
|
||||
"WNW",
|
||||
"NW",
|
||||
"NNW",
|
||||
]
|
||||
idx = round(bearing / 22.5) % 16
|
||||
return directions[idx]
|
||||
92
tui/src/birdcage_tui/widgets/device_status_bar.py
Normal file
92
tui/src/birdcage_tui/widgets/device_status_bar.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""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
|
||||
76
tui/src/birdcage_tui/widgets/motor_status.py
Normal file
76
tui/src/birdcage_tui/widgets/motor_status.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Motor status widget — engagement state, torque, step counts, and EL range."""
|
||||
|
||||
from rich.text import Text
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class MotorStatus(Static):
|
||||
"""Panel showing motor engagement state, torque, and step counts."""
|
||||
|
||||
engaged: reactive[bool] = reactive(False)
|
||||
az_torque: reactive[str] = reactive("LOW")
|
||||
el_torque: reactive[str] = reactive("LOW")
|
||||
az_steps: reactive[int] = reactive(0)
|
||||
el_steps: reactive[int] = reactive(0)
|
||||
el_min: reactive[float] = reactive(18.0)
|
||||
el_max: reactive[float] = reactive(65.0)
|
||||
|
||||
def render(self) -> Text:
|
||||
result = Text()
|
||||
label_w = 10
|
||||
|
||||
# Engaged row
|
||||
result.append("Engaged".ljust(label_w), style="#506878")
|
||||
if self.engaged:
|
||||
result.append("YES", style="#00e060 bold")
|
||||
else:
|
||||
result.append("NO", style="#e04040")
|
||||
result.append("\n")
|
||||
|
||||
# Torque row
|
||||
result.append("Torque".ljust(label_w), style="#506878")
|
||||
result.append("AZ: ", style="#506878")
|
||||
az_style = "#e8c020 bold" if self.az_torque == "HIGH" else "#c8d0d8"
|
||||
result.append(f"{self.az_torque}", style=az_style)
|
||||
result.append(" EL: ", style="#506878")
|
||||
el_style = "#e8c020 bold" if self.el_torque == "HIGH" else "#c8d0d8"
|
||||
result.append(f"{self.el_torque}", style=el_style)
|
||||
result.append("\n")
|
||||
|
||||
# Steps row
|
||||
result.append("Steps".ljust(label_w), style="#506878")
|
||||
result.append("AZ: ", style="#506878")
|
||||
result.append(f"{self.az_steps}", style="#c8d0d8")
|
||||
result.append(" EL: ", style="#506878")
|
||||
result.append(f"{self.el_steps}", style="#c8d0d8")
|
||||
result.append("\n")
|
||||
|
||||
# EL Range row
|
||||
result.append("EL Range".ljust(label_w), style="#506878")
|
||||
result.append(f"{self.el_min:.1f}\u00b0", style="#c8d0d8")
|
||||
result.append(" \u2013 ", style="#506878")
|
||||
result.append(f"{self.el_max:.1f}\u00b0", style="#c8d0d8")
|
||||
|
||||
return result
|
||||
|
||||
def watch_engaged(self, _value: bool) -> None:
|
||||
self.refresh()
|
||||
|
||||
def watch_az_torque(self, _value: str) -> None:
|
||||
self.refresh()
|
||||
|
||||
def watch_el_torque(self, _value: str) -> None:
|
||||
self.refresh()
|
||||
|
||||
def watch_az_steps(self, _value: int) -> None:
|
||||
self.refresh()
|
||||
|
||||
def watch_el_steps(self, _value: int) -> None:
|
||||
self.refresh()
|
||||
|
||||
def watch_el_min(self, _value: float) -> None:
|
||||
self.refresh()
|
||||
|
||||
def watch_el_max(self, _value: float) -> None:
|
||||
self.refresh()
|
||||
93
tui/src/birdcage_tui/widgets/nvs_table.py
Normal file
93
tui/src/birdcage_tui/widgets/nvs_table.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""NVS table widget — DataTable wrapper for non-volatile storage dump display."""
|
||||
|
||||
import re
|
||||
|
||||
from textual.widgets import DataTable
|
||||
|
||||
# Regex to parse NVS dump lines.
|
||||
# Examples:
|
||||
# 0) Log ID's 0x00000007 0x00000007 0x00000007
|
||||
# 20) Disable Tracker Proc? TRUE TRUE FALSE
|
||||
# 101) Minimum Elevation Angle 18.00 18.00 18.00
|
||||
_NVS_LINE_RE = re.compile(
|
||||
r"^\s*(\d+)\)\s+" # index with closing paren
|
||||
r"(.+?)\s{2,}" # name (greedy until 2+ spaces)
|
||||
r"(\S+)\s+" # current value
|
||||
r"(\S+)\s+" # saved value
|
||||
r"(\S+)\s*$" # default value
|
||||
)
|
||||
|
||||
|
||||
class NvsTable(DataTable):
|
||||
"""DataTable displaying NVS (non-volatile storage) dump data."""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._parsed_rows: list[dict[str, str]] = []
|
||||
self._columns_added = False
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Add columns when the widget is mounted."""
|
||||
if not self._columns_added:
|
||||
self.add_columns("Idx", "Name", "Current", "Saved", "Default")
|
||||
self._columns_added = True
|
||||
|
||||
def load_nvs(self, text: str) -> list[dict[str, str]]:
|
||||
"""Parse NVS dump text and populate the table.
|
||||
|
||||
Returns a list of dicts with keys: idx, name, current, saved, default.
|
||||
Rows where current != default are marked for the screen to highlight.
|
||||
"""
|
||||
self.clear_table()
|
||||
self._parsed_rows = []
|
||||
|
||||
for line in text.splitlines():
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
match = _NVS_LINE_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
idx = match.group(1)
|
||||
name = match.group(2).strip()
|
||||
current = match.group(3)
|
||||
saved = match.group(4)
|
||||
default = match.group(5)
|
||||
|
||||
row_data = {
|
||||
"idx": idx,
|
||||
"name": name,
|
||||
"current": current,
|
||||
"saved": saved,
|
||||
"default": default,
|
||||
}
|
||||
self._parsed_rows.append(row_data)
|
||||
|
||||
# Add row to the DataTable
|
||||
modified = current != default
|
||||
# Prefix the index cell to signal modification to the screen.
|
||||
# The screen's CSS rule .nvs-modified handles styling.
|
||||
label = f"*{idx}" if modified else idx
|
||||
|
||||
self.add_row(label, name, current, saved, default, key=f"nvs-{idx}")
|
||||
|
||||
return self._parsed_rows
|
||||
|
||||
def clear_table(self) -> None:
|
||||
"""Remove all rows from the table."""
|
||||
self.clear()
|
||||
self._parsed_rows = []
|
||||
|
||||
@property
|
||||
def parsed_rows(self) -> list[dict[str, str]]:
|
||||
"""Access the most recently parsed NVS data."""
|
||||
return list(self._parsed_rows)
|
||||
|
||||
@property
|
||||
def modified_indices(self) -> list[str]:
|
||||
"""Return indices where current value differs from default."""
|
||||
return [
|
||||
row["idx"] for row in self._parsed_rows if row["current"] != row["default"]
|
||||
]
|
||||
84
tui/src/birdcage_tui/widgets/serial_log.py
Normal file
84
tui/src/birdcage_tui/widgets/serial_log.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Serial log widget — RichLog with color-coded firmware console prompts."""
|
||||
|
||||
import re
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import RichLog
|
||||
|
||||
# Prompt patterns and their colors, ordered by specificity (longest match first).
|
||||
_PROMPT_STYLES: list[tuple[str, str]] = [
|
||||
("A3981>", "#00b8c8"),
|
||||
("STEP>", "#40c0a0"),
|
||||
("TRK>", "#00d4aa"),
|
||||
("MOT>", "#00e060"),
|
||||
("DVB>", "#2080d0"),
|
||||
("NVS>", "#e8a020"),
|
||||
("EE>", "#e8a020"),
|
||||
("OS>", "#8090a0"),
|
||||
("ADC>", "#00b8c8"),
|
||||
("GPIO>", "#40c0a0"),
|
||||
("PEAK>", "#e8c020"),
|
||||
("LATLON>", "#506878"),
|
||||
("DIPSWITCH>", "#506878"),
|
||||
]
|
||||
|
||||
# Build a regex that matches any known prompt at any position in the text.
|
||||
_PROMPT_PATTERN = re.compile(
|
||||
r"(" + "|".join(re.escape(p) for p, _ in _PROMPT_STYLES) + r")"
|
||||
)
|
||||
|
||||
# Lookup dict for color by prompt string
|
||||
_PROMPT_COLOR: dict[str, str] = {p: c for p, c in _PROMPT_STYLES}
|
||||
|
||||
|
||||
class SerialLog(RichLog):
|
||||
"""RichLog that color-codes Winegard firmware console prompts."""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(markup=False, wrap=True, **kwargs)
|
||||
|
||||
def append_output(self, text: str) -> None:
|
||||
"""Append firmware output with color-coded prompts.
|
||||
|
||||
Each line is scanned for known prompt strings (TRK>, MOT>, etc.)
|
||||
which are rendered in their assigned color. All other text uses
|
||||
the default terminal color.
|
||||
"""
|
||||
for line in text.splitlines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
styled = _colorize_line(line)
|
||||
self.write(styled)
|
||||
|
||||
def append_command(self, cmd: str) -> None:
|
||||
"""Append a user-issued command, formatted with a prompt indicator."""
|
||||
styled = Text()
|
||||
styled.append("> ", style="#00d4aa bold")
|
||||
styled.append(cmd, style="#00d4aa")
|
||||
self.write(styled)
|
||||
|
||||
|
||||
def _colorize_line(line: str) -> Text:
|
||||
"""Parse a single line and return a Rich Text with colored prompt spans."""
|
||||
result = Text()
|
||||
last_end = 0
|
||||
|
||||
for match in _PROMPT_PATTERN.finditer(line):
|
||||
start, end = match.span()
|
||||
prompt_str = match.group(1)
|
||||
color = _PROMPT_COLOR[prompt_str]
|
||||
|
||||
# Text before the prompt
|
||||
if start > last_end:
|
||||
result.append(line[last_end:start], style="#c8d0d8")
|
||||
|
||||
# The prompt itself
|
||||
result.append(prompt_str, style=f"{color} bold")
|
||||
last_end = end
|
||||
|
||||
# Remaining text after last prompt
|
||||
if last_end < len(line):
|
||||
result.append(line[last_end:], style="#c8d0d8")
|
||||
|
||||
return result
|
||||
90
tui/src/birdcage_tui/widgets/signal_gauge.py
Normal file
90
tui/src/birdcage_tui/widgets/signal_gauge.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Signal gauge widget — horizontal RSSI bar with color-coded thresholds."""
|
||||
|
||||
from rich.text import Text
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
# RSSI color thresholds (upper bound, color)
|
||||
_THRESHOLDS: list[tuple[int, str]] = [
|
||||
(500, "#2080d0"), # cold — noise floor
|
||||
(1000, "#00b8c8"), # cool — weak signal
|
||||
(2000, "#00e060"), # mid — usable
|
||||
(3000, "#e8c020"), # warm — strong
|
||||
(4096, "#e04040"), # hot — saturating
|
||||
]
|
||||
|
||||
BAR_WIDTH = 40
|
||||
MAX_RSSI = 4096
|
||||
|
||||
# Sub-character bar fragments for smooth rendering (8 levels per cell)
|
||||
_BAR_CHARS = " ▏▎▍▌▋▊▉"
|
||||
_FULL = "\u2588" # █
|
||||
|
||||
|
||||
def _rssi_color(rssi: int) -> str:
|
||||
"""Return the color string for a given RSSI value."""
|
||||
for threshold, color in _THRESHOLDS:
|
||||
if rssi <= threshold:
|
||||
return color
|
||||
return _THRESHOLDS[-1][1]
|
||||
|
||||
|
||||
class SignalGauge(Static):
|
||||
"""Horizontal RSSI signal strength bar gauge."""
|
||||
|
||||
rssi_avg: reactive[int] = reactive(0)
|
||||
rssi_cur: reactive[int] = reactive(0)
|
||||
reads: reactive[int] = reactive(0)
|
||||
|
||||
def render(self) -> Text:
|
||||
result = Text()
|
||||
|
||||
# Title
|
||||
result.append("RSSI", style="#506878 bold")
|
||||
result.append("\n")
|
||||
|
||||
# Compute fill with sub-character precision (8 levels per cell = 320 positions)
|
||||
clamped = max(0, min(self.rssi_cur, MAX_RSSI))
|
||||
fill_frac = clamped / MAX_RSSI * BAR_WIDTH
|
||||
full_cells = int(fill_frac)
|
||||
partial = fill_frac - full_cells
|
||||
partial_idx = int(partial * 8)
|
||||
|
||||
# Build the bar with per-character color based on position thresholds
|
||||
for i in range(full_cells):
|
||||
pos_rssi = round((i + 0.5) / BAR_WIDTH * MAX_RSSI)
|
||||
color = _rssi_color(pos_rssi)
|
||||
result.append(_FULL, style=color)
|
||||
|
||||
# Partial sub-character cell
|
||||
remaining = BAR_WIDTH - full_cells
|
||||
if remaining > 0 and partial_idx > 0:
|
||||
pos_rssi = round((full_cells + 0.5) / BAR_WIDTH * MAX_RSSI)
|
||||
color = _rssi_color(pos_rssi)
|
||||
result.append(_BAR_CHARS[partial_idx], style=color)
|
||||
remaining -= 1
|
||||
|
||||
result.append("\u2591" * remaining, style="#1a2a38")
|
||||
|
||||
# Numeric value at end of bar
|
||||
result.append(f" {self.rssi_cur}", style=_rssi_color(self.rssi_cur))
|
||||
result.append("\n")
|
||||
|
||||
# Label line
|
||||
result.append("avg: ", style="#506878")
|
||||
result.append(f"{self.rssi_avg}", style="#c8d0d8")
|
||||
result.append(" cur: ", style="#506878")
|
||||
result.append(f"{self.rssi_cur}", style="#c8d0d8")
|
||||
result.append(" reads: ", style="#506878")
|
||||
result.append(f"{self.reads}", style="#c8d0d8")
|
||||
|
||||
return result
|
||||
|
||||
def watch_rssi_avg(self, _value: int) -> None:
|
||||
self.refresh()
|
||||
|
||||
def watch_rssi_cur(self, _value: int) -> None:
|
||||
self.refresh()
|
||||
|
||||
def watch_reads(self, _value: int) -> None:
|
||||
self.refresh()
|
||||
123
tui/src/birdcage_tui/widgets/sky_heatmap.py
Normal file
123
tui/src/birdcage_tui/widgets/sky_heatmap.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Sky heatmap widget — 2D AZ x EL grid colored by RSSI for sky scan visualization."""
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
# RSSI color thresholds matching signal_gauge.py
|
||||
_THRESHOLDS: list[tuple[float, str]] = [
|
||||
(500.0, "#2080d0"),
|
||||
(1000.0, "#00b8c8"),
|
||||
(2000.0, "#00e060"),
|
||||
(3000.0, "#e8c020"),
|
||||
(4096.0, "#e04040"),
|
||||
]
|
||||
|
||||
_ZERO_COLOR = "#0e1420"
|
||||
|
||||
|
||||
def _rssi_color(rssi: float) -> str:
|
||||
"""Return the color string for a given RSSI value."""
|
||||
if rssi <= 0:
|
||||
return _ZERO_COLOR
|
||||
for threshold, color in _THRESHOLDS:
|
||||
if rssi <= threshold:
|
||||
return color
|
||||
return _THRESHOLDS[-1][1]
|
||||
|
||||
|
||||
class SkyHeatmap(Static):
|
||||
"""2D azimuth x elevation grid colored by RSSI for sky scan visualization."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
az_bins: int = 40,
|
||||
el_bins: int = 10,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._az_bins = az_bins
|
||||
self._el_bins = el_bins
|
||||
self._grid: list[list[float]] = [[0.0] * az_bins for _ in range(el_bins)]
|
||||
self._active_az: int | None = None
|
||||
self._active_el: int | None = None
|
||||
|
||||
def set_point(self, az_idx: int, el_idx: int, rssi: float) -> None:
|
||||
"""Set RSSI value at a grid cell. Does not refresh — call refresh() explicitly
|
||||
or batch updates and refresh once."""
|
||||
if 0 <= el_idx < self._el_bins and 0 <= az_idx < self._az_bins:
|
||||
self._grid[el_idx][az_idx] = rssi
|
||||
|
||||
def set_active(self, az_idx: int, el_idx: int) -> None:
|
||||
"""Highlight the current scan position and refresh."""
|
||||
self._active_az = az_idx
|
||||
self._active_el = el_idx
|
||||
self.refresh()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Reset all RSSI values to zero and clear active position."""
|
||||
for row in self._grid:
|
||||
for i in range(len(row)):
|
||||
row[i] = 0.0
|
||||
self._active_az = None
|
||||
self._active_el = None
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> Text:
|
||||
result = Text()
|
||||
|
||||
# Column header: AZ labels (every 5 bins)
|
||||
# Left gutter for EL labels
|
||||
gutter = 5
|
||||
result.append(" " * gutter, style="#0e1420")
|
||||
for az in range(self._az_bins):
|
||||
if az % 5 == 0:
|
||||
label = str(az)
|
||||
result.append(label, style="#506878")
|
||||
# Pad to maintain 1-char-per-bin spacing
|
||||
pad = 1 - len(label)
|
||||
if pad > 0:
|
||||
result.append(" " * pad)
|
||||
else:
|
||||
result.append(" ")
|
||||
result.append("\n")
|
||||
|
||||
# Grid rows: highest EL at top
|
||||
for el_idx in range(self._el_bins - 1, -1, -1):
|
||||
# EL label
|
||||
el_label = f"{el_idx:>3d} "
|
||||
result.append(el_label, style="#506878")
|
||||
result.append("\u2502", style="#1a2a38")
|
||||
|
||||
for az_idx in range(self._az_bins):
|
||||
rssi = self._grid[el_idx][az_idx]
|
||||
is_active = az_idx == self._active_az and el_idx == self._active_el
|
||||
|
||||
if is_active:
|
||||
# Active scan position: bright white on dark background
|
||||
result.append("\u2588", style="bold #ffffff on #1a2a38")
|
||||
elif rssi <= 0:
|
||||
# Empty cell
|
||||
result.append("\u2591", style="#0e1420")
|
||||
else:
|
||||
color = _rssi_color(rssi)
|
||||
# Use denser block for higher RSSI
|
||||
ch = "\u2593" if rssi < 500 else "\u2588"
|
||||
result.append(ch, style=color)
|
||||
|
||||
if el_idx > 0:
|
||||
result.append("\n")
|
||||
|
||||
# Bottom border
|
||||
result.append("\n")
|
||||
result.append(" " * gutter, style="#0e1420")
|
||||
result.append("\u2500" * self._az_bins, style="#1a2a38")
|
||||
|
||||
return result
|
||||
|
||||
@property
|
||||
def az_bins(self) -> int:
|
||||
return self._az_bins
|
||||
|
||||
@property
|
||||
def el_bins(self) -> int:
|
||||
return self._el_bins
|
||||
74
tui/src/birdcage_tui/widgets/sparkline_widget.py
Normal file
74
tui/src/birdcage_tui/widgets/sparkline_widget.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Sparkline widget — rolling time series using Unicode block characters."""
|
||||
|
||||
from collections import deque
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
# 8-level vertical block characters for sparkline rendering.
|
||||
# Index 0 = lowest bar, index 7 = tallest bar.
|
||||
_BLOCKS = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"
|
||||
|
||||
|
||||
class SparklineWidget(Static):
|
||||
"""Rolling sparkline time series display."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_points: int = 60,
|
||||
label: str = "",
|
||||
color: str = "#00d4aa",
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._max_points = max_points
|
||||
self._label = label
|
||||
self._color = color
|
||||
self._buffer: deque[float] = deque(maxlen=max_points)
|
||||
|
||||
def push(self, value: float) -> None:
|
||||
"""Add a data point to the sparkline buffer and refresh."""
|
||||
self._buffer.append(value)
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> Text:
|
||||
result = Text()
|
||||
|
||||
# Label prefix
|
||||
if self._label:
|
||||
result.append(f"{self._label} ", style="#506878")
|
||||
|
||||
if not self._buffer:
|
||||
result.append("\u2581" * self._max_points, style="#1a2a38")
|
||||
return result
|
||||
|
||||
values = list(self._buffer)
|
||||
lo = min(values)
|
||||
hi = max(values)
|
||||
span = hi - lo
|
||||
|
||||
for v in values:
|
||||
if span <= 0:
|
||||
# All values identical — render as mid-level
|
||||
idx = 3
|
||||
else:
|
||||
normalized = (v - lo) / span
|
||||
idx = min(int(normalized * 7.999), 7)
|
||||
result.append(_BLOCKS[idx], style=self._color)
|
||||
|
||||
# Pad remaining width with low blocks if buffer not full
|
||||
remaining = self._max_points - len(values)
|
||||
if remaining > 0:
|
||||
result.append(_BLOCKS[0] * remaining, style="#1a2a38")
|
||||
|
||||
# Min/max annotation
|
||||
result.append("\n")
|
||||
if self._label:
|
||||
result.append(" " * (len(self._label) + 1))
|
||||
result.append(f"{lo:.0f}", style="#506878")
|
||||
gap = self._max_points - len(f"{lo:.0f}") - len(f"{hi:.0f}")
|
||||
if gap > 0:
|
||||
result.append(" " * gap)
|
||||
result.append(f"{hi:.0f}", style="#506878")
|
||||
|
||||
return result
|
||||
Loading…
Add table
Add a link
Reference in a new issue