Files
goodwe-addon/goodwe_controller/app/main.py
T
adminandClaude Opus 5 594f8f9fc9 Six silent failures found by installing this on a live system
Migrated the reference site off the YAML packages and onto the add-on. Every
bug below presented identically: the add-on starts, logs "started", serves its
UI, and cannot do its job.

- run.sh needs #!/usr/bin/with-contenv sh. s6-overlay sanitises the environment
  for services, so a plain shebang means SUPERVISOR_TOKEN is absent and every
  Core API call is 401 - while homeassistant_api: true makes permissions look
  granted. Startup now prints the token length and probes the API.
- Supervisor keys the image by config.yaml `version`, so rebuilding without a
  bump reuses the old image. Two fixes appeared not to work because of it.
- Alpine is musl and has no aiohttp wheel on PyPI; deps now come from apk so
  nothing compiles on a client's Pi.
- Alpine ships paho-mqtt 1.x, which has no CallbackAPIVersion. That raised at
  construction and took the control loop down with it - so MQTT setup is now
  wrapped too. Observability must never be able to stop the controller.
- MQTT discovery is published from on_connect: paho silently drops QoS-0
  publishes issued before the CONNACK, so the previous code announced nothing
  while logging "MQTT connected".
- Repeated failures now log once a minute. Six warnings a second rolled the log
  buffer and destroyed the startup diagnostics needed to find the 401.
- auto_start could never fire, because the store's defaults always supplied
  auto: False for the fallback to find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
2026-08-23 02:25:58 +02:00

456 lines
19 KiB
Python

"""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)
# Startup self-check: prove we can actually reach the Core API before
# anything tries to control an inverter with it. A 401 here is a
# permissions/token problem, not a configuration mistake, and saying so
# explicitly saves an installer from re-checking entity ids for an hour.
import os as _os
_tok = _os.environ.get("SUPERVISOR_TOKEN", "")
_LOG.info("supervisor token: %s (%d chars); env has: %s",
"present" if _tok else "MISSING", len(_tok),
",".join(sorted(k for k in _os.environ if "TOKEN" in k.upper())) or "none")
try:
async with session.get("http://supervisor/core/api/",
headers={"Authorization": f"Bearer {_tok}"},
timeout=10) as _r:
_LOG.info("core api probe: HTTP %s %s", _r.status, (await _r.text())[:80])
except Exception as _e: # noqa: BLE001
_LOG.error("core api probe failed: %s", _e)
# ⚠️ Status publishing must NEVER be able to stop the controller. A
# broken broker, a missing library, an API change in paho - all of it is
# observability, and the battery does not care. Caught broadly and on
# purpose: this crashed the add-on once already (paho 1.x vs 2.x) and
# took the control loop down with it.
try:
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,
)
except Exception as err: # noqa: BLE001
_LOG.warning("MQTT unavailable (%s) - continuing without status entities", err)
class _NoMqtt:
enabled = False
def publish(self, *a, **k): pass
def close(self): pass
pub = _NoMqtt()
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()