Files
goodwe-addon/goodwe_controller/app/main.py
T
glenn schrooyenandClaude Opus 5 7aa1bd052e SAFETY-18: a meter frozen at a wrong-but-fresh value now raises an alarm
A meter that answers 200 OK with a stale document forever is ARRIVING on every
poll, so every freshness mechanism in the stack reads it as healthy while the
loop integrates a constant error and rails: 360/135/75 s at the live install's
max_w: 5000 for a meter frozen at 350/1000/>=3000 W. Fifteen minutes of a 5 kW
unattended import sets the month's billed capaciteitstarief peak.

The detector is a conjunction, not the bare unchanged_s threshold TEL-05
measured and rejected:

    unchanged_s >= meter_freeze_s   AND   |command| within 5 % of max_w

Both terms already existed and were already on the status page, and they fail
for independent reasons: at the converged point the command is nowhere near the
rail, and at the rail against a real load the kWh registers move in seconds. At
the shipped 300 s the relation is structural rather than a margin - the slowest
a healthy 1 Wh register can tick and still look unchanged is 12 W, which is
inside the 15 W deadband, so the command there is parked at 0 W.

Notification only, deliberately: an HA persistent notification with a fixed
notification_id, an ERROR log line, a UI event and a red status row. No claim,
no latch, no 0 W. A false trip parks the battery; a false notification costs an
alert. Only meter_source: homewizard_local is covered - the status page says
NOT COVERED elsewhere rather than pretending the guard is armed.

DOCS.md states the capacity-tariff exposure this does NOT close: peak_cap_w caps
maintenance charging and nothing caps the loop. That cap is SAFETY-07.

test_freeze.py grades both directions and was verified by breaking what it
watches (six mutations, all red): silent over the committed 40-minute converged
capture, silent over an hour of TEL-05's low-power case where the rejected bare
threshold is shown to fire, alarming on a genuinely frozen meter driven over
real HTTP through the real HomeWizardLocalSource (the rig's own hwsim freeze
fault when reachable), silent on the same railed command against a meter that is
still moving, and asserting on the service call that leaves Controller.cycle()
rather than on the watchdog's own flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 02:39:54 +02:00

929 lines
49 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 .freeze import FreezeWatchdog, FreezeWatchdogConfig, rail_w
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, loop_is_following
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.
#
# ⚠️ RESTORED FROM THE STORE, and that is the difference between a latch
# and a pause. A trip held only in memory is cleared by the one action an
# operator staring at a FAILSAFE banner is most likely to take - restart
# the add-on - and the controller then comes back armed, into the fault,
# with nobody involved. Restored here, the banner and the 0 W survive the
# restart and only `auto_toggle` clears them.
self.sign_wd = SignWatchdog(
SignWatchdogConfig(
min_w=float(opts.get("sign_disagree_w", 300)),
hold_s=float(opts.get("sign_disagree_s", 180)),
),
tripped=bool(store.data.get("sign_tripped", False)),
reason=str(store.data.get("sign_reason", "") or ""),
)
if self.sign_wd.tripped:
_LOG.error("SAFETY-02 was TRIPPED before this restart and stays "
"tripped: %s. The inverter is held at 0 W. Restarting the "
"add-on does not clear this - start control from the UI "
"once you know why the command opposed the meter.",
self.sign_wd.reason or "(reason not recorded)")
# SAFETY-18. The frozen-but-fresh meter detector - see freeze.py for why
# it is a conjunction and not an `unchanged_s` threshold, and for the
# measurements behind the default.
#
# ⚠️ NOT restored from the store, unlike SAFETY-02's latch, and the
# asymmetry is the point: this one does not latch because it does not
# act. A restart re-derives its state from the meter within one
# `meter_freeze_s` window, and until then the honest report is "not
# alarming yet" rather than an alarm nobody can clear.
self.freeze_wd = FreezeWatchdog(
FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300))))
# 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:
# ⚠️ REASON FIRST, FLAG SECOND, and each `set` writes the file. The
# ordering is the whole atomicity story: a crash between the two
# persists a reason nothing reads, which is harmless, where the other
# order persists a latch with no explanation for the operator who has
# to decide whether to clear it.
self.store.set("sign_reason", self.sign_wd.reason)
self.store.set("sign_tripped", True)
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. This survives an add-on "
"restart; restarting will not clear it.",
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 - without this,
# every maintenance grid charge trips the failsafe within three minutes.
#
# ⚠️ THE EXPRESSION LIVES IN watchdog.py, NOT HERE, and that is not
# tidiness. It was inline, test_watchdog.py held a transcription of it,
# and the P_PEAK case meant to force whoever lands peak shaving to come
# and change an expectation did not: editing this line alone to
# `winner in ("loop", "peak")` left every suite green while the watchdog
# went blind for every peak event. Read loop_is_following() before
# touching the gate - the forward hazard and the reasoning are there.
self.following = loop_is_following(resolution, self.loop_w)
self.target = resolution.target_w
self.reason = resolution.explain()
await self.write_setpoint(self.target)
# --- SAFETY-18, and it runs LAST on purpose --------------------------
# Both terms of the conjunction describe the state that now exists: the
# command that has just gone on the wire, and how long the meter that
# chose it has been standing still. Evaluated before write_setpoint()
# it would be reasoning about the PREVIOUS command against THIS cycle's
# meter - the one combination that is true of neither moment.
await self.check_meter_frozen()
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
Notification only - no claim, no latch, no 0 W. freeze.py carries the
reasoning and the euro figure; DOCS.md carries the exposure this does
NOT close.
⚠️ getattr, not an attribute access: `unchanged_s` exists only on
`homewizard_local`, and `p1_source` is None until amain() builds the
transport. Every other meter_source hands the predicate None and gets
False, which is the honest answer - see the transport note in freeze.py.
"""
unchanged = getattr(self.p1_source, "unchanged_s", None)
fired = self.freeze_wd.evaluate(unchanged, self.target,
float(self.o.get("max_w", 2000)),
datetime.now(timezone.utc))
if not fired:
return
self.log_event("METER FROZEN? " + self.freeze_wd.reason)
_LOG.error(
"SAFETY-18 - %s. The grid-following loop is regulating against a "
"number that has stopped moving, and nothing in this stack stops "
"it: the meter is still answering, so the age is fresh, the "
"firmware watchdog is fed and the arbiter only bounds the "
"magnitude. CHECK THE METER. Sustained for a quarter of an hour "
"this sets the month's billed capacity-tariff peak. This is an "
"ALARM ONLY - the loop is still running.", self.freeze_wd.reason)
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
# rather than stacking. An operator who has to dismiss eleven identical
# notifications learns to dismiss the twelfth without reading it.
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_meter_frozen",
"title": "GoodWe: the P1 meter may be frozen",
"message": (
f"{self.freeze_wd.reason}.\n\nThe controller is STILL RUNNING "
f"and still following this reading. Check the meter; if it is "
f"stuck, stop control from the add-on UI."),
})
# -- 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 "")})
# SAFETY-18. Reported in both states, for the reason SAFETY-02 is:
# a detector nobody can see is armed is a detector nobody trusts
# when it fires. ok=False/warn=False so the banner goes red - this
# is an alarm, and the banner is the loudest surface we own. It
# changes NOTHING about control; see check_meter_frozen().
if self.freeze_wd.active:
out.append({"ok": False, "warn": False,
"text": "METER MAY BE FROZEN - " + self.freeze_wd.reason
+ ". The loop is STILL FOLLOWING it and is "
"still running. Check the meter. Held here "
"for a quarter of an hour this sets the "
"month's billed capacity peak."})
elif getattr(self.p1_source, "unchanged_s", None) is None:
# Not "armed". Every transport but homewizard_local is invisible
# to this check, and saying "armed" there would be a lie the
# status page tells on behalf of a guard that cannot run.
out.append({"ok": False, "warn": True,
"text": f"freeze detector: NOT COVERED on "
f"meter_source {o.get('meter_source')} - only "
f"homewizard_local sees whether the meter's own "
f"document changes"})
else:
out.append({"ok": True, "warn": False,
"text": f"freeze detector: armed (alerts after "
f"{self.freeze_wd.cfg.hold_s:.0f} s unchanged "
f"while the command is over "
f"{rail_w(self.o.get('max_w', 2000)):.0f} W)"})
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. Restarting the "
"add-on does NOT clear it."})
else:
held = self.sign_wd.held_s(datetime.now(timezone.utc))
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.store.set("sign_tripped", False)
self.store.set("sign_reason", "")
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()