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