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(); +}