Merge release/1.0 into SAFETY-30
checks / unit (pull_request) Successful in 4m39s

CFG-04 (#45) merged first and moved the base. Two conflicts, both resolved
as the union of the two sides:

- app/main.py: an import-block collision. CFG-04 added validate_unit_ranges
  to the .validate import; SAFETY-30 added AGAINST_METER, AGAINST_OWN_POWER,
  SIGN_OPTION_DEFAULTS and SignWatchdog to the .watchdog import. Neither
  side dropped anything, so the resolution keeps both lists whole.
- CHANGELOG.md: two adjacent new entries under ## Unreleased. Both kept.

Both sides also independently rewrote large parts of test_peakguard.py
(60 and 47 insertions against the merge base) and git merged them
textually, so the full suite was run on the MERGE RESULT rather than
inferred from the two parents: 19 passed, 0 failed, twice.

One caveat recorded rather than hidden: test_peakguard.py failed once
during the first sequential full-suite run and then passed 10/10 in
isolation and in two further full runs on this same tree. It passes on
both parents too, so this is the pre-existing flakiness CFG-04 already
disclosed, not a merge defect - it is timing- or load-sensitive. It sits
on the required CI context and can therefore block an unrelated merge at
random, which wants a card of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-04 12:23:50 +02:00
co-authored by Claude Opus 5
13 changed files with 2090 additions and 120 deletions
+77 -1
View File
@@ -55,6 +55,82 @@ Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
those serve cached values, and a stale reading here ends the maintenance charge
phase having charged nothing.
### More than one inverter (`inverters`)
The six keys above describe **one** inverter, and if that is your site there is
nothing to do here — `inverters` ships empty and empty means *"the flat keys
above describe the one inverter"*. **An install that upgrades into 0.6.0 reads
identically to before: same entities, same rating, same behaviour, no operator
action.**
To run two, declare them. Each entry names its own actuator, its own rating and
its own sensors:
```yaml
setpoint_entity: "" # ⚠️ must be cleared — see below
inverters:
- name: inverter-a
setpoint_entity: number.goodwe_a_setpoint_w
soc_entity: sensor.goodwe_a_soc
batt_entity: sensor.goodwe_a_battery_power
max_w: 5000
- name: inverter-b
setpoint_entity: number.goodwe_b_setpoint_w
soc_entity: sensor.goodwe_b_soc
batt_entity: sensor.goodwe_b_battery_power
max_w: 3000
```
| per-entry key | required | meaning |
|---|---|---|
| `name` | yes | Short identifier. It namespaces this unit's SAFETY-02 latch, heartbeat and write bookkeeping in the add-on's state file, so **two units may not share one** |
| `setpoint_entity` | yes | This inverter's own ESPHome `number.*_goodwe_setpoint_w` |
| `max_w` | yes | The supervised cap on what may be written to **this** inverter. The site's ceiling is the **sum** |
| `soc_entity` | | This pack's state of charge |
| `batt_entity` | | This unit's battery power, `+` = discharging |
| `batt_invert` | | Flip if needed |
⚠️ **Nothing is inherited.** An entry that leaves out `soc_entity` has **no SoC
sensor** — it does *not* fall back to the flat one. That is deliberate: every
per-device value became per-instance in 0.5.x precisely so that no inverter is
ever graded against a sibling's numbers, and a silent fallback would rebuild
that in YAML. `name`, `setpoint_entity` and `max_w` are required of every entry
for the same reason: there is no rating to borrow.
⚠️ **Clear the flat `setpoint_entity` when you declare a roster,** or the add-on
refuses to start and says so. It is the single-inverter form and it is not read
once a roster exists, so leaving it set means one of two things: it names an
entity an entry also names — two writers on one actuator, which specs §1.5 makes
a non-goal and the add-on refuses outright — or it names a different one, which
nothing would ever write, leaving a site running quietly smaller than its
configuration reads. `soc_entity`, `batt_entity`, `batt_invert` and `max_w`
cannot be blanked (the schema gives them no empty value); they are simply not
read, and the add-on writes one line into its action log at every start saying
so.
⚠️ **Two inverters naming the same `setpoint_entity` are refused,** whichever
keys they are declared under and whatever the capitalisation. Two independent
setpoints on one inverter is the arbitration problem this add-on exists not to
have.
⚠️ **Update the add-on first, then add the roster.** Home Assistant validates the
options you save against the schema of the version that is *installed*, so
`inverters` cannot be set before 0.6.0 is installed. The order is: update, then
configure.
⚠️ **Going the other way — downgrading with a roster configured — is recoverable
but NOT uneventful, and the add-on will not start until you refill a field.**
The *schema* side is fine: the older version's schema has no `inverters` key, so
it drops the key with a warning rather than refusing the whole blob (measured —
`DECISIONS/CFG-04-inverter-roster.md` §E). The *add-on* side is not. Rule 9(e)
requires the flat `setpoint_entity` be **cleared** when a roster is declared, so
a config that downgrades cleanly is by construction a config whose flat
actuator is blank — and the older version has nothing else to write to. It stops
at startup validation with a refusal naming `setpoint_entity`, which is loud and
in the log rather than silent, but it is an outage until someone puts the
single-inverter entity back in that field. **Before downgrading, write down the
`setpoint_entity` of the unit you want to keep running.**
### P1 meter ingestion
`meter_entity` above expects one signed sensor, which usually means a template
@@ -272,7 +348,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
| option | default | meaning |
|---|---|---|
| `max_w` | 2000 | Hard limit on what may be commanded. Start low, raise after commissioning |
| `max_w` | 2000 | Hard limit on what may be commanded. Start low, raise after commissioning. **Not read when `inverters` is declared** — each entry carries its own, and the site ceiling is their sum |
| `gain` | 0.6 | Correction per cycle. **At the limit — do not raise** |
| `slew_w` | 1000 | Maximum change per cycle |
| `deadband_w` | 15 | Ignore errors smaller than this |