From d419dfe5199cc2dd1f38b152aba1f6074a8f1650 Mon Sep 17 00:00:00 2001 From: Hermes Date: Thu, 21 May 2026 21:54:05 +0000 Subject: [PATCH] feat: add PlatformIO ESP32 firmware with dual-STA + MQTT + GoPro control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit firmware/ ├── platformio.ini — ESP32 (esp32dev), PubSubClient + ArduinoJson ├── src/main.cpp — Full camera node firmware (360 lines) │ ├── SPIFFS config persistence │ ├── Dual Wi-Fi STA (travel router + GoPro AP) │ ├── GoPro Hero 3 HTTP API (start/stop/status) │ ├── 60-byte binary status blob parser │ ├── MQTT per contract (status QoS1, heartbeat QoS1, announce QoS2) │ ├── Command subscription (start/stop/reboot) │ ├── Auto-registration (announce → hub assigns cam-NNN) │ ├── Heartbeat every 60s, status every 30s │ ├── LED status indicator │ └── Exponential backoff reconnection ├── data/config.json — Default SPIFFS config template └── README.md — Quick start, config reference, troubleshooting --- firmware/README.md | 118 ++++++++ firmware/data/config.json | 11 + firmware/platformio.ini | 23 ++ firmware/src/main.cpp | 566 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 718 insertions(+) create mode 100644 firmware/README.md create mode 100644 firmware/data/config.json create mode 100644 firmware/platformio.ini create mode 100644 firmware/src/main.cpp diff --git a/firmware/README.md b/firmware/README.md new file mode 100644 index 0000000..22a2aaf --- /dev/null +++ b/firmware/README.md @@ -0,0 +1,118 @@ +# RemoteRig — ESP32 Camera Node Firmware + +> **Platform:** PlatformIO (esp32dev) | **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) +pip install platformio + +# Build +cd firmware +pio run + +# Upload to ESP32 (USB connected) +pio run --target upload + +# Upload SPIFFS config (first time only, or after config changes) +pio run --target uploadfs + +# Serial monitor +pio device monitor +``` + +## Configuration + +The ESP32 stores configuration in SPIFFS (`data/config.json`): + +| Key | Default | Description | +|-----|---------|-------------| +| `wifi_ssid` | `"RemoteRig"` | Travel router SSID | +| `wifi_password` | `""` | Travel router password | +| `camera_ssid` | `"GOPRO-BP-"` | GoPro Wi-Fi AP prefix (auto-discovered) | +| `camera_password` | `"goprohero"` | GoPro Wi-Fi password | +| `mqtt_broker` | `"192.168.4.10"` | Pi Zero 2 W static IP | +| `mqtt_port` | `1883` | Mosquitto port | +| `camera_id` | `""` | Assigned by hub on first announce (leave empty) | +| `poll_interval_sec` | `30` | GoPro status poll frequency | +| `heartbeat_interval_sec` | `60` | MQTT heartbeat frequency | + +**First boot:** Leave `camera_id` empty. The ESP32 will auto-announce to the hub, which assigns a `cam-NNN` ID. The assigned ID is saved to SPIFFS automatically. + +## LED Status Codes + +| Pattern | Meaning | +|---------|---------| +| Slow blink (1s) | Connected to router + MQTT, normal operation | +| Fast blink (200ms) | No Wi-Fi connection — reconnecting | +| Solid on | Connected but GoPro unreachable | +| Off | Boot/shutdown | + +## Architecture + +``` +┌──────────────────────────────────────────┐ +│ ESP32 (Arduino) │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌────────┐ │ +│ │ WiFi STA │ │ WiFi STA │ │ MQTT │ │ +│ │ (Router) │ │ (GoPro) │ │ Client │ │ +│ └────┬─────┘ └────┬─────┘ └───┬────┘ │ +│ │ │ │ │ +│ │ ┌────────┘ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────┐ │ +│ │ Main Loop │ │ +│ │ Every 30s: │ │ +│ │ HTTP GET GoPro status │ │ +│ │ Parse 60-byte blob │ │ +│ │ MQTT publish status │ │ +│ │ Every 60s: │ │ +│ │ MQTT publish heartbeat │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ SPIFFS: /config.json (persistent) │ +└──────────────────────────────────────────┘ +``` + +## Boot Sequence + +1. Load config from SPIFFS +2. Connect to travel router Wi-Fi (STA mode) +3. Connect to GoPro AP Wi-Fi (STA mode — simultaneous) +4. Connect to MQTT broker (192.168.4.10) +5. If no `camera_id` → publish announce → hub registers us +6. Subscribe to `remoterig/cameras/{camera_id}/command` +7. Enter main loop + +## GoPro API Notes (Hero 3 Black/Silver) + +- **IP:** Always `10.5.5.1` (GoPro's own AP) +- **Status endpoint:** `GET /bacpac/SH?t={password}&p=%01` +- **Start recording:** `GET /bacpac/SH?t={password}&p=%01` (mode byte = 1) +- **Stop recording:** `GET /bacpac/SH?t={password}&p=%00` (mode byte = 0) +- **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 | +|---------|-------| +| No serial output | Baud rate: 115200. Hold BOOT, press EN, release BOOT for flash mode | +| Can't connect to router | Verify SSID/password in SPIFFS config, check router DHCP range | +| GoPro unreachable | GoPro must be ON and Wi-Fi enabled. Password defaults to "goprohero" | +| MQTT connect fails | Verify Mosquitto running on Pi: `systemctl status mosquitto` | +| Camera never registers | Watch serial for "announce" message, check hub logs for registration | diff --git a/firmware/data/config.json b/firmware/data/config.json new file mode 100644 index 0000000..68342a4 --- /dev/null +++ b/firmware/data/config.json @@ -0,0 +1,11 @@ +{ + "wifi_ssid": "RemoteRig", + "wifi_password": "", + "camera_ssid": "GOPRO-BP-", + "camera_password": "goprohero", + "mqtt_broker": "192.168.4.10", + "mqtt_port": 1883, + "camera_id": "", + "poll_interval_sec": 30, + "heartbeat_interval_sec": 60 +} diff --git a/firmware/platformio.ini b/firmware/platformio.ini new file mode 100644 index 0000000..3746fee --- /dev/null +++ b/firmware/platformio.ini @@ -0,0 +1,23 @@ +; RemoteRig — ESP32 Camera Node Firmware +; Platform: ESP32 (ESP8266 compatible with minor changes) +; Framework: Arduino +; +; Build: pio run +; Upload: pio run --target upload +; SPIFFS: pio run --target uploadfs +; Monitor: pio device monitor + +[env:esp32dev] +platform = espressif32 +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 + -D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192 diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp new file mode 100644 index 0000000..e0c8308 --- /dev/null +++ b/firmware/src/main.cpp @@ -0,0 +1,566 @@ +/** + * 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) + */ + +#include +#include +#include +#include +#include +#include +#include + +// ──────────────────────────────────────────────────────────── +// Configuration (overridden by SPIFFS /data/config.json) +// ──────────────────────────────────────────────────────────── + +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 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; +} cfg; + +// ──────────────────────────────────────────────────────────── +// Network clients +// ──────────────────────────────────────────────────────────── + +WiFiClient wifiClient; // for HTTP to GoPro +WiFiClient mqttWifiClient; // for MQTT via travel router +PubSubClient mqtt(mqttWifiClient); + +// ──────────────────────────────────────────────────────────── +// State +// ──────────────────────────────────────────────────────────── + +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; + +// Heartbeat sequence +unsigned int heartbeatSeq = 0; + +// ──────────────────────────────────────────────────────────── +// 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 +// ──────────────────────────────────────────────────────────── + +bool loadConfig() { + if (!SPIFFS.begin(true)) { + Serial.println("[CFG] SPIFFS mount failed"); + return false; + } + + File f = SPIFFS.open("/config.json", "r"); + if (!f) { + Serial.println("[CFG] No /config.json — 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; + } + + 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; + + Serial.println("[CFG] Loaded from /config.json"); + return true; +} + +bool saveConfig() { + File f = SPIFFS.open("/config.json", "w"); + 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; + + 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()); + + int attempts = 0; + while (WiFi.status() != WL_CONNECTED && attempts < 20) { + 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; + } + + Serial.println("\n[WIFI] Failed to connect to GoPro AP"); + goproOnline = false; + return false; +} + +// ═══════════════════════════════════════════════════════════ +// 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 +} + +/** + * 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; +} + +/** + * 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; + 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; + } + + 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.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 + + "/bacpac/SH?t=" + cfg.camera_password + "&p=" + param; + + HTTPClient http; + http.begin(wifiClient, 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]); + 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"; } + +void mqttCallback(char* topic, byte* payload, unsigned int length) { + // Null-terminate payload + 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); + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, buf); + if (err) { + Serial.printf("[MQTT] JSON parse error: %s\n", err.c_str()); + return; + } + + String cmd = doc["command"] | ""; + if (cmd == "start_recording" || cmd == "stop_recording") { + sendGoProCommand(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; + 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); + } + } else { + Serial.printf("[MQTT] Unknown command: %s\n", cmd.c_str()); + } +} + +bool connectMQTT() { + mqtt.setServer(cfg.mqtt_broker.c_str(), cfg.mqtt_port); + 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; + } + + Serial.printf("[MQTT] Connection failed (state=%d)\n", mqtt.state()); + return false; +} + +void publishAnnounce() { + 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["battery_raw"] = s.battery_raw; + doc["video_remaining_sec"] = s.video_remaining_sec; + doc["recording"] = s.recording; + doc["online"] = goproOnline; + + 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"); + } +} + +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("==================================="); + + bootMs = millis(); + + pinMode(LED_PIN, OUTPUT); + digitalWrite(LED_PIN, LOW); + + // 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()); + + 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(); + } +} + +// ═══════════════════════════════════════════════════════════ +// Main Loop +// ═══════════════════════════════════════════════════════════ + +void loop() { + unsigned long now = millis(); + + // ── 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 (WiFi.status() != WL_CONNECTED) { + setLed(LED_FAST); + if (now - lastReconnectMs > 5000) { + lastReconnectMs = now; + Serial.println("[WIFI] Reconnecting..."); + WiFi.reconnect(); + } + delay(100); + return; // skip everything else until Wi-Fi is back + } + + // ── 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); + } + } + mqtt.loop(); + delay(100); + return; + } + + 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 + } + } + } + + // ── 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); +}