Files
goodwe-addon/goodwe_controller/app/main.py
T
glenn schrooyenandClaude Opus 5 9aed43990b SAFETY-02: a loop can be fresh, tracking and inside its rating and still push the wrong way
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
2026-08-28 21:51:35 +02:00

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()