Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b65a3e69a | ||
|
|
50ccea3dd7 | ||
|
|
2503de124e | ||
|
|
55a6d5dcdb | ||
|
|
fd14569a71 | ||
|
|
59415ea83f | ||
|
|
f37268f3e4 | ||
|
|
397dd15dba | ||
|
|
6028e21298 | ||
|
|
fe132515fe | ||
|
|
00e9a7b4e1 | ||
|
|
82f032e8cf | ||
|
|
b963153de6 | ||
|
|
a2c571b20e | ||
|
|
550bd6fa52 | ||
|
|
6a8a5ffa27 | ||
|
|
8ff42ea85d | ||
|
|
841424463a | ||
|
|
c63eda0b0f | ||
|
|
54cc5263fb | ||
|
|
c493a6d67a | ||
|
|
d0891a6bca | ||
|
|
fe80047235 | ||
|
|
6e3399322a | ||
|
|
16c796fa30 | ||
|
|
20260e86ec | ||
|
|
7fa045af85 | ||
|
|
93f349a6af | ||
|
|
d71f7dbd35 | ||
|
|
43b274035f | ||
|
|
6dbe06e32e | ||
|
|
7559d21eda | ||
|
|
3e38bafcee | ||
|
|
02a822af98 | ||
|
|
6e6527224a | ||
|
|
b06f1fad5f | ||
|
|
71d2d4a37a | ||
|
|
fa3d237ce1 | ||
|
|
f4f72211a9 | ||
|
|
29ca9b5157 |
@@ -0,0 +1,2 @@
|
||||
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
|
||||
76898164b56190f0aa6f7a15be9ce6302c958ce5
|
||||
+33
-13
@@ -286,8 +286,9 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
rm -rf "$WS" && mkdir -p "$WS/goodwe-addon" && cd "$WS/goodwe-addon"
|
||||
git init -q .
|
||||
git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:${TOKEN}@#")"
|
||||
git init -q . && git remote add origin "$URL"
|
||||
# Token as an env-set header: not in argv or .git/config (TOOL-9).
|
||||
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN"
|
||||
if [ -n "$PRNUM" ]; then
|
||||
# Two fetches, not one, so that a failure to reach either ref fails
|
||||
# HERE with the ref in the message, rather than inside the merge
|
||||
@@ -488,6 +489,31 @@ jobs:
|
||||
fi
|
||||
echo "OK: the \`on:\` block of the merged checks.yml carries no event filter."
|
||||
|
||||
# CI-12: the infra repo at the ONE pinned commit in .gitea/infra-pin (read
|
||||
# from the merged tree), as the sibling test_freeze.py looks for. Any
|
||||
# failure here fails the job; the tests step below refuses the stub.
|
||||
- name: check out admin/goodwe at the pin in .gitea/infra-pin
|
||||
env:
|
||||
URL: ${{ github.server_url }}/admin/goodwe.git
|
||||
# Read access to admin/goodwe only; this job's own token cannot see it.
|
||||
TOKEN: ${{ secrets.INFRA_READ_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
# `|| true`: under pipefail a missing or comment-only pin would skip the FAIL line (TOOL-9).
|
||||
PIN=$(grep -v '^#' "$WS/goodwe-addon/.gitea/infra-pin" | tr -d '[:space:]') || true
|
||||
echo "$PIN" | grep -Eq '^[0-9a-f]{40}$' || { echo "FAIL: .gitea/infra-pin holds '$PIN', not a full 40-hex SHA."; exit 1; }
|
||||
test -n "$TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
|
||||
rm -rf "$WS/goodwe" && mkdir -p "$WS/goodwe" && cd "$WS/goodwe"
|
||||
git init -q .
|
||||
# The token goes in a header via env: not in argv, and not in .git/config,
|
||||
# which is mounted into the tests' container and outlives a failed job.
|
||||
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
|
||||
git fetch -q "$URL" "$PIN" || { echo "FAIL: could not fetch admin/goodwe at $PIN."; exit 1; }
|
||||
git -c advice.detachedHead=false checkout -qf FETCH_HEAD
|
||||
test "$(git rev-parse HEAD)" = "$PIN"
|
||||
test -f sim/hwsim.py || { echo "FAIL: admin/goodwe@$PIN has no sim/hwsim.py."; exit 1; }
|
||||
echo "infra: admin/goodwe @ $(git log --oneline -1)"
|
||||
|
||||
- name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile)
|
||||
run: |
|
||||
set -eu
|
||||
@@ -508,16 +534,9 @@ jobs:
|
||||
# shorter timeout - a 60 s limit was tried during CI-01 and reported
|
||||
# exactly the false hang the docstring predicts.
|
||||
#
|
||||
# ⚠️ WHAT THIS RUN DOES NOT COVER, stated because a pass here would
|
||||
# otherwise over-claim. test_freeze.py's third section wants the infra
|
||||
# repo's `sim/hwsim.py` to drive a real HTTP meter through the real
|
||||
# transport. admin/goodwe is PRIVATE (checked via the API) and this
|
||||
# workflow's GITHUB_TOKEN is minted for THIS repository only, so hwsim is
|
||||
# absent here and `find_hwsim()` returns None. The rest of the file runs
|
||||
# and passes. It does so SILENTLY - the file prints nothing to say the
|
||||
# hwsim section was skipped - so "test_freeze.py: PASS" below is a weaker
|
||||
# claim than it looks. That belongs to whoever owns test_freeze.py next;
|
||||
# naming it here is the least this gate can do about it.
|
||||
# test_freeze.py part 3 runs against the real `sim/hwsim.py` from the
|
||||
# checkout above; GOODWE_REQUIRE_HWSIM=1 makes it fail rather than use its
|
||||
# stub (CI-12). Lines a test prints with a `CI: ` prefix are echoed on PASS.
|
||||
# ⚠️ PYTHONDONTWRITEBYTECODE=1 IS BELT AND BRACES, NOT A FIX FOR A LIVE
|
||||
# HOLE. TEST-15 measured this runner's actual exposure and found none: the
|
||||
# checkout below is bind mounted `:ro`, so the interpreter physically
|
||||
@@ -533,7 +552,7 @@ jobs:
|
||||
- name: the add-on's own tests
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
|
||||
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -e GOODWE_REQUIRE_HWSIM=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
|
||||
cd /w/goodwe-addon/goodwe_controller
|
||||
set -- test_*.py
|
||||
if [ "$1" = "test_*.py" ]; then
|
||||
@@ -546,6 +565,7 @@ jobs:
|
||||
start=$(date +%s)
|
||||
if python3 "$f" > /tmp/out 2>&1; then
|
||||
echo "PASS $f ($(( $(date +%s) - start ))s)"
|
||||
grep "^CI: " /tmp/out || true
|
||||
else
|
||||
echo "FAIL $f ($(( $(date +%s) - start ))s)"
|
||||
tail -40 /tmp/out
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# CI-12: goes red when admin/goodwe release/1.0 has moved past .gitea/infra-pin.
|
||||
# It runs on add-on activity only; admin/goodwe's own `addon-pin` workflow
|
||||
# covers a move of release/1.0 there. Deliberately NOT a required check: it
|
||||
# reports; `checks / unit` stays the only gate. How to bump: README.md,
|
||||
# "CI and the infra pin".
|
||||
name: infra-pin
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [release/1.0]
|
||||
|
||||
jobs:
|
||||
fresh:
|
||||
runs-on: [self-hosted, goodwe-rig]
|
||||
timeout-minutes: 30 # seconds of work; the rest is queueing for the shared slot
|
||||
steps:
|
||||
- name: compare .gitea/infra-pin with admin/goodwe release/1.0
|
||||
env:
|
||||
SELF: ${{ github.server_url }}/${{ github.repository }}.git
|
||||
INFRA: ${{ github.server_url }}/admin/goodwe.git
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INFRA_TOKEN: ${{ secrets.INFRA_READ_TOKEN }} # admin/goodwe is private
|
||||
run: |
|
||||
set -eu
|
||||
D=$(mktemp -d) && trap 'cd / && rm -rf "$D"' EXIT && cd "$D"
|
||||
git init -q .
|
||||
# The pin of the commit this event is about (the PR head, or the push).
|
||||
# Token as an env-set header: not in argv or .git/config (TOOL-9).
|
||||
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
|
||||
git fetch -q --depth 1 "$SELF" "$GITHUB_SHA" || { echo "FAIL: could not fetch $GITHUB_SHA here."; exit 1; }
|
||||
# `|| true`: under the runner's pipefail a missing or comment-only pin
|
||||
# would stop here on git's error instead of the FAIL line below (TOOL-9).
|
||||
PIN=$(git show FETCH_HEAD:.gitea/infra-pin | grep -v '^#' | tr -d '[:space:]') || true
|
||||
test -n "$PIN" || { echo "FAIL: .gitea/infra-pin is missing at $GITHUB_SHA, or holds only comments."; exit 1; }
|
||||
test -n "$INFRA_TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
|
||||
# Header via env, as in checks.yml: the token stays out of argv.
|
||||
HEAD=$(GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $INFRA_TOKEN" \
|
||||
git ls-remote "$INFRA" refs/heads/release/1.0 | cut -f1) || true
|
||||
test -n "$HEAD" || { echo "FAIL: could not read admin/goodwe release/1.0."; exit 1; }
|
||||
if [ "$PIN" != "$HEAD" ]; then
|
||||
echo "STALE: .gitea/infra-pin is $PIN but admin/goodwe release/1.0 is $HEAD."
|
||||
echo " Test against $HEAD and bump the pin (README.md, \"CI and the infra pin\")."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: .gitea/infra-pin $PIN is admin/goodwe release/1.0 HEAD."
|
||||
+6
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -50,6 +50,9 @@ https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
|
||||
|
||||
then install **GoodWe RS485 Controller**.
|
||||
|
||||
Then turn on **Watchdog** on the add-on's Info tab — see
|
||||
[Before you start](goodwe_controller/DOCS.md#before-you-start).
|
||||
|
||||
⚠️ **The repository must be PUBLIC for this to work.** Home Assistant clones it
|
||||
anonymously; against a private repo the store shows nothing and gives no useful
|
||||
error. If it must stay private, install locally instead: copy
|
||||
@@ -79,8 +82,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
|
||||
|
||||
@@ -102,6 +105,12 @@ has the mechanism, the measurement, and what the flags do not do. They are not u
|
||||
each assertion corresponds to a rule whose absence produced an observed failure
|
||||
on real hardware, and the comments in `control.py` say which.
|
||||
|
||||
### CI and the infra pin
|
||||
|
||||
CI (`.gitea/workflows/checks.yml`) runs every `test_*.py`, with `admin/goodwe` checked out next to this repo at the commit in `.gitea/infra-pin`, so `test_freeze.py` grades against the real `sim/hwsim.py`. If that checkout fails, the job fails. The local stub is used only in local runs, and it prints `STUB USED` when it is.
|
||||
The pin tracks `admin/goodwe` **release/1.0**, not master. Two non-required checks go red when release/1.0 has moved past the pin: `infra-pin / fresh` here, on any add-on PR or push to release/1.0, and `addon-pin / fresh` in `admin/goodwe`, on every push to its release/1.0. Nothing re-runs `addon-pin` after a bump, so it stays red on that admin/goodwe commit; `infra-pin / fresh` green on the bump is the all-clear.
|
||||
**Bump the pin** whenever either is red. Set `.gitea/infra-pin` to the new release/1.0 SHA (`git ls-remote <gitea>/admin/goodwe.git refs/heads/release/1.0`) in a PR, and let `checks / unit` grade the tests against it. CI reads `admin/goodwe` with the Actions secret `INFRA_READ_TOKEN`.
|
||||
|
||||
## Compatibility
|
||||
|
||||
**Check the serial number, not the model name.** Characters 6-8 of the serial are
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# STYLE.md - how user-facing text is written (UX-08)
|
||||
|
||||
This applies to everything a home owner or installer reads: notifications, phone pushes, the add-on page, refusals, add-on log lines at WARNING and above, and the names and descriptions of options. It is enforced by `goodwe_controller/test_messages.py`. Kinds that have not been rewritten yet are on its allowlist, and that allowlist only shrinks.
|
||||
|
||||
## 1. Shape
|
||||
- **Title**: at most 5 everyday words. It says what is wrong ("Meter reading may be stuck"). No "GoodWe:" prefix, no full stop.
|
||||
- **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost.
|
||||
- **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "<title>" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body.
|
||||
- **Status row**: at most 15 words. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md.
|
||||
- **Log line** (WARNING/ERROR): one line, at most 25 words. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated.
|
||||
- **Sentences**: one idea each, active voice, present tense, with nothing nested in brackets and no semicolons (a semicolon is a second sentence in disguise). These translate well into Dutch and French.
|
||||
|
||||
## 2. Numbers
|
||||
Use a number only if it helps the reader act, for example the peak limit, or what was asked and what was sent in a fault report. Write it the way `app/messages.py` does: `5 000 W`, `20 %`, `10 minutes`, `14:05`.
|
||||
|
||||
## 3. Severity
|
||||
Severity is shown by the title and the colour, never by CAPITALS or by words like "BUG SIGNAL".
|
||||
|
||||
| Level | Colour | When |
|
||||
|---|---|---|
|
||||
| alarm | red | Control stopped, money or energy lost now, a fault, or a person must act now. Up to 3 sentences. |
|
||||
| warning | orange | Control is still running, but something needs a look. 1 sentence. |
|
||||
| info | grey | No action needed. 1 sentence. |
|
||||
| resolved | green | An earlier alarm or warning has cleared. 1 sentence. |
|
||||
|
||||
A rewrite never changes an alarm's severity, `notification_id` or cadence.
|
||||
|
||||
## 4. Words - the same word for the same thing everywhere
|
||||
| Say | Not |
|
||||
|---|---|
|
||||
| battery | pack, BMS pack |
|
||||
| inverter, or its name | unit, device, actuator |
|
||||
| controller | loop, planner |
|
||||
| meter | P1 feed, telegram |
|
||||
| peak limit / this month's peak | ceiling, capaciteitstarief, billed quarter |
|
||||
| 15-minute period | quarter |
|
||||
| battery level | SoC, state of charge |
|
||||
| cut-out level | BMS floor, `bms_soc_floor` |
|
||||
| peak reserve | `peak_soc_floor`, EMS floor |
|
||||
| inverter rating | `max_w`, rail |
|
||||
| control is stopped / every inverter is set to 0 W | failsafe, trip, latch, parked |
|
||||
| software fault | BUG SIGNAL |
|
||||
| control board | master, ESP32 |
|
||||
| shadow mode | a mode that makes every decision but commands 0 W. The name is kept. |
|
||||
|
||||
**Banned in titles and bodies** (the test fails on them): SAFETY-/TARIFF-/TEL-/CFG-/FW-/HW-nn and other card ids, integrator, anti-windup, windup, clamp, clamped, saturated, BUG SIGNAL, latch, latched, arbiter, allocator, register, `0x…`, layer 1/2/3, heartbeat, watchdog, telegram, ACK, CRC, OTA, Modbus, planner, `§n`, file names, IP addresses, option keys in snake_case, **HA entity ids**, and runs of 2 or more CAPITALISED words.
|
||||
|
||||
HA entity ids are allowed only in refusals and validator messages, where the installer has to find that entity. They are never allowed in alarms or status rows.
|
||||
+2
-2
@@ -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 & commissioning guide — v0.1.0</div></div>
|
||||
<div class="sub">Field installation & 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> / 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>
|
||||
|
||||
@@ -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 & commissioning guide — v0.1.0</div></div>
|
||||
<div class="sub">Field installation & 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> / 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,28 @@
|
||||
# SAFETY-40: the integrator bound is not a bug
|
||||
|
||||
**Decision.** `i_clamped` (the integrator reaching `integrator_max_w`, i.e.
|
||||
anti-windup) keeps alarming through the same `ClampWatchdog`: it is counted,
|
||||
notified on the same cadence, and escalates on a sustained run
|
||||
(SAFETY-41). It gets its own text, a `WARNING` and an amber status row. Only
|
||||
`clamped` (a command over `max_w`) and write-path clamps keep the BUG SIGNAL,
|
||||
the `ERROR` and the red row.
|
||||
|
||||
**Why it still notifies.** On a default install `clamped` is unreachable
|
||||
(`control.py`, at the integrator bound), so the integrator bound is the only
|
||||
loop-side signal of a runaway such as the 14 768 W one. Silencing it would
|
||||
remove that. The card says the same: "the fix is to say the true thing, not to
|
||||
say less."
|
||||
|
||||
**Why a bug has its own budget.** A genuine over-rating must not wait up to
|
||||
`REASSERT_S` behind anti-windup notices. `ClampWatchdog` tracks a separate
|
||||
`last_bug_alarm`, and a bug's text is never overwritten by later anti-windup
|
||||
text within an episode. Across episodes the WARNING uses its own
|
||||
notification id (`goodwe_integrator_bound`), so it cannot replace a BUG SIGNAL
|
||||
notice still on screen (review of #130). With only bug engagements, the cadence is the same as
|
||||
before (`test_clamp.py` part 9).
|
||||
|
||||
**Numbers.** They come from the `Decision` of the clamping tick (`i_bound_w`,
|
||||
`requested_w`, `clamped_w`, `target_w`), not from `i_w` or `self.target`.
|
||||
|
||||
**Open for the owner.** Whether scattered one-cycle anti-windup episodes should
|
||||
notify at all, or only log and count. They notify now, as before.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,14 @@
|
||||
# UX-08 phase 2a: short notifications, and what moved out of them
|
||||
|
||||
The owner decided on 2026-09-27: a title of at most about 5 everyday words and a one-sentence body. A red safety alarm may use up to 3 sentences, so no action-relevant information is lost. Titles are neutral, with no "GoodWe:" prefix. HA entity ids appear only in refusals. The rule is `STYLE.md` at the repository root, the texts are in `app/messages.py`, and `test_messages.py` enforces both. The detail each notification used to carry is in DOCS.md under "Notifications", and the body links to it.
|
||||
|
||||
**Unchanged:** every `notification_id`, every trigger and cadence (edges, SAFETY-34 re-announce, per-unit ids), every log line and every status row. The status banner and rows are phase 2b, refusals and option texts 2c, logs 2d; `ux08_allowlist.txt` lists them until then.
|
||||
|
||||
## Where a choice was made
|
||||
1. **Severity of "not delivering" (SAFETY-42).** The notification is an *alarm*, so it may use 3 sentences. Its log line is ERROR and the alarm is latched. The page banner stays amber, as SAFETY-42 decided, because control keeps running; the two answer different questions (is control running / must a person act) and HA shows no colour on a notification. The three checks stay in their original order. Clearing it takes stop then start, and the body says so.
|
||||
2. **Figures left the bodies** of the frozen-meter card (the rail and the command), the circulating-power card (the watts and the time window) and the peak cards (the projection). None of them is needed to act, and all of them are still in the reason text that the log line and the status row carry. `test_freeze.py` and `test_watchdog.py` now pin that. The frozen-meter card can therefore no longer quote a wrong rail (SAFETY-20), because it quotes none.
|
||||
3. **The over-rating alarm keeps its numbers** (asked, sent, and which limit cut it), because they are what a fault report needs. `ClampWatchdog.facts` carries them under the same keep-the-bug rule as `reason`, so the words and the numbers always describe the same tick, with asked before sent (SAFETY-40, `test_clamp.py` part 6).
|
||||
4. **SAFETY-25 stays in the peak-reserve alarm** as its last sentence ("the reserve only limits peak shaving … down to its cut-out level"). The alarm now gives each branch its own action: at the reserve, lower it; battery level unread, check the sensor; floor unknown, set the cut-out level. That last branch is `soc_floor is None`, which happens when an inverter's cut-out level is missing, not the peak reserve (that option always has a value). Before, every branch said "lower peak_soc_floor". The title, "Battery stopped limiting the peak", is true on all three branches.
|
||||
5. **SAFETY-32 at a roster of one.** The body no longer ends in a separate "WHICH PACK" paragraph. The pack's name is part of the first sentence ("The battery of inverter-b …"), and at a roster of one that is the only difference.
|
||||
6. **Dropped as background, now in DOCS:** "charging stays blocked" (peak reserve), "why no other guard fired" (circulating power, not delivering), and the saturation-freeze distinction (over-rating). "The meter cannot see this" stays in the circulating-power card, as the owner asked.
|
||||
7. **The Details link** opens the add-on's documentation page (`/hassio/addon/<slug>/documentation`, with the slug taken from the container hostname) and names the section. A `#heading` anchor was not used because nobody has checked that Home Assistant's documentation tab renders heading ids; if it does, adding one is a one-line change. Outside an add-on container it names the section without a link.
|
||||
+176
-32
@@ -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
|
||||
|
||||
@@ -16,6 +16,11 @@ You need:
|
||||
- A T-CAN485 (ESP32) flashed with `firmware/goodwe-master.yaml`
|
||||
- A grid-power sensor already working in Home Assistant, updating every ~5–10 s
|
||||
|
||||
After installing, turn on **Watchdog** on the add-on's Info tab. A crashed
|
||||
control loop stops the add-on at 0 W, and the Supervisor restarts it only when
|
||||
Watchdog is on (it is off by default). See
|
||||
`DECISIONS/SAFETY-61-control-task-death.md`.
|
||||
|
||||
## The safety model, in short
|
||||
|
||||
The inverter **holds its last command forever** — it has no meter-timeout. So:
|
||||
@@ -141,6 +146,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 +171,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 +198,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 +462,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 +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
|
||||
@@ -1625,7 +1630,9 @@ share no reason code, no notification id, no status row and no counter.
|
||||
running - this is not the sign-disagreement failsafe, which commands 0 W. You
|
||||
get a persistent notification (`goodwe_output_clamp`, replaced rather than
|
||||
stacked), an `ERROR` in the log with the reason code `OUTPUT_CLAMP`, a row in
|
||||
the event list, and a red status banner while it is engaged. Once it clears, a
|
||||
the event list, and a red status banner while it is engaged. The message quotes
|
||||
the command the loop computed and the value it was clamped to, both from the
|
||||
cycle that clamped. Once it clears, a
|
||||
warning row keeps the engagement count so the evidence does not vanish with the
|
||||
condition. Consecutive engagements share one event row carrying the exact count,
|
||||
because the event list keeps only 40 rows and a row per cycle would flush every
|
||||
@@ -1634,9 +1641,11 @@ other guard's entries out of it.
|
||||
**There is nothing to configure**, deliberately. The bound is `max_w`, which you
|
||||
already set; giving the guard a limit of its own would be a second number that
|
||||
can disagree with the first, which is exactly the failure the startup validation
|
||||
exists to prevent. The alarm's cadence - first engagement, then re-asserted every
|
||||
30 s while it persists, cleared after 3 clean cycles - is fixed for the same
|
||||
reason `RAIL_FRAC` is: it describes the notification bus, not the plant.
|
||||
exists to prevent. The alarm's cadence - normally at most one notification per
|
||||
150 s (an episode that has not yet notified does so once it has engaged 3
|
||||
cycles in a row, and an over-rating has its own 150 s budget), cleared after 3
|
||||
clean cycles - is fixed for the same reason `RAIL_FRAC` is: it describes the
|
||||
notification bus, not the plant.
|
||||
|
||||
WARNING: **on a default install the engagement you will see is the integrator's,
|
||||
not the output's.** The control law bounds the integrator to `max_w` and *then*
|
||||
@@ -1645,6 +1654,17 @@ rating unless `integrator_max_w` has been deliberately raised above `max_w`.
|
||||
Both are watched. A guard that watched only the emitted value would be silent on
|
||||
every standard configuration.
|
||||
|
||||
**The integrator bound is not a bug, and it is reported as what it is.** It
|
||||
reads *"the loop's integrator reached its +5000 W anti-windup bound; the loop's
|
||||
command this cycle was +2000 W"*, titled *"the control loop is at its integrator
|
||||
bound"*, logged as a `WARNING`, with an amber (not red) status row and its own
|
||||
notification (`goodwe_integrator_bound`), so it never replaces a BUG SIGNAL
|
||||
notice still on screen. Brief
|
||||
engagements after a large load or solar step are normal. If it stays engaged,
|
||||
check that the battery is following its commands. Only a command over the rating
|
||||
carries the BUG SIGNAL text, and it has its own notification budget.
|
||||
Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md).
|
||||
|
||||
### The frozen-meter alarm
|
||||
|
||||
A meter that has died is easy — it stops answering, the sample age climbs past
|
||||
@@ -2782,6 +2802,126 @@ it.
|
||||
| `estop_fitted` | Whether the RS485 e-stop is installed. Drives the warning banner |
|
||||
| `log_level` | `trace`/`debug`/`info`/`warning`/`error` |
|
||||
|
||||
## Notifications
|
||||
|
||||
Each Home Assistant notification this add-on raises has a short title, one
|
||||
sentence (a red alarm may use three), and a **Details** link to the add-on
|
||||
documentation that names the section below. This section holds what the
|
||||
notification leaves out. The wording rule is `STYLE.md` in the repository; the
|
||||
texts live in `app/messages.py`. Every notification keeps a fixed
|
||||
`notification_id`, so a repeat replaces the card instead of adding one.
|
||||
|
||||
| Title | Severity | id |
|
||||
|---|---|---|
|
||||
| Battery is not keeping up | warning | `goodwe_integrator_bound` |
|
||||
| Power request too high | alarm | `goodwe_output_clamp` |
|
||||
| Load too high for battery | alarm | `goodwe_peak_saturated` |
|
||||
| Battery stopped limiting the peak | alarm | `goodwe_peak_soc_floor` |
|
||||
| Inverters working against each other | alarm | `goodwe_circulating_power` |
|
||||
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` |
|
||||
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` |
|
||||
|
||||
### Battery is not keeping up
|
||||
|
||||
The grid-following loop's integrator reached its anti-windup bound
|
||||
(`integrator_max_w`): the controller has been asking for more than the battery
|
||||
delivers for a while. This is SAFETY-40, and it is **not** a fault. No command
|
||||
went over the rating and control keeps running. Short spells after a big load
|
||||
or solar step are normal. If it keeps coming back, check that the battery
|
||||
follows its commands. The bound and the command of that tick are in the add-on
|
||||
log (WARNING, reason code `OUTPUT_CLAMP`). See *The winding-loop alarm* for the
|
||||
full story.
|
||||
|
||||
### Power request too high
|
||||
|
||||
The controller computed a command above a limit, and the command was cut to that
|
||||
limit before it was written. The notification names what was asked, what was
|
||||
sent, and which limit cut it: the configured inverter rating (`max_w`), the
|
||||
inverter's own limit (the setpoint device's range), or the inverter rating on
|
||||
the loop side. Asked always comes first. This is a **software fault** (the old
|
||||
wording was "BUG SIGNAL"). It is not the inverter failing to follow a legal
|
||||
command, which is the saturation freeze and does not alarm. Control keeps
|
||||
running. Report it with the add-on log, whose ERROR line carries the reason code
|
||||
`OUTPUT_CLAMP` and the full figures. See *The winding-loop alarm*.
|
||||
|
||||
### Load too high for battery
|
||||
|
||||
The capacity-peak guard (SAFETY-07) is asking for everything the inverter can
|
||||
discharge, and the running 15-minute average is still projected over the peak
|
||||
limit. That 15-minute period will set or raise the month's billed peak
|
||||
(capaciteitstarief). The controller is not at fault: the house load is larger
|
||||
than the inverter. Switching off load in the next few minutes is the only thing
|
||||
that still changes the bill. The projection and the watts are in the add-on log
|
||||
and on the status page. See *The capacity-peak guard*.
|
||||
|
||||
### Battery stopped limiting the peak
|
||||
|
||||
Two words in this notification are settings. The **peak reserve** is
|
||||
*Peak guard will not discharge below (%)* (`peak_soc_floor`). The
|
||||
**cut-out level** is *Battery cut-out level (%)* (`bms_soc_floor`), where the
|
||||
battery's own system stops discharging.
|
||||
|
||||
The peak guard wanted to discharge to hold the 15-minute peak, but it may not go
|
||||
below the peak reserve (`peak_soc_floor`, SAFETY-24). It also stops when the
|
||||
battery level cannot be read, or when an inverter's cut-out level is not set,
|
||||
because the reserve is graded against it. So this 15-minute
|
||||
period will go over the peak limit and set or raise the month's billed peak.
|
||||
Charging from the grid stays blocked meanwhile. On a site with several inverters
|
||||
the notification names the battery at its limit (SAFETY-32): each battery is
|
||||
graded against its own cut-out level (`bms_soc_floor`), and raising the other
|
||||
batteries' settings will not change this one.
|
||||
|
||||
⚠️ **The reserve binds the peak guard only (SAFETY-25).** It is not a
|
||||
whole-battery reserve. Ordinary self-consumption has no such floor and goes on
|
||||
discharging below it, down to the battery's own cut-out level. See *What
|
||||
`peak_soc_floor` does not protect*.
|
||||
|
||||
### Inverters working against each other
|
||||
|
||||
Two inverters were pushing power into each other: one discharging while another
|
||||
charged (SAFETY-31). **The meter cannot see this.** The two flows cancel at the
|
||||
P1 meter, so the site reads idle while both batteries cycle through two
|
||||
conversion stages. Every other guard reads that net figure, so none of them
|
||||
fires. The add-on has **stopped control and commands 0 W on every inverter**.
|
||||
If the grid reading does not move after that, one inverter is no longer
|
||||
listening to the add-on; this has happened on this hardware, once for
|
||||
6 h 25 min. Switch the inverters off at the isolator. The alarm is latched: a
|
||||
Home Assistant or add-on restart does not clear it, and the card is raised
|
||||
again every 5 min until you start control on the add-on page. The watts and the time
|
||||
window are in the add-on log and on the status page. See *The circulating-power
|
||||
alarm*.
|
||||
|
||||
### An inverter is not delivering
|
||||
|
||||
One inverter has stood well short of its share of the site target for longer
|
||||
than the hold time (SAFETY-42), while the others have not. No other check sees
|
||||
this. A unit at 0 W is absent rather than pushing the wrong way, so the per-unit
|
||||
sign check and the circulation check stay quiet. **Nothing is commanded
|
||||
differently.** The guard only reports: commanding an inverter that has stopped
|
||||
listening achieves nothing, and the only fallback the add-on has is
|
||||
all-or-nothing. The other inverters keep regulating, and the site is short by up
|
||||
to the missing unit's rating. Check, in this order:
|
||||
|
||||
1. Is the inverter answering on RS485 at all?
|
||||
2. Is its battery colder or emptier than its configured cut-out level
|
||||
(`bms_soc_floor`) says? If so, that setting does not describe this battery.
|
||||
3. Is anything else writing to it?
|
||||
|
||||
The alarm is latched and survives restarts. Control is still running, so to
|
||||
clear it, press *Start / stop control* twice (stop, then start) once you know
|
||||
why. See *A unit that stops DELIVERING is a third fault again*.
|
||||
|
||||
### Meter reading may be stuck
|
||||
|
||||
The meter keeps answering, but its value has not changed for longer than the
|
||||
hold time while the command stands at the power limit (SAFETY-18). The loop is
|
||||
regulating against a number that has stopped moving. The age is still fresh, so
|
||||
no other guard stops it. This is an alarm only: control keeps running and keeps
|
||||
following the reading. Held for a whole 15-minute period, it can set the month's
|
||||
billed peak. Check the meter; if it is stuck, stop control on the add-on page.
|
||||
The limit and command figures are in the add-on log and on the status page. See
|
||||
*The frozen-meter alarm*.
|
||||
|
||||
## The Web UI
|
||||
|
||||
The ingress panel shows live values, why the controller is commanding what it
|
||||
@@ -2820,8 +2960,8 @@ a **copy** of the state file and reports the difference.
|
||||
--live-entities /w/live-entities.txt \
|
||||
--reverse --round-trip'
|
||||
|
||||
Exit `0` clean · `1` at least one finding · `2` could not run. ⚠️ **`2` is not a
|
||||
pass.**
|
||||
Exit `0` clean · `1` at least one finding · `2` could not run · `3` clean but
|
||||
degraded (`--no-live-entities`). ⚠️ **`2` and `3` are not a pass.**
|
||||
|
||||
### The owner steps, because this session could not take them
|
||||
|
||||
@@ -2843,7 +2983,8 @@ check, **before** the roster is declared:
|
||||
|
||||
2. **The options blob**, as `--before`. It is not reachable through the Home
|
||||
Assistant REST API and no entity carries it; export it from the Supervisor,
|
||||
the same way `tools/predeploy_check.py` needs it. Write the roster you intend
|
||||
the same way `tools/predeploy_check.py` needs it: the bare options object,
|
||||
not the `ha apps info` wrapper, which is refused. Write the roster you intend
|
||||
to deploy into a second file and pass it as `--after`.
|
||||
|
||||
3. **The entity list**, as `--live-entities` — one entity id per line:
|
||||
@@ -2857,7 +2998,8 @@ check, **before** the roster is declared:
|
||||
hand back a clean bill of health for a half of the transition it did not
|
||||
check. If you genuinely have to run without it, pass `--no-live-entities`:
|
||||
the run then carries a standing banner in the report header **and** on the
|
||||
exit line saying the orphan check was not graded. The asymmetry used to be
|
||||
exit line saying the orphan check was not graded, and a clean result exits
|
||||
3, not 0. The asymmetry used to be
|
||||
the other way round — an unreadable file refused, a missing flag passed with
|
||||
exit 0 and no disclosure at all — and the owner runs this once, at
|
||||
commissioning, under time pressure.
|
||||
@@ -3005,7 +3147,9 @@ Run it inside the container, so the validator half actually runs:
|
||||
--deployed-version 0.7.0 --options /w/live-options.json
|
||||
|
||||
Export `live-options.json` from the Supervisor first — the options blob is not
|
||||
reachable through the Home Assistant REST API, and no entity carries it.
|
||||
reachable through the Home Assistant REST API, and no entity carries it. Pass the
|
||||
bare object (`ha apps info <slug> --raw-json | jq .data.options`); the wrapped
|
||||
output is refused.
|
||||
|
||||
**Exit codes.** `0` cleared · `1` REFUSED, do not deploy · `2` NOT CLEARED,
|
||||
meaning a check did not run. ⚠️ **2 is not a pass.** On a bare host without the
|
||||
|
||||
@@ -73,6 +73,7 @@ An alarm wired only to this module's `ClampResult.engaged` would therefore be
|
||||
silent on the exact configuration the whole ticket is about. The winding is
|
||||
visible one layer up, at the integrator bound, and that is why `Decision` now
|
||||
carries `i_clamped` / `clamped` BOOLEANS and the caller ORs them in here.
|
||||
They alarm in different words: see `loop_clamp_note()` (SAFETY-40).
|
||||
|
||||
AND WHY BOOLEANS RATHER THAN `Decision.reason`. `reason` is a single string that
|
||||
later stages overwrite: a cycle that clamps the integrator and is then slew
|
||||
@@ -200,9 +201,8 @@ REASON_CODE = "OUTPUT_CLAMP"
|
||||
# ceiling rests on one number a test author chose; if that number is ever
|
||||
# revisited, this constant moves with it.
|
||||
#
|
||||
# ⚠️ THIS IS A STOP-GAP AND DOES NOT MAKE THE ALARM CORRECT. The message still
|
||||
# calls the integrator bound a BUG SIGNAL and still pairs a request from one
|
||||
# moment with a `self.target` read at another - SAFETY-40. Nothing here is
|
||||
# ⚠️ THIS WAS A STOP-GAP; the wording and the numbers were SAFETY-40's, fixed
|
||||
# in `loop_clamp_note()` below. Nothing here is
|
||||
# suppressed: every engagement is still counted, the action-log row is written on
|
||||
# every engaged cycle regardless, and the rising edge of every episode alarms
|
||||
# unconditionally, so no alarm is DROPPED - only delayed.
|
||||
@@ -234,6 +234,31 @@ CLEAR_CYCLES = 3
|
||||
SUSTAINED_CYCLES = 3
|
||||
|
||||
|
||||
BUG_SIGNAL = ("This is a BUG SIGNAL, not a limit being used: something "
|
||||
"upstream computed a setpoint the actuator cannot deliver.")
|
||||
|
||||
|
||||
def loop_clamp_note(d) -> str:
|
||||
"""The loop-side alarm text for one `control.Decision`, or "".
|
||||
|
||||
SAFETY-40: the integrator bound is anti-windup doing its job and says so;
|
||||
only a command over the rating is a BUG SIGNAL. Every number is the
|
||||
decision's own, i.e. from the tick that clamped.
|
||||
"""
|
||||
if d.clamped:
|
||||
return (f"{REASON_CODE}: the loop computed {d.requested_w:+.0f} W, over "
|
||||
f"the {abs(d.clamped_w):.0f} W rating, and it was clamped to "
|
||||
f"{d.clamped_w:+.0f} W; the loop's command this cycle was "
|
||||
f"{d.target_w:+.0f} W. {BUG_SIGNAL}")
|
||||
if d.i_clamped:
|
||||
return (f"{REASON_CODE}: the loop's integrator reached its "
|
||||
f"{d.i_bound_w:+.0f} W anti-windup bound; the loop's command "
|
||||
f"this cycle was {d.target_w:+.0f} W. Brief "
|
||||
f"engagements after a large load or solar step are normal. If it "
|
||||
f"persists, check that the battery is following its commands.")
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClampResult:
|
||||
"""What the guard did, with the RAW value preserved.
|
||||
@@ -337,28 +362,60 @@ class ClampWatchdog:
|
||||
self.since: datetime | None = None
|
||||
self.last_alarm: datetime | None = None
|
||||
self.reason = ""
|
||||
# UX-08: the numbers behind `reason`, for the notification - set under
|
||||
# the same rule as `reason`, so both always describe the same tick.
|
||||
self.facts = ()
|
||||
self.clean_cycles = 0
|
||||
# SAFETY-41. Consecutive engaged cycles, and whether THIS episode has
|
||||
# spoken yet - the two the escalation clause needs.
|
||||
self.run_len = 0
|
||||
self.spoken = False
|
||||
# SAFETY-40. Did this episode include a genuine over-rating (`bug`), and
|
||||
# has THAT spoken? A bug has its own budget, so anti-windup notices can
|
||||
# never delay it; with bug=True throughout, cadence is as before.
|
||||
self.bug = False
|
||||
self.bug_spoken = False
|
||||
self.last_bug_alarm: datetime | None = None
|
||||
|
||||
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
|
||||
def _due(self, last, now) -> bool:
|
||||
return last is None or (now - last).total_seconds() >= REASSERT_S
|
||||
|
||||
def _speak(self, now, bug) -> bool:
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
if bug:
|
||||
self.last_bug_alarm = now
|
||||
self.bug_spoken = True
|
||||
return True
|
||||
|
||||
def note(self, engaged: bool, reason: str, now: datetime,
|
||||
bug: bool = True, facts=()) -> bool:
|
||||
"""Advance one cycle. True when the caller should raise the alarm.
|
||||
|
||||
`now` is the caller's clock, the same discipline as
|
||||
`SignWatchdog.evaluate()` and `FreezeWatchdog.evaluate()`.
|
||||
`SignWatchdog.evaluate()` and `FreezeWatchdog.evaluate()`. `bug` False
|
||||
is the integrator bound (SAFETY-40): counted and notified like the
|
||||
rest, but it never replaces a bug's text within an episode.
|
||||
"""
|
||||
if engaged:
|
||||
self.engagements += 1
|
||||
self.clean_cycles = 0
|
||||
self.reason = reason
|
||||
self.run_len += 1
|
||||
if not self.active:
|
||||
rising = not self.active
|
||||
if rising:
|
||||
self.active = True
|
||||
self.since = now
|
||||
self.episodes += 1
|
||||
self.spoken = False
|
||||
self.bug = False
|
||||
self.bug_spoken = False
|
||||
if bug or not self.bug:
|
||||
self.reason = reason
|
||||
self.facts = tuple(facts)
|
||||
self.bug = self.bug or bug
|
||||
if bug and not self.bug_spoken and self._due(self.last_bug_alarm, now):
|
||||
return self._speak(now, True)
|
||||
if rising:
|
||||
# ⚠️ SAFETY-41. THE RISING EDGE IS RATE-LIMITED TOO, and it did
|
||||
# not used to be. It fired unconditionally, so a condition that
|
||||
# engages and clears repeatedly raised one notification PER
|
||||
@@ -384,11 +441,8 @@ class ClampWatchdog:
|
||||
# still written on every engaged cycle, and a genuine runaway -
|
||||
# which by definition keeps engaging - is picked up by the
|
||||
# re-assert path within REASSERT_S. Delay, not loss.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
if self._due(self.last_alarm, now):
|
||||
return self._speak(now, self.bug)
|
||||
return False
|
||||
# ⚠️ THE ESCALATION. An episode that has not yet spoken because the
|
||||
# budget was spent by an EARLIER, unrelated episode speaks anyway
|
||||
@@ -396,15 +450,10 @@ class ClampWatchdog:
|
||||
# engages every cycle and reaches it; the scattered one-cycle
|
||||
# railing that produced the live flood never does.
|
||||
if not self.spoken and self.run_len >= SUSTAINED_CYCLES:
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return self._speak(now, self.bug)
|
||||
# Still engaged: re-assert on the slow cadence, not every cycle.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
if self._due(self.last_alarm, now):
|
||||
return self._speak(now, self.bug)
|
||||
return False
|
||||
|
||||
self.run_len = 0
|
||||
@@ -420,5 +469,7 @@ class ClampWatchdog:
|
||||
# above. The episode ends; the notification budget does not reset
|
||||
# with it.
|
||||
self.reason = ""
|
||||
self.facts = ()
|
||||
self.bug = False
|
||||
self.clean_cycles = 0
|
||||
return False
|
||||
|
||||
@@ -340,6 +340,12 @@ class Decision:
|
||||
# the comment at the bound below, and clamp.py.
|
||||
i_clamped: bool = False
|
||||
clamped: bool = False
|
||||
# SAFETY-40. The alarm's numbers, taken at the clamp so they are one tick's:
|
||||
# the bound the integrator reached, and the command before/after the
|
||||
# rating clamp. `target_w` is this tick's emitted command.
|
||||
i_bound_w: float = 0.0
|
||||
requested_w: float = 0.0
|
||||
clamped_w: float = 0.0
|
||||
|
||||
|
||||
def slew_limit_w(tuning: Tuning) -> float:
|
||||
@@ -563,6 +569,7 @@ def compute(
|
||||
# winding, "clamped" is a command that came out over the rating anyway.
|
||||
reason = "i-clamped"
|
||||
i_w = bounded
|
||||
i_bound_w = bounded if i_clamped else 0.0
|
||||
want = i_w
|
||||
|
||||
# ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live
|
||||
@@ -574,6 +581,7 @@ def compute(
|
||||
# An early draft applied a floor after the clamp and let demand escape it.
|
||||
target = max(-tuning.max_w, min(tuning.max_w, want))
|
||||
clamped = target != want
|
||||
clamped_w = target
|
||||
if clamped:
|
||||
reason = "clamped"
|
||||
|
||||
@@ -740,7 +748,8 @@ def compute(
|
||||
target = round(target / step) * step
|
||||
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||
i_clamped, clamped)
|
||||
i_clamped, clamped, float(i_bound_w), float(want),
|
||||
float(clamped_w))
|
||||
|
||||
|
||||
def maintenance_charge_floor(
|
||||
|
||||
@@ -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
|
||||
|
||||
+196
-158
@@ -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
|
||||
@@ -47,9 +49,11 @@ from .circulation import (
|
||||
circulating_w, opposing,
|
||||
)
|
||||
from .clamp import (
|
||||
REASON_CODE as CLAMP_REASON, ClampWatchdog, clamp_to_rating, is_commandable,
|
||||
BUG_SIGNAL as CLAMP_BUG_SIGNAL, REASON_CODE as CLAMP_REASON, ClampWatchdog,
|
||||
clamp_to_rating, is_commandable, loop_clamp_note,
|
||||
)
|
||||
from . import freshness
|
||||
from . import messages
|
||||
from .freeze import (
|
||||
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
|
||||
)
|
||||
@@ -74,8 +78,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,
|
||||
@@ -111,6 +115,12 @@ __all__ = ["UNIT_A", "Controller", "amain", "main"]
|
||||
# the re-issue happens by itself) than hold the control loop for ten seconds.
|
||||
WRITE_BUDGET_S = 4.0
|
||||
|
||||
# SAFETY-34: a Core-only restart wipes persistent_notification and does NOT
|
||||
# restart this add-on (measured on TEST-23), so a latched alarm is re-asserted
|
||||
# this often. Why: goodwe docs/DECISIONS.md, SAFETY-34.
|
||||
REANNOUNCE_S = 300.0
|
||||
_mono = time.monotonic # patched by the checks; asyncio keeps the real one
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OPT-04: THE BACKGROUND TARIFF REFRESH, AND WHY IT IS NOT OPTIONAL
|
||||
#
|
||||
@@ -689,6 +699,9 @@ class Controller:
|
||||
# does, so the latch alone is not the alarm - re-raising it is.
|
||||
# `run_control()` does that, once, before the first cycle.
|
||||
self._circ_announced = False
|
||||
# SAFETY-34: when each latched alarm was last put in front of HA, by
|
||||
# notification_id - see REANNOUNCE_S.
|
||||
self._announced_mono = {}
|
||||
if self.circ_wd.tripped:
|
||||
_LOG.error(
|
||||
"SAFETY-31 was TRIPPED before this restart and stays tripped: "
|
||||
@@ -983,8 +996,13 @@ class Controller:
|
||||
# the counterpart to `clamp_last` moving onto the Inverter: the integrator
|
||||
# that wound is the SITE integrator, of which there is exactly one, while
|
||||
# the value a clamp bit is a value written to ONE device.
|
||||
self.loop_clamped: tuple[bool, str] = (False, "")
|
||||
# (engaged, note, over_rating) - SAFETY-40 splits the last one out.
|
||||
self.loop_clamped: tuple[bool, str, bool] = (False, "", False)
|
||||
# UX-08: the numbers of a loop-side over-rating, for the notification.
|
||||
self.loop_clamp_facts: tuple = ()
|
||||
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 +1740,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.
|
||||
@@ -3258,12 +3276,16 @@ class Controller:
|
||||
# it - so a string test is silent on the two cases a winding loop
|
||||
# most often presents as. These flags are set where the clamp
|
||||
# happens and nothing downstream touches them.
|
||||
# SAFETY-40: every number comes from THIS decision, at clamp time,
|
||||
# never from `self.target` after slew/freeze/arbiter rewrote it.
|
||||
self.loop_clamped = (
|
||||
decision.i_clamped or decision.clamped,
|
||||
(f"the loop asked for {decision.i_w:+.0f} W"
|
||||
+ (" (integrator bound)" if decision.i_clamped else "")
|
||||
+ (" (output bound)" if decision.clamped else "")),
|
||||
loop_clamp_note(decision),
|
||||
decision.clamped,
|
||||
)
|
||||
self.loop_clamp_facts = (
|
||||
(messages.Clamp(None, decision.requested_w, decision.clamped_w,
|
||||
"rating"),) if decision.clamped else ())
|
||||
# SITE. compute() regulates `grid_w` - the P1 measurement - to
|
||||
# zero. Its subject is the meter, and §6.2's architecture is ONE
|
||||
# integrator on P1 whose output is then SPLIT. This is the claim the
|
||||
@@ -3286,7 +3308,8 @@ class Controller:
|
||||
# A loop that is not running cannot be winding. Clearing this here
|
||||
# rather than letting the last value stand is what makes the alarm
|
||||
# de-assert when control is stopped.
|
||||
self.loop_clamped = (False, "")
|
||||
self.loop_clamped = (False, "", False)
|
||||
self.loop_clamp_facts = ()
|
||||
|
||||
# SAFETY-28. A new resolution is a new tick, and the previous record is
|
||||
# stale from this line onwards. Incremented BEFORE resolve() so that the
|
||||
@@ -3703,20 +3726,24 @@ class Controller:
|
||||
clamped_units = [inv for inv in self.inverters
|
||||
if inv.clamp_last is not None and inv.clamp_last.engaged]
|
||||
engaged_write = bool(clamped_units)
|
||||
engaged_loop, loop_note = self.loop_clamped
|
||||
engaged_loop, loop_note, loop_bug = self.loop_clamped
|
||||
engaged = engaged_write or engaged_loop
|
||||
if engaged:
|
||||
where = (", ".join(f"{inv.name}: {inv.clamp_last.describe()}"
|
||||
for inv in clamped_units) if engaged_write
|
||||
else f"{CLAMP_REASON}: {loop_note}, above the "
|
||||
f"{self.site_max_w:.0f} W rating, and "
|
||||
f"was clamped to {self.target:+.0f} W")
|
||||
reason = (f"{where}. This is a BUG SIGNAL, not a limit being used: "
|
||||
f"something upstream computed a setpoint the actuator "
|
||||
f"cannot deliver.")
|
||||
# SAFETY-40: only an over-rating is a bug; the integrator bound is not.
|
||||
bug = engaged_write or loop_bug
|
||||
if engaged_write:
|
||||
reason = (", ".join(f"{inv.name}: {inv.clamp_last.describe()}"
|
||||
for inv in clamped_units) + ". " + CLAMP_BUG_SIGNAL)
|
||||
_named = len(self.inverters) > 1
|
||||
facts = [messages.Clamp(inv.name if _named else None,
|
||||
inv.clamp_last.requested_w,
|
||||
inv.clamp_last.applied_w,
|
||||
inv.clamp_last.bound)
|
||||
for inv in clamped_units]
|
||||
else:
|
||||
reason = ""
|
||||
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc))
|
||||
reason = loop_note if engaged else ""
|
||||
facts = self.loop_clamp_facts if engaged else ()
|
||||
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc),
|
||||
bug=bug, facts=facts)
|
||||
|
||||
# --- THE ACTION LOG, AND IT IS COALESCED PER EPISODE ------------------
|
||||
# Every engagement is recorded, but consecutive ones are recorded by
|
||||
@@ -3747,6 +3774,15 @@ class Controller:
|
||||
self.log_event(row)
|
||||
if not fired:
|
||||
return
|
||||
if not self.clamp_wd.bug:
|
||||
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
|
||||
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
|
||||
# it can never replace a BUG SIGNAL notice still on screen.
|
||||
_LOG.warning("SAFETY-03 - %s Engagements so far: %d.",
|
||||
self.clamp_wd.reason, self.clamp_wd.engagements)
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.loop_bound().data())
|
||||
return
|
||||
_LOG.error(
|
||||
"SAFETY-03 - %s Silently clipping this is how a winding integrator "
|
||||
"reached 14 768 W on this project before: the clamp makes every "
|
||||
@@ -3757,16 +3793,10 @@ class Controller:
|
||||
# A fixed notification_id, for the reason check_meter_frozen() gives -
|
||||
# and a DIFFERENT one from every other alarm this add-on raises, because
|
||||
# a clamp is not a frozen meter and neither is a saturation freeze.
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": "goodwe_output_clamp",
|
||||
"title": "GoodWe: a computed setpoint exceeded the rating",
|
||||
"message": (
|
||||
f"{self.clamp_wd.reason}\n\nThe command has been clamped and "
|
||||
f"the controller is STILL RUNNING. This is NOT the inverter "
|
||||
f"failing to follow a legal command (that is the saturation "
|
||||
f"freeze, which is normal and does not alarm) - the value asked "
|
||||
f"for was itself out of range."),
|
||||
})
|
||||
# UX-08: the words are messages.output_clamp()'s; the numbers are the
|
||||
# ones `reason` was written from (ClampWatchdog.facts), asked first.
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.output_clamp(self.clamp_wd.facts).data())
|
||||
|
||||
async def check_peak_saturated(self) -> None:
|
||||
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter.
|
||||
@@ -3808,18 +3838,8 @@ class Controller:
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
||||
# is not a frozen meter and is not an out-of-range setpoint.
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": "goodwe_peak_saturated",
|
||||
"title": "GoodWe: the capacity-peak guard has run out of inverter",
|
||||
"message": (
|
||||
f"{g.reason}.\n\nThe battery is being commanded to everything "
|
||||
f"it can give and the quarter is STILL projected over the "
|
||||
f"{g.ceiling_w:.0f} W ceiling, so this quarter will set or raise "
|
||||
f"the month's billed capaciteitstarief peak. This is not a "
|
||||
f"controller fault - the load is simply larger than the "
|
||||
f"inverter. Shedding load in the next few minutes is the only "
|
||||
f"thing that changes the bill."),
|
||||
})
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.peak_saturated(g.ceiling_w).data())
|
||||
|
||||
async def check_peak_floor_limited(self) -> None:
|
||||
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving.
|
||||
@@ -3884,45 +3904,23 @@ class Controller:
|
||||
# think the pack is AT it. `decide()` guards its own reason string the
|
||||
# same way; a f-string that formats a None here would turn the one
|
||||
# notification that matters into a traceback.
|
||||
_at_floor = ("at the floor" if g.soc_floor is None
|
||||
else f"at {float(g.soc_floor):.0f} %")
|
||||
# ⚠️ SAFETY-32. WHICH PACK STOPPED THE SITE. `g.soc_floor` is the floor
|
||||
# the decision was actually taken with, so this re-grades nothing and
|
||||
# cannot disagree with the alarm it is printed inside. At a roster of one
|
||||
# the sentence is omitted entirely and this notification is byte-for-byte
|
||||
# what SAFETY-25 shipped - there is exactly one pack and naming it would
|
||||
# be noise on every existing install.
|
||||
# no pack is named - there is exactly one.
|
||||
_blocked = [name for name, why
|
||||
in peakguard.pack_permissions(self.packs, g.soc_floor)
|
||||
if why is not None]
|
||||
_which = ("" if len(self.inverters) < 2 else
|
||||
f"\n\nSAFETY-32 - WHICH PACK: this is graded per pack against "
|
||||
f"that pack's own BMS cut-out (bms_soc_floor), and the "
|
||||
f"pack(s) at or below their own floor right now are "
|
||||
f"{', '.join(_blocked) if _blocked else '(none - see the reason above)'}"
|
||||
f". The other packs on the roster are not the reason the guard "
|
||||
f"stopped and raising their cut-out will not change this.")
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": "goodwe_peak_soc_floor",
|
||||
"title": "GoodWe: the capacity-peak guard has run out of battery",
|
||||
"message": (
|
||||
f"{g.reason}.\n\nTHIS QUARTER WILL GO OVER THE "
|
||||
f"{g.ceiling_w:.0f} W ceiling and will set or raise the "
|
||||
f"month's billed capaciteitstarief peak, because the peak guard "
|
||||
f"is not allowed to discharge any further: {g.soc_floor_reason}."
|
||||
f" This is not a controller fault - it is the EMS-side energy "
|
||||
f"floor (peak_soc_floor) doing what it is set to do, and it sits "
|
||||
f"deliberately above the pack's own BMS cut-out. Charging stays "
|
||||
f"blocked. Shedding load is the only thing that changes the bill "
|
||||
f"now; if this pack can afford a deeper discharge, lower "
|
||||
f"peak_soc_floor.\n\nSAFETY-25 - WHAT THIS FLOOR IS NOT: "
|
||||
f"peak_soc_floor bounds THE PEAK GUARD ONLY. It is not a "
|
||||
f"pack-wide reserve, and the battery is NOT being held "
|
||||
f"{_at_floor} - ordinary self-consumption has no SoC "
|
||||
f"floor at all and will go on discharging past it, down to the "
|
||||
f"pack's own BMS cut-out. What has stopped is the guard buying "
|
||||
f"you a lower bill, not the pack being spent." + _which),
|
||||
})
|
||||
# ⚠️ SAFETY-25's sentence (the reserve binds the peak guard only) is
|
||||
# part of messages.peak_reserve() on every branch, the unreadable-SoC
|
||||
# one included.
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.peak_reserve(
|
||||
g.ceiling_w, soc_unread=g.soc is None,
|
||||
floor_unknown=(g.soc is not None
|
||||
and g.soc_floor is None),
|
||||
packs=(_blocked if len(self.inverters) > 1
|
||||
else ())).data())
|
||||
|
||||
async def check_circulating(self) -> None:
|
||||
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart.
|
||||
@@ -3939,15 +3937,17 @@ class Controller:
|
||||
The latch says "this site is tripped"; the flag says "this process has
|
||||
put that in front of a human". A restart clears the flag and keeps the
|
||||
latch, so the notification is RE-RAISED on the first cycle after every
|
||||
add-on start - which is also the first cycle after a Home Assistant
|
||||
restart, because the add-on is restarted with it.
|
||||
add-on start. A Core-only restart does NOT restart the add-on, so it is
|
||||
also re-asserted every REANNOUNCE_S (SAFETY-34).
|
||||
|
||||
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE. A fresh trip and a restart into
|
||||
an existing trip raise the alarm through these same lines. Two paths
|
||||
would be two chances for the restart one to rot unnoticed, and the
|
||||
restart one is the case nobody exercises by hand.
|
||||
"""
|
||||
if not self.circ_wd.tripped or self._circ_announced:
|
||||
if not self.circ_wd.tripped:
|
||||
return
|
||||
if self._circ_announced and not self._reannounce_due("goodwe_circulating_power"):
|
||||
return
|
||||
# Set BEFORE the await. `hass.call()` can raise or hang, and a retry
|
||||
# every cycle for a broker that is down would be an alarm storm on the
|
||||
@@ -3955,39 +3955,22 @@ class Controller:
|
||||
# already durable and the log line above already fired; this surface is
|
||||
# best-effort by design.
|
||||
self._circ_announced = True
|
||||
self._announced_mono["goodwe_circulating_power"] = _mono()
|
||||
names = self.units
|
||||
pos, neg = opposing([inv.batt for inv in self.inverters],
|
||||
min_w=self.circ_wd.cfg.min_w / 2.0)
|
||||
who = ""
|
||||
if pos and neg:
|
||||
who = ("\n\nRight now " + ", ".join(names[i] for i in pos)
|
||||
+ " is discharging while " + ", ".join(names[i] for i in neg)
|
||||
+ " charges.")
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# this add-on raises - check_meter_frozen() gives the reasoning. A
|
||||
# circulating loop is not a frozen meter, is not an out-of-range
|
||||
# setpoint and is not a saturated peak guard.
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": "goodwe_circulating_power",
|
||||
"title": "GoodWe: the inverters are pushing against each other",
|
||||
"message": (
|
||||
f"{self.circ_wd.reason}{who}\n\nTHE METER CANNOT SEE THIS. "
|
||||
f"Circulating power nets to zero at P1, so the site reads idle "
|
||||
f"while the packs are cycled through two conversion stages "
|
||||
f"continuously. Every other guard in this add-on is a consumer "
|
||||
f"of that net figure, which is why none of them fired.\n\n"
|
||||
f"CONTROL HAS BEEN STOPPED - every inverter is commanded 0 W "
|
||||
f"and held there. That removes this controller as one of the "
|
||||
f"two participants, and it makes anything still happening "
|
||||
f"VISIBLE at the meter instead of cancelled at it.\n\n"
|
||||
f"⚠️ IF THE GRID READING DOES NOT MOVE AFTER THIS, one inverter "
|
||||
f"is no longer listening to this add-on and no command will "
|
||||
f"reach it - that has happened twice on this hardware, once for "
|
||||
f"6 h 25 m. The remaining actuator is a human at the isolator.\n"
|
||||
f"\nThis alarm survives a Home Assistant restart. Restarting "
|
||||
f"will not clear it; start control from the add-on UI once you "
|
||||
f"know why two inverters were opposing."),
|
||||
})
|
||||
# this add-on raises - check_meter_frozen() gives the reasoning.
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.circulating(
|
||||
[names[i] for i in pos] if pos and neg else (),
|
||||
[names[i] for i in neg] if pos and neg else (),
|
||||
).data())
|
||||
|
||||
def _reannounce_due(self, notification_id: str) -> bool:
|
||||
"""SAFETY-34: has this latched alarm gone REANNOUNCE_S without being re-asserted?"""
|
||||
t = self._announced_mono.get(notification_id)
|
||||
return t is None or _mono() - t >= REANNOUNCE_S
|
||||
|
||||
async def check_not_delivering(self) -> None:
|
||||
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart.
|
||||
@@ -4008,8 +3991,8 @@ class Controller:
|
||||
not persisted. The latch says "this unit is tripped"; membership says
|
||||
"this process has put that in front of a human". A restart empties the
|
||||
set and keeps the latch, so the notification is RE-RAISED on the first
|
||||
cycle after every add-on start - which is also the first cycle after a
|
||||
Home Assistant restart, because the add-on is restarted with it.
|
||||
cycle after every add-on start, and re-asserted every REANNOUNCE_S
|
||||
because a Core-only restart does not restart the add-on (SAFETY-34).
|
||||
|
||||
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE - `check_circulating()`'s
|
||||
argument. A fresh trip and a restart into an existing trip raise the
|
||||
@@ -4027,7 +4010,10 @@ class Controller:
|
||||
names = self.units
|
||||
slugs = unit_slugs(names)
|
||||
for inv, slug in zip(self.inverters, slugs):
|
||||
if not inv.delivery_wd.tripped or inv.name in self._delivery_announced:
|
||||
nid = f"goodwe_not_delivering_{slug}"
|
||||
if not inv.delivery_wd.tripped:
|
||||
continue
|
||||
if inv.name in self._delivery_announced and not self._reannounce_due(nid):
|
||||
continue
|
||||
# Added BEFORE the await, for `check_circulating()`'s reason:
|
||||
# `hass.call()` can raise or hang, and a retry every cycle for a
|
||||
@@ -4035,38 +4021,9 @@ class Controller:
|
||||
# latch is already durable and the log line already fired; this
|
||||
# surface is best-effort by design.
|
||||
self._delivery_announced.add(inv.name)
|
||||
peers = ", ".join(n for n in names if n != inv.name) or "no sibling"
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": f"goodwe_not_delivering_{slug}",
|
||||
"title": f"GoodWe: {inv.name} is not delivering its share",
|
||||
"message": (
|
||||
f"{inv.delivery_wd.reason}\n\nWHY NOTHING ELSE SAID SO. A "
|
||||
f"unit sitting at 0 W against a non-zero share is not "
|
||||
f"pushing the wrong way, it is ABSENT: the per-unit sign "
|
||||
f"check needs opposition on both sides of "
|
||||
f"{self.unit_sign_cfg.min_w:.0f} W and never opens, the "
|
||||
f"site sum still agrees with the meter in "
|
||||
f"direction, and circulating power is exactly zero when one "
|
||||
f"unit does nothing. The only other trace is a saturation "
|
||||
f"freeze, which this add-on's own code calls an alarm "
|
||||
f"operators learn to ignore.\n\nNOTHING HAS BEEN COMMANDED "
|
||||
f"DIFFERENTLY. This guard reports and does not act. "
|
||||
f"Commanding an inverter that has stopped listening "
|
||||
f"achieves nothing - that has happened on this hardware, "
|
||||
f"once for 6 h 25 m - and the only re-allocation this "
|
||||
f"add-on can express is all-or-nothing, which would throw "
|
||||
f"away the output of a unit that is delivering PART of its "
|
||||
f"share. So {peers} is still regulating and the site is "
|
||||
f"short by up to {inv.rating_w():.0f} W until somebody "
|
||||
f"looks at {inv.name}.\n\nWHAT TO CHECK, in order: is "
|
||||
f"{inv.name} answering on RS485 at all; is its pack colder "
|
||||
f"or emptier than its configured bms_soc_floor says (if it "
|
||||
f"is, that setting does not describe this battery and this "
|
||||
f"alarm is telling you so); and is anything else writing to "
|
||||
f"it.\n\nThis alarm survives a Home Assistant restart. "
|
||||
f"Restarting will not clear it; start control from the "
|
||||
f"add-on UI once you know why."),
|
||||
})
|
||||
self._announced_mono[nid] = _mono()
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.not_delivering(inv.name, slug).data())
|
||||
|
||||
async def check_meter_frozen(self) -> None:
|
||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||
@@ -4104,14 +4061,8 @@ class Controller:
|
||||
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
|
||||
# rather than stacking. An operator who has to dismiss eleven identical
|
||||
# notifications learns to dismiss the twelfth without reading it.
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": "goodwe_meter_frozen",
|
||||
"title": "GoodWe: the P1 meter may be frozen",
|
||||
"message": (
|
||||
f"{self.freeze_wd.reason}.\n\nThe controller is STILL RUNNING "
|
||||
f"and still following this reading. Check the meter; if it is "
|
||||
f"stuck, stop control from the add-on UI."),
|
||||
})
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.meter_frozen(unchanged).data())
|
||||
|
||||
# -- tasks ---------------------------------------------------------------
|
||||
# -- OPT-04: the background tariff refresh -------------------------------
|
||||
@@ -4397,6 +4348,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 +4548,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:
|
||||
@@ -4851,16 +4819,21 @@ class Controller:
|
||||
out.append({"ok": False, "warn": "NOT COMMANDABLE" not in note,
|
||||
"text": note})
|
||||
|
||||
# SAFETY-03. Red while a clamp is engaged, and the engagement COUNT is
|
||||
# shown even after it clears - a loop that wound once and stopped is
|
||||
# still a bug, and a row that vanishes with the condition takes the
|
||||
# SAFETY-03. Red while an over-rating clamp is engaged (amber for the
|
||||
# integrator bound, SAFETY-40), and the engagement COUNT is shown even
|
||||
# after it clears - a row that vanishes with the condition takes the
|
||||
# evidence with it. Reported only when there is something to report:
|
||||
# unlike the freeze detector this guard has no "not covered" state, it
|
||||
# runs on every write on every transport.
|
||||
if self.clamp_wd.active:
|
||||
if self.clamp_wd.active and self.clamp_wd.bug:
|
||||
out.append({"ok": False, "warn": False,
|
||||
"text": "OUTPUT CLAMP ENGAGED - " + self.clamp_wd.reason
|
||||
+ f" Engagements: {self.clamp_wd.engagements}."})
|
||||
elif self.clamp_wd.active:
|
||||
# SAFETY-40: the integrator bound alone is amber, not red.
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": "LOOP AT INTEGRATOR BOUND - " + self.clamp_wd.reason
|
||||
+ f" Engagements: {self.clamp_wd.engagements}."})
|
||||
elif self.clamp_wd.engagements:
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": f"output clamp: not engaged now, but it has "
|
||||
@@ -5054,6 +5027,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 +5048,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 +5063,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 +5076,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 +5096,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 +5450,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 +5571,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 +5612,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 +5638,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__":
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Every Home Assistant notification this add-on raises, in plain words (UX-08).
|
||||
|
||||
The rule is `STYLE.md` at the repository root; `test_messages.py` enforces it.
|
||||
Titles, bodies and severities live here and nowhere else, so `main.py` only
|
||||
decides WHEN to raise a notice and this file decides WHAT it says. The detail
|
||||
that used to fill the bodies is in DOCS.md, under "Notifications", one section
|
||||
per title; the body ends with a link to it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Severity (STYLE.md section 3). An ALARM may use up to three sentences so
|
||||
# that no action a person must take is lost; everything else gets one.
|
||||
ALARM, WARNING, INFO, RESOLVED = "alarm", "warning", "info", "resolved"
|
||||
|
||||
DOCS_SECTION = "Notifications"
|
||||
|
||||
|
||||
def watts(value) -> str:
|
||||
"""`7412` -> `7 412 W`: a magnitude, grouped for reading, no sign."""
|
||||
return f"{abs(float(value)):,.0f} W".replace(",", " ")
|
||||
|
||||
|
||||
def duration(seconds) -> str:
|
||||
s = float(seconds)
|
||||
if s < 120:
|
||||
return f"{s:.0f} seconds"
|
||||
return f"{s / 60:.0f} minutes"
|
||||
|
||||
|
||||
def names(items) -> str:
|
||||
items = list(items)
|
||||
if len(items) <= 1:
|
||||
return "".join(items)
|
||||
return ", ".join(items[:-1]) + " and " + items[-1]
|
||||
|
||||
|
||||
def addon_slug() -> str | None:
|
||||
"""This add-on's Supervisor slug, from the container hostname.
|
||||
|
||||
The Supervisor names an add-on's host after its slug with `_` spelled `-`
|
||||
(`local_goodwe_controller` -> `local-goodwe-controller`). Anything else,
|
||||
e.g. a test run, gets no link rather than a wrong one.
|
||||
"""
|
||||
host = os.environ.get("HOSTNAME", "")
|
||||
if re.fullmatch(r"[a-z0-9]+-goodwe-controller", host):
|
||||
return host.replace("-", "_")
|
||||
return None
|
||||
|
||||
|
||||
def details(title: str) -> str:
|
||||
where = f"section “{title}” under {DOCS_SECTION}"
|
||||
slug = addon_slug()
|
||||
if slug is None:
|
||||
return f"Details: add-on documentation, {where}."
|
||||
return (f"Details: [add-on documentation](/hassio/addon/{slug}/documentation),"
|
||||
f" {where}.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Notice:
|
||||
id: str
|
||||
severity: str
|
||||
title: str
|
||||
body: str
|
||||
section: str = "" # the DOCS.md heading, when it is not the title itself
|
||||
|
||||
@property
|
||||
def docs(self) -> str:
|
||||
return self.section or self.title
|
||||
|
||||
def data(self) -> dict:
|
||||
"""The `persistent_notification.create` payload."""
|
||||
return {"notification_id": self.id, "title": self.title,
|
||||
"message": f"{self.body}\n\n{details(self.docs)}"}
|
||||
|
||||
|
||||
# --- the catalogue ----------------------------------------------------------
|
||||
# Each title below is also a DOCS.md heading (test_messages.py checks it).
|
||||
|
||||
T_LOOP_BOUND = "Battery is not keeping up"
|
||||
T_OUTPUT_CLAMP = "Power request too high"
|
||||
T_PEAK_SATURATED = "Load too high for battery"
|
||||
T_PEAK_RESERVE = "Battery stopped limiting the peak"
|
||||
T_CIRCULATING = "Inverters working against each other"
|
||||
T_NOT_DELIVERING = "{unit} is not delivering"
|
||||
S_NOT_DELIVERING = "An inverter is not delivering"
|
||||
T_METER_FROZEN = "Meter reading may be stuck"
|
||||
|
||||
ID_LOOP_BOUND = "goodwe_integrator_bound"
|
||||
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
|
||||
ID_PEAK_SATURATED = "goodwe_peak_saturated"
|
||||
ID_PEAK_RESERVE = "goodwe_peak_soc_floor"
|
||||
ID_CIRCULATING = "goodwe_circulating_power"
|
||||
ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
|
||||
ID_METER_FROZEN = "goodwe_meter_frozen"
|
||||
|
||||
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
|
||||
LIMIT_WORDS = {
|
||||
"max_w": "the configured inverter rating",
|
||||
"device": "the inverter's own limit",
|
||||
"max_w+device": "the configured inverter rating and the inverter's own limit",
|
||||
"rating": "the inverter rating",
|
||||
}
|
||||
|
||||
|
||||
def loop_bound() -> Notice:
|
||||
"""SAFETY-40: the integrator bound. Normal in short bursts; not a fault."""
|
||||
return Notice(ID_LOOP_BOUND, WARNING, T_LOOP_BOUND,
|
||||
"The battery cannot give what the controller asks, which is "
|
||||
"normal for a moment but needs a check if it keeps happening.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Clamp:
|
||||
"""One command that was cut: what was asked, what was sent, and by what."""
|
||||
unit: str | None
|
||||
asked_w: float
|
||||
sent_w: float
|
||||
limit: str # a key of LIMIT_WORDS
|
||||
|
||||
|
||||
def output_clamp(clamps) -> Notice:
|
||||
"""SAFETY-03: a computed command above the limit. A software fault.
|
||||
|
||||
Asked comes before sent, always - reading them the other way round turns
|
||||
the alarm backwards (test_clamp.py part 6).
|
||||
"""
|
||||
if not clamps:
|
||||
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
|
||||
"The controller asked for more power than the inverter "
|
||||
"rating and was cut back. Control is still running. This "
|
||||
"is a software fault, so please report it to your "
|
||||
"installer.")
|
||||
c = clamps[0]
|
||||
who = f"For {c.unit}, the" if c.unit else "The"
|
||||
others = [x.unit for x in clamps[1:] if x.unit]
|
||||
running = (f"{names(others)} {'was' if len(others) == 1 else 'were'} cut "
|
||||
f"too, and control is still running." if others
|
||||
else "Control is still running.")
|
||||
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
|
||||
f"{who} controller asked for {watts(c.asked_w)}, above "
|
||||
f"{LIMIT_WORDS.get(c.limit, LIMIT_WORDS['rating'])}, and sent "
|
||||
f"{watts(c.sent_w)} instead. {running} "
|
||||
f"This is a software fault, so please report it to your "
|
||||
f"installer.")
|
||||
|
||||
|
||||
def peak_saturated(ceiling_w) -> Notice:
|
||||
"""SAFETY-07: the battery gives all it can and the peak still goes up."""
|
||||
return Notice(ID_PEAK_SATURATED, ALARM, T_PEAK_SATURATED,
|
||||
f"The battery is giving all it can, but this 15-minute period "
|
||||
f"will still go over your {watts(ceiling_w)} peak limit and "
|
||||
f"raise this month's peak. Switch off large loads in the next "
|
||||
f"few minutes. This is not a fault: the load is bigger than "
|
||||
f"the inverter.")
|
||||
|
||||
|
||||
def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
packs=()) -> Notice:
|
||||
"""SAFETY-24/25/32: the peak guard may not discharge any further.
|
||||
|
||||
The last sentence is SAFETY-25 and must stay: the reserve binds the peak
|
||||
guard only, and normal home use goes on discharging below it.
|
||||
`packs` names the batteries at their limit, only on a site with several.
|
||||
`floor_unknown`: the floor the guard grades against is None, which at a
|
||||
roster means some inverter's cut-out level is missing (the peak reserve
|
||||
itself always has a value) - so the action names the cut-out level.
|
||||
"""
|
||||
if soc_unread:
|
||||
why = "The battery level cannot be read"
|
||||
act = "Switch off large loads, and check the battery level sensor."
|
||||
elif floor_unknown:
|
||||
why = "The cut-out level of a battery is not known"
|
||||
act = ("Switch off large loads, and set each battery's cut-out level "
|
||||
"in the settings.")
|
||||
else:
|
||||
why = (f"The battery of {names(packs)} is at its peak reserve" if packs
|
||||
else "The battery is at its peak reserve")
|
||||
act = ("Switch off large loads, or lower the peak reserve if the "
|
||||
"battery allows it.")
|
||||
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
|
||||
f"{why}, so this 15-minute period will go over your "
|
||||
f"{watts(ceiling_w)} peak limit and raise this month's peak. "
|
||||
f"{act} The reserve only applies to keeping the peak down: normal home use "
|
||||
f"can still discharge the battery below it, down to its "
|
||||
f"cut-out level.")
|
||||
|
||||
|
||||
def circulating(discharging=(), charging=()) -> Notice:
|
||||
"""SAFETY-31: the inverters push power round each other. Control stopped."""
|
||||
if discharging and charging:
|
||||
what = (f"{names(discharging)} was discharging while "
|
||||
f"{names(charging)} was charging, and your meter cannot see "
|
||||
f"this.")
|
||||
else:
|
||||
what = ("One inverter was charging from another, and your meter "
|
||||
"cannot see this.")
|
||||
return Notice(ID_CIRCULATING, ALARM, T_CIRCULATING,
|
||||
f"{what} Every inverter is set to 0 W: if the grid power does "
|
||||
f"not change now, switch the inverters off at the isolator. "
|
||||
f"Control stays stopped, even after a restart, until you "
|
||||
f"start it on the add-on page once you know why.")
|
||||
|
||||
|
||||
def not_delivering(unit: str, slug: str) -> Notice:
|
||||
"""SAFETY-42: one inverter gives less than its share. Reports, never acts."""
|
||||
return Notice(
|
||||
ID_NOT_DELIVERING.format(slug=slug), ALARM,
|
||||
T_NOT_DELIVERING.format(unit=unit),
|
||||
f"{unit} gives less power than it is asked for, but nothing was "
|
||||
f"changed and control keeps running. Check that {unit} is "
|
||||
f"on and connected, that its battery is not empty or cold, and that "
|
||||
f"nothing else controls it. A restart does not clear this: once you "
|
||||
f"know why, stop and start control on the add-on page.",
|
||||
section=S_NOT_DELIVERING)
|
||||
|
||||
|
||||
def meter_frozen(unchanged_s) -> Notice:
|
||||
"""SAFETY-18: the meter answers but its value does not move."""
|
||||
return Notice(ID_METER_FROZEN, ALARM, T_METER_FROZEN,
|
||||
f"The meter has shown the same value for "
|
||||
f"{duration(unchanged_s)} while the controller asks the "
|
||||
f"battery for full power. Control is still running and still "
|
||||
f"follows this reading. Check the meter, and if it is stuck, "
|
||||
f"stop control on the add-on page.")
|
||||
|
||||
|
||||
def samples() -> list:
|
||||
"""One rendering of every notice, with realistic values, for the test."""
|
||||
return [
|
||||
loop_bound(),
|
||||
output_clamp([Clamp(None, 7412, 5000, "rating")]),
|
||||
output_clamp(()),
|
||||
output_clamp([Clamp("inverter-a", -3000, -2500, "device"),
|
||||
Clamp("inverter-b", 6000, 4600, "max_w+device")]),
|
||||
peak_saturated(5000),
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=False),
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=False,
|
||||
packs=["inverter-b"]),
|
||||
peak_reserve(5000, soc_unread=True, floor_unknown=False),
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=True),
|
||||
circulating(["inverter-a"], ["inverter-b"]),
|
||||
circulating(),
|
||||
not_delivering("inverter-b", "inverter_b"),
|
||||
meter_frozen(600),
|
||||
meter_frozen(90),
|
||||
]
|
||||
+32
-17
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,12 +10,10 @@ IN FULL.
|
||||
⚠️ WHAT THIS FILE CAN AND CANNOT CLAIM, SAID BEFORE THE FIRST CHECK.
|
||||
The two-master rig lives in admin/goodwe (TEST-19, `sim/sitesim.py`), where
|
||||
`compute()` closes the loop against `plantsim.Plant` with the firmware's own
|
||||
encoder on the wire. That repository is PRIVATE and this repository's CI token
|
||||
is minted for this one, so the rig is not reachable from the gate - and a test
|
||||
file that needed it would either fail the gate or, like test_freeze.py's hwsim
|
||||
section, pass SILENTLY having checked nothing.
|
||||
encoder on the wire. CI checks admin/goodwe out at `.gitea/infra-pin` (CI-12),
|
||||
but this file does not import the rig.
|
||||
|
||||
So the split is deliberate:
|
||||
The split:
|
||||
|
||||
* `tools/alloc_sim.py` drives the REAL rig, and its output is quoted in the
|
||||
pull request. It is where "no limit cycle" is a claim about the shipped
|
||||
|
||||
@@ -62,10 +62,9 @@ the step it wants: if checks.yml is restructured so that the step cannot be
|
||||
found, this file goes RED rather than quietly grading nothing.
|
||||
|
||||
⚠️ NEEDS `git`, which is why .gitea/ci/Dockerfile installs it. No network: the
|
||||
fixtures are local repositories built in a temp directory, and the workflow's
|
||||
`git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:...@#")"`
|
||||
is a no-op substitution on a path with no `://` in it, which is what lets the
|
||||
shipped line be used verbatim.
|
||||
fixtures are local repositories built in a temp directory; the workflow's
|
||||
token header (TOOL-9) is ignored for a local path, which is what lets the
|
||||
shipped lines be used verbatim.
|
||||
|
||||
⚠️ This file writes only under a temporary directory. It must stay that way:
|
||||
CI mounts the checkout READ-ONLY.
|
||||
|
||||
+216
-22
@@ -38,6 +38,9 @@ acceptance criteria; part 6 is TEST-11's and part 7 is SAFETY-28's:
|
||||
the check that replaced a config-shape proxy: `ACTUATOR_WRITE_KEYS` graded
|
||||
how many option KEYS exist, which stays 1 while a per-inverter config adds
|
||||
a second real writer.
|
||||
9. SAFETY-40. The integrator bound (normal anti-windup) is reported in its
|
||||
own words and never as a BUG SIGNAL; the over-rating keeps it; and every
|
||||
number in either message is the clamp tick's own Decision.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHY PART 4 ALSO ASSERTS ON `Decision`'s BOOLEANS AND NOT ON `reason`
|
||||
@@ -80,6 +83,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from named_absence import given, nth, present
|
||||
|
||||
from app import messages
|
||||
from app.clamp import (
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog,
|
||||
clamp_to_rating,
|
||||
@@ -99,6 +103,9 @@ T0 = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
|
||||
# which is also the device rating, so this is the bound the alarm is written at.
|
||||
LIVE_MAX_W = 5000.0
|
||||
CLAMP_ID = "goodwe_output_clamp"
|
||||
# SAFETY-40: the integrator-bound WARNING has its own id, so it can never
|
||||
# replace a BUG SIGNAL notice still on screen.
|
||||
BOUND_ID = "goodwe_integrator_bound"
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
@@ -458,7 +465,7 @@ def wired(grid, cycles=40, batt=0.0, track=True, **over):
|
||||
def clamp_notes(hass):
|
||||
return [d for dom, svc, d in hass.calls
|
||||
if (dom, svc) == ("persistent_notification", "create")
|
||||
and d.get("notification_id") == CLAMP_ID]
|
||||
and d.get("notification_id") in (CLAMP_ID, BOUND_ID)]
|
||||
|
||||
|
||||
# THE 10.3 RUNAWAY, replayed. "With the inverter switched off entirely, an
|
||||
@@ -497,12 +504,16 @@ print(f" the loop clamped on {ctl_r.clamp_wd.engagements} of {60} cycles, "
|
||||
f"notification(s)")
|
||||
check("AC 3: and it ALARMED - silently clipping this is the defect",
|
||||
ctl_r.clamp_wd.engagements > 0 and len(notes_r) >= 1)
|
||||
check("...with its own notification_id, not shared with any other guard",
|
||||
all(d["notification_id"] == CLAMP_ID for d in notes_r))
|
||||
check("...with its own notification_id, not shared with any other guard - "
|
||||
"on a default install the integrator bound's (SAFETY-40)",
|
||||
all(d["notification_id"] == BOUND_ID for d in notes_r))
|
||||
check("...one episode, re-asserted rather than one per cycle",
|
||||
ctl_r.clamp_wd.episodes == 1 and len(notes_r) < ctl_r.clamp_wd.engagements)
|
||||
check("...and it names itself a bug signal, not a limit in use",
|
||||
bool(notes_r) and "out of range" in notes_r[0]["message"])
|
||||
# SAFETY-40: on a default install this runaway is the INTEGRATOR bound, so it
|
||||
# says so, and does not call anti-windup a bug (part 8 grades the wording).
|
||||
check("...and it is the integrator-bound notice, which calls nothing a fault",
|
||||
bool(notes_r) and notes_r[0]["title"] == messages.T_LOOP_BOUND
|
||||
and "fault" not in notes_r[0]["message"])
|
||||
rows_r = [e for e in ctl_r.events if REASON_CODE in e]
|
||||
check(f"the action log records every engagement, coalesced into one row per "
|
||||
f"episode ({len(rows_r)} row(s), {ctl_r.clamp_wd.engagements} "
|
||||
@@ -528,8 +539,9 @@ check("...and the count in the row is the counter's, so nothing is lost",
|
||||
f"{ctl_l.clamp_wd.engagements} engagement(s)" in nth(rows_l, -1))
|
||||
# The RED row exists only while engaged, and the episode above is short, so it
|
||||
# is captured mid-run rather than asserted after the hysteresis has cleared it.
|
||||
check("the status page goes red WHILE it is engaged",
|
||||
any(not c["ok"] and not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"]
|
||||
check("the status page flags it WHILE it is engaged - amber, since it is the "
|
||||
"integrator bound (SAFETY-40; red is part 9's over-rating case)",
|
||||
any(not c["ok"] and c["warn"] and "LOOP AT INTEGRATOR BOUND" in c["text"]
|
||||
for c in ctl_r.red_snapshot))
|
||||
check("...and once clear, a warn row still says how many times it engaged",
|
||||
any(c["warn"] and f"{ctl_r.clamp_wd.engagements} time(s)" in c["text"]
|
||||
@@ -806,11 +818,11 @@ class _SpyWatchdog(ClampWatchdog):
|
||||
self.seen = [] # clamp_last.requested_w at each consultation
|
||||
self.seen_write = [] # was the WRITE-path half engaged, per cycle
|
||||
|
||||
def note(self, engaged, reason, now):
|
||||
def note(self, engaged, reason, now, **kw):
|
||||
cl = self.owner.clamp_last
|
||||
self.seen.append(None if cl is None else cl.requested_w)
|
||||
self.seen_write.append(bool(cl and cl.engaged))
|
||||
return super().note(engaged, reason, now)
|
||||
return super().note(engaged, reason, now, **kw)
|
||||
|
||||
|
||||
def probed(cls=Controller, grid=9000.0, cycles=5, **over):
|
||||
@@ -986,12 +998,14 @@ check("PRECONDITION: the write really did clamp, on the DEVICE bound",
|
||||
and hass_w.writes[-1] == 2500.0)
|
||||
check("...and the loop-side half is SILENT here, so self.clamp_last is the "
|
||||
"only evidence that this happened at all",
|
||||
ctl_w.loop_clamped == (False, ""))
|
||||
ctl_w.loop_clamped == (False, "", False))
|
||||
check("AC 3: the guard alarmed on write-path evidence alone",
|
||||
ctl_w.clamp_wd.engagements == 1 and len(notes_w) == 1)
|
||||
check("...and the operator is told WHICH bound bit, so a device-rating clamp "
|
||||
"is not reported as a max_w one",
|
||||
bool(notes_w) and "device bound" in notes_w[0]["message"])
|
||||
bool(notes_w)
|
||||
and messages.LIMIT_WORDS["device"] in notes_w[0]["message"]
|
||||
and messages.LIMIT_WORDS["max_w"] not in notes_w[0]["message"])
|
||||
check("...and the action log carries the row, written by write_setpoint()",
|
||||
any(REASON_CODE in e and "device bound" in e for e in ctl_w.events))
|
||||
|
||||
@@ -1083,19 +1097,24 @@ check("⚠️ THE ORDERED PAIR IN THE §15 ACTION-LOG ROW (main.py:1366). The "
|
||||
"reading backwards on the one line an operator is woken by",
|
||||
any(f"the computed setpoint {_asked_x:+.0f} W was clamped to "
|
||||
f"{_sent_x:+.0f} W" in e for e in _rows_x))
|
||||
check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION REASON (main.py:2452), "
|
||||
"which is a SECOND surface off the same sentence and has to be pinned "
|
||||
"separately - SAFETY-38 round 3's whole lesson was that pinning one call "
|
||||
"site leaves the other shipping",
|
||||
bool(notes_x)
|
||||
and f"the computed setpoint {_asked_x:+.0f} W was clamped to "
|
||||
f"{_sent_x:+.0f} W" in notes_x[0]["message"])
|
||||
# UX-08: the notification is a SECOND surface off the same numbers, in its own
|
||||
# words (messages.output_clamp): asked first, sent second.
|
||||
def _asked_then_sent(msg, asked, sent):
|
||||
return (f"asked for {messages.watts(asked)}," in msg
|
||||
and f"and sent {messages.watts(sent)} instead" in msg
|
||||
and msg.index(messages.watts(asked)) < msg.index("and sent"))
|
||||
|
||||
|
||||
check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION, which is a SECOND surface "
|
||||
"off the same numbers and has to be pinned separately - SAFETY-38 round "
|
||||
"3's whole lesson was that pinning one call site leaves the other "
|
||||
"shipping",
|
||||
bool(notes_x) and _asked_then_sent(notes_x[0]["message"], _asked_x, _sent_x))
|
||||
check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
|
||||
"checks above discriminate rather than decorate",
|
||||
not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to "
|
||||
f"{_asked_x:+.0f} W" in e for e in _rows_x)
|
||||
and f"the computed setpoint {_sent_x:+.0f} W was clamped to "
|
||||
f"{_asked_x:+.0f} W" not in nth(notes_x, 0)["message"]
|
||||
and not _asked_then_sent(nth(notes_x, 0)["message"], _sent_x, _asked_x)
|
||||
and present(nth(notes_x, 0)))
|
||||
# The DEVICE bound, off part 6c's controller: same sentence, other stage. The
|
||||
# unit of coverage is the call site, and `bound` is the only thing that differs
|
||||
@@ -1107,8 +1126,7 @@ check("...and the same ordered pair holds on the DEVICE-bound row, where the "
|
||||
f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}"
|
||||
and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to "
|
||||
f"{_sent_w:+.0f} W" in e for e in ctl_w.events)
|
||||
and f"the computed setpoint {_asked_w:+.0f} W was clamped to "
|
||||
f"{_sent_w:+.0f} W" in nth(notes_w, 0)["message"])
|
||||
and _asked_then_sent(nth(notes_w, 0)["message"], _asked_w, _sent_w))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -1858,6 +1876,182 @@ check("...EDGE-logged, not level-logged: 25 cycles did not flush the 40-row "
|
||||
len(_shadow_lines) < 25 and len(_shad_c.events) <= 40)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("9. SAFETY-40: the integrator bound is not a BUG SIGNAL, and the numbers "
|
||||
"are the clamp tick's own")
|
||||
# Three defects, each graded on the wired Controller.cycle() with compute()
|
||||
# spied, so every number is compared with the Decision of the SAME tick:
|
||||
# 1. `i_clamped` (normal anti-windup) used the over-rating BUG SIGNAL text;
|
||||
# 2. "the loop asked for" printed `Decision.i_w`, the integrator, as a request;
|
||||
# 3. "was clamped to" printed `self.target`, read after slew/freeze rewrote it.
|
||||
# The fixtures are chosen so the numbers the old text used DIFFER from the true
|
||||
# ones (MUTATION.md habit 4); the PRECONDITION checks pin that.
|
||||
import logging
|
||||
import app.main as _main_mod
|
||||
|
||||
_real_compute = _main_mod.compute
|
||||
|
||||
|
||||
class _LogCap(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.records = []
|
||||
|
||||
def emit(self, record):
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
def s40_run(grid, cycles, track, **over):
|
||||
"""Per tick: (Decision, self.target, clamp_wd.reason, engaged) + hass + log."""
|
||||
ticks, decisions = [], []
|
||||
|
||||
def spy(*a, **k):
|
||||
d = _real_compute(*a, **k)
|
||||
decisions.append(d)
|
||||
return d
|
||||
|
||||
cap = _LogCap()
|
||||
lg = logging.getLogger("goodwe")
|
||||
lg.addHandler(cap)
|
||||
_main_mod.compute = spy
|
||||
|
||||
async def go():
|
||||
hass = _Hass(grid, track=track)
|
||||
c = Controller(dict(
|
||||
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
|
||||
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
|
||||
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15},
|
||||
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
red = []
|
||||
for _ in range(cycles):
|
||||
n = len(decisions)
|
||||
before = c.clamp_wd.engagements
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
d = decisions[-1] if len(decisions) > n else None
|
||||
ticks.append((d, c.target, c.clamp_wd.reason,
|
||||
c.clamp_wd.engagements > before))
|
||||
if c.clamp_wd.active and not red:
|
||||
red = c.checks()
|
||||
return hass, c, red
|
||||
|
||||
try:
|
||||
hass, c, red = asyncio.run(go())
|
||||
finally:
|
||||
_main_mod.compute = _real_compute
|
||||
lg.removeHandler(cap)
|
||||
return ticks, hass, c, red, cap.records
|
||||
|
||||
|
||||
def _f(d, name):
|
||||
"""A Decision field, NaN when absent - so an old tree goes red, not crashes."""
|
||||
return getattr(d, name, float("nan"))
|
||||
|
||||
|
||||
# --- 9a. default install: only the INTEGRATOR bound can engage ---------------
|
||||
ticks_i, hass_i, ctl_i, red_i, log_i = s40_run(6000.0, 20, track=False)
|
||||
wind = [(d, t, r) for d, t, r, e in ticks_i
|
||||
if e and d is not None and d.i_clamped and not d.clamped]
|
||||
for d, t, r in wind:
|
||||
print(f" tick: bound {_f(d, 'i_bound_w'):+.0f} i_w {d.i_w:+.0f} command "
|
||||
f"{d.target_w:+.0f} self.target {t:+.0f}\n -> {r}")
|
||||
check("PRECONDITION: the integrator bound engaged, and on a tick where the "
|
||||
"carried integrator AND the emitted command both differ from the bound",
|
||||
any(d.i_w != _f(d, 'i_bound_w') and d.target_w != _f(d, 'i_bound_w')
|
||||
for d, t, r in wind))
|
||||
check("defect 1: the integrator bound is NOT reported as a BUG SIGNAL",
|
||||
wind and all("BUG SIGNAL" not in r for d, t, r in wind))
|
||||
check("defect 1: ...it names the anti-windup bound, from the same tick",
|
||||
wind and all(f"its {_f(d, 'i_bound_w'):+.0f} W anti-windup bound" in r
|
||||
for d, t, r in wind))
|
||||
check("defect 2: the integrator accumulator is not printed as a request",
|
||||
wind and all(f"{d.i_w:+.0f} W" not in r and "asked for" not in r
|
||||
for d, t, r in wind))
|
||||
check("the command quoted is that tick's Decision, not a later read",
|
||||
wind and all(f"command this cycle was {d.target_w:+.0f} W" in r
|
||||
for d, t, r in wind))
|
||||
check("at the bound nothing is described as 'above' or 'over' the rating",
|
||||
wind and all(" above " not in r and " over " not in r
|
||||
for d, t, r in wind))
|
||||
notes_i = clamp_notes(hass_i)
|
||||
check("it still NOTIFIES (not suppressed), under a title that claims no bug",
|
||||
notes_i and all(n["title"] == messages.T_LOOP_BOUND
|
||||
and "fault" not in n["message"] for n in notes_i))
|
||||
check("...under its OWN notification_id, so it cannot replace a BUG SIGNAL "
|
||||
"notice still on screen",
|
||||
notes_i and all(n["notification_id"] == BOUND_ID for n in notes_i))
|
||||
check("...logged as a WARNING, not an ERROR",
|
||||
any(x.levelno == logging.WARNING and "anti-windup" in x.getMessage()
|
||||
for x in log_i)
|
||||
and not any(x.levelno >= logging.ERROR and "OUTPUT_CLAMP" in x.getMessage()
|
||||
for x in log_i))
|
||||
check("...and the status row is amber, not red",
|
||||
any(c["warn"] and "LOOP AT INTEGRATOR BOUND" in c["text"] for c in red_i)
|
||||
and not any(not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"]
|
||||
for c in red_i))
|
||||
|
||||
# --- 9b. the genuine over-rating: integrator_max_w above max_w ----------------
|
||||
ticks_o, hass_o, ctl_o, red_o, log_o = s40_run(9000.0, 12, track=True,
|
||||
integrator_max_w=9000)
|
||||
over = [(d, t, r) for d, t, r, e in ticks_o
|
||||
if e and d is not None and d.clamped]
|
||||
for d, t, r in over[:2]:
|
||||
print(f" tick: requested {_f(d, 'requested_w'):+.0f} clamped {_f(d, 'clamped_w'):+.0f}"
|
||||
f" i_w {d.i_w:+.0f} self.target {t:+.0f}\n -> {r}")
|
||||
check("PRECONDITION: the output clamp engaged on a tick where the slew limit "
|
||||
"rewrote the command AND the integrator differs from the request",
|
||||
any(t != _f(d, 'clamped_w') and d.i_w != _f(d, 'requested_w') for d, t, r in over))
|
||||
check("the over-rating KEEPS the BUG SIGNAL",
|
||||
over and all("BUG SIGNAL" in r for d, t, r in over))
|
||||
check("defect 2: it quotes the requested COMMAND of that tick, not i_w",
|
||||
over and all(f"computed {_f(d, 'requested_w'):+.0f} W" in r for d, t, r in over))
|
||||
check("defect 3: 'clamped to' is the clamp's own result, not self.target",
|
||||
over and all(f"clamped to {_f(d, 'clamped_w'):+.0f} W" in r for d, t, r in over))
|
||||
check("...and the 14 768 W rationale is still in its ERROR",
|
||||
any(x.levelno == logging.ERROR and "14 768 W" in x.getMessage()
|
||||
for x in log_o))
|
||||
check("...under the over-rating title, quoting a tick's own asked and sent "
|
||||
"numbers in that order, with a red status row",
|
||||
clamp_notes(hass_o)
|
||||
and all(n["title"] == messages.T_OUTPUT_CLAMP
|
||||
and n["notification_id"] == CLAMP_ID
|
||||
and any(_asked_then_sent(n["message"], _f(d, 'requested_w'),
|
||||
_f(d, 'clamped_w')) for d, t, r in over)
|
||||
for n in clamp_notes(hass_o))
|
||||
and any(not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"]
|
||||
for c in red_o))
|
||||
|
||||
# --- 9c. the text, directly, and the two budgets -------------------------------
|
||||
from app.clamp import loop_clamp_note # noqa: E402
|
||||
|
||||
d_at = compute(prev_w=1000.0, grid_w=9000.0, actual_w=1000.0,
|
||||
tuning=Tuning(max_w=5000.0, slew_w=1000.0, gain=0.6), i_w=4500.0)
|
||||
check(f"exactly at the rating ({d_at.i_bound_w:+.0f} W) is 'reached', never "
|
||||
f"'above' - {loop_clamp_note(d_at)!r}",
|
||||
d_at.i_clamped and not d_at.clamped and abs(d_at.i_bound_w) == 5000.0
|
||||
and "above" not in loop_clamp_note(d_at))
|
||||
check("a healthy decision produces no text",
|
||||
loop_clamp_note(compute(0.0, 100.0, 0.0, Tuning(max_w=5000.0))) == "")
|
||||
|
||||
# Anti-windup notices must never spend the budget a BUG SIGNAL needs.
|
||||
wd = ClampWatchdog()
|
||||
fired_w = wd.note(True, "wind", T0, bug=False)
|
||||
fired_b = wd.note(True, "bug", T0 + timedelta(seconds=5), bug=True)
|
||||
wd.note(True, "wind again", T0 + timedelta(seconds=10), bug=False)
|
||||
check("an anti-windup notice does not delay a bug in the same episode, and "
|
||||
"does not overwrite its text",
|
||||
fired_w and fired_b and wd.bug and wd.reason == "bug")
|
||||
wd2 = ClampWatchdog()
|
||||
seq = [wd2.note(True, "b", T0 + timedelta(seconds=5 * i)) for i in range(40)]
|
||||
check("bug-only cadence is unchanged: rising edge, then every REASSERT_S",
|
||||
seq[0] and sum(seq) == 1 + int(5 * 39 // REASSERT_S))
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -990,13 +990,11 @@ check("⭐ RESTORING slew_w 1000 AT max_w 5000 IS FASTER THAN THE PLANT, and by
|
||||
#
|
||||
# ⚠️ THE PLANT BELOW IS MODELLED HERE AND IS NOT THE RIG. admin/goodwe's
|
||||
# `plantsim.Plant` is the battery model this project grades control changes
|
||||
# against, and this repository's CI cannot reach it - that repository is private
|
||||
# and this job's token is minted for this one (see .gitea/workflows/checks.yml).
|
||||
# So the rig evidence lives in `tools/slew_sim.py`, is quoted in the pull
|
||||
# request and in DECISIONS/SAFETY-39-slew-against-the-rail.md, and what runs
|
||||
# HERE is a first-order lag with the time constant that reproduces §5.10's
|
||||
# measured ~18 s to the deadband. test_alloc.py makes the same split for the
|
||||
# same reason and says so in the same words.
|
||||
# against. CI checks admin/goodwe out at `.gitea/infra-pin` (CI-12), but this
|
||||
# file does not import `plantsim`. The rig evidence lives in `tools/slew_sim.py`,
|
||||
# is quoted in the pull request and in DECISIONS/SAFETY-39-slew-against-the-rail.md,
|
||||
# and what runs HERE is a first-order lag with the time constant that reproduces
|
||||
# §5.10's measured ~18 s to the deadband. test_alloc.py makes the same split.
|
||||
#
|
||||
# ⚠️ AND IT CANNOT SHOW A BATTERY ABOVE ITS RATING, WHICH IS SAID BEFORE THE
|
||||
# RESULT RATHER THAN AFTER IT. Neither this model nor `plantsim.Plant` lets the
|
||||
|
||||
@@ -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")
|
||||
@@ -33,6 +33,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from named_absence import given, present
|
||||
|
||||
from app import messages
|
||||
from app.control import Tuning
|
||||
from app.delivery import (
|
||||
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
|
||||
@@ -1195,8 +1196,8 @@ async def _restart():
|
||||
c1 = _ctrl(h1, store=shared)
|
||||
await _drive(c1)
|
||||
# A NEW process, a NEW HomeAssistant object (so the in-memory notification
|
||||
# is gone with it), the SAME durable file. That is what a Home Assistant
|
||||
# restart is from this add-on's point of view: the add-on goes with it.
|
||||
# is gone with it), the SAME durable file: an ADD-ON restart. A Core-only
|
||||
# restart keeps the process; graded in part 8b (SAFETY-34).
|
||||
h2 = _DeliveryHass(b_soc=50.0)
|
||||
c2 = _ctrl(h2, store=shared)
|
||||
await c2.check_not_delivering()
|
||||
@@ -1232,7 +1233,7 @@ check("the notification NAMES THE UNIT - in its id, its title and its body - "
|
||||
check("...and it says, in the operator's own sentence, that nothing has been "
|
||||
"commanded differently. An alarm that let somebody believe the add-on "
|
||||
"had handled it would be worse than no alarm",
|
||||
"NOTHING HAS BEEN COMMANDED DIFFERENTLY" in _note[2]["message"])
|
||||
"nothing was changed and control keeps running" in _note[2]["message"])
|
||||
print(f" id={_note[2]['notification_id']!r} title={_note[2]['title']!r}")
|
||||
_before_calls = len(_rh2.calls)
|
||||
await_again = asyncio.run(_r2.check_not_delivering())
|
||||
@@ -1250,6 +1251,40 @@ check("the operator's START press is the only way out, and it clears the "
|
||||
and _rs.data.get(unit_key("inverter-b", "delivery_reason")) == ""
|
||||
and _r2._delivery_announced == set())
|
||||
|
||||
print()
|
||||
print("8b. SAFETY-34: a Core-only restart - same add-on process, HA's memory wiped")
|
||||
import app.main as _main_mod # noqa: E402
|
||||
|
||||
|
||||
async def _core_restart():
|
||||
shared = _MemStore()
|
||||
c = _ctrl(_DeliveryHass(b_soc=50.0), store=shared)
|
||||
await _drive(c)
|
||||
return c
|
||||
|
||||
|
||||
_c = asyncio.run(_core_restart())
|
||||
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
|
||||
_main_mod._mono = lambda: _clock[0]
|
||||
try:
|
||||
_c.hass = _DeliveryHass(b_soc=50.0)
|
||||
asyncio.run(_c.check_not_delivering())
|
||||
_early = [x for x in _c.hass.calls
|
||||
if "not_delivering" in str(x[2].get("notification_id", ""))]
|
||||
_clock[0] += _main_mod.REANNOUNCE_S
|
||||
asyncio.run(_c.check_not_delivering())
|
||||
_late = [x for x in _c.hass.calls
|
||||
if "not_delivering" in str(x[2].get("notification_id", ""))]
|
||||
finally:
|
||||
_main_mod._mono = _real_mono
|
||||
check("SAFETY-34: the latch tripped in this process",
|
||||
_c.inverters[1].delivery_wd.tripped)
|
||||
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise",
|
||||
len(_early) == 0)
|
||||
check("SAFETY-34: after REANNOUNCE_S the SAME process re-asserts the card, "
|
||||
"because a Core-only restart wiped it and did not restart the add-on",
|
||||
len(_late) == 1 and "inverter_b" in _late[0][2]["notification_id"])
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -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")
|
||||
@@ -19,7 +19,8 @@ shipped `meter_freeze_s` itself:
|
||||
a check that calls the predicate directly would not be testing the thing
|
||||
that ships. Uses the rig's own `sim/hwsim.py --fault freeze` when the infra
|
||||
repo is next to this one (or `$GOODWE_SIM`), and an equivalent local stub
|
||||
otherwise. Both are exercised; the run says which one it used.
|
||||
otherwise. Both are exercised; the run says which one it used. CI sets
|
||||
`GOODWE_REQUIRE_HWSIM=1`, which refuses the stub (CI-12).
|
||||
|
||||
4. THE WIRING: the real `Controller.cycle()`, asserted on the service call
|
||||
that leaves the process;
|
||||
@@ -84,6 +85,7 @@ from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app import messages
|
||||
from app.control import Tuning, compute
|
||||
from app.freeze import (
|
||||
RAIL_FRAC, FreezeWatchdog, FreezeWatchdogConfig, looks_frozen,
|
||||
@@ -600,6 +602,15 @@ hw = find_hwsim()
|
||||
# that cannot fail, from the other end.
|
||||
PART3_TRANSPORT = f"the rig hwsim at {hw}" if hw else "the local stub"
|
||||
print(f"frozen meter over HTTP - using {PART3_TRANSPORT}")
|
||||
# CI-12: CI sets GOODWE_REQUIRE_HWSIM=1, so a missing infra checkout fails here
|
||||
# instead of passing on the stub. Local runs may still use the stub, and say so.
|
||||
if not hw:
|
||||
if os.environ.get("GOODWE_REQUIRE_HWSIM"):
|
||||
print("FAIL: GOODWE_REQUIRE_HWSIM is set but sim/hwsim.py was not found "
|
||||
"(sibling ../goodwe or $GOODWE_SIM) - refusing to fall back to the stub")
|
||||
sys.exit(1)
|
||||
print("STUB USED: part 3 ran against the local stub, NOT the infra repo's "
|
||||
"sim/hwsim.py - not a CI-grade result")
|
||||
if hw:
|
||||
def argv_for(mode):
|
||||
return [HWSIM_DRIVER, "8791" if mode == "freeze" else "8792", mode,
|
||||
@@ -763,7 +774,7 @@ def _is_freeze_note(d):
|
||||
calls itself frozen, which would be a confusable alarm on its own account.
|
||||
"""
|
||||
return (d.get("notification_id") == FREEZE_NOTE_ID
|
||||
or "frozen" in str(d.get("title", "")).lower())
|
||||
or d.get("title") == messages.T_METER_FROZEN)
|
||||
|
||||
|
||||
def notes_of(hass):
|
||||
@@ -778,7 +789,7 @@ def notes_by_text_of(hass):
|
||||
both would be true by construction.
|
||||
"""
|
||||
return [d for d in all_notes_of(hass)
|
||||
if "frozen" in str(d.get("title", "")).lower()]
|
||||
if d.get("title") == messages.T_METER_FROZEN]
|
||||
|
||||
|
||||
def wired(unchanged_s, grid, cycles=60, dev=(-5000.0, 5000.0), **over):
|
||||
@@ -825,7 +836,8 @@ frozen_rows = [c for c in ctl_f.checks() if "METER MAY BE FROZEN" in c["text"]]
|
||||
print(f" frozen at +4000 W: command ran to {ctl_f.target:.0f} W, "
|
||||
f"{len(notes)} notification(s), {len(frozen_rows)} red status row(s)")
|
||||
# SURFACE-WIDE, deliberately, and the second guard has now landed. SAFETY-03
|
||||
# ships `goodwe_output_clamp`, and this scenario - a runaway loop winding into
|
||||
# ships `goodwe_output_clamp` (and, for the integrator bound on a default
|
||||
# install, `goodwe_integrator_bound` - SAFETY-40), and this scenario - a runaway loop winding into
|
||||
# the rail - raises it legitimately: the loop asks for more than the rating and
|
||||
# is clamped. So the answer to "is this scenario still meant to raise exactly
|
||||
# one alarm" is NO, it raises exactly two, and BOTH ARE NAMED here rather than
|
||||
@@ -836,7 +848,7 @@ _ids = sorted(d.get("notification_id") for d in all_notes)
|
||||
print(f" the whole notification surface this run: {_ids}")
|
||||
check("the whole notification surface carried exactly two alarms this run - "
|
||||
"SAFETY-18's and SAFETY-03's, both named",
|
||||
_ids == ["goodwe_meter_frozen", "goodwe_output_clamp"])
|
||||
_ids == ["goodwe_integrator_bound", "goodwe_meter_frozen"])
|
||||
# THIS GUARD'S, keyed on the id.
|
||||
check("...and the freeze detector is the one that raised it", len(notes) == 1)
|
||||
# ⚠️ Selected by TEXT, not by id - see notes_by_text_of(). One of the two
|
||||
@@ -847,7 +859,7 @@ check("...with a fixed notification_id so repeats replace, not stack",
|
||||
[d.get("notification_id") for d in notes_by_text_of(hass_f)]
|
||||
== [FREEZE_NOTE_ID])
|
||||
check("...and it says the controller is still running",
|
||||
bool(notes) and "STILL RUNNING" in notes[0]["message"])
|
||||
bool(notes) and "Control is still running" in notes[0]["message"])
|
||||
check("the status page carries a failed row saying so",
|
||||
len(frozen_rows) == 1 and not frozen_rows[0]["ok"] and not frozen_rows[0]["warn"])
|
||||
check("the event log names it", any("METER FROZEN" in e for e in ctl_f.events))
|
||||
@@ -1169,11 +1181,16 @@ check("...and at or above the device rail, which is why it must",
|
||||
check("THE ALARM FIRES through the real cycle() - the rating reaches the "
|
||||
"predicate from the controller, not just from a unit call",
|
||||
len(_n20) == 1 and _c20.freeze_wd.active)
|
||||
check("...and the notification the operator gets quotes the capped rail, "
|
||||
"not the max_w one",
|
||||
# UX-08: the notification no longer quotes a rail at all (the reader's action
|
||||
# is "check the meter"), so it cannot quote the wrong one; the capped rail is
|
||||
# still what the reason - the log line and the status row - carries.
|
||||
check("...and the notification the operator gets names no max_w rail, and the "
|
||||
"reason behind it quotes the capped rail",
|
||||
bool(_n20)
|
||||
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W"
|
||||
not in str(_n20[0].get("message", ""))
|
||||
and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
|
||||
in str(_n20[0].get("message", "")))
|
||||
in _c20.freeze_wd.reason)
|
||||
# p1_enabled is what a homewizard_local install has - set by hand here for the
|
||||
# same reason part 4 sets it, see freeze_rows().
|
||||
_c20.p1_enabled = True
|
||||
@@ -1263,7 +1280,8 @@ check("a Controller built with the key absent lands on the SHIPPED hold, so "
|
||||
_ctl_c.freeze_wd.cfg.hold_s == SHIPPED_HOLD_S)
|
||||
|
||||
print()
|
||||
print(f"part 3 (the live HTTP transport) ran against: {PART3_TRANSPORT}")
|
||||
# `CI: ` lines are echoed into the CI log even on a pass (checks.yml).
|
||||
print(f"CI: test_freeze part 3 (the live HTTP transport) ran against: {PART3_TRANSPORT}")
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
"""UX-08: user-facing text follows STYLE.md. RUN: python3 -B test_messages.py
|
||||
|
||||
1. Every notification in `app/messages.py` obeys the rule. The test renders
|
||||
each one with realistic values and checks the title length, the number of
|
||||
sentences and words for its severity, the banned terms, the entity ids and
|
||||
the Details line.
|
||||
2. No notification is worded anywhere else. An AST scan of `app/` fails any
|
||||
`persistent_notification` call whose payload is not built by
|
||||
`app/messages.py`.
|
||||
3. THE RATCHET for the kinds not rewritten yet. Every status-row and banner
|
||||
literal, every validator refusal, every WARNING+ log line and every option
|
||||
name and description in `translations/en.yaml` must either obey its limit
|
||||
or appear in `ux08_allowlist.txt`. A new string that breaks the rule and is
|
||||
not listed goes red. So does a listed entry that no longer matches a
|
||||
failing string, so the list can only shrink: remove the line when you
|
||||
rewrite the string.
|
||||
4. DOCS.md has a section for every notification, under "## Notifications".
|
||||
5. Mutations: each rule is shown to catch a spoiled input (habit 1 of
|
||||
MUTATION.md).
|
||||
|
||||
Run `python3 -B test_messages.py --violations` to print what part 3 sees.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
|
||||
from app import messages # noqa: E402
|
||||
import translations_lint as TL # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# --- the rule (STYLE.md sections 1 and 4) --------------------------------------
|
||||
BANNED = re.compile(
|
||||
r"\b(?:(?:SAFETY|TARIFF|TEL|CFG|FCST|OPT|UX|HW|FW|WITNESS|PKG|ARCH)-\d+|"
|
||||
r"integrator|anti-?windup|windup|clamp(?:ed|s)?|i_clamped|saturat\w*|"
|
||||
r"BUG SIGNAL|latch(?:ed)?|arbiter|allocator|registers?|0x[0-9A-Fa-f]+|"
|
||||
r"layer [123]|heartbeat|watchdog|telegrams?|ACKs?|CRC|OTA|Modbus|planner|"
|
||||
r"capaciteitstarief|journalctl|ssh|grep|"
|
||||
r"\d+\.\d+\.\d+\.\d+|[\w-]+\.(?:py|md|yaml|json|db))\b|§\s?\d", re.I)
|
||||
ENTITY = re.compile(r"\b(?:sensor|number|binary_sensor|input_\w+|switch|select)"
|
||||
r"\.[a-z0-9_*]+")
|
||||
SNAKE = re.compile(r"\b[a-z0-9]+(?:_[a-z0-9]+)+\b")
|
||||
SHOUT = re.compile(r"\b[A-Z]{2,}[A-Z0-9]*(?:[\s-]+[A-Z]{2,}[A-Z0-9]*)+\b")
|
||||
PLACEHOLDER = re.compile(r"\{[^{}]*\}|%[-+ #0-9.]*[sdrfgx%]")
|
||||
|
||||
|
||||
def words(text):
|
||||
return [w for w in re.findall(r"\S+", PLACEHOLDER.sub("X", text))
|
||||
if re.search(r"[A-Za-z0-9]", w)]
|
||||
|
||||
|
||||
def sentences(text):
|
||||
"""Sentence count: a stop followed by a space and a capital, or the end."""
|
||||
t = PLACEHOLDER.sub("X", text.strip())
|
||||
t = re.sub(r"\b(e\.g|i\.e|etc)\.", "eg", t)
|
||||
return len([s for s in re.split(r"[.!?](?:\s+|$)", t) if s.strip()])
|
||||
|
||||
|
||||
def title_problems(title):
|
||||
out = []
|
||||
if len(words(title)) > 5:
|
||||
out.append(f"title over 5 words ({len(words(title))})")
|
||||
if title.lower().startswith("goodwe"):
|
||||
out.append("title carries a GoodWe prefix")
|
||||
if title.rstrip().endswith("."):
|
||||
out.append("title ends in a full stop")
|
||||
return out + text_problems(title, keys=False, entities=False)
|
||||
|
||||
|
||||
def text_problems(text, *, keys, entities):
|
||||
t = PLACEHOLDER.sub("X", text)
|
||||
out = []
|
||||
hit = sorted({m.group(0) for m in BANNED.finditer(t)})
|
||||
if hit:
|
||||
out.append("banned: " + ", ".join(hit[:4]))
|
||||
if not entities and ENTITY.search(t):
|
||||
out.append("entity id: " + ENTITY.search(t).group(0))
|
||||
if not keys and SNAKE.search(ENTITY.sub("", t)):
|
||||
out.append("option key: " + SNAKE.search(ENTITY.sub("", t)).group(0))
|
||||
if SHOUT.search(t):
|
||||
out.append("capitals: " + SHOUT.search(t).group(0))
|
||||
return out
|
||||
|
||||
|
||||
def body_problems(body, severity):
|
||||
out = []
|
||||
n_s, n_w = sentences(body), len(words(body))
|
||||
if severity == messages.ALARM:
|
||||
if n_s > 3:
|
||||
out.append(f"alarm body over 3 sentences ({n_s})")
|
||||
if n_w > 70:
|
||||
out.append(f"alarm body over 70 words ({n_w})")
|
||||
long = [s for s in re.split(r"(?<=[.!?])\s+", body) if len(words(s)) > 30]
|
||||
if long:
|
||||
out.append(f"sentence over 30 words ({len(words(long[0]))})")
|
||||
else:
|
||||
if n_s > 1:
|
||||
out.append(f"{severity} body over 1 sentence ({n_s})")
|
||||
if n_w > 25:
|
||||
out.append(f"{severity} body over 25 words ({n_w})")
|
||||
if ";" in body:
|
||||
out.append("semicolon: split it into sentences or drop a clause")
|
||||
return out + text_problems(body, keys=False, entities=False)
|
||||
|
||||
|
||||
def notice_problems(n):
|
||||
out = []
|
||||
if n.severity not in (messages.ALARM, messages.WARNING, messages.INFO,
|
||||
messages.RESOLVED):
|
||||
out.append(f"unknown severity {n.severity!r}")
|
||||
out += title_problems(n.title) + body_problems(n.body, n.severity)
|
||||
if not ends_in_details(n.data()["message"], n.docs):
|
||||
out.append("message does not end in its Details line")
|
||||
return out
|
||||
|
||||
|
||||
def ends_in_details(message, docs):
|
||||
return message.endswith("\n\n" + messages.details(docs))
|
||||
|
||||
|
||||
# ============================================================================== #
|
||||
print("1. every notification obeys the rule")
|
||||
SAMPLES = messages.samples()
|
||||
for n in SAMPLES:
|
||||
p = notice_problems(n)
|
||||
check(f"{n.severity:8} {n.title!r}: {len(words(n.body))} words, "
|
||||
f"{sentences(n.body)} sentence(s){' - ' + '; '.join(p) if p else ''}",
|
||||
not p)
|
||||
ids = {v for k, v in vars(messages).items() if k.startswith("ID_")}
|
||||
seen = {re.sub(r"inverter_b$", "{slug}", n.id) for n in SAMPLES}
|
||||
check(f"the samples cover every notification id in the catalogue "
|
||||
f"(missing: {sorted(ids - seen)})", ids <= seen)
|
||||
check("the payload carries exactly the three keys Home Assistant accepts",
|
||||
all(set(n.data()) == {"notification_id", "title", "message"}
|
||||
for n in SAMPLES))
|
||||
os.environ["HOSTNAME"] = "abcd1234-goodwe-controller"
|
||||
check("inside the add-on the Details line links to its documentation page",
|
||||
"(/hassio/addon/abcd1234_goodwe_controller/documentation)"
|
||||
in SAMPLES[0].data()["message"])
|
||||
os.environ["HOSTNAME"] = "some-laptop"
|
||||
check("...and anywhere else it names the section without a guessed link",
|
||||
"](" not in SAMPLES[0].data()["message"]
|
||||
and "under Notifications" in SAMPLES[0].data()["message"])
|
||||
del os.environ["HOSTNAME"]
|
||||
check("the output-clamp alarm quotes asked before sent",
|
||||
messages.output_clamp([messages.Clamp(None, 7412, 5000, "rating")]).body
|
||||
.index("7 412 W") < messages.output_clamp(
|
||||
[messages.Clamp(None, 7412, 5000, "rating")]).body.index("5 000 W"))
|
||||
|
||||
|
||||
# ============================================================================== #
|
||||
print("\n2. no notification is worded outside app/messages.py")
|
||||
APP = HERE / "app"
|
||||
|
||||
|
||||
def render(node):
|
||||
"""A string expression as text, placeholders as {expr}; None otherwise."""
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return node.value
|
||||
if isinstance(node, ast.JoinedStr):
|
||||
return "".join(v.value if isinstance(v, ast.Constant)
|
||||
else "{" + ast.unparse(v.value) + "}" for v in node.values)
|
||||
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
||||
a, b = render(node.left), render(node.right)
|
||||
if a is None and b is None:
|
||||
return None
|
||||
return (a if a is not None else "{…}") + (b if b is not None else "{…}")
|
||||
if isinstance(node, ast.IfExp):
|
||||
a, b = render(node.body), render(node.orelse)
|
||||
if a is None and b is None:
|
||||
return None
|
||||
return (a or "") + " / " + (b or "")
|
||||
return None
|
||||
|
||||
|
||||
def notification_calls(tree):
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Call) and node.args
|
||||
and ast.unparse(node.func).endswith("call")
|
||||
and render(node.args[0]) == "persistent_notification"):
|
||||
yield node
|
||||
|
||||
|
||||
bad_calls, n_calls = [], 0
|
||||
for f in sorted(APP.glob("*.py")):
|
||||
for call in notification_calls(ast.parse(f.read_text(encoding="utf-8"))):
|
||||
n_calls += 1
|
||||
payload = call.args[2] if len(call.args) > 2 else None
|
||||
ok = (isinstance(payload, ast.Call)
|
||||
and isinstance(payload.func, ast.Attribute)
|
||||
and payload.func.attr == "data"
|
||||
and "messages." in ast.unparse(payload.func.value))
|
||||
if not ok:
|
||||
bad_calls.append(f"{f.name}:{call.lineno}")
|
||||
check(f"the scan found the add-on's notification calls ({n_calls})", n_calls >= 7)
|
||||
check(f"every one is built by app/messages.py (not: {bad_calls})", not bad_calls)
|
||||
|
||||
|
||||
# ============================================================================== #
|
||||
print("\n3. the ratchet: other user-facing text obeys its limit or is listed")
|
||||
|
||||
LIMITS = { # kind: (words, keys ok, entity ids ok)
|
||||
"row": (15, False, False),
|
||||
"banner": (12, False, False),
|
||||
"refusal": (30, True, True),
|
||||
"log": (25, True, True),
|
||||
"option-name": (6, False, False),
|
||||
"option-desc": (30, True, True),
|
||||
}
|
||||
LOG_CALL = re.compile(r"(?:^|\.)(?:_LOG|log|logger|LOG)\.(warning|error|critical|exception)$")
|
||||
|
||||
|
||||
def strings_in(node):
|
||||
"""Top-level string expressions inside `node` (not their parts)."""
|
||||
t = render(node)
|
||||
if t is not None:
|
||||
yield node, t
|
||||
return
|
||||
for child in ast.iter_child_nodes(node):
|
||||
yield from strings_in(child)
|
||||
|
||||
|
||||
def collect():
|
||||
"""(kind, where, text) for every user-facing literal of a ratcheted kind."""
|
||||
out = []
|
||||
for f in sorted(APP.glob("*.py")):
|
||||
if f.name == "messages.py":
|
||||
continue
|
||||
tree = ast.parse(f.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Dict):
|
||||
for k, v in zip(node.keys, node.values):
|
||||
key = render(k) if k is not None else None
|
||||
if key in ("text", "banner"):
|
||||
for n, t in strings_in(v):
|
||||
out.append(("row" if key == "text" else "banner",
|
||||
f"{f.name}:{n.lineno}", t))
|
||||
elif isinstance(node, ast.Assign) and f.name == "main.py":
|
||||
tgt = ast.unparse(node.targets[0]).replace(" ", "").strip("()")
|
||||
if tgt in ("level,banner", "banner"):
|
||||
for n, t in strings_in(node.value):
|
||||
if len(words(t)) > 1:
|
||||
out.append(("banner", f"{f.name}:{n.lineno}", t))
|
||||
elif isinstance(node, ast.Call):
|
||||
name = ast.unparse(node.func)
|
||||
if LOG_CALL.search(name) or name.endswith("._moan"):
|
||||
arg = node.args[1 if name.endswith("._moan") else 0] \
|
||||
if node.args else None
|
||||
if arg is not None:
|
||||
for n, t in strings_in(arg):
|
||||
out.append(("log", f"{f.name}:{n.lineno}", t))
|
||||
elif re.search(r"problems\.(append|extend)$", name):
|
||||
for a in node.args:
|
||||
for n, t in strings_in(a):
|
||||
if len(words(t)) > 1:
|
||||
out.append(("refusal", f"{f.name}:{n.lineno}", t))
|
||||
tr = TL.parse_translation_file(HERE / "translations" / "en.yaml")
|
||||
|
||||
def walk(conf, path):
|
||||
for key, v in (conf or {}).items():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
if isinstance(v.get("name"), str):
|
||||
out.append(("option-name", f"en.yaml:{path}{key}",
|
||||
v["name"].split("·")[-1].strip()))
|
||||
if isinstance(v.get("description"), str):
|
||||
out.append(("option-desc", f"en.yaml:{path}{key}",
|
||||
v["description"]))
|
||||
if isinstance(v.get("fields"), dict):
|
||||
walk(v["fields"], f"{path}{key}.")
|
||||
walk(tr.get("configuration"), "")
|
||||
return out
|
||||
|
||||
|
||||
def problems_of(kind, text):
|
||||
lim, keys, ents = LIMITS[kind]
|
||||
p = []
|
||||
if len(words(text)) > lim:
|
||||
p.append(f"over {lim} words ({len(words(text))})")
|
||||
return p + text_problems(text, keys=keys, entities=ents)
|
||||
|
||||
|
||||
def key_of(kind, text):
|
||||
return f"{kind} {hashlib.sha1(text.encode('utf-8')).hexdigest()[:12]}"
|
||||
|
||||
|
||||
ALLOW_FILE = HERE / "ux08_allowlist.txt"
|
||||
# The allowlist may never grow. The test demands the list be exactly this long,
|
||||
# so deleting lines means lowering this number in the same change, and adding
|
||||
# one means raising it where a reviewer sees it.
|
||||
ALLOWLIST_MAX = 200
|
||||
|
||||
|
||||
def read_allowlist(path):
|
||||
keys = set()
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.split("#", 1)[0].strip()
|
||||
if line:
|
||||
keys.add(" ".join(line.split()[:2]))
|
||||
return keys
|
||||
|
||||
|
||||
found = collect()
|
||||
violating = {}
|
||||
for kind, where, text in found:
|
||||
p = problems_of(kind, text)
|
||||
if p:
|
||||
violating.setdefault(key_of(kind, text), (kind, where, text, p))
|
||||
if "--violations" in sys.argv:
|
||||
for k, (kind, where, text, p) in sorted(violating.items(),
|
||||
key=lambda kv: kv[1][:2]):
|
||||
print(f"{k} # {where} {'; '.join(p)} :: {text[:70]!r}")
|
||||
sys.exit(0)
|
||||
|
||||
allow = read_allowlist(ALLOW_FILE)
|
||||
by_kind = {}
|
||||
for kind, *_ in found:
|
||||
by_kind[kind] = by_kind.get(kind, 0) + 1
|
||||
check(f"the scan found every ratcheted kind ({by_kind})",
|
||||
all(by_kind.get(k, 0) >= 5 for k in LIMITS))
|
||||
new = sorted(k for k in violating if k not in allow)
|
||||
for k in new:
|
||||
kind, where, text, p = violating[k]
|
||||
print(f" NEW {where}: {'; '.join(p)} :: {text[:90]!r}\n"
|
||||
f" (rewrite it to STYLE.md; listing it needs a reason)")
|
||||
check(f"no new string breaks the rule ({len(new)} new)", not new)
|
||||
stale = sorted(k for k in allow if k not in violating)
|
||||
for k in stale:
|
||||
print(f" STALE {k} - that string now obeys the rule or is gone: "
|
||||
f"delete its line from ux08_allowlist.txt")
|
||||
check(f"no allowlist entry is stale, so the list only shrinks "
|
||||
f"({len(stale)} stale)", not stale)
|
||||
check(f"the allowlist is exactly its cap ({len(allow)} entries, cap "
|
||||
f"{ALLOWLIST_MAX}): lower the cap when you delete lines",
|
||||
len(allow) == ALLOWLIST_MAX)
|
||||
print(f"CI: UX-08 allowlist {len(allow)} entries "
|
||||
+ ", ".join(f"{k} {sum(1 for x in allow if x.startswith(k + ' '))}"
|
||||
for k in LIMITS))
|
||||
|
||||
|
||||
# ============================================================================== #
|
||||
print("\n4. DOCS.md holds the detail of every notification")
|
||||
DOCS = (HERE / "DOCS.md").read_text(encoding="utf-8")
|
||||
part = DOCS.split("\n## Notifications\n", 1)
|
||||
check("DOCS.md has a '## Notifications' section", len(part) == 2)
|
||||
section = part[-1].split("\n## ", 1)[0] if len(part) == 2 else ""
|
||||
heads = set(re.findall(r"^### (.+)$", section, re.M))
|
||||
missing = sorted({n.docs for n in SAMPLES} - heads)
|
||||
check(f"...with a heading for every Details line (missing: {missing})", not missing)
|
||||
|
||||
|
||||
# ============================================================================== #
|
||||
print("\n5. mutations: each rule catches a spoiled input")
|
||||
N = messages.Notice
|
||||
check("a six-word title is refused",
|
||||
bool(title_problems("The meter reading may be stuck")))
|
||||
check("a GoodWe prefix is refused", bool(title_problems("GoodWe: meter stuck")))
|
||||
check("a second sentence in a warning is refused",
|
||||
bool(body_problems("The battery lags. Check it.", messages.WARNING)))
|
||||
check("a semicolon run-on is refused, in an alarm too",
|
||||
bool(body_problems("It stopped; check it.", messages.ALARM)))
|
||||
check("a fourth sentence in an alarm is refused",
|
||||
bool(body_problems("One. Two. Three. Four.", messages.ALARM)))
|
||||
check("an internal name is refused", bool(body_problems(
|
||||
"The integrator reached its bound.", messages.WARNING)))
|
||||
check("a card id is refused", bool(body_problems(
|
||||
"See SAFETY-25.", messages.WARNING)))
|
||||
check("shouting is refused", bool(body_problems(
|
||||
"Control is STILL RUNNING.", messages.WARNING)))
|
||||
check("an entity id in an alarm is refused", bool(body_problems(
|
||||
"Check sensor.battery_power now.", messages.ALARM)))
|
||||
check("...but allowed in a refusal", not problems_of(
|
||||
"refusal", "Check sensor.battery_power in Developer Tools."))
|
||||
check("an option key in an alarm is refused", bool(body_problems(
|
||||
"Lower peak_soc_floor.", messages.ALARM)))
|
||||
check("a message that lost its Details line is refused",
|
||||
ends_in_details(N("x", messages.WARNING, "Meter stuck", "It is.")
|
||||
.data()["message"], "Meter stuck")
|
||||
and not ends_in_details("It is.", "Meter stuck"))
|
||||
_fake = " x = {'text': 'THE METER IS FROZEN AND NOTHING WORKS AT ALL'}\n"
|
||||
_n = [t for node in ast.walk(ast.parse(_fake.strip()))
|
||||
if isinstance(node, ast.Dict) for _, t in strings_in(node.values[0])]
|
||||
check("a new shouting status row is seen by the scan and is not listed",
|
||||
_n and problems_of("row", _n[0])
|
||||
and key_of("row", _n[0]) not in allow)
|
||||
_bad = ast.parse("await self.hass.call('persistent_notification', 'create', "
|
||||
"{'title': 'x', 'message': 'y'})", mode="exec")
|
||||
check("a hand-written notification payload is seen by part 2",
|
||||
any(not (isinstance(c.args[2], ast.Call)) for c in notification_calls(_bad)))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -114,6 +114,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from math import inf, isinf
|
||||
from pathlib import Path
|
||||
|
||||
from app import messages
|
||||
from app import peakguard
|
||||
from named_absence import first, given, nth, present
|
||||
|
||||
@@ -2522,13 +2523,21 @@ check("...and shortening the banner did NOT shorten the row: the full AC 3 "
|
||||
and "BMS cut-out" in rows25[0]["text"])
|
||||
|
||||
_body25 = hass25.last_message("goodwe_peak_soc_floor")
|
||||
|
||||
|
||||
def _says_scope(body):
|
||||
"""SAFETY-25 in messages.peak_reserve()'s words: the reserve binds the peak
|
||||
guard only, and home use goes on discharging below it to the cut-out."""
|
||||
return ("The reserve only applies to keeping the peak down" in body
|
||||
and "normal home use can still discharge the battery below it" in body
|
||||
and "down to its cut-out level" in body)
|
||||
|
||||
|
||||
print(f" notification body tail: ...{_body25[-180:]}")
|
||||
check("...and so does the persistent notification, which outlives the page and "
|
||||
"is the thing an operator reads on a phone at 19:00. It names what has "
|
||||
"stopped - the guard buying a lower bill - and what has not",
|
||||
"peak_soc_floor bounds THE PEAK GUARD ONLY" in _body25
|
||||
and "not a" in _body25 and "pack-wide reserve" in _body25
|
||||
and "BMS cut-out" in _body25)
|
||||
_says_scope(_body25))
|
||||
|
||||
|
||||
# --- 10f. THE CLAIM-SOURCE SET, PINNED --------------------------------------
|
||||
@@ -4332,8 +4341,8 @@ check("the notification an operator reads at 19:00 NAMES THE PACK that stopped "
|
||||
"the site, and names only that one. 'the battery is at its floor' on a "
|
||||
"site with two batteries is an instruction to go and look at both, and "
|
||||
"the wrong one is the one that is fine",
|
||||
"SAFETY-32" in _body32 and "inverter-b" in _body32
|
||||
and "inverter-a" not in _body32.split("WHICH PACK")[-1])
|
||||
"The battery of inverter-b is at its peak reserve" in _body32
|
||||
and "inverter-a" not in _body32)
|
||||
# The mirror again, on the SoC axis: swap which pack is empty and the name
|
||||
# swaps. A hardwired `self.inverters[1].name` passes the line above.
|
||||
_body32b = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
@@ -4341,8 +4350,8 @@ _body32b = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
check("...and with the SAME two readings swapped between the packs it names "
|
||||
"inverter-a instead - so the sentence follows the measurement rather "
|
||||
"than a position in the roster",
|
||||
"inverter-a" in _body32b
|
||||
and "inverter-b" not in _body32b.split("WHICH PACK")[-1])
|
||||
"The battery of inverter-a is at its peak reserve" in _body32b
|
||||
and "inverter-b" not in _body32b)
|
||||
|
||||
|
||||
# ⚠️ AC 5, THE OTHER HALF: THE SAME METHOD ON A ROSTER OF ONE MUST PRINT WHAT IT
|
||||
@@ -4366,13 +4375,14 @@ async def _notify32_one():
|
||||
|
||||
_body32one = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
_notify32_one())
|
||||
check("⚠️ AC 5. At a roster of ONE the same notification carries NO per-pack "
|
||||
"sentence at all - byte for byte the message SAFETY-25 shipped. There is "
|
||||
check("⚠️ AC 5. At a roster of ONE the same notification names NO pack - "
|
||||
"byte for byte the two-pack message minus the name (UX-08). There is "
|
||||
"one battery, naming it is noise, and this alarm reaches every existing "
|
||||
"install",
|
||||
"SAFETY-32" not in _body32one and "WHICH PACK" not in _body32one
|
||||
and _body32one == _body32.split("\n\nSAFETY-32")[0]
|
||||
and "peak_soc_floor bounds THE PEAK GUARD ONLY" in _body32one)
|
||||
"inverter-" not in _body32one
|
||||
and _body32one == _body32.replace("The battery of inverter-b is",
|
||||
"The battery is")
|
||||
and _says_scope(_body32one))
|
||||
|
||||
|
||||
# --- 12i. THE SIBLING ARGUMENT: `pack_floor_soc`, THE OTHER HALF OF THE PAIR --
|
||||
@@ -5604,8 +5614,7 @@ MUT.append(("rule 8's refusal naming a hardwired inverter",
|
||||
# 21. the notification naming a fixed roster position rather than the pack that
|
||||
# is actually at its floor.
|
||||
MUT.append(("the per-pack notification naming a fixed roster position",
|
||||
"inverter-a" in _body32b
|
||||
and "inverter-b" not in _body32b.split("WHICH PACK")[-1],
|
||||
"inverter-a" in _body32b and "inverter-b" not in _body32b,
|
||||
"part 12h: the same two readings swapped between the packs"))
|
||||
|
||||
# ---- the two PR #48 SURVIVORS, and why they survived -------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -666,7 +667,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:
|
||||
@@ -1020,6 +1023,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")
|
||||
@@ -1032,10 +1238,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",
|
||||
@@ -1142,6 +1347,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:")
|
||||
|
||||
@@ -75,9 +75,10 @@ print("1. the harness's own preconditions")
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import rehearse_roster as RR # noqa: E402
|
||||
|
||||
check("the tool refuses rather than passing when it cannot run: its three exit "
|
||||
"codes are distinct and 2 is not 0",
|
||||
(RR.EXIT_OK, RR.EXIT_FINDING, RR.EXIT_COULD_NOT_RUN) == (0, 1, 2))
|
||||
check("the tool refuses rather than passing when it cannot run: its four exit "
|
||||
"codes are distinct and only 0 is a pass",
|
||||
(RR.EXIT_OK, RR.EXIT_FINDING, RR.EXIT_COULD_NOT_RUN,
|
||||
RR.EXIT_DEGRADED) == (0, 1, 2, 3))
|
||||
|
||||
# ⚠️ THE ANCHOR. `app/mqtt.py` records `sensor.goodwe_p1_sample_age` as VERIFIED
|
||||
# against the live system on 2026-08-26, and the ESP32's ingest guard subscribes
|
||||
@@ -204,6 +205,10 @@ def mutant_tree(edits) -> str:
|
||||
shutil.copytree(FIX, dest / "tools" / "rehearsal", ignore=ignore)
|
||||
shutil.copyfile(HERE / "tools" / "rehearse_roster.py",
|
||||
dest / "tools" / "rehearse_roster.py")
|
||||
# The input-shape gate (SAFETY-62) and the option names it checks against.
|
||||
shutil.copyfile(HERE / "tools" / "predeploy_check.py",
|
||||
dest / "tools" / "predeploy_check.py")
|
||||
shutil.copyfile(HERE / "config.yaml", dest / "config.yaml")
|
||||
for rel, old, new in edits:
|
||||
path = dest / rel
|
||||
text = path.read_text(encoding="utf-8")
|
||||
@@ -356,8 +361,9 @@ check("omitting --live-entities REFUSES with exit 2 rather than reporting a "
|
||||
_no_code == RR.EXIT_COULD_NOT_RUN and "REFUSED" in _no_out + _no_err)
|
||||
|
||||
_deg_code, _deg_out, _ = raw("--no-live-entities")
|
||||
check("...and the explicit opt-out RUNS, so the escape hatch exists",
|
||||
_deg_code in (RR.EXIT_OK, RR.EXIT_FINDING))
|
||||
check("...and the explicit opt-out RUNS, so the escape hatch exists - but a "
|
||||
"clean degraded run is exit 3, never 0 (SAFETY-62)",
|
||||
_deg_code == RR.EXIT_DEGRADED and "NO FINDINGS" in _deg_out)
|
||||
check("⚠️ ...carrying a standing banner in the report HEADER and again in "
|
||||
"the closing block, because a report is skimmed from the bottom",
|
||||
_deg_out.count("DEGRADED RUN") >= 2
|
||||
|
||||
@@ -90,9 +90,9 @@ guard has to improve on, which is the useful thing a 2-day replay can say.
|
||||
|
||||
The two days are committed here as `scenarios/p1-net-<date>.jsonl.gz`
|
||||
(`{"t": iso8601, "w": signed_watts}` per line, derived from the HA recorder
|
||||
export) for the reason `scenarios/p1-age-2026-08-26.jsonl.gz` already is: the
|
||||
infra repo is private, CI's token is minted for this repository only, and a
|
||||
regression fixture that CI cannot see is not a regression fixture.
|
||||
export), as `scenarios/p1-age-2026-08-26.jsonl.gz` already is. When they were
|
||||
committed, CI could not see the infra repo; since CI-12 it checks admin/goodwe
|
||||
out at `.gitea/infra-pin`, but these fixtures stay here with the tests.
|
||||
|
||||
The recorder's window rolls: 2026-08-01 leaves it around 2026-09-15, which is
|
||||
why the committed copy is the artefact and not the API call.
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""SAFETY-60/62: the operator tools refuse input they cannot read. `python3 test_tool_inputs.py`
|
||||
|
||||
Each tool is driven as the operator runs it, first on good input (the accepted
|
||||
baseline, MUTATION.md habit 7) and then on the shapes that used to pass.
|
||||
"""
|
||||
import contextlib
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
TOOLS = HERE / "tools"
|
||||
FIX = TOOLS / "rehearsal"
|
||||
CAPTURE = HERE / "scenarios" / "safety57-2026-09-21" / \
|
||||
"bus-a-2026-09-21-1030-1110.log.gz"
|
||||
TMP = tempfile.mkdtemp(prefix="test_tool_inputs.")
|
||||
ENV = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, ok):
|
||||
print(f" {'ok ' if ok else 'FAIL'} {name}")
|
||||
if not ok:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def dump(name, obj=None, text=None):
|
||||
p = Path(TMP) / name
|
||||
p.write_text(text if text is not None else json.dumps(obj, indent=1),
|
||||
encoding="utf-8")
|
||||
return str(p)
|
||||
|
||||
|
||||
def tool(script, *args):
|
||||
r = subprocess.run([sys.executable, str(TOOLS / script), *args],
|
||||
capture_output=True, text=True, env=ENV)
|
||||
return r.returncode, r.stdout + r.stderr
|
||||
|
||||
|
||||
def ha_apps_info(options):
|
||||
"""What `ha apps info <slug> --raw-json` prints: the options sit under data.options."""
|
||||
text = {"en": ("Setpoint entity", "The ESPHome number carrying the setpoint."),
|
||||
"nl": ("Setpoint-entiteit", "Het ESPHome-getal met het setpoint.")}
|
||||
return {"result": "ok", "data": {
|
||||
"name": "GoodWe RS485 Controller", "slug": "local_goodwe_controller",
|
||||
"version": "0.12.0", "version_latest": "0.12.0", "state": "started",
|
||||
"boot": "auto", "ingress": True, "hassio_api": True,
|
||||
"options": copy.deepcopy(options),
|
||||
"schema": [{"name": k, "required": True, "optional": False,
|
||||
"type": "string"} for k in options],
|
||||
"translations": {lang: {"configuration": {
|
||||
"setpoint_entity": {"name": name, "description": desc},
|
||||
"inverters": {"name": "Inverters", "description": "Roster.",
|
||||
"fields": {"setpoint_entity": {
|
||||
"name": name, "description": f"{desc} (per unit)"}}}}}
|
||||
for lang, (name, desc) in text.items()}}}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. predeploy_check.py --options (SAFETY-60)")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# A deployed version that differs from the shipped one, so only the options half decides.
|
||||
BARE = {"setpoint_entity": "number.goodwe_setpoint_w", "inverters": [],
|
||||
"max_w": 5000, "slew_w": 0, "gain": 0.6, "target_grid_w": -10,
|
||||
"deadband_w": 15, "meter_source": "off", "log_level": "info"}
|
||||
NOREPO = tempfile.mkdtemp(prefix="norepo.", dir=TMP)
|
||||
|
||||
|
||||
def gate(opts_path):
|
||||
return tool("predeploy_check.py", "--deployed-version", "0.0.0-test",
|
||||
"--repo", NOREPO, "--options", opts_path)
|
||||
|
||||
|
||||
code, out = gate(dump("bare.json", BARE))
|
||||
print(f" bare options -> exit {code}")
|
||||
check("baseline: the bare options object is CLEARED (exit 0)",
|
||||
code == 0 and "CLEARED for deploy" in out)
|
||||
code, out = gate(dump("bare-bad.json", dict(BARE, slew_w=5000)))
|
||||
check("baseline: the bare object with slew_w 5000 is REFUSED by the validator",
|
||||
code == 1 and "slew_w is 5000 W" in out)
|
||||
|
||||
for label, doc in (("wrapped", ha_apps_info(BARE)),
|
||||
("wrapped, slew_w 5000", ha_apps_info(dict(BARE, slew_w=5000)))):
|
||||
code, out = gate(dump("wrapped.json", doc))
|
||||
print(f" {label} -> exit {code}")
|
||||
check(f"the `ha apps info` capture ({label}) is REFUSED, exit 1",
|
||||
code == 1 and "CLEARED" not in out)
|
||||
check("...naming the shape and the fix, not a validator rule",
|
||||
"0 of 2 top-level key(s) recognised" in out and ".data.options" in out
|
||||
and "VALIDATOR" not in out)
|
||||
|
||||
from app.validate import actuator_writers, validate_startup_config # noqa: E402
|
||||
_w = ha_apps_info(dict(BARE, slew_w=5000))
|
||||
print(f" (for reference: the validator alone, handed that capture, finds "
|
||||
f"{len(validate_startup_config(_w))} problem(s) and "
|
||||
f"{len(actuator_writers(_w))} 'actuators' - the gate has to refuse it)")
|
||||
|
||||
code, out = gate(dump("list.json", [BARE]))
|
||||
check("a list-wrapped options object is REFUSED, naming the type",
|
||||
code == 1 and "is a JSON list, not an object" in out)
|
||||
code, out = gate(dump("mostly-foreign.json",
|
||||
{"setpoint_entity": "number.x", "a": 1, "b": 2}))
|
||||
check("an object with fewer than half its keys recognised is REFUSED",
|
||||
code == 1 and "1 of 3 top-level key(s) recognised" in out)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. rehearse_roster.py --state/--before/--after (SAFETY-62)")
|
||||
# --------------------------------------------------------------------------- #
|
||||
sys.path.insert(0, str(TOOLS))
|
||||
import rehearse_roster as RR # noqa: E402
|
||||
|
||||
STATE = FIX / "state-legacy.json"
|
||||
LIVE = ["--live-entities", str(FIX / "live-entities-recorded.txt"),
|
||||
"--reference", str(FIX / "announced-reference.txt")]
|
||||
|
||||
|
||||
def rehearse(state=str(STATE), before=str(FIX / "flat.yaml"),
|
||||
after=str(FIX / "roster.yaml"), extra=LIVE):
|
||||
return tool("rehearse_roster.py", "--state", state, "--before", before,
|
||||
"--after", after, *extra)
|
||||
|
||||
|
||||
code, out = rehearse()
|
||||
print(f" control -> exit {code}")
|
||||
check("baseline: the control run reports its 2 findings, exit 1",
|
||||
code == RR.EXIT_FINDING and "2 FINDING(S)" in out)
|
||||
code, out = rehearse(state=str(FIX / "state-fresh.json"))
|
||||
check("baseline: a fresh (comment-only) state file still runs clean, exit 0",
|
||||
code == RR.EXIT_OK and "NO FINDINGS" in out)
|
||||
|
||||
raw = STATE.read_text(encoding="utf-8")
|
||||
state = json.loads(raw)
|
||||
cases = {
|
||||
"wrapped": (dump("s-wrapped.json", {"result": "ok", "data": state}),
|
||||
"0 of 2 top-level key(s) recognised"),
|
||||
"truncated by 20 bytes": (dump("s-trunc.json", text=raw[:-20]),
|
||||
"not a readable JSON state file"),
|
||||
"list-wrapped": (dump("s-list.json", [state]),
|
||||
"is a JSON list, not an object"),
|
||||
}
|
||||
for label, (path, words) in cases.items():
|
||||
code, out = rehearse(state=path)
|
||||
print(f" state {label} -> exit {code}")
|
||||
check(f"state {label}: REFUSED with exit 2 and the reason",
|
||||
code == RR.EXIT_COULD_NOT_RUN and words in out
|
||||
and "NO FINDINGS" not in out)
|
||||
|
||||
try:
|
||||
_auto = RR.load_state(dump("s-auto.json", {"auto": True}))
|
||||
except RR.Refused as err:
|
||||
_auto = str(err)
|
||||
check("a state holding only the Automatic toggle (`auto`, app/main.py) is "
|
||||
"recognised as a store", _auto == {"auto": True})
|
||||
|
||||
flat = RR.load_options(str(FIX / "flat.yaml"))
|
||||
roster = RR.load_options(str(FIX / "roster.yaml"))
|
||||
for side, kw in (("--before", {"before": dump("b.json", ha_apps_info(flat))}),
|
||||
("--after", {"after": dump("a.json", ha_apps_info(roster))})):
|
||||
code, out = rehearse(**kw)
|
||||
print(f" {side} wrapped -> exit {code}")
|
||||
check(f"{side} as an `ha apps info` capture: REFUSED with exit 2",
|
||||
code == RR.EXIT_COULD_NOT_RUN and ".data.options" in out
|
||||
and "NO FINDINGS" not in out)
|
||||
|
||||
code, out = rehearse(after=str(FIX / "roster-inverter-a.yaml"),
|
||||
extra=["--no-live-entities", "--reference",
|
||||
str(FIX / "announced-reference.txt")])
|
||||
check("a clean --no-live-entities run is exit 3, not 0",
|
||||
code == RR.EXIT_DEGRADED and "NO FINDINGS" in out)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. alloc_sim.py - the split-invariance verdict reaches the exit code")
|
||||
# --------------------------------------------------------------------------- #
|
||||
spec = importlib.util.spec_from_file_location("_alloc_sim", TOOLS / "alloc_sim.py")
|
||||
AS = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(AS)
|
||||
|
||||
|
||||
class _Run:
|
||||
"""One rig run, reduced to what `splits()` reads."""
|
||||
|
||||
def __init__(self, grid):
|
||||
self.samples = [{"shares": [1000.0, 1000.0]}]
|
||||
self._grid = grid
|
||||
|
||||
def grids(self):
|
||||
return self._grid
|
||||
|
||||
def targets(self):
|
||||
return [2000.0] * len(self._grid)
|
||||
|
||||
|
||||
class _Sitesim:
|
||||
def load_addon(self, *_):
|
||||
return None
|
||||
|
||||
|
||||
def alloc_main(same):
|
||||
"""`main(--splits)` with the rig stubbed; the real `splits()` grades the traces."""
|
||||
seen = []
|
||||
|
||||
def fake(_sitesim, _control, mode, *a, **kw):
|
||||
seen.append(mode)
|
||||
return _Run([0.0, 1.0] if same or len(seen) == 1 else [0.0, 2.0])
|
||||
|
||||
AS.find_sim = lambda: Path("/nonexistent-rig")
|
||||
AS.load = lambda _sim: (None, _Sitesim(), None, None)
|
||||
AS.run_site_feedback = fake
|
||||
os.environ["GOODWE_ADDON_REPO"] = str(HERE.parent)
|
||||
sys.argv = ["alloc_sim.py", "--splits"]
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = AS.main()
|
||||
return rc, buf.getvalue()
|
||||
|
||||
|
||||
rc, out = alloc_main(same=True)
|
||||
check("baseline: identical traces HOLD, exit 0",
|
||||
rc == 0 and "split-invariance HOLDS" in out)
|
||||
rc, out = alloc_main(same=False)
|
||||
print(f" differing traces -> exit {rc}")
|
||||
check("differing traces FAIL, exit 1",
|
||||
rc == 1 and "split-invariance FAILS" in out)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. decode_bus_capture.py - a file that is not a capture")
|
||||
# --------------------------------------------------------------------------- #
|
||||
code, out = tool("decode_bus_capture.py", str(CAPTURE))
|
||||
check("baseline: the committed capture decodes, exit 0",
|
||||
code == 0 and "writes 1200" in out and "REFUSED" not in out)
|
||||
junk = dump("junk.log", text="hello\nthis is not a capture\n")
|
||||
code, out = tool("decode_bus_capture.py", junk)
|
||||
print(f" junk -> exit {code}")
|
||||
check("a junk file is REFUSED, exit 1",
|
||||
code == 1 and "REFUSED" in out and "0 of 2 line(s) decoded" in out)
|
||||
code, out = tool("decode_bus_capture.py", dump("empty.log", text=""))
|
||||
check("an empty file is REFUSED, exit 1",
|
||||
code == 1 and "0 of 0 line(s) decoded" in out)
|
||||
code, out = tool("decode_bus_capture.py", str(CAPTURE), junk)
|
||||
check("several paths, as the docstring's glob passes them: each is decoded and "
|
||||
"one bad file makes the run exit 1",
|
||||
code == 1 and f"== {CAPTURE}" in out and f"== {junk}" in out)
|
||||
code, out = tool("decode_bus_capture.py", str(CAPTURE), str(CAPTURE))
|
||||
check("...and two good files exit 0", code == 0 and out.count("REFUSED") == 0)
|
||||
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all tool-input checks passed")
|
||||
@@ -22,6 +22,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from named_absence import nth, present
|
||||
|
||||
from app import messages # noqa: E402
|
||||
from app.watchdog import (
|
||||
SignWatchdog, SignWatchdogConfig, loop_is_following,
|
||||
)
|
||||
@@ -1055,8 +1056,8 @@ async def _circ_restart():
|
||||
await a.read_inputs()
|
||||
await a.cycle()
|
||||
await asyncio.sleep(0.25)
|
||||
# THE RESTART. Same store, brand new Controller and Hass - which is exactly
|
||||
# what a Home Assistant restart does to this add-on. The house has stopped
|
||||
# THE RESTART. Same store, brand new Controller and Hass - an ADD-ON
|
||||
# restart. A Core-only restart keeps the process; graded below (SAFETY-34). The house has stopped
|
||||
# circulating in the meantime, so nothing in the new process's own
|
||||
# measurements could re-derive the trip.
|
||||
h2 = _CircHass(a_w=0.0, b_w=0.0)
|
||||
@@ -1105,17 +1106,49 @@ check("...under a fixed notification_id of its own, so a second episode "
|
||||
for c in _notes1 + _notes2)
|
||||
and len({c[2]["notification_id"] for c in _notes1 + _notes2}) == 1)
|
||||
_msg = nth(_notes2, 0)[2]["message"]
|
||||
|
||||
|
||||
def _says_three(m):
|
||||
"""UX-08: the three things only this alarm can say, in its plain words
|
||||
(messages.circulating): the meter cannot see it, control is stopped at
|
||||
0 W, and if the grid does not move a person at the isolator is next."""
|
||||
return ("meter cannot see this" in m and "Control stays stopped" in m
|
||||
and "Every inverter is set to 0 W" in m and "isolator" in m
|
||||
and "if the grid power does not change" in m)
|
||||
|
||||
|
||||
check("...and the message tells the operator the three things only this alarm "
|
||||
"can: that the meter cannot see it, that control has been stopped, and "
|
||||
"that if the grid reading does not move afterwards one inverter is no "
|
||||
"longer listening and a human at the isolator is the last actuator",
|
||||
"METER CANNOT SEE" in _msg and "CONTROL HAS BEEN STOPPED" in _msg
|
||||
and "isolator" in _msg)
|
||||
_says_three(_msg))
|
||||
check("...and it does not raise the same card on every cycle - a per-PROCESS "
|
||||
"flag, deliberately not persisted, is what makes 'has a human been shown "
|
||||
"this' a different question from 'is this site tripped'",
|
||||
len(_notes2) == 1)
|
||||
|
||||
# SAFETY-34: a Core-ONLY restart. The add-on process lives on (measured on
|
||||
# TEST-23), HA's memory is wiped, so the SAME process must re-assert the card.
|
||||
import app.main as _main_mod # noqa: E402
|
||||
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
|
||||
_main_mod._mono = lambda: _clock[0]
|
||||
try:
|
||||
_h3 = _CircHass(a_w=0.0, b_w=0.0)
|
||||
_b.hass = _h3
|
||||
asyncio.run(_b.check_circulating())
|
||||
_early = [c for c in _h3.calls if c[0] == "persistent_notification"]
|
||||
_clock[0] += _main_mod.REANNOUNCE_S
|
||||
asyncio.run(_b.check_circulating())
|
||||
_late = [c for c in _h3.calls if c[0] == "persistent_notification"]
|
||||
finally:
|
||||
_main_mod._mono = _real_mono
|
||||
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise - no "
|
||||
"storm on the event bus", len(_early) == 0)
|
||||
check("SAFETY-34: a Core-only restart wipes the card and the add-on process "
|
||||
"survives it, so after REANNOUNCE_S the SAME process re-asserts it",
|
||||
len(_late) == 1
|
||||
and _late[0][2]["notification_id"] == "goodwe_circulating_power")
|
||||
|
||||
|
||||
print()
|
||||
print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND")
|
||||
@@ -1240,8 +1273,8 @@ check("PRECONDITION: both runs actually tripped and actually raised the card, "
|
||||
and len(_notes_g) == 1 and len(_notes_s) == 1)
|
||||
|
||||
# --- AC 4: THE ATTRIBUTION ITSELF -------------------------------------------
|
||||
_WHO_G = "Right now garage is discharging while shed charges."
|
||||
_WHO_S = "Right now shed is discharging while garage charges."
|
||||
_WHO_G = "garage was discharging while shed was charging"
|
||||
_WHO_S = "shed was discharging while garage was charging"
|
||||
check("⚠️ THE ALARM NAMES THE TWO PACKS AND SAYS WHICH WAY EACH ONE IS PULLING. "
|
||||
"`circulation.py` calls this the first thing somebody woken at 3 a.m. "
|
||||
"needs and impossible to reconstruct from the watts alone, and until this "
|
||||
@@ -1262,12 +1295,14 @@ check("...and the RESTING pack is not named. `cellar` is on the roster, is read
|
||||
"beside a 6 kW loop is not reported as one of its two participants",
|
||||
"cellar" not in _msg_g and "cellar" not in _msg_s
|
||||
and _cg.inverters[2].batt == _REST_W)
|
||||
check("...and the sentence sits with the figure, not instead of it: the same "
|
||||
"card still carries the watts, the window and the three things only this "
|
||||
"alarm can say",
|
||||
all("CIRCULATING_POWER" in m and "METER CANNOT SEE" in m
|
||||
and "CONTROL HAS BEEN STOPPED" in m and "isolator" in m
|
||||
for m in (_msg_g, _msg_s)))
|
||||
# UX-08: the watts and the window moved out of the card into the reason the
|
||||
# log line and the status row carry; the card keeps the three things.
|
||||
check("...and the sentence sits with the three things only this alarm can say, "
|
||||
"while the watts and the window stay in the reason behind it",
|
||||
all(_says_three(m) for m in (_msg_g, _msg_s))
|
||||
and all("CIRCULATING_POWER" in c.circ_wd.reason
|
||||
and "W measured going round" in c.circ_wd.reason
|
||||
for c in (_cg, _cs)))
|
||||
print()
|
||||
print(" the operator's press is the only way out, and it clears BOTH halves")
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
python3 tools/alloc_sim.py --sweep # AC 3: gain to 1.0 at every split
|
||||
python3 tools/alloc_sim.py --all
|
||||
|
||||
Exit 0 only if every split-invariance run HOLDS; 1 if one FAILS; 2 if no rig.
|
||||
|
||||
# DEV-02: the split-invariance finding ALLOC-01's review handed on, and the
|
||||
# sensing change that closed it. The two rows are the evidence.
|
||||
python3 tools/alloc_sim.py --splits --gain 0.4 --feedback unit # 250 W apart
|
||||
@@ -279,13 +281,14 @@ def main():
|
||||
if a.table or a.all:
|
||||
table(sitesim, control, watchdog, a.cycles, a.load)
|
||||
print()
|
||||
held = True
|
||||
if a.splits or a.all:
|
||||
splits(sitesim, control, watchdog, a.cycles, a.load, a.gain,
|
||||
a.feedback)
|
||||
held = splits(sitesim, control, watchdog, a.cycles, a.load, a.gain,
|
||||
a.feedback)
|
||||
print()
|
||||
if a.sweep or a.all:
|
||||
sweep(sitesim, control, watchdog, a.cycles, a.load)
|
||||
return 0
|
||||
return 0 if held else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -195,14 +195,37 @@ def _hms(t):
|
||||
return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:06.3f}"
|
||||
|
||||
|
||||
def unreadable(stats):
|
||||
"""Why a capture decoded to too little to trust, or None: most lines must be frames."""
|
||||
framed = sum(stats[k] for k in ("requests", "responses", "writes",
|
||||
"write_echoes"))
|
||||
if framed > 0 and stats["lines"] - framed <= framed:
|
||||
return None
|
||||
return (f"{framed} of {stats['lines']} line(s) decoded as frames "
|
||||
f"(unparsed {stats['unparsed']}, bad_crc {stats['bad_crc']}, "
|
||||
f"unknown {stats['unknown']}) - not a capture this decoder can read")
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 2:
|
||||
"""Decode each capture named; exit 1 if any is unreadable, 2 on no argument."""
|
||||
if len(argv) < 2:
|
||||
print(__doc__.splitlines()[2].strip())
|
||||
return 2
|
||||
events, stats = decode(argv[1])
|
||||
print(f"== {argv[1]}")
|
||||
rc = 0
|
||||
for path in argv[1:]:
|
||||
rc |= _report(path)
|
||||
return rc
|
||||
|
||||
|
||||
def _report(path):
|
||||
events, stats = decode(path)
|
||||
print(f"== {path}")
|
||||
for k, v in stats.items():
|
||||
print(f" {k:14} {v}")
|
||||
bad = unreadable(stats)
|
||||
if bad:
|
||||
print(f"REFUSED: {path}: {bad}")
|
||||
return 1
|
||||
for kind in ("setpoint_w", "batt_w", "soc_pct"):
|
||||
rows = series(events, kind)
|
||||
if not rows:
|
||||
|
||||
@@ -39,8 +39,10 @@ and not at merge. The check belongs where the mistake is made.
|
||||
|
||||
⚠️ WHAT THIS DOES NOT DO. It does not read the live box. The options blob is
|
||||
reachable only through the Supervisor API, which needs a supervisor token, so
|
||||
the operator exports it and passes `--options`. Without that flag the validator
|
||||
half is SKIPPED and says so - a skipped check must never look like a passed one.
|
||||
the operator exports it and passes `--options` - the bare options object
|
||||
(`.data.options` of `ha apps info <slug> --raw-json`); any other shape is
|
||||
refused. Without that flag the validator half is SKIPPED and says so - a
|
||||
skipped check must never look like a passed one.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
@@ -79,6 +81,43 @@ def shipped_version(tree=ADDON):
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def option_keys(addon=ADDON):
|
||||
"""Top-level option names in `addon`'s config.yaml `options:`/`schema:` blocks."""
|
||||
keys, section = set(), None
|
||||
with open(os.path.join(addon, "config.yaml"), encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
if not line.strip() or line.startswith("#"):
|
||||
continue
|
||||
if not line[0].isspace():
|
||||
section = line.split(":", 1)[0].strip()
|
||||
continue
|
||||
m = re.match(r" ([A-Za-z_][A-Za-z0-9_]*):", line)
|
||||
if m and section in ("options", "schema"):
|
||||
keys.add(m.group(1))
|
||||
return keys
|
||||
|
||||
|
||||
def shape_problem(doc, known, what, allow_empty=False):
|
||||
"""Why `doc` is not a document of `known` keys, or None (SAFETY-60/62).
|
||||
|
||||
Positive property: a JSON object, at least half of whose top-level keys
|
||||
(`_`-prefixed comments aside) `known(key)` recognises.
|
||||
"""
|
||||
if not isinstance(doc, dict):
|
||||
return f"{what} is a JSON {type(doc).__name__}, not an object"
|
||||
keys = [k for k in doc if not str(k).startswith("_")]
|
||||
hits = [k for k in keys if known(k)]
|
||||
if (keys or allow_empty) and len(hits) * 2 >= len(keys):
|
||||
return None
|
||||
msg = (f"{what} is not the expected document: {len(hits)} of {len(keys)} "
|
||||
f"top-level key(s) recognised {sorted(map(str, keys))[:8]}")
|
||||
inner = doc.get("data")
|
||||
if isinstance(inner, dict) and isinstance(inner.get("options"), dict):
|
||||
msg += ("; this is `ha apps info --raw-json` output - pass its "
|
||||
"`.data.options` instead")
|
||||
return msg
|
||||
|
||||
|
||||
def is_runtime(path):
|
||||
return any(path == p or path.startswith(p) for p in RUNTIME)
|
||||
|
||||
@@ -256,6 +295,12 @@ def main():
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
||||
problems.append(f"--options file could not be read as JSON "
|
||||
f"({type(exc).__name__}: {exc}); the config was NOT checked")
|
||||
# SAFETY-60: the validator accepts any dict, so the shape is checked here.
|
||||
bad = (shape_problem(opts, option_keys().__contains__, "--options")
|
||||
if opts is not None else None)
|
||||
if bad:
|
||||
problems.append(f"{bad}; the config was NOT checked")
|
||||
opts = None
|
||||
if a.options and opts is not None:
|
||||
try:
|
||||
from app.validate import validate_startup_config
|
||||
@@ -281,7 +326,7 @@ def main():
|
||||
print(f" - {p}")
|
||||
else:
|
||||
print(" live options pass this version's validator")
|
||||
else:
|
||||
elif not a.options:
|
||||
skipped.append("--options was not given, so the live config was NEVER CHECKED "
|
||||
"against this version's validator. A config refused at startup is "
|
||||
"exit 78. Export the options and re-run before deploying.")
|
||||
|
||||
@@ -46,8 +46,11 @@ EXIT CODES
|
||||
1 ⭐ AT LEAST ONE OF THOSE HAPPENED. This is the code that must be
|
||||
reachable: `test_rehearse.py` mutates the shipped migration nine
|
||||
different ways and requires this exit code for each.
|
||||
2 the rehearsal could not be performed (bad file, unparseable config, the
|
||||
entity-id model failed its own anchor). ⚠️ NOT A PASS.
|
||||
2 the rehearsal could not be performed (bad file, unparseable config, a
|
||||
state or options file of the wrong shape, the entity-id model failed
|
||||
its own anchor). ⚠️ NOT A PASS.
|
||||
3 no findings, but --no-live-entities: the orphan check was not graded
|
||||
against the install. ⚠️ NOT A PASS.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -64,10 +67,12 @@ from app.inverter import UNIT_A, UNIT_STORE_KEYS, Inverter, roster_names # noqa
|
||||
from app.main import Controller # noqa: E402
|
||||
from app import mqtt # noqa: E402
|
||||
from app.p1 import is_enabled # noqa: E402
|
||||
from app.store import (DEFAULTS, MAINT_UNIT_KEYS, Store, # noqa: E402
|
||||
unit_key)
|
||||
from app.store import (DEFAULTS, MAINT_SCHEMA_KEY, MAINT_UNIT_KEYS, # noqa: E402
|
||||
SCHEMA_KEY, Store, unit_key)
|
||||
from predeploy_check import option_keys, shape_problem # noqa: E402
|
||||
|
||||
EXIT_OK, EXIT_FINDING, EXIT_COULD_NOT_RUN = 0, 1, 2
|
||||
EXIT_OK, EXIT_FINDING, EXIT_COULD_NOT_RUN, EXIT_DEGRADED = 0, 1, 2, 3
|
||||
ADDON = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
@@ -184,8 +189,30 @@ def load_options(path: str) -> dict:
|
||||
obj = _mini_yaml(text)
|
||||
else:
|
||||
obj = yaml.safe_load(text)
|
||||
if not isinstance(obj, dict):
|
||||
raise Refused(f"{path}: options must be a mapping, got {type(obj).__name__}")
|
||||
# SAFETY-62: a wrapped export is a dict too, so assert the keys are options.
|
||||
bad = shape_problem(obj, option_keys(str(ADDON)).__contains__, path)
|
||||
if bad:
|
||||
raise Refused(bad)
|
||||
return obj
|
||||
|
||||
|
||||
_UNIT_PREFIX = unit_key("\0", "").split("\0")[0]
|
||||
# "auto" is the Automatic toggle, written by app/main.py outside DEFAULTS.
|
||||
_STORE_KEYS = {*DEFAULTS, *MAINT_UNIT_KEYS, SCHEMA_KEY, MAINT_SCHEMA_KEY, "auto"}
|
||||
|
||||
|
||||
def load_state(path: str) -> dict:
|
||||
"""Refuse a state file `Store.load()` would silently start fresh on (SAFETY-62)."""
|
||||
try:
|
||||
obj = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except ValueError as err:
|
||||
raise Refused(f"{path}: not a readable JSON state file ({err}); "
|
||||
"Store.load() would start fresh and grade an empty store")
|
||||
bad = shape_problem(
|
||||
obj, lambda k: k in _STORE_KEYS or str(k).startswith(_UNIT_PREFIX),
|
||||
path, allow_empty=True)
|
||||
if bad:
|
||||
raise Refused(bad)
|
||||
return obj
|
||||
|
||||
|
||||
@@ -1061,6 +1088,7 @@ def main(argv=None) -> int:
|
||||
if args.live_entities else None)
|
||||
if not Path(args.state).is_file():
|
||||
raise Refused(f"{args.state}: not a file")
|
||||
load_state(args.state)
|
||||
except (OSError, Refused) as err:
|
||||
print(f"REFUSED: {err}", file=sys.stderr)
|
||||
return EXIT_COULD_NOT_RUN
|
||||
@@ -1179,7 +1207,9 @@ def main(argv=None) -> int:
|
||||
print(" the install's own entity list. This result does not cover "
|
||||
"orphaned entities.")
|
||||
print("=" * 78)
|
||||
return EXIT_FINDING if findings else EXIT_OK
|
||||
if findings:
|
||||
return EXIT_FINDING
|
||||
return EXIT_DEGRADED if degraded else EXIT_OK
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# UX-08 ratchet (test_messages.py part 3). Each line is a user-facing string that
|
||||
# does not follow STYLE.md yet: "<kind> <sha1-12 of its text> # where :: start".
|
||||
# These are waiting for phase 2b (row, banner), 2c (refusal, option-name,
|
||||
# option-desc) and 2d (log). Delete the line when you rewrite the string, because
|
||||
# a stale line fails the test. Never add a line for a new string: write the new
|
||||
# string to STYLE.md instead.
|
||||
|
||||
banner c185022e391f # main.py banned: SAFETY-25; option key: peak_soc_floor; capitals: THE PEAK GUARD ONLY :: '{…} - and peak_soc_floor bounds THE PEAK GUARD ONLY (SAFETY-25, see be'
|
||||
banner 4a246fc26def # main.py over 12 words (14); capitals: CIRCULATING POWER :: 'CIRCULATING POWER — the inverters were pushing against each other. Sit'
|
||||
banner 750ccd4f2126 # main.py over 12 words (14) :: "FAILSAFE — the site's total command opposed the meter. Every inverter "
|
||||
banner 637f334f130a # main.py over 12 words (26) :: 'FAILSAFE — the command opposed the meter. Inverter held at 0 W. / FAIL'
|
||||
banner 0a3d2d9c4232 # main.py capitals: NOT READY :: "NOT READY — {bad[0].get('banner') or bad[0]['text']}"
|
||||
banner dbcc227a9589 # main.py over 12 words (26); capitals: NOT DELIVERING :: 'NOT DELIVERING — {_nd} is standing at a share it is not producing. The'
|
||||
log a5e99679fd80 # main.py banned: SAFETY-02, latch :: 'SAFETY-02: the per-inverter store migration did not verify (%s). The l'
|
||||
log 128b18b32d86 # main.py over 25 words (41); banned: LATCHED, SAFETY-44, latched :: 'SAFETY-44: this store carries a LATCHED sign-disagreement failsafe und'
|
||||
log f60446541ae5 # main.py over 25 words (83); banned: LATCH, LATCHED, SAFETY-02, latch; capitals: LATCH GUARD :: 'SAFETY-02 LATCH GUARD: this store carries a LATCHED sign-disagreement '
|
||||
log 04d06bf223c8 # main.py over 25 words (73); banned: REGISTER, SAFETY-37, SAFETY-57, register; capitals: WHICH IS NOT BY ITSELF :: 'SAFETY-37: %s battery-power reading did not move under a command that '
|
||||
log aec01642bd07 # main.py banned: SAFETY-57 :: 'SAFETY-57: %s'
|
||||
log aebbca17acb3 # main.py over 25 words (52); banned: allocator :: 'write_setpoint() was asked to put %+.0f W on every one of %d inverters'
|
||||
log b74c3018461e # main.py over 25 words (30); banned: SAFETY-28, allocator :: 'SAFETY-28 REFUSED a write of %+.0f W to %s: %s. Commanding 0 W instead'
|
||||
log cefffafa8b27 # main.py over 25 words (31) :: 'the write of %+.0f W to %s (%s) did not complete in %.0f s and was aba'
|
||||
log 1001c6250b85 # main.py over 25 words (29); banned: SAFETY-02 :: 'SAFETY-02 TRIPPED on %s - %s. Commanding 0 W on that inverter and hold'
|
||||
log 8252ef5eefad # main.py over 25 words (48); banned: SAFETY-30 :: 'SAFETY-30 TRIPPED at the SITE - %s. That is the SUM of every standing '
|
||||
log 8a65cf945e00 # main.py over 25 words (32); banned: SAFETY-31 :: 'SAFETY-31 TRIPPED - %s. Commanding 0 W on every inverter and holding i'
|
||||
log 5d2beed2f1a2 # main.py over 25 words (65); banned: HW-01, SAFETY-42; capitals: NOTHING IS COMMANDED DIFFERENTLY :: 'SAFETY-42 TRIPPED on %s - %s. NOTHING IS COMMANDED DIFFERENTLY: this g'
|
||||
log d50b775b9cd4 # main.py banned: ARBITER; capitals: ARBITER SCOPE :: 'ARBITER SCOPE: %s'
|
||||
log 749c2c6fe81d # main.py over 25 words (26); banned: SAFETY-10, heartbeat; capitals: SHADOW MODE IS ON :: 'SAFETY-10 SHADOW MODE IS ON. The full decision path runs and NOTHING i'
|
||||
log 4d271dfc6f1c # main.py banned: SAFETY-10 :: 'SAFETY-10 shadow mode is OFF. This controller is commanding the batter'
|
||||
log 730387e0da2b # main.py banned: SAFETY-03 :: 'SAFETY-03 - %s Engagements so far: %d.'
|
||||
log 3bf899e99297 # main.py over 25 words (56); banned: SAFETY-03, clamp, clamped, integrator; capitals: STILL RUNNING :: 'SAFETY-03 - %s Silently clipping this is how a winding integrator reac'
|
||||
log 78cb6bd09633 # main.py over 25 words (48); banned: SAFETY-07; capitals: STILL RUNNING :: 'SAFETY-07 - %s. The guard is asking for everything the inverter has an'
|
||||
log cc735997adc7 # main.py over 25 words (44); banned: SAFETY-24; capitals: STILL RUNNING :: 'SAFETY-24 - %s. The controller is STILL RUNNING and charging is still '
|
||||
log ebcb428a70d0 # main.py over 25 words (70); banned: SAFETY-18, arbiter, watchdog; capitals: CHECK THE METER :: 'SAFETY-18 - %s. The grid-following loop is regulating against a number'
|
||||
log 38018b887495 # main.py over 25 words (43) :: 'tariff refresh degraded (%s); KEEPING the plan resolved at %s, %.0f s '
|
||||
log 0eab1ec96fe8 # main.py capitals: CONTROL LOOP DIED :: 'CONTROL LOOP DIED (%s) - commanding 0 W and exiting with status %d'
|
||||
log db529f2c419d # main.py over 25 words (40); banned: SAFETY-02 :: 'SAFETY-02 was TRIPPED on %s before this restart and stays tripped: %s.'
|
||||
log c5aadd5c8bf9 # main.py over 25 words (45); banned: SAFETY-42; capitals: NOTHING IS BEING COMMANDED DIFFERENTLY :: 'SAFETY-42 was TRIPPED on %s before this restart and stays tripped: %s.'
|
||||
log d72bbdb7e595 # main.py over 25 words (39); banned: SAFETY-30 :: 'SAFETY-30 (site) was TRIPPED before this restart and stays tripped: %s'
|
||||
log 8b880e65a7c3 # main.py over 25 words (38); banned: SAFETY-31 :: 'SAFETY-31 was TRIPPED before this restart and stays tripped: %s. Every'
|
||||
log 23f58b7f305c # p1.py:1264 over 25 words (30) :: 'meter_poll_s %.1f leaves no room under meter_max_age_s %.1f: one misse'
|
||||
log 88a7251e842b # p1.py:470 banned: telegram :: 'P1 telegram rejected: %s'
|
||||
log 0dd83d6dfaf4 # price.py over 25 words (30) :: 'the feed published no price for %d of %d instants (index %s); those in'
|
||||
log bcfd7062e28e # price.py over 25 words (79); banned: §8 :: 'price_feed_url is set, so %s is requesting %s exactly as given and is '
|
||||
log b57bc6de4a6b # price.py over 25 words (47); banned: validate.py :: 'price_taxes_profile cannot be used (%s: %s), so this fetch has NO tax '
|
||||
log d5595aaaacc4 # price_history.py banned: §8 :: 'price history at %s is %s - starting from empty. §8.3 carry-forward ha'
|
||||
log dbed0b8e35ea # static_tariff.py banned: validate.py :: 'static_profile is unusable (%s); no cheap window. This should have bee'
|
||||
log 8fbe9f5407ea # static_tariff.py over 25 words (28); banned: §1, §6 :: '§11.4: %s, and no static_profile is configured to fall back to. The pl'
|
||||
log 4ce29e043d89 # static_tariff.py banned: §1 :: '§11.4: %s - falling back to the static profile, plan marked PROVISIONA'
|
||||
log a3c7c9719ed2 # store.py capitals: LOST STATE :: 'per-inverter store migration LOST STATE and has not been marked done: '
|
||||
log 5b537a77052b # validate.py over 25 words (41); banned: arbiter, saturation :: 'integrator_max_w %.0f W is above max_w %.0f W. Not refused - the arbit'
|
||||
log 12ae40f9d289 # validate.py over 25 words (73); banned: arbiter, clamp, clamps :: 'maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not refused - '
|
||||
log c6dc799502cf # validate.py over 25 words (32) :: '%s carries negative levies %s EUR/kWh. That is accepted, but a tax com'
|
||||
log 1688a7478ceb # validate.py over 25 words (36) :: 'price_formula is filled in but price_provider is %r, so the coefficien'
|
||||
log f37f9fce4433 # validate.py over 25 words (96) :: '%s has no "card" block, so its coefficients are UNGRADED: nothing reco'
|
||||
log 396fbdd009c2 # validate.py over 25 words (52); banned: §1 :: "price_provider is %r but static_profile is empty. §11.4's degradation "
|
||||
log 37bba6ed37e3 # validate.py capitals: REFUSING TO START :: 'REFUSING TO START: the add-on configuration is not usable.'
|
||||
option-desc b927826d01f0 # en.yaml over 30 words (112); capitals: LEAVING IT OFF DOES NOT TURN THE PRICE FEED OFF :: 'Charge in the cheapest published hour and discharge in the dearest, bu'
|
||||
option-desc b98c42e34df7 # en.yaml over 30 words (34) :: 'The reserve price trading may not spend. Deliberately not the same key'
|
||||
option-desc 84e054da385b # en.yaml over 30 words (39) :: 'Off by default. Leave it off until the site has been commissioned: wit'
|
||||
option-desc 0f55d3b37afa # en.yaml over 30 words (38); banned: 0x0518, register, watchdog :: "The sensor that reports this inverter's own battery power, positive wh"
|
||||
option-desc 307afdd74f84 # en.yaml over 30 words (98); banned: 0x0560, Register; capitals: GW5000S-BP :: "MEASURED, not chosen: the percent at which the pack's OWN battery mana"
|
||||
option-desc 4da37ff6250d # en.yaml over 30 words (66); banned: SAFETY-31; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen contr'
|
||||
option-desc db464169ed66 # en.yaml over 30 words (144); banned: SAFETY-31; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY; with one inverter the quantity is identica'
|
||||
option-desc df9735452fa1 # en.yaml over 30 words (88); banned: OPT-08; capitals: THIS ONE IS NOT DERIVED FROM :: 'What one full round trip costs per kWh: wear on the pack plus conversi'
|
||||
option-desc 8147a82a437e # en.yaml over 30 words (51) :: 'A deadline on the gap between cycles, reset by every cycle. On a healt'
|
||||
option-desc cb66f230ef63 # en.yaml over 30 words (45); banned: register :: 'Errors smaller than this are ignored. ⚠️ The deadband is a one-way rat'
|
||||
option-desc 4167af90cfc2 # en.yaml over 30 words (288); banned: SAFETY-42, delivery_window.py, saturation; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire a'
|
||||
option-desc 3c0898cc1149 # en.yaml over 30 words (63) :: 'What you are paid for feeding a kWh back into the grid, all in. One nu'
|
||||
option-desc 05560996cb2e # en.yaml capitals: MEASURED ON REAL HARDWARE :: 'How much of the remaining error the loop corrects each cycle. MEASURED'
|
||||
option-desc f225d7a8f3a5 # en.yaml over 30 words (32); banned: watchdog :: 'How often the setpoint is rewritten even when it has not changed. It m'
|
||||
option-desc ef70d16a93bd # en.yaml over 30 words (98); banned: clamp, saturation :: 'Caps how much stale error can be waiting to unwind when the sign flips'
|
||||
option-desc bde7357af38d # en.yaml over 30 words (107); capitals: EMPTY DOES NOT MEAN :: 'Leave this empty unless the site really has more than one inverter. EM'
|
||||
option-desc 217c5ae9599c # en.yaml over 30 words (66); banned: 0x0560, register :: "The percent at which THIS pack's own battery management system cuts ou"
|
||||
option-desc 746a0f9ae506 # en.yaml over 30 words (143) :: "THIS pack's usable capacity in kilowatt-hours, from its datasheet or i"
|
||||
option-desc cb6ad204f59d # en.yaml over 30 words (52) :: 'The supervised ceiling on what may be written to THIS inverter; the si'
|
||||
option-desc 46142bcc1ab1 # en.yaml over 30 words (36); banned: heartbeat, latch :: 'A short identifier for this inverter, for example inverter-a. It gives'
|
||||
option-desc d787e9f2454d # en.yaml over 30 words (63); banned: arbiter; capitals: MUST STAY AT OR BELOW :: '⚠️ MUST STAY AT OR BELOW the inverter rating, and the add-on refuses t'
|
||||
option-desc a90fced63631 # en.yaml over 30 words (52); banned: CLAMPED :: 'How fast the cycle drains the pack; the surplus is exported. Above the'
|
||||
option-desc ab58c8fae4fb # en.yaml over 30 words (57); capitals: THIS IS NOT THE PEAK GUARD :: 'The depth the monthly recalibration deliberately goes to, once a month'
|
||||
option-desc 73f51bdd3d6e # en.yaml over 30 words (83); capitals: THIS IS YOUR NUMBER :: 'THIS IS YOUR NUMBER, not a derived one: the hard ceiling on what may e'
|
||||
option-desc dd9023e6c812 # en.yaml over 30 words (45); capitals: POSITIVE MUST MEAN DRAWING FROM THE GRID :: "The net grid power sensor, used when the meter source is 'off'. POSITI"
|
||||
option-desc aa5b929f3c22 # en.yaml over 30 words (114); banned: SAFETY-18, latch; capitals: NOT CHOSEN :: 'MEASURED, NOT CHOSEN (SAFETY-18). How long the meter may keep answerin'
|
||||
option-desc 1b3038c5c9e5 # en.yaml over 30 words (46) :: 'Beyond this the grid figure counts as missing. On its own it does not '
|
||||
option-desc bbf26329f056 # en.yaml over 30 words (48); banned: telegram, telegrams :: 'Must match your connection. A telegram whose phase count disagrees is '
|
||||
option-desc 92da72e54e77 # en.yaml over 30 words (52) :: "For meter source homewizard_local only. 5 s is the meter's own update "
|
||||
option-desc 24f329136309 # en.yaml over 30 words (84) :: "'off' keeps the single Home Assistant entity below, which is what an e"
|
||||
option-desc 47e5fa0dba52 # en.yaml banned: register :: 'The UNSIGNED injection register, the counterpart of the import registe'
|
||||
option-desc 9b8f5ea6c656 # en.yaml banned: 192.168.2.250 :: "For meter source homewizard_local only: the meter's own address on you"
|
||||
option-desc d5d3e30ac4d2 # en.yaml over 30 words (37); banned: register :: 'The UNSIGNED consumption register. Do not point this at a signed templ'
|
||||
option-desc 0b05d89400ba # en.yaml over 30 words (39) :: 'For meter source ha_signed only. ONE signed sensor: positive is drawn '
|
||||
option-desc e74a5db257e1 # en.yaml over 30 words (46) :: 'For meter source ha_signed only. Optional, in L1..L3 order, each signe'
|
||||
option-desc 3f07a88a61fd # en.yaml over 30 words (34); capitals: THIS IS YOUR NUMBER :: 'THIS IS YOUR NUMBER: the quarter-hour average demand the peak guard tr'
|
||||
option-desc 6ec195081ef9 # en.yaml over 30 words (112); banned: SAFETY-24; capitals: IT BOUNDS THE PEAK GUARD ONLY :: 'DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usa'
|
||||
option-desc 228fdc168458 # en.yaml over 30 words (270); capitals: INJECTION IS EXEMPT FROM VAT :: 'Optional, and only if your supplier pays you a price that moves with t'
|
||||
option-desc 98987808a7f0 # en.yaml over 30 words (98); capitals: WHAT YOU TYPE IS SENT EXACTLY AS WRITTEN :: "Leave empty to use the chosen source's own feed. Only a host that sour"
|
||||
option-desc 26f17751b2f7 # en.yaml over 30 words (234); capitals: IN THE UNITS THE CARD PRINTS :: "For price source custom_formula only: your own contract's two coeffici"
|
||||
option-desc 011841a82d7d # en.yaml over 30 words (34) :: "Your supplier's margin. The add-on adds it ONLY when the chosen price "
|
||||
option-desc d34ef16ba99a # en.yaml over 30 words (63) :: 'The older way of getting a price, kept because retiring it would take '
|
||||
option-desc c70929091e71 # en.yaml over 30 words (252); capitals: SWITCHING THIS ON IS WHAT STARTS THE PRICE FETCH :: "Where day-ahead or real-time prices come from. 'off' is what ships, an"
|
||||
option-desc a62e17ccf445 # en.yaml over 30 words (230); banned: TARIFF-18; capitals: THIS NUMBER IS NOT :: 'Everything on your bill that is not the energy itself - distribution, '
|
||||
option-desc 56d56f58c43e # en.yaml over 30 words (206); capitals: THE NUMBERS HERE ARE THE LEVIES ALONE :: 'Leave this empty unless your network tariff really does charge a diffe'
|
||||
option-desc 313bdd035b0b # en.yaml over 30 words (193); capitals: MEASUREMENT ONLY :: 'Optional, and MEASUREMENT ONLY - it changes no control behaviour whats'
|
||||
option-desc 4172d9920d65 # en.yaml over 30 words (128); capitals: MEASUREMENT ONLY :: 'Optional, and MEASUREMENT ONLY - it changes no control behaviour whats'
|
||||
option-desc 09ceba2c6afb # en.yaml over 30 words (70); banned: clamp; capitals: MEASUREMENT ONLY :: 'Optional, MEASUREMENT ONLY. Some sensors - typically a kWh meter or a '
|
||||
option-desc 42a7eaac6ff1 # en.yaml banned: saturated :: 'How many consecutive saturated cycles before the loop stops winding up'
|
||||
option-desc 12b0dffec9dd # en.yaml over 30 words (42) :: 'The gap between what was commanded and what the inverter delivers that'
|
||||
option-desc 91fd58c08a59 # en.yaml over 30 words (54) :: 'The ESPHome number entity this add-on writes the power setpoint to, no'
|
||||
option-desc 01e1af709cd5 # en.yaml over 30 words (201); banned: arbiter, clamps, heartbeat, latch :: 'Runs the whole decision path — strategies, arbiter, clamps and guards '
|
||||
option-desc 96b077d4e2e9 # en.yaml over 30 words (72); banned: SAFETY-02; capitals: NOT CHOSEN :: 'MEASURED, NOT CHOSEN (SAFETY-02). How long the setpoint and the meter '
|
||||
option-desc addf5c495ae8 # en.yaml over 30 words (90); banned: SAFETY-02, watchdog; capitals: NOT CHOSEN :: 'MEASURED, NOT CHOSEN (SAFETY-02). Both the setpoint being emitted and '
|
||||
option-desc 0a66ac772b27 # en.yaml over 30 words (117); banned: SAFETY-30; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (S'
|
||||
option-desc c0deabb69ab6 # en.yaml over 30 words (60); banned: SAFETY-30; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. The same 300 W as the single-inverter thre'
|
||||
option-desc 7d7272743277 # en.yaml over 30 words (123); banned: SAFETY-39; capitals: MEANS FOLLOW THE INVERTER RATING :: 'How far the setpoint may move in one control cycle. ⚠️ 0 MEANS FOLLOW '
|
||||
option-desc 436efca21a7d # en.yaml over 30 words (31) :: "The sensor that reports the pack's state of charge in percent. Use the"
|
||||
option-desc 9ac8a3b21a1e # en.yaml over 30 words (43) :: 'How long the inputs may be missing before the add-on commands 0 W. Wit'
|
||||
option-desc 86cbfd6a5693 # en.yaml over 30 words (130); capitals: RECOMMENDED EVEN ON :: 'Your fixed or day/night contract, written as a single line of JSON. RE'
|
||||
option-desc 996dffbf563d # en.yaml over 30 words (74); banned: register :: 'What the meter should sit at when nothing is happening; negative means'
|
||||
option-desc faf346256008 # en.yaml over 30 words (52); banned: SAFETY-30; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (S'
|
||||
option-desc 5323134f3131 # en.yaml over 30 words (95); banned: SAFETY-30, saturation; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY; with one inverter this setting does nothin'
|
||||
option-name ca3a62bb8260 # en.yaml over 6 words (7) :: 'Circulating-power hold (s) — derived, do not change'
|
||||
option-name edee163fdc2b # en.yaml over 6 words (7) :: 'Circulating-power threshold (W) — measured, do not change'
|
||||
option-name 99b3beeab621 # en.yaml over 6 words (7) :: 'Cost of one full charge/discharge cycle (EUR/kWh)'
|
||||
option-name cccd8a42f51a # en.yaml over 6 words (8) :: 'Not-delivering report after (s) — derived, do not change'
|
||||
option-name 989891e2ce23 # en.yaml over 6 words (10); banned: Integrator :: 'Integrator bound (W, 0 means the same as the rating)'
|
||||
option-name 42c6b595320c # en.yaml over 6 words (9) :: 'Inverter list (leave empty if you have one inverter)'
|
||||
option-name 230e6dc9d872 # en.yaml over 6 words (7) :: 'Hour of day a due cycle starts'
|
||||
option-name 18e1de907c54 # en.yaml over 6 words (8) :: 'Frozen-meter alarm after (s) — measured, do not change'
|
||||
option-name 9071c4cfa460 # en.yaml over 6 words (7) :: 'A reading counts as stale after (s)'
|
||||
option-name e3d06ed93e2e # en.yaml banned: register :: 'Export register entity (W)'
|
||||
option-name a0f59ea5de36 # en.yaml banned: register :: 'Import register entity (W)'
|
||||
option-name ed757924f40f # en.yaml over 6 words (7) :: 'Per-phase signed net entities (L1, L2, L3)'
|
||||
option-name 52d03808da01 # en.yaml over 6 words (8) :: 'Your own INJECTION formula (one line of JSON)'
|
||||
option-name bf22f2121af4 # en.yaml over 6 words (8) :: 'Your own tariff formula (one line of JSON)'
|
||||
option-name 5593992fcf5b # en.yaml over 6 words (7) :: 'How far ahead to fetch prices (hours)'
|
||||
option-name d9fee5514a4b # en.yaml over 6 words (11) :: 'Levies and VAT by time of day (one line of JSON)'
|
||||
option-name 7d8d691905e0 # en.yaml over 6 words (7) :: 'PV production entities, one per inverter (W)'
|
||||
option-name eac7ee084aaa # en.yaml banned: Saturated :: 'Saturated cycles before freezing (count)'
|
||||
option-name 5c6bbc5cab40 # en.yaml banned: Saturation :: 'Saturation threshold (W)'
|
||||
option-name 99a1ec154d36 # en.yaml over 6 words (7) :: 'Shadow mode — run every decision, command nothing'
|
||||
option-name 82fcd0faa687 # en.yaml over 6 words (8); banned: watchdog :: 'Sign watchdog hold (s) — measured, do not change'
|
||||
option-name 8fc34172dc01 # en.yaml over 6 words (8); banned: watchdog :: 'Sign watchdog threshold (W) — measured, do not change'
|
||||
option-name 8516934704f5 # en.yaml over 6 words (8) :: 'Site-wide sign hold (s) — derived, do not change'
|
||||
option-name 2b8278ab2b16 # en.yaml over 6 words (8) :: 'Site-wide sign threshold (W) — measured, do not change'
|
||||
option-name bd52e795ea97 # en.yaml over 6 words (8) :: 'Command 0 W after inputs missing for (s)'
|
||||
option-name 9e33febf4481 # en.yaml over 6 words (8) :: 'Fixed or day/night tariff (one line of JSON)'
|
||||
option-name 40e1e3ba3841 # en.yaml over 6 words (8) :: 'Per-inverter sign hold (s) — derived, do not change'
|
||||
option-name 4ba7087578dc # en.yaml over 6 words (8) :: 'Per-inverter sign threshold (W) — derived, do not change'
|
||||
refusal 87620efa0704 # p1.py:1176 banned: telegram :: 'p1_phase_net_entities has {len(phase_net)} entities but meter_phases i'
|
||||
refusal 5d1b8147792b # p1.py:1186 banned: 192.168.2.250 :: "meter_source {SOURCE_HOMEWIZARD} needs p1_host (the meter's own addres"
|
||||
refusal 701db346696d # validate.py over 30 words (85); banned: SAFETY-30, watchdog :: 'unit_sign_disagree_w is {unit_sign_w} W but max_w{unit_where} is {weak'
|
||||
refusal 710b4d89a9d7 # validate.py over 30 words (88); banned: ARBITER, arbiter; capitals: THE CEILING WINS :: 'maintenance_charge_w is {charge_w} W but max_w{maint_where} is {maint_'
|
||||
refusal 16d40434116b # validate.py over 30 words (193); banned: NOTES.md, §5; capitals: THE PLANT WINS :: 'slew_w is {slew_w} W against a site rail of {rail} W, so the loop cros'
|
||||
refusal 613375fb6b5f # validate.py over 30 words (107); banned: SAFETY-07; capitals: THE BMS WINS :: "peak_soc_floor is {soc_floor} % but the pack's own cut-out (bms_soc_fl"
|
||||
refusal ba21c5ba8c0d # validate.py over 30 words (224); banned: TARIFF-07, §1; capitals: IT HAS NEVER FETCHED :: 'price_provider is {pid}, which WAS a shipped plugin and has been WITHD'
|
||||
refusal de8df32186b6 # validate.py over 30 words (46); banned: §1 :: 'price_provider is {pid}, which is not a shipped plugin. The ids that e'
|
||||
refusal 0ee7c15eafef # validate.py over 30 words (103); banned: arbitrage.py, main.py, static_tariff.py; capitals: THE HOME ASSISTANT ENTITIES WIN :: "price_provider is {pid} AND {' and '.join(ha_price)} {'is' if len(ha_p"
|
||||
refusal c4149a762c39 # validate.py over 30 words (62); banned: main.py; capitals: THE HOME ASSISTANT ENTITIES WIN :: "static_profile is set AND {' and '.join(ha_price)} {'is' if len(ha_pri"
|
||||
refusal 6af34466bbc2 # validate.py over 30 words (83); capitals: THE TABLE WINS :: '{TAXES_PROFILE_OPTION} is set AND price_taxes_eur_kwh is {scalar} rath'
|
||||
refusal 8f69bdbbf3fc # validate.py over 30 words (55) :: '{TAXES_PROFILE_OPTION} carries {steep} EUR/kWh. Belgian levies and dis'
|
||||
refusal 96a090d2ad97 # validate.py over 30 words (72); banned: DOCS.md, §1 :: "price_provider is 'custom_formula' but price_formula is empty, so the "
|
||||
refusal db9d0d53c4f1 # validate.py over 30 words (45); banned: §1 :: "cycle_cost_threshold_eur is {opts.get('cycle_cost_threshold_eur')} and"
|
||||
refusal 58ccb3bbd560 # validate.py over 30 words (63); banned: §1; capitals: NO PRICES AT ALL :: "arbitrage_enabled is on but price_provider is 'off' and static_profile"
|
||||
refusal 780592c897fb # validate.py over 30 words (85); banned: arbitrage.py, telegram, §9 :: "arbitrage_enabled is on but meter_source is 'off', so there is no P1 t"
|
||||
refusal 4f80c091ca95 # validate.py over 30 words (69) :: 'arbitrage_soc_floor is {floor} % and arbitrage_soc_ceiling is {ceiling'
|
||||
refusal 0c089bb2f200 # validate.py over 30 words (57); banned: §1 :: "price_provider is 'manual_csv' but price_csv_path is empty, so the off"
|
||||
refusal 152dfec79224 # validate.py over 30 words (48); banned: SAFETY-02, heartbeat, latch :: '{where} has no name. Every inverter needs one: the Store namespaces th'
|
||||
refusal eb14af760f5c # validate.py over 30 words (51); banned: SAFETY-02, heartbeat, latch :: '{where} and {seen[name]} are both called {name}. The Store keys every '
|
||||
refusal 36f53c989b4a # validate.py over 30 words (55) :: '{who} has no setpoint_entity, so this add-on cannot command it. It wou'
|
||||
refusal c98c6ab66e9f # validate.py over 30 words (56) :: "{who} has no usable max_w (got {entry.get('max_w')}). It is the superv"
|
||||
refusal 5ca16a4d0a1e # validate.py over 30 words (71) :: '{who} has capacity_kwh {raw}, which is not a usable number of kilowatt'
|
||||
refusal 63a864d2e0a3 # validate.py over 30 words (73) :: "capacity_kwh is stated on {', '.join(stated)} and left empty on {', '."
|
||||
refusal 8d82500ecb1d # validate.py over 30 words (64) :: 'setpoint_entity is set to {flat_sp} AND an inverters list of {len(entr'
|
||||
refusal 3d778e0ab5e1 # validate.py over 30 words (36); capitals: THE DEVICE WINS :: '{name}: max_w is {cap} W but {entity} accepts at most {float(dev_max)}'
|
||||
refusal ecd0883f5649 # validate.py over 30 words (36); capitals: THE DEVICE WINS :: '{name}: max_w is {cap} W, so this controller may command {-cap} W, but'
|
||||
refusal eff7efac51f8 # validate.py over 30 words (56); banned: heartbeat :: 'setpoint_entity is not set, so this add-on has no actuator: every writ'
|
||||
refusal b126f1a88b77 # validate.py over 30 words (76); banned: §1 :: '{len(who)} configured writers can reach the one actuator {actuator}: {'
|
||||
refusal 07a88756c864 # validate.py over 30 words (48); banned: arbiter :: 'max_w is {max_w} W and must be positive - it is a magnitude, not a sig'
|
||||
refusal c4a1d0371e31 # validate.py over 30 words (42); capitals: THE DEVICE WINS :: "max_w is {max_w} W but {opts.get('setpoint_entity') or 'the setpoint d"
|
||||
refusal a15624d1aa10 # validate.py over 30 words (33); capitals: THE DEVICE WINS :: 'max_w is {max_w} W, so this controller may command {-max_w} W, but {op'
|
||||
refusal 26d0602c32d7 # validate.py over 30 words (79); capitals: THE FIRMWARE WINS :: 'meter_max_age_s is {max_age_s} s but the ESP32 firmware vetoes any P1 '
|
||||
refusal 2ade09aaa579 # validate.py over 30 words (73); banned: SAFETY-02, arbiter, watchdog :: 'sign_disagree_w is {sign_w} W but max_w{where} is {weakest} W, so the '
|
||||
row c24ab8bd9cb3 # main.py over 15 words (18); banned: heartbeat, layer 1; capitals: NO PASS FOR :: 'control loop: NO PASS FOR {_pass_age} s (layer 1 zeroes the inverter a'
|
||||
row c771d4bc1e0a # main.py over 15 words (17); banned: telegrams :: "P1 meter ({o.get('meter_source')}): {self.p1.net_w} W, {age} s old, {s"
|
||||
row f6195fee8575 # main.py over 15 words (33); capitals: METER MAY BE FROZEN :: 'METER MAY BE FROZEN - {…}. The loop is STILL FOLLOWING it and is still'
|
||||
row db05be7cd99c # main.py over 15 words (16); option key: meter_source; capitals: NOT COVERED :: "freeze detector: NOT COVERED on meter_source {o.get('meter_source')} -"
|
||||
row 9a21960bbbe8 # main.py capitals: NOT COVERED :: 'freeze detector: NOT COVERED - {…}'
|
||||
row c38b746a6585 # main.py banned: SATURATED; capitals: PEAK GUARD SATURATED :: 'PEAK GUARD SATURATED - {…}'
|
||||
row dd0629834473 # main.py over 15 words (36); banned: SAFETY-25; option key: peak_soc_floor; capitals: THE PEAK GUARD ONLY :: '{…}. NOTE: peak_soc_floor bounds THE PEAK GUARD ONLY - it is not a pac'
|
||||
row 4132e246e675 # main.py over 15 words (26); banned: telegrams; option key: meter_source; capitals: NOT COVERED :: "peak guard: NOT COVERED on meter_source {o.get('meter_source')} - the "
|
||||
row 7b34b16d8bb2 # main.py over 15 words (61) :: '{label}: no usable value this cycle from {entity} - and this row canno'
|
||||
row 04e0381f16c2 # main.py banned: CLAMP; capitals: OUTPUT CLAMP ENGAGED :: 'OUTPUT CLAMP ENGAGED - {…} Engagements: {self.clamp_wd.engagements}.'
|
||||
row 9f8b8adcbc6c # main.py banned: INTEGRATOR; capitals: LOOP AT INTEGRATOR BOUND :: 'LOOP AT INTEGRATOR BOUND - {…} Engagements: {self.clamp_wd.engagements'
|
||||
row f00cc9688eca # main.py over 15 words (20); banned: clamp :: 'output clamp: not engaged now, but it has engaged {self.clamp_wd.engag'
|
||||
row a44333431331 # main.py over 15 words (31); capitals: SIGN-DISAGREEMENT FAILSAFEX :: 'SIGN-DISAGREEMENT FAILSAFE{tag} - {…}. The inverter is held at 0 W. Fi'
|
||||
row 3ef4e60f2328 # main.py over 15 words (17) :: 'sign watchdog{tag}: armed{vs} (trips after {inv.sign_wd.cfg.hold_s} s '
|
||||
row 9f879b339282 # main.py over 15 words (50); banned: integrator; capitals: SITE SIGN-DISAGREEMENT FAILSAFE :: 'SITE SIGN-DISAGREEMENT FAILSAFE - {…}. EVERY inverter is held at 0 W. '
|
||||
row 96e93e34bdbf # main.py over 15 words (24); banned: watchdog :: 'site sign watchdog: armed against the sum of the commands (trips after'
|
||||
row 9ee8d293fea5 # main.py over 15 words (38); capitals: CIRCULATING POWER :: 'CIRCULATING POWER - {…}. EVERY inverter is held at 0 W. The meter cann'
|
||||
row 55eb950acd5f # main.py over 15 words (17); banned: watchdog :: 'circulation watchdog: armed (trips after {self.circ_wd.cfg.hold_s} s o'
|
||||
row 1817192a7f1c # main.py over 15 words (42); capitals: NOT DELIVERINGX :: 'NOT DELIVERING{tag} - {…}. Nothing has been commanded differently and '
|
||||
row 685c1416364b # main.py over 15 words (26) :: 'delivery watchdog{tag}: armed (reports after {inv.delivery_wd.cfg.hold'
|
||||
row d3cd52827dd0 # main.py over 15 words (29); banned: watchdog :: 'delivery watchdog: n/a at one inverter - a unit short of its share is '
|
||||
row d6981c8d7b56 # main.py banned: latched :: 'no e-stop fitted: if this host dies the battery stays latched at its l'
|
||||
row 685c8f02c97a # main.py capitals: NO SITE TOTAL :: 'PV production: NO SITE TOTAL - {…}. Reporting: {…}'
|
||||
row abef339bf192 # main.py over 15 words (25); option key: pv_entities :: 'PV production: `pv_entities` is set, so `pv_entity` is IGNORED - the {'
|
||||
row 514511bd0a93 # main.py over 15 words (32); option key: pv_invert_entities :: 'PV production: {ent} reads {watt} W - far below zero, so its sign is p'
|
||||
row 328abd6171ce # main.py over 15 words (22); option key: pv_invert_entities :: 'PV production: `pv_invert_entities` names {ent}, which is not a PV ent'
|
||||
Reference in New Issue
Block a user