MAINT-04 (#10) landed on release/1.0 mid-review, so both branches had prepended a stanza. config.yaml and DOCS.md auto-merged clean; only the CHANGELOG needed a hand. Both stanzas are kept verbatim, MAINT-04's Unreleased above the new 0.5.2 - no text from either author edited. MAINT-04 is unreleased and 0.5.2 is being cut here, so a release manager may want to fold the two; that is an editorial call, not a reviewer's. Full suite green on the merged tree: arbiter, control, loop, maintenance, p1, validate, watchdog, freeze. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
32 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
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.
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 |
gain |
0.6 | Correction per cycle. At the limit — do not raise |
slew_w |
1000 | Maximum change per cycle |
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 |
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.
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.jsonand restored on startup. 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 |
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 |
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
⚠️ 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 |
❌ — not built. This is SAFETY-07 |
| Frozen meter detected | ✅ on homewizard_local only, alarm only |
| Frozen meter stopped | ❌ — the alarm does not command anything |
| SoC guard on the loop | ❌ — the BMS is the only energy backstop |
So the frozen-meter alarm below narrows the window; it does not close it. On
a capacity-tariff site where an unattended runaway is unacceptable, the mitigation
today is 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 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
max_w.
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.
| 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.
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, 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.