Files
adminandClaude Opus 5.5 d94cb0c263
checks / unit (pull_request) Successful in 36m38s
infra-pin / fresh (pull_request) Successful in 2s
SAFETY-112: say plainly that control waits for Home Assistant to start
The Supervisor proxy answers 502 to every add-on read, write and websocket
until Core is RUNNING (proxy.py api()/websocket() -> check_api_state()), so
the add-on cannot control while a slow integration holds HA's start, even
with its entities live. Measured on a safety112 repro stack with the
2026.08.0 proxy code: first write 1.0-4.4 s after RUNNING, 238-300 s after
live readings. Not routed around (DECISIONS/SAFETY-112-...).

While the API does not answer, the first status row is red and is the
banner ("Waiting for Home Assistant to finish starting"), Home shows a
"waiting" state in EN/NL instead of "Needs you now", and the log adds a
"still waiting" line every 5 minutes and the wait's length at the end.
A safety stop, a stalled loop or shadow mode keeps its own headline.
Control, writes, heartbeat and the stand-down are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:07:51 +02:00

248 KiB
Raw Permalink Blame History

GoodWe RS485 Controller

Drives a GoodWe ES/BP battery inverter over its RS485 meter bus: holds net grid exchange at zero, and runs a monthly battery maintenance cycle so the BMS can balance cells and recalibrate its coulomb counter.

Installers: the printed field guide (docs/GoodWe-RS485-Field-Guide.pdf) is v0.1.0 and outdated; where it disagrees with this page, this page wins.

Words used on this page

  • Control board: the small computer on the inverter's meter cable. It passes the add-on's power request to the inverter. If the add-on goes quiet for about 30 seconds, it sets the inverter to 0 W by itself. (For installers: a T-CAN485 board with an ESP32 chip on the RS485 bus.)
  • Isolator: the switch that cuts an inverter off from the mains. Switching it off stops the inverter, whatever the add-on or the control board asks.
  • Developer Tools: the Home Assistant page that lists every entity and its current value, on its States tab. Use it to check that a sensor the add-on reads is really there and updating.
  • Shadow mode: the add-on makes every decision but sends nothing, so every inverter stays at 0 W. Pages and notifications say what it would have done.
  • Safety stop: the add-on has set one inverter, or all of them, to 0 W and keeps it there until you start control on the add-on page.
  • Peak limit: the 15-minute average grid use you want to stay under this month, because your capacity tariff bills the highest one.
  • Peak guard: the part of the add-on that uses the battery to keep each 15-minute period under the peak limit.
  • Peak reserve and battery cut-out level: the charge the peak guard keeps back, and the level where the battery switches itself off.

Before you start

You need:

  • A GoodWe ES / BP family inverter (AA55 / RS485 meter-bus generation)
  • The vendor's meter-emulating controller disconnected from the bus
  • A control board: a T-CAN485 (ESP32) flashed with firmware/goodwe-master.yaml
  • A grid-power sensor already working in Home Assistant, updating every ~5–10 s

After installing, turn on Watchdog on the add-on's Info tab. If control stops by accident, the add-on sets the inverters to 0 W and exits, and Home Assistant starts it again only when Watchdog is on. It is off by default.

The safety model, in short

The inverter keeps its last power request forever. It has no timeout of its own. So:

  • The control board sets the inverter to 0 W if this add-on stops sending for about 30 seconds, and keeps it there.
  • This add-on sets the inverters to 0 W when a reading goes missing, when you stop control, and when it shuts down.
  • This add-on sets the inverters to 0 W and keeps them there if the power goes the opposite way to what it asks for three minutes. See "The sign-disagreement failsafe" below.
  • The optional emergency stop is the only thing that covers this computer failing. Without it, the battery keeps its last power request until someone steps in.
  • This add-on raises an alarm, and does nothing more, when the meter keeps answering with a value that never changes while the battery is asked for full power. See "The frozen-meter alarm" and, before you rely on it, "Capacity-tariff exposure of the grid-following loop".

If anything looks wrong: stop the add-on. That sets the inverters to 0 W, and the control board keeps them there.

For installers: a crashed control task is SAFETY-61, see DECISIONS/SAFETY-61-control-task-death.md.

Configuration

⚠️ There are TWO places to configure this add-on

For installers: card UX-02.

Since UX-02 the ingress panel has a Configuration page of its own, beside Home Assistant's Configuration tab. Both edit the same settings — there is one options blob, and neither surface is a copy of it.

Ingress panel → Configuration Supervisor → Configuration tab
Use it for everyday editing recovery, and when the add-on is down
Sections, units, help real headings, units beside the boxes, the description under each field one flat list of 71 fields, no headings, no units
Tells you a setting is wrong yes — before you save, next to the field no. It knows types and ranges only
Can express a rule spanning two keys yes, because it runs the add-on's own validator no. The schema grammar cannot say "must be at or below max_w"
Available when the add-on is not running no — it is served by the add-on yes
Editing the inverter list cards you add and remove, with the per-entry labels rendered a repeatable section whose per-entry keys render raw on frontend 20250925.1

Which one is authoritative: neither. The stored options blob is, and both are editors of it. DECISIONS/UX-02-two-config-surfaces.md is the record, with the measurements behind it.

The one thing you need to know in practice: the panel refuses to save a configuration the add-on would not start on, and the Configuration tab does not. The tab will happily store maintenance_charge_w: 2500 beside max_w: 2000; the add-on then exits 78 on its next start, the panel goes down with it, and the tab is where you fix it. That asymmetry is the reason both exist.

What the panel does when you press Save

  1. It runs the shipped validator — the same validate_startup_config() and validate_unit_ranges() the add-on runs at startup, not a copy of them — and if anything is refused, nothing is written and your edits stay on screen.
  2. It re-reads the stored blob and refuses if it changed while you were editing — someone in the Configuration tab, or you on another device. It names the keys that moved and shows both values; pressing Save again then writes yours.
  3. It writes the whole blob. It does not restart: POST …/options replaces the stored options, and a key left out would revert to the shipped default, so the panel always sends everything it read.

⚠️ Saving costs nothing. Applying costs a control interruption

Stored settings take effect when the add-on next starts. The panel shows which stored settings are not what the add-on is currently running, and Restart to apply is a separate button with its own confirmation — because the wind-down commands 0 W on every inverter before the process exits, and the battery does nothing until it is back. Nothing else is changed by that restart: a latched sign-disagreement failsafe is restored from the Store on the way back up, not cleared.

Two range checks can be skipped, and the page says so

Two of the refusals compare a configured ceiling against the setpoint device's own reported range, which means asking Home Assistant. If ESPHome has not come up, that range is unknown and those two checks are skipped — on the page and at startup, identically. The page prints a note saying so rather than looking green for a reason you cannot see. If the device answers later and disagrees, the add-on refuses to start then, with the same sentence.

On a phone

One column, big targets, sections collapsed until you open them, and the refusals pinned where you cannot scroll past them. It is the screen for standing in front of the inverter.

What the Configuration tab actually shows you

For installers: card UX-01.

Since UX-01 the add-on ships translations/en.yaml and translations/nl.yaml, so Home Assistant's Configuration tab renders human labels and a description for every option instead of the raw option keys. What that does and does not buy you is worth stating plainly, because it was measured against the Supervisor image this site runs (ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0) rather than assumed — tools/supervisor_shape_probe.py section F GRADES (it used only to print) the whole rendered list, in both languages.

All 72 options are always shown The frontend keeps a field when name in options || required, and every key is in options:. There is no "advanced" fold
Order comes from schema: App.schema_ui is UiOptions(...)(data["schema"]), so the schema half of config.yaml decides the order the tab renders in — not the options: half
There are no section headers Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by order plus the Group · prefix in each label, and by nothing else
Groups, in task order Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced
The description says whether to touch it One or two plain sentences. A measured or derived value says "leave it unchanged", a number that is genuinely yours (max_w, peak_cap_w) says "Your own number", and cycle_cost_threshold_eur says it is a product choice. Where each number comes from is under For installers
Dutch falls back per key translations[your language]?.name || translations["en"]?.name || the raw key. A key missing from nl.yaml degrades to English; a key present but wrong does not degrade at all
⚠️ The per-entry keys inside inverters: still render raw Measured, and negative. A list-of-objects becomes an object selector with multiple: true, and ha-selector-object labels its fields fields[name].label || name — the converter sets no label and no translation_key. The fields: translations are shipped and validated (they are the shape Supervisor's own schema defines) but on frontend 20250925.1 they do not reach the screen. The inverters label and description themselves do

test_translations.py is what stops this rotting: a translation naming an option that does not exist, or an option with no translation, fails CI — in either direction, and the check is mutation-tested both ways.

Settings at a glance

The label and description each setting shows on the Configuration tab, in tab order. translations/en.yaml is the source, and test_translations.py keeps this table in step with it.

Group Setting Key What it does
Hardware Inverter setpoint entity setpoint_entity The ESPHome number entity the add-on writes the inverter's power to. Leave it empty when you fill in the inverter list.
Hardware Battery level entity (%) soc_entity The sensor with the battery level in percent. Use the control board's own reading.
Hardware Battery power entity (W) batt_entity The sensor with this inverter's battery power. Positive must mean the battery is giving power.
Hardware Battery power sign is reversed batt_invert Switch this on only if your battery power sensor shows giving power as a negative number.
Hardware Inverter list inverters Leave it empty if you have one inverter. With two or more, add one entry per inverter, which replaces the inverter settings above.
Hardware · Inverter list Name inverters.name A short, unique name for this inverter, for example inverter-a.
Hardware · Inverter list Setpoint entity inverters.setpoint_entity This inverter's own ESPHome setpoint entity. Two entries may not use the same one.
Hardware · Inverter list Inverter rating (W) inverters.max_w The most power the add-on may ask of this inverter. The site total is the sum of the list.
Hardware · Inverter list Battery cut-out level (%) inverters.bms_soc_floor The level at which this battery's own management system stops giving power. Read it off the battery that is installed.
Hardware · Inverter list Battery level entity (%) (optional) inverters.soc_entity This battery's own level sensor. Leave it empty if this inverter has none.
Hardware · Inverter list Battery power entity (W) (optional) inverters.batt_entity This inverter's own battery power sensor. Leave it empty if it has none.
Hardware · Inverter list Battery power sign is reversed (optional) inverters.batt_invert Switch this on if this inverter's sensor shows giving power as a negative number.
Hardware · Inverter list Usable battery capacity (kWh) (optional) inverters.capacity_kwh This battery's usable capacity from its datasheet. Fill it in on every inverter or on none. The add-on then shares the work by the energy each battery has left.
Hardware RS485 emergency stop is fitted estop_fitted Switch this on if the emergency stop is installed. It only changes a warning on the status page.
Hardware Battery modes battery_modes_enabled Give each battery an on and off switch and a mode, set on the Batteries screen.
Hardware Solar forecast, today (kWh) pv_forecast_entity Optional. A forecast sensor with today's remaining solar energy, such as Forecast.Solar or Solcast. A battery then buys only what the sun will not give.
Hardware Solar forecast, tomorrow (kWh) pv_forecast_tomorrow_entity Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight.
Hardware Several solar forecasts, today (kWh) pv_forecast_entities Optional. One row per forecast sensor, such as one per inverter, used instead of the single one above. They are added up.
Hardware Several solar forecasts, tomorrow (kWh) pv_forecast_tomorrow_entities Optional. The same list for tomorrow. If one sensor has no reading, that day's forecast is not used.
Hardware Trust in the forecast (%) solar_trust_pct Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast.
Hardware Solar production entity (W) pv_entity Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control.
Hardware Solar production entities, per inverter (W) pv_entities Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded.
Hardware Reversed-sign solar entities pv_invert_entities Optional. Add each solar entity above that reads negative while producing, spelled the same way. Its sign is flipped before adding up.
Meter Where the meter reading comes from meter_source How the add-on reads your meter. 'off' uses “Grid power entity” below, and only homewizard_local can spot a stuck meter.
Meter Grid power entity (W) meter_entity Used when the meter source is 'off'. Positive must mean drawing from the grid.
Meter Grid power sign is reversed meter_invert Switch this on if your grid sensor shows drawing from the grid as a negative number.
Meter Number of phases (1 or 3) meter_phases Must match your grid connection. Meter readings with a different number of phases are rejected.
Meter HomeWizard P1 address p1_host For homewizard_local only. The meter's address on your network, as host or host:port.
Meter Time between meter reads (s) meter_poll_s For homewizard_local only. The meter updates every 5 s, so lower gains nothing. Keep it well below “Reading too old after”.
Meter Reading too old after (s) meter_max_age_s After this a meter reading counts as missing. The “Missing inputs time limit” starts only after this one runs out.
Meter MQTT topic meter_mqtt_topic For mqtt_p1 only. The topic your meter readings are published on.
Meter Grid import entity (W) p1_import_entity The sensor for power drawn from the grid, never negative. The add-on works out the net value itself.
Meter Grid export entity (W) p1_export_entity The sensor for power fed into the grid, never negative. It pairs with “Grid import entity”.
Meter Signed net power entity (W) p1_net_entity For ha_signed only. One sensor that is positive when drawing from the grid and negative when feeding in.
Meter Import entities per phase (L1-L3) p1_phase_import_entities Optional, in L1, L2, L3 order. Needed for the peak limit on a three-phase connection, one entry per phase.
Meter Export entities per phase (L1-L3) p1_phase_export_entities Optional, in L1, L2, L3 order, next to the import entities per phase. One entry per phase.
Meter Net entities per phase (L1-L3) p1_phase_net_entities For ha_signed only. Optional, in L1, L2, L3 order, signed like “Signed net power entity”, one entry per phase.
Limits Inverter rating (W) max_w Your own number: the most power the add-on may ever ask of the inverter. Start low and raise it after commissioning.
Limits Battery cut-out level (%) bms_soc_floor The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure.
Limits Peak limit (W) peak_cap_w Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract.
Limits Margin below the peak limit (W) peak_margin_w The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it.
Limits 15-minute demand forecast entity (W) peak_forecast_entity Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit.
Limits Peak reserve (%) peak_soc_floor Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged.
Tariff Dynamic price source price_provider Where prices come from. Choosing a source starts the price fetch, even with trading off.
Tariff Supplier markup (EUR/kWh) price_markup_eur_kwh Your supplier's margin. It is added only when the price source does not already include it.
Tariff Distribution, levies and VAT (EUR/kWh) price_taxes_eur_kwh Grid costs, levies and VAT per kWh, preset for Flanders. Check it against your own grid operator's rates.
Tariff Injection price (EUR/kWh) export_price_eur_kwh What you are paid per kWh fed into the grid, all in. It may be negative.
Tariff Fixed or day/night tariff static_profile Your fixed or day/night contract on one line, prices all in, as the documentation shows. Fill it in even on a dynamic contract, as the fallback.
Tariff Day/night levies and VAT price_taxes_profile Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both.
Tariff Your own tariff formula price_formula For custom_formula only. Type the numbers from your supplier's tariff card on one line, as the documentation shows. The add-on checks them against the card.
Tariff Your own injection formula price_export_formula Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price.
Tariff Price CSV file price_csv_path For manual_csv only. Where in /share the add-on reads your price file.
Tariff Price feed URL override price_feed_url Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day.
Tariff Price look-ahead (hours) price_horizon_h How many hours of prices to ask the source for.
Tariff Price request timeout (s) price_timeout_s How long one request to the price source may take before it is given up.
Tariff Current price entity (older) price_now_entity The older way to get a price, used only to charge in a cheap window during maintenance. Do not combine it with a dynamic price source.
Tariff Average price entity (older) price_avg_entity The average the current price is compared with. Use it only with the current price entity, not with a dynamic price source.
Tariff Trade on the price spread arbitrage_enabled Charge in the cheapest hour and draw the battery down in the dearest, when the spread covers the cost. Turning it off does not turn the price feed off.
Tariff Battery cycle cost (EUR/kWh) cycle_cost_threshold_eur What one full battery cycle costs per kWh. A smaller price spread is not traded, and the value is a product choice, not a measured one.
Tariff Trading keeps at least (%) arbitrage_soc_floor Price trading will not draw the battery below this level. It is separate from the maintenance settings.
Tariff Trading charges at most (%) arbitrage_soc_ceiling Price trading will not charge the battery above this level.
Maintenance Run the monthly battery cycle maintenance_enabled Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default.
Maintenance Minimum days between cycles maintenance_interval_days The shortest gap between two maintenance cycles.
Maintenance Start hour (local time) maintenance_start_hour The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00.
Maintenance Draw-down rate (W) maintenance_discharge_w How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer.
Maintenance Recharge rate (W) maintenance_charge_w How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start.
Maintenance Draw down to (%) maintenance_soc_floor The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose.
Maintenance Recharge up to (%) maintenance_soc_target The level the recharge aims for before the hold starts.
Maintenance Hold at full for (minutes) maintenance_hold_min How long the battery stays full so its cells can balance.
Safety Wrong-direction threshold (W) sign_disagree_w Part of the safety stop for an inverter pushing the wrong way. It counts only when command and meter reading both exceed this. Measured value, leave it unchanged.
Safety Wrong-direction time (s) sign_disagree_s How long a wrong direction may last before a safety stop sets every inverter to 0 W. Measured value, leave it unchanged.
Safety Per-inverter wrong-direction threshold (W) unit_sign_disagree_w For two or more inverters: the wrong-direction threshold for each inverter. Derived value, leave it unchanged.
Safety Per-inverter wrong-direction time (s) unit_sign_disagree_s For two or more inverters: how long one inverter may push the wrong way before it is set to 0 W. Derived value, leave it unchanged.
Safety Site wrong-direction threshold (W) site_sign_disagree_w For two or more inverters: the wrong-direction threshold for the whole site. Measured value, leave it unchanged.
Safety Site wrong-direction time (s) site_sign_disagree_s For two or more inverters: how long the whole site may push the wrong way before a safety stop. Derived value, leave it unchanged.
Safety Stuck meter alarm after (s) meter_freeze_s An alarm when the meter repeats one reading this long while the command is at full power, with homewizard_local only. Measured value, leave it unchanged.
Safety Opposing inverters threshold (W) circulation_w For two or more inverters: how much power one may push against another before it counts. Measured value, leave it unchanged.
Safety Opposing inverters time (s) circulation_hold_s For two or more inverters: how long they may push against each other before a safety stop. Derived value, leave it unchanged.
Safety Not delivering alarm after (s) delivery_hold_s For two or more inverters: how long one may fall short of its share before an alarm, which changes nothing. Derived value, leave it unchanged.
Safety Shadow mode shadow_mode The add-on makes every decision but keeps every inverter at 0 W. 'auto' does this while control is stopped, 'on' always, 'off' never.
Advanced Start control on boot auto_start Off by default. Leave it off until the site is commissioned, so someone presses Start after each restart.
Advanced Control gain gain How much of the remaining error control corrects each cycle. Measured on real hardware, do not raise it.
Advanced Largest change per cycle (W) slew_w How far the command may move in one cycle. 0 follows “Inverter rating” and is recommended.
Advanced Ignore errors below (W) deadband_w Errors smaller than this are ignored. Keep it small, since any resting point inside it holds.
Advanced Meter resting point (W) target_grid_w What the meter should show when nothing happens. A slight export avoids paying for tiny imports all day.
Advanced Command rounding (W) step_w The command is rounded to a multiple of this, so noise does not change it every cycle.
Advanced At-limit shortfall (W) saturation_w When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this.
Advanced Cycles at limit before freezing saturation_cycles How many cycles in a row at the limit before control stops building up error. Do not set it to 1.
Advanced Catch-up limit (W) integrator_max_w Limits how hard control keeps pushing while the battery falls behind. 0 means “Inverter rating”. Do not raise it above the rating.
Advanced Command refresh interval (s) heartbeat_s How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit.
Advanced Missing inputs time limit (s) stale_input_s How long inputs may be missing before every inverter is set to 0 W. With a P1 meter it starts after “Reading too old after”.
Advanced Longest time between cycles (s) cycle_floor_s A deadline between control cycles that a healthy meter never reaches. Raise it only for a meter slower than about 10 s.
Advanced Log level log_level How much detail the add-on writes to its log. 'debug' and 'trace' are for finding a problem.

For installers: where the numbers come from

The Configuration tab keeps each description to one or two sentences. The detail that used to sit there is here, one entry per setting. A value marked measured or derived in its description came from the record named below; change it only after re-deriving it from a new measurement.

When the add-on refuses to start, each reason names the setting by its label and then its key, for example “Inverter rating” (max_w), and adds the inverter's name when there is a list. The configuration panel shows a reason beside every setting it names.

  • Battery power entity (batt_entity): on the reference installation this is holding register 0x0518, read by the control board. The wrong-direction checks and the opposing-inverters alarm both grade each inverter against it.
  • Inverter list (inverters): empty means the single-inverter settings describe the one inverter you have. Filled in, the list replaces them and nothing is inherited: each entry names its own setpoint entity, rating and cut-out level, so no inverter is judged by another's numbers. “Inverter setpoint entity” must then be empty, and the add-on refuses to start when both are set. Each entry's name keys its own safety stop and records in the add-on's state, so two entries may not share one.
  • Usable battery capacity (inverters.capacity_kwh): read only to share the site target by the energy each battery has left rather than by its rating. The reference batteries are 5000 W with 15.36 kWh and 4600 W with 20.48 kWh, so the two ways of sharing give the work to different batteries. Stated on some entries and not others, the add-on refuses to start.
  • Solar production entities (pv_entity, pv_entities, pv_invert_entities): measurement only, no control behaviour changes. The unit is read off each entity, so a sensor without one is refused rather than guessed. With several entities, one that stops reporting gives no site total at all, because a partial sum looks like a real one. Setting pv_entities ignores pv_entity.
  • Where the meter reading comes from (meter_source): 'off' keeps “Grid power entity”. ha_dsmr follows the DSMR integration, mqtt_p1 reads an MQTT topic, ha_signed reads one signed entity (what a HomeWizard P1 publishes), and homewizard_local polls the meter's own local API. Only homewizard_local can tell "the meter did not answer" from "the value did not change", so the stuck-meter alarm only means something there.
  • Grid import and export entities (p1_import_entity, p1_export_entity, p1_net_entity): the add-on computes net power itself, so the sign convention lives in tested code rather than in a template sensor.
  • Number of phases (meter_phases): a reading whose phase count disagrees is rejected and logged as it arrives, so a wrong value shows up at once. The range accepts 2, which is not a real Belgian connection.
  • Time between meter reads, Reading too old after (meter_poll_s, meter_max_age_s): the add-on refuses to start if the poll interval is at or above the age limit. 30 s is six polls of headroom at the default 5 s. The reading-age limit on its own does not command 0 W. “Missing inputs time limit” starts after it, so the two add up.
  • Inverter rating (max_w): your own number, not a derived one. It is not read when the inverter list is filled in, since each entry carries its own and the site limit is their sum. Several safety thresholds must stay below it, and the add-on refuses to start if one does not.
  • Battery cut-out level (bms_soc_floor, inverters.bms_soc_floor): measured, not chosen. Register 0x0560 reads 10 % on the reference GW5000S-BP. It exists so the add-on can refuse a “Peak reserve” at or below it, which could never take effect. Each inverter entry states its own, because one default for two different batteries protects the shallower one with the deeper one's number.
  • Peak limit (peak_cap_w): your own number, from your grid contract. In Belgium it is the capacity-tariff peak your distribution charge is billed on.
  • Margin below the peak limit (peak_margin_w): 50 W by default, 0 to 500 W (SAFETY-79). Peak limiting aims at the peak limit minus this margin, because a 15-minute period aimed exactly at the limit can end a watt or two over it (3501.9 W against 3500 W in a test), and even 1 W over sets the billed peak. Once this month's peak is already above the limit, it aims the same margin below that peak. The bill, this month's peak and the warnings still compare against the peak limit itself. 0 aims at the limit, as before.
  • Peak reserve (peak_soc_floor): derived, not picked (SAFETY-24). The reference battery holds 13.8 kWh usable and the worst modelled peak day asks for 878 Wh, or 6.4 %. 20 % sits ten points above the cut-out level and leaves about 1.6 worst days of room. It bounds peak limiting only: ordinary use goes on down to the cut-out level. It must stay above “Battery cut-out level”.
  • Dynamic price source (price_provider): selecting a provider is what starts the fetch, every half hour, whether or not trading is on. It may not be combined with the two older price entities. engie_dynamic, totalenergies_dynamic and eneco_eddy are withdrawn and refuse to start: their endpoints never existed. They stay in the list only so such a site gets that explanation.
  • Supplier markup (price_markup_eur_kwh): under manual_csv, whose prices are all in, it is ignored and logged as ignored.
  • Distribution, levies and VAT (price_taxes_eur_kwh): 0.11736 EUR/kWh, derived in TARIFF-18 from the Eneco residential card for Flanders of September 2026 with 6 % VAT: Fluvius distribution for Antwerpen 5,35 plus excise 4,876 plus green power and CHP levy 1,51 c/kWh. Check it against your own DSO: across the eight Fluvius areas distribution runs from 5,28 to 6,70 c/kWh, so the Flemish total runs from about 0.117 to 0.131. Changing it moves no trading decision, because the same amount lands on every hour. It does move every price shown, and the rule that refuses to draw the battery down into an hour priced below zero. See What price_taxes_eur_kwh is made of.
  • Day/night levies and VAT (price_taxes_profile): for Wallonia and Brussels, where distribution has a day and a night rate (ORES 13,27 against 7,39 c/kWh, AIESH 15,17 against 8,21, Sibelga 9,39 against 7,10). The figures are levies alone, added to the market price. The add-on refuses to start if it and the flat figure disagree. See Levies and VAT by time of day.
  • Injection price (export_price_eur_kwh): one number because no supplier here publishes a dynamic injection curve. In Belgium it is often near zero, and at a sunny midday it can be negative.
  • Fixed or day/night tariff (static_profile): the periods must cover every day type exactly once with no gap and no overlap, and a gap is refused rather than filled. A window 22:00-07:00 on mon-fri does not cover Saturday morning, so a weekend row is required. See Static day/night and fully-fixed tariffs.
  • Your own tariff and injection formula (price_formula, price_export_formula): type the card's numbers in the card's units. The card block is required on the tariff formula: the add-on recomputes the card's example price and refuses to start if it does not come out. Injection is VAT-exempt in Belgium, so vat_pct there is normally 0. See Your own tariff formula and price_export_formula, field by field.
  • Price feed URL override (price_feed_url): only a host the chosen source declares is accepted. Nothing is added to the URL, not even the date range, so an override often returns one day and the rest of the plan is carried over and excluded from trading. The log warns on every refresh.
  • Current and average price entities (price_now_entity, price_avg_entity): kept because removing them would take the maintenance cycle's only price signal away from a live site.
  • Trade on the price spread (arbitrage_enabled): off as shipped, on purpose, because it moves energy against the meter by design. It needs a price source and a P1 meter, and the add-on refuses combinations that cannot work.
  • Battery cycle cost (cycle_cost_threshold_eur): wear plus conversion losses. It is not derived from a measurement: 0.10 is a product decision still open with the owner (OPT-08). At this value a fixed day/night site does not trade, since a realistic Belgian day/night spread is about 0.08 EUR/kWh.
  • Trading keeps at least, Trading charges at most (arbitrage_soc_floor, arbitrage_soc_ceiling): kept apart from the maintenance levels, so one setting does not couple two unrelated policies.
  • Draw-down and recharge rate (maintenance_discharge_w, maintenance_charge_w): the draw-down rate above the rating is cut back, not refused. A recharge rate above the rating would ask for more than the limit allows and resolve to 0 W, so maintenance would never charge. That is why the add-on refuses it.
  • Draw down to (maintenance_soc_floor): the monthly cycle goes this deep on purpose, while “Peak reserve” is a depth peak limiting never reaches. 11 % against 20 % is why they are two settings.
  • Wrong-direction threshold and time (sign_disagree_w, sign_disagree_s): measured, not chosen (SAFETY-02). On the captured healthy day (2026-08-23) signs agree on 93.4 % of gated samples, on the fault day (2026-08-20) 0.0-10.5 %, with opposed runs up to 1752 s. The longest legitimate opposed run is 30 s, and the fault day still trips 13 times at 300 s, so 180 s sits in a wide gap. The threshold must stay below “Inverter rating”.
  • Per-inverter wrong-direction threshold and time (unit_sign_disagree_w, unit_sign_disagree_s): two or more inverters only. Derived from 1890 closed-loop test runs (SAFETY-30) with nothing faulty. 250 W is half the at-limit shortfall. 85 s is 8.5 times the worst legitimate opposition (10.0 s) and 2.8 times the control board's 30 s link limit.
  • Site wrong-direction threshold (site_sign_disagree_w): the single-inverter 300 W carried across deliberately (SAFETY-30), because near zero the sign is noise whatever is behind the one meter.
  • Site wrong-direction time (site_sign_disagree_s): derived from the same 1890 runs (SAFETY-30). The worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, so 180 s would false-trip. 325 s is 1.51 times that and inside the 900 s billing period.
  • Stuck meter alarm after (meter_freeze_s): measured, not chosen (SAFETY-18). Over 2399 s of this house at its operating point, the longest run without a new reading is 15.4 s, and 300 s is 19.5 times that. It raises an alarm only and does not set 0 W.
  • Opposing inverters threshold (circulation_w): measured on 924 closed-loop test runs (SAFETY-31). The worst legitimate opposition drops from 505 s at 100 W to 5 s at 400 W, the lowest value accepted. 700 W is 1.75 times that knee, with margin because the second inverter is still a copy of the first.
  • Opposing inverters time (circulation_hold_s): derived (SAFETY-31). 75 s is fifteen control cycles, 15 times the worst legitimate episode, and 2.5 times the control board's 30 s link limit.
  • Not delivering alarm after (delivery_hold_s): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported, and neither is one that gives under 100 W (SAFETY-73: it is a battery that refuses). Re-derive with admin/goodwe sim/delivery_window.py --campaign --blag.
  • Shadow mode (shadow_mode): the whole decision path runs and nothing reaches the inverter. The status page and action log show what would have been sent. A safety stop that is already up stays up. See Shadow mode.
  • Control gain (gain): measured on real hardware and already at the limit.
  • Largest change per cycle (slew_w): 0 means follow the rating: about a ninth of it, derived (SAFETY-39) from a measured ~18 s step response against a 2 s meter tick. A fixed value faster than the rating allows is refused. At 1000 W against a 5000 W rating control swung the meter from 3.4 kW export to 2.8 kW import on 2026-09-05.
  • Ignore errors below, Meter resting point (deadband_w, target_grid_w): the meter's import counter bills every positive watt, so a resting point at 0 W leaks about half the deadband as billed import, roughly 0.2-0.35 kWh a day at 15 W. -10 W moves that leak to export.
  • At-limit shortfall (saturation_w): the per-inverter wrong-direction threshold is derived as half of it, so moving this moves that argument too.
  • Catch-up limit (integrator_max_w): measured on the closed-loop simulator with a 4000 W load dropped to zero: at a limit equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating 1800 W. Lowering it is the useful direction.
  • Command refresh interval (heartbeat_s): the control board refuses a link that has been quiet for 30 s.
  • Longest time between cycles (cycle_floor_s): keep it under “Reading too old after”, since past that there is no reading left to act on.

Sources

option required meaning
meter_entity yes Net grid power. Positive must mean importing
meter_invert Flip the sign if the meter reports the other way
soc_entity yes Battery state of charge — use the ESP32's own read
batt_entity yes Battery power — again the ESP32's read, + = discharging
batt_invert Flip if needed
capacity_kwh This pack's usable capacity. Read only to divide the site target between packs by energy headroom — see below
setpoint_entity yes The ESPHome number.*_goodwe_setpoint_w
pv_entity Optional. One solar inverter's power sensor, + = producing. Measurement only — it changes no control behaviour. See PV production
pv_entities Optional. Several solar inverters, one row each — use this instead of pv_entity when your roof has more than one. Setting it ignores pv_entity. See Several PV inverters
pv_invert_entities Optional. The PV entities above that read negative while producing; each one's sign is flipped. See The sign

Use the ESP32's readings rather than the inverter's cloud or dongle sensors: those serve cached values, and a stale reading here ends the maintenance charge phase having charged nothing.

More than one inverter (inverters)

The six keys above describe one inverter, and if that is your site there is nothing to do here — inverters ships empty and empty means "the flat keys above describe the one inverter". An install that upgrades into 0.6.0 reads identically to before: same entities, same rating, same behaviour, no operator action.

To run two, declare them. Each entry names its own actuator, its own rating and its own sensors:

setpoint_entity: ""          # ⚠️ must be cleared — see below
inverters:
  - name: inverter-a
    setpoint_entity: number.goodwe_a_setpoint_w
    soc_entity: sensor.goodwe_a_soc
    batt_entity: sensor.goodwe_a_battery_power
    max_w: 5000
    bms_soc_floor: 10
    capacity_kwh: 15.36
  - name: inverter-b
    setpoint_entity: number.goodwe_b_setpoint_w
    soc_entity: sensor.goodwe_b_soc
    batt_entity: sensor.goodwe_b_battery_power
    max_w: 4600
    bms_soc_floor: 10
    capacity_kwh: 20.48

⚠️ The 4600 W is inverter B's datasheet figure, not a measured one. B is a GoodWe GW5048D-ES (serial 55048ESU22AW0992, identified 2026-09-04) whose datasheet gives max charging and discharging power as 4600 W; with nothing connected to its PV inputs the battery path is the ceiling, not the 5000 VA AC nameplate. The datasheet prints that number under Battery Input Data, so it may be a DC figure, while max_w is graded against register 0x0518 — the inverter's own AC power. If it is DC the AC-side ceiling is nearer 4324 W. max_w: 4600 is still the right thing to configure, because the freeze rail is min(max_w, dev_max) and the device range read off the unit is what binds. goodwe/NOTES.md §1.1b records both readings and picks neither.

per-entry key required meaning
name yes Short identifier. It namespaces this unit's SAFETY-02 latch, heartbeat and write bookkeeping in the add-on's state file, so two units may not share one
setpoint_entity yes This inverter's own ESPHome number.*_goodwe_setpoint_w
max_w yes The supervised cap on what may be written to this inverter. The site's ceiling is the sum
bms_soc_floor yes This pack's own BMS cut-out percent (SAFETY-32). See bms_soc_floor
soc_entity This pack's state of charge
batt_entity This unit's battery power, + = discharging
batt_invert Flip if needed
capacity_kwh This pack's usable capacity. State it on every entry or on none — see OPT-09

⚠️ Nothing is inherited. An entry that leaves out soc_entity has no SoC sensor — it does not fall back to the flat one. That is deliberate: every per-device value became per-instance in 0.5.x precisely so that no inverter is ever graded against a sibling's numbers, and a silent fallback would rebuild that in YAML. name, setpoint_entity, max_w and bms_soc_floor are required of every entry for the same reason: there is no rating or cut-out to borrow.

⚠️ Clear the flat setpoint_entity when you declare a roster, or the add-on refuses to start and says so. It is the single-inverter form and it is not read once a roster exists, so leaving it set means one of two things: it names an entity an entry also names — two writers on one actuator, which specs §1.5 makes a non-goal and the add-on refuses outright — or it names a different one, which nothing would ever write, leaving a site running quietly smaller than its configuration reads. soc_entity, batt_entity, batt_invert and max_w cannot be blanked (the schema gives them no empty value); they are simply not read, and the add-on writes one line into its action log at every start saying so.

⚠️ Two inverters naming the same setpoint_entity are refused, whichever keys they are declared under and whatever the capitalisation. Two independent setpoints on one inverter is the arbitration problem this add-on exists not to have.

⚠️ Update the add-on first, then add the roster. Home Assistant validates the options you save against the schema of the version that is installed, so inverters cannot be set before 0.6.0 is installed. The order is: update, then configure.

⚠️ Going the other way — downgrading with a roster configured — is recoverable but NOT uneventful, and the add-on will not start until you refill a field. The schema side is fine: the older version's schema has no inverters key, so it drops the key with a warning rather than refusing the whole blob (measured — DECISIONS/CFG-04-inverter-roster.md §E). The add-on side is not. Rule 9(e) requires the flat setpoint_entity be cleared when a roster is declared, so a config that downgrades cleanly is by construction a config whose flat actuator is blank — and the older version has nothing else to write to. It stops at startup validation with a refusal naming setpoint_entity, which is loud and in the log rather than silent, but it is an outage until someone puts the single-inverter entity back in that field. Before downgrading, write down the setpoint_entity of the unit you want to keep running.

How the site target is divided between the packs

For installers: card OPT-09.

Two packs do not get half each from 0.8.0 onwards. ⚠️ On 0.7.0 and every version before it the split is a fixed 50/50 regardless of what either pack can do — that is the defect this section describes the fix for, so if the add-on you are running reports 0.7.0, none of what follows is happening yet. From 0.8.0 the split is driven by two separate things, and it is worth knowing which is which, because only one of them is a rule:

  • The envelope — a permission. A pack whose own state of charge is at or under its own bms_soc_floor is not asked to discharge; a pack at 100 % is not asked to charge. Its share goes to the sibling, inside that sibling's own rating. This is absolute: the allocator cannot exceed it.
  • The weights — a preference. Between those limits, each pack is asked for the fraction of the site target that matches its energy headroom: the kWh it has above its own cut-out when the site is discharging, the kWh it has below full when the site is charging. That is what capacity_kwh is for.

⚠️ capacity_kwh is optional, and it is all-or-nothing. Leave it out everywhere and the add-on has no opinion about the split — the target is divided evenly, exactly as it was before. Fill it in on every entry or on none: a pack measured in kWh beside one that is not is not a comparison, and the add-on refuses to start rather than ignore half of what you typed.

⚠️ Why energy and not the rating. The two packs here pull in opposite directions — A is 5000 W with 15.36 kWh, B is 4600 W with 20.48 kWh, so B has about a third more energy and about 8 % less power. Dividing by rating hands the work to A; dividing by energy hands it to B. The question a split is answering is "which pack can sustain its share for the hour", so it is the energy one. Each inverter's own rating still binds its own share, separately, through max_w.

⚠️ What the weights will NOT do for you, stated plainly. They are a comfort choice about which pack does the work. They are not a way to make the site deliver. A unit that is at its energy limit — or that has simply stopped responding — does not hand its load to its sibling: the loop sees the shortfall, freezes, and the site runs short by that unit's share. Weighting a failing unit down does not fix that, because when the healthy unit reaches its own rating the allocator hands the remainder to the down-weighted one anyway rather than leave watts unplaced. What you get instead is honesty: the action log reports the watts that could not be placed (ALLOCATOR: +2000 W of the site target could not be placed on any inverter), and a unit that accepts its share and does not produce it is named by the delivery alarm (SAFETY-42, above).

⚠️ At one inverter none of this applies and nothing changed. There is no split to weight, capacity_kwh is not read, and no envelope is closed — self-consumption still discharges down to the battery's own cut-out exactly as it always has.

What appears in Home Assistant at two inverters

Nothing you already have changes. The eight entities this add-on has always published — Setpoint, Grid power, Battery power, Battery SoC, Circulating power, Maintenance phase, Controller status, P1 sample age — keep their device, their unique_ids and therefore their entity ids and their whole recorded history. At a roster of one, nothing new is announced by the per-unit machinery at all: no second device, no renamed entity, not one added topic. (TEL-10's three loop rows, below, are site rows and are added at every roster length, including one.)

Declare a second inverter and four rows per unit appear beside them:

per-unit row what it is
Setpoint this unit's own share of the site target — the number its heartbeat re-emits
Battery power this unit's own reading, + = discharging
Battery SoC this pack. The site Battery SoC row is the emptiest pack, which may be the other one
Sign latch this unit's SAFETY-02 latch: tripped or clear

⚠️ The devices are deliberately asymmetric, and it is not an oversight. The first inverter's rows are added to the existing GoodWe device; every later inverter gets its own device (GoodWe Inverter B), shown in Home Assistant as connected via the controller. Home Assistant composes an entity id from the device name plus the entity name, so giving the first inverter a device of its own would rename every entity you already have — and because HA keeps the id it composed at first discovery, you would get a new entity beside a dead one: the history stays on the old id and every dashboard, template and automation naming it silently stops updating. The entity ids come out symmetric anyway — sensor.goodwe_inverter_a_battery_power beside sensor.goodwe_inverter_b_battery_power.

Do not rename the device. Not in this add-on, and not in Home Assistant's device page. It is half of every entity id, including sensor.goodwe_p1_sample_age, which the ESP32's stale-input watchdog subscribes to; a rename disarms that guard by leaving it waiting for a state that never arrives, and it fails silently and looks correct.

Grid power and P1 sample age stay site-scoped and are announced once. There is one P1 meter and one grid connection; a per-inverter copy would invent two of a thing there is one of, and P1 sample age is a safety input. Same for Circulating power (it is a property of the set of inverters), the controller status, the maintenance phase, the capacity-tariff peak and the three loop rows below.

Battery modes (battery_modes_enabled)

Off by default. Turn it on, restart the add-on, and the Batteries screen (and Settings, Simple) gives each battery two controls. They apply at once, with no restart.

  • On or off. Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover.
  • Mode.
    • Solar first charges from spare solar before the other batteries and covers the house at any hour. It never charges from the grid and never sells.
    • Leftover solar charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but only what the sun will not give (see below). It saves its power for the expensive hours and covers the house then.
    • Cheap hours only charges from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then.
  • Selling is off until it can be judged on the price the grid pays (owner decision 2026-09-29, option A).

With battery modes on, these choices replace the Charging and selling on price switch (arbitrage_enabled); that switch is only read with modes off.

Cheap and expensive hours. They come from the published day-ahead prices, as runs of consecutive 15-minute periods, not a single period.

  • The cheap run is the cheapest stretch long enough to fill the batteries that may charge, at their power.
  • An expensive hour costs at least Battery cycle cost (cycle_cost_threshold_eur, widened as for trading) more than the cheap run. The batteries hold their power until then.
  • The sale uses the most expensive stretch inside those hours, and never a period below 0.
  • A carried (not yet published) price is never used. A run that has started runs to its end.
  • With no prices, or too small a gap, every battery follows the house.

Solar forecast (pv_forecast_entity, optional). A Home Assistant sensor with the solar energy still to come today, in kWh. These work: Forecast.Solar (sensor.energy_production_today_remaining), Solcast PV Forecast (sensor.solcast_pv_forecast_forecast_remaining_today) and Open-Meteo Solar Forecast (sensor.energy_production_today_remaining). Set pv_forecast_tomorrow_entity to the matching tomorrow sensor to plan a cheap run before midnight.

  • Several forecasts. If your forecast comes as several sensors (one per inverter or roof side), list them in pv_forecast_entities and, for tomorrow, pv_forecast_tomorrow_entities, instead of the single key. They are added up, each in its own unit (Wh, kWh or MWh). Set the list or the single key for a day, not both.
  • If one of them has no reading, is stale or is not in an energy unit, that day's forecast is not used at all. A part of the sun is not counted as all of it. The status page names the source by its place in the list.
  • The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
  • Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on solar_trust_pct of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
  • Leftover solar then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level.
  • Cheap hours only ignores the forecast and fills to the top level.
  • Solar first never charges from the grid.
  • The house use is the add-on's own measurement (grid + battery power) per hour, over the last 7 days. It needs one day of it before the first plan.
  • The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it.
  • No forecast, one older than 3 hours or from before midnight, or no house use yet: Leftover solar takes solar only and Cheap hours only fills to the top level. The Batteries screen says so.
  • A battery needs its capacity (inverters.capacity_kwh) for a plan.

What always comes first.

  • The peak limit, the safety stops and the monthly cycle come before a battery's mode.
  • A grid charge is sized so the 15-minute period stays under the peak limit less its margin (peak_margin_w). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period.
  • While one battery charges from the grid, the others rest, and the house uses the grid.
  • While one battery empties for its monthly cycle, no battery charges from the grid.
  • A battery that refuses to charge or give power takes no part in a run in that direction.

In Home Assistant. With MQTT set up, each battery also gets a Mode list and a Use switch, for example select.goodwe_inverter_a_mode and switch.goodwe_inverter_a_use. They do the same as the Batteries screen. Turn battery modes off and restart, and they are removed.

For installers: commands arrive on goodwe_ctl/unit/<battery>/mode/set (a Mode option, such as Solar first) and .../use/set (ON or OFF). They go through the same checks as the Batteries screen. A refusal is logged once, and later refusals are counted in the log. A retained command is never applied, so the broker cannot replay an old choice after a restart; the add-on keeps the last choice itself.

For installers: a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is DECISIONS/OPT-11-battery-modes.md.

For installers: when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts. On the replay model that removes a ~3 kW import spike after a 2.5 kW charge and halves the settling time; about 0.1 × the battery's power still overshoots as export. The Mode list gains Cheap hours only in Home Assistant, at the end.

Reading the grid-following loop: Loop integrator, Loop integrator lead, Loop reason

The control law is a bounded integrator: an accumulator that is not the command. Each cycle it moves by gain × error; the command is then derived from it and passed through the output clamp, the slew limit and a 10 W quantisation before anything is written. Three entities publish what the accumulator is doing:

entity unit what it is
Loop integrator W the accumulator after this cycle — the value before the clamp, the slew limit and quantisation
Loop integrator lead W that value minus the command it was computed against, i.e. the command the previous cycle left standing
Loop reason — the law's own word for this cycle

Loop reason takes one of eleven values:

value what happened
tracking ordinary regulation
deadband the error is inside deadband_w; the loop is holding
i-clamped the integrator hit integrator_max_w (which follows max_w by default)
clamped the command came out over max_w — only reachable if integrator_max_w is deliberately set above the rail
slew-limited the command could not move as far as the integrator asked for; slew_w is what stopped it
saturated-freeze the inverter is not following, so the command's magnitude may fall but not rise
freeze-released the freeze had run out of plant-shaped explanations and the command was let move again
mode-bound the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance
not-running control is stopped and shadow mode is off, so the loop did not run
battery refuses the battery gave less than 100 W for 12 cycles and at least 22 s (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below)
battery probe the add-on asks 200 W to test whether a refusing battery gives power again

When the battery refuses. At its cut-out level the battery gives nothing, whatever it is asked. The add-on then asks 0 W instead of repeating its last request, and the Why row reads The battery refuses to give power: 0 W. Every 15 minutes it asks 200 W for about half a minute. Control resumes as soon as the battery gives 100 W or more, when the grid needs charging instead, and when control is stopped or started. 200 W is below the 250 W the stale-reading check needs, so a test never trips it. A battery that gives some power, even 100 W, is not refusing and keeps its request. Nor is a battery whose reading is still moving towards the request, such as one turning from charging to giving (SAFETY-94). "Gives" is counted from what the battery's reading shows at rest, which the add-on learns after two quiet minutes and keeps: some inverters show about -100 W at rest (SAFETY-97). While another task sets the power, such as a maintenance charge, the peak guard or a trade, the refusal stays as long as the battery still refuses that task's request. It is logged once, and the 200 W test waits until control sets the power again (SAFETY-107).

With two or more inverters the same rule works per inverter. An inverter whose battery gives less than 100 W for 12 cycles, and at least 22 s, while asked for more than 200 W is asked 0 W, and the other inverters take over its share, each up to its own limits. The status page names it: inverter-a: the battery refuses to give power, the others take over. It is tested at 200 W every 15 minutes, one inverter at a time, and only while the site asks at least 200 W that way. It still accepts charge, and 12 cycles of charging end the refusal. When every inverter refuses the same way, the whole site asks 0 W as with one inverter. If the battery refuses to give power while its level reads above its “Battery cut-out level”, the log adds one warning ending in REFUSES_ABOVE_CUT_OUT: check that setting. A battery that refuses is not reported as not delivering.

For installers: SAFETY-52, SAFETY-73 and SAFETY-107. The event log reads BATTERY REFUSES … and BATTERY REFUSES ENDED … (with the inverter's name at N>1), and the INFO line ends in BATTERY_REFUSES. DECISIONS/SAFETY-52-a-battery-that-refuses.md has the replay figures and DECISIONS/SAFETY-73-refusal-per-inverter.md the two-inverter ones.

⚠️ Loop reason is not the Why row on the status page, and the two will often disagree. Why is the arbiter's answer in words — Keeping the grid near zero: -1 200 W — which names the strategy that won and the watts it won with. The log keeps the arbiter's own loop -> -1200 W. It has never carried the strategy's own reason, so a loop that is slew-limited or frozen says so only here.

⚠️ Loop integrator is not Setpoint, even though it usually looks like it. With nothing binding, the command is the integrator rounded to 10 W, so on a quiet day the two rows track each other and the difference is a rounding step. They separate exactly when something is wrong: a large disturbance sends the integrator to its bound in one or two cycles while the command may only travel slew_w per cycle, and Loop integrator lead is the gap. A standing, large lead means the loop has wound up further than the command can follow, and the number of cycles needed to unwind it is that lead divided by slew_w.

Both numbers are state_class: measurement, so Home Assistant's recorder keeps long-term statistics for them.

⚠️ Loop integrator lead is BOUNDED, and the bound is two slew steps. While the slew limit is what is holding the command back, the integrator may not stand more than one slew_w ahead of the command it emits — so the command always covers the gap in one cycle, and a reversal costs one cycle of unwind rather than integrator_max_w / slew_w (12.5 at max_w: 5000, slew_w: 400). Measured against the previous cycle's command, which is what this row publishes, that is two steps: 800 W at the live shape. A published lead persistently above two slew steps means something is wrong — either the saturation freeze is holding the command (Loop reason says saturated-freeze), or the bound is not doing its job.

Before this bound existed, the same 6 kW export drove the lead to 4600 W and the integrator to its 5000 W rail, and the command then needed twelve cycles — about a minute at this install's meter rate — to walk back down once the disturbance cleared.

⚠️ What the bound buys — and how much of it is a model rather than a meter reading. Sixteen days of the reference install's own recorded disturbances were replayed through both laws (tools/replay_recorded_loop.py). The result that survives every plant and tuning tried is the export reduction: about an eighth of the modelled export — 5.5 kWh of 39.6 at the plant lag the replay fits, 3.8 kWh of 28.9 at the shorter lag that best reproduces what this install actually exported — with wasted battery throughput falling alongside it, 4.67 to 3.19 kWh at the fitted plant. ⚠️ Those are simulated watt-hours. The replay's fitted inverter carries a ~375 W RMS residual and prints +35 % PESSIMISTIC beside its own export figure on every run; the direction is what it establishes, not the absolute kWh.

⚠️ And it does NOT promise you a lower capacity-tariff peak. The worst quarter-hour average offtake — the billed quantity — improves from 3739 W to 3434 W only at the longest plant lag the replay fits. At every shorter lag, including the one that best reproduces the recorded export, release/1.0 already sits at 3434 W and this bound changes it by exactly zero. The plant lag is not identified by the recording, so treat the peak as unchanged and the export as the reason this exists. DECISIONS/SAFETY-48-bounding-the-integrator-lead.md §3a has the table.

⚠️ Two roster names that differ only in punctuation or case — inverter-b and Inverter B — are two names to the validator and one id once slugged. The add-on gives the second one a _2 suffix so nothing collides, but the ids you get then depend on roster order. Give your inverters plainly different names.

PV production (pv_entity)

For installers: card TEL-11.

More than one solar inverter? Use pv_entities instead — see Several PV inverters below. The rest of this section applies to both.

Optional, off by default, and it changes no control behaviour whatsoever. Set it and the add-on records what your solar is producing beside what the meter and the battery are doing. Leave it empty and the add-on reads no entity, announces no entity, publishes no topic and shows no row — which is what every install that upgrades into this version does.

Why it is a Home Assistant entity and not a register

This add-on's RS485 bus carries the battery inverter it drives. On nearly every site the PV inverter is a different box from a different vendor, on its own connection, reporting into Home Assistant through its own integration. So pv_entity takes whatever sensor.* your PV integration already provides — any brand Home Assistant can read. It does not have to be on the bus, and there is no assumption anywhere that it is.

What it is for

OPT-10 measured this site over sixteen days: the battery reached 90 % on two of them, and 20.5 kWh of surplus left the site while the pack still had room — 12.6 kWh of it with the battery not charging at all. Three different things produce that number and they need three different fixes:

  • there was no irradiance to store — nothing to fix;
  • production was there and something limited the charge power;
  • production was there, the battery could take it, and the optimiser declined.

Only the third is a software defect, and none of them can be told apart without a record of production. This entity is that record. The attribution itself is OPT-10's, with a week of history behind it.

The sign, and pv_invert_entities

For installers: card TEL-14.

Positive must mean producing. Some sensors — typically a kWh meter or an ESPHome power clamp in the PV circuit — read negative while producing and a few watts positive at night: list each of those in pv_invert_entities, spelled as in pv_entity/pv_entities, and its sign is flipped before the sum; the status page marks it [sign inverted] and cautions on any entity reading below −50 W. History recorded before you set it keeps the old sign. See DECISIONS/TEL-14-pv-sign.md.

The unit is read off the entity

The add-on reads your entity's own unit_of_measurement and converts: mW, W, kW, MW, GW, TW — Home Assistant's own power units. A sensor with no unit, or with a unit that is not one of those, is refused, and the status page says so (the sensor has no unit). That is deliberate: there is no magnitude test that can separate 30 W of dusk from 30 kW of noon, so a guess here would be a silent factor of 1000 in the one channel that exists to be believed a week later.

⚠️ Absence is never zero

what happened sensor.goodwe_pv_power on the status page
neither pv_entity nor pv_entities set the entity does not exist no row at all
set, sensor answered 4100 W 4100.0 PV 4 100 W
set, sensor answered 0 W 0.0 PV 0 W
set, sensor unavailable / missing / bad unit unknown PV —, plus a caution naming the reason

The last two rows are the point. A dead sensor publishes Home Assistant's own "no value" payload, so the entity goes to unknown and the recorder stores a gap — not a flat line at the last number it saw. Reading a week of history back, "no PV was reported" and "PV reported 0 W" are different pictures, which is exactly the distinction the whole card exists to preserve.

A failed read also clears the previous good one: the value never outlives its own sensor.

Several PV inverters (pv_entities)

For installers: card TEL-12.

A multi-string or multi-orientation roof routinely carries two or three PV inverters, each publishing its own power sensor into Home Assistant. List them all and the add-on records the site total:

pv_entities:
  - sensor.pv_east_power
  - sensor.pv_west_power
  - sensor.pv_garage_power

In the Configuration tab this is a repeatable field: press the + and add a row per inverter. Each entity's own unit_of_measurement is read, so a kW inverter beside a W one adds up correctly.

It replaces pv_entity — clear that one

Setting pv_entities makes pv_entity ignored, and the status page says so until you clear it. That is deliberate rather than tidy: if the two were added together, an upgrade where you leave your first inverter in pv_entity and also list it here would count it twice, and a doubled total is as wrong as a missing one in the one channel that exists to be believed a week later.

⚠️⚠️ If ANY of them stops reporting, there is NO site total

This is the whole reason the key is shaped this way, so it is worth being plain about:

what happened sensor.goodwe_pv_power on the status page
all three reporting 1200 / 2100 / 450 W 3750.0 PV 3 750 W
all three reporting, one of them 0 W 1650.0 PV 1 650 W
one of the three unavailable unknown PV —, plus a caution naming which source is missing
all three gone unknown as above

It does not publish the sum of the ones that answered. Two inverters out of three add up to a smaller number that is indistinguishable from a real one — and read back a week later that says "there was less sun than there was", which is precisely the conclusion this recording exists to test rather than to manufacture. A gap makes the analysis say "I do not know"; a partial makes it say something false. So the total goes to unknown and the recorder stores a gap, exactly as it does for a single dead sensor.

The diagnostic is not thrown away — it is put where a person will see it. The status page names the missing source by its place in the pv_entities list (source 2 of 3). That page is read live by a human who wants to fix something; the recorded number is read cold by an analysis that would believe it.

Note the third row against the second: a member honestly reporting 0 W is summed, not treated as missing. Shading, a tripped string and nightfall are all real zeros, and blanking the site whenever one member read zero would report an absence on every clear evening.

⚠️ Not "just use a template sensor that adds them up"

You may legitimately prefer a template sensor, and pv_entity will happily point at one. But it is not the answer to this problem: a template sensor summing an unavailable source yields the partial total silently, which is the same defect one layer down — in a place this add-on cannot see and cannot report to you.

⚠️ What it does NOT do

It is instrumentation. It is not an input to the control law, the allocator, the arbiter, the peak guard, the maintenance cycle or any watchdog; it is not part of inputs_ok, so a PV integration that breaks cannot stop the battery being regulated; and its status row is a caution, never a Not ready. Wiring production into the control law would be a behaviour change on a system driving a real battery — and it would destroy the measurement, because the loop would then be reacting to the thing being measured.

tools/tel11_pv_trace.py is what says so rather than claims it: it drives 41 stimulus worlds through the shipped controller and hashes a document of control decisions containing none of this material, on this tree and on release/1.0, with the option set and unset.

P1 meter ingestion

meter_entity above expects one signed sensor, which usually means a template someone wrote by hand. Setting meter_source moves the whole derivation into the add-on, where it is done once and tested, and replaces meter_entity entirely.

Which mode you want depends on what your P1 reader publishes, and there are two shapes in the wild:

  • Two unsigned registers, consumption and injection, which is what a Belgian P1 read over DSMR gives you → ha_dsmr, or mqtt_p1 for a bridge. The add-on subtracts them.
  • One signed figure, positive = import and negative = export, which is what a HomeWizard P1 gives you (sensor.p1_meter_active_power) → ha_signed. The add-on splits it. ha_dsmr cannot read this: it wants two registers and rejects a negative one outright, which is every exporting telegram.

Either way, do not build the missing shape out of template sensors. The point of meter_source is that the sign convention is derived in one tested place rather than in YAML nobody reviews underneath a safety input.

✅ If your meter is a HomeWizard P1, use homewizard_local, not ha_signed. It reads the same meter and produces the same numbers, but it polls the meter directly instead of watching a Home Assistant entity — and that is the difference between an age sensor a watchdog can threshold and one it cannot. See "sensor.goodwe_p1_sample_age" below; ha_signed remains for installs where the meter is only reachable through Home Assistant.

⚠️ ha_dsmr and mqtt_p1 have never processed a telegram from real hardware. No meter in this installation uses either one. Both were written to the assumption in specs.md §5.2 that a Belgian P1 exposes two unsigned registers, and the meter actually fitted here does not — it is the HomeWizard P1 that ha_signed reads. They are covered by the unit checks in test_p1.py and by an end-to-end test against a fake Home Assistant, and nothing more.

This is recorded because the realistic way it bites is someone debugging a meter problem months from now treating those two paths as proven and looking for the fault elsewhere. If you are the first person to point one at a real meter, expect to find something, and please update this note when you do.

option default meaning
meter_source off off keeps meter_entity. ha_dsmr subscribes to the DSMR integration over the HA WebSocket; mqtt_p1 reads a topic; ha_signed subscribes to one signed entity over the HA WebSocket; homewizard_local polls a HomeWizard P1's own local API, bypassing Home Assistant
meter_phases 1 1 or 3. Must match the telegram, or every telegram is rejected and logged
meter_max_age_s 30 Beyond this the reading is stale and grid power reads as missing. On its own it does not command 0 W — see the timing note below. It is also the longest a reading is held forward into the 15-minute average
meter_poll_s 5 homewizard_local only. Seconds between polls. Must be well under meter_max_age_s — see below
p1_host homewizard_local only. The meter's own address, host or host:port (e.g. 192.168.2.250)
meter_mqtt_topic mqtt_p1 only
p1_import_entity The unsigned consumption sensor. Do not point this at a signed template
p1_export_entity The unsigned injection sensor
p1_phase_import_entities [] L1..L3, in order. Needed for the capacity-tariff peak on a three-phase connection
p1_phase_export_entities [] L1..L3, in order
p1_net_entity ha_signed only. The signed net-power sensor: + import, - export
p1_phase_net_entities [] ha_signed only. L1..L3, in order, each signed the same way. Needed for the capacity-tariff peak on a three-phase connection. The list length must equal meter_phases

Both per-phase lists are checked against meter_phases once at startup: a list of the wrong length disables P1 ingestion with an error in the log, rather than letting every telegram fail its phase-count check one at a time. Leaving the list empty is fine and is not an error — you simply get no per-phase figures, and therefore no capacity-tariff peak. On a three-phase connection that is a much bigger omission than it looks: on a surveyed reading here the phases carried 2769 W of import while the connection netted 187 W, so the billed quantity is understated roughly fifteenfold if the phases are missing.

How long a dead meter takes to reach 0 W

meter_max_age_s and stale_input_s stack. They are two different clocks and neither one is the whole answer:

step option default
telegrams stop, P1 sample goes stale, grid power starts reading missing meter_max_age_s 30 s
inputs have been missing long enough for the loop to command 0 W stale_input_s 15 s
total, meter death → 0 W commanded by this add-on 45 s

So in P1 mode stale_input_s is not "how long inputs may be missing before commanding 0 W" measured from the meter dying — it is measured from the moment the P1 sample already went stale. Size the pair together: the ESP32's own watchdog commands 0 W after ~30 s of silence from this add-on regardless, and that layer is unaffected by either option.

How long a FROZEN inverter power register takes to reach 0 W

A third clock, and it is a different failure from the one above: the entity is still there and still answering — it has simply stopped changing. Before SAFETY-37 that read as a healthy measurement for ever, which is what happened on 2026-09-02 (HW-01): a value held for 6 h 25 m while the pack drained to its floor, and every detector read healthy throughout.

Inverter.batt — the inverter's own AC power, register 0x0518 — is now withheld from the control path when all three of these are true:

the reading has not changed for 35 s
and the command standing on that inverter is over 250 W
and that command has travelled, since the reading last moved, over 250 W

All three, because a converged site legitimately repeats one power figure for minutes — measured at up to 263 s over 30 days of this house's own meter — and a site at rest is not owed movement. It is only when the controller has asked the inverter to move and the register has not answered that the reading is treated as missing.

A withheld reading is a MISSING INPUT, so it joins the stale_input_s clock above: 35 s to withhold, then 15 s of hold, then 0 W. It clears itself the instant the register reports a different value — one watt is enough, and this register dithers ±1 W while holding — so a working inverter clears it on its next 10 s publish. There is no option to configure: the 35 s is derived from the ESPHome publish interval and the two watchdog windows it has to stay inside, and the add-on's own checks refuse a value that does not.

⚠️ AND IF THE REGISTER DOES NOT REPORT A DIFFERENT VALUE, THE ADD-ON STOPS WAITING AFTER 17 s (SAFETY-57). This paragraph used to end at the sentence above, and on 2026-09-21 that turned out to be a promise the state could not keep: at 0 W nothing is asking the register to be anywhere, so "report a different value" is evidence a parked site cannot ask for. The live site sat at 0 W for 790 s while the house exported 2.4 kW, on a register that was working perfectly — the pack was simply at its floor and the BMS was refusing the discharge — and it was freed by a person restarting the add-on.

So: once the standing command has been under 250 W for 17 s, the verdict is withdrawn and the register is graded again from scratch over a fresh 35 s. The whole parked interval is now stale_input_s + 17 s — 32 s at the defaults — and it does not depend on what the battery decides to do.

in the action log what it means what to do
STALE <unit> alone, then nothing the register stopped following and has not come back look at the unit — comms, the ESP32, the inverter
STALE <unit> then STALE WITHDRAWN <unit>, once the pack was probably empty or full and the BMS refused the command. The site has resumed nothing
the pair repeating every ~70 s the register really is not following, and the add-on is re-testing it look at the unit; the site is regulating on the meter in between

⚠️ STALE WITHDRAWN IS NOT "RECOVERED", AND THE LOG LINE SAYS SO ITSELF. Nothing has been learned about the register when it is printed; the verdict is withdrawn because it can no longer be tested, and the next 35 s are the test. During those 35 s the held number reaches the site's telemetry again. That is deliberate and it is bounded: 35 s is shorter than both the circulating-power hold (75 s) and the per-unit sign hold (85 s), so neither of those alarms can be made to say anything by a held number, and the command itself is derived from the meter, never from this register.

⚠️ What this does NOT catch: a register whose value is WRONG rather than STOPPED, including a dead publisher whose last two samples happen to alternate. Detecting that needs an arrival stamp inside the ESP32 firmware itself, which is TEL-06.

There is no fallback to an inverter-side power figure, deliberately. The inverter's own AC power tracks its battery almost perfectly and the real meter hardly at all, so a controller that failed over to it would be regulating against its own output while looking healthy.

The mqtt_p1 payload is one JSON object per telegram, and the schema is strict — a key it does not recognise is a telegram from something other than what was tested, and guessing a key here means guessing a kilowatt:

{"import_w": 1234.0,
 "export_w": 0.0,
 "phases": [{"import_w": 500, "export_w": 0},
            {"import_w": 400, "export_w": 0},
            {"import_w": 334, "export_w": 0}],
 "timestamp": "2026-08-24T18:00:05+02:00"}

phases and timestamp are optional; timestamp must carry a UTC offset. Where it is present it is used for the age, which is what stops a retained message replayed on reconnect from presenting a ten-minute-old reading as current.

homewizard_local — polling the meter instead of Home Assistant

Set p1_host to the meter's address and the add-on does GET /api/v1/data on it every meter_poll_s seconds, reading active_power_w (signed, same convention as ha_signed) and the three active_power_l{1,2,3}_w fields. Home Assistant is not involved: no entity, no WebSocket, no integration to mis-configure. Per-phase figures are used only when the meter serves all meter_phases of them — a single-phase meter returns null for L2/L3, and the connection-level reading is still accepted on its own.

Why this mode exists: every HTTP response is an arrival. The meter answered, now, with its current reading — whether or not the number moved. That is the signal sensor.goodwe_p1_sample_age needs and the one Home Assistant cannot give it at all (see the note below). It is also simply fewer moving parts: the five-second cadence is the meter's own, rather than an integration's polling of it re-published as a state change.

Cadence. The age is never fresher than the poll interval, so:

meter's own update rate ~5.0 s (measured 4.97 s)
meter_poll_s default 5 s — nothing to gain below the meter's own rate
meter_max_age_s default 30 s, i.e. six polls of headroom
meter_poll_s >= meter_max_age_s refused at startup — every reading would be stale before its successor arrived
meter_poll_s > meter_max_age_s / 2 warned — one missed poll makes the reading stale

A failed poll — timeout, connection refused, non-200, unparseable body — is a missing reading. It submits nothing, so the reading does not become 0 W, the last good value and its timestamp are left alone, and the age goes on climbing. That is exactly what a dead meter should look like.

What it still cannot see: a frozen meter. A meter that answers 200 OK forever with a stale number is arriving, so no arrival detector — this one included — can tell it from a healthy one. The local API does expose the raw material the HA path never had (the total_power_*_kwh registers stop advancing), and the transport tracks it as unchanged_s, but it is deliberately not folded into the age and not thresholded: this controller regulates grid power toward ~0 W, and at a converged −10 W the export register needs six minutes to move by its 1 Wh resolution while the power figure legitimately repeats. Thresholding that at 30 s would rebuild the false-trip limit cycle at the exact operating point the controller aims for. Freeze detection is a separate problem and needs the low-power case solved first.

You read it yourself instead: the add-on's status page shows it on the Meter row, as … 4 readings refused, unchanged for 5 minutes. On a house drawing real power that figure stays in the seconds; minutes of it while the load is clearly not near zero is the meter to go and look at.

sensor.goodwe_p1_sample_age

Published over MQTT discovery whenever a broker is available: seconds since the newest accepted telegram, refreshed every second rather than only when a telegram lands. The ESP32's stale-input watchdog subscribes to this exact entity id; do not rename the GoodWe device or this entity, since HA composes the id from both (see What appears in Home Assistant at two inverters).

The reason it is recomputed against the clock is that Home Assistant only pushes a state when the state changes. A meter sitting at a genuinely constant reading emits nothing, which is indistinguishable — to anything watching the value — from a meter that has died. Watching the age instead separates the two: it climbs when telegrams stop and resets when they arrive, whatever the reading says.

The entity is only created when meter_source is not off. With P1 ingestion disabled there is nothing feeding it, and an age sensor climbing with no ingester behind it would trip the firmware watchdog on a system that is working fine.

Known limit, mqtt_p1. The age measures arrival. On the MQTT path a bridge that is stuck republishing its last telegram keeps arriving, so the age stays near zero and a frozen meter still looks fresh. Detecting that needs a change-detector rather than an arrival-detector, and it is not in this version.

⚠️ Known limit, ha_signed — do not drive a watchdog off this age yet. On the HA WebSocket paths the age is stamped when a state_changed arrives, which means it measures time since the value last changed, not time since the meter last reported. Home Assistant offers nothing better: a repeated reading produces no state_changed, does not advance last_reported on either the REST or the WebSocket serialiser, and state_reported cannot be subscribed to over the WebSocket at all (Event filter is required for event state_reported). All three measured on the ENV-01 rig against the real HomeWizard integration with the meter frozen: 0 state_changed in 70 s and no timestamp movement anywhere.

ha_dsmr mostly escapes this because a DSMR telegram updates several entities and something in the set almost always moves. ha_signed has exactly one entity, so a healthy meter under a flat load is indistinguishable from a dead one. This is not hypothetical: in our own captures (sim/scenarios/ha-p1_meter_active_power-2026-08-20.json) the real house meter went 42.2 s and 97.0 s between changes, and 23 Aug peaks at 29.1 s — all past the default meter_max_age_s of 30.

So sensor.goodwe_p1_sample_age on ha_signed is safe to read, and it is correct whenever the value is moving, but it must not yet be thresholded by the ESP32 stale-input watchdog: a quiet house would trip the battery to 0 W. Raising meter_max_age_s is not the fix — the two conditions produce an identical signal, so a bigger number only chooses which of the two errors you get. The real fix is an arrival stamp the meter itself provides — and that now exists: meter_source: homewizard_local. If you have a HomeWizard P1, switch to it. If your meter is only reachable through Home Assistant, this limit still applies to you and the watchdog threshold still must not be armed.

Where the age is trustworthy:

mode the age measures safe to threshold from firmware
homewizard_local time since the meter answered yes — every HTTP response is an arrival
ha_dsmr time since one of several entities changed no — statistically usually fine, which is a masked bug, not an absent one
ha_signed time since the one entity changed no — see above
mqtt_p1 time since a message arrived arrivals yes, but a stuck bridge republishing keeps arriving

Control

option default meaning
max_w 2000 Hard limit on what may be commanded. Start low, raise after commissioning. Not read when inverters is declared — each entry carries its own, and the site ceiling is their sum
gain 0.6 Correction per cycle. At the limit — do not raise
slew_w 0 Largest change in one cycle. 0 means follow the rail - max_w / 9, i.e. the loop takes the plant's own measured ~18 s settling time to cross its whole output range at the 2 s tick the live install runs. 222 W at max_w: 2000, 556 W at 5000, 1067 W at a ~9600 W two-inverter site. A fixed number is accepted and never silently reduced, but one FASTER than the rail implies is refused at startup (SAFETY-39): slew_w: 1000 against max_w: 5000 crossed the rail in 10 s against an 18 s plant, and on 2026-09-05 drove the meter from 3.4 kW of export to 2.8 kW of import
deadband_w 15 Ignore errors smaller than this
target_grid_w -10 What the meter should rest at. Negative = a slight export
step_w 10 Quantisation
saturation_w 500 Divergence that counts as "the inverter is at a limit"
saturation_cycles 3 How many consecutive cycles before freezing. A cycle is one meter update or cycle_floor_s, whichever comes first - see the note below. Do not set to 1. Every value this file accepts (1-10) is below the freeze's own release bound of 12 cycles, so no accepted setting can stop the block engaging - see SAFETY-13, below
integrator_max_w 0 Bound on the loop's accumulator, and 0 means "same as max_w". Caps how much stale error can be waiting to unwind when the sign flips. Do not raise it above max_w - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below max_w is the useful direction
heartbeat_s 10 Refresh interval; must stay well under the firmware watchdog
cycle_floor_s 15 The longest the loop may go without evaluating. A deadline on the gap between cycles, reset by every cycle — on a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than ~10 s; it must stay under meter_max_age_s, past which there is no reading to evaluate anyway
stale_input_s 15 How long inputs may be missing before commanding 0 W. In P1 mode this clock starts only after meter_max_age_s has already expired — the two stack, see "How long a dead meter takes to reach 0 W"
auto_start false Start controlling on boot (only after commissioning)
sign_disagree_w 300 Both the command and the meter must exceed this before a sign difference counts. Must stay below max_w or the watchdog can never fire, and the add-on refuses to start
sign_disagree_s 180 How long they must stay opposed, continuously, before the failsafe trips
unit_sign_disagree_w 250 Two or more inverters only. Both a unit's own share and its own measured power must exceed this before their sign difference counts. It is saturation_w / 2, so the smallest divergence it can see is exactly the gap at which the loop already calls the inverter "not following". Must stay below max_w or it can never fire, and the add-on refuses to start
unit_sign_disagree_s 85 Two or more inverters only. How long one unit's command and its own power must stay opposed before that unit is parked
site_sign_disagree_w 300 Two or more inverters only. Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as sign_disagree_w, for the same reason: near zero the sign is noise
site_sign_disagree_s 325 Two or more inverters only. How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than sign_disagree_s because the command has to cross zero over a rail that is the sum of the roster, at a slew_w that is not - measured at 110 s with one inverter and 215 s with two
delivery_hold_s 185 Two or more inverters only. How long one inverter may stand at a discharge share it is not producing - short by more than saturation_w, with no reason this add-on can read - while at least one sibling is not in that state, before it is named. It reports; it does not act. Nothing is commanded differently and the site goes on running short by that unit's share
shadow_mode auto Run every decision and command nothing. auto = shadow mode while control is stopped, ordinary control once you press Start; on = shadow mode always; off = ordinary control. See Shadow mode below

Shadow mode (shadow_mode)

For installers: card SAFETY-10.

Shadow mode runs the whole decision path — the price strategy, the capacity guard, the maintenance shaping, the grid-following loop, the arbiter that ranks them, SAFETY-28's authorisation gate and SAFETY-03's clamp — and sends nothing to the inverter. It is how you find out what this add-on would do on a site before you let it do it.

Every defect in the optimiser that this project has found was invisible from outside while it was happening: a price lookup answering for the wrong quarter-hour, a trade against a binding capacity cap that ran 28 charge onsets in an hour, an SoC ceiling churning 598 Wh to gain 310 Wh. Each one was paid for at the meter first and noticed afterwards. Shadow mode is the surface that would have shown all three on the day.

What "commands nothing" means, exactly. Every inverter is held at 0 W, written by the ordinary single writer and refreshed on the ordinary heartbeat. It is not silence. ⚠️ The inverter holds its last command forever — it has no meter timeout — so a mode that simply stopped writing would leave whatever was standing when you switched it on standing for as long as you left it on, under a page saying nothing was being commanded. Refreshing 0 W is also what the ESP32's 30 s stale-input watchdog requires of us: going quiet would put that board into a tripped watchdog state for the whole run, which is an alarm being used as a mode.

The three values.

value what happens
auto the shipped default. Shadow mode while control is stopped; ordinary control once control is started. A new install ships auto_start: false, so it comes up in shadow mode and observes before it ever commands anything. An install that is already controlling is started, so nothing about it changes
on shadow mode always, whatever the Start / stop control button says. This is the one to pick to watch an armed site for a day — the loop runs, arbitrage decides, the peak guard files its claims, and the battery does not move
off ordinary control

⚠️ Existing installs are not changed. The default is auto rather than a boolean true precisely because a boolean would have changed them: the Supervisor composes an add-on's options as its defaults, overlaid with your stored options, so a new key with a new default reaches every install on the next update. shadow_mode: true would have silently stopped a battery that is regulating today, and the only symptom would be a battery that stopped doing anything.

⚠️ Under auto, pressing Start leaves shadow mode. That is the point of auto — the button means "control the battery". Set on if you want the decisions recorded while the loop runs.

What you get out of it. The status page carries a red-amber banner saying the battery is not being controlled, a Shadow mode row saying which rung decided, what it would have commanded on that cycle and why — with the caveat below, where the number was sized from a meter the battery never moved — and the Why row with the arbiter's own sentence. The action log gets one line each time the decision changes — the winner or the watts — rather than one per cycle, so a 40-row log still contains the context of what it found. MQTT reports status: shadow.

The failsafes. The sign watchdogs (SAFETY-02/30) are inert: they grade what this controller has standing on the wire, and that is 0 W, so they cannot arm. That is deliberate and it is the single most important decision in this feature — fed the shadow decision instead they would disagree with a meter that never saw it, and latch a trip that survives into live operation and a restart. The clamp (SAFETY-03), the write-authorisation gate (SAFETY-28), the frozen-meter alarm (SAFETY-18) and the capacity guard all observe: they run on the number the decision path produced, so a shadow run can tell you the clamp would have engaged. ⚠️ The capacity guard observes a meter with no battery in it. Every term of the quantity it thresholds is measured, so it engages in a shadow run only if the house alone would have put the quarter over the ceiling — 410 engaged cycles live against 0 in shadow, on the hour tabulated below. Its ceiling and its reasons are worth reading from a shadow run; its engagements are not evidence of anything. The circulation (SAFETY-31) and delivery (SAFETY-42) watchdogs observe and may legitimately latch, because their evidence is measured power, not a command. The operator stop is enforced at the wire instead of as a claim, so that the recorded decision is the real one.

⚠️ A latch that was already up stays up. Shadow mode does not clear anything. The banner names any standing latch and goes red for it, because a latch that is up while the controller is inert is up the moment it stops being inert.

⚠️ Maintenance is unchanged. The maintenance state machines are ticked on every cycle whether control is started or not, so they already run against a battery that does not move on a stopped install. Shadow mode inherits that exactly; it neither improves it nor makes it worse.

⚠️ What a shadow run will NOT tell you — and it is a whole category, not a footnote. The controller's carried command is re-seeded to the 0 W standing on the wire at the end of every shadow cycle — that is what makes turning shadow mode off safe, because the loop then resumes from where the battery actually is. Everything below follows from the same fact.

A shadow run cannot show you a closed-loop defect. Not partially — not at all. The battery is held at 0 W, so every measured input — the meter, the billed-quarter accumulator behind it, the battery power, the state of charge — describes a plant that did not respond. A closed-loop defect is one whose stimulus is the plant's response, and there is no response to be the stimulus. This is not a gap waiting to be closed: feeding the guards a simulated plant instead is exactly what the sign-watchdog decision below refuses, and for the same reason — a controller reacting to a plant that does not exist drifts further from reality every tick. The closed-loop answer comes from the closed-loop rig or from a live run, and there is no third place.

Concretely, measured on the rig at peak_cap_w 1500 under a 1200 W house, max_w 2000, one hour at a 5 s cycle — the same hour run twice, live and in shadow, on the same code:

live shadow
charge onsets in the hour 28 1
grid −455.5 … +3200.0 W 1200.0 W flat — the house
capacity guard engaged 410 / 720 cycles 0 / 720
state of charge 50.000019 → 51.856789 % 50.000000 → 50.000000 %

(The onset and engagement figures are taken with the trade-sizing fix reverted, because that is the defect being looked for; the SoC and grid figures are the shipped build.)

⚠️ The "strategies are unaffected" exemption that used to stand here was true until the trade-sizing fix landed, and is now false by hours. That fix makes the buy leg min(asked, headroom), where the headroom is (900 / seconds remaining) × (ceiling − projected quarter average) — and the projection reads the open quarter's accumulator, i.e. the meter, i.e. a function of what the battery did earlier in that same quarter. What survives of the exemption is exactly this much:

Which hour a strategy picks, and why, are unaffected — the clock and the price series are not functions of the battery. Measured: identical live and shadow on 720 of 720 charging cycles at a binding cap. How hard it trades is not.

⚠️ So the watts on the Shadow mode row are the right answer to a question nobody asked, and the row now says so. In a shadow run the billed quarter really is under its ceiling, because the battery really is not charging, so 900 / remaining multiplies a gap a live run would have closed. On the same rig, shipped code:

cap / house live commanded shadow recorded over by
1500 / 1200 −302.4 W −2000.0 W 6.61×
3500 / 3000 −502.6 W −2000.0 W 3.98×
3500 / 2000 (shipped cap) −1502.6 W −2000.0 W 1.33×
3500 / 1800 −1702.6 W −2000.0 W 1.17×
15000 / 350 −2000.0 W −2000.0 W 1.00×

Whenever a measured billed quarter sized the number, the row and the action log carry ⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W — and the watts are introduced as what it would have commanded on this cycle, in every state, because no number a shadow run records is a forecast of a live one. The figure is not re-computed to look right: a shadow-only sizing would mean the recorded decision was no longer the decision the shipped path reached, which is the one thing this feature has to be able to say.

The grid-following loop is the same story told first: its recorded decision is one slew step and stays there, because a trajectory depends on a battery responding and none did.

What a shadow run genuinely is for, then: which strategy wins, on which hour, at which price, for which reason, against which limits — the wrong-quarter-hour class of defect, a trade armed on a day it should not be, a refusal whose sentence names the wrong subsystem, a clamp that would have engaged, a standing failsafe latch. That is a real and useful set, and it is the set this feature covers.

The sign-disagreement failsafe

If the setpoint this add-on is emitting and the grid figure it is regulating on point opposite ways — charging the battery while the house imports, or discharging it while the house exports — with both over sign_disagree_w, for sign_disagree_s continuously, the add-on commands 0 W and holds it there. The banner reads The meter saw power go the wrong way, the add-on log says which two numbers disagreed and for how long, and the Controller status entity reports failsafe.

⚠️ At two or more inverters this is TWO checks, not one

For installers: card SAFETY-30.

The paragraph above describes what a one-inverter install does, and it is unchanged: there is one meter, one actuator, and comparing the command against the meter is comparing a thing with a view of itself.

With a second inverter that stops being true. The meter answers for both units, so a command put on one of them can be opposed to it with nothing wrong (inverter A charging while inverter B discharges more), and - the worse direction - a unit genuinely driving the wrong way can be hidden, because its sibling's watts arrive at the meter and the sum looks healthy. So the check splits in two, and they are different faults:

  • Per inverter — that unit's own share against its own measured power (the batt_entity reading, register 0x0518). This catches "this inverter is not doing what it was told". Only that inverter is parked; the site keeps running on the one that works. Thresholds: unit_sign_disagree_w / unit_sign_disagree_s.
  • Site-wide — the sum of what every inverter has standing, against the meter. This catches "the site as a whole is pushing the wrong way", which no per-inverter check can see because every unit may be faithfully executing its share of a number that is itself wrong. Every inverter is parked and the banner names the site rather than an inverter. Thresholds: site_sign_disagree_w / site_sign_disagree_s, latched under flat site_sign_tripped in /data/state.json.

Both latch, both are cleared by the same stop, then start, and both survive a restart. On a one-inverter install neither pair does anything at all - the two questions are the same question there, and only the check described above is armed.

This is not the circulating-power alarm further down: that one compares the inverters against each other and catches them pushing power through each other, which nets to zero at the meter and which no command is evidence about. It is also not the saturation freeze, which is a magnitude test that shapes the loop rather than a direction test that parks an inverter.

It does not clear itself, and restarting the add-on does not clear it either. Pressing Start / stop control twice — stop, then start — is the only thing that does. That is deliberate on both counts:

  • Commanding 0 W removes the command side of the comparison, so the disagreement always vanishes the instant the trip works. A watchdog that took that as proof the fault was over would hand the inverter straight back to the loop that was driving the wrong way, three minutes later, forever.
  • The trip is written to /data/state.json — under unit/<inverter>/sign_tripped, one key per inverter since DEV-02, so a trip caused by one inverter cannot latch another — and restored on startup. The pre-DEV-02 flat sign_tripped is written and cleared alongside it as the rollback view, so downgrading the add-on past DEV-02 still finds the latch, and a trip taken while the install is rolled back is re-armed by the version that comes back up rather than silently lost. Restarting the add-on is the first thing most people try when they see a red banner, and a latch that a restart clears would bring the controller back armed, into the same fault, unattended — and under a Supervisor that restarts a crashed add-on automatically, would do so in a loop. After a restart the banner, the 0 W and the recorded reason are all still there.

What it is for. This is a loop-integrity check: it asks whether the loop's output direction still matches the input state it was regulating. It catches a polarity error anywhere between the meter and the wire, and an integrator so far wound up that unwinding takes minutes. It is the check that would have caught all four episodes of 2026-08-20, when a commercial controller charged the battery out of the grid, four times, for up to 77 minutes at a time.

What it is not for. It does not detect an inverter that acts opposite to what it was told — that moves the meter further the same way, so command and meter agree while everything runs away. That is the saturation freeze's job (saturation_w / saturation_cycles), which compares the command against the battery's actual power. The two checks are complementary and neither replaces the other.

Why it will not false-trip on ordinary operation. A battery that refuses a command (empty, full, standby) leaves the meter where it was, so command and meter agree. A frozen meter drives the loop into agreeing with it. Around the resting point both signals are inside the 300 W gate, so the deadband can never produce one. A load step does flip the meter's sign before the loop catches up — measured on a full captured day, the longest such crossing is 30 s, against a 180 s window. And the watchdog is disarmed whenever the grid-following loop is not what is driving: a maintenance grid charge opposes the meter by design and must not be mistaken for a fault.

Both numbers are measured rather than chosen, and are exposed only because the ticket requires defaults rather than constants. On the captured healthy day sign agreement is 93.4 %; on the four captured fault clusters it is 0.3 %, 0.9 %, 10.5 % and 0.0 %. There is no reason to move either value without a capture that says so.

⚠️ A unit that stops DELIVERING is a third fault again

For installers: card SAFETY-42.

The two checks above both key on direction. A unit sitting at 0 W against a non-zero share has no direction: it is not pushing the wrong way, it is absent. So the per-unit sign gate never opens (it needs opposition on both sides of unit_sign_disagree_w), the site sum still agrees with the meter in direction, and circulating power is exactly zero — that quantity needs two units pulling against each other, and one of these is pulling nowhere.

The only thing that does fire is the saturation freeze, and this add-on's own code calls that an alarm operators learn to ignore, because a loop can be legally saturated all day: a cold pack, a curtailed inverter, an export limit.

So delivery_hold_s grades a different quantity — how many watts of a discharge share did not arrive — and makes it readable with a reduction that only exists once there is more than one inverter:

a unit is named while it is short of its share by more than saturation_w, with nothing this add-on can read to explain it, and the units in that state are fewer than the units that were asked to discharge.

Both halves matter, and the second is why this cannot fire on a one-inverter install at all:

  • "nothing explains it" is what keeps a legitimately empty pack out. A pack at or below its own bms_soc_floor cannot discharge, and that is the only fact this add-on holds that distinguishes an empty pack from a wedged one — the two are otherwise identical at the unit and at the meter, watt for watt. A unit the add-on has already dropped from the split, or parked by a sign trip, is likewise accounted for.
  • "fewer than were asked" is what keeps a whole-site cause out. A derate this add-on cannot see — thermal, grid-code, DC-side — hits every unit. One box that has stopped listening does not. If every unit that was asked is short and unexplained, nothing is reported: there is no instrument here that tells that from a plant which simply cannot deliver.

It reports. It does not act. Commanding an inverter that has stopped listening achieves nothing — that happened on this hardware once for 6 h 25 m — and the only re-allocation this add-on can express is all-or-nothing, which would throw away the output of a unit delivering part of its share. So the loop is untouched, the sibling keeps regulating, and the site keeps running short by that unit's share until somebody looks. The status page shows it as a caution rather than a failure for exactly that reason: the controller has not stopped.

The report survives a Home Assistant restart (unit/<name>/delivery_tripped in the add-on's own state file) and the notification is re-raised on the next start, because a persistent_notification is in memory and that is precisely how the 2026-09-02 outage went unseen. It is cleared by pressing Start, like the other latches.

The banner reads <unit> is not delivering. Control keeps running. as a warning, not a failure, and it displaces Running — holding grid at zero. That last sentence is a claim the site cannot make while a unit is absent, and a page that goes on reading healthy while the fault runs is the whole shape this check exists to remove.

Charge shares are not graded, and that is a stated gap rather than an oversight: there is no configured charge ceiling anywhere in this add-on, so a pack in constant-voltage taper — which legitimately takes hundreds of watts less than it is told, for tens of minutes — could not be explained, and arming that direction would fire on the taper of every maintenance charge.

Saturation is counted in cycles, not seconds

The specification states the saturation window as "> 10 s". This add-on counts cycles instead, and that is a deliberate, accepted deviation rather than an oversight - the acceptance criterion is not met as literally written.

A cycle here is one meter update or cycle_floor_s elapsing, whichever comes first. At the reference P1's ~5 s update rate the default of 3 cycles is usually around 15 s, and the worst case is bounded at 3 x cycle_floor_s = 45 s. It is still not the 10 s the specification asks for, so the deviation stands - but it is now a bounded number rather than an open one.

⚠️ This passage used to say something different, and the second half of it was wrong. Until SAFETY-12 the loop ran only when the meter value changed, so a meter that repeated a value stalled the counter for as long as it repeated, with no upper bound at all. That was defended here on the grounds that "a stalled counter is a detection-latency limit and not a runaway risk - the condition that stalls it stops the whole loop, so nothing accumulates while it is stalled". That argument covers integrator windup and nothing else. The hazard it misses is staleness: while the loop was stopped the heartbeat kept re-emitting the last command and the firmware kept putting it on the wire every 2 s, so a command computed from an old reading went on being executed through a real load change, with every indicator reading healthy. Measured on the rig during TEL-05: 70 s at a literally constant 350.0 W, 14 arrivals, zero control cycles.

The half that survives is the first one: the control law is still a pure function with no clock, and the wall-clock window still lives in Controller, which has one - see run_control(). What changed is that the controller now enforces a floor on the gap between cycles, so saturation_cycles finally means a bounded number of seconds.

If a tighter window matters on your site, lower cycle_floor_s (not below your meter's update interval, or the floor starts firing on a healthy meter) or raise saturation_cycles for a fast meter.

This is not a freeze detector, and it deliberately cannot be one. Nothing here decides whether a repeated reading is true — see unchanged_s in the meter section for why no threshold on that is safe at the converged operating point. The floor only stops "the number is the same" from meaning "do not think". The consequence is worth stating plainly: against a meter that is genuinely frozen at a wrong value, the loop will now correct toward that wrong value instead of holding a stale command. It is bounded by max_w, the arbiter and the device rating, and it is the same thing that already happened to a lying meter whose value merely jittered — bit-exact repetition was never a safety property.

When the freeze lets go, and when it deliberately never does

For installers: card SAFETY-13.

While the loop is frozen the command's magnitude may fall but never rise. That is the anti-windup rule, and at prev_w == 0 it reads as a refusal to start: with nothing commanded there is no magnitude left to fall, so a charge cannot begin. That refusal is deliberate. Commanding 0 W while the inverter reports hundreds of watts means something is driving the actuator that this add-on did not ask, and starting to push power the other way into it is how two controllers end up fighting over one actuator — the configuration §1.5 of the specification rules out.

What was missing was an end to it. Nothing cleared the block: not the error growing, not the integrator moving, not time. The exit is now explicit, and which freezes get one is the decision:

the inverter is… can it clear itself? what happens now
doing less than the command, in the command's own direction — a cold pack, a curtailment, an export cap yes. The command is pinned at something the inverter can reach, so it catches up and the freeze clears unchanged: frozen for as long as it lasts, with no exit
doing something outside what the command can explain — overshooting it, or the opposite sign not necessarily. If another writer is on the actuator, clearing the freeze needs the inverter to track our command and the other writer is what stops it, so nothing ends it the freeze releases once the divergence has outlasted the plant that could otherwise have explained it — see what freeze-released actually tells you, below, because this row is also where our own ramp lands

The second row's threshold is the plant's own measured settling time — the slower of the two directions it was measured in. The 2026-08-22 step test timed the same 1.78 kW step both ways: about 18 s to settle going up, about 23 s coming back down. 23 s is the one that belongs here, because the question this bound answers is how long may the plant still be the explanation, and the safe answer is to keep believing the plant for as long as the plant has ever been seen to take. At the measured 2 s meter tick that is 12 cycles (control.py, SETTLE_CYCLES).

⚠️ The first version of this feature used 18 s, and 9 cycles was wrong in the direction that accuses. Nine cycles is three short of the plant's own down-ramp, so an ordinary load step on a single inverter — the command falling through zero while the pack is still coasting the old way — could produce freeze-released with nothing else on the bus at all. Driven in the container against a model of the measured plant, one inverter and no second writer anywhere: over 272 load-step pairs the 9-cycle bound produced a release in 62 of them and the shipped 12-cycle bound in 2.

Releasing the output freeze does not unfreeze the loop. The integrator freeze is untouched, so the command is free to move only as far as an accumulator that stopped accumulating cycles ago, one slew step per cycle. Measured on the simulator at the live install's shape (max_w 5000, slew_w 400, target_grid_w -10), with another writer holding the pack at +700 W and 2 kW of export standing: the command holds at 0 W while the counter climbs, starts on the twelfth saturated cycle, walks 0 → −400 → −800, and settles at −1190 W against a frozen integrator of −1194 W — not at the 5000 W rail. Before this change the same run held 0.0 W on every cycle, for as long as the other writer stayed.

⚠️ The site still does not reach its target in that state, and it is not supposed to. What the exit buys is that the battery stops being idle while the site exports; what it does not buy is correct regulation against an actuator somebody else is also writing to. A standing grid error with another writer on the bus is the symptom of a configuration this add-on refuses to support, not a tuning problem.

What freeze-released in the action log actually tells you

It says: the inverter's power did not match the command for longer than this plant has ever been measured to take to settle. That is all it says. It is a reason to go looking; it is not a finding, and in particular it is not proof that a second writer exists.

The test behind it compares the reading against one cycle's command, and the pack carries more than one cycle of history. So our own power, arriving late, can look like somebody else's:

  • A big, fast load step is the ordinary innocent cause. The command falls through zero while the pack is still coasting in the old direction, and for a few cycles the reading has the sign the command used to have. Measured, one inverter, nothing else on the bus: eight cycles after a +1990 W command the command was 0 and the pack still read +522.3 W.
  • A plant slower than the one that was measured does it too. The 12-cycle bound is derived from a 23 s down-ramp. A colder or larger pack that takes longer than that will produce releases on its own, and no constant read off the 2026-08-22 measurement can prevent it. In the same container sweep, holding the bound at 12 and lengthening only the plant's down-ramp, single-inverter releases went from 8 of 320 scenarios at the measured 23 s to 59 of 320 at 34 s and 268 of 320 at 50 s.

So, in order: if you see freeze-released once, during or just after a large load or PV swing, that is most likely the loop's own ramp and needs nothing. If you see it standing — cycle after cycle, at a steady load, with a standing grid error the battery is not correcting — then look for the second writer: another controller, a scheduler in the inverter's own app, or an installer tool left connected. That is the configuration §1.5 of the specification rules out, and it is the one this exit exists to stop the loop stalling on forever.

⚠️ No saturation_cycles you can set removes the block. This file accepts 1-10, and all of those are below the 12-cycle release bound, so the freeze always engages before its own exit is due. (The first version of this feature had the bound at 9, where a setting of 9 or 10 did silently delete the block; moving the bound to 12 closed that by construction rather than by warning about it.)

Why target_grid_w is not zero

The deadband is a one-way ratchet: any resting point inside it holds until something disturbs it. Import and export are separate registers on the meter, so a rest point of +14 W is billed for every second it holds and no amount of export cancels it - 14 W all day is 0.34 kWh.

Biasing the target below zero moves that residue into the export register, which is not billed. The resting band becomes target ± deadband, so:

target_grid_w resting band worst billed leak export given away
0 -15 … +15 W ~15 W (0.35 kWh/day) none
-10 -25 … +5 W ~5 W (0.12 kWh/day) ~10 W
-15 -30 … 0 W none ~15 W (0.36 kWh/day)

Set it to -deadband_w if injection is worth nothing to you and you would rather give the energy away than buy it back. Set it to 0 if you are paid properly for export, or if you are debugging and want the loop centred.

⚠️ This is a billing knob, not a speed knob. If import is arriving in bursts rather than as a trickle, the cause is tracking lag, and this will not help - see "Why the tuning is what it is".

Maintenance

option default meaning
maintenance_enabled false Enable the monthly cycle
maintenance_interval_days 28 Minimum gap between cycles
maintenance_start_hour 19 Local hour a due cycle begins (see below)
maintenance_discharge_w 2000 Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87), and at N≥2 so may the house while another battery is at its peak reserve (SAFETY-91). Above max_w it is clamped to max_w — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (limited by supervised(max_w)). Warned about, not refused: the cycle still completes, just slower
maintenance_charge_w 2000 Charge ceiling, capped again by peak headroom. Must stay at or below max_w or the cheap-window shaping (hi = -maintenance_charge_w) contradicts the supervised limit (lo = -max_w), the arbiter resolves it to 0 W, and maintenance silently never charges in a cheap window — the add-on refuses to start
maintenance_soc_floor 11 Drain target — stay just above the inverter's own floor. ⚠️ This is not peak_soc_floor. It is the depth the monthly recalibration deliberately goes to, once, on purpose; peak_soc_floor is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys
maintenance_soc_target 99 Charge target
maintenance_hold_min 120 Hold at full so the BMS can balance

The start hour is local time. The add-on reads Home Assistant's time zone (Settings → System → General) at startup. If Home Assistant does not answer, it uses the add-on's own time zone, and failing that UTC. The log says which one it used, and warns if it is UTC: then restart the add-on once Home Assistant is up. On the day the clocks go forward, a start hour that does not exist starts at the first hour after the jump. On the day they go back, a repeated hour starts only once, in its first pass. With two inverters both batteries use the same local hour; one cycles at a time, and the other starts at that hour on a later day.

Upgrading. The hour used to be read as UTC. An existing install keeps its saved hour, and that hour now means local time: a saved 10 now starts at 10:00, not 12:00 in summer. The new default, 19, only reaches installs that have no hour saved. If you set an earlier hour to get an evening start in UTC (for example 17 for 19:00 in summer), set it to 19. The add-on never changes a saved value. For installers: DECISIONS/MAINT-10-local-start-hour.md.

When the charge counts as done. A battery may stop at 98 or 99 % while it balances, so it need not reach maintenance_soc_target. The charge counts as done when the battery is within 3 points of the target and has taken no more charge for 60 minutes of charging time, or when the charging time (36 hours) runs out within 3 points of the target. Only the minutes the cheap-hours gate let the battery charge count, and the charge ends after 7 days at the latest. Then the usual hold follows.

When a cycle fails. If the battery does not empty in 12 hours, or does not get within 3 points of the target in 36 hours of charging time, the cycle stops, you get the notification Battery cycle did not finish, and it is not tried again for 7 days. The status page shows Maintenance paused with the date. Starting a cycle by hand ignores the wait. For installers: DECISIONS/MAINT-13-full-pack-is-complete.md.

Tariff (all optional)

option meaning
peak_forecast_entity Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap
peak_cap_w The site's capacity-tariff target
peak_margin_w (50) Watts the peak guard aims under peak_cap_w (SAFETY-79). Reports and the bill use peak_cap_w itself
peak_soc_floor (20) The percent below which the capacity-peak guard will not discharge the battery. ⚠️ It bounds the peak guard only — it is not a pack-wide reserve, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as maintenance_soc_floor
bms_soc_floor (10) The pack's own cut-out percent, the number the BMS enforces whatever you set above. peak_soc_floor must be strictly above it or the add-on refuses to start. ⚠️ At a declared inverters: roster this key moves INTO each entry and is REQUIRED there (SAFETY-32): it describes a battery, and two packs of different models have different cut-outs, so one number for both protects the shallower pack with the deeper pack's BMS. Nothing is inherited — an entry that omits it is refused by name at startup, and the flat key below is then not read at all
price_now_entity, price_avg_entity Dynamic tariff. Empty = never force a paid grid top-up

On a capacity-tariff site the maintenance charge is capped by the headroom left under peak_cap_w, and if the forecast goes over the cap the charge-only clamp is dropped so the battery can shave the peak instead. Money outranks the maintenance schedule.

With two or more batteries, the one being emptied gives its drain rate and the others only add power when the house needs more; none charges while it drains (SAFETY-78). Below the drain rate the rest is exported, as with one battery. If the house would still go over the peak limit, the battery being emptied gives more than its drain rate, with one battery too (SAFETY-83). A battery at its peak reserve gives last: the others give their full power first, and the battery being emptied rises above its drain rate before it (SAFETY-91). In a cheap window the battery being filled charges and the others only add charge; none gives while it charges. The charge is cut to what keeps the 15-minute peak at the limit minus peak_margin_w (SAFETY-84). While a full battery rests after its charge (the hold), it gives just enough to keep the 15-minute peak at that aim if the house needs it; with two batteries the other one gives first. The hold still ends on its clock (SAFETY-86). When a charge ends, the other batteries take over one slew step a cycle, and none starts charging from the grid (SAFETY-93). When a battery's link drops, the others take over one slew step a cycle, and control does not stall on the dropped battery's power (SAFETY-96). When a drain ends (done, Off, timeout, abort, lost link, refused), the other batteries cover the house at once but do not export its drain; then they move one slew step a cycle (SAFETY-95). While that battery settles, an old battery reading does not move them (SAFETY-103).

Capacity-tariff exposure of the grid-following loop

⚠️ This section described the exposure BEFORE SAFETY-07. The loop is now capped — see "The capacity-peak guard" below — and what follows is kept because it is still the clearest statement of WHAT is being guarded and why the number is worth what it is.

⚠️ peak_cap_w caps MAINTENANCE charging. It does not cap the loop. The grid-following loop is bounded by max_w and by the device rating, and by nothing else. That is deliberate — the loop exists to reduce what crosses the meter, so under every healthy condition it lowers the peak rather than raising it — but it means one thing is true and worth writing down:

If the meter lies, the loop will happily buy max_w from the grid for as long as the lie lasts, and no cap in this add-on stops it. A meter frozen at a large export reads as "the house is exporting hard", the loop charges the battery to correct it, and the correction is a grid import. At the schema maximum max_w: 5000 — which is what this house runs, and is also the device rating — that is a 5 kW unattended import.

The bill is not the energy. In the Belgian capaciteitstarief the month is billed on its highest quarter-hour average, so fifteen minutes of this sets the whole month's peak. A measured case: one 4998 W quarter moved a monthly peak from 4832 W to 5013 W, and every day of that month was billed at the higher figure.

What exists today, and what does not:

max_w bounds the magnitude ✅ — but the exposure IS the magnitude
Maintenance charging capped under peak_cap_w ✅
Arbitrage charging capped under peak_cap_w ✅ — SAFETY-45, sized to the guard's own MEASURED headroom rather than to peak_forecast_entity
Loop capped under peak_cap_w ✅ — SAFETY-07, on every meter_source but off
Frozen meter detected ✅ on homewizard_local only, alarm only
Frozen meter stopped ✅ — SAFETY-07 forbids importing while the meter is frozen
SoC guard on the loop ❌ — the BMS is the only energy backstop

On the legacy meter_source: off path there are no P1 telegrams, so there is no quarter-hour average to guard, and the status page says Peak guard: off. There the mitigation is still operational: set max_w no higher than the loop actually needs (the tuning was measured at 2000 W and a 1.78 kW load step is tracked fine there), fit the e-stop, and watch for the alarm.

The capacity-peak guard

What it does. Every cycle the add-on projects the open quarter to its end-of-interval average — from P1 telegrams only, never from the command — and compares it to a ceiling. Over the ceiling it files one claim: a lower bound on the setpoint, at P_PEAK. That single shape is both behaviours §10.1 asks for: a hard clamp on charging while the bound sits above the standing charge, and pre-emptive discharge while it sits above zero. It can never cap discharge or export, because a floor cannot.

The number. excess = 900 × (projected − ceiling) ÷ seconds remaining, and the floor is the measured battery power plus that. The 900/remaining factor is the design rather than an accident: it is 1.0 at the top of the quarter and grows, so the guard is gentlest when its projection rests on the least evidence and becomes aggressive only once the quarter is nearly measured.

The ceiling is peak_cap_w, raised to this month's recorded peak when that already stands higher — once a month's bill is set at 5013 W, holding later quarters to 3500 W buys nothing and costs real charging. That relaxation is granted only by an OBSERVED reading: a peak store that cannot be read leaves the ceiling at the cap, so a broken instrument makes the guard stricter, never looser.

When it cannot see, it forbids importing. Three states are not evidence and are checked before the projection is graded: no open quarter (a fresh averager projects a confident 0 W, which is under every ceiling there is), a meter gap longer than meter_max_age_s while it lasts, and SAFETY-18's frozen-meter conjunction. In all three the claim becomes a floor of 0 W. That is the backstop for the frozen meter: the alarm arrives at meter_freeze_s (300 s) of a 900 s billing quarter and commands nothing, and this stops the import.

After a gap it goes on (SAFETY-90). The seconds the meter missed count at the higher of the peak limit and the import seen just before and just after the gap, with the batteries at 0 W. Seconds before the add-on's first reading count the same way, at the import seen first, and the page says part of the period was not measured. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md has the bound.

It trips, where the freeze detector only notifies, and the captures are why. On the healthy reference day (2026-08-23) it engages on zero of 95 quarters. The action is level-triggered and unlatched — re-derived from a fresh projection every cycle, released the instant the projection drops back — so a false engagement costs one cycle of discharge rather than a parked battery. And it is a LIMIT, so every stop in the watchdog table still outranks it.

Replayed on 2026-08-20, the day the vendor controller failed: the billed quarter measured 5029.8 W, above the inverter's own 5000 W rating, and the guard brings it to 3485 W — under the soft limit, without ever asking for more than the inverter can give. Across both committed days the quarters under the limit go from 189/190 to 190/190. Two days is not thirty and does not close AC 3; it is the comparison two days can support.

What it alarms on is saturation, not engagement. An engagement is an ordinary control action and goes to the action log and the status page. A saturated guard — asking for everything the inverter has while the quarter is still projected over — raises one notification (goodwe_peak_saturated), because that is the one state where the money will be spent and shedding load is the only thing left that changes the bill. SAFETY-81 keeps a 1 s meter spike, noise and a quarter's last seconds from raising it: see peakguard.load_too_high().

And it sizes a price-driven charge, not only a forced discharge (SAFETY-45). The same arithmetic read the other way answers "how hard may this site charge without putting the quarter over" — peakguard.charge_headroom_w() — and arbitrage.decide() sizes its buy leg to it. Before that, a trade commanded max_w until the projection rose far enough to engage the guard, at which point the interlock cancelled the trade outright, the loop took the actuator, the projection collapsed and the trade came back at max_w: 28 charge onsets in one hour on TEST-23 stage 3's closed rig, with the grid swinging −456 to +3200 W and every billed quarter finishing over the cap. It is one onset now, the trade runs continuously at the affordable rate, and the billed quarters land on the cap. ⚠️ The guard is what changed around arbitrage; decide() itself is untouched, so nothing above this line is different.

No new option for the CEILING. It is peak_cap_w, which already shipped. The coverage threshold is meter_max_age_s, the freeze predicate is meter_freeze_s, and the achievable discharge comes from max_w and the device rating — every one of them a key that already exists.

The energy floor under the forced discharge (peak_soc_floor)

The guard above can command a discharge, and until SAFETY-24 nothing in the add-on bounded how far down it took the pack — the BMS was the only backstop. On the 2026-08-20 replay the guard asks for 878 Wh in a day.

That is not absurd: the BMS floor is real and it works. But it is the pack's protection, not yours. A month of aggressive quarters can leave the battery repeatedly parked at that cut-out with no record on this side of why, and the first symptom — a battery that is never available when you want it — looks exactly like the SoC drift the monthly maintenance cycle exists to correct.

  • peak_soc_floor (20 %) is where the forced discharge stops. Below it the guard files a 0 W floor instead of a sized one: charging stays blocked (that can only ever lower your offtake), and no further discharge is asked for.
  • ⚠️ IT BOUNDS THE PEAK GUARD ONLY. IT IS NOT A PACK-WIDE RESERVE, AND THE NAME READS LIKE ONE. The ordinary self-consumption loop — the grid-following control law that holds your meter at zero — has no SoC floor of any kind. It will discharge straight past this number and keep going until the pack's own BMS cut-out stops it. What you are promised by peak_soc_floor: 20 is "the capacity-peak guard will not spend the pack for money below 20 %", and nothing more. See below for what that costs on a real evening. With two or more batteries, one under it is used last: only once the others give their full power (SAFETY-91).
  • When it binds you are told, once per episode, with notification goodwe_peak_soc_floor and a red status-page row reading Battery at its peak reserve (PEAK_GUARD_FLOOR_LIMITED in the log): the quarter will go over the ceiling, so shedding load is the only thing left that changes the bill. Failing to shave is never silent — the euro figure is the whole point of the guard.
  • An unreadable SoC is treated as floor-limited, not as a full battery. A full battery is exactly the value that would disarm this floor.
  • bms_soc_floor (10 %) is the pack's own cut-out — register 0x0560 on the reference GW5000S-BP, consistent with DoD 90. It exists so the add-on can refuse to start when peak_soc_floor is at or below it: such a floor can never be the thing that stops the discharge, and a guard that advertises a backstop it does not have is the one thing this project will not ship. Equal is refused too — equal buys zero margin.

Why 20 %: the reference pack is 13.8 kWh usable, so 10 points of margin over the BMS cut-out is about 1.6× the guard's worst measured day. If your pack is smaller, or your quarters are worse, raise it.

⚠️ What peak_soc_floor does not protect

For installers: card SAFETY-25.

On a sustained-load evening this add-on will take your battery to the BMS cut-out, whatever peak_soc_floor says. That is measured, not argued. Replayed on the committed 2026-08-20 capture, 19:00–22:00 UTC — three hours of house load after the sun has gone, 830 W average, not one watt of export in the window — with the 13.8 kWh reference pack started at 21 %, one point above the shipped floor:

pack at the start of the evening 21 % (one point above peak_soc_floor)
pack three hours later 10.0 % — the bms_soc_floor cut-out
energy the loop took out 1525 Wh
control cycles below peak_soc_floor 549 of 587, and 343 of those still delivering discharge
cycles on which the peak guard filed any floor 0
same replay at peak_soc_floor = 5, 11, 20, 60 the same 10.0 %, to four decimal places

That last row is the point: this is a property of the control law, not a value you can configure. Raising peak_soc_floor to 60 % changes nothing at all, because the guard it bounds is not the thing doing the discharging.

And the two can happen in the same cycle. On the high-load block of the same day (15:00–17:00 UTC) with the pack below the floor, the guard reports PEAK_GUARD_FLOOR_LIMITED — "there is no forced discharge" — on 49 cycles, and on all 49 of them the loop is simultaneously being granted a discharge of up to 2000 W by the arbiter. The status page is telling you the truth about the guard while the battery empties.

Why it is built this way, and why SAFETY-24 could not fix it. The peak guard files a lo limit — a lower bound on the setpoint. That asymmetry is exactly what makes the guard safe: it is provably incapable of lowering a target, capping a discharge or contradicting the arbiter. The same property makes it incapable of stopping a discharge the loop itself wants. A floor on the guard cannot become a floor on the pack by being raised.

Is this a runaway? No. Self-consumption discharge is demand-driven and self-limiting: it stops when the house stops drawing, and every watt of it is serving load you would otherwise have imported. It is not SAFETY-18's frozen-meter rail. What it means is that the depth your pack reaches on a heavy evening is set by your house, not by any number in this file, and the BMS is the only thing underneath it.

This is now a recorded decision, not an open question. On 2026-09-02 the product owner took Option A: the BMS cut-out is deliberately the only backstop for demand-driven discharge, and a loop-side reserve floor is deferred rather than rejected. The options, their costs, the measured replay and the reasoning are in DECISIONS/SAFETY-25-loop-soc-floor.md, together with the revisit trigger (ALLOC-01, the host-side share allocator) under which a reserve becomes safe to build. Nothing about the behaviour above changed — Option A is what has always shipped. What changed is that this section now describes a choice somebody made, and you can read why.

The winding-loop alarm (the output clamp)

Every setpoint this add-on writes is clamped to max_w before it leaves the process, and when that clamp actually engages, it alarms. The clamp on its own is not the feature - this project already clipped, and the clipping is what hid the defect the guard exists for: with the inverter switched off entirely an unclamped integrator climbed ~130 W per 4 s past 10 kW and, pinned at the 5 kW rail, reported 14 768 W. Clipped silently, that is a flat, legal-looking 5000 W hold on every dashboard, and nothing anywhere says the loop asked for three times it.

What "the rating" is. The bound is max_w, and only max_w. It is already validated against the setpoint device's own range at startup - the add-on refuses to start if max_w exceeds what the device accepts (and again once ESPHome is up and the real rating is known), so by the time the loop runs the two numbers agree. The device range is applied a second time inside the write as defence in depth; after a successful validation it cannot bite, and if it ever does, that engagement alarms too and names the bound.

Clamping is not saturation, and the two never share a channel.

what it means what happens
clamp the computed value exceeded the rating a software bug signal - something upstream is winding. Alarm.
saturation the inverter is not following a perfectly legal command a hardware condition - freeze the integrator. No alarm.

A loop can be legally saturated all day: a cold pack, a curtailed inverter, an export limit. Routing the clamp through a saturation-shaped alarm puts the bug signal inside the noise, which is the same silence as clipping quietly. They share no reason code, no notification id, no status row and no counter.

It is an alarm, not a trip. The clamped value is written and the loop keeps running - this is not the sign-disagreement failsafe, which commands 0 W. You get a persistent notification (goodwe_output_clamp, replaced rather than stacked), an ERROR in the log with the reason code OUTPUT_CLAMP, a row in the event list, and a red status banner while it is engaged. The message quotes the command the loop computed and the value it was clamped to, both from the cycle that clamped. Once it clears, a warning row keeps the engagement count so the evidence does not vanish with the condition. Consecutive engagements share one event row carrying the exact count, because the event list keeps only 40 rows and a row per cycle would flush every other guard's entries out of it.

There is nothing to configure, deliberately. The bound is max_w, which you already set; giving the guard a limit of its own would be a second number that can disagree with the first, which is exactly the failure the startup validation exists to prevent. The alarm's cadence - normally at most one notification per 150 s (an episode that has not yet notified does so once it has engaged 3 cycles in a row, and an over-rating has its own 150 s budget), cleared after 3 clean cycles - is fixed for the same reason RAIL_FRAC is: it describes the notification bus, not the plant.

WARNING: on a default install the engagement you will see is the integrator's, not the output's. The control law bounds the integrator to max_w and then derives the command from it, so the command itself can never come out over the rating unless integrator_max_w has been deliberately raised above max_w. Both are watched. A guard that watched only the emitted value would be silent on every standard configuration.

The integrator bound is not a bug, and it is reported as what it is. It reads "the correction the add-on builds up reached its +5000 W limit, command now +2000 W", titled "Battery is not keeping up", logged as a WARNING, with an amber (not red) status row and its own notification (goodwe_integrator_bound), so it never replaces a software-fault notice still on screen. Brief engagements after a large load or solar step are normal. If it stays engaged, check that the battery is following its commands. Only a command over the rating is logged as a software fault, at ERROR, and it has its own notification budget. Why: DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md.

The integrator bound counts only on a cycle the loop decided. While maintenance, the peak guard or a safety limit sets a battery to discharge, the same alarm fires only when that battery gives more than saturation_w less than the least it was told over the plant's settle time (at least 23 s), for saturation_cycles cycles. A charge is not graded: a full battery takes less as it tops up. At two or more inverters the loop does not wind while maintenance holds a battery. Why: DECISIONS/SAFETY-85-no-false-not-keeping-up.md.

The frozen-meter alarm

A meter that has died is easy — it stops answering, the sample age climbs past meter_max_age_s, the reading becomes missing, and the add-on and the ESP32 both command 0 W. A meter that keeps answering 200 OK with a document that never changes is the hard one: every freshness mechanism in the stack reads it as healthy, because it really is arriving on every poll. The loop then integrates a constant error and winds to the rail, in 75–360 s at max_w: 5000.

The add-on alarms when both of these are true at once:

  • the meter's own document (power figure and both kWh registers) has not changed for meter_freeze_s — 300 s by default; and
  • the command standing on the wire is at the rail, within 5 % of the ceiling the command can actually reach.

That ceiling is min(max_w, the setpoint device's own rating), not max_w. The emitted command is clipped to the device's reported range as well as to max_w, so on a 4700 W inverter configured with max_w: 5000 a rail measured at 4750 W is one the command can never stand at — the guard could not fire, and the status page said armed anyway. It now measures the rail against the smaller number and the page names it, saying "below max_w because …" when the device is what capped it. (On an install that started, the two are equal: a max_w above the device rating is refused at startup. This matters when the rating could not be read at boot — see below.)

Neither half is usable alone. A bare "unchanged for N seconds" rule false-trips at the exact operating point this controller aims for — at a converged −10 W a 1 Wh register legitimately takes about six minutes to move — and a bare "at the rail" rule fires on any genuinely large load. Together they are safe in both directions, because a real railed condition means kilowatts still crossing the meter, and a kilowatt advances a 1 Wh register every 3.6 s.

It is an alarm and nothing else. It does not command 0 W, does not latch, and does not claim the actuator. A false trip here parks the battery; a false notification costs an alert. Turning it into a trip is a separate decision that needs data from a real install — and an answer to "what should the loop do instead", given that 0 W is itself an import of whatever the house is drawing.

When it fires you get a Home Assistant persistent notification (goodwe_meter_frozen, replaced rather than stacked on a repeat), an ERROR in the add-on log, an entry in the UI's event list, and a red banner on the status page. The loop keeps running. If the meter really is stuck, stop control from the add-on UI.

⚠️ Only meter_source: homewizard_local is covered. It is the only transport that reads the meter's own document; on ha_dsmr, mqtt_p1, ha_signed and off there is no signal to threshold and the status page says Stuck-meter check: off rather than pretending the guard is armed.

The same row appears for a second reason: a setpoint device that reports a range from which no command magnitude is available in both directions leaves no rail to measure at all, and a 0 W rail would be satisfied by every command. That is reported as off too, and the detector stays silent rather than degenerating into the bare "unchanged for N seconds" rule this design exists to avoid.

option default meaning
meter_freeze_s 300 Seconds of an unchanged measurement, while the command is at the rail, before the alarm

Why 300 s: over 2399 s of this house at the converged operating point (mean grid −14.2 W, 1194 polls, 735 of them repeating the previous reading) the largest unchanged_s any sample sees is 8.0 s, and the longest interval serving no distinct reading at all is 15.4 s. There is also a structural reason not to lower it much: at 300 s the slowest a healthy register can tick and still look unchanged is 12 W, which is inside the 15 W deadband — so the command there is parked at 0 W and the two halves of the rule cannot both be true. At the schema floor of 60 s that figure is 60 W and only the margin is left.

The circulating-power alarm

For installers: card SAFETY-31.

Only at two or more inverters. At the shipped roster of one this quantity is identically zero for every reading, no claim is ever filed, and the status page says n/a rather than pretending to watch it.

Two inverters can move a great deal of energy while the meter reads almost nothing. Inverter A charging at 3 kW while inverter B discharges at 3 kW nets ~0 W at P1: the site looks idle, and in reality the packs are being cycled through two round-trip conversion losses continuously, for no benefit.

Nothing else in this add-on can see it, and that is structural rather than an oversight. Every other guard is a consumer of the net figure at the grid connection — the sign-disagreement failsafe gates on abs(grid_w) > 300 W and that gate never opens at a netted zero; the capacity-peak guard and the tariff both bill grid exchange. Circulating power is precisely the component of per-inverter power that the sum destroys.

So the add-on computes a quantity none of them were computing:

circulating_w = Σ|Bᵢ| − |ΣBᵢ|

Zero when the units pull the same way. 2 × min(|B_a|, |B_b|) when they oppose — twice the power going round the loop, which is what both conversion stages are processing between them. It is published as sensor.goodwe_circulating_power, deliberately beside the grid figure: "grid 0 W, circulating 6000 W" is the whole fault in two dashboard rows, and no arithmetic on the grid figure alone can produce the second one.

It is derived from each inverter's own measured power, not from what the controller believed it commanded. That matters because the allocator already refuses to command opposite directions (ALLOC-01's sign-coherence gate), so a figure computed from the allocation record would read zero by construction and detect nothing. What this catches is circulation nobody commanded — a unit ignoring or lagging its share, a stale share still being executed, or an inverter doing something of its own accord.

option default meaning
circulation_w 700 Watts of measured opposition below which nothing is reported
circulation_hold_s 75 Seconds it must stand there continuously before the trip

Why 700 W and 75 s, and why neither is a round number. Both were read off 924 closed-loop runs on the two-master test rig in which nothing was faulty, swept across the whole range the schema accepts for gain and slew_w. A momentary opposition is legitimate: writes to the inverters are serialised, so on a reversing cycle one unit gets its new number up to a control cycle before its sibling, both slew through zero, and between the two crossings they genuinely oppose. The worst continuous legitimate opposition, by threshold, was 505 s at 100 W, 40 s at 250 W, 10 s at 300 W and 5.0 s — one control cycle — at 400 W and above. The knee is 400 W and it is the schema floor; below it, ordinary operation would hold the sensor over the line for minutes and no usable window could tell that from a fault. 700 W carries margin above the knee because the second inverter's ramp and lag are not yet measured. 75 s is 15× the worst legitimate episode, 2.5× the firmware's own 30 s link-veto window, and comfortably inside the 900 s quarter that bills the capacity tariff.

What happens when it trips, and what it honestly cannot do. The site is commanded 0 W on every inverter and latched there until you press start. That is not aimed at making a misbehaving inverter stop — if a unit has stopped listening, no command will reach it. It does two things that do not need its cooperation: it removes this controller as one of the two participants in the loop, and it makes anything still happening visible at the meter instead of cancelled at it, where the ordinary guards can see it. If the grid reading does not move after the trip, one inverter is no longer listening and the remaining actuator is a human at the isolator.

The alarm survives a Home Assistant restart. The latch is written to /data and the notification is re-raised on the first cycle after every start — persistent_notification lives only in memory, and an alarm that vanishes with a restart is how a real outage on this system went unseen for over six hours. Restarting does not clear the trip; starting control from the add-on UI does, and only after you have read why.

Energy price providers

A fetched price moves energy only when you switch arbitrage on. This release ships the five provider plugins, the canonical all-in format they emit, the config that selects one, and — new in OPT-04 — a price-arbitrage strategy that trades on them. With arbitrage_enabled: false, which is the shipped default, selecting a provider is what starts the fetch: the feed is read every 30 minutes, validated, and the day recorded in /data/price_history.json, which the Price history status row reports and the add-on log names at every refresh. No charging decision changes.

⚠️ That has been the documented behaviour since OPT-04 and was not the actual behaviour until TARIFF-10. The background refresh was started only when arbitrage_enabled was on — and it ships false — so an install with a provider configured fetched nothing at all, never created the store, and read Price history: empty for as long as it ran. If you configured a provider and saw an empty Price history row, that is what you were looking at. Trading is unchanged and is still arbitrage_enabled alone.

static_profile below needs no network at all, so its cheap window is computed on every cycle and reaches the maintenance cycle's grid top-up whether or not arbitrage is armed. ⚠️ And it is still the only source of that window. A provider-backed plan now exists on every configured site, but Cheap window and the maintenance cycle's grid top-up read static_profile and nothing else — a deliberate limit, recorded with what it would take to change in DECISIONS/TARIFF-10-fetching-is-not-trading.md.

option meaning
price_provider off (default) or one of the three plugins below
price_markup_eur_kwh Your supplier's margin, EUR/kWh. Added only when the chosen plugin's feed does not already contain it
price_taxes_eur_kwh Distribution, levies and VAT, EUR/kWh. Same rule. Defaults to 0.11736 — a real Flemish figure, and yours is probably different: see What price_taxes_eur_kwh is made of below
export_price_eur_kwh The flat injection price, EUR/kWh. May be negative — see below
price_export_formula The injection formula, if your contract has one. Same shape as price_formula, its own coefficients, its own vat_pct — see below
price_csv_path Where manual_csv reads its file
price_feed_url Override the plugin's default feed. Used exactly as written — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted
price_horizon_h How far ahead to ask for, hours
price_timeout_s Per-request timeout
static_profile A static day/night or fully-fixed tariff. Empty by default — see below
price_formula For custom_formula only: your own contract's two coefficients, off your own tariff card. Empty by default — see below

Which plugin sources what. This is the one thing to get right, because adding your markup to a price that already contains it makes every future arbitrage decision wrong by that amount, in a direction nothing reports.

plugin product the published figure contains the add-on adds
generic_dayahead day-ahead energy only (wholesale EPEX BE) markup and taxes
custom_formula day-ahead energy and your contract's markup — your card's Tariefformule line taxes
manual_csv day-ahead everything, all-in nothing

⚠️ Three plugins were withdrawn in TARIFF-07, and if your configuration names one of them, read this. engie_dynamic, totalenergies_dynamic and eneco_eddy are gone. They did not stop working — they never worked. Each pointed at an endpoint that does not exist and never did:

id its endpoint what that endpoint actually answers
engie_dynamic https://api.engie.be/dynamic/be 404, gateway error no Route matched with those values
totalenergies_dynamic https://api.totalenergies.be/prices/dynamic TLS failure — that hostname is not in the server's certificate
eneco_eddy https://api.eneco.be/eddy/prices 404, empty body

Every fetch any of them ever made failed, and a failed fetch falls back to the static tariff silently — so a site set to one of these has been running on static_profile, or on nothing at all, since the day it was configured, with nothing on the status page saying so. The add-on now refuses to start on one of these ids and tells you all of the above by name. That is deliberate: a silent fallback is what hid this for as long as it lasted.

There is no automatic migration and there cannot be one. These were retail tariffs of the form B x spot + A with per-contract coefficients that live behind your supplier's customer login; there is no public index to read them from. Any alias would have to invent the numbers your battery is priced against. (eneco_eddy was doubly wrong: "Eddy" is not an Eneco product at all — Eneco's dynamic product is Zon & Wind Dynamisch, and it is indexed to Belpex/EPEX day-ahead, not to imbalance.)

So what should a Belgian dynamic-tariff customer do today?

Honestly: generic_dayahead gets you close, and you should know exactly how close. It reads the wholesale EPEX BE day-ahead curve and adds your price_markup_eur_kwh and price_taxes_eur_kwh as flat scalars. Your real Belgian dynamic contract is a formula:

consumption:  price = B x spot + A          <- B and A are in YOUR contract

generic_dayahead cannot express that. Two gaps, and the second is the harder one — both are closed by custom_formula, which is what the rest of this section is about:

  • No multiplier. B is typically slightly above 1 (it scales the wholesale price before anything is added). A flat markup cannot scale anything, so the shape of your curve is right but its amplitude is not — the peaks and troughs the optimiser trades against are compressed relative to your bill.
  • No negative offset. A is frequently negative on injection and can be on consumption. price_markup_eur_kwh is float(0,1) in the schema — it cannot hold a negative number at all, so that contract shape is currently inexpressible rather than merely approximate.

What that means in practice: arbitrage decisions made on generic_dayahead are made against a curve that is correlated with your bill, not equal to it. For deciding which hours are cheap that is usually good enough; for deciding whether a cycle pays for itself — which is what cycle_cost_threshold_eur compares against — it is an approximation, and one that errs by however far B is from 1.

custom_formula is the answer to exactly this, and it shipped in TARIFF-15. You type your contract's two coefficients once, off your own tariff card, and the add-on evaluates B x index + A against the same EPEX BE day-ahead curve generic_dayahead reads. Both gaps above close: B is a real multiplier, and A may be negative. It is documented in full in Your own tariff formula (custom_formula) below.

So there are three honest options rather than two:

  1. custom_formula — your contract's own arithmetic, if you can read the two coefficients off your tariff card. This is the one to use.
  2. generic_dayahead with your markup and taxes set as well as you can — accepting the approximation above; or
  3. manual_csv, where you compute the all-in prices yourself from your own contract's formula and hand the add-on the answers. Nothing is approximated, but nothing is automatic either.

If you are on a fixed or day/night tariff — most Belgian households — neither of those applies: use static_profile and leave price_provider: off.

You cannot get this wrong by editing the markup: each plugin declares what its feed contains and the add-on adds only the rest. Set a markup under a plugin that already carries one and it is ignored, with a line in the log saying so — never applied twice.

That holds given a correct declaration, and it is worth knowing where the line is. What the add-on enforces mechanically is that a plugin cannot obtain the markup or apply it: the numbers are not reachable from plugin code, and a plugin whose code mentions them fails to load at all. What it cannot check is whether a plugin described its supplier's feed truthfully — that is a claim about somebody else's tariff, and no code can verify it. If you write a plugin, the sources declaration is the part a reviewer has to read.

Real-time / imbalance products still have a place in the machinery, but no plugin declares one. A plugin that declares PriceProduct.REALTIME is flagged high-volatility and gets widened arbitrage thresholds; that comes from the plugin's own declaration, never from its name. All three shipped plugins are day-ahead, so nothing exercises that path on a real install today — it is kept on purpose so the first genuine imbalance product does not have to rebuild it. See DECISIONS/TARIFF-07-withdrawing-three-plugins.md.

generic_dayahead and the shape of the public feed. It reads Fraunhofer ISE's Energy-Charts API (https://api.energy-charts.info/price?bzn=BE, no key and no registration), which publishes a columnar document — parallel unix_seconds and price arrays, in EUR/MWh, with the unit and the licence stated in the response. Two things follow that are worth knowing before you point price_feed_url somewhere else:

  • The timestamps are UTC instants. They are rendered at the Brussels offset that applies to each one, so the hour that happens twice every October is two points rather than one overwritten one.
  • Belgium moved to a 15-minute market time unit on 2025-10-01, and the feed followed. A published day is 96 points, not 24. Nothing in the add-on assumes either spacing, and nothing aggregates the quarters away.
  • The request names the days it wants, derived from the horizon on the Brussels calendar — ?bzn=BE&start=2026-09-09&end=2026-09-10, two inclusive local dates. Without that the feed answers a single day, and everything past today would be carried forward from yesterday's profile and flagged provisional — which §8.3 excludes from arbitrage, so tomorrow would never be tradeable at any hour. The calendar is Brussels rather than UTC because 22:30 UTC in summer is already tomorrow in Belgium.

What you get before the auction publishes. The day-ahead auction clears at about 13:00–14:00 CET. Ask for tomorrow before that and the feed returns fewer days than requested — that is normal, not a failure. The add-on keeps the series of what exists and fills the remaining hours from yesterday's profile, flagged provisional; arbitrage runs on the firm hours and ignores the flagged ones. So a dynamic site's plan is part firm and part carried every morning and fully firm once tomorrow is published, and neither state is an error.

⚠️ If you set price_feed_url, you keep the short horizon. The override is sent exactly as you wrote it and the add-on adds no start/end to it, because those are this feed's parameter names and an override may point somewhere with different ones — rewriting a URL you typed is how a request nobody intended gets made. The consequence is a WARNING in the log on every price refresh, naming the range that would otherwise have been requested. If you only wanted a different host and not a different horizon, the fix is to clear the option.

If the arrays ever come back different lengths, or the unit stops saying EUR/MWh, the fetch is refused outright rather than trusted in part: a price series missing its last hours is a series missing the evening peak, and it would be traded on as though it were complete. A refused fetch is the §11.4 row — static profile, provisional plan, no arbitrage — and the reason is in the log.

A refused fetch does not take the plan you already have. The last plan that actually came from the feed keeps standing until it is 90 minutes old (three missed refreshes), and only then does the §11.4 row above take over. The add-on logs a WARNING naming the failure, the fetch it is keeping, its age and the moment it retires; the plan's own age is not reset by the failed refresh, so a kept plan still expires on schedule and is never presented as a current one. One failed fetch is a blip, not a lost tariff.

Your own tariff formula (custom_formula)

What it is for. A Belgian dynamic contract prices energy as a formula — your supplier's tariff card prints it as Tariefformule — and the two coefficients in it belong to your contract. There is no machine-readable source for them anywhere: CREG's consumer tariff-card pages return 404, CREG Scan is a JavaScript wizard, the annual report lists suppliers without per-product coefficients, and VREG was unreachable. Every coefficient found in research came off a supplier's own PDF, one of them image-only. You are the only party who can see your own coefficients, so you type them, once.

Set price_provider: custom_formula and fill in price_formula with one line of JSON:

{"index": "epex_be", "resolution": "hourly",
 "factor": 0.102, "offset": 1.00,
 "units": "c_eur_per_kwh", "vat_pct": 6,
 "card": {"month": "2026-09", "index_eur_per_mwh": 133.9905,
          "expect_incl_vat": 15.55}}

The contract it expresses is:

price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct / 100)

expressed in units, and converted to EUR/kWh by the add-on. You type what your card prints, in the card's own units; every unit conversion and the VAT multiplication are the add-on's job, because those are exactly the two steps where a human silently loses a factor of 10, 100 or 1.06.

field what it is
index the reference series. epex_be — the EPEX BE day-ahead curve in EUR/MWh, the same feed generic_dayahead reads
resolution quarter_hourly (the 96 points the feed publishes) or hourly (each hour's quarters averaged into one point). Your card says which series your contract is written against
factor B — the multiplier on the index. Must be a number, and positive
offset A — the fixed part, in the same units. May be negative
units c_eur_per_kwh, eur_per_kwh or eur_per_mwh. Required, with no default
vat_pct the VAT percentage. 6 for Belgian residential electricity — not 0.06 and not 106. 0 is legitimate
card the month the card is dated, the reference index it assumed, and the price it prints. Required — see below
A worked example, from a real card

Eneco's September 2026 card prints the formula, the reference index it assumed and the resulting price. With factor: 0.102, offset: 1.00, vat_pct: 6 and the card's own index of 133.9905 EUR/MWh:

(0.102 x 133.9905 + 1.00) x 1.06 = 15.5471 c€/kWh     the card prints 15,55

ENGIE's card, in the same shape with its own numbers:

(0.100 x 61.96 + 0.90) x 1.06 = 7.5218 c€/kWh         the card prints 7,522

Both reproduce to the last digit the card prints, through the shipped code — test_price.py section 15a runs exactly these two.

⚠️ The card block is required, and it is the safety mechanism

Because your card prints the index it assumed and the price that comes out, the add-on can check your coefficients against your supplier's own worked example. At startup it recomputes that printed price from the numbers you typed and refuses to start unless the two agree to within 0.01 — naming the discrepancy, the ratio, and the likely causes.

That catches, in one check: VAT left out, VAT applied twice, a transposed digit, and a unit mismatch between your coefficients and the price you typed beside them (a factor of 10, 100 or 1000).

And it catches the expensive one. Your card's formula line is the energy component only. Eneco tabulates network tariffs in one table and levies in a second; ENGIE states that the formula excludes network, distribution, transport, grid levies and excise. The add-on therefore declares that custom_formula supplies energy and markup, and adds price_taxes_eur_kwh on top — once. If you fold your network charges into offset, you are billed for them twice, and nothing on the status page would ever show it. It cannot happen: ~12 c€/kWh of network charge moves the computed 15.55 to about 27, and the add-on refuses to start and says so.

⚠️ And the limit of that check, in the same breath as the claim: it grades your coefficients against your card, not your card against reality. Two numbers copied off the wrong product — or off a card your contract has since rolled over from — agree with each other perfectly and pass. Nothing can catch that. What is mechanised is that your coefficients and your card's printed price agree; whether the card is the right card is yours to know. The add-on warns (and does not refuse) when card.month is more than about six months old, because ENGIE freezes coefficients for a contract's duration and an old card is often exactly right.

One more check runs beside it: the card's own point must land inside the range a Belgian retail tariff can occupy (0.02–1.00 EUR/kWh). That exists because a unit error applied to both sides at once cancels out of the comparison above and cannot cancel out of this one. It bounds the card's own point, not every hour — a live hour may legitimately fall outside it, and a negative day-ahead hour, which is the best charging opportunity of the year, is never refused.

⚠️ Fixed coefficients. No expression is evaluated, ever

price_formula takes numbers. There is no field that accepts an expression, and an unrecognised field is refused rather than ignored, so typing "expr": "..." tells you it does not exist instead of quietly pricing you on the other fields.

Two reasons, and the second is the decisive one:

  • This string is settable over HTTP — the add-on's own ingress configuration page posts it to the Supervisor — so evaluating it would be remote code execution inside a root container that holds a Supervisor token and drives an inverter. (eval with an emptied __builtins__ is not a sandbox.)
  • An expression could not be graded against anything. Two coefficients can be checked against your card's printed price; an arbitrary expression cannot, so it would make the invisible-wrong-coefficient problem strictly worse — which is the exact failure this provider exists to prevent.

All 24 surveyed Belgian products, both injection legs and aWATTar AT fit B x index + A, so an expression would buy nothing the market needs. If a future contract needs a different shape, it gets a named field (clamp_lo, factor_negative, offset_bands) that can be refused on its own. Never a grammar.

What you see at startup

The first time the feed answers, the add-on logs a preview of the next 24 hours as index EUR/MWh -> all-in EUR/kWh, followed by the minimum, median, maximum and the day's spread. It is there so that a formula off by a factor of 1000 is visible in one line rather than in a month of bills:

custom_formula preview - the next 24 h of YOUR tariff, index EUR/MWh -> all-in
EUR/kWh, computed as (0.102 x index + 1) x (1 + 6/100) c_eur_per_kwh, converted
to EUR/kWh and with price_taxes_eur_kwh added by the framework:
    2026-09-10T00:00:00+02:00    216.080 ->   0.30423
    2026-09-10T00:15:00+02:00    216.080 ->   0.30423
    ... 72 further point(s) not listed; the figures below cover ALL 96
  min 0.17277 / median 0.28031 / max 0.67499 EUR/kWh, spread 0.50222 EUR/kWh
  over 96 point(s). ⚠️ If those are a thousand times what your bill says,
  `units` is wrong; if the spread is 0, `factor` is.

(That transcript is the shipped code run against the committed scenarios/energy-charts-be-price-2026-09-10-quarterhourly.json capture with the default markup and taxes, not an illustration.)

What it does not do

The injection leg is a separate card, and it has its own option: price_export_formula (TARIFF-16). Eneco uses 0,102 on consumption and 0,100 on injection, and injection is VAT-exempt where consumption is at 6 %, so it is a second per-contract formula rather than a second copy of this one — the same parser, its own coefficients. export_price_eur_kwh remains the flat injection price for a site that is paid one, and no strategy reads either; see Injection is priced separately above.

manual_csv — the offline fallback

It needs no internet, no supplier account and no HTTP stack at all, and it is the supported way to run this add-on price-aware on a site with no outbound connectivity. Write a file and point price_csv_path at it:

start,eur_per_kwh,provisional
2026-01-15T00:00:00+01:00,0.1834,false
2026-01-15T01:00:00+01:00,-0.0210,true
2026-01-15T02:00:00+01:00,0.3115,false
  • start must carry its UTC offset (+01:00 in winter, +02:00 in summer). A bare 2026-01-15 00:00 is refused, because for one hour every October a local wall clock names two different moments.
  • eur_per_kwh is all-in — energy plus markup plus taxes. This plugin has no way to know what your numbers already include, so it assumes everything and adds nothing.
  • Negative prices are valid and are kept. They are the best charging hours of the year.
  • provisional is optional; omit the column and every hour is treated as settled.

When there are no prices at all — provider off, feed unreachable, CSV missing — the add-on does not guess. It reports no price data, which is a distinct state from "prices, all of them zero": the plan is marked provisional and arbitrage is disabled, per the degradation matrix. It never falls back to a price of zero, which would read as free electricity.

The hours the feed has not published yet

For installers: card FCST-04.

A day-ahead auction publishes tomorrow's prices once a day, at 14–16 h CET. Before that, the feed is serving today and nothing else — so a price_horizon_h of 48 asks for 48 hours and gets back the ten or fifteen that are left in the day. Until FCST-04 those other hours were simply absent from the plan: not priced badly, not flagged, just missing, and nothing said so.

Now the add-on keeps the published prices of the last few days in /data/price_history.json and fills an unpublished hour of the horizon with yesterday's price for the same Brussels wall-clock hour, flagged provisional: true. Two things follow, and the second one matters more:

  • The plan says it is guessing — provisional: true on every carried hour, which is what the optimiser reads. Before, a half-length plan looked completely settled.
  • Nothing may trade on a carried hour. §8.3 forbids acting on a provisional price, so arbitrage drops those hours from the trade set entirely — it needs a buy hour and a sell hour that were both published. Carrying a price forward widens what the optimiser can see; it never widens what it may do.

"An unpublished hour", not "every unpublished hour" — the fill can only carry an hour yesterday actually published. If yesterday's record is itself partial (a first day, an outage, a feed that only ever serves the rest of the day), the local hours it never held are left out of the series: §11.4 applies to those hours, and asking the plan for one raises rather than answering. An hour with no price is not a free hour, and the fill does not invent a neighbour, a mean or a zero for it.

A quarter-hourly feed keeps all four of its prints. Belgian day-ahead settles per quarter — the 15-minute MTU took effect on 2025-10-01, so generic_dayahead reads 96 points a day — and the fill decides which whole hours need carrying, never what a published hour may contain. Every point the provider published inside the horizon is passed through untouched; a carried point is added only for a whole hour the feed did not print at all.

What you can actually see on the status page. Price history (below) says what is held. Arbitrage carries the strategy's own sentence, which names the firm/provisional split whenever that split is what refused a trade. And Tariff on the status page reads provisional through the morning, because since TARIFF-11 that row carries the provider plan itself:

Tariff: No fixed tariff set. Price plan from 412 s ago, for 48 hours ahead:
        day-ahead prices from the price feed, 60 prices over 33 hours,
        36 published and 24 estimated, one every 15 minutes or every hour

⚠️ The two halves of that row are not computed at the same moment, and the row says so. Everything before "Price plan" is the static-profile or Home Assistant price-entity sentence, regenerated on the 30 s tick from options alone — no network. Everything after it is the background refresh's resolution, which runs every 1800 s and is served for up to 5400 s before it is retired; Price plan from N s ago is the age of the fetch that produced it and is the only figure in the row that tells you whether the feed is still answering. On a site with a provider configured and no usable plan the row says that too, rather than falling silently back to the static half.

Read the plan half as five separate facts: which product the feed publishes and whether it came from the feed or from your fixed tariff, how many prices it carries and how many whole hours they cover, how many are published (firm, for their own interval) against estimated (carried from yesterday under §8.3), and how far apart they are — "one every 15 minutes or every hour" is the normal mixed steady state, quarter-hourly firm points beside hourly carried ones. The plan is provisional exactly when some prices are estimated, so the estimated count says it.

⚠️ Points and hours are two different numbers and this row used to print one of them under the other's name. words was f"… {len(points)} h", so a 33-hour plan of quarter-hourly points logged dynamic_dayahead from generic_dayahead, 132 h. It is the same conflation the Price history row carried before FCST-04 and the same one at() carried before TARIFF-14, and it is not a constant factor: measured over a 40-hour soak the overstatement ranged 1.79× to 5.33×, because the carried points are hourly while the firm ones are quarter-hourly, so the ratio moves with the mix as the day goes on. The same sentence is in the add-on log at every refresh, alongside §8.3: 33 of 48 hours carried forward from yesterday's profile and flagged provisional; the feed published 60 points over the other 15 hours.

The §8.3 refusal an operator reads when a trade is declined counts in the same two units — §8.3: only 1 of 96 points carry a published price - 1 of 24 hours — where it used to say only 1 of 96 hours about a 24-hour day.

The plan is also now cut to the horizon you asked for, which means hours that have already elapsed are no longer in it. That is a real change to arbitrage: a cheap night that has already gone used to sit in the series as the cheapest hour of the day, and a cycle can never buy in an hour that is past — so the trade was quietly never taken. The spread is now taken over hours still ahead.

A new status row, Price history, says which days are held, how many price points each holds and how many whole hours those cover — 2026-01-13 (96 points over 24 h). ⚠️ Those are two different numbers and the row used to print one of them under the other's name: it counted points and labelled them h, so a quarter-hourly feed read 96 h for a 24-hour day. The figure that says whether tomorrow morning's fill has a whole day to carry is the hour one. If it reads empty or unavailable, nothing is carried, the horizon is as short as the feed makes it, and every other guarantee is unchanged — a cache that cannot answer is not allowed to be a controller that cannot regulate. The store is rebuilt by the next successful fetch; on a brand-new install the first fill is possible the day after the first refresh.

The same applies to a file that is partly broken: a day record this build cannot read is dropped, the row says how many were dropped and why, and the rest of the file still loads. And a store that cannot persist records nothing — not even in memory. That covers a missing /data, and it also covers a /data that is there and cannot be written: a full volume, a read-only remount. An in-memory yesterday would answer for one run and vanish on the next restart, which is a cache that forgets without saying so; the row naming the reason and no prices carried are the honest pair.

⚠️ What a failed write does not do is throw away what is already in the file. A volume that fills at 14:00 leaves the yesterday written at 13:30 still answering, because that record survives a restart — the store stops adding to it, nothing more. And it never stops the add-on: a write that cannot complete is logged and reported on the status row, never raised. A cache that cannot answer is not allowed to be a controller that cannot regulate, and that applies to the volume being full exactly as it applies to the volume being absent.

Injection is priced separately

There are two ways to say what you are paid for an exported kWh, and which one you want depends on your contract:

your contract what to set
a flat injection price export_price_eur_kwh — one number, EUR/kWh. This is the default and what every existing install uses
a price that moves with the market price_export_formula — your card's injection line as factor × index + offset, in exactly the shape price_formula uses for consumption

Negative values are accepted on both — you can be charged to export at midday in summer, and the add-on says so rather than flooring it.

⚠️ What the add-on does not do with an injection price

No strategy in this add-on reads one. Not the scalar, not the formula. arbitrage.py prices the sell side as displaced import — both hours it compares are all-in consumption prices — and its own comment gives the reason: §9.3's near-zero Belgian injection price is exactly why the economically sound trade on this market is charge cheap to displace expensive import, not charge cheap to export dear. So setting price_export_formula changes no decision the add-on takes today. It changes what you are shown at startup, and it is the shape any future export-aware strategy would read.

That is the argument for doing it now rather than later: OPT-06 and any export logic would otherwise inherit a scalar that cannot represent the market, and the 6% VAT asymmetry between the two legs would be invisible in the arithmetic. test_price.py 17h parses every module under app/ and goes red the day something calls an injection reader, so this paragraph is graded rather than attested.

Why a scalar was not enough

Belgian suppliers publish the injection leg as a formula, on the same card as the consumption one. The reason given here until TARIFF-07 — "no supported supplier publishes a dynamic injection tariff" — was false, and what they publish has four properties one number cannot hold:

Eneco Zon & Wind Dynamisch   0,1 x BELPEX-H - 1,188   c/kWh
    "Het injectietarief is dynamisch en kan per uur wijzigen"
    - identical on the January and the September 2026 cards
ENGIE Dynamic                (B x eSpot_15) - A
Aspiravi (non-dynamic)       0,07 x Belpex - 2
  1. Its own factor. Eneco uses 0,102 on consumption and 0,100 on injection; Aspiravi 0,07 against about 0,10 on its consumption leg. It is not the consumption B with a sign flip.
  2. A negative offset — − 1,188, − 2. Note that price_markup_eur_kwh: float(0,1) cannot express a negative markup at all, so the offset has no home on the consumption side either.
  3. VAT-exempt. "Injectietarieven zijn vrijgesteld van btw", while consumption is at 6%. vat_pct is per leg, so that six-point asymmetry is expressible; a single scalar carries no VAT field at all.
  4. It goes negative, with no floor. Eneco's injection price is below zero for any BELPEX-H under 11,88 EUR/MWh — and a PV household exports precisely in the hours most likely to be negative. You can be charged to export.

price_export_formula, field by field

Same grammar as price_formula — literally the same parser, with its option name as an argument, so there is no second unit table and no second card rule to diverge from the first. Eneco's published injection line, typed out:

{"index":"epex_be","resolution":"hourly","factor":0.1,"offset":-1.188,
 "units":"c_eur_per_kwh","vat_pct":0}

⚠️ vat_pct is 0 here and 6 on the consumption leg. That is the one field people copy across from the other formula and get wrong.

⚠️ The card block is optional on this leg and required on the other, and that is a statement about the cards rather than a relaxed rule. price_formula can demand one because a Belgian card prints the consumption formula, the reference index it assumed and the resulting price — so the add-on can recompute that price and refuse to start unless your numbers reproduce it. Eneco's injection formula is printed with no price beside it. There is nothing to grade against on that card. Requiring a block would make the market's own published line inexpressible; inventing the estimate would grade your coefficients against arithmetic done with your coefficients, which is not a check. So:

  • if your card prints an injection estimate, fill the block in and it is graded at the same tolerance as the consumption leg — the add-on refuses to start if your numbers do not reproduce it;
  • if it does not, the add-on says so at startup — "its coefficients are UNGRADED" — and names what it checked instead.

What it checks instead, always, on both paths:

  • pass-through: factor, converted to a share of the wholesale price, must be between 0.10× and 10×. Eneco's injection leg is 1.00× and Aspiravi's 0.70×; a coefficient typed a hundred times out reads 0.01× or 100×.
  • offset magnitude: |offset| must be at most 0.20 EUR/kWh. The published ones are −0.01188 and −0.02.

Both bounds are on the coefficients, not on the price, which is why they can bound a leg whose price legitimately goes below zero. price_formula's plausibility band is deliberately not reused here: its floor is +0.02 EUR/kWh of energy, and applying that to injection would refuse the real market in exactly the hours where knowing the number matters most.

⚠️ And the gap those bounds leave, with its size. They bound the shape of the coefficients, never their truth. With no printed estimate, a factor of 0.070 where your contract says 0.100 — 30% of every export payment — passes both, because 0.70× is a pass-through somebody really publishes. Only a printed estimate can catch a wrong-contract pair, and on Eneco's card there is not one. That is why the startup line says the leg is ungraded instead of implying it was checked.

The old Home Assistant price sensors

price_now_entity and price_avg_entity still work and are unchanged: the add-on compares them and uses the result as a cheap-window hint for the maintenance charge. They are not compatible with price_provider, and not with static_profile either — configure two of them and the add-on refuses to start, naming the conflict, because the add-on reads exactly one and the loser is silent. Use one.

The three buckets — which price components can move a decision

For installers: card TARIFF-17.

Read this before adding a price component of any kind. The optimiser reads exactly two quantities off a price series, and nothing else:

  • the spread, max − min over the firm points, weighed against cycle_cost_threshold_eur; and
  • which hour is cheapest, min(firm, key=…) — the hour a battery would buy in.

Every component of a Belgian bill reaches those two through one of three buckets, and which bucket it is in is a property of its shape, not of its size:

bucket what it looks like reaches the decision?
LEVEL additive, identical every hour No — provably. An additive constant cancels exactly in max − min, and adding it to every point leaves the argmin where it was
SPREAD multiplicative on the spot index Yes. It scales the spread against a fixed cycle_cost_threshold_eur
ORDER varies by hour, block or day-type Yes, and worst. It changes which hour is cheapest

Measured on the real arithmetic (test_price.py section 16b, printed on every run): with a flat taxes figure of 0.00, 0.0739, 0.11736 (the shipped default), 0.1198, 0.1327 or 1.00 EUR/kWh, the same 24-hour feed is cheapest at 13:00 in all six cases and its spread is 0.1500 EUR/kWh in all six cases. That is the LEVEL bucket doing nothing, and it is not a rounding artefact — it is arithmetic.

Three consequences worth having in mind:

  1. price_taxes_eur_kwh is not a wrong number and never was. It sits in the LEVEL bucket and cannot distort either quantity. What it also cannot do is express a component that varies by hour — which is why price_taxes_profile exists. The defect TARIFF-17 closed was a missing capability, not a bad constant.
  2. custom_formula's factor is a SPREAD component, and its offset is LEVEL. The contract price is (factor × index + offset) × (1 + VAT), so the spread over a feed is factor × (1 + VAT) × (max index − min index) — the offset drops out entirely, and the spread is proportional to factor. Moving factor from 0.100 to 0.10812 therefore moves the spread by exactly +8.1 % on any feed whatever. A coefficient typed one digit wrong is not a small pricing error; it is a different trading threshold. The VAT rate is multiplicative on the whole expression, so it is SPREAD too.
  3. A check that grades the spread is grading the wrong bucket. An implementation that reads an hour-varying table once and adds the same number to every point produces a series of the right magnitude and the wrong shape — the LEVEL bucket wearing a period table. The spread would look plausible; the buy hour would be wrong. So the regression check for an ORDER-bucket component asserts which hour is cheapest, not how big the spread is (test_price.py 16c and 16d).

⚠️ Where the buckets are today. price_markup_eur_kwh, price_taxes_eur_kwh and custom_formula's offset are LEVEL. custom_formula's factor and its VAT rate are SPREAD. price_taxes_profile is the only ORDER component the add-on has. Anything added next belongs in one of the three, and saying which is part of adding it.

What price_taxes_eur_kwh is made of, and why yours is different

For installers: card TARIFF-18.

It ships at 0.11736 EUR/kWh. That is the all-in non-energy part of a Flemish residential bill — everything on your invoice that is not the energy itself — read off the Eneco residential card for Flanders, September 2026, including 6 % VAT:

component c€/kWh
Fluvius distribution (Antwerpen) 5,35
Bijzondere accijns, "Alle verbruik" — no volume bands 4,876
Bijdrage groene stroom en WKK Vlaanderen 1,51
Energiebijdrage 0
total 11,736 c€/kWh = 0.11736 €/kWh

The rows are in the code, in app/price.py's FLEMISH_TAXES_COMPONENTS_EUR_KWH, and test_price.py section 1b re-adds them and refuses a default that is not their sum. It replaces a shipped 0.06, which was roughly half the true figure and had no recorded provenance at all.

⚠️ energiebijdrage is zero and that is not a mistake. "Vanaf 1 augustus 2026 bedraagt de energiebijdrage 0 EUR/MWh" — it was merged into the bijzondere accijns. If you are re-deriving this total from a guide written before that date you will find a separate energiebijdrage line; adding it would count it twice. It is carried as a zero row rather than left out for exactly that reason.

⚠️ THIS IS A PER-DSO FIGURE AND YOURS IS PROBABLY NOT 0.11736. Only the distribution row moves. Across the eight Fluvius DSOs it runs 5,28 c€/kWh (Midden-Vlaanderen) to 6,70 (West), so the Flemish all-in total runs about 0.117 to 0.131 €/kWh — a 1,42 c€/kWh spread, which on 4 000 kWh a year is about €57. The other two rows are Flanders-wide and do not move. Look up your own distribution row, add the same accijns and groenestroombijdrage, and type the total.

⚠️ A WALLOON OR BRUSSELS SITE SHOULD NOT USE THIS SCALAR AT ALL. Their distribution tariff has a day and a night band — Enkelvoudig | Dag | Nacht | Uitsl. nacht on the same card, where Fluvius prints a single rate — and a flat figure flattens exactly the component that is anti-correlated with what the battery wants to do. Use price_taxes_profile (the next section); it is the only ORDER-bucket component the add-on has.

⚠️ THIS IS A DISPLAY AND GUARD FIX. IT DOES NOT IMPROVE ARBITRAGE, AND IT CANNOT. price_taxes_eur_kwh is a LEVEL component — added identically to every hour — so it cancels exactly in max − min and leaves the argmin where it was. It does not change which hour is cheapest, it does not change the spread, and it does not make any trade happen that did not happen before. See The three buckets above, where six flat scalars spanning and exceeding the whole per-DSO range give the identical cheapest hour and the identical spread to the last digit. What raising it does change is exactly two things:

  • every figure an operator reads — the all-in prices on the status page, custom_formula's startup preview and the recorded price history. At half the true non-energy component they made the add-on look considerably better than it is.
  • §17's negative-price guard (arbitrage.py), which is the one rung that tests an absolute all-in price rather than a difference. The guard fires when spot < −(markup + taxes), so its boundary moved from −0.09000 to −0.14736 €/kWh of spot. A spot hour at −0.11868 €/kWh used to read −0.02868 €/kWh all-in and the discharge was refused as paid-for disposal; at the real tax level it reads +0.02868 and the discharge is allowed, because the operator really does pay those levies and that hour really was profitable (test_arbitrage.py section 2c drives both, and pins the boundary from below as well).

⚠️ Which is why the default sits near the LOW end of the per-DSO band rather than in the middle of it. The two error directions are not symmetric. Set too high, the figure carries a genuinely negative hour up above zero and the add-on exports into paid-for disposal — §17's own regression case. Set too low, it only declines a discharge that would have paid. Where one default has to stand in for eight DSOs, it belongs at the bottom.

If you set this yourself, nothing above affects you. A site with its own price_taxes_eur_kwh keeps it; the default only answers where the option is absent from the stored options blob.

Levies and VAT by time of day (price_taxes_profile)

Leave this empty unless your network tariff really does have a day and a night rate, which in Flanders it does not. Fluvius distribution is a single rate: its row on the tariff card reads Normaal | Uitsl. nacht, and exclusive night is a separate physical circuit (a night-storage heater on its own meter), not the night register of a dual-rate meter. For a Flemish site the flat price_taxes_eur_kwh is correct, and this option changes nothing.

Wallonia and Brussels are different, and the same card proves it. The Walloon DSOs and Sibelga get four columns — Enkelvoudig | Dag | Nacht | Uitsl. nacht — and the day/night gap is real money (c€/kWh):

DSO enkelvoudig dag nacht gap
ORES 11,98 13,27 7,39 5,88
AIESH 13,64 15,17 8,21 6,96
Tecteo RESA 11,06 12,20 7,01 5,19
Sibelga (Brussels) 9,39 9,39 7,10 2,29
ORES IMPACT (2026) — 16,578 5,090 11,49

⚠️ The bands are anti-correlated with what the battery wants to do. The cheap network hours are the cheap spot hours. So flattening them does not merely shift the curve — it systematically understates the true spread, and the add-on declines cycles that are genuinely profitable. On ORES's bands over one ordinary winter weekday the measured effect is: cheapest hour 13:00 → 03:00, spread 0.1500 → 0.1788 EUR/kWh.

It is written in exactly the same grammar as static_profile — the same periods, the same day selectors, the same rule that every day type is covered once with no gap and no overlap, the same DST handling. It is literally the same parser; there is no second tiling rule, because a second one would be a second set of 23- and 25-hour-day bugs.

{"periods": [
  {"name":"dag",  "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.1327},
  {"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.0739},
  {"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.0739}]}
  • ⚠️ eur_per_kwh here is the LEVIES ALONE, not an all-in price. It is the non-energy part of the bill for that band, and the add-on adds it to whatever your price source publishes — exactly as it adds the flat figure. This is the one place the two options differ in meaning while sharing a grammar, and the refusal you get for leaving the number out says which one is wanted.
  • ⚠️ The card prints cents. This option takes EUR. ORES prints 13,27; type 0.1327. A whole card typed in cents is refused at startup, naming both bands and the factor — including the cards (Sibelga's 9,39 / 7,10) whose figures are small enough to slip past the ±10 EUR/kWh bound the parser holds every price to.
  • ⚠️ Set this or price_taxes_eur_kwh, not both. The table wins when it is set, so a scalar left at a non-default value beside it would be a number you typed that decides nothing. That is refused at startup rather than silently resolved; leave price_taxes_eur_kwh at its shipped 0.11736 when you fill this in. ⚠️ A scalar still sitting on the superseded 0.06 is also accepted, and deliberately so — TARIFF-18 moved that default, and your stored options blob carries the old number whether you typed it or not, so refusing it would have stopped working Walloon and Brussels sites on an upgrade over a figure the table overrides anyway.
  • A malformed table is refused at startup, not at the next fetch. It is read on the ~30 s planning path, and a table that first fails at 03:00 is a site that stops pricing at 03:00 — which §11.4 turns into a quiet fallback that looks exactly like a healthy fixed-tariff day.
  • It applies to the components the framework adds: generic_dayahead (which declares {ENERGY}) and custom_formula (which declares {ENERGY, MARKUP}) both get it. manual_csv publishes an all-in figure and is untouched — the table is routed through the same capabilities().adds rule as the scalar, not bolted on.
  • It does not apply to static_profile, whose prices are already all-in, nor to export_price_eur_kwh or price_export_formula. Those are the injection leg, and nothing is added to what you are paid for export: price_taxes_eur_kwh is a consumption component.

Static day/night and fully-fixed tariffs (static_profile)

Most Belgian households are not on a dynamic contract, and an EMS that only works under hourly pricing is an EMS for a minority. static_profile is how a two-period day/night tariff or a single fixed price becomes a first-class configuration rather than "turn the price feature off and hope".

On the add-on page, choose Other time windows under Settings, Tariff and prices, or in Setup step 4. Add one row per time window, with its start, end, days and price. The page names any overlap, gap or empty price before you can save. You only need the JSON below for days the page cannot choose, such as Monday and Wednesday only.

It is one option, holding a JSON object, on one line:

{"periods": [
  {"name":"day",   "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
  {"name":"night", "from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
  {"name":"night", "from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}

A fully fixed tariff is one period covering the whole week:

{"periods": [
  {"name":"fixed","from":"00:00","to":"24:00","days":"all","eur_per_kwh":0.31}]}
  • eur_per_kwh is all-in — energy plus your supplier's markup plus taxes — exactly like manual_csv. price_markup_eur_kwh and price_taxes_eur_kwh are not added to it.
  • days takes mon…sun, ranges (mon-fri, fri-mon), comma lists (sat,sun) and the words weekday, weekend, all.
  • Times are local Belgian time and windows are half-open: 07:00–22:00 includes 07:00 and excludes 22:00. A window may cross midnight.
  • The clock changes twice a year and the add-on handles it. It generates prices against absolute instants, so a spring-forward day is 23 hours and an autumn-back day is 25.

⚠️ A window belongs to the day the clock reads, not the day it started. 22:00–07:00 on mon-fri covers Friday 23:00 but not Saturday 03:00 — Saturday 03:00 is Saturday's. That is why the example above has a third row for the weekend. Leave it out and the add-on refuses to start, naming the uncovered hours.

Every day type must be covered exactly once. The periods have to tile all seven days with no gap and no overlap, and the add-on checks that at startup. A gap is refused rather than filled in, because a made-up price is one the optimiser would trade on; an overlap is refused because otherwise one of the two prices silently wins on the order you typed them in.

What it changes today. The add-on works out which window you are in and whether that window is cheap — strictly below the tariff's seven-day average. The Web UI shows both on a new Tariff row, and the maintenance cycle's cheap-window grid top-up uses it, which is exactly the "shift load into the cheap window" behaviour a day/night tariff asks for. Nothing else moves energy on a price yet.

Under a fully fixed tariff every hour equals the average, so there is no cheap window by construction and the add-on runs self-consumption only — which is the correct behaviour for that product, not a limitation. Peak shaving, the capacity-tariff tracker, the reserve floor and the maintenance schedule are all unaffected: the capacity tariff is a network cost charged whatever your energy contract is.

Recommended even on a dynamic contract. When a price feed is unreachable the add-on falls back to your static profile, marks the plan provisional and disables arbitrage. With static_profile empty there is nothing to fall back to, and the site simply has no tariff until the feed returns. It is not required — the add-on warns rather than refusing — but filling it in is what makes that fallback work.

Price arbitrage (arbitrage_enabled)

§10.1 priority 3: charge in the cheapest published hour, discharge in the dearest, and only when the spread covers what the round trip costs. Off by default — this is the first strategy in the add-on that commands the battery against the meter on purpose, so it is armed deliberately or not at all.

option meaning
arbitrage_enabled false by default. Arms the strategy
cycle_cost_threshold_eur What one full round trip costs per kWh — pack wear plus conversion losses. A spread below this is not traded
arbitrage_soc_floor The reserve arbitrage may not spend, %
arbitrage_soc_ceiling The headroom it may not overfill, %

What it needs, and what the add-on refuses. Arbitrage requires a price source (price_provider or static_profile) and a P1 meter (meter_source). Without a price source there is nothing to tell cheap from expensive; without P1 the capacity-tariff peak guard never runs, and §9.1 puts peak shaving above arbitrage — a charge that nothing measures is exactly what that guard exists to prevent. Both combinations are refused at startup rather than left looking armed.

Peak shaving always wins. The strategy cannot obtain a spread at all without first answering whether the peak guard is holding, and even if it could, the guard's floor is filed at a higher arbiter priority and clamps an arbitrage charge. Discharging is never capped by it — only offtake is billed.

It never exports into a negative price (§17). A negative hour is the best possible hour to charge in and that is still allowed; selling into one gives energy away and pays for the privilege.

At the shipped threshold a static day/night site does not trade. The realistic Belgian day/night spread is about 0.08 EUR/kWh and the default threshold is 0.10, so the honest answer on such a site is "no opportunity", and the status page says so with the numbers. Whether 0.10 is the right default is a product decision, not a bug: lower it only if you know what a cycle costs your pack.

The prices are fetched in the background, never on the control tick — a supplier's TLS timeout on that path would be a supplier able to push the add-on towards the firmware's 30 s failsafe. The plan refreshes every 30 minutes and retires itself if the refresh stops answering, at which point arbitrage stops with it.

⚠️ That refresh is not gated on this switch (TARIFF-10). It runs when a price_provider is configured or when arbitrage is armed, so turning arbitrage off does not turn the price feed off — and a site with no provider that arms arbitrage on a static_profile keeps the plan it has always had. The only thing that decides whether a request leaves the machine is price_provider: with it off, the refresh resolves the static profile in memory and opens no connection.

The Arbitrage status row says which of three things is true — trading is off, trading is on and this cycle reached no decision, or a decision was reached — and it stamps every decision with its age. Until TARIFF-11 it said one of two, because the first two rendered identically. They have three different remedies:

the row opens with what is true what to do
off — "arbitrage_enabled is false, so nothing trades" Trading is not armed. Nothing will happen, and nothing is wrong Arm it, if you meant to
ON (arbitrage_enabled), but NO DECISION has been reached yet Trading is armed, and no cycle has run as far as the arbitrage step. The row then names the control loop's own reason, usually inputs missing Fix the input the loop names
decided N s ago: … The strategy answered this cycle, and N is how old that answer is Read the sentence that follows — it is the strategy's own
no decision this cycle (reason); the last one was taken N s ago: … Trading is armed, a decision stands from an earlier cycle, and this cycle stopped before reaching the step As row 2 — the standing decision is history, not a verdict on now

A fifth rendering — "…no decision this cycle …, and none stands from an earlier one" — covers the case where trading was armed after a cycle had already run without it.

⚠️ The second row used to render exactly like the first one, naming arbitrage_enabled on a site where arbitrage_enabled is true. A cycle with a missing input returns after roughly stale_input_s (15 s by default) without reaching the arbitrage step at all, so on an armed site the row reported off at startup and, once one decision had been taken, went on showing that decision for up to 15 s after the site stopped deciding anything. The age on the third row is printed always, including when it is zero: a row that reports staleness only once it is stale cannot be told from one that never reports it.

Site

option meaning
estop_fitted Whether the RS485 e-stop is installed. Drives the warning banner
log_level trace/debug/info/warning/error

Notifications

The add-on tells you about a problem with a Home Assistant notification. Each one has a short title, one to three sentences, and a Details link to this page. The link opens the add-on's Documentation tab. Look for the section with the same name as the notification title below. A repeat of the same problem replaces the notification rather than adding a new one.

In shadow mode the add-on makes every decision but sends nothing: every inverter stays at 0 W. Notifications then say what the add-on would have sent.

Battery is not keeping up

The battery gives less power than the add-on asks for. This is normal for a short time, for example just after a large load switches on or the sun comes out. It is not a fault, and control keeps running. If it keeps coming back, check that the battery is switched on, and that it is not empty (when it should give power) or full (when it should charge). During the monthly maintenance or while the peak limit is guarded, it only shows when the battery gives clearly less than it was told. The status page counts how many times it happened.

Power request too high

The add-on worked out a power request above what is allowed, and cut it back before sending it. The notification shows what was asked and what was sent, and which limit cut it: the “Inverter rating” setting, or the inverter's own limit. In shadow mode it shows what would have been sent. Control keeps running. This should never happen, so it is a software fault: please send the notification and the add-on log to your installer.

Load too high for battery

Your home uses more power than the battery can supply, so this 15-minute period will go over your peak limit and raise this month's peak. The battery is already giving all it can. This is not a fault. Switch off large loads, such as an oven, a car charger or a heat pump, for the next few minutes. In shadow mode the battery gives nothing, so this can happen with a much smaller load.

Battery stopped limiting the peak

The add-on stopped using the battery to keep your peak down, so this 15-minute period will go over your peak limit and raise this month's peak. Switch off large loads for the next few minutes. The notification says why, and what to do:

  • The battery is at its peak reserve. The “Peak reserve” setting keeps that much charge back from peak limiting. Lower it if you accept a lower battery level.
  • The battery level cannot be read. Check the “Battery level entity” setting and that sensor in Home Assistant.
  • A battery cut-out level is not known. Fill in “Battery cut-out level” for each inverter in the settings.

With several inverters, a battery at its peak reserve does not give for the peak and the others do. This alarm then comes only when the others cannot keep the period under the limit alone. Control keeps running meanwhile.

The peak reserve only applies to keeping the peak down. Normal home use can still draw the battery below it, down to the battery cut-out level, where the battery switches itself off.

Inverters working against each other

With several inverters, one was charging its battery from another. Your meter cannot see this, because the two flows cancel out, but it wastes energy. The add-on has stopped control and set every inverter to 0 W. If the grid power does not change after that, switch the inverters off at the isolator. A restart does not clear this. Find out why, then start control on the add-on page.

An inverter is not delivering

With several inverters, one gives much less power than it is asked for, while the others do. Nothing is changed: control keeps running, and the other inverters carry on. Check, in this order:

  1. Is the inverter on and connected?
  2. Is its battery empty or cold? If it is emptier than its “Battery cut-out level” says, that setting does not match this battery.
  3. Is anything else controlling it?

A restart does not clear this. Once you know why, stop and then start control on the add-on page.

Meter reading may be stuck

The meter keeps answering, but its value has not changed for a long time while the add-on asks the battery for full power. That usually means the meter reading is stuck. Control keeps running and keeps following the reading, so a stuck reading can raise this month's peak. Check the meter. If it is stuck, stop control on the add-on page.

Battery cycle did not finish

The monthly battery cycle stopped because the battery did not empty, or did not fill up, in time. The battery is back under normal control. The add-on waits 7 days before it tries again, so it does not empty the battery every night. To try sooner, start a cycle on the add-on page. If it keeps happening, check that the battery follows its commands and that the cheap hours leave time to charge.

For installers: notifications

This part is for installers. The texts live in app/messages.py and follow STYLE.md. Every notification keeps a fixed notification_id, so a repeat replaces the card. The ERROR or WARNING line of the add-on log names the reason code. The full figures are in events in the status JSON, also for a stop restored after a restart, and on the DEBUG line logged right after it. That DEBUG line only shows with “Log level” (log_level) set to debug; the default info hides it.

Title Severity id Card
Battery is not keeping up warning goodwe_integrator_bound SAFETY-40
Power request too high alarm goodwe_output_clamp SAFETY-03
Load too high for battery alarm goodwe_peak_saturated SAFETY-07
Battery stopped limiting the peak alarm goodwe_peak_soc_floor SAFETY-24/25/32
Inverters working against each other alarm goodwe_circulating_power SAFETY-31
name is not delivering alarm goodwe_not_delivering_<name> SAFETY-42
Meter reading may be stuck alarm goodwe_meter_frozen SAFETY-18
Battery cycle did not finish warning goodwe_maint_failed_<name> MAINT-13

The Details link. Home Assistant 2026.2 and newer show add-on pages at /config/app/<slug>/documentation; 2026.5 removed the old /hassio/addon/<slug>/documentation, which 2026.1 and older still use. At start the add-on reads its slug from GET /addons/self/info and the Home Assistant version from GET /info, and builds the link for that version. If the version cannot be read it uses the new path. If the slug cannot be read it falls back to the container hostname, and outside an add-on the line names the section without a link. The documentation tab adds no heading anchors, so the link cannot jump to the section.

Battery is not keeping up. The grid-following loop's integrator reached its anti-windup bound (integrator_max_w, SAFETY-40), or a battery that another owner set to discharge gave less than it was told (SAFETY-85). No command went over the rating. The log line is a WARNING with reason code OUTPUT_CLAMP. See The winding-loop alarm.

Power request too high. A computed command above a limit was cut before it was written (SAFETY-03). The limits are max_w (label “Inverter rating”), the setpoint device's own range, or the rating on the loop side. Asked always comes first. The old wording was "BUG SIGNAL". It is not the inverter failing to follow a legal command, which is the saturation freeze and does not alarm. In shadow mode the clamp still grades the decision (SAFETY-10), so the alarm can fire while 0 W is on the wire, and the text says "would have sent". ERROR line, reason code OUTPUT_CLAMP.

Load too high for battery. The capacity-peak guard (SAFETY-07) asks for everything the inverter can discharge and the running 15-minute average is still projected over the peak limit, so the month's billed peak (capaciteitstarief) goes up. It fires only when, for 10 s, the period would still end more than 10 W over the real limit (not the margin's aim) with the battery at full power. It notifies at most once per period; later overloads in the same period go to the log (SAFETY-81). In shadow mode the guard's projection is made on a plant that did not respond (SAFETY-47), and the text says so. See The capacity-peak guard.

Battery stopped limiting the peak. The peak guard may not discharge below peak_soc_floor (label “Peak reserve”, SAFETY-24). It also stops when the state of charge is unread, or when an inverter's bms_soc_floor (label “Battery cut-out level”) is missing, because the reserve is graded against it. Charging from the grid stays blocked meanwhile. With several inverters the notification names the battery at its limit (SAFETY-32), each graded against its own cut-out level. That battery is held (weight 0 while the site gives) and the guard's need goes to the others, up to their rating; the alarm fires only if they miss the limit at full power for ALARM_HOLD_S (SAFETY-87). ⚠️ The reserve binds the peak guard only (SAFETY-25). Self-consumption has no such floor. See What peak_soc_floor does not protect.

Inverters working against each other. SAFETY-31. The flows cancel at the P1 meter, so every other guard, which reads that net figure, stays quiet. This has happened on this hardware once for 6 h 25 min. The alarm is latched, survives restarts, and is raised again every 5 min until control is started. The watts and the time window are in the log. The Check for inverters working against each other row shows the live watts. See The circulating-power alarm.

An inverter is not delivering. SAFETY-42. The unit stood well short of its share of the site target for longer than delivery_hold_s. A unit at 0 W is absent rather than pushing the wrong way, so the per-unit sign check and the circulation check stay quiet. The guard only reports: commanding an inverter that has stopped listening achieves nothing. Check first whether it answers on RS485. The alarm is latched and survives restarts; stop then start clears it. See A unit that stops DELIVERING is a third fault again.

Meter reading may be stuck. SAFETY-18. The value has not changed for longer than meter_freeze_s while the command stands at the power limit, and the age is still fresh, so no other guard stops it. Only meter_source: homewizard_local can tell a silent meter from an unchanged value. The limit and command figures are in the log. See The frozen-meter alarm.

The Web UI

The add-on page shows live values, why the battery does what it does, and a Commissioning checklist that names any problem in words. It also has three buttons: start or stop control, force a maintenance cycle, and abort one. Under the buttons is the last thing that happened, in a few words.

It has a second page, Configuration. See There are TWO places to configure this add-on, above. It adds nothing you can do to the inverter. It reads and writes the add-on's own settings and can restart the add-on. Only the three buttons reach the inverter.

For installers: the Configuration page is UX-02. The line under the buttons is messages.hint(), the plain form of the newest entry of the add-on's own action log (Controller.events). The page no longer shows that log, but the status JSON still carries its last 40 entries as events: open the add-on page and add status to its address. The action-log tables in this document refer to it.

Language and screenshots

The page is in Dutch when your browser's language is Dutch, and in English otherwise. Status lines, alarms and the start checks' messages stay in English.

Screenshots of each screen are in the add-on's repository, under docs/img/ux13/, including the Batteries screen and Settings, Simple.

For installers: regenerate the screenshots with tools/ux13_screenshots.sh, which serves the page from test fixtures.

The Batteries screen and Settings, Simple

The Batteries tab has a card per battery: its level, and three lines. Now says what it does, Today lists the blocks planned for it (price trades, the monthly cycle, the peak limit), and Why says the reason. With battery modes on, each card also has the On/Off switch and the mode list; both apply at once. Today reads "Not planned yet" until the add-on has made the day's price runs.

An Off battery that the peak limit still needs says so, with the power it gives: "Off, but gives 0.5 kW for the peak limit."

Settings opens on the Simple view, or on the view you last picked: the peak limit, the batteries' switches and modes, and shadow mode, each marked "Applies right away" or "Applies after a restart". Installer shows every setting.

Setup, for a new install

The Setup tab walks an installer through six steps: the meter, the inverters and batteries, solar (optional), prices, the peak limit, and a last check. Each step shows the same fields as Settings, and nothing is saved until the last step.

  • The kettle test on the meter step reads the meter, then reads it again after you switch on a kettle. If the reading goes down, the add-on flips its sign for you; with a net or import/export meter it tells you what to change.
  • The last step runs the add-on's own start checks and reads Home Assistant: the meter reading, that each power request entity exists, the battery levels, and whether prices are known. A missing power request or meter entity is red, so it cannot be saved.
  • Save and restart saves, then restarts the add-on. Every inverter is set to 0 W while it restarts, and with "Start in shadow mode" on (the default) it stays at 0 W until you turn shadow mode off under Settings.

Setup opens by itself on a new install's first start, until Setup finishes or control is started, and whenever the stored settings name no inverter to control. A site that already ran before is never sent there.

A new install names no inverter yet, so the add-on starts in setup: it serves the page and sends nothing to any inverter. Home says "Setup: nothing is controlled yet", and there is no Start button. The installer's steps:

  1. Install the add-on, turn on Watchdog on its Info tab, and start it. Leave the Configuration tab as it is.
  2. Open the add-on's page. Setup opens by itself.
  3. Go through the six steps and press Save and restart. With "Start in shadow mode" on, every inverter stays at 0 W after the restart.
  4. On Home, check the meter direction and what the add-on would do. Turn shadow mode off under Settings when it looks right.

Any other problem in the stored settings still stops the add-on (exit code 78), and the log names each one. Fix those on the Configuration tab.

For installers: cards UX-13 and UX-15, DECISIONS/UX-13-setup-wizard.md and DECISIONS/UX-15-setup-only-mode.md. In setup the status JSON has setup_only: true, and no MQTT entity is published.

Status page

The add-on page shows a banner at the top and a Commissioning list below it. The banner says in one line what matters most now. Each row of the list is one check: ✓ green is fine, ! amber needs a look, ✗ red needs action. Rows name settings by their label, never by a Home Assistant entity id.

In shadow mode the banner says so first, whatever else is going on: the add-on makes every decision but the battery is not controlled, and every inverter stays at 0 W. If the banner is red and says a safety stop stays on, one of the red rows below is a stop that will still be there when shadow mode ends. That row says how to clear it.

In setup (a new install that names no inverter yet) the banner says nothing is controlled yet, and the one row says to finish Setup. See Setup, for a new install.

Row starts with What it tells you
Home Assistant is not answering Home Assistant is not passing readings or power requests on, usually because it is still starting. The add-on cannot control until then. It resumes by itself. Nothing to do unless it lasts.
Control When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W.
Meter The grid reading, its age, how many readings were refused, and how long its value has not changed. Just after a start it is amber and waiting for the first reading. Red means no reading for longer than the limit.
Stuck-meter check Whether the add-on can warn you about a stuck meter.
Meter reading may be stuck See Meter reading may be stuck under Notifications.
Month peak This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %.
15-minute periods measured this month How many 15-minute periods the add-on has measured and saved this month.
Peak guard, Load too high for battery, Battery at its peak reserve What the add-on does to keep your peak down. Idle, heading for … is where this 15-minute period is heading. Waiting for meter readings after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without Not ready.
Battery level, Battery power, Grid power The reading of that entity. No usable reading means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it.
Inverter control Whether the inverter can be controlled, and how much it can charge and give.
name: battery power reading stopped changing The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes.
name: … cannot be controlled, commands refused, “Setpoint entity” is not set Whether each inverter still takes commands.
Power request too high, Battery is not keeping up See those titles under Notifications. Each shows how often it happened since the add-on started, and fine now once it has cleared. In shadow mode the row says what would have been sent.
Direction check, Site direction check Whether each inverter's power goes the way the add-on asks. Opposite for 12 of 60 seconds counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control.
Check for inverters working against each other, Inverters worked against each other Only with several inverters. See Inverters working against each other.
Delivery check, name is not delivering Only with several inverters. See An inverter is not delivering.
Solar production Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”.

For installers: status page

The texts are in app/messages.py. An alarm's ERROR or WARNING line names its reason code, and its full figures are on the DEBUG line right after it and in events in the status JSON.

  • Stuck-meter check: off needs meter_source: homewizard_local (label “Where the meter reading comes from”), a max_w (“Inverter rating”) and a power range the inverter can reach. The inverter's own limit means the setpoint device's range sets the threshold, below max_w.
  • Peak guard: off means meter_source is off, so there is no 15-minute average to guard.
  • Battery level / Battery power / Grid power are soc_entity, batt_entity and meter_entity. No usable reading is SAFETY-56: missing or held back as stale, and a separate stale-reading row then says which.
  • Inverter control is the setpoint_entity and the range it accepts.
  • name cannot be controlled (red): every write was rejected for LINK_STALE_S, so the unit is dropped and the others carry its share (DEV-02). name: commands refused (amber) is the same before that limit. name: “Setpoint entity” is not set (amber): a roster entry nobody can write, so the site refuses to run at part strength and holds 0 W.
  • Direction check, Site direction check are the sign checks (SAFETY-02/30). Check for inverters working against each other is SAFETY-31, Delivery check SAFETY-42.
  • Month peak and 15-minute periods measured are TARIFF-01 and TARIFF-13. The store's state at start is the INFO line Month peak store: …, and the peak guard's full reason is on the DEBUG line after its ERROR.
  • name: battery power reading stopped changing is SAFETY-37/56/57. The ERROR line says when it clears and names STALE_READING. The full reason, with both possible causes, is on the DEBUG line after it. The 35 s counts only while the command asks the reading to move (SAFETY-94).
  • Meter: no reading for … (red): the add-on logs one WARNING when the meter goes quiet, "No meter reading for …", and an INFO line when readings return, at most once every 10 minutes. Past stale_input_s the Why line names the missing reading.
  • The table at the top: Why is messages.why(), the arbiter's answer in words (Resolution.explain() stays on the log). Tariff, Price history, Arbitrage and Shadow mode keep every figure they had (TARIFF-11), in words.
  • Solar production reads pv_entity or pv_entities (TEL-11/12). The Reversed-sign solar entities setting is pv_invert_entities (TEL-14).
  • Home Assistant is not answering (SAFETY-112): the Supervisor answers 502 to every add-on read, write and websocket until Home Assistant is RUNNING, even when its entities are already live. A slow integration at start-up therefore holds control for as long as it holds Home Assistant's start. The log has one WARNING, a "Still waiting" INFO line every 5 minutes and "answers again after …" at the end. See DECISIONS/SAFETY-112-waiting-for-home-assistant.md.
  • Shadow mode (SAFETY-10): the banner turns red when a latch is up (circulating power, or a site or per-inverter sign trip), and that latch keeps its own red row.

Status entities

If an MQTT broker is available the add-on publishes setpoint, grid power, battery power, circulating power, state of charge, maintenance phase and controller status by MQTT discovery. This is observability only — the controller works fine without a broker, and MQTT problems can never affect control.

Before you commission inverter B

For installers: card ARCH-02. The tool is tools/rehearse_roster.py.

The day the inverters: roster is declared, two things change that are one-way on a live box and invisible until after the restart that applies them: the store migration renames the keys that hold the SAFETY-02 latch and the maintenance history, and the entity ids Home Assistant composes change shape. This tool runs the real Controller through that transition against a copy of the state file and reports the difference.

docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v <repo>:/w:ro \
  goodwe-addon-checks:1 sh -c 'cd /w/goodwe_controller && \
  python3 tools/rehearse_roster.py \
    --state /w/state-copy.json \
    --before /w/live-options.json \
    --after  /w/roster-options.json \
    --live-entities /w/live-entities.txt \
    --reverse --round-trip'

Exit 0 clean · 1 at least one finding · 2 could not run · 3 clean but degraded (--no-live-entities). ⚠️ 2 and 3 are not a pass.

The owner steps, because this session could not take them

⚠️ The rehearsal is only as good as the state it is pointed at, and nothing in this repository can fetch that. SSH to the live box is blocked by policy and that is correct — /data/state.json is the file the running add-on is writing. So three things have to be exported by hand, onto the machine that runs the check, before the roster is declared:

  1. The state file. From the Supervisor host:

    ha addons info local_goodwe_controller     # confirm the slug first
    docker cp addon_local_goodwe_controller:/data/state.json ./state-copy.json
    

    or take it out of a full add-on backup, which is the same bytes. ⚠️ Copy it, never move it, and never write to the original. The tool opens the path it is given exactly once and does all of its writing on a scratch copy — but the safe habit is to hand it a file the add-on has never heard of.

  2. The options blob, as --before. It is not reachable through the Home Assistant REST API and no entity carries it; export it from the Supervisor, the same way tools/predeploy_check.py needs it: the bare options object, not the ha apps info wrapper, which is refused. Write the roster you intend to deploy into a second file and pass it as --after.

  3. The entity list, as --live-entities — one entity id per line:

    Developer tools -> Template:
    {{ states | map(attribute='entity_id') | select('search','goodwe') | join('\n') }}
    

    ⚠️ Omitting it is a REFUSAL (exit 2), not a quieter run. The orphan check is graded against the ids the install actually has, and without that file it is not graded against reality at all — so the tool will not run rather than hand back a clean bill of health for a half of the transition it did not check. If you genuinely have to run without it, pass --no-live-entities: the run then carries a standing banner in the report header and on the exit line saying the orphan check was not graded, and a clean result exits 3, not 0. The asymmetry used to be the other way round — an unreadable file refused, a missing flag passed with exit 0 and no disclosure at all — and the owner runs this once, at commissioning, under time pressure.

    The install carries roughly 49 goodwe* entities and this add-on announces eight of them. The tool separates the ids it owns from the ids it does not, rather than claiming orphans it cannot cause.

The reference, which is loaded on every run

--reference defaults to tools/rehearsal/announced-reference.txt and is read whether or not you pass an entity list. It records what the build is supposed to announce, three columns per row:

<entity_id>   <unique_id>   <device identifier>

⚠️ All three, and the second and third are the ones an entity_id-only list cannot see. The tool's own before/after diff is composed twice by the same build, so a change to the composition moves both sides equally and the diff reports nothing. Worse, two changes orphan an install without moving a single composed entity_id: renaming mqtt.BASE (every unique_id changes) and changing DEVICE["identifiers"] (HA sees a new device). Home Assistant keys its entity registry on unique_id and its device registry on the identifier, so either one creates a new entity beside the old — and because discovery is published retain=True with no removal payload, the old config stays on the broker and the old entity stays in the registry, still named by every automation that referenced it, never updating again. Both were measured coming back exit 0 before the reference carried three columns.

object_id is deliberately not a column: app/mqtt.py records that HA 2026.8 does not honour it in a discovery payload, so renaming one is a true no-op, and test_rehearse.py requires that mutation to survive — otherwise "the reference notices any edit to mqtt.py" would be indistinguishable from "the reference notices what HA keys on".

⚠️ No result in this repository was produced against real state. Everything recorded here and in test_rehearse.py was measured on the synthetic fixtures in tools/rehearsal/.

What it confirms on the synthetic fixtures

⚠️ This is a SECOND INSTRUMENT reaching a conclusion this repository already holds, not a discovery. test_maintenance.py section 13 — "RECORDED, NOT FIXED: SAFETY-02's migration has the same wrong-namespace hole, and it is a LATCH" — already states it, already names namespaced_latched, and already carries the sentence "the guard is defeated by the very key the defect wrote". That file is on release/1.0 and this card does not touch it. What is new here is that the finding is now reachable from a state file the owner supplies rather than only from a fixture built in a test, and that it is reached by a different route: a full Controller start diffed against its predecessor, rather than assertions over a MemStore. Two instruments, one answer.

The finding. A pre-DEV-02 flat store that declares a multi-inverter roster comes up with its SAFETY-02 latch DISARMED, unless the first roster entry happens to be named inverter-a. Controller.__init__ calls migrate_unit_keys(store, UNIT_A) with a hardcoded constant, so the latch lands in unit/inverter-a/sign_tripped; a roster of ["garage", "shed"] reads unit/garage/sign_tripped, which is absent. And _grade_store_migration() — the guard written for exactly this — sees the migration's own misdirected key satisfy its namespaced_latched test and returns without re-arming. The file says sign_tripped: true, the banner is green, and nothing is held at 0 W.

Both routes reach it, and they are NOT the same defect. The rehearsal reports them as separate legs because they need separate fixes:

leg how it gets there
SAME-STEP the roster is declared in the same step as the upgrade. The migration runs, and is handed UNIT_A.
STAGED the flat config runs once under 0.7.0+ first. That start stamps schema_version = 1, so the roster start's migration takes its from_version >= SCHEMA_VERSION early return and the latch never moves at all.

⚠️ So "hand migrate_unit_keys() the roster's own first name" is NOT the whole fix, and the rehearsal measures that rather than asserting it. test_rehearse.py part 5b builds exactly that change and reports:

LEG STAGED: DISARMED
LEG SAME-STEP: clean

The staged path never reaches the line that was changed. A fix that stops there closes the half an operator is least likely to take and leaves the half that "upgrade first, declare the roster later" produces. The guard side — namespaced_latched counting a namespace that is not on the roster — is what covers both, and a change there turns every SAFETY-44 check in part 5 red, which is what a fix should do to a pin that specifies a defect.

The fix belongs to its own card, shown red first. Part 5 pins the current behaviour as a requirement so it cannot drift green by accident.

The reverse direction: the retreat is supported, the round trip is not

If commissioning B goes wrong, backing out to the flat single-inverter config is supported and does not corrupt state. Measured on the fixtures:

  • the SAFETY-02 latch and the first pack's maintenance history both survive, because store.py rule 2 keeps the flat spellings live rather than merely present — maintenance.UnitHistory.mirror_flat writes them on every change, so a single-FSM add-on finds a current history rather than one dated the day the roster was declared;

  • it orphans eight Home Assistant entities — the four per-unit rows for each of the two units. Measured: the add-on stops announcing them and stops publishing their state, so their history gaps and any automation whose trigger names them stops firing, silently and without an error.

    ⚠️ What is NOT measured, and was previously overstated here: whether advancing again restores them. An earlier draft of this section said the orphaning was "not recoverable by advancing again: HA keeps the id it composed at first discovery" — that reasoning cuts the other way, since keeping the composed id is precisely what would let a re-advertised entity land back on it, and discovery is published retain=True with no removal payload. Nothing in this repository has tested a re-advance against a real Home Assistant, so the claim is withdrawn rather than restated in the other direction. Plan the retreat around the measured part: the eight rows stop updating for as long as you are retreated.

⚠️ What is NOT supported is the round trip. A maintenance cycle that completes while the install is rolled back is discarded when it re-advances: maint_schema_version is already stamped, so migrate_maint_keys() takes its early return and the pack reads the namespaced history from before the retreat. The pack is then recalibrated on a schedule that does not know it already was. So: retreat freely, but treat a retreat as ending the current maintenance bookkeeping — and if a cycle ran during it, correct unit/<first>/last_completed by hand before advancing again. This is a stated decision, not a discovery made at the inverter.

Before you deploy

For installers: card PKG-09. The tool is tools/predeploy_check.py.

Two ways a deploy has gone wrong on this system, both of which the Supervisor reports as success:

The silent no-op. The Supervisor keys the built image by version: in config.yaml. ha apps update against an unchanged version installs nothing and says it worked. This happened twice: PKG-06 (six merges behind an unbumped version) and PKG-07 (six again). Both times a human noticed. Nothing else would have.

The refused config. A config the shipped validator rejects is SystemExit(78) — the add-on does not start. ⚠️ And the ingress panel goes with it, because the add-on serving it is not running, so recovery means the Supervisor UI. SAFETY-39's rule 10 made this reachable from a config that started fine on the previous version: 0.7.0's own default slew_w: 1000 exceeds the 555.6 W ceiling on a 5000 W rail.

Run it inside the container, so the validator half actually runs:

docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
  python3 /w/goodwe_controller/tools/predeploy_check.py \
  --deployed-version 0.7.0 --options /w/live-options.json

Export live-options.json from the Supervisor first — the options blob is not reachable through the Home Assistant REST API, and no entity carries it. Pass the bare object (ha apps info <slug> --raw-json | jq .data.options); the wrapped output is refused.

Exit codes. 0 cleared · 1 REFUSED, do not deploy · 2 NOT CLEARED, meaning a check did not run. ⚠️ 2 is not a pass. On a bare host without the add-on's dependencies the validator half skips and returns 2 rather than pretending; test_version_gate.py pins that a skipped check can never exit 0, because the first draft of that test reported four silent skips as passes.

⚠️ Why this is not a CI check. The obvious gate — "a pull request touching runtime code must bump the version" — is the wrong shape here. This project deliberately batches many merges into one deploy, so a per-PR bump would put every branch in conflict over one line and still not prevent the failure, which happens at deploy and not at merge. The check belongs where the mistake is made.

⚠️ This file's own change does not need a version bump, and the gate agrees: tools/ and test_*.py are not in its runtime set, because demanding a bump for a typo fix trains people to bump without reading — the habit the check exists to prevent.