Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 5.5 7fa045af85 TEL-14 N1: trim DECISIONS (drop probe transcript, §1 to the reason), move the mutation ledger there with M2/M5
checks / unit (pull_request) Successful in 8m44s
test_pv.py part 11 now prints a one-line pointer instead of the 12-row table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 01:11:40 +02:00
adminandClaude Opus 5.5 93f349a6af TEL-14 B1: pin stray caution on the reported case and whitespace normalisation end to end
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 01:10:13 +02:00
glenn schrooyenandClaude Opus 5.5 7559d21eda TEL-14: PV sign declared per entity (pv_invert_entities)
checks / unit (pull_request) Successful in 8m39s
Some PV entities (kWh meters, ESPHome clamps in the PV circuit) read positive
for consumption and negative for production. `pv_invert_entities` lists the
ones to flip; the flip is applied per member inside pv.total(), before the sum,
so the MQTT `pv` row and the status page carry the corrected figure. Default []
is byte-identical to 0.12.0.

Shape measured in supervisor_shape_probe.py section H (Supervisor 2026.08.0):
a parallel list validates the 0.12.0 blob unchanged and renders as its own
translated repeatable field; dicts on pv_entities refuse 0.12.0's stored
strings; a prefix is invisible syntax; a global bool cannot describe a mixed
roster. Status page marks flipped entities and cautions per entity below
-50 W, saying ADD or REMOVE. Telemetry only: control documents unchanged.

Record: DECISIONS/TEL-14-pv-sign.md (reverses TEL-11 section 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:25:24 +02:00
admin 6e6527224a Merge pull request 'SAFETY-61: the control task's death ends the process, after the wind-down' (#121) from safety-61-control-task-death into release/1.0 2026-09-25 00:07:41 +02:00
glenn schrooyenandClaude Opus 5.5 71d2d4a37a SAFETY-61: the control task's death ends the process, after the wind-down
checks / unit (pull_request) Successful in 8m22s
A done-callback on run_control()'s task logs the traceback at the death and
sets `stop`; amain() runs the ordinary wind-down (shutdown() ->
command_site_zero()) and main() exits 70 (EX_SOFTWARE). Normal stops and
run_control()'s refusal path are ignored by the callback (still 0 / 78).

hass.py: a malformed JSON body, a JSON non-object, and an undecodable error
body are an absence / a failed write, not an exception into the loop.

checks() gains a first "control loop" row, red past FIRMWARE_INGEST_VETO_S.

Why exit rather than restart in-process, and what the Supervisor actually
does on exit (restarts only with the per-install Watchdog toggle on, default
off): DECISIONS/SAFETY-61-control-task-death.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:52:42 +02:00
admin fa3d237ce1 Merge pull request 'DOC-1: operator docs name the real P1 entity and a roster that starts' (#119) from doc-1-operator-docs into release/1.0 2026-09-24 23:42:13 +02:00
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
18 changed files with 1252 additions and 76 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>
@@ -0,0 +1,106 @@
# SAFETY-61 — the control task's death ends the process
## 0. The defect
`amain()` started `run_control()` as a bare task and waited on `stop`. Nothing
watched the task. An uncaught exception in `read_inputs()`, `cycle()` or
`publish()` ended the task silently, and `stop` was never set. The web UI, P1
and tariff tasks kept running, and the Supervisor saw a healthy container. The
traceback appeared only at shutdown. The ESP32's layer-1 veto zeroed the
inverter about 30 s later, so the hardware was safe. The pack then sat at 0 W
indefinitely while the add-on showed green.
`test_control_task.py` reproduces this on the pre-fix tree. The child process
reaches its 40 s deadline, has written no 0 W, and has logged nothing.
## 1. Decision: exit with status 70 after the ordinary wind-down
A done-callback on the control task (`control_task_done()`) handles the death.
It logs the traceback at CRITICAL at the moment of death, then sets `stop`.
`amain()` then runs the same wind-down that SIGTERM runs (`shutdown()` →
`command_site_zero()`, then cancel, then clean up). After that it raises
`ControlTaskDied`, and `main()` turns that into `SystemExit(70)` (sysexits
EX_SOFTWARE). A normal stop and `run_control()`'s own refusal path both set
`stop` before the task ends, so the callback ignores them. The refusal still
exits with 78.
**Why not restart the loop in the same process:**
* `cycle()` writes controller state before its awaits: `cycle_at`,
`peak_guard`, `allocation`, then `target`/`reason`, then the writes. An
exception between those steps leaves the controller half-updated. A loop
restarted in the same process would continue from a state that no boot ever
produces. A process restart rebuilds the controller from the Store, which is
the path every boot already runs.
* An unbounded retry is the invisible spin the card forbids. A bounded retry
would reimplement the Supervisor watchdog's own throttle: 10 restarts per
30 min, and 5 attempts with backoff (`apps/const.py`).
## 2. What happens after the exit, verified rather than assumed
* **Exit status reaches Docker.** The add-on image (s6-overlay, `init: false`,
`CMD /run.sh`) was run with `exec python3 -c 'raise SystemExit(70)'`.
`docker inspect` gave ExitCode 70. The same test with exit 0 gave 0, and an
uncaught exception gave 1.
* **Supervisor 2026.08.0** (`ghcr.io/home-assistant/amd64-hassio-supervisor`,
source read from the image):
* A non-zero container exit is recorded as `ContainerState.FAILED`
(`docker/monitor.py`). The Supervisor logs *"App … exited with non-zero
exit code 70"* at ERROR (`apps/app.py container_state_changed`).
* The Supervisor restarts the add-on **only if the per-install Watchdog
toggle is on**. `watchdog_container()` returns when `not self.watchdog`.
The toggle defaults to **off** (`apps/validate.py`:
`vol.Optional(ATTR_WATCHDOG, default=False)`).
* Add-on containers get **no Docker restart policy**. The only
`restart_policy=` in the Supervisor belongs to the observer.
* `boot: auto` in `config.yaml` only controls starting at host boot. The
`watchdog:` key in `config.yaml` is a health-check URL, not a restart
policy, and this add-on does not set it.
* **So on a default install the add-on stays stopped after this exit.** The UI
shows it as stopped, its log holds the traceback, and the Supervisor log holds
an ERROR line. The pack is at 0 W, which is the same physical state as before
this card. The difference is that 0 W is written immediately instead of after
the ~30 s veto, and the add-on no longer shows green. With the Watchdog toggle
on, the Supervisor restarts the add-on.
* **Not verified:** an actual restart by a running Supervisor. That would need a
live Supervisor, and the only one available is the live house.
## 3. Left open for the owner
* Whether `DOCS.md` should tell installers to enable Watchdog. DOCS.md is not
this card's file.
* Setting the Watchdog toggle from inside the add-on (`/addons/self/options`)
was rejected here, because it would change a user setting without the user
knowing.
## 4. Scope: the siblings, and why they are not supervised
* `run_tariff_refresh()` and the P1 HTTP and HA transports already catch
`Exception` inside their loops by design, and the values they feed are
age-bounded downstream.
* The P1 MQTT source *returns* on purpose when it is misconfigured. Making every
long-lived task's end exit the process would turn that deliberate fail-safe
into a restart loop.
* Only the control task is supervised.
## 5. `hass.py`: an undecodable body is an absence
A non-JSON `200` served as `text/html` was **already** treated as an absence,
because aiohttp's `ContentTypeError` is a `ClientError`. The reads that actually
escaped were these three:
* a malformed body labelled `application/json`, which raises `JSONDecodeError`
(a `ValueError`);
* a JSON body that is not an object, which raises `AttributeError` in
`number()` and `limits()`;
* an undecodable error body in `call()`, which raises `UnicodeDecodeError`.
All three were measured against aiohttp 3.13.5. The first two now read as
`None`, and the third is now a failed write (`False`).
## 6. Status row
`checks()` now starts with a *control loop* row showing how long ago the loop
last completed a pass. The row turns red once that age exceeds
`FIRMWARE_INGEST_VETO_S`. The heartbeat is sent from this loop, so past that age
layer 1 has zeroed the inverter.
@@ -38,6 +38,8 @@ the trade [[product-not-one-house]] names.
## 2. The sign convention: **positive is production**, and there is no `pv_invert`
> Reversed by TEL-14 (flag and negative caution): `DECISIONS/TEL-14-pv-sign.md`.
### The convention is derived, not assumed
Two sign rules already ship, and both are recorded in `DOCS.md`'s *Sources*
@@ -0,0 +1,83 @@
# TEL-14 — the PV sign, declared per entity
Owner report on 0.12.0: `sensor.espressif_vermogen` (ESPHome clamp) and
`sensor.kwh_meter_actueel_vermogen` (kWh meter in the PV circuit) read
**positive for consumption, negative for production**. At night the page said
"✓ PV production: 13 W" — standby draw. By day production would publish negative.
## 1. This reverses TEL-11 §2, and why
TEL-11 §2 refused a `pv_invert` on two grounds. "PV is read by nothing in the
control path" still holds: the flip lives in `pv.total()`, and `test_pv.py`
part 12 re-runs part 6's 40-cycle identity with a flip set. "A reversed sensor
announces itself" did not survive a real site: these sensors follow a different
convention, read a plausible small positive number at night, and the only other
fix is a template sensor, the place TEL-12 §2 says goes quietly wrong.
## 2. The shape: a parallel list, `pv_invert_entities`
Measured by `tools/supervisor_shape_probe.py` §H (Supervisor 2026.08.0, against
a 0.12.0 blob); re-run it for the transcript.
| shape | schema | why it lost / won |
|---|---|---|
| (a) `-sensor.x` prefix | accepted | invisible: the sign is a syntax only the description explains, in a field an installer fills with the entity picker's text |
| (b) parallel list | accepted, 0.12.0 blob unchanged | **ships**: its own labelled, translated field ("PV entities whose sign is reversed"); an installer lists the odd ones out |
| (c) dicts on `pv_entities` | **refuses 0.12.0's stored strings** | bricks the upgrade — TEL-12's trap again |
| (c2) dicts as a third key | accepted | per-entry labels render raw (`entity`, `invert`) — probe §F; and a third PV surface |
| (d) global bool | accepted | one sign cannot describe inverter + meter in one roster |
(b)'s known cost is a typo in the second list: `pv.stray_inverts()` turns it into
a named caution. `[]` in `options:` is load-bearing (§H, isolated counterfactual).
Default `[]` flips nothing, so every 0.12.0 install is byte-identical (part 12).
## 3. The negative caution: per entity, below −50 W
The owner's two meters draw ~13 W at night, so a *correctly* signed meter reads
about −13 W then. −50 W clears that with room for a few such devices behind one
entity; a reversed sensor by day reads hundreds to thousands of watts below
zero. It is tested **per entity, not on the total** (a deviation from the brief):
three correct meters at −20 W sum to −60 W and must stay quiet, while a reversed
−1500 W hidden inside a positive total must not. It is a caution, never `bad`;
a wrong threshold costs a spurious or missed caution, never data. The text says
ADD (unflipped) or REMOVE (already flipped).
## 4. Mutations
Fresh `cp -a` each, `__pycache__` purged, CI image, `:ro`, `--network none`;
`test_pv.py` only unless stated. T1-T12 at 7559d21; M2/M5 are review #1628's,
red since B1 (merged into release/1.0 @ 43b2740).
| # | mutation | bytes | reds |
|---|---|---|---|
| T1 | flip ignored in `total()` | −45 | 9 |
| T2 | flip applied to every member | −7 | 6 |
| T3 | `0.0 - w` → `-w` | −5 | 1 |
| T4 | flip written as `abs()` | −1 | 2 |
| T5 | `_read_pv()` stops passing the flip | −26 | 7 |
| T6 | caution threshold −50 → 0 W | −17 | 3 |
| T7 | `[sign inverted]` marker dropped | −39 | 3 |
| T8 | negative caution made `bad` | +1 | 1 |
| T9 | `stray_inverts()` always empty | −50 | 1 |
| T10 | `inverted()` stops stripping | −8 | 1 |
| T11 | ADD/REMOVE advice swapped | +4 | 2 |
| T12 | `pv_invert_entities: []` deleted from `options:` | −25 | 2 test_translations + 17 probe |
| M2 | `stray_inverts()` roster ignores `pv_entity` | +15 | 2 (REPORTED CASE, PADDING) |
| M5 | `_read_pv()` flip set not stripped | +25 | 1 (PADDING) |
## 5. History
OPT-10's `sensor.goodwe_pv_power` recorded since 2026-09-24 on an install with
such sensors carries the old sign; nothing rewrites it.
## 6. CHANGELOG entry for the next PKG cut
Not written into `CHANGELOG.md`: `test_version_gate.py` 11b/11d refuse an
`## Unreleased` heading (measured on this branch: 4 reds). For PKG to paste:
> **`pv_invert_entities` (TEL-14).** A PV sensor that reads negative while
> producing — typically a kWh meter or power clamp in the PV circuit — can now
> be listed under *PV entities whose sign is reversed* in the Configuration tab;
> its sign is flipped before the sum and the status page marks it. Empty (the
> default) changes nothing. ⚠️ PV history recorded before you set it keeps the
> old sign.
+23 -23
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
@@ -141,6 +141,7 @@ either direction, and the check is mutation-tested both ways.
| `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
@@ -165,12 +166,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 +193,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
@@ -452,21 +457,15 @@ 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 why there is no `pv_invert`
#### The sign, and `pv_invert_entities` (TEL-14)
**Positive must mean producing.** That is the same convention as the two sign
rules the add-on already ships — `meter_entity` is positive when **importing**
and `batt_entity` is positive when **discharging**, both of which are "power
arriving at the house" — and production arrives there too.
`meter_invert` and `batt_invert` exist because those two quantities are
**bipolar and are read by the control path**: a reversed battery sensor is a
perfectly plausible number that the per-inverter sign failsafe then acts on.
Production is unipolar and is read by **nothing**, so a reversed sensor here
cannot mislead a decision — and it announces itself immediately as −4 kW at
noon in the very history this entity creates. A flag would only hide it, so
there is not one. If your sensor really is inverted, fix it where it is
produced.
**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
@@ -600,7 +599,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 +754,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 +791,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 +833,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
+11 -3
View File
@@ -61,10 +61,17 @@ class HomeAssistant:
if resp.status == 404:
return None
resp.raise_for_status()
return await resp.json()
except (aiohttp.ClientError, TimeoutError) as err:
obj = await resp.json()
# SAFETY-61: an undecodable body is an absence like any other failed
# read, never an exception into the control loop.
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
self._moan(f"read:{entity_id}", "read %s failed: %s", entity_id, err)
return None
if not isinstance(obj, dict):
self._moan(f"shape:{entity_id}", "read %s: not a state object: %.80r",
entity_id, obj)
return None
return obj
async def number(self, entity_id: str, invert: bool = False) -> float | None:
"""Numeric state, or None. Never substitutes a default."""
@@ -117,7 +124,8 @@ class HomeAssistant:
domain, service, resp.status, body[:200])
return False
return True
except (aiohttp.ClientError, TimeoutError) as err:
# ValueError: an undecodable error body (SAFETY-61) is a failed write.
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
self._moan(f"svcerr:{domain}.{service}", "service %s.%s failed: %s",
domain, service, err)
return False
+92 -6
View File
@@ -25,9 +25,11 @@ battery to zero on its own. Never "hold the last value to be safe".
import asyncio
import contextlib
import functools
import json
import logging
import signal
import time
from datetime import datetime, timedelta, timezone
import aiohttp
@@ -74,8 +76,8 @@ from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
from .store import migrate_maint_keys, migrate_unit_keys
from .validate import (ConfigRefused, refuse, validate_startup_config,
validate_unit_ranges)
from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
validate_startup_config, validate_unit_ranges)
from .watchdog import (
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
SignWatchdog, SignWatchdogConfig, loop_is_following,
@@ -985,6 +987,8 @@ class Controller:
# the value a clamp bit is a value written to ONE device.
self.loop_clamped: tuple[bool, str] = (False, "")
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
# -- helpers -------------------------------------------------------------
def log_event(self, msg: str) -> None:
@@ -1722,7 +1726,7 @@ class Controller:
states = await asyncio.gather(
*(self.hass.state(entity) for entity in roster))
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
list(zip(roster, states)))
list(zip(roster, states)), pv_read.inverted(self.o))
async def read_inputs(self, now_mono: float | None = None) -> None:
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
@@ -4397,6 +4401,7 @@ class Controller:
await self.send_heartbeats(asyncio.get_running_loop().time(),
heartbeat)
self.publish()
self.loop_pass_mono = time.monotonic()
await asyncio.sleep(1)
async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None:
@@ -4596,6 +4601,22 @@ class Controller:
"""
o = self.o
out = []
# SAFETY-61. First, so a stalled loop is the headline: the heartbeat is
# sent from this loop, so past layer 1's veto the inverter is at 0 W.
if self.loop_pass_mono is None:
out.append({"ok": True, "warn": False,
"text": "control loop: starting"})
else:
_pass_age = time.monotonic() - self.loop_pass_mono
if _pass_age > FIRMWARE_INGEST_VETO_S:
out.append({"ok": False, "warn": False,
"text": f"control loop: NO PASS FOR {_pass_age:.0f} s"
f" (layer 1 zeroes the inverter after "
f"{FIRMWARE_INGEST_VETO_S:.0f} s without a "
f"heartbeat)"})
else:
out.append({"ok": True, "warn": False,
"text": f"control loop: last pass {_pass_age:.0f} s ago"})
if self.p1_enabled:
age = self.p1.published_age_s
if self.p1.stale:
@@ -5054,6 +5075,13 @@ class Controller:
# have to say something the single-entity ones never could.
roster = self.pv_roster
if roster:
# TEL-14: each flipped entity is marked where its watts are printed;
# an unflipped one gets "", so 0.12.0's sentences are unchanged.
flip = pv_read.inverted(self.o)
def inv(ent):
return " [sign inverted]" if ent in flip else ""
if len(roster) == 1:
if self.pv_w is None:
out.append({"ok": False, "warn": True,
@@ -5068,7 +5096,7 @@ class Controller:
out.append({"ok": True, "warn": False,
"text": f"PV production: {self.pv_w:.0f} W "
f"from {roster[0]} (reported "
f"in {self.pv_why})"})
f"in {self.pv_why})" + inv(roster[0])})
elif self.pv_w is None:
# ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN
# THOSE WORDS. `pv.total()`'s note is the argument; this is
@@ -5083,7 +5111,7 @@ class Controller:
"text": "PV production: NO SITE TOTAL - "
+ self.pv_why + ". Reporting: "
+ (", ".join(
f"{ent} {watt:.0f} W"
f"{ent} {watt:.0f} W" + inv(ent)
for ent, watt, _y in self.pv_parts
if watt is not None) or "nothing")})
else:
@@ -5096,6 +5124,7 @@ class Controller:
f"{len(self.pv_parts)} entities ("
+ ", ".join(
f"{ent} {watt:.0f} W in {why}"
+ inv(ent)
for ent, watt, why in self.pv_parts)
+ ")"})
# ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
@@ -5115,6 +5144,26 @@ class Controller:
f"{len(roster)} entities in the "
"list are what is summed. Clear "
"`pv_entity` to remove this caution"})
# TEL-14: a sign that looks reversed, per entity rather than on the
# total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3.
for ent, watt, _y in self.pv_parts:
if watt is not None and watt < pv_read.NEGATIVE_W:
out.append({"ok": False, "warn": True,
"text": f"PV production: {ent} reads "
f"{watt:.0f} W - far below zero, so "
f"its sign is probably reversed. If "
f"it is producing now, "
+ ("REMOVE it from "
"`pv_invert_entities`"
if ent in flip else
"ADD it to `pv_invert_entities`")
+ " in the Configuration tab"})
for ent in pv_read.stray_inverts(self.o):
out.append({"ok": False, "warn": True,
"text": f"PV production: `pv_invert_entities` "
f"names {ent}, which is not a PV entity in "
f"use, so it flips nothing - check the "
f"spelling against `pv_entities`"})
return out
def status_payload(self) -> dict:
@@ -5449,6 +5498,30 @@ class Controller:
return None
# SAFETY-61. Exit status when the control task dies; 70 is sysexits(3)
# EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md.
EXIT_CONTROL_DIED = 70
class ControlTaskDied(RuntimeError):
"""The control task ended while nothing had asked it to stop."""
def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
died: list) -> None:
"""Done-callback on the control task: log its death now and wake amain()
to wind down and exit (SAFETY-61, see its DECISIONS record)."""
if stop.is_set() or controller.stopping:
return # the normal wind-down, or run_control()'s refusal path
exc = None if task.cancelled() else task.exception()
_LOG.critical("CONTROL LOOP DIED (%s) - commanding 0 W and exiting with "
"status %d", "cancelled" if task.cancelled() else
(repr(exc) if exc else "returned"), EXIT_CONTROL_DIED,
exc_info=exc)
died.append(exc)
stop.set()
async def amain() -> None:
opts = load_options()
logging.basicConfig(
@@ -5546,7 +5619,13 @@ async def amain() -> None:
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, stop.set)
tasks = [asyncio.create_task(controller.run_control())]
# SAFETY-61: the control task's death ends the process, after the
# wind-down below. See DECISIONS/SAFETY-61-control-task-death.md.
died: list = []
control = asyncio.create_task(controller.run_control())
control.add_done_callback(functools.partial(
control_task_done, controller=controller, stop=stop, died=died))
tasks = [control]
# OPT-04. The tariff refresh runs as its OWN long-lived task, for the
# reason P1 ingestion does and then some: `resolve_tariff()` may open an
@@ -5581,10 +5660,14 @@ async def amain() -> None:
for task in tasks:
task.cancel()
for task in tasks:
if task is control and died:
continue # already logged by control_task_done()
with contextlib.suppress(asyncio.CancelledError):
await task
await runner.cleanup()
_LOG.info("stopped")
if died:
raise ControlTaskDied(died[0])
# The late-arriving-device refusal from run_control(). Re-raised only
# now, so the wind-down above still ran and the inverter is at 0 W.
if controller.refused:
@@ -5603,6 +5686,9 @@ def main() -> None:
# EX_CONFIG, so even a script reading the exit status can tell a bad
# config from a bug.
raise SystemExit(78)
except ControlTaskDied:
# No second traceback: control_task_done() logged it at the death.
raise SystemExit(EXIT_CONTROL_DIED)
if __name__ == "__main__":
+32 -17
View File
@@ -50,21 +50,10 @@ SITE figure, and it publishes as an absence. A partial sum is a second way of
reporting 0-where-there-was-production, one layer up, and it is refused for the
same reason.
THE SIGN CONVENTION: POSITIVE IS PRODUCTION. Derived from the two conventions
this add-on already ships rather than assumed:
`meter_entity` positive = IMPORTING from the grid (DOCS.md, Sources)
`batt_entity` positive = DISCHARGING the pack (DOCS.md, Sources)
Both are "positive = power arriving at the house's AC bus", and production
arrives there too. There is deliberately NO `pv_invert` to go with
`meter_invert` and `batt_invert`: those two quantities are BIPOLAR and are READ
BY THE CONTROL PATH - a reversed `batt_entity` feeds SAFETY-30's per-unit sign
check and SAFETY-31's circulating-power sensor with a plausible number the site
then acts on - whereas production is unipolar and is read by nothing. A reversed
PV sensor announces itself as -4 kW at noon in the very history this card
creates, and flipping it here would only hide it. See
`DECISIONS/TEL-11-pv-production.md` §2.
THE SIGN CONVENTION: POSITIVE IS PRODUCTION, as for `meter_entity` and
`batt_entity` ("power arriving at the house's AC bus"). TEL-14: a kWh meter or
power clamp in the PV circuit reads the other way, so `pv_invert_entities`
flips the sign per entity. See `DECISIONS/TEL-14-pv-sign.md`.
"""
from .hass import BAD_STATES
@@ -193,7 +182,28 @@ def entity_is_ignored(opts: dict) -> bool:
if str(x or "").strip()])
def total(readings) -> tuple:
def inverted(opts: dict) -> frozenset:
"""The entity ids `pv_invert_entities` flips. TEL-14."""
return frozenset(e for e in (str(x or "").strip()
for x in (opts.get("pv_invert_entities") or []))
if e)
def stray_inverts(opts: dict) -> list:
"""`pv_invert_entities` entries naming no entity in the roster - a typo
that would otherwise leave a sign unflipped with nothing said. TEL-14."""
roster = set(entities(opts))
return sorted(e for e in inverted(opts) if e not in roster)
# TEL-14: a reading below this is not standby draw. A correctly signed meter in
# the PV circuit reads a few watts NEGATIVE at night (the owner's: 13 W); -50 W
# clears that with room for a couple of such devices on one entity, while a
# reversed sensor in daylight reads hundreds to thousands of watts below zero.
NEGATIVE_W = -50.0
def total(readings, flip=frozenset()) -> tuple:
"""`(watts, why, parts)` - the SITE's production, or an ABSENCE. TEL-12.
`readings` is `[(entity_id, state), ...]` in `entities()` order, one entry
@@ -237,8 +247,13 @@ def total(readings) -> tuple:
site: shading, a tripped string and nightfall are all real 0s, and a roster
that went to `unknown` whenever one member read zero would report an absence
on every clear evening and defeat the channel from the other end.
⚠️ TEL-14: `flip` is `inverted(opts)`; each member's sign is applied HERE,
before the sum, so every surface reads the corrected figure. `0.0 - w`
rather than `-w`, so a flipped 0 W is `0.0` and never `-0.0` on the wire.
"""
parts = [(ent, *watts(st)) for ent, st in readings]
parts = [(ent, w if w is None or ent not in flip else 0.0 - w, why)
for ent, (w, why) in ((e, watts(st)) for e, st in readings)]
if not parts:
# No roster. `Controller` does not call this in that state - the gate is
# `pv_roster` - but a reader of `parts` should never have to wonder.
+18 -11
View File
@@ -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".
@@ -159,12 +159,8 @@ options:
# So this takes whatever `sensor.*` the site's PV integration provides, any
# brand Home Assistant can read.
#
# ⚠️ POSITIVE IS PRODUCTION, and there is deliberately no `pv_invert` beside
# `meter_invert` and `batt_invert`. Those two quantities are bipolar AND are
# read by the control path, so a reversed sensor there is a plausible number
# the site acts on; production is unipolar and is read by nothing, so a
# reversed sensor announces itself as -4 kW at noon in the history this key
# exists to create. See DECISIONS/TEL-11-pv-production.md.
# POSITIVE IS PRODUCTION. An entity that reads the other way round goes in
# `pv_invert_entities` below - see DECISIONS/TEL-14-pv-sign.md.
#
# ⚠️ THE ENTITY'S OWN `unit_of_measurement` IS WHAT IS BELIEVED - W, kW, MW
# and the rest of Home Assistant's power units. A sensor with no unit, or with
@@ -215,6 +211,12 @@ options:
# which is also empty" - i.e. no PV channel at all, which is every install.
pv_entities: []
# TEL-14. Every PV entity listed here (in `pv_entity` or `pv_entities`) has
# its sign flipped, for a kWh meter or power clamp that reads production as
# negative. Per entity, because one roster can mix both conventions. `[]` is
# load-bearing like `pv_entities: []`; see DECISIONS/TEL-14-pv-sign.md.
pv_invert_entities: []
# ==========================================================================
# METER - where the grid reading comes from
# ==========================================================================
@@ -224,7 +226,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 +918,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
@@ -971,6 +974,10 @@ schema:
# `p1_phase_import_entities` is the shipped precedent for the pair.
pv_entities:
- str
# TEL-14. A parallel list, not a prefix or a dict: measured by
# tools/supervisor_shape_probe.py section H, DECISIONS/TEL-14-pv-sign.md.
pv_invert_entities:
- str
# ==========================================================================
# METER - where the grid reading comes from
+373
View File
@@ -0,0 +1,373 @@
"""Runnable check for SAFETY-61: the control task's death. `python3 test_control_task.py`
If `run_control()` raised, nothing noticed: the task ended, `stop` was never
set, the web UI kept serving and the Supervisor saw a healthy container. The
card's fix and its reasons are in DECISIONS/SAFETY-61-control-task-death.md.
Parts 1-3 drive the REAL `app.main.main()` - the process entry point - in a
child process (this file re-invoked with `--driver`), with only the outside
world faked: options, Home Assistant, the store, the web server and the core
API probe. The child's exit status is the real one. Each child runs under a
deadline, because the defect this card closes is a HANG (MUTATION.md habit 5).
Takes ~15 s. Purge `__pycache__` before a mutation run (MUTATION.md).
"""
import asyncio
import json
import os
import signal
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
FAULT = "injected SAFETY-61 fault"
DEADLINE_S = 40
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# --------------------------------------------------------------------------- #
# The child: the real main() with the outside world faked.
# --------------------------------------------------------------------------- #
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 2000, "gain": 0.6, "slew_w": 0,
"deadband_w": 15, "target_grid_w": -10, "step_w": 10,
"heartbeat_s": 10, "stale_input_s": 15, "cycle_floor_s": 1,
"auto_start": True}
def driver(scenario):
import aiohttp
import app.main as M
import app.store
import app.web
def event(*e):
"""Streamed as it happens, so a child killed at the deadline or by
os._exit() still reports what it did."""
print("EV " + json.dumps(list(e)), flush=True)
t0 = time.monotonic()
class FakeHass:
def __init__(self):
self.batt = 0.0
self.limit_calls = 0
async def limits(self, entity):
self.limit_calls += 1
# "refuse": ESPHome not up at the startup gate, then a rating
# below max_w once run_control() re-reads it -> its refusal path.
if scenario == "refuse":
return (None, None, None) if self.limit_calls == 1 else (-1000.0, 1000.0, 1.0)
return (-5000.0, 5000.0, 1.0)
async def number(self, entity, invert=False):
if entity == "sensor.grid":
# a steady 600 W import, alternating by 1 W so every pass cycles
return 600.0 + (int(time.monotonic() - t0) % 2)
if entity == "sensor.batt":
return self.batt
if entity == "sensor.soc":
return 50.0
return None
async def state(self, entity):
return None
async def set_number(self, entity, value):
event("w", float(value))
self.batt = float(value)
return True
async def call(self, *a, **k):
return True
async def mqtt_service(self):
raise RuntimeError("no broker in this test")
class _Store:
data = {}
def get(self, key, default=None):
return default
def set(self, *a, **k):
pass
class _Runner:
async def cleanup(self):
event("cleanup")
async def _web_start(controller, port=8099, selfcfg=None):
return _Runner()
class _NoProbeSession:
"""The core API probe in amain() must not touch a network."""
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def get(self, *a, **k):
raise OSError("no network in this test")
M.load_options = lambda: dict(BASE)
M.HomeAssistant = lambda session: FakeHass()
app.store.Store = _Store
app.web.start = _web_start
aiohttp.ClientSession = _NoProbeSession
real_cycle = M.Controller.cycle
n = {"cycles": 0}
async def cycle(self):
n["cycles"] += 1
if scenario == "fault" and n["cycles"] == 4:
event("fault")
raise RuntimeError(FAULT)
await real_cycle(self)
if scenario == "clean" and n["cycles"] == 4:
os.kill(os.getpid(), signal.SIGTERM) # the Supervisor's stop
M.Controller.cycle = cycle
M.main()
def run_child(scenario):
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
try:
p = subprocess.run([sys.executable, "-B", __file__, "--driver", scenario],
cwd=HERE, env=env, capture_output=True, text=True,
timeout=DEADLINE_S)
except subprocess.TimeoutExpired as e:
rc, out, err = None, e.stdout or "", e.stderr or ""
else:
rc, out, err = p.returncode, p.stdout, p.stderr
out = out.decode() if isinstance(out, bytes) else out
err = err.decode() if isinstance(err, bytes) else err
events = [json.loads(ln[3:]) for ln in out.splitlines() if ln.startswith("EV ")]
return rc, out, err, events
def tail(text, n=15):
return "\n".join(" | " + ln for ln in text.splitlines()[-n:])
if len(sys.argv) == 3 and sys.argv[1] == "--driver":
driver(sys.argv[2])
sys.exit(0) # unreachable in every scenario: main() exits itself
# --------------------------------------------------------------------------- #
print("1. an exception inside cycle() - the real main(), in a child process")
rc, out, err, ev = run_child("fault")
print(f" exit status {rc}; events {ev}")
check(f"the process ENDS inside {DEADLINE_S} s rather than sitting there "
"healthy (the defect is a hang)", rc is not None)
check("exit status is 70 (EX_SOFTWARE), not 0 and not an unhandled crash's 1",
rc == 70)
fault_at = ev.index(["fault"]) if ["fault"] in ev else None
before = [e[1] for e in ev[:fault_at] if e[0] == "w"] if fault_at is not None else []
after = [e[1] for e in ev[fault_at + 1:] if e[0] == "w"] if fault_at is not None else []
# Precondition (habit 23): the inverter was holding a NON-ZERO command when the
# fault hit, so a 0 W write afterwards is evidence of the wind-down, not rest.
check("precondition: the fault hit while the last command was non-zero "
f"(last before fault = {before[-1] if before else None})",
fault_at is not None and bool(before) and before[-1] != 0.0)
check(f"the wind-down ran: 0 W written AFTER the fault (writes after = {after})",
after == [0.0])
check("the web runner was cleaned up, i.e. the ordinary shutdown path ran",
["cleanup"] in ev[(fault_at or 0):])
died_line = next((i for i, ln in enumerate(err.splitlines())
if "CONTROL LOOP DIED (RuntimeError('" + FAULT + "'))" in ln), None)
wind_line = next((i for i, ln in enumerate(err.splitlines())
if "shutting down - commanding 0 W" in ln), None)
check("a CRITICAL line names the exception and the exit status",
died_line is not None
and "CRITICAL" in err.splitlines()[died_line]
and "commanding 0 W and exiting with status 70" in err.splitlines()[died_line])
check("the traceback is logged, down to the frame that raised",
"Traceback (most recent call last)" in err
and f"RuntimeError: {FAULT}" in err and "in cycle" in err)
check("it is logged AT THE DEATH, before the wind-down - not only at exit",
died_line is not None and wind_line is not None and died_line < wind_line)
check("exactly one traceback: main() does not print a second one",
err.count("Traceback (most recent call last)") == 1)
if fails:
print(tail(err, 30))
# --------------------------------------------------------------------------- #
print("2. the same entry point stopped by SIGTERM - the callback must stay quiet")
rc2, out2, err2, ev2 = run_child("clean")
print(f" exit status {rc2}; events {ev2}")
check("an ordinary stop still exits 0", rc2 == 0)
check("an ordinary stop logs no CONTROL LOOP DIED", "CONTROL LOOP DIED" not in err2)
check("an ordinary stop still winds down to 0 W, from a non-zero command",
any(e[0] == "w" and e[1] != 0.0 for e in ev2)
and [e for e in ev2 if e[0] == "w"][-1:] == [["w", 0.0]])
# --------------------------------------------------------------------------- #
print("3. run_control()'s own refusal path returns on purpose - still exit 78")
rc3, out3, err3, ev3 = run_child("refuse")
print(f" exit status {rc3}")
check("a late-arriving device rating refusal exits 78 (EX_CONFIG), not 70",
rc3 == 78)
check("and is not reported as a death", "CONTROL LOOP DIED" not in err3)
if rc3 != 78:
print(tail(err3))
# --------------------------------------------------------------------------- #
print("4. hass.py: a body that is not a state object is an ABSENCE")
import aiohttp # noqa: E402
from aiohttp import web as aioweb # noqa: E402
import app.hass as H # noqa: E402
async def hass_cases():
async def handler(req):
k = req.match_info["k"]
if k == "sensor.good":
return aioweb.json_response({"entity_id": k, "state": "42"})
if k == "sensor.badjson":
return aioweb.Response(text="{not json", content_type="application/json")
if k == "sensor.list":
return aioweb.Response(text="[1, 2]", content_type="application/json")
if k == "sensor.html":
return aioweb.Response(text="<html>", content_type="text/html")
return aioweb.Response(status=404)
async def svc(req):
return aioweb.Response(body=b"\xff\xfe\xfa", status=400,
headers={"Content-Type": "text/plain; charset=utf-8"})
app_ = aioweb.Application()
app_.router.add_get("/states/{k}", handler)
app_.router.add_post("/services/{d}/{s}", svc)
runner = aioweb.AppRunner(app_)
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
H.CORE_API = f"http://127.0.0.1:{port}"
got = {}
try:
async with aiohttp.ClientSession() as session:
h = H.HomeAssistant(session, token="t")
for e in ("sensor.good", "sensor.badjson", "sensor.list", "sensor.html"):
try:
got[e] = ("ok", await h.number(e))
except Exception as err: # noqa: BLE001
got[e] = ("raised", type(err).__name__)
try:
got["call"] = ("ok", await h.set_number("number.sp", 0))
except Exception as err: # noqa: BLE001
got["call"] = ("raised", type(err).__name__)
finally:
await runner.cleanup()
return got
got = asyncio.run(hass_cases())
print(f" {got}")
check("baseline: a well-formed state reads as its value (42.0)",
got["sensor.good"] == ("ok", 42.0))
check("a malformed JSON body labelled application/json reads as None, not a raise",
got["sensor.badjson"] == ("ok", None))
check("a JSON body that is not an object reads as None, not a raise",
got["sensor.list"] == ("ok", None))
check("a text/html 200 reads as None (already true before SAFETY-61)",
got["sensor.html"] == ("ok", None))
check("a rejected write with an undecodable body is a failed write (False)",
got["call"] == ("ok", False))
# --------------------------------------------------------------------------- #
print("5. the control-loop status row")
from app.main import Controller # noqa: E402
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class _Store:
data = {}
def set(self, *a, **k):
pass
class _RowHass:
async def limits(self, entity):
return (-5000.0, 5000.0, 1.0)
async def number(self, entity, invert=False):
return {"sensor.grid": 600.0, "sensor.soc": 50.0}.get(entity, 0.0)
async def state(self, entity):
return None
async def set_number(self, entity, value):
return True
async def call(self, *a, **k):
return True
def rows_for(pass_age):
c = Controller(dict(BASE), _RowHass(), _Store(), _Pub(), stop=asyncio.Event())
c.loop_pass_mono = None if pass_age is None else time.monotonic() - pass_age
return c.checks()
r_new, r_29, r_31 = rows_for(None), rows_for(29), rows_for(31)
print(f" {r_new[0]} | {r_29[0]} | {r_31[0]}")
check("before the first pass the row is ok and says 'starting'",
r_new[0] == {"ok": True, "warn": False, "text": "control loop: starting"})
check("29 s after a pass it is ok",
r_29[0] == {"ok": True, "warn": False,
"text": "control loop: last pass 29 s ago"})
check("31 s after a pass it is RED and first, naming the age and the veto",
r_31[0] == {"ok": False, "warn": False,
"text": "control loop: NO PASS FOR 31 s (layer 1 zeroes the "
"inverter after 30 s without a heartbeat)"})
async def one_pass():
c = Controller(dict(BASE), _RowHass(), _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
before = getattr(c, "loop_pass_mono", "absent")
t = asyncio.create_task(c.run_control())
await asyncio.sleep(2.5)
c.stopping = True
await asyncio.wait_for(t, timeout=10)
return before, getattr(c, "loop_pass_mono", None)
_before, _after = asyncio.run(one_pass())
print(f" stamp before run: {_before}; age after 2.5 s: "
f"{None if _after is None else round(time.monotonic() - _after, 2)} s")
check("the real run_control() stamps each pass",
_before is None and _after is not None and time.monotonic() - _after < 3)
print()
if fails:
print(f"FAILED: {len(fails)}")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all SAFETY-61 checks passed")
+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")
+215 -6
View File
@@ -56,7 +56,8 @@ absence every clear evening.
Part 8 ⭐⭐ THE SUM, AND WHAT ONE ABSENT MEMBER DOES TO IT.
Part 9 ⭐ publication at a roster of three, as payload bytes.
Part 10 the operator surfaces at three - and 0.11.0's, unchanged, at one.
Part 11 what this file cannot see, with sizes, and both mutation tables.
Part 11 what this file cannot see, with sizes, and the mutation tables.
Part 12 TEL-14: the sign, declared per entity (runs before part 11).
⚠️ MUTATING A MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
@@ -663,7 +664,9 @@ NEEDLES = ("pv_entity", "pv_w", "pv_why", "optional_keys", "ABSENT_AS_UNKNOWN",
# TEL-12's own names. `pv_entities` and `pv_entity` share a prefix so
# the first would have caught it anyway; spelled out because a scope
# that only works by accident stops working when a name is renamed.
"pv_entities", "pv_parts", "pv_roster", "entity_is_ignored")
"pv_entities", "pv_parts", "pv_roster", "entity_is_ignored",
# TEL-14's.
"pv_invert_entities", "stray_inverts", "NEGATIVE_W")
def touches(src: str) -> bool:
@@ -1016,6 +1019,209 @@ check("...and an install with NO PV roster still adds no row of any kind - the "
and run(cycles=1, pv_entities=[" "])[1].state_calls == [])
# ==============================================================================
print()
print("12. TEL-14: the sign, declared PER ENTITY")
# ==============================================================================
# Some installs' PV entities read POSITIVE for consumption and NEGATIVE for
# production (a kWh meter or an ESPHome clamp in the PV circuit). The fixture is
# a MIXED roster, because that is the case a global flag gets wrong:
# E1 an inverter, positive = producing 1200 W -> 1200
# E2 a meter, NEGATIVE = producing, flipped -2.1 kW -> 2100
# E3 a meter at night, correctly signed, NOT flipped: -30 W standby -> -30
# and the values keep every wrong reduction apart:
# right 3270; no flip -930; flip all 930; abs() of every member 3330;
# flip E3 instead of E2 -870.
# ⚠️ EVERY CHECK BELOW READS THROUGH `_at`/`_row` AND NEVER INDEXES BLIND, so a
# missing value is a NAMED FAIL rather than a traceback that hides the rest.
def _at(seq, i):
"""`seq[i]`, or None when there is no such element."""
try:
return seq[i]
except (IndexError, KeyError, TypeError):
return None
def _row(c, needle):
"""Every status-row dict whose text contains `needle` (a list, maybe [])."""
try:
return [r for r in c.checks() if needle in r.get("text", "")]
except Exception as e: # noqa: BLE001 - named, below
return [{"text": f"checks() raised {e!r}", "ok": None, "warn": None}]
MIX = {E1: [state("1200", "W")], E2: [state("-2.1", "kW")],
E3: [state("-30", "W")]}
MIX_TOTAL = 3270.0
_MIX_WRONG = {"no flip": -930.0, "flip all": 930.0, "abs": 3330.0,
"flip E3 not E2": -870.0}
PV_FLIP = dict(PV_MANY, pv_invert_entities=[E2])
check(f"PRECONDITION: the mixed fixture's right answer {MIX_TOTAL:g} W is apart "
f"from every wrong reduction {_MIX_WRONG}",
MIX_TOTAL not in _MIX_WRONG.values()
and len(set(_MIX_WRONG.values())) == len(_MIX_WRONG))
# --- the option ---------------------------------------------------------------
check("inverted() is the stripped, non-empty entries of `pv_invert_entities`, "
"and a 0.12.0 blob (no key at all) flips nothing",
pv.inverted({"pv_invert_entities": [" sensor.a ", "", None, "sensor.b"]})
== frozenset({"sensor.a", "sensor.b"})
and pv.inverted({}) == frozenset()
and pv.inverted({"pv_invert_entities": None}) == frozenset())
# --- the arithmetic -----------------------------------------------------------
_t_mix = pv.total([(e, _at(MIX[e], 0)) for e in (E1, E2, E3)],
pv.inverted(PV_FLIP))
_t_raw = pv.total([(e, _at(MIX[e], 0)) for e in (E1, E2, E3)])
print(f" mixed, E2 flipped: {_t_mix[0]} no flip: {_t_raw[0]}")
check(f"⭐ A MIXED ROSTER: only the listed entity is flipped, before the sum - "
f"{MIX_TOTAL:g} W, not any of {_MIX_WRONG}",
_t_mix[0] == MIX_TOTAL
and [w for _e, w, _y in _t_mix[2]] == [1200.0, 2100.0, -30.0])
check("⭐ DEFAULT IS 0.12.0: with no flip `total()` answers exactly what it "
"always did - the raw sum, the same `why`, the same parts",
_t_raw == (-930.0, "3 entities",
[(E1, 1200.0, "W"), (E2, -2100.0, "kW"), (E3, -30.0, "W")]))
_t_zero = pv.total([(E2, state("0", "W"))], frozenset({E2}))
check("a flipped 0 W is +0.0, never -0.0 - so the wire says `0.0` and the page "
"says `0 W`, not `-0`",
_t_zero[0] == 0.0 and str(_t_zero[0]) == "0.0"
and f"{_t_zero[0]:.0f}" == "0")
_t_gone = pv.total([(E1, state("1200")), (E2, None)], frozenset({E2}))
check("a flipped member that is ABSENT is still an absence of the total - the "
"flip never turns None into a number",
_t_gone[0] is None and _at(_at(_t_gone[2], 1), 1) is None)
check("a single flipped entity keeps 0.11.0's pass-through `why` (the unit)",
pv.total([(E2, state("-13", "W"))], frozenset({E2}))
== (13.0, "W", [(E2, 13.0, "W")]))
# --- the read path, the wire and the page -------------------------------------
_c_mix, _, _p_mix = run(cycles=1, by_entity=MIX, **PV_FLIP)
_f_mix = _at(_p_mix.frames, -1) or {}
print(f" controller pv_w {_c_mix.pv_w} wire {pv_msg(_f_mix)}")
check("⭐ END TO END: the controller reads the option, and the MQTT `pv` row "
"carries the corrected site total",
_c_mix.pv_w == MIX_TOTAL
and pv_msg(_f_mix) == ("goodwe_ctl/pv", "3270.0", False))
_mix_row = [r["text"] for r in _row(_c_mix, "W from 3 entities")]
print(f" {_mix_row}")
check("the status row prints each entity's watts AFTER the sign and marks "
"ONLY the flipped one",
_mix_row == [f"PV production: 3270 W from 3 entities ({E1} 1200 W in W, "
f"{E2} 2100 W in kW [sign inverted], {E3} -30 W in W)"])
check("...and the page's PV row is the corrected total",
("PV", "3 270 W") in page_rows(_c_mix))
_c_hole_f, _, _ = run(cycles=1, by_entity=dict(MIX, **{E1: [None]}), **PV_FLIP)
check("a holed roster's 'Reporting:' list marks the flipped entity too",
[r["text"] for r in _row(_c_hole_f, "Reporting:")]
and f"{E2} 2100 W [sign inverted]" in
(_at(_row(_c_hole_f, "Reporting:"), 0) or {}).get("text", ""))
# The owner's report, at a roster of one: a meter reading +13 W at night.
_OWN = "sensor.pv_meter_power"
_c_own, _, _ = run(cycles=1, by_entity={_OWN: [state("13", "W")]},
pv_entity=_OWN, pv_invert_entities=[_OWN])
_c_own0, _, _ = run(cycles=1, by_entity={_OWN: [state("13", "W")]},
pv_entity=_OWN)
check("⭐ THE REPORTED CASE: 13 W of night-time standby on a reversed meter is "
"-13 W once flipped, the row says so and marks it - and it raises NO "
"negative caution, because -13 W is standby and not a reversed sign - "
"and NO stray caution, because a lone `pv_entity` is in the roster",
_c_own.pv_w == -13.0
and [r["text"] for r in _row(_c_own, "PV production: -13 W")]
== [f"PV production: -13 W from {_OWN} (reported in W) [sign inverted]"]
and _row(_c_own, "probably reversed") == []
and _row(_c_own, "flips nothing") == [])
check("...and WITHOUT the option it is 0.12.0's row, byte for byte",
[r["text"] for r in _row(_c_own0, "PV production:")]
== [f"PV production: 13 W from {_OWN} (reported in W)"])
# Whitespace: the roster entry and the invert entry differ only by padding. Both
# sides must normalise the same way, or the read path leaves the sign unflipped
# while the page marks it, or the page calls a flipped entity a stray.
_c_pad, _, _ = run(cycles=1, by_entity=MIX,
pv_entities=[E1, f" {E2}", E3],
pv_invert_entities=[f"{E2} "])
check("⭐ PADDING: ` sensor.x` in the roster and `sensor.x ` in "
"`pv_invert_entities` are the same entity - flipped on the read path, "
"marked on the page, and no stray caution",
_c_pad.pv_w == MIX_TOTAL
and f"{E2} 2100 W in kW [sign inverted]"
in (_at(_row(_c_pad, "W from 3 entities"), 0) or {}).get("text", "")
and _row(_c_pad, "flips nothing") == [])
# --- the negative caution -------------------------------------------------------
def _neg(watts_by_entity, flip=()):
opts = dict(PV_MANY, pv_invert_entities=list(flip))
c, _, _ = run(cycles=1, by_entity={e: [state(str(w), "W")]
for e, w in watts_by_entity.items()},
**opts)
return _row(c, "probably reversed")
_n_add = _neg({E1: 1200, E2: -1500, E3: 450})
_n_rm = _neg({E1: 1200, E2: 1500, E3: 450}, flip=[E2])
print(f" {[r['text'] for r in _n_add + _n_rm]}")
check("⭐ AN UNFLIPPED ENTITY FAR BELOW ZERO gets a caution that names it, its "
"watts, and says to ADD it to `pv_invert_entities`",
[r["text"] for r in _n_add]
== [f"PV production: {E2} reads -1500 W - far below zero, so its sign "
"is probably reversed. If it is producing now, ADD it to "
"`pv_invert_entities` in the Configuration tab"])
check("...and a FLIPPED one far below zero is told to REMOVE it instead",
len(_n_rm) == 1 and "REMOVE it from `pv_invert_entities`"
in (_at(_n_rm, 0) or {}).get("text", ""))
check("⭐ ...and it is a CAUTION, never a `bad` row - a telemetry sign cannot "
"put a battery site into NOT READY",
bool(_n_add) and all(r.get("ok") is False and r.get("warn") is True
for r in _n_add + _n_rm))
check(f"the threshold is {pv.NEGATIVE_W:g} W per ENTITY: -50 W is standby and "
"stays quiet, -51 W cautions",
pv.NEGATIVE_W == -50.0
and _neg({E1: -50, E2: 0, E3: 0}) == []
and len(_neg({E1: -51, E2: 0, E3: 0})) == 1)
check("⭐ ...PER ENTITY, NOT ON THE TOTAL: three correctly signed meters at "
"-20 W standby each (total -60 W) raise nothing, and a reversed -1500 W "
"hidden in a positive total (+1650 W) is still caught",
_neg({E1: -20, E2: -20, E3: -20}) == []
and len(_neg({E1: 2700, E2: -1500, E3: 450})) == 1)
# --- the stray entry ------------------------------------------------------------
_c_typo, _, _ = run(cycles=1, by_entity=FULL,
**dict(PV_MANY, pv_invert_entities=["sensor.pv_wset"]))
check("a `pv_invert_entities` entry naming no PV entity in use is a named "
"caution (a typo would otherwise leave the sign silently unflipped)",
[(r["text"], r.get("ok"), r.get("warn"))
for r in _row(_c_typo, "flips nothing")]
== [("PV production: `pv_invert_entities` names sensor.pv_wset, which "
"is not a PV entity in use, so it flips nothing - check the "
"spelling against `pv_entities`", False, True)]
and _row(_c_mix, "flips nothing") == [])
# --- default: byte-identical to 0.12.0 ------------------------------------------
_c_d0, _, _p_d0 = run(cycles=1, by_entity=MIX, **PV_MANY)
_c_d1, _, _p_d1 = run(cycles=1, by_entity=MIX,
**dict(PV_MANY, pv_invert_entities=[]))
check("⭐ DEFAULT: `pv_invert_entities: []` and a 0.12.0 blob with no such key "
"give the same rows, the same frame and the raw sum",
[r["text"] for r in _c_d0.checks()] == [r["text"] for r in _c_d1.checks()]
and _at(_p_d0.frames, -1) == _at(_p_d1.frames, -1)
and _c_d0.pv_w == -930.0)
# --- still not a control input --------------------------------------------------
_BIG_FLIP = {E1: [state("1500", "W")], E2: [state("-4.5", "kW")],
E3: [state("2000", "W")]}
_c_bf, _h_bf, _ = run(**_stim, by_entity=_BIG_FLIP,
**dict(PV_MANY, pv_invert_entities=[E2]))
check("⭐ NOT A CONTROL INPUT, WITH A FLIP: 40 cycles against the settled 600 W "
"import write exactly what `unset` writes, and the flip really did move "
"the reading (8000 W, part 6's shift precondition)",
_h_bf.writes == _w_none and _c_bf.target == _c_none.target
and _c_bf.pv_w == BIG_TOTAL)
# ==============================================================================
print()
print("11. what this file CANNOT see, with sizes - and the mutation table")
@@ -1028,10 +1234,9 @@ for line in [
" (mqtt/const.py PAYLOAD_NONE, mqtt/sensor.py's `_message_received`,",
" retrieved 2026-09-24) and quoted in app/mqtt.py. No HA runs in this",
" suite, so what is graded here is the BYTES this add-on publishes.",
"Whether the operator's PV sensor is telling the truth. A sensor wired",
" backwards reads -4 kW at noon; this file asserts that the sign is",
" published unchanged, never that it is right. That is what the history",
" the card creates is FOR - see DECISIONS/TEL-11-pv-production.md §2.",
"Whether the operator's PV sensor is telling the truth. TEL-14 lets the",
" installer declare a reversed one; this file grades the flip and the",
" -50 W caution, never that the declaration is right.",
"The control-path claim OUTSIDE app/. `tools/`, the ESPHome firmware and",
" the ingress page are not scanned by part 6's census.",
"A roster of more than one INVERTER (CFG-04's battery kind). Every",
@@ -1138,6 +1343,10 @@ print(" time - see DECISIONS/TEL-11-pv-production.md §5 for the transcript."
print(" ⚠️ A RED COUNT IS A PROPERTY OF THE SUITE AS MUCH AS OF THE MUTATION:")
print(" these name THIS tree. Re-measure rather than inherit.")
print()
print(" TEL-14: 14 mutations, all red, no survivors - bytes and named reds in")
print(" DECISIONS/TEL-14-pv-sign.md §4.")
print()
if fails:
print(f"{len(fails)} FAILED:")
@@ -683,6 +683,72 @@ except vol.Invalid as e:
str(e))
print("\n== H. TEL-14: the SHAPE of a per-entity PV sign option ==")
# Four candidate shapes, each against a 0.12.0 blob (`pv_entities` set, no new
# key). Transcript and decision: DECISIONS/TEL-14-pv-sign.md.
PV12 = {"pv_entity": "", "pv_entities": ["sensor.pv_inverter", "sensor.pv_meter"]}
_pv12 = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(PV12))
_nk = "pv_invert_entities"
def _ui_of(sch, name):
return [e for e in UiOptions(None)(sch) if e.get("name") == name]
# (a) a `-` prefix on a list item, under the shipped `- str`.
ok_a, out_a = validate(dict(_pv12, pv_entities=["sensor.pv_inverter",
"-sensor.pv_meter"]))
show("(a) PREFIX: `-sensor.pv_meter` validates under the shipped `- str` - "
"Supervisor does not look inside the string, so the sign would live in a "
"syntax that only the description text explains",
ok_a and out_a.get("pv_entities") == ["sensor.pv_inverter",
"-sensor.pv_meter"],
"" if ok_a else out_a)
# (b) a parallel list - THE SHIPPED SHAPE.
ok_b, out_b = validate(_pv12)
show("(b) ⭐ PARALLEL LIST, SHIPPED: the 0.12.0 blob (no `pv_invert_entities` "
"key) validates unchanged and the new key defaults to []",
ok_b and out_b.get(_nk) == []
and out_b.get("pv_entities") == PV12["pv_entities"],
"" if ok_b else out_b)
ok_b2, out_b2 = validate(dict(_pv12, **{_nk: ["sensor.pv_meter"]}))
show("...and one entity marked inverted validates",
ok_b2 and out_b2.get(_nk) == ["sensor.pv_meter"], "" if ok_b2 else out_b2)
ok_b3, out_b3 = validate({k: v for k, v in defaults.items() if k != _nk})
show("⭐ ...and the shipped `options:` block with ONLY `pv_invert_entities` "
"removed is REFUSED BY NAME - its `[]` default is load-bearing, as "
"`pv_entities: []` is",
(not ok_b3) and f"Missing option '{_nk}' in root" in str(out_b3), out_b3)
_ui_b = _ui_of(schema, _nk)
show("...and the Configuration tab renders it as a REPEATABLE string field "
"with its own top-level (translated) label",
len(_ui_b) == 1 and _ui_b[0].get("multiple") is True
and _ui_b[0].get("type") == "string", json.dumps(_ui_b))
# (c) a list of dicts - on `pv_entities` itself, and as a new key.
_s_dicts = dict(schema, pv_entities=[{"entity": "str", "invert": "bool?"}])
ok_c, out_c = validate_with(_s_dicts, _pv12)
show("(c) LIST OF DICTS ON `pv_entities`: the STRINGS a 0.12.0 install stored "
"are REFUSED - that shape would stop the add-on starting on upgrade",
(not ok_c) and "pv_entities" in str(out_c), out_c)
_s_src = dict(schema, pv_sources=[{"entity": "str", "invert": "bool?"}])
ok_c2, out_c2 = validate_with(_s_src, dict(_pv12, pv_sources=[]))
_ui_c = _ui_of(_s_src, "pv_sources")
show("(c2) ...as a THIRD key it validates, but renders as a type-schema "
"repeatable section whose per-entry keys reach the screen RAW "
"(`entity`, `invert`) on this frontend - see section F",
ok_c2 and len(_ui_c) == 1 and _ui_c[0].get("type") == "schema"
and _ui_c[0].get("multiple") is True,
json.dumps(_ui_c)[:200] if ok_c2 else out_c2)
# (d) one global bool.
_s_bool = dict(schema, pv_invert="bool")
ok_d, out_d = validate_with(_s_bool, dict(_pv12, pv_invert=False))
show("(d) GLOBAL `pv_invert: bool` validates too - it loses on SEMANTICS (one "
"sign for a mixed roster), not on the schema", ok_d, "" if ok_d else out_d)
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
# and still succeeds, and a transcript is only evidence if a wrong answer is
# distinguishable from a right one by something other than reading it.
+4 -1
View File
@@ -86,10 +86,13 @@ configuration:
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
pv_entity:
name: "Hardware · PV production entity (W)"
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Point it at your solar inverter's own power sensor (any brand Home Assistant reads; it does not have to be on the RS485 bus) and the add-on records production beside the grid and battery figures, so a day of unstored surplus can be told apart from a day with no sun. POSITIVE MUST MEAN PRODUCING. The unit is read from the entity itself - W, kW and the rest of Home Assistant's power units - so a sensor with no unit is refused rather than guessed at, and the status page says so. Leave it empty and nothing is read, nothing is published and nothing is shown."
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Point it at your solar inverter's own power sensor (any brand Home Assistant reads; it does not have to be on the RS485 bus) and the add-on records production beside the grid and battery figures, so a day of unstored surplus can be told apart from a day with no sun. POSITIVE MUST MEAN PRODUCING - if yours reads negative while producing, list it under 'sign is reversed' below. The unit is read from the entity itself - W, kW and the rest of Home Assistant's power units - so a sensor with no unit is refused rather than guessed at, and the status page says so. Leave it empty and nothing is read, nothing is published and nothing is shown."
pv_entities:
name: "Hardware · PV production entities, one per inverter (W)"
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Use this instead of the single entity above when your roof has SEVERAL solar inverters: add one row per inverter, pointing at each one's own power sensor, and the add-on records the SITE total. Each entity's own unit is read, so a kW inverter beside a W one adds up correctly. ⚠️ SETTING THIS IGNORES THE SINGLE ENTITY ABOVE - clear that one when you fill this in, or you will see a caution on the status page saying so. ⚠️ AND IF ANY ONE OF THESE STOPS REPORTING THERE IS NO SITE TOTAL: the sensor goes to 'unknown' and the history records a gap, rather than publishing the sum of the ones that did answer. Two inverters out of three add up to a smaller number that looks exactly like a real one, and a week later that reads as 'there was less sun than there was' - which is the one conclusion this recording exists to test. The status page names which entity is missing, why, and what the others reported. Leave it empty and nothing is read, nothing is published and nothing is shown."
pv_invert_entities:
name: "Hardware · PV entities whose sign is reversed"
description: "Optional, MEASUREMENT ONLY. Some sensors - typically a kWh meter or a power clamp in the PV circuit - read NEGATIVE while producing and a few watts POSITIVE at night. Add each such entity here, spelled exactly as it is above, and its sign is flipped before it is added up; leave the others out. The status page marks every flipped entity. History recorded before you set this keeps the old sign."
meter_source:
name: "Meter · Where the meter reading comes from"
description: "'off' keeps the single Home Assistant entity below, which is what an existing installation runs on until you choose otherwise. ha_dsmr subscribes to the DSMR integration; mqtt_p1 reads an MQTT topic; ha_signed reads one signed entity, which is what a HomeWizard P1 publishes; homewizard_local polls the meter's own local API and skips Home Assistant entirely. ⚠️ Only homewizard_local can tell 'the meter has not answered' apart from 'the value has not changed', so it is the only source on which the frozen-meter alarm means anything."
+4 -1
View File
@@ -66,10 +66,13 @@ configuration:
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
pv_entity:
name: "Hardware · Zonneproductie-entiteit (W)"
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Verwijs naar de vermogenssensor van je omvormer (elk merk dat Home Assistant kan lezen; hij hoeft niet op de RS485-bus te zitten) en de add-on registreert de productie naast de meter- en batterijwaarden, zodat een dag met niet-opgeslagen overschot te onderscheiden is van een dag zonder zon. POSITIEF MOET PRODUCEREN BETEKENEN. De eenheid wordt van de entiteit zelf gelezen - W, kW en de overige vermogenseenheden van Home Assistant - dus een sensor zonder eenheid wordt geweigerd in plaats van geraden, en de statuspagina zegt waarom. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Verwijs naar de vermogenssensor van je omvormer (elk merk dat Home Assistant kan lezen; hij hoeft niet op de RS485-bus te zitten) en de add-on registreert de productie naast de meter- en batterijwaarden, zodat een dag met niet-opgeslagen overschot te onderscheiden is van een dag zonder zon. POSITIEF MOET PRODUCEREN BETEKENEN - leest de uwe negatief tijdens productie, zet hem dan ook bij 'teken omgekeerd' hieronder. De eenheid wordt van de entiteit zelf gelezen - W, kW en de overige vermogenseenheden van Home Assistant - dus een sensor zonder eenheid wordt geweigerd in plaats van geraden, en de statuspagina zegt waarom. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
pv_entities:
name: "Hardware · Zonneproductie-entiteiten, één per omvormer (W)"
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Gebruik dit in plaats van de losse entiteit hierboven wanneer uw dak MEERDERE zonne-omvormers heeft: voeg één rij per omvormer toe, elk verwijzend naar haar eigen vermogenssensor, en de add-on registreert het totaal van de installatie. De eenheid van elke entiteit wordt afzonderlijk gelezen, dus een omvormer in kW naast een in W telt correct op. ⚠️ ALS U DIT INVULT WORDT DE LOSSE ENTITEIT HIERBOVEN GENEGEERD - maak die leeg, anders verschijnt er een waarschuwing op de statuspagina die dat meldt. ⚠️ EN ALS ÉÉN VAN DEZE ENTITEITEN NIETS MEER DOORGEEFT, IS ER GEEN TOTAAL: de sensor gaat naar 'unknown' en de geschiedenis bewaart een gat, in plaats van de som van de entiteiten die wél antwoordden te publiceren. Twee omvormers van de drie tellen op tot een kleiner getal dat precies op een echt getal lijkt, en een week later leest dat als 'er was minder zon dan er was' - net de conclusie die deze registratie moet kunnen testen. De statuspagina noemt welke entiteit ontbreekt, waarom, en wat de andere doorgaven. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
pv_invert_entities:
name: "Hardware · Zonneproductie-entiteiten met omgekeerd teken"
description: "Optioneel, UITSLUITEND EEN METING. Sommige sensoren - meestal een kWh-meter of een stroomtang in het PV-circuit - lezen NEGATIEF tijdens productie en 's nachts enkele watt POSITIEF. Zet elke zulke entiteit hier, exact gespeld zoals hierboven, en haar teken wordt omgedraaid vóór het optellen; laat de andere weg. De statuspagina markeert elke omgedraaide entiteit. Geschiedenis van vóór deze instelling behoudt het oude teken."
meter_source:
name: "Meter · Waar de meterstand vandaan komt"
description: "'off' houdt de losse Home Assistant-entiteit hieronder aan, waar een bestaande installatie op draait tot u iets anders kiest. ha_dsmr luistert mee met de DSMR-integratie; mqtt_p1 leest een MQTT-topic; ha_signed leest één entiteit met teken, wat een HomeWizard P1 publiceert; homewizard_local bevraagt de lokale API van de meter zelf en slaat Home Assistant helemaal over. ⚠️ Alleen homewizard_local kan 'de meter heeft niet geantwoord' onderscheiden van 'de waarde is niet veranderd', en is dus de enige bron waarop het alarm voor een vastgelopen meter iets betekent."