The stale-input watchdog asks whether the meter is still talking. The saturation freeze asks whether the inverter can follow. The output clamp asks whether the number is too big. A loop can pass all three while charging the battery out of the grid, because the only thing wrong is the sign. On 2026-08-20 a commercial controller did exactly that four times in one day, for up to 77 minutes at a stretch, reaching a commanded -14 768 W against a ±5000 W inverter. app/watchdog.py compares the emitted setpoint against the authoritative P1 figure and, when they oppose each other with both over 300 W for 180 s continuously, files a failsafe SET claim at P_SAFETY, commands 0 W and latches there until an operator starts control again. Validated against that day rather than against an argument: sim/replay_sign.py in the infra repo drives this module over the committed captures of both 2026-08-20 and the healthy 2026-08-23 and reaches 18 trips on the fault day - every one inside a window NOTES.md §4.7 accounts for - and 0 on the healthy day. The four episodes do share one signature: 0.3 / 0.9 / 10.5 / 0.0 % sign agreement inside them against 92.9 % healthy. Three decisions the ticket could not make: - It is DISARMED whenever the grid-following loop is not what is driving. Sign agreement is a property of a loop that follows the meter, and a maintenance grid charge opposes the meter by design - 2500 W of charge into a house drawing 500 W is +3000 W on the meter against a -2500 W command, for hours. The gate tests the emitted number, not the winning claim's name, because a limit claim can let the loop "win" and still replace its value. - The trip LATCHES. Commanding 0 W removes the command side of the comparison, so the disagreement always clears the moment the trip works. - sign_disagree_w at or above max_w is refused at startup. The arbiter never lets a command exceed max_w, so such a threshold leaves the watchdog incapable of firing while the status page still reports it armed. What it does not catch, said out loud: an inverter that ACTS opposite to its command moves the meter further the same way, leaving command and meter in agreement. That is the saturation freeze's job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
821 lines
42 KiB
Python
821 lines
42 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 .arbiter import (
|
|
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_SAFETY, Claim, resolve,
|
|
)
|
|
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 .p1 import P1Ingest, build_source, is_enabled
|
|
from .validate import ConfigRefused, refuse, validate_startup_config
|
|
from .watchdog import SignWatchdog, SignWatchdogConfig
|
|
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, *, stop):
|
|
self.o = opts
|
|
self.hass = hass
|
|
self.store = store
|
|
self.mqtt = mqtt_pub
|
|
# ⚠️ REQUIRED, AND KEYWORD-ONLY, AFTER A REVIEW CATCH. This started life
|
|
# as `stop=None`, which made a Controller that CANNOT STOP ITSELF the
|
|
# default one. On the late-refusal path in run_control() that default
|
|
# meant: log the banner, return, and leave the process alive forever
|
|
# with no control loop, no exit and no further output - the exact
|
|
# "inert but looks healthy" failure this whole module exists to refuse.
|
|
# amain() always passed a real Event, so it was latent, but test_p1.py
|
|
# was already constructing Controllers without one. No default now, so
|
|
# the broken shape does not typecheck.
|
|
self.stop_event = stop
|
|
self.refused: list[str] | None = None
|
|
|
|
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)),
|
|
target_grid_w=float(opts.get("target_grid_w", 0)),
|
|
step_w=int(opts.get("step_w", 10)),
|
|
saturation_w=float(opts.get("saturation_w", 500)),
|
|
saturation_cycles=int(opts.get("saturation_cycles", 3)),
|
|
# 0 / unset means "follow max_w", which is the recommended
|
|
# value. Read the note in control.py before raising it above
|
|
# max_w: every watt above the rail is unwind latency.
|
|
integrator_max_w=(float(opts["integrator_max_w"])
|
|
if opts.get("integrator_max_w") else None),
|
|
)
|
|
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,
|
|
)
|
|
|
|
# SAFETY-02. One instance, one actuator, one cadence - see watchdog.py.
|
|
self.sign_wd = SignWatchdog(SignWatchdogConfig(
|
|
min_w=float(opts.get("sign_disagree_w", 300)),
|
|
hold_s=float(opts.get("sign_disagree_s", 180)),
|
|
))
|
|
|
|
# P1 ingestion (TEL-01). `meter_source: off` keeps the original
|
|
# single-entity meter_entity path, so an existing install is unchanged
|
|
# until it opts in.
|
|
self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)),
|
|
max_age_s=float(opts.get("meter_max_age_s", 30)))
|
|
self.p1_enabled = is_enabled(opts)
|
|
# Set by amain() once the transport is built, so the status page can show
|
|
# what only the transport knows (homewizard_local's unchanged_s). Stays
|
|
# None when P1 is off, or under a transport that has no such counter.
|
|
self.p1_source = None
|
|
|
|
# live state
|
|
self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
|
|
self.target = 0.0
|
|
self.sat_count = 0
|
|
self.i_w = 0.0 # the loop's integrator, carried between cycles
|
|
self.loop_w = None # what the loop asked for last cycle, or None
|
|
# Did the grid-following loop actually win the LAST arbitration? That is
|
|
# the question SAFETY-02 has to ask about the command standing on the
|
|
# wire right now, so it is recorded when the arbitration happens rather
|
|
# than re-derived a cycle later. Starts False: nothing is on the wire.
|
|
self.following = False
|
|
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
|
|
if self.p1_enabled:
|
|
# ⚠️ P1 is the only authoritative measurement of what the utility
|
|
# sees (§5.1). When it is stale this is None, which falls into the
|
|
# existing "inputs missing -> command 0 W" path below. There is
|
|
# deliberately NO fallback to an inverter-side figure: the
|
|
# inverter's own AC power correlates 0.998 with battery power and
|
|
# 0.09 with the real meter, so a controller that failed over to it
|
|
# would be regulating against its own output.
|
|
self.grid = self.p1.net_w
|
|
else:
|
|
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:
|
|
"""Gather claims, resolve precedence, write the winner.
|
|
|
|
Nothing here decides who wins - arbiter.py does, by one rule. This
|
|
method's only job is to state honestly what each strategy wants.
|
|
"""
|
|
now = datetime.now(timezone.utc)
|
|
|
|
result = self.maint.tick(now, self.soc)
|
|
for msg in result.events:
|
|
self.log_event(msg)
|
|
|
|
# --- SAFETY-02, and it runs BEFORE the early return below -------------
|
|
# ⚠️ ORDER MATTERS HERE. If this were evaluated after the "inputs missing
|
|
# but not yet stale" hold, a run of held cycles would leave
|
|
# `disagree_since` untouched across the gap and the timer would resume
|
|
# from a start time that predates it. It is evaluated every cycle,
|
|
# unconditionally, so a missing meter CLEARS the window - see watchdog.py.
|
|
#
|
|
# The value compared is the command STANDING ON THE WIRE, which is what
|
|
# this cycle's fresh meter reading is a response to. `last_write` is that
|
|
# value after the device clamp and confirmed accepted; `target` covers
|
|
# the case where nothing has been written yet (and, deliberately, the
|
|
# case where the last write was REJECTED - the inverter is then still
|
|
# latched on `last_write`, which is exactly what `last_write` holds).
|
|
was_tripped = self.sign_wd.tripped
|
|
standing = self.last_write if self.last_write is not None else self.target
|
|
if self.sign_wd.evaluate(standing, self.grid, self.following, now) \
|
|
and not was_tripped:
|
|
self.log_event("FAILSAFE " + self.sign_wd.reason)
|
|
_LOG.error("SAFETY-02 TRIPPED - %s. Commanding 0 W and holding it "
|
|
"until control is started again.", self.sign_wd.reason)
|
|
|
|
stale_after = float(self.o.get("stale_input_s", 15))
|
|
if not self.inputs_ok and not self.sign_wd.tripped:
|
|
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
|
|
|
|
claims = []
|
|
|
|
# --- safety limits: these bind EVERY strategy, always ----------------
|
|
# Defence in depth: write_setpoint() clamps to the device range too. The
|
|
# duplication is deliberate - one of them is the policy, the other is the
|
|
# last thing between a bug and the hardware.
|
|
if self.dev_min is not None and self.dev_max is not None:
|
|
claims.append(Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max, "rating"))
|
|
max_w = float(self.o.get("max_w", 2000))
|
|
claims.append(Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"))
|
|
|
|
# --- safety stops ----------------------------------------------------
|
|
# ⚠️ SAFETY-02 is claimed FIRST and unconditionally while latched. It is
|
|
# a SET at P_SAFETY, so it outranks maintenance, peak shaving and the
|
|
# loop alike - a controller pushing power the wrong way is wrong
|
|
# whatever it thinks it is doing. Re-filed EVERY cycle: claims are not
|
|
# sticky, and one filed once would lapse the next cycle and hand the
|
|
# actuator silently back to the loop that was driving the wrong way.
|
|
if self.sign_wd.tripped:
|
|
claims.append(Claim.set("failsafe", P_SAFETY, 0.0,
|
|
"sign disagreement"))
|
|
|
|
if not self.auto:
|
|
claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped"))
|
|
elif not self.inputs_ok:
|
|
if self.target != 0.0:
|
|
self.log_event("inputs missing - commanding 0 W")
|
|
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing"))
|
|
|
|
# --- maintenance -----------------------------------------------------
|
|
if result.owns_setpoint:
|
|
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
|
float(result.setpoint_w or 0.0), result.phase))
|
|
self.sat_count = 0
|
|
elif result.charge_only:
|
|
# Money outranks the maintenance schedule: while the quarter-hour
|
|
# projection is over the cap, the charge-only shaping is simply not
|
|
# claimed, so the loop can discharge and shave the peak. When a
|
|
# dedicated peak-shaving strategy arrives it will claim SET at
|
|
# P_PEAK and outrank this limit without any code here changing.
|
|
if peak_at_risk(self.peak_fc, float(self.o.get("peak_cap_w", 3500))):
|
|
self.log_event("peak at risk - maintenance charge shaping suspended")
|
|
else:
|
|
hi = 0.0
|
|
reason = "charge-only"
|
|
if self.cheap:
|
|
floor = maintenance_charge_floor(
|
|
float(self.o.get("maintenance_charge_w", 2500)),
|
|
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
|
|
if floor > 0:
|
|
hi, reason = -floor, "cheap-window charge"
|
|
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason))
|
|
|
|
# --- the grid-following loop -----------------------------------------
|
|
if self.auto and self.inputs_ok:
|
|
# ⚠️ prev is the ARBITER's last output, not the loop's own last
|
|
# wish. If something outranked the loop, that is what the hardware
|
|
# actually did, and the controller must track reality or it jumps
|
|
# the moment it regains control.
|
|
#
|
|
# ⚠️ The integrator has to track the same reality, and it is no
|
|
# longer prev_w, so it needs saying out loud: if the arbiter did not
|
|
# give the loop what it asked for last cycle, the loop's
|
|
# accumulated error belongs to a command that never happened.
|
|
# Re-seed from what the hardware was actually told. This is the
|
|
# failsafe case too - every layer-1 stop resolves to 0 W, so
|
|
# entering failsafe re-seeds the integrator to zero and the first
|
|
# cycle after release starts from zero instead of dumping the whole
|
|
# stale period as power.
|
|
if self.loop_w is None or self.target != self.loop_w:
|
|
self.i_w = self.target
|
|
decision = compute(
|
|
prev_w=self.target,
|
|
grid_w=self.grid,
|
|
actual_w=self.batt,
|
|
tuning=self.tuning,
|
|
sat_count=self.sat_count,
|
|
i_w=self.i_w,
|
|
)
|
|
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.sat_count = decision.sat_count
|
|
self.i_w = decision.i_w
|
|
self.loop_w = decision.target_w
|
|
claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason))
|
|
else:
|
|
# Stopped or blind: no accumulation may survive the outage.
|
|
self.i_w = 0.0
|
|
self.loop_w = None
|
|
|
|
resolution = resolve(claims)
|
|
if resolution.contradiction:
|
|
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log")
|
|
# --- SAFETY-02's arming gate ------------------------------------------
|
|
# Was the number about to go on the wire chosen by the grid-following
|
|
# loop, or by a strategy that commands against the meter on purpose?
|
|
# Only the former is expected to agree in sign with P1. See gate 1 in
|
|
# watchdog.py - without this, every maintenance grid charge trips the
|
|
# failsafe within three minutes of starting.
|
|
#
|
|
# ⚠️ "THE LOOP WON" IS NOT ENOUGH, and the difference is a whole class of
|
|
# false trip. A LIMIT claim can survive the loop winning and still
|
|
# replace its number: maintenance's charge-only shaping is
|
|
# `hi = -charge_w`, so the loop can ask for +500 W of discharge, win the
|
|
# arbitration, and have the emitted value come out at -2500 W of grid
|
|
# charge. Winner says "loop"; the wire carries maintenance's intent. So
|
|
# the test is on the NUMBER, not on the name: the loop is following only
|
|
# while the emitted value points the way the loop asked and is no larger
|
|
# than it asked - i.e. limits reduced it, they did not redirect it.
|
|
# Zero on either side falls out as not-following, which costs nothing:
|
|
# a 0 W command cannot be a wrong-direction command.
|
|
asked = self.loop_w
|
|
self.following = (resolution.winner == "loop" and asked is not None
|
|
and resolution.target_w * asked > 0
|
|
and abs(resolution.target_w) <= abs(asked))
|
|
self.target = resolution.target_w
|
|
self.reason = resolution.explain()
|
|
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)
|
|
# ⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01
|
|
# exists to remove: a max_w above the device's rating meant
|
|
# write_setpoint() silently clamped and the configured ceiling was
|
|
# never the real one.
|
|
#
|
|
# amain() already ran this gate before anything started. It runs
|
|
# AGAIN here for one case the gate cannot cover: ESPHome is often
|
|
# not up yet when the add-on boots, so hass.limits() returned
|
|
# (None, None) and the range checks were skipped rather than
|
|
# guessed at. This is the first moment the device's real rating is
|
|
# known. If it conflicts, refuse now - stopping deterministically
|
|
# through the normal shutdown, which commands 0 W on the way out.
|
|
problems = validate_startup_config(self.o, self.dev_min, self.dev_max)
|
|
if problems:
|
|
refuse(problems)
|
|
self.refused = problems
|
|
self.log_event("REFUSED: " + problems[0])
|
|
# Signal, then return WITHOUT entering the loop. amain() wakes
|
|
# on this, runs the normal wind-down (which commands 0 W) and
|
|
# re-raises ConfigRefused afterwards, so the exit code is 78 and
|
|
# the inverter is left at zero rather than latched.
|
|
self.stop_event.set()
|
|
return
|
|
|
|
last_grid = object()
|
|
heartbeat = float(self.o.get("heartbeat_s", 10))
|
|
last_beat = 0.0
|
|
# ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not a
|
|
# second scheduler running alongside the arrival trigger. Every cycle
|
|
# resets it, whichever trigger fired, so on a healthy changing meter it
|
|
# never elapses and not one existing cycle moves - and therefore no
|
|
# change to the per-cycle gain the tuning was measured against.
|
|
#
|
|
# That is true twice over, and the second reason is the sturdier one:
|
|
# the combined cycle rate is max(arrival rate, 1/floor), never the sum.
|
|
# At the defaults 1/15 s is strictly slower than the ~1/5 s arrival
|
|
# rate, so even a floor that raced the arrivals could not push the loop
|
|
# past its nominal healthy rate. The re-tune is unreachable by
|
|
# construction, not merely by the reset. ⚠️ That argument holds only
|
|
# while floor > the meter's update interval - which is why the schema
|
|
# minimum of 5 s deserves the second look validate.py has not given it
|
|
# yet: 5 s IS the reference meter's interval, i.e. the one configurable
|
|
# value that would double the cycle rate.
|
|
#
|
|
# Why it has to exist, and it is not only about latency: `self.grid !=
|
|
# last_grid` alone means a meter that repeats a value runs NO control
|
|
# cycles at all. The heartbeat below keeps re-emitting the last command
|
|
# and the firmware keeps putting it on the wire, so every indicator
|
|
# reads healthy - and, worse, the `inputs missing -> 0 W` claim lives
|
|
# INSIDE cycle(), so on a repeating meter THAT SAFETY CLAIM CANNOT FIRE.
|
|
# Demonstrated in test_loop.py: constant meter, SoC entity dies at t=4 s,
|
|
# and the pre-fix controller holds 220 W through the whole outage with
|
|
# the status line still reading "loop -> 220 W". Layer 1 does not cover
|
|
# it on a `meter_source: off` install either - no P1 age sensor is
|
|
# published, so the firmware has nothing to veto on. Measured on the rig
|
|
# during TEL-05: 70 s at a literally constant 350.0 W, 14 arrivals, zero
|
|
# cycles.
|
|
#
|
|
# This is NOT a freeze detector, and deliberately so - see
|
|
# HomeWizardLocalSource.unchanged_s for why one cannot be built here (at
|
|
# the converged -10 W target a 1 Wh register needs ~6 minutes to move,
|
|
# so any freeze threshold false-trips at the exact operating point we
|
|
# run at). Nothing here decides whether a repeated reading is true. It
|
|
# only stops "the number is the same" from meaning "do not think".
|
|
#
|
|
# ⚠️ THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN. It is under
|
|
# validate.FIRMWARE_INGEST_VETO_S (30 s) and under meter_max_age_s on
|
|
# purpose, so the loop re-decides at least twice inside any window in
|
|
# which layer 1 might otherwise be the only thing deciding. If the
|
|
# firmware's veto ever moves, this default moves with it - it is not
|
|
# independently justified at 15.
|
|
floor = float(self.o.get("cycle_floor_s", 15))
|
|
last_cycle = None
|
|
while not self.stopping:
|
|
await self.read_inputs()
|
|
now_mono = asyncio.get_running_loop().time()
|
|
# A cycle is one meter update, exactly as on the reference install -
|
|
# or the floor elapsing, whichever comes first.
|
|
overdue = last_cycle is None or (now_mono - last_cycle) >= floor
|
|
if self.grid != last_grid or overdue:
|
|
last_grid = self.grid
|
|
last_cycle = now_mono
|
|
# ⚠️ The FULL cycle, not a degraded one. A floor cycle runs the
|
|
# same law on the same inputs as an arrival cycle, because in P1
|
|
# mode the freshness question is already answered upstream and
|
|
# answered harder: past `meter_max_age_s` (30 s) p1.net_w is
|
|
# None, which is "inputs missing", which becomes a P_SAFETY 0 W
|
|
# claim. So a floor cycle cannot run the PI on a 60 s old
|
|
# figure, because at 60 s there is no figure.
|
|
#
|
|
# ⚠️ THAT GUARANTEE IS TRANSPORT-CONDITIONAL, and saying so
|
|
# matters more than the guarantee: with `meter_source: off`,
|
|
# read_inputs() takes self.grid straight from hass.number() with
|
|
# NO age gate at all, so a Home Assistant entity that has gone
|
|
# quiet reads as a current value on that path with or without
|
|
# this change. Running the full law is still the right choice
|
|
# there - the alternative is a degraded second law reasoning
|
|
# about an age nobody measured - but the floor does not make
|
|
# that path fresh, and nothing here should be read as claiming
|
|
# it does. And the integrator
|
|
# advances per CYCLE, not per second (control.py), so bounding
|
|
# the gap bounds the accumulation: at most one extra
|
|
# gain x error per floor interval, under the same
|
|
# integrator_max_w as every other cycle. A second, gentler
|
|
# control law for floor cycles would be a second set of
|
|
# dynamics nobody can test on this hardware.
|
|
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:
|
|
values = {
|
|
"setpoint": self.target,
|
|
"grid": self.grid,
|
|
"battery": self.batt,
|
|
"soc": self.soc,
|
|
"phase": self.maint.phase,
|
|
# ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing
|
|
# one. IF-01/IF-02 own `ems/event/failsafe` and the `failsafe active`
|
|
# binary sensor and neither is built yet; inventing a bespoke topic
|
|
# here would be a surface to migrate later. This sensor is already
|
|
# discovered, already on every dashboard, and "failsafe" is
|
|
# additive to its vocabulary rather than a change to it.
|
|
"status": ("failsafe" if self.sign_wd.tripped
|
|
else "running" if self.auto else "stopped"),
|
|
}
|
|
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
|
|
# watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id
|
|
# note in mqtt.py; the composed name is not the object_id - and forces layer-1
|
|
# failsafe once it reaches max_age_s. With meter_source off there is no
|
|
# ingester feeding it, so published_age_s would be time-since-startup
|
|
# climbing without bound - i.e. every existing install would cross the
|
|
# threshold within 30 s and pin its inverter at 0 W forever. Publishing
|
|
# nothing leaves the entity non-existent, which is the status quo and
|
|
# what has_state() in the firmware is checking for.
|
|
if self.p1_enabled:
|
|
# Recomputed here, once a second, on purpose - see P1Ingest.
|
|
values["p1_age"] = round(self.p1.published_age_s, 1)
|
|
self.mqtt.publish(values)
|
|
|
|
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 = []
|
|
if self.p1_enabled:
|
|
age = self.p1.published_age_s
|
|
if self.p1.stale:
|
|
out.append({"ok": False, "warn": False,
|
|
"text": f"P1 meter ({o.get('meter_source')}): no reading for "
|
|
f"{age:.0f} s (limit {self.p1.max_age_s:.0f} s)"
|
|
+ (f" - last error: {self.p1.last_error}"
|
|
if self.p1.last_error else "")})
|
|
else:
|
|
# ⚠️ unchanged_s is REPORTED, never thresholded and never folded
|
|
# into the age - see HomeWizardLocalSource.unchanged_s for why
|
|
# (at the converged -10 W this controller aims for, a 1 Wh
|
|
# register needs ~6 minutes to move, so any limit false-trips at
|
|
# the exact operating point we target). The whole argument for
|
|
# leaving it unthresholded is that a human interprets it, which
|
|
# requires a human being able to see it - so here it is. getattr:
|
|
# only homewizard_local has one, and p1_source is None until
|
|
# amain() builds the transport.
|
|
unchanged = getattr(self.p1_source, "unchanged_s", None)
|
|
out.append({"ok": True, "warn": False,
|
|
"text": f"P1 meter ({o.get('meter_source')}): {self.p1.net_w:g} W, "
|
|
f"{age:.0f} s old, {self.p1.samples} telegrams, "
|
|
f"{self.p1.parse_errors} rejected"
|
|
+ (f", measurement unchanged for {unchanged:.0f} s"
|
|
if unchanged is not None else "")})
|
|
rows = [("battery SoC", self.soc, o.get("soc_entity")),
|
|
("battery power", self.batt, o.get("batt_entity"))]
|
|
if not self.p1_enabled:
|
|
# In P1 mode the check above replaces this one; leaving both in
|
|
# would report "no entity configured" for a meter_entity that is
|
|
# correctly unused, i.e. a permanent false NOT READY.
|
|
rows.insert(0, ("grid power", self.grid, o.get("meter_entity")))
|
|
for label, value, entity in rows:
|
|
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"})
|
|
|
|
# SAFETY-02. Reported in both states: a watchdog nobody can see is armed
|
|
# is a watchdog nobody trusts when it fires.
|
|
if self.sign_wd.tripped:
|
|
out.append({"ok": False, "warn": False,
|
|
"text": "SIGN-DISAGREEMENT FAILSAFE - " + self.sign_wd.reason
|
|
+ ". The inverter is held at 0 W. Find out why the "
|
|
"command opposed the meter before starting control "
|
|
"again; starting clears this."})
|
|
else:
|
|
held = self.sign_wd.held_s
|
|
out.append({"ok": True, "warn": False,
|
|
"text": f"sign watchdog: armed (trips after "
|
|
f"{self.sign_wd.cfg.hold_s:.0f} s opposed, both over "
|
|
f"{self.sign_wd.cfg.min_w:.0f} W)"
|
|
+ (f" - opposed for {held:.0f} s now" if held else "")})
|
|
|
|
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 self.sign_wd.tripped:
|
|
# Outranks "NOT READY": nothing is missing, something went wrong.
|
|
level, banner = "bad", ("FAILSAFE — the command opposed the meter. "
|
|
"Inverter held at 0 W.")
|
|
elif 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 self.auto and self.sign_wd.tripped:
|
|
# ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose.
|
|
# It clears on a deliberate human start, not on the condition
|
|
# going away - the condition ALWAYS goes away, because the trip
|
|
# commands 0 W and a 0 W command cannot disagree with anything.
|
|
# An operator pressing start has, at minimum, looked at the
|
|
# banner that says why.
|
|
self.sign_wd.reset()
|
|
self.log_event("sign-disagreement failsafe cleared by operator")
|
|
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)
|
|
|
|
# ⚠️ THE STARTUP GATE (CFG-01). Nothing below this line may run on a
|
|
# configuration validate.py rejects - specs §1.5: a config the system
|
|
# cannot honour is refused at startup, not survived at runtime.
|
|
#
|
|
# It sits HERE, before the broker, before the web server and before any
|
|
# task exists, so a refusal cannot leave half a controller running with
|
|
# a UI in front of it. Reading the device range is the one thing the
|
|
# validator cannot do for itself; (None, None) when ESPHome has not come
|
|
# up yet is not a refusal - run_control() re-checks once the real rating
|
|
# is known. See validate.py for where the line is drawn and why.
|
|
_sp = str(opts.get("setpoint_entity", "") or "").strip()
|
|
_dev_min = _dev_max = None
|
|
if _sp:
|
|
_dev_min, _dev_max, _ = await hass.limits(_sp)
|
|
_LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max)
|
|
_problems = validate_startup_config(opts, _dev_min, _dev_max)
|
|
if _problems:
|
|
refuse(_problems)
|
|
raise ConfigRefused(_problems)
|
|
|
|
# ⚠️ 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.
|
|
broker = None
|
|
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,
|
|
omit=() if is_enabled(opts) else ("p1_age",),
|
|
)
|
|
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()
|
|
|
|
stop = asyncio.Event()
|
|
controller = Controller(opts, hass, store, pub, stop=stop)
|
|
runner = await web.start(controller, port=8099)
|
|
|
|
loop = asyncio.get_running_loop()
|
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
with contextlib.suppress(NotImplementedError):
|
|
loop.add_signal_handler(sig, stop.set)
|
|
|
|
tasks = [asyncio.create_task(controller.run_control())]
|
|
|
|
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
|
|
# off the control loop: telegrams arrive every ~5 s and the loop would
|
|
# decimate them, so the 15-minute average - the capacity-tariff billing
|
|
# unit - would be computed from a fraction of the data.
|
|
p1_source = build_source(opts, controller.p1, session, broker)
|
|
if p1_source is not None:
|
|
controller.p1_source = p1_source # so checks() can report on it
|
|
tasks.append(asyncio.create_task(p1_source.run()))
|
|
|
|
await stop.wait()
|
|
|
|
await controller.shutdown()
|
|
for task in tasks:
|
|
task.cancel()
|
|
for task in tasks:
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
await runner.cleanup()
|
|
_LOG.info("stopped")
|
|
# The late-arriving-device refusal from run_control(). Re-raised only
|
|
# now, so the wind-down above still ran and the inverter is at 0 W.
|
|
if controller.refused:
|
|
raise ConfigRefused(controller.refused)
|
|
|
|
|
|
def main() -> None:
|
|
try:
|
|
asyncio.run(amain())
|
|
except KeyboardInterrupt:
|
|
pass
|
|
except ConfigRefused:
|
|
# ⚠️ No traceback, on purpose. The banner refuse() already printed says
|
|
# everything an operator needs, and a stack trace on top of it would say
|
|
# "this crashed" - the exact confusion AC3 is about. 78 is sysexits(3)
|
|
# EX_CONFIG, so even a script reading the exit status can tell a bad
|
|
# config from a bug.
|
|
raise SystemExit(78)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|