// RemoteRig — ESP32 + ESP8266 Unified Firmware // ============================================ // Compatible with both ESP32 and ESP8266 via PlatformIO build targets. // // ESP32: Dual-STA — simultaneously connected to GoPro AP + travel router // ESP8266: Time-shared STA — alternates between GoPro AP and travel router // (30s GoPro polling → switch to router → MQTT publish → switch back) // // Build: pio run -e esp32dev (ESP32) // pio run -e esp8266dev (ESP8266 / D1 Mini) // ──────────────────────────────────────────── // Platform-specific includes and types // ──────────────────────────────────────────── #include #ifdef ESP32 #include #define HAS_DUAL_STA 1 #elif defined(ESP8266) #include #define HAS_DUAL_STA 0 #else #error "Unsupported platform — use ESP32 or ESP8266" #endif #include #include #include #include #ifdef ESP8266 #include #define SPIFFS LittleFS #else #include #endif // ──────────────────────────────────────────── // Configuration // ──────────────────────────────────────────── struct Config { String wifi_ssid = "RemoteRig"; String wifi_password = ""; String camera_ssid = "GOPRO-BP-"; String camera_password = "goprohero"; String camera_ip = "10.5.5.1"; String mqtt_broker = "192.168.4.10"; int mqtt_port = 1883; String camera_id = ""; int poll_sec = 30; int heartbeat_sec = 60; bool dirty = false; } cfg; // ──────────────────────────────────────────── // Platform-specific Wi-Fi // ──────────────────────────────────────────── WiFiClient goproClient; WiFiClient routerClient; PubSubClient mqtt(routerClient); unsigned long bootMs = 0; bool cameraOnline = false; // ESP8266: track which network we're on #if !HAS_DUAL_STA enum NetState { NET_ROUTER, NET_CAMERA, NET_SWITCHING }; NetState netState = NET_ROUTER; unsigned long lastNetSwitch = 0; const unsigned long NET_SWITCH_DELAY = 2000; // 2s to switch networks #endif const int LED_PIN = #ifdef ESP32 2 #else LED_BUILTIN // ESP8266 D1 Mini = GPIO 2 (active low!) #endif ; // ──────────────────────────────────────────── // SPIFFS / LittleFS Config // ──────────────────────────────────────────── bool loadConfig() { #ifdef ESP8266 if (!LittleFS.begin()) { Serial.println("LittleFS mount failed"); return false; } #else if (!SPIFFS.begin(true)) { Serial.println("SPIFFS mount failed"); return false; } #endif File f = #ifdef ESP8266 LittleFS.open("/config.json", "r"); #else SPIFFS.open("/config.json", "r"); #endif if (!f) { Serial.println("No config — using defaults"); return false; } JsonDocument doc; DeserializationError err = deserializeJson(doc, f); f.close(); if (err) { Serial.printf("Config parse error: %s\n", err.c_str()); return false; } cfg.wifi_ssid = doc["wifi_ssid"] | cfg.wifi_ssid; cfg.wifi_password = doc["wifi_password"] | cfg.wifi_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.mqtt_broker = doc["mqtt_broker"] | cfg.mqtt_broker; cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port; cfg.camera_id = doc["camera_id"] | cfg.camera_id; cfg.poll_sec = doc["poll_interval_sec"] | cfg.poll_sec; cfg.heartbeat_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_sec; Serial.println("Config loaded"); return true; } bool saveConfig() { File f = #ifdef ESP8266 LittleFS.open("/config.json", "w"); #else SPIFFS.open("/config.json", "w"); #endif if (!f) return false; JsonDocument doc; doc["wifi_ssid"] = cfg.wifi_ssid; doc["wifi_password"] = cfg.wifi_password; doc["camera_ssid"] = cfg.camera_ssid; doc["camera_password"] = cfg.camera_password; doc["camera_ip"] = cfg.camera_ip; doc["mqtt_broker"] = cfg.mqtt_broker; doc["mqtt_port"] = cfg.mqtt_port; doc["camera_id"] = cfg.camera_id; doc["poll_interval_sec"] = cfg.poll_sec; doc["heartbeat_interval_sec"] = cfg.heartbeat_sec; serializeJson(doc, f); f.close(); return true; } // ──────────────────────────────────────────── // Wi-Fi Connection // ──────────────────────────────────────────── #if HAS_DUAL_STA // ESP32: connect to both simultaneously bool connectBoth() { WiFi.mode(WIFI_STA); WiFi.begin(cfg.wifi_ssid.c_str(), cfg.wifi_password.c_str()); int attempts = 0; while (WiFi.status() != WL_CONNECTED && attempts < 40) { delay(500); Serial.print("."); attempts++; } if (WiFi.status() != WL_CONNECTED) return false; Serial.printf("\nRouter: %s\n", WiFi.localIP().toString().c_str()); // GoPro connection handled per-poll (just HTTP to camera IP) // Camera is assumed always reachable at camera_ip via the GoPro AP return true; } #else // ESP8266: connect to one network at a time bool connectTo(const String& ssid, const String& pass) { WiFi.mode(WIFI_STA); WiFi.begin(ssid.c_str(), pass.c_str()); int attempts = 0; while (WiFi.status() != WL_CONNECTED && attempts < 30) { delay(500); Serial.print("."); attempts++; } return WiFi.status() == WL_CONNECTED; } void switchToRouter() { if (netState == NET_ROUTER) return; netState = NET_SWITCHING; WiFi.disconnect(); delay(500); connectTo(cfg.wifi_ssid, cfg.wifi_password); netState = NET_ROUTER; lastNetSwitch = millis(); Serial.printf("Switched to router: %s\n", WiFi.localIP().toString().c_str()); } void switchToCamera() { if (netState == NET_CAMERA) return; netState = NET_SWITCHING; WiFi.disconnect(); delay(500); connectTo(cfg.camera_ssid, cfg.camera_password); netState = NET_CAMERA; lastNetSwitch = millis(); Serial.printf("Switched to camera AP: %s\n", WiFi.localIP().toString().c_str()); } #endif // ──────────────────────────────────────────── // GoPro / Camera HTTP API // ──────────────────────────────────────────── struct CamStatus { bool valid = false; int video_remaining_sec = 0; bool recording = false; int battery_raw = 0; }; CamStatus fetchCameraStatus() { 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 sendCameraCommand(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); } // ──────────────────────────────────────────── // MQTT // ──────────────────────────────────────────── String clientID() { uint8_t mac[6]; WiFi.macAddress(mac); char buf[32]; snprintf(buf, sizeof(buf), "rig-%02x%02x%02x", mac[3], mac[4], mac[5]); return String(buf); } String top(const char* t) { return "remoterig/cameras/" + cfg.camera_id + "/" + t; } void mqttCallback(char* topic, byte* payload, unsigned int len) { char buf[256]; unsigned int n = len < 255 ? len : 255; memcpy(buf, payload, n); buf[n] = 0; JsonDocument doc; if (deserializeJson(doc, buf)) return; String cmd = doc["command"] | ""; if (cmd == "start_recording" || cmd == "stop_recording") { sendCameraCommand(cmd); } else if (cmd == "reboot") { ESP.restart(); } else if (cmd == "registered") { String id = doc["camera_id"] | ""; if (id.length() > 0 && id != cfg.camera_id) { cfg.camera_id = id; cfg.dirty = true; mqtt.unsubscribe(top("command").c_str()); mqtt.subscribe(top("command").c_str(), 2); Serial.printf("Registered as %s\n", id.c_str()); } } } bool connectMQTT() { mqtt.setServer(cfg.mqtt_broker.c_str(), cfg.mqtt_port); mqtt.setCallback(mqttCallback); mqtt.setKeepAlive(60); if (!mqtt.connect(clientID().c_str())) { Serial.printf("MQTT fail (state=%d)\n", mqtt.state()); return false; } mqtt.subscribe(top("command").c_str(), 2); if (cfg.camera_id.length() == 0) { // Announce as new camera JsonDocument doc; doc["mac_address"] = WiFi.macAddress(); doc["firmware_version"] = #ifdef ESP32 "0.2.0-esp32" #else "0.2.0-esp8266" #endif ; doc["friendly_name"] = "Cam-" + clientID(); JsonArray caps = doc["capabilities"].to(); caps.add("start_stop"); caps.add("status"); String payload; serializeJson(doc, payload); mqtt.publish("remoterig/cameras/announce-" + clientID(), payload.c_str(), true); Serial.println("Announced for registration"); } return true; } void publishStatus(const CamStatus& s) { if (cfg.camera_id.length() == 0) return; JsonDocument doc; doc["camera_id"] = cfg.camera_id; doc["timestamp"] = millis(); doc["battery_raw"] = s.battery_raw; doc["video_remaining_sec"] = s.video_remaining_sec; doc["recording"] = s.recording; doc["online"] = cameraOnline; String payload; serializeJson(doc, payload); mqtt.publish(top("status").c_str(), payload.c_str(), true); } // ──────────────────────────────────────────── // Setup // ──────────────────────────────────────────── void setup() { Serial.begin(115200); delay(500); Serial.printf("\n\nRemoteRig v0.2.0 [%s]\n", #ifdef ESP32 "ESP32" #else "ESP8266" #endif ); bootMs = millis(); pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, #ifdef ESP8266 HIGH // ESP8266 LED is active-low #else LOW #endif ); loadConfig(); #if HAS_DUAL_STA connectBoth(); #else connectTo(cfg.wifi_ssid, cfg.wifi_password); netState = NET_ROUTER; #endif connectMQTT(); } // ──────────────────────────────────────────── // Main Loop // ──────────────────────────────────────────── void loop() { unsigned long now = millis(); static unsigned long lastPoll = 0, lastBeat = 0, lastRecon = 0; static int reconDelay = 1; #if HAS_DUAL_STA // ESP32: simple loop — everything concurrent if (WiFi.status() != WL_CONNECTED) { if (now - lastRecon > 5000) { lastRecon = now; WiFi.reconnect(); } delay(100); return; } if (!mqtt.connected()) { if (now - lastRecon > (unsigned long)(reconDelay * 1000)) { lastRecon = now; if (connectMQTT()) reconDelay = 1; else reconDelay = min(reconDelay * 2, 30); } mqtt.loop(); delay(100); return; } mqtt.loop(); // Poll camera every poll_sec if (now - lastPoll > (unsigned long)(cfg.poll_sec * 1000)) { lastPoll = now; CamStatus s = fetchCameraStatus(); cameraOnline = s.valid; publishStatus(s); } // Heartbeat every heartbeat_sec if (now - lastBeat > (unsigned long)(cfg.heartbeat_sec * 1000)) { lastBeat = now; if (cfg.camera_id.length() > 0) { JsonDocument doc; doc["camera_id"] = cfg.camera_id; doc["timestamp"] = millis(); doc["uptime_sec"] = (now - bootMs) / 1000; doc["free_heap"] = #ifdef ESP32 ESP.getFreeHeap() #else ESP.getFreeHeap() #endif ; String p; serializeJson(doc, p); mqtt.publish(top("heartbeat").c_str(), p.c_str(), false); } } #else // ESP8266: time-shared loop // Ensure we're on the right network for the current phase static bool polledThisCycle = false; if (now - lastPoll > (unsigned long)(cfg.poll_sec * 1000) && !polledThisCycle) { // Phase 1: Switch to camera AP, poll status switchToCamera(); delay(500); CamStatus s = fetchCameraStatus(); cameraOnline = s.valid; // Phase 2: Switch to router, publish MQTT switchToRouter(); delay(500); // Reconnect MQTT if needed (router may have new IP) if (!mqtt.connected()) { if (now - lastRecon > (unsigned long)(reconDelay * 1000)) { lastRecon = now; if (connectMQTT()) reconDelay = 1; else reconDelay = min(reconDelay * 2, 30); } } mqtt.loop(); publishStatus(s); lastPoll = now; polledThisCycle = true; } if (now - lastBeat > (unsigned long)(cfg.heartbeat_sec * 1000)) { // Already on router from poll — just publish heartbeat if (!mqtt.connected()) { connectMQTT(); } mqtt.loop(); if (cfg.camera_id.length() > 0) { JsonDocument doc; doc["camera_id"] = cfg.camera_id; doc["timestamp"] = millis(); doc["uptime_sec"] = (now - bootMs) / 1000; doc["free_heap"] = ESP.getFreeHeap(); String p; serializeJson(doc, p); mqtt.publish(top("heartbeat").c_str(), p.c_str(), false); } lastBeat = now; } // Reset poll cycle flag if (now - lastPoll > 2000) polledThisCycle = false; // Stay on router for MQTT command reception mqtt.loop(); #endif // Save config if (cfg.dirty) { cfg.dirty = false; saveConfig(); } // LED heartbeat static unsigned long lastLed = 0; int interval = cameraOnline ? 1000 : 200; if (now - lastLed > interval) { lastLed = now; #ifdef ESP8266 digitalWrite(LED_PIN, !digitalRead(LED_PIN)); // toggle (active-low handled) #else digitalWrite(LED_PIN, !digitalRead(LED_PIN)); #endif } delay(50); }