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
456 lines
19 KiB
Python
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()
|