admin a6e64fb66e Merge pull request 'TARIFF-12: a degradation is not a refresh' (#79) from TARIFF-12 into release/1.0
run_tariff_refresh()'s docstring promised 'A FAILURE DOES NOT CLEAR THE PLAN' and guarded the wrong thing. resolve_tariff() deliberately never raises on a feed failure - load_prices() returns no_data rather than letting a traceback into the planning tick - so a dead feed returned a degraded TariffPlan, the try SUCCEEDED, and the good plan was overwritten. The except branch that promised otherwise was reachable only by unexpected exceptions; a supplier outage, the case the docstring names first, never reached it. With static_profile unset - the shipped default for a dynamic site - a complete 48-hour plan was replaced by origin='none', 0 points.

A 40-hour soak drove a 90-minute outage and measured it: the plan was gone at the FIRST failed fetch, ~27 min after the last good one, with TARIFF_MAX_AGE_S (5400 s) governing nothing. There was no serve-stale window; a transient blip had the same effect as a long outage.

The fix keeps a plan that is still fresh, via fresh_tariff_plan() - reused rather than transcribed, and pinned on the AST. The §11.4 static fallback does NOT replace a fresh provider plan: every point _degrade() builds is provisional, so firm_points() is empty and arbitrage and cheap_now are already off, while a day-ahead price for a given hour does not change once published - a 30-minute-old observation is exactly as true about 19:00 as one fetched now. The fallback is strictly less actionable and no more current. Deliberately NOT symmetrical: a standing plan that is itself a degradation IS replaced, or a static site's plan would freeze at the window it was first generated for. tariff_at does not move on a kept plan, so TARIFF_MAX_AGE_S now genuinely governs and a kept plan stays distinguishable from a fresh one.

Rebased onto TARIFF-10 and re-verified rather than assumed. The interaction was confirmed by driving it: nothing on this path reads arbitrage_enabled, and the static-profile case was driven twice consecutively because a guard that latched there would freeze a site's plan for the life of the install. One honesty fix followed from TARIFF-10 - the kept-plan log said 'arbitrage stops with it', which is false on a site that fetches without trading, and now reads 'arbitrage, where it is armed, stops with it'.

Evidence: 29/29 green on the merged tree, including TARIFF-10's new reachability section. Eleven mutations, all re-driven on the merged tree because cached reds from the old base are not evidence about the new one; eleven red, no survivors, every revert fingerprinted after the run as well as before.

Two process findings recorded rather than quietly fixed. Two drafts of one check were red against a CORRECT tree because they substring-matched the docstring that states the property - a text search over a function grades its prose as much as its behaviour, which is habit 4 in a third costume; the check now scans AST symbols and non-docstring constants, with positive controls. And the mutation harness was overwritten mid-session by a concurrent agent's harness sharing the scratchpad, pointing it at a different worktree - MUTATION.md habit 18's second bullet almost verbatim. It surfaced as an error rather than a silent wrong result, and the per-run revert fingerprints are what prove the worktree was never left mutated.
2026-09-12 21:39:24 +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 — 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:

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
18 MiB
Languages
Python 98.1%
JavaScript 1.2%
CSS 0.3%
Shell 0.2%
HTML 0.2%