ESPHome BLE Modbus monitor for Duracell Dura-i5 inverter (with PV consistency + grid lockout)
I’ve been working on an ESPHome node to monitor a Duracell Dura-i5 inverter that only exposes data over Bluetooth (no RS-485 wired connection used here). The goal was to:
- Pull reliable PV / grid / load / battery values over BLE
- Make them behave sensibly at night and during noisy transitions
- Feed clean metrics into Home Assistant’s Energy dashboard
- Add a bit of logic + hysteresis so grid import behaves more like a “mode”, not random noise
This post shares my current ESPHome config and explains what it does.
Hardware & stack
- Inverter: Duracell Dura-i5 (BLE device name
BLE1423PH)
- Bridge: ESP32-S3 dev board running ESPHome
- Protocol: BLE → vendor bridge → Modbus-style frames on service
0xFF10, write 0xFF11, notify 0xFF12
- Target: Home Assistant, including Energy dashboard
What this ESPHome node actually does
At a high level, the node:
- Connects via BLE client to the inverter and sends Modbus-like read requests
- Reassembles notify frames, verifies CRC16 Modbus, and parses register windows:
0x2000 region: SoC, battery volts/amps
0x1400 region: MPPT1 / MPPT2 power
0x150F : an alternative PV power candidate (scaled u/10)
0x130B : used as load power (AC side)
- Derives:
- Battery DC power (charge/discharge)
- Load consumption (absolute W)
- PV power from a mix of sources (derived vs MPPT vs 0x150F)
- Grid power (import/export) from the power balance
It then publishes:
- Clean instantaneous sensors: PV, grid, load, battery charge/discharge, SoC, etc.
- Integration sensors (kWh) for:
- PV energy
- Grid import / export
- Load energy
- Battery charge / discharge energy
These map directly into the Energy dashboard.
PV consistency layer: choosing a sane PV value
The tricky bit with this inverter is that different sources disagree or go stale:
- Derived PV:
load – grid – battery_bus
- MPPT1/2 power from
0x1400
- 150F window (u/10 scaled) as another PV candidate
The script adds a “consistency layer” that:
- Starts with derived PV as a baseline
- Defines a tolerance band using substitutions:
pv_consistency_w: "80" # absolute tolerance in W
pv_consistency_frac: "0.25" # fractional tolerance vs derived PV
pv_headroom_w: "120" # slack over Load+charge for rounding/noise
- Checks each candidate (MPPT sum, 150F) against:
- Difference from derived PV (
tol_abs / tol_frac)
- Physical ceiling:
load + discharge + pv_headroom_w
- Non-negative
- Prefers candidates in this order:
- MPPT (if fresh and within the window)
- 150F (if fresh, sane range, and consistent)
- Otherwise stays with derived PV
- Adds night-time behaviour:
- When it’s clearly night and we’re still on derived PV, PV is forced to 0 W
- A small PV floor is configurable so tiny noise doesn’t show as phantom PV
Result: PV doesn’t randomly jump or show “ghost solar” at night.
Grid lockout with SoC hysteresis
On top of that, there’s a simple grid lockout with hysteresis and PV coverage check:
Substitutions:
soc_unlock_pct: "16.0" # below -> allow grid import
soc_relock_pct: "18.0" # above -> re-lock (self mode)
pv_lock_margin_w: "50.0" # require PV >= Load - margin to lock grid
Logic:
- A small internal state (
grid_lock_active) flips when:
- SoC drops below
soc_unlock_pct → allow grid import
- SoC climbs back above
soc_relock_pct → re-enable lock
- Even when locked, we only clamp grid to 0 W if:
- It’s daytime (based on a day/night hint)
- Battery power is within a safe range (below a clamp threshold)
- PV is actually covering the load (
PV ≥ load - pv_lock_margin_w)
If those conditions aren’t met, grid power is allowed to go positive so the house doesn’t go dark or cook the battery.
User-tunable controls (numbers, selects, switches)
I’ve exposed the main tunables as ESPHome number, select, and switch entities so you don’t have to recompile to tweak behaviour.
Numbers
Ibat Scale K
Used when using register 0x2008 for battery current; this scales the raw value into real amps, considering pack count.
Grid Deadband (W)
Anything below this magnitude is treated as 0 W to avoid grid noise around zero.
Battery Clamp Threshold (W)
Maximum comfortable battery power magnitude (discharge) before we stop assuming “grid ≈ 0” in the PV derivation.
PV Floor (W)
Minimum PV power before we allow non-zero PV when using derived values, mainly to clean up low-noise at night/edges.
Selects
Ibat Source – choose which battery current register to trust:
Auto (prefers 0x2008 when present, else 0x200D)
0x2008
0x200D
PV Mode – how strict we are with MPPTs:
AnyPresent – if either MPPT1 or MPPT2 looks sane, we can use the sum
BothRequired – insist on having both MPPT channels valid
Switch
PV Derive: Assume Grid~0
For testing or specific scenarios: forces the PV derivation to treat grid as ~0 W (or use last good cached grid as a hint). Handy when you know grid import should be minimal.
Day/night hint & MPPT “hammering”
There’s a small support layer that decides day vs night and keeps MPPT fresh:
- A 30s interval task:
- Uses SNTP time to treat 06:00–20:00 as a day window
- Also looks at recent MPPT values; if they’re non-zero and fresh, it nudges the hint to “day” even outside the simple time window
- Another task runs every 12s during the day:
- Sends extra reads to the
0x1400 MPPT window (function 0x03 and 0x04) to “hammer” it and keep those numbers alive
On boot, there’s also a hammer_mppt script that runs a quick burst of MPPT reads once we’ve seen a valid load value. This helps populate sensors as soon as possible after a restart.
Home Assistant entities you get
Some of the key sensors exposed to Home Assistant:
Instantaneous:
Battery SoC (%)
Battery Power (-dis) (W, negative means discharge)
Load Power (W)
PV Power (W, after consistency logic)
Grid Power (W, positive import / negative export)
Battery Voltage (V, internal)
Battery Current (A, internal)
PV Source Status (text: tells you which PV source is being used and current PV W)
Frames OK, CRC Errors, Exception Frames, Last Frame Age for debugging the BLE/Modbus layer
Energy (for the Energy dashboard):
PV Energy (kWh)
Grid Import Energy (kWh)
Grid Export Energy (kWh)
Load Energy (kWh)
Battery Charge Energy (kWh)
Battery Discharge Energy (kWh)
These are all sensor.integration entities using trapezoid integration and converted from W·h to kWh with a simple *0.001 filter.