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