diff --git a/docs/design/esp01s-uart-ota.md b/docs/design/esp01s-uart-ota.md new file mode 100644 index 0000000..059b42c --- /dev/null +++ b/docs/design/esp01s-uart-ota.md @@ -0,0 +1,100 @@ +# ESP-01S firmware updates without a USB-UART adapter + +Status: **design / not yet implemented.** Interim mitigations (config-over-UART, +GPIO reservation) are shipped; see "Shipped now" below. + +## Problem + +The ESP-01S camera bridge has no native USB. Today it is flashed with an external +3.3 V USB-UART adapter and a `GPIO0 → GND` jumper held during reset. On an assembled +field node that is impractical — we want to update it over the network. + +## Why not Wi-Fi OTA on the ESP-01S itself + +1. **Network topology.** The ESP-01S joins the *GoPro* AP (`10.5.5.1`), not the hub / + travel-router network. The hub cannot reach it to push an OTA. +2. **1 MB flash.** Standard ESP8266 OTA stages a second copy of the sketch alongside the + running one. Our sketch is ~333 KB; a 1 MB module has no room for two copies plus FS + and reserved areas. + +So updates must arrive **through the XIAO**, which is already UART-connected to the +ESP-01S and sits on the hub network. + +## Approach: XIAO ESP32-C6 as the flasher (UART OTA) + +The XIAO plays the role the USB-UART adapter plays today, driving the ESP-01S's ROM +serial bootloader over the existing UART. + +### Hardware — two added control lines + +| XIAO pin | → ESP-01S | purpose | +|----------|-----------|---------| +| `D8` (`ESP01_RST_PIN`) | `RST` | pulse low to reset the ESP-01S | +| `D10` (`ESP01_PGM_PIN`) | `GPIO0` | hold low across reset → enter bootloader | +| `D6` (TX) / `D7` (RX) | `RX` / `TX` | existing `Serial1` link (crossed) | +| GND | GND | common ground | + +> **Confirm before committing the PCB/wiring:** verify `D8`/`D10` on the actual XIAO +> ESP32-C6 variant do **not** map to ESP32-C6 strapping pins (`GPIO8`, `GPIO9`, `GPIO15`) +> or the USB-JTAG pins. Pins are reserved in firmware (`ESP01_RST_PIN`, `ESP01_PGM_PIN`) +> but not yet driven. + +### Bootloader entry + +`GPIO0 = LOW`, pulse `RST` low→high → ESP-01S enters the serial bootloader on the UART. +After writing: `GPIO0 = HIGH`, pulse `RST` → run the new firmware. Always restore +`GPIO0 = HIGH` on give-up so the ESP can boot normally. + +### Flash protocol + +Implement enough of the ESP8266 ROM bootloader / esptool SLIP protocol on the XIAO over +`Serial1`: + +- `SYNC`, then `FLASH_BEGIN` / `FLASH_DATA` (≈1 KB blocks) / `FLASH_END` to write the app + at offset `0x0`. +- Start at 115200 baud; optionally raise after sync. +- Verify with the ROM `SPI_FLASH_MD5` against the expected MD5. + +### Firmware delivery (hub → XIAO) + +Greenfield on the Go hub (only a `firmware_version` field exists today). Recommended: + +- **HTTP pull.** Hub exposes `GET /firmware/esp01s/.bin` (+ MD5). XIAO is + triggered by an MQTT command, e.g. + `{"command":"update_esp01s","url":"http:///firmware/esp01s/0.4.0.bin","md5":"…"}`, + fetches the `.bin` in chunks, and streams each chunk straight into a `FLASH_DATA` block. +- Avoid buffering the whole image in RAM — stream HTTP chunk → flash block → repeat. +- MQTT chunked transfer is possible but heavier on the broker; prefer HTTP. + +### Sequencing / safety + +- Pause the UART JSON status/command protocol while flashing (the link is busy with the + bootloader protocol). +- On failure leave the ESP recoverable and retry; report progress/result to the hub over + MQTT. + +### XIAO self-update (separate, easy) + +The XIAO (4 MB flash, on the hub network) can use standard ESP32 OTA (`ArduinoOTA` or +`httpUpdate`). No gymnastics required. Split: **XIAO = native OTA; ESP-01S = flashed by +the XIAO over UART.** + +## Scope estimate + +- **XIAO firmware:** ESP8266 ROM-loader client over `Serial1` + `GPIO0`/`RST` control + + HTTP fetch + MQTT trigger. Medium–large. +- **Hub (Go):** firmware store + HTTP endpoint + MQTT trigger command. Small–medium. +- **Hardware:** two control wires + confirm non-strapping pins. Small. + +## Shipped now (interim) + +- **Config-over-UART (`set_config`)** — change GoPro SSID/password/IP and poll interval + with no reflash. Hub → `set_camera_config` (MQTT) → XIAO → `set_config` (UART) → + ESP-01S persists to LittleFS. +- **GPIO reservation** — `ESP01_RST_PIN = D8`, `ESP01_PGM_PIN = D10` reserved in the XIAO + firmware for the flasher. + +## References + +- ESP8266 ROM serial bootloader protocol (esptool). +- Wiring: Notion "XIAO ESP32-C6 Pin-to-Pin Wiring Diagram". diff --git a/firmware/src/esp32-mqtt-bridge.cpp b/firmware/src/esp32-mqtt-bridge.cpp index ec6c5b8..bfa4c2c 100644 --- a/firmware/src/esp32-mqtt-bridge.cpp +++ b/firmware/src/esp32-mqtt-bridge.cpp @@ -113,6 +113,13 @@ int batteryPct(int raw) { #define UART_RX_PIN D7 #define UART_TX_PIN D6 +// Reserved for future ESP-01S UART OTA ("XIAO as flasher"): two control +// lines let the XIAO drive the ESP-01S into its serial bootloader and +// reflash it over Serial1 — no USB-UART adapter or GPIO0 jumper needed. +// Not driven yet; see docs/design/esp01s-uart-ota.md. +#define ESP01_RST_PIN D8 // → ESP-01S RST (pulse low to reset) +#define ESP01_PGM_PIN D10 // → ESP-01S GPIO0 (low at reset = bootloader) + // ──────────────────────────────────────────── // RGB STAT LED — D0/D1/D2 (red/green/blue) via 220Ω each // ──────────────────────────────────────────── @@ -263,6 +270,20 @@ void mqttCallback(char* topic, byte* payload, unsigned int len) { saveConfig(); Serial.printf("[BAT] Calibration set: raw_min=%d raw_max=%d\n", cfg.bat_raw_min, cfg.bat_raw_max); + } else if (cmd == "set_camera_config") { + // Forward camera-bridge config to the ESP-01S over UART so the + // GoPro creds / poll rate can change without reflashing it. + JsonDocument out; + out["type"] = "cmd"; + out["command"] = "set_config"; + if (!doc["camera_ssid"].isNull()) out["camera_ssid"] = doc["camera_ssid"]; + if (!doc["camera_password"].isNull()) out["camera_password"] = doc["camera_password"]; + if (!doc["camera_ip"].isNull()) out["camera_ip"] = doc["camera_ip"]; + if (!doc["poll_interval_sec"].isNull()) out["poll_interval_sec"] = doc["poll_interval_sec"]; + String line; serializeJson(out, line); + UART_ESP8266.println(line); + UART_ESP8266.flush(); + Serial.println("[MQTT] Forwarded set_config → ESP-01S"); } else if (cmd == "registered") { String id = doc["camera_id"] | ""; if (id.length() > 0 && id != cfg.camera_id) { diff --git a/firmware/src/esp8266-camera-bridge.cpp b/firmware/src/esp8266-camera-bridge.cpp index c816992..1cd056c 100644 --- a/firmware/src/esp8266-camera-bridge.cpp +++ b/firmware/src/esp8266-camera-bridge.cpp @@ -16,17 +16,23 @@ * ESP32 → ESP8266: {"type":"cmd","command":"start_recording"}\n * * Hardware: - * - ESP8266 D1 Mini (or NodeMCU) - * - UART TX → ESP32 RX (GPIO 16) - * - UART RX → ESP32 TX (GPIO 16) + * - ESP-01S (ESP8266, 1MB flash) on its own 3.3V buck + * - UART is the hardware Serial (GPIO1 TX / GPIO3 RX), crossed: + * ESP-01S TX (GPIO1) → XIAO D7 (RX) + * ESP-01S RX (GPIO3) ← XIAO D6 (TX) * - Shared GND between boards - * - LiPo → 3.3V buck → VIN on both boards + * - Flash with a 3.3V USB-UART adapter, GPIO0 → GND on power-up + * + * Note: the JSON protocol shares the same UART as the boot-ROM/debug + * output, so the ESP32 also sees boot chatter and ignores it as + * non-JSON. There is no spare pin for a status LED on the ESP-01S + * (GPIO1 is the UART TX) — status is shown on the XIAO panel instead. */ #include #include #include -#include +#include #include #include @@ -58,6 +64,22 @@ bool loadConfig() { return true; } +// Persist current config to LittleFS. Lets the hub update camera +// credentials/poll rate over UART without reflashing the ESP-01S. +bool saveConfig() { + if (!LittleFS.begin()) { Serial.println("[CFG] LittleFS mount failed"); return false; } + File f = LittleFS.open("/config.json", "w"); + if (!f) { Serial.println("[CFG] open for write failed"); return false; } + JsonDocument doc; + doc["camera_ssid"] = cfg.camera_ssid; + doc["camera_password"] = cfg.camera_password; + doc["camera_ip"] = cfg.camera_ip; + doc["poll_interval_sec"] = cfg.poll_interval_sec; + serializeJson(doc, f); + f.close(); + return true; +} + // ──────────────────────────────────────────── // Camera HTTP Client (GoPro Hero 3) // ──────────────────────────────────────────── @@ -173,6 +195,21 @@ void handleCommand(const JsonDocument& doc) { pong["type"] = "pong"; pong["uptime_ms"] = millis(); sendToESP32(pong); + } else if (cmd == "set_config") { + // No-reflash config update from the hub (via the XIAO over UART). + // Only provided fields change; the rest keep their current value. + String oldSsid = cfg.camera_ssid, oldPw = cfg.camera_password; + cfg.camera_ssid = doc["camera_ssid"] | cfg.camera_ssid; + cfg.camera_password = doc["camera_password"] | cfg.camera_password; + cfg.camera_ip = doc["camera_ip"] | cfg.camera_ip; + cfg.poll_interval_sec = doc["poll_interval_sec"] | cfg.poll_interval_sec; + saveConfig(); + sendAck("set_config"); + // Re-associate if the camera Wi-Fi credentials changed. + if (cfg.camera_ssid != oldSsid || cfg.camera_password != oldPw) { + WiFi.disconnect(); + WiFi.begin(cfg.camera_ssid.c_str(), cfg.camera_password.c_str()); + } } else { sendError("Unknown command: " + cmd); } @@ -197,26 +234,16 @@ bool readLine(String& line) { return false; } -// ──────────────────────────────────────────── -// LED -// ──────────────────────────────────────────── - -const int LED = LED_BUILTIN; // active-low on ESP8266 D1 Mini - -void ledOn() { digitalWrite(LED, LOW); } -void ledOff() { digitalWrite(LED, HIGH); } - // ──────────────────────────────────────────── // Setup // ──────────────────────────────────────────── +// No status LED: GPIO1 is the UART TX to the XIAO and GPIO3 is RX, +// leaving no free pin on the ESP-01S. Status lives on the XIAO panel. void setup() { Serial.begin(115200); delay(500); - Serial.println("\n[BRIDGE] ESP8266 Camera Bridge v1.0"); - - pinMode(LED, OUTPUT); - ledOff(); + Serial.println("\n[BRIDGE] ESP-01S Camera Bridge v1.0"); loadConfig(); @@ -232,7 +259,6 @@ void setup() { if (WiFi.status() == WL_CONNECTED) { Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str()); - ledOn(); // Solid = connected } else { Serial.println("\n[WIFI] FAILED — will retry in loop"); } @@ -246,7 +272,6 @@ void loop() { unsigned long now = millis(); static unsigned long lastPoll = 0; static unsigned long lastWiFiRetry = 0; - static bool cameraOnline = false; // ── Wi-Fi reconnection ── if (WiFi.status() != WL_CONNECTED && now - lastWiFiRetry > 10000) { @@ -261,15 +286,6 @@ void loop() { if (WiFi.status() == WL_CONNECTED) { CamStatus s = fetchStatus(); - - if (s.valid && !cameraOnline) { - cameraOnline = true; - ledOn(); - } else if (!s.valid && cameraOnline) { - cameraOnline = false; - ledOff(); - } - sendStatus(s); } else { // Offline — send empty status so ESP32 knows we're alive but camera is down @@ -291,13 +307,4 @@ void loop() { // Ignore other message types — they're for the ESP32 } } - - // ── LED blink when offline ── - if (!cameraOnline) { - static unsigned long lastBlink = 0; - if (now - lastBlink > 500) { - lastBlink = now; - digitalWrite(LED, !digitalRead(LED)); - } - } } diff --git a/hardware/README.md b/hardware/README.md index 9ce239e..b29be10 100644 --- a/hardware/README.md +++ b/hardware/README.md @@ -129,7 +129,7 @@ USB Power Bank └── USB-C cable → bottom USB-C female input on Camera Node Case ├── rocker switch → node power rail ├── PWR LED indicator - ├── ESP32-C3 Super Mini + ├── XIAO ESP32-C6 ├── ESP-01S / ESP8266 ├── 1.3-inch OLED display ├── RGB status LED @@ -138,9 +138,14 @@ USB Power Bank (power only — no data) UART / control inside case: - ESP-01S TX ──→ ESP32-C3 RX - ESP-01S RX ←── ESP32-C3 TX - ESP-01S GND ─── ESP32-C3 GND + ESP-01S TX (GPIO1) ──→ XIAO D7 (RX) + ESP-01S RX (GPIO3) ←── XIAO D6 (TX) + ESP-01S GND ─── XIAO GND + + # Reserved for UART OTA (XIAO reflashes the ESP-01S — no adapter): + ESP-01S RST ←── XIAO D8 (pulse low to reset) + ESP-01S GPIO0 ←── XIAO D10 (low at reset = bootloader) + # See docs/design/esp01s-uart-ota.md ``` **Power note:** exact wiring depends on the regulator/power board used. Confirm OLED voltage, LED current limiting, and whether the rocker switches USB 5 V input or a regulated node rail.