- Details link: /config/app/<slug>/documentation on HA 2026.2+ (the old /hassio/addon path is a 404 from 2026.5), /hassio/addon on 2026.1 and older. Slug from GET /addons/self/info, version from GET /info, hostname as fallback. - Shadow mode: the over-rating alarm and row say what would have been sent, the peak alarm no longer says the battery is giving all it can. - "controller" -> "the add-on" / "control". Settings named by their labels; four labels changed to match (Peak reserve, Battery level entity, Solar production ..., Reversed-sign solar entities). - Shadow banner and inverter link notes moved to messages.py and checked. - DOCS Notifications and Status page rewritten for home owners, with a "For installers" part each. Allowlist 168 -> 167. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
3276 lines
194 KiB
Markdown
3276 lines
194 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: the printed field guide (`docs/GoodWe-RS485-Field-Guide.pdf`) is
|
||
v0.1.0 and outdated; where it disagrees with this page, this page wins.
|
||
|
||
## 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
|
||
|
||
After installing, turn on **Watchdog** on the add-on's Info tab. A crashed
|
||
control loop stops the add-on at 0 W, and the Supervisor restarts it only when
|
||
Watchdog is on (it is off by default). See
|
||
`DECISIONS/SAFETY-61-control-task-death.md`.
|
||
|
||
## 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 72 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
|
||
| **Order comes from `schema:`** | `App.schema_ui` is `UiOptions(...)(data["schema"])`, so the **schema half** of `config.yaml` decides the order the tab renders in — not the `options:` half |
|
||
| **There are no section headers** | Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by **order** plus the `Group · ` prefix in each label, and by nothing else |
|
||
| **Groups, in task order** | Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced |
|
||
| **The description says whether to touch it** | 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` |
|
||
| `pv_entity` | | Optional. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity-tel-11) |
|
||
| `pv_entities` | | Optional. **Several** solar inverters, one row each — use this instead of `pv_entity` when your roof has more than one. Setting it **ignores** `pv_entity`. See [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) |
|
||
| `pv_invert_entities` | | Optional. The PV entities above that read **negative** while producing; each one's sign is flipped. See [The sign](#the-sign-and-pv_invert_entities-tel-14) |
|
||
|
||
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
|
||
bms_soc_floor: 10
|
||
capacity_kwh: 15.36
|
||
- name: inverter-b
|
||
setpoint_entity: number.goodwe_b_setpoint_w
|
||
soc_entity: sensor.goodwe_b_soc
|
||
batt_entity: sensor.goodwe_b_battery_power
|
||
max_w: 4600
|
||
bms_soc_floor: 10
|
||
capacity_kwh: 20.48
|
||
```
|
||
|
||
⚠️ **The 4600 W is inverter B's *datasheet* figure, not a measured one.** B is a
|
||
GoodWe GW5048D-ES (serial `55048ESU22AW0992`, identified 2026-09-04) whose
|
||
datasheet gives max charging and discharging power as 4600 W; with nothing
|
||
connected to its PV inputs the battery path is the ceiling, not the 5000 VA AC
|
||
nameplate. The datasheet prints that number under *Battery Input Data*, so it
|
||
may be a DC figure, while `max_w` is graded against register `0x0518` — the
|
||
inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
|
||
`max_w: 4600` is still the right thing to configure, because the freeze rail is
|
||
`min(max_w, dev_max)` and the device range read off the unit is what binds.
|
||
`goodwe/NOTES.md` §1.1b records both readings and picks neither.
|
||
|
||
| per-entry key | required | meaning |
|
||
|---|---|---|
|
||
| `name` | yes | Short identifier. It namespaces this unit's SAFETY-02 latch, heartbeat and write bookkeeping in the add-on's state file, so **two units may not share one** |
|
||
| `setpoint_entity` | yes | This inverter's own ESPHome `number.*_goodwe_setpoint_w` |
|
||
| `max_w` | yes | The supervised cap on what may be written to **this** inverter. The site's ceiling is the **sum** |
|
||
| `bms_soc_floor` | yes | This pack's own BMS cut-out percent (SAFETY-32). See [`bms_soc_floor`](#tariff-all-optional) |
|
||
| `soc_entity` | | This pack's state of charge |
|
||
| `batt_entity` | | This unit's battery power, `+` = discharging |
|
||
| `batt_invert` | | Flip if needed |
|
||
| `capacity_kwh` | | This pack's usable capacity. State it on every entry or on none — see [OPT-09](#how-the-site-target-is-divided-between-the-packs-opt-09) |
|
||
|
||
⚠️ **Nothing is inherited.** An entry that leaves out `soc_entity` has **no SoC
|
||
sensor** — it does *not* fall back to the flat one. That is deliberate: every
|
||
per-device value became per-instance in 0.5.x precisely so that no inverter is
|
||
ever graded against a sibling's numbers, and a silent fallback would rebuild
|
||
that in YAML. `name`, `setpoint_entity`, `max_w` and `bms_soc_floor` are required
|
||
of every entry for the same reason: there is no rating or cut-out to borrow.
|
||
|
||
⚠️ **Clear the flat `setpoint_entity` when you declare a roster,** or the add-on
|
||
refuses to start and says so. It is the single-inverter form and it is not read
|
||
once a roster exists, so leaving it set means one of two things: it names an
|
||
entity an entry also names — two writers on one actuator, which specs §1.5 makes
|
||
a non-goal and the add-on refuses outright — or it names a different one, which
|
||
nothing would ever write, leaving a site running quietly smaller than its
|
||
configuration reads. `soc_entity`, `batt_entity`, `batt_invert` and `max_w`
|
||
cannot be blanked (the schema gives them no empty value); they are simply not
|
||
read, and the add-on writes one line into its action log at every start saying
|
||
so.
|
||
|
||
⚠️ **Two inverters naming the same `setpoint_entity` are refused,** whichever
|
||
keys they are declared under and whatever the capitalisation. Two independent
|
||
setpoints on one inverter is the arbitration problem this add-on exists not to
|
||
have.
|
||
|
||
⚠️ **Update the add-on first, then add the roster.** Home Assistant validates the
|
||
options you save against the schema of the version that is *installed*, so
|
||
`inverters` cannot be set before 0.6.0 is installed. The order is: update, then
|
||
configure.
|
||
|
||
⚠️ **Going the other way — downgrading with a roster configured — is recoverable
|
||
but NOT uneventful, and the add-on will not start until you refill a field.**
|
||
The *schema* side is fine: the older version's schema has no `inverters` key, so
|
||
it drops the key with a warning rather than refusing the whole blob (measured —
|
||
`DECISIONS/CFG-04-inverter-roster.md` §E). The *add-on* side is not. Rule 9(e)
|
||
requires the flat `setpoint_entity` be **cleared** when a roster is declared, so
|
||
a config that downgrades cleanly is by construction a config whose flat
|
||
actuator is blank — and the older version has nothing else to write to. It stops
|
||
at startup validation with a refusal naming `setpoint_entity`, which is loud and
|
||
in the log rather than silent, but it is an outage until someone puts the
|
||
single-inverter entity back in that field. **Before downgrading, write down the
|
||
`setpoint_entity` of the unit you want to keep running.**
|
||
|
||
#### How the site target is divided between the packs (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 by the
|
||
per-unit machinery at all: no second device, no renamed entity, not one added
|
||
topic. (TEL-10's three loop rows, below, are site rows and are added at every
|
||
roster length, including one.)
|
||
|
||
Declare a second inverter and **four rows per unit** appear beside them:
|
||
|
||
| per-unit row | what it is |
|
||
|---|---|
|
||
| `Setpoint` | this unit's own share of the site target — the number its heartbeat re-emits |
|
||
| `Battery power` | this unit's own reading, `+` = discharging |
|
||
| `Battery SoC` | **this** pack. The site `Battery SoC` row is the *emptiest* pack, which may be the other one |
|
||
| `Sign latch` | this unit's SAFETY-02 latch: `tripped` or `clear` |
|
||
|
||
⚠️ **The devices are deliberately asymmetric, and it is not an oversight.** The
|
||
first inverter's rows are added to the **existing** *GoodWe* device; every later
|
||
inverter gets its **own** device (*GoodWe Inverter B*), shown in Home Assistant
|
||
as connected via the controller. Home Assistant composes an entity id from the
|
||
**device name plus the entity name**, so giving the first inverter a device of
|
||
its own would rename every entity you already have — and because HA keeps the id
|
||
it composed at first discovery, you would get a new entity beside a dead one:
|
||
the history stays on the old id and every dashboard, template and automation
|
||
naming it silently stops updating. The entity ids come out symmetric anyway —
|
||
`sensor.goodwe_inverter_a_battery_power` beside
|
||
`sensor.goodwe_inverter_b_battery_power`.
|
||
|
||
**Do not rename the device.** Not in this add-on, and not in Home Assistant's
|
||
device page. It is half of every entity id, including
|
||
`sensor.goodwe_p1_sample_age`, which the ESP32's stale-input watchdog subscribes
|
||
to; a rename disarms that guard by leaving it waiting for a state that never
|
||
arrives, and it fails silently and looks correct.
|
||
|
||
**`Grid power` and `P1 sample age` stay site-scoped and are announced once.**
|
||
There is one P1 meter and one grid connection; a per-inverter copy would invent
|
||
two of a thing there is one of, and `P1 sample age` is a safety input. Same for
|
||
`Circulating power` (it is a property of the *set* of inverters), the controller
|
||
status, the maintenance phase, the capacity-tariff peak and the three loop rows
|
||
below.
|
||
|
||
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
|
||
|
||
The control law is a bounded integrator: an accumulator that is **not** the
|
||
command. Each cycle it moves by `gain × error`; the command is then derived
|
||
from it and passed through the output clamp, the slew limit and a 10 W
|
||
quantisation before anything is written. Three entities publish what the
|
||
accumulator is doing:
|
||
|
||
| entity | unit | what it is |
|
||
|---|---|---|
|
||
| `Loop integrator` | W | the accumulator **after** this cycle — the value *before* the clamp, the slew limit and quantisation |
|
||
| `Loop integrator lead` | W | that value minus **the command it was computed against**, i.e. the command the previous cycle left standing |
|
||
| `Loop reason` | — | the law's own word for this cycle |
|
||
|
||
`Loop reason` takes one of eight values:
|
||
|
||
| value | what happened |
|
||
|---|---|
|
||
| `tracking` | ordinary regulation |
|
||
| `deadband` | the error is inside `deadband_w`; the loop is holding |
|
||
| `i-clamped` | the integrator hit `integrator_max_w` (which follows `max_w` by default) |
|
||
| `clamped` | the *command* came out over `max_w` — only reachable if `integrator_max_w` is deliberately set above the rail |
|
||
| `slew-limited` | the command could not move as far as the integrator asked for; `slew_w` is what stopped it |
|
||
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
|
||
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
|
||
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
|
||
|
||
⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will
|
||
often disagree.** `Why` is the *arbiter's* sentence — `loop -> -1200 W` — which
|
||
names the strategy that won and the watts it won with. It has never carried the
|
||
strategy's own reason, so a loop that is slew-limited or frozen says so **only**
|
||
here.
|
||
|
||
⚠️ **`Loop integrator` is not `Setpoint`, even though it usually looks like
|
||
it.** With nothing binding, the command *is* the integrator rounded to 10 W, so
|
||
on a quiet day the two rows track each other and the difference is a rounding
|
||
step. They separate exactly when something is wrong: a large disturbance sends
|
||
the integrator to its bound in one or two cycles while the command may only
|
||
travel `slew_w` per cycle, and `Loop integrator lead` is the gap. A standing,
|
||
large lead means the loop has wound up further than the command can follow, and
|
||
the number of cycles needed to unwind it is that lead divided by `slew_w`.
|
||
|
||
Both numbers are `state_class: measurement`, so Home Assistant's recorder keeps
|
||
long-term statistics for them.
|
||
|
||
⚠️ **`Loop integrator lead` is BOUNDED, and the bound is two slew steps.** While
|
||
the slew limit is what is holding the command back, the integrator may not stand
|
||
more than **one** `slew_w` ahead of the command it emits — so the command always
|
||
covers the gap in one cycle, and a reversal costs one cycle of unwind rather
|
||
than `integrator_max_w / slew_w` (12.5 at `max_w: 5000`, `slew_w: 400`).
|
||
Measured against the *previous* cycle's command, which is what this row
|
||
publishes, that is two steps: 800 W at the live shape. **A published lead
|
||
persistently above two slew steps means something is wrong** — either the
|
||
saturation freeze is holding the command (`Loop reason` says
|
||
`saturated-freeze`), or the bound is not doing its job.
|
||
|
||
Before this bound existed, the same 6 kW export drove the lead to 4600 W and the
|
||
integrator to its 5000 W rail, and the command then needed twelve cycles — about
|
||
a minute at this install's meter rate — to walk back down once the disturbance
|
||
cleared.
|
||
|
||
⚠️ **What the bound buys — and how much of it is a model rather than a meter
|
||
reading.** Sixteen days of the reference install's own recorded disturbances
|
||
were replayed through both laws (`tools/replay_recorded_loop.py`). The result
|
||
that survives every plant and tuning tried is the **export reduction: about an
|
||
eighth of the modelled export** — 5.5 kWh of 39.6 at the plant lag the replay
|
||
fits, 3.8 kWh of 28.9 at the shorter lag that best reproduces what this install
|
||
actually exported — with wasted battery throughput falling alongside it, 4.67 to
|
||
3.19 kWh at the fitted plant. ⚠️ **Those are simulated watt-hours.** The
|
||
replay's fitted inverter carries a ~375 W RMS residual and prints `+35 %
|
||
PESSIMISTIC` beside its own export figure on every run; the *direction* is what
|
||
it establishes, not the absolute kWh.
|
||
|
||
⚠️ **And it does NOT promise you a lower capacity-tariff peak.** The worst
|
||
quarter-hour average offtake — the billed quantity — improves from 3739 W to
|
||
3434 W **only at the longest plant lag the replay fits**. At every shorter lag,
|
||
including the one that best reproduces the recorded export, release/1.0 already
|
||
sits at 3434 W and this bound changes it by exactly zero. The plant lag is not
|
||
identified by the recording, so treat the peak as unchanged and the export as
|
||
the reason this exists. `DECISIONS/SAFETY-48-bounding-the-integrator-lead.md`
|
||
§3a has the table.
|
||
|
||
⚠️ **Two roster names that differ only in punctuation or case** — `inverter-b`
|
||
and `Inverter B` — are two names to the validator and one id once slugged. The
|
||
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
|
||
get then depend on roster order. Give your inverters plainly different names.
|
||
|
||
### PV production (`pv_entity`, TEL-11)
|
||
|
||
> **More than one solar inverter?** Use `pv_entities` instead — see
|
||
> [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) below. The
|
||
> rest of this section applies to both.
|
||
|
||
|
||
**Optional, off by default, and it changes no control behaviour whatsoever.**
|
||
Set it and the add-on records what your solar is producing beside what the
|
||
meter and the battery are doing. Leave it empty and the add-on reads no entity,
|
||
announces no entity, publishes no topic and shows no row — which is what every
|
||
install that upgrades into this version does.
|
||
|
||
#### Why it is a Home Assistant entity and not a register
|
||
|
||
This add-on's RS485 bus carries the **battery** inverter it drives. On nearly
|
||
every site the PV inverter is a different box from a different vendor, on its
|
||
own connection, reporting into Home Assistant through its own integration. So
|
||
`pv_entity` takes whatever `sensor.*` your PV integration already provides —
|
||
any brand Home Assistant can read. It does not have to be on the bus, and there
|
||
is no assumption anywhere that it is.
|
||
|
||
#### What it is for
|
||
|
||
OPT-10 measured this site over sixteen days: the battery reached 90 % on **two
|
||
of them**, and **20.5 kWh of surplus left the site while the pack still had
|
||
room** — 12.6 kWh of it with the battery not charging at all. Three different
|
||
things produce that number and they need three different fixes:
|
||
|
||
* there was no irradiance to store — nothing to fix;
|
||
* production was there and something limited the charge power;
|
||
* production was there, the battery could take it, and the optimiser declined.
|
||
|
||
Only the third is a software defect, and **none of them can be told apart
|
||
without a record of production.** This entity is that record. The attribution
|
||
itself is OPT-10's, with a week of history behind it.
|
||
|
||
#### The sign, and `pv_invert_entities` (TEL-14)
|
||
|
||
**Positive must mean producing.** Some sensors — typically a kWh meter or an
|
||
ESPHome power clamp in the PV circuit — read negative while producing and a few
|
||
watts positive at night: list each of those in `pv_invert_entities`, spelled as
|
||
in `pv_entity`/`pv_entities`, and its sign is flipped before the sum; the status
|
||
page marks it `[sign inverted]` and cautions on any entity reading below −50 W.
|
||
History recorded before you set it keeps the old sign. See
|
||
`DECISIONS/TEL-14-pv-sign.md`.
|
||
|
||
#### The unit is read off the entity
|
||
|
||
The add-on reads your entity's own `unit_of_measurement` and converts: `mW`,
|
||
`W`, `kW`, `MW`, `GW`, `TW` — Home Assistant's own power units. A sensor with
|
||
**no** unit, or with a unit that is not one of those, is **refused**, and the
|
||
status page says so (*the sensor has no unit*). That is deliberate: there is no
|
||
magnitude test that can separate 30 W of dusk from 30 kW of noon, so a guess
|
||
here would be a silent factor of 1000 in the one channel that exists to be
|
||
believed a week later.
|
||
|
||
#### ⚠️ Absence is never zero
|
||
|
||
| what happened | `sensor.goodwe_pv_power` | on the status page |
|
||
|---|---|---|
|
||
| neither `pv_entity` nor `pv_entities` set | the entity does not exist | no row at all |
|
||
| set, sensor answered `4100 W` | `4100.0` | `PV 4 100 W` |
|
||
| set, sensor answered `0 W` | `0.0` | `PV 0 W` |
|
||
| set, sensor unavailable / missing / bad unit | `unknown` | `PV —`, plus a caution naming the reason |
|
||
|
||
The last two rows are the point. A dead sensor publishes Home Assistant's own
|
||
"no value" payload, so the entity goes to **`unknown`** and the recorder stores
|
||
a **gap** — not a flat line at the last number it saw. Reading a week of
|
||
history back, *"no PV was reported"* and *"PV reported 0 W"* are different
|
||
pictures, which is exactly the distinction the whole card exists to preserve.
|
||
|
||
A failed read also **clears** the previous good one: the value never outlives
|
||
its own sensor.
|
||
|
||
### Several PV inverters (`pv_entities`, TEL-12)
|
||
|
||
A multi-string or multi-orientation roof routinely carries **two or three PV
|
||
inverters**, each publishing its own power sensor into Home Assistant. List them
|
||
all and the add-on records the **site total**:
|
||
|
||
```yaml
|
||
pv_entities:
|
||
- sensor.pv_east_power
|
||
- sensor.pv_west_power
|
||
- sensor.pv_garage_power
|
||
```
|
||
|
||
In the Configuration tab this is a repeatable field: press the **+** and add a
|
||
row per inverter. Each entity's **own** `unit_of_measurement` is read, so a
|
||
kW inverter beside a W one adds up correctly.
|
||
|
||
#### It replaces `pv_entity` — clear that one
|
||
|
||
Setting `pv_entities` makes `pv_entity` **ignored**, and the status page says so
|
||
until you clear it. That is deliberate rather than tidy: if the two were *added*
|
||
together, an upgrade where you leave your first inverter in `pv_entity` and also
|
||
list it here would count it **twice**, and a doubled total is as wrong as a
|
||
missing one in the one channel that exists to be believed a week later.
|
||
|
||
#### ⚠️⚠️ If ANY of them stops reporting, there is NO site total
|
||
|
||
This is the whole reason the key is shaped this way, so it is worth being plain
|
||
about:
|
||
|
||
| what happened | `sensor.goodwe_pv_power` | on the status page |
|
||
|---|---|---|
|
||
| all three reporting 1200 / 2100 / 450 W | `3750.0` | `PV 3 750 W` |
|
||
| all three reporting, one of them **0 W** | `1650.0` | `PV 1 650 W` |
|
||
| one of the three `unavailable` | `unknown` | `PV —`, plus a caution naming which source is missing |
|
||
| all three gone | `unknown` | as above |
|
||
|
||
**It does not publish the sum of the ones that answered.** Two inverters out of
|
||
three add up to a smaller number that is *indistinguishable from a real one* —
|
||
and read back a week later that says *"there was less sun than there was"*,
|
||
which is precisely the conclusion this recording exists to **test** rather than
|
||
to manufacture. A gap makes the analysis say *"I do not know"*; a partial makes
|
||
it say something false. So the total goes to `unknown` and the recorder stores a
|
||
gap, exactly as it does for a single dead sensor.
|
||
|
||
The diagnostic is not thrown away — it is put where a person will see it. The
|
||
status page names the missing source by its place in the `pv_entities` list
|
||
(*source 2 of 3*). That page is read live by a human who wants to fix something;
|
||
the recorded number is read cold by an analysis that would believe it.
|
||
|
||
Note the **third row against the second**: a member honestly reporting `0 W` is
|
||
**summed**, not treated as missing. Shading, a tripped string and nightfall are
|
||
all real zeros, and blanking the site whenever one member read zero would report
|
||
an absence on every clear evening.
|
||
|
||
#### ⚠️ Not "just use a template sensor that adds them up"
|
||
|
||
You may legitimately prefer a template sensor, and `pv_entity` will happily
|
||
point at one. But it is **not** the answer to this problem: a template sensor
|
||
summing an `unavailable` source yields the partial total *silently*, which is
|
||
the same defect one layer down — in a place this add-on cannot see and cannot
|
||
report to you.
|
||
|
||
#### ⚠️ What it does NOT do
|
||
|
||
It is **instrumentation**. It is not an input to the control law, the
|
||
allocator, the arbiter, the peak guard, the maintenance cycle or any watchdog;
|
||
it is not part of `inputs_ok`, so a PV integration that breaks cannot stop the
|
||
battery being regulated; and its status row is a **caution**, never a
|
||
*Not ready*. Wiring production into the control law would be a behaviour change
|
||
on a system driving a real battery — and it would destroy the measurement,
|
||
because the loop would then be reacting to the thing being measured.
|
||
|
||
`tools/tel11_pv_trace.py` is what says so rather than claims it: it drives 41
|
||
stimulus worlds through the shipped controller and hashes a document of control
|
||
decisions containing none of this material, on this tree and on `release/1.0`,
|
||
with the option set and unset.
|
||
|
||
### P1 meter ingestion
|
||
|
||
`meter_entity` above expects one signed sensor, which usually means a template
|
||
someone wrote by hand. Setting `meter_source` moves the whole derivation into
|
||
the add-on, where it is done once and tested, and replaces `meter_entity`
|
||
entirely.
|
||
|
||
Which mode you want depends on what your P1 reader publishes, and there are two
|
||
shapes in the wild:
|
||
|
||
- **Two unsigned registers**, consumption and injection, which is what a Belgian
|
||
P1 read over DSMR gives you → `ha_dsmr`, or `mqtt_p1` for a bridge. The add-on
|
||
subtracts them.
|
||
- **One signed figure**, positive = import and negative = export, which is what
|
||
a HomeWizard P1 gives you (`sensor.p1_meter_active_power`) → `ha_signed`. The
|
||
add-on splits it. `ha_dsmr` **cannot** read this: it wants two registers and
|
||
rejects a negative one outright, which is every exporting telegram.
|
||
|
||
Either way, do not build the missing shape out of template sensors. The point of
|
||
`meter_source` is that the sign convention is derived in one tested place rather
|
||
than in YAML nobody reviews underneath a safety input.
|
||
|
||
> ✅ **If your meter is a HomeWizard P1, use `homewizard_local`, not
|
||
> `ha_signed`.** It reads the same meter and produces the same numbers, but it
|
||
> polls the meter directly instead of watching a Home Assistant entity — and
|
||
> that is the difference between an age sensor a watchdog can threshold and one
|
||
> it cannot. See "`sensor.goodwe_p1_sample_age`" below; `ha_signed` remains for
|
||
> installs where the meter is only reachable through Home Assistant.
|
||
|
||
> ⚠️ **`ha_dsmr` and `mqtt_p1` have never processed a telegram from real
|
||
> hardware.** No meter in this installation uses either one. Both were written
|
||
> to the assumption in `specs.md` §5.2 that a Belgian P1 exposes two unsigned
|
||
> registers, and the meter actually fitted here does not — it is the HomeWizard
|
||
> P1 that `ha_signed` reads. They are covered by the unit checks in `test_p1.py`
|
||
> and by an end-to-end test against a fake Home Assistant, and nothing more.
|
||
>
|
||
> This is recorded because the realistic way it bites is someone debugging a
|
||
> meter problem months from now treating those two paths as proven and looking
|
||
> for the fault elsewhere. If you are the first person to point one at a real
|
||
> meter, expect to find something, and please update this note when you do.
|
||
|
||
| option | default | meaning |
|
||
|---|---|---|
|
||
| `meter_source` | `off` | `off` keeps `meter_entity`. `ha_dsmr` subscribes to the DSMR integration over the HA WebSocket; `mqtt_p1` reads a topic; `ha_signed` subscribes to one signed entity over the HA WebSocket; `homewizard_local` polls a HomeWizard P1's own local API, bypassing Home Assistant |
|
||
| `meter_phases` | 1 | 1 or 3. Must match the telegram, or every telegram is rejected and logged |
|
||
| `meter_max_age_s` | 30 | Beyond this the reading is stale and grid power reads as *missing*. On its own it does **not** command 0 W — see the timing note below. It is also the longest a reading is held forward into the 15-minute average |
|
||
| `meter_poll_s` | 5 | `homewizard_local` only. Seconds between polls. **Must be well under `meter_max_age_s`** — see below |
|
||
| `p1_host` | | `homewizard_local` only. The meter's own address, `host` or `host:port` (e.g. `192.168.2.250`) |
|
||
| `meter_mqtt_topic` | | `mqtt_p1` only |
|
||
| `p1_import_entity` | | The **unsigned** consumption sensor. Do not point this at a signed template |
|
||
| `p1_export_entity` | | The **unsigned** injection sensor |
|
||
| `p1_phase_import_entities` | `[]` | L1..L3, in order. Needed for the capacity-tariff peak on a three-phase connection |
|
||
| `p1_phase_export_entities` | `[]` | L1..L3, in order |
|
||
| `p1_net_entity` | | `ha_signed` only. The **signed** net-power sensor: `+` import, `-` export |
|
||
| `p1_phase_net_entities` | `[]` | `ha_signed` only. L1..L3, in order, each signed the same way. Needed for the capacity-tariff peak on a three-phase connection. **The list length must equal `meter_phases`** |
|
||
|
||
Both per-phase lists are checked against `meter_phases` **once at startup**: a
|
||
list of the wrong length disables P1 ingestion with an error in the log, rather
|
||
than letting every telegram fail its phase-count check one at a time. Leaving
|
||
the list empty is fine and is not an error — you simply get no per-phase
|
||
figures, and therefore no capacity-tariff peak. On a three-phase connection
|
||
that is a much bigger omission than it looks: on a surveyed reading here the
|
||
phases carried 2769 W of import while the connection netted 187 W, so the
|
||
billed quantity is understated roughly fifteenfold if the phases are missing.
|
||
|
||
#### How long a dead meter takes to reach 0 W
|
||
|
||
`meter_max_age_s` and `stale_input_s` **stack**. They are two different clocks
|
||
and neither one is the whole answer:
|
||
|
||
| step | option | default |
|
||
|---|---|---|
|
||
| telegrams stop, P1 sample goes stale, grid power starts reading *missing* | `meter_max_age_s` | 30 s |
|
||
| inputs have been missing long enough for the loop to command 0 W | `stale_input_s` | 15 s |
|
||
| **total, meter death → 0 W commanded by this add-on** | | **45 s** |
|
||
|
||
So in P1 mode `stale_input_s` is *not* "how long inputs may be missing before
|
||
commanding 0 W" measured from the meter dying — it is measured from the moment
|
||
the P1 sample already went stale. Size the pair together: the ESP32's own
|
||
watchdog commands 0 W after ~30 s of silence from this add-on regardless, and
|
||
that layer is unaffected by either option.
|
||
|
||
#### How long a FROZEN inverter power register takes to reach 0 W
|
||
|
||
A third clock, and it is a different failure from the one above: the entity is
|
||
still there and still answering — it has simply stopped changing. Before
|
||
SAFETY-37 that read as a healthy measurement for ever, which is what happened on
|
||
2026-09-02 (HW-01): a value held for 6 h 25 m while the pack drained to its
|
||
floor, and every detector read healthy throughout.
|
||
|
||
`Inverter.batt` — the inverter's own AC power, register `0x0518` — is now
|
||
withheld from the control path when **all three** of these are true:
|
||
|
||
| | |
|
||
|---|---|
|
||
| the reading has not changed for | **35 s** |
|
||
| **and** the command standing on that inverter is over | **250 W** |
|
||
| **and** that command has travelled, since the reading last moved, over | **250 W** |
|
||
|
||
All three, because a converged site legitimately repeats one power figure for
|
||
minutes — measured at up to **263 s** over 30 days of this house's own meter —
|
||
and a site at rest is not owed movement. It is only when the controller has
|
||
*asked the inverter to move* and the register has not answered that the reading
|
||
is treated as missing.
|
||
|
||
**A withheld reading is a MISSING INPUT**, so it joins the `stale_input_s` clock
|
||
above: 35 s to withhold, then 15 s of hold, then 0 W. It clears itself the
|
||
instant the register reports a different value — one watt is enough, and this
|
||
register dithers ±1 W while holding — so a working inverter clears it on its
|
||
next 10 s publish. There is **no option to configure**: the 35 s is derived from
|
||
the ESPHome publish interval and the two watchdog windows it has to stay inside,
|
||
and the add-on's own checks refuse a value that does not.
|
||
|
||
⚠️ **AND IF THE REGISTER DOES NOT REPORT A DIFFERENT VALUE, THE ADD-ON STOPS
|
||
WAITING AFTER 17 s (SAFETY-57).** This paragraph used to end at the sentence
|
||
above, and on 2026-09-21 that turned out to be a promise the state could not
|
||
keep: at 0 W **nothing is asking the register to be anywhere**, so "report a
|
||
different value" is evidence a parked site cannot ask for. The live site sat at
|
||
0 W for **790 s** while the house exported 2.4 kW, on a register that was
|
||
working perfectly — the pack was simply at its floor and the BMS was refusing
|
||
the discharge — and it was freed by a person restarting the add-on.
|
||
|
||
So: once the standing command has been **under 250 W for 17 s**, the verdict is
|
||
**withdrawn** and the register is graded again from scratch over a fresh 35 s.
|
||
The whole parked interval is now `stale_input_s` + 17 s — **32 s at the
|
||
defaults** — and it does not depend on what the battery decides to do.
|
||
|
||
| in the action log | what it means | what to do |
|
||
|---|---|---|
|
||
| `STALE <unit>` alone, then nothing | the register stopped following and has not come back | look at the unit — comms, the ESP32, the inverter |
|
||
| `STALE <unit>` then `STALE WITHDRAWN <unit>`, **once** | the pack was probably empty or full and the BMS refused the command. The site has resumed | nothing |
|
||
| the pair repeating every ~70 s | the register really is not following, and the add-on is re-testing it | look at the unit; the site is regulating on the meter in between |
|
||
|
||
⚠️ **`STALE WITHDRAWN` IS NOT "RECOVERED", AND THE LOG LINE SAYS SO ITSELF.**
|
||
Nothing has been learned about the register when it is printed; the verdict is
|
||
withdrawn because it can no longer be *tested*, and the next 35 s are the test.
|
||
During those 35 s the held number reaches the site's telemetry again. That is
|
||
deliberate and it is bounded: 35 s is shorter than both the circulating-power
|
||
hold (75 s) and the per-unit sign hold (85 s), so neither of those alarms can be
|
||
made to say anything by a held number, and the command itself is derived from
|
||
the **meter**, never from this register.
|
||
|
||
⚠️ **What this does NOT catch: a register whose value is WRONG rather than
|
||
STOPPED**, including a dead publisher whose last two samples happen to
|
||
alternate. Detecting that needs an arrival stamp inside the ESP32 firmware
|
||
itself, which is TEL-06.
|
||
|
||
There is **no fallback to an inverter-side power figure**, deliberately. The
|
||
inverter's own AC power tracks its battery almost perfectly and the real meter
|
||
hardly at all, so a controller that failed over to it would be regulating
|
||
against its own output while looking healthy.
|
||
|
||
The `mqtt_p1` payload is one JSON object per telegram, and the schema is strict —
|
||
a key it does not recognise is a telegram from something other than what was
|
||
tested, and guessing a key here means guessing a kilowatt:
|
||
|
||
```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.goodwe_p1_sample_age` needs and the one Home Assistant cannot
|
||
give it at all (see the note below). It is also simply fewer moving parts: the
|
||
five-second cadence is the meter's own, rather than an integration's polling of
|
||
it re-published as a state change.
|
||
|
||
**Cadence.** The age is never fresher than the poll interval, so:
|
||
|
||
| | |
|
||
|---|---|
|
||
| meter's own update rate | ~5.0 s (measured 4.97 s) |
|
||
| `meter_poll_s` default | 5 s — nothing to gain below the meter's own rate |
|
||
| `meter_max_age_s` default | 30 s, i.e. six polls of headroom |
|
||
| `meter_poll_s >= meter_max_age_s` | **refused at startup** — every reading would be stale before its successor arrived |
|
||
| `meter_poll_s > meter_max_age_s / 2` | warned — one missed poll makes the reading stale |
|
||
|
||
A failed poll — timeout, connection refused, non-200, unparseable body — is a
|
||
**missing** reading. It submits nothing, so the reading does not become 0 W, the
|
||
last good value and its timestamp are left alone, and the age goes on climbing.
|
||
That is exactly what a dead meter should look like.
|
||
|
||
**What it still cannot see: a frozen meter.** A meter that answers `200 OK`
|
||
forever with a stale number is arriving, so no arrival detector — this one
|
||
included — can tell it from a healthy one. The local API does expose the raw
|
||
material the HA path never had (the `total_power_*_kwh` registers stop
|
||
advancing), and the transport tracks it as `unchanged_s`, but it is deliberately
|
||
*not* folded into the age and *not* thresholded: this controller regulates grid
|
||
power toward ~0 W, and at a converged −10 W the export register needs six
|
||
minutes to move by its 1 Wh resolution while the power figure legitimately
|
||
repeats. Thresholding that at 30 s would rebuild the false-trip limit cycle at
|
||
the exact operating point the controller aims for. Freeze detection is a
|
||
separate problem and needs the low-power case solved first.
|
||
|
||
You read it yourself instead: the add-on's status page shows it on the Meter
|
||
row, as `… 4 readings refused, unchanged for 5 minutes`. On a house drawing real
|
||
power that figure stays in the seconds; minutes of it while the load is clearly
|
||
not near zero is the meter to go and look at.
|
||
|
||
#### `sensor.goodwe_p1_sample_age`
|
||
|
||
Published over MQTT discovery whenever a broker is available: **seconds since the
|
||
newest accepted telegram**, refreshed every second rather than only when a
|
||
telegram lands. The ESP32's stale-input watchdog subscribes to this exact entity
|
||
id; do not rename the `GoodWe` device or this entity, since HA composes the id
|
||
from both (see [What appears in Home Assistant at two inverters](#what-appears-in-home-assistant-at-two-inverters)).
|
||
|
||
The reason it is recomputed against the clock is that Home Assistant only pushes
|
||
a state when the state *changes*. A meter sitting at a genuinely constant reading
|
||
emits nothing, which is indistinguishable — to anything watching the value — from
|
||
a meter that has died. Watching the age instead separates the two: it climbs when
|
||
telegrams stop and resets when they arrive, whatever the reading says.
|
||
|
||
The entity is only created when `meter_source` is not `off`. With P1 ingestion
|
||
disabled there is nothing feeding it, and an age sensor climbing with no ingester
|
||
behind it would trip the firmware watchdog on a system that is working fine.
|
||
|
||
> **Known limit, `mqtt_p1`.** The age measures *arrival*. On the MQTT path a
|
||
> bridge that is stuck republishing its last telegram keeps arriving, so the age
|
||
> stays near zero and a frozen meter still looks fresh. Detecting *that* needs a
|
||
> change-detector rather than an arrival-detector, and it is not in this version.
|
||
|
||
> ⚠️ **Known limit, `ha_signed` — do not drive a watchdog off this age yet.**
|
||
> On the HA WebSocket paths the age is stamped when a `state_changed` arrives,
|
||
> which means it measures *time since the value last changed*, not time since
|
||
> the meter last reported. Home Assistant offers nothing better: a repeated
|
||
> reading produces no `state_changed`, does **not** advance `last_reported` on
|
||
> either the REST or the WebSocket serialiser, and `state_reported` cannot be
|
||
> subscribed to over the WebSocket at all (`Event filter is required for event
|
||
> state_reported`). All three measured on the ENV-01 rig against the real
|
||
> HomeWizard integration with the meter frozen: 0 `state_changed` in 70 s and no
|
||
> timestamp movement anywhere.
|
||
>
|
||
> `ha_dsmr` mostly escapes this because a DSMR telegram updates several entities
|
||
> and something in the set almost always moves. **`ha_signed` has exactly one
|
||
> entity, so a healthy meter under a flat load is indistinguishable from a dead
|
||
> one.** This is not hypothetical: in our own captures
|
||
> (`sim/scenarios/ha-p1_meter_active_power-2026-08-20.json`) the real house meter
|
||
> went **42.2 s and 97.0 s** between changes, and 23 Aug peaks at 29.1 s — all
|
||
> past the default `meter_max_age_s` of 30.
|
||
>
|
||
> So `sensor.goodwe_p1_sample_age` on `ha_signed` is safe to *read*, and it is
|
||
> correct whenever the value is moving, but it must not yet be thresholded by
|
||
> the ESP32 stale-input watchdog: a quiet house would trip the battery to 0 W.
|
||
> Raising `meter_max_age_s` is **not** the fix — the two conditions produce an
|
||
> identical signal, so a bigger number only chooses which of the two errors you
|
||
> get. The real fix is an arrival stamp the meter itself provides — and that now
|
||
> exists: **`meter_source: homewizard_local`**. If you have a HomeWizard P1,
|
||
> switch to it. If your meter is only reachable through Home Assistant, this
|
||
> limit still applies to you and the watchdog threshold still must not be armed.
|
||
|
||
**Where the age is trustworthy:**
|
||
|
||
| mode | the age measures | safe to threshold from firmware |
|
||
|---|---|---|
|
||
| `homewizard_local` | time since the meter **answered** | **yes** — every HTTP response is an arrival |
|
||
| `ha_dsmr` | time since one of several entities changed | no — statistically usually fine, which is a masked bug, not an absent one |
|
||
| `ha_signed` | time since the one entity changed | **no** — see above |
|
||
| `mqtt_p1` | time since a message arrived | arrivals yes, but a stuck bridge republishing keeps arriving |
|
||
|
||
### Control
|
||
|
||
| option | default | meaning |
|
||
|---|---|---|
|
||
| `max_w` | 2000 | Hard limit on what may be commanded. Start low, raise after commissioning. **Not read when `inverters` is declared** — each entry carries its own, and the site ceiling is their sum |
|
||
| `gain` | 0.6 | Correction per cycle. **At the limit — do not raise** |
|
||
| `slew_w` | 0 | Largest change in one cycle. **0 means follow the rail** - `max_w / 9`, i.e. the loop takes the plant's own measured ~18 s settling time to cross its whole output range at the 2 s tick the live install runs. 222 W at `max_w: 2000`, 556 W at 5000, 1067 W at a ~9600 W two-inverter site. A fixed number is accepted and never silently reduced, but one FASTER than the rail implies is refused at startup (SAFETY-39): `slew_w: 1000` against `max_w: 5000` crossed the rail in 10 s against an 18 s plant, and on 2026-09-05 drove the meter from 3.4 kW of export to 2.8 kW of import |
|
||
| `deadband_w` | 15 | Ignore errors smaller than this |
|
||
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
||
| `step_w` | 10 | Quantisation |
|
||
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
|
||
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1**. Every value this file accepts (1-10) is below the freeze's own release bound of 12 cycles, so no accepted setting can stop the block engaging - see SAFETY-13, below |
|
||
| `integrator_max_w` | 0 | Bound on the loop's accumulator, and 0 means "same as `max_w`". Caps how much stale error can be waiting to unwind when the sign flips. **Do not raise it above `max_w`** - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below `max_w` is the useful direction |
|
||
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
|
||
| `cycle_floor_s` | 15 | The longest the loop may go without evaluating. A deadline on the gap between cycles, reset by every cycle — on a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than ~10 s; it must stay under `meter_max_age_s`, past which there is no reading to evaluate anyway |
|
||
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W. In P1 mode this clock starts only *after* `meter_max_age_s` has already expired — the two stack, see "How long a dead meter takes to reach 0 W" |
|
||
| `auto_start` | false | Start controlling on boot (only after commissioning) |
|
||
| `sign_disagree_w` | 300 | Both the command and the meter must exceed this before a sign difference counts. Must stay **below `max_w`** or the watchdog can never fire, and the add-on refuses to start |
|
||
| `sign_disagree_s` | 180 | How long they must stay opposed, continuously, before the failsafe trips |
|
||
| `unit_sign_disagree_w` | 250 | **Two or more inverters only.** Both a unit's own share and its own measured power must exceed this before their sign difference counts. It is `saturation_w / 2`, so the smallest divergence it can see is exactly the gap at which the loop already calls the inverter "not following". Must stay **below `max_w`** or it can never fire, and the add-on refuses to start |
|
||
| `unit_sign_disagree_s` | 85 | **Two or more inverters only.** How long one unit's command and its own power must stay opposed before that unit is parked |
|
||
| `site_sign_disagree_w` | 300 | **Two or more inverters only.** Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as `sign_disagree_w`, for the same reason: near zero the sign is noise |
|
||
| `site_sign_disagree_s` | 325 | **Two or more inverters only.** How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than `sign_disagree_s` because the command has to cross zero over a rail that is the sum of the roster, at a `slew_w` that is not - measured at 110 s with one inverter and 215 s with two |
|
||
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
|
||
| `shadow_mode` | `auto` | **Run every decision and command nothing.** `auto` = shadow mode while control is stopped, ordinary control once you press Start; `on` = shadow mode always; `off` = ordinary control. See *Shadow mode* below |
|
||
|
||
#### Shadow mode (`shadow_mode`, SAFETY-10)
|
||
|
||
Shadow mode runs the **whole** decision path — the price strategy, the capacity
|
||
guard, the maintenance shaping, the grid-following loop, the arbiter that ranks
|
||
them, SAFETY-28's authorisation gate and SAFETY-03's clamp — and **sends nothing
|
||
to the inverter**. It is how you find out what this add-on *would* do on a site
|
||
before you let it do it.
|
||
|
||
Every defect in the optimiser that this project has found was invisible from
|
||
outside while it was happening: a price lookup answering for the wrong
|
||
quarter-hour, a trade against a binding capacity cap that ran 28 charge onsets
|
||
in an hour, an SoC ceiling churning 598 Wh to gain 310 Wh. Each one was paid for
|
||
at the meter first and noticed afterwards. Shadow mode is the surface that
|
||
would have shown all three on the day.
|
||
|
||
**What "commands nothing" means, exactly.** Every inverter is held at **0 W**,
|
||
written by the ordinary single writer and refreshed on the ordinary heartbeat.
|
||
It is not silence. ⚠️ **The inverter holds its last command forever** — it has
|
||
no meter timeout — so a mode that simply stopped writing would leave whatever
|
||
was standing when you switched it on standing for as long as you left it on,
|
||
under a page saying nothing was being commanded. Refreshing 0 W is also what
|
||
the ESP32's 30 s stale-input watchdog requires of us: going quiet would put
|
||
that board into a *tripped watchdog* state for the whole run, which is an alarm
|
||
being used as a mode.
|
||
|
||
**The three values.**
|
||
|
||
| value | what happens |
|
||
|---|---|
|
||
| `auto` | **the shipped default.** Shadow mode while control is **stopped**; ordinary control once control is **started**. A new install ships `auto_start: false`, so it comes up in shadow mode and observes before it ever commands anything. An install that is already controlling is started, so nothing about it changes |
|
||
| `on` | shadow mode **always**, whatever the *Start / stop control* button says. This is the one to pick to watch an armed site for a day — the loop runs, arbitrage decides, the peak guard files its claims, and the battery does not move |
|
||
| `off` | ordinary control |
|
||
|
||
⚠️ **Existing installs are not changed.** The default is `auto` rather than a
|
||
boolean `true` precisely because a boolean would have changed them: the
|
||
Supervisor composes an add-on's options as *its defaults, overlaid with your
|
||
stored options*, so a new key with a new default reaches every install on the
|
||
next update. `shadow_mode: true` would have silently stopped a battery that is
|
||
regulating today, and the only symptom would be a battery that stopped doing
|
||
anything.
|
||
|
||
⚠️ **Under `auto`, pressing *Start* leaves shadow mode.** That is the point of
|
||
`auto` — the button means "control the battery". Set `on` if you want the
|
||
decisions recorded while the loop runs.
|
||
|
||
**What you get out of it.** The status page carries a red-amber banner saying
|
||
the battery is not being controlled, a `Shadow mode` row saying which rung
|
||
decided, what it **would have commanded on that cycle** and why — with the
|
||
caveat below, where the number was sized from a meter the battery never
|
||
moved — and the `Why` row with the arbiter's own sentence. The action log gets one line each time the decision
|
||
changes — the winner or the watts — rather than one per cycle, so a 40-row log
|
||
still contains the context of what it found. MQTT reports `status: shadow`.
|
||
|
||
**The failsafes.** The sign watchdogs (SAFETY-02/30) are **inert**: they grade
|
||
what this controller has standing on the wire, and that is 0 W, so they cannot
|
||
arm. That is deliberate and it is the single most important decision in this
|
||
feature — fed the shadow decision instead they would disagree with a meter that
|
||
never saw it, and **latch a trip that survives into live operation and a
|
||
restart**. The clamp (SAFETY-03), the write-authorisation gate (SAFETY-28), the
|
||
frozen-meter alarm (SAFETY-18) and the capacity guard all **observe**: they run
|
||
on the number the decision path produced, so a shadow run can tell you the
|
||
clamp would have engaged. ⚠️ **The capacity guard observes a meter with no
|
||
battery in it.** Every term of the quantity it thresholds is measured, so it
|
||
engages in a shadow run only if the *house alone* would have put the quarter
|
||
over the ceiling — 410 engaged cycles live against 0 in shadow, on the hour
|
||
tabulated below. Its *ceiling* and its *reasons* are worth reading from a
|
||
shadow run; its *engagements* are not evidence of anything. The circulation (SAFETY-31) and delivery (SAFETY-42)
|
||
watchdogs observe and may legitimately latch, because their evidence is
|
||
*measured* power, not a command. The operator stop is enforced at the wire
|
||
instead of as a claim, so that the recorded decision is the real one.
|
||
|
||
⚠️ **A latch that was already up stays up.** Shadow mode does not clear
|
||
anything. The banner names any standing latch and goes red for it, because a
|
||
latch that is up while the controller is inert is up the moment it stops being
|
||
inert.
|
||
|
||
⚠️ **Maintenance is unchanged.** The maintenance state machines are ticked on
|
||
every cycle whether control is started or not, so they already run against a
|
||
battery that does not move on a stopped install. Shadow mode inherits that
|
||
exactly; it neither improves it nor makes it worse.
|
||
|
||
⚠️ **What a shadow run will NOT tell you — and it is a whole category, not a
|
||
footnote.** The controller's carried command is re-seeded to the 0 W standing
|
||
on the wire at the end of every shadow cycle — that is what makes turning
|
||
shadow mode off safe, because the loop then resumes from where the battery
|
||
actually is. Everything below follows from the same fact.
|
||
|
||
**A shadow run cannot show you a closed-loop defect. Not partially — not at
|
||
all.** The battery is held at 0 W, so every *measured* input — the meter, the
|
||
billed-quarter accumulator behind it, the battery power, the state of charge —
|
||
describes a plant that did not respond. A closed-loop defect is one whose
|
||
stimulus **is** the plant's response, and there is no response to be the
|
||
stimulus. This is not a gap waiting to be closed: feeding the guards a
|
||
simulated plant instead is exactly what the sign-watchdog decision below
|
||
refuses, and for the same reason — a controller reacting to a plant that does
|
||
not exist drifts further from reality every tick. **The closed-loop answer
|
||
comes from the closed-loop rig or from a live run, and there is no third
|
||
place.**
|
||
|
||
Concretely, measured on the rig at `peak_cap_w` 1500 under a 1200 W house,
|
||
`max_w` 2000, one hour at a 5 s cycle — the same hour run twice, live and in
|
||
shadow, on the same code:
|
||
|
||
| | live | shadow |
|
||
|---|---|---|
|
||
| charge onsets in the hour | **28** | **1** |
|
||
| grid | **−455.5 … +3200.0 W** | **1200.0 W flat — the house** |
|
||
| capacity guard engaged | **410 / 720 cycles** | **0 / 720** |
|
||
| state of charge | 50.000019 → 51.856789 % | **50.000000 → 50.000000 %** |
|
||
|
||
(The onset and engagement figures are taken with the trade-sizing fix reverted,
|
||
because that is the defect being looked for; the SoC and grid figures are the
|
||
shipped build.)
|
||
|
||
⚠️ **The "strategies are unaffected" exemption that used to stand here was true
|
||
until the trade-sizing fix landed, and is now false by hours.** That fix makes
|
||
the buy leg `min(asked, headroom)`, where the headroom is
|
||
`(900 / seconds remaining) × (ceiling − projected quarter average)` — and the
|
||
projection reads the **open quarter's accumulator**, i.e. the meter, i.e. a
|
||
function of what the battery did earlier in that same quarter. What survives
|
||
of the exemption is exactly this much:
|
||
|
||
> **Which** hour a strategy picks, and **why**, are unaffected — the clock and
|
||
> the price series are not functions of the battery. Measured: identical live
|
||
> and shadow on 720 of 720 charging cycles at a binding cap. **How hard** it
|
||
> trades is not.
|
||
|
||
⚠️ **So the watts on the `Shadow mode` row are the right answer to a question
|
||
nobody asked, and the row now says so.** In a shadow run the billed quarter
|
||
really *is* under its ceiling, because the battery really *is* not charging, so
|
||
`900 / remaining` multiplies a gap a live run would have closed. On the same
|
||
rig, shipped code:
|
||
|
||
| cap / house | live commanded | shadow recorded | over by |
|
||
|---|---|---|---|
|
||
| 1500 / 1200 | −302.4 W | **−2000.0 W** | **6.61×** |
|
||
| 3500 / 3000 | −502.6 W | −2000.0 W | 3.98× |
|
||
| 3500 / 2000 *(shipped cap)* | −1502.6 W | −2000.0 W | 1.33× |
|
||
| 3500 / 1800 | −1702.6 W | −2000.0 W | 1.17× |
|
||
| 15000 / 350 | −2000.0 W | −2000.0 W | 1.00× |
|
||
|
||
Whenever a measured billed quarter sized the number, the row and the action log
|
||
carry **⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE
|
||
HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W** — and the watts are introduced
|
||
as what it would have commanded **on this cycle**, in every state, because no
|
||
number a shadow run records is a forecast of a live one. The figure is **not**
|
||
re-computed to look right: a shadow-only sizing would mean the recorded
|
||
decision was no longer the decision the shipped path reached, which is the one
|
||
thing this feature has to be able to say.
|
||
|
||
The **grid-following loop** is the same story told first: its recorded decision
|
||
is **one slew step** and stays there, because a trajectory depends on a battery
|
||
responding and none did.
|
||
|
||
**What a shadow run genuinely is for**, then: *which* strategy wins, on *which*
|
||
hour, at *which* price, for *which* reason, against which limits — the
|
||
wrong-quarter-hour class of defect, a trade armed on a day it should not be, a
|
||
refusal whose sentence names the wrong subsystem, a clamp that would have
|
||
engaged, a standing failsafe latch. That is a real and useful set, and it is
|
||
the set this feature covers.
|
||
|
||
#### The sign-disagreement failsafe
|
||
|
||
If the setpoint this add-on is emitting and the grid figure it is regulating on
|
||
point **opposite ways** — charging the battery while the house imports, or
|
||
discharging it while the house exports — with both over `sign_disagree_w`, for
|
||
`sign_disagree_s` **continuously**, the add-on commands **0 W** and holds it
|
||
there. The banner reads *Command and meter point opposite ways*, the add-on
|
||
log says which two numbers disagreed and for how long, and the `Controller
|
||
status` entity reports `failsafe`.
|
||
|
||
#### ⚠️ At two or more inverters this is TWO checks, not one (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 **`<unit> is not delivering. Control keeps running.`** as a
|
||
*warning*, not a failure,
|
||
and it **displaces** `Running — holding grid at zero`. That last sentence is a
|
||
claim the site cannot make while a unit is absent, and a page that goes on
|
||
reading healthy while the fault runs is the whole shape this check exists to
|
||
remove.
|
||
|
||
**Charge shares are not graded**, and that is a stated gap rather than an
|
||
oversight: there is no configured charge ceiling anywhere in this add-on, so a
|
||
pack in constant-voltage taper — which legitimately takes hundreds of watts less
|
||
than it is told, for tens of minutes — could not be explained, and arming that
|
||
direction would fire on the taper of every maintenance charge.
|
||
|
||
#### Saturation is counted in cycles, not seconds
|
||
|
||
The specification states the saturation window as **"> 10 s"**. This add-on counts
|
||
**cycles** instead, and that is a deliberate, accepted deviation rather than an
|
||
oversight - the acceptance criterion is not met as literally written.
|
||
|
||
A cycle here is one meter update **or `cycle_floor_s` elapsing, whichever comes
|
||
first**. At the reference P1's ~5 s update rate the default of 3 cycles is usually
|
||
around 15 s, and the worst case is bounded at `3 x cycle_floor_s` = 45 s. It is still
|
||
not the 10 s the specification asks for, so the deviation stands - but it is now a
|
||
bounded number rather than an open one.
|
||
|
||
⚠️ **This passage used to say something different, and the second half of it was
|
||
wrong.** Until SAFETY-12 the loop ran only when the meter value *changed*, so a meter
|
||
that repeated a value stalled the counter for as long as it repeated, with no upper
|
||
bound at all. That was defended here on the grounds that "a stalled counter is a
|
||
detection-latency limit and not a runaway risk - the condition that stalls it stops the
|
||
whole loop, so nothing accumulates while it is stalled". That argument covers
|
||
**integrator windup and nothing else**. The hazard it misses is **staleness**: while the
|
||
loop was stopped the heartbeat kept re-emitting the last command and the firmware kept
|
||
putting it on the wire every 2 s, so a command computed from an old reading went on
|
||
being executed through a real load change, with every indicator reading healthy.
|
||
Measured on the rig during TEL-05: 70 s at a literally constant 350.0 W, 14 arrivals,
|
||
zero control cycles.
|
||
|
||
The half that survives is the first one: the control law is still a pure function with
|
||
no clock, and the wall-clock window still lives in `Controller`, which has one - see
|
||
`run_control()`. What changed is that the controller now enforces a floor on the gap
|
||
between cycles, so `saturation_cycles` finally means a bounded number of seconds.
|
||
|
||
If a tighter window matters on your site, lower `cycle_floor_s` (not below your meter's
|
||
update interval, or the floor starts firing on a healthy meter) or raise
|
||
`saturation_cycles` for a fast meter.
|
||
|
||
**This is not a freeze detector, and it deliberately cannot be one.** Nothing here
|
||
decides whether a repeated reading is true — see `unchanged_s` in the meter section for
|
||
why no threshold on that is safe at the converged operating point. The floor only stops
|
||
"the number is the same" from meaning "do not think". The consequence is worth stating
|
||
plainly: against a meter that is genuinely frozen at a wrong value, the loop will now
|
||
correct toward that wrong value instead of holding a stale command. It is bounded by
|
||
`max_w`, the arbiter and the device rating, and it is the same thing that already
|
||
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
|
||
safety property.
|
||
|
||
#### When the freeze lets go, and when it deliberately never does (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` | ✅ |
|
||
| **Arbitrage charging capped under `peak_cap_w`** | ✅ — SAFETY-45, sized to the guard's own MEASURED headroom rather than to `peak_forecast_entity` |
|
||
| **Loop capped under `peak_cap_w`** | ✅ — SAFETY-07, on every `meter_source` but `off` |
|
||
| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** |
|
||
| Frozen meter *stopped* | ✅ — SAFETY-07 forbids importing while the meter is frozen |
|
||
| SoC guard on the loop | ❌ — the BMS is the only energy backstop |
|
||
|
||
On the legacy `meter_source: off` path there are no P1 telegrams, so there is no
|
||
quarter-hour average to guard, and the status page says *Peak guard: off*. There
|
||
the mitigation is still operational: set `max_w` no higher than the loop actually
|
||
needs (the tuning was measured at 2000 W and a 1.78 kW load step is tracked fine
|
||
there), fit the e-stop, and watch for the alarm.
|
||
|
||
### The capacity-peak guard
|
||
|
||
**What it does.** Every cycle the add-on projects the open quarter to its
|
||
end-of-interval average — from P1 telegrams only, never from the command — and
|
||
compares it to a ceiling. Over the ceiling it files one claim: a **lower bound on
|
||
the setpoint**, at `P_PEAK`. That single shape is both behaviours §10.1 asks for:
|
||
a hard clamp on charging while the bound sits above the standing charge, and
|
||
pre-emptive discharge while it sits above zero. It can never cap discharge or
|
||
export, because a floor cannot.
|
||
|
||
**The number.** `excess = 900 × (projected − ceiling) ÷ seconds remaining`, and
|
||
the floor is the **measured** battery power plus that. The 900/remaining factor
|
||
is the design rather than an accident: it is 1.0 at the top of the quarter and
|
||
grows, so the guard is gentlest when its projection rests on the least evidence
|
||
and becomes aggressive only once the quarter is nearly measured.
|
||
|
||
**The ceiling** is `peak_cap_w`, raised to 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.
|
||
|
||
**And it sizes a price-driven charge, not only a forced discharge (SAFETY-45).**
|
||
The same arithmetic read the other way answers *"how hard may this site charge
|
||
without putting the quarter over"* — `peakguard.charge_headroom_w()` — and
|
||
`arbitrage.decide()` sizes its buy leg to it. Before that, a trade commanded
|
||
`max_w` until the projection rose far enough to engage the guard, at which point
|
||
the interlock cancelled the trade outright, the loop took the actuator, the
|
||
projection collapsed and the trade came back at `max_w`: **28 charge onsets in
|
||
one hour** on TEST-23 stage 3's closed rig, with the grid swinging −456 to
|
||
+3200 W and every billed quarter finishing over the cap. It is one onset now,
|
||
the trade runs continuously at the affordable rate, and the billed quarters land
|
||
**on** the cap. ⚠️ The guard is what changed *around* arbitrage; `decide()`
|
||
itself is untouched, so nothing above this line is different.
|
||
|
||
**No new option for the CEILING.** It is `peak_cap_w`, which already shipped. The
|
||
coverage threshold is `meter_max_age_s`, the freeze predicate is
|
||
`meter_freeze_s`, and the achievable discharge comes from `max_w` and the device
|
||
rating — every one of them a key that already exists.
|
||
|
||
#### The energy floor under the forced discharge (`peak_soc_floor`)
|
||
|
||
The guard above can **command a discharge**, and until SAFETY-24 nothing in the
|
||
add-on bounded how far down it took the pack — the BMS was the only backstop.
|
||
On the 2026-08-20 replay the guard asks for **878 Wh in a day**.
|
||
|
||
That is not absurd: the BMS floor is real and it works. But it is *the pack's*
|
||
protection, not *yours*. A month of aggressive quarters can leave the battery
|
||
repeatedly parked at that cut-out with no record on this side of why, and the
|
||
first symptom — a battery that is never available when you want it — looks
|
||
exactly like the SoC drift the monthly maintenance cycle exists to correct.
|
||
|
||
- **`peak_soc_floor` (20 %)** is where the forced discharge stops. Below it the
|
||
guard files a **0 W** floor instead of a sized one: charging stays blocked
|
||
(that can only ever lower your offtake), and no further discharge is asked for.
|
||
- **⚠️ IT BOUNDS THE PEAK GUARD ONLY. IT IS NOT A PACK-WIDE RESERVE, AND THE NAME
|
||
READS LIKE ONE.** The ordinary self-consumption loop — the grid-following
|
||
control law that holds your meter at zero — has **no SoC floor of any kind**.
|
||
It will discharge straight past this number and keep going until the pack's own
|
||
BMS cut-out stops it. What you are promised by `peak_soc_floor: 20` is *"the
|
||
capacity-peak guard will not spend the pack for money below 20 %"*, and
|
||
**nothing more**. See below for what that costs on a real evening.
|
||
- **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. The message quotes
|
||
the command the loop computed and the value it was clamped to, both from the
|
||
cycle that clamped. Once it clears, a
|
||
warning row keeps the engagement count so the evidence does not vanish with the
|
||
condition. Consecutive engagements share one event row carrying the exact count,
|
||
because the event list keeps only 40 rows and a row per cycle would flush every
|
||
other guard's entries out of it.
|
||
|
||
**There is nothing to configure**, deliberately. The bound is `max_w`, which you
|
||
already set; giving the guard a limit of its own would be a second number that
|
||
can disagree with the first, which is exactly the failure the startup validation
|
||
exists to prevent. The alarm's cadence - normally at most one notification per
|
||
150 s (an episode that has not yet notified does so once it has engaged 3
|
||
cycles in a row, and an over-rating has its own 150 s budget), cleared after 3
|
||
clean cycles - is fixed for the same reason `RAIL_FRAC` is: it describes the
|
||
notification bus, not the plant.
|
||
|
||
WARNING: **on a default install the engagement you will see is the integrator's,
|
||
not the output's.** The control law bounds the integrator to `max_w` and *then*
|
||
derives the command from it, so the command itself can never come out over the
|
||
rating unless `integrator_max_w` has been deliberately raised above `max_w`.
|
||
Both are watched. A guard that watched only the emitted value would be silent on
|
||
every standard configuration.
|
||
|
||
**The integrator bound is not a bug, and it is reported as what it is.** It
|
||
reads *"the loop's integrator reached its +5000 W anti-windup bound; the loop's
|
||
command this cycle was +2000 W"*, titled *"the control loop is at its integrator
|
||
bound"*, logged as a `WARNING`, with an amber (not red) status row and its own
|
||
notification (`goodwe_integrator_bound`), so it never replaces a BUG SIGNAL
|
||
notice still on screen. Brief
|
||
engagements after a large load or solar step are normal. If it stays engaged,
|
||
check that the battery is following its commands. Only a command over the rating
|
||
carries the BUG SIGNAL text, and it has its own notification budget.
|
||
Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md).
|
||
|
||
### The frozen-meter alarm
|
||
|
||
A meter that has died is easy — it stops answering, the sample age climbs past
|
||
`meter_max_age_s`, the reading becomes missing, and the add-on and the ESP32 both
|
||
command 0 W. A meter that keeps answering `200 OK` with a document that never
|
||
changes is the hard one: **every freshness mechanism in the stack reads it as
|
||
healthy**, because it really is arriving on every poll. The loop then integrates
|
||
a constant error and winds to the rail, in 75–360 s at `max_w: 5000`.
|
||
|
||
The add-on alarms when **both** of these are true at once:
|
||
|
||
- the meter's own document (power figure *and* both kWh registers) has not
|
||
changed for `meter_freeze_s` — 300 s by default; **and**
|
||
- the command standing on the wire is at the rail, within 5 % of the ceiling
|
||
the command can actually reach.
|
||
|
||
**That ceiling is `min(max_w, the setpoint device's own rating)`, not `max_w`.**
|
||
The emitted command is clipped to the device's reported range as well as to
|
||
`max_w`, so on a 4700 W inverter configured with `max_w: 5000` a rail measured
|
||
at 4750 W is one the command can never stand at — the guard could not fire, and
|
||
the status page said `armed` anyway. It now measures the rail against the
|
||
smaller number and the page names it, saying *"below max_w because …"* when the
|
||
device is what capped it. (On an install that started, the two are equal: a
|
||
`max_w` above the device rating is refused at startup. This matters when the
|
||
rating could not be read at boot — see below.)
|
||
|
||
Neither half is usable alone. A bare "unchanged for N seconds" rule false-trips
|
||
at the exact operating point this controller aims for — at a converged −10 W a
|
||
1 Wh register legitimately takes about six minutes to move — and a bare "at the
|
||
rail" rule fires on any genuinely large load. Together they are safe in both
|
||
directions, because a real railed condition means kilowatts still crossing the
|
||
meter, and a kilowatt advances a 1 Wh register every 3.6 s.
|
||
|
||
**It is an alarm and nothing else.** It does not command 0 W, does not latch, and
|
||
does not claim the actuator. A false trip here parks the battery; a false
|
||
notification costs an alert. Turning it into a trip is a separate decision that
|
||
needs data from a real install — and an answer to "what should the loop do
|
||
instead", given that 0 W is itself an import of whatever the house is drawing.
|
||
|
||
When it fires you get a Home Assistant persistent notification
|
||
(`goodwe_meter_frozen`, replaced rather than stacked on a repeat), an `ERROR` in
|
||
the add-on log, an entry in the UI's event list, and a red banner on the status
|
||
page. **The loop keeps running.** If the meter really is stuck, stop control from
|
||
the add-on UI.
|
||
|
||
⚠️ **Only `meter_source: homewizard_local` is covered.** It is the only transport
|
||
that reads the meter's own document; on `ha_dsmr`, `mqtt_p1`, `ha_signed` and
|
||
`off` there is no signal to threshold and the status page says *Stuck-meter
|
||
check: off* rather than pretending the guard is armed.
|
||
|
||
The same row appears for a second reason: a setpoint device that reports a range
|
||
from which no command magnitude is available in both directions leaves no rail
|
||
to measure at all, and a 0 W rail would be satisfied by *every* command. That is
|
||
reported as *off* too, and the detector stays silent rather than
|
||
degenerating into the bare "unchanged for N seconds" rule this design exists to
|
||
avoid.
|
||
|
||
| option | default | meaning |
|
||
|---|---|---|
|
||
| `meter_freeze_s` | 300 | Seconds of an unchanged measurement, *while the command is at the rail*, before the alarm |
|
||
|
||
Why 300 s: over 2399 s of this house at the converged operating point (mean grid
|
||
−14.2 W, 1194 polls, 735 of them repeating the previous reading) the largest
|
||
`unchanged_s` any sample sees is **8.0 s**, and the longest interval serving no
|
||
distinct reading at all is **15.4 s**. There is also a structural reason not to
|
||
lower it much: at 300 s the slowest a *healthy* register can tick and still look
|
||
unchanged is 12 W, which is inside the 15 W deadband — so the command there is
|
||
parked at 0 W and the two halves of the rule cannot both be true. At the schema
|
||
floor of 60 s that figure is 60 W and only the margin is left.
|
||
|
||
### The circulating-power alarm (SAFETY-31)
|
||
|
||
> **Only at two or more inverters.** At the shipped roster of one this quantity
|
||
> is identically zero for every reading, no claim is ever filed, and the status
|
||
> page says *n/a* rather than pretending to watch it.
|
||
|
||
Two inverters can move a great deal of energy while the meter reads almost
|
||
nothing. Inverter A charging at 3 kW while inverter B discharges at 3 kW nets
|
||
**~0 W at P1**: the site looks idle, and in reality the packs are being cycled
|
||
through two round-trip conversion losses continuously, for no benefit.
|
||
|
||
**Nothing else in this add-on can see it, and that is structural rather than an
|
||
oversight.** Every other guard is a consumer of the *net* figure at the grid
|
||
connection — the sign-disagreement failsafe gates on `abs(grid_w) > 300 W` and
|
||
that gate never opens at a netted zero; the capacity-peak guard and the tariff
|
||
both bill grid exchange. Circulating power is precisely the component of
|
||
per-inverter power that the sum destroys.
|
||
|
||
So the add-on computes a quantity none of them were computing:
|
||
|
||
```
|
||
circulating_w = Σ|Bᵢ| − |ΣBᵢ|
|
||
```
|
||
|
||
Zero when the units pull the same way. `2 × min(|B_a|, |B_b|)` when they oppose
|
||
— twice the power going round the loop, which is what both conversion stages are
|
||
processing between them. It is published as
|
||
`sensor.goodwe_circulating_power`, deliberately **beside** the grid figure:
|
||
*"grid 0 W, circulating 6000 W"* is the whole fault in two dashboard rows, and
|
||
no arithmetic on the grid figure alone can produce the second one.
|
||
|
||
**It is derived from each inverter's own measured power, not from what the
|
||
controller believed it commanded.** That matters because the allocator already
|
||
*refuses* to command opposite directions (ALLOC-01's sign-coherence gate), so a
|
||
figure computed from the allocation record would read zero by construction and
|
||
detect nothing. What this catches is circulation **nobody commanded** — a unit
|
||
ignoring or lagging its share, a stale share still being executed, or an
|
||
inverter doing something of its own accord.
|
||
|
||
| option | default | meaning |
|
||
|---|---|---|
|
||
| `circulation_w` | 700 | Watts of measured opposition below which nothing is reported |
|
||
| `circulation_hold_s` | 75 | Seconds it must stand there continuously before the trip |
|
||
|
||
**Why 700 W and 75 s, and why neither is a round number.** Both were read off
|
||
924 closed-loop runs on the two-master test rig in which *nothing* was faulty,
|
||
swept across the whole range the schema accepts for `gain` and `slew_w`. A
|
||
momentary opposition is **legitimate**: writes to the inverters are serialised,
|
||
so on a reversing cycle one unit gets its new number up to a control cycle
|
||
before its sibling, both slew through zero, and between the two crossings they
|
||
genuinely oppose. The worst *continuous* legitimate opposition, by threshold,
|
||
was **505 s at 100 W, 40 s at 250 W, 10 s at 300 W and 5.0 s — one control
|
||
cycle — at 400 W and above**. The knee is 400 W and it is the schema floor;
|
||
below it, ordinary operation would hold the sensor over the line for minutes and
|
||
no usable window could tell that from a fault. 700 W carries margin above the
|
||
knee because the second inverter's ramp and lag are not yet measured. 75 s is
|
||
15× the worst legitimate episode, 2.5× the firmware's own 30 s link-veto window,
|
||
and comfortably inside the 900 s quarter that bills the capacity tariff.
|
||
|
||
**What happens when it trips, and what it honestly cannot do.** The site is
|
||
commanded **0 W on every inverter** and latched there until you press start.
|
||
That is not aimed at making a misbehaving inverter stop — if a unit has stopped
|
||
listening, no command will reach it. It does two things that do not need its
|
||
cooperation: it removes *this controller* as one of the two participants in the
|
||
loop, and it makes anything still happening **visible at the meter** instead of
|
||
cancelled at it, where the ordinary guards can see it. **If the grid reading
|
||
does not move after the trip, one inverter is no longer listening and the
|
||
remaining actuator is a human at the isolator.**
|
||
|
||
**The alarm survives a Home Assistant restart.** The latch is written to `/data`
|
||
and the notification is re-raised on the first cycle after every start —
|
||
`persistent_notification` lives only in memory, and an alarm that vanishes with
|
||
a restart is how a real outage on this system went unseen for over six hours.
|
||
Restarting does **not** clear the trip; starting control from the add-on UI
|
||
does, and only after you have read why.
|
||
|
||
### Energy price providers
|
||
|
||
> **A fetched price moves energy only when you switch arbitrage on.** This
|
||
> release ships the five provider plugins, the canonical all-in format they
|
||
> emit, the config that selects one, and — new in OPT-04 — a price-arbitrage
|
||
> strategy that trades on them. With `arbitrage_enabled: false`, which is the
|
||
> shipped default, **selecting a provider is what starts the fetch**: the feed
|
||
> is read every 30 minutes, validated, and the day recorded in
|
||
> `/data/price_history.json`, which the *Price history* status row reports and
|
||
> the add-on log names at every refresh. No charging decision changes.
|
||
>
|
||
> ⚠️ **That has been the documented behaviour since OPT-04 and was not the
|
||
> actual behaviour until TARIFF-10.** The background refresh was started only
|
||
> when `arbitrage_enabled` was on — and it ships `false` — so an install with a
|
||
> provider configured fetched nothing at all, never created the store, and read
|
||
> *Price history: empty* for as long as it ran. If you configured a provider and saw an empty
|
||
> Price history row, that is what you were looking at. Trading is unchanged and
|
||
> is still `arbitrage_enabled` alone.
|
||
>
|
||
> `static_profile` below needs no network at all, so its cheap window is
|
||
> computed on every cycle and reaches the maintenance cycle's grid top-up
|
||
> whether or not arbitrage is armed. ⚠️ **And it is still the only source of
|
||
> that window.** A provider-backed plan now exists on every configured site,
|
||
> but *Cheap window* and the maintenance cycle's grid top-up read
|
||
> `static_profile` and nothing else — a deliberate limit, recorded with what it
|
||
> would take to change in `DECISIONS/TARIFF-10-fetching-is-not-trading.md`.
|
||
|
||
| option | meaning |
|
||
|---|---|
|
||
| `price_provider` | `off` (default) or one of the three plugins below |
|
||
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
|
||
| `price_taxes_eur_kwh` | Distribution, levies and VAT, EUR/kWh. Same rule. Defaults to **0.11736** — a real Flemish figure, and **yours is probably different**: see *What `price_taxes_eur_kwh` is made of* below |
|
||
| `export_price_eur_kwh` | The **flat injection** price, EUR/kWh. May be negative — see below |
|
||
| `price_export_formula` | The **injection formula**, if your contract has one. Same shape as `price_formula`, its own coefficients, its own `vat_pct` — see below |
|
||
| `price_csv_path` | Where `manual_csv` reads its file |
|
||
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
|
||
| `price_horizon_h` | How far ahead to ask for, hours |
|
||
| `price_timeout_s` | Per-request timeout |
|
||
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
|
||
| `price_formula` | For `custom_formula` only: your own contract's two coefficients, off your own tariff card. Empty by default — see below |
|
||
|
||
**Which plugin sources what.** This is the one thing to get right, because
|
||
adding your markup to a price that already contains it makes every future
|
||
arbitrage decision wrong by that amount, in a direction nothing reports.
|
||
|
||
| plugin | product | the published figure contains | the add-on adds |
|
||
|---|---|---|---|
|
||
| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes |
|
||
| `custom_formula` | day-ahead | energy **and** your contract's markup — your card's `Tariefformule` line | taxes |
|
||
| `manual_csv` | day-ahead | everything, all-in | nothing |
|
||
|
||
⚠️ **Three plugins were withdrawn in TARIFF-07, and if your configuration names
|
||
one of them, read this.** `engie_dynamic`, `totalenergies_dynamic` and
|
||
`eneco_eddy` are gone. They did not stop working — **they never worked.** Each
|
||
pointed at an endpoint that does not exist and never did:
|
||
|
||
| id | its endpoint | what that endpoint actually answers |
|
||
|---|---|---|
|
||
| `engie_dynamic` | `https://api.engie.be/dynamic/be` | **404**, gateway error `no Route matched with those values` |
|
||
| `totalenergies_dynamic` | `https://api.totalenergies.be/prices/dynamic` | **TLS failure** — that hostname is not in the server's certificate |
|
||
| `eneco_eddy` | `https://api.eneco.be/eddy/prices` | **404**, empty body |
|
||
|
||
Every fetch any of them ever made failed, and a failed fetch falls back to the
|
||
static tariff **silently** — so a site set to one of these has been running on
|
||
`static_profile`, or on nothing at all, since the day it was configured, with
|
||
nothing on the status page saying so. The add-on now **refuses to start** on one
|
||
of these ids and tells you all of the above by name. That is deliberate: a
|
||
silent fallback is what hid this for as long as it lasted.
|
||
|
||
There is no automatic migration and there cannot be one. These were *retail*
|
||
tariffs of the form `B x spot + A` with **per-contract** coefficients that live
|
||
behind your supplier's customer login; there is no public index to read them
|
||
from. Any alias would have to invent the numbers your battery is priced against.
|
||
(`eneco_eddy` was doubly wrong: **"Eddy" is not an Eneco product at all** —
|
||
Eneco's dynamic product is *Zon & Wind Dynamisch*, and it is indexed to
|
||
Belpex/EPEX **day-ahead**, not to imbalance.)
|
||
|
||
#### So what should a Belgian dynamic-tariff customer do today?
|
||
|
||
**Honestly: `generic_dayahead` gets you close, and you should know exactly how
|
||
close.** It reads the wholesale EPEX BE day-ahead curve and adds your
|
||
`price_markup_eur_kwh` and `price_taxes_eur_kwh` as **flat** scalars. Your real
|
||
Belgian dynamic contract is a **formula**:
|
||
|
||
```
|
||
consumption: price = B x spot + A <- B and A are in YOUR contract
|
||
```
|
||
|
||
`generic_dayahead` cannot express that. Two gaps, and the second is the
|
||
harder one — **both are closed by `custom_formula`**, which is what the
|
||
rest of this section is about:
|
||
|
||
* **No multiplier.** `B` is typically slightly above 1 (it scales the wholesale
|
||
price before anything is added). A flat markup cannot scale anything, so the
|
||
*shape* of your curve is right but its *amplitude* is not — the peaks and
|
||
troughs the optimiser trades against are compressed relative to your bill.
|
||
* **No negative offset.** `A` is frequently **negative** on injection and can be
|
||
on consumption. `price_markup_eur_kwh` is `float(0,1)` in the schema — it
|
||
**cannot hold a negative number at all**, so that contract shape is currently
|
||
inexpressible rather than merely approximate.
|
||
|
||
What that means in practice: arbitrage decisions made on `generic_dayahead` are
|
||
made against a curve that is *correlated* with your bill, not equal to it. For
|
||
deciding **which hours** are cheap that is usually good enough; for deciding
|
||
**whether a cycle pays for itself** — which is what `cycle_cost_threshold_eur`
|
||
compares against — it is an approximation, and one that errs by however far `B`
|
||
is from 1.
|
||
|
||
**`custom_formula` is the answer to exactly this, and it shipped in TARIFF-15.**
|
||
You type your contract's two coefficients once, off your own tariff card, and
|
||
the add-on evaluates `B x index + A` against the same EPEX BE day-ahead curve
|
||
`generic_dayahead` reads. Both gaps above close: `B` is a real multiplier, and
|
||
`A` may be negative. It is documented in full in **[Your own tariff formula
|
||
(`custom_formula`)](#your-own-tariff-formula-custom_formula)** below.
|
||
|
||
So there are three honest options rather than two:
|
||
|
||
1. **`custom_formula`** — your contract's own arithmetic, if you can read the
|
||
two coefficients off your tariff card. This is the one to use.
|
||
2. **`generic_dayahead`** with your markup and taxes set as well as you can —
|
||
accepting the approximation above; or
|
||
3. **`manual_csv`**, where you compute the all-in prices yourself from your own
|
||
contract's formula and hand the add-on the answers. Nothing is approximated,
|
||
but nothing is automatic either.
|
||
|
||
If you are on a **fixed** or **day/night** tariff — most Belgian households —
|
||
neither of those applies: use `static_profile` and leave `price_provider: off`.
|
||
|
||
You cannot get this wrong by editing the markup: each plugin *declares* what its
|
||
feed contains and the add-on adds only the rest. Set a markup under a plugin
|
||
that already carries one and it is **ignored, with a line in the log saying so**
|
||
— never applied twice.
|
||
|
||
That holds **given a correct declaration**, and it is worth knowing where the
|
||
line is. What the add-on enforces mechanically is that a plugin cannot obtain
|
||
the markup or apply it: the numbers are not reachable from plugin code, and a
|
||
plugin whose code mentions them fails to load at all. What it cannot check is
|
||
whether a plugin described its supplier's feed truthfully — that is a claim
|
||
about somebody else's tariff, and no code can verify it. If you write a plugin,
|
||
the `sources` declaration is the part a reviewer has to read.
|
||
|
||
**Real-time / imbalance products still have a place in the machinery, but no
|
||
plugin declares one.** A plugin that declares `PriceProduct.REALTIME` is flagged
|
||
high-volatility and gets widened arbitrage thresholds; that comes from the
|
||
plugin's own declaration, never from its name. All three shipped plugins are
|
||
day-ahead, so nothing exercises that path on a real install today — it is kept
|
||
on purpose so the first genuine imbalance product does not have to rebuild it.
|
||
See `DECISIONS/TARIFF-07-withdrawing-three-plugins.md`.
|
||
|
||
**`generic_dayahead` and the shape of the public feed.** It reads Fraunhofer
|
||
ISE's Energy-Charts API (`https://api.energy-charts.info/price?bzn=BE`, no key
|
||
and no registration), which publishes a *columnar* document — parallel
|
||
`unix_seconds` and `price` arrays, in EUR/MWh, with the unit and the licence
|
||
stated in the response. Two things follow that are worth knowing before you
|
||
point `price_feed_url` somewhere else:
|
||
|
||
* The timestamps are **UTC instants**. They are rendered at the Brussels offset
|
||
that applies to each one, so the hour that happens twice every October is two
|
||
points rather than one overwritten one.
|
||
* Belgium moved to a **15-minute market time unit** on 2025-10-01, and the feed
|
||
followed. A published day is **96 points, not 24**. Nothing in the add-on
|
||
assumes either spacing, and nothing aggregates the quarters away.
|
||
* The request **names the days it wants**, derived from the horizon on the
|
||
**Brussels** calendar — `?bzn=BE&start=2026-09-09&end=2026-09-10`, two
|
||
inclusive local dates. Without that the feed answers a single day, and
|
||
everything past today would be carried forward from yesterday's profile and
|
||
flagged provisional — which §8.3 excludes from arbitrage, so tomorrow would
|
||
never be tradeable at any hour. The calendar is Brussels rather than UTC
|
||
because 22:30 UTC in summer is already tomorrow in Belgium.
|
||
|
||
**What you get before the auction publishes.** The day-ahead auction clears at
|
||
about 13:00–14:00 CET. Ask for tomorrow before that and the feed returns fewer
|
||
days than requested — that is normal, not a failure. The add-on keeps the
|
||
series of what exists and fills the remaining hours from yesterday's profile,
|
||
flagged provisional; arbitrage runs on the firm hours and ignores the flagged
|
||
ones. So a dynamic site's plan is part firm and part carried every morning and
|
||
fully firm once tomorrow is published, and neither state is an error.
|
||
|
||
⚠️ **If you set `price_feed_url`, you keep the short horizon.** The override is
|
||
sent exactly as you wrote it and the add-on adds no `start`/`end` to it, because
|
||
those are this feed's parameter names and an override may point somewhere with
|
||
different ones — rewriting a URL you typed is how a request nobody intended gets
|
||
made. The consequence is a **`WARNING` in the log on every price refresh**,
|
||
naming the range that would otherwise have been requested. If you only wanted a
|
||
different host and not a different horizon, the fix is to clear the option.
|
||
|
||
If the arrays ever come back different lengths, or the unit stops saying
|
||
EUR/MWh, the fetch is refused outright rather than trusted in part: a price
|
||
series missing its last hours is a series missing the evening peak, and it
|
||
would be traded on as though it were complete. A refused fetch is the §11.4
|
||
row — static profile, provisional plan, no arbitrage — and the reason is in the
|
||
log.
|
||
|
||
**A refused fetch does not take the plan you already have.** The last plan that
|
||
actually came from the feed keeps standing until it is 90 minutes old (three
|
||
missed refreshes), and only then does the §11.4 row above take over. The
|
||
add-on logs a `WARNING` naming the failure, the fetch it is keeping, its age
|
||
and the moment it retires; the plan's own age is **not** reset by the failed
|
||
refresh, so a kept plan still expires on schedule and is never presented as a
|
||
current one. One failed fetch is a blip, not a lost tariff.
|
||
|
||
#### Your own tariff formula (`custom_formula`)
|
||
|
||
**What it is for.** A Belgian dynamic contract prices energy as a formula —
|
||
your supplier's tariff card prints it as `Tariefformule` — and the two
|
||
coefficients in it belong to *your contract*. There is no machine-readable
|
||
source for them anywhere: CREG's consumer tariff-card pages return 404, CREG
|
||
Scan is a JavaScript wizard, the annual report lists suppliers without
|
||
per-product coefficients, and VREG was unreachable. Every coefficient found in
|
||
research came off a supplier's own PDF, one of them image-only. **You are the
|
||
only party who can see your own coefficients**, so you type them, once.
|
||
|
||
Set `price_provider: custom_formula` and fill in `price_formula` with one line
|
||
of JSON:
|
||
|
||
```json
|
||
{"index": "epex_be", "resolution": "hourly",
|
||
"factor": 0.102, "offset": 1.00,
|
||
"units": "c_eur_per_kwh", "vat_pct": 6,
|
||
"card": {"month": "2026-09", "index_eur_per_mwh": 133.9905,
|
||
"expect_incl_vat": 15.55}}
|
||
```
|
||
|
||
The contract it expresses is:
|
||
|
||
```
|
||
price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct / 100)
|
||
```
|
||
|
||
expressed in `units`, and **converted to EUR/kWh by the add-on**. You type what
|
||
your card prints, in the card's own units; every unit conversion and the VAT
|
||
multiplication are the add-on's job, because those are exactly the two steps
|
||
where a human silently loses a factor of 10, 100 or 1.06.
|
||
|
||
| field | what it is |
|
||
|---|---|
|
||
| `index` | the reference series. `epex_be` — the EPEX BE day-ahead curve in EUR/MWh, the same feed `generic_dayahead` reads |
|
||
| `resolution` | `quarter_hourly` (the 96 points the feed publishes) or `hourly` (each hour's quarters averaged into one point). Your card says which series your contract is written against |
|
||
| `factor` | `B` — the multiplier on the index. Must be a number, and positive |
|
||
| `offset` | `A` — the fixed part, in the same units. **May be negative** |
|
||
| `units` | `c_eur_per_kwh`, `eur_per_kwh` or `eur_per_mwh`. **Required, with no default** |
|
||
| `vat_pct` | the VAT percentage. `6` for Belgian residential electricity — not `0.06` and not `106`. `0` is legitimate |
|
||
| `card` | the month the card is dated, the reference index it assumed, and the price it prints. **Required** — see below |
|
||
|
||
##### A worked example, from a real card
|
||
|
||
Eneco's September 2026 card prints the formula, the reference index it assumed
|
||
*and* the resulting price. With `factor: 0.102`, `offset: 1.00`, `vat_pct: 6`
|
||
and the card's own index of `133.9905` EUR/MWh:
|
||
|
||
```
|
||
(0.102 x 133.9905 + 1.00) x 1.06 = 15.5471 c€/kWh the card prints 15,55
|
||
```
|
||
|
||
ENGIE's card, in the same shape with its own numbers:
|
||
|
||
```
|
||
(0.100 x 61.96 + 0.90) x 1.06 = 7.5218 c€/kWh the card prints 7,522
|
||
```
|
||
|
||
Both reproduce to the last digit the card prints, through the shipped code —
|
||
`test_price.py` section 15a runs exactly these two.
|
||
|
||
##### ⚠️ The `card` block is required, and it is the safety mechanism
|
||
|
||
Because your card prints the index it assumed **and** the price that comes out,
|
||
the add-on can check your coefficients against your supplier's own worked
|
||
example. At startup it recomputes that printed price from the numbers you
|
||
typed and **refuses to start** unless the two agree to within `0.01` — naming
|
||
the discrepancy, the ratio, and the likely causes.
|
||
|
||
That catches, in one check: VAT left out, VAT applied twice, a transposed
|
||
digit, and a **unit mismatch** between your coefficients and the price you
|
||
typed beside them (a factor of 10, 100 or 1000).
|
||
|
||
And it catches the expensive one. **Your card's formula line is the energy
|
||
component only.** Eneco tabulates network tariffs in one table and levies in a
|
||
second; ENGIE states that the formula excludes network, distribution,
|
||
transport, grid levies and excise. The add-on therefore declares that
|
||
`custom_formula` supplies *energy and markup*, and adds `price_taxes_eur_kwh`
|
||
on top — once. If you fold your network charges into `offset`, you are billed
|
||
for them twice, and nothing on the status page would ever show it. It cannot
|
||
happen: ~12 c€/kWh of network charge moves the computed 15.55 to about 27, and
|
||
the add-on refuses to start and says so.
|
||
|
||
⚠️ **And the limit of that check, in the same breath as the claim: it grades
|
||
your coefficients against your card, not your card against reality.** Two
|
||
numbers copied off the wrong product — or off a card your contract has since
|
||
rolled over from — agree with each other perfectly and pass. Nothing can catch
|
||
that. What is mechanised is that your coefficients and your card's printed
|
||
price *agree*; whether the card is the right card is yours to know. The add-on
|
||
warns (and does not refuse) when `card.month` is more than about six months
|
||
old, because ENGIE freezes coefficients for a contract's duration and an old
|
||
card is often exactly right.
|
||
|
||
One more check runs beside it: the card's own point must land inside the range
|
||
a Belgian retail tariff can occupy (0.02–1.00 EUR/kWh). That exists because a
|
||
unit error applied to **both** sides at once cancels out of the comparison
|
||
above and cannot cancel out of this one. It bounds the card's own point, **not
|
||
every hour** — a live hour may legitimately fall outside it, and a negative
|
||
day-ahead hour, which is the best charging opportunity of the year, is never
|
||
refused.
|
||
|
||
##### ⚠️ Fixed coefficients. No expression is evaluated, ever
|
||
|
||
`price_formula` takes numbers. There is no field that accepts an expression,
|
||
and an unrecognised field is **refused** rather than ignored, so typing
|
||
`"expr": "..."` tells you it does not exist instead of quietly pricing you on
|
||
the other fields.
|
||
|
||
Two reasons, and the second is the decisive one:
|
||
|
||
* This string is settable over HTTP — the add-on's own ingress configuration
|
||
page posts it to the Supervisor — so evaluating it would be remote code
|
||
execution inside a root container that holds a Supervisor token and drives
|
||
an inverter. (`eval` with an emptied `__builtins__` is not a sandbox.)
|
||
* **An expression could not be graded against anything.** Two coefficients can
|
||
be checked against your card's printed price; an arbitrary expression
|
||
cannot, so it would make the invisible-wrong-coefficient problem strictly
|
||
worse — which is the exact failure this provider exists to prevent.
|
||
|
||
All 24 surveyed Belgian products, both injection legs and aWATTar AT fit
|
||
`B x index + A`, so an expression would buy nothing the market needs. If a
|
||
future contract needs a different shape, it gets a **named field**
|
||
(`clamp_lo`, `factor_negative`, `offset_bands`) that can be refused on its
|
||
own. Never a grammar.
|
||
|
||
##### What you see at startup
|
||
|
||
The first time the feed answers, the add-on logs a preview of the next 24
|
||
hours as `index EUR/MWh -> all-in EUR/kWh`, followed by the minimum, median,
|
||
maximum and the day's spread. It is there so that a formula off by a factor of
|
||
1000 is visible in one line rather than in a month of bills:
|
||
|
||
```
|
||
custom_formula preview - the next 24 h of YOUR tariff, index EUR/MWh -> all-in
|
||
EUR/kWh, computed as (0.102 x index + 1) x (1 + 6/100) c_eur_per_kwh, converted
|
||
to EUR/kWh and with price_taxes_eur_kwh added by the framework:
|
||
2026-09-10T00:00:00+02:00 216.080 -> 0.30423
|
||
2026-09-10T00:15:00+02:00 216.080 -> 0.30423
|
||
... 72 further point(s) not listed; the figures below cover ALL 96
|
||
min 0.17277 / median 0.28031 / max 0.67499 EUR/kWh, spread 0.50222 EUR/kWh
|
||
over 96 point(s). ⚠️ If those are a thousand times what your bill says,
|
||
`units` is wrong; if the spread is 0, `factor` is.
|
||
```
|
||
|
||
(That transcript is the shipped code run against the committed
|
||
`scenarios/energy-charts-be-price-2026-09-10-quarterhourly.json` capture with
|
||
the default markup and taxes, not an illustration.)
|
||
|
||
##### What it does not do
|
||
|
||
The **injection** leg is a separate card, and it has its own option:
|
||
`price_export_formula` (TARIFF-16). Eneco uses `0,102` on consumption and
|
||
`0,100` on injection, and injection is VAT-exempt where consumption is at 6 %,
|
||
so it is a second per-contract formula rather than a second copy of this one —
|
||
the same parser, its own coefficients. `export_price_eur_kwh` remains the flat
|
||
injection price for a site that is paid one, and **no strategy reads either**;
|
||
see *Injection is priced separately* above.
|
||
|
||
#### `manual_csv` — the offline fallback
|
||
|
||
It needs no internet, no supplier
|
||
account and no HTTP stack at all, and it is the supported way to run this
|
||
add-on price-aware on a site with no outbound connectivity. Write a file and
|
||
point `price_csv_path` at it:
|
||
|
||
```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.** Belgian day-ahead
|
||
settles per quarter — the 15-minute MTU took effect on 2025-10-01, so
|
||
`generic_dayahead` reads 96 points a day — and the fill decides *which whole
|
||
hours need carrying*, never what a published hour may contain. Every point the provider published inside the
|
||
horizon is passed through untouched; a carried point is added only for a whole
|
||
hour the feed did not print at all.
|
||
|
||
**What you can actually see on the status page.** *Price history* (below) says
|
||
what is held. *Arbitrage* carries the strategy's own sentence, which names the
|
||
firm/provisional split whenever that split is what refused a trade. And
|
||
**Tariff** on the status page reads `provisional` through the morning, because
|
||
since TARIFF-11 that row carries the provider plan itself:
|
||
|
||
```
|
||
Tariff: no static profile (recomputed this tick); provider plan fetched 412 s
|
||
ago for a 48 h horizon: dynamic_dayahead from generic_dayahead,
|
||
60 points over 33 h, 36 firm / 24 carried, 900+3600 s spacing,
|
||
provisional
|
||
```
|
||
|
||
⚠️ **The two halves of that row are not computed at the same moment, and the
|
||
row says so.** Everything before the semicolon is the static-profile or Home
|
||
Assistant price-entity sentence, regenerated on the **30 s tick** from options
|
||
alone — no network. Everything after it is the **background refresh's**
|
||
resolution, which runs every 1800 s and is served for up to 5400 s before it is
|
||
retired; `fetched N s ago` is the age of the fetch that produced it and is the
|
||
only figure in the row that tells you whether the feed is still answering. On a
|
||
site with a provider configured and no usable plan the row says that too,
|
||
rather than falling silently back to the static half.
|
||
|
||
Read the plan half as five separate facts: which *product* the feed publishes
|
||
and which *plugin* read it, how many **points** it carries and how many **whole
|
||
hours** those points cover, how many of the points are **firm** (published for
|
||
their own interval) against **carried** (filled from yesterday under §8.3), the
|
||
**spacing** of the points in seconds — `900+3600` is the normal mixed steady
|
||
state, quarter-hourly firm points beside hourly carried ones — and `provisional`
|
||
whenever any point is carried.
|
||
|
||
⚠️ **Points and hours are two different numbers and this row used to print one
|
||
of them under the other's name.** `words` was `f"… {len(points)} h"`, so a
|
||
33-hour plan of quarter-hourly points logged `dynamic_dayahead from
|
||
generic_dayahead, 132 h`. It is the same conflation the *Price history* row
|
||
carried before FCST-04 and the same one `at()` carried before TARIFF-14, and it
|
||
is **not a constant factor**: measured over a 40-hour soak the overstatement
|
||
ranged **1.79× to 5.33×**, because the carried points are hourly while the firm
|
||
ones are quarter-hourly, so the ratio moves with the mix as the day goes on.
|
||
The same sentence is in the **add-on log** at every refresh, alongside `§8.3:
|
||
33 of 48 hours carried forward from yesterday's profile and flagged
|
||
provisional; the feed published 60 points over the other 15 hours`.
|
||
|
||
The §8.3 refusal an operator reads when a trade is declined counts in the same
|
||
two units — `§8.3: only 1 of 96 points carry a published price - 1 of 24 hours`
|
||
— where it used to say `only 1 of 96 hours` about a 24-hour day.
|
||
|
||
The plan is also now cut to the horizon you asked for, which means **hours that
|
||
have already elapsed are no longer in it**. That is a real change to arbitrage:
|
||
a cheap night that has already gone used to sit in the series as the cheapest
|
||
hour of the day, and a cycle can never buy in an hour that is past — so the
|
||
trade was quietly never taken. The spread is now taken over hours still ahead.
|
||
|
||
A new status row, **Price history**, says which days are held, how many price
|
||
points each holds and how many whole hours those cover — `2026-01-13 (96 points
|
||
over 24 h)`. ⚠️ **Those are two different numbers and the row used to print one
|
||
of them under the other's name**: it counted points and labelled them `h`, so a
|
||
quarter-hourly feed read `96 h` for a 24-hour day. The figure that says whether
|
||
tomorrow morning's fill has a whole day to carry is the **hour** one. If it
|
||
reads `empty` or `unavailable`, nothing is carried, the horizon
|
||
is as short as the feed makes it, and every other guarantee is unchanged — a
|
||
cache that cannot answer is not allowed to be a controller that cannot regulate.
|
||
The store is rebuilt by the next successful fetch; on a brand-new install the
|
||
first fill is possible the day after the first refresh.
|
||
|
||
The same applies to a file that is *partly* broken: a day record this build
|
||
cannot read is dropped, the row says how many were dropped and why, and the rest
|
||
of the file still loads. And a store that **cannot persist records nothing** —
|
||
not even in memory. That covers a missing `/data`, and it also covers a `/data`
|
||
that is there and cannot be **written**: a full volume, a read-only remount. An
|
||
in-memory yesterday would answer for one run and vanish on the next restart,
|
||
which is a cache that forgets without saying so; the row naming the reason and
|
||
*no prices carried* are the honest pair.
|
||
|
||
⚠️ **What a failed write does not do is throw away what is already in the
|
||
file.** A volume that fills at 14:00 leaves the yesterday written at 13:30 still
|
||
answering, because that record survives a restart — the store stops *adding* to
|
||
it, nothing more. And it never stops the add-on: a write that cannot complete is
|
||
logged and reported on the status row, never raised. A cache that cannot answer
|
||
is not allowed to be a controller that cannot regulate, and that applies to the
|
||
volume being full exactly as it applies to the volume being absent.
|
||
|
||
### Injection is priced separately
|
||
|
||
There are two ways to say what you are paid for an exported kWh, and which one
|
||
you want depends on your contract:
|
||
|
||
| your contract | what to set |
|
||
|---|---|
|
||
| a flat injection price | `export_price_eur_kwh` — one number, EUR/kWh. This is the default and what every existing install uses |
|
||
| a price that moves with the market | `price_export_formula` — your card's injection line as `factor × index + offset`, in exactly the shape `price_formula` uses for consumption |
|
||
|
||
Negative values are accepted on both — you can be charged to export at midday in
|
||
summer, and the add-on says so rather than flooring it.
|
||
|
||
#### ⚠️ What the add-on does *not* do with an injection price
|
||
|
||
**No strategy in this add-on reads one.** Not the scalar, not the formula.
|
||
`arbitrage.py` prices the sell side as **displaced import** — both hours it
|
||
compares are all-in *consumption* prices — and its own comment gives the reason:
|
||
§9.3's near-zero Belgian injection price is exactly why the economically sound
|
||
trade on this market is *charge cheap to displace expensive import*, not *charge
|
||
cheap to export dear*. So setting `price_export_formula` changes **no decision
|
||
the add-on takes today**. It changes what you are shown at startup, and it is
|
||
the shape any future export-aware strategy would read.
|
||
|
||
That is the argument for doing it now rather than later: OPT-06 and any export
|
||
logic would otherwise inherit a scalar that cannot represent the market, and the
|
||
6% VAT asymmetry between the two legs would be invisible in the arithmetic.
|
||
`test_price.py` 17h parses every module under `app/` and goes red the day
|
||
something calls an injection reader, so this paragraph is graded rather than
|
||
attested.
|
||
|
||
#### Why a scalar was not enough
|
||
|
||
Belgian suppliers publish the injection leg as a formula, on the same card as
|
||
the consumption one. The reason given here until TARIFF-07 — *"no supported
|
||
supplier publishes a dynamic injection tariff"* — was **false**, and what they
|
||
publish has four properties one number cannot hold:
|
||
|
||
```
|
||
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c/kWh
|
||
"Het injectietarief is dynamisch en kan per uur wijzigen"
|
||
- identical on the January and the September 2026 cards
|
||
ENGIE Dynamic (B x eSpot_15) - A
|
||
Aspiravi (non-dynamic) 0,07 x Belpex - 2
|
||
```
|
||
|
||
1. **Its own factor.** Eneco uses `0,102` on consumption and `0,100` on
|
||
injection; Aspiravi `0,07` against about `0,10` on its consumption leg. It is
|
||
**not** the consumption `B` with a sign flip.
|
||
2. **A negative offset** — `− 1,188`, `− 2`. Note that
|
||
`price_markup_eur_kwh: float(0,1)` cannot express a negative markup at all,
|
||
so the offset has no home on the consumption side either.
|
||
3. **VAT-exempt.** *"Injectietarieven zijn vrijgesteld van btw"*, while
|
||
consumption is at 6%. `vat_pct` is **per leg**, so that six-point asymmetry
|
||
is expressible; a single scalar carries no VAT field at all.
|
||
4. **It goes negative, with no floor.** Eneco's injection price is below zero
|
||
for any BELPEX-H under **11,88 EUR/MWh** — and a PV household exports
|
||
precisely in the hours most likely to be negative. **You can be charged to
|
||
export.**
|
||
|
||
#### `price_export_formula`, field by field
|
||
|
||
Same grammar as `price_formula` — literally the same parser, with its option
|
||
name as an argument, so there is no second unit table and no second card rule to
|
||
diverge from the first. Eneco's published injection line, typed out:
|
||
|
||
```json
|
||
{"index":"epex_be","resolution":"hourly","factor":0.1,"offset":-1.188,
|
||
"units":"c_eur_per_kwh","vat_pct":0}
|
||
```
|
||
|
||
⚠️ **`vat_pct` is 0 here and 6 on the consumption leg.** That is the one field
|
||
people copy across from the other formula and get wrong.
|
||
|
||
⚠️ **The `card` block is optional on this leg and required on the other**, and
|
||
that is a statement about the cards rather than a relaxed rule. `price_formula`
|
||
can demand one because a Belgian card prints the consumption formula, the
|
||
reference index it assumed *and* the resulting price — so the add-on can
|
||
recompute that price and refuse to start unless your numbers reproduce it.
|
||
**Eneco's injection formula is printed with no price beside it.** There is
|
||
nothing to grade against on that card. Requiring a block would make the market's
|
||
own published line inexpressible; inventing the estimate would grade your
|
||
coefficients against arithmetic done with your coefficients, which is not a
|
||
check. So:
|
||
|
||
* if **your** card prints an injection estimate, fill the block in and it is
|
||
graded at the same tolerance as the consumption leg — the add-on refuses to
|
||
start if your numbers do not reproduce it;
|
||
* if it does not, the add-on **says so at startup** — *"its coefficients are
|
||
UNGRADED"* — and names what it checked instead.
|
||
|
||
What it checks instead, always, on both paths:
|
||
|
||
* **pass-through**: `factor`, converted to a share of the wholesale price, must
|
||
be between 0.10× and 10×. Eneco's injection leg is `1.00×` and Aspiravi's
|
||
`0.70×`; a coefficient typed a hundred times out reads `0.01×` or `100×`.
|
||
* **offset magnitude**: `|offset|` must be at most 0.20 EUR/kWh. The published
|
||
ones are `−0.01188` and `−0.02`.
|
||
|
||
Both bounds are on the **coefficients**, not on the price, which is why they can
|
||
bound a leg whose price legitimately goes below zero. `price_formula`'s
|
||
plausibility band is deliberately **not** reused here: its floor is `+0.02
|
||
EUR/kWh` of energy, and applying that to injection would refuse the real market
|
||
in exactly the hours where knowing the number matters most.
|
||
|
||
⚠️ **And the gap those bounds leave, with its size.** They bound the *shape* of
|
||
the coefficients, never their truth. With no printed estimate, a `factor` of
|
||
`0.070` where your contract says `0.100` — 30% of every export payment — passes
|
||
both, because `0.70×` is a pass-through somebody really publishes. Only a
|
||
printed estimate can catch a wrong-contract pair, and on Eneco's card there is
|
||
not one. That is why the startup line says the leg is ungraded instead of
|
||
implying it was checked.
|
||
|
||
### The old Home Assistant price sensors
|
||
|
||
`price_now_entity` and
|
||
`price_avg_entity` still work and are unchanged: the add-on compares them and
|
||
uses the result as a cheap-window hint for the maintenance charge. They are
|
||
**not compatible with `price_provider`, and not with `static_profile` either**
|
||
— configure two of them and the add-on refuses to start, naming the conflict,
|
||
because the add-on reads exactly one and the loser is silent. Use one.
|
||
|
||
### The three buckets — which price components can move a decision (TARIFF-17)
|
||
|
||
**Read this before adding a price component of any kind.** The optimiser reads
|
||
exactly two quantities off a price series, and nothing else:
|
||
|
||
- the **spread**, `max − min` over the *firm* points, weighed against
|
||
`cycle_cost_threshold_eur`; and
|
||
- **which hour is cheapest**, `min(firm, key=…)` — the hour a battery would
|
||
buy in.
|
||
|
||
Every component of a Belgian bill reaches those two through one of three
|
||
buckets, and which bucket it is in is a property of its *shape*, not of its
|
||
size:
|
||
|
||
| bucket | what it looks like | reaches the decision? |
|
||
|---|---|---|
|
||
| **LEVEL** | additive, **identical every hour** | **No — provably.** An additive constant cancels exactly in `max − min`, and adding it to every point leaves the argmin where it was |
|
||
| **SPREAD** | **multiplicative** on the spot index | **Yes.** It scales the spread against a fixed `cycle_cost_threshold_eur` |
|
||
| **ORDER** | **varies** by hour, block or day-type | **Yes, and worst.** It changes *which hour is cheapest* |
|
||
|
||
Measured on the real arithmetic (`test_price.py` section 16b, printed on every
|
||
run): with a flat taxes figure of 0.00, 0.0739, 0.11736 (the shipped default),
|
||
0.1198, 0.1327 or 1.00 EUR/kWh, the same 24-hour feed is cheapest at **13:00 in
|
||
all six cases** and its spread is **0.1500 EUR/kWh in all six cases**. That is
|
||
the LEVEL bucket doing nothing, and it is not a rounding artefact — it is
|
||
arithmetic.
|
||
|
||
Three consequences worth having in mind:
|
||
|
||
1. **`price_taxes_eur_kwh` is not a wrong number and never was.** It sits in
|
||
the LEVEL bucket and *cannot* distort either quantity. What it also cannot
|
||
do is express a component that varies by hour — which is why
|
||
`price_taxes_profile` exists. The defect TARIFF-17 closed was a **missing
|
||
capability**, not a bad constant.
|
||
2. **`custom_formula`'s `factor` is a SPREAD component, and its `offset` is
|
||
LEVEL.** The contract price is `(factor × index + offset) × (1 + VAT)`, so
|
||
the spread over a feed is `factor × (1 + VAT) × (max index − min index)` —
|
||
the offset drops out entirely, and the spread is *proportional* to `factor`.
|
||
Moving `factor` from 0.100 to 0.10812 therefore moves the spread by exactly
|
||
**+8.1 %** on any feed whatever. A coefficient typed one digit wrong is not
|
||
a small pricing error; it is a different trading threshold. The VAT rate is
|
||
multiplicative on the whole expression, so it is SPREAD too.
|
||
3. **A check that grades the spread is grading the wrong bucket.** An
|
||
implementation that reads an hour-varying table *once* and adds the same
|
||
number to every point produces a series of the right magnitude and the wrong
|
||
shape — the LEVEL bucket wearing a period table. The spread would look
|
||
plausible; the buy hour would be wrong. So the regression check for an
|
||
ORDER-bucket component asserts **which hour is cheapest**, not how big the
|
||
spread is (`test_price.py` 16c and 16d).
|
||
|
||
⚠️ **Where the buckets are today.** `price_markup_eur_kwh`,
|
||
`price_taxes_eur_kwh` and `custom_formula`'s `offset` are LEVEL.
|
||
`custom_formula`'s `factor` and its VAT rate are SPREAD. `price_taxes_profile`
|
||
is the only ORDER component the add-on has. Anything added next belongs in one
|
||
of the three, and saying which is part of adding it.
|
||
|
||
### What `price_taxes_eur_kwh` is made of, and why yours is different (TARIFF-18)
|
||
|
||
**It ships at `0.11736` EUR/kWh.** That is the all-in non-energy part of a
|
||
Flemish residential bill — everything on your invoice that is *not* the energy
|
||
itself — read off the Eneco residential card for Flanders, **September 2026**,
|
||
**including 6 % VAT**:
|
||
|
||
| component | c€/kWh |
|
||
|---|---|
|
||
| Fluvius distribution (**Antwerpen**) | 5,35 |
|
||
| Bijzondere accijns, *"Alle verbruik"* — no volume bands | 4,876 |
|
||
| Bijdrage groene stroom en WKK Vlaanderen | 1,51 |
|
||
| Energiebijdrage | **0** |
|
||
| **total** | **11,736 c€/kWh = 0.11736 €/kWh** |
|
||
|
||
The rows are in the code, in `app/price.py`'s
|
||
`FLEMISH_TAXES_COMPONENTS_EUR_KWH`, and `test_price.py` section 1b re-adds them
|
||
and refuses a default that is not their sum. It replaces a shipped `0.06`,
|
||
which was roughly **half** the true figure and had no recorded provenance at
|
||
all.
|
||
|
||
⚠️ **`energiebijdrage` is zero and that is not a mistake.** *"Vanaf 1 augustus
|
||
2026 bedraagt de energiebijdrage 0 EUR/MWh"* — it was merged into the
|
||
bijzondere accijns. If you are re-deriving this total from a guide written
|
||
before that date you will find a separate energiebijdrage line; adding it would
|
||
count it twice. It is carried as a **zero row** rather than left out for
|
||
exactly that reason.
|
||
|
||
⚠️ **THIS IS A PER-DSO FIGURE AND YOURS IS PROBABLY NOT 0.11736.** Only the
|
||
distribution row moves. Across the eight Fluvius DSOs it runs **5,28 c€/kWh**
|
||
(Midden-Vlaanderen) to **6,70** (West), so the Flemish all-in total runs about
|
||
**0.117 to 0.131 €/kWh** — a 1,42 c€/kWh spread, which on 4 000 kWh a year is
|
||
about €57. The other two rows are Flanders-wide and do not move. **Look up your
|
||
own distribution row, add the same accijns and groenestroombijdrage, and type
|
||
the total.**
|
||
|
||
⚠️ **A WALLOON OR BRUSSELS SITE SHOULD NOT USE THIS SCALAR AT ALL.** Their
|
||
distribution tariff has a **day and a night band** — `Enkelvoudig | Dag |
|
||
Nacht | Uitsl. nacht` on the same card, where Fluvius prints a single rate —
|
||
and a flat figure flattens exactly the component that is anti-correlated with
|
||
what the battery wants to do. Use **`price_taxes_profile`** (the next section);
|
||
it is the only ORDER-bucket component the add-on has.
|
||
|
||
⚠️ **THIS IS A DISPLAY AND GUARD FIX. IT DOES NOT IMPROVE ARBITRAGE, AND IT
|
||
CANNOT.** `price_taxes_eur_kwh` is a **LEVEL** component — added identically to
|
||
every hour — so it **cancels exactly** in `max − min` and leaves the argmin
|
||
where it was. It does not change which hour is cheapest, it does not change the
|
||
spread, and it does not make any trade happen that did not happen before. See
|
||
*The three buckets* above, where six flat scalars spanning and exceeding the
|
||
whole per-DSO range give the identical cheapest hour and the identical spread
|
||
to the last digit. What raising it **does** change is exactly two things:
|
||
|
||
* **every figure an operator reads** — the all-in prices on the status page,
|
||
`custom_formula`'s startup preview and the recorded price history. At half
|
||
the true non-energy component they made the add-on look considerably better
|
||
than it is.
|
||
* **§17's negative-price guard** (`arbitrage.py`), which is the one rung that
|
||
tests an **absolute** all-in price rather than a difference. The guard fires
|
||
when `spot < −(markup + taxes)`, so its boundary moved from **−0.09000** to
|
||
**−0.14736 €/kWh** of spot. A spot hour at −0.11868 €/kWh used to read
|
||
**−0.02868 €/kWh** all-in and the discharge was refused as paid-for disposal;
|
||
at the real tax level it reads **+0.02868** and the discharge is allowed,
|
||
because the operator really does pay those levies and that hour really was
|
||
profitable (`test_arbitrage.py` section 2c drives both, and pins the boundary
|
||
from below as well).
|
||
|
||
⚠️ **Which is why the default sits near the LOW end of the per-DSO band rather
|
||
than in the middle of it.** The two error directions are not symmetric. Set too
|
||
**high**, the figure carries a genuinely negative hour up above zero and the
|
||
add-on exports into paid-for disposal — §17's own regression case. Set too
|
||
**low**, it only declines a discharge that would have paid. Where one default
|
||
has to stand in for eight DSOs, it belongs at the bottom.
|
||
|
||
**If you set this yourself, nothing above affects you.** A site with its own
|
||
`price_taxes_eur_kwh` keeps it; the default only answers where the option is
|
||
absent from the stored options blob.
|
||
|
||
### Levies and VAT by time of day (`price_taxes_profile`)
|
||
|
||
**Leave this empty unless your network tariff really does have a day and a
|
||
night rate**, which in Flanders it does not. Fluvius distribution is a single
|
||
rate: its row on the tariff card reads `Normaal | Uitsl. nacht`, and
|
||
*exclusive night* is a **separate physical circuit** (a night-storage heater on
|
||
its own meter), not the night register of a dual-rate meter. For a Flemish site
|
||
the flat `price_taxes_eur_kwh` is correct, and this option changes nothing.
|
||
|
||
**Wallonia and Brussels are different, and the same card proves it.** The
|
||
Walloon DSOs and Sibelga get four columns — `Enkelvoudig | Dag | Nacht |
|
||
Uitsl. nacht` — and the day/night gap is real money (c€/kWh):
|
||
|
||
| DSO | enkelvoudig | dag | nacht | gap |
|
||
|---|---|---|---|---|
|
||
| ORES | 11,98 | 13,27 | 7,39 | **5,88** |
|
||
| AIESH | 13,64 | 15,17 | 8,21 | **6,96** |
|
||
| Tecteo RESA | 11,06 | 12,20 | 7,01 | 5,19 |
|
||
| Sibelga (Brussels) | 9,39 | 9,39 | 7,10 | 2,29 |
|
||
| ORES IMPACT (2026) | — | 16,578 | 5,090 | **11,49** |
|
||
|
||
⚠️ **The bands are anti-correlated with what the battery wants to do.** The
|
||
cheap network hours are the cheap spot hours. So flattening them does not
|
||
merely shift the curve — it **systematically understates the true spread**, and
|
||
the add-on declines cycles that are genuinely profitable. On ORES's bands over
|
||
one ordinary winter weekday the measured effect is: cheapest hour **13:00 →
|
||
03:00**, spread **0.1500 → 0.1788 EUR/kWh**.
|
||
|
||
It is written in **exactly the same grammar as `static_profile`** — the same
|
||
periods, the same day selectors, the same rule that every day type is covered
|
||
once with no gap and no overlap, the same DST handling. It is literally the
|
||
same parser; there is no second tiling rule, because a second one would be a
|
||
second set of 23- and 25-hour-day bugs.
|
||
|
||
```json
|
||
{"periods": [
|
||
{"name":"dag", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.1327},
|
||
{"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.0739},
|
||
{"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.0739}]}
|
||
```
|
||
|
||
- ⚠️ **`eur_per_kwh` here is the LEVIES ALONE, not an all-in price.** It is the
|
||
non-energy part of the bill for that band, and the add-on adds it to whatever
|
||
your price source publishes — exactly as it adds the flat figure. This is the
|
||
one place the two options differ in meaning while sharing a grammar, and the
|
||
refusal you get for leaving the number out says which one is wanted.
|
||
- ⚠️ **The card prints cents. This option takes EUR.** ORES prints `13,27`;
|
||
type `0.1327`. A whole card typed in cents is refused at startup, naming both
|
||
bands and the factor — including the cards (Sibelga's 9,39 / 7,10) whose
|
||
figures are small enough to slip past the ±10 EUR/kWh bound the parser holds
|
||
every price to.
|
||
- ⚠️ **Set this *or* `price_taxes_eur_kwh`, not both.** The table wins when it
|
||
is set, so a scalar left at a non-default value beside it would be a number
|
||
you typed that decides nothing. That is refused at startup rather than
|
||
silently resolved; leave `price_taxes_eur_kwh` at its shipped `0.11736` when
|
||
you fill this in. ⚠️ **A scalar still sitting on the superseded `0.06` is
|
||
also accepted**, and deliberately so — TARIFF-18 moved that default, and your
|
||
stored options blob carries the old number whether you typed it or not, so
|
||
refusing it would have stopped working Walloon and Brussels sites on an
|
||
upgrade over a figure the table overrides anyway.
|
||
- A **malformed table is refused at startup**, not at the next fetch. It is
|
||
read on the ~30 s planning path, and a table that first fails at 03:00 is a
|
||
site that stops pricing at 03:00 — which §11.4 turns into a quiet fallback
|
||
that looks exactly like a healthy fixed-tariff day.
|
||
- It applies to the components the framework **adds**: `generic_dayahead`
|
||
(which declares `{ENERGY}`) and `custom_formula` (which declares
|
||
`{ENERGY, MARKUP}`) both get it. `manual_csv` publishes an all-in figure and
|
||
is untouched — the table is routed through the same `capabilities().adds`
|
||
rule as the scalar, not bolted on.
|
||
- It does **not** apply to `static_profile`, whose prices are already all-in,
|
||
nor to `export_price_eur_kwh` or `price_export_formula`. Those are the
|
||
injection leg, and nothing is added to what you are *paid* for export:
|
||
`price_taxes_eur_kwh` is a consumption component.
|
||
|
||
### Static day/night and fully-fixed tariffs (`static_profile`)
|
||
|
||
Most Belgian households are **not** on a dynamic contract, and an EMS that only
|
||
works under hourly pricing is an EMS for a minority. `static_profile` is how a
|
||
two-period day/night tariff or a single fixed price becomes a first-class
|
||
configuration rather than "turn the price feature off and hope".
|
||
|
||
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.
|
||
|
||
⚠️ **That refresh is not gated on this switch** (TARIFF-10). It runs when a
|
||
`price_provider` is configured *or* when arbitrage is armed, so turning
|
||
arbitrage off does not turn the price feed off — and a site with no provider
|
||
that arms arbitrage on a `static_profile` keeps the plan it has always had. The
|
||
only thing that decides whether a request leaves the machine is
|
||
`price_provider`: with it `off`, the refresh resolves the static profile in
|
||
memory and opens no connection.
|
||
|
||
**The *Arbitrage* status row says which of three things is true** — trading is
|
||
off, trading is on and this cycle reached no decision, or a decision was
|
||
reached — and it stamps every decision with its age. Until TARIFF-11 it said
|
||
one of two, because the first two rendered identically. They have three
|
||
different remedies:
|
||
|
||
| the row opens with | what is true | what to do |
|
||
|---|---|---|
|
||
| **off** — *"arbitrage_enabled is false, so nothing trades"* | Trading is not armed. Nothing will happen, and nothing is wrong | Arm it, if you meant to |
|
||
| **ON (arbitrage_enabled), but NO DECISION has been reached yet** | Trading **is** armed, and no cycle has run as far as the arbitrage step. The row then names the control loop's own reason, usually *inputs missing* | Fix the input the loop names |
|
||
| **decided N s ago:** … | The strategy answered this cycle, and `N` is how old that answer is | Read the sentence that follows — it is the strategy's own |
|
||
| **no decision this cycle (reason); the last one was taken N s ago:** … | Trading is armed, a decision stands from an earlier cycle, and **this** cycle stopped before reaching the step | As row 2 — the standing decision is history, not a verdict on now |
|
||
|
||
A fifth rendering — *"…no decision this cycle …, and none stands from an
|
||
earlier one"* — covers the case where trading was armed after a cycle had
|
||
already run without it.
|
||
|
||
⚠️ **The second row used to render exactly like the first one**, naming
|
||
`arbitrage_enabled` on a site where `arbitrage_enabled` is `true`. A cycle with
|
||
a missing input returns after roughly `stale_input_s` (15 s by default) without
|
||
reaching the arbitrage step at all, so on an armed site the row reported *off*
|
||
at startup and, once one decision had been taken, went on showing that decision
|
||
for up to 15 s after the site stopped deciding anything. The age on the third
|
||
row is printed **always**, including when it is zero: a row that reports
|
||
staleness only once it is stale cannot be told from one that never reports
|
||
it.
|
||
|
||
### Site
|
||
|
||
| option | meaning |
|
||
|---|---|
|
||
| `estop_fitted` | Whether the RS485 e-stop is installed. Drives the warning banner |
|
||
| `log_level` | `trace`/`debug`/`info`/`warning`/`error` |
|
||
|
||
## Notifications
|
||
|
||
The add-on tells you about a problem with a Home Assistant notification. Each
|
||
one has a short title, one to three sentences, and a **Details** link to this
|
||
page. The link opens the add-on's Documentation tab. Look for the section with
|
||
the same name as the notification title below. A repeat of the same problem
|
||
replaces the notification rather than adding a new one.
|
||
|
||
In **shadow mode** the add-on makes every decision but sends nothing: every
|
||
inverter stays at 0 W. Notifications then say what the add-on *would* have
|
||
sent.
|
||
|
||
### Battery is not keeping up
|
||
|
||
The battery gives less power than the add-on asks for. This is normal for a
|
||
short time, for example just after a large load switches on or the sun comes
|
||
out. It is not a fault, and control keeps running. If it keeps coming back,
|
||
check that the battery is switched on, and that it is not empty (when it should
|
||
give power) or full (when it should charge).
|
||
|
||
### Power request too high
|
||
|
||
The add-on worked out a power request above what is allowed, and cut it back
|
||
before sending it. The notification shows what was asked and what was sent,
|
||
and which limit cut it: the “Inverter rating” setting, or the inverter's own
|
||
limit. In shadow mode it shows what *would* have been sent. Control keeps
|
||
running. This should never happen, so it is a software fault: please send the
|
||
notification and the add-on log to your installer.
|
||
|
||
### Load too high for battery
|
||
|
||
Your home uses more power than the battery can supply, so this 15-minute
|
||
period will go over your peak limit and raise this month's peak. The battery
|
||
is already giving all it can. This is not a fault. Switch off large loads,
|
||
such as an oven, a car charger or a heat pump, for the next few minutes. In
|
||
shadow mode the battery gives nothing, so this can happen with a much smaller
|
||
load.
|
||
|
||
### Battery stopped limiting the peak
|
||
|
||
The add-on stopped using the battery to keep your peak down, so this
|
||
15-minute period will go over your peak limit and raise this month's peak.
|
||
Switch off large loads for the next few minutes. The notification says why,
|
||
and what to do:
|
||
|
||
- **The battery is at its peak reserve.** The “Peak reserve” setting keeps
|
||
that much charge back from peak limiting. Lower it if you accept a lower
|
||
battery level.
|
||
- **The battery level cannot be read.** Check the “Battery level entity”
|
||
setting and that sensor in Home Assistant.
|
||
- **A battery cut-out level is not known.** Fill in “Battery cut-out level”
|
||
for each inverter in the settings.
|
||
|
||
The peak reserve only applies to keeping the peak down. Normal home use can
|
||
still draw the battery below it, down to the battery cut-out level, where the
|
||
battery switches itself off.
|
||
|
||
### Inverters working against each other
|
||
|
||
With several inverters, one was charging its battery from another. Your meter
|
||
cannot see this, because the two flows cancel out, but it wastes energy. The
|
||
add-on has stopped control and set every inverter to 0 W. If the grid power
|
||
does not change after that, switch the inverters off at the isolator. A
|
||
restart does not clear this. Find out why, then start control on the add-on
|
||
page.
|
||
|
||
### An inverter is not delivering
|
||
|
||
With several inverters, one gives much less power than it is asked for, while
|
||
the others do. Nothing is changed: control keeps running, and the other
|
||
inverters carry on. Check, in this order:
|
||
|
||
1. Is the inverter on and connected?
|
||
2. Is its battery empty or cold? If it is emptier than its “Battery cut-out
|
||
level” says, that setting does not match this battery.
|
||
3. Is anything else controlling it?
|
||
|
||
A restart does not clear this. Once you know why, stop and then start control
|
||
on the add-on page.
|
||
|
||
### Meter reading may be stuck
|
||
|
||
The meter keeps answering, but its value has not changed for a long time while
|
||
the add-on asks the battery for full power. That usually means the meter
|
||
reading is stuck. Control keeps running and keeps following the reading, so a
|
||
stuck reading can raise this month's peak. Check the meter. If it is stuck,
|
||
stop control on the add-on page.
|
||
|
||
### For installers: notifications
|
||
|
||
This part is for installers. The texts live in `app/messages.py` and follow
|
||
`STYLE.md`. Every notification keeps a fixed `notification_id`, so a repeat
|
||
replaces the card. The full figures and reason codes are on the ERROR or
|
||
WARNING line of the add-on log.
|
||
|
||
| Title | Severity | id | Card |
|
||
|---|---|---|---|
|
||
| Battery is not keeping up | warning | `goodwe_integrator_bound` | SAFETY-40 |
|
||
| Power request too high | alarm | `goodwe_output_clamp` | SAFETY-03 |
|
||
| Load too high for battery | alarm | `goodwe_peak_saturated` | SAFETY-07 |
|
||
| Battery stopped limiting the peak | alarm | `goodwe_peak_soc_floor` | SAFETY-24/25/32 |
|
||
| Inverters working against each other | alarm | `goodwe_circulating_power` | SAFETY-31 |
|
||
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` | SAFETY-42 |
|
||
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
|
||
|
||
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
|
||
`/config/app/<slug>/documentation`; 2026.5 removed the old
|
||
`/hassio/addon/<slug>/documentation`, which 2026.1 and older still use. At
|
||
start the add-on reads its slug from `GET /addons/self/info` and the Home
|
||
Assistant version from `GET /info`, and builds the link for that version. If
|
||
the version cannot be read it uses the new path. If the slug cannot be read it
|
||
falls back to the container hostname, and outside an add-on the line names the
|
||
section without a link. The documentation tab adds no heading anchors, so the
|
||
link cannot jump to the section.
|
||
|
||
**Battery is not keeping up.** The grid-following loop's integrator reached its
|
||
anti-windup bound (`integrator_max_w`, SAFETY-40). No command went over the
|
||
rating. The log line is a WARNING with reason code `OUTPUT_CLAMP`. See *The
|
||
winding-loop alarm*.
|
||
|
||
**Power request too high.** A computed command above a limit was cut before it
|
||
was written (SAFETY-03). The limits are `max_w` (label “Inverter rating”), the
|
||
setpoint device's own range, or the rating on the loop side. Asked always comes
|
||
first. The old wording was "BUG SIGNAL". It is not the inverter failing to
|
||
follow a legal command, which is the saturation freeze and does not alarm. In
|
||
shadow mode the clamp still grades the decision (SAFETY-10), so the alarm can
|
||
fire while 0 W is on the wire, and the text says "would have sent". ERROR line,
|
||
reason code `OUTPUT_CLAMP`.
|
||
|
||
**Load too high for battery.** The capacity-peak guard (SAFETY-07) asks for
|
||
everything the inverter can discharge and the running 15-minute average is
|
||
still projected over the peak limit, so the month's billed peak
|
||
(capaciteitstarief) goes up. In shadow mode the guard's projection is made on a
|
||
plant that did not respond (SAFETY-47), and the text says so. See *The
|
||
capacity-peak guard*.
|
||
|
||
**Battery stopped limiting the peak.** The peak guard may not discharge below
|
||
`peak_soc_floor` (label “Peak reserve”, SAFETY-24). It also stops when the
|
||
state of charge is unread, or when an inverter's `bms_soc_floor` (label
|
||
“Battery cut-out level”) is missing, because the reserve is graded against it.
|
||
Charging from the grid stays blocked meanwhile. With several inverters the
|
||
notification names the battery at its limit (SAFETY-32), each graded against
|
||
its own cut-out level. ⚠️ **The reserve binds the peak guard only
|
||
(SAFETY-25).** Self-consumption has no such floor. See *What `peak_soc_floor`
|
||
does not protect*.
|
||
|
||
**Inverters working against each other.** SAFETY-31. The flows cancel at the
|
||
P1 meter, so every other guard, which reads that net figure, stays quiet. This
|
||
has happened on this hardware once for 6 h 25 min. The alarm is latched,
|
||
survives restarts, and is raised again every 5 min until control is started.
|
||
The watts and the time window are in the log; the *Circulating power check*
|
||
row shows the live watts. See *The circulating-power alarm*.
|
||
|
||
**An inverter is not delivering.** SAFETY-42. The unit stood well short of its
|
||
share of the site target for longer than `delivery_hold_s`. A unit at 0 W is
|
||
absent rather than pushing the wrong way, so the per-unit sign check and the
|
||
circulation check stay quiet. The guard only reports: commanding an inverter
|
||
that has stopped listening achieves nothing. Check first whether it answers on
|
||
RS485. The alarm is latched and survives restarts; stop then start clears it.
|
||
See *A unit that stops DELIVERING is a third fault again*.
|
||
|
||
**Meter reading may be stuck.** SAFETY-18. The value has not changed for
|
||
longer than `meter_freeze_s` while the command stands at the power limit, and
|
||
the age is still fresh, so no other guard stops it. Only `meter_source:
|
||
homewizard_local` can tell a silent meter from an unchanged value. The limit
|
||
and command figures are in the log. See *The frozen-meter alarm*.
|
||
|
||
## The Web UI
|
||
|
||
The 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 page
|
||
|
||
The add-on page shows a banner at the top and a **Commissioning** list below
|
||
it. The banner says in one line what matters most now. Each row of the list is
|
||
one check: ✓ green is fine, ! amber needs a look, ✗ red needs action. Rows name
|
||
settings by their label, never by a Home Assistant entity id.
|
||
|
||
In **shadow mode** the banner says so first, whatever else is going on: the
|
||
add-on makes every decision but the battery is not controlled, and every
|
||
inverter stays at 0 W. If the banner is red and says a safety stop stays on,
|
||
one of the red rows below is a stop that will still be there when shadow mode
|
||
ends. That row says how to clear it.
|
||
|
||
| Row starts with | What it tells you |
|
||
|---|---|
|
||
| Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. |
|
||
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. |
|
||
| Stuck-meter check | Whether the add-on can warn you about a stuck meter. |
|
||
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
|
||
| *month*: peak … | This month's peak, and how close it is to your peak limit. |
|
||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. |
|
||
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
|
||
| Inverter control | Whether the inverter can be controlled, and the power range it accepts. |
|
||
| *name*: … cannot be controlled, commands refused, “Setpoint entity” is not set | Whether each inverter still takes commands. |
|
||
| Power request too high, Battery is not keeping up | See those titles under Notifications. Each shows how often it happened since the add-on started, and *fine now* once it has cleared. In shadow mode the row says what *would* have been sent. |
|
||
| Direction check, Site direction check | Whether each inverter's power goes the way it is told. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
|
||
| Circulating power check, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
|
||
| Delivery check, *name* is not delivering | Only with several inverters. See *An inverter is not delivering*. |
|
||
| Solar production | Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”. |
|
||
|
||
### For installers: status page
|
||
|
||
The texts are in `app/messages.py`. The full figures of any alarm are on its
|
||
ERROR or WARNING line in the add-on log.
|
||
|
||
- **Stuck-meter check: off** needs `meter_source: homewizard_local` (label
|
||
“Where the meter reading comes from”), a `max_w` (“Inverter rating”) and a
|
||
power range the inverter can reach. *The inverter's own limit* means the
|
||
setpoint device's range sets the threshold, below `max_w`.
|
||
- **Peak guard: off** means `meter_source` is `off`, so there is no 15-minute
|
||
average to guard.
|
||
- **Battery level / Battery power / Grid power** are `soc_entity`,
|
||
`batt_entity` and `meter_entity`. *No usable reading* is SAFETY-56: missing
|
||
**or** held back as stale, and a separate stale-reading row then says which.
|
||
- **Inverter control** is the `setpoint_entity` and the range it accepts.
|
||
- ***name* cannot be controlled** (red): every write was rejected for
|
||
`LINK_STALE_S`, so the unit is dropped and the others carry its share
|
||
(DEV-02). ***name*: commands refused** (amber) is the same before that limit.
|
||
***name*: “Setpoint entity” is not set** (amber): a roster entry nobody can
|
||
write, so the site refuses to run at part strength and holds 0 W.
|
||
- **Direction check, Site direction check** are the sign checks (SAFETY-02/30).
|
||
**Circulating power check** is SAFETY-31, **Delivery check** SAFETY-42.
|
||
- **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The
|
||
*Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14).
|
||
- **Shadow mode** (SAFETY-10): the banner turns red when a latch is up
|
||
(circulating power, or a site or per-inverter sign trip), and that latch keeps
|
||
its own red row.
|
||
|
||
## Status entities
|
||
|
||
If an MQTT broker is available the add-on publishes setpoint, grid power,
|
||
battery power, circulating power, state of charge, maintenance phase and
|
||
controller status by MQTT discovery. This is observability only — the controller works fine without a
|
||
broker, and MQTT problems can never affect control.
|
||
|
||
## Before you commission inverter B: `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 · `3` clean but
|
||
degraded (`--no-live-entities`). ⚠️ **`2` and `3` are not a pass.**
|
||
|
||
### The owner steps, because this session could not take them
|
||
|
||
⚠️ **The rehearsal is only as good as the state it is pointed at, and nothing in
|
||
this repository can fetch that.** SSH to the live box is blocked by policy and
|
||
that is correct — `/data/state.json` is the file the running add-on is writing.
|
||
So three things have to be exported by hand, onto the machine that runs the
|
||
check, **before** the roster is declared:
|
||
|
||
1. **The state file.** From the Supervisor host:
|
||
|
||
ha addons info local_goodwe_controller # confirm the slug first
|
||
docker cp addon_local_goodwe_controller:/data/state.json ./state-copy.json
|
||
|
||
or take it out of a full add-on backup, which is the same bytes. ⚠️ **Copy
|
||
it, never move it, and never write to the original.** The tool opens the path
|
||
it is given exactly once and does all of its writing on a scratch copy — but
|
||
the safe habit is to hand it a file the add-on has never heard of.
|
||
|
||
2. **The options blob**, as `--before`. It is not reachable through the Home
|
||
Assistant REST API and no entity carries it; export it from the Supervisor,
|
||
the same way `tools/predeploy_check.py` needs it: the bare options object,
|
||
not the `ha apps info` wrapper, which is refused. Write the roster you intend
|
||
to deploy into a second file and pass it as `--after`.
|
||
|
||
3. **The entity list**, as `--live-entities` — one entity id per line:
|
||
|
||
Developer tools -> Template:
|
||
{{ states | map(attribute='entity_id') | select('search','goodwe') | join('\n') }}
|
||
|
||
⚠️ **Omitting it is a REFUSAL (exit 2), not a quieter run.** The orphan check
|
||
is graded against the ids the install actually has, and without that file it
|
||
is not graded against reality at all — so the tool will not run rather than
|
||
hand back a clean bill of health for a half of the transition it did not
|
||
check. If you genuinely have to run without it, pass `--no-live-entities`:
|
||
the run then carries a standing banner in the report header **and** on the
|
||
exit line saying the orphan check was not graded, and a clean result exits
|
||
3, not 0. The asymmetry used to be
|
||
the other way round — an unreadable file refused, a missing flag passed with
|
||
exit 0 and no disclosure at all — and the owner runs this once, at
|
||
commissioning, under time pressure.
|
||
|
||
The install carries roughly 49 `goodwe*` entities and this add-on announces
|
||
eight of them. The tool separates the ids it owns from the ids it does not,
|
||
rather than claiming orphans it cannot cause.
|
||
|
||
### The reference, which is loaded on every run
|
||
|
||
`--reference` defaults to `tools/rehearsal/announced-reference.txt` and is read
|
||
**whether or not** you pass an entity list. It records what the build is
|
||
supposed to announce, three columns per row:
|
||
|
||
<entity_id> <unique_id> <device identifier>
|
||
|
||
⚠️ **All three, and the second and third are the ones an entity_id-only list
|
||
cannot see.** The tool's own before/after diff is composed twice by the same
|
||
build, so a change to the composition moves both sides equally and the diff
|
||
reports nothing. Worse, two changes orphan an install *without moving a single
|
||
composed entity_id*: renaming `mqtt.BASE` (every `unique_id` changes) and
|
||
changing `DEVICE["identifiers"]` (HA sees a new device). Home Assistant keys its
|
||
entity registry on `unique_id` and its device registry on the identifier, so
|
||
either one creates a **new** entity beside the old — and because discovery is
|
||
published `retain=True` with no removal payload, the old config stays on the
|
||
broker and the old entity stays in the registry, still named by every automation
|
||
that referenced it, never updating again. Both were measured coming back
|
||
**exit 0** before the reference carried three columns.
|
||
|
||
`object_id` is deliberately **not** a column: `app/mqtt.py` records that HA
|
||
2026.8 does not honour it in a discovery payload, so renaming one is a true
|
||
no-op, and `test_rehearse.py` requires that mutation to **survive** — otherwise
|
||
"the reference notices any edit to `mqtt.py`" would be indistinguishable from
|
||
"the reference notices what HA keys on".
|
||
|
||
⚠️ **No result in this repository was produced against real state.** Everything
|
||
recorded here and in `test_rehearse.py` was measured on the synthetic fixtures
|
||
in `tools/rehearsal/`.
|
||
|
||
### What it confirms on the synthetic fixtures
|
||
|
||
⚠️ **This is a SECOND INSTRUMENT reaching a conclusion this repository already
|
||
holds, not a discovery.** `test_maintenance.py` section 13 — *"RECORDED, NOT
|
||
FIXED: SAFETY-02's migration has the same wrong-namespace hole, and it is a
|
||
LATCH"* — already states it, already names `namespaced_latched`, and already
|
||
carries the sentence *"the guard is defeated by the very key the defect wrote"*.
|
||
That file is on `release/1.0` and this card does not touch it. What is new here
|
||
is that the finding is now reachable **from a state file the owner supplies**
|
||
rather than only from a fixture built in a test, and that it is reached by a
|
||
different route: a full `Controller` start diffed against its predecessor,
|
||
rather than assertions over a `MemStore`. Two instruments, one answer.
|
||
|
||
**The finding.** A pre-DEV-02 flat store that declares a multi-inverter roster
|
||
comes up with its SAFETY-02 latch **DISARMED**, unless the first roster entry
|
||
happens to be named `inverter-a`. `Controller.__init__` calls
|
||
`migrate_unit_keys(store, UNIT_A)` with a hardcoded constant, so the latch lands
|
||
in `unit/inverter-a/sign_tripped`; a roster of `["garage", "shed"]` reads
|
||
`unit/garage/sign_tripped`, which is absent. And `_grade_store_migration()` —
|
||
the guard written for exactly this — sees the migration's own misdirected key
|
||
satisfy its `namespaced_latched` test and returns without re-arming. The file
|
||
says `sign_tripped: true`, the banner is green, and nothing is held at 0 W.
|
||
|
||
**Both routes reach it, and they are NOT the same defect.** The rehearsal
|
||
reports them as separate legs because they need separate fixes:
|
||
|
||
| leg | how it gets there |
|
||
|---|---|
|
||
| `SAME-STEP` | the roster is declared in the same step as the upgrade. The migration runs, and is handed `UNIT_A`. |
|
||
| `STAGED` | the flat config runs once under 0.7.0+ first. That start stamps `schema_version = 1`, so the roster start's migration takes its `from_version >= SCHEMA_VERSION` early return and **the latch never moves at all**. |
|
||
|
||
⚠️ **So "hand `migrate_unit_keys()` the roster's own first name" is NOT the
|
||
whole fix, and the rehearsal measures that rather than asserting it.**
|
||
`test_rehearse.py` part 5b builds exactly that change and reports:
|
||
|
||
LEG STAGED: DISARMED
|
||
LEG SAME-STEP: clean
|
||
|
||
The staged path never reaches the line that was changed. A fix that stops there
|
||
closes the half an operator is least likely to take and leaves the half that
|
||
"upgrade first, declare the roster later" produces. The guard side —
|
||
`namespaced_latched` counting a namespace that is not on the roster — is what
|
||
covers both, and a change there turns every SAFETY-44 check in part 5 red, which
|
||
is what a fix should do to a pin that specifies a defect.
|
||
|
||
The fix belongs to its own card, shown red first. Part 5 pins the current
|
||
behaviour as a requirement so it cannot drift green by accident.
|
||
|
||
### The reverse direction: the retreat is supported, the round trip is not
|
||
|
||
If commissioning B goes wrong, **backing out to the flat single-inverter config
|
||
is supported and does not corrupt state.** Measured on the fixtures:
|
||
|
||
* the SAFETY-02 latch and the first pack's maintenance history both survive,
|
||
because `store.py` rule 2 keeps the flat spellings **live** rather than merely
|
||
present — `maintenance.UnitHistory.mirror_flat` writes them on every change,
|
||
so a single-FSM add-on finds a current history rather than one dated the day
|
||
the roster was declared;
|
||
* **it orphans eight Home Assistant entities** — the four per-unit rows for each
|
||
of the two units. **Measured:** the add-on stops announcing them and stops
|
||
publishing their state, so their history gaps and any automation whose
|
||
trigger names them stops firing, silently and without an error.
|
||
|
||
⚠️ **What is NOT measured, and was previously overstated here:** whether
|
||
advancing again restores them. An earlier draft of this section said the
|
||
orphaning was "not recoverable by advancing again: HA keeps the id it composed
|
||
at first discovery" — that reasoning cuts the other way, since keeping the
|
||
composed id is precisely what would let a re-advertised entity land back on
|
||
it, and discovery is published `retain=True` with no removal payload. Nothing
|
||
in this repository has tested a re-advance against a real Home Assistant, so
|
||
the claim is withdrawn rather than restated in the other direction. Plan the
|
||
retreat around the measured part: **the eight rows stop updating for as long
|
||
as you are retreated.**
|
||
|
||
⚠️ **What is NOT supported is the round trip.** A maintenance cycle that
|
||
completes *while the install is rolled back* is **discarded** when it
|
||
re-advances: `maint_schema_version` is already stamped, so `migrate_maint_keys()`
|
||
takes its early return and the pack reads the namespaced history from before the
|
||
retreat. The pack is then recalibrated on a schedule that does not know it
|
||
already was. **So: retreat freely, but treat a retreat as ending the current
|
||
maintenance bookkeeping — and if a cycle ran during it, correct
|
||
`unit/<first>/last_completed` by hand before advancing again.** This is a stated
|
||
decision, not a discovery made at the inverter.
|
||
|
||
## Before you deploy: `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. Pass the
|
||
bare object (`ha apps info <slug> --raw-json | jq .data.options`); the wrapped
|
||
output is refused.
|
||
|
||
**Exit codes.** `0` cleared · `1` REFUSED, do not deploy · `2` NOT CLEARED,
|
||
meaning a check did not run. ⚠️ **2 is not a pass.** On a bare host without the
|
||
add-on's dependencies the validator half skips and returns 2 rather than
|
||
pretending; `test_version_gate.py` pins that a skipped check can never exit 0,
|
||
because the first draft of that test reported four silent skips as passes.
|
||
|
||
⚠️ **Why this is not a CI check.** The obvious gate — "a pull request touching
|
||
runtime code must bump the version" — is the wrong shape here. This project
|
||
deliberately batches many merges into one deploy, so a per-PR bump would put
|
||
every branch in conflict over one line and still not prevent the failure, which
|
||
happens at deploy and not at merge. The check belongs where the mistake is made.
|
||
|
||
⚠️ This file's own change does **not** need a version bump, and the gate agrees:
|
||
`tools/` and `test_*.py` are not in its runtime set, because demanding a bump
|
||
for a typo fix trains people to bump without reading — the habit the check
|
||
exists to prevent.
|