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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user