This is a hobby project and I add features as and when I have time, it means sometimes it's not updated for a while and then sometimes I add a number of features!
I now accept donations towards supporting the project: https://buymeacoffee.com/sijones2012
The easiest to get started is to use the web flasher at https://diy.power-pilot.uk
Use an ESP32-S3 with PSRAM. The Victron BLE shunt source needs real PSRAM, so it is only built
into the -psram variants (and the XIAO, which has it soldered in); the classic ESP32 has BLE
disabled for the same reason.
You can now sponsor and donate to me, the link is on here to buy me a coffee ;)
The new UI means no coding knowledge is required, just flash and set up.
A lot of people use it from Diy Solar Forum, support from community users is also available: https://diysolarforum.com/threads/diy-battery-via-smart-shunt-to-inverter-integration-solis-etc.44750/
DiyBatteryBMS takes data from a Victron Smart Shunt and sends it to a inverter over CAN using Pylontech protocol allowing for "DIY LifePO4" Batteries to be integrated.
The Victron Smart Shunt provides the actual monitoring data, this software translates it to Pylontech protocol that most Inverters understand, the part that you need to configure is the charging voltage, current that is also sent to the Inverter.
As long as the Inverter accepts Pylontech protocol over CAN Bus this software should work with it, it's tested with Solis Inverters but forum users have reported it working with others.
The data is also sent over MQTT and allows commands to be sent back to control Charge/Discharge/Force Charge.
This software supports:
-
ESP32 developer boards with MCP2515 CAN Bus adapter
-
ESP32 with built-in CAN controller (e.g. LilyGo T-CAN485)
-
ESP32-S3 with built-in CAN controller (using TWAI driver) or a MCP2515 adapter
-
Seeed XIAO ESP32-S3 with a CAN expansion board
-
If you're buying new, a board with 8MB or 16MB flash and 8MB PSRAM - is best as it gives the web UI and MQTT plenty of headroom over a bare 4MB/no-PSRAM module.
The ESP32-C3 is no longer supported as of 3.0.0-BETA9. It has no external RAM interface, so it can never have PSRAM and could never run the Victron BLE shunt source; it is single-core, against a design that keeps the CAN send task off the core running WiFi; and it was the tightest build here. If you are running a C3, the last build for it is 3.0.0-BETA8 — it will keep working, but it will not get new releases.
See the WIKI for more detailed documentation.
With the help of the MQTT server you can integrate the monitoring data to virtually any Home Automation System. I use Home Assistant to automate off peak battery charging (using Force Charge) and can also enable and disable the charging and discharging.
- Setup from a browser, flash to your ESP32 device then go to http://192.168.4.1 and connect it to your wifi, once connected go to http://diy-battery-bms.local or it's IP address to configure all settings.
- NEW: Improved WiFi Setup Experience:
- WebSocket-based WiFi network scanning with automatic background refresh
- Networks sorted by signal strength for easier selection
- Duplicate networks filtered (strongest signal retained)
- Preserved saved SSID selection across scans
- WiFi configuration requires explicit save (manual save button for SSID, password, and mDNS hostname)
- Takes battery data from VE.Direct serial, Victron BLE, or MQTT topics - pick a primary source and an optional fallback - and publishes telemetry to a MQTT broker
The MQTT Topic is fully configurable. Victron BLE requires a PSRAM build (see Choosing an environment) - the NimBLE stack alone can exhaust the heap on a board without it, so BLE is disabled and hidden from the web UI on non-PSRAM builds. - Home Assistant MQTT Discovery - Automatically creates all sensors and switches in Home Assistant with no manual configuration required
- Supports MQTT Commands to enable and disable charge/discharging of an inverter, force charge the batteries to be able to charge over night at off peak rates.
- Supports single MQTT server
- OTA (Over The Air Update)
use your browser and go to http://IPADDRESS/update and upload the lastest binary - LCD Screen Support (LCD 20x4 via I2C)
- Configurable NTP Server with timezone support - used for log and syslog timestamps
- Voltage Limited Charging, automatically reducing charge current to keep the voltage stable
- SOC Reset, if the Smart Shunt is at 100% but battery voltage is under charged voltage sends 99% until met
- SOC recharge, hold off recharging until under the SOC restart limit.
- Temperature protection - separate charge and discharge cut-offs, with the battery temperature taken from the Smart Shunt or an external MQTT topic
- Fan control - linear PWM ramp between configurable off and full-speed temperatures
- Backup and restore all settings to a JSON file
- PowerPilot integration - PowerPilot can use a DiyBatteryBMS device as a networked hardware node - battery telemetry, inverter control, or both - over WiFi with no CAN HAT on the Pi and no MQTT broker. See PowerPilot.md for the WebSocket interface, or the summary below.
- Dark, light and automatic themes - the toggle is in the header and your choice is remembered
- Laid out for phones as well as desktops; the dashboard packs two readouts per row in portrait
- Charge and discharge are proper toggle switches, reachable by keyboard and screen reader
- The tab you are on is kept in the URL, so a refresh or bookmark returns you to it
- If the connection drops the dashboard greys out and tells you the readings are frozen, instead of showing stale numbers as though they were live
- Settings that conflict are flagged as you type - for example a charge voltage above the over-voltage cutoff, or a low SOC limit above the high one
- Factory Reset now lives behind a "Danger Zone" section and asks you to type ERASE
- Voltages and currents are now entered in V, A and Ah rather than mV and mA
The dashboard now shows what the firmware is actually doing rather than leaving you to infer it:
- SOC sent to the inverter - when the reported value differs from the real pack SOC, the dashboard shows both and explains why (holding 99% until fully charged, "never send 100%", or the force-charge trick)
- Absorption progress - both of the timers that can end absorption, side by side: the tail-current hold and the maximum absorption time. The tail timer resets whenever current rises back above the threshold, and the panel says so, along with which condition is currently holding things up
- Forwards the same log lines shown on the Logs tab to a syslog collector over UDP (RFC 3164)
- Configured under Settings, applies immediately without a reboot
- Enter the collector's IP address - hostnames are deliberately not resolved, so logging can never block on DNS
- The Logs tab also gained level filtering, plus Copy and Download buttons for reporting problems
A CAN sniffer checkbox on the Logs tab logs every frame received on the bus, with its data bytes, so you can see exactly what another BMS puts on the wire. It exists for the question that comes up whenever an inverter misbehaves — "but my JK/Pace/Seplos works fine" — which can only really be answered by looking at what the other BMS sends at the moment the two differ.
- The device sends nothing while it is on. That is deliberate: it means you can clip a spare node onto someone else's BMS-to-inverter bus without the two fighting over the same message IDs. It also means do not leave it enabled on your own inverter, which is left without a BMS.
- Turns itself off after 30 minutes, and a reboot always clears it. The setting is never saved and is not part of settings export/import.
- Repeated identical frames are logged once and then every 30 seconds, so a long capture stays readable and shows only the bytes that actually moved.
- Switching it on selects the new CAN frames only log level, so the capture is not buried in ordinary log lines; switching it off puts the previous level back. The level is a normal dropdown choice, so you can move off it and back at any time.
- Use Copy or Download on the Logs tab to get the capture out - both follow the filter, so with CAN frames only selected you get just the capture.
- Nothing on the Logs tab counts as configuration, so none of it raises the unsaved-changes bar.
- Timezone support. Log and syslog timestamps are now in your local time rather than UTC. Pick a region from the dropdown under Settings and daylight saving is handled automatically - the UK preset switches to BST and back on its own with no firmware update. The underlying POSIX TZ string stays visible and editable if your zone is not listed. Applies immediately, no reboot.
- Clock Sync status on the Firmware tab shows how long ago the clock last synced, and warns if syncing has stopped. The device re-syncs automatically every 3 hours.
The device now supports MQTT Discovery which automatically creates all entities when connected:
- Sensors: Battery SOC (%), Voltage (V), Current (A), Power (W), Temperature, Charge/Discharge Current Limits, Charge Phase, Time To Go, Fan PWM, Free Heap
- Binary Sensors: Charge/Discharge/Force Charge status indicators
- Switches: Charge Enable, Discharge Enable, Force Charge, Request Full Charge, SOC Trick Enable, Request Flags Enable
- Number Controls: Charge Voltage, Charge Current (with 0.1 precision), Float Voltage, Float Current
- Charging detail: SOC Sent To Inverter, SOC Override Reason, SOC Override Active, Absorption Elapsed, Absorption Remaining, Tail Current Held, Tail Current Remaining, Tail Current Active
- All entities are grouped under one "DIY Battery BMS" device
- No manual YAML configuration required (HomeAssistant.yaml is now optional for reference only)
- Discovery messages published automatically on MQTT connect
See MQTT.md for the whole MQTT interface: every topic published, every set/ command
accepted with its units and whether it persists, the schedule payload format, and the traps worth
knowing before you automate against it.
Protocol Control Features:
- SOC Trick Enable: Sends 1/10 of the actual SOC to trick the inverter into force charging
- Request Flags Enable: Controls charge/discharge request flags sent to inverter (allows different ways to control inverter response)
Until now the charge cycle ended at Complete: the charge current limit dropped to zero and the charge-enable flag cleared, while the voltage limit stayed at the absorption target. Several hybrid inverters — Deye in particular — read that combination as an over-voltage they can only resolve by discharging the pack, so instead of resting after a full charge the battery was pushed back into the house or the grid at a kilowatt or so until the voltage fell.
If you are still seeing a forced discharge at 100% SOC with float running, check High SOC (Stop Charge) and the Slow Charge thresholds — both cut the charge current limit at an SOC boundary while the voltage limit stays high, which produces the same symptom by a different route.
PowerPilot is a home energy manager that plans battery charge and discharge cycles against solar
forecasts and tariff prices. It can use a DiyBatteryBMS device as a networked hardware interface,
over the web UI's own WebSocket at ws://<device>/ws — an interface open to any external energy
manager, not just PowerPilot.
See PowerPilot.md for the full interface: non-persisting set-commands, the override latch, force charge versus full charge, and live current requests.
Every board now builds against Arduino core 3.3.11 on ESP-IDF 5.5.5, up from core 2.0.17 on IDF
4.4. All seven environments share one platform definition in [common], so there is a single line
to change next time.
The core update is the reason the partition change had to come first: the same source is about 155KB larger on the new core, which no longer fits the old 1.25MB app slot at all (the ESP32-C3 build would be at 114% of it).
Fan control now works on the ESP32-C3.
3.0 replaces the Arduino default.csv partition table. An over-the-air update cannot replace a
partition table, so this one upgrade has to be done over USB with all three files —
bootloader.bin, partitions.bin and firmware.bin. The zip in dist/ contains them along with a
ready-made flash_example.cmd / flash_example.sh.
Your settings are kept. The new tables leave nvs at exactly the offset and size it has always
had (0x9000, 0x5000), so everything you have configured survives the re-flash untouched. Do not
pass erase_flash — that erases NVS and is what actually loses settings.
If you update over the air anyway, the device runs fine but keeps 2.x's 1.25MB app slot, and will refuse a future update once the firmware outgrows it. The Firmware tab shows the running App Partition size and warns when a device is still on the old table.
The old layout spent 1.375MB on a SPIFFS partition that used to hold the UI — the web interface is now compiled into the firmware and every setting lives in NVS. That space now goes to the app:
Old (default.csv) |
New | |
|---|---|---|
| App slot (4MB boards) | 1.25MB | 1.9375MB (partitions_4mb.csv) |
| App slot (8MB boards) | 1.25MB | 3.9375MB (partitions_8mb.csv) |
| SPIFFS | 1.375MB, unused | removed |
| NVS | 20KB @ 0x9000 |
unchanged, deliberately |
The 8MB boards were running the 4MB table, so half the chip was not addressed at all, and the 16MB S3 was running the 8MB one. They are sized generously now on purpose: a partition change costs every user a re-flash, so the one forced at 3.0 should be the last one those boards ever need.
Which table a build uses is now stated in its environment name — see Choosing an environment — rather than being a property of the board name that nobody could see.
Two OTA app slots are kept throughout, so the firmware update page still works as before.
- OneWire temperature sensors for charge control
- Heater control (fan control and temperature monitoring are now implemented)
- DiyBatteryBMS only reads battery data - it does not request anything special or change any parameters on the source, whether that's VE.Direct Serial, Victron BLE, or MQTT topics.
See the Wiki page
An environment name is the wiring, then the flash size, then whether the module has PSRAM:
esp32s3-ESPCAN-16mb-psram
└──────┬─────┘ └─┬─┘ └─┬─┘
wiring flash PSRAM
Pick the one that matches the module in your hand. Flash size is not cosmetic: a 16MB partition table on a 4MB chip is the fastest way to end up with a board that does not boot, and a build that addresses only 4MB of a 16MB chip wastes three quarters of it. If you are unsure what you have, the browser flasher at diy.power-pilot.uk reads it off the chip and greys out everything that would not work.
If you're choosing hardware rather than working with what you already have, prefer 8MB or 16MB flash with 8MB PSRAM. Large allocations - the web UI, JSON documents, the MQTT outbox - spill into PSRAM instead of competing with WiFi for internal RAM, which is the tightest resource on a 4MB/no-PSRAM module.
Victron BLE needs PSRAM. A non-PSRAM build refuses to start the BLE radio and hides it from the
web UI (no "Prefer BLE" option, no Victron BLE Shunt section) - the NimBLE stack alone is enough to
push a non-PSRAM board's heap into the failed-allocation crash that a low heapmin and a
Panic / exception reset reason on the Diag tab point to. Use VE.Direct serial or MQTT topics as the
shunt source on those boards instead, or move to a -psram environment if you need BLE.
VEDIRECT_TX is optional. Nothing is ever sent to the shunt, so only VEDIRECT_RX has to be wired and set. Leave the TX pin blank in the web interface to keep that GPIO free; clearing an existing value unassigns it.
I WILL NOT BE HELD LIABLE FOR ANY DAMAGE THAT YOU DO TO YOU, ONE OF YOUR DEVICES, BURN YOUR HOUSE DOWN, ETC. A CERTAIN LEVEL OF KNOWLEDGE IS EXPECTED, LIKE HOW TO WIRE THINGS AND PROGRAM THE SOFTWARE TO YOUR DEVICE.
Copyright (c) 2022-2026 Nexion Software Solutions Ltd.
Free to use, modify and share for personal, non-commercial use. Selling it, or shipping it as part of a product or on pre-loaded hardware, needs written permission — commercial licensing enquiries via nexion.uk. See LICENSE for the full terms.
Third-party components keep their own licences — src/ONEWIRE.h (Ralf Lehmann),
src/VeDirectFrameHandler.cpp (Victron Energy BV), and the libraries listed in
platformio.ini.