checks / unit (pull_request) Successful in 6m17s
The Configuration tab can only render types and ranges. `validate.py`'s most
useful refusals are CROSS-FIELD - two inverters naming one `setpoint_entity`, a
`maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a
declared roster - and the Supervisor's UI has no way to express a rule spanning
two keys. Today the operator meets those as exit 78 in a log, after saving and
restarting. This puts them on the screen beside the field they name, before
anything is written.
WHAT IT ADDS
app/configui.py the field model, built from `GET /addons/self/info`'s own
`schema` + `translations`. No option key, no label, no
rule of its own. UX-01's `Group · ` label prefix becomes a
real section heading, in whichever language resolved.
app/selfconfig.py the Supervisor conversation and the write gate.
app/config_page.py the page. Its longest function decides which HTML input a
type deserves; it contains no rule.
app/web.py five routes. Still the only file a reviewer has to read to
answer "does anything here reach the inverter".
THE FOUR THINGS THAT COULD HURT SOMEBODY, AND HOW EACH IS HELD
1. A blob the SHIPPED validator refuses is never written - not warned about,
not written. An invalid blob plus a restart is exit 78, and the panel is
gone with the add-on that serves it.
2. A blob that moved underneath is refused, not clobbered. Read hands out a
sha256 of the canonical blob, save hands it back, and the stored blob is
re-read IMMEDIATELY BEFORE the write. This is SAFETY-28's discipline
applied to configuration - "a blob reaches the Supervisor only if it was
derived from the blob stored right now" - with the honest weakening stated
in the decision record: a second writer is DETECTED, not made
inexpressible.
3. Every message is the validator's own, byte for byte. test_selfconfig.py
part 6 inserts ONE new rule into a COPY of validate.py, hashes every file
under app/ to show validate.py is the only one that differs, and serves the
real /config/validate route out of that copy in a subprocess. The new
sentence arrives, filed under the key it names.
4. Nothing on the surface commands an inverter or clears a latch. Saving does
not restart: applying costs a control interruption - the wind-down commands
0 W on every inverter - so that is a separate, confirmed button that says
so first.
MEASURED, NOT CITED (tools/supervisor_selfapi_probe.py, run against
Supervisor 2026.08.0's own code; it contacts nothing)
- the three self-paths are permitted, and `/addons/self/info` is admitted by
the `default` role too while `options` and `restart` rest on the bypass
ALONE. The probe's first draft claimed the role admitted none of the three
and went red.
- `/addons/self/options/validate` is NOT reachable from inside an add-on, so
the only validator this panel can call is ours - which is the one with the
cross-field rules.
- a POST that omits a key does not preserve it: the key reverts to
config.yaml's default. Hence whole-blob read-modify-write.
- the Configuration tab CAN be removed, and doing so drops 0-of-71 options
through and takes the add-on down. The card said it could not be; it stays
by decision, which is a better reason.
DOES NOT TOUCH config.yaml, main.py or any control path. UX-01 is a dependency,
not something this supersedes: its translations are where every label,
description and section heading on this page comes from, and the Configuration
tab is still the recovery surface. DECISIONS/UX-02-two-config-surfaces.md
records which surface is authoritative (neither: the stored blob is) and what
the second surface costs.
Also corrects a stale "70 options" in three prose spots to the 71 the probe
counts - config.yaml grew a key in SAFETY-42 and the sentences did not follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
---
⚠️ REBASED ONTO release/1.0 @ 3368231, AND HISTORY DELIBERATELY FLATTENED.
The original branch carried `653874d` - an ORPHANED first attempt at PKG-07
whose commit message justified the version bump with a claim that was FALSE
(that `slew_w: 0` used to mean "no limit"; the schema was `int(50,5000)` from
the repo's first commit, so 0 was never legal). That commit's CONTENT was
superseded when release/1.0 was merged in, and the merge resolved correctly -
one 0.8.0 section, no fabricated text. But the retracted claim would have
entered release/1.0's history as a commit message with no retraction attached
to it, where PKG-07's own merged commit is the thing that retracts it.
So the branch is replayed as a single commit on top of the corrected PKG-07
rather than merged with the bad one trailing behind. The tree is byte-identical
to the reviewed branch tip; only the ancestry changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
124 lines
5.7 KiB
Markdown
124 lines
5.7 KiB
Markdown
# GoodWe RS485 Controller — Home Assistant add-on repository
|
|
|
|
Replaces a GoodWe battery inverter's vendor controller with a local one: holds
|
|
net grid exchange at zero by emulating the inverter's smart meter over RS485,
|
|
and runs the monthly battery maintenance cycle the vendor box was doing.
|
|
|
|
```
|
|
repository.yaml add-on repository metadata
|
|
goodwe_controller/ the add-on
|
|
config.yaml manifest + options schema (the per-site config form)
|
|
app/ the controller
|
|
control.py the control law — pure functions, no I/O
|
|
maintenance.py the monthly cycle state machine
|
|
hass.py Supervisor/Core API access
|
|
store.py persistent state in /data
|
|
mqtt.py optional status entities
|
|
web.py ingress UI — routes only, status and configuration
|
|
config_page.py the configuration page's markup, style and script
|
|
configui.py its field model, built from the Supervisor's own
|
|
schema + translations — owns no key, label or rule
|
|
selfconfig.py reading and writing this add-on's own options,
|
|
with the validate-first / compare-and-swap gate
|
|
main.py orchestration, heartbeat, failsafe behaviour
|
|
test_control.py runnable checks — no framework needed
|
|
test_maintenance.py runnable checks — walks a full cycle in fake time
|
|
DOCS.md the add-on's documentation tab
|
|
MUTATION.md how to run a mutation so the result means something
|
|
tools/mutation_run.sh the mutation harness - purges bytecode, then grades
|
|
tools/supervisor_selfapi_probe.py what the Supervisor really permits and
|
|
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/build_guide.py its single source - rebuilds the HTML and PDF
|
|
FIELD-GUIDE.md how to edit and rebuild the guide
|
|
```
|
|
|
|
**Picking this up after a break? Read `HANDOVER.md` first** — current state,
|
|
what is known broken, and what to do next.
|
|
|
|
## Install
|
|
|
|
Settings → Add-ons → Add-on store → ⋮ → **Repositories** → add
|
|
|
|
```
|
|
https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
|
|
```
|
|
|
|
then install **GoodWe RS485 Controller**.
|
|
|
|
⚠️ **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
|
|
`goodwe_controller/` into the machine's `/addons/` folder, then
|
|
`ha store reload` — it appears as a Local add-on.
|
|
|
|
⚠️ **Bump `version:` in `goodwe_controller/config.yaml` for every change.**
|
|
Supervisor keys the built image by that version, so without a bump it silently
|
|
reuses the old image and your fix appears not to work — on your bench and on
|
|
every client's machine.
|
|
|
|
Requires **Home Assistant OS or Supervised**. Add-ons cannot be installed on HA
|
|
Container or Core.
|
|
|
|
## Read this first
|
|
|
|
**The inverter holds its last command forever.** It has no meter-timeout of its
|
|
own — a controller that dies mid-command leaves the battery running until a
|
|
human intervenes. Measured on real hardware: 5 kW of discharge held for 113
|
|
seconds after a controller went silent.
|
|
|
|
Three layers exist because of that, and only the third covers the Home Assistant
|
|
machine itself dying:
|
|
|
|
1. **ESP32 watchdog** — no fresh setpoint for ~30 s → command 0 W, and keep
|
|
commanding it.
|
|
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.
|
|
|
|
## Development
|
|
|
|
The control law and the maintenance machine are pure Python with no Home
|
|
Assistant imports, so they run anywhere:
|
|
|
|
```bash
|
|
cd goodwe_controller
|
|
python3 test_control.py
|
|
python3 test_maintenance.py
|
|
```
|
|
|
|
Both must pass before shipping any change. To prove a check *bites* - the way
|
|
almost every claim in this repository was established - mutate the module and
|
|
run `tools/mutation_run.sh`, which clears `__pycache__` first. It is not
|
|
optional: a same-size edit reverted inside one second leaves CPython reusing the
|
|
old bytecode, and the run grades code that is not there. `goodwe_controller/MUTATION.md`
|
|
has the mechanism, the measurement, and what the flags do not do. They are not unit-test theatre —
|
|
each assertion corresponds to a rule whose absence produced an observed failure
|
|
on real hardware, and the comments in `control.py` say which.
|
|
|
|
## Compatibility
|
|
|
|
**Check the serial number, not the model name.** Characters 6-8 of the serial are
|
|
the platform tag:
|
|
|
|
| tag | platform | verdict |
|
|
|---|---|---|
|
|
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era meter bus | **supported** |
|
|
| `SPB SPN` | 745 — SBP **G2** (`GW…-SBP-20`) | no |
|
|
| `ESN ESC` | 745 — ES **G2** (`GW…-ES-20`) | no |
|
|
| `ETU EHU BTU …` | 205/745/753 — ET/EH/BT hybrids | no |
|
|
| `SDT DST MSU NSU …` | PV-only inverters — no battery to control | no |
|
|
|
|
A "-20" or "G2" suffix means a different platform with a different meter
|
|
protocol. The tags come from the `goodwe` library's `PLATFORM_105_MODELS`, which
|
|
is also what Home Assistant's own integration uses to pick a protocol.
|
|
|
|
- GoodWe **ES / BP family** inverters (AA55 / RS485 meter-bus generation)
|
|
- The protocol is **reverse-engineered**. There is no vendor contract, and a
|
|
firmware change on GoodWe's side could break every installation at once. Say
|
|
so when you sell it.
|