Packaged as a Home Assistant add-on, with a field guide

Turns the reference RS485 controller into something a technician can install
at a client site: a typed config form instead of YAML, an ingress UI that
names misconfiguration in words, and persistent state that cannot be broken by
a timezone.

Why an add-on rather than YAML packages or blueprints:

- Blueprints cannot create helpers, and the maintenance cycle is a state
  machine whose phase and completion date must survive restarts.
- YAML packages need filesystem access, a configuration.yaml edit and a
  restart - none of which belong in a client install.
- Add-ons authenticate with SUPERVISOR_TOKEN, so there is no long-lived token
  to generate, store or leak on someone else's machine.
- Requires HA OS/Supervised. Container and Core installs cannot run add-ons at
  all, which is a market decision, not an oversight.

The control law and the maintenance machine are pure functions with no Home
Assistant imports, and both ship with runnable checks (22 and 22 assertions).
Every assertion corresponds to a rule whose absence caused an observed failure
on hardware - the saturation duration term, the clamp-before-slew ordering, the
deadband, the sign convention.

One behaviour deliberately differs from the implementation it replaces: when
its inputs go missing this commands 0 W rather than replaying the last
setpoint. The reference version kept replaying, which the hardware watchdog
cannot catch - from the ESP32's side, Home Assistant is still talking to it.

Includes the ESPHome firmware (now parameterised: node name, inverter rating,
watchdog timeout) and the optional RS485 e-stop. FIELD-GUIDE.md carries the
commissioning gates, all judged on the wire rather than on how Home Assistant
looks, plus the written statement a site without an e-stop needs signed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
This commit is contained in:
2026-08-23 01:15:25 +02:00
co-authored by Claude Opus 5
commit 0a1e61dbc9
24 changed files with 2737 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
.esphome/
secrets.yaml
*.log
+382
View File
@@ -0,0 +1,382 @@
# GoodWe RS485 Controller — Field Guide
For installing technicians. Read section 1 before the first site visit; work
through sections 511 in order at the site; do not sign off until section 12 is
complete.
---
## 1. The one fact this whole product is built around
**The inverter holds the last command it understood, forever.** It has no
meter-timeout of its own. If whatever is driving it stops talking, it does not
fall back to idle, to a safe value, or to anything else — it keeps doing exactly
what it was last told, indefinitely.
This is not a theory. On the reference system a controller went silent
mid-command and the inverter held **5 kW of discharge for 113 seconds**, until a
person noticed and intervened.
Everything else in this guide follows from that:
| layer | covers | where it lives |
|---|---|---|
| 1 — watchdog | controller alive but silent | ESP32 firmware: no fresh setpoint for 30 s → command 0 W **and keep commanding it** |
| 2 — wind-down | planned firmware update | ESP32 firmware: 0 W written *before* the update starts |
| 3 — e-stop | **the controller or its host is dead** | optional RS485 e-stop: writes 0 W after 30 s of total bus silence |
**Layer 3 is the only thing that covers the Home Assistant machine dying.**
Nothing running on that machine can cover its own death. On a site without the
e-stop, a failed Pi or a pulled plug leaves the battery running at whatever it
was last commanded until a human intervenes. Section 10 tells you what to say
about that, in writing.
> Stopping is the failure mode, not the fix. Anything in this system that finds
> itself in doubt must command **0 W** — never hold the last value "to be safe".
---
## 2. Before you go: what to confirm with the client
Do not treat these as formalities. Each has ended an installation.
- [ ] **Inverter is a GoodWe ES / BP family unit** (the AA55 / RS485 meter-bus
generation). Other GoodWe families use a different protocol and are not
supported.
- [ ] **There is an existing controller on the meter bus** (vendor box emulating
a smart meter). Confirm what it is, and that the client accepts it being
**disconnected**.
- [ ] **Warranty and installer agreement.** Replacing the vendor controller may
affect both. Get the client's written acknowledgement. This is a
commercial question, and it is cheaper to ask than to discover.
- [ ] **Grid connection rules.** In Belgium the installation is governed
(Synergrid C10/11). Modifying how the inverter is driven can touch the DSO
agreement. Confirm the client's position before quoting.
- [ ] **Any active vendor subscription** — cancel *after* the replacement is
proven, not before.
- [ ] **Home Assistant is HA OS or Supervised.** Add-ons cannot be installed on
HA Container or Core. Check Settings → System → Repairs → System
information. **If it says Container, this product cannot be installed.**
- [ ] **A working grid-power sensor exists in HA** with a fast update
(≤ ~10 s). HomeWizard P1, DSMR, Shelly EM are all fine. Note its update
rate — see §9.
- [ ] **E-stop fitted or not** — and if not, that the client has signed the
statement in §10.
---
## 3. What is in the box
**Base SKU**
- T-CAN485 (ESP32 + RS485 transceiver), pre-flashed or flashed on site
- Wiring loom to the inverter's meter port
- The add-on (installed from your repository URL)
**E-stop upgrade**
- Raspberry Pi (any model with USB) + USB-RS485 adapter
- Pre-loaded `rs485_log.py`
- Tap wiring to the same bus
---
## 4. How the pieces fit
```
grid meter ──► Home Assistant ──► GoodWe RS485 Controller add-on
(client's) (client's) │
│ number.<node>_goodwe_setpoint_w
T-CAN485 (ESP32)
│ RS485, 9600 8N1, AA55/Modbus
GoodWe inverter meter port
│ passive tap + emergency write
Pi e-stop (optional)
```
The add-on never talks to the inverter directly. It writes one number; the ESP32
turns that into meter frames at a steady cadence and owns the safety timing.
---
## 5. Site survey and safety
⚠️ **Qualified persons only.** The inverter carries mains AC and battery DC.
The meter bus itself is low-voltage, but you are working inside an energised
installation.
1. Photograph the existing wiring at the inverter's meter port **before**
touching anything.
2. Identify the RS485 pair (A/B) going to the vendor controller.
3. Note the inverter model and serial from its label.
4. Record the battery: capacity (Ah), nominal voltage, and the inverter's
depth-of-discharge setting if visible.
**Never do these:**
- ❌ Do not flash third-party firmware onto the inverter's WiFi dongle. The
widely circulated image targets different hardware and will brick it.
- ❌ Do not long-press (35 s) the inverter's WiFi Reset/Reload button. It
factory-resets the dongle and you lose network access to it. A short press
(~1 s) is safe.
- ❌ Do not connect our controller while the vendor controller is still
attached. **Two masters on one bus is the one configuration that can produce
contradictory commands.** Disconnect the vendor box first, at 0 W.
---
## 6. Hardware installation
1. **Bring the system to 0 W.** Set the vendor controller to idle if it allows
it, or simply confirm on the inverter display that the battery is neither
charging nor discharging.
2. **Disconnect the vendor controller** from the meter port. Leave it physically
installed but disconnected if the client wants a reversible install — that
also gives you a rollback story if you ever need one.
3. **Wire the T-CAN485** to the meter port: A→A, B→B, plus its own power supply.
Observe polarity; swapped A/B produces a completely silent bus, not an error.
4. If fitting the **e-stop**, connect the USB-RS485 adapter to the *same* pair,
in parallel. It is passive until it decides to act.
5. Power up the T-CAN485 **last**.
⚠️ It begins transmitting within seconds of boot. Never power it up while the
vendor controller is still connected.
---
## 7. Flash the ESP32
Use the ESPHome Device Builder add-on on the client's HA, or your own laptop.
1. Copy `firmware/goodwe-master.yaml` into ESPHome.
2. Set the three substitutions at the top — **and only those**:
- `name` — the node name. Write it on the commissioning sheet. **Changing it
later renames every entity in HA and silently breaks the add-on's config.**
- `max_w` — the inverter's continuous rating (e.g. `5000`). This is a hard
firmware limit, independent of anything HA asks for, and it is the last
line of defence against a controller bug.
- `wd_ms` — watchdog timeout, default `30000`. Must stay comfortably above
the add-on's heartbeat.
3. Add the client's WiFi credentials to ESPHome's `secrets.yaml`.
4. Install. First flash is by USB; everything after that is over the air.
⚠️ **A firmware update that lands new shutdown-path code still runs the OLD code
on the way out.** If you change the wind-down or watchdog behaviour, upload
twice before believing a test result.
---
## 8. Install and configure the add-on
1. Settings → Add-ons → Add-on store → ⋮ → **Repositories** → add your repo URL.
2. Install **GoodWe RS485 Controller**. Do not start it yet.
3. Open **Configuration** and fill in:
| option | what to put | notes |
|---|---|---|
| `meter_entity` | the client's grid power sensor | **+ must mean importing.** If theirs is the other way round, set `meter_invert` |
| `soc_entity` | `sensor.<node>_goodwe_battery_soc` | **the ESP32's own read**, not the inverter's cloud/dongle sensor |
| `batt_entity` | `sensor.<node>_goodwe_inverter_ac_power` | same — the ESP32's read |
| `setpoint_entity` | `number.<node>_goodwe_setpoint_w` | what the add-on writes |
| `max_w` | start at **1000** for commissioning | raise after §9 passes |
| `estop_fitted` | true only if you actually fitted one | drives the warning banner |
| `peak_forecast_entity` | capacity-tariff sites only | leave empty elsewhere |
| `price_now_entity` / `price_avg_entity` | dynamic-tariff sites only | leave empty on fixed tariffs |
⚠️ **Get the entity ids exactly right.** Home Assistant prefixes entity ids with
the device's *area* at creation time, so the same firmware produces
`sensor.goodwe_master_...` on one site and `sensor.cellar_goodwe_master_...` on
another. **A wrong entity id is not an error anywhere in HA** — it simply never
produces a value. On the reference install two safety alarms pointed at
non-existent entities and were dead for a day while their state read "on".
Copy ids from Developer Tools → States. Do not type them from memory.
4. Start the add-on and open its **Web UI** (ingress panel).
5. Work down the **Commissioning** list until every line is green. It tells you
what is wrong in words. Do not proceed while anything is red.
---
## 9. Commissioning — the acceptance gates
**Every gate is judged on the wire or the meter, never on "it looks right in
Home Assistant".** If you fitted the e-stop, its log is your witness; if not,
use the inverter's own display and the client's meter.
### Gate 1 — the inverter answers us
Add-on running, control **stopped**. Expect a steady 0 W command being written,
the inverter idle, no errors in the add-on log.
If the e-stop is fitted, `bus.log` shows our write frames being ACKed:
```
F7 10 05 6E 00 02 04 00 03 00 00 A6 D0 our write, 0 W
F7 10 05 6E 00 02 34 4F inverter ACK
```
✅ Pass: frames ACKed, no CRC errors, inverter idle.
### Gate 2 — we can move power, both directions
With control still stopped, set the setpoint by hand from Developer Tools
(`number.set_value` on the setpoint entity):
- **+300 W** → battery discharges ~300 W within ~10 s
- **300 W** → battery charges ~300 W
✅ Pass: both signs work and the inverter's reported power follows within ~10 s.
❌ If the sign is inverted, fix `batt_invert` / your wiring — **do not**
"compensate" in the tuning.
### Gate 3 — telemetry agrees
Compare the ESP32's readings against the inverter display or the vendor app:
state of charge should match exactly; power within a few percent (conversion
loss). ✅ Pass: SoC identical, power within ~5 %.
### Gate 4 — the watchdog (**the important one**)
Set +300 W by hand, confirm it is running, then **stop the add-on**.
✅ Pass: the inverter reaches **0 W within ~30 s** and stays there.
❌ Fail: anything still moving after a minute. Stop the installation and
investigate — without this, nothing else in this product is safe.
Restart the add-on afterwards.
### Gate 5 — the closed loop
Set `max_w` to 1000, start control from the Web UI, and watch the client's grid
power.
✅ Pass: grid settles to within a few tens of watts of zero and *stays* there,
with the command resting rather than hunting continuously.
### Gate 6 — a real load step
Switch on a kettle or oven (~2 kW).
✅ Pass: grid returns to near zero within ~20 s, and the command does **not**
keep climbing after the battery has caught up. A command that keeps rising while
the battery is pinned is runaway — stop immediately and see §13.
Then raise `max_w` to the value the site is sold with (typically the inverter
rating) and repeat Gate 6 once.
### Gate 7 — the maintenance cycle
See §11. **Do not sign off without it.**
---
## 10. The e-stop, and what it means when it is absent
**Fitted:** connect the Pi to the same RS485 pair, power it, and run:
```bash
python3 rs485_log.py --out /home/pi/bus.log --panic
```
Confirm it prints `PANIC ARMED`. Test it: with the battery at +300 W, cut power
to the T-CAN485. Within ~35 s the log must show a ` TX ` line and the inverter
must go to 0 W.
⚠️ Arm the panic write **only** while our controller owns the bus. Pointed at a
bus somebody else is driving, it is unrequested interference.
⚠️ Expect it to fire during every firmware update — the upload silence exceeds
30 s. That is correct behaviour, not a fault. Do not raise the threshold to
silence it.
**Not fitted — put this in front of the client, in writing:**
> Without the RS485 e-stop, if the Home Assistant machine fails, loses power, or
> its storage fails, the battery inverter will continue charging or discharging
> at whatever level it was last commanded, indefinitely, until someone
> intervenes manually. The inverter has no automatic fallback of its own. The
> e-stop is the only component that prevents this.
Have them acknowledge it. Note it on the commissioning sheet.
---
## 11. Prove the maintenance cycle
The monthly cycle takes the battery low, then charges it fully and holds it
there. It exists so the BMS can **balance cells** and **recalibrate its coulomb
counter**. Skipping it breaks nothing visibly — it degrades the pack over months,
and the first symptom is a state-of-charge reading nobody can trust.
**Do not wait a month to discover the schedule does not fire.** Force one:
1. Web UI → **Force maintenance cycle**.
2. Watch the phase go `drain → charge → hold → idle`.
3. Confirm the inverter actually exports during `drain`, actually charges during
`charge`, and sits at 0 W during `hold`.
A full cycle takes hours. If the client cannot spare the time on the day,
temporarily set `maintenance_soc_floor` just below current SoC and
`maintenance_soc_target` just below it again, plus `maintenance_hold_min: 5`
that walks the whole state machine in about ten minutes. **Put the real values
back afterwards and note it on the sheet as a partial test.**
Finally set `maintenance_enabled: true`.
---
## 12. Handover and sign-off
- [ ] All gates in §9 passed, on the wire or the meter
- [ ] Maintenance cycle proven (full or partial — state which)
- [ ] E-stop fitted and tested, **or** client acknowledgement signed (§10)
- [ ] `max_w` set to the agreed value
- [ ] Node name, entity ids and add-on version written on the sheet
- [ ] Client shown: the Web UI, the start/stop button, and what "stopped" means
- [ ] Client told: **if anything looks wrong, stop the add-on** — that commands
0 W and the battery idles safely
- [ ] Vendor subscription cancelled only *after* the above
---
## 13. Troubleshooting
| symptom | likely cause | what to do |
|---|---|---|
| Web UI says **NOT READY** | an entity id is wrong or the sensor is unavailable | The banner names the failing item. Copy the id from Developer Tools → States |
| Everything looks configured but nothing moves | control is stopped | Press start in the Web UI; the banner should turn green |
| Inverter does nothing, no errors anywhere | A/B swapped, or the vendor controller is still connected | Swapped RS485 gives a *silent* bus, not an error |
| HA shows one setpoint, the inverter does another | the firmware `max_w` is lower than the add-on's `max_w` | The firmware wins by design. Raise it there, or lower it in the add-on |
| Writes rejected in the log (HTTP 400) | value outside the number entity's range | Lower `max_w`; check the firmware substitution |
| Command keeps climbing while the battery is pinned | **runaway** — sign inverted, or saturation detection defeated | **Stop the add-on immediately.** Verify `meter_invert` with a known load |
| Grid hunts continuously, never rests | deadband too small for that meter, or the meter is slow | Raise `deadband_w`; if the meter updates slower than ~10 s, lower `gain` |
| Battery goes flat overnight and stays flat | maintenance `drain` phase never exited | Check the SoC entity is the ESP32's, not a cached cloud value |
| Maintenance never runs | schedule disabled, or never became due | Check `maintenance_enabled`, and the "Maintenance due" row in the Web UI |
| E-stop fires during every update | expected — update silence exceeds 30 s | Nothing to fix. Do not raise the threshold |
| Add-on will not install | HA is Container/Core, not OS/Supervised | Not supportable. See §2 |
**When in doubt: stop the add-on.** That commands 0 W, the watchdog holds it
there, and nothing is at risk while you think.
---
## 14. Why the tuning is what it is
Do not change these without measuring. Every value came from hardware.
- **`gain` 0.6 per cycle** — a cycle is one meter update (~5 s). The inverter
needs 36 s to settle: ~1.4 s dead time, 94 % of a step by 3.3 s. So the loop's
next correction lands just as the plant arrives. **0.6 is at the limit — do
not raise it**, and do not shorten the cycle below the meter's update rate.
- **`slew_w` 1000 W per cycle** — most of a correction in the first cycle
without letting the command run far ahead of the hardware.
- **`deadband_w` 15 W** — measured residual while regulating: mean 15.4 W, max
27 W. At 10 W, ~69 % of cycles act and the command never rests. A resting
command is a diagnostic asset: "flat for 70 s" is how you recognise a healthy
loop at a glance.
- **`saturation_w` 500 W over `saturation_cycles` 3** — command and reading
diverging means the inverter is at a limit; then the magnitude may fall but
never rise. **The 3-cycle duration term is essential**: tested instantaneously
it fires on every large correction, because the plant itself lags.
- **`step_w` 10 W** — the register is 1 W, but the inverter's response lands on
a coarser ladder (~17.6 W measured at ~900 W). 10 W just avoids a visible
staircase; finer is meaningless.
- **`heartbeat_s` 10 s against a 30 s watchdog** — three chances to be heard
before the hardware takes over.
The failure this tuning is designed against is real: the vendor controller
commanded **14 547 W** against an inverter reporting 5250 W, and kept climbing
for six minutes, because its integrator never stopped.
+9
View File
@@ -0,0 +1,9 @@
All rights reserved.
This software is provided to licensed installers and their clients under the
terms of a separate commercial agreement. No warranty is expressed or implied.
⚠️ This software commands a grid-tied battery inverter. It moves real power and
can cost real money at the meter. The inverter it controls has no automatic
failsafe of its own. Do not deploy it without completing the commissioning
procedure in FIELD-GUIDE.md.
+73
View File
@@ -0,0 +1,73 @@
# GoodWe RS485 Controller — Home Assistant add-on repository
Replaces a GoodWe battery inverter's vendor controller with a local one: holds
net grid exchange at zero by emulating the inverter's smart meter over RS485,
and runs the monthly battery maintenance cycle the vendor box was doing.
```
repository.yaml add-on repository metadata
goodwe_controller/ the add-on
config.yaml manifest + options schema (the per-site config form)
app/ the controller
control.py the control law — pure functions, no I/O
maintenance.py the monthly cycle state machine
hass.py Supervisor/Core API access
store.py persistent state in /data
mqtt.py optional status entities
web.py ingress UI
main.py orchestration, heartbeat, failsafe behaviour
test_control.py runnable checks — no framework needed
test_maintenance.py runnable checks — walks a full cycle in fake time
DOCS.md the add-on's documentation tab
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
estop/rs485_log.py optional RS485 e-stop / bus witness
FIELD-GUIDE.md installation, commissioning gates, troubleshooting
```
## Install
Settings → Add-ons → Add-on store → ⋮ → **Repositories** → add this repository's
URL, then install **GoodWe RS485 Controller**.
Requires **Home Assistant OS or Supervised**. Add-ons cannot be installed on HA
Container or Core.
## Read this first
**The inverter holds its last command forever.** It has no meter-timeout of its
own — a controller that dies mid-command leaves the battery running until a
human intervenes. Measured on real hardware: 5 kW of discharge held for 113
seconds after a controller went silent.
Three layers exist because of that, and only the third covers the Home Assistant
machine itself dying:
1. **ESP32 watchdog** — no fresh setpoint for ~30 s → command 0 W, and keep
commanding it.
2. **Wind-down before firmware updates** — 0 W written before the update starts.
3. **RS485 e-stop (optional)** — writes 0 W after 30 s of total bus silence.
Sites sold without the e-stop must have the acknowledgement in `FIELD-GUIDE.md`
§10 signed.
## Development
The control law and the maintenance machine are pure Python with no Home
Assistant imports, so they run anywhere:
```bash
cd goodwe_controller
python3 test_control.py
python3 test_maintenance.py
```
Both must pass before shipping any change. They are not unit-test theatre —
each assertion corresponds to a rule whose absence produced an observed failure
on real hardware, and the comments in `control.py` say which.
## Compatibility
- GoodWe **ES / BP family** inverters (AA55 / RS485 meter-bus generation)
- The protocol is **reverse-engineered**. There is no vendor contract, and a
firmware change on GoodWe's side could break every installation at once. Say
so when you sell it.
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""Passive RS485 frame logger for the GoodWe bus.
The Pi takes over as witness during stage 1 (NOTES 18.4), when the T-CAN485
sniffer board is reflashed as the master and stops listening.
python3 rs485_log.py --selftest # no hardware needed
sudo python3 rs485_log.py --out bus.log # sudo: see tune_latency()
One line per frame: 21:00:29.412 ok F7 10 05 6F 00 01 02 05 DC D7 62
Hex is space separated and CRC-suffixed exactly like master/frames.txt, so
frame_check.py and the existing analysis take this file with an awk of $3-.
The log ROTATES (--max-mb / --keep): the Pi's SD card is finite and this file
grows ~23 MB/day. History lands in bus.log.1 .. bus.log.N, oldest discarded.
With --panic it stops being purely passive: after --panic-after seconds of TOTAL
bus silence it writes the 0 W frame itself. See panic_frame() for why that is
safe and why 0 W is the only thing it may ever send.
"""
import argparse, itertools, logging, logging.handlers, pathlib, sys, time
from datetime import datetime
BAUD = 9600
GAP = 0.004 # 3.5 char times @ 9600 8N1 = 3.65 ms. Modbus RTU frame boundary.
# The atomic "grid = import, 0 W" write - NOTES §5.3/§5.5. The inverter has ACKed
# this exact frame several hundred times. Byte-for-byte identical to
# master/frame_check.py::setpoint_atomic(0); the selftest checks its CRC.
ZERO_FRAME = bytes.fromhex("F710056E000204000300 00A6D0".replace(" ", ""))
def crc16(data: bytes) -> int:
"""Modbus RTU CRC16, little-endian on the wire. Same as master/frame_check.py."""
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return crc
def crc_ok(frame: bytes) -> bool:
return len(frame) > 2 and crc16(frame[:-2]) == int.from_bytes(frame[-2:], "little")
def frames(ser, idle=None):
"""Split the byte stream on the inter-frame gap. ser.timeout must be GAP.
ponytail: byte-at-a-time. 960 B/s is nothing for a Pi 3, and it keeps the
gap detection honest - a batched read cannot tell you where the silence was.
`idle` is called on every empty read, i.e. roughly every GAP while the bus is
quiet. Without it the caller never regains control during silence - which is
exactly the condition the panic writer has to notice.
"""
buf = bytearray()
while True:
b = ser.read(1) # blocks up to GAP, then returns b""
if b:
buf += b
if len(buf) < 260: # 256 payload + header; runaway guard
continue
if buf:
yield time.time(), bytes(buf)
buf.clear()
elif idle:
idle()
def tune_latency(port: str) -> str:
"""USB serial chips buffer before handing bytes up; FTDI defaults to 16 ms,
which smears the 3.65 ms gap that framing depends on. 1 ms is the minimum.
Needs root, hence sudo. Not all chips expose it - CRC rate is the real check.
"""
p = pathlib.Path("/sys/bus/usb-serial/devices") / pathlib.Path(port).name / "latency_timer"
try:
now = p.read_text().strip()
except OSError:
return "latency_timer: chip does not expose it; watch the bad-CRC count"
if now == "1":
return "latency_timer = 1 ms"
try: # needs root; the udev rule is the durable fix
p.write_text("1")
return "latency_timer = 1 ms (was %s)" % now
except OSError:
return ("latency_timer = %s ms and NOT writable as this user - frames will be "
"glued together. Install the udev rule, or run under sudo." % now)
def open_sink(path, max_mb, keep):
"""Size-rotating log sink: bus.log, bus.log.1 .. bus.log.N, oldest dropped.
stdlib RotatingFileHandler, deliberately. logrotate would need root (a
password someone has to type) plus a cron that actually ran - and a cron that
silently stopped running is a full SD card and a dead witness.
ponytail: no gzip. Compressing 25 MB on a Pi 3 blocks this single-threaded
loop for seconds and the tap would miss frames while it worked. Disk is the
cheap resource here (25 GB free against 23 MB/day); the witness's continuity
is not. Add compression only if space ever actually gets tight.
"""
h = logging.handlers.RotatingFileHandler(path, maxBytes=int(max_mb * 1_000_000),
backupCount=keep, encoding="utf-8")
h.setFormatter(logging.Formatter("%(message)s")) # no level/date prefix:
log = logging.getLogger("bus") # the line format is the
log.setLevel(logging.INFO) # documented interface
log.addHandler(h)
log.propagate = False
return log
class _FakeSerial:
def __init__(self, chunks): self.q = list(chunks)
def read(self, _n=1): return self.q.pop(0) if self.q else b""
def selftest() -> int:
real = [bytes.fromhex("F710056E0001020003947B"),
bytes.fromhex("F710056F00010205DCD762")]
assert all(crc_ok(f) for f in real), "known-good frame failed CRC"
assert not crc_ok(real[1][:-3] + b"\x00" + real[1][-2:]), "corrupt frame passed CRC"
# the framer: two frames separated by one gap, split on silence not on length
fake = _FakeSerial([bytes([b]) for b in real[0]] + [b""] +
[bytes([b]) for b in real[1]] + [b""])
got = [f for _, f in itertools.islice(frames(fake), 2)]
assert got == real, [g.hex(" ") for g in got]
assert crc_ok(ZERO_FRAME), "the panic frame's own CRC is wrong"
assert ZERO_FRAME.hex(" ").upper() == "F7 10 05 6E 00 02 04 00 03 00 00 A6 D0"
slave, fc, hi, lo, _, cnt, nbytes, dhi, dlo, mhi, mlo = ZERO_FRAME[:11]
assert (slave, fc, (hi << 8) | lo, cnt, nbytes) == (0xF7, 0x10, 0x056E, 2, 4)
assert (dhi << 8) | dlo == 3 and (mhi << 8) | mlo == 0, "panic frame is not 0 W"
# the idle callback must fire during silence, or the panic writer never runs
ticks = []
class _Stop(Exception): pass
def _idle():
ticks.append(1)
if len(ticks) == 3:
raise _Stop
try:
for _ in frames(_FakeSerial([]), idle=_idle):
pass
except _Stop:
pass
assert len(ticks) == 3, ticks
print("selftest ok: crc, corruption, gap framing, panic frame, idle tick")
return 0
def open_port(serial, port, rts=False, dtr=False):
"""rts/dtr OFF: pyserial asserts RTS on open, and many USB-RS485 adapters use it
as driver-enable - that would make a "passive" tap drive the bus. (Tested
2026-08-19: not the cause of the frame corruption, but correct regardless.)"""
s = serial.Serial(port, BAUD, bytesize=8, parity="N", stopbits=1, timeout=GAP,
rtscts=False, dsrdtr=False)
s.rts = rts
s.dtr = dtr
return s
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--port", default="/dev/ttyUSB0")
ap.add_argument("--out", default="bus.log")
ap.add_argument("--selftest", action="store_true")
ap.add_argument("--rts", choices=["on", "off"], default="off",
help="driver-enable polarity differs per adapter; test both")
ap.add_argument("--seconds", type=float, default=0.0, help="stop after N s (0 = forever)")
ap.add_argument("--max-mb", type=float, default=25.0, help="rotate at this size")
ap.add_argument("--keep", type=int, default=30, help="how many rotated files to keep")
ap.add_argument("--panic", action="store_true",
help="EMERGENCY STOP: write 0 W if the bus goes totally silent")
ap.add_argument("--panic-after", type=float, default=30.0,
help="seconds of silence before the panic write (NOTES §4.6)")
a = ap.parse_args()
if a.selftest:
return selftest()
import serial
ser = open_port(serial, a.port, rts=(a.rts == "on"))
print(tune_latency(a.port), file=sys.stderr)
print(f"logging {a.port} -> {a.out}", file=sys.stderr)
sink = open_sink(a.out, a.max_mb, a.keep)
print("rotating at %g MB, keeping %d (cap ~%g MB)"
% (a.max_mb, a.keep, a.max_mb * (a.keep + 1)), file=sys.stderr)
if a.panic:
print("PANIC ARMED: 0 W after %gs of silence" % a.panic_after, file=sys.stderr)
good = bad = panics = 0
t0 = time.time()
last_frame = time.time()
def stamp_now():
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
def idle():
"""Total bus silence means no master is alive - see NOTES §3.6/§4.6.
Silence is the discriminator precisely because a healthy master makes the
bus busy every 5 s: if anything is driving, this never fires, so the tap
cannot become a second master. And 0 W is the ONLY frame it may send, so
even a false positive (say this adapter wedges while the bus is actually
fine) costs at most one setpoint dip that the real master overwrites
within its next 5 s cycle. A panic button that could send anything else
would have to be as trustworthy as the master it replaces. This one does
not.
"""
nonlocal last_frame, panics
if not a.panic or time.time() - last_frame < a.panic_after:
return
ser.write(ZERO_FRAME)
panics += 1
sink.info("%s TX %s" % (stamp_now(), ZERO_FRAME.hex(" ").upper()))
print("PANIC #%d: bus silent %gs, wrote 0 W" % (panics, a.panic_after),
file=sys.stderr, flush=True)
# The inverter's ACK lands in ~150 ms and resets this anyway; setting it
# here makes the repeat cadence panic_after either way. Repeats matter:
# §3.6 says never stop writing, a latched inverter is the failure mode.
last_frame = time.time()
while True: # a replug re-enumerates the adapter and
try: # invalidates the fd; do not just die
for ts, fr in frames(ser, idle=idle):
ok = crc_ok(fr)
good, bad = good + ok, bad + (not ok)
last_frame = ts # ANY traffic means a master is alive
sink.info("%s %s %s" % (datetime.fromtimestamp(ts).strftime("%H:%M:%S.%f")[:-3],
"ok " if ok else "BAD", fr.hex(" ").upper()))
if (good + bad) % 100 == 0: # not every frame: often a file
print(good, "frames,", bad, "bad CRC", file=sys.stderr, flush=True)
if a.seconds and time.time() - t0 > a.seconds:
print("RESULT rts=%s: %d ok, %d bad, %d panic" % (a.rts, good, bad, panics),
file=sys.stderr, flush=True)
return 0
except serial.SerialException as e:
print(f"lost {a.port}: {e}", file=sys.stderr, flush=True)
try:
ser.close()
except Exception:
pass
while True: # udev has to reapply latency_timer too,
time.sleep(2) # so there is no point hammering it
try:
ser = open_port(serial, a.port, rts=(a.rts == "on"))
break
except Exception:
pass
last_frame = time.time() # a 60 s replug is OUR blindness, not bus
# silence - do not panic-write on reopen
print("reopened", a.port, "|", tune_latency(a.port), file=sys.stderr, flush=True)
if __name__ == "__main__":
sys.exit(main())
+298
View File
@@ -0,0 +1,298 @@
# STAGE 4: the failsafe - NOTES.md §4.6, §4.4.
#
# Everything up to stage 3 was safe only because a human was watching. §3.6 is why
# that cannot continue: the inverter holds the last command it understood FOREVER,
# and nothing downstream notices the master died. Measured cost of exactly this:
# Illusmart went silent mid-command and the inverter held +5000 W for 113 s.
#
# Three layers, each covering what the previous cannot:
# 1. THIS FILE, stale-input watchdog: HA alive but silent -> command 0 W.
# 2. THIS FILE, wind-down on shutdown: planned reboots and OTA.
# 3. The Pi tap (`rs485_log.py --panic`): this whole board dead -> the tap
# writes 0 W after 30 s of total bus silence. Nothing running on this MCU
# can cover its own death.
#
# ⚠️ CONTRACT CHANGE: HA must now REWRITE the setpoint every ~10 s even when the
# value has not changed. Without a refresh, "stale" and "steady" are the same
# thing on the wire and the watchdog is meaningless. See ha/goodwe-stage4.yaml.
#
# ⚠️ TRANSMIT IS LIVE and this one MOVES POWER.
# ⚠️ NEVER connect this board while Illusmart is still attached.
substitutions:
# ⚠️ Set these THREE things per site and nothing else.
#
# name the ESPHome node name. Changing it after commissioning renames every
# entity in Home Assistant and silently breaks the add-on's config -
# pick it once, write it on the commissioning sheet, never touch it.
# max_w the inverter's continuous rating. This is a HARD limit in the
# firmware, deliberately independent of anything Home Assistant asks
# for, and it is the last line of defence against a controller bug.
# wd_ms how long without a fresh setpoint before the board commands 0 W and
# keeps commanding it. Must stay comfortably above the add-on's
# heartbeat (default 10 s) or a slow network will trip it constantly.
name: goodwe-master
max_w: "5000"
wd_ms: "30000"
esphome:
name: ${name}
on_shutdown:
# Deterministic wind-down. NOT queue_command: the modbus queue may not drain
# before the reset, which made the old version best-effort. These are the
# literal bytes of setpoint_atomic(0) - the frame the inverter has ACKed
# several hundred times - written straight at the UART.
# ⚠️ Do NOT go back to `uart.write:` + `- delay: 50ms`. That version put
# NOTHING on the wire - measured 2026-08-22, an OTA at 300 W left the inverter
# latched for the full 29.75 s reboot. uart.write only fills the driver's TX
# ring, and `- delay:` is an async action whose continuation never runs because
# the reset does not wait for it. flush() (uart_wait_tx_done) is what makes
# this deterministic; the delay() after it is the blocking kind.
- lambda: |-
static const uint8_t wind_down[13] = {
0xF7, 0x10, 0x05, 0x6E, 0x00, 0x02, 0x04, 0x00, 0x03, 0x00, 0x00, 0xA6, 0xD0
};
id(rs485).write_array(wind_down, sizeof(wind_down));
id(rs485).flush(); // blocks until the last bit is out - 13.5 ms @ 9600
delay(20); // blocking delay(), not the `- delay:` action
esp32:
board: esp32dev
globals:
# millis() at the last setpoint HA pushed. 0 means "HA has never spoken since
# boot" and is treated as stale explicitly - see the script, a bare subtraction
# gets this wrong for the first 30 s of uptime.
# uint32_t subtraction wraps correctly, so the 49-day millis() rollover is a
# non-event. Do not "fix" it with a signed type.
- id: last_ha_update
type: uint32_t
restore_value: false
initial_value: '0'
switch:
- platform: gpio
id: rs485_boost
pin: GPIO16
internal: true
restore_mode: ALWAYS_ON
# MAX13487E: AutoDirection, no DE pin. GPIO17 = SHDN must stay HIGH (handing it
# to modbus as flow_control_pin shuts the chip down between frames - verified
# 2026-08-19, silent bus with perfect TX logs). GPIO19 HIGH = AutoDirection RX.
- platform: gpio
id: rs485_shdn
pin: GPIO17
internal: true
restore_mode: ALWAYS_ON
- platform: gpio
id: rs485_re
pin: GPIO19
internal: true
restore_mode: ALWAYS_ON
- platform: template
name: "GoodWe atomic write"
id: atomic_write
optimistic: true
restore_mode: ALWAYS_ON # start atomic; turn OFF for the sequential fallback
number:
- platform: template
name: "GoodWe setpoint W"
id: setpoint
min_value: -${max_w} # the inverter's rating - see substitutions.
max_value: ${max_w} # There is no headroom above this.
# ⚠️ Must stay in step with input_number.goodwe_setpoint
# in ha/goodwe-stage4.yaml - HA clamps SILENTLY, so a
# mismatch shows up as a frame that is quietly wrong.
step: 10 # 10 W, not 50: the 50 W step was the visible
# staircase on the dashboard (§5.11). The register
# itself is 1 W - Illusmart wrote values like -1190.
initial_value: 0
optimistic: true
restore_value: false # every boot starts at 0 W. Deliberate.
on_value:
# Records freshness and NOTHING else. It is safe to have this back only
# because it does not touch the bus: on 2026-08-22 00:09 an on_value that
# *did* write fired before the atomic_write switch had restored and emitted
# the sequential pair (the §5.3 shape). The 5 s interval stays the only
# writer - one writer, no race.
- lambda: |-
id(last_ha_update) = millis();
// Send NOW rather than waiting up to 5 s for the interval - the stage-5
// latency budget cannot afford it (NOTES §4.4b). The interval stays as
// the heartbeat, so this is an extra path, not a replacement.
// has_state() is the guard for the 2026-08-22 00:09 boot race: on_value
// fires before a restored switch has its state, and the setpoint script
// then takes the sequential branch (§5.3 shape). If the switch has not
// settled, skip - the interval sends it a moment later anyway.
if (id(atomic_write).has_state()) id(send_setpoint)->execute();
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
logger:
level: DEBUG
api:
reboot_timeout: 0s # never self-reboot on API loss - a reboot mid-test
# would latch whatever was last commanded (§3.6).
ota:
- platform: esphome
# ⚠️ THIS, not on_shutdown, is what actually covers a planned OTA. Measured
# 2026-08-22: OTA blocks the main loop for the whole ~25 s upload, so the bus
# is silent and the inverter stays latched at the old setpoint the entire
# time - a wind-down at shutdown fires ~25 s too late to matter. Both OTAs
# that night were rescued by the Pi tap's 30 s panic write instead.
# on_begin runs before the first flash write, with the UART still alive.
on_begin:
- lambda: |-
static const uint8_t wind_down[13] = {
0xF7, 0x10, 0x05, 0x6E, 0x00, 0x02, 0x04, 0x00, 0x03, 0x00, 0x00, 0xA6, 0xD0
};
id(rs485).write_array(wind_down, sizeof(wind_down));
id(rs485).flush();
delay(20);
uart:
id: rs485
rx_pin: GPIO21
tx_pin: GPIO22
baud_rate: 9600 # §3.1 - do not change
stop_bits: 1
debug:
direction: BOTH # the ACK is the whole result
after:
timeout: 5ms
bytes: 256
sequence:
- lambda: UARTDebug::log_hex(direction, bytes, ' ');
modbus:
id: mb
uart_id: rs485
# NO flow_control_pin - see the SHDN note above.
modbus_controller:
- id: inv
address: 0xF7
modbus_id: mb
update_interval: 10s # slower than the 5 s write cadence: writes keep priority.
# Briefly 1 s on 2026-08-22 for the step-response
# measurement (§5.9); 10 s is the operating value.
setup_priority: -10
sensor:
# §3.3: 0x518 is the inverter reporting ITSELF (r=0.998 with battery_power,
# r=0.09 with the real P1 meter). ×1, SIGNED - reading it unsigned is the
# 65237-for-299 trap that has already caused three separate errors.
- platform: modbus_controller
modbus_controller_id: inv
name: "GoodWe inverter AC power"
id: inv_ac_power
register_type: holding
address: 0x0518
value_type: S_WORD
unit_of_measurement: W
device_class: power
state_class: measurement
accuracy_decimals: 0
# 3b - the 0x050B block. Offsets from §5: [3] SoC, [6] SoH, [7] mode. 0x0512 is
# independently named battery_mode_code in §3.3, which cross-checks the indexing.
# All three are unsigned; ESPHome coalesces 0x050E..0x0512 into one read.
- platform: modbus_controller
modbus_controller_id: inv
name: "GoodWe battery SoC"
id: inv_soc
register_type: holding
address: 0x050E
value_type: U_WORD
unit_of_measurement: "%"
device_class: battery
state_class: measurement
accuracy_decimals: 0
- platform: modbus_controller
modbus_controller_id: inv
name: "GoodWe battery SoH"
id: inv_soh
register_type: holding
address: 0x0511
value_type: U_WORD
unit_of_measurement: "%"
accuracy_decimals: 0
- platform: modbus_controller
modbus_controller_id: inv
name: "GoodWe battery mode code"
id: inv_bat_mode
register_type: holding
address: 0x0512
value_type: U_WORD # 0 no battery / 1 standby / 2 discharge / 3 charge
accuracy_decimals: 0
binary_sensor:
# Layer 3's hook: HA alarms when this goes unavailable. Nothing running on this
# MCU can report its own death, which is the whole reason the Pi tap exists.
- platform: status
name: "GoodWe master status"
- platform: template
name: "GoodWe watchdog tripped"
id: wd_tripped
device_class: problem
script:
- id: send_setpoint
then:
- lambda: |-
// §4.6 watchdog. Note what this does NOT do: stop writing. Stopping is
// the failure mode itself - a silent bus leaves the inverter latched.
// The ==0 arm matters: at boot millis() is only a few thousand, so a
// plain subtraction is NOT stale for the first 30 s of uptime - a
// window where the board would honour a setpoint HA never sent.
bool stale = id(last_ha_update) == 0 ||
(millis() - id(last_ha_update)) > ${wd_ms};
int w = stale ? 0 : (int) id(setpoint).state;
// has_state() matters: publishing only on CHANGE leaves the entity
// `unknown` in HA from boot until the first transition (seen 2026-08-22
// 02:30), so any automation keyed on `from: "off"` would never fire.
if (!id(wd_tripped).has_state() || stale != id(wd_tripped).state)
id(wd_tripped).publish_state(stale);
if (stale) ESP_LOGW("stage4", "HA setpoint stale >30s -> commanding 0 W");
// ⚠️ Clamp to the number's OWN configured range - never a literal. A
// hard-coded ±500 here (2026-08-22) survived raising min_value/max_value
// to ±2000 and silently truncated every frame: HA read 1100 W, the wire
// carried 500 W, and nothing reported a conflict. The loop then saw
// |1100-506| > 500, decided the INVERTER was saturated, and froze - the
// anti-windup firing correctly against a fault that was ours.
const int lo = (int) id(setpoint).traits.get_min_value();
const int hi = (int) id(setpoint).traits.get_max_value();
if (w > hi) w = hi;
if (w < lo) w = lo;
uint16_t dir = (w < 0) ? 2 : 3; // 2 = reported export -> charges; 3 = import -> discharges
uint16_t mag = (uint16_t) abs(w);
if (id(atomic_write).state) {
ESP_LOGI("stage2", "setpoint %d W -> atomic 0x56E={%u,%u}", w, dir, mag);
id(inv)->queue_command(
esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(
id(inv), 0x056E, 2, {dir, mag}));
} else {
// Fallback: direction first (§3.2). Still exposed to the §5.3 reorder -
// only use it if the inverter rejects count=2.
ESP_LOGI("stage2", "setpoint %d W -> sequential 0x56E=%u 0x56F=%u", w, dir, mag);
id(inv)->queue_command(
esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(
id(inv), 0x056E, 1, {dir}));
id(inv)->queue_command(
esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(
id(inv), 0x056F, 1, {mag}));
}
# Illusmart's own cadence was 4-8 s; degradation below that was its failure tell (§4.7).
interval:
- interval: 5s
then:
- script.execute: send_setpoint
+27
View File
@@ -0,0 +1,27 @@
# Changelog
## 0.1.0
First packaged release. Ports the control loop and the monthly maintenance
cycle from the reference Home Assistant implementation into an add-on.
- Grid-following control: gain/slew/clamp/deadband with anti-windup, all tuned
against measured hardware behaviour (see FIELD-GUIDE.md §14).
- Saturation freeze **with the duration term** — three consecutive diverging
cycles, not one. The instantaneous test fires on every large correction,
because the plant itself needs 3-6 s to settle.
- Monthly maintenance cycle as an ownership state machine: drain / charge /
hold, with exactly one writer of the setpoint at any moment.
- Capacity-tariff awareness: the maintenance charge is capped by quarter-hour
peak headroom, and peak shaving outranks the maintenance schedule.
- Failsafe behaviour: commands 0 W on missing inputs, on stop, and on shutdown.
Never replays a stale setpoint - the reference implementation did, and the
hardware watchdog cannot catch that.
- Ingress UI with a commissioning checklist that names problems in words.
- Optional MQTT discovery for status entities.
Known limits:
- Home Assistant OS / Supervised only (add-ons cannot run on Container/Core).
- The inverter protocol is reverse-engineered; no vendor contract.
- Without the optional RS485 e-stop, nothing covers the host machine dying.
+111
View File
@@ -0,0 +1,111 @@
# 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 |
| `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** |
| `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) |
### 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.
+19
View File
@@ -0,0 +1,19 @@
ARG BUILD_FROM
FROM ${BUILD_FROM}
ENV LANG=C.UTF-8 PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
RUN apk add --no-cache python3 py3-pip
WORKDIR /opt/goodwe
COPY requirements.txt ./
# --break-system-packages: Alpine marks its python "externally managed" (PEP 668).
# This is a single-purpose container, so there is no environment to protect and a
# venv would only add a layer and a PATH to get wrong.
RUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt
COPY app/ ./app/
COPY run.sh /run.sh
RUN chmod a+x /run.sh
CMD ["/run.sh"]
View File
+162
View File
@@ -0,0 +1,162 @@
"""The grid-following control law.
Pure functions on purpose: this is the part that moves real power, so it must be
testable without Home Assistant, without MQTT and without an inverter. See
test_control.py, and run it before shipping any change to this file.
Sign convention, used everywhere in this add-on:
grid > 0 = IMPORTING from the grid
target > 0 = inverter should DISCHARGE
target < 0 = inverter should CHARGE
Every constant here was measured on real hardware, not chosen for elegance.
The reasoning lives in the field guide under "Why the tuning is what it is";
the short version is in the comments below. Do not "clean this up" - each rule
exists because its absence produced a specific, observed failure.
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class Tuning:
gain: float = 0.6
max_w: float = 2000.0
slew_w: float = 1000.0
deadband_w: float = 15.0
step_w: int = 10
saturation_w: float = 500.0
saturation_cycles: int = 3
@dataclass(frozen=True)
class Decision:
target_w: float
sat_count: int
frozen: bool
reason: str
def compute(
prev_w: float,
grid_w: float,
actual_w: float,
tuning: Tuning,
sat_count: int = 0,
*,
charge_only: bool = False,
charge_floor_w: float = 0.0,
) -> Decision:
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
`prev_w` what we last commanded
`grid_w` net grid power, + = importing
`actual_w` what the inverter reports it is doing, + = discharging
"""
reason = "tracking"
# --- saturation, WITH the duration term -------------------------------
# Command and actual diverging means the inverter cannot follow - it is at
# a limit. Then the magnitude may fall but never rise, which is the
# anti-windup that the vendor controller lacked: it once commanded
# -14547 W against an inverter reporting -5250 W and kept climbing.
#
# ⚠️ The duration term is not optional. Tested instantaneously, this fires
# on EVERY large correction, because the plant itself needs 3-6 s to settle
# while a cycle is ~5 s. Requiring N consecutive saturated cycles is what
# lets slew be larger than saturation_w.
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
sat_count = min(sat_count + 1, 10) if saturated_now else 0
frozen = sat_count >= tuning.saturation_cycles
# --- deadband ----------------------------------------------------------
# Inside the meter's own noise, hold. Measured on the reference install
# while regulating: mean |error| 15.4 W, max 27 W. A 10 W deadband makes
# ~69 % of cycles act and the command never rests; 15 W leaves a
# recognisable resting state, which is worth more than it looks - "flat for
# 70 s" is how a healthy loop is recognised at a glance, and a command
# frozen where it should not be is how two real bugs were caught.
if abs(grid_w) < tuning.deadband_w:
want = prev_w
reason = "deadband"
else:
want = prev_w + tuning.gain * grid_w
# --- maintenance charge shaping ---------------------------------------
# Applied BEFORE clamp and slew so a forced charge is still rate-limited
# like any other demand.
if charge_only:
want = min(want, 0.0)
reason = "charge-only"
if charge_floor_w > 0:
want = min(want, -charge_floor_w)
reason = "charge-floor"
# --- ORDER MATTERS: clamp -> slew -> freeze ----------------------------
# An early draft applied a floor after the clamp and let demand escape it.
target = max(-tuning.max_w, min(tuning.max_w, want))
if target != want:
reason = "clamped"
slewed = max(prev_w - tuning.slew_w, min(prev_w + tuning.slew_w, target))
if slewed != target:
reason = "slew-limited"
target = slewed
if frozen:
# Magnitude may fall, never rise.
# ⚠️ At prev == 0 this forbids charging while saturated, because
# max(t, 0) wins. That is deliberate and matches the reference
# implementation: commanding 0 while the inverter reports >500 W means
# something else is driving the bus, and that is not the moment to
# start pushing power the other way.
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
reason = "saturated-freeze"
# --- quantise ----------------------------------------------------------
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
# response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent
# with a fixed DC-side current step). So sub-17 W precision is nominal;
# 10 W simply avoids a visible staircase on dashboards.
step = max(1, int(tuning.step_w))
target = round(target / step) * step
return Decision(float(target), sat_count, frozen, reason)
def maintenance_charge_floor(
charge_w: float,
peak_forecast_w: float | None,
peak_cap_w: float,
) -> float:
"""How hard a maintenance charge may pull, in W (positive number).
⚠️ CAPACITY TARIFF. In Belgium (capaciteitstarief) and similar markets the
bill carries the month's worst quarter-hour AVERAGE OFFTAKE. A maintenance
charge is the only thing this system does that is big enough and long
enough to set that peak, so it is capped by the headroom left under the
site's cap. Discharge is never capped this way: export is not offtake.
`peak_forecast_w is None` means the site has no capacity tariff (or no
forecast sensor) - then there is nothing to protect and the configured
rate is used as-is.
"""
if peak_forecast_w is None:
return max(0.0, charge_w)
headroom = max(0.0, peak_cap_w - peak_forecast_w)
return max(0.0, min(charge_w, headroom))
def peak_at_risk(peak_forecast_w: float | None, peak_cap_w: float) -> bool:
"""True when the quarter-hour projection is already over the site's cap.
⚠️ MONEY OUTRANKS THE MAINTENANCE SCHEDULE. While charging, the battery is
clamped out of discharging and therefore cannot shave a peak - and one oven
during a charge phase can cost more than the whole cycle saves. When this
is True the charge-only clamp is dropped and normal grid-following resumes;
the charge picks up again afterwards.
"""
if peak_forecast_w is None:
return False
return peak_forecast_w > peak_cap_w
+123
View File
@@ -0,0 +1,123 @@
"""Talking to Home Assistant through the Supervisor proxy.
Add-ons authenticate with SUPERVISOR_TOKEN against http://supervisor/core/api,
so there is no long-lived token to create, store, paste into a config file, or
leak at a client site. That is one of the main reasons this is an add-on.
"""
import logging
import os
import aiohttp
_LOG = logging.getLogger("goodwe.hass")
CORE_API = "http://supervisor/core/api"
SUPERVISOR_API = "http://supervisor"
BAD_STATES = ("unknown", "unavailable", "none", "")
class HomeAssistant:
def __init__(self, session: aiohttp.ClientSession, token: str | None = None):
self.session = session
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
if not self.token:
_LOG.error("SUPERVISOR_TOKEN missing - is this running as an add-on?")
@property
def _headers(self) -> dict:
return {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}
async def state(self, entity_id: str) -> dict | None:
"""Full state object, or None if it does not exist.
⚠️ A non-existent entity is not an error anywhere in Home Assistant - it
simply never produces a value. On the reference install two safety
alarms pointed at entity ids that did not exist and were therefore dead
for a day while looking perfectly healthy. So: None here is always
surfaced to the operator, never treated as zero.
"""
if not entity_id:
return None
try:
async with self.session.get(
f"{CORE_API}/states/{entity_id}", headers=self._headers, timeout=10
) as resp:
if resp.status == 404:
return None
resp.raise_for_status()
return await resp.json()
except (aiohttp.ClientError, TimeoutError) as err:
_LOG.warning("read %s failed: %s", entity_id, err)
return None
async def number(self, entity_id: str, invert: bool = False) -> float | None:
"""Numeric state, or None. Never substitutes a default."""
obj = await self.state(entity_id)
if obj is None:
return None
raw = str(obj.get("state", "")).strip().lower()
if raw in BAD_STATES:
return None
try:
value = float(raw)
except ValueError:
_LOG.warning("%s is not numeric: %r", entity_id, raw)
return None
return -value if invert else value
async def limits(self, entity_id: str) -> tuple[float | None, float | None, float | None]:
"""(min, max, step) of a number entity, so we never send out of range.
⚠️ Worth the extra call. On the reference install the control range and
the firmware clamp disagreed (±1500 asked, ±500 enforced) and the result
was silent: Home Assistant reported one value while the wire carried
another, with no error anywhere.
"""
obj = await self.state(entity_id)
if obj is None:
return (None, None, None)
attrs = obj.get("attributes", {})
def _f(key):
try:
return float(attrs[key])
except (KeyError, TypeError, ValueError):
return None
return (_f("min"), _f("max"), _f("step"))
async def call(self, domain: str, service: str, data: dict) -> bool:
try:
async with self.session.post(
f"{CORE_API}/services/{domain}/{service}",
headers=self._headers, json=data, timeout=10,
) as resp:
if resp.status >= 400:
body = await resp.text()
# 400 here usually means out-of-range for the entity - the
# value is rejected outright, not clamped. Always log it:
# silently dropped commands are how a controller ends up
# believing something the hardware never did.
_LOG.error("service %s.%s rejected (%s): %s",
domain, service, resp.status, body[:200])
return False
return True
except (aiohttp.ClientError, TimeoutError) as err:
_LOG.warning("service %s.%s failed: %s", domain, service, err)
return False
async def set_number(self, entity_id: str, value: float) -> bool:
return await self.call("number", "set_value",
{"entity_id": entity_id, "value": value})
async def mqtt_service(self) -> dict | None:
"""Broker details from the Supervisor, if an MQTT service is available."""
try:
async with self.session.get(
f"{SUPERVISOR_API}/services/mqtt", headers=self._headers, timeout=10
) as resp:
if resp.status != 200:
return None
payload = await resp.json()
return payload.get("data")
except (aiohttp.ClientError, TimeoutError):
return None
+423
View File
@@ -0,0 +1,423 @@
"""GoodWe RS485 Controller add-on - entry point and orchestration.
WHAT THIS THING DOES, in one paragraph: it reads the house's net grid power from
Home Assistant, decides how hard the battery should charge or discharge to hold
that at zero, and writes that figure to an ESP32 which puts it on the inverter's
RS485 meter bus. Once a month it runs a full low->full battery cycle so the BMS
can balance cells and recalibrate its coulomb counter.
⚠️ THE ONE THING TO UNDERSTAND BEFORE CHANGING ANY OF THIS: the inverter holds
the last command it understood FOREVER. It has no meter-timeout of its own.
Measured on real hardware: a controller died mid-command and the inverter held
5 kW of discharge for 113 s until a human noticed. Every failsafe in this system
exists because of that one fact:
layer 1 the ESP32's own watchdog - if we stop refreshing for ~30 s it
commands 0 W and KEEPS WRITING it. Stopping is the failure, not the
fix. This add-on's heartbeat is what feeds it.
layer 2 wind-down before a firmware update, in the ESP32.
layer 3 the optional RS485 e-stop, which writes 0 W after 30 s of total bus
silence. It is the ONLY thing that covers this add-on's host dying.
So: when in doubt, this process stops writing, and the hardware takes the
battery to zero on its own. Never "hold the last value to be safe".
"""
import asyncio
import contextlib
import json
import logging
import signal
from datetime import datetime, timezone
import aiohttp
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from .hass import HomeAssistant
from .maintenance import IDLE, MaintConfig, Maintenance
from .mqtt import MqttPublisher
from . import web
OPTIONS_PATH = "/data/options.json"
_LOG = logging.getLogger("goodwe")
def load_options() -> dict:
try:
with open(OPTIONS_PATH, "r", encoding="utf-8") as fh:
return json.load(fh)
except (OSError, json.JSONDecodeError) as err:
_LOG.error("cannot read %s (%s) - using defaults", OPTIONS_PATH, err)
return {}
class Controller:
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub):
self.o = opts
self.hass = hass
self.store = store
self.mqtt = mqtt_pub
self.tuning = Tuning(
gain=float(opts.get("gain", 0.6)),
max_w=float(opts.get("max_w", 2000)),
slew_w=float(opts.get("slew_w", 1000)),
deadband_w=float(opts.get("deadband_w", 15)),
step_w=int(opts.get("step_w", 10)),
saturation_w=float(opts.get("saturation_w", 500)),
saturation_cycles=int(opts.get("saturation_cycles", 3)),
)
self.maint = Maintenance(
MaintConfig(
enabled=bool(opts.get("maintenance_enabled", False)),
interval_days=int(opts.get("maintenance_interval_days", 28)),
start_hour=int(opts.get("maintenance_start_hour", 10)),
discharge_w=float(opts.get("maintenance_discharge_w", 2500)),
charge_w=float(opts.get("maintenance_charge_w", 2500)),
soc_floor=float(opts.get("maintenance_soc_floor", 11)),
soc_target=float(opts.get("maintenance_soc_target", 99)),
hold_min=int(opts.get("maintenance_hold_min", 120)),
),
store,
)
# live state
self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
self.target = 0.0
self.sat_count = 0
self.reason = "starting"
self.grid = self.soc = self.batt = None
self.peak_fc = None
self.cheap = False
self.last_write = None
self.last_write_at = None
self.last_write_ok = None
self.inputs_bad_since = None
self.dev_min = self.dev_max = None
self.events: list[str] = []
self.stopping = False
# -- helpers -------------------------------------------------------------
def log_event(self, msg: str) -> None:
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
self.events.insert(0, f"{stamp} {msg}")
del self.events[40:]
@property
def inputs_ok(self) -> bool:
return None not in (self.grid, self.soc, self.batt)
# -- io ------------------------------------------------------------------
async def read_inputs(self) -> None:
o = self.o
self.grid = await self.hass.number(o.get("meter_entity", ""),
bool(o.get("meter_invert")))
self.soc = await self.hass.number(o.get("soc_entity", ""))
self.batt = await self.hass.number(o.get("batt_entity", ""),
bool(o.get("batt_invert")))
if o.get("peak_forecast_entity"):
raw = await self.hass.number(o["peak_forecast_entity"])
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
self.peak_fc = None if raw is None else (raw * 1000 if abs(raw) < 50 else raw)
else:
self.peak_fc = None
now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity")
if now_e and avg_e:
now_p = await self.hass.number(now_e)
avg_p = await self.hass.number(avg_e)
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
else:
# Fixed-tariff site: never force a paid grid top-up, wait for sun.
self.cheap = False
if self.inputs_ok:
if self.inputs_bad_since is not None:
self.log_event("inputs recovered")
self.inputs_bad_since = None
elif self.inputs_bad_since is None:
self.inputs_bad_since = datetime.now(timezone.utc)
async def write_setpoint(self, value: float, *, force: bool = False) -> None:
entity = self.o.get("setpoint_entity", "")
if not entity:
return
# Never send a value the device will reject outright. A rejected write is
# silent from the controller's point of view, and the inverter then keeps
# doing whatever it was already doing.
if self.dev_min is not None and self.dev_max is not None:
value = max(self.dev_min, min(self.dev_max, value))
changed = (self.last_write is None or value != self.last_write)
if not (changed or force):
return
ok = await self.hass.set_number(entity, value)
self.last_write_ok = ok
if ok:
self.last_write = value
self.last_write_at = datetime.now(timezone.utc)
# -- the cycle -----------------------------------------------------------
async def cycle(self) -> None:
now = datetime.now(timezone.utc)
result = self.maint.tick(now, self.soc)
for msg in result.events:
self.log_event(msg)
# ⚠️ Fail toward inaction, and do it ACTIVELY. If the inputs are missing
# we command 0 rather than replaying the last value. The YAML
# implementation this replaces kept replaying its last setpoint when the
# meter died - which the hardware watchdog cannot catch, because from the
# ESP32's point of view Home Assistant is still talking to it.
stale_after = float(self.o.get("stale_input_s", 15))
if not self.inputs_ok:
bad_for = (now - self.inputs_bad_since).total_seconds() if self.inputs_bad_since else 0.0
if bad_for < stale_after:
# A single missed poll is not a fault. Hold, keep the heartbeat
# going, and give it a few seconds to come back.
self.reason = f"inputs missing {bad_for:.0f}s"
return
if self.target != 0.0:
self.log_event(f"inputs missing for {bad_for:.0f}s - commanding 0 W")
self.target, self.reason = 0.0, "inputs-missing"
await self.write_setpoint(0.0)
return
if result.owns_setpoint:
# drain / hold / abort: maintenance drives directly.
self.target = float(result.setpoint_w or 0.0)
self.reason = f"maintenance:{result.phase}"
self.sat_count = 0
await self.write_setpoint(self.target)
return
if not self.auto:
self.target, self.reason = 0.0, "stopped"
await self.write_setpoint(0.0)
return
charge_floor = 0.0
charge_only = result.charge_only
if charge_only:
# Money outranks the maintenance schedule (see control.peak_at_risk).
if peak_at_risk(self.peak_fc, float(self.o.get("peak_cap_w", 3500))):
charge_only = False
self.log_event("peak at risk - suspending charge-only clamp")
elif self.cheap:
charge_floor = maintenance_charge_floor(
float(self.o.get("maintenance_charge_w", 2500)),
self.peak_fc,
float(self.o.get("peak_cap_w", 3500)),
)
decision = compute(
prev_w=self.target,
grid_w=self.grid,
actual_w=self.batt,
tuning=self.tuning,
sat_count=self.sat_count,
charge_only=charge_only,
charge_floor_w=charge_floor,
)
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
self.log_event(f"saturation freeze ({self.target:.0f} W vs {self.batt:.0f} W)")
self.target, self.sat_count, self.reason = (
decision.target_w, decision.sat_count, decision.reason)
await self.write_setpoint(self.target)
# -- tasks ---------------------------------------------------------------
async def run_control(self) -> None:
entity = self.o.get("setpoint_entity", "")
if entity:
self.dev_min, self.dev_max, _ = await self.hass.limits(entity)
if self.dev_max is not None and self.tuning.max_w > self.dev_max:
self.log_event(
f"clamp {self.tuning.max_w:.0f} W exceeds the device maximum "
f"{self.dev_max:.0f} W - the device wins")
last_grid = object()
heartbeat = float(self.o.get("heartbeat_s", 10))
last_beat = 0.0
while not self.stopping:
await self.read_inputs()
# A cycle is one meter update, exactly as on the reference install.
if self.grid != last_grid:
last_grid = self.grid
await self.cycle()
# ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence
# longer than ~30 s as "the controller is gone" and zeroes the
# inverter. Refreshing the SAME value is what proves we are alive.
loop_now = asyncio.get_running_loop().time()
if loop_now - last_beat >= heartbeat:
last_beat = loop_now
await self.write_setpoint(self.target, force=True)
self.publish()
await asyncio.sleep(1)
def publish(self) -> None:
self.mqtt.publish({
"setpoint": self.target,
"grid": self.grid,
"battery": self.batt,
"soc": self.soc,
"phase": self.maint.phase,
"status": "running" if self.auto else "stopped",
})
async def shutdown(self) -> None:
"""Deterministic wind-down. Do not skip this."""
self.stopping = True
_LOG.info("shutting down - commanding 0 W")
await self.write_setpoint(0.0, force=True)
self.mqtt.close()
# -- UI ------------------------------------------------------------------
def checks(self) -> list:
o = self.o
out = []
for label, value, entity in (
("grid power", self.grid, o.get("meter_entity")),
("battery SoC", self.soc, o.get("soc_entity")),
("battery power", self.batt, o.get("batt_entity")),
):
if not entity:
out.append({"ok": False, "warn": False, "text": f"{label}: no entity configured"})
elif value is None:
out.append({"ok": False, "warn": False,
"text": f"{label}: {entity} is missing or not numeric"})
else:
out.append({"ok": True, "warn": False, "text": f"{label}: {entity} = {value:g}"})
sp = o.get("setpoint_entity")
if not sp:
out.append({"ok": False, "warn": False, "text": "setpoint entity not configured"})
elif self.dev_max is None:
out.append({"ok": False, "warn": False,
"text": f"setpoint entity {sp} not found"})
else:
out.append({"ok": True, "warn": False,
"text": f"setpoint {sp} (range {self.dev_min:g}{self.dev_max:g} W)"})
if self.last_write_ok is False:
out.append({"ok": False, "warn": False,
"text": "last write to the inverter was REJECTED - check the log"})
if not o.get("estop_fitted", False):
out.append({"ok": False, "warn": True,
"text": "no e-stop fitted: if this host dies the battery stays "
"latched at its last command"})
else:
out.append({"ok": True, "warn": False, "text": "e-stop fitted"})
if not o.get("peak_forecast_entity"):
# ok=False + warn=True renders as a caution, not a pass. A check that
# is both is a check nobody reads.
out.append({"ok": False, "warn": True,
"text": "no peak forecast: maintenance charging is not capacity-capped"})
return out
def status_payload(self) -> dict:
checks = self.checks()
bad = [c for c in checks if not c["ok"] and not c["warn"]]
if bad:
level, banner = "bad", f"NOT READY — {bad[0]['text']}"
elif not self.auto:
level, banner = "warn", "Stopped — inverter commanded to 0 W"
elif self.maint.phase != IDLE:
level, banner = "warn", f"Maintenance: {self.maint.phase}"
else:
level, banner = "ok", "Running — holding grid at zero"
def w(v):
return "" if v is None else f"{v:,.0f} W".replace(",", " ")
next_due = self.maint.next_due(datetime.now(timezone.utc))
rows = [
("Grid", w(self.grid)),
("Battery", w(self.batt)),
("State of charge", "" if self.soc is None else f"{self.soc:g} %"),
("Commanded", w(self.target)),
("Why", self.reason),
("Maintenance phase", self.maint.phase),
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
]
return {
"banner": banner, "level": level, "rows": rows, "checks": checks,
"hint": self.events[0] if self.events else "",
}
async def handle_action(self, what: str):
now = datetime.now(timezone.utc)
if what == "auto_toggle":
self.auto = not self.auto
self.store.set("auto", self.auto)
self.log_event("control started" if self.auto else "control stopped")
if not self.auto:
self.target = 0.0
await self.write_setpoint(0.0, force=True)
return {"auto": self.auto}
if what == "maint_start":
for msg in self.maint.force_start(now):
self.log_event(msg)
return {"phase": self.maint.phase}
if what == "maint_abort":
for msg in self.maint.abort(now, "operator"):
self.log_event(msg)
self.target = 0.0
await self.write_setpoint(0.0, force=True)
return {"phase": self.maint.phase}
return None
async def amain() -> None:
opts = load_options()
logging.basicConfig(
level=getattr(logging, str(opts.get("log_level", "info")).upper(), logging.INFO),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
_LOG.info("GoodWe RS485 Controller starting")
from .store import Store
store = Store()
async with aiohttp.ClientSession() as session:
hass = HomeAssistant(session)
broker = await hass.mqtt_service()
pub = MqttPublisher(
broker.get("host") if broker else None,
broker.get("port", 1883) if broker else 1883,
broker.get("username") if broker else None,
broker.get("password") if broker else None,
)
controller = Controller(opts, hass, store, pub)
runner = await web.start(controller, port=8099)
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, stop.set)
task = asyncio.create_task(controller.run_control())
await stop.wait()
await controller.shutdown()
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await runner.cleanup()
_LOG.info("stopped")
def main() -> None:
try:
asyncio.run(amain())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
+179
View File
@@ -0,0 +1,179 @@
"""The monthly battery maintenance cycle.
⚠️ THIS IS THE PART THE VENDOR CONTROLLER DID THAT NOTHING ELSE REPLACES.
A full low -> full cycle each month is a BMS-health behaviour on a 16S LiFePO4
pack: the full charge is when the BMS gets to BALANCE CELLS, the deep discharge
is what RECALIBRATES THE COULOMB COUNTER. Dropping it breaks nothing visibly -
it degrades the pack over months, and the first symptom is a state-of-charge
reading nobody can trust any more. Highest consequence, lowest visibility.
OWNERSHIP, not priority. Exactly one thing writes the setpoint at any moment:
phase setpoint owned by behaviour
idle the control loop normal grid-to-zero
drain THIS module forced discharge, exports the surplus
charge the control loop charge-only clamp + cheap-window floor
hold THIS module 0 W, parked full so the BMS can balance
The loop is never "disabled" - it yields. The heartbeat to the inverter keeps
running in every phase, so the hardware watchdog stays fed and a crash here
still ends with the inverter at 0 W rather than latched.
"""
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
_LOG = logging.getLogger("goodwe.maint")
IDLE, DRAIN, CHARGE, HOLD = "idle", "drain", "charge", "hold"
# A drain of a 13.5 kWh usable pack at 2500 W is ~5 h even with no house load
# helping. 12 h means the battery is not following commands - a fault, not a
# slow day. The charge phase deliberately waits for sun and for cheap hours, so
# it is expected to span a night; 36 h still bounds it.
DRAIN_TIMEOUT_MIN = 720
CHARGE_TIMEOUT_MIN = 2160
# After an abort, do not immediately re-trigger in the same start hour.
RESTART_COOLDOWN_MIN = 90
@dataclass(frozen=True)
class MaintConfig:
enabled: bool = False
interval_days: int = 28
start_hour: int = 10
discharge_w: float = 2500.0
charge_w: float = 2500.0
soc_floor: float = 11.0
soc_target: float = 99.0
hold_min: int = 120
@dataclass
class MaintResult:
phase: str
owns_setpoint: bool
setpoint_w: float | None = None
charge_only: bool = False
events: list = field(default_factory=list)
class Maintenance:
def __init__(self, cfg: MaintConfig, store):
self.cfg = cfg
self.store = store
# -- state ---------------------------------------------------------------
@property
def phase(self) -> str:
return self.store.data.get("phase", IDLE)
def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None:
self.store.set("phase", phase)
self.store.set_time("phase_started", now)
_LOG.info("%s", msg)
events.append(msg)
def _elapsed_min(self, now: datetime) -> float:
started = self.store.get_time("phase_started")
return 0.0 if started is None else (now - started).total_seconds() / 60.0
def due(self, now: datetime) -> bool:
last = self.store.get_time("last_completed")
if last is None:
return True
return (now - last) >= timedelta(days=self.cfg.interval_days)
def next_due(self, now: datetime):
last = self.store.get_time("last_completed")
return None if last is None else last + timedelta(days=self.cfg.interval_days)
# -- operations ----------------------------------------------------------
def force_start(self, now: datetime) -> list:
"""Start a cycle regardless of the calendar.
Commissioning uses this: a cycle must be proven end to end before a site
is signed off, and nobody waits a month to find out the schedule never
fires.
"""
events = []
self.store.set_time("last_start_attempt", now)
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
return events
def abort(self, now: datetime, why: str) -> list:
events = []
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
return events
# -- the machine ---------------------------------------------------------
def tick(self, now: datetime, soc: float | None) -> MaintResult:
events: list = []
phase = self.phase
if phase == IDLE:
if self.cfg.enabled and now.hour == self.cfg.start_hour and self.due(now):
last_try = self.store.get_time("last_start_attempt")
cooled = (
last_try is None
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN)
)
if cooled:
self.store.set_time("last_start_attempt", now)
self._enter(DRAIN, now, events,
"maintenance: due -> drain (exports the surplus)")
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
return MaintResult(IDLE, False, None, False, events)
# ⚠️ Fail toward inaction. Without a state-of-charge reading none of the
# transitions below mean anything, and a stale "100 %" would end the
# charge phase having charged nothing. Hold the current phase's command
# and wait; the phase timeout is the backstop.
if soc is None:
_LOG.warning("maintenance: no SoC reading, holding phase %s", phase)
if phase == DRAIN:
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
if phase == HOLD:
return MaintResult(HOLD, True, 0.0, False, events)
return MaintResult(CHARGE, False, None, True, events)
elapsed = self._elapsed_min(now)
if phase == DRAIN:
if soc <= self.cfg.soc_floor:
self._enter(CHARGE, now, events,
f"maintenance: drain complete at {soc:.0f} % "
f"after {elapsed:.0f} min -> charge")
return MaintResult(CHARGE, False, None, True, events)
if elapsed > DRAIN_TIMEOUT_MIN:
events += self.abort(now, f"drain stuck at {soc:.0f} % after {elapsed:.0f} min")
return MaintResult(IDLE, True, 0.0, False, events)
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
if phase == CHARGE:
if soc >= self.cfg.soc_target:
self._enter(HOLD, now, events,
f"maintenance: charge complete at {soc:.0f} % after "
f"{elapsed:.0f} min -> holding {self.cfg.hold_min} min to balance")
return MaintResult(HOLD, True, 0.0, False, events)
if elapsed > CHARGE_TIMEOUT_MIN:
events += self.abort(now, f"charge stuck at {soc:.0f} % after {elapsed:.0f} min")
return MaintResult(IDLE, True, 0.0, False, events)
return MaintResult(CHARGE, False, None, True, events)
if phase == HOLD:
if elapsed >= self.cfg.hold_min:
self.store.set_time("last_completed", now)
self._enter(IDLE, now, events,
f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) "
"- handing back to the loop")
return MaintResult(IDLE, False, None, False, events)
# 0 W, not "do nothing": commanding zero is what stops the loop
# pulling the pack straight back down the moment it is full, which
# is exactly when the BMS needs it parked at the top.
return MaintResult(HOLD, True, 0.0, False, events)
_LOG.error("maintenance: unknown phase %r, forcing idle", phase)
events += self.abort(now, f"unknown phase {phase!r}")
return MaintResult(IDLE, True, 0.0, False, events)
+104
View File
@@ -0,0 +1,104 @@
"""Optional MQTT discovery, so the controller's state appears as real HA entities.
Optional on purpose: the add-on runs headless without a broker and simply
publishes nothing. Nothing in the control path depends on this - if MQTT breaks,
the battery keeps being controlled correctly and only the dashboard goes stale.
That separation is deliberate; observability must never be able to take down
control.
"""
import json
import logging
try:
import paho.mqtt.client as mqtt
except ImportError: # pragma: no cover - container always has it
mqtt = None
_LOG = logging.getLogger("goodwe.mqtt")
DEVICE = {
"identifiers": ["goodwe_rs485_controller"],
"name": "GoodWe RS485 Controller",
"manufacturer": "GoodWe (via RS485 meter emulation)",
"model": "ES/BP series",
}
# (key, name, unit, device_class, state_class, icon)
SENSORS = [
("setpoint", "GoodWe setpoint", "W", "power", "measurement", None),
("grid", "GoodWe grid power", "W", "power", "measurement", None),
("battery", "GoodWe battery power", "W", "power", "measurement", None),
("soc", "GoodWe battery SoC", "%", "battery", "measurement", None),
("phase", "GoodWe maintenance phase", None, None, None, "mdi:battery-sync"),
("status", "GoodWe controller status", None, None, None, "mdi:heart-pulse"),
]
BASE = "goodwe_ctl"
AVAILABILITY = f"{BASE}/availability"
class MqttPublisher:
def __init__(self, host, port, username=None, password=None):
self.enabled = mqtt is not None and bool(host)
self.client = None
if not self.enabled:
_LOG.info("MQTT not configured - status entities will not be published")
return
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
client_id="goodwe_rs485_controller")
if username:
self.client.username_pw_set(username, password or "")
self.client.will_set(AVAILABILITY, "offline", retain=True)
try:
self.client.connect(host, int(port), keepalive=60)
self.client.loop_start()
self._announce()
_LOG.info("MQTT connected to %s:%s", host, port)
except OSError as err:
_LOG.warning("MQTT connect failed (%s) - continuing without it", err)
self.enabled = False
def _announce(self) -> None:
for key, name, unit, dev_class, state_class, icon in SENSORS:
cfg = {
"name": name,
"unique_id": f"{BASE}_{key}",
"state_topic": f"{BASE}/{key}",
"availability_topic": AVAILABILITY,
"device": DEVICE,
}
if unit:
cfg["unit_of_measurement"] = unit
if dev_class:
cfg["device_class"] = dev_class
if state_class:
cfg["state_class"] = state_class
if icon:
cfg["icon"] = icon
self.client.publish(
f"homeassistant/sensor/{BASE}_{key}/config",
json.dumps(cfg), retain=True,
)
self.client.publish(AVAILABILITY, "online", retain=True)
def publish(self, values: dict) -> None:
if not self.enabled or self.client is None:
return
try:
for key, value in values.items():
if value is None:
continue
self.client.publish(f"{BASE}/{key}", str(value))
except OSError as err: # pragma: no cover
_LOG.debug("MQTT publish failed: %s", err)
def close(self) -> None:
if not self.enabled or self.client is None:
return
try:
self.client.publish(AVAILABILITY, "offline", retain=True)
self.client.loop_stop()
self.client.disconnect()
except OSError: # pragma: no cover
pass
+83
View File
@@ -0,0 +1,83 @@
"""Persistent state, in /data (the add-on's only durable volume).
The maintenance cycle is month-scale state: it MUST survive add-on restarts,
HA restarts and power cuts, or the battery quietly stops being maintained and
nothing says so.
Times are stored as ISO-8601 with an explicit UTC offset and parsed back to
aware datetimes. That is not fussiness: the YAML implementation this replaces
was disabled for hours by exactly one naive-vs-aware subtraction, and it failed
silently - the automation never even recorded itself as triggered.
"""
import json
import logging
import os
import tempfile
from datetime import datetime, timezone
_LOG = logging.getLogger("goodwe.store")
DEFAULTS = {
"phase": "idle",
"phase_started": None,
"last_completed": None,
"last_start_attempt": None,
"auto": False,
}
class Store:
def __init__(self, path: str = "/data/state.json"):
self.path = path
self.data = dict(DEFAULTS)
self.load()
def load(self) -> None:
try:
with open(self.path, "r", encoding="utf-8") as fh:
loaded = json.load(fh)
if isinstance(loaded, dict):
self.data = {**DEFAULTS, **loaded}
_LOG.info("state restored: phase=%s last_completed=%s",
self.data.get("phase"), self.data.get("last_completed"))
except FileNotFoundError:
_LOG.info("no saved state, starting fresh")
except (json.JSONDecodeError, OSError) as err:
# A corrupt state file must not stop the controller: losing the
# maintenance history is recoverable, refusing to run is not.
_LOG.warning("state unreadable (%s) - starting fresh", err)
def save(self) -> None:
# Atomic replace: a power cut mid-write must not leave a truncated file
# that reads as "no maintenance ever ran".
try:
os.makedirs(os.path.dirname(self.path), exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path))
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(self.data, fh, indent=1)
os.replace(tmp, self.path)
except OSError as err:
_LOG.error("could not persist state: %s", err)
# -- typed helpers ------------------------------------------------------
def get_time(self, key: str):
raw = self.data.get(key)
if not raw:
return None
try:
parsed = datetime.fromisoformat(raw)
except (TypeError, ValueError):
_LOG.warning("unparseable timestamp for %s: %r", key, raw)
return None
# Anything that ever escaped without an offset is treated as UTC rather
# than raising later in an arithmetic expression.
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def set_time(self, key: str, when: datetime | None) -> None:
self.data[key] = when.astimezone(timezone.utc).isoformat() if when else None
self.save()
def set(self, key: str, value) -> None:
self.data[key] = value
self.save()
+131
View File
@@ -0,0 +1,131 @@
"""The ingress UI: status, commissioning checks and the two buttons that matter.
Served inside Home Assistant, so no authentication of our own and no external
port. All URLs here are RELATIVE - ingress serves the add-on under a generated
path prefix, and an absolute "/status" would 404 in the field while working
perfectly on a developer's laptop.
Design bias: this page must be readable at the end of a long day, on a phone, in
a cellar, by someone who did not write it. Numbers alone are not enough - it
says what is wrong in words.
"""
import logging
from aiohttp import web
_LOG = logging.getLogger("goodwe.web")
PAGE = """<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>GoodWe RS485 Controller</title>
<style>
:root{--bg:#f5f6f8;--card:#fff;--ink:#1c1e21;--muted:#6b7280;--line:#e5e7eb;
--ok:#137333;--warn:#b25e02;--bad:#c5221f}
@media(prefers-color-scheme:dark){:root{--bg:#111317;--card:#1b1e24;--ink:#e8eaed;
--muted:#9aa0a6;--line:#2c3038;--ok:#81c995;--warn:#fdd663;--bad:#f28b82}}
*{box-sizing:border-box}
body{margin:0;padding:16px;background:var(--bg);color:var(--ink);
font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
h1{font-size:18px;margin:0 0 12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:14px;margin-bottom:12px}
.banner{font-weight:600;padding:12px 14px;border-radius:10px;margin-bottom:12px}
.b-ok{background:rgba(19,115,51,.12);color:var(--ok)}
.b-warn{background:rgba(178,94,2,.12);color:var(--warn)}
.b-bad{background:rgba(197,34,31,.12);color:var(--bad)}
table{width:100%;border-collapse:collapse}
td{padding:6px 0;border-bottom:1px solid var(--line);vertical-align:top}
td:last-child{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
tr:last-child td{border-bottom:0}
.k{color:var(--muted);font-weight:400}
ul{margin:6px 0 0;padding-left:18px}
li{margin:3px 0}
.ok{color:var(--ok)} .bad{color:var(--bad)} .warn{color:var(--warn)}
button{font:inherit;padding:9px 14px;border-radius:8px;border:1px solid var(--line);
background:var(--card);color:var(--ink);cursor:pointer;margin:0 6px 6px 0}
button.primary{background:#1a73e8;border-color:#1a73e8;color:#fff}
button.danger{background:var(--bad);border-color:var(--bad);color:#fff}
.muted{color:var(--muted);font-size:13px}
</style></head><body>
<h1>GoodWe RS485 Controller</h1>
<div id="banner" class="banner b-warn">loading…</div>
<div class="card">
<table id="live"></table>
</div>
<div class="card">
<div class="k">Commissioning</div>
<ul id="checks"></ul>
</div>
<div class="card">
<button class="primary" onclick="act('auto_toggle')">Start / stop control</button>
<button onclick="act('maint_start')">Force maintenance cycle</button>
<button class="danger" onclick="act('maint_abort')">Abort maintenance</button>
<div class="muted" id="hint"></div>
</div>
<script>
async function refresh(){
try{
const r = await fetch('status', {cache:'no-store'});
const s = await r.json();
const b = document.getElementById('banner');
b.textContent = s.banner;
b.className = 'banner ' + (s.level==='ok'?'b-ok':s.level==='bad'?'b-bad':'b-warn');
document.getElementById('live').innerHTML = s.rows
.map(([k,v]) => `<tr><td class="k">${k}</td><td>${v}</td></tr>`).join('');
document.getElementById('checks').innerHTML = s.checks
.map(c => `<li class="${c.ok?'ok':(c.warn?'warn':'bad')}">${c.ok?'':(c.warn?'!':'')} ${c.text}</li>`)
.join('');
document.getElementById('hint').textContent = s.hint || '';
}catch(e){
document.getElementById('banner').textContent = 'cannot reach the add-on';
}
}
async function act(what){
await fetch('action', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({action: what})});
refresh();
}
refresh(); setInterval(refresh, 3000);
</script></body></html>
"""
def build_app(controller) -> web.Application:
async def index(_request):
return web.Response(text=PAGE, content_type="text/html")
async def status(_request):
return web.json_response(controller.status_payload())
async def action(request):
try:
body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
what = body.get("action")
result = await controller.handle_action(what)
if result is None:
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
return web.json_response({"ok": True, "result": result})
app = web.Application()
app.add_routes([
web.get("/", index),
web.get("/status", status),
web.post("/action", action),
])
return app
async def start(controller, port: int = 8099):
runner = web.AppRunner(build_app(controller))
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
_LOG.info("ingress UI listening on :%s", port)
return runner
+7
View File
@@ -0,0 +1,7 @@
# ⚠️ If a build fails with "manifest unknown", it is almost always these tags:
# Home Assistant retires base-image tags as Alpine moves on. Bump all three
# together and rebuild. This is the only place they appear.
build_from:
aarch64: ghcr.io/home-assistant/aarch64-base:3.20
amd64: ghcr.io/home-assistant/amd64-base:3.20
armv7: ghcr.io/home-assistant/armv7-base:3.20
+108
View File
@@ -0,0 +1,108 @@
name: GoodWe RS485 Controller
version: "0.1.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
meter, holding net grid exchange at zero and running a monthly battery
maintenance cycle.
url: https://github.com/REPLACE-ME/goodwe-addon
arch:
- aarch64
- amd64
- armv7
init: false
startup: application
boot: auto
# Needed to read the meter and to write the ESPHome setpoint number.
homeassistant_api: true
hassio_api: true
# Own diagnostics/operations UI inside HA. This is what a field tech and a
# remote supporter both look at, so it is not optional.
ingress: true
ingress_port: 8099
panel_icon: mdi:battery-sync
panel_title: GoodWe
# Optional: publish status entities by MQTT discovery. `want` rather than
# `need` - the add-on runs fine with no broker, it just publishes nothing.
services:
- mqtt:want
options:
# --- sources (required) ---------------------------------------------------
meter_entity: sensor.p1_meter_active_power
meter_invert: false
soc_entity: ""
batt_entity: ""
batt_invert: false
setpoint_entity: ""
# --- control ---------------------------------------------------------------
max_w: 2000
gain: 0.6
slew_w: 1000
deadband_w: 15
step_w: 10
saturation_w: 500
saturation_cycles: 3
heartbeat_s: 10
stale_input_s: 15
auto_start: false
# --- maintenance -----------------------------------------------------------
maintenance_enabled: false
maintenance_interval_days: 28
maintenance_start_hour: 10
maintenance_discharge_w: 2500
maintenance_charge_w: 2500
maintenance_soc_floor: 11
maintenance_soc_target: 99
maintenance_hold_min: 120
# --- tariff / capacity tariff (all optional) ------------------------------
peak_forecast_entity: ""
peak_cap_w: 3500
price_now_entity: ""
price_avg_entity: ""
# --- site ------------------------------------------------------------------
estop_fitted: false
log_level: info
schema:
meter_entity: str
meter_invert: bool
soc_entity: str
batt_entity: str
batt_invert: bool
setpoint_entity: str
max_w: int(100,5000)
gain: float(0.05,1.0)
slew_w: int(50,5000)
deadband_w: int(0,500)
step_w: int(1,100)
saturation_w: int(100,2000)
saturation_cycles: int(1,10)
heartbeat_s: int(2,25)
stale_input_s: int(5,120)
auto_start: bool
maintenance_enabled: bool
maintenance_interval_days: int(1,90)
maintenance_start_hour: int(0,23)
maintenance_discharge_w: int(500,5000)
maintenance_charge_w: int(500,5000)
maintenance_soc_floor: int(5,30)
maintenance_soc_target: int(50,100)
maintenance_hold_min: int(5,480)
peak_forecast_entity: str?
peak_cap_w: int(500,15000)
price_now_entity: str?
price_avg_entity: str?
estop_fitted: bool
log_level: list(trace|debug|info|warning|error)
+2
View File
@@ -0,0 +1,2 @@
aiohttp==3.10.11
paho-mqtt==2.1.0
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
# The add-on entrypoint. Deliberately thin: everything that can fail in an
# interesting way belongs in Python where it can be logged and tested.
set -e
cd /opt/goodwe
exec python3 -m app.main
+113
View File
@@ -0,0 +1,113 @@
"""Runnable check for the control law. `python3 test_control.py`
No framework, no fixtures - it needs to run on a tech's laptop and in CI with
nothing installed. Every assert here corresponds to a rule that exists because
its absence caused an observed failure on real hardware.
If you change control.py, run this. If it fails, the inverter would have done
something you did not intend.
"""
import sys
from app.control import Tuning, compute, maintenance_charge_floor, peak_at_risk
T = Tuning()
fails = []
def check(name, cond):
if cond:
print(f" ok {name}")
else:
print(f" FAIL {name}")
fails.append(name)
print("control law")
# Deadband: inside meter noise, hold exactly - do not drift.
d = compute(prev_w=900, grid_w=10, actual_w=900, tuning=T)
check("deadband holds the command", d.target_w == 900 and d.reason == "deadband")
d = compute(prev_w=900, grid_w=20, actual_w=900, tuning=T)
check("outside deadband it acts", d.target_w != 900)
# Proportional: 0 + 0.6*500 = 300
d = compute(prev_w=0, grid_w=500, actual_w=0, tuning=T)
check("proportional step (gain 0.6)", d.target_w == 300)
# Sign: exporting (negative grid) must CHARGE (negative target).
d = compute(prev_w=0, grid_w=-500, actual_w=0, tuning=T)
check("export drives charging", d.target_w == -300)
# Clamp
d = compute(prev_w=1900, grid_w=1000, actual_w=1900, tuning=Tuning(max_w=2000, slew_w=5000))
check("clamped to max_w", d.target_w == 2000)
# Slew: from 0 with a huge error, no more than slew_w in one cycle.
d = compute(prev_w=0, grid_w=5000, actual_w=0, tuning=Tuning(max_w=5000, slew_w=1000))
check("slew limits one cycle", d.target_w == 1000)
# Saturation needs DURATION: one diverging cycle must NOT freeze.
t = Tuning(saturation_w=500, saturation_cycles=3)
d1 = compute(prev_w=2000, grid_w=500, actual_w=1000, tuning=t, sat_count=0)
check("one saturated cycle does not freeze", not d1.frozen and d1.sat_count == 1)
d2 = compute(prev_w=2000, grid_w=500, actual_w=1000, tuning=t, sat_count=d1.sat_count)
d3 = compute(prev_w=2000, grid_w=500, actual_w=1000, tuning=t, sat_count=d2.sat_count)
check("three consecutive saturated cycles freeze", d3.frozen)
check("freeze forbids raising magnitude", d3.target_w <= 2000)
# ...and one good cycle clears the counter immediately.
d4 = compute(prev_w=2000, grid_w=500, actual_w=1990, tuning=t, sat_count=3)
check("counter resets when tracking resumes", d4.sat_count == 0 and not d4.frozen)
# Freeze must still allow the magnitude to FALL (that is the escape route).
d = compute(prev_w=2000, grid_w=-800, actual_w=1000, tuning=t, sat_count=3)
check("freeze still allows magnitude to fall", d.target_w < 2000)
# Charge-only (maintenance charge phase)
d = compute(prev_w=500, grid_w=500, actual_w=500, tuning=T, charge_only=True)
check("charge-only never discharges", d.target_w <= 0)
d = compute(prev_w=0, grid_w=0, actual_w=0, tuning=T, charge_only=True, charge_floor_w=800)
check("charge floor pulls at least the floor", d.target_w == -800)
# Charge floor must still respect slew (floor applied BEFORE slew).
d = compute(prev_w=0, grid_w=0, actual_w=0, tuning=Tuning(slew_w=200),
charge_only=True, charge_floor_w=2000)
check("charge floor is still slew-limited", d.target_w == -200)
# Quantisation
d = compute(prev_w=0, grid_w=7, actual_w=0, tuning=Tuning(deadband_w=1, step_w=10))
check("quantised to step_w", d.target_w % 10 == 0)
print("capacity tariff")
check("no forecast means no cap", maintenance_charge_floor(2500, None, 3500) == 2500)
check("headroom caps the charge", maintenance_charge_floor(2500, 2000, 3500) == 1500)
check("no headroom means no charge", maintenance_charge_floor(2500, 4000, 3500) == 0)
check("peak risk detected", peak_at_risk(4000, 3500) is True)
check("peak risk off without forecast", peak_at_risk(None, 3500) is False)
print("behaviour: 2 kW load step converges")
# Closed-loop sim. The plant is modelled as first-order-ish: it moves most of
# the way to the command each cycle (measured: 94 % by 3.3 s against a ~5 s
# cycle). House load steps by 2000 W at t=0.
prev, actual, sat, load = 0.0, 0.0, 0, 2000.0
cycles = 0
for i in range(12):
grid = load - actual # what the meter sees
d = compute(prev, grid, actual, T, sat)
prev, sat = d.target_w, d.sat_count
actual = actual + 0.94 * (prev - actual) # plant follows
cycles += 1
if abs(load - actual) < T.deadband_w:
break
check(f"converges within deadband in {cycles} cycles (<=6)", cycles <= 6)
check("no overshoot past the load", actual <= load + T.deadband_w)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")
+111
View File
@@ -0,0 +1,111 @@
"""Runnable check for the maintenance state machine. `python3 test_maintenance.py`
Walks a whole cycle in simulated time, which is the test the YAML version never
had - and it is exactly the phase transitions that a real cycle takes ten hours
to exercise once a month.
"""
import sys
from datetime import datetime, timedelta, timezone
from app.maintenance import CHARGE, DRAIN, HOLD, IDLE, MaintConfig, Maintenance
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
class FakeStore:
"""Same surface as Store, no disk."""
def __init__(self):
self.data = {"phase": IDLE, "phase_started": None,
"last_completed": None, "last_start_attempt": None}
def set(self, k, v):
self.data[k] = v
def set_time(self, k, when):
self.data[k] = when
def get_time(self, k):
return self.data.get(k)
def save(self):
pass
cfg = MaintConfig(enabled=True, interval_days=28, start_hour=10,
discharge_w=2500, charge_w=2500,
soc_floor=11, soc_target=99, hold_min=120)
t0 = datetime(2026, 8, 23, 10, 0, tzinfo=timezone.utc)
print("scheduling")
m = Maintenance(cfg, FakeStore())
check("never run before is due", m.due(t0))
r = m.tick(t0, soc=80)
check("starts at the start hour when due", r.phase == DRAIN and r.owns_setpoint)
check("drain commands the discharge rate", r.setpoint_w == 2500)
m2 = Maintenance(cfg, FakeStore())
r = m2.tick(t0.replace(hour=11), soc=80)
check("does not start outside the start hour", r.phase == IDLE)
m3 = Maintenance(cfg, FakeStore())
m3.store.set_time("last_completed", t0 - timedelta(days=3))
check("not due three days after a cycle", not m3.due(t0))
check("due 29 days after a cycle",
Maintenance(cfg, FakeStore()).due(t0) and
(t0 - (t0 - timedelta(days=29))) >= timedelta(days=28))
print("ownership per phase")
m = Maintenance(cfg, FakeStore())
m.force_start(t0)
r = m.tick(t0, soc=80)
check("drain: maintenance owns the setpoint", r.owns_setpoint and not r.charge_only)
r = m.tick(t0 + timedelta(hours=3), soc=10.5)
check("drain exits at the floor", r.phase == CHARGE)
check("charge: the LOOP owns the setpoint", not r.owns_setpoint and r.charge_only)
r = m.tick(t0 + timedelta(hours=20), soc=99)
check("charge exits at the target", r.phase == HOLD)
check("hold: maintenance owns and commands zero",
r.owns_setpoint and r.setpoint_w == 0.0)
r = m.tick(t0 + timedelta(hours=21), soc=100)
check("hold keeps holding before hold_min", r.phase == HOLD)
r = m.tick(t0 + timedelta(hours=22, minutes=5), soc=100)
check("hold completes after hold_min", r.phase == IDLE)
check("completion is recorded", m.store.get_time("last_completed") is not None)
check("no longer due right after completing", not m.due(t0 + timedelta(hours=22)))
print("failure handling")
m = Maintenance(cfg, FakeStore())
m.force_start(t0)
r = m.tick(t0 + timedelta(hours=13), soc=60)
check("drain aborts on timeout", r.phase == IDLE)
check("abort commands zero", r.setpoint_w == 0.0)
m = Maintenance(cfg, FakeStore())
m.force_start(t0)
r = m.tick(t0 + timedelta(minutes=5), soc=None)
check("no SoC: holds the phase rather than guessing", r.phase == DRAIN)
check("no SoC: still commands the drain rate", r.setpoint_w == 2500)
m = Maintenance(cfg, FakeStore())
m.store.set("phase", "banana")
r = m.tick(t0, soc=50)
check("unknown phase recovers to idle at 0 W", r.phase == IDLE and r.setpoint_w == 0.0)
print("disabled schedule")
m = Maintenance(MaintConfig(enabled=False), FakeStore())
check("disabled never starts", m.tick(t0, soc=80).phase == IDLE)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")
+3
View File
@@ -0,0 +1,3 @@
name: GoodWe RS485 Controller
url: https://github.com/REPLACE-ME/goodwe-addon
maintainer: REPLACE-ME <REPLACE-ME@example.com>