checks / unit (pull_request) Successful in 4m39s
CFG-04 (#45) merged first and moved the base. Two conflicts, both resolved as the union of the two sides: - app/main.py: an import-block collision. CFG-04 added validate_unit_ranges to the .validate import; SAFETY-30 added AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS and SignWatchdog to the .watchdog import. Neither side dropped anything, so the resolution keeps both lists whole. - CHANGELOG.md: two adjacent new entries under ## Unreleased. Both kept. Both sides also independently rewrote large parts of test_peakguard.py (60 and 47 insertions against the merge base) and git merged them textually, so the full suite was run on the MERGE RESULT rather than inferred from the two parents: 19 passed, 0 failed, twice. One caveat recorded rather than hidden: test_peakguard.py failed once during the first sequential full-suite run and then passed 10/10 in isolation and in two further full runs on this same tree. It passes on both parents too, so this is the pre-existing flakiness CFG-04 already disclosed, not a merge defect - it is timing- or load-sensitive. It sits on the required CI context and can therefore block an unrelated merge at random, which wants a card of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
1184 lines
69 KiB
Markdown
1184 lines
69 KiB
Markdown
# 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.
|
||
|
||
### 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:
|
||
|
||
```yaml
|
||
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: 3000
|
||
```
|
||
|
||
| 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.**
|
||
|
||
### P1 meter ingestion
|
||
|
||
`meter_entity` above expects one signed sensor, which usually means a template
|
||
someone wrote by hand. Setting `meter_source` moves the whole derivation into
|
||
the add-on, where it is done once and tested, and replaces `meter_entity`
|
||
entirely.
|
||
|
||
Which mode you want depends on what your P1 reader publishes, and there are two
|
||
shapes in the wild:
|
||
|
||
- **Two unsigned registers**, consumption and injection, which is what a Belgian
|
||
P1 read over DSMR gives you → `ha_dsmr`, or `mqtt_p1` for a bridge. The add-on
|
||
subtracts them.
|
||
- **One signed figure**, positive = import and negative = export, which is what
|
||
a HomeWizard P1 gives you (`sensor.p1_meter_active_power`) → `ha_signed`. The
|
||
add-on splits it. `ha_dsmr` **cannot** read this: it wants two registers and
|
||
rejects a negative one outright, which is every exporting telegram.
|
||
|
||
Either way, do not build the missing shape out of template sensors. The point of
|
||
`meter_source` is that the sign convention is derived in one tested place rather
|
||
than in YAML nobody reviews underneath a safety input.
|
||
|
||
> ✅ **If your meter is a HomeWizard P1, use `homewizard_local`, not
|
||
> `ha_signed`.** It reads the same meter and produces the same numbers, but it
|
||
> polls the meter directly instead of watching a Home Assistant entity — and
|
||
> that is the difference between an age sensor a watchdog can threshold and one
|
||
> it cannot. See "`sensor.p1_sample_age_s`" below; `ha_signed` remains for
|
||
> installs where the meter is only reachable through Home Assistant.
|
||
|
||
> ⚠️ **`ha_dsmr` and `mqtt_p1` have never processed a telegram from real
|
||
> hardware.** No meter in this installation uses either one. Both were written
|
||
> to the assumption in `specs.md` §5.2 that a Belgian P1 exposes two unsigned
|
||
> registers, and the meter actually fitted here does not — it is the HomeWizard
|
||
> P1 that `ha_signed` reads. They are covered by the unit checks in `test_p1.py`
|
||
> and by an end-to-end test against a fake Home Assistant, and nothing more.
|
||
>
|
||
> This is recorded because the realistic way it bites is someone debugging a
|
||
> meter problem months from now treating those two paths as proven and looking
|
||
> for the fault elsewhere. If you are the first person to point one at a real
|
||
> meter, expect to find something, and please update this note when you do.
|
||
|
||
| option | default | meaning |
|
||
|---|---|---|
|
||
| `meter_source` | `off` | `off` keeps `meter_entity`. `ha_dsmr` subscribes to the DSMR integration over the HA WebSocket; `mqtt_p1` reads a topic; `ha_signed` subscribes to one signed entity over the HA WebSocket; `homewizard_local` polls a HomeWizard P1's own local API, bypassing Home Assistant |
|
||
| `meter_phases` | 1 | 1 or 3. Must match the telegram, or every telegram is rejected and logged |
|
||
| `meter_max_age_s` | 30 | Beyond this the reading is stale and grid power reads as *missing*. On its own it does **not** command 0 W — see the timing note below. It is also the longest a reading is held forward into the 15-minute average |
|
||
| `meter_poll_s` | 5 | `homewizard_local` only. Seconds between polls. **Must be well under `meter_max_age_s`** — see below |
|
||
| `p1_host` | | `homewizard_local` only. The meter's own address, `host` or `host:port` (e.g. `192.168.2.250`) |
|
||
| `meter_mqtt_topic` | | `mqtt_p1` only |
|
||
| `p1_import_entity` | | The **unsigned** consumption sensor. Do not point this at a signed template |
|
||
| `p1_export_entity` | | The **unsigned** injection sensor |
|
||
| `p1_phase_import_entities` | `[]` | L1..L3, in order. Needed for the capacity-tariff peak on a three-phase connection |
|
||
| `p1_phase_export_entities` | `[]` | L1..L3, in order |
|
||
| `p1_net_entity` | | `ha_signed` only. The **signed** net-power sensor: `+` import, `-` export |
|
||
| `p1_phase_net_entities` | `[]` | `ha_signed` only. L1..L3, in order, each signed the same way. Needed for the capacity-tariff peak on a three-phase connection. **The list length must equal `meter_phases`** |
|
||
|
||
Both per-phase lists are checked against `meter_phases` **once at startup**: a
|
||
list of the wrong length disables P1 ingestion with an error in the log, rather
|
||
than letting every telegram fail its phase-count check one at a time. Leaving
|
||
the list empty is fine and is not an error — you simply get no per-phase
|
||
figures, and therefore no capacity-tariff peak. On a three-phase connection
|
||
that is a much bigger omission than it looks: on a surveyed reading here the
|
||
phases carried 2769 W of import while the connection netted 187 W, so the
|
||
billed quantity is understated roughly fifteenfold if the phases are missing.
|
||
|
||
#### How long a dead meter takes to reach 0 W
|
||
|
||
`meter_max_age_s` and `stale_input_s` **stack**. They are two different clocks
|
||
and neither one is the whole answer:
|
||
|
||
| step | option | default |
|
||
|---|---|---|
|
||
| telegrams stop, P1 sample goes stale, grid power starts reading *missing* | `meter_max_age_s` | 30 s |
|
||
| inputs have been missing long enough for the loop to command 0 W | `stale_input_s` | 15 s |
|
||
| **total, meter death → 0 W commanded by this add-on** | | **45 s** |
|
||
|
||
So in P1 mode `stale_input_s` is *not* "how long inputs may be missing before
|
||
commanding 0 W" measured from the meter dying — it is measured from the moment
|
||
the P1 sample already went stale. Size the pair together: the ESP32's own
|
||
watchdog commands 0 W after ~30 s of silence from this add-on regardless, and
|
||
that layer is unaffected by either option.
|
||
|
||
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:
|
||
|
||
```json
|
||
{"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 a `state_changed` arrives,
|
||
> which means it measures *time since the value last changed*, not time since
|
||
> the meter last reported. Home Assistant offers nothing better: a repeated
|
||
> reading produces no `state_changed`, does **not** advance `last_reported` on
|
||
> either the REST or the WebSocket serialiser, and `state_reported` cannot be
|
||
> subscribed to over the WebSocket at all (`Event filter is required for event
|
||
> state_reported`). All three measured on the ENV-01 rig against the real
|
||
> HomeWizard integration with the meter frozen: 0 `state_changed` in 70 s and no
|
||
> timestamp movement anywhere.
|
||
>
|
||
> `ha_dsmr` mostly escapes this because a DSMR telegram updates several entities
|
||
> and something in the set almost always moves. **`ha_signed` has exactly one
|
||
> entity, so a healthy meter under a flat load is indistinguishable from a dead
|
||
> one.** This is not hypothetical: in our own captures
|
||
> (`sim/scenarios/ha-p1_meter_active_power-2026-08-20.json`) the real house meter
|
||
> went **42.2 s and 97.0 s** between changes, and 23 Aug peaks at 29.1 s — all
|
||
> past the default `meter_max_age_s` of 30.
|
||
>
|
||
> So `sensor.p1_sample_age_s` on `ha_signed` is safe to *read*, and it is
|
||
> correct whenever the value is moving, but it must not yet be thresholded by
|
||
> the ESP32 stale-input watchdog: a quiet house would trip the battery to 0 W.
|
||
> Raising `meter_max_age_s` is **not** the fix — the two conditions produce an
|
||
> identical signal, so a bigger number only chooses which of the two errors you
|
||
> get. The real fix is an arrival stamp the meter itself provides — and that now
|
||
> exists: **`meter_source: homewizard_local`**. If you have a HomeWizard P1,
|
||
> switch to it. If your meter is only reachable through Home Assistant, this
|
||
> limit still applies to you and the watchdog threshold still must not be armed.
|
||
|
||
**Where the age is trustworthy:**
|
||
|
||
| mode | the age measures | safe to threshold from firmware |
|
||
|---|---|---|
|
||
| `homewizard_local` | time since the meter **answered** | **yes** — every HTTP response is an arrival |
|
||
| `ha_dsmr` | time since one of several entities changed | no — statistically usually fine, which is a masked bug, not an absent one |
|
||
| `ha_signed` | time since the one entity changed | **no** — see above |
|
||
| `mqtt_p1` | time since a message arrived | arrivals yes, but a stuck bridge republishing keeps arriving |
|
||
|
||
### Control
|
||
|
||
| option | default | meaning |
|
||
|---|---|---|
|
||
| `max_w` | 2000 | Hard limit on what may be commanded. Start low, raise after commissioning. **Not read when `inverters` is declared** — each entry carries its own, and the site ceiling is their sum |
|
||
| `gain` | 0.6 | Correction per cycle. **At the limit — do not raise** |
|
||
| `slew_w` | 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 |
|
||
| `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_entity` reading, register 0x0518). This catches *"this inverter is
|
||
not doing what it was told"*. Only that inverter is parked; the site keeps
|
||
running on the one that works. Thresholds: `unit_sign_disagree_w` /
|
||
`unit_sign_disagree_s`.
|
||
- **Site-wide** — the **sum** of what every inverter has standing, against the
|
||
meter. This catches *"the site as a whole is pushing the wrong way"*, which no
|
||
per-inverter check can see because every unit may be faithfully executing its
|
||
share of a number that is itself wrong. **Every** inverter is parked and the
|
||
banner names the site rather than an inverter. Thresholds:
|
||
`site_sign_disagree_w` / `site_sign_disagree_s`, latched under flat
|
||
`site_sign_tripped` in `/data/state.json`.
|
||
|
||
Both latch, both are cleared by the same *stop, then start*, and both survive a
|
||
restart. **On a one-inverter install neither pair does anything at all** - the
|
||
two questions are the same question there, and only the check described above is
|
||
armed.
|
||
|
||
This is **not** the circulating-power alarm further down: that one compares the
|
||
inverters *against each other* and catches them pushing power through each other,
|
||
which nets to zero at the meter and which no command is evidence about. It is
|
||
also not the saturation freeze, which is a *magnitude* test that shapes the loop
|
||
rather than a *direction* test that parks an inverter.
|
||
|
||
**It does not clear itself, and restarting the add-on does not clear it either.**
|
||
Pressing *Start / stop control* twice — stop, then start — is the only thing that
|
||
does. That is deliberate on both counts:
|
||
|
||
- Commanding 0 W removes the command side of the comparison, so the disagreement
|
||
always vanishes the instant the trip works. A watchdog that took that as proof
|
||
the fault was over would hand the inverter straight back to the loop that was
|
||
driving the wrong way, three minutes later, forever.
|
||
- The trip is written to `/data/state.json` — under
|
||
`unit/<inverter>/sign_tripped`, one key per inverter since DEV-02, so a trip
|
||
caused by one inverter cannot latch another — and restored on startup. The
|
||
pre-DEV-02 flat `sign_tripped` is written and cleared alongside it as the
|
||
**rollback view**, so downgrading the add-on past DEV-02 still finds the
|
||
latch, and a trip taken while the install is rolled back is re-armed by the
|
||
version that comes back up rather than silently lost. Restarting
|
||
the add-on is the first thing most people try when they see a red banner, and
|
||
a latch that a restart clears would bring the controller back **armed, into the
|
||
same fault, unattended** — and under a Supervisor that restarts a crashed add-on
|
||
automatically, would do so in a loop. After a restart the banner, the 0 W and
|
||
the recorded reason are all still there.
|
||
|
||
**What it is for.** This is a loop-integrity check: it asks whether the loop's
|
||
output direction still matches the input state it was regulating. It catches a
|
||
polarity error anywhere between the meter and the wire, and an integrator so far
|
||
wound up that unwinding takes minutes. It is the check that would have caught all
|
||
four episodes of 2026-08-20, when a commercial controller charged the battery out
|
||
of the grid, four times, for up to 77 minutes at a time.
|
||
|
||
**What it is not for.** It does not detect an inverter that acts opposite to what
|
||
it was told — that moves the meter *further* the same way, so command and meter
|
||
agree while everything runs away. That is the saturation freeze's job
|
||
(`saturation_w` / `saturation_cycles`), which compares the command against the
|
||
battery's actual power. The two checks are complementary and neither replaces the
|
||
other.
|
||
|
||
**Why it will not false-trip on ordinary operation.** A battery that refuses a
|
||
command (empty, full, standby) leaves the meter where it was, so command and
|
||
meter agree. A frozen meter drives the loop into agreeing with it. Around the
|
||
resting point both signals are inside the 300 W gate, so the deadband can never
|
||
produce one. A load step does flip the meter's sign before the loop catches up —
|
||
measured on a full captured day, the longest such crossing is 30 s, against a
|
||
180 s window. And the watchdog is **disarmed whenever the grid-following loop is
|
||
not what is driving**: a maintenance grid charge opposes the meter by design and
|
||
must not be mistaken for a fault.
|
||
|
||
Both numbers are measured rather than chosen, and are exposed only because the
|
||
ticket requires defaults rather than constants. On the captured healthy day sign
|
||
agreement is 93.4 %; on the four captured fault clusters it is 0.3 %, 0.9 %,
|
||
10.5 % and 0.0 %. There is no reason to move either value without a capture that
|
||
says so.
|
||
|
||
#### 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 |
|
||
| `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: 20` is *"the
|
||
capacity-peak guard will not spend the pack for money below 20 %"*, and
|
||
**nothing more**. See below for what that costs on a real evening.
|
||
- **When it binds you are told**, once per episode, with notification
|
||
`goodwe_peak_soc_floor` and a status-page row reading `PEAK 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 — register `0x0560` on the
|
||
reference GW5000S-BP, consistent with DoD 90. It exists so the add-on can
|
||
**refuse to start** when `peak_soc_floor` is at or below it: such a floor can
|
||
never be the thing that stops the discharge, and a guard that advertises a
|
||
backstop it does not have is the one thing this project will not ship. Equal is
|
||
refused too — equal buys zero margin.
|
||
|
||
Why 20 %: the reference pack is 13.8 kWh usable, so 10 points of margin over the
|
||
BMS cut-out is about 1.6× the guard's worst measured day. If your pack is
|
||
smaller, or your quarters are worse, raise it.
|
||
|
||
#### ⚠️ What `peak_soc_floor` does **not** protect (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`](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_profile` below needs no network at all, so its cheap window is
|
||
> computed on every cycle and reaches the maintenance cycle's grid top-up
|
||
> whether or not arbitrage is armed.
|
||
|
||
| 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:
|
||
|
||
```csv
|
||
start,eur_per_kwh,provisional
|
||
2026-01-15T00:00:00+01:00,0.1834,false
|
||
2026-01-15T01:00:00+01:00,-0.0210,true
|
||
2026-01-15T02:00:00+01:00,0.3115,false
|
||
```
|
||
|
||
- `start` **must carry its UTC offset** (`+01:00` in winter, `+02:00` in
|
||
summer). A bare `2026-01-15 00:00` is refused, because for one hour every
|
||
October a local wall clock names two different moments.
|
||
- `eur_per_kwh` is **all-in** — energy *plus* markup *plus* taxes. This plugin
|
||
has no way to know what your numbers already include, so it assumes
|
||
everything and adds nothing.
|
||
- Negative prices are valid and are kept. They are the best charging hours of
|
||
the year.
|
||
- `provisional` is optional; omit the column and every hour is treated as
|
||
settled.
|
||
|
||
**When there are no prices at all** — provider off, feed unreachable, CSV
|
||
missing — the add-on does not guess. It reports *no price data*, which is a
|
||
distinct state from "prices, all of them zero": the plan is marked provisional
|
||
and arbitrage is disabled, per the degradation matrix. It never falls back to a
|
||
price of zero, which would read as free electricity.
|
||
|
||
**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:
|
||
|
||
```json
|
||
{"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:
|
||
|
||
```json
|
||
{"periods": [
|
||
{"name":"fixed","from":"00:00","to":"24:00","days":"all","eur_per_kwh":0.31}]}
|
||
```
|
||
|
||
- `eur_per_kwh` is **all-in** — energy *plus* your supplier's markup *plus*
|
||
taxes — exactly like `manual_csv`. `price_markup_eur_kwh` and
|
||
`price_taxes_eur_kwh` are **not** added to it.
|
||
- `days` takes `mon`…`sun`, ranges (`mon-fri`, `fri-mon`), comma lists
|
||
(`sat,sun`) and the words `weekday`, `weekend`, `all`.
|
||
- Times are **local Belgian time** and windows are half-open: `07:00`–`22:00`
|
||
includes 07:00 and excludes 22:00. A window may cross midnight.
|
||
- The clock changes twice a year and the add-on handles it. It generates prices
|
||
against absolute instants, so a spring-forward day is 23 hours and an
|
||
autumn-back day is 25.
|
||
|
||
> ⚠️ **A window belongs to the day the clock reads, not the day it started.**
|
||
> `22:00`–`07:00` on `mon-fri` covers Friday 23:00 but **not** Saturday 03:00 —
|
||
> Saturday 03:00 is Saturday's. That is why the example above has a third row
|
||
> for the weekend. Leave it out and the add-on **refuses to start**, naming the
|
||
> uncovered hours.
|
||
|
||
**Every day type must be covered exactly once.** The periods have to tile all
|
||
seven days with no gap and no overlap, and the add-on checks that at startup. A
|
||
gap is refused rather than filled in, because a made-up price is one the
|
||
optimiser would trade on; an overlap is refused because otherwise one of the two
|
||
prices silently wins on the order you typed them in.
|
||
|
||
**What it changes today.** The add-on works out which window you are in and
|
||
whether that window is *cheap* — strictly below the tariff's seven-day average.
|
||
The Web UI shows both on a new **Tariff** row, and the maintenance cycle's
|
||
cheap-window grid top-up uses it, which is exactly the "shift load into the
|
||
cheap window" behaviour a day/night tariff asks for. Nothing else moves energy
|
||
on a price yet.
|
||
|
||
Under a **fully fixed** tariff every hour equals the average, so there is *no*
|
||
cheap window by construction and the add-on runs self-consumption only — which
|
||
is the correct behaviour for that product, not a limitation. Peak shaving, the
|
||
capacity-tariff tracker, the reserve floor and the maintenance schedule are all
|
||
unaffected: the capacity tariff is a network cost charged whatever your energy
|
||
contract is.
|
||
|
||
**Recommended even on a dynamic contract.** When a price feed is unreachable the
|
||
add-on falls back to your static profile, marks the plan provisional and
|
||
disables arbitrage. With `static_profile` empty there is nothing to fall back
|
||
to, and the site simply has no tariff until the feed returns. It is not required
|
||
— the add-on warns rather than refusing — but filling it in is what makes that
|
||
fallback work.
|
||
|
||
### Price arbitrage (`arbitrage_enabled`)
|
||
|
||
§10.1 priority 3: charge in the cheapest published hour, discharge in the
|
||
dearest, **and only when the spread covers what the round trip costs.** Off by
|
||
default — this is the first strategy in the add-on that commands the battery
|
||
*against* the meter on purpose, so it is armed deliberately or not at all.
|
||
|
||
| option | meaning |
|
||
|---|---|
|
||
| `arbitrage_enabled` | `false` by default. Arms the strategy |
|
||
| `cycle_cost_threshold_eur` | What one full round trip costs per kWh — pack wear plus conversion losses. A spread below this is not traded |
|
||
| `arbitrage_soc_floor` | The reserve arbitrage may not spend, % |
|
||
| `arbitrage_soc_ceiling` | The headroom it may not overfill, % |
|
||
|
||
**What it needs, and what the add-on refuses.** Arbitrage requires a price
|
||
source (`price_provider` or `static_profile`) and a P1 meter (`meter_source`).
|
||
Without a price source there is nothing to tell cheap from expensive; without
|
||
P1 the capacity-tariff peak guard never runs, and §9.1 puts peak shaving above
|
||
arbitrage — a charge that nothing measures is exactly what that guard exists to
|
||
prevent. Both combinations are **refused at startup** rather than left looking
|
||
armed.
|
||
|
||
**Peak shaving always wins.** The strategy cannot obtain a spread at all
|
||
without first answering whether the peak guard is holding, and even if it
|
||
could, the guard's floor is filed at a higher arbiter priority and clamps an
|
||
arbitrage charge. Discharging is never capped by it — only offtake is billed.
|
||
|
||
**It never exports into a negative price** (§17). A negative hour is the best
|
||
possible hour to *charge* in and that is still allowed; selling into one gives
|
||
energy away and pays for the privilege.
|
||
|
||
**At the shipped threshold a static day/night site does not trade.** The
|
||
realistic Belgian day/night spread is about 0.08 EUR/kWh and the default
|
||
threshold is 0.10, so the honest answer on such a site is "no opportunity", and
|
||
the status page says so with the numbers. Whether 0.10 is the right default is
|
||
a product decision, not a bug: lower it only if you know what a cycle costs
|
||
your pack.
|
||
|
||
**The prices are fetched in the background**, never on the control tick — a
|
||
supplier's TLS timeout on that path would be a supplier able to push the add-on
|
||
towards the firmware's 30 s failsafe. The plan refreshes every 30 minutes and
|
||
retires itself if the refresh stops answering, at which point arbitrage stops
|
||
with it.
|
||
|
||
### 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.
|