Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 5.5 29ca9b5157 DOC-1: operator docs name the real P1 entity and a roster that starts
checks / unit (pull_request) Successful in 8m8s
- DOCS.md / config.yaml: the watchdog's P1 age entity is
  sensor.goodwe_p1_sample_age (device "GoodWe" + "P1 sample age", as
  app/mqtt.py announces and the live install recorded), not
  sensor.p1_sample_age_s.
- DOCS.md: the inverters: example and per-entry table carry the required
  bms_soc_floor (and capacity_kwh); config.yaml's schema comment says four
  required keys, not three.
- Field guide: marked v0.1.0/outdated; slew_w row no longer recommends the
  1000 W that SAFETY-39 now refuses. HTML regenerated; the PDF is NOT
  rebuilt (no Chrome here). README/DOCS pointers fixed.
- test_docs.py: the documented roster must pass app/validate.py and the
  schema's per-entry keys; the documented entity id must equal the id
  composed from app/mqtt.py; the wrong id may appear in no shipped doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:24:50 +02:00
adminandClaude Opus 5.5 a45ed06c05 PKG-18: cut 0.12.0, so TEL-12's pv_entities can reach an installed 0.11.0 (#117)
checks / unit (pull_request) Successful in 8m0s
Reviewed at head 1ad4d3a. CI run 830 (attempt 1, pull_request, PR 117 -> release/1.0), 31/31 PASS. Review: #issuecomment-1588, #issuecomment-1589.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:09:28 +02:00
glenn schrooyenandClaude Opus 5.5 1ad4d3a5bb PKG-18: cut 0.12.0, so TEL-12's pv_entities can reach an installed 0.11.0
checks / unit (pull_request) Successful in 8m5s
One runtime merge since 0.11.0 (cccb2fa): TEL-12 (#116), a new optional
list option `pv_entities` beside `pv_entity`. Option surface 76 -> 77, so
a minor bump. config.yaml version and a closed `## 0.12.0` section only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 22:59:44 +02:00
admin f863ecaa8c TEL-12: several PV inverters, and one absent member is an absent TOTAL (#116)
pv_entity becomes a roster: pv_entities beside it, replacing it, with the SITE total over the roster. One absent member makes the TOTAL absent - never the partial sum of the ones that answered, because a smaller number indistinguishable from a real one reads a week later as 'there was less sun than there was', which is the conclusion OPT-10 exists to test rather than to manufacture. A member honestly reporting 0 W is still summed.

Reviewed on PR 116. Re-run independently: supervisor_shape_probe.py SSG (exit 0, transcript reproduces byte for byte), the owner's real 75-key options blob through Supervisor 2026.08.0's own AppOptions (ACCEPTED, zero drift on any stored value, pv_entities defaulted to []), tel11_pv_trace.py --compare across both trees (every hash reproduces, control 846a10a5b89e unmoved on unset and one), the rejected-design mutant (trace REFUSED 5/5 with the partial sums named), the planted leak N8 (52/26/10 red cells), and eight mutations on fresh purged copies including N5 sum->max at +0 bytes - no survivors. Habit 24's central claim measured: TEL-11's own --compare refuses on every honest run now that release/1.0 is 0.11.0.

Green traced to this PR's own run: 829/1954, attempt 1, event pull_request, path checks.yml@refs/pull/116/head, payload naming PR 116 with base release/1.0 @ cccb2fa, 31 suites PASS in 7m53s - a real execution, not a laundered skip.

Not deployable as merged: config.yaml stays 0.11.0 and the CHANGELOG entry is PKG's call.
2026-09-24 22:41:19 +02:00
8 changed files with 376 additions and 23 deletions
+6 -1
View File
@@ -4,6 +4,10 @@ The guide technicians carry is the PDF:
**`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided.
⚠️ **Outdated: it is v0.1.0.** Its `slew_w` row is corrected in `build_guide.py`
and `field-guide.html`, but the PDF has not been rebuilt (see below). Where the
guide disagrees with `goodwe_controller/DOCS.md`, DOCS.md wins.
It covers, in the order the work is done: pre-visit checks, hardware, firmware,
add-on install and configuration, how to read the Web UI, the six commissioning
gates, the e-stop (and the statement a site without one must sign), proving a
@@ -18,7 +22,8 @@ copy cannot drift from the PDF someone is holding in a cellar.
python3 docs/build_guide.py # regenerates field-guide.html and the PDF
```
Requires Chrome or Chromium (used headless for print-to-PDF). Screenshots live
Requires Chrome or Chromium (used headless for print-to-PDF); `CHROME` in the
script is a Windows path, so edit it on any other machine. Screenshots live
in `docs/img/` and are base64-embedded, so `field-guide.html` is standalone.
⚠️ **Every section must stay under the A4 printable height** (1039 px at 96 dpi
+3 -3
View File
@@ -30,7 +30,7 @@ goodwe_controller/ the add-on
returns, run against its own release image
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
estop/rs485_log.py optional RS485 e-stop / bus witness
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; outdated)
docs/build_guide.py its single source - rebuilds the HTML and PDF
FIELD-GUIDE.md how to edit and rebuild the guide
docs/RELEASE.md how a change reaches the battery - `master` is
@@ -79,8 +79,8 @@ machine itself dying:
2. **Wind-down before firmware updates** — 0 W written before the update starts.
3. **RS485 e-stop (optional)** — writes 0 W after 30 s of total bus silence.
Sites sold without the e-stop must have the acknowledgement in `FIELD-GUIDE.md`
§10 signed.
Sites sold without the e-stop must have the client acknowledgement in the field
guide (Step 7b) signed.
## Development
+2 -2
View File
@@ -95,7 +95,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="page" id="p1">
<div class="hdr">
<div><h1>GoodWe RS485 Controller</h1>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0</div></div>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0, <b>outdated</b>: where it disagrees with DOCS.md, DOCS.md wins</div></div>
<small>Installer copy</small>
</div>
@@ -503,7 +503,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table>
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 3–6 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
<tr><td><code>slew_w</code></td><td>0</td><td>0 follows the rail (<code>max_w</code>&nbsp;/&nbsp;9). A faster fixed value is <b>refused at startup</b> (SAFETY-39) — see DOCS.md</td></tr>
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
+2 -2
View File
@@ -60,7 +60,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="page" id="p1">
<div class="hdr">
<div><h1>GoodWe RS485 Controller</h1>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0</div></div>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0, <b>outdated</b>: where it disagrees with DOCS.md, DOCS.md wins</div></div>
<small>Installer copy</small>
</div>
@@ -468,7 +468,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table>
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 3–6 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
<tr><td><code>slew_w</code></td><td>0</td><td>0 follows the rail (<code>max_w</code>&nbsp;/&nbsp;9). A faster fixed value is <b>refused at startup</b> (SAFETY-39) — see DOCS.md</td></tr>
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
+132
View File
@@ -1,5 +1,137 @@
# Changelog
## 0.12.0
**A MINOR BUMP FOR ONE NEW OPTION: `pv_entities` ([[TEL-12]]), for sites with
more than one PV inverter.** The option surface moves **76 keys -> 77**,
`options:` and `schema:` in step. 0.11.0's `pv_entity` takes one entity; a roof
with two or three inverters, each publishing its own power sensor into Home
Assistant, could only record one of them. `pv_entities` is a list, and the
Configuration tab renders it as a repeatable field, so you add your second and
third inverter there rather than in YAML.
⚠️ **UNSET, NOTHING CHANGES.** `pv_entities` ships as `[]`, which is what every
install that presses *Update* lands on, and empty means "use `pv_entity`" - so
an install with no PV entity still has no PV channel, and an install with one
`pv_entity` keeps exactly the single-entity channel 0.11.0 gave it. **No rule was
added to `validate.py`**, which mentions neither key, so **a stored options blob
that starts the add-on on 0.11.0 starts it on 0.12.0**. There is no
`SystemExit(78)` to plan around on this hop.
⚠️ **IT IS INSTRUMENTATION ONLY, NOT A CONTROL INPUT.** `app/control.py`,
`allocator.py`, `arbiter.py`, `peakguard.py` and the watchdogs are not in this
release's diff; the runtime files that move are `app/pv.py`, `app/main.py`,
`app/mqtt.py`, `config.yaml` and `translations/`. `tools/tel11_pv_trace.py`,
extended for this card, hashes the controller's decisions on 0.11.0 and on this
tree: the control document is **`846a10a5b89e…` with PV unset and with one
entity, on both trees** - the same hash TEL-11 recorded - and with a
three-entity roster it equals each grid world's unset document, including every
cell where one inverter is dead. Neither the total nor any partial total reaches
a decision. With PV unset or with one entity, the **telemetry** is byte-identical
to 0.11.0's as well.
Contents of this release, in merge order since 0.11.0 (`cccb2fa`):
| | |
|---|---|
| `f863eca` | TEL-12 - several PV inverters, and one absent member is an absent TOTAL **⚠️ NEW OPTIONAL OPTION, EMPTY ON EVERY EXISTING INSTALL** |
### TEL-12 — `pv_entities`: the PV channel for a roof with several inverters
**WHAT TO DO ON A SITE WITH MORE THAN ONE PV INVERTER.** List every inverter's
power sensor under `pv_entities`:
```yaml
pv_entities:
- sensor.pv_east_power
- sensor.pv_west_power
- sensor.pv_garage_power
```
`sensor.goodwe_pv_power` then carries the **site total**. Each entity's own
`unit_of_measurement` is read, so a kW inverter beside a W one sums correctly;
a sensor with no unit or a non-power unit is refused by name on the status
page, as in 0.11.0.
⚠️ **`pv_entities` REPLACES `pv_entity` - IT DOES NOT ADD TO IT.** When the list
is set, `pv_entity` is not read at all. **If you are upgrading from 0.11.0 with
`pv_entity` already set, the inverter it names must also be in the list**, or it
drops out of the total. Put it in the list and it is **counted once**, not
twice - that is why the rule is replacement: under a union, the natural action
(put all your inverters in the new field) would double-count the one the old
key already names. While both keys are set the status page shows a caution
saying which one won - *"`pv_entities` is set, so `pv_entity` is IGNORED - the N
entities in the list are what is summed. Clear `pv_entity` to remove this
caution"*. It is a caution, never a refusal to start and never a NOT READY row.
A repeated id inside the list is also counted once.
⚠️ **IF ANY CONFIGURED PV ENTITY IS ABSENT, THE PUBLISHED TOTAL IS `unknown` -
NOT THE SUM OF THE ONES THAT ANSWERED.** Three inverters configured and one
`unavailable`: `sensor.goodwe_pv_power` goes to `unknown` and the recorder
stores a **gap**. The sum of the other two would be a smaller number
indistinguishable from a real one, and a week later it would read as *"there
was less sun than there was"* - the conclusion this channel exists to test
rather than to manufacture. **A gap in the history, never an understated
number.** The status page, read live, still gives you the diagnostic: `PV —`
plus a caution that says there is **no site total**, names the missing entity
and its reason, and lists what the others reported. An inverter honestly
reporting **0 W** is summed, not treated as absent - shading and nightfall are
real zeros.
| what happened | `sensor.goodwe_pv_power` | the status page |
|---|---|---|
| neither key set | the entity does not exist | no row |
| all three answer, 1200 W + 2.1 kW + 450 W | `3750.0` | `PV 3 750 W` |
| all three answer, one of them `0 W` | the sum of the two others | the sum |
| one (or more) `unavailable` / missing / bad unit | `unknown` - a recorder gap | `PV —` + a caution naming what is missing |
⚠️ **THE FIX FOR A MISSING INVERTER IS NOT A TEMPLATE SENSOR THAT SUMS THEM.**
You can still point `pv_entity` at one if you prefer, but a template sensor
summing an `unavailable` source yields the partial total silently - the same
defect one layer down, where this add-on cannot see it or report it.
**WHY A SECOND KEY, RATHER THAN MAKING `pv_entity` A LIST.** Because an 0.11.0
install that has `pv_entity` set would not start. Measured against Supervisor
**2026.08.0** by `tools/supervisor_shape_probe.py` §G: with `pv_entity` declared
as a list, the string an 0.11.0 install has persisted is refused -
`Invalid list for option 'pv_entity'` - and a shipped `[]` default does not
rescue it, because the Supervisor's merge keeps your stored string. The one
single-key spelling that accepts both shapes, `match(.*)?`, validates nothing
(it accepts the integer `417`) and renders as a plain text box, so you could not
add a second inverter in the UI. So `pv_entity` stays a string, and the same
probe shows an 0.11.0 options blob - `pv_entity` set, no `pv_entities` key at
all - validating against this version untouched. ⚠️ **Rolling back to 0.11.0 is
also safe**: a schema without `pv_entities` drops the key with a warning and
leaves `pv_entity` standing.
**Why it matters now:** [[OPT-10]] needs a week of PV history to attribute the
surplus that left the reference site while the pack had room, and that site has
several PV inverters. The week starts when the site total is recorded.
**Full account: `DECISIONS/TEL-12-several-pv-inverters.md`, with the operator's
version under "Several PV inverters" in `DOCS.md`.**
### If you are crossing from a version older than 0.10.0
⚠️ **THIS SECTION IS NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.**
Updating installs 0.12.0 in one step, so every validator rule added in
0.8.0, 0.9.0 and 0.10.0 arrives at the same moment. Two of them can refuse a
config nobody has edited: [[SAFETY-39]]'s **rule 10** (added in 0.8.0) against
0.7.0's shipped default `slew_w: 1000` - the ceiling is **555.6 W** on a 5000 W
rail - and [[TARIFF-07]]'s three withdrawn `price_provider` ids,
**`engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy`**, each still
refused by name at startup on 0.12.0. Neither is new here, and a site already on
0.10.0 or later has already met both. **The full account of both, including
what to set instead of each withdrawn id, is in `## 0.10.0`**, which withdrew
them and is not superseded by this section.
`tools/predeploy_check.py` checks your live options against this version's
validator before you press *Update*, and on this tree reports a real version
change from 0.11.0. ⚠️ **Pass it the bare options dict - the `data.options`
object - NOT the whole `ha apps info --raw-json` document.** Handed the wrapped
export it currently prints CLEARED while having checked nothing ([[SAFETY-60]],
open). Extract the options first, e.g. `jq '.data.options'`.
## 0.11.0
**A MINOR BUMP, AND ONE NEW OPTION IS THE WHOLE REASON FOR THE SECOND DIGIT:
+14 -9
View File
@@ -4,8 +4,8 @@ Drives a GoodWe ES/BP battery inverter over its RS485 meter bus: holds net grid
exchange at zero, and runs a monthly battery maintenance cycle so the BMS can
balance cells and recalibrate its coulomb counter.
Installers: read `FIELD-GUIDE.md` in the repository. It is not optional reading —
it contains the commissioning gates and the failure modes.
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
@@ -165,12 +165,14 @@ inverters:
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
```
@@ -190,16 +192,18 @@ inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
| `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` and `max_w` are required of every entry
for the same reason: there is no rating to borrow.
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
@@ -600,7 +604,7 @@ than in YAML nobody reviews underneath a safety input.
> `ha_signed`.** It reads the same meter and produces the same numbers, but it
> polls the meter directly instead of watching a Home Assistant entity — and
> that is the difference between an age sensor a watchdog can threshold and one
> it cannot. See "`sensor.p1_sample_age_s`" below; `ha_signed` remains for
> 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
@@ -755,7 +759,7 @@ connection-level reading is still accepted on its own.
**Why this mode exists:** every HTTP response is an *arrival*. The meter
answered, now, with its current reading — whether or not the number moved. That
is the signal `sensor.p1_sample_age_s` needs and the one Home Assistant cannot
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.
@@ -792,12 +796,13 @@ as `… 4 rejected, measurement unchanged for 312 s`. On a house drawing real
power that figure stays in the seconds; minutes of it while the load is clearly
not near zero is the meter to go and look at.
#### `sensor.p1_sample_age_s`
#### `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, so do not rename it.
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
@@ -833,7 +838,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
> went **42.2 s and 97.0 s** between changes, and 23 Aug peaks at 29.1 s — all
> past the default `meter_max_age_s` of 30.
>
> So `sensor.p1_sample_age_s` on `ha_signed` is safe to *read*, and it is
> 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
+7 -6
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.11.0"
version: "0.12.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -93,7 +93,7 @@ options:
# against a sibling's numbers; an entry silently inheriting `soc_entity` would
# rebuild that defect in YAML. `name`, `setpoint_entity`, `max_w` and
# `bms_soc_floor` are therefore REQUIRED of every entry (the schema below enforces it, and
# validate.py rule 9 enforces it again for the callers the Supervisor never
# validate.py rules 8 and 9 enforce it again for the callers the Supervisor never
# sees). An omitted `soc_entity`/`batt_entity` means THIS UNIT HAS NO SUCH
# SENSOR - `inputs_ok` already stops the site for that - and never means
# "use the other one's".
@@ -224,7 +224,7 @@ options:
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a
# HomeWizard P1 publishes and what ha_dsmr cannot consume.
# homewizard_local skips Home Assistant entirely and polls the meter's own
# local API. ⚠️ It is the ONLY mode whose sensor.p1_sample_age_s measures when
# local API. ⚠️ It is the ONLY mode whose sensor.goodwe_p1_sample_age measures when
# the meter REPORTED rather than when the value last CHANGED - HA emits
# nothing at all for a repeated reading - so it is the only mode a firmware
# watchdog may threshold. See DOCS.md.
@@ -916,12 +916,13 @@ schema:
# batteries, whereas an absent capacity has an honest answer - no opinion
# about the split, every unit weighs 1.0, which is what shipped.
#
# ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
# ⚠️ FOUR KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
# no `name` has no Store namespace and would share another unit's SAFETY-02
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
# no `max_w` would have to borrow a rating, which is the sibling-grading
# defect DEV-02 removed. The optional pair is the sensors, whose absence
# honestly means "this unit reports none".
# defect DEV-02 removed; `bms_soc_floor` is SAFETY-32's (options block above).
# The optional keys are the sensors, whose absence honestly means "this unit
# reports none", and `capacity_kwh`.
#
# ⚠️ THIS BLOCK IS DECLARED BEFORE `max_w:` ON PURPOSE. `max_w` now appears
# TWICE under `schema:` - once nested here, once flat below - and a reader
+210
View File
@@ -0,0 +1,210 @@
"""Runnable check that the operator docs say what the code does. `python3 test_docs.py`
DOC-1. Two things the docs got wrong and an installer would have copied:
1. The `inverters:` example in DOCS.md must be ACCEPTED by the shipped
validator (`app/validate.py`) and carry every key config.yaml's schema
requires per entry. It omitted `bms_soc_floor` and would not start.
2. The P1 age entity the ESP32 watchdog subscribes to must be documented under
the id Home Assistant composes from what `app/mqtt.py` announces, and the
old wrong id must appear in no shipped doc.
Each part also runs its own mutation (habit 1 of MUTATION.md): the spoilt input
must turn it red, or the green above it means nothing.
"""
import os
import re
import sys
from app import mqtt
from app.validate import validate_startup_config
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def read(*parts):
with open(os.path.join(*parts), encoding="utf-8") as fh:
return fh.read()
DOCS = read(HERE, "DOCS.md")
CFG = read(HERE, "config.yaml")
# --------------------------------------------------------------------------- #
# A reader for the two YAML shapes used here. No PyYAML in the checks image
# (test_defaults_pin.py says why), so this is by hand, and part 1a checks it
# actually found something.
def scalar(raw):
raw = raw.split("#", 1)[0].strip()
if raw[:1] in "\"'" and raw[-1:] == raw[:1] and len(raw) >= 2:
return raw[1:-1]
if raw in ("true", "false"):
return raw == "true"
if raw == "[]":
return []
for cast in (int, float):
try:
return cast(raw)
except ValueError:
pass
return raw
def parse_example(text):
"""Top-level `key: value` lines plus one list of flat mappings."""
out, cur_list, cur = {}, None, None
for line in text.splitlines():
if not line.split("#", 1)[0].strip():
continue
m = re.match(r"^([a-z0-9_]+):\s*(.*)$", line)
if m:
if m.group(2).split("#", 1)[0].strip():
out[m.group(1)] = scalar(m.group(2))
cur_list = None
else:
cur_list = out[m.group(1)] = []
continue
m = re.match(r"^\s+-\s+([a-z0-9_]+):\s*(.*)$", line)
if m and cur_list is not None:
cur = {m.group(1): scalar(m.group(2))}
cur_list.append(cur)
continue
m = re.match(r"^\s+([a-z0-9_]+):\s*(.*)$", line)
if m and cur is not None:
cur[m.group(1)] = scalar(m.group(2))
continue
raise ValueError(f"unparsed line in the DOCS.md example: {line!r}")
return out
# --------------------------------------------------------------------------- #
print("DOC-1 - the docs say what the code does\n")
print("1. the `inverters:` example in DOCS.md starts")
section = DOCS[DOCS.index("### More than one inverter (`inverters`)"):]
section = section[:section.index("\n### ", 1)]
block = re.search(r"```yaml\n(.*?)```", section, re.S).group(1)
example = parse_example(block)
entries = example.get("inverters") or []
# The options a fresh install is given, from config.yaml itself: the example is
# pasted on top of these, exactly as an installer does.
opts_block = CFG[CFG.index("\noptions:"):CFG.index("\nschema:")]
shipped = {m.group(1): scalar(m.group(2))
for m in re.finditer(r"^ ([a-z0-9_]+): *(.*)$", opts_block, re.M)}
# config.yaml's per-entry schema: required keys are the ones without `?`.
schema_block = CFG[CFG.index("\nschema:"):]
inv_schema = re.search(r"^ inverters:\n((?: .*\n)+)", schema_block, re.M).group(1)
schema_keys = dict(re.findall(r"^ -? ?\s*([a-z0-9_]+): *(\S+)", inv_schema, re.M))
required = sorted(k for k, t in schema_keys.items() if not t.endswith("?"))
check(f"1a. the reader found the example: {len(entries)} entries, "
f"{len(shipped)} shipped options, schema keys {sorted(schema_keys)}",
len(entries) == 2 and all(len(e) >= 4 for e in entries)
and len(shipped) > 50 and "bms_soc_floor" in required)
missing = [(e.get("name"), k) for e in entries for k in required if k not in e]
check(f"1b. every entry carries every key the schema requires {required} "
f"(missing: {missing})", not missing)
unknown = [(e.get("name"), k) for e in entries for k in e if k not in schema_keys]
check(f"1c. no entry uses a key the schema does not declare (found: {unknown})",
not unknown)
def with_example(ex):
opts = dict(shipped)
opts.update(ex)
return opts
problems = validate_startup_config(with_example(example))
for p in problems:
print(f" refused: {p[:160]}")
check("1d. the shipped validator accepts the example as documented", problems == [])
# Precondition: the example clears the flat setpoint_entity, so without its
# roster there is no actuator. If this passed, 1d graded the flat keys, not the
# roster.
flat_only = {k: v for k, v in example.items() if k != "inverters"}
check("1e. ...and it is the ROSTER that 1d graded (the example without it is "
"refused)", validate_startup_config(with_example(flat_only)) != [])
# The mutation this card is about: the key the docs used to leave out.
spoilt = dict(example, inverters=[
{k: v for k, v in e.items() if k != "bms_soc_floor"} for e in entries])
sp = validate_startup_config(with_example(spoilt))
check("1f. MUTATION: the example with `bms_soc_floor` removed is refused, "
"and the refusal names it",
sp != [] and any("bms_soc_floor" in p for p in sp))
# --------------------------------------------------------------------------- #
print("\n2. the P1 age entity is documented under its real id")
def compose(device_name, entity_name):
"""HA's entity_id from device name + entity name (mqtt.py's note on
DEVICE). Written here, not borrowed from mqtt._slug, so a change to that
helper cannot move both sides of the comparison."""
s = re.sub(r"[^a-z0-9]+", "_", f"{device_name} {entity_name}".lower())
return "sensor." + s.strip("_")
p1_rows = [r for r in mqtt.SENSORS if r[0] == "p1_age"]
check("2a. mqtt.py announces exactly one `p1_age` sensor", len(p1_rows) == 1)
real_id = compose(mqtt.DEVICE["name"], p1_rows[0][2]) if p1_rows else ""
print(f" composed from mqtt.py: {real_id}")
# Anchor outside mqtt.py: the id recorded from the live install.
recorded = read(HERE, "tools", "rehearsal", "announced-reference.txt")
check("2b. ...and that composes to the id recorded from the live install "
"(tools/rehearsal/announced-reference.txt)",
re.search(rf"^{re.escape(real_id)}\s+goodwe_ctl_p1_age\b", recorded, re.M)
is not None)
m = re.search(r"^#### `(sensor\.[a-z0-9_]+)`\n\n(?:.*\n){0,6}?.*stale-input watchdog",
DOCS, re.M)
documented = m.group(1) if m else None
check(f"2c. DOCS.md's watchdog-entity section is headed with that id "
f"(found {documented!r})", documented == real_id)
# The wrong id, anywhere an installer reads. CHANGELOG.md and DECISIONS/ are
# history and keep what was said at the time.
SHIPPED_DOCS = [
os.path.join(HERE, "DOCS.md"), os.path.join(HERE, "config.yaml"),
os.path.join(REPO, "README.md"), os.path.join(REPO, "FIELD-GUIDE.md"),
os.path.join(REPO, "docs", "build_guide.py"),
os.path.join(REPO, "docs", "field-guide.html"),
]
WRONG = "p1_sample_age_s"
hits = []
for path in SHIPPED_DOCS:
for n, line in enumerate(read(path).splitlines(), 1):
if WRONG in line:
hits.append(f"{os.path.relpath(path, REPO)}:{n}")
check(f"2d. `{WRONG}` appears in no shipped doc (hits: {hits})", not hits)
# MUTATION: the old heading must fail 2c's comparison.
bad = DOCS.replace(f"#### `{real_id}`", f"#### `sensor.{WRONG}`")
mb = re.search(r"^#### `(sensor\.[a-z0-9_]+)`\n\n(?:.*\n){0,6}?.*stale-input watchdog",
bad, re.M)
check("2e. MUTATION: the old heading is found and does not match",
mb is not None and mb.group(1) != real_id)
print()
if fails:
print(f"FAILED: {len(fails)}")
sys.exit(1)
print("all ok")