Four follow-ups on the reviewed and approved TEL-05 work. Additive; no shipped behaviour changes except the two failure paths below. 1. unchanged_s had no operator surface. DOCS.md told a reader "the transport tracks it as unchanged_s" and there was nowhere to look: main.py built the transport, scheduled run(), and never read the object again. The status page now shows it on the healthy P1 line. Still NOT thresholded and NOT folded into the age - that refusal was reviewed and upheld, because at the converged -10 W this controller aims for a 1 Wh register needs ~6 minutes to move, so any limit false-trips at the target operating point. The whole argument for leaving it to a human requires the human being able to see it. 2. The "equivalent mutant" note on the content_type guard was wrong, and the comment is downgraded to say so. web.Response(text=...) defaults to text/plain, so the fake meter CAN serve valid JSON under the wrong mimetype. Test added; shipped behaviour was already correct. 3. A timed-out poll logged an empty reason: str(asyncio.TimeoutError()) is "", so the status page read "last error:" and then nothing, on a hung meter, at the moment the battery had just gone to 0 W. Falls back to the class name. Note str(err), not `err or ...` - an exception object is always truthy. 4. submit() sat outside the try in poll_once() and run() had no except, so a raise would kill the poll task permanently and SILENTLY - safe (the age climbs, the controller commands 0 W) but indistinguishable from a dead meter. Both wrapped; poll_s is already the retry cadence, so no backoff. Also a comment at the parse_homewizard range(phases) slice: a 3-phase meter configured as 1-phase understates the capacity-tariff figure. Filed separately, not fixed here. 242 checks in test_p1.py (236 before, 6 new). test_control 55, test_arbiter 18, test_maintenance 21, all untouched and green. Each new check proved non-vacuous: six mutations, six named reds, no suite aborts, sources restored byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
374 lines
20 KiB
Markdown
374 lines
20 KiB
Markdown
# GoodWe RS485 Controller
|
||
|
||
Drives a GoodWe ES/BP battery inverter over its RS485 meter bus: holds net grid
|
||
exchange at zero, and runs a monthly battery maintenance cycle so the BMS can
|
||
balance cells and recalibrate its coulomb counter.
|
||
|
||
Installers: read `FIELD-GUIDE.md` in the repository. It is not optional reading —
|
||
it contains the commissioning gates and the failure modes.
|
||
|
||
## Before you start
|
||
|
||
You need:
|
||
|
||
- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation)
|
||
- The vendor's meter-emulating controller **disconnected** from the bus
|
||
- A T-CAN485 (ESP32) flashed with `firmware/goodwe-master.yaml`
|
||
- A grid-power sensor already working in Home Assistant, updating every ~5–10 s
|
||
|
||
## The safety model, in short
|
||
|
||
The inverter **holds its last command forever** — it has no meter-timeout. So:
|
||
|
||
- The ESP32 commands 0 W if this add-on stops refreshing for ~30 s, and keeps
|
||
commanding it.
|
||
- This add-on commands 0 W when its inputs go missing, when you stop control,
|
||
and when it shuts down.
|
||
- The **optional RS485 e-stop** is the only thing that covers this machine
|
||
dying. Without it, a failed host leaves the battery latched at its last
|
||
command until someone intervenes.
|
||
|
||
**If anything looks wrong: stop the add-on.** That commands 0 W and the
|
||
hardware holds it there.
|
||
|
||
## Configuration
|
||
|
||
### Sources
|
||
|
||
| option | required | meaning |
|
||
|---|---|---|
|
||
| `meter_entity` | yes | Net grid power. **Positive must mean importing** |
|
||
| `meter_invert` | | Flip the sign if the meter reports the other way |
|
||
| `soc_entity` | yes | Battery state of charge — use the **ESP32's own read** |
|
||
| `batt_entity` | yes | Battery power — again the ESP32's read, `+` = discharging |
|
||
| `batt_invert` | | Flip if needed |
|
||
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
|
||
|
||
Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
|
||
those serve cached values, and a stale reading here ends the maintenance charge
|
||
phase having charged nothing.
|
||
|
||
### P1 meter ingestion
|
||
|
||
`meter_entity` above expects one signed sensor, which usually means a template
|
||
someone wrote by hand. Setting `meter_source` moves the whole derivation into
|
||
the add-on, where it is done once and tested, and replaces `meter_entity`
|
||
entirely.
|
||
|
||
Which mode you want depends on what your P1 reader publishes, and there are two
|
||
shapes in the wild:
|
||
|
||
- **Two unsigned registers**, consumption and injection, which is what a Belgian
|
||
P1 read over DSMR gives you → `ha_dsmr`, or `mqtt_p1` for a bridge. The add-on
|
||
subtracts them.
|
||
- **One signed figure**, positive = import and negative = export, which is what
|
||
a HomeWizard P1 gives you (`sensor.p1_meter_active_power`) → `ha_signed`. The
|
||
add-on splits it. `ha_dsmr` **cannot** read this: it wants two registers and
|
||
rejects a negative one outright, which is every exporting telegram.
|
||
|
||
Either way, do not build the missing shape out of template sensors. The point of
|
||
`meter_source` is that the sign convention is derived in one tested place rather
|
||
than in YAML nobody reviews underneath a safety input.
|
||
|
||
> ✅ **If your meter is a HomeWizard P1, use `homewizard_local`, not
|
||
> `ha_signed`.** It reads the same meter and produces the same numbers, but it
|
||
> polls the meter directly instead of watching a Home Assistant entity — and
|
||
> that is the difference between an age sensor a watchdog can threshold and one
|
||
> it cannot. See "`sensor.p1_sample_age_s`" below; `ha_signed` remains for
|
||
> installs where the meter is only reachable through Home Assistant.
|
||
|
||
> ⚠️ **`ha_dsmr` and `mqtt_p1` have never processed a telegram from real
|
||
> hardware.** No meter in this installation uses either one. Both were written
|
||
> to the assumption in `specs.md` §5.2 that a Belgian P1 exposes two unsigned
|
||
> registers, and the meter actually fitted here does not — it is the HomeWizard
|
||
> P1 that `ha_signed` reads. They are covered by the unit checks in `test_p1.py`
|
||
> and by an end-to-end test against a fake Home Assistant, and nothing more.
|
||
>
|
||
> This is recorded because the realistic way it bites is someone debugging a
|
||
> meter problem months from now treating those two paths as proven and looking
|
||
> for the fault elsewhere. If you are the first person to point one at a real
|
||
> meter, expect to find something, and please update this note when you do.
|
||
|
||
| option | default | meaning |
|
||
|---|---|---|
|
||
| `meter_source` | `off` | `off` keeps `meter_entity`. `ha_dsmr` subscribes to the DSMR integration over the HA WebSocket; `mqtt_p1` reads a topic; `ha_signed` subscribes to one signed entity over the HA WebSocket; `homewizard_local` polls a HomeWizard P1's own local API, bypassing Home Assistant |
|
||
| `meter_phases` | 1 | 1 or 3. Must match the telegram, or every telegram is rejected and logged |
|
||
| `meter_max_age_s` | 30 | Beyond this the reading is stale and grid power reads as *missing*. On its own it does **not** command 0 W — see the timing note below. It is also the longest a reading is held forward into the 15-minute average |
|
||
| `meter_poll_s` | 5 | `homewizard_local` only. Seconds between polls. **Must be well under `meter_max_age_s`** — see below |
|
||
| `p1_host` | | `homewizard_local` only. The meter's own address, `host` or `host:port` (e.g. `192.168.2.250`) |
|
||
| `meter_mqtt_topic` | | `mqtt_p1` only |
|
||
| `p1_import_entity` | | The **unsigned** consumption sensor. Do not point this at a signed template |
|
||
| `p1_export_entity` | | The **unsigned** injection sensor |
|
||
| `p1_phase_import_entities` | `[]` | L1..L3, in order. Needed for the capacity-tariff peak on a three-phase connection |
|
||
| `p1_phase_export_entities` | `[]` | L1..L3, in order |
|
||
| `p1_net_entity` | | `ha_signed` only. The **signed** net-power sensor: `+` import, `-` export |
|
||
| `p1_phase_net_entities` | `[]` | `ha_signed` only. L1..L3, in order, each signed the same way. Needed for the capacity-tariff peak on a three-phase connection. **The list length must equal `meter_phases`** |
|
||
|
||
Both per-phase lists are checked against `meter_phases` **once at startup**: a
|
||
list of the wrong length disables P1 ingestion with an error in the log, rather
|
||
than letting every telegram fail its phase-count check one at a time. Leaving
|
||
the list empty is fine and is not an error — you simply get no per-phase
|
||
figures, and therefore no capacity-tariff peak. On a three-phase connection
|
||
that is a much bigger omission than it looks: on a surveyed reading here the
|
||
phases carried 2769 W of import while the connection netted 187 W, so the
|
||
billed quantity is understated roughly fifteenfold if the phases are missing.
|
||
|
||
#### How long a dead meter takes to reach 0 W
|
||
|
||
`meter_max_age_s` and `stale_input_s` **stack**. They are two different clocks
|
||
and neither one is the whole answer:
|
||
|
||
| step | option | default |
|
||
|---|---|---|
|
||
| telegrams stop, P1 sample goes stale, grid power starts reading *missing* | `meter_max_age_s` | 30 s |
|
||
| inputs have been missing long enough for the loop to command 0 W | `stale_input_s` | 15 s |
|
||
| **total, meter death → 0 W commanded by this add-on** | | **45 s** |
|
||
|
||
So in P1 mode `stale_input_s` is *not* "how long inputs may be missing before
|
||
commanding 0 W" measured from the meter dying — it is measured from the moment
|
||
the P1 sample already went stale. Size the pair together: the ESP32's own
|
||
watchdog commands 0 W after ~30 s of silence from this add-on regardless, and
|
||
that layer is unaffected by either option.
|
||
|
||
There is **no fallback to an inverter-side power figure**, deliberately. The
|
||
inverter's own AC power tracks its battery almost perfectly and the real meter
|
||
hardly at all, so a controller that failed over to it would be regulating
|
||
against its own output while looking healthy.
|
||
|
||
The `mqtt_p1` payload is one JSON object per telegram, and the schema is strict —
|
||
a key it does not recognise is a telegram from something other than what was
|
||
tested, and guessing a key here means guessing a kilowatt:
|
||
|
||
```json
|
||
{"import_w": 1234.0,
|
||
"export_w": 0.0,
|
||
"phases": [{"import_w": 500, "export_w": 0},
|
||
{"import_w": 400, "export_w": 0},
|
||
{"import_w": 334, "export_w": 0}],
|
||
"timestamp": "2026-08-24T18:00:05+02:00"}
|
||
```
|
||
|
||
`phases` and `timestamp` are optional; `timestamp` must carry a UTC offset. Where
|
||
it is present it is used for the age, which is what stops a retained message
|
||
replayed on reconnect from presenting a ten-minute-old reading as current.
|
||
|
||
#### `homewizard_local` — polling the meter instead of Home Assistant
|
||
|
||
Set `p1_host` to the meter's address and the add-on does `GET /api/v1/data` on
|
||
it every `meter_poll_s` seconds, reading `active_power_w` (signed, same
|
||
convention as `ha_signed`) and the three `active_power_l{1,2,3}_w` fields. Home
|
||
Assistant is not involved: no entity, no WebSocket, no integration to
|
||
mis-configure. Per-phase figures are used only when the meter serves all
|
||
`meter_phases` of them — a single-phase meter returns `null` for L2/L3, and the
|
||
connection-level reading is still accepted on its own.
|
||
|
||
**Why this mode exists:** every HTTP response is an *arrival*. The meter
|
||
answered, now, with its current reading — whether or not the number moved. That
|
||
is the signal `sensor.p1_sample_age_s` needs and the one Home Assistant cannot
|
||
give it at all (see the note below). It is also simply fewer moving parts: the
|
||
five-second cadence is the meter's own, rather than an integration's polling of
|
||
it re-published as a state change.
|
||
|
||
**Cadence.** The age is never fresher than the poll interval, so:
|
||
|
||
| | |
|
||
|---|---|
|
||
| meter's own update rate | ~5.0 s (measured 4.97 s) |
|
||
| `meter_poll_s` default | 5 s — nothing to gain below the meter's own rate |
|
||
| `meter_max_age_s` default | 30 s, i.e. six polls of headroom |
|
||
| `meter_poll_s >= meter_max_age_s` | **refused at startup** — every reading would be stale before its successor arrived |
|
||
| `meter_poll_s > meter_max_age_s / 2` | warned — one missed poll makes the reading stale |
|
||
|
||
A failed poll — timeout, connection refused, non-200, unparseable body — is a
|
||
**missing** reading. It submits nothing, so the reading does not become 0 W, the
|
||
last good value and its timestamp are left alone, and the age goes on climbing.
|
||
That is exactly what a dead meter should look like.
|
||
|
||
**What it still cannot see: a frozen meter.** A meter that answers `200 OK`
|
||
forever with a stale number is arriving, so no arrival detector — this one
|
||
included — can tell it from a healthy one. The local API does expose the raw
|
||
material the HA path never had (the `total_power_*_kwh` registers stop
|
||
advancing), and the transport tracks it as `unchanged_s`, but it is deliberately
|
||
*not* folded into the age and *not* thresholded: this controller regulates grid
|
||
power toward ~0 W, and at a converged −10 W the export register needs six
|
||
minutes to move by its 1 Wh resolution while the power figure legitimately
|
||
repeats. Thresholding that at 30 s would rebuild the false-trip limit cycle at
|
||
the exact operating point the controller aims for. Freeze detection is a
|
||
separate problem and needs the low-power case solved first.
|
||
|
||
You read it yourself instead: the add-on's status page shows it on the P1 line,
|
||
as `… 4 rejected, measurement unchanged for 312 s`. On a house drawing real
|
||
power that figure stays in the seconds; minutes of it while the load is clearly
|
||
not near zero is the meter to go and look at.
|
||
|
||
#### `sensor.p1_sample_age_s`
|
||
|
||
Published over MQTT discovery whenever a broker is available: **seconds since the
|
||
newest accepted telegram**, refreshed every second rather than only when a
|
||
telegram lands. The ESP32's stale-input watchdog subscribes to this exact entity
|
||
id, so do not rename it.
|
||
|
||
The reason it is recomputed against the clock is that Home Assistant only pushes
|
||
a state when the state *changes*. A meter sitting at a genuinely constant reading
|
||
emits nothing, which is indistinguishable — to anything watching the value — from
|
||
a meter that has died. Watching the age instead separates the two: it climbs when
|
||
telegrams stop and resets when they arrive, whatever the reading says.
|
||
|
||
The entity is only created when `meter_source` is not `off`. With P1 ingestion
|
||
disabled there is nothing feeding it, and an age sensor climbing with no ingester
|
||
behind it would trip the firmware watchdog on a system that is working fine.
|
||
|
||
> **Known limit, `mqtt_p1`.** The age measures *arrival*. On the MQTT path a
|
||
> bridge that is stuck republishing its last telegram keeps arriving, so the age
|
||
> stays near zero and a frozen meter still looks fresh. Detecting *that* needs a
|
||
> change-detector rather than an arrival-detector, and it is not in this version.
|
||
|
||
> ⚠️ **Known limit, `ha_signed` — do not drive a watchdog off this age yet.**
|
||
> On the HA WebSocket paths the age is stamped when a `state_changed` arrives,
|
||
> which means it measures *time since the value last changed*, not time since
|
||
> the meter last reported. Home Assistant offers nothing better: a repeated
|
||
> reading produces no `state_changed`, does **not** advance `last_reported` on
|
||
> either the REST or the WebSocket serialiser, and `state_reported` cannot be
|
||
> subscribed to over the WebSocket at all (`Event filter is required for event
|
||
> state_reported`). All three measured on the ENV-01 rig against the real
|
||
> HomeWizard integration with the meter frozen: 0 `state_changed` in 70 s and no
|
||
> timestamp movement anywhere.
|
||
>
|
||
> `ha_dsmr` mostly escapes this because a DSMR telegram updates several entities
|
||
> and something in the set almost always moves. **`ha_signed` has exactly one
|
||
> entity, so a healthy meter under a flat load is indistinguishable from a dead
|
||
> one.** This is not hypothetical: in our own captures
|
||
> (`sim/scenarios/ha-p1_meter_active_power-2026-08-20.json`) the real house meter
|
||
> went **42.2 s and 97.0 s** between changes, and 23 Aug peaks at 29.1 s — all
|
||
> past the default `meter_max_age_s` of 30.
|
||
>
|
||
> So `sensor.p1_sample_age_s` on `ha_signed` is safe to *read*, and it is
|
||
> correct whenever the value is moving, but it must not yet be thresholded by
|
||
> the ESP32 stale-input watchdog: a quiet house would trip the battery to 0 W.
|
||
> Raising `meter_max_age_s` is **not** the fix — the two conditions produce an
|
||
> identical signal, so a bigger number only chooses which of the two errors you
|
||
> get. The real fix is an arrival stamp the meter itself provides — and that now
|
||
> exists: **`meter_source: homewizard_local`**. If you have a HomeWizard P1,
|
||
> switch to it. If your meter is only reachable through Home Assistant, this
|
||
> limit still applies to you and the watchdog threshold still must not be armed.
|
||
|
||
**Where the age is trustworthy:**
|
||
|
||
| mode | the age measures | safe to threshold from firmware |
|
||
|---|---|---|
|
||
| `homewizard_local` | time since the meter **answered** | **yes** — every HTTP response is an arrival |
|
||
| `ha_dsmr` | time since one of several entities changed | no — statistically usually fine, which is a masked bug, not an absent one |
|
||
| `ha_signed` | time since the one entity changed | **no** — see above |
|
||
| `mqtt_p1` | time since a message arrived | arrivals yes, but a stuck bridge republishing keeps arriving |
|
||
|
||
### Control
|
||
|
||
| option | default | meaning |
|
||
|---|---|---|
|
||
| `max_w` | 2000 | Hard limit on what may be commanded. Start low, raise after commissioning |
|
||
| `gain` | 0.6 | Correction per cycle. **At the limit — do not raise** |
|
||
| `slew_w` | 1000 | Maximum change per cycle |
|
||
| `deadband_w` | 15 | Ignore errors smaller than this |
|
||
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
||
| `step_w` | 10 | Quantisation |
|
||
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
|
||
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one *changed* meter reading, not a fixed period - see the note below. **Do not set to 1** |
|
||
| `integrator_max_w` | 0 | Bound on the loop's accumulator, and 0 means "same as `max_w`". Caps how much stale error can be waiting to unwind when the sign flips. **Do not raise it above `max_w`** - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below `max_w` is the useful direction |
|
||
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
|
||
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W. In P1 mode this clock starts only *after* `meter_max_age_s` has already expired — the two stack, see "How long a dead meter takes to reach 0 W" |
|
||
| `auto_start` | false | Start controlling on boot (only after commissioning) |
|
||
|
||
#### Saturation is counted in cycles, not seconds
|
||
|
||
The specification states the saturation window as **"> 10 s"**. This add-on counts
|
||
**cycles** instead, and that is a deliberate, accepted deviation rather than an
|
||
oversight - the acceptance criterion is not met as literally written.
|
||
|
||
A cycle here is one *changed* meter reading: the controller only runs the loop when the
|
||
meter value differs from the previous poll. At the reference P1's ~5 s update rate the
|
||
default of 3 cycles is usually around 15 s, but there is **no guaranteed wall-clock
|
||
window** - a meter that repeats the same value stalls the counter for as long as it
|
||
repeats.
|
||
|
||
Two reasons that is acceptable:
|
||
|
||
- the control law is a pure function with no clock, which is what makes it testable
|
||
without hardware, and a seconds-based window would have to live in the controller;
|
||
- a stalled counter is a detection-latency limit and not a runaway risk. The condition
|
||
that stalls it - an unchanging meter - stops the whole loop, so nothing accumulates
|
||
while it is stalled.
|
||
|
||
If a guaranteed window matters on your site, raise `saturation_cycles` for a fast meter,
|
||
and treat the figure as "N meter updates" rather than "N seconds".
|
||
|
||
#### Why `target_grid_w` is not zero
|
||
|
||
The deadband is a one-way ratchet: any resting point inside it holds until
|
||
something disturbs it. Import and export are **separate registers on the
|
||
meter**, so a rest point of +14 W is billed for every second it holds and no
|
||
amount of export cancels it - 14 W all day is 0.34 kWh.
|
||
|
||
Biasing the target below zero moves that residue into the export register,
|
||
which is not billed. The resting band becomes `target ± deadband`, so:
|
||
|
||
| `target_grid_w` | resting band | worst billed leak | export given away |
|
||
|---|---|---|---|
|
||
| 0 | -15 … +15 W | ~15 W (0.35 kWh/day) | none |
|
||
| **-10** | -25 … +5 W | ~5 W (0.12 kWh/day) | ~10 W |
|
||
| -15 | -30 … 0 W | none | ~15 W (0.36 kWh/day) |
|
||
|
||
Set it to `-deadband_w` if injection is worth nothing to you and you would
|
||
rather give the energy away than buy it back. Set it to `0` if you are paid
|
||
properly for export, or if you are debugging and want the loop centred.
|
||
|
||
⚠️ This is a **billing** knob, not a speed knob. If import is arriving in
|
||
bursts rather than as a trickle, the cause is tracking lag, and this will not
|
||
help - see "Why the tuning is what it is".
|
||
|
||
### Maintenance
|
||
|
||
| option | default | meaning |
|
||
|---|---|---|
|
||
| `maintenance_enabled` | false | Enable the monthly cycle |
|
||
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
|
||
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
||
| `maintenance_discharge_w` | 2500 | Drain rate (exports the surplus) |
|
||
| `maintenance_charge_w` | 2500 | Charge ceiling, capped again by peak headroom |
|
||
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor |
|
||
| `maintenance_soc_target` | 99 | Charge target |
|
||
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
|
||
|
||
### Tariff (all optional)
|
||
|
||
| option | meaning |
|
||
|---|---|
|
||
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
|
||
| `peak_cap_w` | The site's capacity-tariff target |
|
||
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
|
||
|
||
On a capacity-tariff site the maintenance charge is capped by the headroom left
|
||
under `peak_cap_w`, and if the forecast goes over the cap the charge-only clamp
|
||
is dropped so the battery can shave the peak instead. Money outranks the
|
||
maintenance schedule.
|
||
|
||
### Site
|
||
|
||
| option | meaning |
|
||
|---|---|
|
||
| `estop_fitted` | Whether the RS485 e-stop is installed. Drives the warning banner |
|
||
| `log_level` | `trace`/`debug`/`info`/`warning`/`error` |
|
||
|
||
## The Web UI
|
||
|
||
The ingress panel shows live values, why the controller is commanding what it
|
||
is, and a **Commissioning** checklist that names any problem in words. It also
|
||
carries the three buttons: start/stop control, force a maintenance cycle, and
|
||
abort one.
|
||
|
||
## Status entities
|
||
|
||
If an MQTT broker is available the add-on publishes setpoint, grid power,
|
||
battery power, state of charge, maintenance phase and controller status by MQTT
|
||
discovery. This is observability only — the controller works fine without a
|
||
broker, and MQTT problems can never affect control.
|