T-1, and it was a fleet-wide trip to zero. publish() emitted p1_age unconditionally, and published_age_s counts from P1Ingest.__init__ when no sample has ever arrived. With meter_source defaulting to off, every existing install would have published sensor.p1_sample_age_s climbing without bound; the ESP32 does `has_state() && state >= max_age_s` and forces the layer-1 failsafe, so each of them would have pinned its inverter at 0 W within 30 s. Exactly the opposite of the zero-regression the off default was for. The key is now omitted from the payload AND from MQTT discovery when P1 is off, so the entity does not exist at all - which is the status quo, and what has_state() is testing for. The predicate is one function, is_enabled(), because the grid reading, the task start and the discovery announcement have to agree or this comes back. T-2, connect no longer manufactures a sample. get_states returns whatever HA currently holds, which after a Core restart is a RestoreEntity value of unknown age; stamping it with ingest_ts=now reset the age and reported a fresh meter that could have been dead for an hour. run()'s own docstring already said a reconnect must emit nothing - the code disagreed with it, and a test asserted the violation. The cache is still primed, so the first real state_changed builds a complete sample; the age just stays honest until one arrives. T-3, gaps are no longer filled with the last held value. The averager held a sample forward across any interval, so a meter dying at 5 kW and returning ten minutes later credited 5 kW x 600 s to the capacity-tariff accumulator - a fabricated peak on a permanent record. The hold is capped at max_age_s: past that the stretch is walked so block boundaries still land correctly, but nothing accumulates and elapsed does not grow, which is what finally makes the comment about a gap dragging the billed average down true. Same threshold for control and billing: a reading too old to steer by is too old to bill by. T-4, the out-of-order/duplicate guard is covered. It was untested, and the reason is worth recording: the obvious assertion passes without the guard, because the negative interval is separately refused by the covered > 0 test. What the guard prevents is the timestamp REWIND, which only shows up one sample later as a re-integrated window. The test now goes one sample later. T-6, DOCS was wrong about latency. meter_max_age_s and stale_input_s stack, so meter death to 0 W is 45 s and not 30. Documented as a table with both clocks. Also documented the T-5 asymmetry rather than papering over it: the age measures arrival, not change, so a stuck MQTT bridge republishing its last telegram still looks fresh. Correct on ha_dsmr, not detectable on mqtt_p1 without a change-detector. Written up as a known limit. Writing the T-1 test caught a second defect in the test itself: it recorded only MQTT topics, and object_id lives in the payload, so "the age sensor is not announced" had been passing for the wrong reason. test_p1.py: 99 -> 122 checks. 14 mutations run, all 14 red, files restored byte-identical - including one per fix above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
12 KiB
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 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_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 |
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:
{"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.
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_p1only. The age measures arrival, not change. On theha_dsmrpath that is exactly right: a frozen meter emits nostate_changed, so nothing arrives and the age climbs. 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. Preferha_dsmrwhere both are available.
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.