From cefb7ef52c4f900d2aa58c2d740940fab01ee6c9 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Thu, 4 Jun 2026 18:33:36 -0400 Subject: [PATCH] firmware: battery calibration + full RGB STAT LED (C6) Battery calibration: - two-point linear cal (bat_raw_min->0%, bat_raw_max->100%) of the GoPro offset-57 raw byte, persisted in SPIFFS config - publish battery_pct in MQTT status only when calibrated (omit otherwise, per MQTT_CONTRACT); OLED shows % when calibrated, raw until then - set_battery_cal MQTT command: explicit {raw_min,raw_max} or capture-current {point:"full"|"empty"} for field calibration RGB STAT LED: - drive D0/D1/D2 (R/G/B) with health colors instead of the single green channel: red=offline, magenta=wifi-but-no-hub, yellow=hub-but-no-camera, green=healthy; blue during boot - RGB_COMMON_ANODE polarity flag; this module is common-anode Verified on hardware: boots, OLED ok, RGB shows correct colors (blue->red on the bench). Co-Authored-By: Claude Opus 4.8 --- firmware/data/esp32-config.json | 4 +- firmware/src/esp32-mqtt-bridge.cpp | 98 +++++++++++++++++++++++++----- 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/firmware/data/esp32-config.json b/firmware/data/esp32-config.json index e9738c6..784dbed 100644 --- a/firmware/data/esp32-config.json +++ b/firmware/data/esp32-config.json @@ -4,5 +4,7 @@ "mqtt_broker": "10.60.1.56", "mqtt_port": 1883, "camera_id": "", - "heartbeat_interval_sec": 60 + "heartbeat_interval_sec": 60, + "bat_raw_min": 0, + "bat_raw_max": 0 } diff --git a/firmware/src/esp32-mqtt-bridge.cpp b/firmware/src/esp32-mqtt-bridge.cpp index 5ae9072..ec6c5b8 100644 --- a/firmware/src/esp32-mqtt-bridge.cpp +++ b/firmware/src/esp32-mqtt-bridge.cpp @@ -45,6 +45,12 @@ struct Config { int mqtt_port = 1883; String camera_id = ""; // assigned by hub int heartbeat_sec = 60; + // Battery calibration: two-point linear map of the GoPro offset-57 + // raw byte → percent. Uncalibrated when max <= min (then we omit + // battery_pct per the MQTT contract). Set via the set_battery_cal + // command and persisted here. + int bat_raw_min = 0; // raw at 0% + int bat_raw_max = 0; // raw at 100% } cfg; bool loadConfig() { @@ -63,6 +69,8 @@ bool loadConfig() { 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; + cfg.bat_raw_min = doc["bat_raw_min"] | cfg.bat_raw_min; + cfg.bat_raw_max = doc["bat_raw_max"] | cfg.bat_raw_max; return true; } @@ -76,11 +84,24 @@ bool saveConfig() { doc["mqtt_port"] = cfg.mqtt_port; doc["camera_id"] = cfg.camera_id; doc["heartbeat_interval_sec"] = cfg.heartbeat_sec; + doc["bat_raw_min"] = cfg.bat_raw_min; + doc["bat_raw_max"] = cfg.bat_raw_max; serializeJson(doc, f); f.close(); return true; } +// Map a raw offset-57 byte to battery percent using the stored +// two-point calibration. Returns -1 when uncalibrated. +int batteryPct(int raw) { + if (cfg.bat_raw_max <= cfg.bat_raw_min) return -1; // uncalibrated + long pct = (long)(raw - cfg.bat_raw_min) * 100 / + (cfg.bat_raw_max - cfg.bat_raw_min); + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + return (int)pct; +} + // ──────────────────────────────────────────── // UART to ESP-01S (HardwareSerial1) // ──────────────────────────────────────────── @@ -92,8 +113,30 @@ bool saveConfig() { #define UART_RX_PIN D7 #define UART_TX_PIN D6 -// Camera-online indicator → green channel of the RGB STAT LED. -#define STAT_LED_PIN D1 +// ──────────────────────────────────────────── +// RGB STAT LED — D0/D1/D2 (red/green/blue) via 220Ω each +// ──────────────────────────────────────────── +// Wiring assumes common cathode (HIGH = on). Set RGB_COMMON_ANODE to +// 1 for a common-anode part (LOW = on). +#define RGB_PIN_R D0 +#define RGB_PIN_G D1 +#define RGB_PIN_B D2 +#define RGB_COMMON_ANODE 1 // this module is common-anode (LOW = on) + +void rgbWrite(bool r, bool g, bool b) { +#if RGB_COMMON_ANODE + digitalWrite(RGB_PIN_R, !r); digitalWrite(RGB_PIN_G, !g); digitalWrite(RGB_PIN_B, !b); +#else + digitalWrite(RGB_PIN_R, r); digitalWrite(RGB_PIN_G, g); digitalWrite(RGB_PIN_B, b); +#endif +} + +void rgbInit() { + pinMode(RGB_PIN_R, OUTPUT); + pinMode(RGB_PIN_G, OUTPUT); + pinMode(RGB_PIN_B, OUTPUT); + rgbWrite(0, 0, 1); // boot = blue +} // ──────────────────────────────────────────── // Status OLED — 1.3" I2C panel on D4(SDA)/D5(SCL) @@ -206,6 +249,20 @@ void mqttCallback(char* topic, byte* payload, unsigned int len) { sendCmdToESP8266(cmd); } else if (cmd == "reboot") { ESP.restart(); + } else if (cmd == "set_battery_cal") { + // Two ways to calibrate: + // explicit: {"raw_min":185,"raw_max":245} + // capture: {"point":"full"|"empty"} → uses the latest raw reading + String point = doc["point"] | ""; + if (point == "full") cfg.bat_raw_max = dispBatteryRaw; + else if (point == "empty") cfg.bat_raw_min = dispBatteryRaw; + else { + cfg.bat_raw_min = doc["raw_min"] | cfg.bat_raw_min; + cfg.bat_raw_max = doc["raw_max"] | cfg.bat_raw_max; + } + saveConfig(); + Serial.printf("[BAT] Calibration set: raw_min=%d raw_max=%d\n", + cfg.bat_raw_min, cfg.bat_raw_max); } else if (cmd == "registered") { String id = doc["camera_id"] | ""; if (id.length() > 0 && id != cfg.camera_id) { @@ -253,9 +310,21 @@ bool connectMQTT() { } // ──────────────────────────────────────────── -// Status screen +// Status screen + LED // ──────────────────────────────────────────── +// Reflect overall health on the RGB STAT LED. +// red = offline (no Wi-Fi) +// magenta = Wi-Fi up, hub (MQTT) unreachable +// yellow = hub up, GoPro unreachable +// green = healthy (hub + camera reachable) +void updateStatusLed() { + if (WiFi.status() != WL_CONNECTED) rgbWrite(1, 0, 0); // red + else if (!mqtt.connected()) rgbWrite(1, 0, 1); // magenta + else if (!cameraOnline) rgbWrite(1, 1, 0); // yellow + else rgbWrite(0, 1, 0); // green +} + void renderStatus() { if (!oledReady) return; oled.clearBuffer(); @@ -278,8 +347,10 @@ void renderStatus() { oled.drawStr(0, 26, "IDLE"); } - // Battery (raw until calibrated) + video remaining (minutes) - snprintf(line, sizeof(line), "BAT %d VID %dm", dispBatteryRaw, dispVideoRemain / 60); + // Battery (% when calibrated, else raw) + video remaining (minutes) + int pct = batteryPct(dispBatteryRaw); + if (pct >= 0) snprintf(line, sizeof(line), "BAT %d%% VID %dm", pct, dispVideoRemain / 60); + else snprintf(line, sizeof(line), "BAT %d VID %dm", dispBatteryRaw, dispVideoRemain / 60); oled.drawStr(0, 38, line); // Uplink to the hub @@ -304,8 +375,7 @@ void setup() { Serial.println("\n[BRIDGE] ESP32 MQTT Bridge v1.0"); bootMs = millis(); - pinMode(STAT_LED_PIN, OUTPUT); // RGB STAT LED — green = camera online - digitalWrite(STAT_LED_PIN, LOW); + rgbInit(); // RGB STAT LED — blue during boot displayInit(); // I2C scan + OLED splash @@ -346,9 +416,9 @@ void loop() { static unsigned long lastBeat = 0, lastRecon = 0; static int reconDelay = 1; - // ── OLED refresh (always — keep the panel live even when offline) ── + // ── OLED + LED refresh (always — keep them live even when offline) ── static unsigned long lastDisp = 0; - if (now - lastDisp > 500) { lastDisp = now; renderStatus(); } + if (now - lastDisp > 500) { lastDisp = now; renderStatus(); updateStatusLed(); } // ── Wi-Fi watchdog ── if (WiFi.status() != WL_CONNECTED) { @@ -381,11 +451,7 @@ void loop() { // Relay camera status to MQTT hub lastStatusMs = now; bool online = doc["online"] | false; - - if (online != cameraOnline) { - cameraOnline = online; - digitalWrite(STAT_LED_PIN, online ? HIGH : LOW); - } + cameraOnline = online; // reflected on the RGB LED by updateStatusLed() // Mirror status onto the OLED fields dispBatteryRaw = doc["battery_raw"] | 0; @@ -400,7 +466,9 @@ void loop() { JsonDocument mqttDoc; mqttDoc["camera_id"] = cfg.camera_id; mqttDoc["timestamp"] = millis(); - mqttDoc["battery_raw"] = doc["battery_raw"] | 0; + mqttDoc["battery_raw"] = dispBatteryRaw; + int pct = batteryPct(dispBatteryRaw); + if (pct >= 0) mqttDoc["battery_pct"] = pct; // omit when uncalibrated mqttDoc["video_remaining_sec"] = doc["video_remaining_sec"] | 0; mqttDoc["recording"] = doc["recording"] | false; mqttDoc["online"] = online;