Files
remote-rig/firmware/src/esp8266-camera-bridge.cpp
T
Joshua King 403e1d9edd
Build (Dev) / build (push) Failing after 9s
CI/CD / lint-and-typecheck (push) Successful in 9m28s
CI/CD / test (push) Successful in 9m27s
CI/CD / build (push) Failing after 4m53s
CI/CD / deploy (push) Has been skipped
firmware: no-reflash config updates for ESP-01S + UART-OTA groundwork
Updating the buried ESP-01S currently means a USB-UART adapter and a
GPIO0 jumper. Add a path to change its settings without reflashing, and
lay the groundwork for full firmware updates over the existing UART.

set_config (no reflash for settings):
- ESP-01S: add saveConfig() + a set_config command — updates GoPro
  SSID/password/IP and poll interval, persists to LittleFS, acks, and
  re-associates Wi-Fi if creds changed
- XIAO: forward an MQTT set_camera_config down to the ESP-01S over UART
  (hub -> MQTT -> XIAO -> UART -> ESP-01S/LittleFS)

UART-OTA groundwork ("XIAO as flasher"):
- reserve XIAO GPIOs ESP01_RST_PIN=D8, ESP01_PGM_PIN=D10 for driving the
  ESP-01S serial bootloader (not driven yet)
- docs/design/esp01s-uart-ota.md: full design (why Wi-Fi OTA doesn't fit
  the 1MB ESP-01S on the GoPro AP, bootloader entry, ROM flash protocol,
  HTTP-pull delivery, scope)
- hardware/README.md: fix stale ESP32-C3 -> XIAO ESP32-C6 wiring, add the
  two control lines (Notion wiring diagram updated to match)

Both firmwares build clean and are flashed; set_config round-trip needs
the broker to exercise end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 19:11:34 -04:00

311 lines
11 KiB
C++

/**
* RemoteRig — ESP8266 Camera Bridge Firmware
* ==========================================
* Dedicated board clipped to each GoPro Hero 3.
*
* ONE JOB: talk to the camera.
* - Connects to GoPro Wi-Fi AP (10.5.5.1)
* - Polls status every 30s → sends JSON over UART to ESP32
* - Receives commands from ESP32 over UART → executes against camera
* - Zero network switching, zero MQTT, zero cloud
*
* UART Protocol: JSON-per-line at 115200 8N1
* ESP8266 → ESP32: {"type":"status","battery_raw":217,...}\n
* ESP8266 → ESP32: {"type":"ack","cmd":"start_recording"}\n
* ESP8266 → ESP32: {"type":"error","msg":"..."}\n
* ESP32 → ESP8266: {"type":"cmd","command":"start_recording"}\n
*
* Hardware:
* - 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
* - 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 <Arduino.h>
#include <ESP8266WiFi.h>
#include <WiFiClient.h>
#include <ESP8266HTTPClient.h>
#include <ArduinoJson.h>
#include <LittleFS.h>
// ────────────────────────────────────────────
// Configuration (SPIFFS via LittleFS)
// ────────────────────────────────────────────
struct Config {
String camera_ssid = "GOPRO-BP-";
String camera_password = "goprohero";
String camera_ip = "10.5.5.1";
int poll_interval_sec = 30;
} cfg;
bool loadConfig() {
if (!LittleFS.begin()) { Serial.println("[CFG] LittleFS mount failed"); return false; }
File f = LittleFS.open("/config.json", "r");
if (!f) { Serial.println("[CFG] No config — using defaults"); return false; }
JsonDocument doc;
DeserializationError err = deserializeJson(doc, f);
f.close();
if (err) { Serial.printf("[CFG] Parse error: %s\n", err.c_str()); return false; }
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;
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)
// ────────────────────────────────────────────
WiFiClient goproClient;
struct CamStatus {
bool valid = false;
int video_remaining_sec = 0;
bool recording = false;
int battery_raw = 0;
};
CamStatus fetchStatus() {
CamStatus s;
String url = "http://" + cfg.camera_ip +
"/bacpac/SH?t=" + cfg.camera_password + "&p=%01";
HTTPClient http;
http.useHTTP10(true);
http.begin(goproClient, url);
http.setTimeout(5000);
int code = http.GET();
if (code != 200) { http.end(); return s; }
String raw = http.getString();
http.end();
if (raw.length() < 58) return s;
const uint8_t* buf = (const uint8_t*)raw.c_str();
s.valid = true;
s.video_remaining_sec = buf[25] | (buf[26] << 8);
s.recording = (buf[29] == 1);
s.battery_raw = buf[57];
return s;
}
bool sendCommand(const String& cmd) {
String param = (cmd == "start_recording") ? "%01" : "%00";
String url = "http://" + cfg.camera_ip +
"/bacpac/SH?t=" + cfg.camera_password + "&p=" + param;
HTTPClient http;
http.useHTTP10(true);
http.begin(goproClient, url);
http.setTimeout(5000);
int code = http.GET();
http.end();
return (code == 200);
}
// ────────────────────────────────────────────
// UART Protocol (to ESP32)
// ────────────────────────────────────────────
// Using HardwareSerial on GPIO1/3 (D1 Mini default TX/RX)
// On D1 Mini: TX=GPIO1, RX=GPIO3 (labeled TX/RX on board)
// Send JSON line to ESP32
void sendToESP32(const JsonDocument& doc) {
String line;
serializeJson(doc, line);
Serial.println(line); // newline-terminated for framing
Serial.flush();
}
// Send status update
void sendStatus(const CamStatus& s) {
JsonDocument doc;
doc["type"] = "status";
doc["valid"] = s.valid;
doc["battery_raw"] = s.battery_raw;
doc["video_remaining_sec"] = s.video_remaining_sec;
doc["recording"] = s.recording;
doc["online"] = s.valid;
doc["uptime_ms"] = millis();
sendToESP32(doc);
}
// Send acknowledgment
void sendAck(const String& cmd) {
JsonDocument doc;
doc["type"] = "ack";
doc["cmd"] = cmd;
sendToESP32(doc);
}
// Send error
void sendError(const String& msg) {
JsonDocument doc;
doc["type"] = "error";
doc["msg"] = msg;
sendToESP32(doc);
}
// ────────────────────────────────────────────
// Command handling (from ESP32 over UART)
// ────────────────────────────────────────────
void handleCommand(const JsonDocument& doc) {
String cmd = doc["command"] | "";
if (cmd == "start_recording" || cmd == "stop_recording") {
bool ok = sendCommand(cmd);
if (ok) {
sendAck(cmd);
} else {
sendError("Camera unreachable — command failed");
}
} else if (cmd == "ping") {
JsonDocument pong;
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);
}
}
// ────────────────────────────────────────────
// UART line reader (non-blocking)
// ────────────────────────────────────────────
String serialLine;
bool readLine(String& line) {
while (Serial.available()) {
char c = Serial.read();
if (c == '\n') {
line = serialLine;
serialLine = "";
return true;
}
if (c != '\r') serialLine += c;
}
return false;
}
// ────────────────────────────────────────────
// 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] ESP-01S Camera Bridge v1.0");
loadConfig();
// Connect to GoPro AP — this is the ONLY network we touch
Serial.printf("[WIFI] Connecting to camera AP: %s\n", cfg.camera_ssid.c_str());
WiFi.mode(WIFI_STA);
WiFi.begin(cfg.camera_ssid.c_str(), cfg.camera_password.c_str());
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
delay(500); Serial.print("."); attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
} else {
Serial.println("\n[WIFI] FAILED — will retry in loop");
}
}
// ────────────────────────────────────────────
// Main Loop — poll camera, relay over UART
// ────────────────────────────────────────────
void loop() {
unsigned long now = millis();
static unsigned long lastPoll = 0;
static unsigned long lastWiFiRetry = 0;
// ── Wi-Fi reconnection ──
if (WiFi.status() != WL_CONNECTED && now - lastWiFiRetry > 10000) {
lastWiFiRetry = now;
Serial.println("[WIFI] Reconnecting...");
WiFi.reconnect();
}
// ── Poll camera ──
if (now - lastPoll > (unsigned long)(cfg.poll_interval_sec * 1000)) {
lastPoll = now;
if (WiFi.status() == WL_CONNECTED) {
CamStatus s = fetchStatus();
sendStatus(s);
} else {
// Offline — send empty status so ESP32 knows we're alive but camera is down
CamStatus s;
sendStatus(s);
}
}
// ── Read commands from ESP32 over UART ──
String line;
if (readLine(line)) {
JsonDocument doc;
DeserializationError err = deserializeJson(doc, line);
if (!err) {
String type = doc["type"] | "";
if (type == "cmd") {
handleCommand(doc);
}
// Ignore other message types — they're for the ESP32
}
}
}