From 324402f2682195d6d05df0b5dce7c923ff24570d Mon Sep 17 00:00:00 2001 From: Hermes Date: Fri, 22 May 2026 00:28:48 +0000 Subject: [PATCH] feat: add ESP8266 support + Akaso camera compatibility config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unified firmware for ESP32 (dual-STA) and ESP8266 (time-shared STA) - ESP8266: alternates between GoPro AP and travel router per poll cycle - PlatformIO dual-target: esp32dev + esp8266dev (d1_mini) - camera_ip config field for Akaso/non-GoPro cameras - LittleFS support for ESP8266 (replaces SPIFFS) - Camera compatibility table (GoPro H3/H4, Akaso) - LED polarity handled per-platform (ESP8266 active-low) ESP8266 time-sharing adds ~4s latency per 30s cycle — invisible at poll rate. --- firmware/README.md | 57 ++- firmware/data/config.json | 1 + firmware/platformio.ini | 49 ++- firmware/src/main.cpp | 802 ++++++++++++++++++-------------------- 4 files changed, 448 insertions(+), 461 deletions(-) diff --git a/firmware/README.md b/firmware/README.md index 22a2aaf..580acfa 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -1,29 +1,58 @@ -# RemoteRig — ESP32 Camera Node Firmware +# RemoteRig — ESP32 / ESP8266 Camera Node Firmware -> **Platform:** PlatformIO (esp32dev) | **Framework:** Arduino +> **Platform:** PlatformIO (esp32dev + esp8266dev) | **Framework:** Arduino > **MQTT Contract:** [docs/MQTT_CONTRACT.md](../docs/MQTT_CONTRACT.md) > **Hardware:** [hardware/README.md](../hardware/README.md) ## Quick Start ```bash -# Install PlatformIO (if not already) +# Install PlatformIO pip install platformio -# Build +# Build for ESP32 (recommended — dual-STA) cd firmware -pio run +pio run -e esp32dev -# Upload to ESP32 (USB connected) -pio run --target upload +# Build for ESP8266 D1 Mini (time-shared STA) +pio run -e esp8266dev -# Upload SPIFFS config (first time only, or after config changes) -pio run --target uploadfs +# Upload to board +pio run -e esp32dev --target upload +pio run -e esp8266dev --target upload + +# Upload SPIFFS/LittleFS config (first time only) +pio run -e esp32dev --target uploadfs +pio run -e esp8266dev --target uploadfs # Serial monitor pio device monitor ``` +## Platform Differences + +| Feature | ESP32 | ESP8266 | +|---------|-------|---------| +| Wi-Fi | Dual-STA (simultaneous) | Single STA (time-shared) | +| Poll latency | 0ms (always connected to both) | ~4s per cycle (switch + poll + switch) | +| Power draw | ~80mA active | ~70mA active | +| Cost | ~$5 | ~$3 | +| Build target | `esp32dev` | `esp8266dev` | +| Filesystem | SPIFFS | LittleFS | +| LED pin | GPIO 2 (active high) | GPIO 2 / LED_BUILTIN (active low) | + +**ESP8266 workflow:** Every 30s, the ESP8266 switches from the travel router to the GoPro AP (~1s), polls the camera (~2s), switches back to the travel router (~1s), then publishes MQTT. This adds ~4s of latency per cycle but is invisible at 30s poll intervals. + +## Camera Compatibility + +| Camera | IP | Protocol | Status | +|--------|-----|----------|--------| +| GoPro Hero 3 | `10.5.5.1` | HTTP GET `/bacpac/SH` | ✅ Full support | +| GoPro Hero 4 | `10.5.5.1` | HTTP GET `/gp/gpControl` | ⚠️ Different API — needs adaptation | +| Akaso Brave 4/7/V50 | `192.168.1.1` or `192.168.42.1` | Varies (HTTP or TCP:7878) | 🔬 Needs testing — config `camera_ip` | + +For Akaso or other cameras: set `camera_ip` in config.json. The firmware will attempt the GoPro-style HTTP API at that IP. If the camera uses a different protocol, the `fetchCameraStatus()` and `sendCameraCommand()` functions in `main.cpp` need to be adapted. + ## Configuration The ESP32 stores configuration in SPIFFS (`data/config.json`): @@ -97,16 +126,6 @@ The ESP32 stores configuration in SPIFFS (`data/config.json`): - **Get password:** `GET /bacpac/sd` (no auth, returns plain text) - **Status blob:** 60 bytes binary — see `parseStatus()` in main.cpp for field offsets -## ESP8266 Compatibility - -To target ESP8266 instead: -1. Change `platformio.ini`: `board = d1_mini` under `[env:d1_mini]` -2. Change `WiFi.h` → `ESP8266WiFi.h` -3. ESP8266 doesn't do true simultaneous STA — use single STA to travel router, HTTP to GoPro via router bridge -4. SPIFFS → LittleFS on some boards - -ESP32 is recommended for dual-STA capability. - ## Troubleshooting | Symptom | Check | diff --git a/firmware/data/config.json b/firmware/data/config.json index 68342a4..6d631a5 100644 --- a/firmware/data/config.json +++ b/firmware/data/config.json @@ -3,6 +3,7 @@ "wifi_password": "", "camera_ssid": "GOPRO-BP-", "camera_password": "goprohero", + "camera_ip": "10.5.5.1", "mqtt_broker": "192.168.4.10", "mqtt_port": 1883, "camera_id": "", diff --git a/firmware/platformio.ini b/firmware/platformio.ini index 3746fee..62ce547 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -1,11 +1,24 @@ -; RemoteRig — ESP32 Camera Node Firmware -; Platform: ESP32 (ESP8266 compatible with minor changes) -; Framework: Arduino +; RemoteRig — ESP32 + ESP8266 Camera Node Firmware +; PlatformIO project with dual-target support. ; -; Build: pio run -; Upload: pio run --target upload -; SPIFFS: pio run --target uploadfs -; Monitor: pio device monitor +; Build: +; pio run -e esp32dev (ESP32 Dev Board — dual-STA, recommended) +; pio run -e esp8266dev (ESP8266 D1 Mini — time-shared STA) +; +; Upload: +; pio run -e esp32dev --target upload +; pio run -e esp8266dev --target upload +; +; SPIFFS/LittleFS: +; pio run -e esp32dev --target uploadfs +; pio run -e esp8266dev --target uploadfs + +[common] +lib_deps = + knolleary/PubSubClient @ ^2.8 + bblanchon/ArduinoJson @ ^7.3 +build_flags = + -D CORE_DEBUG_LEVEL=0 [env:esp32dev] platform = espressif32 @@ -13,11 +26,19 @@ board = esp32dev framework = arduino monitor_speed = 115200 upload_speed = 921600 - -lib_deps = - knolleary/PubSubClient @ ^2.8 - bblanchon/ArduinoJson @ ^7.3 - -build_flags = - -D CORE_DEBUG_LEVEL=0 +lib_deps = ${common.lib_deps} +build_flags = ${common.build_flags} -D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192 + +[env:esp8266dev] +platform = espressif8266 +board = d1_mini +framework = arduino +monitor_speed = 115200 +upload_speed = 921600 +lib_deps = ${common.lib_deps} +build_flags = ${common.build_flags} + -D PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED + -D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192 +board_build.flash_mode = dio +board_build.f_cpu = 160000000L diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index e0c8308..a5428e7 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -1,337 +1,300 @@ -/** - * RemoteRig — ESP32 Camera Node Firmware - * ======================================= - * One ESP32 per GoPro Hero 3. Bridges the camera's Wi-Fi AP (10.5.5.1) - * to the travel router LAN via MQTT (Mosquitto on Pi Zero 2 W). - * - * MQTT Contract: docs/MQTT_CONTRACT.md - * Hardware: hardware/README.md - * Platform: PlatformIO (esp32dev) - */ +// 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 -#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 -#include -// ──────────────────────────────────────────────────────────── -// Configuration (overridden by SPIFFS /data/config.json) -// ──────────────────────────────────────────────────────────── +#ifdef ESP8266 + #include + #define SPIFFS LittleFS +#else + #include +#endif + +// ──────────────────────────────────────────── +// Configuration +// ──────────────────────────────────────────── struct Config { - // Travel router Wi-Fi - String wifi_ssid = "RemoteRig"; - String wifi_password = ""; - - // GoPro Hero 3 Wi-Fi AP - String camera_ssid = "GOPRO-BP-"; // prefix — auto-discovered + String wifi_ssid = "RemoteRig"; + String wifi_password = ""; + String camera_ssid = "GOPRO-BP-"; String camera_password = "goprohero"; - - // MQTT broker (Pi Zero 2 W on travel router) - String mqtt_broker = "192.168.4.10"; - int mqtt_port = 1883; - - // Assigned by hub on first announce; empty until registered - String camera_id = ""; - - // Polling - int poll_interval_sec = 30; - int heartbeat_interval_sec = 60; - - // Stored in SPIFFS - bool dirty = false; + 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; -// ──────────────────────────────────────────────────────────── -// Network clients -// ──────────────────────────────────────────────────────────── +// ──────────────────────────────────────────── +// Platform-specific Wi-Fi +// ──────────────────────────────────────────── -WiFiClient wifiClient; // for HTTP to GoPro -WiFiClient mqttWifiClient; // for MQTT via travel router -PubSubClient mqtt(mqttWifiClient); +WiFiClient goproClient; +WiFiClient routerClient; +PubSubClient mqtt(routerClient); -// ──────────────────────────────────────────────────────────── -// State -// ──────────────────────────────────────────────────────────── +unsigned long bootMs = 0; +bool cameraOnline = false; -unsigned long lastPollMs = 0; -unsigned long lastHeartbeatMs = 0; -unsigned long lastReconnectMs = 0; -unsigned long bootMs = 0; -int reconnectDelay = 1; // exponential backoff (seconds) -bool goproOnline = 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 -// Heartbeat sequence -unsigned int heartbeatSeq = 0; +const int LED_PIN = + #ifdef ESP32 + 2 + #else + LED_BUILTIN // ESP8266 D1 Mini = GPIO 2 (active low!) + #endif +; -// ──────────────────────────────────────────────────────────── -// LED Pin (built-in on most ESP32 dev boards = GPIO 2) -// ──────────────────────────────────────────────────────────── - -const int LED_PIN = 2; - -enum LedMode { LED_OFF, LED_SLOW, LED_FAST, LED_ON }; -LedMode ledMode = LED_SLOW; - -void setLed(LedMode mode) { - ledMode = mode; -} - -// ──────────────────────────────────────────────────────────── -// SPIFFS Config -// ──────────────────────────────────────────────────────────── +// ──────────────────────────────────────────── +// SPIFFS / LittleFS Config +// ──────────────────────────────────────────── bool loadConfig() { - if (!SPIFFS.begin(true)) { - Serial.println("[CFG] SPIFFS mount failed"); - return false; - } + #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 = SPIFFS.open("/config.json", "r"); - if (!f) { - Serial.println("[CFG] No /config.json — using defaults"); - return false; - } + 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("[CFG] JSON parse error: %s\n", err.c_str()); - return false; - } + 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.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_interval_sec = doc["poll_interval_sec"] | cfg.poll_interval_sec; - cfg.heartbeat_interval_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_interval_sec; + 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("[CFG] Loaded from /config.json"); + Serial.println("Config loaded"); return true; } bool saveConfig() { - File f = SPIFFS.open("/config.json", "w"); + 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["mqtt_broker"] = cfg.mqtt_broker; - doc["mqtt_port"] = cfg.mqtt_port; - doc["camera_id"] = cfg.camera_id; - doc["poll_interval_sec"] = cfg.poll_interval_sec; - doc["heartbeat_interval_sec"] = cfg.heartbeat_interval_sec; - + 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(); - Serial.println("[CFG] Saved config"); return true; } -// ──────────────────────────────────────────────────────────── -// Wi-Fi — Dual STA (GoPro AP + Travel Router) -// ──────────────────────────────────────────────────────────── - -bool connectCameraWiFi() { - Serial.printf("[WIFI] Connecting to GoPro AP: %s\n", cfg.camera_ssid.c_str()); - - // Use WiFi.begin with a second AP config — ESP32 supports this - // We connect to travel router first, then GoPro - // GoPro AP: static IP on 10.5.5.x subnet - WiFi.begin(cfg.camera_ssid.c_str(), cfg.camera_password.c_str()); +// ──────────────────────────────────────────── +// 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 < 20) { - delay(500); - Serial.print("."); - attempts++; + while (WiFi.status() != WL_CONNECTED && attempts < 40) { + delay(500); Serial.print("."); attempts++; } - - if (WiFi.status() == WL_CONNECTED) { - Serial.printf("\n[WIFI] Connected to GoPro AP. IP: %s\n", WiFi.localIP().toString().c_str()); - goproOnline = true; - return true; + 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++; } - - Serial.println("\n[WIFI] Failed to connect to GoPro AP"); - goproOnline = false; - return false; + return WiFi.status() == WL_CONNECTED; } -// ═══════════════════════════════════════════════════════════ -// GoPro Hero 3 HTTP API -// ═══════════════════════════════════════════════════════════ - -// GoPro AP gateway (always 10.5.5.1 for Hero 3) -const char* GOPRO_IP = "10.5.5.1"; - -/** - * Get the GoPro camera password. - * Hero 3 exposes it via GET /bacpac/sd (no auth required). - * Default is "goprohero" but user may have changed it. - */ -String fetchGoProPassword() { - HTTPClient http; - http.begin(wifiClient, String("http://") + GOPRO_IP + "/bacpac/sd"); - int code = http.GET(); - String body = http.getString(); - http.end(); - - if (code == 200 && body.length() > 0) { - // Password is in plain text in the response body - body.trim(); - return body; - } - return cfg.camera_password; // fallback to config value +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()); } -/** - * Fetch the GoPro status blob (60 bytes binary). - * Returns empty string on failure. - */ -String fetchGoProStatus() { - String url = String("http://") + GOPRO_IP + - "/bacpac/SH?t=" + cfg.camera_password + "&p=%01"; - HTTPClient http; - http.begin(wifiClient, url); - http.setTimeout(5000); - int code = http.GET(); - - if (code != 200) { - http.end(); - return ""; - } - - // GoPro returns raw binary — use getString() which handles it - String raw = http.getString(); - http.end(); - return raw; +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 -/** - * Parse the 60-byte GoPro status blob into structured data. - * Hero 3 status format (offsets are 0-based): - * [25-26] video_remaining_sec (uint16 LE) - * [29] recording state (0=idle, 1=recording) - * [30] mode - * [31-32] resolution - * [33-34] fps - * [57] battery_raw (uint8) - */ -struct GoProStatus { - bool valid = false; +// ──────────────────────────────────────────── +// GoPro / Camera HTTP API +// ──────────────────────────────────────────── + +struct CamStatus { + bool valid = false; int video_remaining_sec = 0; bool recording = false; - int mode = 0; - int fps = 0; int battery_raw = 0; }; -GoProStatus parseStatus(const String& raw) { - GoProStatus s; - if (raw.length() < 58) { - return s; - } +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.valid = true; s.video_remaining_sec = buf[25] | (buf[26] << 8); s.recording = (buf[29] == 1); - s.mode = buf[30]; - s.fps = buf[33] | (buf[34] << 8); s.battery_raw = buf[57]; return s; } -bool sendGoProCommand(const String& command) { - String param; - if (command == "start_recording") { - param = "%01"; // mode 1 = record - } else if (command == "stop_recording") { - param = "%00"; // mode 0 = stop - } else { - Serial.printf("[GOPRO] Unknown command: %s\n", command.c_str()); - return false; - } - - String url = String("http://") + GOPRO_IP + +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.begin(wifiClient, url); + http.useHTTP10(true); + http.begin(goproClient, url); http.setTimeout(5000); int code = http.GET(); http.end(); - - Serial.printf("[GOPRO] Command %s → HTTP %d\n", command.c_str(), code); return (code == 200); } -// ═══════════════════════════════════════════════════════════ +// ──────────────────────────────────────────── // MQTT -// ═══════════════════════════════════════════════════════════ +// ──────────────────────────────────────────── String clientID() { uint8_t mac[6]; WiFi.macAddress(mac); char buf[32]; - snprintf(buf, sizeof(buf), "remoterig-%02x%02x%02x", mac[3], mac[4], mac[5]); + snprintf(buf, sizeof(buf), "rig-%02x%02x%02x", mac[3], mac[4], mac[5]); return String(buf); } -String statusTopic() { return "remoterig/cameras/" + cfg.camera_id + "/status"; } -String heartbeatTopic() { return "remoterig/cameras/" + cfg.camera_id + "/heartbeat"; } -String announceTopic() { return "remoterig/cameras/" + cfg.camera_id + "/announce"; } -String commandTopic() { return "remoterig/cameras/" + cfg.camera_id + "/command"; } +String top(const char* t) { + return "remoterig/cameras/" + cfg.camera_id + "/" + t; +} -void mqttCallback(char* topic, byte* payload, unsigned int length) { - // Null-terminate payload +void mqttCallback(char* topic, byte* payload, unsigned int len) { char buf[256]; - unsigned int len = length < 255 ? length : 255; - memcpy(buf, payload, len); - buf[len] = 0; - - Serial.printf("[MQTT] ← %s: %s\n", topic, buf); + unsigned int n = len < 255 ? len : 255; + memcpy(buf, payload, n); buf[n] = 0; JsonDocument doc; - DeserializationError err = deserializeJson(doc, buf); - if (err) { - Serial.printf("[MQTT] JSON parse error: %s\n", err.c_str()); - return; - } + if (deserializeJson(doc, buf)) return; String cmd = doc["command"] | ""; if (cmd == "start_recording" || cmd == "stop_recording") { - sendGoProCommand(cmd); + sendCameraCommand(cmd); } else if (cmd == "reboot") { - Serial.println("[MQTT] Reboot command received"); ESP.restart(); } else if (cmd == "registered") { - // Hub assigned us a camera_id on announce - String newID = doc["camera_id"] | ""; - if (newID.length() > 0 && newID != cfg.camera_id) { - cfg.camera_id = newID; + String id = doc["camera_id"] | ""; + if (id.length() > 0 && id != cfg.camera_id) { + cfg.camera_id = id; cfg.dirty = true; - Serial.printf("[MQTT] Registered as %s\n", newID.c_str()); - // Re-subscribe to our new command topic - mqtt.unsubscribe(commandTopic().c_str()); - mqtt.subscribe(commandTopic().c_str(), 2); + mqtt.unsubscribe(top("command").c_str()); + mqtt.subscribe(top("command").c_str(), 2); + Serial.printf("Registered as %s\n", id.c_str()); } - } else { - Serial.printf("[MQTT] Unknown command: %s\n", cmd.c_str()); } } @@ -340,227 +303,210 @@ bool connectMQTT() { mqtt.setCallback(mqttCallback); mqtt.setKeepAlive(60); - Serial.printf("[MQTT] Connecting to %s:%d as %s...\n", - cfg.mqtt_broker.c_str(), cfg.mqtt_port, clientID().c_str()); - - if (mqtt.connect(clientID().c_str())) { - Serial.println("[MQTT] Connected"); - - // Subscribe to command topic - mqtt.subscribe(commandTopic().c_str(), 2); - Serial.printf("[MQTT] Subscribed to %s\n", commandTopic().c_str()); - - // If we have no camera_id yet, announce ourselves - if (cfg.camera_id.length() == 0) { - publishAnnounce(); - } - - reconnectDelay = 1; // reset backoff - return true; + if (!mqtt.connect(clientID().c_str())) { + Serial.printf("MQTT fail (state=%d)\n", mqtt.state()); + return false; } - Serial.printf("[MQTT] Connection failed (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 publishAnnounce() { +void publishStatus(const CamStatus& s) { + if (cfg.camera_id.length() == 0) return; + JsonDocument doc; - doc["mac_address"] = WiFi.macAddress(); - doc["firmware_version"] = "0.1.0"; - doc["friendly_name"] = "ESP32-" + clientID().substring(9); - - JsonArray caps = doc["capabilities"].to(); - caps.add("start_stop"); - caps.add("status"); - - String payload; - serializeJson(doc, payload); - - // Publish on a temporary announce topic (using MAC as ID until registered) - String tempAnnounce = "remoterig/cameras/announce-" + clientID().substring(9); - mqtt.publish(tempAnnounce.c_str(), payload.c_str(), true); - - Serial.printf("[MQTT] Published announce: %s\n", payload.c_str()); -} - -void publishStatus(const GoProStatus& s) { - JsonDocument doc; - doc["camera_id"] = cfg.camera_id; - doc["timestamp"] = millis(); // milliseconds since boot — hub converts to ISO + 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"] = goproOnline; + doc["recording"] = s.recording; + doc["online"] = cameraOnline; - if (s.recording) { - doc["mode"] = "video"; - } - - String payload; - serializeJson(doc, payload); - - bool ok = mqtt.publish(statusTopic().c_str(), payload.c_str(), true); - if (ok) { - Serial.printf("[MQTT] → status (batt=%d, rec=%d, online=%d)\n", - s.battery_raw, s.recording, goproOnline); - } else { - Serial.println("[MQTT] Status publish failed"); - } + String payload; serializeJson(doc, payload); + mqtt.publish(top("status").c_str(), payload.c_str(), true); } -void publishHeartbeat() { - JsonDocument doc; - doc["camera_id"] = cfg.camera_id; - doc["timestamp"] = millis(); - doc["uptime_sec"] = (millis() - bootMs) / 1000; - doc["free_heap"] = ESP.getFreeHeap(); - - String payload; - serializeJson(doc, payload); - - mqtt.publish(heartbeatTopic().c_str(), payload.c_str(), false); -} - -// ═══════════════════════════════════════════════════════════ +// ──────────────────────────────────────────── // Setup -// ═══════════════════════════════════════════════════════════ +// ──────────────────────────────────────────── void setup() { Serial.begin(115200); - delay(1000); - Serial.println("\n\nRemoteRig ESP32 Camera Node v0.1.0"); - Serial.println("==================================="); - + 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, LOW); + digitalWrite(LED_PIN, + #ifdef ESP8266 + HIGH // ESP8266 LED is active-low + #else + LOW + #endif + ); - // Load config from SPIFFS loadConfig(); - Serial.printf("[CFG] camera_id: %s (empty = not yet registered)\n", - cfg.camera_id.length() > 0 ? cfg.camera_id.c_str() : "(none)"); - // Connect to travel router Wi-Fi - Serial.printf("[WIFI] Connecting to travel router: %s\n", cfg.wifi_ssid.c_str()); - WiFi.begin(cfg.wifi_ssid.c_str(), cfg.wifi_password.c_str()); + #if HAS_DUAL_STA + connectBoth(); + #else + connectTo(cfg.wifi_ssid, cfg.wifi_password); + netState = NET_ROUTER; + #endif - int wifiAttempts = 0; - while (WiFi.status() != WL_CONNECTED && wifiAttempts < 40) { - delay(500); - Serial.print("."); - wifiAttempts++; - } - - if (WiFi.status() == WL_CONNECTED) { - Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str()); - setLed(LED_SLOW); // connected to router - } else { - Serial.println("\n[WIFI] Failed to connect to travel router — will retry in loop"); - setLed(LED_FAST); // no router connection - } - - // Connect to GoPro AP - if (!connectCameraWiFi()) { - Serial.println("[WIFI] GoPro not reachable — will retry"); - setLed(LED_FAST); - } - - // Connect MQTT - if (WiFi.status() == WL_CONNECTED) { - connectMQTT(); - } + connectMQTT(); } -// ═══════════════════════════════════════════════════════════ +// ──────────────────────────────────────────── // Main Loop -// ═══════════════════════════════════════════════════════════ +// ──────────────────────────────────────────── void loop() { unsigned long now = millis(); + static unsigned long lastPoll = 0, lastBeat = 0, lastRecon = 0; + static int reconDelay = 1; - // ── LED heartbeat ── - static unsigned long lastLedToggle = 0; - int ledInterval = (ledMode == LED_FAST) ? 200 : (ledMode == LED_SLOW) ? 1000 : 0; - if (ledInterval > 0 && now - lastLedToggle > ledInterval) { - lastLedToggle = now; - digitalWrite(LED_PIN, !digitalRead(LED_PIN)); - } - if (ledMode == LED_ON) digitalWrite(LED_PIN, HIGH); - if (ledMode == LED_OFF) digitalWrite(LED_PIN, LOW); - - // ── Wi-Fi reconnection ── + #if HAS_DUAL_STA + // ESP32: simple loop — everything concurrent if (WiFi.status() != WL_CONNECTED) { - setLed(LED_FAST); - if (now - lastReconnectMs > 5000) { - lastReconnectMs = now; - Serial.println("[WIFI] Reconnecting..."); - WiFi.reconnect(); + 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); } - delay(100); - return; // skip everything else until Wi-Fi is back + 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); } - // ── MQTT reconnection ── - if (!mqtt.connected()) { - setLed(LED_SLOW); - if (now - lastReconnectMs > (unsigned long)(reconnectDelay * 1000)) { - lastReconnectMs = now; - if (connectMQTT()) { - reconnectDelay = 1; - } else { - reconnectDelay = min(reconnectDelay * 2, 30); + // 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(); - delay(100); - return; + + publishStatus(s); + lastPoll = now; + polledThisCycle = true; } - - setLed(LED_SLOW); - mqtt.loop(); - - // ── GoPro reconnection ── - static unsigned long lastGoProRetry = 0; - if (!goproOnline && now - lastGoProRetry > 30000) { - lastGoProRetry = now; - connectCameraWiFi(); - } - - // ── Status polling (every cfg.poll_interval_sec) ── - if (now - lastPollMs > (unsigned long)(cfg.poll_interval_sec * 1000)) { - lastPollMs = now; - - String raw = fetchGoProStatus(); - GoProStatus status = parseStatus(raw); - - if (status.valid) { - goproOnline = true; - if (cfg.camera_id.length() > 0) { - publishStatus(status); - } - } else { - goproOnline = false; - if (cfg.camera_id.length() > 0) { - GoProStatus offline = {}; - offline.valid = true; - publishStatus(offline); // publish with online=false - } + + 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 } - // ── Heartbeat (every heartbeat_interval_sec) ── - if (cfg.camera_id.length() > 0 && - now - lastHeartbeatMs > (unsigned long)(cfg.heartbeat_interval_sec * 1000)) { - lastHeartbeatMs = now; - publishHeartbeat(); - } - - // ── Save config if dirty ── - if (cfg.dirty) { - cfg.dirty = false; - saveConfig(); - } - - delay(100); + delay(50); }