A Belgian P1 meter publishes two UNSIGNED registers, not one signed figure. Until now the add-on asked the installer to bridge that gap with a template sensor, which put the sign convention of the whole control loop in a text box. This moves it into the EMS: net = import - export, derived once, in one place, with a test that fails if anyone inverts it. Two transports behind one contract, chosen by `meter_source`: the HA WebSocket subscribing to the DSMR integration's entities, and MQTT on a configurable topic. Everything downstream reads P1Ingest, so switching is a config edit. `meter_source: off` is the default and keeps the existing meter_entity path, so no installed system changes until it opts in. The other half is the timestamp. Every accepted sample is stamped at ingest with a monotonic clock, `meter_max_age_s` is applied to it, and the age is published as sensor.p1_sample_age_s for the ESP32's stale-input watchdog. That entity is recomputed against the clock every second rather than only when a telegram lands, because HA pushes state only on change: a meter frozen at a constant reading emits nothing and looks, to anything watching the value, exactly like a meter that has died. The age tells them apart. Deliberately absent: any fallback to an inverter-side power figure. The inverter's own AC power correlates 0.998 with battery power and 0.09 with the real meter, so failing over to it means regulating against your own output. A gap stays a gap - a reconnect emits no synthetic sample, and a rejected telegram never resolves to 0 W or refreshes the timestamp. Quarter-hour averages are time-weighted over clock-aligned blocks rather than a mean of samples, so a cadence change cannot bias the capacity-tariff figure, and only offtake is accumulated so a quarter of pure export averages to 0 kW. Per-phase import is kept separately: on an unbalanced three-phase load the phase sum and the connection net are different numbers, and only one of them is billed. test_p1.py: 99 checks, runnable with a bare interpreter and no meter. Includes an end-to-end run of the HA transport against a fake Home Assistant websocket. Stacked on SAFETY-04; nothing here touches control.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
215 lines
10 KiB
Markdown
215 lines
10 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. A Belgian P1 meter does not publish one: it publishes two
|
||
**unsigned** registers, consumption and injection. Setting `meter_source` moves
|
||
that subtraction into the add-on, where it is done once and tested, and replaces
|
||
`meter_entity` entirely.
|
||
|
||
| 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 |
|
||
| `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: grid power reads as *missing*, and the existing failsafe commands 0 W |
|
||
| `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 |
|
||
|
||
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.
|
||
|
||
#### `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.
|
||
|
||
### 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 |
|
||
| `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.
|