generated from CubeCraft-Creations/Tracehound
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.
This commit is contained in:
+38
-19
@@ -1,29 +1,58 @@
|
|||||||
# RemoteRig — ESP32 Camera Node Firmware
|
# RemoteRig — ESP32 / ESP8266 Camera Node Firmware
|
||||||
|
|
||||||
> **Platform:** PlatformIO (esp32dev) | **Framework:** Arduino
|
> **Platform:** PlatformIO (esp32dev + esp8266dev) | **Framework:** Arduino
|
||||||
> **MQTT Contract:** [docs/MQTT_CONTRACT.md](../docs/MQTT_CONTRACT.md)
|
> **MQTT Contract:** [docs/MQTT_CONTRACT.md](../docs/MQTT_CONTRACT.md)
|
||||||
> **Hardware:** [hardware/README.md](../hardware/README.md)
|
> **Hardware:** [hardware/README.md](../hardware/README.md)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install PlatformIO (if not already)
|
# Install PlatformIO
|
||||||
pip install platformio
|
pip install platformio
|
||||||
|
|
||||||
# Build
|
# Build for ESP32 (recommended — dual-STA)
|
||||||
cd firmware
|
cd firmware
|
||||||
pio run
|
pio run -e esp32dev
|
||||||
|
|
||||||
# Upload to ESP32 (USB connected)
|
# Build for ESP8266 D1 Mini (time-shared STA)
|
||||||
pio run --target upload
|
pio run -e esp8266dev
|
||||||
|
|
||||||
# Upload SPIFFS config (first time only, or after config changes)
|
# Upload to board
|
||||||
pio run --target uploadfs
|
pio run -e esp32dev --target upload
|
||||||
|
pio run -e esp8266dev --target upload
|
||||||
|
|
||||||
|
# Upload SPIFFS/LittleFS config (first time only)
|
||||||
|
pio run -e esp32dev --target uploadfs
|
||||||
|
pio run -e esp8266dev --target uploadfs
|
||||||
|
|
||||||
# Serial monitor
|
# Serial monitor
|
||||||
pio device monitor
|
pio device monitor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Platform Differences
|
||||||
|
|
||||||
|
| Feature | ESP32 | ESP8266 |
|
||||||
|
|---------|-------|---------|
|
||||||
|
| Wi-Fi | Dual-STA (simultaneous) | Single STA (time-shared) |
|
||||||
|
| Poll latency | 0ms (always connected to both) | ~4s per cycle (switch + poll + switch) |
|
||||||
|
| Power draw | ~80mA active | ~70mA active |
|
||||||
|
| Cost | ~$5 | ~$3 |
|
||||||
|
| Build target | `esp32dev` | `esp8266dev` |
|
||||||
|
| Filesystem | SPIFFS | LittleFS |
|
||||||
|
| LED pin | GPIO 2 (active high) | GPIO 2 / LED_BUILTIN (active low) |
|
||||||
|
|
||||||
|
**ESP8266 workflow:** Every 30s, the ESP8266 switches from the travel router to the GoPro AP (~1s), polls the camera (~2s), switches back to the travel router (~1s), then publishes MQTT. This adds ~4s of latency per cycle but is invisible at 30s poll intervals.
|
||||||
|
|
||||||
|
## Camera Compatibility
|
||||||
|
|
||||||
|
| Camera | IP | Protocol | Status |
|
||||||
|
|--------|-----|----------|--------|
|
||||||
|
| GoPro Hero 3 | `10.5.5.1` | HTTP GET `/bacpac/SH` | ✅ Full support |
|
||||||
|
| GoPro Hero 4 | `10.5.5.1` | HTTP GET `/gp/gpControl` | ⚠️ Different API — needs adaptation |
|
||||||
|
| Akaso Brave 4/7/V50 | `192.168.1.1` or `192.168.42.1` | Varies (HTTP or TCP:7878) | 🔬 Needs testing — config `camera_ip` |
|
||||||
|
|
||||||
|
For Akaso or other cameras: set `camera_ip` in config.json. The firmware will attempt the GoPro-style HTTP API at that IP. If the camera uses a different protocol, the `fetchCameraStatus()` and `sendCameraCommand()` functions in `main.cpp` need to be adapted.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The ESP32 stores configuration in SPIFFS (`data/config.json`):
|
The ESP32 stores configuration in SPIFFS (`data/config.json`):
|
||||||
@@ -97,16 +126,6 @@ The ESP32 stores configuration in SPIFFS (`data/config.json`):
|
|||||||
- **Get password:** `GET /bacpac/sd` (no auth, returns plain text)
|
- **Get password:** `GET /bacpac/sd` (no auth, returns plain text)
|
||||||
- **Status blob:** 60 bytes binary — see `parseStatus()` in main.cpp for field offsets
|
- **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
|
## Troubleshooting
|
||||||
|
|
||||||
| Symptom | Check |
|
| Symptom | Check |
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"wifi_password": "",
|
"wifi_password": "",
|
||||||
"camera_ssid": "GOPRO-BP-",
|
"camera_ssid": "GOPRO-BP-",
|
||||||
"camera_password": "goprohero",
|
"camera_password": "goprohero",
|
||||||
|
"camera_ip": "10.5.5.1",
|
||||||
"mqtt_broker": "192.168.4.10",
|
"mqtt_broker": "192.168.4.10",
|
||||||
"mqtt_port": 1883,
|
"mqtt_port": 1883,
|
||||||
"camera_id": "",
|
"camera_id": "",
|
||||||
|
|||||||
+35
-14
@@ -1,11 +1,24 @@
|
|||||||
; RemoteRig — ESP32 Camera Node Firmware
|
; RemoteRig — ESP32 + ESP8266 Camera Node Firmware
|
||||||
; Platform: ESP32 (ESP8266 compatible with minor changes)
|
; PlatformIO project with dual-target support.
|
||||||
; Framework: Arduino
|
|
||||||
;
|
;
|
||||||
; Build: pio run
|
; Build:
|
||||||
; Upload: pio run --target upload
|
; pio run -e esp32dev (ESP32 Dev Board — dual-STA, recommended)
|
||||||
; SPIFFS: pio run --target uploadfs
|
; pio run -e esp8266dev (ESP8266 D1 Mini — time-shared STA)
|
||||||
; Monitor: pio device monitor
|
;
|
||||||
|
; Upload:
|
||||||
|
; pio run -e esp32dev --target upload
|
||||||
|
; pio run -e esp8266dev --target upload
|
||||||
|
;
|
||||||
|
; SPIFFS/LittleFS:
|
||||||
|
; pio run -e esp32dev --target uploadfs
|
||||||
|
; pio run -e esp8266dev --target uploadfs
|
||||||
|
|
||||||
|
[common]
|
||||||
|
lib_deps =
|
||||||
|
knolleary/PubSubClient @ ^2.8
|
||||||
|
bblanchon/ArduinoJson @ ^7.3
|
||||||
|
build_flags =
|
||||||
|
-D CORE_DEBUG_LEVEL=0
|
||||||
|
|
||||||
[env:esp32dev]
|
[env:esp32dev]
|
||||||
platform = espressif32
|
platform = espressif32
|
||||||
@@ -13,11 +26,19 @@ board = esp32dev
|
|||||||
framework = arduino
|
framework = arduino
|
||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
upload_speed = 921600
|
upload_speed = 921600
|
||||||
|
lib_deps = ${common.lib_deps}
|
||||||
lib_deps =
|
build_flags = ${common.build_flags}
|
||||||
knolleary/PubSubClient @ ^2.8
|
|
||||||
bblanchon/ArduinoJson @ ^7.3
|
|
||||||
|
|
||||||
build_flags =
|
|
||||||
-D CORE_DEBUG_LEVEL=0
|
|
||||||
-D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192
|
-D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192
|
||||||
|
|
||||||
|
[env:esp8266dev]
|
||||||
|
platform = espressif8266
|
||||||
|
board = d1_mini
|
||||||
|
framework = arduino
|
||||||
|
monitor_speed = 115200
|
||||||
|
upload_speed = 921600
|
||||||
|
lib_deps = ${common.lib_deps}
|
||||||
|
build_flags = ${common.build_flags}
|
||||||
|
-D PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED
|
||||||
|
-D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192
|
||||||
|
board_build.flash_mode = dio
|
||||||
|
board_build.f_cpu = 160000000L
|
||||||
|
|||||||
+374
-428
@@ -1,337 +1,300 @@
|
|||||||
/**
|
// RemoteRig — ESP32 + ESP8266 Unified Firmware
|
||||||
* RemoteRig — ESP32 Camera Node Firmware
|
// ============================================
|
||||||
* =======================================
|
// Compatible with both ESP32 and ESP8266 via PlatformIO build targets.
|
||||||
* 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).
|
// ESP32: Dual-STA — simultaneously connected to GoPro AP + travel router
|
||||||
*
|
// ESP8266: Time-shared STA — alternates between GoPro AP and travel router
|
||||||
* MQTT Contract: docs/MQTT_CONTRACT.md
|
// (30s GoPro polling → switch to router → MQTT publish → switch back)
|
||||||
* Hardware: hardware/README.md
|
//
|
||||||
* Platform: PlatformIO (esp32dev)
|
// Build: pio run -e esp32dev (ESP32)
|
||||||
*/
|
// pio run -e esp8266dev (ESP8266 / D1 Mini)
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Platform-specific includes and types
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <WiFi.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 <WiFiClient.h>
|
||||||
#include <HTTPClient.h>
|
#include <HTTPClient.h>
|
||||||
#include <PubSubClient.h>
|
#include <PubSubClient.h>
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
#include <SPIFFS.h>
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
#ifdef ESP8266
|
||||||
// Configuration (overridden by SPIFFS /data/config.json)
|
#include <LittleFS.h>
|
||||||
// ────────────────────────────────────────────────────────────
|
#define SPIFFS LittleFS
|
||||||
|
#else
|
||||||
|
#include <SPIFFS.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Configuration
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
struct Config {
|
struct Config {
|
||||||
// Travel router Wi-Fi
|
String wifi_ssid = "RemoteRig";
|
||||||
String wifi_ssid = "RemoteRig";
|
String wifi_password = "";
|
||||||
String wifi_password = "";
|
String camera_ssid = "GOPRO-BP-";
|
||||||
|
|
||||||
// GoPro Hero 3 Wi-Fi AP
|
|
||||||
String camera_ssid = "GOPRO-BP-"; // prefix — auto-discovered
|
|
||||||
String camera_password = "goprohero";
|
String camera_password = "goprohero";
|
||||||
|
String camera_ip = "10.5.5.1";
|
||||||
// MQTT broker (Pi Zero 2 W on travel router)
|
String mqtt_broker = "192.168.4.10";
|
||||||
String mqtt_broker = "192.168.4.10";
|
int mqtt_port = 1883;
|
||||||
int mqtt_port = 1883;
|
String camera_id = "";
|
||||||
|
int poll_sec = 30;
|
||||||
// Assigned by hub on first announce; empty until registered
|
int heartbeat_sec = 60;
|
||||||
String camera_id = "";
|
bool dirty = false;
|
||||||
|
|
||||||
// Polling
|
|
||||||
int poll_interval_sec = 30;
|
|
||||||
int heartbeat_interval_sec = 60;
|
|
||||||
|
|
||||||
// Stored in SPIFFS
|
|
||||||
bool dirty = false;
|
|
||||||
} cfg;
|
} cfg;
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────
|
||||||
// Network clients
|
// Platform-specific Wi-Fi
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
WiFiClient wifiClient; // for HTTP to GoPro
|
WiFiClient goproClient;
|
||||||
WiFiClient mqttWifiClient; // for MQTT via travel router
|
WiFiClient routerClient;
|
||||||
PubSubClient mqtt(mqttWifiClient);
|
PubSubClient mqtt(routerClient);
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
unsigned long bootMs = 0;
|
||||||
// State
|
bool cameraOnline = false;
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
unsigned long lastPollMs = 0;
|
// ESP8266: track which network we're on
|
||||||
unsigned long lastHeartbeatMs = 0;
|
#if !HAS_DUAL_STA
|
||||||
unsigned long lastReconnectMs = 0;
|
enum NetState { NET_ROUTER, NET_CAMERA, NET_SWITCHING };
|
||||||
unsigned long bootMs = 0;
|
NetState netState = NET_ROUTER;
|
||||||
int reconnectDelay = 1; // exponential backoff (seconds)
|
unsigned long lastNetSwitch = 0;
|
||||||
bool goproOnline = false;
|
const unsigned long NET_SWITCH_DELAY = 2000; // 2s to switch networks
|
||||||
|
#endif
|
||||||
|
|
||||||
// Heartbeat sequence
|
const int LED_PIN =
|
||||||
unsigned int heartbeatSeq = 0;
|
#ifdef ESP32
|
||||||
|
2
|
||||||
|
#else
|
||||||
|
LED_BUILTIN // ESP8266 D1 Mini = GPIO 2 (active low!)
|
||||||
|
#endif
|
||||||
|
;
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────
|
||||||
// LED Pin (built-in on most ESP32 dev boards = GPIO 2)
|
// SPIFFS / LittleFS Config
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
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() {
|
bool loadConfig() {
|
||||||
if (!SPIFFS.begin(true)) {
|
#ifdef ESP8266
|
||||||
Serial.println("[CFG] SPIFFS mount failed");
|
if (!LittleFS.begin()) { Serial.println("LittleFS mount failed"); return false; }
|
||||||
return false;
|
#else
|
||||||
}
|
if (!SPIFFS.begin(true)) { Serial.println("SPIFFS mount failed"); return false; }
|
||||||
|
#endif
|
||||||
|
|
||||||
File f = SPIFFS.open("/config.json", "r");
|
File f =
|
||||||
if (!f) {
|
#ifdef ESP8266
|
||||||
Serial.println("[CFG] No /config.json — using defaults");
|
LittleFS.open("/config.json", "r");
|
||||||
return false;
|
#else
|
||||||
}
|
SPIFFS.open("/config.json", "r");
|
||||||
|
#endif
|
||||||
|
if (!f) { Serial.println("No config — using defaults"); return false; }
|
||||||
|
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
DeserializationError err = deserializeJson(doc, f);
|
DeserializationError err = deserializeJson(doc, f);
|
||||||
f.close();
|
f.close();
|
||||||
if (err) {
|
if (err) { Serial.printf("Config parse error: %s\n", err.c_str()); return false; }
|
||||||
Serial.printf("[CFG] JSON parse error: %s\n", err.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.wifi_ssid = doc["wifi_ssid"] | cfg.wifi_ssid;
|
cfg.wifi_ssid = doc["wifi_ssid"] | cfg.wifi_ssid;
|
||||||
cfg.wifi_password = doc["wifi_password"] | cfg.wifi_password;
|
cfg.wifi_password = doc["wifi_password"] | cfg.wifi_password;
|
||||||
cfg.camera_ssid = doc["camera_ssid"] | cfg.camera_ssid;
|
cfg.camera_ssid = doc["camera_ssid"] | cfg.camera_ssid;
|
||||||
cfg.camera_password = doc["camera_password"] | cfg.camera_password;
|
cfg.camera_password = doc["camera_password"] | cfg.camera_password;
|
||||||
cfg.mqtt_broker = doc["mqtt_broker"] | cfg.mqtt_broker;
|
cfg.camera_ip = doc["camera_ip"] | cfg.camera_ip;
|
||||||
cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port;
|
cfg.mqtt_broker = doc["mqtt_broker"] | cfg.mqtt_broker;
|
||||||
cfg.camera_id = doc["camera_id"] | cfg.camera_id;
|
cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port;
|
||||||
cfg.poll_interval_sec = doc["poll_interval_sec"] | cfg.poll_interval_sec;
|
cfg.camera_id = doc["camera_id"] | cfg.camera_id;
|
||||||
cfg.heartbeat_interval_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_interval_sec;
|
cfg.poll_sec = doc["poll_interval_sec"] | cfg.poll_sec;
|
||||||
|
cfg.heartbeat_sec = doc["heartbeat_interval_sec"] | cfg.heartbeat_sec;
|
||||||
|
|
||||||
Serial.println("[CFG] Loaded from /config.json");
|
Serial.println("Config loaded");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool saveConfig() {
|
bool saveConfig() {
|
||||||
File f = SPIFFS.open("/config.json", "w");
|
File f =
|
||||||
|
#ifdef ESP8266
|
||||||
|
LittleFS.open("/config.json", "w");
|
||||||
|
#else
|
||||||
|
SPIFFS.open("/config.json", "w");
|
||||||
|
#endif
|
||||||
if (!f) return false;
|
if (!f) return false;
|
||||||
|
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
doc["wifi_ssid"] = cfg.wifi_ssid;
|
doc["wifi_ssid"] = cfg.wifi_ssid;
|
||||||
doc["wifi_password"] = cfg.wifi_password;
|
doc["wifi_password"] = cfg.wifi_password;
|
||||||
doc["camera_ssid"] = cfg.camera_ssid;
|
doc["camera_ssid"] = cfg.camera_ssid;
|
||||||
doc["camera_password"] = cfg.camera_password;
|
doc["camera_password"] = cfg.camera_password;
|
||||||
doc["mqtt_broker"] = cfg.mqtt_broker;
|
doc["camera_ip"] = cfg.camera_ip;
|
||||||
doc["mqtt_port"] = cfg.mqtt_port;
|
doc["mqtt_broker"] = cfg.mqtt_broker;
|
||||||
doc["camera_id"] = cfg.camera_id;
|
doc["mqtt_port"] = cfg.mqtt_port;
|
||||||
doc["poll_interval_sec"] = cfg.poll_interval_sec;
|
doc["camera_id"] = cfg.camera_id;
|
||||||
doc["heartbeat_interval_sec"] = cfg.heartbeat_interval_sec;
|
doc["poll_interval_sec"] = cfg.poll_sec;
|
||||||
|
doc["heartbeat_interval_sec"] = cfg.heartbeat_sec;
|
||||||
serializeJson(doc, f);
|
serializeJson(doc, f);
|
||||||
f.close();
|
f.close();
|
||||||
Serial.println("[CFG] Saved config");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────
|
||||||
// Wi-Fi — Dual STA (GoPro AP + Travel Router)
|
// Wi-Fi Connection
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
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());
|
|
||||||
|
|
||||||
|
#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;
|
int attempts = 0;
|
||||||
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
while (WiFi.status() != WL_CONNECTED && attempts < 40) {
|
||||||
delay(500);
|
delay(500); Serial.print("."); attempts++;
|
||||||
Serial.print(".");
|
|
||||||
attempts++;
|
|
||||||
}
|
}
|
||||||
|
if (WiFi.status() != WL_CONNECTED) return false;
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
|
||||||
Serial.printf("\n[WIFI] Connected to GoPro AP. IP: %s\n", WiFi.localIP().toString().c_str());
|
Serial.printf("\nRouter: %s\n", WiFi.localIP().toString().c_str());
|
||||||
goproOnline = true;
|
|
||||||
return true;
|
// 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;
|
||||||
Serial.println("\n[WIFI] Failed to connect to GoPro AP");
|
|
||||||
goproOnline = false;
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
void switchToRouter() {
|
||||||
// GoPro Hero 3 HTTP API
|
if (netState == NET_ROUTER) return;
|
||||||
// ═══════════════════════════════════════════════════════════
|
netState = NET_SWITCHING;
|
||||||
|
WiFi.disconnect();
|
||||||
// GoPro AP gateway (always 10.5.5.1 for Hero 3)
|
delay(500);
|
||||||
const char* GOPRO_IP = "10.5.5.1";
|
connectTo(cfg.wifi_ssid, cfg.wifi_password);
|
||||||
|
netState = NET_ROUTER;
|
||||||
/**
|
lastNetSwitch = millis();
|
||||||
* Get the GoPro camera password.
|
Serial.printf("Switched to router: %s\n", WiFi.localIP().toString().c_str());
|
||||||
* 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
void switchToCamera() {
|
||||||
* Fetch the GoPro status blob (60 bytes binary).
|
if (netState == NET_CAMERA) return;
|
||||||
* Returns empty string on failure.
|
netState = NET_SWITCHING;
|
||||||
*/
|
WiFi.disconnect();
|
||||||
String fetchGoProStatus() {
|
delay(500);
|
||||||
String url = String("http://") + GOPRO_IP +
|
connectTo(cfg.camera_ssid, cfg.camera_password);
|
||||||
"/bacpac/SH?t=" + cfg.camera_password + "&p=%01";
|
netState = NET_CAMERA;
|
||||||
HTTPClient http;
|
lastNetSwitch = millis();
|
||||||
http.begin(wifiClient, url);
|
Serial.printf("Switched to camera AP: %s\n", WiFi.localIP().toString().c_str());
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/**
|
// ────────────────────────────────────────────
|
||||||
* Parse the 60-byte GoPro status blob into structured data.
|
// GoPro / Camera HTTP API
|
||||||
* Hero 3 status format (offsets are 0-based):
|
// ────────────────────────────────────────────
|
||||||
* [25-26] video_remaining_sec (uint16 LE)
|
|
||||||
* [29] recording state (0=idle, 1=recording)
|
struct CamStatus {
|
||||||
* [30] mode
|
bool valid = false;
|
||||||
* [31-32] resolution
|
|
||||||
* [33-34] fps
|
|
||||||
* [57] battery_raw (uint8)
|
|
||||||
*/
|
|
||||||
struct GoProStatus {
|
|
||||||
bool valid = false;
|
|
||||||
int video_remaining_sec = 0;
|
int video_remaining_sec = 0;
|
||||||
bool recording = false;
|
bool recording = false;
|
||||||
int mode = 0;
|
|
||||||
int fps = 0;
|
|
||||||
int battery_raw = 0;
|
int battery_raw = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
GoProStatus parseStatus(const String& raw) {
|
CamStatus fetchCameraStatus() {
|
||||||
GoProStatus s;
|
CamStatus s;
|
||||||
if (raw.length() < 58) {
|
|
||||||
return 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();
|
const uint8_t* buf = (const uint8_t*)raw.c_str();
|
||||||
|
s.valid = true;
|
||||||
s.valid = true;
|
|
||||||
s.video_remaining_sec = buf[25] | (buf[26] << 8);
|
s.video_remaining_sec = buf[25] | (buf[26] << 8);
|
||||||
s.recording = (buf[29] == 1);
|
s.recording = (buf[29] == 1);
|
||||||
s.mode = buf[30];
|
|
||||||
s.fps = buf[33] | (buf[34] << 8);
|
|
||||||
s.battery_raw = buf[57];
|
s.battery_raw = buf[57];
|
||||||
|
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool sendGoProCommand(const String& command) {
|
bool sendCameraCommand(const String& cmd) {
|
||||||
String param;
|
String param = (cmd == "start_recording") ? "%01" : "%00";
|
||||||
if (command == "start_recording") {
|
String url = "http://" + cfg.camera_ip +
|
||||||
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;
|
"/bacpac/SH?t=" + cfg.camera_password + "&p=" + param;
|
||||||
|
|
||||||
HTTPClient http;
|
HTTPClient http;
|
||||||
http.begin(wifiClient, url);
|
http.useHTTP10(true);
|
||||||
|
http.begin(goproClient, url);
|
||||||
http.setTimeout(5000);
|
http.setTimeout(5000);
|
||||||
int code = http.GET();
|
int code = http.GET();
|
||||||
http.end();
|
http.end();
|
||||||
|
|
||||||
Serial.printf("[GOPRO] Command %s → HTTP %d\n", command.c_str(), code);
|
|
||||||
return (code == 200);
|
return (code == 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ────────────────────────────────────────────
|
||||||
// MQTT
|
// MQTT
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
String clientID() {
|
String clientID() {
|
||||||
uint8_t mac[6];
|
uint8_t mac[6];
|
||||||
WiFi.macAddress(mac);
|
WiFi.macAddress(mac);
|
||||||
char buf[32];
|
char buf[32];
|
||||||
snprintf(buf, sizeof(buf), "remoterig-%02x%02x%02x", mac[3], mac[4], mac[5]);
|
snprintf(buf, sizeof(buf), "rig-%02x%02x%02x", mac[3], mac[4], mac[5]);
|
||||||
return String(buf);
|
return String(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
String statusTopic() { return "remoterig/cameras/" + cfg.camera_id + "/status"; }
|
String top(const char* t) {
|
||||||
String heartbeatTopic() { return "remoterig/cameras/" + cfg.camera_id + "/heartbeat"; }
|
return "remoterig/cameras/" + cfg.camera_id + "/" + t;
|
||||||
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) {
|
void mqttCallback(char* topic, byte* payload, unsigned int len) {
|
||||||
// Null-terminate payload
|
|
||||||
char buf[256];
|
char buf[256];
|
||||||
unsigned int len = length < 255 ? length : 255;
|
unsigned int n = len < 255 ? len : 255;
|
||||||
memcpy(buf, payload, len);
|
memcpy(buf, payload, n); buf[n] = 0;
|
||||||
buf[len] = 0;
|
|
||||||
|
|
||||||
Serial.printf("[MQTT] ← %s: %s\n", topic, buf);
|
|
||||||
|
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
DeserializationError err = deserializeJson(doc, buf);
|
if (deserializeJson(doc, buf)) return;
|
||||||
if (err) {
|
|
||||||
Serial.printf("[MQTT] JSON parse error: %s\n", err.c_str());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String cmd = doc["command"] | "";
|
String cmd = doc["command"] | "";
|
||||||
if (cmd == "start_recording" || cmd == "stop_recording") {
|
if (cmd == "start_recording" || cmd == "stop_recording") {
|
||||||
sendGoProCommand(cmd);
|
sendCameraCommand(cmd);
|
||||||
} else if (cmd == "reboot") {
|
} else if (cmd == "reboot") {
|
||||||
Serial.println("[MQTT] Reboot command received");
|
|
||||||
ESP.restart();
|
ESP.restart();
|
||||||
} else if (cmd == "registered") {
|
} else if (cmd == "registered") {
|
||||||
// Hub assigned us a camera_id on announce
|
String id = doc["camera_id"] | "";
|
||||||
String newID = doc["camera_id"] | "";
|
if (id.length() > 0 && id != cfg.camera_id) {
|
||||||
if (newID.length() > 0 && newID != cfg.camera_id) {
|
cfg.camera_id = id;
|
||||||
cfg.camera_id = newID;
|
|
||||||
cfg.dirty = true;
|
cfg.dirty = true;
|
||||||
Serial.printf("[MQTT] Registered as %s\n", newID.c_str());
|
mqtt.unsubscribe(top("command").c_str());
|
||||||
// Re-subscribe to our new command topic
|
mqtt.subscribe(top("command").c_str(), 2);
|
||||||
mqtt.unsubscribe(commandTopic().c_str());
|
Serial.printf("Registered as %s\n", id.c_str());
|
||||||
mqtt.subscribe(commandTopic().c_str(), 2);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Serial.printf("[MQTT] Unknown command: %s\n", cmd.c_str());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,227 +303,210 @@ bool connectMQTT() {
|
|||||||
mqtt.setCallback(mqttCallback);
|
mqtt.setCallback(mqttCallback);
|
||||||
mqtt.setKeepAlive(60);
|
mqtt.setKeepAlive(60);
|
||||||
|
|
||||||
Serial.printf("[MQTT] Connecting to %s:%d as %s...\n",
|
if (!mqtt.connect(clientID().c_str())) {
|
||||||
cfg.mqtt_broker.c_str(), cfg.mqtt_port, clientID().c_str());
|
Serial.printf("MQTT fail (state=%d)\n", mqtt.state());
|
||||||
|
return false;
|
||||||
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());
|
mqtt.subscribe(top("command").c_str(), 2);
|
||||||
return false;
|
|
||||||
|
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 publishAnnounce() {
|
void publishStatus(const CamStatus& s) {
|
||||||
|
if (cfg.camera_id.length() == 0) return;
|
||||||
|
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
doc["mac_address"] = WiFi.macAddress();
|
doc["camera_id"] = cfg.camera_id;
|
||||||
doc["firmware_version"] = "0.1.0";
|
doc["timestamp"] = millis();
|
||||||
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["battery_raw"] = s.battery_raw;
|
||||||
doc["video_remaining_sec"] = s.video_remaining_sec;
|
doc["video_remaining_sec"] = s.video_remaining_sec;
|
||||||
doc["recording"] = s.recording;
|
doc["recording"] = s.recording;
|
||||||
doc["online"] = goproOnline;
|
doc["online"] = cameraOnline;
|
||||||
|
|
||||||
if (s.recording) {
|
String payload; serializeJson(doc, payload);
|
||||||
doc["mode"] = "video";
|
mqtt.publish(top("status").c_str(), payload.c_str(), true);
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// Setup
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
void setup() {
|
void setup() {
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
delay(1000);
|
delay(500);
|
||||||
Serial.println("\n\nRemoteRig ESP32 Camera Node v0.1.0");
|
Serial.printf("\n\nRemoteRig v0.2.0 [%s]\n",
|
||||||
Serial.println("===================================");
|
#ifdef ESP32
|
||||||
|
"ESP32"
|
||||||
|
#else
|
||||||
|
"ESP8266"
|
||||||
|
#endif
|
||||||
|
);
|
||||||
bootMs = millis();
|
bootMs = millis();
|
||||||
|
|
||||||
pinMode(LED_PIN, OUTPUT);
|
pinMode(LED_PIN, OUTPUT);
|
||||||
digitalWrite(LED_PIN, LOW);
|
digitalWrite(LED_PIN,
|
||||||
|
#ifdef ESP8266
|
||||||
|
HIGH // ESP8266 LED is active-low
|
||||||
|
#else
|
||||||
|
LOW
|
||||||
|
#endif
|
||||||
|
);
|
||||||
|
|
||||||
// Load config from SPIFFS
|
|
||||||
loadConfig();
|
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
|
#if HAS_DUAL_STA
|
||||||
Serial.printf("[WIFI] Connecting to travel router: %s\n", cfg.wifi_ssid.c_str());
|
connectBoth();
|
||||||
WiFi.begin(cfg.wifi_ssid.c_str(), cfg.wifi_password.c_str());
|
#else
|
||||||
|
connectTo(cfg.wifi_ssid, cfg.wifi_password);
|
||||||
|
netState = NET_ROUTER;
|
||||||
|
#endif
|
||||||
|
|
||||||
int wifiAttempts = 0;
|
connectMQTT();
|
||||||
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
|
// Main Loop
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
unsigned long now = millis();
|
unsigned long now = millis();
|
||||||
|
static unsigned long lastPoll = 0, lastBeat = 0, lastRecon = 0;
|
||||||
|
static int reconDelay = 1;
|
||||||
|
|
||||||
// ── LED heartbeat ──
|
#if HAS_DUAL_STA
|
||||||
static unsigned long lastLedToggle = 0;
|
// ESP32: simple loop — everything concurrent
|
||||||
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) {
|
if (WiFi.status() != WL_CONNECTED) {
|
||||||
setLed(LED_FAST);
|
if (now - lastRecon > 5000) { lastRecon = now; WiFi.reconnect(); }
|
||||||
if (now - lastReconnectMs > 5000) {
|
delay(100); return;
|
||||||
lastReconnectMs = now;
|
}
|
||||||
Serial.println("[WIFI] Reconnecting...");
|
if (!mqtt.connected()) {
|
||||||
WiFi.reconnect();
|
if (now - lastRecon > (unsigned long)(reconDelay * 1000)) {
|
||||||
|
lastRecon = now;
|
||||||
|
if (connectMQTT()) reconDelay = 1;
|
||||||
|
else reconDelay = min(reconDelay * 2, 30);
|
||||||
}
|
}
|
||||||
delay(100);
|
mqtt.loop(); delay(100); return;
|
||||||
return; // skip everything else until Wi-Fi is back
|
}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MQTT reconnection ──
|
// Heartbeat every heartbeat_sec
|
||||||
if (!mqtt.connected()) {
|
if (now - lastBeat > (unsigned long)(cfg.heartbeat_sec * 1000)) {
|
||||||
setLed(LED_SLOW);
|
lastBeat = now;
|
||||||
if (now - lastReconnectMs > (unsigned long)(reconnectDelay * 1000)) {
|
if (cfg.camera_id.length() > 0) {
|
||||||
lastReconnectMs = now;
|
JsonDocument doc;
|
||||||
if (connectMQTT()) {
|
doc["camera_id"] = cfg.camera_id;
|
||||||
reconnectDelay = 1;
|
doc["timestamp"] = millis();
|
||||||
} else {
|
doc["uptime_sec"] = (now - bootMs) / 1000;
|
||||||
reconnectDelay = min(reconnectDelay * 2, 30);
|
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();
|
mqtt.loop();
|
||||||
delay(100);
|
|
||||||
return;
|
publishStatus(s);
|
||||||
|
lastPoll = now;
|
||||||
|
polledThisCycle = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLed(LED_SLOW);
|
if (now - lastBeat > (unsigned long)(cfg.heartbeat_sec * 1000)) {
|
||||||
mqtt.loop();
|
// Already on router from poll — just publish heartbeat
|
||||||
|
if (!mqtt.connected()) {
|
||||||
// ── GoPro reconnection ──
|
connectMQTT();
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Heartbeat (every heartbeat_interval_sec) ──
|
delay(50);
|
||||||
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