Merge feature/skyscan-rssi: G2 sky-scan RSSI, console-probe, birdcage rename
This commit is contained in:
commit
7ba0a99279
5
.gitignore
vendored
5
.gitignore
vendored
@ -10,3 +10,8 @@ build/
|
|||||||
.env
|
.env
|
||||||
*.so
|
*.so
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PlatformIO
|
||||||
|
.pio/
|
||||||
|
.pioenvs/
|
||||||
|
.piolibdeps/
|
||||||
|
|||||||
515
CLAUDE.md
515
CLAUDE.md
@ -4,18 +4,19 @@ Control a Winegard Trav'ler motorized satellite dish via RS-485 for amateur radi
|
|||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
- **Package:** `travler-rotor` (installed via `uv sync`)
|
- **Packages:** `birdcage` + `console-probe` (installed via `uv sync`)
|
||||||
- **CLI entry point:** `travler-rotor` (init / serve / pos / move)
|
- **CLI entry points:** `birdcage` (init / serve / pos / move), `console-probe` (probe / discover)
|
||||||
- **Source layout:** `src/travler_rotor/` (src-layout)
|
- **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)
|
- **Original upstream:** `Trav-ler-Rotor-For-HAL-2.05/` — Gabe Emerson's scripts, kept as reference (do not modify)
|
||||||
|
|
||||||
## Build & Lint
|
## Build & Lint
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync # Install deps + package
|
uv sync # Install deps + both packages
|
||||||
uv run ruff check src/ # Lint
|
uv run ruff check src/ # Lint
|
||||||
uv run ruff format --check src/ # Format check
|
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
|
## Architecture
|
||||||
@ -25,13 +26,29 @@ protocol.py — FirmwareProtocol ABC + HAL205Protocol / HAL000Protocol
|
|||||||
Serial I/O owned here. Each firmware version is a subclass.
|
Serial I/O owned here. Each firmware version is a subclass.
|
||||||
leapfrog.py — Pure function: apply_leapfrog(target, current) -> adjusted
|
leapfrog.py — Pure function: apply_leapfrog(target, current) -> adjusted
|
||||||
Predictive overshoot to compensate for mechanical motor lag.
|
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.
|
This is what consumers (CLI, rotctld, future MCP server) call.
|
||||||
rotctld.py — RotctldServer: Hamlib rotctld TCP protocol (p/P/S/_/q)
|
rotctld.py — RotctldServer: Hamlib rotctld TCP protocol (p/P/S/_/q)
|
||||||
Bridges Gpredict to the antenna.
|
Bridges Gpredict to the antenna.
|
||||||
cli.py — Click CLI with init/serve/pos/move subcommands
|
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
|
## Firmware Variants
|
||||||
|
|
||||||
Five known Winegard dish variants documented by Gabe Emerson (KL1FI) / saveitforparts and cdavidson0522:
|
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 submenu** | `mot` | `motor` | `odu` then `mot` | N/A (`target` + `g`) | `mot` |
|
||||||
| **Motor control** | `a <id> <deg>` | `a <id> <deg>` | `a <id> <deg>` | `g <az> <el>` only | `a <id> <deg>` |
|
| **Motor control** | `a <id> <deg>` | `a <id> <deg>` | `a <id> <deg>` | `g <az> <el>` only | `a <id> <deg>` |
|
||||||
| **Search kill** | `os` -> `kill Search` | `ngsearch` -> `s` -> `q` | `os` -> `kill Search` | N/A | NVS 20 (permanent disable) |
|
| **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) |
|
| **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) |
|
| **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 |
|
| **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 |
|
| **Tested model** | LG-2112 | LG-2112 | SK2DISH | 2003 Carryout | Carryout G2 |
|
||||||
| **HAL version** | 0.0.00 | 2.05.003 | unknown | 1.00.065 | unknown |
|
| **HAL version** | 0.0.00 | 2.05.003 | unknown | 1.00.065 | 02.02.48 |
|
||||||
| **Prompt char** | `>` (likely) | `>` (likely) | undocumented | undocumented | `>` (confirmed) |
|
| **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
|
### 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.
|
- **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 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 <id> <deg>` motor addressing and the `mot` submenu — protocol-compatible with the Trav'ler family.
|
- **Carryout G2 uses `a` not `g`:** Unlike the 2003 Carryout, the G2 uses standard `a <id> <deg>` 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 <id>` homing:** Explicit motor home-to-reference command. Not documented on other variants.
|
- **Carryout G2 has `h <id>` 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 <n>` 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 <n>` (bounded, returns `Reads:<n> RSSI[avg: <v> cur: <v>]`), `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)
|
## 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):**
|
**Carryout G2 pinout (RJ-12, clip away, per Davidson's wiring guide):**
|
||||||
|
|
||||||
| Pin | Wire Color | RS-422 Function |
|
| Pin | Wire Color (Davidson) | Wire Color (confirmed) | RS-422 Function |
|
||||||
|-----|-----------|-----------------|
|
|-----|----------------------|----------------------|-----------------|
|
||||||
| 1 | White | GND (PE) |
|
| 1 | White | Orange/White | GND (PE) |
|
||||||
| 2 | Red | TX+ (TA) — computer→dish |
|
| 2 | Red | Orange | TX+ (TA) — computer→dish |
|
||||||
| 3 | Black | TX- (TB) — computer→dish |
|
| 3 | Black | Green/White | TX- (TB) — computer→dish |
|
||||||
| 4 | Yellow | RX+ (RA) — dish→computer |
|
| 4 | Yellow | Blue | RX+ (RA) — dish→computer |
|
||||||
| 5 | Green | RX- (RB) — dish→computer |
|
| 5 | Green | Blue/White | RX- (RB) — dish→computer |
|
||||||
| 6 | Blue | Not connected |
|
| 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:**
|
**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) |
|
| 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 (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) |
|
| Carryout G2 (ESP32) | ESP32 UART2→RS422 module (DIYables) | Pins 2-5 (full-duplex) |
|
||||||
|
|
||||||
### RS-422 Module Notes (DIYables MAX490)
|
### 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
|
### 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
|
? — list available commands (alias: help)
|
||||||
motor / mot — enter motor submenu (firmware-dependent)
|
a3981 — enter motor driver submenu
|
||||||
a — show position (in motor submenu)
|
adc — enter ADC submenu
|
||||||
a <id> <deg> — move motor to absolute position
|
dipswitch — enter dipswitch submenu
|
||||||
h <id> — home motor to reference position (G2, possibly others)
|
dvb — enter DVB tuner submenu
|
||||||
g <az> <el> — go to AZ/EL (aborts on new input)
|
eeprom — enter EEPROM submenu
|
||||||
q — exit current submenu
|
gpio — enter GPIO submenu
|
||||||
odu — tunnel to outdoor unit (Trav'ler Pro only)
|
latlon — enter lat/lon calculator submenu
|
||||||
os — enter OS submenu
|
mot — enter motor control submenu
|
||||||
tasks — list running tasks
|
|
||||||
kill <name> — kill a named task (e.g. "kill Search")
|
|
||||||
ngsearch — enter search submenu (HAL 2.05 only)
|
|
||||||
s — stop search
|
|
||||||
nvs — enter non-volatile storage submenu
|
nvs — enter non-volatile storage submenu
|
||||||
d — dump all values (confirmed on Pro and G2)
|
os — enter OS submenu
|
||||||
d <idx> — dump single value with name/current/saved/default
|
peak — enter peak/DiSEqC switch submenu
|
||||||
e <idx> — read NVS value
|
step — enter stepper motor submenu
|
||||||
e <idx> <v> — write NVS value
|
q — terminate shell (WARNING: kills UART, requires power cycle!)
|
||||||
s — save changes
|
|
||||||
dvb — signal info / LNB signal strength submenu
|
|
||||||
lnbdc odu — enable LNA in ODU mode (powers LNB for reception)
|
|
||||||
rssi <n> — read RSSI signal strength averaged over n samples
|
|
||||||
reboot — reboot firmware
|
reboot — reboot firmware
|
||||||
stow — fold dish flat (caution: modified feeds may not survive)
|
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 [<command>]` usage text, where
|
||||||
|
`<command>` 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:<id> Angle:<cdeg> RSSI:<adc> Lock:<0/1> SNR:<dB> Scan Delta:<step>"
|
||||||
|
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:<hex32>" (raw GPIO) + "app_dipswitch:<decimal>" (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 <n> <v> — 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 <n> — 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 <n> — 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 <hex> — 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 <idx> [<v>] — read/write EEPROM value at index
|
||||||
|
Read: "Index:<n> Read value = <v>" or "Failed to read eeprom index:<n> val:65793"
|
||||||
|
inv <idx> — 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: `<port><pin>` (e.g., B0, E12).
|
||||||
|
|
||||||
|
```
|
||||||
|
dir <pin> — query pin direction: returns "INPUT" or "OUTPUT"
|
||||||
|
r <pin> — 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 <pin> <val> — 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 <p1> <p2> <p3> <p4>
|
||||||
|
— calculate lat/lon from 4 parameters (likely AZ/EL pairs for 2 satellites)
|
||||||
|
Output: "anglesentered = <cdeg1> <cdeg2> <cdeg3> <cdeg4>"
|
||||||
|
"Lat = <cdeg> Lon = <cdeg>" (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 <id> <deg> — move motor to absolute angle (0=AZ, 1=EL)
|
||||||
|
a <id> +/-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 <deg> — elevation angle to steps converter (centidegrees internally)
|
||||||
|
elminmaxhome — show EL limits: "Min: <v> Max: <v> Home: <v>" (NVS values)
|
||||||
|
els2a <steps> — elevation steps to angle converter (reports overflow if out of range)
|
||||||
|
g <az> <el> — go to AZ/EL (aborts on new input)
|
||||||
|
h <id> — 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] = <v> / Max Vel [1] = <v>"
|
||||||
|
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 <idx> <value>` 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 <idx> — dump single value with details
|
||||||
|
e <idx> — read NVS value at index
|
||||||
|
e <idx> <v> — 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 <name> — 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 = <v>, Odd_sig = <v>".
|
||||||
|
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<binary> <decimal>"
|
||||||
|
(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:<id> Angle:<cdeg> RSSI:<adc> Lock:<0/1> SNR:<dB> Scan Delta:<step>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 <hex>` | 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
|
### Known NVS Indices
|
||||||
|
|
||||||
| Index | Setting |
|
Full dump in `docs/g2-nvs-dump.md` (firmware 02.02.48, captured 2026-02-12).
|
||||||
|-------|---------|
|
|
||||||
| 20 | Disable tracker procedure (FALSE/TRUE) |
|
| Index | Setting | Default | Notes |
|
||||||
| 102 | Max elevation |
|
|-------|---------|---------|-------|
|
||||||
| 125 | Search minimum elevation |
|
| 20 | Disable Tracker Proc? | FALSE | Set TRUE to prevent TV satellite search on boot |
|
||||||
| 127 | Safe minimum elevation |
|
| 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
|
### Error Messages
|
||||||
|
|
||||||
@ -219,6 +632,16 @@ stow — fold dish flat (caution: modified feeds may not survive)
|
|||||||
| `AZ MOTOR STALLED` | Obstruction preventing rotation |
|
| `AZ MOTOR STALLED` | Obstruction preventing rotation |
|
||||||
| `EL MOTOR STALLED` | Obstruction preventing elevation change |
|
| `EL MOTOR STALLED` | Obstruction preventing elevation change |
|
||||||
| `EL Motor Home Failure` | Requires EL recalibration via IDU menu |
|
| `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)
|
### IDU/ODU Cable Wiring (if cut)
|
||||||
|
|
||||||
|
|||||||
275
docs/ble-bridge-wiring.md
Normal file
275
docs/ble-bridge-wiring.md
Normal file
@ -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.
|
||||||
1707
docs/g2-nvs-dump.md
Normal file
1707
docs/g2-nvs-dump.md
Normal file
File diff suppressed because it is too large
Load Diff
71
firmware/ble-bridge/include/config.h
Normal file
71
firmware/ble-bridge/include/config.h
Normal file
@ -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
|
||||||
23
firmware/ble-bridge/platformio.ini
Normal file
23
firmware/ble-bridge/platformio.ini
Normal file
@ -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
|
||||||
591
firmware/ble-bridge/src/main.cpp
Normal file
591
firmware/ble-bridge/src/main.cpp
Normal file
@ -0,0 +1,591 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include <NimBLEDevice.h>
|
||||||
|
#include <Adafruit_NeoPixel.h>
|
||||||
|
#include <Wire.h>
|
||||||
|
#include <TinyGPSPlus.h>
|
||||||
|
#include <MPU9250.h>
|
||||||
|
#include <Adafruit_BMP3XX.h>
|
||||||
|
#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,<cmdId>,<result> 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,<cmdId>,<result>
|
||||||
|
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();
|
||||||
|
}
|
||||||
@ -3,9 +3,9 @@ requires = ["hatchling"]
|
|||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "travler-rotor"
|
name = "birdcage"
|
||||||
version = "2025.06.11"
|
version = "2026.02.12.1"
|
||||||
description = "Python library for controlling Winegard Trav'ler satellite dishes via RS-485"
|
description = "Winegard satellite dish control for amateur radio sky tracking"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
|
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
|
||||||
@ -15,7 +15,8 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
travler-rotor = "travler_rotor.cli:main"
|
birdcage = "birdcage.cli:main"
|
||||||
|
console-probe = "console_probe.cli:main"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
@ -25,4 +26,4 @@ src = ["src"]
|
|||||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/travler_rotor"]
|
packages = ["src/birdcage", "src/console_probe"]
|
||||||
|
|||||||
1043
scripts/hidden_menu_probe.py
Normal file
1043
scripts/hidden_menu_probe.py
Normal file
File diff suppressed because it is too large
Load Diff
60
scripts/wordlists/winegard.txt
Normal file
60
scripts/wordlists/winegard.txt
Normal file
@ -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
|
||||||
@ -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 birdcage.antenna import AntennaConfig, BirdcageAntenna
|
||||||
from travler_rotor.leapfrog import apply_leapfrog
|
from birdcage.leapfrog import apply_leapfrog
|
||||||
from travler_rotor.protocol import (
|
from birdcage.protocol import (
|
||||||
CarryoutG2Protocol,
|
CarryoutG2Protocol,
|
||||||
FirmwareProtocol,
|
FirmwareProtocol,
|
||||||
HAL000Protocol,
|
HAL000Protocol,
|
||||||
@ -10,10 +10,11 @@ from travler_rotor.protocol import (
|
|||||||
Position,
|
Position,
|
||||||
RssiReading,
|
RssiReading,
|
||||||
)
|
)
|
||||||
from travler_rotor.rotctld import RotctldServer
|
from birdcage.rotctld import RotctldServer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AntennaConfig",
|
"AntennaConfig",
|
||||||
|
"BirdcageAntenna",
|
||||||
"CarryoutG2Protocol",
|
"CarryoutG2Protocol",
|
||||||
"FirmwareProtocol",
|
"FirmwareProtocol",
|
||||||
"HAL000Protocol",
|
"HAL000Protocol",
|
||||||
@ -21,6 +22,5 @@ __all__ = [
|
|||||||
"Position",
|
"Position",
|
||||||
"RssiReading",
|
"RssiReading",
|
||||||
"RotctldServer",
|
"RotctldServer",
|
||||||
"TravlerAntenna",
|
|
||||||
"apply_leapfrog",
|
"apply_leapfrog",
|
||||||
]
|
]
|
||||||
@ -10,8 +10,8 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from travler_rotor.leapfrog import apply_leapfrog
|
from birdcage.leapfrog import apply_leapfrog
|
||||||
from travler_rotor.protocol import (
|
from birdcage.protocol import (
|
||||||
MOTOR_AZIMUTH,
|
MOTOR_AZIMUTH,
|
||||||
MOTOR_ELEVATION,
|
MOTOR_ELEVATION,
|
||||||
FirmwareProtocol,
|
FirmwareProtocol,
|
||||||
@ -31,7 +31,7 @@ class AntennaConfig:
|
|||||||
leapfrog_enabled: bool = True
|
leapfrog_enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class TravlerAntenna:
|
class BirdcageAntenna:
|
||||||
"""High-level interface to a Winegard Trav'ler dish.
|
"""High-level interface to a Winegard Trav'ler dish.
|
||||||
|
|
||||||
Manages the full lifecycle: connect, initialize (boot + search kill),
|
Manages the full lifecycle: connect, initialize (boot + search kill),
|
||||||
@ -50,6 +50,11 @@ class TravlerAntenna:
|
|||||||
def config(self) -> AntennaConfig:
|
def config(self) -> AntennaConfig:
|
||||||
return self._config
|
return self._config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def protocol(self) -> FirmwareProtocol:
|
||||||
|
"""Access the underlying firmware protocol (for capability checks)."""
|
||||||
|
return self._protocol
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
return self._protocol.is_connected
|
return self._protocol.is_connected
|
||||||
@ -1,4 +1,4 @@
|
|||||||
"""CLI entry point for travler-rotor.
|
"""CLI entry point for birdcage.
|
||||||
|
|
||||||
Provides subcommands for initialization, position queries, manual moves,
|
Provides subcommands for initialization, position queries, manual moves,
|
||||||
and running a full rotctld-compatible server for Gpredict integration.
|
and running a full rotctld-compatible server for Gpredict integration.
|
||||||
@ -11,9 +11,9 @@ import sys
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from travler_rotor.antenna import AntennaConfig, TravlerAntenna
|
from birdcage.antenna import AntennaConfig, BirdcageAntenna
|
||||||
from travler_rotor.protocol import get_protocol
|
from birdcage.protocol import get_protocol
|
||||||
from travler_rotor.rotctld import RotctldServer
|
from birdcage.rotctld import RotctldServer
|
||||||
|
|
||||||
|
|
||||||
def _setup_logging(verbose: bool) -> None:
|
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:
|
def _build_antenna(port: str, firmware: str, **config_kwargs) -> BirdcageAntenna:
|
||||||
"""Create a TravlerAntenna from CLI options."""
|
"""Create a BirdcageAntenna from CLI options."""
|
||||||
protocol = get_protocol(firmware)
|
protocol = get_protocol(firmware)
|
||||||
# G2 defaults: 115200 baud, 18 deg min elevation
|
# G2 defaults: 115200 baud, 18 deg min elevation
|
||||||
if firmware.lower() == "g2":
|
if firmware.lower() == "g2":
|
||||||
config_kwargs.setdefault("baudrate", 115200)
|
config_kwargs.setdefault("baudrate", 115200)
|
||||||
config_kwargs.setdefault("min_elevation", 18.0)
|
config_kwargs.setdefault("min_elevation", 18.0)
|
||||||
config = AntennaConfig(port=port, **config_kwargs)
|
config = AntennaConfig(port=port, **config_kwargs)
|
||||||
return TravlerAntenna(protocol, config)
|
return BirdcageAntenna(protocol, config)
|
||||||
|
|
||||||
|
|
||||||
@click.group()
|
@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.")
|
@click.option("-v", "--verbose", is_flag=True, help="Enable debug logging.")
|
||||||
def main(verbose: bool) -> None:
|
def main(verbose: bool) -> None:
|
||||||
"""Control a Winegard Trav'ler satellite dish via RS-485."""
|
"""Control a Winegard Trav'ler satellite dish via RS-485."""
|
||||||
@ -47,14 +47,14 @@ def main(verbose: bool) -> None:
|
|||||||
@main.command()
|
@main.command()
|
||||||
@click.option(
|
@click.option(
|
||||||
"--port",
|
"--port",
|
||||||
envvar="TRAVLER_PORT",
|
envvar="BIRDCAGE_PORT",
|
||||||
default="/dev/ttyUSB0",
|
default="/dev/ttyUSB0",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
help="Serial port for the RS-485 adapter.",
|
help="Serial port for the RS-485 adapter.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--firmware",
|
"--firmware",
|
||||||
envvar="TRAVLER_FIRMWARE",
|
envvar="BIRDCAGE_FIRMWARE",
|
||||||
default="hal205",
|
default="hal205",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False),
|
type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False),
|
||||||
@ -76,14 +76,14 @@ def init(port: str, firmware: str) -> None:
|
|||||||
@main.command()
|
@main.command()
|
||||||
@click.option(
|
@click.option(
|
||||||
"--port",
|
"--port",
|
||||||
envvar="TRAVLER_PORT",
|
envvar="BIRDCAGE_PORT",
|
||||||
default="/dev/ttyUSB0",
|
default="/dev/ttyUSB0",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
help="Serial port for the RS-485 adapter.",
|
help="Serial port for the RS-485 adapter.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--firmware",
|
"--firmware",
|
||||||
envvar="TRAVLER_FIRMWARE",
|
envvar="BIRDCAGE_FIRMWARE",
|
||||||
default="hal205",
|
default="hal205",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False),
|
type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False),
|
||||||
@ -91,14 +91,14 @@ def init(port: str, firmware: str) -> None:
|
|||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--host",
|
"--host",
|
||||||
envvar="TRAVLER_LISTEN_HOST",
|
envvar="BIRDCAGE_LISTEN_HOST",
|
||||||
default="127.0.0.1",
|
default="127.0.0.1",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
help="Address to listen on for rotctld connections.",
|
help="Address to listen on for rotctld connections.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--listen-port",
|
"--listen-port",
|
||||||
envvar="TRAVLER_LISTEN_PORT",
|
envvar="BIRDCAGE_LISTEN_PORT",
|
||||||
default=4533,
|
default=4533,
|
||||||
show_default=True,
|
show_default=True,
|
||||||
type=int,
|
type=int,
|
||||||
@ -136,14 +136,14 @@ def serve(
|
|||||||
@main.command()
|
@main.command()
|
||||||
@click.option(
|
@click.option(
|
||||||
"--port",
|
"--port",
|
||||||
envvar="TRAVLER_PORT",
|
envvar="BIRDCAGE_PORT",
|
||||||
default="/dev/ttyUSB0",
|
default="/dev/ttyUSB0",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
help="Serial port for the RS-485 adapter.",
|
help="Serial port for the RS-485 adapter.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--firmware",
|
"--firmware",
|
||||||
envvar="TRAVLER_FIRMWARE",
|
envvar="BIRDCAGE_FIRMWARE",
|
||||||
default="hal205",
|
default="hal205",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False),
|
type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False),
|
||||||
@ -170,14 +170,14 @@ def pos(port: str, firmware: str) -> None:
|
|||||||
@main.command()
|
@main.command()
|
||||||
@click.option(
|
@click.option(
|
||||||
"--port",
|
"--port",
|
||||||
envvar="TRAVLER_PORT",
|
envvar="BIRDCAGE_PORT",
|
||||||
default="/dev/ttyUSB0",
|
default="/dev/ttyUSB0",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
help="Serial port for the RS-485 adapter.",
|
help="Serial port for the RS-485 adapter.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--firmware",
|
"--firmware",
|
||||||
envvar="TRAVLER_FIRMWARE",
|
envvar="BIRDCAGE_FIRMWARE",
|
||||||
default="hal205",
|
default="hal205",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False),
|
type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False),
|
||||||
@ -312,29 +312,24 @@ class CarryoutG2Protocol(FirmwareProtocol):
|
|||||||
def get_position(self) -> Position:
|
def get_position(self) -> Position:
|
||||||
"""Query dish position.
|
"""Query dish position.
|
||||||
|
|
||||||
The G2 may return floats without AZ=/EL= labels, so we try the
|
G2 firmware 02.02.48 returns position as::
|
||||||
labeled format first and fall back to raw float extraction.
|
|
||||||
|
Angle[0] = 180.00
|
||||||
|
Angle[1] = 45.00
|
||||||
|
MOT>
|
||||||
|
|
||||||
|
Where Angle[0] is azimuth and Angle[1] is elevation.
|
||||||
"""
|
"""
|
||||||
response = self._send("a")
|
response = self._send("a")
|
||||||
|
|
||||||
# Try labeled format (AZ = / EL =) for compatibility
|
# G2 format: Angle[0] = <az>, Angle[1] = <el>
|
||||||
az_match = re.search(r"AZ\s*=?\s*(\d+\.\d+)", response)
|
az_match = re.search(r"Angle\[0\]\s*=\s*(-?\d+\.\d+)", response)
|
||||||
el_match = re.search(r"EL\s*=?\s*(\d+\.\d+)", response)
|
el_match = re.search(r"Angle\[1\]\s*=\s*(-?\d+\.\d+)", response)
|
||||||
|
|
||||||
if az_match and el_match:
|
if az_match and el_match:
|
||||||
sk_match = re.search(r"SK\s*=?\s*(\d+\.\d+)", response)
|
|
||||||
return Position(
|
return Position(
|
||||||
azimuth=float(az_match.group(1)),
|
azimuth=float(az_match.group(1)),
|
||||||
elevation=float(el_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}")
|
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:
|
def get_rssi(self, iterations: int = 10) -> RssiReading:
|
||||||
"""Read averaged RSSI signal strength (DVB submenu).
|
"""Read averaged RSSI signal strength (DVB submenu).
|
||||||
|
|
||||||
|
Firmware response format::
|
||||||
|
|
||||||
|
iterations:5 interval(msec):20
|
||||||
|
Reads:5 RSSI[avg: 500 cur: 500]
|
||||||
|
DVB>
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
iterations: Number of samples to average.
|
iterations: Number of samples to average.
|
||||||
|
|
||||||
@ -377,13 +378,16 @@ class CarryoutG2Protocol(FirmwareProtocol):
|
|||||||
ValueError: If the RSSI response can't be parsed.
|
ValueError: If the RSSI response can't be parsed.
|
||||||
"""
|
"""
|
||||||
response = self._send(f"rssi {iterations}")
|
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(
|
return RssiReading(
|
||||||
reads=int(results[3]),
|
reads=int(match.group(1)),
|
||||||
average=int(results[4]),
|
average=int(match.group(2)),
|
||||||
current=int(results[5]),
|
current=int(match.group(3)),
|
||||||
)
|
)
|
||||||
|
|
||||||
raise ValueError(f"Could not parse RSSI from: {response!r}")
|
raise ValueError(f"Could not parse RSSI from: {response!r}")
|
||||||
@ -8,6 +8,12 @@ Hamlib clients) use for AZ/EL rotor control:
|
|||||||
S — stop / disconnect
|
S — stop / disconnect
|
||||||
_ — get model name
|
_ — get model name
|
||||||
q — quit connection
|
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
|
from __future__ import annotations
|
||||||
@ -15,11 +21,12 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
from travler_rotor.antenna import TravlerAntenna
|
from birdcage.antenna import BirdcageAntenna
|
||||||
|
from birdcage.protocol import CarryoutG2Protocol
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MODEL_NAME = "Winegard Trav'ler RS-485 Rotor"
|
MODEL_NAME = "Birdcage — Winegard RS-485 Rotor"
|
||||||
|
|
||||||
|
|
||||||
class RotctldServer:
|
class RotctldServer:
|
||||||
@ -27,7 +34,7 @@ class RotctldServer:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
antenna: TravlerAntenna,
|
antenna: BirdcageAntenna,
|
||||||
host: str = "127.0.0.1",
|
host: str = "127.0.0.1",
|
||||||
port: int = 4533,
|
port: int = 4533,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -97,6 +104,12 @@ class RotctldServer:
|
|||||||
self._handle_model_name(conn)
|
self._handle_model_name(conn)
|
||||||
elif cmd == "q":
|
elif cmd == "q":
|
||||||
break
|
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:
|
else:
|
||||||
logger.warning("Unknown command: %s", cmd)
|
logger.warning("Unknown command: %s", cmd)
|
||||||
conn.sendall(b"RPRT -1\n")
|
conn.sendall(b"RPRT -1\n")
|
||||||
@ -133,3 +146,61 @@ class RotctldServer:
|
|||||||
def _handle_model_name(self, conn: socket.socket) -> None:
|
def _handle_model_name(self, conn: socket.socket) -> None:
|
||||||
"""Respond to '_' — return model identification string."""
|
"""Respond to '_' — return model identification string."""
|
||||||
conn.sendall(f"{MODEL_NAME}\n".encode())
|
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")
|
||||||
30
uv.lock
generated
30
uv.lock
generated
@ -2,6 +2,21 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.11"
|
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]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.3.1"
|
version = "8.3.1"
|
||||||
@ -31,18 +46,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6
|
|||||||
wheels = [
|
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" },
|
{ 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" },
|
|
||||||
]
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user