An ESP32-C3 Super Mini firmware that displays a battery/power percentage on any WS2812B LED strip or compatible lamp. The percentage value is received from Domoticz/Home Assistant via MQTT, from the Victron VRM Portal, or set directly through the built-in web interface or REST API.
Author: PA1DVB
| Component | Value |
|---|---|
| MCU | ESP32-C3 Super Mini |
| LED strip | WS2812B / WS2811 (colour order and signal speed configurable) |
| Default LED count | 38 pixels |
| Default data pin | GPIO 4 |
Where to buy the ESP32-C3 Super Mini:
- AliExpress
- Search for "ESP32-C3 Super Mini" on Amazon
The firmware works with any WS2812B or WS2811-based LED strip or lamp. The pixel colour order (NEO_GRB, NEO_RGB, NEO_RBG, …) and signal speed (800 kHz for WS2812B, 400 kHz for WS2811) are configurable in the web interface without recompilation. Tested with:
| Product | Notes |
|---|---|
| Generic WS2812B strip | Any density / length |
| Generic WS2811 strip | Set signal speed to 400 kHz in configuration |
| Philips RGBIC Ambient Floor Lamp (142 cm) | Tap into the WS2812B data line |
| Grundig LED Corner Floor Lamp | Replace the built-in controller with the ESP32-C3 |
5 V LED strips (e.g. WS2812B): Power the ESP32-C3 and the strip directly from a 5 V supply — no converter needed.
12 V LED strips (e.g. WS2811): The ESP32-C3 runs on 5 V, so add a DC-DC Step Down Buck Converter to step the 12 V rail down to 5 V and power the ESP32 from it — do not connect 12 V directly to the board. We recommend an MP1584EN-based module, as these are well suited to DIY / ESP projects.
Wiring diagram for a 12 V setup:
The LED strip fills from bottom to top proportional to the percentage value. Three colour zones indicate the charge level:
| Zone | Default range | Colour |
|---|---|---|
| Low | 0 – threshold 1 | Red |
| Middle | threshold 1 – threshold 2 | Configurable (default blue) |
| High | threshold 2 – 100 % | Green |
A configurable fade gradient blends between zones.
If your lamp hides the first and last LEDs inside its fitting, set the visible window (First/Last visible pixel) in the configuration so the percentage fills only the LEDs you can actually see — see Configuration.
IDE: Arduino IDE with the ESP32 Arduino core installed.
Board settings (Tools menu):
| Setting | Value |
|---|---|
| Board | ESP32C3 Dev Module |
| Partition Scheme | Minimal SPIFFS (1.9 MB APP with OTA / 128 KB SPIFFS) |
| Upload Speed | 921600 |
| USB CDC On Boot | Enabled — required for Serial Monitor output |
| Serial monitor | 115200 baud |
The default 1.25 MB partition is too small because
WiFiClientSecure(used for VRM) adds ~400 KB. Select the Minimal SPIFFS scheme to get a 1.9 MB app partition.
Required libraries — install all via Sketch → Include Library → Manage Libraries:
| Library | Notes |
|---|---|
| Adafruit NeoPixel | LED strip control |
| ArduinoJson | JSON serialisation |
| ArduinoOTA | OTA updates |
| PubSubClient | MQTT client |
| WiFiManager (tzapu) | Captive portal |
| WebServer | Built-in ESP32 library |
| SPIFFS | Built-in ESP32 library |
Modbus TCP uses a raw
WiFiClientsocket — no extra library needed.
Upload methods:
- USB — first flash; use the normal upload button in Arduino IDE
- OTA — after first flash; device appears under Tools → Port → Network ports using hostname
LEDPOWER-AABBCCDDEEFF(password is the same device ID)
On first boot (or after a WiFi reset) the device opens a captive portal access point named after its device ID (e.g. LEDPOWER-AABBCCDDEEFF). Connect to it with any phone or computer — the captive portal should open automatically; if it does not, browse to http://192.168.4.1 manually. Fill in:
- WiFi SSID / password (selected from a scan list)
All other settings (MQTT, VRM, LED) are configured through the web interface after WiFi is connected.
The portal closes automatically after 180 seconds of inactivity and reboots the device. If the saved WiFi network is slow to boot, the device waits up to 60 seconds before opening the portal.
While in AP mode the bottom ~10 % of the strip breathes blue. While trying to connect to WiFi or MQTT the same pixels show a single orange bouncing dot. Two green blinks confirm a successful WiFi connection.
Once connected, browse to the device IP address (shown on the serial monitor, or find it in your router's DHCP list).
Displays:
- Device ID (including MAC address)
- Firmware version and author
- Current LED state (ON / OFF)
- Current level (%)
- Charging state badge (Idle / Charging / Discharging)
- Active animation name
Controls:
- Slider — drag to set the level (0–100 %); the strip updates automatically when you release. Setting to 0 turns the strip off; any value above 0 turns it on.
- Turn ON / Turn OFF buttons — toggle without changing the saved level. Turning on at 0 % defaults to 100 %.
- Idle / Charging / Discharging buttons — set the charging state animation. Selecting Charging or Discharging automatically turns the strip on if it is off.
- Animation dropdown — select an ambient animation from a grouped list (see Animations below). Choosing an animation overrides the colour display; selecting -- Off -- returns to the normal percentage display.
The status values update every 5 seconds via background fetch without reloading the page. The brightness slider is not overwritten while it is being dragged.
| Setting | Description |
|---|---|
| MQTT Enabled | Enable MQTT — can run alongside any Victron data source |
| MQTT Server | Broker hostname or IP address |
| MQTT Port | Broker port (default 1883; use 8883 for TLS) |
| MQTT Username | Leave blank if not required |
| MQTT Password | Leave blank if not required |
| MQTT Secure (TLS) | Enable TLS encryption (certificate errors are ignored) |
| MQTT Discovery Prefix | Home Assistant / Domoticz discovery prefix (default homeassistant) |
| Victron data source | None / VRM (cloud API) / Modbus TCP (local) — radio button |
| VRM API Token | (VRM only) Personal access token from VRM Portal → Preferences → Integrations → Access tokens |
| VRM Site ID | (VRM only) 0 = auto-select first installation |
| Modbus Host (GX IP) | (Modbus only) IP address of the Victron GX device |
| Battery Instance | VRM: -1 = auto. Modbus: unit ID (0 = first battery) |
| Charge threshold (A) | Current above this = Charging (default 0.5 A) |
| Discharge threshold (A) | Current below this = Discharging (default −0.5 A) |
| Poll interval (s) | Seconds between polls, shared by VRM and Modbus (default 60) |
| Low threshold (%) | Upper edge of the red zone |
| High threshold (%) | Lower edge of the green zone |
| LED pixel count | Number of pixels on the strip (1–500) |
| First visible pixel | First pixel of the visible window (1-based, inclusive) — pixels before it stay dark |
| Last visible pixel | Last pixel of the visible window (1-based, inclusive) — pixels after it stay dark |
| LED data pin (GPIO) | WS2812B data pin — dropdown restricted to valid ESP32-C3 pins |
| Pixel color order | RGB byte order of the strip (default NEO_RBG; most strips use NEO_GRB) |
| LED signal speed | 800 KHz (WS2812B) or 400 KHz (WS2811) |
| Fade pixels | Width of the colour-blend gradient between zones |
| Middle zone color | Colour picker for the middle zone |
| Charge/Discharge animation | Enable/disable the white sweep animation |
| Animation interval (s) | Seconds between animation sweeps (2–60, default 10) |
| Base brightness | Overall strip brightness (0–255) |
Changes to pixel count and GPIO pin take effect immediately. Enabling or disabling MQTT / VRM takes effect immediately after saving — the strip blinks twice to confirm.
Visible window. Some lamps hide the first and last few LEDs inside the fitting. Set First/Last visible pixel to the range that is actually visible (e.g. a 38-pixel strip whose visible part runs from pixel 4 to 34). The 0–100% percentage display, the colour zones, and the charge/discharge sweep all map onto this window only; pixels outside it always stay dark. Both values are 1-based and inclusive, default to the whole strip, and are clamped to the pixel count. Use the End Markers diagnostic animation (see Animations) to calibrate: it lights the whole strip dim white and marks the configured first pixel red and last pixel blue, updating live as you save.
Clears saved WiFi credentials and reboots into the captive portal.
The REST API works regardless of whether MQTT is enabled. All endpoints return JSON.
Returns the current device state.
Response:
{
"state": "on",
"brightness": 75,
"charging_state": "idle",
"animation": "none",
"ip": "192.168.1.42",
"rssi": -62
}| Field | Values |
|---|---|
state |
"on" | "off" |
brightness |
0–100 |
charging_state |
"idle" | "charging" | "discharging" |
animation |
"none" | animation name (see Animations) |
ip |
current IP address |
rssi |
WiFi signal strength in dBm |
Example:
curl http://192.168.1.42/api/stateSets one or more state values. Only the fields you include are changed; omitted fields are left unchanged.
Setting brightness to 0 turns the strip off; any value above 0 turns it on.
Request body (JSON):
{
"state": "on",
"brightness": 60,
"charging_state": "charging"
}| Field | Type | Values |
|---|---|---|
state |
string | "on" | "off" |
brightness |
integer | 0–100 |
charging_state |
string | "idle" | "charging" | "discharging" |
Response: same format as GET /api/state with the updated values.
Examples:
# Turn on at 80 %
curl -X POST http://192.168.1.42/api/state \
-H "Content-Type: application/json" \
-d '{"state":"on","brightness":80}'
# Set charging state only
curl -X POST http://192.168.1.42/api/state \
-H "Content-Type: application/json" \
-d '{"charging_state":"discharging"}'
# Turn off via brightness
curl -X POST http://192.168.1.42/api/state \
-H "Content-Type: application/json" \
-d '{"brightness":0}'Starts or stops an ambient animation on the strip.
Request body (JSON):
{"animation": "fire"}| Field | Values |
|---|---|
animation |
"none" or any animation name from the Animations section — dynamic (e.g. "fire", "rainbow"), fixed colours ("solid_red" … "solid_purple"), gradients ("grad_sunset" … "grad_candy"), or the "diag_ends" diagnostic |
Setting "none" stops the animation and returns to the normal percentage display. Any other value starts that animation and overrides the normal display (and clears any active solid-colour override).
Response: same format as GET /api/state with the updated values.
Examples:
# Start fire animation
curl -X POST http://192.168.1.42/api/animation \
-H "Content-Type: application/json" \
-d '{"animation":"fire"}'
# Stop animation
curl -X POST http://192.168.1.42/api/animation \
-H "Content-Type: application/json" \
-d '{"animation":"none"}'Fills the entire strip with a single RGB colour immediately. Useful for testing the Pixel color order setting — send a pure red, green, or blue and confirm the correct pixels light up.
The effect is temporary; the next state change or animation will restore the normal display.
Request body (JSON):
{"r": 255, "g": 0, "b": 0}Response:
{"r": 255, "g": 0, "b": 0}Examples:
# Full red
curl -X POST http://192.168.1.42/api/color \
-H "Content-Type: application/json" \
-d '{"r":255,"g":0,"b":0}'
# Full green
curl -X POST http://192.168.1.42/api/color \
-H "Content-Type: application/json" \
-d '{"r":0,"g":255,"b":0}'
# Strip off
curl -X POST http://192.168.1.42/api/color \
-H "Content-Type: application/json" \
-d '{"r":0,"g":0,"b":0}'MQTT is disabled by default and is mutually exclusive with Victron VRM — only one can be active at a time. Enable it on the Configuration page, fill in the broker details, and press Save. The strip blinks twice to confirm the connection; no reboot is needed.
When enabled, the firmware uses Home Assistant MQTT Auto Discovery and registers four entities automatically.
| Topic | Description |
|---|---|
esp32-ledpower/<ID>/status |
Availability (online / offline) |
esp32-ledpower/<ID>/state |
State JSON (read) |
esp32-ledpower/<ID>/command |
Command JSON (write) |
State payload:
{"state": "ON", "brightness": 75, "charging_state": "idle"}Command payload (sent by HA to control the light):
{"state": "ON", "brightness": 60}The brightness value is on a 0–100 scale (brightness_scale: 100). Setting brightness to 0 via MQTT turns the strip off; any non-zero value turns it on.
| Topic | Description |
|---|---|
esp32-ledpower/<ID>/state |
State JSON — read charging_state field |
esp32-ledpower/<ID>/charging_state/set |
Write idle / charging / discharging |
Example — set charging state via MQTT:
mosquitto_pub -h <broker> \
-t "esp32-ledpower/LEDPOWER-AABBCCDDEEFF/charging_state/set" \
-m "charging"A third entity registers as an RGB light. Sending it a colour fills every pixel on the strip with that solid colour, overriding the normal percentage display. Turning it off restores the normal display. The override is also cleared whenever the main light entity receives a new command.
| Topic | Description |
|---|---|
esp32-ledpower/<ID>/color/state |
State JSON (read) |
esp32-ledpower/<ID>/color/command |
Command JSON (write) |
State / command payload:
{"state": "ON", "color": {"r": 255, "g": 0, "b": 0}}Examples:
# Fill strip with red
mosquitto_pub -h <broker> \
-t "esp32-ledpower/LEDPOWER-AABBCCDDEEFF/color/command" \
-m '{"state":"ON","color":{"r":255,"g":0,"b":0}}'
# Restore normal display
mosquitto_pub -h <broker> \
-t "esp32-ledpower/LEDPOWER-AABBCCDDEEFF/color/command" \
-m '{"state":"OFF"}'A fourth entity registers as a select entity for controlling ambient animations.
| Topic | Description |
|---|---|
esp32-ledpower/<ID>/animation/state |
Current animation name (plain text, read) |
esp32-ledpower/<ID>/animation/set |
Set animation by name (plain text, write) |
Options: none, rainbow, fire, meteor, twinkle, breathe, lava, waterfall, gradient, pulse, rain, starfield, notification
Example — start the fire animation via MQTT:
mosquitto_pub -h <broker> \
-t "esp32-ledpower/LEDPOWER-AABBCCDDEEFF/animation/set" \
-m "fire"The device ID is LEDPOWER- followed by the full 6-byte MAC address in uppercase hex (visible on the status page and in the serial log).
| Situation | Animation |
|---|---|
| AP mode (captive portal open) | Bottom ~10 % of strip: slow blue breathing |
| Connecting to WiFi / MQTT | Bottom ~10 % of strip: single orange bouncing pixel |
| WiFi connected | Bottom ~10 % of strip: two green blinks |
| MQTT or VRM enabled / disabled on config save | Two green blinks |
| Charging state active | Single white pixel sweeping upward, repeating at the configured interval |
| Discharging state active | Single white pixel sweeping downward, repeating at the configured interval |
| Idle state | No animation |
- The charge/discharge animation starts immediately when the state is set, then repeats at the configured interval (default 10 s, range 2–60 s).
- The animation only runs when the LED strip is ON and Charge/Discharge animation is enabled in the configuration.
- Switching between Charging and Discharging resets and restarts the sweep immediately.
Available from the web UI dropdown (grouped into the categories below), the REST API, or MQTT. Selecting any entry overrides the normal percentage display and the charge/discharge sweep for the duration. Selecting none (or "none" via API/MQTT) returns to the normal display.
Ambient / relaxing
| Name | Description |
|---|---|
lava |
Slow-moving warm blobs drifting up and down |
waterfall |
Cyan droplets cascading downward |
gradient |
Breathing gradient cycling through teal / violet / amber waypoints |
breathe |
Whole strip fades in and out in soft white (or the middle-zone colour) |
starfield |
Stars drifting upward and slowly twinkling |
Dynamic / energetic
| Name | Description |
|---|---|
fire |
Flickering red/orange fire effect rising from the base |
pulse |
Bright energy shot racing from the base to the top, then a pause |
meteor |
Bright blue-white comet with a fading trail shooting upward repeatedly |
rain |
Blue rain drops falling at random positions with splashes |
notification |
Two quick amber pulses then a pause — mimics the Alexa notification ring |
Colorful
| Name | Description |
|---|---|
rainbow |
Continuous hue-cycle scrolling across all pixels |
twinkle |
Random pixels sparkle on and off in warm gold / blue / white |
Fixed colors — fill the whole strip with one static colour.
| Name | Colour |
|---|---|
solid_red |
Red |
solid_green |
Green |
solid_blue |
Blue |
solid_white |
White |
solid_warmwhite |
Warm white |
solid_cyan |
Cyan |
solid_magenta |
Magenta |
solid_yellow |
Yellow |
solid_orange |
Orange |
solid_purple |
Purple |
Gradients — static blend from a bottom colour to a top colour.
| Name | Blend (bottom → top) |
|---|---|
grad_sunset |
Orange → purple |
grad_ocean |
Blue → cyan |
grad_forest |
Green → yellow |
grad_fire |
Red → yellow |
grad_candy |
Pink → cyan |
Diagnostics
| Name | Description |
|---|---|
diag_ends |
End Markers — lights the whole strip dim white, marks the configured first visible pixel red and last visible pixel blue. Used to calibrate the visible window (updates live as you save the config). |
Setting a solid colour via /api/color or the MQTT colour entity also clears any active animation. Starting an animation clears any active solid colour override.
OTA (Over-The-Air) updates are supported via the Arduino IDE or espota.py.
- Hostname: device ID (e.g.
LEDPOWER-AABBCCDDEEFF) - Password: same as the device ID
In the Arduino IDE, the device appears under Tools → Port → Network ports after a few seconds.
On boot the device prints a summary to the serial port (115200 baud):
App version: 2026.04.24 rev 1.0
LED pin: GPIO4, pixels: 38
CPU freq: 160 MHz
Device ID: LEDPOWER-AABBCCDDEEFF
MQTT availability: esp32-ledpower/LEDPOWER-AABBCCDDEEFF/status
WiFi: connecting...
WiFi: connected. IP: 192.168.1.42 RSSI: -58 dBm
If MQTT is enabled, connection attempts are also logged with the PubSubClient state code on failure.
All settings are stored in SPIFFS as /config.json. If the filesystem cannot be mounted it is formatted automatically and all settings reset to defaults.
| Setting | Default |
|---|---|
| mqtt_enabled | false |
| mqtt_secure | false |
| mqtt_server | example.tld |
| mqtt_port | 1883 |
| mqtt_username | (empty) |
| mqtt_password | (empty) |
| mqtt_discovery_prefix | homeassistant |
| vrm_enabled | false |
| vrm_api_token | (empty) |
| vrm_site_id | 0 (auto) |
| vrm_battery_instance | -1 (auto) |
| vrm_charge_threshold | 0.5 |
| vrm_discharge_threshold | -0.5 |
| vrm_interval | 60 |
| threshold_1 | 20 |
| threshold_2 | 60 |
| fade_pixels | 3 |
| num_pixels | 38 |
| first_pixel | 1 (1-based, inclusive) |
| last_pixel | 38 (= num_pixels, whole strip) |
| led_pin | 4 |
| pixel_color_order | 9 (NEO_RBG) |
| pixel_khz | 800 |
| middle_r / g / b | 0 / 0 / 255 (blue) |
| charge_anim | true |
| charge_anim_interval | 10 |
| base_brightness | 128 |
The firmware can poll the Victron VRM Portal for battery state-of-charge (SOC) and current, and update the LED strip automatically.
- Log in to the VRM Portal and navigate to Preferences → Integrations → Access tokens.
- Click Add, give the token a name (e.g.
LEDPower), and copy it immediately — it cannot be retrieved again. - Open the device configuration page, enable Victron VRM, paste the token into VRM API Token, and optionally enter a VRM Site ID (leave at 0 to auto-select the first installation).
- Optionally set VRM Battery Instance if you have multiple battery monitors and the auto-selected one is wrong (leave at -1 to auto-select the first Battery Monitor found).
- Set the VRM Poll interval (default 60 s) and press Save. The strip blinks twice to confirm; no reboot is needed.
| VRM field | LED effect |
|---|---|
| SOC (%) | Sets the LED level (0 = strip off, 1–100 = strip on at that %) |
| Current > charge threshold | Charging state → Charging |
| Current < discharge threshold | Charging state → Discharging |
| Current between both thresholds | Charging state → Idle |
Serial log messages are prefixed with [VRM].
The firmware can read battery state directly from a Victron GX device (Cerbo GX, Venus GX, CCGX, …) over Modbus TCP on the local network — no internet or VRM account required.
- On the GX device, go to Settings → Services → Modbus TCP and enable it.
- Note the GX device's IP address (assign a static IP via your router's DHCP reservation).
- Open the device configuration page, select Modbus TCP (local) as the Victron data source, enter the GX IP in Modbus Host, and press Save.
All reads target com.victronenergy.battery (holding registers):
| Register | Description | Type | Scale | Unit |
|---|---|---|---|---|
| 266 | State of charge | uint16 | ÷10 | % |
| 261 | Current | int16 | ÷10 | A |
The unit ID defaults to 0 (first battery monitor). Set Battery Instance to the device instance number if you have multiple battery monitors.
Charging state is derived from current using the shared charge/discharge thresholds (same as VRM).
Serial log messages are prefixed with [Modbus].
Install via the Arduino Library Manager:
| Library | Purpose |
|---|---|
| Adafruit NeoPixel | WS2812B LED control |
| ArduinoJson | JSON serialisation |
| ArduinoOTA | OTA firmware updates |
| PubSubClient | MQTT client |
| WiFiManager (tzapu) | Captive portal WiFi setup |
| WebServer (built-in) | HTTP server |
| SPIFFS (built-in) | Filesystem for config storage |
Board: ESP32C3 Dev Module (or compatible) via the ESP32 Arduino core.
Partition scheme: The sketch exceeds the default 1.25 MB app partition because
WiFiClientSecure(used by the Victron VRM integration) pulls in the mbedTLS library (~400 KB). In the Arduino IDE select Tools → Partition Scheme → Minimal SPIFFS (1.9MB APP with OTA/190KB SPIFFS) before uploading. This keeps OTA support and provides ample space forconfig.jsonin SPIFFS.
LEDPowerPercentage/
├── LEDPowerPercentage.ino # Main file: globals, setup(), loop()
├── Config.h # All persistent settings + SPIFFS load/save
├── LEDControl.ino # LED rendering, colour zones, charge/discharge sweep
├── Animations.ino # Animation engine: dynamic effects, fixed colours, gradients, diagnostics
├── MQTTAutoDiscovery.ino # MQTT state publishing, HA auto-discovery, command callback
├── WebServer.ino # Web UI handlers + REST API
└── WiFiMqttSetup.ino # WiFi captive portal, OTA, MQTT reconnect



