Files
goodwe-addon/goodwe_controller/DOCS.md
T
glenn schrooyenandClaude Opus 5 e46175559b SAFETY-04 review fixes: the freeze deadlocked, the bound was too loose
S-1. The frozen branch admitted a correction only if it shrank |i_w|. That is
unsatisfiable for BOTH signs of error whenever |correction| > 2*|i_w|, i.e.
whenever the integrator is near zero, so the loop stopped moving and the freeze
could never clear - it clears when the inverter tracks, and not tracking is
what saturation means. Measured: 0 W held into a 2 kW import indefinitely,
where release/1.0 recovers on the next cycle. Re-encoded as the same asymmetric
rule the output freeze has always used: may not wind further in the direction
it is already pushing, may fall, cross zero or reverse. Same interpretation,
an encoding that cannot deadlock.

S-2. integrator_max_w defaulted to 1.5x max_w, which ADDED windup: in
release/1.0 the accumulator was the post-clamp command and could never pass the
rail. Default is now "follow max_w" (config 0 = unset). Measured on the 4000 W
load-drop sim, first cycle after the drop: 1000 W at the new default, 1800 W at
3000. DOCS row inverted - the useful direction is below max_w, and the 14 768 W
anecdote is a vendor controller, not evidence about this code.

S-3. The claim that i_w=None preserved release/1.0 exactly was false, because
the S-1 gate ran regardless of seeding. It is true again, and now asserted
rather than asserted-about: 3024-case exhaustive comparison against a
transcription of the old law, over both freeze states, both signs and either
side of the deadband. Added the carried-i_w convergence/overshoot sim that the
shipped configuration was missing.

S-4. Cycles are distinct meter values, not seconds: cycle() runs only when the
meter reading changes, so the window has no wall-clock bound. Comment and DOCS
corrected; the stall is detection latency, not a windup hazard, because the
same condition stalls the whole loop.

test_control.py: 33 -> 41 checks, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-24 22:13:55 +02:00

6.2 KiB
Raw Blame History

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.

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 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)

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.