From 0a1e61dbc94c0f809e25bd2e09f6b0981b7bfa3f Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 23 Aug 2026 01:15:25 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6 --- .gitignore | 5 + FIELD-GUIDE.md | 382 +++++++++++++++++++++++ LICENSE | 9 + README.md | 73 +++++ estop/rs485_log.py | 258 ++++++++++++++++ firmware/goodwe-master.yaml | 298 ++++++++++++++++++ goodwe_controller/CHANGELOG.md | 27 ++ goodwe_controller/DOCS.md | 111 +++++++ goodwe_controller/Dockerfile | 19 ++ goodwe_controller/app/__init__.py | 0 goodwe_controller/app/control.py | 162 ++++++++++ goodwe_controller/app/hass.py | 123 ++++++++ goodwe_controller/app/main.py | 423 ++++++++++++++++++++++++++ goodwe_controller/app/maintenance.py | 179 +++++++++++ goodwe_controller/app/mqtt.py | 104 +++++++ goodwe_controller/app/store.py | 83 +++++ goodwe_controller/app/web.py | 131 ++++++++ goodwe_controller/build.yaml | 7 + goodwe_controller/config.yaml | 108 +++++++ goodwe_controller/requirements.txt | 2 + goodwe_controller/run.sh | 6 + goodwe_controller/test_control.py | 113 +++++++ goodwe_controller/test_maintenance.py | 111 +++++++ repository.yaml | 3 + 24 files changed, 2737 insertions(+) create mode 100644 .gitignore create mode 100644 FIELD-GUIDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 estop/rs485_log.py create mode 100644 firmware/goodwe-master.yaml create mode 100644 goodwe_controller/CHANGELOG.md create mode 100644 goodwe_controller/DOCS.md create mode 100644 goodwe_controller/Dockerfile create mode 100644 goodwe_controller/app/__init__.py create mode 100644 goodwe_controller/app/control.py create mode 100644 goodwe_controller/app/hass.py create mode 100644 goodwe_controller/app/main.py create mode 100644 goodwe_controller/app/maintenance.py create mode 100644 goodwe_controller/app/mqtt.py create mode 100644 goodwe_controller/app/store.py create mode 100644 goodwe_controller/app/web.py create mode 100644 goodwe_controller/build.yaml create mode 100644 goodwe_controller/config.yaml create mode 100644 goodwe_controller/requirements.txt create mode 100644 goodwe_controller/run.sh create mode 100644 goodwe_controller/test_control.py create mode 100644 goodwe_controller/test_maintenance.py create mode 100644 repository.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9724bcb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.esphome/ +secrets.yaml +*.log diff --git a/FIELD-GUIDE.md b/FIELD-GUIDE.md new file mode 100644 index 0000000..a3fc315 --- /dev/null +++ b/FIELD-GUIDE.md @@ -0,0 +1,382 @@ +# GoodWe RS485 Controller — Field Guide + +For installing technicians. Read section 1 before the first site visit; work +through sections 5–11 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._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 (3–5 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._goodwe_battery_soc` | **the ESP32's own read**, not the inverter's cloud/dongle sensor | +| `batt_entity` | `sensor._goodwe_inverter_ac_power` | same — the ESP32's read | +| `setpoint_entity` | `number._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 3–6 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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d32dac5 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f70acf --- /dev/null +++ b/README.md @@ -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. diff --git a/estop/rs485_log.py b/estop/rs485_log.py new file mode 100644 index 0000000..3133aa1 --- /dev/null +++ b/estop/rs485_log.py @@ -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()) diff --git a/firmware/goodwe-master.yaml b/firmware/goodwe-master.yaml new file mode 100644 index 0000000..e84ecd2 --- /dev/null +++ b/firmware/goodwe-master.yaml @@ -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 diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md new file mode 100644 index 0000000..4e04c4d --- /dev/null +++ b/goodwe_controller/CHANGELOG.md @@ -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. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md new file mode 100644 index 0000000..6aafd43 --- /dev/null +++ b/goodwe_controller/DOCS.md @@ -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 ~5–10 s + +## The safety model, in short + +The inverter **holds its last command forever** — it has no meter-timeout. So: + +- The ESP32 commands 0 W if this add-on stops refreshing for ~30 s, and keeps + commanding it. +- This add-on commands 0 W when its inputs go missing, when you stop control, + and when it shuts down. +- The **optional RS485 e-stop** is the only thing that covers this machine + dying. Without it, a failed host leaves the battery latched at its last + command until someone intervenes. + +**If anything looks wrong: stop the add-on.** That commands 0 W and the +hardware holds it there. + +## Configuration + +### Sources + +| option | required | meaning | +|---|---|---| +| `meter_entity` | yes | Net grid power. **Positive must mean importing** | +| `meter_invert` | | Flip the sign if the meter reports the other way | +| `soc_entity` | yes | Battery state of charge — use the **ESP32's own read** | +| `batt_entity` | yes | Battery power — again the ESP32's read, `+` = discharging | +| `batt_invert` | | Flip if needed | +| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` | + +Use the ESP32's readings rather than the inverter's cloud or dongle sensors: +those serve cached values, and a stale reading here ends the maintenance charge +phase having charged nothing. + +### Control + +| option | default | meaning | +|---|---|---| +| `max_w` | 2000 | Hard limit on what may be commanded. Start low, raise after commissioning | +| `gain` | 0.6 | Correction per cycle. **At the limit — do not raise** | +| `slew_w` | 1000 | Maximum change per cycle | +| `deadband_w` | 15 | Ignore errors smaller than this | +| `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. diff --git a/goodwe_controller/Dockerfile b/goodwe_controller/Dockerfile new file mode 100644 index 0000000..de7dfad --- /dev/null +++ b/goodwe_controller/Dockerfile @@ -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"] diff --git a/goodwe_controller/app/__init__.py b/goodwe_controller/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/goodwe_controller/app/control.py b/goodwe_controller/app/control.py new file mode 100644 index 0000000..667688d --- /dev/null +++ b/goodwe_controller/app/control.py @@ -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 diff --git a/goodwe_controller/app/hass.py b/goodwe_controller/app/hass.py new file mode 100644 index 0000000..979602d --- /dev/null +++ b/goodwe_controller/app/hass.py @@ -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 diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py new file mode 100644 index 0000000..1557339 --- /dev/null +++ b/goodwe_controller/app/main.py @@ -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() diff --git a/goodwe_controller/app/maintenance.py b/goodwe_controller/app/maintenance.py new file mode 100644 index 0000000..b12669d --- /dev/null +++ b/goodwe_controller/app/maintenance.py @@ -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) diff --git a/goodwe_controller/app/mqtt.py b/goodwe_controller/app/mqtt.py new file mode 100644 index 0000000..3f30103 --- /dev/null +++ b/goodwe_controller/app/mqtt.py @@ -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 diff --git a/goodwe_controller/app/store.py b/goodwe_controller/app/store.py new file mode 100644 index 0000000..fdf4444 --- /dev/null +++ b/goodwe_controller/app/store.py @@ -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() diff --git a/goodwe_controller/app/web.py b/goodwe_controller/app/web.py new file mode 100644 index 0000000..f2acb85 --- /dev/null +++ b/goodwe_controller/app/web.py @@ -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 = """ + +GoodWe RS485 Controller + +

GoodWe RS485 Controller

+ + +
+
+
+ +
+
Commissioning
+
    +
    + +
    + + + +
    +
    + + +""" + + +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 diff --git a/goodwe_controller/build.yaml b/goodwe_controller/build.yaml new file mode 100644 index 0000000..82edaa1 --- /dev/null +++ b/goodwe_controller/build.yaml @@ -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 diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml new file mode 100644 index 0000000..4f82e0b --- /dev/null +++ b/goodwe_controller/config.yaml @@ -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) diff --git a/goodwe_controller/requirements.txt b/goodwe_controller/requirements.txt new file mode 100644 index 0000000..e7522ba --- /dev/null +++ b/goodwe_controller/requirements.txt @@ -0,0 +1,2 @@ +aiohttp==3.10.11 +paho-mqtt==2.1.0 diff --git a/goodwe_controller/run.sh b/goodwe_controller/run.sh new file mode 100644 index 0000000..1f48da2 --- /dev/null +++ b/goodwe_controller/run.sh @@ -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 diff --git a/goodwe_controller/test_control.py b/goodwe_controller/test_control.py new file mode 100644 index 0000000..97a8835 --- /dev/null +++ b/goodwe_controller/test_control.py @@ -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") diff --git a/goodwe_controller/test_maintenance.py b/goodwe_controller/test_maintenance.py new file mode 100644 index 0000000..54172a6 --- /dev/null +++ b/goodwe_controller/test_maintenance.py @@ -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") diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 0000000..3916ef8 --- /dev/null +++ b/repository.yaml @@ -0,0 +1,3 @@ +name: GoodWe RS485 Controller +url: https://github.com/REPLACE-ME/goodwe-addon +maintainer: REPLACE-ME