generated from CubeCraft-Creations/Tracehound
feat: dual-board architecture — ESP8266 camera bridge + ESP32 MQTT bridge
Complete rewrite of firmware into two dedicated boards per camera node: ESP8266 (Camera Bridge): - Connects ONLY to GoPro AP — polls status, sends over UART - Zero network switching, zero MQTT - HTTP GET /bacpac/SH for status, start/stop - JSON-per-line UART protocol to ESP32 ESP32 (MQTT Bridge): - Connects ONLY to travel router — MQTT to Pi hub - Reads status from ESP8266 over UART2 (RX16/TX17) - Auto-registration, heartbeat, command forwarding - Zero camera communication UART Protocol: JSON-per-line at 115200 8N1 ESP8266→ESP32: status/ack/pong/error ESP32→ESP8266: cmd (start_recording/stop_recording/ping) Hardware updates: - BOM now includes both boards (~4/node) - 3D case has stacked dual-board compartment - UART wire channel between board recesses - Shared 3.3V power rail for both boards
This commit is contained in:
+92
-95
@@ -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
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"camera_ssid": "GOPRO-BP-",
|
||||||
|
"camera_password": "goprohero",
|
||||||
|
"camera_ip": "10.5.5.1",
|
||||||
|
"poll_interval_sec": 30
|
||||||
|
}
|
||||||
+43
-20
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user