glenn schrooyenandClaude Opus 5 504e8daac3
checks / unit (pull_request) Successful in 3m35s
TEST-17: grade the status page's peak row at a stated month, not at today's
release/1.0 went red at 00:00 on 2026-09-01 on a commit nobody had
touched. `Controller.checks()` read the capacity-tariff peak with
`self.peak.peak()` - no argument - so tariff.py resolved the month from
`datetime.now()`, while test_tariff.py section 10 banks its quarter at
the fixed fixture 2026-08-30. From 1 September the page was correctly
asked for September, correctly answered NO_RECORD, and the row read "no
completed quarter recorded yet" - a string containing no "peak", which
the section's own filter required. Four checks failed.

ISOLATED BY MOVING ONLY THE CLOCK. Same commit, same shipped-deps image,
libfaketime: green at 2026-08-30, four failures at 2026-09-01. Identical
at f4914a1, 04292c4~1 and 04292c4, so this is the calendar and not a
regression. It has been latent since TARIFF-01 merged on 2026-08-30, and
every PR that ever ran this suite ran it in August.

THE PRODUCT IS RIGHT AND THE TEST WAS WRONG, so the live page is
unchanged. Section 5 of this same file calls NO_RECORD "a normal state
on the 1st of the month" and a capacity tariff is billed monthly, so a
page showing the current month is correct. `checks()` now takes
`when: datetime | None = None` and threads it into peak() and
warning_frac() - the convention PeakStore.peak(), PeakTracker.peak(),
headroom_w() and warning_frac() all already follow. The one production
caller passes nothing and still means today.

AND THE DEFECT IS NOW DETECTABLE RATHER THAN SEASONAL. Three checks:
the same controller read at two different reference months must produce
two different rows, which no clock can satisfy; the later month must
read NO_RECORD and stay distinguishable from both an observed peak and
an unreadable store; and checks() with no argument must still mean
today. Verified by mutation - a checks() that drops `when` is red at
every clock tried, where the old section went green in August. The first
draft of these checks was itself wrong in the same way and reproduced
the bug: it selected rows on "peak", which the NO_RECORD row does not
contain, so it dropped the row and then graded its absence. They select
on "quarter(s) banked" instead.

Full suite green, 15 files, at the real clock and at faked clocks in
2026-08, 2026-12, 2027-02 and 2027-03.

Left alone deliberately: only the UNREADABLE branch prefixes its row
with "capacity-tariff peak:". The NO_RECORD row carries neither that
phrase nor the word "peak". That asymmetry is what made the original
filter brittle and what broke the first draft above; it is product text
with other suites reading it, so it is reported rather than changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-01 00:58:20 +02:00

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
    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
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:

cd goodwe_controller
python3 test_control.py
python3 test_maintenance.py

Both must pass before shipping any change. 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.
S
Description
No description provided
Readme
20 MiB
Languages
Python 98.2%
JavaScript 1.2%
CSS 0.3%
HTML 0.2%
Shell 0.1%