Files
goodwe-addon/goodwe_controller/CHANGELOG.md
T
glenn schrooyenandClaude Opus 5 8b51a51e20 TEL-04: a third meter_source for a single signed entity
TEL-01 shipped ha_dsmr and mqtt_p1, and neither can read the meter that is
actually fitted here. The house has a HomeWizard P1 exposing ONE signed
entity, sensor.p1_meter_active_power (+ import, - export); ha_dsmr wants two
unsigned registers and refuses a negative one outright, which is every
exporting telegram. So sensor.p1_sample_age_s could not be produced at this
site, and FW-01's watchdog needs it - measured, not theoretical: the house P1
went 51.1 s and 36.2 s without a state change overnight, both past
meter_max_age_s 30, so without the age sensor the watchdog would false-trip
the battery to 0 W.

Adds meter_source: ha_signed, reading p1_net_entity (and optionally
p1_phase_net_entities in L1..L3 order for the capacity-tariff peak). The
derivation is split_signed(), sitting next to make_sample's subtraction for
the same reason it does - the moment a user is asked to write two template
sensors that split a signed value, the sign convention is back in unreviewed
YAML underneath a safety input, which is exactly what TEL-01 removed.

The transport is a subclass of HaDsmrSource overriding only _wanted() and
build(), so every rule TEL-01 established is inherited rather than
re-implemented: ingest timestamping, meter_max_age_s, the clock-recomputed
sensor.p1_sample_age_s republished ~1 Hz, the plausibility ceiling, the
"prime the cache from get_states but never build a sample out of it" rule,
"a reconnect emits nothing", and unavailable/unknown treated as a MISSING
reading and never as 0 W.

Defaults to off. An existing install is unaffected until it opts in.

test_p1.py: 122 -> 174 checks. Includes an end-to-end run of the new
transport against a fake Home Assistant websocket, and the sign convention
asserted against real captured readings from
sim/scenarios/ha-p1_meter_active_power-2026-08-{20,23}.json (-5710 W at
13:46 local under full sun is export; +775 W at midnight is import).

Non-vacuity: ten mutations of the new rules, each applied alone and reverted
byte-identical. Nine turn the suite red. The tenth - splitting the per-phase
signed values rather than passing them through - is an equivalent mutant,
because make_sample subtracts the two lists again and does not sign-check
per-phase figures. That is recorded in a ponytail: comment at the site rather
than left for the next reviewer to rediscover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-25 16:44:22 +02:00

160 lines
8.4 KiB
Markdown

# Changelog
## Unreleased
**TEL-04.** A third `meter_source`, `ha_signed`, reading **one signed** Home
Assistant entity: positive = import, negative = export. That is the shape a
HomeWizard P1 publishes (`sensor.p1_meter_active_power`), and it is the meter
actually fitted here - which neither TEL-01 transport can read, because
`ha_dsmr` needs two unsigned registers and refuses a negative one, i.e. every
exporting telegram. Set `p1_net_entity`, and `p1_phase_net_entities` for the
per-phase capacity-tariff figures on a three-phase connection.
Everything TEL-01 established is inherited rather than re-implemented - the
new transport is a subclass of the `ha_dsmr` one overriding only which
entities it wants and how they become a sample. So ingest timestamping,
`meter_max_age_s`, `sensor.p1_sample_age_s` recomputed against the clock and
republished once a second, the plausibility bounds, and `unavailable` /
`unknown` treated as a *missing reading and never 0 W* all behave identically
across the three sources.
Still defaults to `off`; an existing install is unaffected until it opts in.
## 0.3.0
**SAFETY-04.** The control law's integrator is now an explicit accumulator,
bounded independently of the output clamp instead of inheriting whatever
headroom the clamp happened to leave. It also freezes while the inverter is
not tracking, rather than continuing to wind up against a command nothing is
acting on. `integrator_max_w` (default `0`) governs the bound; `0` means
"follow `max_w`", which is the existing behaviour.
Behaviour is unchanged at the defaults - a 4,928-case equivalence sweep
against the previous control law confirms it decides identically at
`integrator_max_w: 0`.
**TEL-01.** P1 meter ingestion, so a Belgian P1's two unsigned registers
(consumption, injection) no longer need a hand-written signed template
sensor: the subtraction moves into the add-on, done once and tested. Two
transports, chosen with the new `meter_source` option: `ha_dsmr` subscribes
to the DSMR integration over the HA WebSocket, `mqtt_p1` reads a topic.
Defaults to `off`, which keeps the existing `meter_entity` path untouched -
nothing changes for an install that does not opt in.
Enabling it publishes `sensor.p1_sample_age_s`: seconds since the newest
accepted telegram, recomputed against the clock and republished roughly once
a second rather than only when a telegram lands. That is deliberate - Home
Assistant only pushes a state on change, so a meter sitting at a genuinely
constant reading would otherwise look identical to a dead one. Watching the
age instead means a frozen meter shows a climbing age, not a flat line. The
firmware watchdog subscribes to this exact entity id.
Known limits, both already in DOCS.md: on `mqtt_p1`, a bridge stuck
republishing its last telegram still "arrives", so the age cannot detect
that particular failure - prefer `ha_dsmr` where both are available. And a
dead P1 meter takes 45 s to reach 0 W commanded (30 s for `meter_max_age_s`
to call the reading stale, then 15 s of `stale_input_s` on top), which is
`meter_max_age_s` and `stale_input_s` stacking, not either one alone.
## 0.2.1
`target_grid_w` (default -10 W): what the meter should rest at. The deadband
holds any resting point inside it indefinitely, and the meter bills import and
export on separate registers, so resting at +14 W import costs 0.34 kWh/day
with the loop behaving perfectly. Biasing the target slightly negative moves
that residue onto the export register. Configurable in the Configuration tab;
see DOCS.md for the trade-off table.
Behaviour is unchanged at `target_grid_w: 0`.
## 0.2.0
Precedence between strategies is now a first-class object instead of an if/else
ladder, ahead of there being more than three of them.
Every strategy returns a CLAIM each cycle - `set` ("I want X") or `limit` ("the
result must stay within these bounds") - and `arbiter.py` resolves them by one
rule:
1. Highest-priority `set` wins; no claim at all means 0 W.
2. Then every `limit` whose priority is >= that set's priority applies, most
restrictive first.
3. Contradictory limits are a BUG: command 0 W and say so.
Clause 2 is why "money outranks maintenance" is now a consequence of the
priorities rather than a special case in a Jinja template: the maintenance
charge-only limit binds the loop, but will not bind a higher-priority peak
shaving claim when one exists.
- Maintenance shaping (charge-only, cheap-window floor) moved out of the control
law. `control.py` is once again only a controller that tracks the meter.
- The loop computes from the ARBITER's last output, not its own last wish. If
something outranked it, that is what the hardware actually did, and tracking
anything else makes it jump when it regains control.
- Every decision is explainable: "loop -> 0 W, limited by maintenance
(charge-only)" now appears in the UI and the log, instead of a bare number.
- Safety limits (device rating, supervised max_w) bind every strategy including
the highest, and are still enforced a second time at the point of writing.
## 0.1.6
Findings from installing this on a live system, replacing a working YAML
implementation. Every one of these was silent - the add-on looked healthy while
being completely unable to do its job.
- **`run.sh` must use `#!/usr/bin/with-contenv sh`.** The HA base images run
s6-overlay, which starts services with a SANITISED environment. With a plain
shebang, SUPERVISOR_TOKEN is simply absent and every Home Assistant call
returns 401 - while `homeassistant_api: true` makes the permissions look
correctly granted. Startup now logs the token length and probes the Core API,
so the next person sees it in one line.
- **Bump `version:` for every change.** Supervisor keys the built image by
version, so editing source and rebuilding silently reuses the old image. Two
fixes appeared not to work because of this.
- **Dependencies come from apk, not pip.** Alpine is musl and there are no musl
wheels for aiohttp; pip would compile it on the client's Pi.
- **paho-mqtt 1.x and 2.x are both supported.** Alpine ships 1.x, which has no
`CallbackAPIVersion`; that raised and took the whole add-on down with it.
- **MQTT can no longer take down control.** Publisher construction is wrapped -
observability must never stop the controller.
- **MQTT discovery is published from `on_connect`.** paho drops QoS-0 publishes
issued before the CONNACK, so announcing straight after `connect()` published
nothing at all while logging "MQTT connected".
- **Repeated failures log at most once a minute.** The control loop retries every
second; unthrottled warnings rolled the log buffer and destroyed the startup
diagnostics needed to debug the 401 above.
- **`auto_start` works.** The store's defaults supplied `auto: False`, so the
fallback to the option could never fire.
Known issue: after deleting the MQTT entities from the registry during
development, Home Assistant would not re-adopt them from retained discovery -
not even after clearing the retained topics and reconnecting. The add-on
publishes correct discovery and live state (verified on the broker); this is an
HA-side adoption problem and affects status entities only, never control.
## 0.1.0
First packaged release. Ports the control loop and the monthly maintenance
cycle from the reference Home Assistant implementation into an add-on.
- Grid-following control: gain/slew/clamp/deadband with anti-windup, all tuned
against measured hardware behaviour (see FIELD-GUIDE.md §14).
- Saturation freeze **with the duration term** — three consecutive diverging
cycles, not one. The instantaneous test fires on every large correction,
because the plant itself needs 3-6 s to settle.
- Monthly maintenance cycle as an ownership state machine: drain / charge /
hold, with exactly one writer of the setpoint at any moment.
- Capacity-tariff awareness: the maintenance charge is capped by quarter-hour
peak headroom, and peak shaving outranks the maintenance schedule.
- Failsafe behaviour: commands 0 W on missing inputs, on stop, and on shutdown.
Never replays a stale setpoint - the reference implementation did, and the
hardware watchdog cannot catch that.
- Ingress UI with a commissioning checklist that names problems in words.
- Optional MQTT discovery for status entities.
Known limits:
- Home Assistant OS / Supervised only (add-ons cannot run on Container/Core).
- The inverter protocol is reverse-engineered; no vendor contract.
- Without the optional RS485 e-stop, nothing covers the host machine dying.