Files
goodwe-addon/goodwe_controller/DOCS.md
T
glenn schrooyenandClaude Opus 5 147456c2a2 TEL-01: P1 ingestion, with the derivation and the age the EMS owns
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
2026-08-24 21:52:51 +02:00

192 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ~510 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. **Do not set to 1** |
| `integrator_max_w` | 3000 | Bound on the loop's accumulator, separate from `max_w`. Caps how much stale error can be waiting to unwind when the sign flips. **Keep it above `max_w`, and do not set it equal to `max_w`** |
| `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) |
#### 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.