A fourth meter_source, `homewizard_local`, polling a HomeWizard P1's own local API (GET /api/v1/data) instead of watching an HA entity. The point is the age sensor. sensor.p1_sample_age_s is FW-01's watchdog input, and on every transport we had it measured "time since the value CHANGED", not "time since the meter REPORTED". Home Assistant offers nothing better: a repeated reading emits no state_changed, advances last_reported on neither serialiser, and state_reported cannot be subscribed to at all. Measured twice - 70 s of a frozen meter on the ENV-01 rig, and ten repeated readings against the live house. Our own capture of this house's meter goes 42.2 s and 97.0 s between changes, both past the default meter_max_age_s of 30, so the age sensor would have commanded 0 W on a perfectly healthy meter. Here every HTTP response is an arrival. The meter answered, now, with its current reading; whether the number moved is not consulted. Five identical readings are five arrivals. Reuses TEL-01's pipeline rather than restructuring it: same split_signed sign convention as ha_signed, same make_sample, same ingest stamping, meter_max_age_s, clock-recomputed age, plausibility bounds and the §20 unsigned-decode rejection. A failed or timed-out poll submits nothing, so it is a missing reading - never 0 W - and does not reset the age. meter_poll_s (default 5 s, the meter's own rate) is checked against meter_max_age_s once at startup, like the ha_signed entity ids. ⚠️ An arrival stamp cannot see a FROZEN meter, and no arrival detector can - one answering 200 OK with a stale number is arriving. The local API does expose what HA 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 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 would rebuild the false-trip limit cycle at the exact operating point we aim for. test_p1.py 179 -> 236 checks. Still defaults to off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
369 lines
20 KiB
Markdown
369 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.
|
||
|
||
#### `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.
|