diff --git a/firmware/README.md b/firmware/README.md index 580acfa..86d5a81 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -1,137 +1,134 @@ -# RemoteRig — ESP32 / ESP8266 Camera Node Firmware +# RemoteRig — Dual-Board Camera Node Firmware -> **Platform:** PlatformIO (esp32dev + esp8266dev) | **Framework:** Arduino +> **Platform:** PlatformIO (esp8266-camera + esp32-mqtt) > **MQTT Contract:** [docs/MQTT_CONTRACT.md](../docs/MQTT_CONTRACT.md) > **Hardware:** [hardware/README.md](../hardware/README.md) +## Architecture + +Each camera node uses **two boards** connected via UART — zero network switching: + +``` +┌─────────────────────┐ UART ┌─────────────────────┐ +│ ESP8266 D1 Mini │ TX──────→RX │ ESP32 Dev Board │ +│ (Camera Bridge) │ RX←──────TX │ (MQTT Bridge) │ +│ │ 115200 │ │ +│ STA → GoPro AP │ 8N1 │ STA → Travel Router │ +│ HTTP → 10.5.5.1 │ │ MQTT → 192.168.4.10│ +│ Start/stop/status │ │ Hub registration │ +└─────────────────────┘ └──────────────────────┘ +``` + +| Board | Job | Network | Protocol | +|-------|-----|---------|----------| +| ESP8266 | Camera control | GoPro AP only (10.5.5.1) | HTTP → UART JSON | +| ESP32 | Hub relay | Travel router only (192.168.4.x) | UART JSON → MQTT | + ## Quick Start ```bash -# Install PlatformIO pip install platformio - -# Build for ESP32 (recommended — dual-STA) cd firmware -pio run -e esp32dev -# Build for ESP8266 D1 Mini (time-shared STA) -pio run -e esp8266dev +# Build both +pio run -e esp8266-camera +pio run -e esp32-mqtt -# Upload to board -pio run -e esp32dev --target upload -pio run -e esp8266dev --target upload +# Upload to boards (connect one at a time via USB) +pio run -e esp8266-camera --target upload +pio run -e esp32-mqtt --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 +# Upload configs (each board needs its own) +# ESP8266: copy esp8266-config.json to data/config.json, then: +pio run -e esp8266-camera --target uploadfs +# ESP32: copy esp32-config.json to data/config.json, then: +pio run -e esp32-mqtt --target uploadfs ``` -## Platform Differences +## UART Protocol (ESP8266 ↔ ESP32) -| 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) | +JSON-per-line at 115200 8N1. GPIO16 on both boards. -**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. +| Direction | Type | Format | Purpose | +|-----------|------|--------|---------| +| ESP8266 → ESP32 | `status` | `{"type":"status","battery_raw":217,...}` | Camera poll result | +| ESP8266 → ESP32 | `ack` | `{"type":"ack","cmd":"start_recording"}` | Command confirmation | +| ESP8266 → ESP32 | `pong` | `{"type":"pong","uptime_ms":12345}` | Ping response | +| ESP8266 → ESP32 | `error` | `{"type":"error","msg":"camera unreachable"}` | Error report | +| ESP32 → ESP8266 | `cmd` | `{"type":"cmd","command":"start_recording"}` | Hub command | +| ESP32 → ESP8266 | `cmd` | `{"type":"cmd","command":"ping"}` | Link health check | ## Configuration -The ESP32 stores configuration in SPIFFS (`data/config.json`): +### ESP8266 (`data/esp8266-config.json`) + +| Key | Default | Description | +|-----|---------|-------------| +| `camera_ssid` | `"GOPRO-BP-"` | GoPro Wi-Fi AP name | +| `camera_password` | `"goprohero"` | GoPro Wi-Fi password | +| `camera_ip` | `"10.5.5.1"` | Camera IP (change for Akaso to 192.168.1.1) | +| `poll_interval_sec` | `30` | How often to poll camera | + +### ESP32 (`data/esp32-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_broker` | `"192.168.4.10"` | Pi Zero 2 W 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 +## Wiring ``` -┌──────────────────────────────────────────┐ -│ 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) │ -└──────────────────────────────────────────┘ +ESP8266 D1 Mini ESP32 Dev Board +┌────────────┐ ┌────────────┐ +│ │ │ │ +│ TX (GPIO1)│──────────→│ RX (GPIO16)│ +│ RX (GPIO3)│←──────────│ TX (GPIO17)│ +│ GND │───────────│ GND │ +│ 3.3V │ │ 3.3V │ +│ │ │ │ +└────────────┘ └────────────┘ + │ │ + └────────┬─────────────┘ + │ + LiPo → 3.3V Buck + (shared power) ``` ## 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 +1. **ESP8266:** Connect to GoPro AP → wait for UART commands +2. **ESP32:** Connect to travel router → connect MQTT → announce if new +3. **ESP8266:** Poll camera every 30s → send status over UART +4. **ESP32:** Receive status → publish MQTT +5. **Hub → MQTT command → ESP32 → UART → ESP8266 → HTTP → GoPro** -## GoPro API Notes (Hero 3 Black/Silver) +## Camera Compatibility -- **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 +| Camera | `camera_ip` | Protocol | Status | +|--------|------------|----------|--------| +| GoPro Hero 3 | `10.5.5.1` | HTTP GET `/bacpac/SH` | ✅ Full support | +| Akaso Brave 7 | `192.168.1.1` | Varies | 🔬 Set `camera_ip`, test | + +For non-GoPro cameras: only the ESP8266 firmware needs changes — the ESP32 stays the same. + +## LED Status (ESP8266) + +| LED | Meaning | +|-----|---------| +| Solid on | Connected to camera AP, camera responding | +| Slow blink (500ms) | Connected to AP but camera not responding | +| Off | Wi-Fi disconnected | ## 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 | +| No UART communication | Verify TX→RX crossover. Both boards at 115200. Shared GND. | +| ESP8266 can't connect | GoPro must be ON with Wi-Fi enabled. Default password: `goprohero` | +| ESP32 can't connect MQTT | `systemctl status mosquitto` on Pi. Port 1883 open. | +| Camera never registers | Watch ESP32 serial for "Announced" message. Check hub logs. | diff --git a/firmware/data/config.json b/firmware/data/esp32-config.json similarity index 57% rename from firmware/data/config.json rename to firmware/data/esp32-config.json index 6d631a5..93fed37 100644 --- a/firmware/data/config.json +++ b/firmware/data/esp32-config.json @@ -1,12 +1,8 @@ { "wifi_ssid": "RemoteRig", "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": "", - "poll_interval_sec": 30, "heartbeat_interval_sec": 60 } diff --git a/firmware/data/esp8266-config.json b/firmware/data/esp8266-config.json new file mode 100644 index 0000000..e46d3b2 --- /dev/null +++ b/firmware/data/esp8266-config.json @@ -0,0 +1,6 @@ +{ + "camera_ssid": "GOPRO-BP-", + "camera_password": "goprohero", + "camera_ip": "10.5.5.1", + "poll_interval_sec": 30 +} diff --git a/firmware/platformio.ini b/firmware/platformio.ini index 62ce547..1a54271 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -1,17 +1,24 @@ -; RemoteRig — ESP32 + ESP8266 Camera Node Firmware -; PlatformIO project with dual-target support. +; RemoteRig — Dual-Board Camera Node Firmware +; ============================================ +; Each camera node has TWO boards connected via UART: +; +; ESP8266 (Camera Bridge): Connects to GoPro AP → HTTP status/control +; ESP32 (MQTT Bridge): Connects to travel router → MQTT to hub +; +; ESP8266 ←──UART──→ ESP32 +; (TX/RX) (RX16/TX17) ; ; Build: -; pio run -e esp32dev (ESP32 Dev Board — dual-STA, recommended) -; pio run -e esp8266dev (ESP8266 D1 Mini — time-shared STA) +; pio run -e esp8266-camera (ESP8266 D1 Mini — camera bridge) +; pio run -e esp32-mqtt (ESP32 Dev Board — MQTT bridge) ; ; Upload: -; pio run -e esp32dev --target upload -; pio run -e esp8266dev --target upload +; pio run -e esp8266-camera --target upload +; pio run -e esp32-mqtt --target upload ; -; SPIFFS/LittleFS: -; pio run -e esp32dev --target uploadfs -; pio run -e esp8266dev --target uploadfs +; Filesystem: +; pio run -e esp8266-camera --target uploadfs +; pio run -e esp32-mqtt --target uploadfs [common] lib_deps = @@ -20,17 +27,11 @@ lib_deps = build_flags = -D CORE_DEBUG_LEVEL=0 -[env:esp32dev] -platform = espressif32 -board = esp32dev -framework = arduino -monitor_speed = 115200 -upload_speed = 921600 -lib_deps = ${common.lib_deps} -build_flags = ${common.build_flags} - -D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192 +; ── ESP8266: Camera Bridge ────────────────────────────────── +; Flashed onto D1 Mini. Talks to GoPro over Wi-Fi, relays to +; ESP32 over UART (TX/RX pins). No MQTT, no router connection. -[env:esp8266dev] +[env:esp8266-camera] platform = espressif8266 board = d1_mini framework = arduino @@ -39,6 +40,28 @@ 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 +build_src_filter = + +<../lib/> + + + -<*.cpp> + +; ── ESP32: MQTT Bridge ───────────────────────────────────── +; Flashed onto ESP32 Dev Board. Connects to travel router, +; publishes MQTT to Pi hub. Reads camera status from ESP8266 +; over UART2 (RX16/TX17). No direct camera communication. + +[env:esp32-mqtt] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +upload_speed = 921600 +lib_deps = ${common.lib_deps} +build_flags = ${common.build_flags} + -D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192 +build_src_filter = + +<../lib/> + + + -<*.cpp> diff --git a/firmware/src/esp32-mqtt-bridge.cpp b/firmware/src/esp32-mqtt-bridge.cpp new file mode 100644 index 0000000..72ec4c2 --- /dev/null +++ b/firmware/src/esp32-mqtt-bridge.cpp @@ -0,0 +1,332 @@ +/** + * RemoteRig — ESP32 MQTT Bridge Firmware + * ====================================== + * Dedicated board per camera node. Connects the ESP8266 camera bridge + * to the RemoteRig MQTT hub. + * + * ONE JOB: relay between UART (ESP8266) and MQTT (Pi hub). + * - Connects to travel router Wi-Fi + * - Reads status JSON from ESP8266 over UART → publishes via MQTT + * - Receives commands via MQTT from hub → forwards to ESP8266 over UART + * - Handles auto-registration (announce on first boot) + * - Heartbeat publishing + * - Zero camera communication, zero network switching + * + * UART Protocol: JSON-per-line at 115200 8N1 + * ESP8266 → ESP32: {"type":"status","battery_raw":217,...}\n + * ESP8266 → ESP32: {"type":"ack","cmd":"start_recording"}\n + * ESP32 → ESP8266: {"type":"cmd","command":"start_recording"}\n + * ESP32 → ESP8266: {"type":"cmd","command":"ping"}\n + * + * Hardware: + * - ESP32 Dev Board (or D1 Mini ESP32) + * - UART2: RX=GPIO16, TX=GPIO17 (connected to ESP8266) + * - Shared GND between boards + * - LiPo → 3.3V buck → VIN on both boards + */ + +#include +#include +#include +#include +#include +#include + +// ──────────────────────────────────────────── +// Configuration (SPIFFS) +// ──────────────────────────────────────────── + +struct Config { + String wifi_ssid = "RemoteRig"; + String wifi_password = ""; + String mqtt_broker = "192.168.4.10"; + int mqtt_port = 1883; + String camera_id = ""; // assigned by hub + int heartbeat_sec = 60; +} cfg; + +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 — 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.wifi_ssid = doc["wifi_ssid"] | cfg.wifi_ssid; + cfg.wifi_password = doc["wifi_password"] | cfg.wifi_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.heartbeat_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_sec; + 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["mqtt_broker"] = cfg.mqtt_broker; + doc["mqtt_port"] = cfg.mqtt_port; + doc["camera_id"] = cfg.camera_id; + doc["heartbeat_interval_sec"] = cfg.heartbeat_sec; + serializeJson(doc, f); + f.close(); + return true; +} + +// ──────────────────────────────────────────── +// UART to ESP8266 (HardwareSerial2) +// ──────────────────────────────────────────── +// ESP32 UART2: RX=GPIO16, TX=GPIO17 +// Connect: ESP32 RX(16) ← ESP8266 TX +// ESP32 TX(17) → ESP8266 RX + +#define UART_ESP8266 Serial2 + +void sendCmdToESP8266(const String& command) { + JsonDocument doc; + doc["type"] = "cmd"; + doc["command"] = command; + String line; + serializeJson(doc, line); + UART_ESP8266.println(line); + UART_ESP8266.flush(); +} + +String uartLine; +bool readFromESP8266(String& line) { + while (UART_ESP8266.available()) { + char c = UART_ESP8266.read(); + if (c == '\n') { + line = uartLine; + uartLine = ""; + return true; + } + if (c != '\r') uartLine += c; + } + return false; +} + +// ──────────────────────────────────────────── +// MQTT +// ──────────────────────────────────────────── + +WiFiClient routerClient; +PubSubClient mqtt(routerClient); + +unsigned long bootMs = 0; +bool cameraOnline = false; +unsigned long lastStatusMs = 0; + +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 mqttTopic(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") { + Serial.printf("[MQTT] Forwarding command: %s → ESP8266\n", cmd.c_str()); + sendCmdToESP8266(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; + saveConfig(); + mqtt.unsubscribe(mqttTopic("command").c_str()); + mqtt.subscribe(mqttTopic("command").c_str(), 2); + Serial.printf("[MQTT] 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] Connect fail (state=%d)\n", mqtt.state()); + return false; + } + + Serial.println("[MQTT] Connected"); + + // Subscribe to commands (if registered) + if (cfg.camera_id.length() > 0) { + mqtt.subscribe(mqttTopic("command").c_str(), 2); + } + + // Announce if new + if (cfg.camera_id.length() == 0) { + JsonDocument doc; + doc["mac_address"] = WiFi.macAddress(); + doc["firmware_version"] = "0.3.0-esp32-mqtt-bridge"; + 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("[MQTT] Announced for registration"); + } + + return true; +} + +// ──────────────────────────────────────────── +// Setup +// ──────────────────────────────────────────── + +void setup() { + Serial.begin(115200); + delay(500); + Serial.println("\n[BRIDGE] ESP32 MQTT Bridge v1.0"); + + bootMs = millis(); + pinMode(2, OUTPUT); // built-in LED + digitalWrite(2, LOW); + + loadConfig(); + + // UART to ESP8266 + UART_ESP8266.begin(115200, SERIAL_8N1, 16, 17); // RX=16, TX=17 + Serial.println("[UART] ESP8266 link on RX16/TX17 @ 115200"); + + // Connect to travel router — the ONLY network we touch + Serial.printf("[WIFI] Connecting to: %s\n", cfg.wifi_ssid.c_str()); + 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) { + Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str()); + } else { + Serial.println("\n[WIFI] FAILED — will retry"); + } + + // MQTT + if (WiFi.status() == WL_CONNECTED) { + connectMQTT(); + } +} + +// ──────────────────────────────────────────── +// Main Loop +// ──────────────────────────────────────────── + +void loop() { + unsigned long now = millis(); + static unsigned long lastBeat = 0, lastRecon = 0; + static int reconDelay = 1; + + // ── Wi-Fi watchdog ── + if (WiFi.status() != WL_CONNECTED) { + if (now - lastRecon > 5000) { lastRecon = now; WiFi.reconnect(); } + delay(100); return; + } + + // ── MQTT watchdog ── + 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(); + + // ── Read status from ESP8266 over UART → publish via MQTT ── + String line; + while (readFromESP8266(line)) { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, line); + if (err) { Serial.printf("[UART] Bad JSON: %s\n", line.c_str()); continue; } + + String type = doc["type"] | ""; + + if (type == "status") { + // Relay camera status to MQTT hub + lastStatusMs = now; + bool online = doc["online"] | false; + + if (online != cameraOnline) { + cameraOnline = online; + digitalWrite(2, online ? HIGH : LOW); + } + + if (cfg.camera_id.length() > 0) { + // Build the MQTT status payload per contract + JsonDocument mqttDoc; + mqttDoc["camera_id"] = cfg.camera_id; + mqttDoc["timestamp"] = millis(); + mqttDoc["battery_raw"] = doc["battery_raw"] | 0; + mqttDoc["video_remaining_sec"] = doc["video_remaining_sec"] | 0; + mqttDoc["recording"] = doc["recording"] | false; + mqttDoc["online"] = online; + + String payload; + serializeJson(mqttDoc, payload); + mqtt.publish(mqttTopic("status").c_str(), payload.c_str(), true); + } + } + else if (type == "ack") { + Serial.printf("[UART] ESP8266 ack: %s\n", (doc["cmd"] | "?").c_str()); + } + else if (type == "pong") { + Serial.printf("[UART] ESP8266 pong (uptime=%d)\n", doc["uptime_ms"] | 0); + } + else if (type == "error") { + Serial.printf("[UART] ESP8266 error: %s\n", (doc["msg"] | "?").c_str()); + } + } + + // ── Heartbeat to hub (every heartbeat_sec) ── + if (cfg.camera_id.length() > 0 && + now - lastBeat > (unsigned long)(cfg.heartbeat_sec * 1000)) { + lastBeat = now; + JsonDocument doc; + doc["camera_id"] = cfg.camera_id; + doc["timestamp"] = millis(); + doc["uptime_sec"] = (now - bootMs) / 1000; + doc["free_heap"] = ESP.getFreeHeap(); + doc["status_age_ms"] = now - lastStatusMs; + String payload; serializeJson(doc, payload); + mqtt.publish(mqttTopic("heartbeat").c_str(), payload.c_str(), false); + } + + // ── Periodic ping to ESP8266 to verify UART link ── + static unsigned long lastPing = 0; + if (now - lastPing > 30000) { + lastPing = now; + sendCmdToESP8266("ping"); + } + + delay(50); +} diff --git a/firmware/src/esp8266-camera-bridge.cpp b/firmware/src/esp8266-camera-bridge.cpp new file mode 100644 index 0000000..c816992 --- /dev/null +++ b/firmware/src/esp8266-camera-bridge.cpp @@ -0,0 +1,303 @@ +/** + * 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: + * - ESP8266 D1 Mini (or NodeMCU) + * - UART TX → ESP32 RX (GPIO 16) + * - UART RX → ESP32 TX (GPIO 16) + * - Shared GND between boards + * - LiPo → 3.3V buck → VIN on both boards + */ + +#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; +} + +// ──────────────────────────────────────────── +// 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 { + 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; +} + +// ──────────────────────────────────────────── +// LED +// ──────────────────────────────────────────── + +const int LED = LED_BUILTIN; // active-low on ESP8266 D1 Mini + +void ledOn() { digitalWrite(LED, LOW); } +void ledOff() { digitalWrite(LED, HIGH); } + +// ──────────────────────────────────────────── +// Setup +// ──────────────────────────────────────────── + +void setup() { + Serial.begin(115200); + delay(500); + Serial.println("\n[BRIDGE] ESP8266 Camera Bridge v1.0"); + + pinMode(LED, OUTPUT); + ledOff(); + + 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()); + ledOn(); // Solid = connected + } 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; + static bool cameraOnline = false; + + // ── 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(); + + if (s.valid && !cameraOnline) { + cameraOnline = true; + ledOn(); + } else if (!s.valid && cameraOnline) { + cameraOnline = false; + ledOff(); + } + + 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 + } + } + + // ── LED blink when offline ── + if (!cameraOnline) { + static unsigned long lastBlink = 0; + if (now - lastBlink > 500) { + lastBlink = now; + digitalWrite(LED, !digitalRead(LED)); + } + } +} diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp deleted file mode 100644 index a5428e7..0000000 --- a/firmware/src/main.cpp +++ /dev/null @@ -1,512 +0,0 @@ -// 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); -} diff --git a/hardware/README.md b/hardware/README.md index 1286c48..6dded05 100644 --- a/hardware/README.md +++ b/hardware/README.md @@ -23,11 +23,13 @@ Each camera node is a self-contained unit clipped onto a GoPro Hero 3. It provid │ │ Screen │ │ │ └─────────────────────────┘ │ │ ┌──────────┐ │ -│ 3D Sleeve ─────→│ ESP32 │ │ ← clips onto back/bottom +│ 3D Sleeve ─────→│ ESP8266 │ │ ← Camera bridge (GoPro Wi-Fi) │ │ D1 Mini │ │ -│ └──────────┘ │ -│ ┌──────────┐ │ -│ │ LiPo │ │ ← slides under GoPro +│ ├──────────┤ │ +│ │ ESP32 │ │ ← MQTT bridge (travel router) +│ │ Dev │ │ +│ ├──────────┤ │ +│ │ LiPo │ │ ← Shared power │ │ 1000mAh │ │ │ └──────────┘ │ └─────────────────────────────────┘ @@ -38,16 +40,18 @@ Each camera node is a self-contained unit clipped onto a GoPro Hero 3. It provid | Item | Qty | Cost | Notes | |------|-----|------|-------| | GoPro Hero 3 Black/Silver | 1 | Already owned | Target camera | -| ESP32 D1 Mini | 1 | ~$4 | Or NodeMCU-32S (~$5) | +| ESP32 Dev Board | 1 | ~$5 | MQTT bridge — talks to hub | +| ESP8266 D1 Mini | 1 | ~$3 | Camera bridge — talks to GoPro | | LiPo 3.7V 1000mAh | 1 | ~$8 | 50x34x8mm typical | -| 5V/3A buck converter | 1 | ~$2 | LiPo → GoPro USB | -| 3.3V buck converter | 1 | ~$1 | LiPo → ESP32 VIN | +| 3.3V buck converter | 1 | ~$1 | LiPo → both boards (shared VIN) | +| 5V/3A buck converter | 1 | ~$2 | LiPo → GoPro USB (power only) | | JST-XH 2-pin connectors | 2 | ~$1 | Battery quick-disconnect | | Micro-USB right-angle cable | 1 | ~$2 | Buck → GoPro | +| Jumper wires (female-female) | 4 | ~$0.50 | UART + GND between boards | | Velcro strap (20cm) | 1 | ~$0.50 | Secure to GoPro | -| PETG filament | ~30g | ~$0.60 | 3D printed case | +| PETG filament | ~35g | ~$0.70 | 3D printed case | -**Total per node:** ~$20 +**Total per node:** ~$24 ## 3D Printed Case @@ -88,17 +92,17 @@ Slides under the GoPro. Contains: LiPo 3.7V ├── JST-XH connector │ - ├──→ 5V/3A Buck Converter → Micro-USB right-angle → GoPro USB port - │ (power only — no data over USB) + ├──→ 3.3V Buck Converter → ESP8266 VIN + GND + │ → ESP32 VIN + GND + │ (both boards share the same 3.3V rail) │ - └──→ 3.3V Buck Converter → ESP32 VIN + GND - (or ESP32 D1 Mini has built-in regulator — connect directly to 5V pin) -``` + └──→ 5V/3A Buck Converter → Micro-USB right-angle → GoPro USB port + (power only — no data over USB) -**Note:** ESP32 D1 Mini has an onboard 3.3V regulator. You can feed it 5V directly to the 5V pin if using a single 5V buck converter. This simplifies wiring: -``` -LiPo → 5V Buck → ├── ESP32 5V pin - └── GoPro USB port +UART (ESP8266 ↔ ESP32): + ESP8266 TX (GPIO1) ──→ ESP32 RX (GPIO16) + ESP8266 RX (GPIO3) ←── ESP32 TX (GPIO17) + ESP8266 GND ─────────── ESP32 GND ``` ## Wi-Fi Topology (No Cables for Camera Control) diff --git a/hardware/case/remoterig-case.scad b/hardware/case/remoterig-case.scad index 2c8b991..100ad90 100644 --- a/hardware/case/remoterig-case.scad +++ b/hardware/case/remoterig-case.scad @@ -15,12 +15,19 @@ gopro_depth = 30; // mm — body depth (front to back) gopro_lens_dia = 28; // mm — lens protrusion diameter gopro_lens_offset = 18; // mm — lens center from top -// ── ESP32 D1 Mini ── -esp_width = 34.2; -esp_height = 25.6; -esp_thick = 5; // board + components -usb_cutout_w = 10; -usb_cutout_h = 5; +// ── ESP8266 D1 Mini + ESP32 Dev Board (stacked) ── +esp8266_width = 34.2; +esp8266_height = 25.6; +esp8266_thick = 5; // board + components + +esp32_width = 52; // ESP32 Dev Board is larger +esp32_height = 28; +esp32_thick = 5; + +// Combined stack +board_width = max(esp8266_width, esp32_width); +board_height = max(esp8266_height, esp32_height); +board_thick = esp8266_thick + esp32_thick + 3; // 3mm gap between boards // ── LiPo Battery (1000mAh typical) ── lipo_width = 35; @@ -30,7 +37,7 @@ lipo_thick = 8; // ── Case parameters ── wall = 2.0; // case wall thickness tolerance = 0.3; // print tolerance for friction fit -compartment_height = max(esp_thick, lipo_thick) + 3; // internal compartment height +compartment_height = board_thick + 5; // internal compartment height for stacked boards // ── Cable channels ── cable_dia = 4; // USB cable diameter @@ -103,13 +110,13 @@ module gopro_sleeve() { } // ══════════════════════════════════════════════════════════════ -// Electronics Compartment — holds ESP32 + routes cables +// Electronics Compartment — holds ESP8266 + ESP32 stacked // ══════════════════════════════════════════════════════════════ module electronics_compartment() { - comp_w = max(esp_width, esp_height) + wall*2 + 10; + comp_w = board_width + wall*2 + 8; comp_h = compartment_height + wall*2; - comp_d = gopro_depth + wall*2; + comp_d = board_height + wall*2 + 8; difference() { union() { @@ -128,14 +135,22 @@ module electronics_compartment() { translate([0, 0, wall]) rounded_cube(comp_w - wall*2, comp_d - wall*2, comp_h - wall, 2); - // ESP32 board recess + // Bottom board (ESP32 — larger) recess translate([0, 5, wall + 1]) - cube([esp_width + tolerance, esp_height + tolerance, esp_thick + 1], center=true); + cube([esp32_width + tolerance, esp32_height + tolerance, esp32_thick + 1], center=true); - // USB cable entry (side hole) + // Top board (ESP8266 — smaller) recess + translate([0, 5, wall + esp32_thick + 4]) + cube([esp8266_width + tolerance, esp8266_height + tolerance, esp8266_thick + 1], center=true); + + // UART wire channel (between boards) + translate([comp_w/2, 0, wall + esp32_thick + 1]) + cube([wall*3, 6, 3], center=true); + + // USB cable entry (power to boards) translate([comp_w/2, 15, comp_h/2]) rotate([0, 90, 0]) - cylinder(d=usb_cutout_w, h=wall*3, center=true); + cylinder(d=6, h=wall*3, center=true); // USB cable exit (to GoPro) translate([comp_w/2, -15, comp_h/2]) @@ -150,9 +165,11 @@ module electronics_compartment() { } } - // LED window (thin wall for ESP32 LED visibility) + // LED windows (thin walls for ESP LEDs) translate([0, 0, wall]) cube([5, 5, wall], center=true); + translate([0, 0, wall + esp32_thick + 4]) + cube([5, 5, wall], center=true); } }