adminandClaude Opus 5 9b270a66a5
checks / unit (pull_request) Successful in 4m56s
CI-06: test_peakguard.py answered two of its checks with the wall clock
`test_peakguard.py` is flaky on the required `checks / unit (pull_request)`
context. Reproduced, measured and removed - not widened, and nothing skipped:
the file still carries all of SAFETY-07 and SAFETY-25, and gains a check.

TWO MECHANISMS, both the control law's own gain `900 / remaining` applied to a
wall-clock gap this file never meant to have.

1. THE CROSS-TIME PROJECTION (the "one cycle of the intent form..." check).
   `_proj3` was a SECOND `PeakTracker.project(b3.p1.averager, datetime.now())`
   taken after the real cycle had already returned, and its result was compared
   against a floor decided from the FIRST projection - while the comment three
   lines above says both forms run "against the same frozen projection".
   `excess = 900 x (projected - ceiling) / remaining` moves at `excess /
   remaining` W/s, so at a 1500 W floor the 1.5 W tolerance bought exactly
   `remaining / 1000` seconds: 333 ms with 880 s left in the quarter and 6 ms
   with 10 s left (measured by bisection, probe in the PR). The gap between the
   two statements measured 12.7-39.5 ms over 26 instrumented runs, so the check
   was decided by WHERE IN THE QUARTER it ran, about `gap_ms / 900` of the time.
   FIX: `_recording_decide` keeps the Projection OBJECT the real cycle graded.
   Both forms are now decided from it, the clock is out of the comparison, and
   the tolerance is EXACT rather than 1.5 W. Measured on a real cycle: the
   delivered form re-decided from that object reproduces the controller's own
   floor to 0.000000000000 W, and the identity holds to 0.000000000000 W.
   It also gains a check - that re-decide now pins `_kw` as main.py's actual
   call rather than an approximation of it.

2. THE FIXTURE'S OWN ROOM (the three `wired_over_limit` checks named on the
   card: "the real cycle files the peak claim...", "...and the number written to
   the inverter is the guard's floor", "the STATUS PAGE row for a floor-limited
   guard..."). `range(0, span + 1, 5)` leaves 0-5 s of the open block uncovered
   by construction, and `excess` multiplies an uncovered second back up by
   `900 / remaining`:

       floor ~ want x (1 - uncovered/900) - net_w x uncovered / remaining

   Measured on this tree, shipped: at 12 s of remaining a fixture that asked for
   a 1500 W floor delivered 613 W (-59 %), and at 2 s the guard did not engage
   at all - which fails all three of those checks together. The four-attempt
   retry cannot help, because four attempts take ~40 ms and all land in the same
   dying quarter.
   FIX: one telegram stamped at `now` (which is what a real meter does and what
   the docstring already claimed), plus FIXTURE_ROOM_S - the helper waits out
   the tail of a quarter it cannot size a fixture in. 24 s is DERIVED from the
   1 % accuracy the helper promises, at this file's smallest fixture and the
   measured 0.1 s worst-case lag, not picked:
       net_w x lag / (remaining x want) <= 0.01 - lag/900
       remaining >= 3500 x 0.1 / (1500 x 0.00989) = 23.6 s
   Same probe after the fix: -0.3 % at 12 s, -0.5 % at 8 s, still engaged at 2 s.

NO TIMEOUT WAS WIDENED, no check skipped, no xfail. One tolerance was made
STRICTER (1.5 W -> exact) because removing the clock made exactness possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 19:40:34 +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
  MUTATION.md              how to run a mutation so the result means something
  tools/mutation_run.sh    the mutation harness - purges bytecode, then grades
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. 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.
S
Description
No description provided
Readme
20 MiB
Languages
Python 98.2%
JavaScript 1.2%
CSS 0.3%
HTML 0.2%
Shell 0.1%