Two things a green from `checks / unit (pull_request)` was claiming without having observed. Both were measured on a deliberately conflicted throwaway pull request (#29, now closed and its branch deleted) rather than read off the workflow, because the Actions log endpoints 404 on this Gitea and a step's own conclusion is the only channel out of a job. CASE 1, AND THE TICKET'S MECHANISM IS WRONG IN A WAY THAT MAKES IT WORSE. The card says a conflicted pull request loses its merge ref, so that git fetch -q --depth 1 origin "$GITHUB_REF" \ || git fetch -q --depth 1 origin "$GITHUB_SHA" falls through to the second half and quietly grades the bare branch head. The symptom is real - PR #14 reported success while `mergeable: false` - but that is not how. Asking one question per step on the conflicted probe (runs 558, 559): GITHUB_REF == refs/pull/<n>/head P01 passed GITHUB_REF != refs/pull/<n>/merge P02 failed GITHUB_SHA == the pull request head sha P03 passed fetching GITHUB_REF succeeds P04 passed <- even conflicted ...and it lands on the bare head P05 passed refs/pull/<n>/merge does not exist P06 failed `git ls-remote origin 'refs/pull/*'` on this server advertises only `/head`, for open and closed pull requests alike. There are no merge refs here at all. The first fetch has therefore always succeeded and the fallback has never fired once; both halves name the same commit anyway. So the gate did not lose merged content on conflicted pull requests - it has never had merged content, on any pull request in this repository's history. Green has meant "this branch passed on its own", and with `block_on_outdated_branch` off on release/1.0 the base has usually moved underneath it. Reproduced end to end on one pull request, same conflict, gate swapped: run 567 the ORIGINAL gate -> success, "Successful in 3m38s" run 566 the gate below -> failure in 2s, merge step red, both test steps `skipped` The checkout now fetches the base tip and the pull request head, merges them, and refuses if the merge does not apply - as its own NAMED step, because /actions/runs/<id>/jobs carries a per-step conclusion and that is what lets a reader tell "nothing was tested" from "the tests spoke". `--depth 1` is gone: a merge needs a merge base, and a full fetch of this repository measured 6.8 MB and 1.1 s. The fallback survives only on the workflow_dispatch path, which has no pull request to merge and now announces which bare ref it took. CASE 2, AND `cancel-in-progress` IS NOT THE KNOB. Runs 531 and 533 on PR #14 were cancelled at 0s, each at the exact second the next run on that pull request was created: 531 created 17:08:19 `Waiting to run`, cancelled 17:10:44 = 532's creation 533 created 17:45:11 `Waiting to run`, cancelled 18:11:22 = 534's creation Both have `started_at` at the Unix epoch. A cancelled run posts `failure` on the required context, described `Canceled after 0s`; branch protection compares state, not description, so "nothing ran" and "your tests are broken" arrive as the same red wanting opposite responses. Three settings were measured, each with one run holding the single runner slot and two further pushes behind it: no concurrency block at all runs 561 and 562 both queued, both executed group, cancel-in-progress:false run 564 sat `Blocked by required conditions` and was cancelled at 0s the second 565 existed A shared group cancels a run that has not started regardless of that flag - GitHub documents it and Gitea 1.27 implements it. Having no group is the only configuration measured to leave a queued run alive, so the block is gone and the measurement is written above it. The cost is stated in the file: a developer who pushes four times occupies the shared runner four times. That is CI-01's own trade written the other way round - a delayed verdict, never a destroyed one - and a destroyed verdict is worse here because it is indistinguishable from a real failure. A sha-keyed group was considered and rejected: the only cancellations it still permits are same-sha ones, whose red lands on a commit that IS still the head. WHAT IS NOT ESTABLISHED, SAID PLAINLY. Nothing here makes a cancelled run distinguishable at the gate BY STATE; that is Gitea posting the status, not this workflow, and no edit to this file can change it. What ships removes the cause instead of relabelling the symptom. And a commit that never had a run carries an empty `/statuses/<sha>` list, while a queued one carries `pending / Waiting to run` within seconds and a concurrency-held one `pending / Blocked by required conditions` - those were already distinguishable at the API. `/actions/tasks`, which is what was polled when this was first reported, lists only tasks that started and cannot answer the question at all. test_ci_gate.py grades the gate by RUNNING it. It extracts the checkout and merge steps out of checks.yml and executes the shipped commands against two throwaway local repositories it builds - one whose pull request merges, one whose does not - and pins that the old one-liner exits 0 on the unmergeable one while the new steps refuse and say nothing was tested. Part 1 pins the two tokens that assemble `checks / unit (pull_request)`: renaming `name:` or the `unit:` job key would leave every open and future pull request waiting forever on a context that never arrives, with no error anywhere in that sequence, and admin override blocked. Seven mutations of checks.yml were each confirmed red, including both renames and a restored `--depth 1`. .gitea/ci/Dockerfile gains `git` for it; the fixtures are local paths and no network is touched. 17/17 green in the checks image. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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:
- ESP32 watchdog — no fresh setpoint for ~30 s → command 0 W, and keep commanding it.
- Wind-down before firmware updates — 0 W written before the update starts.
- 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.