`slew_w: 1000` was derived on 2026-08-22 against a clamp of 1500 W, where "capping how far the loop can jump ahead of the inverter" was true because the rail itself bounded the excursion. The live install went to max_w 5000 and the constant did not move, so the loop crosses its whole output range in 10 s against a plant NOTES.md §5.10 measured at ~18 s. Measured on the owner's system 2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000 W in five 2 s ticks, the battery to -5154 W past a 5000 VA rating, and the grid from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken; the rail moved. REPRODUCED FIRST, and one half of it is not reproducible at all. On plantsim.Plant and on an independent in-repo model: the setpoint stepping a full 1000 W per tick to the rail (48 of 51 export steps), and the meter crossing from export into import. The -5154 W figure is NOT reproducible in either model, because both clamp the plant at its rating - so the card's AC "battery no longer exceeds max_w in simulation" is unfalsifiable and was rewritten rather than met. THE DELIVERABLE IS THE RELATIONSHIP. SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 1/9 slew_w = 0 now means FOLLOW THE RAIL - SLEW_RAIL_FRAC * max_w - exactly as integrator_max_w = 0 already means follow max_w. It is the shipped default: 222 W at max_w 2000, 556 W at 5000, 1067 W at a ~9600 W two-inverter site. validate.py rule 10 refuses an explicit value faster than that, graded against sum(unit_caps(opts)) - the roster sum, i.e. Controller.site_max_w - so a second inverter moves the bound with no operator action. VERIFIED BY BREAKING IT: restoring slew_w 1000 at max_w 5000 is refused, and the refusal names the rail, the crossing time, the measurement and what to do. Equality, one watt of slack, the live interim 400 and slew_w 0 at every rating all still start. GAIN IS THE CONSTANT THAT IS STILL WRONG, and it is NOT changed here. Over 51 export steps the over-absorption is FLAT in slew (958 W at 1000, 1081 W at 250) and monotone in gain (987 W at 0.6, 876 at 0.3, 435 at 0.2): slew_w bounds the OUTPUT, while the integrator moves gain*error per cycle and reaches a 5000 W rail in under three ticks whatever the slew is. Two control-law constants moving in one release on a running house makes the next measurement uninterpretable. ⚠️ N=2: the site rail becomes sum(max_w) ~ 9600 W (inverter B FROM DATASHEET AND SERIAL, not measured, and sim/plantsim.py's INVERTER_B is A copied), and the enforced ratio produces 1067 W there automatically. But a growing rail LOOSENS this bound, and the >= 900 W symptom goes stale at N=2 the same way the constant did - both stated in the record rather than left to be discovered. Nothing reached the inverter. The live install still runs the interim slew_w 400 applied by hand on 2026-09-05, which rule 10 accepts unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
76 KiB
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: read FIELD-GUIDE.md in the repository. It is not optional reading —
it contains the commissioning gates and the failure modes.
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 T-CAN485 (ESP32) flashed with
firmware/goodwe-master.yaml - A grid-power sensor already working in Home Assistant, updating every ~5–10 s
The safety model, in short
The inverter holds its last command forever — it has no meter-timeout. So:
- The ESP32 commands 0 W if this add-on stops refreshing for ~30 s, and keeps commanding it.
- This add-on commands 0 W when its inputs go missing, when you stop control, and when it shuts down.
- This add-on commands 0 W and latches there if what it is commanding and what the meter reads point opposite ways for three minutes — see "The sign-disagreement failsafe" below.
- The optional RS485 e-stop is the only thing that covers this machine dying. Without it, a failed host leaves the battery latched at its last command until someone intervenes.
- This add-on raises an alarm — and nothing more — when the meter keeps answering with a reading that never changes while the command sits at the rail. 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 commands 0 W and the hardware holds it there.
Configuration
What the Configuration tab actually shows you (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 prints the
whole rendered list, in both languages.
| All 70 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 | A number that was derived names the campaign it came from and says not to change it without re-deriving; a number that is genuinely yours (max_w, peak_cap_w) says "THIS IS YOUR NUMBER"; and cycle_cost_threshold_eur, which is neither, says it is an open product question |
| 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.
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 | |
setpoint_entity |
yes | The ESPHome number.*_goodwe_setpoint_w |
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
- 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
⚠️ 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 |
soc_entity |
This pack's state of charge | |
batt_entity |
This unit's battery power, + = discharging |
|
batt_invert |
Flip if needed |
⚠️ 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 and max_w are required of every entry
for the same reason: there is no rating 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.
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 at all:
no second device, no renamed entity, not one added topic.
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 and the capacity-tariff peak.
⚠️ 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.
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, ormqtt_p1for 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_dsmrcannot 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, notha_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.p1_sample_age_s" below;ha_signedremains for installs where the meter is only reachable through Home Assistant.
⚠️
ha_dsmrandmqtt_p1have never processed a telegram from real hardware. No meter in this installation uses either one. Both were written to the assumption inspecs.md§5.2 that a Belgian P1 exposes two unsigned registers, and the meter actually fitted here does not — it is the HomeWizard P1 thatha_signedreads. They are covered by the unit checks intest_p1.pyand 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.
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.p1_sample_age_s 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 P1 line,
as … 4 rejected, measurement unchanged for 312 s. 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.p1_sample_age_s
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, so do not rename it.
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 astate_changedarrives, 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 nostate_changed, does not advancelast_reportedon either the REST or the WebSocket serialiser, andstate_reportedcannot 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: 0state_changedin 70 s and no timestamp movement anywhere.
ha_dsmrmostly escapes this because a DSMR telegram updates several entities and something in the set almost always moves.ha_signedhas 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 defaultmeter_max_age_sof 30.So
sensor.p1_sample_age_sonha_signedis 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. Raisingmeter_max_age_sis 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 |
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 |
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 FAILSAFE, the commissioning list 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 (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_entityreading, 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 flatsite_sign_trippedin/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— underunit/<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 flatsign_trippedis 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.
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.
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 |
10 | Hour of day a due cycle begins |
maintenance_discharge_w |
2000 | Drain rate (exports the surplus). 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 |
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_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.
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 |
✅ |
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 NOT COVERED. 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 the month's banked 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, 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.
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.
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: 20is "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. - When it binds you are told, once per episode, with notification
goodwe_peak_soc_floorand a status-page row readingPEAK GUARD FLOOR-LIMITED: 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 — register0x0560on the reference GW5000S-BP, consistent with DoD 90. It exists so the add-on can refuse to start whenpeak_soc_flooris 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 (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 — "commanding 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. 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 - first engagement, then re-asserted every
30 s while it persists, 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 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 NOT COVERED
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 NOT COVERED 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 (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, turning a provider on still fetches, validates and publishes prices and changes no charging decision.
static_profilebelow 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.
| option | meaning |
|---|---|
price_provider |
off (default) or one of the five 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 |
Levies and VAT, EUR/kWh. Same rule |
export_price_eur_kwh |
The injection price. A separate scalar, not a series — see below |
price_csv_path |
Where manual_csv reads its file |
price_feed_url |
Override the plugin's default feed. 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 |
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 |
engie_dynamic |
day-ahead | energy + Engie's markup | taxes |
totalenergies_dynamic |
day-ahead | energy + TotalEnergies' markup | taxes |
eneco_eddy |
real-time / imbalance | everything, all-in | nothing |
manual_csv |
day-ahead | everything, all-in | nothing |
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.
eneco_eddy is a real-time/imbalance product rather than a day-ahead one. It is
flagged as high-volatility, which widens arbitrage thresholds once the
optimiser lands. That comes from the plugin's own declaration, not from its
name.
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
startmust carry its UTC offset (+01:00in winter,+02:00in summer). A bare2026-01-15 00:00is refused, because for one hour every October a local wall clock names two different moments.eur_per_kwhis 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.
provisionalis 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.
Injection is priced separately (export_price_eur_kwh). In Belgium it is
often near zero, which by itself strongly favours self-consumption over export.
It is a single number rather than a curve because no supported supplier
publishes a dynamic injection tariff; negative values are accepted.
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.
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".
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_kwhis all-in — energy plus your supplier's markup plus taxes — exactly likemanual_csv.price_markup_eur_kwhandprice_taxes_eur_kwhare not added to it.daystakesmon…sun, ranges (mon-fri,fri-mon), comma lists (sat,sun) and the wordsweekday,weekend,all.- Times are local Belgian time and windows are half-open:
07:00–22:00includes 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:00onmon-fricovers 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.
Site
| option | meaning |
|---|---|
estop_fitted |
Whether the RS485 e-stop is installed. Drives the warning banner |
log_level |
trace/debug/info/warning/error |
The Web UI
The ingress panel shows live values, why the controller is commanding what it is, and a Commissioning checklist that names any problem in words. It also carries the three buttons: start/stop control, force a maintenance cycle, and abort one.
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.