Packaged as a Home Assistant add-on, with a field guide
Turns the reference RS485 controller into something a technician can install at a client site: a typed config form instead of YAML, an ingress UI that names misconfiguration in words, and persistent state that cannot be broken by a timezone. Why an add-on rather than YAML packages or blueprints: - Blueprints cannot create helpers, and the maintenance cycle is a state machine whose phase and completion date must survive restarts. - YAML packages need filesystem access, a configuration.yaml edit and a restart - none of which belong in a client install. - Add-ons authenticate with SUPERVISOR_TOKEN, so there is no long-lived token to generate, store or leak on someone else's machine. - Requires HA OS/Supervised. Container and Core installs cannot run add-ons at all, which is a market decision, not an oversight. The control law and the maintenance machine are pure functions with no Home Assistant imports, and both ship with runnable checks (22 and 22 assertions). Every assertion corresponds to a rule whose absence caused an observed failure on hardware - the saturation duration term, the clamp-before-slew ordering, the deadband, the sign convention. One behaviour deliberately differs from the implementation it replaces: when its inputs go missing this commands 0 W rather than replaying the last setpoint. The reference version kept replaying, which the hardware watchdog cannot catch - from the ESP32's side, Home Assistant is still talking to it. Includes the ESPHome firmware (now parameterised: node name, inverter rating, watchdog timeout) and the optional RS485 e-stop. FIELD-GUIDE.md carries the commissioning gates, all judged on the wire rather than on how Home Assistant looks, plus the written statement a site without an e-stop needs signed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""The ingress UI: status, commissioning checks and the two buttons that matter.
|
||||
|
||||
Served inside Home Assistant, so no authentication of our own and no external
|
||||
port. All URLs here are RELATIVE - ingress serves the add-on under a generated
|
||||
path prefix, and an absolute "/status" would 404 in the field while working
|
||||
perfectly on a developer's laptop.
|
||||
|
||||
Design bias: this page must be readable at the end of a long day, on a phone, in
|
||||
a cellar, by someone who did not write it. Numbers alone are not enough - it
|
||||
says what is wrong in words.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
_LOG = logging.getLogger("goodwe.web")
|
||||
|
||||
PAGE = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>GoodWe RS485 Controller</title>
|
||||
<style>
|
||||
:root{--bg:#f5f6f8;--card:#fff;--ink:#1c1e21;--muted:#6b7280;--line:#e5e7eb;
|
||||
--ok:#137333;--warn:#b25e02;--bad:#c5221f}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#111317;--card:#1b1e24;--ink:#e8eaed;
|
||||
--muted:#9aa0a6;--line:#2c3038;--ok:#81c995;--warn:#fdd663;--bad:#f28b82}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;padding:16px;background:var(--bg);color:var(--ink);
|
||||
font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
|
||||
h1{font-size:18px;margin:0 0 12px}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
|
||||
padding:14px;margin-bottom:12px}
|
||||
.banner{font-weight:600;padding:12px 14px;border-radius:10px;margin-bottom:12px}
|
||||
.b-ok{background:rgba(19,115,51,.12);color:var(--ok)}
|
||||
.b-warn{background:rgba(178,94,2,.12);color:var(--warn)}
|
||||
.b-bad{background:rgba(197,34,31,.12);color:var(--bad)}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
td{padding:6px 0;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
td:last-child{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
|
||||
tr:last-child td{border-bottom:0}
|
||||
.k{color:var(--muted);font-weight:400}
|
||||
ul{margin:6px 0 0;padding-left:18px}
|
||||
li{margin:3px 0}
|
||||
.ok{color:var(--ok)} .bad{color:var(--bad)} .warn{color:var(--warn)}
|
||||
button{font:inherit;padding:9px 14px;border-radius:8px;border:1px solid var(--line);
|
||||
background:var(--card);color:var(--ink);cursor:pointer;margin:0 6px 6px 0}
|
||||
button.primary{background:#1a73e8;border-color:#1a73e8;color:#fff}
|
||||
button.danger{background:var(--bad);border-color:var(--bad);color:#fff}
|
||||
.muted{color:var(--muted);font-size:13px}
|
||||
</style></head><body>
|
||||
<h1>GoodWe RS485 Controller</h1>
|
||||
<div id="banner" class="banner b-warn">loading…</div>
|
||||
|
||||
<div class="card">
|
||||
<table id="live"></table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="k">Commissioning</div>
|
||||
<ul id="checks"></ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<button class="primary" onclick="act('auto_toggle')">Start / stop control</button>
|
||||
<button onclick="act('maint_start')">Force maintenance cycle</button>
|
||||
<button class="danger" onclick="act('maint_abort')">Abort maintenance</button>
|
||||
<div class="muted" id="hint"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function refresh(){
|
||||
try{
|
||||
const r = await fetch('status', {cache:'no-store'});
|
||||
const s = await r.json();
|
||||
const b = document.getElementById('banner');
|
||||
b.textContent = s.banner;
|
||||
b.className = 'banner ' + (s.level==='ok'?'b-ok':s.level==='bad'?'b-bad':'b-warn');
|
||||
document.getElementById('live').innerHTML = s.rows
|
||||
.map(([k,v]) => `<tr><td class="k">${k}</td><td>${v}</td></tr>`).join('');
|
||||
document.getElementById('checks').innerHTML = s.checks
|
||||
.map(c => `<li class="${c.ok?'ok':(c.warn?'warn':'bad')}">${c.ok?'✓':(c.warn?'!':'✗')} ${c.text}</li>`)
|
||||
.join('');
|
||||
document.getElementById('hint').textContent = s.hint || '';
|
||||
}catch(e){
|
||||
document.getElementById('banner').textContent = 'cannot reach the add-on';
|
||||
}
|
||||
}
|
||||
async function act(what){
|
||||
await fetch('action', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({action: what})});
|
||||
refresh();
|
||||
}
|
||||
refresh(); setInterval(refresh, 3000);
|
||||
</script></body></html>
|
||||
"""
|
||||
|
||||
|
||||
def build_app(controller) -> web.Application:
|
||||
async def index(_request):
|
||||
return web.Response(text=PAGE, content_type="text/html")
|
||||
|
||||
async def status(_request):
|
||||
return web.json_response(controller.status_payload())
|
||||
|
||||
async def action(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(text="expected JSON")
|
||||
what = body.get("action")
|
||||
result = await controller.handle_action(what)
|
||||
if result is None:
|
||||
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
|
||||
return web.json_response({"ok": True, "result": result})
|
||||
|
||||
app = web.Application()
|
||||
app.add_routes([
|
||||
web.get("/", index),
|
||||
web.get("/status", status),
|
||||
web.post("/action", action),
|
||||
])
|
||||
return app
|
||||
|
||||
|
||||
async def start(controller, port: int = 8099):
|
||||
runner = web.AppRunner(build_app(controller))
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "0.0.0.0", port)
|
||||
await site.start()
|
||||
_LOG.info("ingress UI listening on :%s", port)
|
||||
return runner
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
aiohttp==3.10.11
|
||||
paho-mqtt==2.1.0
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user