/** * 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 #include #include #include #include #include // ──────────────────────────────────────────── // 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 } } }