A Home Assistant custom integration that turns two or more Anker SOLIX Max AC batteries into one coordinated system — without them ever charging each other.
It is solar-agnostic: it steers on your household grid-power sensor (P1 meter), so it works with any solar setup (Enphase, string inverter, none at all).
- One brain. A single anti-windup control loop drives the grid meter toward ~0 (with a small configurable import bias so you never accidentally export).
- No cross-charging. Both units are always commanded the same direction (both charge, or both discharge) — so one unit can never charge the other.
- SoC-balanced split. The fuller unit discharges more; the emptier charges more, so the packs even out over time.
- No micro-cycling. Tiny shares are consolidated onto one unit instead of making the other flip between idle and active.
- Kill-switch. A single switch to hand control back to the batteries' native Self-Consumption mode (safe fallback).
- Fast charge (emergency). One switch to charge both packs to full as fast as possible (from the grid if needed), then hold them there until you release it.
- Fails safe. If the integration is unloaded or errors out, the units are reverted to Self-Consumption.
- The batteries must be reachable through an integration that exposes, per unit:
an operating-mode select (with
self_consumption/third_party_control), a grid-flow select (charge/discharge), a target-grid-power number, and a state-of-charge sensor. The Anker SOLIX Official (Modbus TCP) entities provide exactly these. - A grid-power sensor in watts where + = import and - = export (a P1 meter / HomeWizard / DSMR sensor).
The batteries are driven in Third-Party Control mode while the coordinator is enabled. Turning the kill-switch off returns them to Self-Consumption.
- HACS → three-dot menu → Custom repositories.
- Add
https://github.com/Mischa323/ha-battery-managementas an Integration. - Install Battery Management, then restart Home Assistant.
- Settings → Devices & Services → Add Integration → Battery Management.
- Pick your grid-power sensor, the number of units, and per unit its four (or six) control entities. Done.
Manual install: copy custom_components/battery_management into your HA
config/custom_components/ folder and restart.
Configure opens a menu, one entry per question somebody actually has. Every field carries its own explanation underneath it in Home Assistant, so this is only a map of where to look:
| Screen | What lives there |
|---|---|
| Regulating | How hard and how often the packs are corrected: bias, deadband, the two gains, the tick interval, the minimum output. |
| The packs | What the hardware can take: max per unit, minutes from empty to full, the discharge-recovery band, whether a fast charge holds. |
| Dynamic tariff | When to buy and what counts as dear — including the fallback buy ceiling and fill up before a dearer day. |
| Solar | The forecast sensors, and the fallback kWh limit. This is what the buy ceiling leaves room for. |
| Fuse protection per phase | The per-leg sensors and the limit under them. |
| Battery unit entities | Correcting a mis-picked entity on a pack, without deleting the entry. |
| Logging and safety nets | The trace file, and the two staleness timeouts. |
| Shadow running | Only relevant while Dry run is on. |
The regulating defaults:
| Option | Default | Meaning |
|---|---|---|
| Import bias | 30 W | aim for a tiny import so you never export |
| Deadband | 100 W | ignore errors smaller than this |
| Gain (Kp) | 0.25 | lower = gentler/slower, less overshoot |
| Return gain | 2 × Kp | how fast the command is wound back down (see below) |
| Interval | 15 s | control tick period |
| Min output | 150 W | below this a unit is idled (avoids micro-cycling) |
| Max per unit | 3500 W | hard ceiling per unit (under The packs) |
Going out on a command and coming back from one are not the same risk. Every step up is a bet on packs that answer 10–30 s later, which is what makes a high gain oscillate. Coming back down cannot run away — the far end of "less" is a pack sitting at 0 W.
It is also where the exports come from: a pack still discharging into a load that has already gone away. Measured by replaying one site's own hour through the real loop:
| exported | ticks below −300 W | mean deviation | |
|---|---|---|---|
| symmetric (both 0.25) | 323 Wh | 58 | 833 W |
| return gain 0.5 | 244 Wh | 44 | 861 W |
| best possible without seeing the future | 210 Wh | 51 | 718 W |
A quarter less export for 3 % on the mean, and within reach of the arithmetic limit for any loop that only reacts. It is a factor rather than a fixed number so that raising Kp cannot silently flatten the asymmetry back out; set it equal to Kp for the old symmetric behaviour.
Every tick is written to config/battery_management_trace/YYYY-MM-DD.csv, one
row per tick, kept for 14 days. The diagnostics download only holds the last
four hours and dies with the process — which is no use at all when the question
is "why did the packs do that on Saturday".
Get at it with the File editor, Samba share or Terminal add-on, or
ha over SSH. Every column is flat, so pandas.read_csv or a spreadsheet can
plot any of it without unpacking anything.
What each row carries:
| column | meaning |
|---|---|
grid_w |
the meter, as used (reconstructed in a shadow run) |
observed_grid_w |
what the meter actually said |
error_w, sp_before_w, sp_wanted_w, setpoint_w |
the whole integrator step |
sp_reason |
integrate · deadband · clamped_upper · clamped_lower · dynamic_buy · external_plan |
gain |
which of the two gains was applied |
upper_w, lower_w |
the bounds in force |
free_discharge_w, fuse_discharge_w |
what the packs could give, and what the fuse left of it |
phase1_w…phase3_w |
each leg |
<pack>_target_w |
what we told that pack — signed: + discharging, − charging |
schema |
the row format; 2 signed the column above, 1 did not |
<pack>_actual_w |
what the pack says it is doing — the lag, measured |
grid_age_s, grid_changed_s |
how old the meter reading was, and when it last actually moved — equal means nothing arrived, different means it arrived and held steady |
run |
which coordinator run wrote the row; two values interleaved means two loops |
<pack>_readback_w |
what the device holds as its target |
<pack>_ack_s |
seconds until the target entity echoed the command — acceptance, not proof the hardware moved; use <pack>_actual_w for that |
<pack>_actual_age_s, <pack>_soc_age_s |
how stale that pack's readings were |
<pack>_cap_w, <pack>_soc, <pack>_phase, <pack>_recovering |
its ceiling and state |
mode, policy, status, dry_run, offline |
context |
price_eur_kwh, price_role |
what this hour cost, and whether it was ranked cheap · dear · normal — so a trace can be read against the tariff without cross-checking the price chart by hand |
market_price_eur_kwh |
the exchange price of the same slot, which is what export is settled against — what a kWh sent back was worth while a bought one cost price_eur_kwh. Direct supplier route only |
solar_produced_today_kwh |
the configured solar sensor's cumulative reading for the day, if one is set — diff two rows to get the rate; the only way to confirm "was that solar" rather than guessing from grid_w |
Rows with event=phase_probe record a phase measurement: the deltas per leg,
the winning leg, the runner-up and the verdict.
<pack>_actual_w needs the optional Power sensor on each unit (the wizard
finds ..._ac_output by itself). Without it the column is empty and the trace
cannot show whether a pack followed its orders — which is usually the question.
The control loop is a port of a field-tested YAML setup, but this integration itself has not yet run a full season on real hardware. It ships with dry run switched on so a new site watches before it acts — see the reference below.
Every setting carries its own explanation in Home Assistant, under the field. Every entity is explained in the reference section further down.
Not affiliated with Anker.
Open the repo in VS Code and Reopen in Container (Dev Containers extension).
Setup installs Home Assistant plus the test tooling and symlinks the integration
into config/custom_components/, so an HA restart picks up your edits.
scripts/develop # Home Assistant on http://localhost:8123
scripts/test # the pytest suiteconfig/ is a throwaway instance and ships a simulator
(config/packages/simulator.yaml): a fake P1 meter and two fake Max AC units as
template select/number/sensor entities. You can complete the config-flow
wizard against them and watch the loop regulate — no hardware needed.
It is a closed loop: sensor.p1_meter_power is the house load minus whatever
the packs are commanded to do, so the setpoint actually settles. Drive it with
input_number.sim_house_load (negative = PV export) and
input_number.sim_0X_soc.
| Wizard field | Simulator entity |
|---|---|
| Grid power sensor | sensor.p1_meter_power |
| Operating-mode select | select.sim_0X_operating_mode |
| Grid-flow select | select.sim_0X_grid_flow |
| Target grid power | number.sim_0X_target_grid_power |
| State of charge | sensor.sim_0X_soc |
| Charge / discharge limit | leave blank (falls back to 100 % / 5 %) |
Home Assistant only installs on Linux/macOS with a matching Python, so the test suite deliberately does not require it: conftest.py stubs the handful of HA symbols the control loop imports when the real package is absent.
python -m pip install -r requirements-test.txt
python -m pytestCI runs the suite both ways — stubbed and against a real Home Assistant — plus hassfest and HACS validation.
The integration ships its Lovelace cards and registers them itself, so there is no manual resource step:
| Card | What it is for |
|---|---|
| Battery Management Card | The control panel: on/off, fast charge, state of charge per pack, and the price chart underneath. |
| Battery Management Prices | Only the prices — current price large, today's bars, and the cheapest and dearest hour with their times. |
| Battery Management Trading | Selling to the grid: Off / Shadow / On, whether it pays right now with the sum written out, and what it sold (or would have, in shadow) today. Belongs beside the controls. See Selling to the grid. |
| Battery Management Savings | What the packs saved today, this month and since the start — green when they saved, red when they lost — and the payback time. Made for a dashboard of its own. |
Edit the dashboard → + Add card → the By card tab → type battery.
That tab is the one that matters, and it is worth saying why: By entity builds a standard card around an entity you pick, and a custom card is not an entity — it can never appear there, however hard you look. The By card tab may also open on Suggestions for whatever was selected; either search in it, or use the "Can't find the card you want? → Browse all cards" link at the bottom, where custom cards are grouped at the end.
Pick either card and press Save. They fill themselves in: the switches, the packs, the meter and the price chart are all found from the entities that exist, so there is no YAML to write. Both can sit on the same dashboard.
Almost always a stale script. The browser caches the card file hard, so an update can appear to do nothing — the old script keeps running, and a card added in a later release is simply not in it.
- Check the version in HACS. Cards appeared in
0.8.0; the cache fix that makes updates take effect at all landed in0.8.3. Below that, one manual hard refresh (Ctrl+Shift+R) is needed after each update. - Check the file is served. Open
http://<your-ha>:8123/battery_management/battery-management-card.jsdirectly. You should get JavaScript; search it forprices-cardto confirm it is the current one. A 404 means the static path was not registered — the log will say so, and the fallback is to add that URL by hand under Settings → Dashboards → Resources. - Restart Home Assistant, not just reload the integration. The card is registered during setup.
- Check for a hand-added copy under Settings → Dashboards → Resources.
The integration registers the script itself, so a manual entry pointing at
the same file loads it a second time. That used to be fatal - the second
copy died on a duplicate element definition before it reached the second
card, so the card list showed a stale entry and the prices card was simply
missing. Harmless from
0.9.1, but the manual entry is still redundant.
From 0.11.2 the server answers this itself. Download the diagnostics and
read the card block: it says where the file was looked for, how many bytes
were served, which URL was offered to the frontend, and what failed if
anything did. "error": "file_missing" means HACS did not copy the script;
"error": "static_path: …" means it is not being served; a complete block with
no error means the file is fine and the browser is holding an old copy, so
hard-refresh. The same appears in the log at startup.
Before 0.11.4 the card was registered during setup, which on a cold boot can
run before the frontend exists — and a card offered to a frontend that has
not initialised is simply lost. If cards worked after reloading the integration
but not after a reboot, that was why. It now waits for the frontend instead.
Deliberately not by making the frontend a hard dependency: that was tried
in 0.11.2 and is the worse trade, because the batteries then stop being
coordinated at all if the frontend fails to start. A battery controller has no
business depending on a web interface.
From 0.8.3 the script URL carries the version, so an update busts the cache on
its own and none of this should be needed again.
The integration ships and auto-registers a Lovelace card, so after install you can add it straight away (no manual resource step needed):
type: custom:battery-management-card
title: Battery Management
enable: switch.battery_management_coordinator_enabled
fast_charge: switch.battery_management_fast_charge
status: sensor.battery_management_status
setpoint: sensor.battery_management_setpoint
grid_power: sensor.p1_meter_power
# the mode as a control, and the policy that explains what it is actually doing
mode: select.battery_management_mode
policy: sensor.battery_management_active_policy
# how much went in, and how much of it was bought - see the package below
charged_total: sensor.battery_management_charged
charged_grid: sensor.battery_management_charged_from_grid
units:
- name: Batterij 01
soc: sensor.anker_solix_solarbank_max_ac_093_soc
target: sensor.battery_management_batterij_01_target
status: binary_sensor.battery_management_batterij_01_online
- name: Batterij 02
soc: sensor.tuin_batterij_02_soc
target: sensor.battery_management_batterij_02_target
status: binary_sensor.battery_management_batterij_02_online
# optional solar-forecast line:
forecast_today: sensor.zonverwachting_vandaag
forecast_tomorrow: sensor.zonverwachting_morgentarget points at the coordinator's own per-unit sensor rather than the Anker
number, so it shows the commanded value with a sign (+ discharge, − charge).
Comparing that against the unit's own power sensor is how you spot a pack that
is not following orders — those Modbus sensors lag 10–30 s.
mode makes the card a control rather than a readout: it is the same
select.battery_management_mode described below, with the options the entity
itself offers, so an install without a price source is not shown a Dynamic it
cannot take. Its labels come from Home Assistant's own translations, not from a
list inside the card.
policy is the read-only companion, and it is what makes a flat graph legible.
The mode says what is allowed; the policy says what is actually binding right
now. “Follow the meter” with a setpoint pinned at zero reads as a broken card
until the line underneath says Accu's leeg.
charged_total and charged_grid draw a split bar: how much energy went into
the packs, and how much of it was bought rather than caught.
Nothing to install. Give each pack its Charge power sensor in the
wizard — on an Anker device that is Battery Charging Power, and the setup
wizard suggests it for you — and the integration counts it itself, publishing
sensor.battery_management_charged and
sensor.battery_management_charged_from_grid. Both are proper energy sensors,
so they can go on the Energy dashboard, and a utility_meter helper on top of
either gives you a daily or monthly figure.
Skip the field and nothing is counted: the two sensors report unavailable rather than nought, because zero is a claim about the packs, and a graph flat on the floor must not also be able to mean “nobody is counting”.
The sun is the remainder, not a third measurement, so the two halves always add up to the total that really went in. Two independent counters would drift apart within a day and the card would then be splitting something that is not the whole. Give the card only one of the two and it shows that figure without a split.
These numbers are measured by the packs, never derived from what this integration commanded. That distinction is the whole point. A command is a plan; the packs answer 10–30 s later and their Modbus sensors update in bursts, so integrating our own orders would put an authoritative-looking kWh figure on the dashboard that is not what happened.
The split itself is exact rather than a convention: while the packs draw Y W
and the meter reads X W of import, the house without them would have been at
X − Y, so min(X, Y) came off the meter and the rest came out of the
surplus. Export means all of it was sun. The honest caveat is timing — the
meter is near-live and the pack sensors lag, so by the minute the attribution
is noisy. Over a day it settles.
Counting keeps running while the coordinator is switched off, and throughout a shadow run. The question is how the packs got charged, not who ordered it — a counter that only ran while we were steering would make a month of shadow running look like a month of nothing. Two things are deliberately not counted: a pack whose sensor cannot be read (under-report rather than hold a stale value forward), and any tick following a long gap, because multiplying the power read now by two hours of downtime would manufacture kilowatt-hours that were never stored.
select.battery_management_mode picks one strategy at a time:
| Mode | What it does |
|---|---|
| Follow the meter | charge on surplus, discharge on deficit (the default) |
| Charge only | still fills from surplus, never discharges |
| Discharge only | still covers a deficit, never charges |
| Pause | holds at zero, but stays in control of the units |
Every mode is that same grid-zero regulation with a bound on it, so the packs keep responding to the house and the sun inside whatever you pick — they never sit idle merely because they are inside a window. Pause is not the kill-switch: the units stay under third-party control holding at 0, whereas the kill-switch hands them back to self-consumption entirely.
number.battery_management_soc_reserve applies in every mode: charge kept back
for the evening peak, so grid-zero cannot empty the packs by late afternoon.
sensor.battery_management_active_policy says which rule is currently limiting
things, so "why is it doing nothing?" has an answer without reading logs.
Two automation blueprints flip the mode on a schedule. Import them from Settings → Automations & scenes → Blueprints → Import blueprint:
mode_during_schedule.yaml— draw blocks in a Schedule helper, pick a mode for inside and outside them. Re-syncs after a restart, so a window that began while HA was down still applies.mode_between_times.yaml— one start and end time with optional weekdays; no helper to create.be_full_by_time.yaml— start a fast charge late enough to be efficient and early enough to finish. Needs Minutes from empty to full filled in under Configure → Control parameters; measure it once. Until you do,sensor.…_minutes_to_full(Fast charge duration) stays unavailable and the automation does nothing, on purpose — arriving late on a guessed duration defeats the point.
Deliberately not a scheduler inside the integration: less to maintain, and anything the blueprints did not anticipate you can still build with an ordinary automation.
Dynamic mode needs to know what electricity costs. There are two ways to tell it, chosen under Configure → Dynamic tariff.
A sensor from another integration is the original route and still the
default. Nord Pool, ENTSO-e, EnergyZero, Tibber, Zonneplan — anything that
publishes a list of upcoming prices in its attributes works, because
prices.py recognises the shapes rather than the integrations. Changing
supplier means pointing this at a different entity. A shape it does not
recognise gives an empty forecast, which disables buying rather than
guessing at a price.
Fetched directly is for suppliers who publish their prices openly. Today that is Frank Energie, whose market-price API needs no account. It exists because "first install another custom integration" is a real obstacle at a house you do not live in, and this is meant to be maintained centrally across several of them.
The direct route uses the all-in price — market price plus VAT, sourcing markup and energy tax — not the bare exchange price. That matters less than it sounds for deciding: tax and markup are a fixed adder and VAT a fixed multiplier, so the transform is monotonic and the cheap-to-expensive ranking is identical either way. It matters for reading: the number on the Plan sensor is then what you actually pay.
The direct route asks for quarter-hourly prices — 96 slots a day rather than 24 — because that is how the market settles and how Frank publishes. How to read the prices still decides what happens to them, but it decides it here rather than at the supplier: folding quarters into hours is a choice this integration can make, whereas a feed that never carried the quarters cannot be unfolded, and a cheap quarter inside an ordinary hour is exactly what an hourly average hides.
Today and tomorrow are fetched as two separate requests. Tomorrow is not published until the afternoon, and Frank answers a date it has no prices for with an error rather than an empty day — so asking for both in one GraphQL document would lose today along with it, every morning. Two requests let the missing day fail on its own.
Prices are re-fetched hourly, which is about noticing that tomorrow has been
published rather than tracking anything. A supplier that cannot be reached is
not an error state — no forecast disables cheap-hour charging and leaves
grid-zero regulating exactly as it does without a dynamic contract. The previous
answer is kept, because prices do not change retroactively and old slots fall
out of the ranking window by themselves. prices_error and prices_fetched_at
in the diagnostics say which of those is happening.
The chart lives on the card that ships with the integration, not on the device page — a device page lists entities, and a chart is not one.
Added the same way as the other cards — see The cards, and where to find them above.
It draws the whole day — one bar per hour, from midnight, coloured by the decision that hour belongs to. Hours that have already gone are drawn faint and carry no colour of their own: the ranking looks forward, so calling a past hour "cheap" would be inventing a decision that was never made.
Green and red are about prices; the blue ring is about the plan. Green marks today's cheapest hours and red its dearest — both ranked over the calendar day, so the band is a stable statement rather than one that creeps as a rolling window slides. What the coordinator will actually buy on is the blue ring, a separate channel drawn on top, because the plan is a subset of the cheap band and a bar cannot be two fills at once. Everything else stays grey: following the meter, no price decision involved. All of it is computed by the integration, so the chart cannot draw a different plan than the one being executed; a card picking its own threshold could.
The green band will not split a tie. Where more hours share the cheapest price than the configured hours allow — six at €0.129 against a budget of four — all six are drawn green, because nothing but the count tells them apart and the band is a claim about price. The budget still binds what gets bought: that is hours you agreed to spend, and they are spent earliest-first among equals. A flat day is painted nothing at all, since the margin rules out a day with nothing to choose between.
The two hues clear a colourblind-separation check on both a light and a dark card (CVD ΔE 9.7, contrast ≥ 3:1 on both), but colour never carries the meaning on its own — every bar names its role in its tooltip and the legend spells all three out. The current hour is marked by an outline rather than another colour, and tapping any bar reads out that hour instead — price, time span and what the coordinator intends there. Tap it again to hand the readout back to the clock. A tapped bar gets a dashed outline so it is never mistaken for the current one. The hover tooltip is still there for a mouse, but a tap is what works on a phone, which is where a price chart actually gets read. Negative prices hang below the zero line instead of being clipped, because on a dynamic tariff they are real.
To place it by hand instead, the only line the chart needs is prices:, and it
points at the Plan sensor rather than a price sensor — that is where the
whole series lives, each hour carrying its role:
type: custom:battery-management-card
prices: sensor.battery_management_planBoth charts show one day at a time, with ‹ and › under them; tapping the
label in the middle returns to today.
Forward costs nothing — the plan sensor already carries every slot the supplier
has published, so tomorrow appears there by itself once it is released, usually
in the early afternoon. Backward is fetched on demand from long-term
statistics of sensor.battery_management_current_price, once per day viewed
and cached for the session. Statistics rather than raw history on purpose: the
recorder purges after ten days by default and statistics are kept indefinitely,
so a month ago is still there. They are hourly means, which is also why a
quarter-hourly feed folds to 24 bars once it is in the past.
History is drawn grey, and that is deliberate. Green on these charts does not mean "a low price", it means this is where the coordinator buys — a decision taken at the time, against the ranking published at the time, and recorded nowhere. Colouring yesterday with today's ranking would draw decisions that were never taken. A past day gets its average as the headline instead, which is the number that day is actually about, and every bar can still be tapped for its own price.
The market settles in 15-minute blocks and suppliers are starting to publish
that way. Nothing in the ranking ever assumed hours — cheap_hours is a
duration, converted into however many slots the feed happens to use — so a
quarter-hourly feed works with no change: ask for 3 cheap hours and it picks
the 12 cheapest quarters.
How to read the prices under Dynamic tariff chooses between the two:
- As the supplier publishes them (the default) is the more precise: the coordinator will buy on a single cheap quarter.
- By the hour folds them, weighted by duration, before anything is decided. You lose the peaks inside each hour in exchange for a simpler day.
Whichever you pick applies to the decisions and the chart, because they must never be able to disagree about what "cheap" meant.
That setting is about the decisions. How many bars you care to look at is a separate question, so the chart has its own zoom.
Pinch it with two fingers, the way you would anything else. One spread of the fingers covers the whole range — from the fitted day to the last quarter — and one pinch back returns; the travel is squared, so a comfortable 2.8× spread does what a straight mapping would have needed eight times the reach for. Dragging with one finger scrolls through the day and is left entirely to the browser, so it keeps its momentum and its rubber-band. A trackpad pinch (ctrl+wheel) does the same thing, and − / + under the day picker are there for a mouse, a keyboard, or a hand that is already full.
While the fingers are down the bars are only re-widened, not rebuilt: within one resolution they are the same bars at a new width, and a width is a CSS variable. Rebuilding ninety-six elements per touch event is what made the zoom arrive in steps. The bars are rebuilt where they genuinely differ — crossing between hours and quarters — and once more when the fingers lift, which is what puts the axis labels back at the right spacing.
Zoom is one continuous number rather than two settings: 1 fits the day to the
width, 4 draws it four times as wide and scrolls. The resolution then falls
out of the arithmetic instead of being a second thing to choose — once a
quarter has enough pixels to be told apart from its neighbours, quarters are
what gets drawn, and the label under the chart says which you are looking at.
It always opens on hours, on every screen. 96 bars across a phone is about two pixels each, which is a texture rather than a chart; and even on a wall tablet wide enough to fit them comfortably it still opens folded, so the day reads the same way everywhere and the quarters are something you go and ask for. The zoom only appears when the feed actually has quarters in it.
A folded hour is a summary, and summaries are where a chart gets to overstate things, so two rules keep it honest:
- Its colour is the verdict covering most of the hour. An hour split evenly between cheap and dear is drawn as neither, and its tooltip says it is mixed — which is the nudge to go and look at the quarters.
- Its blue ring survives if any quarter inside will be bought on, because hiding that charging happens in an hour is the one error worth avoiding here. The tooltip then says how much of the hour it was — "hier gaat hij laden (1 van de 4 kwartieren)" — so the ring cannot read as a promise about the whole hour.
price_zoom: quarter in the card's YAML opens zoomed in instead, for a screen
wide enough to take it. Like the charge-period pills, pinching changes it for
the session only — a card cannot rewrite its own YAML, and pretending otherwise
would lose the choice on the next reload without saying why.
For a dashboard that just wants to know whether to run the dishwasher, there is a second card: Battery Management Prices. Current price large at the top, the same chart underneath, and the cheapest and dearest hour of the day with the times they fall.
Settings → Dashboards → Energy → Grid consumption → Use an entity with current price and pick Current price. That is the all-in price, which is what import is billed at.
Or match your supplier's app. Frank's app shows het dynamische deel: the price without the energy tax, which is the same every hour and billed apart. On 2026-09-23 that put the same 11.7 kWh at €1.29 in the app and €2.52 here — the difference is 11.7 kWh × ~€0.11 of tax. Pick Price without energy tax and the two agree; the tax is still on the invoice either way.
Gas works the same way: Gas consumption → Use an entity with current price and pick Gas price, or Gas price without energy tax to match the app. It is fetched in requests of its own, so anything that goes wrong with gas can only lose the gas price — never the electricity prices the packs are steered on.
Export is a different number. Energy tax and VAT are not paid back to you, so pointing export compensation at the same entity overstates what you earn — and a wrong figure on an energy dashboard looks exactly like a right one. Use Market price instead, which is the exchange component on its own.
What your supplier actually pays back is calculated from that, with their own fee or deduction on top. This integration does not know your contract and does not guess at it: if Frank deducts a fixed amount per kWh, a static rate or a template sensor built on Market price will be closer than either raw number.
The control loop regulates the household total. The packs are single-phase, and each one sits on one leg of the supply. Nothing in the Modbus data connects those two facts, so on its own the loop will happily tell a pack to charge at 3500 W on a leg that is already pulling 20 A — taking that leg to 35 A while the other two sit half idle and the total looks perfectly reasonable. That is how a main fuse drops.
This is off unless you configure it. Point Configure → Fuse protection per phase at your meter's per-phase power sensors, in L1, L2, L3 order, and each leg gets its own ceiling.
The ceiling is a bound on the setpoint, applied per unit — the same mechanism as the SoC reserve and the modes, at the same anti-windup clamp. So grid-zero keeps regulating inside it and the integrator can never build pressure against a fuse it may not cross.
For each leg it works out what the household is drawing without us:
without us = leg reading + our own command on that leg
room to charge = fuse limit - without us
room to discharge = fuse limit + without us
Two things are worth knowing about that:
- Both directions count. A main fuse carries the net current through it, so a leg exporting 25 A is as far into the fuse as a leg importing 25 A. Discharging into a leg already full of sun is a real way to trip one.
- It only bites when it matters. A leg doing 500 W of a 5175 W budget has more room than a pack can use, so nothing is limited and you will never notice this is running. It appears exactly when it was going to matter.
The margin (10 % by default) is deliberate. A fuse is not a cliff — a B-curve holds well above its rating for a long time — but running it to the last ampere leaves nothing for the kettle somebody switches on while we are deciding, and our picture of the leg is up to 30 s stale anyway (gotcha 2).
Fast charge respects this too. It is the one place that commands full rating outright, so it is the single most likely thing to drop a leg.
The limit is worthless without knowing where each pack is, and nothing reports it. So it is measured: every other pack is held at 0, one pack is commanded, and after 20 s the leg that moved with it is the answer. It takes about a minute per pack, during which the packs do not regulate.
The probe is itself bounded by the fuse — the legs are unknown, so its power is capped by whichever leg has least room. On a busy evening there may be no room to push into, and then it simply declines and tries again later.
It refuses to answer rather than guess. The winning leg has to show at least
half of what was asked for, and stand clearly above the runner-up, or the oven
that switched on halfway through gets a vote. A pack placed on the wrong leg
would mean guarding the leg that was never in danger, so no answer is much better
than a wrong one. Whatever it saw is kept in the Phase detection sensor's
probes attribute, and in the diagnostics download.
It measures when:
- a placement is missing — including on a fresh install,
- a pack dropped offline and came back (it is no longer provably the same pack on the same wiring),
- Home Assistant restarted — an integration that has been down cannot vouch for what changed while it was. Switch Measure again after a restart off once the wiring is known and stable, otherwise every restart parks the packs for a minute,
- you press the Detect phases button.
It never measures during a dry run: a shadow run writes nothing, and this has to write to see anything. So during a shadow month the detection sensor reads Not while in dry run — either type the phases in by hand on each unit, or accept that the first probe happens when you go live.
Typing them in wins. Each unit's page in the wizard has a Phase field; anything other than 0 there overrides every measurement. If you have read the meter cupboard, you know better than a probe does.
A pack whose leg is unknown is treated as if it were on whichever leg has least room — because it might be. That is deliberately the pessimistic reading, and it is why it is worth letting the detection finish or typing the phases in. On a quiet house it changes nothing at all; on a loaded one it will hold that pack back.
If the phase sensors are configured but cannot be read, the packs are held at 0. Falling back to "no limit" would disarm the guard at exactly the moment the meter is misbehaving.
Home Assistant does not read an integration icon from its own repository — it
fetches brand images from brands.home-assistant.io, which is fed by the
home-assistant/brands repository.
Until an icon lands there, the integration page shows a grey placeholder.
The files are ready in brands/, laid out exactly as that repository
expects. To publish them:
- Fork
home-assistant/brands - Copy
brands/custom_integrations/battery_management/into the same path there - Open a pull request
scripts/make_brand_icon.py regenerates them (needs Pillow, which is
deliberately not a dependency of the integration itself, so the manifest can
keep requirements: []).
Once merged, drop ignore: brands from the HACS step in
.github/workflows/ci.yml — it is only there because the brand does not exist
yet.
Settings carry their own explanation in Home Assistant, under each field in the setup wizard and under Configure. Entities have nowhere to put that, so they are listed here.
| Switch | What it does |
|---|---|
| Coordinator enabled | The kill switch. Off hands the packs back and stops all coordination. This is not a mode: it is "let go entirely", which is why it is a separate switch. |
| Fast charge (emergency) | Charges every pack at full power to its limit, from the grid if needed. Once full it holds them there until you switch it off, topping up if they drift down — you pressed it to be ready for something. Never resumed after a restart. |
| Dry run | Decide everything, command nothing. On by default. Blocks every write including the safe revert, so it cannot fight another controller. The suppressed-command counter on this switch is its proof of life: a shadow run that suppressed nothing is a broken one. Switching it back on hands the packs back first — otherwise they would hold the last live command indefinitely. |
| One pack at low load | Off by default. A discharge up to 1500 W is carried by the fullest pack alone, the other rests; above that both share again, and it goes back to one below 1125 W. The attributes compare, night by night, what the packs delivered against how far their charge fell, with it on and off — see One pack at low load. |
One strategy at a time. Every mode is grid-zero regulation with a bound on it, so the packs keep responding to the house and the sun inside whatever you pick — they never sit idle merely because they are inside a window.
| Mode | What it does |
|---|---|
| Follow the meter | Charge on surplus, discharge on deficit. The floor under every other mode. |
| Charge only | Still fills from surplus, never discharges. |
| Discharge only | Still covers a deficit, never charges. |
| Pause | Holds at zero. Not the kill switch: the packs stay under control. |
| Dynamic tariff | Grid-zero plus buying on the cheapest hours and saving the charge for the dearest. Only offered once a price sensor is configured. |
| External plan | Executes a plan from elsewhere (EMHASS) through the set_setpoint service. Hands control back if no plan arrives. |
A second select, Smart trading, sits beside it: Off, Shadow or On. It only acts in Dynamic tariff — see Selling to the grid.
| Number | What it does |
|---|---|
| SoC reserve | Charge held back, in every mode. Raises each pack's own discharge floor rather than clamping the pair, so the split tapers towards it and a fuller pack carries the load alone. 0 = off. |
| Buy up to: at least | Floor under the computed charge ceiling. Use it when the solar forecast is too gloomy to trust. |
| Buy up to: at most | Cap on the computed charge ceiling. Use it when the forecast under-reads, which would otherwise let it buy more than needed. |
| Sell down to: at least | How far selling to the grid may empty a pack. The reserve still applies underneath; the higher of the two wins. |
The last two bound grid buying only. Charging from your own surplus is never capped — that would be throwing sun away.
| Sensor | What it means | Unavailable when |
|---|---|---|
| Setpoint | Total power the packs are told to deliver. Positive discharging, negative charging. This is the integrator's own state, not a reading of the packs. | never |
| Status | idle · charging · discharging · fast_charge · hold · off · degraded | never |
| Active policy | Which rule is limiting things right now — see the table below. | never |
| ‹unit› target | What that pack was told to do, signed. Keeps its last value when the pack drops offline, because it is still executing that command. | never |
| Grid power (as read) | The meter reading as this integration read it. Should sit exactly on your own P1 sensor. | the meter cannot be read |
| Grid power (regulated against) | What it actually steered on. Equal to the above when live; the reconstruction during a shadow run. | the meter cannot be read |
| Other controller | What the site's own automations are commanding, signed. | not shadow running |
| Fast charge duration | How long a fast charge would take from now — at full power, which is what the be-full-by blueprint triggers. Slowest pack, since they charge in parallel. Its at_current_rate_minutes attribute answers the other question: how long at the rate being commanded right now, which on solar surplus alone is far longer. |
the charge time has not been measured |
| Solar remaining | Sun still expected today. Its attributes break down every forecast sensor separately, so "0 kWh" can be told apart from a sensor that is not reading. | no forecast sensors configured |
| Charge ceiling | How full it is worth buying to: 100 % − (remaining sun × the measured share that reaches the packs) ÷ capacity, within your two bounds. Its solar_capture_share and solar_capture_days attributes say how much of the forecast it expects to arrive, and on how many measured days that rests. |
the charge time has not been measured |
| Market price | The exchange component of this hour, without tax or markup. For the Energy dashboard's export compensation — see below. | not on the direct route |
| Current price | What this hour costs, in EUR/kWh. Its attributes say which decision the hour belongs to, when it changes, and what the next one is. | no prices available |
| Price without energy tax | This hour's all-in price less the energy tax — what Frank's app shows as het dynamische deel. The tax is the same every hour and billed apart, so the app leaves it out; point the Energy dashboard at this sensor to make the two agree. The tax is still paid, on the invoice. | not on the direct route |
| Gas price | This gas day's all-in price per m³, from Frank. For the Energy dashboard's gas cost only — nothing here steers on gas. Set per gas day, so the same number all day. | not on the direct route, or Frank gave no gas price (the diagnostics say why under gas_error) |
| Gas price without energy tax | The same less the energy tax, to agree with Frank's app. | as above |
| Savings today / this month / since start | What the packs saved, in EUR, against the same house without them: the import they replaced at the all-in price, less the export they gave up at what your contract pays. Shown on the footing that applies today (with saldering until it ends); the attributes carry both, plus what selling earned (traded_kwh, traded_eur) and what shadow would have (shadow_kwh, shadow_eur). |
never (nought until the first counted tick) |
| Payback time | In how many years the packs pay for themselves on a dynamic contract without saldering: purchase price ÷ the yearly saving on that footing. The attributes add the same with saldering, years_to_go from today (saldering at its rate until it ends, then without, less what is already saved), the yearly savings and counted_days. reliable turns true after 30 counted days. Shows no value (unknown) until there is a purchase price and a day counted; the attributes stay, so the card can say which of the two it is waiting for. |
never |
| Standby load | What the house draws while everyone sleeps, in W: last night's lowest five minutes between 01:00 and 05:00, meter plus packs. The attributes add when that was (lowest_at), the night_average_w, the average_7_nights_w, what it comes to in a year (per_year_kwh), tonight_lowest_w while the night is being measured, and the last 30 nights in history. See Standby load. |
never (unknown until the first night) |
| Smart trading status | selling, would_sell (shadow), waiting, off or not_dynamic. The attributes carry the three prices it weighed — export_value_eur_kwh, refill_eur_kwh, wear_eur_kwh — the margin_eur_kwh they come to, and why it is not selling. |
never |
| Plan | Today's cheap and dear hours with their prices, plus the numbers the ceiling was computed from, all in attributes. Its hours attribute is the whole series, each slot carrying the role it belongs to — cheap, dear or normal — which is what the card's chart is drawn from. |
never |
| Fuse headroom | Amps still available on the busiest single leg — not a total, and not per leg. It is the one that would trip first; tightest_phase in the attributes says which. Measured against the usable limit (the fuse less your margin), so the margin is still there underneath. Per-leg detail — amps through the fuse, amps_without_us, headroom, and which packs sit on it — is in the attributes. |
no per-phase sensors configured |
| Phase detection | Whether it knows which pack is on which leg, and how it found out. The probes attribute holds the measurements behind each placement. |
never |
| ‹unit› phase | Which leg that pack is on — L1, L2 or L3. Its source attribute says whether that was measured or typed in. |
no per-phase sensors configured |
| Sensor | What it means |
|---|---|
| Healthy | Off is good. On means the control loop is degraded — usually the grid sensor cannot be read. |
| ‹unit› online | Whether that pack's state of charge could be read on the last tick. Off means it was skipped; it keeps running its last command regardless, so check its target sensor too. |
| Button | What it does |
|---|---|
| Detect phases | Measures again which pack sits on which leg of the supply. Takes about a minute per pack, during which they hold at 0. A phase you typed in yourself is left alone — set that field back to 0 first if you want it re-measured. |
| Policy | Meaning |
|---|---|
| Following the meter | Regulating normally, nothing limiting it |
| Difference too small to act on | Inside the deadband |
| Holding the SoC reserve | Would discharge, but the reserve says no |
| Packs empty / Packs full | Nothing left to give, or nowhere to put it |
| Mode: … | Your chosen mode is the limit |
| Held back by the main fuse | One leg of your supply is close to its fuse; see the Fuse headroom sensor |
| Measuring which pack is on which phase | A detection run is in progress; the packs hold at 0 for about a minute |
| Buying now, prices are low | A cheap hour, packs low, little sun coming |
| Holding the charge for dearer hours | Refusing to discharge now so the kWh go to the peak |
| Holding back, a cheaper day is coming | Tomorrow buys cheaper, so tonight takes only what the floor asks for |
| Holding back, cheaper later today | A cheaper window follows the coming peak; buying stops at the floor until then |
| Not buying, the sun still fits | What is coming free would not fit if it bought now |
| Dynamic, but no prices available | The mode is on but the price sensor is mute |
| Selling to the grid, it pays | Smart trading is On and this slot earns more than the refill and the wear |
| Filling up to sell tonight, it pays | Smart trading is On and tonight's sale earns more than buying now costs; buying goes past the house's own ceiling, up to Buy up to: at most |
| Following an external plan | EMHASS or similar is driving |
| External plan went quiet | It stopped arriving; back to following the meter |
| Fast charging / Charged, keeping full | The override is running |
| No grid reading | The meter cannot be read; nothing is commanded |
| Coordinator off | The kill switch is off |
Three sensors, which together are how a shadow run is verified:
| Sensor | What it is |
|---|---|
…_grid_power_as_read |
the meter reading as this integration read it |
…_grid_power_regulated_against |
what it actually steered on |
…_other_controller |
what the site's own automations are commanding, signed |
The first should track your own P1 sensor exactly — same value, same sign (+ import). If it does not, the entity or the sign convention is wrong and nothing downstream can be trusted. It goes unavailable the moment the meter cannot be read, which is the fastest way to notice a dropout.
During a shadow run the second is the reconstruction: the meter as it would read if this coordinator were in charge instead of the site's automations. It should differ from the first — that difference is roughly what the other controller is doing. When running live the two are identical.
type: history-graph
title: Is the meter read correctly?
hours_to_show: 6
entities:
- entity: sensor.p1_meter_power
name: Your own P1 sensor
- entity: sensor.battery_management_grid_power_as_read
name: What the integration read
- entity: sensor.battery_management_grid_power_regulated_against
name: What it steered on (reconstructed)
- entity: sensor.battery_management_other_controller
name: What your automations commandThe first two lines should sit exactly on top of each other. If they ever separate, that is a bug worth reporting.
The Battery Management Plan card reads it for you, top to bottom:
- What rule is in force and the mode, then one line on what comes next — "Nu: laden van het net tot 14:15" or "Hierna: 18:45–19:30 verkopen aan het net".
- Vandaag — one timeline of buying from and selling to the grid, in time order. Consecutive quarters with the same verdict are one row, with the price range of the stretch (or, for a sale, its best margin). What has gone is greyed and says what became of it: geladen, niet geladen, verkocht.
- Hoeveel wil hij nog laden — how much the sun and the grid are still meant to put in, and why.
Via het net is what the planned hours will actually buy, not the room up to the ceiling. The ceiling says how full buying may make the packs. On a flat day, buying stops short of it: a quarter is bought on only if it beats the dear hours it saves for by Minimale besparing (Configure → Dynamisch tarief, 5 cents by default). Below that, the 12 % round trip and the wear cost more than the stored kWh saves. Reported on 9 October: the card said "Laadt tot 89 %" and 19.7 kWh from the grid, with the packs at 18 % and a single quarter planned. Now the tile reads what the plan will buy, and the sentence under it says why it stops there and which setting decides it: "Mag laden tot 89 %, maar vandaag loont maar 1.8 kWh van het net: alleen in de tijden hierboven is stroom minstens €0,05 per kWh goedkoper dan de dure uren waarvoor hij laadt…".
The margin is measured against the dear hours of the next 24 hours. Once
tomorrow's prices are published, a dear morning tomorrow lets today's cheap
afternoon qualify, and it fills further. On the Plan sensor the figure is
expected.grid_planned_kwh, beside expected.grid_kwh (the room) and
price_margin.
sensor.battery_management_plan carries the day's intentions as attributes:
which hours it picked as cheap and dear, how much sun is still expected, the
usable capacity, and the resulting charge ceiling. A markdown card renders it:
type: markdown
title: Plan for today
content: >-
{% set p = state_attr('sensor.battery_management_plan','cheap_hours') %}
{% set d = state_attr('sensor.battery_management_plan','dear_hours') %}
{% set sun = state_attr('sensor.battery_management_plan','solar_remaining_kwh') %}
{% set cap = state_attr('sensor.battery_management_plan','usable_capacity_kwh') %}
{% set ceil = state_attr('sensor.battery_management_plan','charge_ceiling') %}
**Sun still to come:** {{ sun if sun is not none else '?' }} kWh
{%- if cap %} of {{ cap | round(1) }} kWh storage{% endif %}
**Buy up to:** {{ ceil | round(0) if ceil is not none else 'not computed' }} %
{% if p %}**Cheap hours** — buy here
{% for h in p %}
- {{ as_timestamp(h.start) | timestamp_custom('%H:%M') }} · {{ h.price }}
{%- endfor %}
{% endif %}
{% if d %}**Dear hours** — the charge is saved for these
{% for h in d %}
- {{ as_timestamp(h.start) | timestamp_custom('%H:%M') }} · {{ h.price }}
{%- endfor %}
{% endif %}The ceiling reserves room for the sun still to come. Reserving room for all of it assumes every kilowatt hour the panels make reaches a pack — but the house is first in the queue, and at most sites it takes the larger share.
That assumption has a cost. On one site the ceiling held buying back at 45 % through a five-hour window at €0.129/kWh, because the forecast promised ~15 kWh more sun. 7.4 kWh arrived, and 1.7 kWh of that reached the packs. They were flat by 08:00 the next morning.
So the reservation is scaled by what recent days actually delivered:
buy up to 100 % − (sun still coming × measured share) ÷ capacity
The share is (charged − bought) ÷ produced with room, taken as the median
of the last 14 days that had at least 1 kWh of such sun and actually recorded
charging.
"With room" is the part of the panels' output that fell while at least one pack was more than three points below its own charge limit. Sun that arrives at full packs is exported whatever the packs would have wanted, so it cannot say what share a pack with room takes — and counting it fed on itself. On 2026-09-21 the grid had filled the packs by 12:45; 7.6 of the day's 17.2 kWh came after that, 5.5 kWh went back out, and the share read 32 % where 61 % had gone in while there was room. A low share raises the ceiling, which buys the packs full again, which keeps the share low.
The production sensor steps every few minutes, so each step is shared out over the ticks it covers by how many of them had room. Days closed before this was measured have no with-room figure and are skipped, so right after the update the share starts again from nothing and needs three new days.
- A fortnight covers two weekends, so weekend habits cannot pass for the whole picture, and it is short enough that the season shows through — once the heating is on the house takes a larger share, and that reaches the ceiling within a couple of weeks. Being a share, shrinking daylight moves both halves together, so it stays comparable through the autumn.
- The median, not the mean, because a fortnight contains the odd day away from home or with a car on the charger, and one of those must not move a number the packs are steered by.
- Under three measured days it is not used at all, and the ceiling behaves exactly as it did before this existed. Days that recorded no charging are skipped rather than counted as zero — the charge-power sensors are optional, and missing data is not the same as a sun that delivered nothing.
The share never exceeds 1, so this can only ever raise the ceiling — it can talk the packs into buying more, never into buying less, and cannot introduce a new way to be caught empty. It is floored at 0.05 so a fortnight of cloud cannot say "buy to full" on the morning of a blazing day.
sensor.battery_management_charge_ceiling carries solar_capture_share and
solar_capture_days in its attributes, so what it learned is visible rather
than implied. The Plan card says it in words under the split:
Er komt nog 7.4 kWh zon; daarvan belandt naar verwachting 1.7 kWh in de accu's (23 %, gemeten over 14 dagen). De rest gaat rechtstreeks het huis in.
and while it is still unmeasured, says that instead — room is being held for the whole forecast, which is the assumption worth seeing rather than hiding.
The Plan sensor's expected block publishes both figures: solar_remaining_kwh
is what the panels will make and solar_expected_kwh what is expected to get
past the house. The split shown on the card is read off the second, the same
number the ceiling was set from — reading it off the first would have the card
promise sun the ceiling had already written off.
The cheap hours are ranked over a rolling 24 hours, which is right for deciding when — at 16:00 what matters is what is still ahead. It was wrong across an expensive stretch. Energy bought now serves what comes after it is bought and before it is spent, so a cheap hour on the far side of tonight's peak is not an alternative to one on this side. They are not substitutes, and ranking them together meant tomorrow morning's cheaper quarters took the budget that tonight needed.
Reported on 2026-09-19: the packs sat at their floor for four and a quarter hours, charged for 75 minutes, and stopped at 31 % on a quarter boundary — the price unchanged, the ceiling at 79 %, the market price at −0.0012 and no dear quarter yet that day. So the buying now stops at the next expensive stretch: only the hours before it compete for the budget.
The margin is still measured against the whole window, deliberately. What the bound cuts off is the dear hours the buying is for, and comparing the cheap hours only with each other would rule every one of them out and buy nothing at all.
Stopping the ranking at the peak fixed when it buys, and quietly broke how much. Whatever is cheapest of what is left on this side wins by default — however dear it has become in absolute terms — and as the genuinely cheap quarters go by, "cheapest of what remains" climbs.
Reported on the morning of 2026-09-22: 6.1 kWh bought overnight at an average of €0.337 with the packs already at 77 %, hours before an afternoon a third cheaper that they held far more than enough charge to reach. The last four purchases were at €0.348, €0.363, €0.369 and €0.398 — each one the cheapest thing still left before the peak.
So the deadline stays and the amount now looks past it. When the cheap hours on the far side of the coming peak are cheaper than this side by more than the margin, it holds back — and how far depends on when that cheaper window is.
Later the same day: buy nothing now. Buy up to: at least is a target for the end of the day, in the owner's words: "die ondergrens moet voor het eind van de dag zijn niet in de ochtend". A cheaper window later that day is still in time to reach it, at a better price, so the morning peak is met on whatever the packs hold — floor or no floor. "The same day" is the peak's own day, so seen from 22:00, tomorrow morning's peak is weighed against tomorrow afternoon.
Only past midnight: buy the floor, no more. Then the end of the day comes before any cheaper chance, and the floor is what the packs meet it with — the bridge into the evening. With Buy up to: at least at 0 nothing is held back here, because "buy nothing before the evening peak" is exactly the fault above arriving from the other direction.
If you want charge in hand for the morning peak regardless, that is what SoC reserve is for: it stops the packs discharging below it at any hour.
When the packs can carry the peak alone, any cheaper window will do. The margin on the hold exists so the packs do not meet a peak emptier for a penny. When the charge above their floor sees the house through to the end of the peak — the measured house draw over the time to the peak plus the peak itself, with half again on top — meeting it emptier costs nothing, and the hold applies to any cheaper window after it. Reported on 1 October: two top-ups at €0.34 with the packs at 85 %, before a peak they would have crossed with charge to spare. Until the house draw has been measured, the margin applies as before.
The comparison either side of the peak is made over what is actually to be
bought (at least one slot, at most cheap_hours), so it does not jump from a
quarter to five hours as a pack crosses the band — that flipped the verdict
between "buy" and "wait" with every percent the house drew. And a hold that
cuts a purchase short frees its slot: the packs go back to covering the house
rather than sitting at nought while the meter imports.
The hold changes when the packs are filled, not how full, and the card has to read that way from the first look in the morning. On 2026-09-23 it did not: before the peak it showed the floor the hold stops at as though it were the day's ceiling, and an empty hour list with "the packs reach the ceiling without the grid" — and after the peak, the real ceiling and noon. Both true of their moment; read an hour apart, a contradiction.
So while a hold is on:
- Charge ceiling (sensor and card) is today's target. The hold has its
own attributes on the sensor:
held_toandheld_until. - The card adds "Vóór de piek van 07:45 koopt hij niets — later vandaag is het goedkoper." — or, before the last peak of the day, "hooguit tot 50 %".
- The hours it is waiting for are listed as verwacht, outlined rather than filled: expected, not promised, because the need is measured again when they arrive. Once the peak passes the same hours become gaat laden.
The packs report whole percentage points. A bank sitting on a ceiling of 90.9 therefore reads 90 and 91 in alternate ticks, and each 90 used to restart a purchase at full power that each 91 then stopped. Going out is immediate and coming back is integrated, so each one cost some 45 seconds of importing at 7 kW.
Anything within two percentage points of the ceiling now counts as at it. Both halves measure that room through one helper on purpose: when the earmarking and the draw disagreed, an hour was marked for buying that the tick then refused, which is the flapping itself.
That band only gates starting. With one line for both ends it merely moved the flapping two points down: on the night of 2026-09-24, floor at 50 %, a pack at 47 % started a purchase, stopped one point later at 48 %, was drawn back to 47 % by the house and started again — three 7 kW bursts in an hour. A purchase under way now runs to the line itself, and once there it is not restarted in the same slot.
Whether to buy before the peak is settled by the peak. How full is a fair question for tomorrow's prices to answer: with a much cheaper day coming there is no sense filling to the brim tonight.
So when the cheap hours beyond local midnight are cheaper than the rest of today by more than the margin, the ceiling drops to Buy up to: at least — take what tonight needs, top up on the cheap day. That comparison is made across the day boundary rather than across the peak: everything beyond a peak is dearer than the cheap hour before it on every ordinary day, so that comparison fires constantly and says nothing. It also stands down when either side is too thin a slice to judge — two night hours seen from 02:00 are not a day.
The solar ceiling still has the last word on how much. A full day of sun into packs that hold exactly that much means buy nothing, peak ahead or not; half a day of sun means buy to half and leave the rest to the roof. The peak boundary decides when it may buy, never how much.
This does nothing until you set a floor. Buy up to: at least ships at 0, and reading that as a level to stop at would mean "buy nothing", which is the fault it was reported alongside. With no floor stated, nothing is lowered.
The other direction is a setting rather than a behaviour, because it overrides Buy up to (fallback) — a threshold somebody chose — and that should be opted into rather than arrive with an update.
Fill up before a dearer day (off by default, under Configure → Dynamic tariff): when tomorrow's cheap hours are clearly dearer than what is left of today, buy to Buy up to: at most instead of stopping at Buy up to (fallback).
It never buys room the sun is expected to fill. That is not caution, it is arithmetic: that energy is free, and buying it does not make tomorrow cheaper — it exports the afternoon instead of storing it. So it only lifts a ceiling the plain SoC threshold was holding down, never one the sun was, and with a solar forecast configured it changes little. Buy up to: at most is still the last word on how full.
Two sliders bound the computed ceiling, because it is only as good as the solar forecast behind it: Buy up to: at least and Buy up to: at most. Leave them at 0 and 100 and the calculation passes through untouched. They only limit buying from the grid — charging from your own surplus is never capped, since that would be throwing sun away.
sensor.battery_management_solar_remaining and
sensor.battery_management_charge_ceiling carry the same two numbers on their
own, so they can be graphed.
The plan deliberately does not predict the setpoint. That depends on the house minute by minute, and a graph claiming otherwise would look authoritative and be wrong.
Off by default, and it stays off until you fill in what the packs cost. With Smart trading on, Dynamic tariff also sells: in a slot where a kilowatt hour sent back earns more than buying it back later costs, it discharges the packs at full power — within the fuse — down to Sell down to: at least.
Per slot it weighs three prices, all in Configure → Smart trading:
| Where it comes from | |
|---|---|
| What it earns | Your contract's rule: the bare market price, the market price with VAT, or a fixed rate, plus a correction (a feed-in fee goes in negative). While saldering lasts the energy tax of that slot is added, because it is netted. |
| What refilling costs | The mean of the cheapest hours still ahead — the same hours buying ranks — divided by 0.88 for the round trip. When the sun will refill the sale instead, that share costs what the sun would have earned going to the grid between 10:00 and 16:00 — see Refilled by the sun below. |
| What it wears off | The packs' purchase price ÷ (rated cycles × the measured capacity). EUR 5000 over 6000 cycles of 28 kWh is 3 cents a kWh. |
A slot pays when earns − refill ÷ 0.88 − wear is at least the minimum profit (5 cents by default). Of the slots that pay up to the next midday it then picks the best, the way buying picks the cheapest hours: as many as it takes until the first pack reaches its sell line at full power — at full power every pack gives its own maximum, so the emptier pack decides how long a sale lasts, not the total above the line. It sells only in those, so the evening's best quarters get the energy rather than the first one that clears the threshold; while it waits for a better one the card says so. It starts only with every pack more than two points above its sell line. It then carries on to the line and does not restart in that slot. It never sells in an hour it is buying in, and only in Dynamic tariff.
Filling up to sell. Buying for the house alone stops where the house needs it to: on a day before a cheaper one only at Buy up to: at least. If that is also the sell line, the evening has nothing to sell. Reported on 8 October: both at 70 %, a sale on the plan, and nothing above the line. So with trading On (not in shadow, which sells nothing), a cheap afternoon is filled further when tonight pays for it. It is the same test a sale is held to, with today's purchase in place of tomorrow's refill:
- Paired kWh by kWh: the green hours still to come before the peak, cheapest first, are set against the best-paying slots of the evening, up to the next midday. Both are weighed at the packs' full power. The evening's best slots go first to what is above the sell line already.
- Only while it pays: a kWh is bought only while earns − purchase ÷ 0.88 − wear is at least the minimum profit.
- Within your bounds: no further than Buy up to: at most, and never into the room the sun is expected to fill. Sun is free, and buying its room would export it instead.
- Never in a dear hour: only in the hours the chart colours green. The arithmetic alone would buy at 18:00 for 0.40 to sell at 21:00 for 0.60.
- Holds do not stop it: "cheaper tomorrow" and "cheaper later today" are
about the house's energy, so they do not hold this back. The policy reads
Filling up to sell tonight, it pays, and the plan card says how full and
why. The Plan sensor carries it as
trade.fill_to.
The plan also no longer lists a sale the tick would not start. A sale only starts with more than two points above the line, so packs resting just above it are not shown as about to sell.
Refilled by the sun. A kWh sold tonight need not come back from the
grid: if tomorrow's sun fills the packs anyway, it comes back for what that sun
would have earned exported — usually less than the cheapest grid hour. Only
the sun beyond the room the packs already have by sunrise refills a sale:
first the room there is now, then what the house draws from them overnight
(measured, as a slow average of meter plus packs), and only then the slice the
sale emptied. A sale before noon counts on the same day's sun, one after noon
on the next day's. The sun into the packs is the forecast times the measured
capture share (see How much of the sun actually arrives), so nothing is
counted until that share has a few days behind it — the forecast alone is
what the panels make, and most of it the house takes first. Tomorrow's
forecast comes from Configure → Solar → Solar forecast tomorrow, or, left
empty, from the …_tomorrow twins of the today sensors, which is how
Forecast.Solar names them. The trading card shows the sum as a receipt — Opbrengst per kWh,
Terugvullen (60 % zon, incl. 12 % verlies), Slijtage accu's, and the
Winst per kWh beside the minimum it has to reach; the trace has
refill_solar_share.
Saldering. Until the date in the settings (1 January 2027 by law) the energy tax comes back on everything fed in, which roughly doubles what a peak earns. After that date selling stops paying on most evenings by itself — the same peak that earned 18 cents a kWh earns 2. Nothing needs switching off.
Shadow first. In Shadow it decides exactly as On would and commands
nothing; what it would have sold is counted in shadow_kwh and shadow_eur
on the savings sensors. Shadow never empties a pack, so it keeps its own
account of what it has "sold" and stops where the real packs would have, until
the next buy fills them again. Dry run with trading On counts as shadow too.
In the plan. With trading on or in shadow, the Plan card gets a
Wanneer verkopen list: the slots still to come that would pay to sell in —
the live decision asked of every slot ahead, with that slot's own prices,
the cheapest refill after it, and saldering as it will be on that day —
and the slots it did sell in (or would have, in shadow). Consecutive quarters
read as one row, with the best margin of the run. Only the slots it will
actually sell in are listed — the best ones, as many as the packs can fill —
not every slot that would pay. A buy hour is never listed:
the tick gives that hour to buying. What no price list can know is whether the
packs will be above Sell down to when the hour comes, so the card says how
many kWh sit above it right now. The same is on the Plan sensor: sell,
sell_margin and sold per hour, and a trade block.
What was saved. The savings sensors count every tick, whoever is driving: the same house without packs would have drawn meter + packs, and the difference in what the two cost is the saving. Pack power is the measured battery power sensor where one is configured, the command read back otherwise. Both footings are kept — with saldering and without — so a payback time can be read on the one that will actually apply. A day can come out negative: sun stored costs the export it replaced, and is only repaid when it is used.
Every tick's verdict is in the trace (export_value_eur_kwh,
refill_eur_kwh, sell_margin_eur_kwh, trade_why) and in the diagnostics
under trading. trade_why says what stopped it: no_battery_price,
no_capacity (measure the full-charge time), no_export_value (a third-party
price sensor has no market price — only a fixed rate works there), or
margin_too_small.
Payback time answers the question as asked: with a dynamic contract, and
once feeding back no longer nets, in how many years do the packs repay what
they cost? It is the purchase price over the yearly saving without
saldering, where the yearly saving is what has been saved per counted hour,
times a year. With saldering sits beside it, and years_to_go is the one that
will actually come true from today: saldering's rate until the end date,
the rate without it after, less what is already saved.
It extrapolates, so read it with the measurement: a few weeks in September say little about a winter, when there is less sun to shift and the peaks differ. The card says how many days it rests on, and calls anything under a month too short to build on.
Trading and saving are two different questions, looked at at different times, so they are two cards:
- Battery Management Trading goes on the battery dashboard, beside the other controls: + Add card → By card → "battery". It finds its own entities, so there is nothing to fill in.
- Battery Management Savings gets a dashboard of its own. Make a new
dashboard and paste
dashboards/besparing.yamlinto its raw configuration editor: the card, savings per day and per month as bars, the running total, and the standby load per night. The card colours each figure green when the packs saved money and red when they lost it; half a cent either way counts as nought and stays uncoloured. The graphs name entity ids; on a Dutch install check them under Settings → Entities first, as the file explains.
Standby load is what the house draws while everyone is asleep: the fridge, the router, the ventilation, the circulation pump, everything left on standby. It stands all day as well, so every 100 W of it is about 875 kWh a year.
It is measured every night between 01:00 and 05:00, as the meter plus what the packs give. That is the house's own draw whether the packs or the grid are covering it, and it does not depend on which. The figure is the lowest five-minute average of the night. Five minutes is long enough that the fridge's compressor switching off is not taken for the floor, and short enough that the compressor switching on does not lift it.
- A purchase is left out. The packs' power sensor trails the meter by 10–30 s, so for that long a purchase starting or stopping looks like a house drawing 7 kW more or less than it does. Any five minutes in which the packs' power moved by more than 1 kW does not count.
- A night needs an hour of steady readings, or it is not reported. The previous night's figure stays.
- It is reported once the night is over, at 05:00. Until then,
tonight_lowest_wshows how low it has been so far. - A restart in the night keeps the night.
What the packs themselves use is not in it: the conversion losses of covering a few hundred watts are paid from the battery, not drawn from the meter. Comparing what the packs delivered overnight with how far their charge fell puts that at roughly 100 W for two Max AC packs.
Tracking it down. Late in the evening, switch the groups in the meter cupboard off one at a time, watching the per-phase power of the P1 meter, and see where the most disappears. The next morning's figure shows whether what was switched off for good made a difference; the dashboard below has a bar per night.
At night the house draws a few hundred watts, and by default both packs cover it, about 225 W each. That is the least efficient part of a pack's range: what it loses converting is large compared with what it delivers. Over the nights in the September traces the packs' charge fell by about 560 W worth while they delivered about 450 W.
The One pack at low load switch lets the fullest pack carry any discharge up to 1500 W alone while the other rests:
- Above 1500 W both share again, and it only goes back to one pack below 1125 W, so a demand resting near the line does not swap every tick.
- The pack carrying it keeps doing so until the other is five SoC points fuller. Then they swap, so both are used about equally.
- Two packs are used whenever one cannot do it alone: when only one may discharge, or when the fullest one's own ceiling (the fuse protection, most often) is below the demand.
- Charging and selling are not affected.
Whether it saves anything depends on what a resting pack still uses, which is
why it is a switch and not a default. Every night between 01:00 and 05:00 the
integration sets what the packs delivered (their AC output sensors) against how
far their charge fell. The switch's attributes average that per setting:
efficiency_one_pack_pct and efficiency_both_pct, with the number of
nights behind each (nights_one_pack, nights_both) and the nights themselves
in history.
- What a night needs: a power sensor on each pack, no charging in the window, and at least 1 kWh drained. SoC comes in whole points, so a single night can be a few percent off either way; compare a few nights of each.
- What does not count: a night in which the switch was flipped is kept
as
mixedand counted towards neither.
What it measured. The line started at 800 W. Measured per 1 % of charge across the September and October traces, a pack returned about:
| per pack | returned |
|---|---|
| 150–300 W | ~79 % |
| 300–500 W | ~87 % |
| 500–800 W | ~93 % |
| 800–1500 W | ~98 % |
These figures assume 1 % is 140 Wh, so read them against each other rather than as absolutes. Nights with both packs sharing ~450 W came out at 78–80 %, and nights with the switch on at 86–90 %. That is some 45–50 W less lost through a sleeping house. One pack at 1200 W also loses less than two at 600 W, so the line is now 1500 W; above that there were too few measurements to say.
The External plan mode does not plan anything itself. It executes a plan produced elsewhere — typically EMHASS, which optimises against price, solar and load forecasts.
EMHASS is a separate project. It is not installed with this integration and
is not a dependency (requirements: []). Install it yourself, then have it call
battery_management.set_setpoint — positive discharges, negative charges.
What this integration keeps doing underneath the plan: the SoC-weighted split across packs, the per-unit and total capacity clamps, never commanding two packs in opposite directions, the SoC reserve (which overrules the plan), and the safe revert on unload.
If no plan arrives for external_timeout minutes (default 15) it hands control
back to normal grid-zero regulation rather than freezing the packs on the last
instruction — they have no watchdog of their own. Home Assistant raises a repair
issue explaining what is missing, and clears it once a plan arrives.
| Service | What it does |
|---|---|
battery_management.set_setpoint |
Force the integrator's setpoint (+ discharge, − charge). The next tick clamps it to what the packs can deliver and regulation carries on. |
battery_management.start_fast_charge |
Charge every unit at full power to its limit, then switch off by itself. |
battery_management.stop_fast_charge |
End fast charging. |
Each takes an optional config_entry_id; leave it out to act on every
coordinator.
The card shows status + setpoint, an enable toggle, a fast-charge button (with a
confirm dialog), and a per-unit SoC bar / target / status. If the card doesn't
appear after install, hard-refresh the browser; if it still doesn't, add
/battery_management/battery-management-card.js (JavaScript Module) under
Settings → Dashboards → Resources.