checks / unit (pull_request) Successful in 7m10s
HIGH - a non-UTF-8 byte in /data/price_history.json stopped the add-on
from starting. `_load()` caught `(json.JSONDecodeError, OSError)`.
`UnicodeDecodeError` is `ValueError -> UnicodeError -> UnicodeDecodeError`:
neither of those, and `issubclass(UnicodeDecodeError, OSError)` is False. So
it escaped `_load()` -> `_open()` -> `PriceHistory.__init__`, which
`Controller.__init__` does not guard - breaking `_load()`'s own stated
contract, "a corrupt cache must not stop the controller". The handler now
names the ValueError FAMILY (the file's content) beside OSError (its bytes),
because a third subclass nobody has thought of is how this arrived the first
time. The suite could not see it: its "unreadable file" fixture writes
"{ this is not json", which is valid UTF-8 and fails one step later, in
`json`. A fixture holding a genuinely undecodable byte is now beside it,
with the difference between the two asserted as a precondition.
MEDIUM - a malformed `start` under the day the feed is writing to killed the
tariff plan permanently. `_remember()` merged with `{q["start"]: q ...}` and
sorted on `q["start"]`, trusting a value read off /data to be a hashable,
sortable string. An int, null, a dict or a list raised TypeError out of
`carry()` -> `resolve_tariff()` -> `run_tariff_refresh`, which catches
Exception - so the add-on stayed UP while every refresh raised forever, the
plan retiring after TARIFF_MAX_AGE_S with arbitrage and the cheap window off
for good, and no self-heal because `_remember()` raised before `_save()`.
The read path was properly defensive throughout, which is why `yesterday()`
survived and the existing fixture passed - it files its malformed rows under
a day the feed is NOT writing to. Both paths now share one validator,
`_point_of()`. A row that is not a point is dropped, reported, and written
back out clean.
...and the status row said the store was healthy while the plan was dead:
`words` read '2026-01-15 (1 h)'. `_load()` now counts the rows inside a kept
record as well as the record itself, so the store stops reporting plain
health from the moment it is opened; the note names what was FOUND at open
and what was DROPPED by the refresh, and clears once the file is rewritten.
...and the merge key is the validated INSTANT rather than its spelling,
which is what rule 4 already said. The lexicographic sort beside the string
key also put the second 02:00 of the 25-hour October day (+01:00) before the
first (+02:00); stored order only, since `_parse_points()` re-sorts.
LOW - the status row counted POINTS and printed them under the HOUR's name:
'2026-01-13 (96 h)' for a 24-hour day on the quarter-hourly feed this add-on
already supports. It is now '96 points over 24 h' - two facts, each under
its own unit.
LOW - two stated reasons were false. `_audit`'s "WHY IT IS STRICTER" claimed
the rework refused the same published point emitted twice and a carried
point sharing a published hour, "which the old count never looked at". A
differential over the whole domain the audit can express finds ZERO
divergence in both directions: the old `seen` count already refused the
first (one point twice is one hour twice) and the old IDENTITY rule already
refused the second. The reworked audit is EQUAL to FCST-03's, not stronger;
the only behavioural change is the intended relaxation, and it stays. The
differential is committed as a check rather than left as prose.
...and the audit's real limit is named: it does not check COMPLETENESS. Fed
the decimation defect FCST-04 was raised for it ACCEPTS, and it accepts an
empty output. So `_audit` is not what stops that recurring - the count and
identity checks are, and the text now points at them. Not made complete:
`published` is the whole read while the fill emits only [since, until), so
the rule as stated is wrong, and building it takes the 1/12/48 h horizon
checks red on a healthy feed. Measured, not assumed.
12 mutations, purged tree, every one RED, no survivors. All 29 test files
green. Nothing deployed; no option added; config.yaml untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
1867 lines
113 KiB
Markdown
1867 lines
113 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
|
||
|
||
### ⚠️ There are TWO places to configure this add-on (UX-02)
|
||
|
||
Since **UX-02** the ingress panel has a **Configuration** page of its own,
|
||
beside Home Assistant's *Configuration* tab. Both edit the **same** settings —
|
||
there is one options blob, and neither surface is a copy of it.
|
||
|
||
| | Ingress panel → **Configuration** | Supervisor → **Configuration** tab |
|
||
|---|---|---|
|
||
| **Use it for** | everyday editing | recovery, and when the add-on is down |
|
||
| **Sections, units, help** | real headings, units beside the boxes, the description under each field | one flat list of 71 fields, no headings, no units |
|
||
| **Tells you a setting is wrong** | **yes — before you save**, next to the field | no. It knows types and ranges only |
|
||
| **Can express a rule spanning two keys** | yes, because it runs the add-on's own validator | **no.** The schema grammar cannot say "must be at or below `max_w`" |
|
||
| **Available when the add-on is not running** | **no** — it is served *by* the add-on | **yes** |
|
||
| **Editing the inverter list** | cards you add and remove, with the per-entry labels rendered | a repeatable section whose per-entry keys render **raw** on frontend 20250925.1 |
|
||
|
||
**Which one is authoritative: neither.** The stored options blob is, and both
|
||
are editors of it. `DECISIONS/UX-02-two-config-surfaces.md` is the record, with
|
||
the measurements behind it.
|
||
|
||
The one thing you need to know in practice: **the panel refuses to save a
|
||
configuration the add-on would not start on, and the Configuration tab does
|
||
not.** The tab will happily store `maintenance_charge_w: 2500` beside
|
||
`max_w: 2000`; the add-on then exits 78 on its next start, the panel goes down
|
||
with it, and the tab is where you fix it. That asymmetry is the reason both
|
||
exist.
|
||
|
||
#### What the panel does when you press Save
|
||
|
||
1. It runs the **shipped** validator — the same `validate_startup_config()` and
|
||
`validate_unit_ranges()` the add-on runs at startup, not a copy of them — and
|
||
if anything is refused, **nothing is written** and your edits stay on screen.
|
||
2. It re-reads the stored blob and refuses if it changed while you were editing
|
||
— someone in the Configuration tab, or you on another device. It names the
|
||
keys that moved and shows both values; pressing Save again then writes yours.
|
||
3. It writes the whole blob. It does **not** restart: `POST …/options` replaces
|
||
the stored options, and a key left out would revert to the shipped default,
|
||
so the panel always sends everything it read.
|
||
|
||
#### ⚠️ Saving costs nothing. Applying costs a control interruption
|
||
|
||
Stored settings take effect when the add-on next starts. The panel shows which
|
||
stored settings are not what the add-on is currently running, and **Restart to
|
||
apply** is a separate button with its own confirmation — because the wind-down
|
||
commands **0 W on every inverter** before the process exits, and the battery
|
||
does nothing until it is back. Nothing else is changed by that restart: a
|
||
latched sign-disagreement failsafe is restored from the Store on the way back
|
||
up, not cleared.
|
||
|
||
#### Two range checks can be skipped, and the page says so
|
||
|
||
Two of the refusals compare a configured ceiling against the setpoint device's
|
||
**own** reported range, which means asking Home Assistant. If ESPHome has not
|
||
come up, that range is unknown and those two checks are skipped — on the page
|
||
and at startup, identically. The page prints a note saying so rather than
|
||
looking green for a reason you cannot see. If the device answers later and
|
||
disagrees, the add-on refuses to start then, with the same sentence.
|
||
|
||
#### On a phone
|
||
|
||
One column, big targets, sections collapsed until you open them, and the
|
||
refusals pinned where you cannot scroll past them. It is the screen for
|
||
standing in front of the inverter.
|
||
|
||
### What the Configuration tab actually shows you (UX-01)
|
||
|
||
Since **UX-01** the add-on ships `translations/en.yaml` and `translations/nl.yaml`,
|
||
so Home Assistant's *Configuration* tab renders human labels and a description
|
||
for every option instead of the raw option keys. What that does and does not buy
|
||
you is worth stating plainly, because it was **measured against the Supervisor
|
||
image this site runs** (`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`)
|
||
rather than assumed — `tools/supervisor_shape_probe.py` section F GRADES (it
|
||
used only to print) the
|
||
whole rendered list, in both languages.
|
||
|
||
| | |
|
||
|---|---|
|
||
| **All 71 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
|
||
| **Order comes from `schema:`** | `App.schema_ui` is `UiOptions(...)(data["schema"])`, so the **schema half** of `config.yaml` decides the order the tab renders in — not the `options:` half |
|
||
| **There are no section headers** | Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by **order** plus the `Group · ` prefix in each label, and by nothing else |
|
||
| **Groups, in task order** | Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced |
|
||
| **The description says whether to touch it** | A number that was **derived** names the campaign it came from and says not to change it without re-deriving; a number that is genuinely yours (`max_w`, `peak_cap_w`) says **"THIS IS YOUR NUMBER"**; and `cycle_cost_threshold_eur`, which is neither, says it is an open product question |
|
||
| **Dutch falls back per key** | `translations[your language]?.name \|\| translations["en"]?.name \|\| the raw key`. A key missing from `nl.yaml` degrades to English; a key present but wrong does not degrade at all |
|
||
| **⚠️ The per-entry keys inside `inverters:` still render raw** | Measured, and negative. A list-of-objects becomes an `object` selector with `multiple: true`, and `ha-selector-object` labels its fields `fields[name].label \|\| name` — the converter sets no `label` and no `translation_key`. The `fields:` translations are shipped and validated (they are the shape Supervisor's own schema defines) but on frontend 20250925.1 they do **not** reach the screen. The `inverters` label and description themselves do |
|
||
|
||
`test_translations.py` is what stops this rotting: a translation naming an
|
||
option that does not exist, or an option with no translation, fails CI — in
|
||
either direction, and the check is mutation-tested both ways.
|
||
|
||
### Sources
|
||
|
||
| option | required | meaning |
|
||
|---|---|---|
|
||
| `meter_entity` | yes | Net grid power. **Positive must mean importing** |
|
||
| `meter_invert` | | Flip the sign if the meter reports the other way |
|
||
| `soc_entity` | yes | Battery state of charge — use the **ESP32's own read** |
|
||
| `batt_entity` | yes | Battery power — again the ESP32's read, `+` = discharging |
|
||
| `batt_invert` | | Flip if needed |
|
||
| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below |
|
||
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
|
||
|
||
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
|
||
capacity_kwh: 15.36
|
||
- name: inverter-b
|
||
setpoint_entity: number.goodwe_b_setpoint_w
|
||
soc_entity: sensor.goodwe_b_soc
|
||
batt_entity: sensor.goodwe_b_battery_power
|
||
max_w: 4600
|
||
capacity_kwh: 20.48
|
||
```
|
||
|
||
⚠️ **The 4600 W is inverter B's *datasheet* figure, not a measured one.** B is a
|
||
GoodWe GW5048D-ES (serial `55048ESU22AW0992`, identified 2026-09-04) whose
|
||
datasheet gives max charging and discharging power as 4600 W; with nothing
|
||
connected to its PV inputs the battery path is the ceiling, not the 5000 VA AC
|
||
nameplate. The datasheet prints that number under *Battery Input Data*, so it
|
||
may be a DC figure, while `max_w` is graded against register `0x0518` — the
|
||
inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
|
||
`max_w: 4600` is still the right thing to configure, because the freeze rail is
|
||
`min(max_w, dev_max)` and the device range read off the unit is what binds.
|
||
`goodwe/NOTES.md` §1.1b records both readings and picks neither.
|
||
|
||
| per-entry key | required | meaning |
|
||
|---|---|---|
|
||
| `name` | yes | Short identifier. It namespaces this unit's SAFETY-02 latch, heartbeat and write bookkeeping in the add-on's state file, so **two units may not share one** |
|
||
| `setpoint_entity` | yes | This inverter's own ESPHome `number.*_goodwe_setpoint_w` |
|
||
| `max_w` | yes | The supervised cap on what may be written to **this** inverter. The site's ceiling is the **sum** |
|
||
| `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.**
|
||
|
||
#### How the site target is divided between the packs (OPT-09)
|
||
|
||
Two packs do not get half each **from 0.8.0 onwards**. ⚠️ **On 0.7.0 and every
|
||
version before it the split is a fixed 50/50 regardless of what either pack can
|
||
do** — that is the defect this section describes the fix for, so if the add-on
|
||
you are running reports 0.7.0, none of what follows is happening yet. From 0.8.0
|
||
the split is driven by two separate things, and it is worth knowing which is
|
||
which, because only one of them is a rule:
|
||
|
||
* **The envelope — a permission.** A pack whose own state of charge is at or
|
||
under its own `bms_soc_floor` is **not asked to discharge**; a pack at 100 %
|
||
is **not asked to charge**. Its share goes to the sibling, inside that
|
||
sibling's own rating. This is absolute: the allocator cannot exceed it.
|
||
* **The weights — a preference.** Between those limits, each pack is asked for
|
||
the fraction of the site target that matches its **energy headroom**: the kWh
|
||
it has above its own cut-out when the site is discharging, the kWh it has
|
||
below full when the site is charging. That is what `capacity_kwh` is for.
|
||
|
||
⚠️ **`capacity_kwh` is optional, and it is all-or-nothing.** Leave it out
|
||
everywhere and the add-on has no opinion about the split — the target is divided
|
||
evenly, exactly as it was before. Fill it in on **every** entry or on none: a
|
||
pack measured in kWh beside one that is not is not a comparison, and the add-on
|
||
refuses to start rather than ignore half of what you typed.
|
||
|
||
⚠️ **Why energy and not the rating.** The two packs here pull in opposite
|
||
directions — A is 5000 W with 15.36 kWh, B is 4600 W with 20.48 kWh, so B has
|
||
about a third more energy and about 8 % less power. Dividing by rating hands the
|
||
work to A; dividing by energy hands it to B. The question a split is answering is
|
||
"which pack can sustain its share for the hour", so it is the energy one. Each
|
||
inverter's own rating still binds its own share, separately, through `max_w`.
|
||
|
||
⚠️ **What the weights will NOT do for you, stated plainly.** They are a comfort
|
||
choice about which pack does the work. They are **not** a way to make the site
|
||
deliver. A unit that is at its energy limit — or that has simply stopped
|
||
responding — does not hand its load to its sibling: the loop sees the shortfall,
|
||
freezes, and the site runs short by that unit's share. Weighting a failing unit
|
||
down does not fix that, because when the healthy unit reaches its own rating the
|
||
allocator hands the remainder to the down-weighted one anyway rather than leave
|
||
watts unplaced. What you get instead is honesty: the action log reports the watts
|
||
that could not be placed (`ALLOCATOR: +2000 W of the site target could not be
|
||
placed on any inverter`), and a unit that accepts its share and does not produce
|
||
it is named by the delivery alarm (SAFETY-42, above).
|
||
|
||
⚠️ **At one inverter none of this applies and nothing changed.** There is no
|
||
split to weight, `capacity_kwh` is not read, and no envelope is closed —
|
||
self-consumption still discharges down to the battery's own cut-out exactly as
|
||
it always has.
|
||
|
||
### What appears in Home Assistant at two inverters
|
||
|
||
**Nothing you already have changes.** The eight entities this add-on has always
|
||
published — `Setpoint`, `Grid power`, `Battery power`, `Battery SoC`,
|
||
`Circulating power`, `Maintenance phase`, `Controller status`, `P1 sample age` —
|
||
keep their device, their `unique_id`s and therefore their **entity ids and their
|
||
whole recorded history**. At a roster of one, nothing new is announced at all:
|
||
no second device, no renamed entity, not one added topic.
|
||
|
||
Declare a second inverter and **four rows per unit** appear beside them:
|
||
|
||
| per-unit row | what it is |
|
||
|---|---|
|
||
| `Setpoint` | this unit's own share of the site target — the number its heartbeat re-emits |
|
||
| `Battery power` | this unit's own reading, `+` = discharging |
|
||
| `Battery SoC` | **this** pack. The site `Battery SoC` row is the *emptiest* pack, which may be the other one |
|
||
| `Sign latch` | this unit's SAFETY-02 latch: `tripped` or `clear` |
|
||
|
||
⚠️ **The devices are deliberately asymmetric, and it is not an oversight.** The
|
||
first inverter's rows are added to the **existing** *GoodWe* device; every later
|
||
inverter gets its **own** device (*GoodWe Inverter B*), shown in Home Assistant
|
||
as connected via the controller. Home Assistant composes an entity id from the
|
||
**device name plus the entity name**, so giving the first inverter a device of
|
||
its own would rename every entity you already have — and because HA keeps the id
|
||
it composed at first discovery, you would get a new entity beside a dead one:
|
||
the history stays on the old id and every dashboard, template and automation
|
||
naming it silently stops updating. The entity ids come out symmetric anyway —
|
||
`sensor.goodwe_inverter_a_battery_power` beside
|
||
`sensor.goodwe_inverter_b_battery_power`.
|
||
|
||
**Do not rename the device.** Not in this add-on, and not in Home Assistant's
|
||
device page. It is half of every entity id, including
|
||
`sensor.goodwe_p1_sample_age`, which the ESP32's stale-input watchdog subscribes
|
||
to; a rename disarms that guard by leaving it waiting for a state that never
|
||
arrives, and it fails silently and looks correct.
|
||
|
||
**`Grid power` and `P1 sample age` stay site-scoped and are announced once.**
|
||
There is one P1 meter and one grid connection; a per-inverter copy would invent
|
||
two of a thing there is one of, and `P1 sample age` is a safety input. Same for
|
||
`Circulating power` (it is a property of the *set* of inverters), the controller
|
||
status, the maintenance phase and the capacity-tariff peak.
|
||
|
||
⚠️ **Two roster names that differ only in punctuation or case** — `inverter-b`
|
||
and `Inverter B` — are two names to the validator and one id once slugged. The
|
||
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
|
||
get then depend on roster order. Give your inverters plainly different names.
|
||
|
||
### P1 meter ingestion
|
||
|
||
`meter_entity` above expects one signed sensor, which usually means a template
|
||
someone wrote by hand. Setting `meter_source` moves the whole derivation into
|
||
the add-on, where it is done once and tested, and replaces `meter_entity`
|
||
entirely.
|
||
|
||
Which mode you want depends on what your P1 reader publishes, and there are two
|
||
shapes in the wild:
|
||
|
||
- **Two unsigned registers**, consumption and injection, which is what a Belgian
|
||
P1 read over DSMR gives you → `ha_dsmr`, 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.
|
||
|
||
#### How long a FROZEN inverter power register takes to reach 0 W
|
||
|
||
A third clock, and it is a different failure from the one above: the entity is
|
||
still there and still answering — it has simply stopped changing. Before
|
||
SAFETY-37 that read as a healthy measurement for ever, which is what happened on
|
||
2026-09-02 (HW-01): a value held for 6 h 25 m while the pack drained to its
|
||
floor, and every detector read healthy throughout.
|
||
|
||
`Inverter.batt` — the inverter's own AC power, register `0x0518` — is now
|
||
withheld from the control path when **all three** of these are true:
|
||
|
||
| | |
|
||
|---|---|
|
||
| the reading has not changed for | **35 s** |
|
||
| **and** the command standing on that inverter is over | **250 W** |
|
||
| **and** that command has travelled, since the reading last moved, over | **250 W** |
|
||
|
||
All three, because a converged site legitimately repeats one power figure for
|
||
minutes — measured at up to **263 s** over 30 days of this house's own meter —
|
||
and a site at rest is not owed movement. It is only when the controller has
|
||
*asked the inverter to move* and the register has not answered that the reading
|
||
is treated as missing.
|
||
|
||
**A withheld reading is a MISSING INPUT**, so it joins the `stale_input_s` clock
|
||
above: 35 s to withhold, then 15 s of hold, then 0 W. It clears itself the
|
||
instant the register reports a different value — one watt is enough, and this
|
||
register dithers ±1 W while holding — so a working inverter clears it on its
|
||
next 10 s publish. There is **no option to configure**: the 35 s is derived from
|
||
the ESPHome publish interval and the two watchdog windows it has to stay inside,
|
||
and the add-on's own checks refuse a value that does not.
|
||
|
||
⚠️ **What this does NOT catch: a register whose value is WRONG rather than
|
||
STOPPED**, including a dead publisher whose last two samples happen to
|
||
alternate. Detecting that needs an arrival stamp inside the ESP32 firmware
|
||
itself, which is TEL-06.
|
||
|
||
There is **no fallback to an inverter-side power figure**, deliberately. The
|
||
inverter's own AC power tracks its battery almost perfectly and the real meter
|
||
hardly at all, so a controller that failed over to it would be regulating
|
||
against its own output while looking healthy.
|
||
|
||
The `mqtt_p1` payload is one JSON object per telegram, and the schema is strict —
|
||
a key it does not recognise is a telegram from something other than what was
|
||
tested, and guessing a key here means guessing a kilowatt:
|
||
|
||
```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` | 0 | Largest change in one cycle. **0 means follow the rail** - `max_w / 9`, i.e. the loop takes the plant's own measured ~18 s settling time to cross its whole output range at the 2 s tick the live install runs. 222 W at `max_w: 2000`, 556 W at 5000, 1067 W at a ~9600 W two-inverter site. A fixed number is accepted and never silently reduced, but one FASTER than the rail implies is refused at startup (SAFETY-39): `slew_w: 1000` against `max_w: 5000` crossed the rail in 10 s against an 18 s plant, and on 2026-09-05 drove the meter from 3.4 kW of export to 2.8 kW of import |
|
||
| `deadband_w` | 15 | Ignore errors smaller than this |
|
||
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
||
| `step_w` | 10 | Quantisation |
|
||
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
|
||
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1**. Every value this file accepts (1-10) is below the freeze's own release bound of 12 cycles, so no accepted setting can stop the block engaging - see SAFETY-13, below |
|
||
| `integrator_max_w` | 0 | Bound on the loop's accumulator, and 0 means "same as `max_w`". Caps how much stale error can be waiting to unwind when the sign flips. **Do not raise it above `max_w`** - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below `max_w` is the useful direction |
|
||
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
|
||
| `cycle_floor_s` | 15 | The longest the loop may go without evaluating. A deadline on the gap between cycles, reset by every cycle — on a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than ~10 s; it must stay under `meter_max_age_s`, past which there is no reading to evaluate anyway |
|
||
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W. In P1 mode this clock starts only *after* `meter_max_age_s` has already expired — the two stack, see "How long a dead meter takes to reach 0 W" |
|
||
| `auto_start` | false | Start controlling on boot (only after commissioning) |
|
||
| `sign_disagree_w` | 300 | Both the command and the meter must exceed this before a sign difference counts. Must stay **below `max_w`** or the watchdog can never fire, and the add-on refuses to start |
|
||
| `sign_disagree_s` | 180 | How long they must stay opposed, continuously, before the failsafe trips |
|
||
| `unit_sign_disagree_w` | 250 | **Two or more inverters only.** Both a unit's own share and its own measured power must exceed this before their sign difference counts. It is `saturation_w / 2`, so the smallest divergence it can see is exactly the gap at which the loop already calls the inverter "not following". Must stay **below `max_w`** or it can never fire, and the add-on refuses to start |
|
||
| `unit_sign_disagree_s` | 85 | **Two or more inverters only.** How long one unit's command and its own power must stay opposed before that unit is parked |
|
||
| `site_sign_disagree_w` | 300 | **Two or more inverters only.** Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as `sign_disagree_w`, for the same reason: near zero the sign is noise |
|
||
| `site_sign_disagree_s` | 325 | **Two or more inverters only.** How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than `sign_disagree_s` because the command has to cross zero over a rail that is the sum of the roster, at a `slew_w` that is not - measured at 110 s with one inverter and 215 s with two |
|
||
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
|
||
|
||
#### 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.
|
||
|
||
|
||
#### ⚠️ A unit that stops DELIVERING is a third fault again (SAFETY-42)
|
||
|
||
The two checks above both key on **direction**. A unit sitting at **0 W against
|
||
a non-zero share** has no direction: it is not pushing the wrong way, it is
|
||
**absent**. So the per-unit sign gate never opens (it needs opposition on both
|
||
sides of `unit_sign_disagree_w`), the site sum still agrees with the meter in
|
||
direction, and circulating power is exactly **zero** — that quantity needs two
|
||
units pulling *against* each other, and one of these is pulling nowhere.
|
||
|
||
The only thing that does fire is the **saturation freeze**, and this add-on's own
|
||
code calls that an alarm operators learn to ignore, because a loop can be legally
|
||
saturated all day: a cold pack, a curtailed inverter, an export limit.
|
||
|
||
So `delivery_hold_s` grades a different quantity — **how many watts of a
|
||
discharge share did not arrive** — and makes it readable with a reduction that
|
||
only exists once there is more than one inverter:
|
||
|
||
> a unit is named while it is short of its share by more than `saturation_w`,
|
||
> **with nothing this add-on can read to explain it**, and the units in that
|
||
> state are **fewer than the units that were asked to discharge**.
|
||
|
||
Both halves matter, and the second is why this cannot fire on a
|
||
one-inverter install at all:
|
||
|
||
- **"nothing explains it"** is what keeps a legitimately empty pack out. A pack
|
||
at or below its own `bms_soc_floor` cannot discharge, and that is the *only*
|
||
fact this add-on holds that distinguishes an empty pack from a wedged one —
|
||
the two are otherwise identical at the unit and at the meter, watt for watt.
|
||
A unit the add-on has already dropped from the split, or parked by a sign
|
||
trip, is likewise accounted for.
|
||
- **"fewer than were asked"** is what keeps a whole-site cause out. A derate this
|
||
add-on cannot see — thermal, grid-code, DC-side — hits every unit. One box that
|
||
has stopped listening does not. If *every* unit that was asked is short and
|
||
unexplained, nothing is reported: there is no instrument here that tells that
|
||
from a plant which simply cannot deliver.
|
||
|
||
**It reports. It does not act.** Commanding an inverter that has stopped
|
||
listening achieves nothing — that happened on this hardware once for 6 h 25 m —
|
||
and the only re-allocation this add-on can express is all-or-nothing, which
|
||
would throw away the output of a unit delivering *part* of its share. So the
|
||
loop is untouched, the sibling keeps regulating, and the site keeps running
|
||
**short by that unit's share** until somebody looks. The status page shows it as
|
||
a caution rather than a failure for exactly that reason: the controller has not
|
||
stopped.
|
||
|
||
The report **survives a Home Assistant restart** (`unit/<name>/delivery_tripped`
|
||
in the add-on's own state file) and the notification is re-raised on the next
|
||
start, because a `persistent_notification` is in memory and that is precisely how
|
||
the 2026-09-02 outage went unseen. It is cleared by pressing **Start**, like the
|
||
other latches.
|
||
|
||
The banner reads **`NOT DELIVERING — <unit> …`** as a *warning*, not a failure,
|
||
and it **displaces** `Running — holding grid at zero`. That last sentence is a
|
||
claim the site cannot make while a unit is absent, and a page that goes on
|
||
reading healthy while the fault runs is the whole shape this check exists to
|
||
remove.
|
||
|
||
**Charge shares are not graded**, and that is a stated gap rather than an
|
||
oversight: there is no configured charge ceiling anywhere in this add-on, so a
|
||
pack in constant-voltage taper — which legitimately takes hundreds of watts less
|
||
than it is told, for tens of minutes — could not be explained, and arming that
|
||
direction would fire on the taper of every maintenance charge.
|
||
|
||
#### Saturation is counted in cycles, not seconds
|
||
|
||
The specification states the saturation window as **"> 10 s"**. This add-on counts
|
||
**cycles** instead, and that is a deliberate, accepted deviation rather than an
|
||
oversight - the acceptance criterion is not met as literally written.
|
||
|
||
A cycle here is one meter update **or `cycle_floor_s` elapsing, whichever comes
|
||
first**. At the reference P1's ~5 s update rate the default of 3 cycles is usually
|
||
around 15 s, and the worst case is bounded at `3 x cycle_floor_s` = 45 s. It is still
|
||
not the 10 s the specification asks for, so the deviation stands - but it is now a
|
||
bounded number rather than an open one.
|
||
|
||
⚠️ **This passage used to say something different, and the second half of it was
|
||
wrong.** Until SAFETY-12 the loop ran only when the meter value *changed*, so a meter
|
||
that repeated a value stalled the counter for as long as it repeated, with no upper
|
||
bound at all. That was defended here on the grounds that "a stalled counter is a
|
||
detection-latency limit and not a runaway risk - the condition that stalls it stops the
|
||
whole loop, so nothing accumulates while it is stalled". That argument covers
|
||
**integrator windup and nothing else**. The hazard it misses is **staleness**: while the
|
||
loop was stopped the heartbeat kept re-emitting the last command and the firmware kept
|
||
putting it on the wire every 2 s, so a command computed from an old reading went on
|
||
being executed through a real load change, with every indicator reading healthy.
|
||
Measured on the rig during TEL-05: 70 s at a literally constant 350.0 W, 14 arrivals,
|
||
zero control cycles.
|
||
|
||
The half that survives is the first one: the control law is still a pure function with
|
||
no clock, and the wall-clock window still lives in `Controller`, which has one - see
|
||
`run_control()`. What changed is that the controller now enforces a floor on the gap
|
||
between cycles, so `saturation_cycles` finally means a bounded number of seconds.
|
||
|
||
If a tighter window matters on your site, lower `cycle_floor_s` (not below your meter's
|
||
update interval, or the floor starts firing on a healthy meter) or raise
|
||
`saturation_cycles` for a fast meter.
|
||
|
||
**This is not a freeze detector, and it deliberately cannot be one.** Nothing here
|
||
decides whether a repeated reading is true — see `unchanged_s` in the meter section for
|
||
why no threshold on that is safe at the converged operating point. The floor only stops
|
||
"the number is the same" from meaning "do not think". The consequence is worth stating
|
||
plainly: against a meter that is genuinely frozen at a wrong value, the loop will now
|
||
correct toward that wrong value instead of holding a stale command. It is bounded by
|
||
`max_w`, the arbiter and the device rating, and it is the same thing that already
|
||
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
|
||
safety property.
|
||
|
||
#### When the freeze lets go, and when it deliberately never does (SAFETY-13)
|
||
|
||
While the loop is frozen the command's **magnitude may fall but never rise**. That is
|
||
the anti-windup rule, and at `prev_w == 0` it reads as a refusal to *start*: with
|
||
nothing commanded there is no magnitude left to fall, so a charge cannot begin. That
|
||
refusal is deliberate. Commanding 0 W while the inverter reports hundreds of watts
|
||
means something is driving the actuator that this add-on did not ask, and starting to
|
||
push power the other way into it is how two controllers end up fighting over one
|
||
actuator — the configuration §1.5 of the specification rules out.
|
||
|
||
**What was missing was an end to it.** Nothing cleared the block: not the error growing,
|
||
not the integrator moving, not time. The exit is now explicit, and *which* freezes get
|
||
one is the decision:
|
||
|
||
| the inverter is… | can it clear itself? | what happens now |
|
||
|---|---|---|
|
||
| doing **less** than the command, in the command's own direction — a cold pack, a curtailment, an export cap | **yes.** The command is pinned at something the inverter can reach, so it catches up and the freeze clears | unchanged: frozen for as long as it lasts, with no exit |
|
||
| doing something **outside** what the command can explain — overshooting it, or the opposite sign | **not necessarily.** If another writer is on the actuator, clearing the freeze needs the inverter to track *our* command and the other writer is what stops it, so nothing ends it | the freeze releases once the divergence has outlasted the plant that could otherwise have explained it — see *what `freeze-released` actually tells you*, below, because this row is also where our own ramp lands |
|
||
|
||
The second row's threshold is the plant's own measured settling time — **the slower of
|
||
the two directions it was measured in.** The 2026-08-22 step test timed the same 1.78 kW
|
||
step both ways: about 18 s to settle going up, about 23 s coming back down. 23 s is the
|
||
one that belongs here, because the question this bound answers is *how long may the plant
|
||
still be the explanation*, and the safe answer is to keep believing the plant for as long
|
||
as the plant has ever been seen to take. At the measured 2 s meter tick that is
|
||
**12 cycles** (`control.py`, `SETTLE_CYCLES`).
|
||
|
||
⚠️ **The first version of this feature used 18 s, and 9 cycles was wrong in the direction
|
||
that accuses.** Nine cycles is three short of the plant's own down-ramp, so an ordinary
|
||
load step on a **single** inverter — the command falling through zero while the pack is
|
||
still coasting the old way — could produce `freeze-released` with nothing else on the bus
|
||
at all. Driven in the container against a model of the measured plant, one inverter and
|
||
no second writer anywhere: over 272 load-step pairs the 9-cycle bound produced a release
|
||
in 62 of them and the shipped 12-cycle bound in 2.
|
||
|
||
**Releasing the output freeze does not unfreeze the loop.** The *integrator* freeze is
|
||
untouched, so the command is free to move only as far as an accumulator that stopped
|
||
accumulating cycles ago, one slew step per cycle. Measured on the simulator at the live
|
||
install's shape (`max_w 5000`, `slew_w 400`, `target_grid_w -10`), with another writer
|
||
holding the pack at +700 W and 2 kW of export standing: the command holds at 0 W while
|
||
the counter climbs, starts on the **twelfth** saturated cycle, walks 0 → −400 → −800, and
|
||
settles at **−1190 W** against a frozen integrator of −1194 W — not at the 5000 W rail.
|
||
Before this change the same run held 0.0 W on every cycle, for as long as the other
|
||
writer stayed.
|
||
|
||
⚠️ **The site still does not reach its target in that state, and it is not supposed
|
||
to.** What the exit buys is that the battery stops being idle while the site exports;
|
||
what it does not buy is correct regulation against an actuator somebody else is also
|
||
writing to. A standing grid error with another writer on the bus is the symptom of a
|
||
configuration this add-on refuses to support, not a tuning problem.
|
||
|
||
#### What `freeze-released` in the action log actually tells you
|
||
|
||
**It says: the inverter's power did not match the command for longer than this plant has
|
||
ever been measured to take to settle.** That is all it says. It is a reason to go
|
||
looking; it is not a finding, and in particular it is **not** proof that a second writer
|
||
exists.
|
||
|
||
The test behind it compares the reading against **one cycle's** command, and the pack
|
||
carries more than one cycle of history. So our own power, arriving late, can look like
|
||
somebody else's:
|
||
|
||
- **A big, fast load step is the ordinary innocent cause.** The command falls through
|
||
zero while the pack is still coasting in the old direction, and for a few cycles the
|
||
reading has the sign the command *used* to have. Measured, one inverter, nothing else
|
||
on the bus: eight cycles after a +1990 W command the command was 0 and the pack still
|
||
read +522.3 W.
|
||
- **A plant slower than the one that was measured does it too.** The 12-cycle bound is
|
||
derived from a 23 s down-ramp. A colder or larger pack that takes longer than that will
|
||
produce releases on its own, and no constant read off the 2026-08-22 measurement can
|
||
prevent it. In the same container sweep, holding the bound at 12 and lengthening only
|
||
the plant's down-ramp, single-inverter releases went from 8 of 320 scenarios at the
|
||
measured 23 s to 59 of 320 at 34 s and 268 of 320 at 50 s.
|
||
|
||
**So, in order:** if you see `freeze-released` **once, during or just after a large load
|
||
or PV swing**, that is most likely the loop's own ramp and needs nothing. If you see it
|
||
**standing** — cycle after cycle, at a steady load, with a standing grid error the
|
||
battery is not correcting — then look for the second writer: another controller, a
|
||
scheduler in the inverter's own app, or an installer tool left connected. That is the
|
||
configuration §1.5 of the specification rules out, and it is the one this exit exists to
|
||
stop the loop stalling on forever.
|
||
|
||
⚠️ **No `saturation_cycles` you can set removes the block.** This file accepts 1-10, and
|
||
all of those are below the 12-cycle release bound, so the freeze always engages before
|
||
its own exit is due. (The first version of this feature had the bound at 9, where a
|
||
setting of 9 or 10 *did* silently delete the block; moving the bound to 12 closed that
|
||
by construction rather than by warning about it.)
|
||
|
||
#### Why `target_grid_w` is not zero
|
||
|
||
The deadband is a one-way ratchet: any resting point inside it holds until
|
||
something disturbs it. Import and export are **separate registers on the
|
||
meter**, so a rest point of +14 W is billed for every second it holds and no
|
||
amount of export cancels it - 14 W all day is 0.34 kWh.
|
||
|
||
Biasing the target below zero moves that residue into the export register,
|
||
which is not billed. The resting band becomes `target ± deadband`, so:
|
||
|
||
| `target_grid_w` | resting band | worst billed leak | export given away |
|
||
|---|---|---|---|
|
||
| 0 | -15 … +15 W | ~15 W (0.35 kWh/day) | none |
|
||
| **-10** | -25 … +5 W | ~5 W (0.12 kWh/day) | ~10 W |
|
||
| -15 | -30 … 0 W | none | ~15 W (0.36 kWh/day) |
|
||
|
||
Set it to `-deadband_w` if injection is worth nothing to you and you would
|
||
rather give the energy away than buy it back. Set it to `0` if you are paid
|
||
properly for export, or if you are debugging and want the loop centred.
|
||
|
||
⚠️ This is a **billing** knob, not a speed knob. If import is arriving in
|
||
bursts rather than as a trickle, the cause is tracking lag, and this will not
|
||
help - see "Why the tuning is what it is".
|
||
|
||
### Maintenance
|
||
|
||
| option | default | meaning |
|
||
|---|---|---|
|
||
| `maintenance_enabled` | false | Enable the monthly cycle |
|
||
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
|
||
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
||
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
|
||
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
|
||
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys |
|
||
| `maintenance_soc_target` | 99 | Charge target |
|
||
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
|
||
|
||
### Tariff (all optional)
|
||
|
||
| option | meaning |
|
||
|---|---|
|
||
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
|
||
| `peak_cap_w` | The site's capacity-tariff target |
|
||
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
|
||
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start. ⚠️ **At a declared `inverters:` roster this key moves INTO each entry and is REQUIRED there** (SAFETY-32): it describes a battery, and two packs of different models have different cut-outs, so one number for both protects the shallower pack with the deeper pack's BMS. Nothing is inherited — an entry that omits it is refused **by name** at startup, and the flat key below is then not read at all |
|
||
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
|
||
|
||
On a capacity-tariff site the maintenance charge is capped by the headroom left
|
||
under `peak_cap_w`, and if the forecast goes over the cap the charge-only clamp
|
||
is dropped so the battery can shave the peak instead. Money outranks the
|
||
maintenance schedule.
|
||
|
||
#### Capacity-tariff exposure of the grid-following loop
|
||
|
||
⚠️ **This section described the exposure BEFORE SAFETY-07. The loop is now
|
||
capped — see "The capacity-peak guard" below — and what follows is kept because it
|
||
is still the clearest statement of WHAT is being guarded and why the number is
|
||
worth what it is.**
|
||
|
||
⚠️ **`peak_cap_w` caps MAINTENANCE charging. It does not cap the loop.** The
|
||
grid-following loop is bounded by `max_w` and by the device rating, and by
|
||
nothing else. That is deliberate — the loop exists to *reduce* what crosses the
|
||
meter, so under every healthy condition it lowers the peak rather than raising
|
||
it — but it means one thing is true and worth writing down:
|
||
|
||
**If the meter lies, the loop will happily buy `max_w` from the grid for as long
|
||
as the lie lasts, and no cap in this add-on stops it.** A meter frozen at a large
|
||
export reads as "the house is exporting hard", the loop charges the battery to
|
||
correct it, and the correction is a grid import. At the schema maximum
|
||
`max_w: 5000` — which is what this house runs, and is also the device rating —
|
||
that is a 5 kW unattended import.
|
||
|
||
The bill is not the energy. In the Belgian capaciteitstarief the month is billed
|
||
on its highest quarter-hour average, so **fifteen minutes of this sets the whole
|
||
month's peak.** A measured case: one 4998 W quarter moved a monthly peak from
|
||
4832 W to 5013 W, and every day of that month was billed at the higher figure.
|
||
|
||
What exists today, and what does not:
|
||
|
||
| | |
|
||
|---|---|
|
||
| `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude |
|
||
| Maintenance charging capped under `peak_cap_w` | ✅ |
|
||
| **Loop capped under `peak_cap_w`** | ✅ — SAFETY-07, on every `meter_source` but `off` |
|
||
| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** |
|
||
| Frozen meter *stopped* | ✅ — SAFETY-07 forbids importing while the meter is frozen |
|
||
| SoC guard on the loop | ❌ — the BMS is the only energy backstop |
|
||
|
||
On the legacy `meter_source: off` path there are no P1 telegrams, so there is no
|
||
quarter-hour average to guard, and the status page says **NOT COVERED**. There
|
||
the mitigation is still operational: set `max_w` no higher than the loop actually
|
||
needs (the tuning was measured at 2000 W and a 1.78 kW load step is tracked fine
|
||
there), fit the e-stop, and watch for the alarm.
|
||
|
||
### The capacity-peak guard
|
||
|
||
**What it does.** Every cycle the add-on projects the open quarter to its
|
||
end-of-interval average — from P1 telegrams only, never from the command — and
|
||
compares it to a ceiling. Over the ceiling it files one claim: a **lower bound on
|
||
the setpoint**, at `P_PEAK`. That single shape is both behaviours §10.1 asks for:
|
||
a hard clamp on charging while the bound sits above the standing charge, and
|
||
pre-emptive discharge while it sits above zero. It can never cap discharge or
|
||
export, because a floor cannot.
|
||
|
||
**The number.** `excess = 900 × (projected − ceiling) ÷ seconds remaining`, and
|
||
the floor is the **measured** battery power plus that. The 900/remaining factor
|
||
is the design rather than an accident: it is 1.0 at the top of the quarter and
|
||
grows, so the guard is gentlest when its projection rests on the least evidence
|
||
and becomes aggressive only once the quarter is nearly measured.
|
||
|
||
**The ceiling** is `peak_cap_w`, raised to the month's banked peak when that
|
||
already stands higher — once a month's bill is set at 5013 W, holding later
|
||
quarters to 3500 W buys nothing and costs real charging. **That relaxation is
|
||
granted only by an OBSERVED reading:** a peak store that cannot be read leaves the
|
||
ceiling at the cap, so a broken instrument makes the guard stricter, never looser.
|
||
|
||
**When it cannot see, it forbids importing.** Three states are not evidence and
|
||
are checked before the projection is graded: no open quarter (a fresh averager
|
||
projects a confident 0 W, which is under every ceiling there is), a meter gap
|
||
longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all
|
||
three the claim becomes a floor of **0 W**. That is the backstop for the frozen
|
||
meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter
|
||
and commands nothing, and this stops the import.
|
||
|
||
**It trips, where the freeze detector only notifies,** and the captures are why.
|
||
On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters.
|
||
The action is level-triggered and unlatched — re-derived from a fresh projection
|
||
every cycle, released the instant the projection drops back — so a false
|
||
engagement costs one cycle of discharge rather than a parked battery. And it is a
|
||
LIMIT, so every stop in the watchdog table still outranks it.
|
||
|
||
**Replayed on 2026-08-20**, the day the vendor controller failed: the billed
|
||
quarter measured **5029.8 W**, above the inverter's own 5000 W rating, and the
|
||
guard brings it to **3485 W** — under the soft limit, without ever asking for more
|
||
than the inverter can give. Across both committed days the quarters under the
|
||
limit go from 189/190 to 190/190. Two days is not thirty and does not close AC 3;
|
||
it is the comparison two days can support.
|
||
|
||
**What it alarms on is saturation, not engagement.** An engagement is an ordinary
|
||
control action and goes to the action log and the status page. A *saturated*
|
||
guard — asking for everything the inverter has while the quarter is still
|
||
projected over — raises one notification (`goodwe_peak_saturated`), because that
|
||
is the one state where the money will be spent and shedding load is the only
|
||
thing left that changes the bill.
|
||
|
||
**No new option for the CEILING.** It is `peak_cap_w`, which already shipped. The
|
||
coverage threshold is `meter_max_age_s`, the freeze predicate is
|
||
`meter_freeze_s`, and the achievable discharge comes from `max_w` and the device
|
||
rating — every one of them a key that already exists.
|
||
|
||
#### The energy floor under the forced discharge (`peak_soc_floor`)
|
||
|
||
The guard above can **command a discharge**, and until SAFETY-24 nothing in the
|
||
add-on bounded how far down it took the pack — the BMS was the only backstop.
|
||
On the 2026-08-20 replay the guard asks for **878 Wh in a day**.
|
||
|
||
That is not absurd: the BMS floor is real and it works. But it is *the pack's*
|
||
protection, not *yours*. A month of aggressive quarters can leave the battery
|
||
repeatedly parked at that cut-out with no record on this side of why, and the
|
||
first symptom — a battery that is never available when you want it — looks
|
||
exactly like the SoC drift the monthly maintenance cycle exists to correct.
|
||
|
||
- **`peak_soc_floor` (20 %)** is where the forced discharge stops. Below it the
|
||
guard files a **0 W** floor instead of a sized one: charging stays blocked
|
||
(that can only ever lower your offtake), and no further discharge is asked for.
|
||
- **⚠️ IT BOUNDS THE PEAK GUARD ONLY. IT IS NOT A PACK-WIDE RESERVE, AND THE NAME
|
||
READS LIKE ONE.** The ordinary self-consumption loop — the grid-following
|
||
control law that holds your meter at zero — has **no SoC floor of any kind**.
|
||
It will discharge straight past this number and keep going until the pack's own
|
||
BMS cut-out stops it. What you are promised by `peak_soc_floor: 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.
|
||
|
||
#### The hours the feed has not published yet (FCST-04)
|
||
|
||
A day-ahead auction publishes tomorrow's prices once a day, at **14–16 h CET**.
|
||
Before that, the feed is serving today and nothing else — so a `price_horizon_h`
|
||
of 48 asks for 48 hours and gets back the ten or fifteen that are left in the
|
||
day. Until FCST-04 those other hours were simply **absent** from the plan: not
|
||
priced badly, not flagged, just missing, and nothing said so.
|
||
|
||
Now the add-on keeps the **published prices of the last few days** in
|
||
`/data/price_history.json` and fills an unpublished hour of the horizon with
|
||
yesterday's price for the same Brussels wall-clock hour, flagged
|
||
`provisional: true`. Two things follow, and the second one matters more:
|
||
|
||
- **The plan says it is guessing** — `provisional: true` on every carried hour,
|
||
which is what the optimiser reads. Before, a half-length plan looked
|
||
completely settled.
|
||
- **Nothing may trade on a carried hour.** §8.3 forbids acting on a provisional
|
||
price, so arbitrage drops those hours from the trade set entirely — it needs a
|
||
buy hour and a sell hour that were *both* published. Carrying a price forward
|
||
widens what the optimiser can **see**; it never widens what it may **do**.
|
||
|
||
**"An unpublished hour", not "every unpublished hour"** — the fill can only
|
||
carry an hour yesterday actually published. If yesterday's record is itself
|
||
partial (a first day, an outage, a feed that only ever serves the rest of the
|
||
day), the local hours it never held are **left out of the series**: §11.4
|
||
applies to those hours, and asking the plan for one raises rather than
|
||
answering. An hour with no price is not a free hour, and the fill does not
|
||
invent a neighbour, a mean or a zero for it.
|
||
|
||
**A quarter-hourly feed keeps all four of its prints.** `eneco_eddy` settles
|
||
per quarter, and the fill decides *which whole hours need carrying* — never what
|
||
a published hour may contain. Every point the provider published inside the
|
||
horizon is passed through untouched; a carried point is added only for a whole
|
||
hour the feed did not print at all.
|
||
|
||
**What you can actually see on the status page.** *Price history* (below) says
|
||
what is held. *Arbitrage* carries the strategy's own sentence, which names the
|
||
firm/provisional split whenever that split is what refused a trade. *Tariff*
|
||
does **not** report the fill: it is the static-profile or Home Assistant
|
||
price-entity sentence, regenerated on the 30 s tick. The provider plan's own
|
||
line — `dynamic_dayahead from generic_dayahead, 48 h, provisional` — goes to the
|
||
**add-on log** at every refresh, alongside `§8.3: 33 of 48 hours carried forward
|
||
from yesterday's profile and flagged provisional; 15 published`.
|
||
|
||
The plan is also now cut to the horizon you asked for, which means **hours that
|
||
have already elapsed are no longer in it**. That is a real change to arbitrage:
|
||
a cheap night that has already gone used to sit in the series as the cheapest
|
||
hour of the day, and a cycle can never buy in an hour that is past — so the
|
||
trade was quietly never taken. The spread is now taken over hours still ahead.
|
||
|
||
A new status row, **Price history**, says which days are held, how many price
|
||
points each holds and how many whole hours those cover — `2026-01-13 (96 points
|
||
over 24 h)`. ⚠️ **Those are two different numbers and the row used to print one
|
||
of them under the other's name**: it counted points and labelled them `h`, so a
|
||
quarter-hourly feed read `96 h` for a 24-hour day. The figure that says whether
|
||
tomorrow morning's fill has a whole day to carry is the **hour** one. If it
|
||
reads `empty` or `unavailable`, nothing is carried, the horizon
|
||
is as short as the feed makes it, and every other guarantee is unchanged — a
|
||
cache that cannot answer is not allowed to be a controller that cannot regulate.
|
||
The store is rebuilt by the next successful fetch; on a brand-new install the
|
||
first fill is possible the day after the first refresh.
|
||
|
||
The same applies to a file that is *partly* broken: a day record this build
|
||
cannot read is dropped, the row says how many were dropped and why, and the rest
|
||
of the file still loads. And where there is no `/data` at all the store records
|
||
**nothing** — not even in memory. An in-memory yesterday would answer for one
|
||
run and vanish on the next restart, which is a cache that forgets without saying
|
||
so; `unavailable` and *no prices carried* are the honest pair.
|
||
|
||
**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.
|
||
|
||
Since **UX-02** it has a second page, **Configuration** — see *There are TWO
|
||
places to configure this add-on*, above. ⚠️ **It adds nothing you can do to the
|
||
inverter.** The configuration page reads and writes the add-on's own settings
|
||
and can restart the add-on; the set of commands that reach the hardware is
|
||
exactly the three buttons it always had.
|
||
|
||
## Status entities
|
||
|
||
If an MQTT broker is available the add-on publishes setpoint, grid power,
|
||
battery power, circulating power, state of charge, maintenance phase and
|
||
controller status by MQTT discovery. This is observability only — the controller works fine without a
|
||
broker, and MQTT problems can never affect control.
|
||
|
||
## Before you commission inverter B: `tools/rehearse_roster.py` (ARCH-02)
|
||
|
||
The day the `inverters:` roster is declared, two things change that are
|
||
**one-way on a live box and invisible until after the restart that applies
|
||
them**: the store migration renames the keys that hold the SAFETY-02 latch and
|
||
the maintenance history, and the entity ids Home Assistant composes change
|
||
shape. This tool runs the **real `Controller`** through that transition against
|
||
a **copy** of the state file and reports the difference.
|
||
|
||
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v <repo>:/w:ro \
|
||
goodwe-addon-checks:1 sh -c 'cd /w/goodwe_controller && \
|
||
python3 tools/rehearse_roster.py \
|
||
--state /w/state-copy.json \
|
||
--before /w/live-options.json \
|
||
--after /w/roster-options.json \
|
||
--live-entities /w/live-entities.txt \
|
||
--reverse --round-trip'
|
||
|
||
Exit `0` clean · `1` at least one finding · `2` could not run. ⚠️ **`2` is not a
|
||
pass.**
|
||
|
||
### The owner steps, because this session could not take them
|
||
|
||
⚠️ **The rehearsal is only as good as the state it is pointed at, and nothing in
|
||
this repository can fetch that.** SSH to the live box is blocked by policy and
|
||
that is correct — `/data/state.json` is the file the running add-on is writing.
|
||
So three things have to be exported by hand, onto the machine that runs the
|
||
check, **before** the roster is declared:
|
||
|
||
1. **The state file.** From the Supervisor host:
|
||
|
||
ha addons info local_goodwe_controller # confirm the slug first
|
||
docker cp addon_local_goodwe_controller:/data/state.json ./state-copy.json
|
||
|
||
or take it out of a full add-on backup, which is the same bytes. ⚠️ **Copy
|
||
it, never move it, and never write to the original.** The tool opens the path
|
||
it is given exactly once and does all of its writing on a scratch copy — but
|
||
the safe habit is to hand it a file the add-on has never heard of.
|
||
|
||
2. **The options blob**, as `--before`. It is not reachable through the Home
|
||
Assistant REST API and no entity carries it; export it from the Supervisor,
|
||
the same way `tools/predeploy_check.py` needs it. Write the roster you intend
|
||
to deploy into a second file and pass it as `--after`.
|
||
|
||
3. **The entity list**, as `--live-entities` — one entity id per line:
|
||
|
||
Developer tools -> Template:
|
||
{{ states | map(attribute='entity_id') | select('search','goodwe') | join('\n') }}
|
||
|
||
⚠️ **Omitting it is a REFUSAL (exit 2), not a quieter run.** The orphan check
|
||
is graded against the ids the install actually has, and without that file it
|
||
is not graded against reality at all — so the tool will not run rather than
|
||
hand back a clean bill of health for a half of the transition it did not
|
||
check. If you genuinely have to run without it, pass `--no-live-entities`:
|
||
the run then carries a standing banner in the report header **and** on the
|
||
exit line saying the orphan check was not graded. The asymmetry used to be
|
||
the other way round — an unreadable file refused, a missing flag passed with
|
||
exit 0 and no disclosure at all — and the owner runs this once, at
|
||
commissioning, under time pressure.
|
||
|
||
The install carries roughly 49 `goodwe*` entities and this add-on announces
|
||
eight of them. The tool separates the ids it owns from the ids it does not,
|
||
rather than claiming orphans it cannot cause.
|
||
|
||
### The reference, which is loaded on every run
|
||
|
||
`--reference` defaults to `tools/rehearsal/announced-reference.txt` and is read
|
||
**whether or not** you pass an entity list. It records what the build is
|
||
supposed to announce, three columns per row:
|
||
|
||
<entity_id> <unique_id> <device identifier>
|
||
|
||
⚠️ **All three, and the second and third are the ones an entity_id-only list
|
||
cannot see.** The tool's own before/after diff is composed twice by the same
|
||
build, so a change to the composition moves both sides equally and the diff
|
||
reports nothing. Worse, two changes orphan an install *without moving a single
|
||
composed entity_id*: renaming `mqtt.BASE` (every `unique_id` changes) and
|
||
changing `DEVICE["identifiers"]` (HA sees a new device). Home Assistant keys its
|
||
entity registry on `unique_id` and its device registry on the identifier, so
|
||
either one creates a **new** entity beside the old — and because discovery is
|
||
published `retain=True` with no removal payload, the old config stays on the
|
||
broker and the old entity stays in the registry, still named by every automation
|
||
that referenced it, never updating again. Both were measured coming back
|
||
**exit 0** before the reference carried three columns.
|
||
|
||
`object_id` is deliberately **not** a column: `app/mqtt.py` records that HA
|
||
2026.8 does not honour it in a discovery payload, so renaming one is a true
|
||
no-op, and `test_rehearse.py` requires that mutation to **survive** — otherwise
|
||
"the reference notices any edit to `mqtt.py`" would be indistinguishable from
|
||
"the reference notices what HA keys on".
|
||
|
||
⚠️ **No result in this repository was produced against real state.** Everything
|
||
recorded here and in `test_rehearse.py` was measured on the synthetic fixtures
|
||
in `tools/rehearsal/`.
|
||
|
||
### What it confirms on the synthetic fixtures
|
||
|
||
⚠️ **This is a SECOND INSTRUMENT reaching a conclusion this repository already
|
||
holds, not a discovery.** `test_maintenance.py` section 13 — *"RECORDED, NOT
|
||
FIXED: SAFETY-02's migration has the same wrong-namespace hole, and it is a
|
||
LATCH"* — already states it, already names `namespaced_latched`, and already
|
||
carries the sentence *"the guard is defeated by the very key the defect wrote"*.
|
||
That file is on `release/1.0` and this card does not touch it. What is new here
|
||
is that the finding is now reachable **from a state file the owner supplies**
|
||
rather than only from a fixture built in a test, and that it is reached by a
|
||
different route: a full `Controller` start diffed against its predecessor,
|
||
rather than assertions over a `MemStore`. Two instruments, one answer.
|
||
|
||
**The finding.** A pre-DEV-02 flat store that declares a multi-inverter roster
|
||
comes up with its SAFETY-02 latch **DISARMED**, unless the first roster entry
|
||
happens to be named `inverter-a`. `Controller.__init__` calls
|
||
`migrate_unit_keys(store, UNIT_A)` with a hardcoded constant, so the latch lands
|
||
in `unit/inverter-a/sign_tripped`; a roster of `["garage", "shed"]` reads
|
||
`unit/garage/sign_tripped`, which is absent. And `_grade_store_migration()` —
|
||
the guard written for exactly this — sees the migration's own misdirected key
|
||
satisfy its `namespaced_latched` test and returns without re-arming. The file
|
||
says `sign_tripped: true`, the banner is green, and nothing is held at 0 W.
|
||
|
||
**Both routes reach it, and they are NOT the same defect.** The rehearsal
|
||
reports them as separate legs because they need separate fixes:
|
||
|
||
| leg | how it gets there |
|
||
|---|---|
|
||
| `SAME-STEP` | the roster is declared in the same step as the upgrade. The migration runs, and is handed `UNIT_A`. |
|
||
| `STAGED` | the flat config runs once under 0.7.0+ first. That start stamps `schema_version = 1`, so the roster start's migration takes its `from_version >= SCHEMA_VERSION` early return and **the latch never moves at all**. |
|
||
|
||
⚠️ **So "hand `migrate_unit_keys()` the roster's own first name" is NOT the
|
||
whole fix, and the rehearsal measures that rather than asserting it.**
|
||
`test_rehearse.py` part 5b builds exactly that change and reports:
|
||
|
||
LEG STAGED: DISARMED
|
||
LEG SAME-STEP: clean
|
||
|
||
The staged path never reaches the line that was changed. A fix that stops there
|
||
closes the half an operator is least likely to take and leaves the half that
|
||
"upgrade first, declare the roster later" produces. The guard side —
|
||
`namespaced_latched` counting a namespace that is not on the roster — is what
|
||
covers both, and a change there turns every SAFETY-44 check in part 5 red, which
|
||
is what a fix should do to a pin that specifies a defect.
|
||
|
||
The fix belongs to its own card, shown red first. Part 5 pins the current
|
||
behaviour as a requirement so it cannot drift green by accident.
|
||
|
||
### The reverse direction: the retreat is supported, the round trip is not
|
||
|
||
If commissioning B goes wrong, **backing out to the flat single-inverter config
|
||
is supported and does not corrupt state.** Measured on the fixtures:
|
||
|
||
* the SAFETY-02 latch and the first pack's maintenance history both survive,
|
||
because `store.py` rule 2 keeps the flat spellings **live** rather than merely
|
||
present — `maintenance.UnitHistory.mirror_flat` writes them on every change,
|
||
so a single-FSM add-on finds a current history rather than one dated the day
|
||
the roster was declared;
|
||
* **it orphans eight Home Assistant entities** — the four per-unit rows for each
|
||
of the two units. **Measured:** the add-on stops announcing them and stops
|
||
publishing their state, so their history gaps and any automation whose
|
||
trigger names them stops firing, silently and without an error.
|
||
|
||
⚠️ **What is NOT measured, and was previously overstated here:** whether
|
||
advancing again restores them. An earlier draft of this section said the
|
||
orphaning was "not recoverable by advancing again: HA keeps the id it composed
|
||
at first discovery" — that reasoning cuts the other way, since keeping the
|
||
composed id is precisely what would let a re-advertised entity land back on
|
||
it, and discovery is published `retain=True` with no removal payload. Nothing
|
||
in this repository has tested a re-advance against a real Home Assistant, so
|
||
the claim is withdrawn rather than restated in the other direction. Plan the
|
||
retreat around the measured part: **the eight rows stop updating for as long
|
||
as you are retreated.**
|
||
|
||
⚠️ **What is NOT supported is the round trip.** A maintenance cycle that
|
||
completes *while the install is rolled back* is **discarded** when it
|
||
re-advances: `maint_schema_version` is already stamped, so `migrate_maint_keys()`
|
||
takes its early return and the pack reads the namespaced history from before the
|
||
retreat. The pack is then recalibrated on a schedule that does not know it
|
||
already was. **So: retreat freely, but treat a retreat as ending the current
|
||
maintenance bookkeeping — and if a cycle ran during it, correct
|
||
`unit/<first>/last_completed` by hand before advancing again.** This is a stated
|
||
decision, not a discovery made at the inverter.
|
||
|
||
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
|
||
|
||
Two ways a deploy has gone wrong on this system, both of which the Supervisor
|
||
reports as success:
|
||
|
||
**The silent no-op.** The Supervisor keys the built image by `version:` in
|
||
`config.yaml`. `ha apps update` against an unchanged version installs nothing
|
||
and says it worked. This happened twice: PKG-06 (six merges behind an unbumped
|
||
version) and PKG-07 (six again). Both times a human noticed. Nothing else would have.
|
||
|
||
**The refused config.** A config the shipped validator rejects is
|
||
`SystemExit(78)` — the add-on does not start. ⚠️ **And the ingress panel goes
|
||
with it**, because the add-on serving it is not running, so recovery means the
|
||
Supervisor UI. SAFETY-39's rule 10 made this reachable from a config that
|
||
started fine on the previous version: 0.7.0's own default `slew_w: 1000`
|
||
exceeds the 555.6 W ceiling on a 5000 W rail.
|
||
|
||
Run it inside the container, so the validator half actually runs:
|
||
|
||
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
|
||
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
||
--deployed-version 0.7.0 --options /w/live-options.json
|
||
|
||
Export `live-options.json` from the Supervisor first — the options blob is not
|
||
reachable through the Home Assistant REST API, and no entity carries it.
|
||
|
||
**Exit codes.** `0` cleared · `1` REFUSED, do not deploy · `2` NOT CLEARED,
|
||
meaning a check did not run. ⚠️ **2 is not a pass.** On a bare host without the
|
||
add-on's dependencies the validator half skips and returns 2 rather than
|
||
pretending; `test_version_gate.py` pins that a skipped check can never exit 0,
|
||
because the first draft of that test reported four silent skips as passes.
|
||
|
||
⚠️ **Why this is not a CI check.** The obvious gate — "a pull request touching
|
||
runtime code must bump the version" — is the wrong shape here. This project
|
||
deliberately batches many merges into one deploy, so a per-PR bump would put
|
||
every branch in conflict over one line and still not prevent the failure, which
|
||
happens at deploy and not at merge. The check belongs where the mistake is made.
|
||
|
||
⚠️ This file's own change does **not** need a version bump, and the gate agrees:
|
||
`tools/` and `test_*.py` are not in its runtime set, because demanding a bump
|
||
for a typo fix trains people to bump without reading — the habit the check
|
||
exists to prevent.
|