diff --git a/.gitignore b/.gitignore index f38fe19..f9e9018 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ build/ .env *.so .ruff_cache/ + +# PlatformIO +.pio/ +.pioenvs/ +.piolibdeps/ diff --git a/CLAUDE.md b/CLAUDE.md index 71bd93f..0c20c32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,18 +4,19 @@ Control a Winegard Trav'ler motorized satellite dish via RS-485 for amateur radi ## Project -- **Package:** `travler-rotor` (installed via `uv sync`) -- **CLI entry point:** `travler-rotor` (init / serve / pos / move) -- **Source layout:** `src/travler_rotor/` (src-layout) +- **Packages:** `birdcage` + `console-probe` (installed via `uv sync`) +- **CLI entry points:** `birdcage` (init / serve / pos / move), `console-probe` (probe / discover) +- **Source layout:** `src/birdcage/` and `src/console_probe/` (src-layout) - **Original upstream:** `Trav-ler-Rotor-For-HAL-2.05/` — Gabe Emerson's scripts, kept as reference (do not modify) ## Build & Lint ```bash -uv sync # Install deps + package +uv sync # Install deps + both packages uv run ruff check src/ # Lint uv run ruff format --check src/ # Format check -uv run travler-rotor --help # CLI smoke test +uv run birdcage --help # CLI smoke test +uv run console-probe --help # Probe tool smoke test ``` ## Architecture @@ -25,13 +26,29 @@ protocol.py — FirmwareProtocol ABC + HAL205Protocol / HAL000Protocol Serial I/O owned here. Each firmware version is a subclass. leapfrog.py — Pure function: apply_leapfrog(target, current) -> adjusted Predictive overshoot to compensate for mechanical motor lag. -antenna.py — TravlerAntenna: high-level control wrapping protocol + leapfrog +antenna.py — BirdcageAntenna: high-level control wrapping protocol + leapfrog This is what consumers (CLI, rotctld, future MCP server) call. rotctld.py — RotctldServer: Hamlib rotctld TCP protocol (p/P/S/_/q) Bridges Gpredict to the antenna. cli.py — Click CLI with init/serve/pos/move subcommands ``` +### console-probe package + +``` +profile.py — DeviceProfile + HelpEntry dataclasses +serial_io.py — Prompt-aware serial I/O (fixes > termination bug) +discovery.py — Auto-discovery, help parsing, submenu probing, candidates +report.py — JSON report with format_version 2 (menus/help/undiscovered) +cli.py — argparse CLI: --discover-only, --deep, --submenu, --json +``` + +**Usage:** +```bash +console-probe --port /dev/ttyUSB2 --baud 115200 --discover-only --json /tmp/d.json +console-probe --port /dev/ttyUSB2 --baud 115200 --deep --wordlist scripts/wordlists/winegard.txt +``` + ## Firmware Variants Five known Winegard dish variants documented by Gabe Emerson (KL1FI) / saveitforparts and cdavidson0522: @@ -44,13 +61,17 @@ Five known Winegard dish variants documented by Gabe Emerson (KL1FI) / saveitfor | **Motor submenu** | `mot` | `motor` | `odu` then `mot` | N/A (`target` + `g`) | `mot` | | **Motor control** | `a ` | `a ` | `a ` | `g ` only | `a ` | | **Search kill** | `os` -> `kill Search` | `ngsearch` -> `s` -> `q` | `os` -> `kill Search` | N/A | NVS 20 (permanent disable) | -| **Boot signal** | `NoGPS` | `NoGPS` or `No LNB Voltage` | undocumented | N/A | undocumented | +| **Boot signal** | `NoGPS` | `NoGPS` or `No LNB Voltage` | undocumented | N/A | `Boot Complete` then `Loc Startup: IDU NOT Present` | | **Min elevation** | 15 deg (firmware) | 15 deg (firmware) | 12 deg (firmware) | 22 deg (firmware-enforced) | 18 deg (firmware) | | **Max elevation** | 90 deg | 90 deg | 75 deg (hardware cap!) | 73 deg (firmware default, NVS 102 override) | 65 deg (firmware) | | **Position query** | `a` -> `AZ = / EL =` | `a` -> `AZ = / EL =` | `a` -> `AZ = / EL =` | raw ints / 100 | `a` -> floats | | **Tested model** | LG-2112 | LG-2112 | SK2DISH | 2003 Carryout | Carryout G2 | -| **HAL version** | 0.0.00 | 2.05.003 | unknown | 1.00.065 | unknown | -| **Prompt char** | `>` (likely) | `>` (likely) | undocumented | undocumented | `>` (confirmed) | +| **HAL version** | 0.0.00 | 2.05.003 | unknown | 1.00.065 | 02.02.48 | +| **Prompt char** | `>` (likely) | `>` (likely) | undocumented | undocumented | `TRK>` / `MOT>` / `NVS>` (confirmed) | +| **Position format** | `AZ = / EL =` | `AZ = / EL =` | `AZ = / EL =` | raw ints / 100 | `Angle[0] = / Angle[1] =` | +| **DVB tuner** | unknown | unknown | unknown | unknown | BCM4515 (Broadcom) | +| **MCU** | unknown | unknown | unknown | unknown | NXP MK60DN512VLQ10 (Kinetis K60, Cortex-M4, 96MHz, 512KB flash, 128KB RAM) | +| **Motor driver** | unknown | unknown | unknown | unknown | 2× Allegro A3981 (SPI, 1/16 microstep, AUTO mode) | ### Key Variant Differences @@ -61,9 +82,13 @@ Five known Winegard dish variants documented by Gabe Emerson (KL1FI) / saveitfor - **NVS `d` command** dumps all NVS values. Confirmed on Pro and Carryout G2; likely available on all variants. - **Carryout DIP switches:** All switches to off (up) may disable search mode, but behavior varies by unit. - **Carryout G2 uses `a` not `g`:** Unlike the 2003 Carryout, the G2 uses standard `a ` motor addressing and the `mot` submenu — protocol-compatible with the Trav'ler family. -- **Carryout G2 is RS-422 full-duplex:** Separate TX/RX pairs at 115200 baud via RJ-12 6P6C, vs. RS-485 half-duplex at 57600 on the Trav'ler variants. Requires a USB-to-RS422 converter (5V TTL). +- **Carryout G2 is RS-422 full-duplex:** Separate TX/RX pairs at 115200 baud via RJ-12 6P6C, vs. RS-485 half-duplex at 57600 on the Trav'ler variants. Tested with DSD TECH SH-U11 USB-to-RS422 adapter (FTDI FT232R). **Polarity matters** — A/B (or +/-) labeling is not standardized; if you get garbled data at the correct baud rate, swap the +/- wires on the RX pair. TX pair polarity swap causes the dish to not receive commands (silent failure). +- **Carryout G2 position format differs from Trav'ler:** Position query `a` in `MOT>` submenu returns `Angle[0] = 180.00` / `Angle[1] = 45.00` — not the `AZ = / EL =` format used by HAL 0.0.00 and HAL 2.05. Move confirmation returns `Angle = 46.00` (no array index). `CarryoutG2Protocol.get_position()` uses `Angle\[0\]`/`Angle\[1\]` regex. Motor overshoot is direction-dependent: +0.01–0.05° in travel direction, -0.02–0.06° on return (stepper backlash). +- **Carryout G2 firmware version 02.02.48** confirmed (Copyright 2013 - Winegard Company). Bootloader version 1.01. MCU: Kinetis (NXP ARM Cortex-M). DVB tuner: BCM4515 (Broadcom). +- **Carryout G2 boot sequence:** Bootloader v1.01 → SPI1 init @ 4 MHz (A3981 motor drivers, mode 0x03) → Motor init (System=12Inch, master=40000 steps, slave=24960 steps, ratio=1.602564) → SPI2 init @ 6.857 MHz (BCM4515 DVB tuner, mode 0x03) → `EXTENDED_DVB_DEBUG ENABLED` → DVB init (AP RAM FW verified, BCM4515 ID 0x4515 Rev B0, FW v113.37, strap 0x25018) → auto-search config (blind scan, 18000-24000 ksps, rolloff 0.35) → `Enabled LNB STB` → `Ant ID - 12-IN G2` → NVS load → EL home (stall detect, 2s timeout) → AZ home (stall detect, 8s timeout) → `Antenna Facing Front` → `TRK>` prompt (if tracker disabled) or search start. When NVS 20 = TRUE (tracker disabled), homing is skipped entirely — motors stay uncalibrated and AZ position reads as INT_MAX (2147483647). +- **Carryout G2 cable wrap:** Confirmed from homing output: `wrap_min:-42333 wrap_max:2333` (centidegrees). Total range ~446.66°. - **Carryout G2 has `h ` homing:** Explicit motor home-to-reference command. Not documented on other variants. -- **Carryout G2 has DVB/RSSI:** Signal strength measurement via `dvb` submenu (`lnbdc odu` to enable LNA, `rssi ` to sample). Used for sky scanning / RF imaging. +- **Carryout G2 has DVB/RSSI:** BCM4515 tuner (ID 0x4515, Rev B0, firmware v113.37). DVB submenu provides `rssi ` (bounded, returns `Reads: RSSI[avg: cur: ]`), `agc` (streaming RF/IF AGC + SNR + NID), `snr`, `lnbdc odu` (enable LNA 13V), `lnbv` (streaming voltage monitor), `dis` (channel params), `config` (hardware ID), `table` (transponder scan), and DiSEqC 2.x commands (`di2*`, `send`). RSSI noise floor is ~500. `lnbdc odu` sets 13V (V-pol); boot default is 18V (H-pol). Streaming commands run until interrupted by `q` or another command. ## Hardware Specs (SK-1000) @@ -136,14 +161,20 @@ The physical connector is an RJ-25 (6P6C) on the Trav'ler or RJ-12 (6P6C) on the **Carryout G2 pinout (RJ-12, clip away, per Davidson's wiring guide):** -| Pin | Wire Color | RS-422 Function | -|-----|-----------|-----------------| -| 1 | White | GND (PE) | -| 2 | Red | TX+ (TA) — computer→dish | -| 3 | Black | TX- (TB) — computer→dish | -| 4 | Yellow | RX+ (RA) — dish→computer | -| 5 | Green | RX- (RB) — dish→computer | -| 6 | Blue | Not connected | +| Pin | Wire Color (Davidson) | Wire Color (confirmed) | RS-422 Function | +|-----|----------------------|----------------------|-----------------| +| 1 | White | Orange/White | GND (PE) | +| 2 | Red | Orange | TX+ (TA) — computer→dish | +| 3 | Black | Green/White | TX- (TB) — computer→dish | +| 4 | Yellow | Blue | RX+ (RA) — dish→computer | +| 5 | Green | Blue/White | RX- (RB) — dish→computer | +| 6 | Blue | Green | Not connected | + +**Note:** Wire colors vary by cable manufacturer. The "confirmed" column is from a standard +6P6C flat cable tested 2026-02-12. Always verify with a multimeter before connecting. +**Polarity is critical:** swapping +/- on the RX pair produces garbled data at the correct +baud rate (systematic bit inversion, not random noise). Swapping +/- on the TX pair causes +silent failure (dish doesn't respond because it can't decode the inverted framing). **Adapter chain by variant:** @@ -151,6 +182,7 @@ The physical connector is an RJ-25 (6P6C) on the Trav'ler or RJ-12 (6P6C) on the |---------|---------|-----------| | Trav'ler (Gabe's setup) | USB→RS232→RS485 (DTECH) | Pins 2-3 only (half-duplex) | | Carryout G2 (Davidson) | USB→RS422 (5V TTL) | Pins 2-5 (full-duplex) | +| Carryout G2 (confirmed) | DSD TECH SH-U11 USB→RS422 (FTDI FT232R) | Pins 1-5 (full-duplex + GND) | | Carryout G2 (ESP32) | ESP32 UART2→RS422 module (DIYables) | Pins 2-5 (full-duplex) | ### RS-422 Module Notes (DIYables MAX490) @@ -176,41 +208,422 @@ For short cable runs (under ~3m between ESP32 and dish), the built-in 120 ohm te ### Firmware Console Commands +Full command inventory from automated deep probe + interactive `?` exploration +(firmware 02.02.48, 2026-02-12). Automated probe finds commands that respond +without arguments; interactive `?` in each submenu reveals the full set including +parameter-requiring commands the probe misses. + +#### Root Menu (TRK>) + ``` -? — list available commands -motor / mot — enter motor submenu (firmware-dependent) -a — show position (in motor submenu) -a — move motor to absolute position -h — home motor to reference position (G2, possibly others) -g — go to AZ/EL (aborts on new input) -q — exit current submenu -odu — tunnel to outdoor unit (Trav'ler Pro only) -os — enter OS submenu - tasks — list running tasks - kill — kill a named task (e.g. "kill Search") -ngsearch — enter search submenu (HAL 2.05 only) - s — stop search +? — list available commands (alias: help) +a3981 — enter motor driver submenu +adc — enter ADC submenu +dipswitch — enter dipswitch submenu +dvb — enter DVB tuner submenu +eeprom — enter EEPROM submenu +gpio — enter GPIO submenu +latlon — enter lat/lon calculator submenu +mot — enter motor control submenu nvs — enter non-volatile storage submenu - d — dump all values (confirmed on Pro and G2) - d — dump single value with name/current/saved/default - e — read NVS value - e — write NVS value - s — save changes -dvb — signal info / LNB signal strength submenu - lnbdc odu — enable LNA in ODU mode (powers LNB for reception) - rssi — read RSSI signal strength averaged over n samples +os — enter OS submenu +peak — enter peak/DiSEqC switch submenu +step — enter stepper motor submenu +q — terminate shell (WARNING: kills UART, requires power cycle!) reboot — reboot firmware stow — fold dish flat (caution: modified feeds may not survive) +odu — tunnel to outdoor unit (Trav'ler Pro only) +ngsearch — enter search submenu (HAL 2.05 only) ``` +Note: `command` appeared in automated probe results — this is a false positive. +The help parser extracted it from the `help []` usage text, where +`` is a parameter placeholder, not an actual command. + +#### A3981 Submenu (A3981>) — Allegro Stepper Driver + +6 commands. Controls the two A3981 stepper motor driver ICs via SPI. + +``` +cm — current control mode: AZ/EL both report "AUTO" or "HiZ"/"LoZ" +diag — fault pin status: "AZ DIAG: OK EL DIAG: OK" (or FAULT) +reset — reset AZ/EL A3981 fault flags +sm — step size mode: AZ/EL both report "AUTO" or fixed mode +ss — step size: returns integer (FULL=16, HALF=8, QTR=4, EIGHTH=2, SIXTEENTH=1) +st — torque level: AZ/EL report "HIGH" (moving) or "LOW" (idle/holding) +? / q — help / return to TRK> +``` + +#### ADC Submenu (ADC>) — Analog-to-Digital Converter + +5 commands. Hardware-level ADC readings from the LNB signal chain and board ID. + +``` +bdid — board identity: returns "STATIONARY" (Carryout G2 variant) +bdrevid — board revision: returns "A" +m — monitor RSSI (streaming, CR-overwrite line, interrupt with q) +rssi — single-shot RSSI (raw ADC count, ~233-238 noise floor) +scan — AZ sweep with per-position RSSI/Lock/SNR readings + Output: "Motor: Angle: RSSI: Lock:<0/1> SNR: Scan Delta:" + WARNING: without arguments on uncalibrated AZ, targets INT_MAX (2147483647) + and DEADLOCKS the shell — requires power cycle to recover! +? / q — help / return to TRK> +``` + +#### DIPSWITCH Submenu (DIPSWITCH>) + +1 command. Reads physical DIP switch GPIOs and interprets satellite config code. + +``` +dipswitch — read dipswitch: "val:" (raw GPIO) + "app_dipswitch:" (interpreted) + val:ffffff01 = all switches OFF/up. app_dipswitch:101 = DISH 110+119+129°W +? / q — help / return to TRK> +``` + +#### DVB Submenu (DVB>) — BCM4515 Tuner + +38 commands. Controls the Broadcom BCM4515 DVB-S2 tuner and DiSEqC 2.x LNB interface. +Help is paginated: `?` shows first page, `man` shows extended commands. + +``` +agc — stream RF/IF AGC + SNR + NID (continuous, interrupt with q) +config — BCM hardware/firmware version (ID 0x4515, Rev B0, FW v113.37) +def — restore DVB defaults +diag — multi-block per-transponder diagnostics +dis — display channel parameters (frequency, symbol rate, LNB polarity) +e — edit channel parameter +freqs — tuner frequency list +lnbdc odu — enable LNA in ODU mode (13V = V-pol; boot default 18V = H-pol) +lnbv — stream LNB voltage readings (continuous, interrupt with q) +ls — lock status (total reads, no-signal count, glitch count, NID table) +man — extended help page (shows srch_mode, stats, DiSEqC commands, etc.) +msw — multi-switch control +nid — streaming NID reads (Network ID, FFFF = no signal) +pwr — power control +qls — quick lock status +range — signal range test +rssi — RSSI averaged over n samples (bounded, returns avg + cur) +shuf — shuffle/reorder transponders +snr — SNR level (streaming) +srch — start satellite search +srch_mode — auto search mode setting +stats — accumulated satellite read statistics +t — select transponder +table — generate transponder table +tablex — extended transponder table +tabto — table timeout setting +to — timeout setting +di2conf — DiSEqC LNB config register (raw: "3 21180544 238 <4.5") +di2cs — DiSEqC committed switch command +di2id — DiSEqC read LNB hardware ID +di2rcs — DiSEqC read committed switch status +di2sc — DiSEqC switch control +di2stat — DiSEqC read LNB status flags +ovraddr — DiSEqC override address +pretx — DiSEqC pre-transmit delay +rrto — DiSEqC receive reply timeout +send — raw DiSEqC packet (max 6 bytes, space-delimited hex) +tdthresh — DiSEqC tone detect threshold +? / q — help / return to TRK> +``` + +#### EEPROM Submenu (EE>) — K60 FlexNVM/EEPROM + +3 commands. Low-level EEPROM access (separate from NVS). Prompt is `EE>`, not `EEPROM>`. +Most indices read as 0 (unwritten) or fail with val:65793 (0x10101 marker = uninitialized). +The firmware primarily uses NVS, not EEPROM, for persistent settings. + +``` +ee [] — read/write EEPROM value at index + Read: "Index: Read value = " or "Failed to read eeprom index: val:65793" +inv — INVALIDATE EEPROM index (DESTRUCTIVE — marks entry invalid, not "inventory"!) +def — restore EEPROM defaults +? / q — help / return to TRK> +``` + +#### GPIO Submenu (GPIO>) + +4 commands. Direct access to K60 GPIO ports A-E. Pin naming: `` (e.g., B0, E12). + +``` +dir — query pin direction: returns "INPUT" or "OUTPUT" +r — read single GPIO pin value (0 or 1) +regs — dump ALL GPIO pin states across ports A-E (26+16+20+16+14 = 92 pins) + Note: A20-A23, B12-B15 absent (not bonded). E29 shows "Unknown bit E29" +w — write GPIO pin (requires pin name and value) +? / q — help / return to TRK> +``` + +#### LATLON Submenu (LATLON>) + +1 command. Satellite triangulation calculator — computes ground station lat/lon from +look angles to two known geostationary satellites. Used for auto-location when GPS +is unavailable. Values stored internally as centidegrees. + +``` +l + — calculate lat/lon from 4 parameters (likely AZ/EL pairs for 2 satellites) + Output: "anglesentered = " + "Lat = Lon = " (centidegrees) +? / q — help / return to TRK> +``` + +#### MOT Submenu (MOT>) — Motor Control + +25 commands. High-level motor control with angle-based positioning. + +``` +a — show position: Angle[0] (AZ), Angle[1] (EL) +a — move motor to absolute angle (0=AZ, 1=EL) +a +/-deg — relative move (G2 only, undocumented) +azscan [az_rel] [el_rel] [delay] + — AZ sweep: scan relative AZ range at EL steps with delay +azscanwxp [motor] [span_deg] [res_cdeg] [num_xponders] + — AZ sweep + transponder cycling (radio telescope mode) +e — engage motors (energize steppers) +ela2s — elevation angle to steps converter (centidegrees internally) +elminmaxhome — show EL limits: "Min: Max: Home: " (NVS values) +els2a — elevation steps to angle converter (reports overflow if out of range) +g — go to AZ/EL (aborts on new input) +h — home motor to reference position (stall-detect based) +l — list motors and state (0=AZIMUTH, 1=ELEVATION) +life — motor lifetime/usage stats +ma — read max acceleration per motor +motorboth — simultaneous dual-motor move test +motorlife — detailed motor life statistics +mv — max velocity per motor: "Max Vel [0] = / Max Vel [1] = " +p — read raw step positions +pid [motor] [Kp] [Kv] [Ki] + — read or set PID gains for motor control loop +r — release motors (de-energize steppers) +sd — stall detection test (motor, direction, timeout) +sp [motor] [pos] + — set position (override current position register) +sw [motor] [pos] + — set wrap position (cable wrap reference point) +v — read motor velocities +vms [motor] [deg_per_rev] [ms] + — velocity move for duration: spin motor at velocity for N milliseconds +w [motor] [ON/OFF] + — wrap manager: enable/disable cable wrap protection per motor +? / q — help / return to TRK> +``` + +#### NVS Submenu (NVS>) — Non-Volatile Storage + +**Caution:** NVS `e ` writes values. Any unrecognized input is treated +as a sequential index read (no error string), which generates false positives during +probing but is harmless. `s` saves pending changes to flash. + +``` +d — dump all NVS values (name/current/saved/default) +d — dump single value with details +e — read NVS value at index +e — write NVS value at index (NOT saved until `s`) +s — save pending changes to flash +? / q — help / return to TRK> +``` + +#### OS Submenu (OS>) + +``` +id — full MCU/firmware identification (NVS version, System ID, chip) +reboot — reboot microcontroller +tasks — list running tasks (HAL 0.0.00 only, not on G2) +kill — kill a named task (HAL 0.0.00 only, not on G2) +? / q — help / return to TRK> +``` + +#### PEAK Submenu (PEAK>) — Signal Peak / DiSEqC Switch + +6 commands. EchoStar/DiSEqC switch control and LNB polarity-switched RSSI. + +``` +pw — peak signal search (likely requires sat lock) +psnr — peak SNR measurement +pxy1 — peak XY single-axis (likely az or el sweep) +rssits — RSSI with LNB toggle switch: alternates H-pol (18V, even transponders) + and V-pol (13V, odd transponders). Reports "Even_sig = , Odd_sig = ". + Noise floor: even ~489, odd ~235 (V-pol quieter). +stb — STB (set-top box) control / DiSEqC switch test +ts — EchoStar switch toggle status: "SW Status: 0b " + (reads 4-bit status, all zeros = no switch connected) +? / q — help / return to TRK> +``` + +#### STEP Submenu (STEP>) — Low-Level Stepper Control + +7 commands. Raw stepper API in microstep units (ustep/sec, ustep/sec/msec). +MOT wraps STEP with angle-to-step conversion. + +``` +e — engage motor (same as MOT `e`) +ma — max acceleration: "Accel[0] = 44 / Accel[1] = 28" (ustep/sec/ms) + Set: `ma [motor] [ustep/sec/ms]` +mv — max velocity: "Max Vel [0] = 7222 / Max Vel [1] = 3120" (ustep/sec) + Set: `mv [motor] [ustep/sec]` + (7222 ustep/s ÷ 40000 steps/rev × 360° = 65.0°/s AZ) + (3120 ustep/s ÷ 24960 steps/rev × 360° = 45.0°/s EL) +p — goto position in raw step counts: `p [motor] [steps]` +pid — PID values: "Kp=250 Kv=50" (no Ki at STEP level) + Set: `pid [motor] [Kp] [Kv]` +r — release motors (same as MOT `r`) +v — go to velocity (continuous spin): `v [motor] [ustep/sec]` +? / q — help / return to TRK> +``` + +### K60 GPIO Functional Pin Map (Carryout G2) + +Cross-referenced from live `gpio dir`/`gpio regs` queries (2026-02-13), K60 datasheet +pin mux table (MK60DN512VLQ10, 144-LQFP), boot log peripheral init, and A3981 datasheet. + +**SPI1 — A3981 Stepper Motor Drivers (4 MHz, mode 0x03)** + +| K60 Pin | GPIO | Alt | Function | Dir | State | Notes | +|---------|------|-----|----------|-----|-------|-------| +| PTE0 | E0 | ALT2 | SPI1_PCS1 | OUT | 1 | A3981 #2 chip select (EL motor) | +| PTE1 | E1 | ALT2 | SPI1_SOUT | (periph) | 1 | MOSI — MCU to A3981 | +| PTE2 | E2 | ALT2 | SPI1_SCK | (periph) | 1 | SPI clock | +| PTE3 | E3 | ALT2 | SPI1_SIN | (periph) | 0 | MISO — A3981 to MCU | +| PTE4 | E4 | ALT2 | SPI1_PCS0 | IN* | 1 | A3981 #1 chip select (AZ motor) | +| PTE5 | E5 | ALT2 | SPI1_PCS2 | OUT | 1 | Possibly A3981 RESET or enable | + +*PTE4 shows INPUT in GPIO dir register, but this is irrelevant when muxed to SPI peripheral. +The SPI controller manages chip select assertion/deassertion directly. + +**SPI2 — BCM4515 DVB-S2 Tuner (6.857 MHz, mode 0x03)** + +| K60 Pin | GPIO | Alt | Function | Dir | State | Notes | +|---------|------|-----|----------|-----|-------|-------| +| PTD11 | D11 | ALT2 | SPI2_PCS0 | OUT | 1 | BCM4515 chip select | +| PTD12 | D12 | ALT2 | SPI2_SCK | IN* | 1 | SPI clock | +| PTD13 | D13 | ALT2 | SPI2_SOUT | IN* | 1 | MOSI — MCU to BCM4515 | +| PTD14 | D14 | ALT2 | SPI2_SIN | — | 0 | MISO — BCM4515 to MCU | +| PTD15 | D15 | ALT2 | SPI2_PCS1 | — | 0 | Secondary chip select (unused?) | + +*GPIO dir register not meaningful for peripheral-muxed pins. + +**UART4 — RS-422 Console (115200 baud)** + +| K60 Pin | GPIO | Alt | Function | Dir | State | Notes | +|---------|------|-----|----------|-----|-------|-------| +| PTE24 | E24 | ALT3 | UART4_TX | OUT | 1 | Console TX (to computer RX pair) | +| PTE25 | E25 | ALT3 | UART4_RX | IN | 1 | Console RX (from computer TX pair) | +| PTE26 | E26 | ALT3 | UART4_CTS | IN | 1 | Hardware flow control (idle high) | +| PTE27 | E27 | — | GPIO | IN | 1 | Unknown (RTS? or pullup) | +| PTE28 | E28 | — | GPIO | IN | 1 | Unknown | + +**DIP Switch GPIOs** + +`dipswitch` reads raw value `val:ffffff01` (all OFF/up) → `app_dipswitch:101` (DISH 110+119+129W). +Exact GPIO pins TBD — likely Port A or Port C inputs with internal pullups. The 0xffffff01 +raw value suggests a 32-bit register read where bits 1-24 are all high (pullup, switches open) +and bit 0 is high (LSB). + +**A3981 Diagnostic Pins** + +The `a3981 diag` command reads fault status from two GPIO pins (one per motor driver). +Confirmed both read "OK" when motors are healthy. The A3981 DIAG output is active-low +open-drain, pulled high when no fault. Exact GPIO pins TBD. + +**Unidentified High-State Outputs** + +| GPIO | Dir | State | Likely Function | +|------|-----|-------|-----------------| +| D10 | OUT | 1 | BCM4515 reset or power enable | +| B0-B3 | — | 1 | SPI0 or I2C bus (B0-B3 cluster) | +| B11 | — | 1 | Status LED or peripheral enable | +| C10-C13 | — | 1 | Contiguous block — possibly bus interface | +| C18 | — | 1 | LNB voltage control or relay | + +### azscanwxp — Radio Telescope Mode (Carryout G2) + +The `azscanwxp` command in MOT> performs an azimuth sweep while cycling through +DVB transponders at each position. This is the core of Davidson's winegard-sky-scan +project for RF imaging of the sky. + +**Usage:** `azscanwxp [motor] [span] [resolution] [num_xponders]` + +| Parameter | Type | Units | Description | +|-----------|------|-------|-------------| +| motor | int | — | Motor ID (0=AZ, 1=EL) | +| span | float | degrees | Total azimuth sweep range | +| resolution | int | centidegrees (0.01 deg) | Step size per position | +| num_xponders | int | — | Number of transponders to cycle at each position | + +**Example:** `azscanwxp 0 10 100 3` — sweep 10 degrees on AZ at 1.00 degree steps, +checking 3 transponders per position. + +**Output format** (from ADC `scan` documentation): +``` +Motor: Angle: RSSI: Lock:<0/1> SNR: Scan Delta: +``` + +**Safety:** Requires homed motors. Do NOT run on uncalibrated axes — the firmware +may target INT_MAX (2147483647 steps) and deadlock the shell. + +**For ham radio sky mapping:** Set the DVB tuner to a frequency near your target +(e.g., 10 GHz Ku-band downconverted through the LNB to ~1178 MHz IF), enable LNA +with `dvb` → `lnbdc odu`, then run azscanwxp. The RSSI values map RF power at +each AZ/EL grid point. Post-process the output into a 2D heatmap for sky imaging. + +### DiSEqC 2.x Interface (Carryout G2) + +The BCM4515 provides a DiSEqC 2.x controller accessible from the DVB> submenu. +DiSEqC (Digital Satellite Equipment Control) uses 22 kHz tone bursts on the coax +LNB bias line to control switches, LNB polarity, and band selection. + +**Timing Parameters (confirmed live 2026-02-13):** + +| Command | Value | Description | +|---------|-------|-------------| +| `ovraddr` | 0x11 | Target LNB address (standard first LNB) | +| `rrto` | 210 ms | Receive reply timeout | +| `pretx` | 15 ms | Pre-command TX delay | +| `tdthresh` | 110 | Tone detect threshold (0.16 counts/mV) | + +**DiSEqC Commands:** + +| Command | Function | Status | +|---------|----------|--------| +| `di2conf` | Read LNB config register | RxReplyTimeout (no switch connected) | +| `di2id` | Read LNB hardware ID | RxReplyTimeout | +| `di2stat` | Read LNB status flags | RxReplyTimeout | +| `di2rcs` | Read committed switch status | RxReplyTimeout | +| `di2cs` | Configure committed switch | Needs parameters | +| `di2sc` | Short circuit test | Untested | +| `send ` | Raw DiSEqC packet (max 6 bytes) | Functional | + +**Raw DiSEqC packets:** The `send` command accepts space-delimited hex bytes. +Standard DiSEqC 1.x commands use the format: `send E0 10 38 Fx` where the +last byte selects the switch port (F0-F3 for ports 1-4). + +**For ham radio:** DiSEqC can control LNB polarity (13V=V-pol, 18V=H-pol) and +22 kHz tone (band select) without rewiring. The `lnbdc odu` command sets 13V; +boot default is 18V. Polarity affects which transponders are visible and RSSI +readings from `rssits` in the PEAK> submenu, which alternates between even +(H-pol/18V) and odd (V-pol/13V) transponders. + ### Known NVS Indices -| Index | Setting | -|-------|---------| -| 20 | Disable tracker procedure (FALSE/TRUE) | -| 102 | Max elevation | -| 125 | Search minimum elevation | -| 127 | Safe minimum elevation | +Full dump in `docs/g2-nvs-dump.md` (firmware 02.02.48, captured 2026-02-12). + +| Index | Setting | Default | Notes | +|-------|---------|---------|-------| +| 20 | Disable Tracker Proc? | FALSE | Set TRUE to prevent TV satellite search on boot | +| 38 | Sleep Mode Timer Secs | 420 | 7 minutes before sleep | +| 41 | Satellite Scan Velocity | 55.00 | °/s during TV search | +| 80 | AZ Max Vel | 65.00 | °/s azimuth max velocity | +| 81 | AZ Max Accel | 400.00 | °/s² azimuth max acceleration | +| 83 | AZ Steps/Rev | 40000 | Stepper steps per full rotation | +| 85 | EL Max Vel | 45.00 | °/s elevation max velocity | +| 88 | EL Steps/Rev | 24960 | Stepper steps per full EL rotation | +| 101 | Minimum Elevation Angle | 18.00 | Firmware floor (degrees) | +| 102 | Maximum Elevation Angle | 65.00 | Firmware ceiling (degrees) | +| 103 | Elevation Home Angle | 65.00 | EL position after homing | +| 112 | Disable Dipswitch? | FALSE | Override physical DIP switches | +| 113 | Dipswitch Value | 101 | DirecTV config (ignored when tracker disabled) | +| 128-133 | AZ/EL PID Gains | varies | Kp/Kv/Ki tuning parameters | ### Error Messages @@ -219,6 +632,16 @@ stow — fold dish flat (caution: modified feeds may not survive) | `AZ MOTOR STALLED` | Obstruction preventing rotation | | `EL MOTOR STALLED` | Obstruction preventing elevation change | | `EL Motor Home Failure` | Requires EL recalibration via IDU menu | +| `Step to Position EL angle error: 2147483647` | INT_MAX sentinel — motor axis uncalibrated/unhomed | + +### Known Console Hazards + +- **ADC `scan` without arguments on uncalibrated AZ:** Targets position 2147483647 (INT_MAX), + motor task blocks forever, shell deadlocks. No serial input (CR, Ctrl+C, ESC, `q`, `reboot`) + can recover — requires hardware power cycle. The firmware shell is single-threaded: UART + input is only parsed between command completions, so a blocking motor move prevents all input. +- **Root `q` command:** Terminates the shell task entirely. Console becomes unresponsive until + power cycle (same as deadlock, but intentional). ### IDU/ODU Cable Wiring (if cut) diff --git a/docs/ble-bridge-wiring.md b/docs/ble-bridge-wiring.md new file mode 100644 index 0000000..7af06c1 --- /dev/null +++ b/docs/ble-bridge-wiring.md @@ -0,0 +1,275 @@ +# BLE Bridge Wiring — ESP32-S3 + 2× MAX485 + +Transparent BLE-to-RS422 bridge for the Winegard Carryout G2 satellite dish, +with optional IMU and barometric sensors for orientation and refraction correction. + +## Parts + +**Bridge (required):** +- ESP32-S3-DevKitC-1-N16R8 +- 2× MAX485 TTL-to-RS485 module +- 1× SparkFun Bidirectional Logic Level Converter (BOB-12009, BSS138-based) +- RJ-12 6P6C straight-wired cable with breakout +- Hookup wire / jumpers + +**Sensors (optional):** +- 1× GY-9250 (MPU-9250) — 9-axis IMU (accelerometer + gyroscope + magnetometer) +- 1× BMP388 — barometric pressure + temperature +- 1× RYS352A GPS module — observer location + PPS timing + +## Schematic + +``` + SparkFun Level Converter (BOB-12009) + ┌──────────────────────────────────────┐ + │ │ +ESP32 3V3 ──────────────►│ LV HV │◄── ESP32 5V +ESP32 GND ──────────────►│ GND GND │◄── (shared) + │ │ +ESP32 GPIO17 (TX) ──────►│ LV1 HV1 │──────► MAX485₁ DI +ESP32 GPIO18 (RX) ◄──────│ LV2 HV2 │◄────── MAX485₂ RO + │ │ + │ LV3 (spare) HV3 (spare) │ + │ LV4 (spare) HV4 (spare) │ + └──────────────────────────────────────┘ + + + MAX485 Board 1 (TX only) MAX485 Board 2 (RX only) + ┌────────────────────────┐ ┌────────────────────────┐ + │ VCC ◄── 5V │ │ VCC ◄── 5V │ + │ GND ◄── GND │ │ GND ◄── GND │ + │ │ │ │ + │ DI ◄── HV1 │ │ RO ──► HV2 │ + │ RO (unused) │ │ DI (unused) │ + │ │ │ │ + │ DE ◄── 5V ┐ locked │ │ DE ◄── GND ┐ locked │ + │ RE ◄── 5V ┘ TX mode │ │ RE ◄── GND ┘ RX mode │ + │ │ │ │ + │ A ───────────────────┼──► pin 2 │ A ◄──────────────────┼── pin 4 + │ B ───────────────────┼──► pin 3 │ B ◄──────────────────┼── pin 5 + └────────────────────────┘ └────────────────────────┘ + + RJ-12 to Carryout G2 + ┌───────────────────────────┐ + │ Pin 1 (White) ── GND │◄── ESP32 GND + │ Pin 2 (Red) ── TX+/TA │◄── A₁ + │ Pin 3 (Black) ── TX-/TB │◄── B₁ + │ Pin 4 (Yellow) ── RX+/RA │──► A₂ + │ Pin 5 (Green) ── RX-/RB │──► B₂ + │ Pin 6 (Blue) ── N/C │ + └───────────────────────────┘ +``` + +## Power Rails + +``` +ESP32 5V ──┬── Level Converter HV + ├── MAX485₁ VCC + ├── MAX485₁ DE + RE (tied high = TX mode) + └── MAX485₂ VCC + +ESP32 3V3 ─── Level Converter LV + +ESP32 GND ─┬── Level Converter GND + ├── MAX485₁ GND + ├── MAX485₂ GND + ├── MAX485₂ DE + RE (tied low = RX mode) + └── RJ-12 Pin 1 +``` + +## RJ-12 Cable Notes + +Straight-wired 6P6C. Pin 1 is leftmost when looking at the jack with the clip +facing away from you (tab down). Wire colors per the standard flat cable: + +| Pin | Color | Function | Connects to | +|-----|--------|------------------|--------------------| +| 1 | White | GND | Common ground | +| 2 | Red | TX+ (TA) | MAX485₁ A | +| 3 | Black | TX- (TB) | MAX485₁ B | +| 4 | Yellow | RX+ (RA) | MAX485₂ A | +| 5 | Green | RX- (RB) | MAX485₂ B | +| 6 | Blue | N/C | — | + +If crimping your own cable, verify pin-to-color with a multimeter before +connecting to the dish. RJ-12 crimps are easy to get reversed (pins mirror +if the connector is flipped). A wrong connection won't damage anything +(differential signals are current-limited) but communication won't work. + +## How It Works + +The Carryout G2 uses RS-422 full-duplex: two separate differential pairs, +one for each direction. The MAX485 is a half-duplex RS-485 transceiver with +a shared A/B pair and direction control pins (DE/RE). By hardwiring DE/RE, +each board is locked into a single direction: + +- **Board 1 (TX):** DE=HIGH, RE=HIGH → driver always enabled, receiver disabled. + ESP32 UART1 TX → level shifter → DI → differential A/B → G2 serial RX. + +- **Board 2 (RX):** DE=LOW, RE=LOW → driver disabled, receiver always enabled. + G2 serial TX → differential A/B → RO → level shifter → ESP32 UART1 RX. + +The SparkFun level converter translates between 3.3V (ESP32) and 5V (MAX485) +on both data lines. The two spare channels (LV3/HV3, LV4/HV4) are available +if DE/RE ever need GPIO control for a half-duplex variant. + +## Firmware + +See `firmware/ble-bridge/` — transparent BLE Nordic UART Service (NUS) bridge. +The firmware is the same regardless of whether the RS-422 transceiver is a +MAX490 (single full-duplex chip) or two MAX485s (locked half-duplex pair). +It only sees UART TX/RX on GPIO17/18. + +## Sensors — I2C Bus + +The MPU-9250 and BMP388 share a single I2C bus on GPIO8 (SDA) / GPIO9 (SCL). +Both run at 3.3V directly from the ESP32, no level shifting needed. + +``` + I2C Bus (3.3V, 400kHz) + ───────────────────── + +ESP32 3V3 ──┬──────────────────┬─── MPU-9250 VCC + │ └─── BMP388 VCC + │ + ├── 4.7KΩ ── SDA bus ──┬── MPU-9250 SDA + │ └── BMP388 SDI + │ + └── 4.7KΩ ── SCL bus ──┬── MPU-9250 SCL + └── BMP388 SCK + +ESP32 GPIO8 (SDA) ──── SDA bus +ESP32 GPIO9 (SCL) ──── SCL bus + +ESP32 GND ──┬── MPU-9250 GND + └── BMP388 GND (SDO to GND = addr 0x76) + +MPU-9250 AD0 ── GND (I2C address = 0x68) +BMP388 SDO ── GND (I2C address = 0x76) +``` + +The 4.7KΩ pull-ups are shared — one pair for the whole bus. Many breakout +boards include onboard pull-ups already; if both the GY-9250 and BMP388 +boards have them, the combined parallel resistance (~2.3KΩ) is still fine +for 400kHz I2C at 3.3V. Only add external pull-ups if neither board has them. + +### MPU-9250 (GY-9250) — 9-Axis IMU + +| I2C Address | 0x68 (AD0 → GND) | +|-------------|-------------------| +| VCC | 3-5V (onboard LDO) | +| Interface | I2C (up to 400kHz) or SPI | + +**What it provides for satellite tracking:** + +- **Magnetometer (AK8963):** Compass heading for automatic north alignment. + Eliminates manual alignment of dish base "BACK" marking to true north. + Apply local magnetic declination to convert magnetic north → true north. +- **Accelerometer:** Gravity vector → tilt angle = elevation. Independent + verification of the dish firmware's reported EL position. +- **Gyroscope:** Angular rate during slews. Detect oscillation, overshoot, + and vibration for tuning the leapfrog overshoot compensation algorithm. + +**Mounting considerations:** The magnetometer is extremely sensitive to nearby +ferrous metals and electromagnetic interference from motors. Mount on the +fixed base plate, away from motor housings, with a known axis aligned to the +dish's reference direction. Rigid mounting — any flex between sensor and dish +structure introduces measurement error. + +### BMP388 — Barometric Pressure + Temperature + +| I2C Address | 0x76 (SDO → GND) | +|-------------|-------------------| +| VCC | 3.3V | +| Pressure range | 300-1250 hPa | +| Pressure resolution | ±0.01 hPa (±8 cm altitude) | +| Temperature accuracy | ±0.5°C | +| Interface | I2C (up to 3.4MHz) or SPI | + +**What it provides for satellite tracking:** + +- **Atmospheric refraction correction.** Radio signals bend as they pass + through the atmosphere, especially at low elevation angles. The amount of + bending depends on air pressure and temperature. At 15° elevation (the + Trav'ler's minimum), refraction shifts apparent position by ~0.2°. + Standard refraction models (Bennett, Saemundsson) take pressure and + temperature as inputs — the BMP388 provides both in real time. +- **Temperature monitoring.** Ambient temperature at the dish for thermal + drift awareness and electronics health monitoring. + +**Refraction formula (simplified Bennett):** +``` +R = 1/tan(el + 7.31/(el + 4.4)) × (P/1010) × (283/(273 + T)) +``` +Where R is refraction in arcminutes, el is apparent elevation in degrees, +P is pressure in hPa, T is temperature in °C. At el=15°, P=1013, T=20°C: +R ≈ 3.4 arcmin ≈ 0.057°. Small but meaningful for narrow-beam antennas. + +## GPS — RYS352A + +The RYS352A is a compact GPS module with PPS output. It connects via UART2 and +provides observer location for satellite pass prediction and a 1Hz PPS pulse +for precise UTC time synchronization. + +``` +ESP32 GPIO5 (UART2 RX) ◄── RYS352A TX (NMEA sentences out) +ESP32 GPIO6 (UART2 TX) ──► RYS352A RX (config commands in, optional) +ESP32 GPIO7 ◄── RYS352A PPS (1Hz rising edge, ~100ns jitter) +ESP32 3V3 ──► RYS352A VCC +ESP32 GND ──► RYS352A GND +``` + +| Module Pin | ESP32 Pin | Function | +|------------|-----------|----------| +| VCC | 3V3 | 3.3V power (onboard LDO on most breakouts) | +| GND | GND | Ground | +| TX | GPIO5 (UART2 RX) | NMEA sentence output at 115200 baud | +| RX | GPIO6 (UART2 TX) | PAIR/NMEA config input (optional) | +| PPS | GPIO7 | 1Hz pulse synchronized to GPS time | + +**PPS (Pulse Per Second):** The RYS352A outputs a precise 1Hz pulse on the +rising edge, synchronized to UTC via GPS constellation. The firmware captures +this edge via interrupt (`micros()` timestamp) for correlating satellite events +with sub-microsecond precision relative to the GPS epoch. The module's RTC +battery backup enables warm starts (~5s) after initial cold start fix (~30-60s). + +**UART notes:** The RYS352A defaults to 115200 baud NMEA output with `GN` +talker ID (multi-constellation). The TX line (GPIO6) is used at boot to send +`$PAIR` proprietary commands (Airoha AG3352 engine) that configure the GPS +module for satellite tracking use. See `docs/RYS352x_PAIR_Command_Guide.md` +for the full command reference. + +**Boot-time PAIR init sequence:** The firmware sends `$PAIR062` commands at +startup to filter NMEA output — only GGA (position/quality), GSA (fix mode/DOP), +RMC (time/date/speed), and GSV (satellite visibility, every 5th fix) are enabled. +Redundant sentences (GLL, VTG, ZDA, GRS, GST, GNS) are disabled to reduce parser +load and latency. A `$PAIR752` command configures PPS to pulse only on 2D/3D fix +with 100ms pulse width. Each command waits for `$PAIR001` ACK; failures are +logged but non-fatal — the GPS works with defaults if PAIR commands are unsupported. + +The firmware uses TinyGPS++ v1.1+ with custom field extractors (`TinyGPSCustom`) +to read GGA quality (field 6: SPS/DGPS/RTK) and GSA nav mode (field 2: 2D/3D) +directly from the NMEA stream, replacing the earlier heuristic that inferred +fix type from altitude validity. Both `GN` and `GP` talker ID variants are +registered for compatibility across constellation configurations. + +## Full GPIO Map + +| GPIO | Function | Interface | Notes | +|------|----------|-----------|-------| +| 5 | GPS RX | UART2 RX | ← RYS352A TX (NMEA out) | +| 6 | GPS TX | UART2 TX | → RYS352A RX (config in) | +| 7 | GPS PPS | GPIO interrupt | 1Hz rising edge | +| 8 | I2C SDA | I2C | MPU-9250 + BMP388 (shared bus) | +| 9 | I2C SCL | I2C | MPU-9250 + BMP388 (shared bus) | +| 17 | RS-422 TX | UART1 TX | → Level shifter → MAX485₁ DI | +| 18 | RS-422 RX | UART1 RX | ← Level shifter ← MAX485₂ RO | +| 38 | RGB LED | WS2812 | Onboard NeoPixel (DevKitC V1.1) | +| 43 | USB Console TX | UART0 | CH343 USB-serial (untouched) | +| 44 | USB Console RX | UART0 | CH343 USB-serial (untouched) | + +## Loopback Test (no dish) + +Before connecting to the G2, verify the bridge by shorting MAX485₁ A to +MAX485₂ A, and MAX485₁ B to MAX485₂ B (loop TX back into RX). Anything +sent via BLE or USB serial should echo back. diff --git a/docs/g2-nvs-dump.md b/docs/g2-nvs-dump.md new file mode 100644 index 0000000..038409c --- /dev/null +++ b/docs/g2-nvs-dump.md @@ -0,0 +1,1707 @@ +# Carryout G2 Firmware Exploration + +**Firmware:** Version 02.02.48 (Copyright 2013 - Winegard Company) +**Date:** 2026-02-12 +**Connection:** DSD TECH SH-U11 USB RS-422 @ 115200 8N1 + +## Hardware Platform + +Discovered via `os` → `id` command: + +``` +NXP Kinetis K60 ARM Cortex-M4 + Package: 144-pin + Silicon Rev: 2.4 + Mask Set: 4N22D + P-Flash: 512 KB + RAM: 128 KB + Core Clock: 96 MHz (CCLK) + Bus Clock: 48 MHz (BCLK) + System ID: TWELINCH + Antenna ID: 12-IN G2 + Software: 02.02.48 + Flash Base: 0x00010000 (65536) + Flash Size: 458752 bytes (448 KB) +``` + +The "TWELINCH" system ID = "Twelve Inch", matching the Carryout G2's ~12" dish +diameter. Flash starts at 64 KB offset (first 64 KB is bootloader/vector table), +leaving 448 KB for application firmware. + +**Exact part number:** MK60DN512VLQ10 +- MK60 = Kinetis K60 family (Cortex-M4 + DSP) +- DN512 = 512 KB program flash (no FlexNVM) +- VLQ = LQFP 144-pin package (20x20mm) +- 10 = 100 MHz max frequency + +**Datasheets:** `docs/K60-datasheet.pdf` (K60P144M100SF2V2, Rev 3, 6/2013), +`docs/K60-reference-manual.pdf` (K60P144M100SF2V2RM, ~1800 pages) + +### Key Peripherals (from datasheet) + +| Peripheral | Count | Notes | +|------------|-------|-------| +| UART | 5 | UART0 = RS-422 console (confirmed) | +| DSPI | 3 | SPI with DMA; DSPI0/1 likely → A3981 motor drivers | +| I2C | 2 | | +| ADC | 2× 16-bit | 863ns conversion; ADC0 likely → RSSI measurement | +| DAC | 2× 12-bit | | +| USB | 1× OTG | On-chip transceiver, no external PHY needed | +| CAN | 2 | Likely unused | +| Ethernet | 1× IEEE 1588 | Likely unused | +| FlexTimer | 3 (12 ch) | Motor PWM / step timing | +| DMA | 16 channel | | + +### USB Port (Potentially Accessible) + +The K60 has **dedicated USB pins** (not muxable with GPIO): + +| LQFP Pin | Signal | Function | +|----------|--------|----------| +| 19 | USB0_DP | USB Data+ | +| 20 | USB0_DM | USB Data- | +| 21 | VOUT33 | USB VREG 3.3V output | +| 22 | VREGIN | USB VREG 5V input (self-power from USB) | + +The Trav'ler Pro uses USB A-to-A (`ttyACM0`) for its serial console — this +proves Winegard has USB CDC/ACM firmware for the Kinetis platform. The G2 may +also have a USB connector on the PCB (possibly internal, for field service). + +NVS indices 2 ("Debug 2nd Console Port") and 4 ("Debug Port Connection") hint +at multiple console port support — USB could be the second port. + +## Root Menu Structure + +At the `TRK>` prompt, `?` lists all available submenus: + +| Submenu | Command | Description | +|---------|---------|-------------| +| A3981 | `a3981` | Allegro A3981 stepper motor driver IC control | +| ADC | `adc` | Analog-to-digital converter / RSSI / board ID | +| Dipswitch | `dipswitch` | DIP switch configuration readout | +| DVB | `dvb` | BCM4515 DVB receiver / signal analysis | +| EEPROM | `eeprom` | Non-volatile EEPROM storage (separate from NVS) | +| GPIO | `gpio` | MCU pin register dump (5 ports, 98 pins) | +| LATLON | `latlon` | Satellite longitude/elevation parameters | +| MOT | `mot` | Degree-based motor positioning (high-level) | +| NVS | `nvs` | Non-volatile settings (operational parameters) | +| OS | `os` | Operating system / task manager / MCU identification | +| PEAK | `peak` | Signal peaking / DiSEqC switch testing | +| STEP | `step` | Raw microstep motor control (low-level) | + +Three-layer motor control architecture: +1. **`step`** — raw microstep commands (ustep/sec, engage/release motors) +2. **`mot`** — degree-based positioning (`a `, `h `) +3. **Application** — satellite tracking (NVS config, peak, DVB) + +## NVS Values + +``` +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 ← MODIFIED + 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 +``` + +## Key Parameters for Satellite Tracking + +| NVS | Name | Value | Notes | +|-----|------|-------|-------| +| 20 | Disable Tracker Proc? | TRUE | Prevents TV satellite search on boot | +| 83 | AZ Steps/Rev | 40000 | Centidegrees per revolution (400.00°) | +| 88 | EL Steps/Rev | 24960 | ~249.60° per revolution | +| 80 | AZ Max Vel | 65.00 | °/s azimuth max velocity | +| 85 | EL Max Vel | 45.00 | °/s elevation max velocity | +| 101 | Min Elevation | 18.00 | Firmware floor (degrees) | +| 102 | Max Elevation | 65.00 | Firmware ceiling (degrees) | +| 103 | EL Home Angle | 65.00 | Where EL homes to on startup | +| 128-133 | PID Gains | varies | AZ/EL motor PID tuning parameters | + +## Boot Sequence Observed + +### Bootloader Phase (<50ms, non-interactive) + +Captured via `scripts/boot_capture.py` with high-resolution timestamps: + +``` +[0.050s] 01 00 ← binary status bytes (bootloader→app handshake?) +[0.050s] Bootloader version: 1.01 +[0.050s] Application is running... +[0.050s] 98 80 96 ← binary bytes (integrity check? jump address?) +[0.100s] Application Starting Kinetis PCB... power up/reset +``` + +The bootloader runs at **115200 baud** (same as application — confirmed by +multi-baud capture at 9600/19200/38400/57600/230400/460800). There is **no +interactive window** — ESC, CR, BREAK, 0x55 autobaud, and other interrupt +sequences at 5-30ms delays all failed to stop the boot. The bootloader +checks a flag (likely in EEPROM or a reserved flash sector) and immediately +jumps to the application at 0x10000 if no firmware update is pending. + +### Application Phase (~10s to prompt) + +``` +Version 02.02.48 +Copyright 2013 - Winegard Company +Boot Complete + +Loc Startup: IDU NOT Present +app_dipswitch:101 +Primary Update: 10100 +Alternate Update: 11900 +Toggle Ability Update: 0 +Alternate2 Update: 0 +Sat Provider Update: 1 +DVB: id:0000, lon:101.00E +Tuner = WIDE +Signal offset = 80 +Signal baseline angle = 6500 +Signal Re-Peak Pct = 25 +NVS Status: 0 Sleep: 420 Dipswitch: 101 +Sleep: 420 NVS: 420 +NoGpsStartUp: 721 +STATIONARY MODE +Enabled LNB ODU 18V +GPS Not Found +``` + +## Homing Sequence + +After boot, the dish homes both motors (EL first, then AZ) using stall detection: + +``` +MotorHome:1 timeout:2000 ← EL motor homing +Home TwelInch El Velocity: 4500 +EL Stall Timeout +El Home Angle: 6500 + +MotorHome:0 timeout:8000 ← AZ motor homing +Home TwelInch Az +End MotorAzStall:part1 +Antenna Facing Front +home:0 wrap_pos:0 wrap_min:-42333 wrap_max:2333 +``` + +## Cable Wrap Limits + +From homing output: `wrap_min:-42333 wrap_max:2333` +- In centidegrees: -423.33° to +23.33° from home position +- Total range: 446.66° (~1.24 full rotations) + +## Motor Control + +### Position Query + +In the `MOT>` submenu, `a` returns position with 4-space indentation: + +``` +a + Angle[0] = 180.00 ← AZ (degrees) + Angle[1] = 45.00 ← EL (degrees) +MOT> +``` + +### Move Command + +`a ` returns a confirmation (no array index) and the prompt immediately +while the motor moves in the background: + +``` +a 1 46 + Angle = 46.00 +MOT> +``` + +### Observed Motor Behavior + +| Test | Command | Target | Actual | Overshoot | +|------|---------|--------|--------|-----------| +| EL move out | `a 1 46` | 46.00 | 46.05 | +0.05° | +| AZ move out | `a 0 181` | 181.00 | 181.01 | +0.01° | +| EL return | `a 1 45` | 45.00 | 44.94 | -0.06° | +| AZ return | `a 0 180` | 180.00 | 179.98 | -0.02° | + +Direction-dependent overshoot: the motor consistently overshoots in the +direction of travel, undershooting on return. This is classic stepper +backlash + PID settling behavior and is what the leapfrog algorithm +compensates for. + +### MOT Submenu — Full Command Reference + +``` +Available commands: + a Go to angle [[[motor] [[+|-]angle]]] + azscan Scan AZ from EL Min-Max Angle [az_rel_angle] [el_rel_angle] [delay] + azscanwxp Scan AZ from EL Min-Max with all transponders [motor] [span] [resolution] [num_xp] + e Engage motors + ela2s Elevation Law Test - Angle to Steps [angle] + elminmaxhome Display Min, Max & Home Elevation Angles + els2a Elevation Law Test - Steps to Angle [steps] + h Home Motors [motor num or * (both)] + l List Motors in System + life Az/El Life test [az_rel_angle] [el_rel_angle] + ma Set Max Acceleration [[motor] [deg/sec/sec]] + motorboth Both Motor Life test [AZ delta(0-25)] [EL delta(0-25)] + motorlife Motor Life Test [motor_id] [min_angle] [max_angle] + mv Set Max Velocity [motor] [deg/sec] + p Go To Position [motor] [pos] + pid Set PID Parameters [motor] [Kp] [Kv] [Ki] + r Release Motors + sd Stall Detect [motor] [dir] [timeout_ms] [iterations (0=forever)] + sp Set Position [motor] [pos] + sw Set Wrap Position [motor] [pos] + v Goto Velocity [motor] [deg/rev] + vms Goto Velocity For Milliseconds [motor] [deg/rev] [ms] + w Wrap Manager [motor] [ON/OFF] +``` + +### Relative Moves + +`a` supports `+`/`-` prefix for **relative** moves: + +``` +a 0 +5 ← move AZ 5° CW from current +a 1 -2 ← move EL 2° down from current +``` + +This is undocumented in the upstream repos and very useful for incremental +positioning during tracking. + +### Motor List + +``` +l +Motors: + 0 - AZIMUTH: local + 1 - ELEVATION: local +``` + +"local" means direct A3981 driver control (vs. a networked motor controller). + +### Motor Dynamics + +``` +ma → Accel[0] = 400.0 Accel[1] = 400.0 (deg/sec²) +mv → Max Vel[0] = 65.0 Max Vel[1] = 45.0 (deg/sec) +``` + +Both axes have identical acceleration (400 deg/sec²). AZ max velocity is +faster (65 deg/sec) than EL (45 deg/sec) — different gear ratios and +mechanical loads. + +### Step Position + +`p` shows position in microsteps: + +``` +p → Position[0] = 19998 Position[1] = 3116 +``` + +Cross-check with angle: AZ 179.98° × (40000/360) = 19998 steps. Linear +mapping for both axes (angle = steps × 360 / steps_per_rev). + +### Elevation Limits + +``` +elminmaxhome → Min: 1800 Max: 6500 Home: 6500 +``` + +All values in centidegrees: **Min=18.00°, Max=65.00°, Home=65.00°**. The +home position is at maximum elevation (stow position). + +### Elevation Law Conversion + +`ela2s` converts angle → steps, `els2a` converts steps → angle: + +| Angle | Steps | Notes | +|-------|-------|-------| +| 0° | 1248 | Below min (warning: "Min: 1800") | +| 18° | 1248 | Minimum EL (same steps as 0°) | +| 45° | 3120 | | +| 65° | 4506 | Maximum EL | +| 90° | 4506 | Above max (warning: "Max: 6500") | + +The mapping is linear: steps ≈ angle × (24960/360). The "law" is a simple +linear function for this dish — no non-linear linkage compensation. + +### Engage / Release + +- `e` — Engage motors (enable A3981 drivers, PID loop holds position) +- `r` — Release motors (disable drivers, dish can move freely by hand) + +### Sky Scan Commands + +**`azscan `** — Scan AZ while stepping EL from +min to max angle. Parameters are relative angles and dwell delay. Used for +basic signal surveys. + +**`azscanwxp `** — Advanced sky scan +that steps in hundredths of a degree and checks all transponders at each +position. This is the core of Davidson's winegard-sky-scan project. The +`resolution` parameter in hundredths of a degree enables 0.01° precision +scanning — far finer than the standard `a` command. + +### Stall Detection + +`sd [iterations]` — Run stall detection on a +motor. Default timeouts: AZ=10000ms, EL=2000ms. The firmware drives the +motor in the specified direction until it stalls (current spike from A3981). +Set iterations to 0 for continuous mode. Used during homing and calibration. + +### Life / Durability Tests + +Factory test commands that continuously exercise the motors: + +- `life ` — Oscillate both axes by relative angles +- `motorlife ` — Sweep a single motor between min/max angles +- `motorboth ` — Exercise both motors, max 25° delta each + +### Write-Only Commands + +These commands require parameters — no read-only mode: + +- `pid ` — Set PID gains (no read command) +- `sp ` — Set step position counter (doesn't move motor) +- `sw ` — Set wrap position +- `w ON/OFF` — Enable/disable wrap manager + +## DVB Subsystem (BCM4515) + +### Hardware + +``` +BCM Hardware= ID: 0x4515 VER: 0xB0 +BCM Firmware= MAJOR VER: 0x71 (113) MINOR VER: 0x25 (37) +BCM Strap Config: 0x25018 +``` + +### Channel Parameters (`dis`) + +``` +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 +``` + +### RSSI Response Format + +``` +rssi 5 +iterations:5 interval(msec):20 + Reads:5 RSSI[avg: 500 cur: 500] +DVB> +``` + +500 is the noise floor (no signal lock, dish pointed at arbitrary sky). + +### LNB Voltage + +`lnbdc odu` enables LNA at 13V. `lnbv` streams continuous voltage readings: + +``` +Reads:1 LNB Voltage (mV): 13239 ( ADC value: 119 ) +Reads:2 LNB Voltage (mV): 13182 ( ADC value: 118 ) +... +``` + +Stable at ~13.11V (ADC 117). Boot default is 18V; `lnbdc odu` switches to 13V. +13V = vertical polarization, 18V = horizontal polarization on a standard LNB. + +### AGC (Streaming) + +`agc` streams RF and IF automatic gain control plus SNR/NID: + +``` +Reads:1 RF_AGC[avg: 1327353088 cur: 1327353088] IF_AGC[avg: 2684354560 cur: 2684354560] SNR: 0.0 NID: FFFF/none +``` + +- RF_AGC values are raw BCM4515 32-bit register values +- IF_AGC constant at 0xA0000000 (fixed IF gain) +- SNR: 0.0 when no signal lock +- NID: FFFF/none = no DVB network ID detected + +### SNR (Streaming) + +`snr` streams signal-to-noise ratio readings: + +``` +Reads:1 SNR[avg: 0.0 cur: 0.0] +Reads:2 SNR[avg: 0.0 cur: 0.0] +... +Reads:223 SNR[avg: 0.0 cur: 3.1] ← transient RF spike +Reads:250 SNR[avg: 0.0 cur: 2.6] ← another transient +``` + +At noise floor, average stays 0.0 but occasional transient spikes appear in +the `cur` field — fleeting RF energy. Stays in DVB submenu when interrupted. + +### Lock Status (`ls`) — Streaming + +`ls` is a STREAMING command that continuously scans all 32 transponders, trying +multiple modulations per frequency. Output is a continuous scan log: + +``` +Xp:1 Freq:974000 SymRate:20000 Mode:blind_scan ... no_lock +Xp:1 Freq:974000 SymRate:20000 Mode:turbo_qpsk_... ... no_lock +... +``` + +**When interrupted with CR, `ls` prints "Terminating shell." and drops to +`TRK>` (exits DVB submenu entirely).** This is unique among DVB streaming +commands — all others stay in DVB submenu when interrupted. + +### Quick Lock Status (`qls`) — Streaming + +`qls` streams compact lock status at ~100ms intervals: + +``` +Lock:0 rssi:500 cnt:0 +Lock:0 rssi:500 cnt:0 +... +``` + +Stays in DVB submenu when interrupted. Ideal for real-time signal monitoring +during dish movement (low bandwidth, high update rate). + +### Network ID (`nid`) — Streaming + +`nid` streams network identification: + +``` +nid: FFFF/none +nid: FFFF/none +``` + +Uses CR-overwrite (carriage return without newline) for in-place updates. +FFFF = no DVB network detected. Can be difficult to interrupt cleanly — +may need multiple CR + flush cycles. + +### Signal Statistics (`stats`) — Streaming + +`stats` streams signal statistics. Produces no output when there is no signal +lock. Stays in DVB submenu when interrupted. + +### Diagnostics (`diag`) — One-Shot + +`diag` is a multi-block ONE-SHOT command that outputs detailed per-transponder +diagnostics for each transponder currently being tried: + +``` + SymRate: 18514984 Freq: 974000 + Bit Rate: 29893160 + SNR: 0.0 SymRateErr: -1258 CarrierOffset: 0 CarrierErr: 3295097 + Tuner LPF: 12 RF_AGC: 2214707264 + BER Errors: 0 MPEG Frm Errors: 0 MPEG Frm Count: 0 + Reacquisitions: 7 + RS Corr/Uncorr: 0 / 0 Pre-Vit: 0 + sp inv: scan phase rotation: 0 + Acq Time: 4259 msec + trans_mod_coderate: no_lock + Tuner PLL: LOCKED Internal BERT: not locked + Demod: not locked Timing Loop Lock: disabled +``` + +### Transponder Table (`table`) — One-Shot (Long) + +`table` generates a full scan across all 32 transponders. Takes ~136 seconds +(~4.25s per transponder, matching `tabto` acquisition timeout of 4000ms). + +The output starts with a configuration summary showing all modifiable parameters +with their current values and the commands to change them, then a detailed +per-transponder table with columns: + +``` +Xp Freq SigDet SymRate PeakPower SNR LPF RF_AGC AcqTime Mode +NID/SAT XpTime LNBVsens RSSI BitrateOut SymRateError CarrierOffset +CarrierError BER_Errors MPEG_Errors MPEG_Count Reacq +``` + +With shuffle mode ON, transponders scan in interleaved groups of 4: +1,2,3,4 → 17,18,19,20 → 5,6,7,8 → 21,22,23,24 → 9,10,11,12 → 25,26,27,28 +→ 13,14,15,16 → 29,30,31,32. + +LNB voltage alternates between ~13V (vertical polarization) and ~20V +(horizontal polarization) across transponder groups. + +After scan completes: `Table Completion Time (seconds): 136 Transponders Locked: 0` +then auto-restores LNB to STB mode: `Enabled LNB STB`. + +### Frequency List (`freqs`) + +Returns the active frequency list name: + +``` +freqs → Non-Stacked +``` + +"Non-Stacked" means standard Ku-band IF frequencies without stacking +(stacked LNBs combine multiple bands into one cable). + +### Transponder Range (`range`) + +``` +range → Transponder Range: [ 1 - 32 ] +``` + +### Power State (`pwr`) + +``` +pwr → SDS and DiSEqC core power enabled +``` + +Shows power state of the Satellite Detection System and DiSEqC subsystems. + +### Search Mode (`srch_mode`) + +``` +srch_mode → Auto Search Mode: 1 +``` + +Mode 1 = automatic transponder search with blind scan. + +### Timeout Settings + +Two separate timeout configurations: + +**Table scan timeouts (`tabto`):** +``` +Timeout (msec): Acq:4000 NID:20000 Signal Detect:0 +``` + +**Single transponder tune timeouts (`to`):** +``` +Timeout (msec): Acq:500 NID:12000 +``` + +`tabto` takes arguments: `tabto ` (set 0 to disable NID/SD). +`to` takes arguments: `to `. + +### Toggle Commands + +These commands toggle a mode and print the new state: + +| Command | What it toggles | +|---------|----------------| +| `srch` | Search Transponders mode (on/off) | +| `shuf` | Transponder shuffle order (on/off) | +| `tablex` | Extended table mode (on/off) | + +### Defaults Reset (`def`) + +`def` silently resets all channel parameters to defaults. **No output, no +confirmation.** Use with caution — there is no undo. + +### Modulation Switch (`msw`) + +`msw` requires arguments but rejects all tested inputs (`msw 0`, `msw 1`, +`msw on`). Format unknown — possibly vestigial or requires a specific +modulation string. + +### Channel Parameter Help (`h `) + +`h ` shows valid values for each of the 13+2 channel parameters: + +| Param | Name | Range / Values | +|-------|------|---------------| +| 1 | Frequency | 250000–2150000 kHz (L-band IF) | +| 2 | Symbol Rate | 0 (Peak Scan) or 2000–45000 ksps | +| 3 | Trans_Mod_CRate | 30+ modes (see table below) | +| 4 | Blind Scan Mode | Bitmask: s2, trb, dvb, dss, dcii (see below) | +| 5 | LNB Polarity | 13V (vertical) / 18V (horizontal) | +| 6 | LNB Tone | off / on (22kHz for high-band switching) | +| 7 | Roll-off | 0.20 / 0.35 | +| 8 | LPF Cutoff | 0 (auto) or 1–40 MHz | +| 9 | Carrier Offset | -5000 to +5000 Hz | +| 10 | FreqSearchRange | 0–10000 kHz | +| 11 | DCII Mode | comb, i, q (QPSK) / ocomb, oi, oq (OQPSK) | +| 12 | Spectral Inversion | 0=normal, 1=q inv, 2=i inv, 3=scan | +| 13 | PScnSymRtRngMin | 2000–45000 ksps | +| 14 | PScnSymRtRngMax | (shown in `dis`, no `h 14` help) | +| 15 | SignalDetectMode | (shown in `dis`, no `h 15` help) | + +#### Supported Modulations (Param 3) + +``` +DVB-S: dvbs_qpsk_1_2, dvbs_qpsk_2_3, dvbs_qpsk_3_4, + dvbs_qpsk_5_6, dvbs_qpsk_7_8 +DSS: dss_qpsk_1_2, dss_qpsk_2_3, dss_qpsk_6_7 +DCII: dcii_qpsk_1_2, dcii_qpsk_2_3, dcii_qpsk_3_4, + dcii_qpsk_5_11, dcii_qpsk_4_5 +DVB-S2: s2_qpsk_1_2, s2_qpsk_3_5, s2_qpsk_2_3, s2_qpsk_3_4, + s2_qpsk_4_5, s2_qpsk_5_6, s2_qpsk_8_9, s2_qpsk_9_10, + s2_8psk_3_5, s2_8psk_2_3, s2_8psk_3_4 +Turbo: turbo_qpsk_1_2, turbo_qpsk_2_3, turbo_qpsk_3_4, + turbo_qpsk_5_6, turbo_qpsk_7_8, + turbo_8psk_2_3, turbo_8psk_3_4, turbo_8psk_4_5, + turbo_8psk_5_6, turbo_8psk_8_9 +Special: blind_scan +``` + +#### Blind Scan Mode Bitmask (Param 4) + +Visual bitmask format: `s2_trb_dvb_dss_dcii` + +| Value | Pattern | Standards enabled | +|-------|---------|-------------------| +| `s2` | `s2_______________` | DVB-S2 only | +| `trb` | `___trb___________` | Turbo FEC only | +| `dvb` | `_______dvb_______` | DVB-S only | +| `dss` | `___________dss___` | DSS (DirecTV) only | +| `dcii` | `_______________dcii` | DCII (DigiCipher) only | +| `all` | `s2_trb_dvb_dss_dcii` | All standards | +| `dish` | `___trb_dvb________` | DISH Network (turbo + DVB-S) | +| `3` | `___trb_dvb_dss___` | DirecTV 3-standard | +| `shaw` | `___trb_________dcii` | Shaw (turbo + DCII) | +| `nodcii` | `s2_trb_dvb_dss___` | All except DCII | +| `nos2` | `___trb_dvb_dss_dcii` | All except DVB-S2 | + +### DiSEqC Commands + +All DiSEqC 2.x read commands fail with `RxReplyTimeout` when no switch is +connected (expected — the G2 has a direct LNB without a multi-switch): + +| Command | Function | Result (no switch) | +|---------|----------|--------------------| +| `di2id` | Read LNB hardware ID | `LNB Read HW ID FAIL` | +| `di2stat` | Read LNB status | `LNB Read Status FAIL` | +| `di2conf` | Read LNB config | `LNB Read Config FAIL` | +| `di2sc` | Short circuit test | `LNB Short Circuit FAIL` | +| `di2rcs` | Read switch state | `LNB Switch State FAIL` | +| `di2cs` | Configure switch | `No Parameters Specified` (needs args) | +| `send <3-6 bytes>` | Raw DiSEqC packet | (not tested — no switch) | + +### DiSEqC Timing Parameters + +| Command | Parameter | Default | +|---------|-----------|---------| +| `ovraddr` | LNB address | 0x11 (standard first LNB) | +| `rrto` | Receive reply timeout | 210 ms | +| `tdthresh` | Tone detect threshold | 110 (units: 0.16 counts/mV) | +| `pretx` | Pre-command TX delay | 15 ms | + +### DVB Command Reference + +| Command | Type | Description | +|---------|------|-------------| +| `rssi ` | One-shot | Average signal strength over n samples | +| `snr` | Streaming | SNR readings (avg + current) | +| `agc` | Streaming | RF/IF AGC + SNR + NID | +| `lnbdc odu` | One-shot | Enable LNB in ODU mode (13V) | +| `lnbv` | Streaming | Continuous LNB voltage monitoring | +| `ls` | Streaming | Full transponder lock scan (exits DVB on interrupt!) | +| `qls` | Streaming | Quick lock status (~100ms updates) | +| `nid` | Streaming | Network ID (CR-overwrite display) | +| `stats` | Streaming | Signal statistics (silent when no lock) | +| `config` | One-shot | BCM hardware/firmware version | +| `dis` | One-shot | Display all channel parameters | +| `diag` | One-shot | Multi-block per-transponder diagnostics | +| `table` | One-shot | Full 32-transponder scan (~136s) | +| `freqs` | One-shot | Frequency list name | +| `range` | One-shot | Transponder scan range | +| `pwr` | One-shot | SDS/DiSEqC power state | +| `srch_mode` | One-shot | Auto search mode value | +| `tabto` | Read/Write | Table scan timeouts (acq/nid/sd) | +| `to` | Read/Write | Single tune timeouts (acq/nid) | +| `t ` | Write | Select transponder | +| `e ` | Write | Edit channel parameter | +| `h ` | Read | Parameter help (valid values for param n, 1-13) | +| `srch` | Toggle | Search transponders mode | +| `shuf` | Toggle | Transponder shuffle order | +| `tablex` | Toggle | Extended table mode | +| `def` | Write | Reset all params to defaults (silent, no undo!) | +| `msw ` | Write | Modulation switch (format unknown) | +| `ovraddr [addr]` | Read/Write | DiSEqC LNB address | +| `rrto [ms]` | Read/Write | DiSEqC receive reply timeout | +| `tdthresh [val]` | Read/Write | DiSEqC tone detect threshold | +| `pretx [ms]` | Read/Write | DiSEqC pre-TX delay | +| `di2id` | Read | DiSEqC 2.x: read LNB hardware ID | +| `di2stat` | Read | DiSEqC 2.x: read LNB status | +| `di2conf` | Read | DiSEqC 2.x: read LNB config | +| `di2sc` | Read | DiSEqC 2.x: short circuit test | +| `di2rcs` | Read | DiSEqC 2.x: read switch state | +| `di2cs ` | Write | DiSEqC 2.x: configure switch | +| `send <3-6 hex>` | Write | Raw DiSEqC packet | + +**Streaming commands:** `snr`, `agc`, `lnbv`, `qls`, `nid`, `stats` run until +CR interrupts. All stay in DVB submenu except `ls` which drops to `TRK>`. + +**Toggle commands:** `srch`, `shuf`, `tablex` alternate on/off and print new state. + +## Satellite Configuration + +``` +DVB: id:0000, lon:101.00E ← DirecTV 101°W (stored as East longitude) +Primary Update: 10100 ← 101.00° in centidegrees +Alternate Update: 11900 ← 119.00° +Sat Provider Update: 1 ← Provider ID +Dipswitch Value: 101 ← DirecTV configuration +``` + +## A3981 Motor Driver IC + +The Allegro A3981 is an automotive-grade programmable stepper motor driver +controlled by the K60 MCU via SPI. Two A3981 chips — one per axis (AZ, EL). + +**Datasheet:** `docs/A3981-datasheet.pdf` (Allegro Microsystems) +**ECAD files:** `docs/A3981-ecad.kicad_sym`, `docs/A3981-ecad.pretty/` + +### A3981 Commands (`A3981>`) + +| Command | Type | Description | +|---------|------|-------------| +| `diag` | Read | Fault diagnostics for both axes | +| `sm` | Read | Step size mode (AUTO/MANUAL) | +| `ss` | Read | Current step size (microstepping level) | +| `cm` | Read | Current control mode (AUTO/MANUAL) | +| `st ` | Write | Set torque/current parameters | +| `reset` | Write | Clear latched A3981 faults on both axes | + +### Diagnostics + +``` +diag +AZ DIAG: OK +EL DIAG: OK +``` + +### Microstepping Configuration + +``` +ss +KEY: FULL-16, HALF-8, QTR-4, EIGHTH-2, SIXTEENTH-1 +AZ Step Size:1 +EL Step Size:1 +``` + +Both axes at step size 1 = **1/16 microstepping** (finest available). The A3981 +supports full, half, quarter, eighth, and sixteenth steps. The inverted key +(FULL=16, SIXTEENTH=1) is the firmware's internal representation — likely a +divisor applied to the full-step pulse count. + +### Step Size and Current Modes + +``` +sm cm +AZ Step Size Mode = AUTO AZ: Mode = AUTO +EL Step Size Mode = AUTO EL: Mode = AUTO +``` + +AUTO mode means the driver dynamically adjusts microstepping resolution and +current level based on motor speed. At low speeds, fine microstepping (1/16) +provides smooth motion and precise positioning. At higher speeds, the driver +may switch to coarser steps to maintain torque. + +### Fault Reset + +``` +reset +Az/El A3981 Faults Reset. +``` + +Clears latched fault conditions (overcurrent, open load, thermal). This is a +**write** operation — it actively clears the fault registers, not a read-only +status check. Use `diag` for non-destructive fault checking. + +## ADC Subsystem (`ADC>`) + +| Command | Type | Description | +|---------|------|-------------| +| `m` | Streaming | Monitor RSSI continuously | +| `rssi` | One-shot | Read RSSI (same noise-floor value as DVB) | +| `scan` | Unknown | Scan ADC on azimuth axis | +| `bdid` | One-shot | Board identification string | +| `bdrevid` | One-shot | Board revision ID | + +### Board Identification + +``` +bdid → STATIONARY +bdrevid → A +``` + +"STATIONARY" confirms this is the non-mobile (non-in-motion) variant. Board +revision "A" is the first production revision. + +### RSSI via ADC + +``` +rssi → 500 +``` + +Same noise floor value (500) as the DVB `rssi` command. The ADC subsystem reads +the same analog signal path — likely a baseband power detector output from the +BCM4515 routed to a K60 ADC input. + +## Dipswitch (`DIPSWITCH>`) + +``` +dipswitch +val:ffffff01 +app_dipswitch:101 +``` + +The raw value `0xFFFFFF01` has the low byte = 0x01. The `app_dipswitch` value +101 corresponds to the DirecTV satellite configuration. Dipswitch values select +the satellite provider (DirecTV, DISH, Bell) and determine which transponder +frequencies the search algorithm tries. + +## EEPROM (`EE>`) + +| Command | Type | Description | +|---------|------|-------------| +| `ee ` | Read | Read EEPROM value at index | +| `ee ` | Write | Write EEPROM value at index | +| `inv []` | Write | **Invalidate** EEPROM index (destructive!) | +| `def` | Write | Restore all EEPROM values to factory defaults | + +EEPROM is a separate storage area from NVS. NVS holds operational parameters +(motor tuning, PID gains, satellite config). EEPROM appears to hold calibration +or factory data — likely stored in an external I2C EEPROM (AT24Cxx or similar) +rather than the K60's internal flash. + +### EEPROM Structure + +The EEPROM has exactly **17 indices (0-16)**. The firmware enforces bounds: + +``` +ee 17 +Index out of bounds:17 Min:0 Max:16 +``` + +Each index stores a 32-bit unsigned integer. Invalid entries return a sentinel +value of **65793 (0x00010101)** — three bytes of 0x01 in the low 24 bits. The +firmware distinguishes between valid and invalid reads: + +- **Valid:** `Read value = ` — data is trusted +- **Invalid:** `Failed to read. val:` — data exists but flagged invalid + +### Complete EEPROM Dump + +Dumped via `scripts/ee_dump.py` (pyserial script scanning all indices): + +| Index | Decimal | Hex | Status | Notes | +|------:|--------:|-----|--------|-------| +| 0 | 65793 | 0x00010101 | INVALID | Accidentally invalidated (`inv 0`) | +| 1 | 0 | 0x00000000 | OK | | +| 2 | 22897 | 0x00005971 | OK | | +| 3 | 3748 | 0x00000EA4 | OK | | +| 4 | 4346 | 0x000010FA | OK | | +| 5 | 11637 | 0x00002D75 | OK | | +| 6 | 65793 | 0x00010101 | INVALID | Factory default (never written) | +| 7 | 65793 | 0x00010101 | INVALID | Factory default | +| 8 | 65793 | 0x00010101 | INVALID | Factory default | +| 9 | 65793 | 0x00010101 | INVALID | Factory default | +| 10 | 65793 | 0x00010101 | INVALID | Factory default | +| 11 | 65793 | 0x00010101 | INVALID | Factory default | +| 12 | 65793 | 0x00010101 | INVALID | Factory default | +| 13 | 65793 | 0x00010101 | INVALID | Factory default | +| 14 | 65793 | 0x00010101 | INVALID | Factory default | +| 15 | 65793 | 0x00010101 | INVALID | Factory default | +| 16 | 65793 | 0x00010101 | INVALID | Factory default | + +Only **6 indices** (0-5) were ever written with valid data. Indices 6-16 have +never been programmed — all contain the 0x00010101 sentinel. The EEPROM has +capacity for 17 values but only the first 6 are used by this firmware version. + +### Value Analysis + +The valid EEPROM values (indices 1-5) don't correspond to any obvious motor +positions, NVS indices, or physical constants. They may represent: + +- **Factory calibration offsets** — motor encoder corrections, sensor trim +- **Manufacturing data** — serial number components, PCB revision, test results +- **Checksum/signature** — authentication for firmware/hardware pairing + +Index 0 (originally valid, now invalidated) was likely a calibration value or +header byte. Its original value is unknown — `def` might restore it. + +### Command Notes + +**`inv []`** — "Invalidate", not "inventory". Marks an index as invalid +by writing the 0x00010101 sentinel. The firmware still returns the raw value +on read but flags it as `Failed to read`. **Destructive and immediate** — no +confirmation prompt. + +**`def`** — Restores all EEPROM indices to factory defaults. Not tested on this +unit (would overwrite the accidentally-invalidated index 0 but also reset any +user-modified values). Worth trying if index 0's missing value causes issues. + +**`ee `** — Write a value to an index. Accepts decimal integers. +Can be used to restore invalidated indices if the original value is known. + +## GPIO Registers (`GPIO>`) + +| Command | Type | Description | +|---------|------|-------------| +| `regs` | Read | Dump all GPIO pin states (0/1) | +| `r ` | Read | Read single GPIO pin (e.g., `r A 5`) | +| `w ` | Write | Write to GPIO pin (toggle) | +| `dir ` | Write | Set pin direction (input/output) | + +The `regs` command dumps all MCU GPIO pins across 5 ports (K60 144-pin). +Note: pins A20-A23 and B12-B15 are not enumerated (reserved or unbonded): + +| Port | Pins Enumerated | High Pins (=1) | +|------|----------------|----------------| +| A | A0–A19, A24–A29 (26 pins) | A1, A3, A4, A5, A15, A16, A25–A29 | +| B | B0–B11, B16–B23 (20 pins) | B0, B1, B2, B3, B11 | +| C | C0–C19 (20 pins) | C10, C11, C12, C13, C18 | +| D | D0–D15 (16 pins) | D11, D12, D13 | +| E | E0–E12, E24–E29 (19 pins) | E0, E1, E2, E4, E5, E7, E9–E12, E24–E28 | + +Total: 101 pins enumerated. "Unknown bit E29" logged by firmware — pin E29 +is defined in hardware but not assigned a function (test point or reserved). + +## LATLON (`LATLON>`) + +| Command | Type | Description | +|---------|------|-------------| +| `l ` | Read | Calculate dish lat/lon from two satellite observations | + +This is the dish's **self-localization algorithm** — it triangulates its own +geographic position by observing two known geostationary satellites. The four +parameters are longitude and elevation pairs for two satellites. + +``` +l -110 -119 40 38 + anglesentered = -11000 -11900 4000 3800 + Lat = 4295 Lon = 25655 +``` + +Output is in centidegrees: Lat 4295 = 42.95°N, Lon 25655 = 256.55°E (= 103.45°W). +This is a reasonable US mid-latitude position given DISH Network satellites at +110°W and 119°W with elevation angles of 40° and 38°. + +The firmware uses this for automatic satellite look-angle computation when no +GPS is available (the G2 has no GPS module — "GPS Not Found" at boot). + +## PEAK Subsystem (`PEAK>`) + +| Command | Type | Description | +|---------|------|-------------| +| `ts` | **Streaming (DANGEROUS)** | EchoStar/DiSEqC switch toggle — runs forever, can't be interrupted | +| `pw ` | Motor+Signal | Peak wide — sweep around AZ/EL to find signal peak | +| `psnr ` | Motor+Signal | Peak SNR — sweep around AZ/EL optimizing for SNR | +| `pxy1 []` | Motor+Signal | Peak XY1 — 2D cross-pattern peak search, repeat n times (default 1) | +| `stb` | One-shot | STB control test — toggles LNB polarity and compares RSSI | +| `rssits` | **Streaming** | RSSI test — prints every 1 minute (exits submenu on interrupt!) | + +### EchoStar Switch Toggle (`ts`) — DANGEROUS! + +The `ts` command runs **indefinitely**, probing for a DiSEqC/EchoStar switch +by toggling LNB voltage and reading the switch response. It cannot be stopped +by sending `q` — the running command consumes all input. The only escape is to +close and reopen the serial port. + +``` +ts +(14000+ reads, all showing: 0b0000 0) +``` + +All reads returned `0b0000 0` — no switch connected (expected, since the G2's +LNB is directly connected without a multi-switch). + +### STB Control Test (`stb`) + +Toggles LNB polarization and compares RSSI at each polarity: + +``` +stb +Enabled LNB ODU 18V +Even_sig = 504 +Enabled LNB STB +Odd_sig = 232 +Enabled LNB STB +Odd_sig = 233 Ctr = 0 +``` + +- `Enabled LNB ODU 18V` → horizontal polarization (18V), `Even_sig = 504` +- `Enabled LNB STB` → vertical polarization (13V), `Odd_sig = 232` +- `Ctr = 0` → no DiSEqC switch response detected +- The 504 vs 232 difference (~2x) reflects the polarization-dependent noise + floor. "Even" = H-pol transponders (18V), "Odd" = V-pol (13V) — DBS convention. + +### RSSI Test (`rssits`) + +Starts a background RSSI monitoring task that prints every 1 minute. When +interrupted, **exits the PEAK submenu entirely** (drops to `TRK>` root), +similar to DVB `ls` behavior. + +## STEP Subsystem (`STEP>`) — Low-Level Motor Control + +| Command | Type | Description | +|---------|------|-------------| +| `e` | Write | Engage motor (enable driver) | +| `r` | Write | Release motors (disable driver, coast to stop) | +| `p` | Read/Write | Go to position (absolute, in microsteps), or read current position | +| `v` | Write | Go to velocity (continuous rotation) | +| `ma` | Read/Write | Set/get max acceleration (ustep/sec/msec) | +| `mv` | Read/Write | Set/get max velocity (ustep/sec) | +| `pid` | Read/Write | Set/get PID tuning values | + +This is the raw stepper motor interface — below the `mot` menu's degree-based +abstraction. Commands operate in **microsteps** rather than degrees. The +relationship between microsteps and degrees depends on: + +- Gear ratio (NVS 49, currently 0x00000000) +- Steps per revolution (NVS 83: AZ=40000, NVS 88: EL=24960) +- Microstepping level (A3981: both at 1/16) + +### Current Values (read mode) + +``` +p → Step Pos[0] = 19998 Step Pos[1] = 3116 +ma → Accel[0] = 44 Accel[1] = 28 +mv → Max Vel [0] = 7222 Max Vel [1] = 3120 +pid → Kp=250 Kv=50 +``` + +### MOT↔STEP Conversion Table + +| Parameter | STEP (microsteps) | MOT (degrees) | Factor | +|-----------|-------------------|---------------|--------| +| AZ position | 19998 | 179.98° | 111.11 steps/° | +| EL position | 3116 | 44.94° | 69.33 steps/° | +| AZ max vel | 7222 ustep/s | 65.0°/s | ÷111.11 | +| EL max vel | 3120 ustep/s | 45.0°/s | ÷69.33 | +| AZ accel | 44 ustep/s/ms | ~396°/s² | ×1000÷111.11 | +| EL accel | 28 ustep/s/ms | ~404°/s² | ×1000÷69.33 | + +### PID Tuning + +`pid` returns `Kp=250 Kv=50` — proportional and velocity gains. No Ki term, +indicating a PD (proportional-derivative) position loop. This is typical for +stepper motors which don't drift under holding torque. Matches NVS indices +128-129. Note: `pid` is write-only in `MOT>` but **readable in `STEP>`**. + +The `e`/`r` commands engage and release the A3981 motor drivers. When released, +the motors are unpowered and the dish can be moved by hand (useful for manual +positioning or emergency stow). When engaged, the PID loop holds position. + +## ADC Subsystem (`ADC>`) + +| Command | Type | Description | +|---------|------|-------------| +| `bdid` | Read | Board ID — returns `STATIONARY` | +| `bdrevid` | Read | Board revision ID — returns `A` | +| `rssi` | Read | Single RSSI reading (ADC value, not DVB RSSI) | +| `m` | Toggle? | Monitor RSSI — returns immediately, may enable background monitoring | +| `scan` | **Streaming** | Continuous ADC scan with position, RSSI, Lock, SNR, and delta | + +### Board Identity + +- **Board ID:** `STATIONARY` — distinguishes from mobile/in-motion antenna variants +- **Board Rev ID:** `A` — PCB revision + +### RSSI (ADC) + +`rssi` returns a single ADC reading: `232` at noise floor. This is the raw +ADC value from an analog RSSI detector (separate from the DVB tuner's digital +RSSI). The ADC RSSI baseline (~232) corresponds to the DVB `Odd_sig` in the +PEAK `stb` test, suggesting both measure the same RF path at V-pol. + +### Scan (Streaming) + +`scan` performs a continuous RF scan at the current position: + +``` +Starting position AZ:17998 EL:4494 Stop at:0 +Motor:0 Angle:17998 RSSI:232 Lock:0 SNR: 0.0 Scan Delta:0 +Motor:0 Angle:17998 RSSI:238 Lock:0 SNR: 0.0 Scan Delta:0 +Motor:0 Angle:17998 RSSI:233 Lock:0 SNR: 0.4 Scan Delta:0 +... +``` + +Positions are in centidegrees (17998 = 179.98°). `Scan Delta:0` indicates no +motor movement — the scan reads RSSI at the fixed position. `Stop at:0` suggests +it scans until manually interrupted. Occasional SNR transients (0.1–0.4 dB) +appear from fleeting RF energy. The scan likely moves the motor when used with +the `azscanwxp` sky-scan command from the MOT menu. + +## Dipswitch Subsystem (`DIPSWITCH>`) + +| Command | Type | Description | +|---------|------|-------------| +| `dipswitch` | Read | Read physical DIP switch state and interpreted value | + +``` +dipswitch +val:ffffff01 +app_dipswitch:101 +``` + +- `val: ffffff01` — raw GPIO register reading. The `0x01` LSB indicates one + switch position is active; `0xFF` bytes are pulled-up (inactive) switches. +- `app_dipswitch: 101` — firmware-interpreted value. Matches NVS index 113 + ("Dipswitch Value: 101"), which encodes a DirecTV satellite configuration. + +The physical DIP switch on the PCB selects the satellite provider's transponder +list for TV search mode. NVS index 112 ("Disable Dipswitch?") controls whether +the firmware reads the physical switch or uses the NVS-stored value. Since +we've disabled the tracker (NVS 20 = TRUE), the DIP switch setting is ignored. + +## A3981 Motor Driver (`A3981>`) + +| Command | Type | Description | +|---------|------|-------------| +| `diag` | Read | Read AZ/EL diagnostic pins (fault status) | +| `sm` | Read/Write | Get/set step size mode (AUTO, manual) | +| `ss` | Read/Write | Get/set step size (microstepping divisor) | +| `st` | Read/Write | Get/set torque level (HIGH/LOW current) | +| `cm` | Read/Write | Get/set current control mode (AUTO, manual) | +| `reset` | Write | Reset AZ/EL A3981 diagnostic/fault registers | + +### Current State + +``` +diag → AZ DIAG: OK EL DIAG: OK +sm → AZ: Step Size Mode = AUTO EL: Step Size Mode = AUTO +ss → AZ: Step Size:1 EL: Step Size:1 +st → AZ Torq:LOW EL Torq:LOW +cm → AZ: Mode = AUTO EL: Mode = AUTO +``` + +### Step Size Key + +The `ss` value is a microstepping **divisor**, not multiplier: + +| Value | Mode | Microsteps per full step | +|-------|------|------------------------| +| 16 | FULL | 1 (full step) | +| 8 | HALF | 2 | +| 4 | QTR | 4 | +| 2 | EIGHTH | 8 | +| 1 | SIXTEENTH | 16 (finest) | + +Both motors are at `1` (1/16 microstepping — finest resolution). In `AUTO` +mode, the A3981 IC automatically selects the step size based on speed: finer +steps at low speed for smooth positioning, coarser steps at high speed for +torque. + +### Torque and Current Control + +`st` shows torque is `LOW` (motors idle/holding). `cm` shows current control +is `AUTO` — the firmware switches between high current (moving) and low current +(holding) automatically. This reduces power consumption and heat when the dish +is stationary. + +The `diag` pins expose A3981 fault conditions: overcurrent, overtemperature, +open-load (disconnected motor winding), or short-to-ground. `OK` means no +faults detected. Use `reset` to clear latched fault flags. + +## OS Subsystem (`OS>`) + +| Command | Type | Description | +|---------|------|-------------| +| `id` | Read | Full MCU and firmware identification | +| `reboot` | Write | Reboot microcontroller (confirmed — full boot cycle ~10s) | + +### System Identification (`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 +``` + +Key details: +- **System ID:** `TWELINCH` — "Twelve Inch" (12" dish diameter) +- **Ant ID:** `12-IN G2` — Carryout G2 model identifier +- **Silicon:** K60-144pin, Rev 2.4, Mask 4N22D — NXP MK60DN512VLQ10 +- **Clocks:** CCLK=96 MHz (core), BCLK=48 MHz (bus) +- **Flash:** Base at 0x10000 (64KB), size 458752 bytes (448KB usable firmware space) +- **NVS Version:** 1.02.13 — the non-volatile storage schema version + +Note: `tasks` and `kill` commands (available on other variants like HAL 0.0.00) +are **not present** in the G2's OS submenu. On the G2, the satellite search is +disabled permanently via NVS index 20 instead of killing a running task. + +## Firmware Command Behavior Notes + +### Command Types + +- **One-shot:** Executes once, returns result, shows prompt (`>`). Safe. +- **Streaming:** Runs indefinitely until interrupted. Some accept `q` to stop, + others require closing the serial port. The `ts`, `agc`, and `lnbv` commands + are known streamers. +- **Write:** Modifies state (motor position, NVS values, fault registers). Use + with caution. + +### Serial Protocol Notes + +- Firmware expects ASCII CR (`0x0D`) as line terminator +- Response terminates with `>` prompt character (ASCII 62) +- Console does not support backspace — press Enter to clear on typo +- Streaming commands consume all serial input while running +- Some commands (e.g., `st`) are setters that return "Invalid params" when + called without arguments — they are not read-only despite appearing in + help listings without obvious setter syntax + +## TODO: Physical Board Inspection + +Tasks for when the dome housing is opened and the control PCB is accessible. + +### USB Port Investigation + +- [ ] Inspect LQFP-144 package pins 19-22 (USB0_DP, USB0_DM, VOUT33, VREGIN) + — these are on the corner near pin 1. Look for traces routing to a + connector, test pads, or unpopulated header +- [ ] Look for a USB mini/micro/Type-A connector anywhere on the PCB (may be + behind a panel, under a shield can, or on the back side) +- [ ] Check for a VBUS sense GPIO trace — the K60 needs a GPIO pin to detect + USB cable insertion (ref manual Section 3.9.2) +- [ ] If USB pads found: probe with multimeter for continuity to K60 pins 19-22 + +### Debug / Programming Interface + +- [ ] Look for SWD/JTAG debug header (K60 has dedicated SWD pins: SWDIO on + PTA3/pin 50, SWDCLK on PTA0/pin 46, SWO on PTA2/pin 49, RESET on pin 74). + Could be a 10-pin Cortex Debug connector, 2x5 shrouded header, or + unpopulated pads +- [ ] Check for a UART boot mode pin — K60 supports serial bootloader via + UART if NMI/FOPT bits are configured. Look for jumpers or DIP switches + near the MCU +- [ ] Identify the boot flash at 0x00000-0x0FFFF — is it internal to the K60 + or an external SPI flash? Check for small SOIC-8 flash chips near MCU + +### Firmware Extraction via SWD + +Serial bootloader has no interactive mode (confirmed: no interrupt window, +no baud rate trick, binary-only status bytes `01 00` and `98 80 96`). SWD +is the primary extraction path. + +**Equipment:** SWD probe (ST-Link V2, J-Link, or similar) + 4 jumper wires. + +**Connections (minimum 3 wires + GND):** + +| Signal | K60 Pin | LQFP-144 | Notes | +|--------|---------|----------|-------| +| SWDIO | PTA3 | Pin 50 | Bidirectional data | +| SWDCLK | PTA0 | Pin 46 | Clock (probe drives) | +| GND | — | Multiple | Common ground with probe | +| RESET | — | Pin 74 | Optional but recommended | +| SWO | PTA2 | Pin 49 | Optional trace output | + +**Step 1: Check flash security (CRITICAL — do this first)** + +```bash +# pyocd (recommended) +pyocd cmd -t mk60dn512xxx10 -c "read32 0x40C" + +# or openocd +openocd -f interface/stlink.cfg -f target/k60.cfg \ + -c "init; halt; mdw 0x40C; exit" +``` + +The Flash Security register (FTFL_FSEC) at `0x40C`: +- Bits [1:0] = `10` → **SECURED** — SWD reads blocked, need EzPort fallback +- Bits [1:0] = `00` or `11` → **UNSECURED** — full flash dump possible + +Also check `0x400` (FTFL_FOPT / Flash Configuration Field at 0x400-0x40F): +```bash +pyocd cmd -t mk60dn512xxx10 -c "read8 0x400 16" +``` +This 16-byte field (programmed into flash at 0x400) controls boot security, +backdoor key, mass erase enable, and other flash protection settings. + +**Step 2: Dump firmware (if unsecured)** + +```bash +# Full 512KB flash dump (bootloader + application) +pyocd flash -t mk60dn512xxx10 -r firmware_full.bin \ + --address 0x00000 --size 0x80000 + +# Or just the application (448KB) +pyocd flash -t mk60dn512xxx10 -r firmware_app.bin \ + --address 0x10000 --size 0x70000 + +# Also dump the 128KB RAM (may contain runtime state) +pyocd cmd -t mk60dn512xxx10 \ + -c "savemem 0x1FFF0000 0x20020000 sram_dump.bin" +``` + +**Step 3: If flash is secured — EzPort fallback** + +EzPort is an SPI-based flash interface activated by holding PTA4 (pin 51) +low during reset. It may bypass flash security for reads on some K60 silicon +revisions (Rev 2.4 / mask 4N22D — check errata). + +``` +EzPort SPI wiring: + EZP_CS = PTA4 (pin 51) — hold LOW during reset to enter EzPort + EZP_CLK = PTA0 (pin 46) — same pin as SWDCLK! + EZP_DOUT = PTA2 (pin 49) — same pin as SWO + EZP_DIN = PTA1 (pin 47) +``` + +EzPort commands: `0x03` = Read, `0x05` = Read Status, `0x0B` = Fast Read. +Use an SPI master (ESP32, RPi, FTDI MPSSE) at ≤ 4 MHz. + +**Flash layout (from `os id`):** + +``` +0x00000 - 0x0FFFF Bootloader v1.01 (64KB) +0x10000 - 0x7FFFF Application v02.02.48 (448KB) + Total: 512KB (0x80000) +``` + +**Boot sequence bytes (from serial capture):** + +``` +reboot echo → 0x01 0x00 (binary status) → "Bootloader version: 1.01" +→ "Application is running..." → 0x98 0x80 0x96 (binary, meaning unknown) +→ "Application Starting Kinetis PCB..." +``` + +The `0x01 0x00` and `0x98 0x80 0x96` are binary protocol bytes between +bootloader and application — possibly a handshake or integrity check. +Understanding these requires disassembling the bootloader. + +### Component Identification + +- [ ] Photograph both sides of the PCB (high-res, good lighting) +- [ ] Read markings on the BCM4515 DVB tuner IC — confirm package, check for + additional Broadcom support ICs nearby +- [ ] Identify the two A3981 stepper driver ICs (TSSOP-28 with exposed pad, + should be near motor connectors). Note their orientation and surrounding + passives (sense resistors for current measurement) +- [ ] Read crystal oscillator marking — frequency determines PLL configuration + (K60 accepts 3-32 MHz, likely 8 MHz or 16 MHz for clean 96 MHz PLL) +- [ ] Check for external EEPROM (separate from K60 internal flash) — likely + I2C EEPROM near MCU, possibly AT24Cxx or M24Cxx series +- [ ] Look for LNB voltage regulator circuit (13V/18V switching) — probably + near the coax F-connector + +### Gyro / IMU Investigation + +- [ ] NVS indices 72-77 configure gyro parameters (sensitivity, filter, mount + type) but boot log says nothing about a gyro. Check if there's an + onboard MEMS gyro or an unpopulated footprint for one +- [ ] If present, identify the gyro IC (likely single-axis rate gyro given + the NVS parameters — "Gyro Mount Type: 1" suggests a specific axis) +- [ ] If unpopulated: the G2 may share a PCB design with a larger model + (Trav'ler SK-1000?) that includes a gyro for in-motion tracking + +### GPS Investigation + +- [ ] Boot log says "GPS Not Found" — look for a GPS module footprint + (populated or empty). NVS 63-64 configure GPS heading/moving thresholds +- [ ] Check for a GPS antenna connector (SMA or U.FL) or ceramic patch antenna + on the PCB +- [ ] If unpopulated: same shared-PCB theory as the gyro — the G2 firmware + has GPS support but the hardware may be DNP (Do Not Populate) + +### Motor / Drive Train + +- [ ] Trace SPI bus from K60 to A3981 drivers — identify which DSPI peripheral + (DSPI0, DSPI1, or DSPI2) connects to which axis (AZ vs EL) +- [ ] Identify motor sense resistors near A3981 chips — value determines + current limit calibration. Compare with NVS current limit hex values + (indices 95-98, 110-111) +- [ ] Check motor connectors — are they direct stepper connections (4-wire) or + do they go through additional driver stages? +- [ ] Look for limit switches or optical encoders (the G2 uses stall detection + for homing, but there may be unused provisions for position feedback) + +### RF Signal Path + +- [ ] Trace signal path: F-connector → LNB bias tee → BCM4515 RF input +- [ ] Identify any bandpass filters, LNAs, or frequency conversion stages + between the coax and the BCM4515 +- [ ] Check if the ADC RSSI signal is a dedicated analog output from BCM4515 + or if it's derived from an AGC control voltage + +### Power Supply + +- [ ] Identify main voltage rails — the board likely has 12V input (from + RP-SK87 PSU), 5V (for logic), 3.3V (K60 core), and motor supply +- [ ] Check if the USB VREG (K60 pins 21-22) is connected to anything or + if VOUT33 is tied to the board's 3.3V rail directly +- [ ] Identify motor power supply — A3981 supports up to 28V, the board + likely runs motors at 12V from the main supply + +## TODO: Firmware Exploration (Serial Console) + +Remaining commands to test via the RS-422 console. + +### DVB Submenu (Task #13) — COMPLETE + +- [x] `ls` — streaming transponder scan (exits DVB submenu on interrupt!) +- [x] `qls` — streaming quick lock status (~100ms: `Lock:0 rssi:500 cnt:0`) +- [x] `snr` — streaming SNR readings (transient spikes in noise floor) +- [x] `diag` — multi-block one-shot per-transponder diagnostics +- [x] `table` — full 32-transponder scan (~136s), detailed per-xp data +- [x] `freqs` — frequency list name ("Non-Stacked") +- [x] `stats` — streaming, silent when no lock +- [x] `nid` — streaming network ID (FFFF/none), CR-overwrite display +- [x] `di2id` / `di2stat` / `di2conf` / `di2sc` / `di2rcs` — all fail (no switch) +- [x] `di2cs` — needs parameters (switch config) +- [x] `send` — needs 3-6 hex bytes (raw DiSEqC packet) +- [x] `h 1-13` — full parameter help documented +- [x] `tabto` / `to` — timeout configs (table vs single tune) +- [x] `ovraddr` / `rrto` / `tdthresh` / `pretx` — DiSEqC timing params +- [x] `srch` / `shuf` / `tablex` — toggle commands +- [x] `pwr` — power state, `srch_mode` — search mode, `range` — scan range +- [x] `def` — silent defaults reset (dangerous!), `msw` — format unknown +- [ ] `e ` — edit channel params (not tested — would modify state) +- [ ] `send ` — raw DiSEqC (not tested — no switch connected) + +### MOT Submenu (Task #14) — COMPLETE + +- [x] Full `?` help listing — 25 commands discovered (see Motor Control section) +- [x] `a` supports relative moves with `+`/`-` prefix (undocumented upstream!) +- [x] `h *` homes both motors simultaneously +- [x] `l` — lists 2 motors: 0=AZIMUTH (local), 1=ELEVATION (local) +- [x] `ma` / `mv` — read motor dynamics (400 deg/s², 65/45 deg/s AZ/EL) +- [x] `p` — read step positions (19998 AZ, 3116 EL) +- [x] `elminmaxhome` — EL min=18°, max=65°, home=65° +- [x] `ela2s` / `els2a` — angle↔step conversion (linear mapping confirmed) +- [x] `e` / `r` — engage/release motors +- [x] `azscan` / `azscanwxp` — sky scan commands documented +- [x] `sd` — stall detection documented +- [x] `life` / `motorlife` / `motorboth` — factory life test commands +- [x] `pid`, `sp`, `sw`, `w`, `v`, `vms` — write-only commands documented +- [ ] `g ` — NOT available on G2 (not in help listing) +- [ ] Test `h 0` and `h 1` homing (requires clear space around dish) +- [ ] Test `azscanwxp` with real scan parameters (RF imaging) + +### NVS Experiments (Caution) + +- [ ] NVS 2 ("Debug 2nd Console Port") — try setting to 1, check if USB + console activates. **Save original value first, restore after test** +- [ ] NVS 4 ("Debug Port Connection") — similar test +- [ ] NVS 112 ("Disable Dipswitch?") — what happens if dipswitch is disabled? +- [ ] NVS 38 ("Sleep Mode Timer") — currently 420s (7 min). Set higher to + prevent sleep during long tracking sessions + +### A3981 Submenu (Task #16) — COMPLETE + +- [x] Full `?` help listing — 6 commands: `diag`, `sm`, `ss`, `st`, `cm`, `reset` +- [x] `diag` — AZ/EL both OK (no faults) +- [x] `sm` — step size mode: both AUTO +- [x] `ss` — step size: both 1 (1/16 microstepping, finest) +- [x] `st` — torque: both LOW (idle) +- [x] `cm` — current control mode: both AUTO +- [ ] Test `diag` output under motor load (move motor, read diag simultaneously) +- [ ] Test changing step mode from AUTO to manual +- [ ] `reset` — clear fault flags (no faults to clear currently) + +### ADC Submenu (Task #16) — COMPLETE + +- [x] Full `?` help listing — 5 commands: `bdid`, `bdrevid`, `rssi`, `m`, `scan` +- [x] `bdid` → STATIONARY, `bdrevid` → A +- [x] `rssi` → 232 (noise floor baseline) +- [x] `scan` — streaming: position + RSSI + Lock + SNR + delta per sample +- [x] `m` — returns immediately (toggle for background monitoring?) + +### Dipswitch Submenu (Task #16) — COMPLETE + +- [x] Full `?` help listing — 1 command: `dipswitch` +- [x] `dipswitch` → val:ffffff01, app_dipswitch:101 + +### GPIO Submenu (Task #16) — COMPLETE + +- [x] Full `?` help listing — 4 commands: `regs`, `r`, `w`, `dir` +- [x] `regs` — full 101-pin dump (5 ports, all pin states) +- [x] `r ` / `w ` / `dir ` — help documented +- [ ] Map specific GPIO pins to hardware functions (SPI→A3981, UART, I2C, etc.) + +### LATLON Submenu (Task #16) — COMPLETE + +- [x] Full `?` help listing — 1 command: `l ` +- [x] Tested with satellite positions — self-localization algorithm confirmed + +### OS Submenu (Task #16) — COMPLETE + +- [x] Full `?` help listing — 2 commands: `id`, `reboot` (no `tasks`/`kill` on G2!) +- [x] `id` — full system identification including NVS version, MCU details, clock speeds +- [x] Deep probe: `go`/`date`/`time` initially appeared as hidden commands but were + confirmed as **false positives** — boot sequence output captured after `reboot` + caused a system restart mid-probe. All three return "Invalid command" post-boot. + +### PEAK Submenu (Task #16) — COMPLETE + +- [x] Full `?` help listing — 6 commands: `ts`, `pw`, `psnr`, `pxy1`, `stb`, `rssits` +- [x] `stb` — LNB polarity switching test (18V=504 RSSI, 13V=232 RSSI) +- [x] `rssits` — streaming every 1 min, exits submenu on interrupt +- [x] `pw` / `psnr` help — both take ` ` (signal peaking algorithms) +- [x] `pxy1` help — takes `` repeat count (2D cross-pattern peak search) +- [ ] Test `pw` / `psnr` / `pxy1` with a real satellite signal + +### EEPROM Submenu (Task #15) — COMPLETE + +- [x] Full `?` help listing — 3 commands: `ee`, `inv`, `def` +- [x] Complete EEPROM dump (all 17 indices, 0-16) via `scripts/ee_dump.py` +- [x] Bounds discovery: firmware enforces Min:0 Max:16 +- [x] Sentinel value: 0x00010101 for invalid/uninitialized entries +- [x] Only 6 indices (0-5) ever written; 6-16 factory default (invalid) +- [ ] `def` — restore factory defaults (not tested — would reset all values) +- [ ] Determine meaning of valid EEPROM values (indices 1-5) +- [ ] Restore index 0 via `def` or `ee 0 ` if original value is discovered + +### STEP Submenu (Task #16) — COMPLETE + +- [x] `e` — engage motors ("Motors engaged"), verified via MOT +- [x] `r` — release motors (documented via MOT, not tested to avoid losing position) +- [x] `p` — read step positions (AZ=19998, EL=3116) +- [x] `ma` / `mv` — read accel and velocity in raw microstep units +- [x] `pid` — **readable in STEP** (write-only in MOT): Kp=250, Kv=50 +- [x] MOT↔STEP conversion table verified (linear mapping) +- [ ] `p ` — test absolute microstep move (40000 steps/rev AZ, 24960 EL) +- [ ] `v ` — test continuous velocity mode + +### Hidden Command Deep Probe — COMPLETE + +Systematic brute-force probe of 415 candidate commands across all 13 menu levels +(root + 12 submenus). Script: `scripts/hidden_menu_probe.py --deep` + +**Results by submenu:** + +| Submenu | Probed | Hits | Known | New | Notes | +|---------|--------|------|-------|-----|-------| +| TRK> | 415 | 3 | 3 | 0 | Only q, Q, help | +| OS> | 415 | 7 | 4 | 0 | `go`/`date`/`time` were false positives (see below) | +| MOT> | 415 | 22 | 22 | 0 | All in help listing already | +| DVB> | 415 | 12 | 12 | 0 | All previously discovered | +| STEP> | 415 | 12 | 12 | 0 | Mirrors MOT (engage/release/pos/vel) | +| A3981> | 415 | 5 | 5 | 0 | All in help listing already | +| ADC> | 415 | 7 | 7 | 0 | | +| GPIO> | 415 | 7 | 7 | 0 | | +| PEAK> | 415 | 3 | 3 | 0 | | +| NVS> | 415 | 412 | n/a | 0 | False positives: auto-advance cursor | +| EE> | 415 | 3 | 3 | 0 | | +| LATLON> | 415 | 5 | 5 | 0 | | +| DIPSWITCH> | 415 | 3 | 3 | 0 | | + +**OS false positives explained:** The probe hit `reboot` early in the OS +sequence, causing a full system restart (~10s). The probe script continued +sending commands into the boot stream. `go`, `date`, and `time` appeared as +"hits" because their responses contained non-error text — but this text was +**unsolicited boot output**, not command responses: + +- `go` "response" = SPI2 initialization output (BCM4515 firmware transfer at boot) +- `date` "response" = BCM4515 hardware init (AP RAM FW VERIFIED, chip IDs) +- `time` "response" = DVB channel init (dvbPrvChangeChannelInit Complete) + +All three return "Invalid command" when tested manually in the OS submenu after +boot completes. The boot output is still valuable — it reveals the SPI2 clock +(6,857,142 Hz = 48 MHz / 7), SPI mode 3 (CPOL=1, CPHA=1), and the full +BCM4515 bring-up sequence. + +**NVS false positives:** The NVS submenu returned 412 hits because its parser +treats any unrecognized input as "read next entry" — an internal cursor +auto-advances through the NVS table. These are NOT hidden commands, just the +fallthrough behavior of the NVS `e` (edit/read) command parser. + +**Conclusion:** Zero genuinely hidden commands found across all 13 menu levels. +The G2 firmware shell is well-scoped — no memory access, no flash read/dump, +no hidden debug backdoors. Firmware extraction requires physical access +(SWD or EzPort). diff --git a/firmware/ble-bridge/include/config.h b/firmware/ble-bridge/include/config.h new file mode 100644 index 0000000..58aea59 --- /dev/null +++ b/firmware/ble-bridge/include/config.h @@ -0,0 +1,71 @@ +#pragma once + +// --- GPIO Pin Assignments --- +// UART1 to RS-422 module via 3.3V<->5V level shifter +#define PIN_RS422_TX 17 // ESP32 TX -> level shifter -> MAX485₁ DI +#define PIN_RS422_RX 18 // MAX485₂ RO -> level shifter -> ESP32 RX + +// GPS UART (UART2) — RYS352A +#define PIN_GPS_RX 5 // ESP32 RX <- GPS TX (NMEA out) +#define PIN_GPS_TX 6 // ESP32 TX -> GPS RX (config in, optional) +#define PIN_GPS_PPS 7 // 1Hz PPS rising edge (interrupt) +#define GPS_BAUD 115200 + +// I2C Sensor Bus +#define PIN_I2C_SDA 8 +#define PIN_I2C_SCL 9 +#define I2C_FREQ 400000 // 400kHz + +// Sensor I2C addresses +#define MPU9250_ADDR 0x68 +#define BMP388_ADDR 0x76 + +// Onboard RGB LED (WS2812, DevKitC-1 V1.1) +#define PIN_LED 38 + +// --- RS-422 UART (UART1) --- +#define RS422_BAUD 115200 +#define RS422_CONFIG SERIAL_8N1 + +// --- BLE Configuration --- +#define BLE_DEVICE_NAME "Travler-G2" +#define BLE_MTU 517 +// Max payload per NUS notification (must fit in ATT_MTU - 3) +#define BLE_NOTIFY_MAX 240 + +// Nordic UART Service UUIDs +#define NUS_SERVICE_UUID "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" +#define NUS_RX_UUID "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" // Client writes here +#define NUS_TX_UUID "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" // ESP32 notifies here + +// Sensor Service UUIDs (custom, A0E7xxxx block) +#define SENSOR_SERVICE_UUID "A0E70001-B5A3-F393-E0A9-E50E24DCCA9E" +#define SENSOR_GPS_UUID "A0E70002-B5A3-F393-E0A9-E50E24DCCA9E" +#define SENSOR_ORIENT_UUID "A0E70003-B5A3-F393-E0A9-E50E24DCCA9E" +#define SENSOR_ENV_UUID "A0E70004-B5A3-F393-E0A9-E50E24DCCA9E" +#define SENSOR_PPS_UUID "A0E70005-B5A3-F393-E0A9-E50E24DCCA9E" + +// --- Timing --- +// Inter-byte coalescing window: collect bytes arriving within this +// gap into one BLE notification instead of sending byte-by-byte +#define COALESCE_MS 3 + +// LED refresh interval +#define LED_UPDATE_MS 50 + +// Sensor read/report intervals +#define GPS_REPORT_MS 1000 // 1Hz GPS position reports +#define IMU_REPORT_MS 100 // 10Hz orientation updates +#define BARO_REPORT_MS 1000 // 1Hz pressure/temperature +#define STATUS_PRINT_MS 1000 // 1Hz USB serial status line + +// GPS PAIR command init +#define GPS_INIT_DELAY_MS 200 // Wait after UART open before sending commands +#define PAIR_ACK_TIMEOUT_MS 500 // Timeout waiting for $PAIR001 acknowledgment + +// --- LED --- +#define LED_BRIGHTNESS 30 // 0-255, keep low to avoid blinding in enclosure +#define LED_COUNT 1 + +// --- Buffers --- +#define UART_RX_BUF_SIZE 512 diff --git a/firmware/ble-bridge/platformio.ini b/firmware/ble-bridge/platformio.ini new file mode 100644 index 0000000..dcf1200 --- /dev/null +++ b/firmware/ble-bridge/platformio.ini @@ -0,0 +1,23 @@ +; ESP32-S3 BLE-to-RS422 Bridge for Winegard Carryout G2 +; Transparent NUS (Nordic UART Service) serial bridge + +[env:esp32s3] +platform = espressif32 +board = esp32-s3-devkitc1-n16r8 +framework = arduino + +; NimBLE for BLE (lighter than BlueDroid), NeoPixel for status LED +; TinyGPSPlus for NMEA parsing, MPU9250 for IMU, BMP3XX for barometer +lib_deps = + h2zero/NimBLE-Arduino@^2.1 + adafruit/Adafruit NeoPixel@^1.12 + mikalhart/TinyGPSPlus@^1.1 + bolderflight/Bolder Flight Systems MPU9250@^1.0 + adafruit/Adafruit BMP3XX Library@^2.1 + +build_flags = + ; Serial via CH343 UART port, not USB-CDC + -DARDUINO_USB_CDC_ON_BOOT=0 + +monitor_speed = 115200 +upload_speed = 921600 diff --git a/firmware/ble-bridge/src/main.cpp b/firmware/ble-bridge/src/main.cpp new file mode 100644 index 0000000..eded9e7 --- /dev/null +++ b/firmware/ble-bridge/src/main.cpp @@ -0,0 +1,591 @@ +#include +#include +#include +#include +#include +#include +#include +#include "config.h" + +// --- BLE Sensor Payloads (packed, little-endian) --- + +struct __attribute__((packed)) GpsPayload { + int32_t lat_1e7; // latitude × 10^7 (0.0000001° resolution) + int32_t lon_1e7; // longitude × 10^7 + int16_t alt_dm; // altitude in decimeters + uint8_t fix_type; // GSA NavMode: 0=none, 2=2D, 3=3D + uint8_t fix_quality; // GGA Quality: 0=invalid, 1=SPS, 2=DGPS, 4=RTK + uint8_t satellites; // visible satellite count + uint8_t hdop_10; // HDOP × 10 +}; + +struct __attribute__((packed)) OrientPayload { + int16_t heading_10; // magnetic heading × 10 (0-3599) + int16_t elevation_10; // tilt from gravity × 10 (-900 to 900) + int16_t roll_10; // roll × 10 + int16_t gyro_x_10; // angular rate × 10 (°/s) + int16_t gyro_y_10; + int16_t gyro_z_10; +}; + +struct __attribute__((packed)) EnvPayload { + uint32_t pressure_pa; // pressure in Pascals (e.g. 101325) + int16_t temp_100; // temperature × 100 (e.g. 2150 = 21.50°C) + uint16_t pad; +}; + +struct __attribute__((packed)) PpsPayload { + uint32_t pps_micros; // micros() at last PPS rising edge + uint32_t pps_count; // cumulative PPS count since boot +}; + +// --- Globals: BLE --- + +static NimBLEServer *pServer = nullptr; +static NimBLECharacteristic *pTxChar = nullptr; // NUS: ESP32 -> Client (notify) +static NimBLECharacteristic *pRxChar = nullptr; // NUS: Client -> ESP32 (write) +static NimBLECharacteristic *pGpsChar = nullptr; // Sensor: GPS position +static NimBLECharacteristic *pOrientChar = nullptr; // Sensor: heading/tilt/gyro +static NimBLECharacteristic *pEnvChar = nullptr; // Sensor: pressure/temperature +static NimBLECharacteristic *pPpsChar = nullptr; // Sensor: PPS timestamp + +// --- Globals: Hardware --- + +static Adafruit_NeoPixel led(LED_COUNT, PIN_LED, NEO_GRB + NEO_KHZ800); +static HardwareSerial SerialGPS(2); +static TinyGPSPlus gps; + +// Custom NMEA field extractors — hooked into gps.encode() automatically +// GGA Quality (field 6): 0=none, 1=SPS, 2=DGPS, 3=PPS, 4=RTK, 5=FloatRTK, 6=DR +static TinyGPSCustom ggaQuality(gps, "GNGGA", 6); +static TinyGPSCustom ggaQualityGP(gps, "GPGGA", 6); +// GSA NavMode (field 2): 1=no fix, 2=2D, 3=3D +static TinyGPSCustom gsaNavMode(gps, "GNGSA", 2); +static TinyGPSCustom gsaNavModeGP(gps, "GPGSA", 2); + +static MPU9250 imu(Wire, MPU9250_ADDR); +static Adafruit_BMP3XX bmp; + +// --- State --- + +static bool deviceConnected = false; +static uint32_t lastActivityMs = 0; +static uint32_t lastLedUpdateMs = 0; + +// Serial bridge coalescing buffer +static uint8_t coalBuf[UART_RX_BUF_SIZE]; +static size_t coalLen = 0; +static uint32_t coalLastByteMs = 0; + +// PPS interrupt state +static volatile uint32_t ppsTimestamp = 0; +static volatile uint32_t ppsCount = 0; +static uint32_t lastPpsNotified = 0; + +// Sensor timing +static uint32_t lastGpsReportMs = 0; +static uint32_t lastImuReportMs = 0; +static uint32_t lastBaroReportMs = 0; +static uint32_t lastStatusPrintMs = 0; + +// Sensor availability (graceful degradation if not wired) +static bool imuReady = false; +static bool baroReady = false; + +// Latest readings cached for USB status line +static float snsHeading = 0, snsElevation = 0; +static float snsPressureHpa = 0, snsTempC = 0; + +// --- BLE Callbacks --- + +class ServerCallbacks : public NimBLEServerCallbacks { + void onConnect(NimBLEServer *server, NimBLEConnInfo &connInfo) override { + deviceConnected = true; + // Request fast connection interval (7.5ms-15ms) for low latency + // intervals in 1.25ms units: 6 = 7.5ms, 12 = 15ms + server->updateConnParams(connInfo.getConnHandle(), 6, 12, 0, 200); + Serial.println("[BLE] Client connected"); + } + + void onDisconnect(NimBLEServer *server, NimBLEConnInfo &connInfo, int reason) override { + deviceConnected = false; + Serial.printf("[BLE] Client disconnected (reason=0x%02x), re-advertising\n", reason); + NimBLEDevice::startAdvertising(); + } +}; + +class RxCallbacks : public NimBLECharacteristicCallbacks { + void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override { + std::string val = pChar->getValue(); + if (val.length() > 0) { + // Forward BLE RX -> RS-422 TX + Serial1.write((const uint8_t *)val.data(), val.length()); + Serial.printf("[BLE->422] %u bytes\n", val.length()); + lastActivityMs = millis(); + } + } +}; + +// --- PPS Interrupt --- + +void IRAM_ATTR gpsPpsISR() { + ppsTimestamp = micros(); + ppsCount++; +} + +// --- LED Status --- + +static void updateLed() { + uint32_t now = millis(); + if (now - lastLedUpdateMs < LED_UPDATE_MS) return; + lastLedUpdateMs = now; + + if (!deviceConnected) { + // Blue breathing: sine wave on blue channel + float phase = (float)(now % 3000) / 3000.0f * TWO_PI; + uint8_t brightness = (uint8_t)((sinf(phase) + 1.0f) * 0.5f * LED_BRIGHTNESS); + led.setPixelColor(0, led.Color(0, 0, brightness)); + } else if (now - lastActivityMs < 100) { + // Cyan flash: recent data activity + led.setPixelColor(0, led.Color(0, LED_BRIGHTNESS, LED_BRIGHTNESS)); + } else { + // Solid green: connected, idle + led.setPixelColor(0, led.Color(0, LED_BRIGHTNESS, 0)); + } + led.show(); +} + +// --- BLE Setup (NUS only) --- + +static void initBLE() { + NimBLEDevice::init(BLE_DEVICE_NAME); + NimBLEDevice::setMTU(BLE_MTU); + // Max TX power for range through enclosure + NimBLEDevice::setPower(ESP_PWR_LVL_P9); + + pServer = NimBLEDevice::createServer(); + pServer->setCallbacks(new ServerCallbacks()); + + // NUS — serial passthrough (unchanged from bridge-only firmware) + NimBLEService *pNus = pServer->createService(NUS_SERVICE_UUID); + + pTxChar = pNus->createCharacteristic( + NUS_TX_UUID, + NIMBLE_PROPERTY::NOTIFY + ); + + pRxChar = pNus->createCharacteristic( + NUS_RX_UUID, + NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR + ); + pRxChar->setCallbacks(new RxCallbacks()); + + pNus->start(); + Serial.println("[BLE] NUS service started"); +} + +// --- Sensor BLE Service --- + +static void initSensorBLE() { + NimBLEService *pSns = pServer->createService(SENSOR_SERVICE_UUID); + + pGpsChar = pSns->createCharacteristic( + SENSOR_GPS_UUID, + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY + ); + pOrientChar = pSns->createCharacteristic( + SENSOR_ORIENT_UUID, + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY + ); + pEnvChar = pSns->createCharacteristic( + SENSOR_ENV_UUID, + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY + ); + pPpsChar = pSns->createCharacteristic( + SENSOR_PPS_UUID, + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY + ); + + pSns->start(); + Serial.println("[BLE] Sensor service started"); +} + +// --- Start BLE Advertising --- + +static void startAdvertising() { + NimBLEAdvertising *pAdv = NimBLEDevice::getAdvertising(); + pAdv->addServiceUUID(NUS_SERVICE_UUID); + // Sensor service discovered via GATT after connect (saves ad packet space) + pAdv->setAppearance(0x0080); // Generic Computer + NimBLEDevice::startAdvertising(); + + Serial.printf("[BLE] Advertising as \"%s\"\n", BLE_DEVICE_NAME); +} + +// --- GPS PAIR Command Helper --- + +// Send a $PAIR command to the RYS352A and wait for $PAIR001 ACK. +// body: command without $ prefix or checksum, e.g. "PAIR062,1,0" +// Returns: 0=success, 1=processing, 2=fail, 3=unsupported, 4=param error, -1=timeout +static int sendPairCmd(const char *body, uint16_t timeoutMs = PAIR_ACK_TIMEOUT_MS) { + // Compute NMEA XOR checksum over the body + uint8_t cksum = 0; + for (const char *p = body; *p; p++) { + cksum ^= (uint8_t)*p; + } + + // Send $body*XX\r\n + char buf[80]; + snprintf(buf, sizeof(buf), "$%s*%02X\r\n", body, cksum); + SerialGPS.print(buf); + + // Extract command ID for matching ACK (e.g. "PAIR062" -> "062") + // PAIR commands are "PAIRnnn,..." — the ACK references just the number + const char *idStr = body + 4; // skip "PAIR" + int cmdId = atoi(idStr); + + // Wait for $PAIR001,, or timeout + for (int attempt = 0; attempt < 2; attempt++) { + uint32_t start = millis(); + char line[128]; + size_t lineLen = 0; + + while (millis() - start < timeoutMs) { + if (SerialGPS.available()) { + char c = SerialGPS.read(); + if (c == '\n') { + line[lineLen] = '\0'; + // Check for $PAIR001,, + int ackCmd = -1, ackResult = -1; + if (sscanf(line, "$PAIR001,%d,%d", &ackCmd, &ackResult) == 2 + && ackCmd == cmdId) { + if (ackResult == 1 && attempt == 0) { + // "Processing" — retry after short delay + delay(200); + break; + } + return ackResult; + } + lineLen = 0; + } else if (c != '\r' && lineLen < sizeof(line) - 1) { + line[lineLen++] = c; + } + } + } + if (attempt == 0 && lineLen == 0) { + // Timeout on first attempt — don't retry + break; + } + } + return -1; // timeout +} + +// --- Sensor Hardware Init --- + +static void initSensors() { + // I2C bus — call before any library that touches Wire + // On ESP32, Wire.begin(sda, scl) locks the pin assignment; + // subsequent Wire.begin() calls from libraries are a no-op. + Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL); + Wire.setClock(I2C_FREQ); + Serial.printf("[I2C] Bus initialized on GPIO%d/GPIO%d at %dkHz\n", + PIN_I2C_SDA, PIN_I2C_SCL, I2C_FREQ / 1000); + + // MPU-9250 IMU + int imuStatus = imu.begin(); + if (imuStatus < 0) { + Serial.printf("[IMU] MPU-9250 not found at 0x%02x (err=%d)\n", + MPU9250_ADDR, imuStatus); + } else { + imu.setAccelRange(MPU9250::ACCEL_RANGE_2G); + imu.setGyroRange(MPU9250::GYRO_RANGE_250DPS); + imu.setDlpfBandwidth(MPU9250::DLPF_BANDWIDTH_20HZ); + imu.setSrd(19); // 50Hz internal sample rate: 1000/(1+19) + imuReady = true; + Serial.printf("[IMU] MPU-9250 found at 0x%02x, calibrating...\n", + MPU9250_ADDR); + } + + // BMP388 barometer + if (!bmp.begin_I2C(BMP388_ADDR, &Wire)) { + Serial.printf("[BARO] BMP388 not found at 0x%02x\n", BMP388_ADDR); + } else { + bmp.setTemperatureOversampling(BMP3_OVERSAMPLING_8X); + bmp.setPressureOversampling(BMP3_OVERSAMPLING_4X); + bmp.setIIRFilterCoeff(BMP3_IIR_FILTER_COEFF_3); + bmp.setOutputDataRate(BMP3_ODR_50_HZ); + baroReady = true; + Serial.printf("[BARO] BMP388 found at 0x%02x\n", BMP388_ADDR); + } + + // GPS UART (UART2) — RYS352A + SerialGPS.begin(GPS_BAUD, SERIAL_8N1, PIN_GPS_RX, PIN_GPS_TX); + Serial.printf("[GPS] UART2 on GPIO%d/GPIO%d at %d baud, waiting for fix...\n", + PIN_GPS_RX, PIN_GPS_TX, GPS_BAUD); + + // Configure NMEA output via $PAIR commands (non-fatal if unsupported) + delay(GPS_INIT_DELAY_MS); + // Drain any boot-up garbage from the GPS UART + while (SerialGPS.available()) SerialGPS.read(); + + struct { const char *cmd; const char *desc; } gpsInit[] = { + {"PAIR062,0,1", "GGA on"}, + {"PAIR062,2,1", "GSA on"}, + {"PAIR062,4,1", "RMC on"}, + {"PAIR062,3,5", "GSV every 5"}, + {"PAIR062,1,0", "GLL off"}, + {"PAIR062,5,0", "VTG off"}, + {"PAIR062,6,0", "ZDA off"}, + {"PAIR062,7,0", "GRS off"}, + {"PAIR062,8,0", "GST off"}, + {"PAIR062,9,0", "GNS off"}, + {"PAIR752,3,100", "PPS 2D/3D fix only"}, + }; + for (auto &c : gpsInit) { + int r = sendPairCmd(c.cmd); + Serial.printf("[GPS] %s -> %s (%s)\n", c.cmd, + r == 0 ? "ACK 0" : r == -1 ? "timeout" : "err", + c.desc); + } + + // PPS interrupt — captures micros() on rising edge + pinMode(PIN_GPS_PPS, INPUT); + attachInterrupt(digitalPinToInterrupt(PIN_GPS_PPS), gpsPpsISR, RISING); + Serial.printf("[PPS] Interrupt attached on GPIO%d\n", PIN_GPS_PPS); +} + +// --- Sensor Read Loop --- + +static void readSensors() { + uint32_t now = millis(); + + // Feed GPS parser from UART2 (every iteration — NMEA bytes trickle in) + while (SerialGPS.available()) { + gps.encode(SerialGPS.read()); + } + + // GPS report (1Hz) + if (now - lastGpsReportMs >= GPS_REPORT_MS) { + lastGpsReportMs = now; + + if (gps.location.isValid()) { + GpsPayload gp = {}; + gp.lat_1e7 = (int32_t)(gps.location.lat() * 1e7); + gp.lon_1e7 = (int32_t)(gps.location.lng() * 1e7); + gp.alt_dm = gps.altitude.isValid() + ? (int16_t)(gps.altitude.meters() * 10) : 0; + + // Authoritative fix data from custom NMEA field extractors + const char *navVal = gsaNavMode.isUpdated() ? gsaNavMode.value() + : gsaNavModeGP.value(); + const char *qualVal = ggaQuality.isUpdated() ? ggaQuality.value() + : ggaQualityGP.value(); + uint8_t navMode = (navVal && navVal[0]) ? (uint8_t)atoi(navVal) : 0; + uint8_t quality = (qualVal && qualVal[0]) ? (uint8_t)atoi(qualVal) : 0; + + gp.fix_type = (navMode >= 2) ? navMode : 0; + gp.fix_quality = quality; + gp.satellites = (uint8_t)min((unsigned long)gps.satellites.value(), + (unsigned long)255); + float hdop = gps.hdop.hdop(); + gp.hdop_10 = (uint8_t)min((int)(hdop * 10), 255); + + if (deviceConnected && pGpsChar) { + pGpsChar->setValue((uint8_t *)&gp, sizeof(gp)); + pGpsChar->notify(); + } + } + } + + // IMU report (10Hz) + if (imuReady && (now - lastImuReportMs >= IMU_REPORT_MS)) { + lastImuReportMs = now; + imu.readSensor(); + + // Heading from magnetometer (uncalibrated — raw mag, no hard/soft iron) + float mx = imu.getMagX_uT(); + float my = imu.getMagY_uT(); + snsHeading = atan2f(my, mx) * 180.0f / PI; + if (snsHeading < 0) snsHeading += 360.0f; + + // Elevation (pitch) and roll from accelerometer gravity vector + float ax = imu.getAccelX_mss(); + float ay = imu.getAccelY_mss(); + float az = imu.getAccelZ_mss(); + snsElevation = atan2f(-ax, sqrtf(ay * ay + az * az)) * 180.0f / PI; + float roll = atan2f(ay, az) * 180.0f / PI; + + // Gyro rates (rad/s -> deg/s) + float gx = imu.getGyroX_rads() * 180.0f / PI; + float gy = imu.getGyroY_rads() * 180.0f / PI; + float gz = imu.getGyroZ_rads() * 180.0f / PI; + + OrientPayload op = {}; + op.heading_10 = (int16_t)(snsHeading * 10); + op.elevation_10 = (int16_t)(snsElevation * 10); + op.roll_10 = (int16_t)(roll * 10); + op.gyro_x_10 = (int16_t)(gx * 10); + op.gyro_y_10 = (int16_t)(gy * 10); + op.gyro_z_10 = (int16_t)(gz * 10); + + if (deviceConnected && pOrientChar) { + pOrientChar->setValue((uint8_t *)&op, sizeof(op)); + pOrientChar->notify(); + } + } + + // Barometer report (1Hz) + if (baroReady && (now - lastBaroReportMs >= BARO_REPORT_MS)) { + lastBaroReportMs = now; + + if (bmp.performReading()) { + snsPressureHpa = bmp.pressure / 100.0f; // Pa -> hPa + snsTempC = bmp.temperature; + + EnvPayload ep = {}; + ep.pressure_pa = (uint32_t)bmp.pressure; + ep.temp_100 = (int16_t)(bmp.temperature * 100); + + if (deviceConnected && pEnvChar) { + pEnvChar->setValue((uint8_t *)&ep, sizeof(ep)); + pEnvChar->notify(); + } + } + } + + // PPS notification (on each new pulse) + uint32_t currentPps = ppsCount; // snapshot volatile + if (currentPps != lastPpsNotified) { + lastPpsNotified = currentPps; + + PpsPayload pp = {}; + pp.pps_micros = ppsTimestamp; + pp.pps_count = currentPps; + + if (deviceConnected && pPpsChar) { + pPpsChar->setValue((uint8_t *)&pp, sizeof(pp)); + pPpsChar->notify(); + } + } + + // USB serial status line (1Hz, human-readable) + if (now - lastStatusPrintMs >= STATUS_PRINT_MS) { + lastStatusPrintMs = now; + + // GGA quality names indexed by quality field value (0-6) + static const char *qualNames[] = { + "none", "SPS", "DGPS", "PPS", "RTK", "FRTK", "DR" + }; + + Serial.printf("[SNS] "); + if (gps.location.isValid()) { + const char *navVal = gsaNavMode.isUpdated() ? gsaNavMode.value() + : gsaNavModeGP.value(); + const char *qualVal = ggaQuality.isUpdated() ? ggaQuality.value() + : ggaQualityGP.value(); + uint8_t navMode = (navVal && navVal[0]) ? (uint8_t)atoi(navVal) : 0; + uint8_t quality = (qualVal && qualVal[0]) ? (uint8_t)atoi(qualVal) : 0; + const char *dimStr = (navMode == 3) ? "3D" : (navMode == 2) ? "2D" : "??"; + const char *qualStr = (quality <= 6) ? qualNames[quality] : "?"; + + Serial.printf("lat=%.4f lon=%.4f alt=%.1fm fix=%s/%s sats=%d ", + gps.location.lat(), gps.location.lng(), + gps.altitude.meters(), dimStr, qualStr, + gps.satellites.value()); + } else { + Serial.printf("fix=none sats=%d ", gps.satellites.value()); + } + if (imuReady) { + Serial.printf("hdg=%.1f el=%.1f ", snsHeading, snsElevation); + } + if (baroReady) { + Serial.printf("P=%.1fhPa T=%.1fC ", snsPressureHpa, snsTempC); + } + Serial.printf("pps=%u\n", (uint32_t)ppsCount); + } +} + +// --- UART Bridge Loop --- + +static void bridgeLoop() { + // --- UART1 RX (G2) -> coalesce -> BLE TX + USB echo --- + while (Serial1.available()) { + if (coalLen < UART_RX_BUF_SIZE) { + coalBuf[coalLen++] = Serial1.read(); + } else { + Serial1.read(); // Drain overflow + } + coalLastByteMs = millis(); + } + + // Flush coalesced buffer after inter-byte gap expires + if (coalLen > 0 && (millis() - coalLastByteMs >= COALESCE_MS)) { + // Always echo to USB serial for monitoring + Serial.write(coalBuf, coalLen); + + // Send via BLE if connected, chunked to BLE_NOTIFY_MAX + if (deviceConnected && pTxChar != nullptr) { + size_t offset = 0; + while (offset < coalLen) { + size_t chunk = min((size_t)BLE_NOTIFY_MAX, coalLen - offset); + pTxChar->setValue(coalBuf + offset, chunk); + pTxChar->notify(); + offset += chunk; + } + Serial.printf("[422->BLE] %u bytes\n", coalLen); + lastActivityMs = millis(); + } + + coalLen = 0; + } + + // --- USB Serial RX -> UART1 TX (fallback input) --- + while (Serial.available()) { + uint8_t c = Serial.read(); + Serial1.write(c); + lastActivityMs = millis(); + } +} + +// --- Arduino Entry Points --- + +void setup() { + // USB serial console (UART0 via CH343) + Serial.begin(115200); + delay(500); // Let USB enumerate + Serial.println(); + Serial.println("=== Travler-G2 BLE Bridge + Sensors ==="); + Serial.println("RS-422: 115200 8N1 on GPIO17(TX)/GPIO18(RX)"); + Serial.println("BLE: NUS (serial) + Sensor Service"); + Serial.println(); + + // RS-422 UART (UART1) + Serial1.begin(RS422_BAUD, RS422_CONFIG, PIN_RS422_RX, PIN_RS422_TX); + Serial.println("[UART1] RS-422 initialized"); + + // Status LED + led.begin(); + led.setBrightness(LED_BRIGHTNESS); + led.setPixelColor(0, led.Color(0, 0, LED_BRIGHTNESS)); // Blue at boot + led.show(); + + // BLE — NUS service (serial bridge) + initBLE(); + + // Sensor hardware — I2C, GPS UART, PPS interrupt + initSensors(); + + // BLE — Sensor service (requires pServer from initBLE) + initSensorBLE(); + + // Start advertising both services + startAdvertising(); + + Serial.println("[BOOT] Ready. Waiting for BLE client..."); +} + +void loop() { + bridgeLoop(); + readSensors(); + updateLed(); +} diff --git a/pyproject.toml b/pyproject.toml index 1dbae39..be32ad1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "travler-rotor" -version = "2025.06.11" -description = "Python library for controlling Winegard Trav'ler satellite dishes via RS-485" +name = "birdcage" +version = "2026.02.12.1" +description = "Winegard satellite dish control for amateur radio sky tracking" license = "MIT" requires-python = ">=3.11" authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}] @@ -15,7 +15,8 @@ dependencies = [ ] [project.scripts] -travler-rotor = "travler_rotor.cli:main" +birdcage = "birdcage.cli:main" +console-probe = "console_probe.cli:main" [tool.ruff] target-version = "py311" @@ -25,4 +26,4 @@ src = ["src"] select = ["E", "F", "I", "UP", "B", "SIM"] [tool.hatch.build.targets.wheel] -packages = ["src/travler_rotor"] +packages = ["src/birdcage", "src/console_probe"] diff --git a/scripts/hidden_menu_probe.py b/scripts/hidden_menu_probe.py new file mode 100644 index 0000000..ea796b2 --- /dev/null +++ b/scripts/hidden_menu_probe.py @@ -0,0 +1,1043 @@ +#!/usr/bin/env python3 +"""Probe for undocumented/hidden commands on any embedded console. + +Auto-discovers the device's prompt character, error string, and submenu +structure, then sends candidate command names and flags anything that +produces a non-error response. + +Works with any prompt-based firmware console: Winegard Trav'ler / G2, +U-Boot, FreeRTOS shells, vendor debug consoles, etc. + +Usage: + uv run scripts/hidden_menu_probe.py --port /dev/ttyUSB2 --baud 115200 + uv run scripts/hidden_menu_probe.py --deep + uv run scripts/hidden_menu_probe.py --submenu mot + uv run scripts/hidden_menu_probe.py --wordlist scripts/wordlists/winegard.txt + uv run scripts/hidden_menu_probe.py --prompt "U-Boot>" --error "Unknown command" +""" + +from __future__ import annotations + +import argparse +import json +import re +import string +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path + +import serial # pyright: ignore[reportMissingImports] + +# --------------------------------------------------------------------------- +# Device profile — populated by auto-discovery or CLI overrides +# --------------------------------------------------------------------------- + + +@dataclass +class DeviceProfile: + """Everything we know (or detected) about the attached console.""" + + port: str = "/dev/ttyUSB0" + baud: int = 115200 + root_prompt: str = "" # e.g. "TRK>" + prompts: list[str] = field(default_factory=list) # all known prompts + error_string: str = "" # e.g. "Invalid command." + known_commands: set[str] = field(default_factory=set) # from help output + submenus: list[str] = field(default_factory=list) # detected submenu names + exit_cmd: str = "q" + line_ending: str = "\r" + + +# --------------------------------------------------------------------------- +# Serial I/O +# --------------------------------------------------------------------------- + + +def send_cmd( + ser: serial.Serial, + cmd: str, + profile: DeviceProfile, + timeout: float = 1.0, +) -> str: + """Send *cmd* + line-ending, read until a known prompt or timeout.""" + ser.reset_input_buffer() + ser.write(f"{cmd}{profile.line_ending}".encode("ascii", errors="replace")) + ser.timeout = timeout + + buf = bytearray() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + chunk = ser.read(4096) + if chunk: + buf.extend(chunk) + text = buf.decode("utf-8", errors="replace") + if text.rstrip().endswith(">"): + break + elif buf: + text = buf.decode("utf-8", errors="replace") + if text.rstrip().endswith(">"): + break + + return buf.decode("utf-8", errors="replace") + + +# --------------------------------------------------------------------------- +# Auto-discovery +# --------------------------------------------------------------------------- + +_PROMPT_RE = re.compile(r"(\S+[>$#])\s*$") + + +def detect_prompt(ser: serial.Serial, profile: DeviceProfile) -> str | None: + """Send a bare line-ending and extract the prompt from the response.""" + ser.reset_input_buffer() + ser.write(profile.line_ending.encode("ascii")) + ser.timeout = 2.0 + + buf = bytearray() + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + chunk = ser.read(4096) + if chunk: + buf.extend(chunk) + text = buf.decode("utf-8", errors="replace") + tail = text.rstrip() + if tail.endswith((">", "#", "$")): + break + elif buf: + break + + text = buf.decode("utf-8", errors="replace").strip() + if not text: + return None + + # Take the last line, look for a prompt-like token + last_line = text.split("\n")[-1].strip() + m = _PROMPT_RE.search(last_line) + return m.group(1) if m else (last_line if last_line else None) + + +def detect_error_string(ser: serial.Serial, profile: DeviceProfile) -> str | None: + """Send a garbage command and extract the error message template.""" + resp = send_cmd(ser, "__xyzzy_probe__", profile, timeout=1.0) + + # Strip the echo of our command and any prompt + lines = resp.replace("__xyzzy_probe__", "").strip().split("\n") + # Filter out lines that are just prompts + content_lines = [] + for line in lines: + stripped = line.strip() + if not stripped: + continue + # Skip lines that are purely a prompt token + if _PROMPT_RE.fullmatch(stripped): + continue + # Strip trailing prompt from content lines + stripped = _PROMPT_RE.sub("", stripped).strip() + if stripped: + content_lines.append(stripped) + + if content_lines: + # The error message is typically a single line + return content_lines[0].strip() + return None + + +def parse_help_output( + help_text: str, + profile: DeviceProfile, +) -> tuple[set[str], list[str]]: + """Parse help output for command names and submenu hints. + + Returns (known_commands, submenu_names). + """ + commands: set[str] = set() + submenus: list[str] = [] + + # Common help-output patterns: + # "command - description" + # "command Description text" + # " command" + # "Enter - Description Menu" (Winegard G2 style) + # "Enter name sub-menu" + cmd_dash_re = re.compile(r"^\s*(\w[\w.]*)\s+[-—]\s+") + cmd_spaces_re = re.compile(r"^\s{0,4}(\w[\w.]*)\s{2,}") + bare_cmd_re = re.compile(r"^\s{0,4}(\w[\w.]*)\s*$") + # Angle-bracket format: "Enter - Description" + bracket_re = re.compile(r"<(\w[\w.]*)>") + enter_re = re.compile(r"[Ee]nter\s+?", re.IGNORECASE) + menu_re = re.compile(r"(\w+)\s+[Mm]enu") + submenu_re = re.compile(r"(\w+)\s+[Ss]ub-?[Mm]enu") + + for line in help_text.split("\n"): + stripped = line.strip() + if not stripped: + continue + # Skip lines that are just prompts + if _PROMPT_RE.fullmatch(stripped): + continue + + # Try angle-bracket commands first (e.g. "Enter - ...") + bracket_match = bracket_re.search(stripped) + if bracket_match: + cmd_name = bracket_match.group(1).lower() + if len(cmd_name) <= 20: + commands.add(cmd_name) + else: + # Fall back to generic patterns + for pat in (cmd_dash_re, cmd_spaces_re, bare_cmd_re): + m = pat.match(stripped) + if m: + cmd_name = m.group(1).lower() + if len(cmd_name) <= 20: + commands.add(cmd_name) + break + + # Look for submenu hints — prefer bracketed names + enter_match = enter_re.search(stripped) + if enter_match: + sub = enter_match.group(1).lower() + if sub not in submenus and len(sub) <= 20: + submenus.append(sub) + else: + for pat in (submenu_re, menu_re): + m = pat.search(stripped) + if m: + sub = m.group(1).lower() + if sub not in submenus and len(sub) <= 20: + submenus.append(sub) + break + + # Any command that appears in the help output might also be a submenu + # if the help text mentions "Enter " or " Menu". + # Commands themselves that are single-word and also in submenus get both. + + return commands, submenus + + +def auto_discover(ser: serial.Serial, profile: DeviceProfile) -> DeviceProfile: + """Run the discovery sequence and populate the profile.""" + # Step 1: Detect root prompt + print("Phase 1: Auto-discovering device console...\n") + + prompt = detect_prompt(ser, profile) + if prompt: + profile.root_prompt = prompt + profile.prompts = [prompt] + print(f" Root prompt: {prompt}") + else: + print(" WARNING: Could not detect root prompt.") + print(" Use --prompt to specify manually.") + + # Step 2: Detect error string + err = detect_error_string(ser, profile) + if err: + profile.error_string = err + print(f' Error string: "{err}"') + else: + print(" WARNING: Could not detect error string.") + print(" Use --error to specify manually.") + + # Step 3: Parse help output + print(" Sending help command: ?") + help_resp = send_cmd(ser, "?", profile, timeout=2.0) + + commands, submenus = parse_help_output(help_resp, profile) + if commands: + profile.known_commands = commands + print(f" Known commands ({len(commands)}): {', '.join(sorted(commands))}") + else: + print(" No commands parsed from help output.") + + if submenus: + profile.submenus = submenus + print(f" Detected submenus ({len(submenus)}): {', '.join(submenus)}") + else: + # Fall back: any known command that looks like a submenu name + # (we'll discover actual submenus during probing) + print(" No submenus detected from help output.") + + # Build prompt list from submenus + for sub in profile.submenus: + sub_prompt = f"{sub.upper()}>" + if sub_prompt not in profile.prompts: + profile.prompts.append(sub_prompt) + + print() + return profile + + +# --------------------------------------------------------------------------- +# Navigation +# --------------------------------------------------------------------------- + + +def navigate_to_root(ser: serial.Serial, profile: DeviceProfile) -> str: + """Send exit command until we're at the root prompt. + + Sends a bare line-ending first to check if we're already at root, + avoiding the case where 'q' at root level kills the shell entirely. + """ + # Check if we're already at root (bare CR won't kill anything) + resp = send_cmd(ser, "", profile) + if profile.root_prompt and profile.root_prompt in resp: + return profile.root_prompt + + for _ in range(5): + resp = send_cmd(ser, profile.exit_cmd, profile) + if profile.root_prompt and profile.root_prompt in resp: + return profile.root_prompt + # If no root prompt set, check if the last line looks prompt-like + last_line = resp.strip().split("\n")[-1].strip() + m = _PROMPT_RE.search(last_line) + if m: + detected = m.group(1) + if not profile.root_prompt: + profile.root_prompt = detected + if detected == profile.root_prompt: + return detected + + # Last resort: bare line-ending + resp = send_cmd(ser, "", profile) + last_line = resp.strip().split("\n")[-1].strip() + return last_line + + +def enter_submenu(ser: serial.Serial, menu: str, profile: DeviceProfile) -> str: + """Enter a submenu and return the prompt we land on.""" + navigate_to_root(ser, profile) + resp = send_cmd(ser, menu, profile) + lines = resp.strip().split("\n") + last = lines[-1].strip() if lines else "" + + # Register this prompt if new + m = _PROMPT_RE.search(last) + if m: + new_prompt = m.group(1) + if new_prompt not in profile.prompts: + profile.prompts.append(new_prompt) + return new_prompt + return last + + +# --------------------------------------------------------------------------- +# Probing +# --------------------------------------------------------------------------- + + +def clean_response( + resp: str, + cmd: str, + profile: DeviceProfile, +) -> str: + """Strip echo, prompts, and whitespace from a response.""" + clean = resp + + # Remove echo of the command (with various line-ending combos) + for suffix in ("\r\n", "\r", "\n", ""): + clean = clean.replace(f"{cmd}{suffix}", "", 1) + + clean = clean.strip() + + # Remove any known prompt tokens + for p in profile.prompts: + clean = clean.replace(p, "") + + # Also strip any bare prompt-like token at the very end + clean = _PROMPT_RE.sub("", clean) + + return clean.strip() + + +def probe_commands( + ser: serial.Serial, + candidates: list[str], + prompt: str, + label: str, + profile: DeviceProfile, + timeout: float = 0.5, +) -> list[tuple[str, str]]: + """Probe candidates at the current menu level. Returns (cmd, preview) hits.""" + hits = [] + total = len(candidates) + + for i, cmd in enumerate(candidates): + if (i + 1) % 50 == 0: + print(f" [{label}] Progress: {i + 1}/{total}...", flush=True) + + resp = send_cmd(ser, cmd, profile, timeout=timeout) + clean = clean_response(resp, cmd, profile) + + # A "hit" is anything that isn't the error string and has content + is_error = profile.error_string and profile.error_string in resp + if not is_error and clean: + preview = clean[:100].replace("\r\n", " | ").replace("\n", " | ") + hits.append((cmd, preview)) + print(f" *** HIT: '{cmd}' -> {preview}", flush=True) + + # If the command kicked us out of the submenu, recover + if "Terminating shell" in resp or "exiting" in resp.lower(): + if profile.root_prompt and prompt != profile.root_prompt: + print(f" ('{cmd}' exited submenu, re-entering...)") + menu_name = prompt.replace(">", "").strip().lower() + enter_submenu(ser, menu_name, profile) + else: + # At root — shell may have died. Wait for firmware + # to respawn it, then verify with a bare CR. + print(f" ('{cmd}' terminated shell, waiting for restart...)") + time.sleep(1.0) + check = send_cmd(ser, "", profile) + if profile.root_prompt and profile.root_prompt not in check: + print( + " WARNING: Shell did not restart. Remaining " + "results may be incomplete.", + flush=True, + ) + + return hits + + +# --------------------------------------------------------------------------- +# Candidate generation +# --------------------------------------------------------------------------- + + +def generate_candidates( + blocklist: set[str], + wordlist_paths: list[Path] | None = None, +) -> list[str]: + """Build the candidate command list. + + Includes generic embedded debug commands + single chars + two-letter combos. + Merges in any external wordlist files. Applies blocklist last. + """ + candidates: list[str] = [] + + # Single characters + candidates.extend(list(string.ascii_lowercase)) + candidates.extend(list(string.ascii_uppercase)) + candidates.extend(list(string.digits)) + + # Generic embedded debug commands (no device-specific words) + generic = [ + # Memory access + "md", + "mw", + "mm", + "mr", + "mem", + "peek", + "poke", + "rd", + "wr", + "read", + "write", + "dump", + "load", + "save", + "md.b", + "md.w", + "md.l", + "x", + "xx", + "xd", + # Flash + "flash", + "fl", + "erase", + "program", + "verify", + "protect", + "flinfo", + "fldump", + "flashdump", + # Boot / system + "boot", + "reboot", + "reset", + "go", + "run", + "exec", + "jump", + "bootd", + "bootm", + "bootp", + "version", + "ver", + "info", + "about", + "sysinfo", + "uptime", + "date", + "time", + "clk", + "clock", + # Debug + "debug", + "dbg", + "trace", + "log", + "print", + "echo", + "test", + "diag", + "selftest", + "bist", + "bench", + "assert", + "crash", + "fault", + "panic", + # Shell / OS + "sh", + "shell", + "cmd", + "command", + "cli", + "task", + "tasks", + "ps", + "top", + "threads", + "kill", + "suspend", + "resume", + "heap", + "stack", + "free", + "malloc", + "meminfo", + "cpu", + "cpuinfo", + "temp", + "temperature", + # Network / comms + "ping", + "net", + "ifconfig", + "ip", + "mac", + "uart", + "serial", + "spi", + "i2c", + "can", + # Service / factory + "factory", + "service", + "mfg", + "production", + "prod", + "cal", + "calibrate", + "calibration", + "config", + "cfg", + "setup", + "settings", + "hidden", + "secret", + "admin", + "su", + "root", + "login", + "password", + "passwd", + "auth", + "unlock", + # Update + "update", + "upgrade", + "firmware", + "fw", + "ota", + "download", + "upload", + "xmodem", + "ymodem", + "zmodem", + "tftp", + "ftp", + # Generic hardware + "sw", + "hw", + "id", + "sn", + "help", + "man", + "usage", + # Two-letter combos + "bl", + "bt", + "db", + "dm", + "dp", + "ds", + "dt", + "eb", + "ed", + "ee", + "ef", + "em", + "en", + "ep", + "er", + "es", + "et", + "fa", + "fb", + "fc", + "fd", + "fe", + "ff", + "fg", + "fh", + "fi", + "fj", + "ga", + "gb", + "gc", + "gd", + "ge", + "gf", + "gg", + "gh", + "gi", + "gj", + "ha", + "hb", + "hc", + "hd", + "he", + "hf", + "hg", + "hh", + "hi", + "hj", + "ia", + "ib", + "ic", + "io", + "ir", + "ka", + "kb", + "kc", + "kd", + "ke", + "la", + "lb", + "lc", + "ld", + "le", + "lf", + "lg", + "lh", + "li", + "lj", + "ma", + "mb", + "mc", + "me", + "mf", + "mg", + "mh", + "mi", + "mj", + "na", + "nb", + "nc", + "nd", + "ne", + "nf", + "ng", + "nh", + "ni", + "nj", + "oa", + "ob", + "oc", + "od", + "oe", + "of", + "og", + "oh", + "oi", + "oj", + "pa", + "pb", + "pc", + "pd", + "pe", + "pf", + "pg", + "ph", + "pi", + "pj", + "ra", + "rb", + "rc", + "re", + "rf", + "rg", + "rh", + "ri", + "rj", + "sa", + "sb", + "sc", + "sd", + "se", + "sf", + "sg", + "si", + "sj", + "ta", + "tb", + "tc", + "td", + "te", + "tf", + "tg", + "th", + "ti", + "tj", + "ua", + "ub", + "uc", + "ud", + "ue", + "uf", + "ug", + "uh", + "ui", + "uj", + "va", + "vb", + "vc", + "vd", + "ve", + "vf", + "vg", + "vh", + "vi", + "vj", + "wa", + "wb", + "wc", + "wd", + "we", + "wf", + "wg", + "wh", + "wi", + "wj", + "za", + "zb", + "zc", + "zd", + "ze", + "zf", + ] + candidates.extend(generic) + + # Merge external wordlists + if wordlist_paths: + for wl_path in wordlist_paths: + try: + text = wl_path.read_text() + for line in text.split("\n"): + word = line.strip() + # Skip blanks and comments + if word and not word.startswith("#"): + candidates.append(word) + except OSError as exc: + print( + f"WARNING: Could not read wordlist {wl_path}: {exc}", + file=sys.stderr, + ) + + # Deduplicate preserving order + seen: set[str] = set() + unique: list[str] = [] + for c in candidates: + if c not in seen: + seen.add(c) + unique.append(c) + + # Apply blocklist + unique = [c for c in unique if c not in blocklist] + + return unique + + +# --------------------------------------------------------------------------- +# JSON output +# --------------------------------------------------------------------------- + + +def write_json_report( + path: Path, + profile: DeviceProfile, + results: dict[str, list[tuple[str, str]]], +) -> None: + """Write machine-readable JSON probe report.""" + report: dict = { + "device": {"port": profile.port, "baud": profile.baud}, + "detected": { + "root_prompt": profile.root_prompt, + "error_string": profile.error_string, + "known_commands": sorted(profile.known_commands), + "submenus": profile.submenus, + }, + "results": {}, + } + + for label, hits in results.items(): + known_hits = [ + (cmd, resp) for cmd, resp in hits if cmd.lower() in profile.known_commands + ] + unknown_hits = [ + (cmd, resp) + for cmd, resp in hits + if cmd.lower() not in profile.known_commands + ] + report["results"][label] = { + "total_hits": len(hits), + "known": len(known_hits), + "unknown": len(unknown_hits), + "hits": [{"cmd": cmd, "response": resp} for cmd, resp in hits], + } + + path.write_text(json.dumps(report, indent=2) + "\n") + print(f"\nJSON report written to {path}") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +LINE_ENDINGS = {"cr": "\r", "lf": "\n", "crlf": "\r\n"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Probe for hidden commands on an embedded console", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +examples: + %(prog)s --port /dev/ttyUSB2 --baud 115200 + %(prog)s --deep + %(prog)s --submenu mot + %(prog)s --wordlist scripts/wordlists/winegard.txt + %(prog)s --prompt "U-Boot>" --error "Unknown command" +""", + ) + + conn = parser.add_argument_group("connection") + conn.add_argument( + "--port", default="/dev/ttyUSB0", help="Serial port (default: /dev/ttyUSB0)" + ) + conn.add_argument( + "--baud", type=int, default=115200, help="Baud rate (default: 115200)" + ) + conn.add_argument( + "--line-ending", + choices=LINE_ENDINGS, + default="cr", + help="Line ending to send (default: cr)", + ) + + disc = parser.add_argument_group("discovery overrides") + disc.add_argument( + "--prompt", default=None, help="Override auto-detected root prompt" + ) + disc.add_argument( + "--error", default=None, help="Override auto-detected error string" + ) + disc.add_argument( + "--help-cmd", default="?", help="Command to request help (default: ?)" + ) + disc.add_argument( + "--exit-cmd", default="q", help="Command to exit submenu (default: q)" + ) + + probe = parser.add_argument_group("probing") + probe.add_argument( + "--deep", action="store_true", help="Probe all discovered submenus" + ) + probe.add_argument( + "--submenu", type=str, default=None, help="Probe a single submenu by name" + ) + probe.add_argument( + "--timeout", + type=float, + default=0.5, + help="Per-command timeout in seconds (default: 0.5)", + ) + probe.add_argument( + "--blocklist", + default="reboot,stow,def,q,Q", + help="Comma-separated commands to never send (default: reboot,stow,def,q,Q)", + ) + probe.add_argument( + "--wordlist", + action="append", + default=None, + metavar="FILE", + help="Extra candidate words file (one per line, repeatable)", + ) + + output = parser.add_argument_group("output") + output.add_argument( + "--json", metavar="FILE", default=None, help="Write results as JSON to FILE" + ) + + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + # Build profile from CLI args + profile = DeviceProfile( + port=args.port, + baud=args.baud, + line_ending=LINE_ENDINGS[args.line_ending], + exit_cmd=args.exit_cmd, + ) + + # Apply explicit overrides (before auto-discovery) + if args.prompt: + profile.root_prompt = args.prompt + profile.prompts = [args.prompt] + if args.error: + profile.error_string = args.error + + # Build candidate list + blocklist = {w.strip() for w in args.blocklist.split(",") if w.strip()} + wordlist_paths = [Path(p) for p in args.wordlist] if args.wordlist else None + candidates = generate_candidates(blocklist, wordlist_paths) + print(f"Generated {len(candidates)} candidate commands\n") + + # Open serial + ser = serial.Serial(profile.port, profile.baud, timeout=1) + ser.reset_input_buffer() + + # Collect results for JSON report + all_results: dict[str, list[tuple[str, str]]] = {} + + try: + # Auto-discovery (or validate overrides) + if not profile.root_prompt or not profile.error_string: + profile = auto_discover(ser, profile) + else: + print("Using overrides:") + print(f" Root prompt: {profile.root_prompt}") + print(f' Error string: "{profile.error_string}"') + + # Still run help to discover known commands and submenus + print(f" Sending help command: {args.help_cmd}") + help_resp = send_cmd(ser, args.help_cmd, profile, timeout=2.0) + commands, submenus = parse_help_output(help_resp, profile) + profile.known_commands = commands + if not profile.submenus: + profile.submenus = submenus + for sub in profile.submenus: + sub_prompt = f"{sub.upper()}>" + if sub_prompt not in profile.prompts: + profile.prompts.append(sub_prompt) + + if commands: + print( + f" Known commands ({len(commands)}): {', '.join(sorted(commands))}" + ) + if submenus: + print(f" Submenus ({len(submenus)}): {', '.join(submenus)}") + print() + + if not profile.root_prompt: + print( + "ERROR: Could not determine root prompt. Use --prompt to specify.", + file=sys.stderr, + ) + sys.exit(1) + + # Navigate to root + prompt = navigate_to_root(ser, profile) + print(f"Starting at: {prompt}\n") + + # Probe root menu + root_label = profile.root_prompt.rstrip(">$#").strip() or "ROOT" + print( + f"=== Probing {profile.root_prompt} (root) " + f"-- {len(candidates)} candidates ===\n" + ) + + root_hits = probe_commands( + ser, + candidates, + profile.root_prompt, + root_label, + profile, + timeout=args.timeout, + ) + all_results[root_label] = root_hits + + # Classify hits + unknown_hits = [ + (cmd, resp) + for cmd, resp in root_hits + if cmd.lower() not in profile.known_commands + ] + known_count = len(root_hits) - len(unknown_hits) + + print("\n--- Root Results ---") + print(f" Total hits: {len(root_hits)}") + print(f" Known commands: {known_count}") + print(f" UNKNOWN commands: {len(unknown_hits)}") + for cmd, resp in unknown_hits: + print(f" '{cmd}' -> {resp}") + + # Determine which submenus to probe + submenus_to_probe: list[str] = [] + if args.submenu: + submenus_to_probe.append(args.submenu) + elif args.deep: + submenus_to_probe = list(profile.submenus) + + for menu in submenus_to_probe: + label = menu.upper() + print(f"\n=== Probing {label} submenu ===\n") + sub_prompt = enter_submenu(ser, menu, profile) + print(f" Prompt: {sub_prompt}") + + sub_hits = probe_commands( + ser, + candidates, + sub_prompt, + label, + profile, + timeout=args.timeout, + ) + all_results[label] = sub_hits + + print(f"\n--- {label} Results: {len(sub_hits)} hits ---") + for cmd, resp in sub_hits: + print(f" '{cmd}' -> {resp}") + + navigate_to_root(ser, profile) + + # JSON report + if args.json: + write_json_report(Path(args.json), profile, all_results) + + except KeyboardInterrupt: + print("\n\nInterrupted.") + finally: + ser.close() + print("\nPort closed.") + + +if __name__ == "__main__": + main() diff --git a/scripts/wordlists/winegard.txt b/scripts/wordlists/winegard.txt new file mode 100644 index 0000000..1a05847 --- /dev/null +++ b/scripts/wordlists/winegard.txt @@ -0,0 +1,60 @@ +# Winegard satellite dish firmware - device-specific candidate commands +# Load with: --wordlist scripts/wordlists/winegard.txt + +# Dish / antenna +stow +deploy +park +home +search +scan +find +locate +track +point +dish +antenna +ant +feed + +# Satellite / signal +sat +satellite +lnb +pol +polarity +rf +signal +snr +ber +rssi +blind +ngsearch + +# Units +idu +odu +iru + +# GPS / position +gps +nmea +position +pos +loc +gyro +imu +accel +tilt +level + +# Motor / motion +motor +drive +move +goto +slew +az +el +sk +skew diff --git a/src/travler_rotor/__init__.py b/src/birdcage/__init__.py similarity index 50% rename from src/travler_rotor/__init__.py rename to src/birdcage/__init__.py index 04aac48..b4e69c8 100644 --- a/src/travler_rotor/__init__.py +++ b/src/birdcage/__init__.py @@ -1,8 +1,8 @@ -"""travler-rotor: Control Winegard Trav'ler satellite dishes via RS-485.""" +"""birdcage: Winegard satellite dish control for amateur radio sky tracking.""" -from travler_rotor.antenna import AntennaConfig, TravlerAntenna -from travler_rotor.leapfrog import apply_leapfrog -from travler_rotor.protocol import ( +from birdcage.antenna import AntennaConfig, BirdcageAntenna +from birdcage.leapfrog import apply_leapfrog +from birdcage.protocol import ( CarryoutG2Protocol, FirmwareProtocol, HAL000Protocol, @@ -10,10 +10,11 @@ from travler_rotor.protocol import ( Position, RssiReading, ) -from travler_rotor.rotctld import RotctldServer +from birdcage.rotctld import RotctldServer __all__ = [ "AntennaConfig", + "BirdcageAntenna", "CarryoutG2Protocol", "FirmwareProtocol", "HAL000Protocol", @@ -21,6 +22,5 @@ __all__ = [ "Position", "RssiReading", "RotctldServer", - "TravlerAntenna", "apply_leapfrog", ] diff --git a/src/travler_rotor/antenna.py b/src/birdcage/antenna.py similarity index 90% rename from src/travler_rotor/antenna.py rename to src/birdcage/antenna.py index 0eb020b..45ae903 100644 --- a/src/travler_rotor/antenna.py +++ b/src/birdcage/antenna.py @@ -10,8 +10,8 @@ from __future__ import annotations import logging from dataclasses import dataclass -from travler_rotor.leapfrog import apply_leapfrog -from travler_rotor.protocol import ( +from birdcage.leapfrog import apply_leapfrog +from birdcage.protocol import ( MOTOR_AZIMUTH, MOTOR_ELEVATION, FirmwareProtocol, @@ -31,7 +31,7 @@ class AntennaConfig: leapfrog_enabled: bool = True -class TravlerAntenna: +class BirdcageAntenna: """High-level interface to a Winegard Trav'ler dish. Manages the full lifecycle: connect, initialize (boot + search kill), @@ -50,6 +50,11 @@ class TravlerAntenna: def config(self) -> AntennaConfig: return self._config + @property + def protocol(self) -> FirmwareProtocol: + """Access the underlying firmware protocol (for capability checks).""" + return self._protocol + @property def is_connected(self) -> bool: return self._protocol.is_connected diff --git a/src/travler_rotor/cli.py b/src/birdcage/cli.py similarity index 86% rename from src/travler_rotor/cli.py rename to src/birdcage/cli.py index 8a558dd..1633e26 100644 --- a/src/travler_rotor/cli.py +++ b/src/birdcage/cli.py @@ -1,4 +1,4 @@ -"""CLI entry point for travler-rotor. +"""CLI entry point for birdcage. Provides subcommands for initialization, position queries, manual moves, and running a full rotctld-compatible server for Gpredict integration. @@ -11,9 +11,9 @@ import sys import click -from travler_rotor.antenna import AntennaConfig, TravlerAntenna -from travler_rotor.protocol import get_protocol -from travler_rotor.rotctld import RotctldServer +from birdcage.antenna import AntennaConfig, BirdcageAntenna +from birdcage.protocol import get_protocol +from birdcage.rotctld import RotctldServer def _setup_logging(verbose: bool) -> None: @@ -25,19 +25,19 @@ def _setup_logging(verbose: bool) -> None: ) -def _build_antenna(port: str, firmware: str, **config_kwargs) -> TravlerAntenna: - """Create a TravlerAntenna from CLI options.""" +def _build_antenna(port: str, firmware: str, **config_kwargs) -> BirdcageAntenna: + """Create a BirdcageAntenna from CLI options.""" protocol = get_protocol(firmware) # G2 defaults: 115200 baud, 18 deg min elevation if firmware.lower() == "g2": config_kwargs.setdefault("baudrate", 115200) config_kwargs.setdefault("min_elevation", 18.0) config = AntennaConfig(port=port, **config_kwargs) - return TravlerAntenna(protocol, config) + return BirdcageAntenna(protocol, config) @click.group() -@click.version_option(package_name="travler-rotor") +@click.version_option(package_name="birdcage") @click.option("-v", "--verbose", is_flag=True, help="Enable debug logging.") def main(verbose: bool) -> None: """Control a Winegard Trav'ler satellite dish via RS-485.""" @@ -47,14 +47,14 @@ def main(verbose: bool) -> None: @main.command() @click.option( "--port", - envvar="TRAVLER_PORT", + envvar="BIRDCAGE_PORT", default="/dev/ttyUSB0", show_default=True, help="Serial port for the RS-485 adapter.", ) @click.option( "--firmware", - envvar="TRAVLER_FIRMWARE", + envvar="BIRDCAGE_FIRMWARE", default="hal205", show_default=True, type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False), @@ -76,14 +76,14 @@ def init(port: str, firmware: str) -> None: @main.command() @click.option( "--port", - envvar="TRAVLER_PORT", + envvar="BIRDCAGE_PORT", default="/dev/ttyUSB0", show_default=True, help="Serial port for the RS-485 adapter.", ) @click.option( "--firmware", - envvar="TRAVLER_FIRMWARE", + envvar="BIRDCAGE_FIRMWARE", default="hal205", show_default=True, type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False), @@ -91,14 +91,14 @@ def init(port: str, firmware: str) -> None: ) @click.option( "--host", - envvar="TRAVLER_LISTEN_HOST", + envvar="BIRDCAGE_LISTEN_HOST", default="127.0.0.1", show_default=True, help="Address to listen on for rotctld connections.", ) @click.option( "--listen-port", - envvar="TRAVLER_LISTEN_PORT", + envvar="BIRDCAGE_LISTEN_PORT", default=4533, show_default=True, type=int, @@ -136,14 +136,14 @@ def serve( @main.command() @click.option( "--port", - envvar="TRAVLER_PORT", + envvar="BIRDCAGE_PORT", default="/dev/ttyUSB0", show_default=True, help="Serial port for the RS-485 adapter.", ) @click.option( "--firmware", - envvar="TRAVLER_FIRMWARE", + envvar="BIRDCAGE_FIRMWARE", default="hal205", show_default=True, type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False), @@ -170,14 +170,14 @@ def pos(port: str, firmware: str) -> None: @main.command() @click.option( "--port", - envvar="TRAVLER_PORT", + envvar="BIRDCAGE_PORT", default="/dev/ttyUSB0", show_default=True, help="Serial port for the RS-485 adapter.", ) @click.option( "--firmware", - envvar="TRAVLER_FIRMWARE", + envvar="BIRDCAGE_FIRMWARE", default="hal205", show_default=True, type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False), diff --git a/src/travler_rotor/leapfrog.py b/src/birdcage/leapfrog.py similarity index 100% rename from src/travler_rotor/leapfrog.py rename to src/birdcage/leapfrog.py diff --git a/src/travler_rotor/protocol.py b/src/birdcage/protocol.py similarity index 90% rename from src/travler_rotor/protocol.py rename to src/birdcage/protocol.py index ad78669..dc7bbc7 100644 --- a/src/travler_rotor/protocol.py +++ b/src/birdcage/protocol.py @@ -312,29 +312,24 @@ class CarryoutG2Protocol(FirmwareProtocol): def get_position(self) -> Position: """Query dish position. - The G2 may return floats without AZ=/EL= labels, so we try the - labeled format first and fall back to raw float extraction. + G2 firmware 02.02.48 returns position as:: + + Angle[0] = 180.00 + Angle[1] = 45.00 + MOT> + + Where Angle[0] is azimuth and Angle[1] is elevation. """ response = self._send("a") - # Try labeled format (AZ = / EL =) for compatibility - az_match = re.search(r"AZ\s*=?\s*(\d+\.\d+)", response) - el_match = re.search(r"EL\s*=?\s*(\d+\.\d+)", response) + # G2 format: Angle[0] = , Angle[1] = + az_match = re.search(r"Angle\[0\]\s*=\s*(-?\d+\.\d+)", response) + el_match = re.search(r"Angle\[1\]\s*=\s*(-?\d+\.\d+)", response) if az_match and el_match: - sk_match = re.search(r"SK\s*=?\s*(\d+\.\d+)", response) return Position( azimuth=float(az_match.group(1)), elevation=float(el_match.group(1)), - skew=float(sk_match.group(1)) if sk_match else None, - ) - - # Fall back to raw float extraction (G2-style: just two numbers) - floats = re.findall(r"\d+\.\d+", response) - if len(floats) >= 2: - return Position( - azimuth=float(floats[0]), - elevation=float(floats[1]), ) raise ValueError(f"Could not parse position from: {response!r}") @@ -367,6 +362,12 @@ class CarryoutG2Protocol(FirmwareProtocol): def get_rssi(self, iterations: int = 10) -> RssiReading: """Read averaged RSSI signal strength (DVB submenu). + Firmware response format:: + + iterations:5 interval(msec):20 + Reads:5 RSSI[avg: 500 cur: 500] + DVB> + Args: iterations: Number of samples to average. @@ -377,13 +378,16 @@ class CarryoutG2Protocol(FirmwareProtocol): ValueError: If the RSSI response can't be parsed. """ response = self._send(f"rssi {iterations}") - results = re.findall(r"\d+", response) - if len(results) >= 6: + match = re.search( + r"Reads:(\d+)\s+RSSI\[avg:\s*(\d+)\s+cur:\s*(\d+)\]", + response, + ) + if match: return RssiReading( - reads=int(results[3]), - average=int(results[4]), - current=int(results[5]), + reads=int(match.group(1)), + average=int(match.group(2)), + current=int(match.group(3)), ) raise ValueError(f"Could not parse RSSI from: {response!r}") diff --git a/src/travler_rotor/rotctld.py b/src/birdcage/rotctld.py similarity index 58% rename from src/travler_rotor/rotctld.py rename to src/birdcage/rotctld.py index 0102edc..8be5ae0 100644 --- a/src/travler_rotor/rotctld.py +++ b/src/birdcage/rotctld.py @@ -8,6 +8,12 @@ Hamlib clients) use for AZ/EL rotor control: S — stop / disconnect _ — get model name q — quit connection + +Extended commands for sky-scan integration (CarryoutG2 only): + + R — read RSSI signal strength ("R [iterations]") + L — enable LNA for signal reception + D — discover supported protocol extensions """ from __future__ import annotations @@ -15,11 +21,12 @@ from __future__ import annotations import logging import socket -from travler_rotor.antenna import TravlerAntenna +from birdcage.antenna import BirdcageAntenna +from birdcage.protocol import CarryoutG2Protocol logger = logging.getLogger(__name__) -MODEL_NAME = "Winegard Trav'ler RS-485 Rotor" +MODEL_NAME = "Birdcage — Winegard RS-485 Rotor" class RotctldServer: @@ -27,7 +34,7 @@ class RotctldServer: def __init__( self, - antenna: TravlerAntenna, + antenna: BirdcageAntenna, host: str = "127.0.0.1", port: int = 4533, ) -> None: @@ -97,6 +104,12 @@ class RotctldServer: self._handle_model_name(conn) elif cmd == "q": break + elif cmd == "R": + self._handle_read_rssi(conn, cmd_parts) + elif cmd == "L": + self._handle_enable_lna(conn) + elif cmd == "D": + self._handle_capabilities(conn) else: logger.warning("Unknown command: %s", cmd) conn.sendall(b"RPRT -1\n") @@ -133,3 +146,61 @@ class RotctldServer: def _handle_model_name(self, conn: socket.socket) -> None: """Respond to '_' — return model identification string.""" conn.sendall(f"{MODEL_NAME}\n".encode()) + + def _handle_read_rssi(self, conn: socket.socket, parts: list[str]) -> None: + """Respond to 'R [n]' — read RSSI signal strength. + + Requires CarryoutG2Protocol. Handles the DVB menu switching internally: + motor menu -> quit -> dvb menu -> rssi -> quit dvb -> motor menu. + Non-G2 rotors return RPRT -6 (not available). + """ + if not isinstance(self._antenna.protocol, CarryoutG2Protocol): + conn.sendall(b"RPRT -6\n") + return + + try: + protocol: CarryoutG2Protocol = self._antenna.protocol # type: ignore[assignment] + iterations = 10 + if len(parts) > 1: + iterations = int(parts[1]) + + protocol.quit_submenu() + protocol.enter_dvb_menu() + reading = protocol.get_rssi(iterations) + protocol.quit_submenu() + protocol.enter_motor_menu() + + response = f"{reading.reads}\n{reading.average}\n{reading.current}\n" + conn.sendall(response.encode("utf-8")) + except Exception: + logger.exception("Failed to read RSSI") + conn.sendall(b"RPRT -1\n") + + def _handle_enable_lna(self, conn: socket.socket) -> None: + """Respond to 'L' — enable LNA for signal reception. + + One-time setup before scanning. Requires CarryoutG2Protocol. + """ + if not isinstance(self._antenna.protocol, CarryoutG2Protocol): + conn.sendall(b"RPRT -6\n") + return + + try: + protocol: CarryoutG2Protocol = self._antenna.protocol # type: ignore[assignment] + protocol.quit_submenu() + protocol.enter_dvb_menu() + protocol.enable_lna() + protocol.quit_submenu() + protocol.enter_motor_menu() + + conn.sendall(b"RPRT 0\n") + except Exception: + logger.exception("Failed to enable LNA") + conn.sendall(b"RPRT -1\n") + + def _handle_capabilities(self, conn: socket.socket) -> None: + """Respond to 'D' — discover supported protocol extensions.""" + if isinstance(self._antenna.protocol, CarryoutG2Protocol): + conn.sendall(b"CAPS:rssi,lna\n") + else: + conn.sendall(b"CAPS:\n") diff --git a/uv.lock b/uv.lock index cfa2d67..80b73c8 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,21 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "birdcage" +version = "2026.2.12.1" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "pyserial" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.0" }, + { name = "pyserial", specifier = ">=3.5" }, +] + [[package]] name = "click" version = "8.3.1" @@ -31,18 +46,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6 wheels = [ { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, ] - -[[package]] -name = "travler-rotor" -version = "2025.6.11" -source = { editable = "." } -dependencies = [ - { name = "click" }, - { name = "pyserial" }, -] - -[package.metadata] -requires-dist = [ - { name = "click", specifier = ">=8.0" }, - { name = "pyserial", specifier = ">=3.5" }, -]