Skip to content
1,006 changes: 443 additions & 563 deletions usermods/Battery/Battery.cpp

Large diffs are not rendered by default.

54 changes: 44 additions & 10 deletions usermods/Battery/UMBattery.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,61 @@ 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;
float voltage;
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 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);
}
}
return last.percent;
}

public:
UMBattery()
{
this->setVoltageMultiplier(USERMOD_BATTERY_VOLTAGE_MULTIPLIER);
this->setCalibration(USERMOD_BATTERY_CALIBRATION);
}

virtual ~UMBattery() = default;

virtual void update(batteryConfig cfg)
{
if(cfg.minVoltage) this->setMinVoltage(cfg.minVoltage);
Expand All @@ -42,15 +77,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));
}



Expand Down Expand Up @@ -110,14 +144,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);
}

/*
Expand Down
41 changes: 25 additions & 16 deletions usermods/Battery/battery_defaults.h
Original file line number Diff line number Diff line change
Expand Up @@ -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

/*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -117,12 +117,21 @@
#define USERMOD_BATTERY_LOW_POWER_INDICATOR_DURATION 5
#endif

// battery capacity in mAh (used for runtime estimation with INA226 current sensor)
#ifndef USERMOD_BATTERY_CAPACITY
#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
{
unknown=0,
lipo=1,
lion=2
lion=2,
lifepo4=3
} batteryType;

// used for initial configuration after boot
Expand Down
197 changes: 193 additions & 4 deletions usermods/Battery/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (requires INA226 current sensor)
- 🚫 Auto-off with configurable threshold
- 🚨 Low power indicator with many configuration possibilities

Expand Down Expand Up @@ -67,6 +69,8 @@ In `platformio_override.ini` (or `platformio.ini`)<br>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 |
Expand All @@ -79,10 +83,175 @@ 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 Ion | lion (Li-Ion) | `USERMOD_BATTERY_LION_MIN_VOLTAGE` |
| Lithium Iron Phosphate | lifepo4 (LFP) | `USERMOD_BATTERY_LIFEPO4_MIN_VOLTAGE` |

<br><br>

## 🔋 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
// 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},
{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.

<br><br>

## ⏱️ 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 additional build flags or compile-time configuration are required beyond adding the INA226 usermod to `custom_usermods` (see [Setup](#setup) below).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the exact INA226 usermod name consistently in setup instructions.

The docs point to INA226_v2, but the custom_usermods example uses INA226. Please keep these aligned to avoid copy/paste misconfiguration.

✏️ Suggested edit
-custom_usermods = Battery INA226
+custom_usermods = Battery INA226_v2

Also applies to: 210-212

🧰 Tools
🪛 LanguageTool

[grammar] ~197-~197: Ensure spelling is correct
Context: ...> ## ⏱️ Estimated Runtime The battery usermod can estimate the remaining runtime of y...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@usermods/Battery/readme.md` at line 197, The README references the INA226
usermod inconsistently—update the setup instructions and the custom_usermods
example so they use the exact same usermod name (either "INA226" or "INA226_v2")
throughout (including the paragraph that mentions "INA226 usermod" and the
example block referenced around lines 197 and 210-212) to prevent copy/paste
misconfiguration; pick the canonical module name used by the repository (e.g.,
the directory name or module identifier) and replace the mismatched occurrences
so all mentions (setup text and custom_usermods example) are identical.


### 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 |

<br><br>

Expand Down Expand Up @@ -129,6 +298,26 @@ 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 (`<deviceTopic>/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
- 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

- Improved MQTT support
Expand Down
Loading