Add ESP32-S3 BLE-to-RS422 bridge firmware for Carryout G2

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.
This commit is contained in:
Ryan Malloy 2026-02-11 14:33:10 -07:00
parent 1192b31166
commit 068f38d7eb
5 changed files with 372 additions and 0 deletions

View file

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

View file

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

View file

@ -0,0 +1,187 @@
#include <Arduino.h>
#include <NimBLEDevice.h>
#include <Adafruit_NeoPixel.h>
#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();
}