The clamp was never the missing half. specs.md 10.3 records an unclamped integrator climbing ~130 W per 4 s past 10 kW and reporting 14 768 W pinned at the 5 kW rail; clipped silently that is a flat, legal-looking 5000 W hold and nothing says the loop asked for three times it. write_setpoint() - the one path every write takes - now clamps to the VALIDATED max_w and reports whether the raw value differed; check_output_clamp() raises OUTPUT_CLAMP as a notification, an ERROR, an event row and a red status banner. Not a trip: the clamped value is written and the loop keeps running. The rating is max_w and nothing is min()ed at runtime - CFG-01 already refuses a max_w outside the device range at startup. The device range is applied second as defence in depth; it cannot bite after a successful validation, so if it ever does it alarms too and names the bound. No new config key. Decision grew i_clamped / clamped booleans, and that is the load-bearing part: `reason` is one string that slew-limiting and the saturation freeze overwrite, so an alarm keyed on it is silent on exactly the cycles a winding loop produces. Mutating main.py back onto the string drops the 10.3 replay from 2 engagements to 0. Distinct from saturation by construction - no shared reason code, notification id, status row or counter - and checked in the negative, after first asserting the saturation freeze really did engage. Two things found by running it rather than by reading it: a row per engagement flushed the 40-row event log and evicted SAFETY-18's own METER FROZEN? row (now coalesced per episode with an exact count), and a naive clamp turns a nan into a command at the full rating because every comparison against nan is False. test_clamp.py is new: assert-based, no framework. Six author mutations reproduced red, including "clamp kept, alarm deleted". Two of the check's own hypotheses about the freeze/clamp interaction were refused by its sweep and rewritten to what it measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
1096 lines
58 KiB
Python
1096 lines
58 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 .clamp import (
|
|
REASON_CODE as CLAMP_REASON, ClampResult, ClampWatchdog, clamp_to_rating,
|
|
is_commandable,
|
|
)
|
|
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))))
|
|
|
|
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
|
|
# itself is the cheap half.
|
|
#
|
|
# ⚠️ NO CONFIG OF ITS OWN, on purpose. The bound it clamps to is `max_w`,
|
|
# which is already an option, already validated against the device's
|
|
# rating at startup, and already used by Tuning and the arbiter. Giving
|
|
# this a second key would be a fourth place a limit has to be kept in
|
|
# step (CFG-03 already tracks three) and, worse, would recreate exactly
|
|
# the two-numbers-that-disagree failure the §14 warning box describes.
|
|
# The anti-flood cadence is a module constant for the same reason
|
|
# freeze.RAIL_FRAC is: it describes the event bus, not the plant.
|
|
self.clamp_wd = ClampWatchdog()
|
|
|
|
# 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
|
|
# SAFETY-03. What the last write's clamp did, and what the loop's own
|
|
# bounds did last cycle. `loop_clamped` is a (engaged, note) pair so the
|
|
# alarm can say WHICH bound bit without re-deriving it from a string.
|
|
self.clamp_last: ClampResult | None = None
|
|
self.loop_clamped: tuple[bool, str] = (False, "")
|
|
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
|
|
|
|
# --- SAFETY-03: THE CLAMP, AND IT IS ON THE ONLY PATH TO THE WIRE -----
|
|
# Every write in this add-on comes through here - the cycle, the
|
|
# heartbeat, the operator's stop button and shutdown() - so no optimizer,
|
|
# maintenance or manual path can route around it. That placement is the
|
|
# first acceptance criterion, and it is why the guard is not in
|
|
# control.py: a guard the control law owns is a guard every other claimer
|
|
# bypasses.
|
|
#
|
|
# ⚠️ A NON-FINITE VALUE IS LOSS OF INPUT, NOT AN OUT-OF-RANGE ONE. §11.1
|
|
# rule 1: command zero and keep commanding. It must not reach the clamp
|
|
# arithmetic - see is_commandable() for what a nan does to it - and it
|
|
# must not be counted as a clamp engagement, or a transport bug lands in
|
|
# the winding-loop counter.
|
|
if not is_commandable(value):
|
|
self.log_event(f"non-finite setpoint {value!r} - commanding 0 W")
|
|
_LOG.error("refusing to write a non-finite setpoint %r - commanding "
|
|
"0 W and continuing to command. This is a bug upstream, "
|
|
"not a limit.", value)
|
|
value = 0.0
|
|
result = clamp_to_rating(value, float(self.o.get("max_w", 2000)),
|
|
self.dev_min, self.dev_max)
|
|
self.clamp_last = result
|
|
if result.engaged:
|
|
# ⚠️ EVERY engagement, never deduplicated: this is the action log
|
|
# (§15), and it is a different surface from the notification, which
|
|
# IS deduplicated. The count is what says how hard the loop pushed;
|
|
# the notification only says that it did.
|
|
self.log_event(result.describe())
|
|
value = result.applied_w
|
|
|
|
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
|
|
# --- SAFETY-03 ---------------------------------------------------
|
|
# ⚠️ READ THE BOOLEANS, NOT `decision.reason`. On a default install
|
|
# the integrator bound is what bites and the output clamp is
|
|
# unreachable (control.py says so), and `reason` is then overwritten
|
|
# by whichever of slew-limiting or the saturation freeze ran after
|
|
# it - so a string test is silent on the two cases a winding loop
|
|
# most often presents as. These flags are set where the clamp
|
|
# happens and nothing downstream touches them.
|
|
self.loop_clamped = (
|
|
decision.i_clamped or decision.clamped,
|
|
(f"the loop asked for {decision.i_w:+.0f} W"
|
|
+ (" (integrator bound)" if decision.i_clamped else "")
|
|
+ (" (output bound)" if decision.clamped else "")),
|
|
)
|
|
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
|
|
# A loop that is not running cannot be winding. Clearing this here
|
|
# rather than letting the last value stand is what makes the alarm
|
|
# de-assert when control is stopped.
|
|
self.loop_clamped = (False, "")
|
|
|
|
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_output_clamp()
|
|
await self.check_meter_frozen()
|
|
|
|
async def check_output_clamp(self) -> None:
|
|
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
|
|
|
|
Runs AFTER write_setpoint() for the same reason SAFETY-18 does: the
|
|
clamp is arithmetic done inside the write, so before the write there is
|
|
nothing yet to report.
|
|
|
|
⚠️ PUBLISHING IS NOT IN THE WRITE PATH. The clamp itself is synchronous
|
|
inside write_setpoint(); this - the awaits, the notification, anything
|
|
that can block on Home Assistant - is out here. A stalled supervisor must
|
|
never be able to delay or drop a frame.
|
|
|
|
⚠️ THIS IS NOT A TRIP. No claim is filed, nothing is latched, 0 W is not
|
|
commanded and `publish()` still reports "running". §11.2's action for
|
|
this row is "clamp and alarm", and the clamped value is written and the
|
|
loop keeps running. Turning this into a trip means coming here and
|
|
changing that deliberately, not discovering it in the field.
|
|
"""
|
|
engaged_write = bool(self.clamp_last and self.clamp_last.engaged)
|
|
engaged_loop, loop_note = self.loop_clamped
|
|
engaged = engaged_write or engaged_loop
|
|
if engaged:
|
|
where = (self.clamp_last.describe() if engaged_write
|
|
else f"{CLAMP_REASON}: {loop_note}, above the "
|
|
f"{float(self.o.get('max_w', 2000)):.0f} W rating, and "
|
|
f"was clamped to {self.target:+.0f} W")
|
|
reason = (f"{where}. This is a BUG SIGNAL, not a limit being used: "
|
|
f"something upstream computed a setpoint the actuator "
|
|
f"cannot deliver.")
|
|
else:
|
|
reason = ""
|
|
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc))
|
|
|
|
# --- THE ACTION LOG, AND IT IS COALESCED PER EPISODE ------------------
|
|
# Every engagement is recorded, but consecutive ones are recorded by
|
|
# UPDATING one row's count rather than inserting a row each.
|
|
#
|
|
# ⚠️ THAT IS NOT COSMETIC, AND IT WAS FOUND THE HARD WAY. `log_event()`
|
|
# keeps 40 rows; a winding loop clamps on every cycle, so a row per
|
|
# engagement flushes the whole ring within 40 cycles - and the first
|
|
# thing it evicted, in test_freeze.py, was SAFETY-18's "METER FROZEN?"
|
|
# row. A guard whose log drowns out the other guards' logs has moved the
|
|
# flood rather than removed it. The count here is `clamp_wd.engagements`,
|
|
# so nothing is lost: the row says exactly how many, and the number is
|
|
# read off the counter rather than off the number of rows.
|
|
#
|
|
# This lives here rather than only in write_setpoint() because on a
|
|
# default install the actuator-side clamp is unreachable - compute() has
|
|
# already bounded the integrator - so the only engagements that exist are
|
|
# loop-side ones. Measured: 60 cycles of the §10.3 runaway produce 0
|
|
# write-path engagements and 2 loop-side ones.
|
|
if engaged and not engaged_write:
|
|
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
|
|
row = (f"{tag}: {self.clamp_wd.engagements} engagement(s) - "
|
|
f"{reason}")
|
|
if self.events and tag in self.events[0]:
|
|
stamp = self.events[0].split(" ", 1)[0]
|
|
self.events[0] = f"{stamp} {row}"
|
|
else:
|
|
self.log_event(row)
|
|
if not fired:
|
|
return
|
|
_LOG.error(
|
|
"SAFETY-03 - %s Silently clipping this is how a winding integrator "
|
|
"reached 14 768 W on this project before: the clamp makes every "
|
|
"dashboard read a legal number while the thing producing it goes on "
|
|
"climbing. The command IS clamped and the loop is STILL RUNNING - "
|
|
"this is an alarm, not a trip. Engagements so far: %d.",
|
|
self.clamp_wd.reason, self.clamp_wd.engagements)
|
|
# A fixed notification_id, for the reason check_meter_frozen() gives -
|
|
# and a DIFFERENT one from every other alarm this add-on raises, because
|
|
# a clamp is not a frozen meter and neither is a saturation freeze.
|
|
await self.hass.call("persistent_notification", "create", {
|
|
"notification_id": "goodwe_output_clamp",
|
|
"title": "GoodWe: a computed setpoint exceeded the rating",
|
|
"message": (
|
|
f"{self.clamp_wd.reason}\n\nThe command has been clamped and "
|
|
f"the controller is STILL RUNNING. This is NOT the inverter "
|
|
f"failing to follow a legal command (that is the saturation "
|
|
f"freeze, which is normal and does not alarm) - the value asked "
|
|
f"for was itself out of range."),
|
|
})
|
|
|
|
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)"})
|
|
|
|
# SAFETY-03. Red while a clamp is engaged, and the engagement COUNT is
|
|
# shown even after it clears - a loop that wound once and stopped is
|
|
# still a bug, and a row that vanishes with the condition takes the
|
|
# evidence with it. Reported only when there is something to report:
|
|
# unlike the freeze detector this guard has no "not covered" state, it
|
|
# runs on every write on every transport.
|
|
if self.clamp_wd.active:
|
|
out.append({"ok": False, "warn": False,
|
|
"text": "OUTPUT CLAMP ENGAGED - " + self.clamp_wd.reason
|
|
+ f" Engagements: {self.clamp_wd.engagements}."})
|
|
elif self.clamp_wd.engagements:
|
|
out.append({"ok": False, "warn": True,
|
|
"text": f"output clamp: not engaged now, but it has "
|
|
f"engaged {self.clamp_wd.engagements} time(s) in "
|
|
f"{self.clamp_wd.episodes} episode(s) since "
|
|
f"start - see the event log"})
|
|
|
|
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()
|