generated from CubeCraft-Creations/Tracehound
feat: add PlatformIO ESP32 firmware with dual-STA + MQTT + GoPro control
CI/CD / lint-and-typecheck (pull_request) Failing after 2s
CI/CD / test (pull_request) Has been skipped
CI/CD / build (pull_request) Has been skipped
CI/CD / deploy (pull_request) Failing after 12m6s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Build (Dev) / build (push) Failing after 14s
CI/CD / lint-and-typecheck (push) Failing after 0s
CI/CD / lint-and-typecheck (pull_request) Failing after 2s
CI/CD / test (pull_request) Has been skipped
CI/CD / build (pull_request) Has been skipped
CI/CD / deploy (pull_request) Failing after 12m6s
CI/CD / test (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / deploy (push) Has been skipped
Build (Dev) / build (push) Failing after 14s
CI/CD / lint-and-typecheck (push) Failing after 0s
firmware/ ├── platformio.ini — ESP32 (esp32dev), PubSubClient + ArduinoJson ├── src/main.cpp — Full camera node firmware (360 lines) │ ├── SPIFFS config persistence │ ├── Dual Wi-Fi STA (travel router + GoPro AP) │ ├── GoPro Hero 3 HTTP API (start/stop/status) │ ├── 60-byte binary status blob parser │ ├── MQTT per contract (status QoS1, heartbeat QoS1, announce QoS2) │ ├── Command subscription (start/stop/reboot) │ ├── Auto-registration (announce → hub assigns cam-NNN) │ ├── Heartbeat every 60s, status every 30s │ ├── LED status indicator │ └── Exponential backoff reconnection ├── data/config.json — Default SPIFFS config template └── README.md — Quick start, config reference, troubleshooting
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user