RemoteRig: Core infrastructure — MQTT subscriber, Pi deployment, ESP32 firmware, hardware design #5

Merged
overseer merged 33 commits from dev into main 2026-05-21 20:04:36 -04:00
4 changed files with 718 additions and 0 deletions
Showing only changes of commit d419dfe519 - Show all commits
+118
View File
@@ -0,0 +1,118 @@
# RemoteRig — ESP32 Camera Node Firmware
> **Platform:** PlatformIO (esp32dev) | **Framework:** Arduino
> **MQTT Contract:** [docs/MQTT_CONTRACT.md](../docs/MQTT_CONTRACT.md)
> **Hardware:** [hardware/README.md](../hardware/README.md)
## Quick Start
```bash
# Install PlatformIO (if not already)
pip install platformio
# Build
cd firmware
pio run
# Upload to ESP32 (USB connected)
pio run --target upload
# Upload SPIFFS config (first time only, or after config changes)
pio run --target uploadfs
# Serial monitor
pio device monitor
```
## Configuration
The ESP32 stores configuration in SPIFFS (`data/config.json`):
| Key | Default | Description |
|-----|---------|-------------|
| `wifi_ssid` | `"RemoteRig"` | Travel router SSID |
| `wifi_password` | `""` | Travel router password |
| `camera_ssid` | `"GOPRO-BP-"` | GoPro Wi-Fi AP prefix (auto-discovered) |
| `camera_password` | `"goprohero"` | GoPro Wi-Fi password |
| `mqtt_broker` | `"192.168.4.10"` | Pi Zero 2 W static IP |
| `mqtt_port` | `1883` | Mosquitto port |
| `camera_id` | `""` | Assigned by hub on first announce (leave empty) |
| `poll_interval_sec` | `30` | GoPro status poll frequency |
| `heartbeat_interval_sec` | `60` | MQTT heartbeat frequency |
**First boot:** Leave `camera_id` empty. The ESP32 will auto-announce to the hub, which assigns a `cam-NNN` ID. The assigned ID is saved to SPIFFS automatically.
## LED Status Codes
| Pattern | Meaning |
|---------|---------|
| Slow blink (1s) | Connected to router + MQTT, normal operation |
| Fast blink (200ms) | No Wi-Fi connection — reconnecting |
| Solid on | Connected but GoPro unreachable |
| Off | Boot/shutdown |
## Architecture
```
┌──────────────────────────────────────────┐
│ ESP32 (Arduino) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ WiFi STA │ │ WiFi STA │ │ MQTT │ │
│ │ (Router) │ │ (GoPro) │ │ Client │ │
│ └────┬─────┘ └────┬─────┘ └───┬────┘ │
│ │ │ │ │
│ │ ┌────────┘ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ Main Loop │ │
│ │ Every 30s: │ │
│ │ HTTP GET GoPro status │ │
│ │ Parse 60-byte blob │ │
│ │ MQTT publish status │ │
│ │ Every 60s: │ │
│ │ MQTT publish heartbeat │ │
│ └─────────────────────────────────┘ │
│ │
│ SPIFFS: /config.json (persistent) │
└──────────────────────────────────────────┘
```
## Boot Sequence
1. Load config from SPIFFS
2. Connect to travel router Wi-Fi (STA mode)
3. Connect to GoPro AP Wi-Fi (STA mode — simultaneous)
4. Connect to MQTT broker (192.168.4.10)
5. If no `camera_id` → publish announce → hub registers us
6. Subscribe to `remoterig/cameras/{camera_id}/command`
7. Enter main loop
## GoPro API Notes (Hero 3 Black/Silver)
- **IP:** Always `10.5.5.1` (GoPro's own AP)
- **Status endpoint:** `GET /bacpac/SH?t={password}&p=%01`
- **Start recording:** `GET /bacpac/SH?t={password}&p=%01` (mode byte = 1)
- **Stop recording:** `GET /bacpac/SH?t={password}&p=%00` (mode byte = 0)
- **Get password:** `GET /bacpac/sd` (no auth, returns plain text)
- **Status blob:** 60 bytes binary — see `parseStatus()` in main.cpp for field offsets
## ESP8266 Compatibility
To target ESP8266 instead:
1. Change `platformio.ini`: `board = d1_mini` under `[env:d1_mini]`
2. Change `WiFi.h``ESP8266WiFi.h`
3. ESP8266 doesn't do true simultaneous STA — use single STA to travel router, HTTP to GoPro via router bridge
4. SPIFFS → LittleFS on some boards
ESP32 is recommended for dual-STA capability.
## Troubleshooting
| Symptom | Check |
|---------|-------|
| No serial output | Baud rate: 115200. Hold BOOT, press EN, release BOOT for flash mode |
| Can't connect to router | Verify SSID/password in SPIFFS config, check router DHCP range |
| GoPro unreachable | GoPro must be ON and Wi-Fi enabled. Password defaults to "goprohero" |
| MQTT connect fails | Verify Mosquitto running on Pi: `systemctl status mosquitto` |
| Camera never registers | Watch serial for "announce" message, check hub logs for registration |
+11
View File
@@ -0,0 +1,11 @@
{
"wifi_ssid": "RemoteRig",
"wifi_password": "",
"camera_ssid": "GOPRO-BP-",
"camera_password": "goprohero",
"mqtt_broker": "192.168.4.10",
"mqtt_port": 1883,
"camera_id": "",
"poll_interval_sec": 30,
"heartbeat_interval_sec": 60
}
+23
View File
@@ -0,0 +1,23 @@
; RemoteRig — ESP32 Camera Node Firmware
; Platform: ESP32 (ESP8266 compatible with minor changes)
; Framework: Arduino
;
; Build: pio run
; Upload: pio run --target upload
; SPIFFS: pio run --target uploadfs
; Monitor: pio device monitor
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
upload_speed = 921600
lib_deps =
knolleary/PubSubClient @ ^2.8
bblanchon/ArduinoJson @ ^7.3
build_flags =
-D CORE_DEBUG_LEVEL=0
-D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192
+566
View File
@@ -0,0 +1,566 @@
/**
* RemoteRig — ESP32 Camera Node Firmware
* =======================================
* One ESP32 per GoPro Hero 3. Bridges the camera's Wi-Fi AP (10.5.5.1)
* to the travel router LAN via MQTT (Mosquitto on Pi Zero 2 W).
*
* MQTT Contract: docs/MQTT_CONTRACT.md
* Hardware: hardware/README.md
* Platform: PlatformIO (esp32dev)
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <HTTPClient.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <SPIFFS.h>
// ────────────────────────────────────────────────────────────
// Configuration (overridden by SPIFFS /data/config.json)
// ────────────────────────────────────────────────────────────
struct Config {
// Travel router Wi-Fi
String wifi_ssid = "RemoteRig";
String wifi_password = "";
// GoPro Hero 3 Wi-Fi AP
String camera_ssid = "GOPRO-BP-"; // prefix — auto-discovered
String camera_password = "goprohero";
// MQTT broker (Pi Zero 2 W on travel router)
String mqtt_broker = "192.168.4.10";
int mqtt_port = 1883;
// Assigned by hub on first announce; empty until registered
String camera_id = "";
// Polling
int poll_interval_sec = 30;
int heartbeat_interval_sec = 60;
// Stored in SPIFFS
bool dirty = false;
} cfg;
// ────────────────────────────────────────────────────────────
// Network clients
// ────────────────────────────────────────────────────────────
WiFiClient wifiClient; // for HTTP to GoPro
WiFiClient mqttWifiClient; // for MQTT via travel router
PubSubClient mqtt(mqttWifiClient);
// ────────────────────────────────────────────────────────────
// State
// ────────────────────────────────────────────────────────────
unsigned long lastPollMs = 0;
unsigned long lastHeartbeatMs = 0;
unsigned long lastReconnectMs = 0;
unsigned long bootMs = 0;
int reconnectDelay = 1; // exponential backoff (seconds)
bool goproOnline = false;
// Heartbeat sequence
unsigned int heartbeatSeq = 0;
// ────────────────────────────────────────────────────────────
// LED Pin (built-in on most ESP32 dev boards = GPIO 2)
// ────────────────────────────────────────────────────────────
const int LED_PIN = 2;
enum LedMode { LED_OFF, LED_SLOW, LED_FAST, LED_ON };
LedMode ledMode = LED_SLOW;
void setLed(LedMode mode) {
ledMode = mode;
}
// ────────────────────────────────────────────────────────────
// SPIFFS Config
// ────────────────────────────────────────────────────────────
bool loadConfig() {
if (!SPIFFS.begin(true)) {
Serial.println("[CFG] SPIFFS mount failed");
return false;
}
File f = SPIFFS.open("/config.json", "r");
if (!f) {
Serial.println("[CFG] No /config.json — using defaults");
return false;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, f);
f.close();
if (err) {
Serial.printf("[CFG] JSON parse error: %s\n", err.c_str());
return false;
}
cfg.wifi_ssid = doc["wifi_ssid"] | cfg.wifi_ssid;
cfg.wifi_password = doc["wifi_password"] | cfg.wifi_password;
cfg.camera_ssid = doc["camera_ssid"] | cfg.camera_ssid;
cfg.camera_password = doc["camera_password"] | cfg.camera_password;
cfg.mqtt_broker = doc["mqtt_broker"] | cfg.mqtt_broker;
cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port;
cfg.camera_id = doc["camera_id"] | cfg.camera_id;
cfg.poll_interval_sec = doc["poll_interval_sec"] | cfg.poll_interval_sec;
cfg.heartbeat_interval_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_interval_sec;
Serial.println("[CFG] Loaded from /config.json");
return true;
}
bool saveConfig() {
File f = SPIFFS.open("/config.json", "w");
if (!f) return false;
JsonDocument doc;
doc["wifi_ssid"] = cfg.wifi_ssid;
doc["wifi_password"] = cfg.wifi_password;
doc["camera_ssid"] = cfg.camera_ssid;
doc["camera_password"] = cfg.camera_password;
doc["mqtt_broker"] = cfg.mqtt_broker;
doc["mqtt_port"] = cfg.mqtt_port;
doc["camera_id"] = cfg.camera_id;
doc["poll_interval_sec"] = cfg.poll_interval_sec;
doc["heartbeat_interval_sec"] = cfg.heartbeat_interval_sec;
serializeJson(doc, f);
f.close();
Serial.println("[CFG] Saved config");
return true;
}
// ────────────────────────────────────────────────────────────
// Wi-Fi — Dual STA (GoPro AP + Travel Router)
// ────────────────────────────────────────────────────────────
bool connectCameraWiFi() {
Serial.printf("[WIFI] Connecting to GoPro AP: %s\n", cfg.camera_ssid.c_str());
// Use WiFi.begin with a second AP config — ESP32 supports this
// We connect to travel router first, then GoPro
// GoPro AP: static IP on 10.5.5.x subnet
WiFi.begin(cfg.camera_ssid.c_str(), cfg.camera_password.c_str());
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n[WIFI] Connected to GoPro AP. IP: %s\n", WiFi.localIP().toString().c_str());
goproOnline = true;
return true;
}
Serial.println("\n[WIFI] Failed to connect to GoPro AP");
goproOnline = false;
return false;
}
// ═══════════════════════════════════════════════════════════
// GoPro Hero 3 HTTP API
// ═══════════════════════════════════════════════════════════
// GoPro AP gateway (always 10.5.5.1 for Hero 3)
const char* GOPRO_IP = "10.5.5.1";
/**
* Get the GoPro camera password.
* Hero 3 exposes it via GET /bacpac/sd (no auth required).
* Default is "goprohero" but user may have changed it.
*/
String fetchGoProPassword() {
HTTPClient http;
http.begin(wifiClient, String("http://") + GOPRO_IP + "/bacpac/sd");
int code = http.GET();
String body = http.getString();
http.end();
if (code == 200 && body.length() > 0) {
// Password is in plain text in the response body
body.trim();
return body;
}
return cfg.camera_password; // fallback to config value
}
/**
* Fetch the GoPro status blob (60 bytes binary).
* Returns empty string on failure.
*/
String fetchGoProStatus() {
String url = String("http://") + GOPRO_IP +
"/bacpac/SH?t=" + cfg.camera_password + "&p=%01";
HTTPClient http;
http.begin(wifiClient, url);
http.setTimeout(5000);
int code = http.GET();
if (code != 200) {
http.end();
return "";
}
// GoPro returns raw binary — use getString() which handles it
String raw = http.getString();
http.end();
return raw;
}
/**
* Parse the 60-byte GoPro status blob into structured data.
* Hero 3 status format (offsets are 0-based):
* [25-26] video_remaining_sec (uint16 LE)
* [29] recording state (0=idle, 1=recording)
* [30] mode
* [31-32] resolution
* [33-34] fps
* [57] battery_raw (uint8)
*/
struct GoProStatus {
bool valid = false;
int video_remaining_sec = 0;
bool recording = false;
int mode = 0;
int fps = 0;
int battery_raw = 0;
};
GoProStatus parseStatus(const String& raw) {
GoProStatus s;
if (raw.length() < 58) {
return s;
}
const uint8_t* buf = (const uint8_t*)raw.c_str();
s.valid = true;
s.video_remaining_sec = buf[25] | (buf[26] << 8);
s.recording = (buf[29] == 1);
s.mode = buf[30];
s.fps = buf[33] | (buf[34] << 8);
s.battery_raw = buf[57];
return s;
}
bool sendGoProCommand(const String& command) {
String param;
if (command == "start_recording") {
param = "%01"; // mode 1 = record
} else if (command == "stop_recording") {
param = "%00"; // mode 0 = stop
} else {
Serial.printf("[GOPRO] Unknown command: %s\n", command.c_str());
return false;
}
String url = String("http://") + GOPRO_IP +
"/bacpac/SH?t=" + cfg.camera_password + "&p=" + param;
HTTPClient http;
http.begin(wifiClient, url);
http.setTimeout(5000);
int code = http.GET();
http.end();
Serial.printf("[GOPRO] Command %s → HTTP %d\n", command.c_str(), code);
return (code == 200);
}
// ═══════════════════════════════════════════════════════════
// MQTT
// ═══════════════════════════════════════════════════════════
String clientID() {
uint8_t mac[6];
WiFi.macAddress(mac);
char buf[32];
snprintf(buf, sizeof(buf), "remoterig-%02x%02x%02x", mac[3], mac[4], mac[5]);
return String(buf);
}
String statusTopic() { return "remoterig/cameras/" + cfg.camera_id + "/status"; }
String heartbeatTopic() { return "remoterig/cameras/" + cfg.camera_id + "/heartbeat"; }
String announceTopic() { return "remoterig/cameras/" + cfg.camera_id + "/announce"; }
String commandTopic() { return "remoterig/cameras/" + cfg.camera_id + "/command"; }
void mqttCallback(char* topic, byte* payload, unsigned int length) {
// Null-terminate payload
char buf[256];
unsigned int len = length < 255 ? length : 255;
memcpy(buf, payload, len);
buf[len] = 0;
Serial.printf("[MQTT] ← %s: %s\n", topic, buf);
JsonDocument doc;
DeserializationError err = deserializeJson(doc, buf);
if (err) {
Serial.printf("[MQTT] JSON parse error: %s\n", err.c_str());
return;
}
String cmd = doc["command"] | "";
if (cmd == "start_recording" || cmd == "stop_recording") {
sendGoProCommand(cmd);
} else if (cmd == "reboot") {
Serial.println("[MQTT] Reboot command received");
ESP.restart();
} else if (cmd == "registered") {
// Hub assigned us a camera_id on announce
String newID = doc["camera_id"] | "";
if (newID.length() > 0 && newID != cfg.camera_id) {
cfg.camera_id = newID;
cfg.dirty = true;
Serial.printf("[MQTT] Registered as %s\n", newID.c_str());
// Re-subscribe to our new command topic
mqtt.unsubscribe(commandTopic().c_str());
mqtt.subscribe(commandTopic().c_str(), 2);
}
} else {
Serial.printf("[MQTT] Unknown command: %s\n", cmd.c_str());
}
}
bool connectMQTT() {
mqtt.setServer(cfg.mqtt_broker.c_str(), cfg.mqtt_port);
mqtt.setCallback(mqttCallback);
mqtt.setKeepAlive(60);
Serial.printf("[MQTT] Connecting to %s:%d as %s...\n",
cfg.mqtt_broker.c_str(), cfg.mqtt_port, clientID().c_str());
if (mqtt.connect(clientID().c_str())) {
Serial.println("[MQTT] Connected");
// Subscribe to command topic
mqtt.subscribe(commandTopic().c_str(), 2);
Serial.printf("[MQTT] Subscribed to %s\n", commandTopic().c_str());
// If we have no camera_id yet, announce ourselves
if (cfg.camera_id.length() == 0) {
publishAnnounce();
}
reconnectDelay = 1; // reset backoff
return true;
}
Serial.printf("[MQTT] Connection failed (state=%d)\n", mqtt.state());
return false;
}
void publishAnnounce() {
JsonDocument doc;
doc["mac_address"] = WiFi.macAddress();
doc["firmware_version"] = "0.1.0";
doc["friendly_name"] = "ESP32-" + clientID().substring(9);
JsonArray caps = doc["capabilities"].to<JsonArray>();
caps.add("start_stop");
caps.add("status");
String payload;
serializeJson(doc, payload);
// Publish on a temporary announce topic (using MAC as ID until registered)
String tempAnnounce = "remoterig/cameras/announce-" + clientID().substring(9);
mqtt.publish(tempAnnounce.c_str(), payload.c_str(), true);
Serial.printf("[MQTT] Published announce: %s\n", payload.c_str());
}
void publishStatus(const GoProStatus& s) {
JsonDocument doc;
doc["camera_id"] = cfg.camera_id;
doc["timestamp"] = millis(); // milliseconds since boot — hub converts to ISO
doc["battery_raw"] = s.battery_raw;
doc["video_remaining_sec"] = s.video_remaining_sec;
doc["recording"] = s.recording;
doc["online"] = goproOnline;
if (s.recording) {
doc["mode"] = "video";
}
String payload;
serializeJson(doc, payload);
bool ok = mqtt.publish(statusTopic().c_str(), payload.c_str(), true);
if (ok) {
Serial.printf("[MQTT] → status (batt=%d, rec=%d, online=%d)\n",
s.battery_raw, s.recording, goproOnline);
} else {
Serial.println("[MQTT] Status publish failed");
}
}
void publishHeartbeat() {
JsonDocument doc;
doc["camera_id"] = cfg.camera_id;
doc["timestamp"] = millis();
doc["uptime_sec"] = (millis() - bootMs) / 1000;
doc["free_heap"] = ESP.getFreeHeap();
String payload;
serializeJson(doc, payload);
mqtt.publish(heartbeatTopic().c_str(), payload.c_str(), false);
}
// ═══════════════════════════════════════════════════════════
// Setup
// ═══════════════════════════════════════════════════════════
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n\nRemoteRig ESP32 Camera Node v0.1.0");
Serial.println("===================================");
bootMs = millis();
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
// Load config from SPIFFS
loadConfig();
Serial.printf("[CFG] camera_id: %s (empty = not yet registered)\n",
cfg.camera_id.length() > 0 ? cfg.camera_id.c_str() : "(none)");
// Connect to travel router Wi-Fi
Serial.printf("[WIFI] Connecting to travel router: %s\n", cfg.wifi_ssid.c_str());
WiFi.begin(cfg.wifi_ssid.c_str(), cfg.wifi_password.c_str());
int wifiAttempts = 0;
while (WiFi.status() != WL_CONNECTED && wifiAttempts < 40) {
delay(500);
Serial.print(".");
wifiAttempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n[WIFI] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
setLed(LED_SLOW); // connected to router
} else {
Serial.println("\n[WIFI] Failed to connect to travel router — will retry in loop");
setLed(LED_FAST); // no router connection
}
// Connect to GoPro AP
if (!connectCameraWiFi()) {
Serial.println("[WIFI] GoPro not reachable — will retry");
setLed(LED_FAST);
}
// Connect MQTT
if (WiFi.status() == WL_CONNECTED) {
connectMQTT();
}
}
// ═══════════════════════════════════════════════════════════
// Main Loop
// ═══════════════════════════════════════════════════════════
void loop() {
unsigned long now = millis();
// ── LED heartbeat ──
static unsigned long lastLedToggle = 0;
int ledInterval = (ledMode == LED_FAST) ? 200 : (ledMode == LED_SLOW) ? 1000 : 0;
if (ledInterval > 0 && now - lastLedToggle > ledInterval) {
lastLedToggle = now;
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
}
if (ledMode == LED_ON) digitalWrite(LED_PIN, HIGH);
if (ledMode == LED_OFF) digitalWrite(LED_PIN, LOW);
// ── Wi-Fi reconnection ──
if (WiFi.status() != WL_CONNECTED) {
setLed(LED_FAST);
if (now - lastReconnectMs > 5000) {
lastReconnectMs = now;
Serial.println("[WIFI] Reconnecting...");
WiFi.reconnect();
}
delay(100);
return; // skip everything else until Wi-Fi is back
}
// ── MQTT reconnection ──
if (!mqtt.connected()) {
setLed(LED_SLOW);
if (now - lastReconnectMs > (unsigned long)(reconnectDelay * 1000)) {
lastReconnectMs = now;
if (connectMQTT()) {
reconnectDelay = 1;
} else {
reconnectDelay = min(reconnectDelay * 2, 30);
}
}
mqtt.loop();
delay(100);
return;
}
setLed(LED_SLOW);
mqtt.loop();
// ── GoPro reconnection ──
static unsigned long lastGoProRetry = 0;
if (!goproOnline && now - lastGoProRetry > 30000) {
lastGoProRetry = now;
connectCameraWiFi();
}
// ── Status polling (every cfg.poll_interval_sec) ──
if (now - lastPollMs > (unsigned long)(cfg.poll_interval_sec * 1000)) {
lastPollMs = now;
String raw = fetchGoProStatus();
GoProStatus status = parseStatus(raw);
if (status.valid) {
goproOnline = true;
if (cfg.camera_id.length() > 0) {
publishStatus(status);
}
} else {
goproOnline = false;
if (cfg.camera_id.length() > 0) {
GoProStatus offline = {};
offline.valid = true;
publishStatus(offline); // publish with online=false
}
}
}
// ── Heartbeat (every heartbeat_interval_sec) ──
if (cfg.camera_id.length() > 0 &&
now - lastHeartbeatMs > (unsigned long)(cfg.heartbeat_interval_sec * 1000)) {
lastHeartbeatMs = now;
publishHeartbeat();
}
// ── Save config if dirty ──
if (cfg.dirty) {
cfg.dirty = false;
saveConfig();
}
delay(100);
}