generated from CubeCraftLabs/Tracehound
304 lines
9.5 KiB
C++
304 lines
9.5 KiB
C++
|
|
/**
|
||
|
|
* 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));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|