Files
remote-rig/firmware/src/main.cpp
T
Hermes 324402f268
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
Build (Dev) / build (push) Failing after 8s
CI/CD / deploy (push) Has been skipped
feat: add ESP8266 support + Akaso camera compatibility config
- Unified firmware for ESP32 (dual-STA) and ESP8266 (time-shared STA)
- ESP8266: alternates between GoPro AP and travel router per poll cycle
- PlatformIO dual-target: esp32dev + esp8266dev (d1_mini)
- camera_ip config field for Akaso/non-GoPro cameras
- LittleFS support for ESP8266 (replaces SPIFFS)
- Camera compatibility table (GoPro H3/H4, Akaso)
- LED polarity handled per-platform (ESP8266 active-low)

ESP8266 time-sharing adds ~4s latency per 30s cycle — invisible at poll rate.
2026-05-22 00:28:48 +00:00

513 lines
15 KiB
C++

// 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);
}