From e01c8c8c5c24fc472de2603b9f4e00fcf34ba4a8 Mon Sep 17 00:00:00 2001 From: Maximilian Mewes Date: Wed, 25 Feb 2026 09:47:54 +0100 Subject: [PATCH 01/11] Minor refactoring, remove Unknown type, add LiFePO4 --- usermods/Battery/Battery.cpp | 878 ++++++++-------------- usermods/Battery/UMBattery.h | 52 +- usermods/Battery/battery_defaults.h | 44 +- usermods/Battery/types/Lifepo4UMBattery.h | 53 ++ usermods/Battery/types/LionUMBattery.h | 34 +- usermods/Battery/types/LipoUMBattery.h | 60 +- usermods/Battery/types/UnkownUMBattery.h | 39 - 7 files changed, 496 insertions(+), 664 deletions(-) create mode 100644 usermods/Battery/types/Lifepo4UMBattery.h delete mode 100644 usermods/Battery/types/UnkownUMBattery.h diff --git a/usermods/Battery/Battery.cpp b/usermods/Battery/Battery.cpp index 5572f55024..64f56d47fa 100644 --- a/usermods/Battery/Battery.cpp +++ b/usermods/Battery/Battery.cpp @@ -1,9 +1,9 @@ #include "wled.h" #include "battery_defaults.h" #include "UMBattery.h" -#include "types/UnkownUMBattery.h" -#include "types/LionUMBattery.h" #include "types/LipoUMBattery.h" +#include "types/LionUMBattery.h" +#include "types/Lifepo4UMBattery.h" /* * Usermod by Maximilian Mewes @@ -11,46 +11,65 @@ * Created at: 25.12.2022 * If you have any questions, please feel free to contact me. */ -class UsermodBattery : public Usermod -{ +class UsermodBattery : public Usermod { private: - // battery pin can be defined in my_config.h + // --- Hardware --- int8_t batteryPin = USERMOD_BATTERY_MEASUREMENT_PIN; - - UMBattery* bat = new UnkownUMBattery(); + + // --- Battery state --- + UMBattery* bat = new LipoUMBattery(); batteryConfig cfg; + float alpha = USERMOD_BATTERY_AVERAGING_ALPHA; - // Initial delay before first reading to allow voltage stabilization + // --- Timing --- unsigned long initialDelay = USERMOD_BATTERY_INITIAL_DELAY; bool initialDelayComplete = false; bool isFirstVoltageReading = true; - // how often to read the battery voltage unsigned long readingInterval = USERMOD_BATTERY_MEASUREMENT_INTERVAL; unsigned long nextReadTime = 0; - unsigned long lastReadTime = 0; - // between 0 and 1, to control strength of voltage smoothing filter - float alpha = USERMOD_BATTERY_AVERAGING_ALPHA; - // auto shutdown/shutoff/master off feature + // --- Auto-off --- bool autoOffEnabled = USERMOD_BATTERY_AUTO_OFF_ENABLED; uint8_t autoOffThreshold = USERMOD_BATTERY_AUTO_OFF_THRESHOLD; - // low power indicator feature + // --- Low power indicator --- bool lowPowerIndicatorEnabled = USERMOD_BATTERY_LOW_POWER_INDICATOR_ENABLED; uint8_t lowPowerIndicatorPreset = USERMOD_BATTERY_LOW_POWER_INDICATOR_PRESET; uint8_t lowPowerIndicatorThreshold = USERMOD_BATTERY_LOW_POWER_INDICATOR_THRESHOLD; - uint8_t lowPowerIndicatorReactivationThreshold = lowPowerIndicatorThreshold+10; + uint8_t lowPowerIndicatorReactivationThreshold = lowPowerIndicatorThreshold + 10; uint8_t lowPowerIndicatorDuration = USERMOD_BATTERY_LOW_POWER_INDICATOR_DURATION; bool lowPowerIndicationDone = false; - unsigned long lowPowerActivationTime = 0; // used temporary during active time + bool lowPowerIndicatorActive = false; + unsigned long lowPowerActivationTime = 0; uint8_t lastPreset = 0; - // + // --- Charging detection --- + bool charging = false; + uint8_t chargingRiseCount = 0; + float previousVoltage = 0.0f; + static const uint8_t CHARGE_DETECT_THRESHOLD = 3; + static const uint8_t CHARGE_COUNT_MAX = 6; + static constexpr float CHARGE_VOLTAGE_DEADBAND = 0.01f; + + // --- Estimated runtime (requires INA226) --- + #ifdef USERMOD_INA226 + bool estimatedRuntimeEnabled = USERMOD_BATTERY_ESTIMATED_RUNTIME_ENABLED; + uint16_t batteryCapacity = USERMOD_BATTERY_CAPACITY; + int32_t estimatedTimeLeft = -1; + float smoothedEstimate = -1.0f; + static constexpr float ESTIMATE_SMOOTHING = 0.3f; + #endif + + // --- Inter-usermod data exchange --- + float umVoltage = 0.0f; + int8_t umLevel = -1; + + // --- State --- bool initDone = false; bool initializing = true; bool HomeAssistantDiscovery = false; - // strings to reduce flash memory usage (used more than twice) + // --- PROGMEM strings (used more than twice) --- static const char _name[]; static const char _readInterval[]; static const char _enabled[]; @@ -60,97 +79,82 @@ class UsermodBattery : public Usermod static const char _init[]; static const char _haDiscovery[]; - /** - * Helper for rounding floating point values - */ - float dot2round(float x) - { + // ============================================= + // Private helpers + // ============================================= + + float dot2round(float x) { float nx = (int)(x * 100 + .5); return (float)(nx / 100); } - /** - * Helper for converting a string to lowercase - */ - String stringToLower(String str) - { - for(int i = 0; i < str.length(); i++) - if(str[i] >= 'A' && str[i] <= 'Z') - str[i] += 32; - return str; - } - - /** - * Turn off all leds - */ - void turnOff() - { + void turnOff() { bri = 0; stateUpdated(CALL_MODE_DIRECT_CHANGE); } - /** - * Indicate low power by activating a configured preset for a given time and then switching back to the preset that was selected previously - */ - void lowPowerIndicator() - { + void lowPowerIndicator() { if (!lowPowerIndicatorEnabled) return; - if (batteryPin < 0) return; // no measurement + if (batteryPin < 0) return; if (lowPowerIndicationDone && lowPowerIndicatorReactivationThreshold <= bat->getLevel()) lowPowerIndicationDone = false; if (lowPowerIndicatorThreshold <= bat->getLevel()) return; if (lowPowerIndicationDone) return; - if (lowPowerActivationTime <= 1) { + if (!lowPowerIndicatorActive) { + lowPowerIndicatorActive = true; lowPowerActivationTime = millis(); lastPreset = currentPreset; applyPreset(lowPowerIndicatorPreset); } - if (lowPowerActivationTime+(lowPowerIndicatorDuration*1000) <= millis()) { + if (millis() - lowPowerActivationTime >= (unsigned long)lowPowerIndicatorDuration * 1000) { lowPowerIndicationDone = true; - lowPowerActivationTime = 0; + lowPowerIndicatorActive = false; applyPreset(lastPreset); - } + } } - /** - * read the battery voltage in different ways depending on the architecture - */ - float readVoltage() - { + float readVoltage() { #ifdef ARDUINO_ARCH_ESP32 - // use calibrated millivolts analogread on esp32 (150 mV ~ 2450 mV default attentuation) and divide by 1000 to get from milivolts to volts and multiply by voltage multiplier and apply calibration value - return (analogReadMilliVolts(batteryPin) / 1000.0f) * bat->getVoltageMultiplier() + bat->getCalibration(); + return (analogReadMilliVolts(batteryPin) / 1000.0f) * bat->getVoltageMultiplier() + bat->getCalibration(); #else - // use analog read on esp8266 ( 0V ~ 1V no attenuation options) and divide by ADC precision 1023 and multiply by voltage multiplier and apply calibration value return (analogRead(batteryPin) / 1023.0f) * bat->getVoltageMultiplier() + bat->getCalibration(); #endif } + #ifdef USERMOD_INA226 + float getINA226Current() { + um_data_t *data = nullptr; + if (!UsermodManager::getUMData(&data, USERMOD_ID_INA226) || !data) return -1.0f; + return *(float*)data->u_data[0]; + } + #endif + #ifndef WLED_DISABLE_MQTT - void addMqttSensor(const String &name, const String &type, const String &topic, const String &deviceClass, const String &unitOfMeasurement = "", const bool &isDiagnostic = false) - { + void addMqttSensor(const String &name, const String &type, const String &topic, const String &deviceClass, const String &unitOfMeasurement = "", const bool &isDiagnostic = false) { StaticJsonDocument<600> doc; char uid[128], json_str[1024], buf[128]; doc[F("name")] = name; doc[F("stat_t")] = topic; - sprintf_P(uid, PSTR("%s_%s_%s"), escapedMac.c_str(), stringToLower(name).c_str(), type); + String nameLower = name; + nameLower.toLowerCase(); + sprintf_P(uid, PSTR("%s_%s_%s"), escapedMac.c_str(), nameLower.c_str(), type); doc[F("uniq_id")] = uid; doc[F("dev_cla")] = deviceClass; doc[F("exp_aft")] = 1800; - if(type == "binary_sensor") { + if (type == "binary_sensor") { doc[F("pl_on")] = "on"; doc[F("pl_off")] = "off"; } - if(unitOfMeasurement != "") + if (unitOfMeasurement != "") doc[F("unit_of_measurement")] = unitOfMeasurement; - if(isDiagnostic) + if (isDiagnostic) doc[F("entity_category")] = "diagnostic"; - JsonObject device = doc.createNestedObject(F("device")); // attach the sensor to the same device + JsonObject device = doc.createNestedObject(F("device")); device[F("name")] = serverDescription; device[F("ids")] = String(F("wled-sensor-")) + mqttClientID; device[F("mf")] = F(WLED_BRAND); @@ -165,8 +169,7 @@ class UsermodBattery : public Usermod mqtt->publish(buf, 0, true, json_str, payload_size); } - void publishMqtt(const char* topic, const char* state) - { + void publishMqtt(const char* topic, const char* state) { if (WLED_MQTT_CONNECTED) { char buf[128]; snprintf_P(buf, 127, PSTR("%s/%s"), mqttDeviceTopic, topic); @@ -176,28 +179,28 @@ class UsermodBattery : public Usermod #endif public: - //Functions called by WLED - - /** - * setup() is called once at boot. WiFi is not yet connected at this point. - * You can use it to initialize variables, sensors or similar. - */ - void setup() - { - // plug in the right battery type - if(cfg.type == (batteryType)lipo) { - bat = new LipoUMBattery(); - } else if(cfg.type == (batteryType)lion) { - bat = new LionUMBattery(); + // ============================================= + // Factory & Lifecycle + // ============================================= + + static UMBattery* createBattery(batteryType type) { + switch (type) { + case lion: return new LionUMBattery(); + case lifepo4: return new Lifepo4UMBattery(); + case lipo: + default: return new LipoUMBattery(); } + } - // update the choosen battery type with configured values + void setup() { + delete bat; + bat = createBattery(cfg.type); bat->update(cfg); #ifdef ARDUINO_ARCH_ESP32 bool success = false; DEBUG_PRINTLN(F("Allocating battery pin...")); - if (batteryPin >= 0 && digitalPinToAnalogChannel(batteryPin) >= 0) + if (batteryPin >= 0 && digitalPinToAnalogChannel(batteryPin) >= 0) if (PinManager::allocatePin(batteryPin, false, PinOwner::UM_Battery)) { DEBUG_PRINTLN(F("Battery pin allocation succeeded.")); success = true; @@ -205,97 +208,139 @@ class UsermodBattery : public Usermod if (!success) { DEBUG_PRINTLN(F("Battery pin allocation failed.")); - batteryPin = -1; // allocation failed + batteryPin = -1; } else { pinMode(batteryPin, INPUT); } - #else //ESP8266 boards have only one analog input pin A0 + #else // ESP8266 boards have only one analog input pin A0 pinMode(batteryPin, INPUT); #endif - // First voltage reading is delayed to allow voltage stabilization after powering up nextReadTime = millis() + initialDelay; - lastReadTime = millis(); - - initDone = true; - } + // expose battery data for other usermods via getUMData() + if (!um_data) { + um_data = new um_data_t; + um_data->u_size = 4; + um_data->u_type = new um_types_t[4]; + um_data->u_data = new void*[4]; + um_data->u_data[0] = &umVoltage; // float, Volts + um_data->u_type[0] = UMT_FLOAT; + um_data->u_data[1] = &umLevel; // int8_t, 0-100% + um_data->u_type[1] = UMT_BYTE; + um_data->u_data[2] = &charging; // bool + um_data->u_type[2] = UMT_BYTE; + um_data->u_data[3] = &cfg.type; // batteryType enum (1=lipo,2=lion,3=lifepo4) + um_data->u_type[3] = UMT_BYTE; + } - /** - * connected() is called every time the WiFi is (re)connected - * Use it to initialize network interfaces - */ - void connected() - { - //Serial.println("Connected to WiFi!"); + initDone = true; } - - /* - * loop() is called continuously. Here you can check for events, read sensors, etc. - * - */ - void loop() - { - if(strip.isUpdating()) return; + void loop() { + if (strip.isUpdating()) return; lowPowerIndicator(); - // Handling the initial delay - if (!initialDelayComplete && millis() < nextReadTime) - return; // Continue to return until the initial delay is over + if (!initialDelayComplete && millis() < nextReadTime) return; - // Once the initial delay is over, set it as complete - if (!initialDelayComplete) - { - initialDelayComplete = true; - // Set the regular interval after initial delay - nextReadTime = millis() + readingInterval; - } + if (!initialDelayComplete) { + initialDelayComplete = true; + nextReadTime = millis() + readingInterval; + } - // Make the first voltage reading after the initial delay has elapsed - if (isFirstVoltageReading) - { - bat->setVoltage(readVoltage()); - isFirstVoltageReading = false; - } + if (isFirstVoltageReading) { + bat->setVoltage(readVoltage()); + isFirstVoltageReading = false; + } - // check the battery level every USERMOD_BATTERY_MEASUREMENT_INTERVAL (ms) if (millis() < nextReadTime) return; - nextReadTime = millis() + readingInterval; - lastReadTime = millis(); - if (batteryPin < 0) return; // nothing to read + if (batteryPin < 0) return; initializing = false; float rawValue = readVoltage(); - // filter with exponential smoothing because ADC in esp32 is fluctuating too much for a good single readout + // exponential smoothing — ADC in ESP32 fluctuates too much for single readout float filteredVoltage = bat->getVoltage() + alpha * (rawValue - bat->getVoltage()); bat->setVoltage(filteredVoltage); - // translate battery voltage into percentage bat->calculateAndSetLevel(filteredVoltage); - // Auto off -- Master power off + umVoltage = filteredVoltage; + umLevel = bat->getLevel(); + + // charging detection based on voltage trend + if (previousVoltage > 0.0f) { + if (filteredVoltage > previousVoltage + CHARGE_VOLTAGE_DEADBAND) { + chargingRiseCount = min((uint8_t)(chargingRiseCount + 1), CHARGE_COUNT_MAX); + } else if (filteredVoltage < previousVoltage - CHARGE_VOLTAGE_DEADBAND) { + if (chargingRiseCount > 0) chargingRiseCount--; + } + charging = (chargingRiseCount >= CHARGE_DETECT_THRESHOLD); + } + previousVoltage = filteredVoltage; + + // estimated runtime via INA226 current sensor + #ifdef USERMOD_INA226 + if (estimatedRuntimeEnabled) { + float current_A = getINA226Current(); + if (!charging && current_A > 0.01f && batteryCapacity > 0 && bat->getLevel() > 0) { + float remaining_Ah = (bat->getLevel() / 100.0f) * (batteryCapacity / 1000.0f); + float rawEstimate = min(remaining_Ah / current_A * 60.0f, 14400.0f); + + if (smoothedEstimate < 0.0f) { + smoothedEstimate = rawEstimate; + } else { + smoothedEstimate = ESTIMATE_SMOOTHING * rawEstimate + (1.0f - ESTIMATE_SMOOTHING) * smoothedEstimate; + } + estimatedTimeLeft = (int32_t)smoothedEstimate; + } else { + estimatedTimeLeft = -1; + smoothedEstimate = -1.0f; + } + } + #endif + + // auto-off if (autoOffEnabled && (autoOffThreshold >= bat->getLevel())) turnOff(); #ifndef WLED_DISABLE_MQTT publishMqtt("battery", String(bat->getLevel(), 0).c_str()); publishMqtt("voltage", String(bat->getVoltage()).c_str()); + publishMqtt("charging", charging ? "on" : "off"); + #ifdef USERMOD_INA226 + if (estimatedRuntimeEnabled && estimatedTimeLeft >= 0) { + publishMqtt("runtime", String(estimatedTimeLeft).c_str()); + } + #endif #endif + } + uint16_t getId() { + return USERMOD_ID_BATTERY; } - /** - * addToJsonInfo() can be used to add custom entries to the /json/info part of the JSON API. - * Creating an "u" object allows you to add custom key/value pairs to the Info section of the WLED web UI. - * Below it is shown how this could be used for e.g. a light sensor + /* + * Battery data exposed to other usermods via getUMData(): + * slot 0: voltage (float, Volts) + * slot 1: level (int8_t, 0-100%) + * slot 2: charging (bool) + * slot 3: type (uint8_t, 1=lipo,2=lion,3=lifepo4) */ - void addToJsonInfo(JsonObject& root) - { + bool getUMData(um_data_t **data) override { + if (!data || !initDone) return false; + *data = um_data; + return true; + } + + // ============================================= + // JSON Info & State + // ============================================= + + void addToJsonInfo(JsonObject& root) { JsonObject user = root["u"]; if (user.isNull()) user = root.createNestedObject("u"); @@ -303,17 +348,17 @@ class UsermodBattery : public Usermod JsonArray infoVoltage = user.createNestedArray(F("Battery voltage")); infoVoltage.add(F("n/a")); infoVoltage.add(F(" invalid GPIO")); - return; // no GPIO - nothing to report + return; } - // info modal display names JsonArray infoPercentage = user.createNestedArray(F("Battery level")); JsonArray infoVoltage = user.createNestedArray(F("Battery voltage")); + JsonArray infoCharging = user.createNestedArray(F("Battery charging")); JsonArray infoNextUpdate = user.createNestedArray(F("Next update")); infoNextUpdate.add((nextReadTime - millis()) / 1000); infoNextUpdate.add(F(" sec")); - + if (initializing) { infoPercentage.add(FPSTR(_init)); infoVoltage.add(FPSTR(_init)); @@ -333,516 +378,223 @@ class UsermodBattery : public Usermod infoVoltage.add(dot2round(bat->getVoltage())); } infoVoltage.add(F(" V")); + + infoCharging.add(charging ? F("Yes") : F("No")); + + #ifdef USERMOD_INA226 + if (estimatedRuntimeEnabled) { + JsonArray infoRuntime = user.createNestedArray(F("Est. runtime")); + if (charging) { + infoRuntime.add(F("charging")); + } else if (estimatedTimeLeft < 0) { + infoRuntime.add(F("calculating")); + } else if (estimatedTimeLeft < 60) { + infoRuntime.add(estimatedTimeLeft); + infoRuntime.add(F(" min")); + } else { + char buf[16]; + snprintf_P(buf, sizeof(buf), PSTR("%dh %dm"), estimatedTimeLeft / 60, estimatedTimeLeft % 60); + infoRuntime.add(buf); + } + } + #endif + } + + void addToJsonState(JsonObject& root) { + JsonObject battery = root.createNestedObject(FPSTR(_name)); + addBatteryToJsonObject(battery, true); + DEBUG_PRINTLN(F("Battery state exposed in JSON API.")); } - void addBatteryToJsonObject(JsonObject& battery, bool forJsonState) - { - if(forJsonState) { battery[F("type")] = cfg.type; } else {battery[F("type")] = (String)cfg.type; } // has to be a String otherwise it won't get converted to a Dropdown + void addBatteryToJsonObject(JsonObject& battery, bool forJsonState) { + if (forJsonState) { + battery[F("type")] = cfg.type; + battery[F("charging")] = charging; + #ifdef USERMOD_INA226 + if (estimatedRuntimeEnabled) { + battery[F("estimated-runtime")] = estimatedTimeLeft; + } + #endif + } else { + battery[F("type")] = (String)cfg.type; + } battery[F("min-voltage")] = bat->getMinVoltage(); battery[F("max-voltage")] = bat->getMaxVoltage(); battery[F("calibration")] = bat->getCalibration(); battery[F("voltage-multiplier")] = bat->getVoltageMultiplier(); + #ifdef USERMOD_INA226 + battery[F("capacity")] = batteryCapacity; + battery[F("estimated-runtime-enabled")] = estimatedRuntimeEnabled; + #endif battery[FPSTR(_readInterval)] = readingInterval; battery[FPSTR(_haDiscovery)] = HomeAssistantDiscovery; - JsonObject ao = battery.createNestedObject(F("auto-off")); // auto off section + JsonObject ao = battery.createNestedObject(F("auto-off")); ao[FPSTR(_enabled)] = autoOffEnabled; ao[FPSTR(_threshold)] = autoOffThreshold; - JsonObject lp = battery.createNestedObject(F("indicator")); // low power section + JsonObject lp = battery.createNestedObject(F("indicator")); lp[FPSTR(_enabled)] = lowPowerIndicatorEnabled; - lp[FPSTR(_preset)] = lowPowerIndicatorPreset; // dropdown trickery (String)lowPowerIndicatorPreset; + lp[FPSTR(_preset)] = lowPowerIndicatorPreset; lp[FPSTR(_threshold)] = lowPowerIndicatorThreshold; lp[FPSTR(_duration)] = lowPowerIndicatorDuration; } - void getUsermodConfigFromJsonObject(JsonObject& battery) - { - getJsonValue(battery[F("type")], cfg.type); - getJsonValue(battery[F("min-voltage")], cfg.minVoltage); - getJsonValue(battery[F("max-voltage")], cfg.maxVoltage); - getJsonValue(battery[F("calibration")], cfg.calibration); - getJsonValue(battery[F("voltage-multiplier")], cfg.voltageMultiplier); - setReadingInterval(battery[FPSTR(_readInterval)] | readingInterval); - setHomeAssistantDiscovery(battery[FPSTR(_haDiscovery)] | HomeAssistantDiscovery); - - JsonObject ao = battery[F("auto-off")]; - setAutoOffEnabled(ao[FPSTR(_enabled)] | autoOffEnabled); - setAutoOffThreshold(ao[FPSTR(_threshold)] | autoOffThreshold); + // ============================================= + // Configuration + // ============================================= - JsonObject lp = battery[F("indicator")]; - setLowPowerIndicatorEnabled(lp[FPSTR(_enabled)] | lowPowerIndicatorEnabled); - setLowPowerIndicatorPreset(lp[FPSTR(_preset)] | lowPowerIndicatorPreset); - setLowPowerIndicatorThreshold(lp[FPSTR(_threshold)] | lowPowerIndicatorThreshold); - lowPowerIndicatorReactivationThreshold = lowPowerIndicatorThreshold+10; - setLowPowerIndicatorDuration(lp[FPSTR(_duration)] | lowPowerIndicatorDuration); - - if(initDone) - bat->update(cfg); - } - - /** - * addToJsonState() can be used to add custom entries to the /json/state part of the JSON API (state object). - * Values in the state object may be modified by connected clients - */ - void addToJsonState(JsonObject& root) - { + void addToConfig(JsonObject& root) { JsonObject battery = root.createNestedObject(FPSTR(_name)); - if (battery.isNull()) - battery = root.createNestedObject(FPSTR(_name)); - - addBatteryToJsonObject(battery, true); - - DEBUG_PRINTLN(F("Battery state exposed in JSON API.")); - } - - - /** - * readFromJsonState() can be used to receive data clients send to the /json/state part of the JSON API (state object). - * Values in the state object may be modified by connected clients - */ - /* - void readFromJsonState(JsonObject& root) - { - if (!initDone) return; // prevent crash on boot applyPreset() - - JsonObject battery = root[FPSTR(_name)]; - - if (!battery.isNull()) { - getUsermodConfigFromJsonObject(battery); - - DEBUG_PRINTLN(F("Battery state read from JSON API.")); - } - } - */ - - - /** - * addToConfig() can be used to add custom persistent settings to the cfg.json file in the "um" (usermod) object. - * It will be called by WLED when settings are actually saved (for example, LED settings are saved) - * If you want to force saving the current state, use serializeConfig() in your loop(). - * - * CAUTION: serializeConfig() will initiate a filesystem write operation. - * It might cause the LEDs to stutter and will cause flash wear if called too often. - * Use it sparingly and always in the loop, never in network callbacks! - * - * addToConfig() will make your settings editable through the Usermod Settings page automatically. - * - * Usermod Settings Overview: - * - Numeric values are treated as floats in the browser. - * - If the numeric value entered into the browser contains a decimal point, it will be parsed as a C float - * before being returned to the Usermod. The float data type has only 6-7 decimal digits of precision, and - * doubles are not supported, numbers will be rounded to the nearest float value when being parsed. - * The range accepted by the input field is +/- 1.175494351e-38 to +/- 3.402823466e+38. - * - If the numeric value entered into the browser doesn't contain a decimal point, it will be parsed as a - * C int32_t (range: -2147483648 to 2147483647) before being returned to the usermod. - * Overflows or underflows are truncated to the max/min value for an int32_t, and again truncated to the type - * used in the Usermod when reading the value from ArduinoJson. - * - Pin values can be treated differently from an integer value by using the key name "pin" - * - "pin" can contain a single or array of integer values - * - On the Usermod Settings page there is simple checking for pin conflicts and warnings for special pins - * - Red color indicates a conflict. Yellow color indicates a pin with a warning (e.g. an input-only pin) - * - Tip: use int8_t to store the pin value in the Usermod, so a -1 value (pin not set) can be used - * - * See usermod_v2_auto_save.h for an example that saves Flash space by reusing ArduinoJson key name strings - * - * If you need a dedicated settings page with custom layout for your Usermod, that takes a lot more work. - * You will have to add the setting to the HTML, xml.cpp and set.cpp manually. - * See the WLED Soundreactive fork (code and wiki) for reference. https://github.com/atuline/WLED - * - * I highly recommend checking out the basics of ArduinoJson serialization and deserialization in order to use custom settings! - */ - void addToConfig(JsonObject& root) - { - JsonObject battery = root.createNestedObject(FPSTR(_name)); - - if (battery.isNull()) { - battery = root.createNestedObject(FPSTR(_name)); - } - #ifdef ARDUINO_ARCH_ESP32 battery[F("pin")] = batteryPin; #endif - + addBatteryToJsonObject(battery, false); - // read voltage in case calibration or voltage multiplier changed to see immediate effect + // re-read voltage in case calibration or multiplier changed bat->setVoltage(readVoltage()); DEBUG_PRINTLN(F("Battery config saved.")); } - void appendConfigData() - { - // Total: 462 Bytes + void appendConfigData() { oappend(F("td=addDropdown('Battery','type');")); // 34 Bytes - oappend(F("addOption(td,'Unkown','0');")); // 28 Bytes oappend(F("addOption(td,'LiPo','1');")); // 26 Bytes oappend(F("addOption(td,'LiOn','2');")); // 26 Bytes + oappend(F("addOption(td,'LiFePO4','3');")); // 30 Bytes oappend(F("addInfo('Battery:type',1,'requires reboot');")); // 81 Bytes oappend(F("addInfo('Battery:min-voltage',1,'v');")); // 38 Bytes oappend(F("addInfo('Battery:max-voltage',1,'v');")); // 38 Bytes + #ifdef USERMOD_INA226 + oappend(F("addInfo('Battery:capacity',1,'mAh');")); // 37 Bytes + oappend(F("addInfo('Battery:estimated-runtime-enabled',1,'');")); // 51 Bytes + #endif oappend(F("addInfo('Battery:interval',1,'ms');")); // 36 Bytes oappend(F("addInfo('Battery:HA-discovery',1,'');")); // 38 Bytes oappend(F("addInfo('Battery:auto-off:threshold',1,'%');")); // 45 Bytes oappend(F("addInfo('Battery:indicator:threshold',1,'%');")); // 46 Bytes oappend(F("addInfo('Battery:indicator:duration',1,'s');")); // 45 Bytes - - // this option list would exeed the oappend() buffer - // a list of all presets to select one from - // oappend(F("bd=addDropdown('Battery:low-power-indicator', 'preset');")); - // the loop generates: oappend(F("addOption(bd, 'preset name', preset id);")); - // for(int8_t i=1; i < 42; i++) { - // oappend(F("addOption(bd, 'Preset#")); - // oappendi(i); - // oappend(F("',")); - // oappendi(i); - // oappend(F(");")); - // } } - - /** - * readFromConfig() can be used to read back the custom settings you added with addToConfig(). - * This is called by WLED when settings are loaded (currently this only happens immediately after boot, or after saving on the Usermod Settings page) - * - * readFromConfig() is called BEFORE setup(). This means you can use your persistent values in setup() (e.g. pin assignments, buffer sizes), - * but also that if you want to write persistent values to a dynamic buffer, you'd need to allocate it here instead of in setup. - * If you don't know what that is, don't fret. It most likely doesn't affect your use case :) - * - * Return true in case the config values returned from Usermod Settings were complete, or false if you'd like WLED to save your defaults to disk (so any missing values are editable in Usermod Settings) - * - * getJsonValue() returns false if the value is missing, or copies the value into the variable provided and returns true if the value is present - * The configComplete variable is true only if the "exampleUsermod" object and all values are present. If any values are missing, WLED will know to call addToConfig() to save them - * - * This function is guaranteed to be called on boot, but could also be called every time settings are updated - */ - bool readFromConfig(JsonObject& root) - { + // Called BEFORE setup() on boot and on settings save + bool readFromConfig(JsonObject& root) { #ifdef ARDUINO_ARCH_ESP32 int8_t newBatteryPin = batteryPin; #endif - + JsonObject battery = root[FPSTR(_name)]; - if (battery.isNull()) - { + if (battery.isNull()) { DEBUG_PRINT(FPSTR(_name)); DEBUG_PRINTLN(F(": No config found. (Using defaults.)")); return false; } #ifdef ARDUINO_ARCH_ESP32 - newBatteryPin = battery[F("pin")] | newBatteryPin; + newBatteryPin = battery[F("pin")] | newBatteryPin; + #endif + + // read directly into bat (handles zero values correctly, unlike bat->update) + bat->setMinVoltage(battery[F("min-voltage")] | bat->getMinVoltage()); + bat->setMaxVoltage(battery[F("max-voltage")] | bat->getMaxVoltage()); + bat->setCalibration(battery[F("calibration")] | bat->getCalibration()); + bat->setVoltageMultiplier(battery[F("voltage-multiplier")] | bat->getVoltageMultiplier()); + + // read into cfg struct for bat->update() + getJsonValue(battery[F("type")], cfg.type); + getJsonValue(battery[F("min-voltage")], cfg.minVoltage); + getJsonValue(battery[F("max-voltage")], cfg.maxVoltage); + getJsonValue(battery[F("calibration")], cfg.calibration); + getJsonValue(battery[F("voltage-multiplier")], cfg.voltageMultiplier); + + #ifdef USERMOD_INA226 + batteryCapacity = battery[F("capacity")] | batteryCapacity; + estimatedRuntimeEnabled = battery[F("estimated-runtime-enabled")] | estimatedRuntimeEnabled; #endif - setMinBatteryVoltage(battery[F("min-voltage")] | bat->getMinVoltage()); - setMaxBatteryVoltage(battery[F("max-voltage")] | bat->getMaxVoltage()); - setCalibration(battery[F("calibration")] | bat->getCalibration()); - setVoltageMultiplier(battery[F("voltage-multiplier")] | bat->getVoltageMultiplier()); + setReadingInterval(battery[FPSTR(_readInterval)] | readingInterval); - setHomeAssistantDiscovery(battery[FPSTR(_haDiscovery)] | HomeAssistantDiscovery); + HomeAssistantDiscovery = battery[FPSTR(_haDiscovery)] | HomeAssistantDiscovery; - getUsermodConfigFromJsonObject(battery); + JsonObject ao = battery[F("auto-off")]; + autoOffEnabled = ao[FPSTR(_enabled)] | autoOffEnabled; + setAutoOffThreshold(ao[FPSTR(_threshold)] | autoOffThreshold); + + JsonObject lp = battery[F("indicator")]; + lowPowerIndicatorEnabled = lp[FPSTR(_enabled)] | lowPowerIndicatorEnabled; + lowPowerIndicatorPreset = lp[FPSTR(_preset)] | lowPowerIndicatorPreset; + setLowPowerIndicatorThreshold(lp[FPSTR(_threshold)] | lowPowerIndicatorThreshold); + lowPowerIndicatorReactivationThreshold = lowPowerIndicatorThreshold + 10; + lowPowerIndicatorDuration = lp[FPSTR(_duration)] | lowPowerIndicatorDuration; + + if (initDone) + bat->update(cfg); #ifdef ARDUINO_ARCH_ESP32 - if (!initDone) - { - // first run: reading from cfg.json + if (!initDone) { batteryPin = newBatteryPin; DEBUG_PRINTLN(F(" config loaded.")); - } - else - { + } else { DEBUG_PRINTLN(F(" config (re)loaded.")); - - // changing parameters from settings page - if (newBatteryPin != batteryPin) - { - // deallocate pin + if (newBatteryPin != batteryPin) { PinManager::deallocatePin(batteryPin, PinOwner::UM_Battery); batteryPin = newBatteryPin; - // initialise setup(); } } #endif - return !battery[FPSTR(_readInterval)].isNull(); - } - -#ifndef WLED_DISABLE_MQTT - void onMqttConnect(bool sessionPresent) - { - // Home Assistant Autodiscovery - if (!HomeAssistantDiscovery) - return; - - // battery percentage - char mqttBatteryTopic[128]; - snprintf_P(mqttBatteryTopic, 127, PSTR("%s/battery"), mqttDeviceTopic); - this->addMqttSensor(F("Battery"), "sensor", mqttBatteryTopic, "battery", "%", true); - - // voltage - char mqttVoltageTopic[128]; - snprintf_P(mqttVoltageTopic, 127, PSTR("%s/voltage"), mqttDeviceTopic); - this->addMqttSensor(F("Voltage"), "sensor", mqttVoltageTopic, "voltage", "V", true); + bool configComplete = !battery[FPSTR(_readInterval)].isNull() + && !battery[F("type")].isNull() + && !battery[F("auto-off")].isNull() + && !battery[F("indicator")].isNull(); + #ifdef USERMOD_INA226 + configComplete = configComplete && !battery[F("estimated-runtime-enabled")].isNull(); + #endif + return configComplete; } -#endif - /* - * - * Getter and Setter. Just in case some other usermod wants to interact with this in the future - * - */ + // ============================================= + // MQTT + // ============================================= - /** - * getId() allows you to optionally give your V2 usermod an unique ID (please define it in const.h!). - * This could be used in the future for the system to determine whether your usermod is installed. - */ - uint16_t getId() - { - return USERMOD_ID_BATTERY; +#ifndef WLED_DISABLE_MQTT + void onMqttConnect(bool sessionPresent) { + if (!HomeAssistantDiscovery) return; + + registerMqttSensor("battery", F("Battery"), "sensor", "battery", "%"); + registerMqttSensor("voltage", F("Voltage"), "sensor", "voltage", "V"); + registerMqttSensor("charging", F("Charging"), "binary_sensor", "battery_charging"); + #ifdef USERMOD_INA226 + if (estimatedRuntimeEnabled) { + registerMqttSensor("runtime", F("Runtime"), "sensor", "duration", "min"); + } + #endif } - /** - * get currently active battery type - */ - batteryType getBatteryType() - { - return cfg.type; + void registerMqttSensor(const char* subtopic, const String &name, const char* type, const char* deviceClass, const char* unit = "", bool diagnostic = true) { + char topic[128]; + snprintf_P(topic, 127, PSTR("%s/%s"), mqttDeviceTopic, subtopic); + this->addMqttSensor(name, type, topic, deviceClass, unit, diagnostic); } +#endif - /** - * - */ - unsigned long getReadingInterval() - { - return readingInterval; - } + // ============================================= + // Validated setters + // ============================================= - /** - * minimum repetition is 3000ms (3s) - */ - void setReadingInterval(unsigned long newReadingInterval) - { + void setReadingInterval(unsigned long newReadingInterval) { readingInterval = max((unsigned long)3000, newReadingInterval); } - /** - * Get lowest configured battery voltage - */ - float getMinBatteryVoltage() - { - return bat->getMinVoltage(); - } - - /** - * Set lowest battery voltage - * can't be below 0 volt - */ - void setMinBatteryVoltage(float voltage) - { - bat->setMinVoltage(voltage); - } - - /** - * Get highest configured battery voltage - */ - float getMaxBatteryVoltage() - { - return bat->getMaxVoltage(); - } - - /** - * Set highest battery voltage - * can't be below minBatteryVoltage - */ - void setMaxBatteryVoltage(float voltage) - { - bat->setMaxVoltage(voltage); - } - - - /** - * Get the calculated voltage - * formula: (adc pin value / adc precision * max voltage) + calibration - */ - float getVoltage() - { - return bat->getVoltage(); - } - - /** - * Get the mapped battery level (0 - 100) based on voltage - * important: voltage can drop when a load is applied, so its only an estimate - */ - int8_t getBatteryLevel() - { - return bat->getLevel(); - } - - /** - * Get the configured calibration value - * a offset value to fine-tune the calculated voltage. - */ - float getCalibration() - { - return bat->getCalibration(); - } - - /** - * Set the voltage calibration offset value - * a offset value to fine-tune the calculated voltage. - */ - void setCalibration(float offset) - { - bat->setCalibration(offset); - } - - /** - * Set the voltage multiplier value - * A multiplier that may need adjusting for different voltage divider setups - */ - void setVoltageMultiplier(float multiplier) - { - bat->setVoltageMultiplier(multiplier); - } - - /* - * Get the voltage multiplier value - * A multiplier that may need adjusting for different voltage divider setups - */ - float getVoltageMultiplier() - { - return bat->getVoltageMultiplier(); - } - - /** - * Get auto-off feature enabled status - * is auto-off enabled, true/false - */ - bool getAutoOffEnabled() - { - return autoOffEnabled; - } - - /** - * Set auto-off feature status - */ - void setAutoOffEnabled(bool enabled) - { - autoOffEnabled = enabled; - } - - /** - * Get auto-off threshold in percent (0-100) - */ - int8_t getAutoOffThreshold() - { - return autoOffThreshold; - } - - /** - * Set auto-off threshold in percent (0-100) - */ - void setAutoOffThreshold(int8_t threshold) - { + void setAutoOffThreshold(int8_t threshold) { autoOffThreshold = min((int8_t)100, max((int8_t)0, threshold)); - // when low power indicator is enabled the auto-off threshold cannot be above indicator threshold - autoOffThreshold = lowPowerIndicatorEnabled /*&& autoOffEnabled*/ ? min(lowPowerIndicatorThreshold-1, (int)autoOffThreshold) : autoOffThreshold; - } - - /** - * Get low-power-indicator feature enabled status - * is the low-power-indicator enabled, true/false - */ - bool getLowPowerIndicatorEnabled() - { - return lowPowerIndicatorEnabled; - } - - /** - * Set low-power-indicator feature status - */ - void setLowPowerIndicatorEnabled(bool enabled) - { - lowPowerIndicatorEnabled = enabled; + autoOffThreshold = lowPowerIndicatorEnabled /*&& autoOffEnabled*/ ? min(lowPowerIndicatorThreshold - 1, (int)autoOffThreshold) : autoOffThreshold; } - /** - * Get low-power-indicator preset to activate when low power is detected - */ - int8_t getLowPowerIndicatorPreset() - { - return lowPowerIndicatorPreset; - } - - /** - * Set low-power-indicator preset to activate when low power is detected - */ - void setLowPowerIndicatorPreset(int8_t presetId) - { - // String tmp = ""; For what ever reason this doesn't work :( - // lowPowerIndicatorPreset = getPresetName(presetId, tmp) ? presetId : lowPowerIndicatorPreset; - lowPowerIndicatorPreset = presetId; - } - - /* - * Get low-power-indicator threshold in percent (0-100) - */ - int8_t getLowPowerIndicatorThreshold() - { - return lowPowerIndicatorThreshold; - } - - /** - * Set low-power-indicator threshold in percent (0-100) - */ - void setLowPowerIndicatorThreshold(int8_t threshold) - { + void setLowPowerIndicatorThreshold(int8_t threshold) { lowPowerIndicatorThreshold = threshold; - // when auto-off is enabled the indicator threshold cannot be below auto-off threshold - lowPowerIndicatorThreshold = autoOffEnabled /*&& lowPowerIndicatorEnabled*/ ? max(autoOffThreshold+1, (int)lowPowerIndicatorThreshold) : max(5, (int)lowPowerIndicatorThreshold); - } - - /** - * Get low-power-indicator duration in seconds - */ - int8_t getLowPowerIndicatorDuration() - { - return lowPowerIndicatorDuration; - } - - /** - * Set low-power-indicator duration in seconds - */ - void setLowPowerIndicatorDuration(int8_t duration) - { - lowPowerIndicatorDuration = duration; - } - - /** - * Get low-power-indicator status when the indication is done this returns true - */ - bool getLowPowerIndicatorDone() - { - return lowPowerIndicationDone; - } - - /** - * Set Home Assistant auto discovery - */ - void setHomeAssistantDiscovery(bool enable) - { - HomeAssistantDiscovery = enable; - } - - /** - * Get Home Assistant auto discovery - */ - bool getHomeAssistantDiscovery() - { - return HomeAssistantDiscovery; + lowPowerIndicatorThreshold = autoOffEnabled /*&& lowPowerIndicatorEnabled*/ ? max(autoOffThreshold + 1, (int)lowPowerIndicatorThreshold) : max(5, (int)lowPowerIndicatorThreshold); } }; @@ -858,4 +610,4 @@ const char UsermodBattery::_haDiscovery[] PROGMEM = "HA-discovery"; static UsermodBattery battery; -REGISTER_USERMOD(battery); \ No newline at end of file +REGISTER_USERMOD(battery); diff --git a/usermods/Battery/UMBattery.h b/usermods/Battery/UMBattery.h index 8a8ad891e6..e8eab8a5c5 100644 --- a/usermods/Battery/UMBattery.h +++ b/usermods/Battery/UMBattery.h @@ -11,6 +11,13 @@ class UMBattery { private: + public: + /** + * Lookup table entry for voltage-to-percentage mapping. + * Table must be sorted descending by voltage. + */ + struct LutEntry { float voltage; float percent; }; + protected: float minVoltage; float maxVoltage; @@ -18,12 +25,36 @@ class UMBattery int8_t level = 100; float calibration; // offset or calibration value to fine tune the calculated voltage float voltageMultiplier; // ratio for the voltage divider - + float linearMapping(float v, float min, float max, float oMin = 0.0f, float oMax = 100.0f) { return (v-min) * (oMax-oMin) / (max-min) + oMin; } + float lutInterpolate(float v, const LutEntry* lut, uint8_t size) + { + if (size == 0) return 0.0f; + + LutEntry first, last; + memcpy_P(&first, &lut[0], sizeof(LutEntry)); + memcpy_P(&last, &lut[size-1], sizeof(LutEntry)); + + if (v >= first.voltage) return first.percent; + if (v <= last.voltage) return last.percent; + + for (uint8_t i = 0; i < size - 1; i++) { + LutEntry hi, lo; + memcpy_P(&hi, &lut[i], sizeof(LutEntry)); + memcpy_P(&lo, &lut[i+1], sizeof(LutEntry)); + + if (v >= lo.voltage) { + float ratio = (v - lo.voltage) / (hi.voltage - lo.voltage); + return lo.percent + ratio * (hi.percent - lo.percent); + } + } + return last.percent; + } + public: UMBattery() { @@ -31,6 +62,8 @@ class UMBattery this->setCalibration(USERMOD_BATTERY_CALIBRATION); } + virtual ~UMBattery() = default; + virtual void update(batteryConfig cfg) { if(cfg.minVoltage) this->setMinVoltage(cfg.minVoltage); @@ -42,15 +75,14 @@ class UMBattery /** * Corresponding battery curves - * calculates the level in % (0-100) with given voltage and possible voltage range + * calculates the level in % (0-100) with given voltage */ - virtual float mapVoltage(float v, float min, float max) = 0; - // { - // example implementation, linear mapping - // return (v-min) * 100 / (max-min); - // }; + virtual float mapVoltage(float v) = 0; - virtual void calculateAndSetLevel(float voltage) = 0; + void calculateAndSetLevel(float voltage) + { + this->setLevel(this->mapVoltage(voltage)); + } @@ -110,14 +142,14 @@ class UMBattery this->voltage = voltage; } - float getLevel() + int8_t getLevel() { return this->level; } void setLevel(float level) { - this->level = constrain(level, 0.0f, 110.0f); + this->level = (int8_t)constrain(level, 0.0f, 110.0f); } /* diff --git a/usermods/Battery/battery_defaults.h b/usermods/Battery/battery_defaults.h index ddbd114e40..40d765cb2e 100644 --- a/usermods/Battery/battery_defaults.h +++ b/usermods/Battery/battery_defaults.h @@ -27,24 +27,12 @@ /* Default Battery Type - * 0 = unkown * 1 = Lipo * 2 = Lion + * 3 = LiFePO4 */ #ifndef USERMOD_BATTERY_DEFAULT_TYPE - #define USERMOD_BATTERY_DEFAULT_TYPE 0 -#endif -/* - * - * Unkown 'Battery' defaults - * - */ -#ifndef USERMOD_BATTERY_UNKOWN_MIN_VOLTAGE - // Extra save defaults - #define USERMOD_BATTERY_UNKOWN_MIN_VOLTAGE 3.3f -#endif -#ifndef USERMOD_BATTERY_UNKOWN_MAX_VOLTAGE - #define USERMOD_BATTERY_UNKOWN_MAX_VOLTAGE 4.2f + #define USERMOD_BATTERY_DEFAULT_TYPE 1 #endif /* @@ -73,6 +61,18 @@ #define USERMOD_BATTERY_LION_MAX_VOLTAGE 4.2f #endif +/* + * + * Lithium Iron Phosphate (LiFePO4) defaults + * + */ +#ifndef USERMOD_BATTERY_LIFEPO4_MIN_VOLTAGE + #define USERMOD_BATTERY_LIFEPO4_MIN_VOLTAGE 2.8f +#endif +#ifndef USERMOD_BATTERY_LIFEPO4_MAX_VOLTAGE + #define USERMOD_BATTERY_LIFEPO4_MAX_VOLTAGE 3.6f +#endif + // the default ratio for the voltage divider #ifndef USERMOD_BATTERY_VOLTAGE_MULTIPLIER #ifdef ARDUINO_ARCH_ESP32 @@ -117,12 +117,24 @@ #define USERMOD_BATTERY_LOW_POWER_INDICATOR_DURATION 5 #endif +// estimated runtime feature (requires INA226 current sensor) +#ifdef USERMOD_INA226 + #ifndef USERMOD_BATTERY_ESTIMATED_RUNTIME_ENABLED + #define USERMOD_BATTERY_ESTIMATED_RUNTIME_ENABLED true + #endif + + // battery capacity in mAh (used for runtime estimation with INA226 current sensor) + #ifndef USERMOD_BATTERY_CAPACITY + #define USERMOD_BATTERY_CAPACITY 3000 + #endif +#endif + // battery types typedef enum { - unknown=0, lipo=1, - lion=2 + lion=2, + lifepo4=3 } batteryType; // used for initial configuration after boot diff --git a/usermods/Battery/types/Lifepo4UMBattery.h b/usermods/Battery/types/Lifepo4UMBattery.h new file mode 100644 index 0000000000..2e1ec9bf0b --- /dev/null +++ b/usermods/Battery/types/Lifepo4UMBattery.h @@ -0,0 +1,53 @@ +#ifndef UMBLifepo4_h +#define UMBLifepo4_h + +#include "../battery_defaults.h" +#include "../UMBattery.h" + +/** + * LiFePO4 Battery + * Uses a lookup table based on typical LiFePO4 discharge curve data. + * LiFePO4 cells have a very flat discharge curve between ~3.3V and ~3.2V + * making voltage-based SoC estimation difficult in the mid-range. + */ +class Lifepo4UMBattery : public UMBattery +{ + private: + // Typical LiFePO4 discharge curve + static const LutEntry dischargeLut[] PROGMEM; + static const uint8_t dischargeLutSize; + + public: + Lifepo4UMBattery() : UMBattery() + { + this->setMinVoltage(USERMOD_BATTERY_LIFEPO4_MIN_VOLTAGE); + this->setMaxVoltage(USERMOD_BATTERY_LIFEPO4_MAX_VOLTAGE); + } + + float mapVoltage(float v) override + { + return this->lutInterpolate(v, dischargeLut, dischargeLutSize); + }; + + void setMaxVoltage(float voltage) override + { + this->maxVoltage = max(getMinVoltage()+0.5f, voltage); + } +}; + +const UMBattery::LutEntry Lifepo4UMBattery::dischargeLut[] PROGMEM = { + {3.60f, 100.0f}, + {3.40f, 99.0f}, + {3.35f, 90.0f}, + {3.33f, 80.0f}, + {3.30f, 70.0f}, + {3.28f, 55.0f}, + {3.25f, 40.0f}, + {3.20f, 20.0f}, + {3.10f, 10.0f}, + {3.00f, 5.0f}, + {2.80f, 0.0f}, +}; +const uint8_t Lifepo4UMBattery::dischargeLutSize = sizeof(Lifepo4UMBattery::dischargeLut) / sizeof(Lifepo4UMBattery::dischargeLut[0]); + +#endif diff --git a/usermods/Battery/types/LionUMBattery.h b/usermods/Battery/types/LionUMBattery.h index 801faee7c5..ecef81fc59 100644 --- a/usermods/Battery/types/LionUMBattery.h +++ b/usermods/Battery/types/LionUMBattery.h @@ -6,11 +6,14 @@ /** * LiOn Battery - * + * Uses a lookup table based on typical 18650 discharge curve data. */ class LionUMBattery : public UMBattery { private: + // Typical 18650 Li-Ion discharge curve + static const LutEntry dischargeLut[] PROGMEM; + static const uint8_t dischargeLutSize; public: LionUMBattery() : UMBattery() @@ -19,20 +22,31 @@ class LionUMBattery : public UMBattery this->setMaxVoltage(USERMOD_BATTERY_LION_MAX_VOLTAGE); } - float mapVoltage(float v, float min, float max) override + float mapVoltage(float v) override { - return this->linearMapping(v, min, max); // basic mapping + return this->lutInterpolate(v, dischargeLut, dischargeLutSize); }; - void calculateAndSetLevel(float voltage) override - { - this->setLevel(this->mapVoltage(voltage, this->getMinVoltage(), this->getMaxVoltage())); - }; - - virtual void setMaxVoltage(float voltage) override + void setMaxVoltage(float voltage) override { this->maxVoltage = max(getMinVoltage()+1.0f, voltage); } }; -#endif \ No newline at end of file +const UMBattery::LutEntry LionUMBattery::dischargeLut[] PROGMEM = { + {4.20f, 100.0f}, + {4.10f, 90.0f}, + {3.97f, 80.0f}, + {3.87f, 70.0f}, + {3.79f, 60.0f}, + {3.73f, 50.0f}, + {3.68f, 40.0f}, + {3.63f, 30.0f}, + {3.55f, 20.0f}, + {3.40f, 10.0f}, + {3.20f, 5.0f}, + {2.60f, 0.0f}, +}; +const uint8_t LionUMBattery::dischargeLutSize = sizeof(LionUMBattery::dischargeLut) / sizeof(LionUMBattery::dischargeLut[0]); + +#endif diff --git a/usermods/Battery/types/LipoUMBattery.h b/usermods/Battery/types/LipoUMBattery.h index bb6a6ef94e..b07a1d3372 100644 --- a/usermods/Battery/types/LipoUMBattery.h +++ b/usermods/Battery/types/LipoUMBattery.h @@ -6,11 +6,15 @@ /** * LiPo Battery - * + * Uses a lookup table based on typical 1S LiPo discharge curve data. + * see https://blog.ampow.com/lipo-voltage-chart/ */ class LipoUMBattery : public UMBattery { private: + // Typical 1S LiPo discharge curve + static const LutEntry dischargeLut[] PROGMEM; + static const uint8_t dischargeLutSize; public: LipoUMBattery() : UMBattery() @@ -19,36 +23,40 @@ class LipoUMBattery : public UMBattery this->setMaxVoltage(USERMOD_BATTERY_LIPO_MAX_VOLTAGE); } - /** - * LiPo batteries have a differnt discharge curve, see - * https://blog.ampow.com/lipo-voltage-chart/ - */ - float mapVoltage(float v, float min, float max) override + float mapVoltage(float v) override { - float lvl = 0.0f; - lvl = this->linearMapping(v, min, max); // basic mapping - - if (lvl < 40.0f) - lvl = this->linearMapping(lvl, 0, 40, 0, 12); // last 45% -> drops very quickly - else { - if (lvl < 90.0f) - lvl = this->linearMapping(lvl, 40, 90, 12, 95); // 90% ... 40% -> almost linear drop - else // level > 90% - lvl = this->linearMapping(lvl, 90, 105, 95, 100); // highest 15% -> drop slowly - } - - return lvl; + return this->lutInterpolate(v, dischargeLut, dischargeLutSize); }; - void calculateAndSetLevel(float voltage) override - { - this->setLevel(this->mapVoltage(voltage, this->getMinVoltage(), this->getMaxVoltage())); - }; - - virtual void setMaxVoltage(float voltage) override + void setMaxVoltage(float voltage) override { this->maxVoltage = max(getMinVoltage()+0.7f, voltage); } }; -#endif \ No newline at end of file +const UMBattery::LutEntry LipoUMBattery::dischargeLut[] PROGMEM = { + {4.20f, 100.0f}, + {4.15f, 95.0f}, + {4.11f, 90.0f}, + {4.08f, 85.0f}, + {4.02f, 80.0f}, + {3.98f, 75.0f}, + {3.95f, 70.0f}, + {3.91f, 65.0f}, + {3.87f, 60.0f}, + {3.85f, 55.0f}, + {3.84f, 50.0f}, + {3.82f, 45.0f}, + {3.80f, 40.0f}, + {3.79f, 35.0f}, + {3.77f, 30.0f}, + {3.75f, 25.0f}, + {3.73f, 20.0f}, + {3.71f, 15.0f}, + {3.69f, 10.0f}, + {3.61f, 5.0f}, + {3.20f, 0.0f}, +}; +const uint8_t LipoUMBattery::dischargeLutSize = sizeof(LipoUMBattery::dischargeLut) / sizeof(LipoUMBattery::dischargeLut[0]); + +#endif diff --git a/usermods/Battery/types/UnkownUMBattery.h b/usermods/Battery/types/UnkownUMBattery.h deleted file mode 100644 index ede5ffd887..0000000000 --- a/usermods/Battery/types/UnkownUMBattery.h +++ /dev/null @@ -1,39 +0,0 @@ -#ifndef UMBUnkown_h -#define UMBUnkown_h - -#include "../battery_defaults.h" -#include "../UMBattery.h" - -/** - * Unkown / Default Battery - * - */ -class UnkownUMBattery : public UMBattery -{ - private: - - public: - UnkownUMBattery() : UMBattery() - { - this->setMinVoltage(USERMOD_BATTERY_UNKOWN_MIN_VOLTAGE); - this->setMaxVoltage(USERMOD_BATTERY_UNKOWN_MAX_VOLTAGE); - } - - void update(batteryConfig cfg) - { - if(cfg.minVoltage) this->setMinVoltage(cfg.minVoltage); else this->setMinVoltage(USERMOD_BATTERY_UNKOWN_MIN_VOLTAGE); - if(cfg.maxVoltage) this->setMaxVoltage(cfg.maxVoltage); else this->setMaxVoltage(USERMOD_BATTERY_UNKOWN_MAX_VOLTAGE); - } - - float mapVoltage(float v, float min, float max) override - { - return this->linearMapping(v, min, max); // basic mapping - }; - - void calculateAndSetLevel(float voltage) override - { - this->setLevel(this->mapVoltage(voltage, this->getMinVoltage(), this->getMaxVoltage())); - }; -}; - -#endif \ No newline at end of file From 44f955443bccacd234d11460d22f5bf10793efcb Mon Sep 17 00:00:00 2001 From: Maximilian Mewes Date: Wed, 25 Feb 2026 09:48:33 +0100 Subject: [PATCH 02/11] INA226: expose sensor data for estimating the battery runtime --- usermods/INA226_v2/INA226_v2.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/usermods/INA226_v2/INA226_v2.cpp b/usermods/INA226_v2/INA226_v2.cpp index 26f92f4945..c18b3f299c 100644 --- a/usermods/INA226_v2/INA226_v2.cpp +++ b/usermods/INA226_v2/INA226_v2.cpp @@ -328,6 +328,27 @@ class UsermodINA226 : public Usermod void setup() { initializeINA226(); + + // expose current, voltage, power for other usermods via getUMData() + if (!um_data) { + um_data = new um_data_t; + um_data->u_size = 3; + um_data->u_type = new um_types_t[3]; + um_data->u_data = new void*[3]; + um_data->u_data[0] = &_lastCurrent; // float, Amps + um_data->u_type[0] = UMT_FLOAT; + um_data->u_data[1] = &_lastVoltage; // float, Volts + um_data->u_type[1] = UMT_FLOAT; + um_data->u_data[2] = &_lastPower; // float, Watts + um_data->u_type[2] = UMT_FLOAT; + } + } + + bool getUMData(um_data_t **data) override + { + if (!data || !_settingEnabled || _lastLoopCheck == 0) return false; + *data = um_data; + return true; } void loop() From 4527f7c7e40e4b2b0ff159f130648e0022bad490 Mon Sep 17 00:00:00 2001 From: Maximilian Mewes Date: Wed, 25 Feb 2026 09:49:36 +0100 Subject: [PATCH 03/11] update readme, guide for adding custom battery type since generic unkown battery type got removed --- usermods/Battery/readme.md | 116 +++++++++++++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 4 deletions(-) diff --git a/usermods/Battery/readme.md b/usermods/Battery/readme.md index 0e203f3a2b..4468f380b7 100644 --- a/usermods/Battery/readme.md +++ b/usermods/Battery/readme.md @@ -16,6 +16,8 @@ Enables battery level monitoring of your project. - 💯 Displays current battery voltage - 🚥 Displays battery level +- 🔌 Charging state detection (voltage trend based) +- ⏱️ Estimated runtime remaining (dV/dt based) - 🚫 Auto-off with configurable threshold - 🚨 Low power indicator with many configuration possibilities @@ -79,10 +81,109 @@ All parameters can be configured at runtime via the Usermods settings page. **NOTICE:** Each Battery type can be pre-configured individualy (in `my_config.h`) -| Name | Alias | `my_config.h` example | -| --------------- | ------------- | ------------------------------------- | -| Lithium Polymer | lipo (Li-Po) | `USERMOD_BATTERY_lipo_MIN_VOLTAGE` | -| Lithium Ionen | lion (Li-Ion) | `USERMOD_BATTERY_lion_TOTAL_CAPACITY` | +| Name | Alias | `my_config.h` example | +| ----------------------- | --------------- | ---------------------------------------- | +| Lithium Polymer | lipo (Li-Po) | `USERMOD_BATTERY_LIPO_MIN_VOLTAGE` | +| Lithium Ionen | lion (Li-Ion) | `USERMOD_BATTERY_LION_MIN_VOLTAGE` | +| Lithium Iron Phosphate | lifepo4 (LFP) | `USERMOD_BATTERY_LIFEPO4_MIN_VOLTAGE` | + +

+ +## 🔋 Adding a Custom Battery Type + +If none of the built-in battery types match your cell chemistry, you can add your own. + +### Step-by-step + +1. **Create a new header** in `usermods/Battery/types/`, e.g. `MyBattery.h`. + Use an existing type as a template (e.g. `LipoUMBattery.h`): + + ```cpp + #ifndef UMBMyBattery_h + #define UMBMyBattery_h + + #include "../battery_defaults.h" + #include "../UMBattery.h" + + class MyBattery : public UMBattery + { + private: + static const LutEntry dischargeLut[] PROGMEM; + static const uint8_t dischargeLutSize; + + public: + MyBattery() : UMBattery() + { + // Set your cell's voltage limits + this->setMinVoltage(3.0f); + this->setMaxVoltage(4.2f); + } + + float mapVoltage(float v) override + { + return this->lutInterpolate(v, dischargeLut, dischargeLutSize); + }; + + // Optional: override setMaxVoltage to enforce a minimum gap + // void setMaxVoltage(float voltage) override + // { + // this->maxVoltage = max(getMinVoltage()+0.5f, voltage); + // } + }; + + // Discharge lookup table – voltage (descending) → percentage + // Obtain this data from your cell's datasheet + const UMBattery::LutEntry MyBattery::dischargeLut[] PROGMEM = { + {4.20f, 100.0f}, + {3.90f, 75.0f}, + {3.60f, 25.0f}, + {3.00f, 0.0f}, + }; + const uint8_t MyBattery::dischargeLutSize = + sizeof(MyBattery::dischargeLut) / sizeof(MyBattery::dischargeLut[0]); + + #endif + ``` + +2. **Add a new enum value** in `battery_defaults.h`: + + ```cpp + typedef enum + { + lipo=1, + lion=2, + lifepo4=3, + mybattery=4 // <-- new + } batteryType; + ``` + +3. **Register defaults** (optional) in `battery_defaults.h`: + + ```cpp + #ifndef USERMOD_BATTERY_MYBATTERY_MIN_VOLTAGE + #define USERMOD_BATTERY_MYBATTERY_MIN_VOLTAGE 3.0f + #endif + #ifndef USERMOD_BATTERY_MYBATTERY_MAX_VOLTAGE + #define USERMOD_BATTERY_MYBATTERY_MAX_VOLTAGE 4.2f + #endif + ``` + +4. **Wire it up** in `Battery.cpp`: + + - Add the include at the top: + ```cpp + #include "types/MyBattery.h" + ``` + - Add a case in `createBattery()`: + ```cpp + case mybattery: return new MyBattery(); + ``` + - Add a dropdown option in `appendConfigData()`: + ```cpp + oappend(F("addOption(td,'My Battery','4');")); + ``` + +5. **Compile and flash** — select your new type from the Battery settings page.

@@ -129,6 +230,13 @@ Specification from: [Molicel INR18650-M35A, 3500mAh 10A Lithium-ion battery, 3.6 ## 📝 Change Log +2025-02-24 + +- Added LiFePO4 battery type with piecewise-linear discharge curve mapping +- Added charging state detection based on voltage trend (dV/dt) +- Added estimated runtime remaining (sliding window dV/dt extrapolation) +- Added charging status and runtime to MQTT, JSON API, and web UI info panel + 2024-08-19 - Improved MQTT support From 885f7a6d7383b6c8ec5501a62bb7001ddecefc2a Mon Sep 17 00:00:00 2001 From: Maximilian Mewes Date: Wed, 25 Feb 2026 10:41:22 +0100 Subject: [PATCH 04/11] Improve battery runtime estimation and charging detection --- usermods/Battery/Battery.cpp | 141 +++++++++++++--------- usermods/Battery/battery_defaults.h | 13 +- usermods/Battery/readme.md | 71 ++++++++++- usermods/Battery/types/UnknownUMBattery.h | 46 +++++++ 4 files changed, 198 insertions(+), 73 deletions(-) create mode 100644 usermods/Battery/types/UnknownUMBattery.h diff --git a/usermods/Battery/Battery.cpp b/usermods/Battery/Battery.cpp index 64f56d47fa..268cb821b3 100644 --- a/usermods/Battery/Battery.cpp +++ b/usermods/Battery/Battery.cpp @@ -43,22 +43,30 @@ class UsermodBattery : public Usermod { unsigned long lowPowerActivationTime = 0; uint8_t lastPreset = 0; - // --- Charging detection --- + // --- Charging detection (voltage trend over sliding window) --- bool charging = false; - uint8_t chargingRiseCount = 0; - float previousVoltage = 0.0f; - static const uint8_t CHARGE_DETECT_THRESHOLD = 3; - static const uint8_t CHARGE_COUNT_MAX = 6; - static constexpr float CHARGE_VOLTAGE_DEADBAND = 0.01f; - - // --- Estimated runtime (requires INA226) --- - #ifdef USERMOD_INA226 - bool estimatedRuntimeEnabled = USERMOD_BATTERY_ESTIMATED_RUNTIME_ENABLED; + static const uint8_t VOLTAGE_HISTORY_SIZE = 5; // 5 × 30s = 2.5 min window + float voltageHistory[5] = {0}; + uint8_t voltageHistoryIdx = 0; + bool voltageHistoryFull = false; + static constexpr float CHARGE_VOLTAGE_THRESHOLD = 0.01f; // 10mV rise over window + + // --- Estimated runtime (auto-enabled when INA226 detected) --- + bool estimatedRuntimeEnabled = false; + bool ina226Probed = false; uint16_t batteryCapacity = USERMOD_BATTERY_CAPACITY; int32_t estimatedTimeLeft = -1; - float smoothedEstimate = -1.0f; - static constexpr float ESTIMATE_SMOOTHING = 0.3f; - #endif + float smoothedCurrent = -1.0f; + static constexpr float CURRENT_SMOOTHING = 0.2f; + + // --- Coulomb counting (active when INA226 is available) --- + float coulombSoC = -1.0f; + bool coulombInitialized = false; + unsigned long lastCoulombTime = 0; + unsigned long restStartTime = 0; + bool atRest = false; + static constexpr float REST_CURRENT_THRESHOLD = 0.01f; + static const unsigned long REST_RECALIBRATE_MS = 60000; // --- Inter-usermod data exchange --- float umVoltage = 0.0f; @@ -121,13 +129,11 @@ class UsermodBattery : public Usermod { #endif } - #ifdef USERMOD_INA226 float getINA226Current() { um_data_t *data = nullptr; if (!UsermodManager::getUMData(&data, USERMOD_ID_INA226) || !data) return -1.0f; return *(float*)data->u_data[0]; } - #endif #ifndef WLED_DISABLE_MQTT void addMqttSensor(const String &name, const String &type, const String &topic, const String &deviceClass, const String &unitOfMeasurement = "", const bool &isDiagnostic = false) { @@ -249,6 +255,14 @@ class UsermodBattery : public Usermod { nextReadTime = millis() + readingInterval; } + // auto-detect INA226 usermod once after initial delay (all usermods are set up by now) + if (!ina226Probed) { + ina226Probed = true; + um_data_t *data = nullptr; + if (UsermodManager::getUMData(&data, USERMOD_ID_INA226) && data) + estimatedRuntimeEnabled = true; + } + if (isFirstVoltageReading) { bat->setVoltage(readVoltage()); isFirstVoltageReading = false; @@ -271,37 +285,62 @@ class UsermodBattery : public Usermod { umVoltage = filteredVoltage; umLevel = bat->getLevel(); - // charging detection based on voltage trend - if (previousVoltage > 0.0f) { - if (filteredVoltage > previousVoltage + CHARGE_VOLTAGE_DEADBAND) { - chargingRiseCount = min((uint8_t)(chargingRiseCount + 1), CHARGE_COUNT_MAX); - } else if (filteredVoltage < previousVoltage - CHARGE_VOLTAGE_DEADBAND) { - if (chargingRiseCount > 0) chargingRiseCount--; - } - charging = (chargingRiseCount >= CHARGE_DETECT_THRESHOLD); + // charging detection: compare current voltage against ~2.5 minutes ago + float oldestVoltage = voltageHistory[voltageHistoryIdx]; + voltageHistory[voltageHistoryIdx] = filteredVoltage; + voltageHistoryIdx = (voltageHistoryIdx + 1) % VOLTAGE_HISTORY_SIZE; + if (!voltageHistoryFull && voltageHistoryIdx == 0) voltageHistoryFull = true; + + if (voltageHistoryFull && oldestVoltage > 0.0f) { + charging = (filteredVoltage - oldestVoltage > CHARGE_VOLTAGE_THRESHOLD); } - previousVoltage = filteredVoltage; - // estimated runtime via INA226 current sensor - #ifdef USERMOD_INA226 - if (estimatedRuntimeEnabled) { - float current_A = getINA226Current(); - if (!charging && current_A > 0.01f && batteryCapacity > 0 && bat->getLevel() > 0) { - float remaining_Ah = (bat->getLevel() / 100.0f) * (batteryCapacity / 1000.0f); - float rawEstimate = min(remaining_Ah / current_A * 60.0f, 14400.0f); + // Coulomb counting & runtime estimation via INA226 + float current_A = estimatedRuntimeEnabled ? getINA226Current() : -1.0f; + if (estimatedRuntimeEnabled && current_A >= 0.0f) { + unsigned long now = millis(); + float capacity_Ah = batteryCapacity / 1000.0f; + + // initialize Coulomb counter from voltage-based SoC on first valid reading + if (!coulombInitialized) { + coulombSoC = bat->getLevel() / 100.0f; + coulombInitialized = true; + lastCoulombTime = now; + } else { + float dt_hours = (now - lastCoulombTime) / 3600000.0f; + lastCoulombTime = now; - if (smoothedEstimate < 0.0f) { - smoothedEstimate = rawEstimate; + // only subtract charge while discharging + if (!charging && current_A > 0.0f && capacity_Ah > 0.0f) { + coulombSoC -= (current_A * dt_hours) / capacity_Ah; + } + coulombSoC = constrain(coulombSoC, 0.0f, 1.0f); + } + + // recalibrate from voltage-based SoC when battery is at rest (OCV is accurate) + if (current_A < REST_CURRENT_THRESHOLD) { + if (!atRest) { atRest = true; restStartTime = now; } + if (now - restStartTime >= REST_RECALIBRATE_MS) { + coulombSoC = bat->getLevel() / 100.0f; + } + } else { + atRest = false; + } + + // runtime estimation using Coulomb-tracked SoC + if (!charging && current_A > 0.01f && batteryCapacity > 0 && coulombSoC > 0.0f) { + if (smoothedCurrent < 0.0f) { + smoothedCurrent = current_A; } else { - smoothedEstimate = ESTIMATE_SMOOTHING * rawEstimate + (1.0f - ESTIMATE_SMOOTHING) * smoothedEstimate; + smoothedCurrent = CURRENT_SMOOTHING * current_A + (1.0f - CURRENT_SMOOTHING) * smoothedCurrent; } - estimatedTimeLeft = (int32_t)smoothedEstimate; + float remaining_Ah = coulombSoC * capacity_Ah; + estimatedTimeLeft = (int32_t)min(remaining_Ah / smoothedCurrent * 60.0f, 14400.0f); } else { estimatedTimeLeft = -1; - smoothedEstimate = -1.0f; + smoothedCurrent = -1.0f; } } - #endif // auto-off if (autoOffEnabled && (autoOffThreshold >= bat->getLevel())) @@ -311,11 +350,9 @@ class UsermodBattery : public Usermod { publishMqtt("battery", String(bat->getLevel(), 0).c_str()); publishMqtt("voltage", String(bat->getVoltage()).c_str()); publishMqtt("charging", charging ? "on" : "off"); - #ifdef USERMOD_INA226 if (estimatedRuntimeEnabled && estimatedTimeLeft >= 0) { publishMqtt("runtime", String(estimatedTimeLeft).c_str()); } - #endif #endif } @@ -381,7 +418,6 @@ class UsermodBattery : public Usermod { infoCharging.add(charging ? F("Yes") : F("No")); - #ifdef USERMOD_INA226 if (estimatedRuntimeEnabled) { JsonArray infoRuntime = user.createNestedArray(F("Est. runtime")); if (charging) { @@ -390,14 +426,14 @@ class UsermodBattery : public Usermod { infoRuntime.add(F("calculating")); } else if (estimatedTimeLeft < 60) { infoRuntime.add(estimatedTimeLeft); - infoRuntime.add(F(" min")); + infoRuntime.add(cfg.type == lifepo4 ? F(" min (approx)") : F(" min")); } else { - char buf[16]; - snprintf_P(buf, sizeof(buf), PSTR("%dh %dm"), estimatedTimeLeft / 60, estimatedTimeLeft % 60); + char buf[24]; + snprintf_P(buf, sizeof(buf), cfg.type == lifepo4 ? PSTR("%dh %dm (approx)") : PSTR("%dh %dm"), + estimatedTimeLeft / 60, estimatedTimeLeft % 60); infoRuntime.add(buf); } } - #endif } void addToJsonState(JsonObject& root) { @@ -410,11 +446,10 @@ class UsermodBattery : public Usermod { if (forJsonState) { battery[F("type")] = cfg.type; battery[F("charging")] = charging; - #ifdef USERMOD_INA226 + battery[F("estimated-runtime-enabled")] = estimatedRuntimeEnabled; if (estimatedRuntimeEnabled) { battery[F("estimated-runtime")] = estimatedTimeLeft; } - #endif } else { battery[F("type")] = (String)cfg.type; } @@ -422,10 +457,7 @@ class UsermodBattery : public Usermod { battery[F("max-voltage")] = bat->getMaxVoltage(); battery[F("calibration")] = bat->getCalibration(); battery[F("voltage-multiplier")] = bat->getVoltageMultiplier(); - #ifdef USERMOD_INA226 battery[F("capacity")] = batteryCapacity; - battery[F("estimated-runtime-enabled")] = estimatedRuntimeEnabled; - #endif battery[FPSTR(_readInterval)] = readingInterval; battery[FPSTR(_haDiscovery)] = HomeAssistantDiscovery; @@ -467,10 +499,7 @@ class UsermodBattery : public Usermod { oappend(F("addInfo('Battery:type',1,'requires reboot');")); // 81 Bytes oappend(F("addInfo('Battery:min-voltage',1,'v');")); // 38 Bytes oappend(F("addInfo('Battery:max-voltage',1,'v');")); // 38 Bytes - #ifdef USERMOD_INA226 oappend(F("addInfo('Battery:capacity',1,'mAh');")); // 37 Bytes - oappend(F("addInfo('Battery:estimated-runtime-enabled',1,'');")); // 51 Bytes - #endif oappend(F("addInfo('Battery:interval',1,'ms');")); // 36 Bytes oappend(F("addInfo('Battery:HA-discovery',1,'');")); // 38 Bytes oappend(F("addInfo('Battery:auto-off:threshold',1,'%');")); // 45 Bytes @@ -508,10 +537,7 @@ class UsermodBattery : public Usermod { getJsonValue(battery[F("calibration")], cfg.calibration); getJsonValue(battery[F("voltage-multiplier")], cfg.voltageMultiplier); - #ifdef USERMOD_INA226 batteryCapacity = battery[F("capacity")] | batteryCapacity; - estimatedRuntimeEnabled = battery[F("estimated-runtime-enabled")] | estimatedRuntimeEnabled; - #endif setReadingInterval(battery[FPSTR(_readInterval)] | readingInterval); HomeAssistantDiscovery = battery[FPSTR(_haDiscovery)] | HomeAssistantDiscovery; @@ -548,9 +574,6 @@ class UsermodBattery : public Usermod { && !battery[F("type")].isNull() && !battery[F("auto-off")].isNull() && !battery[F("indicator")].isNull(); - #ifdef USERMOD_INA226 - configComplete = configComplete && !battery[F("estimated-runtime-enabled")].isNull(); - #endif return configComplete; } @@ -565,11 +588,9 @@ class UsermodBattery : public Usermod { registerMqttSensor("battery", F("Battery"), "sensor", "battery", "%"); registerMqttSensor("voltage", F("Voltage"), "sensor", "voltage", "V"); registerMqttSensor("charging", F("Charging"), "binary_sensor", "battery_charging"); - #ifdef USERMOD_INA226 if (estimatedRuntimeEnabled) { registerMqttSensor("runtime", F("Runtime"), "sensor", "duration", "min"); } - #endif } void registerMqttSensor(const char* subtopic, const String &name, const char* type, const char* deviceClass, const char* unit = "", bool diagnostic = true) { diff --git a/usermods/Battery/battery_defaults.h b/usermods/Battery/battery_defaults.h index 40d765cb2e..c5f530a71d 100644 --- a/usermods/Battery/battery_defaults.h +++ b/usermods/Battery/battery_defaults.h @@ -117,16 +117,9 @@ #define USERMOD_BATTERY_LOW_POWER_INDICATOR_DURATION 5 #endif -// estimated runtime feature (requires INA226 current sensor) -#ifdef USERMOD_INA226 - #ifndef USERMOD_BATTERY_ESTIMATED_RUNTIME_ENABLED - #define USERMOD_BATTERY_ESTIMATED_RUNTIME_ENABLED true - #endif - - // battery capacity in mAh (used for runtime estimation with INA226 current sensor) - #ifndef USERMOD_BATTERY_CAPACITY - #define USERMOD_BATTERY_CAPACITY 3000 - #endif +// battery capacity in mAh (used for runtime estimation with INA226 current sensor) +#ifndef USERMOD_BATTERY_CAPACITY + #define USERMOD_BATTERY_CAPACITY 3000 #endif // battery types diff --git a/usermods/Battery/readme.md b/usermods/Battery/readme.md index 4468f380b7..bdf1363e5c 100644 --- a/usermods/Battery/readme.md +++ b/usermods/Battery/readme.md @@ -17,7 +17,7 @@ Enables battery level monitoring of your project. - 💯 Displays current battery voltage - 🚥 Displays battery level - 🔌 Charging state detection (voltage trend based) -- ⏱️ Estimated runtime remaining (dV/dt based) +- ⏱️ Estimated runtime remaining (requires INA226 current sensor) - 🚫 Auto-off with configurable threshold - 🚨 Low power indicator with many configuration possibilities @@ -69,6 +69,8 @@ In `platformio_override.ini` (or `platformio.ini`)
Under: `custom_usermods =` | Auto-Off | --- | --- | | `USERMOD_BATTERY_AUTO_OFF_ENABLED` | true/false | Enables auto-off | | `USERMOD_BATTERY_AUTO_OFF_THRESHOLD` | % (0-100) | When this threshold is reached master power turns off | +| Estimated Runtime | --- | --- | +| `USERMOD_BATTERY_CAPACITY` | mAh | Total battery capacity for runtime calculation. defaults to 3000 | | Low-Power-Indicator | --- | --- | | `USERMOD_BATTERY_LOW_POWER_INDICATOR_ENABLED` | true/false | Enables low power indication | | `USERMOD_BATTERY_LOW_POWER_INDICATOR_PRESET` | preset id | When low power is detected then use this preset to indicate low power | @@ -187,6 +189,69 @@ If none of the built-in battery types match your cell chemistry, you can add you

+## ⏱️ Estimated Runtime + +The battery usermod can estimate the remaining runtime of your project. This feature is **automatically enabled** when the [INA226 usermod](../INA226_v2/) is detected at runtime — no extra configuration needed. + +### How it works + +1. The INA226 current sensor measures the actual current draw of your project +2. **Coulomb counting**: Current is integrated over time (`SoC -= I × dt / capacity`) to track charge consumed, instead of relying solely on voltage. When the battery is at rest (current < 10mA for 60s), the Coulomb counter recalibrates from the voltage-based SoC (OCV is accurate at rest) +3. The current reading is smoothed using an exponential moving average to reduce jitter from load fluctuations (e.g. LED effect changes, WiFi traffic) +4. The estimated time remaining is: `remaining_Ah / smoothed_current_A` + +### Setup + +Add both usermods to your `platformio_override.ini`: + +```ini +custom_usermods = Battery INA226 +``` + +Set your battery capacity in the WLED Usermods settings page or at compile time: + +```ini +-D USERMOD_BATTERY_CAPACITY=3000 +``` + +### Accuracy + +> **This is an estimation, not a precise measurement.** + +The battery usermod combines voltage-based lookup tables with software Coulomb counting. This is the same approach used by many consumer battery management systems. It is suitable for hobby projects and provides a useful estimate, but it is **not a substitute for a dedicated battery fuel gauge IC**. + +What the usermod does to improve accuracy: + +- **Coulomb counting**: Instead of relying solely on voltage, the usermod integrates current over time to track charge consumed. This is more accurate during discharge than voltage-based estimation alone. +- **Rest recalibration**: When the battery is at rest (current < 10mA for 60 seconds), the Coulomb counter recalibrates from the voltage-based SoC. Open-circuit voltage is accurate at rest, which corrects for Coulomb counting drift. + +Remaining limitations: + +- **Voltage under load**: SoC is estimated from the battery voltage while your LEDs are drawing current. The voltage drop across the battery's internal resistance makes the initial SoC seed and rest recalibrations more pessimistic than reality. Typical error: 10-15%. +- **Variable loads**: LED effects with changing brightness cause current fluctuations. The smoothing filter takes a few minutes to converge after a load change. +- **LiFePO4 flat curve**: LiFePO4 cells have an extremely flat discharge curve (25% of SoC maps to just ~50mV). Even with Coulomb counting, the initial SoC seed and rest recalibrations are voltage-based. Runtime estimates for LiFePO4 are marked as approximate. +- **Battery aging**: The configured capacity (mAh) does not account for capacity fade over charge cycles. +- **30-second interval**: The measurement interval (default 30s) is relatively coarse for Coulomb counting. Rapid load changes between readings may not be captured. + +**Realistic accuracy**: 15-25% error at steady load for LiPo/Li-Ion, 20-40% for variable loads. For LiFePO4, expect 25-50% error in the mid-range. + +For hobby LED projects, this level of accuracy is usually sufficient — you'll know roughly how many hours you have left, which is better than no estimate at all. + +### For advanced users: dedicated battery fuel gauge ICs + +If you need more accurate hardware-based readings, consider using a dedicated battery fuel gauge IC. The [MAX17048 usermod](../MAX17048_v2/) supports one such IC out of the box — no sense resistor required. + +Other ICs that use hardware Coulomb counting, temperature compensation, and sophisticated algorithms to achieve 1-3% SoC accuracy: + +| IC | Interface | Method | Notes | +| ------------------ | --------- | ------------------------------- | ----------------------------------------------- | +| **TI INA228** | I2C | Voltage/current + charge accum. | INA226 successor with hardware Coulomb counter | +| **TI BQ27441** | I2C | Impedance Track | Full fuel gauge with temperature compensation | +| **Analog LTC2941** | I2C | Coulomb counting | Simple, accurate, programmable alerts | +| **ST STC3117** | I2C | OptimGauge (combined approach) | Coulomb counting with voltage-based corrections | + +

+ ## 🔧 Calibration The calibration number is a value that is added to the final computed voltage after it has been scaled by the voltage multiplier. @@ -233,8 +298,8 @@ Specification from: [Molicel INR18650-M35A, 3500mAh 10A Lithium-ion battery, 3.6 2025-02-24 - Added LiFePO4 battery type with piecewise-linear discharge curve mapping -- Added charging state detection based on voltage trend (dV/dt) -- Added estimated runtime remaining (sliding window dV/dt extrapolation) +- Added charging state detection based on voltage trend (sliding window) +- Added estimated runtime remaining (INA226 current sensor, auto-detected at runtime) - Added charging status and runtime to MQTT, JSON API, and web UI info panel 2024-08-19 diff --git a/usermods/Battery/types/UnknownUMBattery.h b/usermods/Battery/types/UnknownUMBattery.h new file mode 100644 index 0000000000..5b1049bc8a --- /dev/null +++ b/usermods/Battery/types/UnknownUMBattery.h @@ -0,0 +1,46 @@ +#ifndef UMBUnknown_h +#define UMBUnknown_h + +#include "../battery_defaults.h" +#include "../UMBattery.h" + +/** + * Unknown / Default Battery + * Uses a generic discharge curve LUT that approximates a typical + * single-cell lithium battery. Since the chemistry is unknown, + * the curve is a mild non-linear shape between configured min/max. + */ +class UnknownUMBattery : public UMBattery +{ + private: + // Generic single-cell discharge curve + // Mild non-linear shape, works reasonably for most lithium chemistries + static const LutEntry dischargeLut[] PROGMEM; + static const uint8_t dischargeLutSize; + + public: + UnknownUMBattery() : UMBattery() + { + this->setMinVoltage(USERMOD_BATTERY_UNKNOWN_MIN_VOLTAGE); + this->setMaxVoltage(USERMOD_BATTERY_UNKNOWN_MAX_VOLTAGE); + } + + float mapVoltage(float v) override + { + return this->lutInterpolate(v, dischargeLut, dischargeLutSize); + }; +}; + +const UMBattery::LutEntry UnknownUMBattery::dischargeLut[] PROGMEM = { + {4.20f, 100.0f}, + {4.10f, 90.0f}, + {3.95f, 75.0f}, + {3.80f, 50.0f}, + {3.70f, 30.0f}, + {3.60f, 15.0f}, + {3.50f, 5.0f}, + {3.30f, 0.0f}, +}; +const uint8_t UnknownUMBattery::dischargeLutSize = sizeof(UnknownUMBattery::dischargeLut) / sizeof(UnknownUMBattery::dischargeLut[0]); + +#endif From d3fac23701ef3ad0beb44faaa4b2fab1950b82d6 Mon Sep 17 00:00:00 2001 From: Maximilian Mewes Date: Wed, 25 Feb 2026 11:12:51 +0100 Subject: [PATCH 05/11] bugfix --- usermods/Battery/Battery.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/usermods/Battery/Battery.cpp b/usermods/Battery/Battery.cpp index 268cb821b3..e788f880d2 100644 --- a/usermods/Battery/Battery.cpp +++ b/usermods/Battery/Battery.cpp @@ -255,12 +255,13 @@ class UsermodBattery : public Usermod { nextReadTime = millis() + readingInterval; } - // auto-detect INA226 usermod once after initial delay (all usermods are set up by now) + // auto-detect INA226 usermod (keep trying until first successful sensor read) if (!ina226Probed) { - ina226Probed = true; um_data_t *data = nullptr; - if (UsermodManager::getUMData(&data, USERMOD_ID_INA226) && data) + if (UsermodManager::getUMData(&data, USERMOD_ID_INA226) && data) { estimatedRuntimeEnabled = true; + ina226Probed = true; + } } if (isFirstVoltageReading) { From 9149d8b4c45a0f7580928b0812f1d83cfc10d01d Mon Sep 17 00:00:00 2001 From: Maximilian Mewes Date: Wed, 25 Feb 2026 11:24:20 +0100 Subject: [PATCH 06/11] update readme --- usermods/Battery/readme.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/usermods/Battery/readme.md b/usermods/Battery/readme.md index bdf1363e5c..9b8884a0b6 100644 --- a/usermods/Battery/readme.md +++ b/usermods/Battery/readme.md @@ -295,12 +295,13 @@ Specification from: [Molicel INR18650-M35A, 3500mAh 10A Lithium-ion battery, 3.6 ## 📝 Change Log -2025-02-24 +2025-02-25 -- Added LiFePO4 battery type with piecewise-linear discharge curve mapping -- Added charging state detection based on voltage trend (sliding window) -- Added estimated runtime remaining (INA226 current sensor, auto-detected at runtime) +- Added LiFePO4 battery type with piecewise-linear discharge curve +- Added estimated runtime with Coulomb counting (auto-detected via INA226 usermod) +- Added charging detection using sliding window voltage trend - Added charging status and runtime to MQTT, JSON API, and web UI info panel +- Code cleanup and reorganization 2024-08-19 From b6e8fd902dc65c46582c36c7e5d119670315c9ab Mon Sep 17 00:00:00 2001 From: Maximilian Mewes Date: Wed, 25 Feb 2026 12:03:57 +0100 Subject: [PATCH 07/11] Fixes for CodeRabbit findings --- usermods/Battery/Battery.cpp | 22 +++++++++-- usermods/Battery/UMBattery.h | 4 +- usermods/Battery/readme.md | 2 +- usermods/Battery/types/UnknownUMBattery.h | 46 ----------------------- usermods/INA226_v2/INA226_v2.cpp | 4 +- 5 files changed, 26 insertions(+), 52 deletions(-) delete mode 100644 usermods/Battery/types/UnknownUMBattery.h diff --git a/usermods/Battery/Battery.cpp b/usermods/Battery/Battery.cpp index e788f880d2..2e346b0116 100644 --- a/usermods/Battery/Battery.cpp +++ b/usermods/Battery/Battery.cpp @@ -104,8 +104,10 @@ class UsermodBattery : public Usermod { void lowPowerIndicator() { if (!lowPowerIndicatorEnabled) return; if (batteryPin < 0) return; - if (lowPowerIndicationDone && lowPowerIndicatorReactivationThreshold <= bat->getLevel()) lowPowerIndicationDone = false; - if (lowPowerIndicatorThreshold <= bat->getLevel()) return; + const int8_t level = bat->getLevel(); + if (level < 0) return; // wait for first valid reading + if (lowPowerIndicationDone && lowPowerIndicatorReactivationThreshold <= level) lowPowerIndicationDone = false; + if (lowPowerIndicatorThreshold <= level) return; if (lowPowerIndicationDone) return; if (!lowPowerIndicatorActive) { lowPowerIndicatorActive = true; @@ -341,6 +343,10 @@ class UsermodBattery : public Usermod { estimatedTimeLeft = -1; smoothedCurrent = -1.0f; } + } else { + estimatedTimeLeft = -1; + smoothedCurrent = -1.0f; + atRest = false; } // auto-off @@ -394,7 +400,8 @@ class UsermodBattery : public Usermod { JsonArray infoCharging = user.createNestedArray(F("Battery charging")); JsonArray infoNextUpdate = user.createNestedArray(F("Next update")); - infoNextUpdate.add((nextReadTime - millis()) / 1000); + unsigned long now = millis(); + infoNextUpdate.add(nextReadTime > now ? (nextReadTime - now) / 1000 : 0); infoNextUpdate.add(F(" sec")); if (initializing) { @@ -586,6 +593,15 @@ class UsermodBattery : public Usermod { void onMqttConnect(bool sessionPresent) { if (!HomeAssistantDiscovery) return; + // probe INA226 now in case it wasn't detected yet during loop() + if (!ina226Probed) { + um_data_t *data = nullptr; + if (UsermodManager::getUMData(&data, USERMOD_ID_INA226) && data) { + estimatedRuntimeEnabled = true; + ina226Probed = true; + } + } + registerMqttSensor("battery", F("Battery"), "sensor", "battery", "%"); registerMqttSensor("voltage", F("Voltage"), "sensor", "voltage", "V"); registerMqttSensor("charging", F("Charging"), "binary_sensor", "battery_charging"); diff --git a/usermods/Battery/UMBattery.h b/usermods/Battery/UMBattery.h index e8eab8a5c5..f56998a78f 100644 --- a/usermods/Battery/UMBattery.h +++ b/usermods/Battery/UMBattery.h @@ -48,7 +48,9 @@ class UMBattery memcpy_P(&lo, &lut[i+1], sizeof(LutEntry)); if (v >= lo.voltage) { - float ratio = (v - lo.voltage) / (hi.voltage - lo.voltage); + float span = hi.voltage - lo.voltage; + if (fabsf(span) < 1e-6f) return hi.percent; + float ratio = (v - lo.voltage) / span; return lo.percent + ratio * (hi.percent - lo.percent); } } diff --git a/usermods/Battery/readme.md b/usermods/Battery/readme.md index 9b8884a0b6..2d88a24b9f 100644 --- a/usermods/Battery/readme.md +++ b/usermods/Battery/readme.md @@ -295,7 +295,7 @@ Specification from: [Molicel INR18650-M35A, 3500mAh 10A Lithium-ion battery, 3.6 ## 📝 Change Log -2025-02-25 +2026-02-25 - Added LiFePO4 battery type with piecewise-linear discharge curve - Added estimated runtime with Coulomb counting (auto-detected via INA226 usermod) diff --git a/usermods/Battery/types/UnknownUMBattery.h b/usermods/Battery/types/UnknownUMBattery.h deleted file mode 100644 index 5b1049bc8a..0000000000 --- a/usermods/Battery/types/UnknownUMBattery.h +++ /dev/null @@ -1,46 +0,0 @@ -#ifndef UMBUnknown_h -#define UMBUnknown_h - -#include "../battery_defaults.h" -#include "../UMBattery.h" - -/** - * Unknown / Default Battery - * Uses a generic discharge curve LUT that approximates a typical - * single-cell lithium battery. Since the chemistry is unknown, - * the curve is a mild non-linear shape between configured min/max. - */ -class UnknownUMBattery : public UMBattery -{ - private: - // Generic single-cell discharge curve - // Mild non-linear shape, works reasonably for most lithium chemistries - static const LutEntry dischargeLut[] PROGMEM; - static const uint8_t dischargeLutSize; - - public: - UnknownUMBattery() : UMBattery() - { - this->setMinVoltage(USERMOD_BATTERY_UNKNOWN_MIN_VOLTAGE); - this->setMaxVoltage(USERMOD_BATTERY_UNKNOWN_MAX_VOLTAGE); - } - - float mapVoltage(float v) override - { - return this->lutInterpolate(v, dischargeLut, dischargeLutSize); - }; -}; - -const UMBattery::LutEntry UnknownUMBattery::dischargeLut[] PROGMEM = { - {4.20f, 100.0f}, - {4.10f, 90.0f}, - {3.95f, 75.0f}, - {3.80f, 50.0f}, - {3.70f, 30.0f}, - {3.60f, 15.0f}, - {3.50f, 5.0f}, - {3.30f, 0.0f}, -}; -const uint8_t UnknownUMBattery::dischargeLutSize = sizeof(UnknownUMBattery::dischargeLut) / sizeof(UnknownUMBattery::dischargeLut[0]); - -#endif diff --git a/usermods/INA226_v2/INA226_v2.cpp b/usermods/INA226_v2/INA226_v2.cpp index c18b3f299c..3ea9681723 100644 --- a/usermods/INA226_v2/INA226_v2.cpp +++ b/usermods/INA226_v2/INA226_v2.cpp @@ -69,6 +69,7 @@ class UsermodINA226 : public Usermod unsigned long _lastLoopCheck = 0; unsigned long _lastTriggerTime = 0; + bool _hasValidReading = false; bool _settingEnabled : 1; // Enable the usermod bool _mqttPublish : 1; // Publish MQTT values @@ -169,6 +170,7 @@ class UsermodINA226 : public Usermod _lastPower = power; _lastShuntVoltage = shuntVoltage; _lastOverflow = overflow; + _hasValidReading = true; } void handleTriggeredMode(unsigned long currentTime) @@ -346,7 +348,7 @@ class UsermodINA226 : public Usermod bool getUMData(um_data_t **data) override { - if (!data || !_settingEnabled || _lastLoopCheck == 0) return false; + if (!data || !_settingEnabled || !_hasValidReading) return false; *data = um_data; return true; } From d1ec2c4aa96a9e4d9e61e4d4e2904015082070e3 Mon Sep 17 00:00:00 2001 From: Maximilian Mewes Date: Wed, 25 Feb 2026 12:38:14 +0100 Subject: [PATCH 08/11] Fixes for CodeRabbit findings - round 2 --- usermods/Battery/Battery.cpp | 21 ++++++++++++++++++--- usermods/Battery/readme.md | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/usermods/Battery/Battery.cpp b/usermods/Battery/Battery.cpp index 2e346b0116..4ae7cf604c 100644 --- a/usermods/Battery/Battery.cpp +++ b/usermods/Battery/Battery.cpp @@ -134,6 +134,7 @@ class UsermodBattery : public Usermod { float getINA226Current() { um_data_t *data = nullptr; if (!UsermodManager::getUMData(&data, USERMOD_ID_INA226) || !data) return -1.0f; + if (data->u_size < 1 || !data->u_data || !data->u_data[0]) return -1.0f; return *(float*)data->u_data[0]; } @@ -263,9 +264,20 @@ class UsermodBattery : public Usermod { if (UsermodManager::getUMData(&data, USERMOD_ID_INA226) && data) { estimatedRuntimeEnabled = true; ina226Probed = true; +#ifndef WLED_DISABLE_MQTT + if (HomeAssistantDiscovery && WLED_MQTT_CONNECTED) { + registerMqttSensor("runtime", F("Runtime"), "sensor", "duration", "min"); + } +#endif } } + #ifdef ARDUINO_ARCH_ESP32 + if (batteryPin < 0 || !PinManager::isPinAllocated(batteryPin, PinOwner::UM_Battery)) return; + #else + if (batteryPin < 0) return; + #endif + if (isFirstVoltageReading) { bat->setVoltage(readVoltage()); isFirstVoltageReading = false; @@ -274,8 +286,6 @@ class UsermodBattery : public Usermod { if (millis() < nextReadTime) return; nextReadTime = millis() + readingInterval; - if (batteryPin < 0) return; - initializing = false; float rawValue = readVoltage(); @@ -494,7 +504,12 @@ class UsermodBattery : public Usermod { addBatteryToJsonObject(battery, false); // re-read voltage in case calibration or multiplier changed - bat->setVoltage(readVoltage()); + #ifdef ARDUINO_ARCH_ESP32 + if (batteryPin >= 0 && PinManager::isPinAllocated(batteryPin, PinOwner::UM_Battery)) + #else + if (batteryPin >= 0) + #endif + bat->setVoltage(readVoltage()); DEBUG_PRINTLN(F("Battery config saved.")); } diff --git a/usermods/Battery/readme.md b/usermods/Battery/readme.md index 2d88a24b9f..21ba78e84d 100644 --- a/usermods/Battery/readme.md +++ b/usermods/Battery/readme.md @@ -86,7 +86,7 @@ All parameters can be configured at runtime via the Usermods settings page. | Name | Alias | `my_config.h` example | | ----------------------- | --------------- | ---------------------------------------- | | Lithium Polymer | lipo (Li-Po) | `USERMOD_BATTERY_LIPO_MIN_VOLTAGE` | -| Lithium Ionen | lion (Li-Ion) | `USERMOD_BATTERY_LION_MIN_VOLTAGE` | +| Lithium Ion | lion (Li-Ion) | `USERMOD_BATTERY_LION_MIN_VOLTAGE` | | Lithium Iron Phosphate | lifepo4 (LFP) | `USERMOD_BATTERY_LIFEPO4_MIN_VOLTAGE` |

From 51160d468344b5fa4372abd7df4088d0ec0d5cfe Mon Sep 17 00:00:00 2001 From: Maximilian Mewes Date: Sat, 28 Feb 2026 20:01:10 +0100 Subject: [PATCH 09/11] Review findings - round 3 --- .claude/settings.local.json | 8 ++++++++ usermods/Battery/Battery.cpp | 20 +++++++++++--------- usermods/Battery/readme.md | 5 ++++- 3 files changed, 23 insertions(+), 10 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000..151c501eed --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(gh api repos/wled/WLED/pulls/5399/reviews --jq '.[] | {id, user: .user.login, state: .state, submitted_at: .submitted_at, body: .body}')", + "Bash(gh api repos/wled/WLED/pulls/5399/comments --jq '.[] | {id, user: .user.login, created_at: .created_at, path: .path, line: .line, body: .body}')" + ] + } +} diff --git a/usermods/Battery/Battery.cpp b/usermods/Battery/Battery.cpp index 4ae7cf604c..83edb98a4a 100644 --- a/usermods/Battery/Battery.cpp +++ b/usermods/Battery/Battery.cpp @@ -46,7 +46,7 @@ class UsermodBattery : public Usermod { // --- Charging detection (voltage trend over sliding window) --- bool charging = false; static const uint8_t VOLTAGE_HISTORY_SIZE = 5; // 5 × 30s = 2.5 min window - float voltageHistory[5] = {0}; + float voltageHistory[VOLTAGE_HISTORY_SIZE] = {0}; uint8_t voltageHistoryIdx = 0; bool voltageHistoryFull = false; static constexpr float CHARGE_VOLTAGE_THRESHOLD = 0.01f; // 10mV rise over window @@ -70,7 +70,7 @@ class UsermodBattery : public Usermod { // --- Inter-usermod data exchange --- float umVoltage = 0.0f; - int8_t umLevel = -1; + int16_t umLevel = -1; // --- State --- bool initDone = false; @@ -140,7 +140,7 @@ class UsermodBattery : public Usermod { #ifndef WLED_DISABLE_MQTT void addMqttSensor(const String &name, const String &type, const String &topic, const String &deviceClass, const String &unitOfMeasurement = "", const bool &isDiagnostic = false) { - StaticJsonDocument<600> doc; + StaticJsonDocument<1024> doc; char uid[128], json_str[1024], buf[128]; doc[F("name")] = name; @@ -235,8 +235,8 @@ class UsermodBattery : public Usermod { um_data->u_data = new void*[4]; um_data->u_data[0] = &umVoltage; // float, Volts um_data->u_type[0] = UMT_FLOAT; - um_data->u_data[1] = &umLevel; // int8_t, 0-100% - um_data->u_type[1] = UMT_BYTE; + um_data->u_data[1] = &umLevel; // int16_t, 0-100% (or -1 if invalid) + um_data->u_type[1] = UMT_INT16; um_data->u_data[2] = &charging; // bool um_data->u_type[2] = UMT_BYTE; um_data->u_data[3] = &cfg.type; // batteryType enum (1=lipo,2=lion,3=lifepo4) @@ -316,8 +316,10 @@ class UsermodBattery : public Usermod { // initialize Coulomb counter from voltage-based SoC on first valid reading if (!coulombInitialized) { - coulombSoC = bat->getLevel() / 100.0f; - coulombInitialized = true; + if (bat->getLevel() >= 0) { + coulombSoC = bat->getLevel() / 100.0f; + coulombInitialized = true; + } lastCoulombTime = now; } else { float dt_hours = (now - lastCoulombTime) / 3600000.0f; @@ -333,7 +335,7 @@ class UsermodBattery : public Usermod { // recalibrate from voltage-based SoC when battery is at rest (OCV is accurate) if (current_A < REST_CURRENT_THRESHOLD) { if (!atRest) { atRest = true; restStartTime = now; } - if (now - restStartTime >= REST_RECALIBRATE_MS) { + if (now - restStartTime >= REST_RECALIBRATE_MS && bat->getLevel() >= 0) { coulombSoC = bat->getLevel() / 100.0f; } } else { @@ -360,7 +362,7 @@ class UsermodBattery : public Usermod { } // auto-off - if (autoOffEnabled && (autoOffThreshold >= bat->getLevel())) + if (autoOffEnabled && bat->getLevel() >= 0 && autoOffThreshold >= bat->getLevel()) turnOff(); #ifndef WLED_DISABLE_MQTT diff --git a/usermods/Battery/readme.md b/usermods/Battery/readme.md index 21ba78e84d..a1e55e5fb2 100644 --- a/usermods/Battery/readme.md +++ b/usermods/Battery/readme.md @@ -135,6 +135,9 @@ If none of the built-in battery types match your cell chemistry, you can add you // Discharge lookup table – voltage (descending) → percentage // Obtain this data from your cell's datasheet + // Note: these definitions live in the header because Battery.cpp is the only + // translation unit that includes battery-type headers (same pattern as the + // built-in types). Do not include this header from other .cpp files. const UMBattery::LutEntry MyBattery::dischargeLut[] PROGMEM = { {4.20f, 100.0f}, {3.90f, 75.0f}, @@ -191,7 +194,7 @@ If none of the built-in battery types match your cell chemistry, you can add you ## ⏱️ Estimated Runtime -The battery usermod can estimate the remaining runtime of your project. This feature is **automatically enabled** when the [INA226 usermod](../INA226_v2/) is detected at runtime — no extra configuration needed. +The battery usermod can estimate the remaining runtime of your project. This feature is **automatically enabled** when the [INA226 usermod](../INA226_v2/) is detected at runtime — no additional build flags or compile-time configuration are required beyond adding the INA226 usermod to `custom_usermods` (see [Setup](#setup) below). ### How it works From 519eefe6ff3a2ad0aa3005515408c1d60055cc2e Mon Sep 17 00:00:00 2001 From: Maximilian Mewes Date: Sat, 28 Feb 2026 20:19:25 +0100 Subject: [PATCH 10/11] Update use suggestions from the example usermod repo --- usermods/Battery/Battery.cpp | 95 +++++++++++++++++++++++++---- usermods/Battery/battery_defaults.h | 4 ++ usermods/Battery/readme.md | 12 ++++ 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/usermods/Battery/Battery.cpp b/usermods/Battery/Battery.cpp index 83edb98a4a..a4a4315e3f 100644 --- a/usermods/Battery/Battery.cpp +++ b/usermods/Battery/Battery.cpp @@ -73,6 +73,7 @@ class UsermodBattery : public Usermod { int16_t umLevel = -1; // --- State --- + bool enabled = true; bool initDone = false; bool initializing = true; bool HomeAssistantDiscovery = false; @@ -101,6 +102,33 @@ class UsermodBattery : public Usermod { stateUpdated(CALL_MODE_DIRECT_CHANGE); } +#ifdef USERMOD_BATTERY_ALLOW_REMOTE_UPDATE + void applyJsonConfig(JsonObject& battery) { + enabled = battery[FPSTR(_enabled)] | enabled; + bat->setCalibration(battery[F("calibration")] | bat->getCalibration()); + bat->setVoltageMultiplier(battery[F("voltage-multiplier")] | bat->getVoltageMultiplier()); + batteryCapacity = battery[F("capacity")] | batteryCapacity; + bat->setMinVoltage(battery[F("min-voltage")] | bat->getMinVoltage()); + bat->setMaxVoltage(battery[F("max-voltage")] | bat->getMaxVoltage()); + setReadingInterval(battery[FPSTR(_readInterval)] | readingInterval); + + JsonObject ao = battery[F("auto-off")]; + if (!ao.isNull()) { + autoOffEnabled = ao[FPSTR(_enabled)] | autoOffEnabled; + setAutoOffThreshold(ao[FPSTR(_threshold)] | autoOffThreshold); + } + + JsonObject lp = battery[F("indicator")]; + if (!lp.isNull()) { + lowPowerIndicatorEnabled = lp[FPSTR(_enabled)] | lowPowerIndicatorEnabled; + lowPowerIndicatorPreset = lp[FPSTR(_preset)] | lowPowerIndicatorPreset; + setLowPowerIndicatorThreshold(lp[FPSTR(_threshold)] | lowPowerIndicatorThreshold); + lowPowerIndicatorReactivationThreshold = lowPowerIndicatorThreshold + 10; + lowPowerIndicatorDuration = lp[FPSTR(_duration)] | lowPowerIndicatorDuration; + } + } +#endif + void lowPowerIndicator() { if (!lowPowerIndicatorEnabled) return; if (batteryPin < 0) return; @@ -188,6 +216,9 @@ class UsermodBattery : public Usermod { #endif public: + inline void enable(bool en) { enabled = en; } + inline bool isEnabled() { return enabled; } + // ============================================= // Factory & Lifecycle // ============================================= @@ -201,7 +232,7 @@ class UsermodBattery : public Usermod { } } - void setup() { + void setup() override { delete bat; bat = createBattery(cfg.type); bat->update(cfg); @@ -246,8 +277,8 @@ class UsermodBattery : public Usermod { initDone = true; } - void loop() { - if (strip.isUpdating()) return; + void loop() override { + if (!enabled || strip.isUpdating()) return; lowPowerIndicator(); @@ -375,14 +406,14 @@ class UsermodBattery : public Usermod { #endif } - uint16_t getId() { + uint16_t getId() override { return USERMOD_ID_BATTERY; } /* * Battery data exposed to other usermods via getUMData(): * slot 0: voltage (float, Volts) - * slot 1: level (int8_t, 0-100%) + * slot 1: level (int16_t, 0-100% or -1 if invalid) * slot 2: charging (bool) * slot 3: type (uint8_t, 1=lipo,2=lion,3=lifepo4) */ @@ -396,10 +427,16 @@ class UsermodBattery : public Usermod { // JSON Info & State // ============================================= - void addToJsonInfo(JsonObject& root) { + void addToJsonInfo(JsonObject& root) override { JsonObject user = root["u"]; if (user.isNull()) user = root.createNestedObject("u"); + if (!enabled) { + JsonArray infoBattery = user.createNestedArray(FPSTR(_name)); + infoBattery.add(F("disabled")); + return; + } + if (batteryPin < 0) { JsonArray infoVoltage = user.createNestedArray(F("Battery voltage")); infoVoltage.add(F("n/a")); @@ -456,13 +493,24 @@ class UsermodBattery : public Usermod { } } - void addToJsonState(JsonObject& root) { + void addToJsonState(JsonObject& root) override { + if (!initDone) return; // prevent crash on boot applyPreset() JsonObject battery = root.createNestedObject(FPSTR(_name)); addBatteryToJsonObject(battery, true); DEBUG_PRINTLN(F("Battery state exposed in JSON API.")); } +#ifdef USERMOD_BATTERY_ALLOW_REMOTE_UPDATE + void readFromJsonState(JsonObject& root) override { + if (!initDone) return; + JsonObject battery = root[FPSTR(_name)]; + if (battery.isNull()) return; + applyJsonConfig(battery); + } +#endif + void addBatteryToJsonObject(JsonObject& battery, bool forJsonState) { + battery[FPSTR(_enabled)] = enabled; if (forJsonState) { battery[F("type")] = cfg.type; battery[F("charging")] = charging; @@ -496,7 +544,7 @@ class UsermodBattery : public Usermod { // Configuration // ============================================= - void addToConfig(JsonObject& root) { + void addToConfig(JsonObject& root) override { JsonObject battery = root.createNestedObject(FPSTR(_name)); #ifdef ARDUINO_ARCH_ESP32 @@ -516,7 +564,7 @@ class UsermodBattery : public Usermod { DEBUG_PRINTLN(F("Battery config saved.")); } - void appendConfigData() { + void appendConfigData() override { oappend(F("td=addDropdown('Battery','type');")); // 34 Bytes oappend(F("addOption(td,'LiPo','1');")); // 26 Bytes oappend(F("addOption(td,'LiOn','2');")); // 26 Bytes @@ -533,7 +581,7 @@ class UsermodBattery : public Usermod { } // Called BEFORE setup() on boot and on settings save - bool readFromConfig(JsonObject& root) { + bool readFromConfig(JsonObject& root) override { #ifdef ARDUINO_ARCH_ESP32 int8_t newBatteryPin = batteryPin; #endif @@ -545,6 +593,8 @@ class UsermodBattery : public Usermod { return false; } + enabled = battery[FPSTR(_enabled)] | enabled; + #ifdef ARDUINO_ARCH_ESP32 newBatteryPin = battery[F("pin")] | newBatteryPin; #endif @@ -607,7 +657,17 @@ class UsermodBattery : public Usermod { // ============================================= #ifndef WLED_DISABLE_MQTT - void onMqttConnect(bool sessionPresent) { + void onMqttConnect(bool sessionPresent) override { +#ifdef USERMOD_BATTERY_ALLOW_REMOTE_UPDATE + // subscribe to battery config updates + if (mqttDeviceTopic[0] != 0) { + char subuf[64]; + strcpy(subuf, mqttDeviceTopic); + strcat_P(subuf, PSTR("/battery/set")); + mqtt->subscribe(subuf, 0); + } +#endif + if (!HomeAssistantDiscovery) return; // probe INA226 now in case it wasn't detected yet during loop() @@ -627,6 +687,19 @@ class UsermodBattery : public Usermod { } } +#ifdef USERMOD_BATTERY_ALLOW_REMOTE_UPDATE + bool onMqttMessage(char* topic, char* payload) override { + if (strlen(topic) < 12 || strncmp_P(topic, PSTR("/battery/set"), 12) != 0) return false; + + StaticJsonDocument<256> doc; + if (deserializeJson(doc, payload)) return false; + + JsonObject obj = doc.as(); + applyJsonConfig(obj); + return true; + } +#endif + void registerMqttSensor(const char* subtopic, const String &name, const char* type, const char* deviceClass, const char* unit = "", bool diagnostic = true) { char topic[128]; snprintf_P(topic, 127, PSTR("%s/%s"), mqttDeviceTopic, subtopic); diff --git a/usermods/Battery/battery_defaults.h b/usermods/Battery/battery_defaults.h index c5f530a71d..5e09f992d9 100644 --- a/usermods/Battery/battery_defaults.h +++ b/usermods/Battery/battery_defaults.h @@ -122,6 +122,10 @@ #define USERMOD_BATTERY_CAPACITY 3000 #endif +// Enable remote battery config updates via JSON API and MQTT +// Uncomment below or define in my_config.h / build flags to allow runtime config changes +// #define USERMOD_BATTERY_ALLOW_REMOTE_UPDATE + // battery types typedef enum { diff --git a/usermods/Battery/readme.md b/usermods/Battery/readme.md index a1e55e5fb2..c253c6579b 100644 --- a/usermods/Battery/readme.md +++ b/usermods/Battery/readme.md @@ -298,6 +298,18 @@ Specification from: [Molicel INR18650-M35A, 3500mAh 10A Lithium-ion battery, 3.6 ## 📝 Change Log +2026-02-28 + +- Added `readFromJsonState()` for remote config updates via JSON API +- Added `onMqttMessage()` for remote config updates via MQTT (`/battery/set`) +- Both gated behind `USERMOD_BATTERY_ALLOW_REMOTE_UPDATE` compile-time flag +- Added `override` keyword to all overridden methods for compile-time safety +- Added `addToJsonState()` init guard to prevent crash on boot +- Increased MQTT discovery JSON buffer from 600 to 1024 bytes +- Fixed `umLevel` type mismatch (`int8_t`/`UMT_BYTE` → `int16_t`/`UMT_INT16`) +- Fixed auto-off, Coulomb init, and rest recalibration firing on invalid `-1` level +- Used `VOLTAGE_HISTORY_SIZE` constant instead of magic number for array size + 2026-02-25 - Added LiFePO4 battery type with piecewise-linear discharge curve From 5b633541a5eed83de8c30f69cbec4a39e0814818 Mon Sep 17 00:00:00 2001 From: Maximilian Mewes Date: Mon, 9 Mar 2026 23:12:57 +0100 Subject: [PATCH 11/11] remove settings.local.json --- .claude/settings.local.json | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 151c501eed..0000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(gh api repos/wled/WLED/pulls/5399/reviews --jq '.[] | {id, user: .user.login, state: .state, submitted_at: .submitted_at, body: .body}')", - "Bash(gh api repos/wled/WLED/pulls/5399/comments --jq '.[] | {id, user: .user.login, created_at: .created_at, path: .path, line: .line, body: .body}')" - ] - } -}