The loop was in velocity form: the accumulator WAS the commanded power, so "clamp the integrator" and "clamp the output" were the same line of code and could not be set apart. This makes the accumulator an explicit carried value (`i_w`), bounds it with its own `integrator_max_w`, and keeps the output clamp where it was. Killing either mechanism now still leaves the other holding - which is the point of the ticket, and what the new regression test asserts. Freeze semantics: while saturated the integrator may unwind but not wind further. A strict freeze would strand the command at whatever it reached, because the condition that releases it is the inverter tracking again, and not tracking is exactly what saturation means. The integrator is re-seeded from the arbiter's actual output whenever the loop did not get what it asked for, so entering any failsafe (all of which resolve to 0 W) zeroes it, and the first cycle after release does not dump the stale period as power. test_control.py: 24 -> 33 checks, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
138 lines
6.0 KiB
Markdown
138 lines
6.0 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.
|
||
|
||
### 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.
|