Dev #26

Open
overseer wants to merge 65 commits from dev into main
9 changed files with 831 additions and 665 deletions
Showing only changes of commit b3d4226b1c - Show all commits
+92 -95
View File
@@ -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) > **MQTT Contract:** [docs/MQTT_CONTRACT.md](../docs/MQTT_CONTRACT.md)
> **Hardware:** [hardware/README.md](../hardware/README.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 ## Quick Start
```bash ```bash
# Install PlatformIO
pip install platformio pip install platformio
# Build for ESP32 (recommended — dual-STA)
cd firmware cd firmware
pio run -e esp32dev
# Build for ESP8266 D1 Mini (time-shared STA) # Build both
pio run -e esp8266dev pio run -e esp8266-camera
pio run -e esp32-mqtt
# Upload to board # Upload to boards (connect one at a time via USB)
pio run -e esp32dev --target upload pio run -e esp8266-camera --target upload
pio run -e esp8266dev --target upload pio run -e esp32-mqtt --target upload
# Upload SPIFFS/LittleFS config (first time only) # Upload configs (each board needs its own)
pio run -e esp32dev --target uploadfs # ESP8266: copy esp8266-config.json to data/config.json, then:
pio run -e esp8266dev --target uploadfs pio run -e esp8266-camera --target uploadfs
# ESP32: copy esp32-config.json to data/config.json, then:
# Serial monitor pio run -e esp32-mqtt --target uploadfs
pio device monitor
``` ```
## Platform Differences ## UART Protocol (ESP8266 ↔ ESP32)
| Feature | ESP32 | ESP8266 | JSON-per-line at 115200 8N1. GPIO16 on both boards.
|---------|-------|---------|
| 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. | Direction | Type | Format | Purpose |
|-----------|------|--------|---------|
## Camera Compatibility | ESP8266 → ESP32 | `status` | `{"type":"status","battery_raw":217,...}` | Camera poll result |
| ESP8266 → ESP32 | `ack` | `{"type":"ack","cmd":"start_recording"}` | Command confirmation |
| Camera | IP | Protocol | Status | | ESP8266 → ESP32 | `pong` | `{"type":"pong","uptime_ms":12345}` | Ping response |
|--------|-----|----------|--------| | ESP8266 → ESP32 | `error` | `{"type":"error","msg":"camera unreachable"}` | Error report |
| GoPro Hero 3 | `10.5.5.1` | HTTP GET `/bacpac/SH` | ✅ Full support | | ESP32 → ESP8266 | `cmd` | `{"type":"cmd","command":"start_recording"}` | Hub command |
| GoPro Hero 4 | `10.5.5.1` | HTTP GET `/gp/gpControl` | ⚠️ Different API — needs adaptation | | ESP32 → ESP8266 | `cmd` | `{"type":"cmd","command":"ping"}` | Link health check |
| 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 ## 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 | | Key | Default | Description |
|-----|---------|-------------| |-----|---------|-------------|
| `wifi_ssid` | `"RemoteRig"` | Travel router SSID | | `wifi_ssid` | `"RemoteRig"` | Travel router SSID |
| `wifi_password` | `""` | Travel router password | | `wifi_password` | `""` | Travel router password |
| `camera_ssid` | `"GOPRO-BP-"` | GoPro Wi-Fi AP prefix (auto-discovered) | | `mqtt_broker` | `"192.168.4.10"` | Pi Zero 2 W IP |
| `camera_password` | `"goprohero"` | GoPro Wi-Fi password |
| `mqtt_broker` | `"192.168.4.10"` | Pi Zero 2 W static IP |
| `mqtt_port` | `1883` | Mosquitto port | | `mqtt_port` | `1883` | Mosquitto port |
| `camera_id` | `""` | Assigned by hub on first announce (leave empty) | | `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 | | `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. ## Wiring
## 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
``` ```
┌──────────────────────────────────────────┐ ESP8266 D1 Mini ESP32 Dev Board
│ ESP32 (Arduino) │ ┌────────────┐ ┌────────────┐
│ │ │ │
│ TX (GPIO1)│──────────→│ RX (GPIO16)│
│ RX (GPIO3)│←──────────│ TX (GPIO17)│
│ GND │───────────│ GND │
│ 3.3V │ │ 3.3V │
│ │ │ │
└────────────┘ └────────────┘
│ │ │ │
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │ ─────────────────────┘
│ WiFi STA │ │ WiFi STA │ MQTT
│ │ (Router) │ │ (GoPro) │ │ Client │ │ LiPo → 3.3V Buck
│ └────┬─────┘ └────┬─────┘ └───┬────┘ │ (shared power)
│ │ │ │ │
│ │ ┌────────┘ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ 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 ## Boot Sequence
1. Load config from SPIFFS 1. **ESP8266:** Connect to GoPro AP → wait for UART commands
2. Connect to travel router Wi-Fi (STA mode) 2. **ESP32:** Connect to travel router → connect MQTT → announce if new
3. Connect to GoPro AP Wi-Fi (STA mode — simultaneous) 3. **ESP8266:** Poll camera every 30s → send status over UART
4. Connect to MQTT broker (192.168.4.10) 4. **ESP32:** Receive status → publish MQTT
5. If no `camera_id` → publish announce → hub registers us 5. **Hub → MQTT command → ESP32 → UART → ESP8266 → HTTP → GoPro**
6. Subscribe to `remoterig/cameras/{camera_id}/command`
7. Enter main loop
## GoPro API Notes (Hero 3 Black/Silver) ## Camera Compatibility
- **IP:** Always `10.5.5.1` (GoPro's own AP) | Camera | `camera_ip` | Protocol | Status |
- **Status endpoint:** `GET /bacpac/SH?t={password}&p=%01` |--------|------------|----------|--------|
- **Start recording:** `GET /bacpac/SH?t={password}&p=%01` (mode byte = 1) | GoPro Hero 3 | `10.5.5.1` | HTTP GET `/bacpac/SH` | ✅ Full support |
- **Stop recording:** `GET /bacpac/SH?t={password}&p=%00` (mode byte = 0) | Akaso Brave 7 | `192.168.1.1` | Varies | 🔬 Set `camera_ip`, test |
- **Get password:** `GET /bacpac/sd` (no auth, returns plain text)
- **Status blob:** 60 bytes binary — see `parseStatus()` in main.cpp for field offsets 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 ## Troubleshooting
| Symptom | Check | | Symptom | Check |
|---------|-------| |---------|-------|
| No serial output | Baud rate: 115200. Hold BOOT, press EN, release BOOT for flash mode | | No UART communication | Verify TX→RX crossover. Both boards at 115200. Shared GND. |
| Can't connect to router | Verify SSID/password in SPIFFS config, check router DHCP range | | ESP8266 can't connect | GoPro must be ON with Wi-Fi enabled. Default password: `goprohero` |
| GoPro unreachable | GoPro must be ON and Wi-Fi enabled. Password defaults to "goprohero" | | ESP32 can't connect MQTT | `systemctl status mosquitto` on Pi. Port 1883 open. |
| MQTT connect fails | Verify Mosquitto running on Pi: `systemctl status mosquitto` | | Camera never registers | Watch ESP32 serial for "Announced" message. Check hub logs. |
| Camera never registers | Watch serial for "announce" message, check hub logs for registration |
@@ -1,12 +1,8 @@
{ {
"wifi_ssid": "RemoteRig", "wifi_ssid": "RemoteRig",
"wifi_password": "", "wifi_password": "",
"camera_ssid": "GOPRO-BP-",
"camera_password": "goprohero",
"camera_ip": "10.5.5.1",
"mqtt_broker": "192.168.4.10", "mqtt_broker": "192.168.4.10",
"mqtt_port": 1883, "mqtt_port": 1883,
"camera_id": "", "camera_id": "",
"poll_interval_sec": 30,
"heartbeat_interval_sec": 60 "heartbeat_interval_sec": 60
} }
+6
View File
@@ -0,0 +1,6 @@
{
"camera_ssid": "GOPRO-BP-",
"camera_password": "goprohero",
"camera_ip": "10.5.5.1",
"poll_interval_sec": 30
}
+43 -20
View File
@@ -1,17 +1,24 @@
; RemoteRig — ESP32 + ESP8266 Camera Node Firmware ; RemoteRig — Dual-Board Camera Node Firmware
; PlatformIO project with dual-target support. ; ============================================
; 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: ; Build:
; pio run -e esp32dev (ESP32 Dev Board — dual-STA, recommended) ; pio run -e esp8266-camera (ESP8266 D1 Mini — camera bridge)
; pio run -e esp8266dev (ESP8266 D1 Mini — time-shared STA) ; pio run -e esp32-mqtt (ESP32 Dev Board — MQTT bridge)
; ;
; Upload: ; Upload:
; pio run -e esp32dev --target upload ; pio run -e esp8266-camera --target upload
; pio run -e esp8266dev --target upload ; pio run -e esp32-mqtt --target upload
; ;
; SPIFFS/LittleFS: ; Filesystem:
; pio run -e esp32dev --target uploadfs ; pio run -e esp8266-camera --target uploadfs
; pio run -e esp8266dev --target uploadfs ; pio run -e esp32-mqtt --target uploadfs
[common] [common]
lib_deps = lib_deps =
@@ -20,17 +27,11 @@ lib_deps =
build_flags = build_flags =
-D CORE_DEBUG_LEVEL=0 -D CORE_DEBUG_LEVEL=0
[env:esp32dev] ; ── ESP8266: Camera Bridge ──────────────────────────────────
platform = espressif32 ; Flashed onto D1 Mini. Talks to GoPro over Wi-Fi, relays to
board = esp32dev ; ESP32 over UART (TX/RX pins). No MQTT, no router connection.
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
[env:esp8266dev] [env:esp8266-camera]
platform = espressif8266 platform = espressif8266
board = d1_mini board = d1_mini
framework = arduino framework = arduino
@@ -39,6 +40,28 @@ upload_speed = 921600
lib_deps = ${common.lib_deps} lib_deps = ${common.lib_deps}
build_flags = ${common.build_flags} build_flags = ${common.build_flags}
-D PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED -D PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED
-D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192
board_build.flash_mode = dio board_build.flash_mode = dio
board_build.f_cpu = 160000000L board_build.f_cpu = 160000000L
build_src_filter =
+<../lib/>
+<esp8266-camera-bridge.cpp>
-<*.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/>
+<esp32-mqtt-bridge.cpp>
-<*.cpp>
+332
View File
@@ -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 <Arduino.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <SPIFFS.h>
// ────────────────────────────────────────────
// 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<JsonArray>();
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);
}
+303
View File
@@ -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 <Arduino.h>
#include <ESP8266WiFi.h>
#include <WiFiClient.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <LittleFS.h>
// ────────────────────────────────────────────
// 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));
}
}
}
-512
View File
@@ -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 <Arduino.h>
#ifdef ESP32
#include <WiFi.h>
#define HAS_DUAL_STA 1
#elif defined(ESP8266)
#include <ESP8266WiFi.h>
#define HAS_DUAL_STA 0
#else
#error "Unsupported platform — use ESP32 or ESP8266"
#endif
#include <WiFiClient.h>
#include <HTTPClient.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#ifdef ESP8266
#include <LittleFS.h>
#define SPIFFS LittleFS
#else
#include <SPIFFS.h>
#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<JsonArray>();
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);
}
+22 -18
View File
@@ -23,11 +23,13 @@ Each camera node is a self-contained unit clipped onto a GoPro Hero 3. It provid
│ │ Screen │ │ │ │ Screen │ │
│ └─────────────────────────┘ │ │ └─────────────────────────┘ │
│ ┌──────────┐ │ │ ┌──────────┐ │
│ 3D Sleeve ─────→│ ESP32 │ │ ← clips onto back/bottom │ 3D Sleeve ─────→│ ESP8266 │ │ ← Camera bridge (GoPro Wi-Fi)
│ │ D1 Mini │ │ │ │ D1 Mini │ │
────────── ──────────
┌──────────┐ │ │ ESP32 │ │ ← MQTT bridge (travel router)
│ │ LiPo │ │ ← slides under GoPro │ │ Dev │ │
│ ├──────────┤ │
│ │ LiPo │ │ ← Shared power
│ │ 1000mAh │ │ │ │ 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 | | Item | Qty | Cost | Notes |
|------|-----|------|-------| |------|-----|------|-------|
| GoPro Hero 3 Black/Silver | 1 | Already owned | Target camera | | 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 | | LiPo 3.7V 1000mAh | 1 | ~$8 | 50x34x8mm typical |
| 5V/3A buck converter | 1 | ~$2 | LiPo → GoPro USB | | 3.3V buck converter | 1 | ~$1 | LiPo → both boards (shared VIN) |
| 3.3V buck converter | 1 | ~$1 | LiPo → ESP32 VIN | | 5V/3A buck converter | 1 | ~$2 | LiPo → GoPro USB (power only) |
| JST-XH 2-pin connectors | 2 | ~$1 | Battery quick-disconnect | | JST-XH 2-pin connectors | 2 | ~$1 | Battery quick-disconnect |
| Micro-USB right-angle cable | 1 | ~$2 | Buck → GoPro | | 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 | | 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 ## 3D Printed Case
@@ -88,17 +92,17 @@ Slides under the GoPro. Contains:
LiPo 3.7V LiPo 3.7V
├── JST-XH connector ├── JST-XH connector
├──→ 5V/3A Buck Converter → Micro-USB right-angle → GoPro USB port ├──→ 3.3V Buck Converter → ESP8266 VIN + GND
(power only — no data over USB) → ESP32 VIN + GND
│ (both boards share the same 3.3V rail)
└──→ 3.3V Buck Converter → ESP32 VIN + GND └──→ 5V/3A Buck Converter → Micro-USB right-angle → GoPro USB port
(or ESP32 D1 Mini has built-in regulator — connect directly to 5V pin) (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: UART (ESP8266 ↔ ESP32):
``` ESP8266 TX (GPIO1) ──→ ESP32 RX (GPIO16)
LiPo → 5V Buck → ├── ESP32 5V pin ESP8266 RX (GPIO3) ←── ESP32 TX (GPIO17)
└── GoPro USB port ESP8266 GND ─────────── ESP32 GND
``` ```
## Wi-Fi Topology (No Cables for Camera Control) ## Wi-Fi Topology (No Cables for Camera Control)
+32 -15
View File
@@ -15,12 +15,19 @@ gopro_depth = 30; // mm — body depth (front to back)
gopro_lens_dia = 28; // mm — lens protrusion diameter gopro_lens_dia = 28; // mm — lens protrusion diameter
gopro_lens_offset = 18; // mm — lens center from top gopro_lens_offset = 18; // mm — lens center from top
// ── ESP32 D1 Mini ── // ── ESP8266 D1 Mini + ESP32 Dev Board (stacked) ──
esp_width = 34.2; esp8266_width = 34.2;
esp_height = 25.6; esp8266_height = 25.6;
esp_thick = 5; // board + components esp8266_thick = 5; // board + components
usb_cutout_w = 10;
usb_cutout_h = 5; 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 Battery (1000mAh typical) ──
lipo_width = 35; lipo_width = 35;
@@ -30,7 +37,7 @@ lipo_thick = 8;
// ── Case parameters ── // ── Case parameters ──
wall = 2.0; // case wall thickness wall = 2.0; // case wall thickness
tolerance = 0.3; // print tolerance for friction fit 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 channels ──
cable_dia = 4; // USB cable diameter 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() { 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_h = compartment_height + wall*2;
comp_d = gopro_depth + wall*2; comp_d = board_height + wall*2 + 8;
difference() { difference() {
union() { union() {
@@ -128,14 +135,22 @@ module electronics_compartment() {
translate([0, 0, wall]) translate([0, 0, wall])
rounded_cube(comp_w - wall*2, comp_d - wall*2, comp_h - wall, 2); 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]) 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]) translate([comp_w/2, 15, comp_h/2])
rotate([0, 90, 0]) 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) // USB cable exit (to GoPro)
translate([comp_w/2, -15, comp_h/2]) 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]) translate([0, 0, wall])
cube([5, 5, wall], center=true); cube([5, 5, wall], center=true);
translate([0, 0, wall + esp32_thick + 4])
cube([5, 5, wall], center=true);
} }
} }