Ryan Malloy 7271b53c63 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.
2026-02-13 08:53:03 -07:00

632 lines
24 KiB
Python

"""Synthetic demo device for the Birdcage TUI.
Drop-in replacement for SerialBridge that simulates a Winegard Carryout G2
dish with motor movement, RSSI signal modeling, and canned firmware responses.
No serial hardware required.
"""
import contextlib
import math
import random
import time
from enum import Enum, auto
class _DemoMenu(Enum):
"""Simulated firmware submenu states."""
ROOT = auto()
MOT = auto()
DVB = auto()
NVS = auto()
A3981 = auto()
ADC = auto()
OS = auto()
STEP = auto()
PEAK = auto()
EEPROM = auto()
GPIO = auto()
LATLON = auto()
DIPSWITCH = auto()
# Complete NVS dump text from firmware 02.02.48 (captured 2026-02-12).
_NVS_DUMP_TEXT = """\
Num Name Current Saved Default
---- -------------------------- ---------- ---------- ----------
0) Log ID's 0x00000007 0x00000007 0x00000007
1) Log Device 0x00000001 0x00000001 0x00000001
2) Debug 2nd Console Port 0 0 0
3) Debug 2nd Packet Port 0 0 0
4) Debug Port Connection 0 0 0
16) Pitch Deadband 0.00 0.00 0.00
17) Roll Deadband 0.00 0.00 0.00
18) Yaw Deadband 0.00 0.00 0.00
20) Disable Tracker Proc? TRUE TRUE FALSE
21) Tracker Proc Run Mode 0 0 0
22) Conical Alpha Az 200 200 200
23) Conical Alpha El 200 200 200
24) Conical Radius 1.00 1.00 1.00
25) Conical Count Max 20 20 20
26) Conical Test Drift +0 +0 +0
27) Circle RPM 120 120 120
28) Circle Pts/Rev 6 6 6
32) Conical Az Clamp 8.00 8.00 8.00
33) Conical El Clamp 8.00 8.00 8.00
35) Motor Pts/Rev 72 72 72
36) Circle Az Radius 1.00 1.00 1.00
37) Circle El Radius 1.00 1.00 1.00
38) Sleep Mode Timer Secs 420 420 420
40) Motor Type 0 0 0
41) Satellite Scan Velocity 55.00 55.00 55.00
48) Motor Spiral Velocity 55.00 55.00 55.00
49) Motor Gear Ratio 0x00000000 0x00000000 0x00000000
63) GPS Heading Threshold 1.00 1.00 1.00
64) GPS Moving Threshold 5.00 MPH 5.00 MPH 5.00 MPH
66) Spiral Signal In A Row Min +3 +3 +3
67) Spiral Signal In A Row Max +20 +20 +20
68) Signal Odd to Even Offset +0 +0 +0
69) Signal Offset 80 80 80
70) Signal Baseline Angle 65.00 65.00 65.00
71) Signal Re-Peak Degrade Percent 25 25 25
72) Gyro Sensitivity +1110 +1110 +1110
73) Gyro Filter Size +1 +1 +1
74) Gyro Calib Readings 100 100 100
75) Gyro Mount Type 1 1 1
76) Gyro Velocity Offset 4 4 4
77) Gyro Max Accel 600 600 600
80) AZ Max Vel 65.00 65.00 65.00
81) AZ Max Accel 400.00 400.00 400.00
82) AZ Home Velocity 55.00 55.00 55.00
83) AZ Steps/Rev 40000 40000 40000
84) AZ Direction +1 +1 +1
85) EL Max Vel 45.00 45.00 45.00
86) EL Max Accel 400.00 400.00 400.00
87) EL Home Velocity 45.00 45.00 45.00
88) EL Steps/Rev 24960 24960 24960
89) EL Direction +1 +1 +1
95) AZ Low current limit 0x0000ff0c 0x0000ff0c 0x0000ff0c
96) AZ High current limit 0x0000ff30 0x0000ff30 0x0000ff30
97) EL Low current limit 0x0000ff0c 0x0000ff0c 0x0000ff0c
98) EL High current limit 0x0000ff40 0x0000ff40 0x0000ff40
101) Minimum Elevation Angle 18.00 18.00 18.00
102) Maximum Elevation Angle 65.00 65.00 65.00
103) Elevation Home Angle 65.00 65.00 65.00
106) Az Stall Detect 78 78 78
107) El Stall Detect 75 75 75
108) Az Stall Samples 100 100 100
109) El Stall Samples 100 100 100
110) EL Home Current Limit 0x0000ff28 0x0000ff28 0x0000ff28
111) AZ Home Current Limit 0x0000ff40 0x0000ff40 0x0000ff40
112) Disable Dipswitch? FALSE FALSE FALSE
113) Dipswitch Value 101 101 101
114) Dipswitch Front/Rear Mount 0 0 0
115) Mount Offset Angle +0 +0 +0
118) Signal Use LNB Clamp FALSE FALSE FALSE
128) AZ PID Kp +600 +600 +600
129) AZ PID Kv +60 +60 +60
130) AZ PID Ki +1 +1 +1
131) EL PID Kp +250 +250 +250
132) EL PID Kv +50 +50 +50
133) EL PID Ki +1 +1 +1
136) AZ PWM Stall Cnt 6 6 6
137) EL PWM Stall Cnt 5 5 5
143) Tracking Number 0 0 0"""
# Parse NVS lines into a dict keyed by index for nvs_read().
_NVS_LINES: dict[int, str] = {}
for _line in _NVS_DUMP_TEXT.splitlines():
_line_stripped = _line.strip()
if _line_stripped and _line_stripped[0].isdigit():
_idx_str = _line_stripped.split(")")[0].strip()
with contextlib.suppress(ValueError):
_NVS_LINES[int(_idx_str)] = _line_stripped
# Firmware identification text matching ``os > id`` output.
_FIRMWARE_ID = """\
NVS Version: 1.02.13
System ID: TWELINCH
K60-144pin
Silicon Rev 2.4
Mask Set 4N22D
512 kBytes of P-flash
P-flash only
128 kBytes of RAM
Board Rev ID: A
Board ID: STATIONARY
Ant ID: 12-IN G2
Software version: 02.02.48
CCLK: 96000000
BCLK: 48000000
Flash Base Address: 65536
Flash Size: 458752"""
_DVB_CONFIG = """\
BCM Hardware= ID: 0x4515 VER: 0xB0
BCM Firmware= MAJOR VER: 0x71 (113) MINOR VER: 0x25 (37)
BCM Strap Config: 0x25018"""
_CHANNEL_PARAMS = """\
Power Mode: ON
Search Transponders: ON
Auto Search Mode: 1
Shuffle Mode: ON
Frequency List: Non-Stacked
Num Parameter Current Default
1 Frequency 1090640 (kHz) 974000 (kHz)
2 Symbol Rate 0 (PeakScanEnabled) 20000 (ksps)
3 Trans_Mod_CRate blind_scan blind_scan
4 Blind Scan Mode ___trb_dvb_dss_____ ___trb_dvb_dss_____
5 LNB Polarity ODU:13V ---
6 LNB Tone (ODU) off off
7 Roll-off 0.35 0.35
8 LPF Cutoff 0 (auto) 0 (MHz)
9 Carrier Offset 0 (kHz) 0 (kHz)
10 FreqSearchRange 5000 (kHz) 5000 (kHz)
11 DCII Mode dcii_qpsk_comb dcii_qpsk_comb
12 Spectral Inv scan scan
13 PScnSymRtRngMin 18000 (ksps) 18000 (ksps)
14 PScnSymRtRngMax 24000 (ksps) 24000 (ksps)
15 SignalDetectMode off off"""
_MOTOR_LIFE = """\
AZ total moves: 847
AZ total degrees: 52340.50
EL total moves: 423
EL total degrees: 18920.75
Uptime hours: 312.4"""
# Simulated satellite at AZ=200, EL=38 for RSSI modeling.
_SAT_AZ = 200.0
_SAT_EL = 38.0
_RSSI_NOISE_FLOOR = 500
_RSSI_PEAK = 2000
_RSSI_BEAM_WIDTH = 50.0 # Gaussian denominator (degrees squared)
# Motor simulation speed (degrees per second).
_MOTOR_SPEED = 10.0
class DemoDevice:
"""Synthetic demo device implementing the same interface as SerialBridge.
Simulates a Carryout G2 dish with motor movement, RSSI signal modeling,
and canned firmware responses. No serial hardware required.
"""
def __init__(self) -> None:
self._connected = False
self._engaged = True
# Current position and movement targets.
self._az = 180.0
self._el = 45.0
self._target_az = 180.0
self._target_el = 45.0
self._last_move_time = time.monotonic()
# Submenu tracking for console simulation.
self._menu = _DemoMenu.ROOT
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _update_position(self) -> None:
"""Interpolate position toward target at ~10 deg/s."""
now = time.monotonic()
dt = now - self._last_move_time
self._last_move_time = now
max_step = _MOTOR_SPEED * dt
for axis in ("az", "el"):
current = getattr(self, f"_{axis}")
target = getattr(self, f"_target_{axis}")
delta = target - current
if abs(delta) < 0.001:
continue
if abs(delta) <= max_step:
# Arrived — add a tiny settling noise.
noise = random.gauss(0.0, 0.02)
setattr(self, f"_{axis}", target + noise)
else:
direction = 1.0 if delta > 0 else -1.0
noise = random.gauss(0.0, 0.02)
setattr(self, f"_{axis}", current + direction * max_step + noise)
def _compute_rssi(self) -> float:
"""Gaussian signal model centered on the simulated satellite."""
self._update_position()
dist_sq = (self._az - _SAT_AZ) ** 2 + (self._el - _SAT_EL) ** 2
signal = _RSSI_PEAK * math.exp(-dist_sq / _RSSI_BEAM_WIDTH)
drift = math.sin(time.monotonic() / 60.0) * 50.0
return _RSSI_NOISE_FLOOR + signal + drift
@property
def _is_moving(self) -> bool:
return (
abs(self._az - self._target_az) > 0.05
or abs(self._el - self._target_el) > 0.05
)
# ------------------------------------------------------------------
# Connection
# ------------------------------------------------------------------
def connect(self, port: str = "/dev/demo", baudrate: int = 115200) -> None:
self._connected = True
self._menu = _DemoMenu.ROOT
def disconnect(self) -> None:
self._connected = False
self._menu = _DemoMenu.ROOT
@property
def is_connected(self) -> bool:
return self._connected
def initialize(self, skip_init: bool = False) -> None:
self._connected = True
self._menu = _DemoMenu.MOT
# ------------------------------------------------------------------
# Motor (MOT>)
# ------------------------------------------------------------------
def get_position(self) -> dict[str, float]:
self._update_position()
return {
"azimuth": round(self._az, 2),
"elevation": round(self._el, 2),
}
def move_to(self, az: float, el: float) -> None:
self._target_az = az
self._target_el = el
self._last_move_time = time.monotonic()
def move_motor(self, motor_id: int, degrees: float) -> None:
if motor_id == 0:
self._target_az = degrees
elif motor_id == 1:
self._target_el = degrees
self._last_move_time = time.monotonic()
def home_motor(self, motor_id: int) -> None:
if motor_id == 0:
self._target_az = 0.0
elif motor_id == 1:
self._target_el = 65.0
self._last_move_time = time.monotonic()
def engage(self) -> None:
self._engaged = True
def release(self) -> None:
self._engaged = False
def get_motor_list(self) -> str:
return "Motors:\n 0 - AZIMUTH: local\n 1 - ELEVATION: local"
def get_motor_dynamics(self) -> dict[str, float]:
return {
"az_max_vel": 65.0,
"el_max_vel": 45.0,
"az_accel": 400.0,
"el_accel": 400.0,
}
def get_motor_life(self) -> str:
return _MOTOR_LIFE
def get_el_limits(self) -> dict[str, float]:
return {"min": 18.0, "max": 65.0, "home": 65.0}
def get_step_positions(self) -> dict[str, int]:
self._update_position()
return {
"az_steps": int(self._az * 40000 / 360),
"el_steps": int(self._el * 24960 / 360),
}
# ------------------------------------------------------------------
# Signal (DVB>)
# ------------------------------------------------------------------
def get_rssi(self, iterations: int = 10) -> dict[str, int]:
rssi = self._compute_rssi()
noise = random.gauss(0.0, 30.0)
return {
"reads": iterations,
"average": int(rssi),
"current": int(rssi + noise),
}
def enable_lna(self) -> None:
pass # No-op in demo mode.
def get_lock_status(self) -> str:
rssi = int(self._compute_rssi())
locked = 1 if rssi > 1500 else 0
return f"Lock:{locked} rssi:{rssi} cnt:0"
def get_dvb_config(self) -> str:
return _DVB_CONFIG
def get_channel_params(self) -> str:
return _CHANNEL_PARAMS
# ------------------------------------------------------------------
# A3981
# ------------------------------------------------------------------
def get_a3981_diag(self) -> str:
return "AZ DIAG: OK\nEL DIAG: OK"
def get_a3981_modes(self) -> dict[str, str]:
return {
"step_mode": "AZ Step Size Mode = AUTO\nEL Step Size Mode = AUTO",
"current_mode": "AZ: Mode = AUTO\nEL: Mode = AUTO",
"step_size": (
"KEY: FULL-16, HALF-8, QTR-4, EIGHTH-2, SIXTEENTH-1\n"
"AZ Step Size:1\n"
"EL Step Size:1"
),
}
def get_a3981_torque(self) -> str:
if self._is_moving:
return "AZ Torq:HIGH\nEL Torq:HIGH"
return "AZ Torq:LOW\nEL Torq:LOW"
# ------------------------------------------------------------------
# NVS
# ------------------------------------------------------------------
def nvs_dump(self) -> str:
return _NVS_DUMP_TEXT
def nvs_read(self, index: int) -> str:
line = _NVS_LINES.get(index)
if line:
return line
return f"NVS index {index} not found"
# ------------------------------------------------------------------
# ADC
# ------------------------------------------------------------------
def get_adc_rssi(self) -> str:
rssi = self._compute_rssi()
return str(int(rssi))
def get_board_id(self) -> str:
return "STATIONARY"
# ------------------------------------------------------------------
# OS
# ------------------------------------------------------------------
def get_firmware_id(self) -> str:
return _FIRMWARE_ID
# ------------------------------------------------------------------
# Raw / Console
# ------------------------------------------------------------------
def send_raw(self, cmd: str) -> str:
"""Simulate firmware console with basic submenu tracking."""
cmd_stripped = cmd.strip().lower()
# Submenu navigation.
if cmd_stripped == "q":
self._menu = _DemoMenu.ROOT
return "TRK>"
_enter_map: dict[str, _DemoMenu] = {
"mot": _DemoMenu.MOT,
"dvb": _DemoMenu.DVB,
"nvs": _DemoMenu.NVS,
"a3981": _DemoMenu.A3981,
"adc": _DemoMenu.ADC,
"os": _DemoMenu.OS,
"step": _DemoMenu.STEP,
"peak": _DemoMenu.PEAK,
"eeprom": _DemoMenu.EEPROM,
"gpio": _DemoMenu.GPIO,
"latlon": _DemoMenu.LATLON,
"dipswitch": _DemoMenu.DIPSWITCH,
}
if cmd_stripped in _enter_map and self._menu == _DemoMenu.ROOT:
self._menu = _enter_map[cmd_stripped]
prompt = cmd_stripped.upper() + ">"
return prompt
# Context-dependent responses.
if self._menu == _DemoMenu.MOT:
return self._handle_mot(cmd_stripped)
if self._menu == _DemoMenu.DVB:
return self._handle_dvb(cmd_stripped)
if self._menu == _DemoMenu.NVS:
return self._handle_nvs(cmd_stripped)
if self._menu == _DemoMenu.A3981:
return self._handle_a3981(cmd_stripped)
if self._menu == _DemoMenu.ADC:
return self._handle_adc(cmd_stripped)
if self._menu == _DemoMenu.OS:
return self._handle_os(cmd_stripped)
if self._menu == _DemoMenu.ROOT:
return self._handle_root(cmd_stripped)
return f"Unknown command: {cmd}\nTRK>"
def _handle_root(self, cmd: str) -> str:
if cmd in ("?", "help"):
return (
"Available commands:\n"
" a3981 adc dipswitch dvb eeprom gpio\n"
" latlon mot nvs os peak step\n"
" q reboot stow\n"
"TRK>"
)
if cmd == "reboot":
return "Rebooting...\nApplication Starting Kinetis PCB...\nTRK>"
return f"Unknown command: {cmd}\nTRK>"
def _handle_mot(self, cmd: str) -> str:
if cmd in ("?", "help"):
return (
"Available commands:\n"
" a azscan azscanwxp e ela2s elminmaxhome\n"
" els2a g h l life ma motorboth motorlife\n"
" mv p pid r sd sp sw v vms w\n"
"MOT>"
)
if cmd == "a":
self._update_position()
return f" Angle[0] = {self._az:.2f}\n Angle[1] = {self._el:.2f}\nMOT>"
if cmd == "l":
return "Motors:\n 0 - AZIMUTH: local\n 1 - ELEVATION: local\nMOT>"
if cmd == "e":
self._engaged = True
return "Motors engaged\nMOT>"
if cmd == "r":
self._engaged = False
return "Motors released\nMOT>"
if cmd == "elminmaxhome":
return "Min: 1800 Max: 6500 Home: 6500\nMOT>"
if cmd == "life":
return _MOTOR_LIFE + "\nMOT>"
if cmd.startswith("a "):
parts = cmd.split()
if len(parts) >= 3:
motor_id = int(parts[1])
degrees = float(parts[2])
if motor_id == 0:
self._target_az = degrees
elif motor_id == 1:
self._target_el = degrees
self._last_move_time = time.monotonic()
return f" Angle = {degrees:.2f}\nMOT>"
return "Invalid parameters\nMOT>"
if cmd.startswith("h "):
parts = cmd.split()
if len(parts) >= 2:
motor_id = int(parts[1])
self.home_motor(motor_id)
return f"Homing motor {motor_id}\nMOT>"
return "Invalid parameters\nMOT>"
if cmd == "mv":
return "Max Vel [0] = 65.0 Max Vel [1] = 45.0\nMOT>"
if cmd == "ma":
return "Accel[0] = 400.0 Accel[1] = 400.0\nMOT>"
if cmd == "p":
self._update_position()
az_steps = int(self._az * 40000 / 360)
el_steps = int(self._el * 24960 / 360)
return f"Position[0] = {az_steps} Position[1] = {el_steps}\nMOT>"
return f"Unknown command: {cmd}\nMOT>"
def _handle_dvb(self, cmd: str) -> str:
if cmd in ("?", "help"):
return (
"Available commands:\n"
" agc config def diag dis e freqs\n"
" lnbdc lnbv ls man msw nid pwr\n"
" qls range rssi shuf snr srch srch_mode\n"
" stats t table tablex tabto to\n"
"DVB>"
)
if cmd.startswith("rssi"):
rssi_val = int(self._compute_rssi())
parts = cmd.split()
iters = int(parts[1]) if len(parts) > 1 else 10
noise = random.gauss(0.0, 30.0)
cur = int(rssi_val + noise)
return (
f"iterations:{iters} interval(msec):20\n"
f" Reads:{iters} RSSI[avg: {rssi_val} cur: {cur}]\n"
"DVB>"
)
if cmd == "config":
return _DVB_CONFIG + "\nDVB>"
if cmd == "dis":
return _CHANNEL_PARAMS + "\nDVB>"
if cmd == "lnbdc odu":
return "Enabled LNB ODU 13V\nDVB>"
if cmd == "qls":
rssi_val = int(self._compute_rssi())
locked = 1 if rssi_val > 1500 else 0
return f"Lock:{locked} rssi:{rssi_val} cnt:0\nDVB>"
return f"Unknown command: {cmd}\nDVB>"
def _handle_nvs(self, cmd: str) -> str:
if cmd in ("?", "help"):
return "Available commands:\n d e s\nNVS>"
if cmd == "d":
return _NVS_DUMP_TEXT + "\nNVS>"
if cmd == "s":
return "NVS saved\nNVS>"
if cmd.startswith("e "):
parts = cmd.split()
if len(parts) >= 2:
try:
idx = int(parts[1])
line = _NVS_LINES.get(idx)
if line:
return line + "\nNVS>"
return f"NVS index {idx} not found\nNVS>"
except ValueError:
pass
return "Invalid parameters\nNVS>"
return f"Unknown command: {cmd}\nNVS>"
def _handle_a3981(self, cmd: str) -> str:
if cmd in ("?", "help"):
return "Available commands:\n cm diag reset sm ss st\nA3981>"
if cmd == "diag":
return "AZ DIAG: OK\nEL DIAG: OK\nA3981>"
if cmd == "sm":
return "AZ Step Size Mode = AUTO\nEL Step Size Mode = AUTO\nA3981>"
if cmd == "cm":
return "AZ: Mode = AUTO\nEL: Mode = AUTO\nA3981>"
if cmd == "ss":
return (
"KEY: FULL-16, HALF-8, QTR-4, EIGHTH-2, SIXTEENTH-1\n"
"AZ Step Size:1\n"
"EL Step Size:1\n"
"A3981>"
)
if cmd == "st":
if self._is_moving:
return "AZ Torq:HIGH\nEL Torq:HIGH\nA3981>"
return "AZ Torq:LOW\nEL Torq:LOW\nA3981>"
if cmd == "reset":
return "Az/El A3981 Faults Reset.\nA3981>"
return f"Unknown command: {cmd}\nA3981>"
def _handle_adc(self, cmd: str) -> str:
if cmd in ("?", "help"):
return "Available commands:\n bdid bdrevid m rssi scan\nADC>"
if cmd == "rssi":
return str(int(self._compute_rssi())) + "\nADC>"
if cmd == "bdid":
return "STATIONARY\nADC>"
if cmd == "bdrevid":
return "A\nADC>"
return f"Unknown command: {cmd}\nADC>"
def _handle_os(self, cmd: str) -> str:
if cmd in ("?", "help"):
return "Available commands:\n id reboot\nOS>"
if cmd == "id":
return _FIRMWARE_ID + "\nOS>"
if cmd == "reboot":
return "Rebooting...\nApplication Starting Kinetis PCB...\nTRK>"
return f"Unknown command: {cmd}\nOS>"