Files
goodwe-addon/goodwe_controller/app/main.py
T
adminandClaude Opus 5 5d7e275df4
checks / unit (pull_request) Successful in 5m16s
SAFETY-39: slew_w is a ratio to the rail, and gain is what is still wrong
`slew_w: 1000` was derived on 2026-08-22 against a clamp of 1500 W, where
"capping how far the loop can jump ahead of the inverter" was true because the
rail itself bounded the excursion. The live install went to max_w 5000 and the
constant did not move, so the loop crosses its whole output range in 10 s
against a plant NOTES.md §5.10 measured at ~18 s. Measured on the owner's system
2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000 W in five
2 s ticks, the battery to -5154 W past a 5000 VA rating, and the grid from
3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken; the rail moved.

REPRODUCED FIRST, and one half of it is not reproducible at all. On
plantsim.Plant and on an independent in-repo model: the setpoint stepping a full
1000 W per tick to the rail (48 of 51 export steps), and the meter crossing from
export into import. The -5154 W figure is NOT reproducible in either model,
because both clamp the plant at its rating - so the card's AC "battery no longer
exceeds max_w in simulation" is unfalsifiable and was rewritten rather than met.

THE DELIVERABLE IS THE RELATIONSHIP.
  SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 1/9
  slew_w = 0 now means FOLLOW THE RAIL - SLEW_RAIL_FRAC * max_w - exactly as
  integrator_max_w = 0 already means follow max_w. It is the shipped default:
  222 W at max_w 2000, 556 W at 5000, 1067 W at a ~9600 W two-inverter site.
  validate.py rule 10 refuses an explicit value faster than that, graded against
  sum(unit_caps(opts)) - the roster sum, i.e. Controller.site_max_w - so a
  second inverter moves the bound with no operator action.

VERIFIED BY BREAKING IT: restoring slew_w 1000 at max_w 5000 is refused, and the
refusal names the rail, the crossing time, the measurement and what to do.
Equality, one watt of slack, the live interim 400 and slew_w 0 at every rating
all still start.

GAIN IS THE CONSTANT THAT IS STILL WRONG, and it is NOT changed here. Over 51
export steps the over-absorption is FLAT in slew (958 W at 1000, 1081 W at 250)
and monotone in gain (987 W at 0.6, 876 at 0.3, 435 at 0.2): slew_w bounds the
OUTPUT, while the integrator moves gain*error per cycle and reaches a 5000 W
rail in under three ticks whatever the slew is. Two control-law constants moving
in one release on a running house makes the next measurement uninterpretable.

⚠️ N=2: the site rail becomes sum(max_w) ~ 9600 W (inverter B FROM DATASHEET AND
SERIAL, not measured, and sim/plantsim.py's INVERTER_B is A copied), and the
enforced ratio produces 1067 W there automatically. But a growing rail LOOSENS
this bound, and the >= 900 W symptom goes stale at N=2 the same way the constant
did - both stated in the record rather than left to be discovered.

Nothing reached the inverter. The live install still runs the interim slew_w 400
applied by hand on 2026-09-05, which rule 10 accepts unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 01:10:15 +02:00

3894 lines
230 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, timedelta, timezone
import aiohttp
from . import arbitrage
from .arbiter import (
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY,
Claim, Resolution, actuator_for, resolve,
)
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from .circulation import (
CIRC_OPTION_DEFAULTS, CirculationConfig, CirculationWatchdog,
circulating_w, opposing,
)
from .clamp import (
REASON_CODE as CLAMP_REASON, ClampWatchdog, clamp_to_rating, is_commandable,
)
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
)
from .hass import HomeAssistant
# ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
# window an operator would look up here is the one the roster actually uses.
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
)
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
unit_state_key)
from . import peakguard
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
from .store import migrate_maint_keys, migrate_unit_keys
from .validate import (ConfigRefused, refuse, validate_startup_config,
validate_unit_ranges)
from .watchdog import (
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
SignWatchdog, SignWatchdogConfig, loop_is_following,
)
from . import web
OPTIONS_PATH = "/data/options.json"
_LOG = logging.getLogger("goodwe")
# ⚠️ `UNIT_A` NOW LIVES IN app/inverter.py AND IS RE-EXPORTED HERE, because the
# roster does. It stays importable from this module: four checks read
# `app.main.UNIT_A` and it is still the name of the inverter this add-on drives.
# DEV-02 is what turned "the one inverter" into a list; `build_roster()` is the
# one function CFG-04 changes to put a second entry in it.
__all__ = ["UNIT_A", "Controller", "amain", "main"]
# --- DEV-02: THE PER-WRITE BUDGET, AND WHY IT IS SMALLER THAN A HEARTBEAT ----
# Every write to every inverter is dispatched CONCURRENTLY and each one is
# bounded by this. Both halves are the acceptance criterion, not an
# optimisation: one hung `hass.call()` used to sit on the single serialised
# write path and eat a SIBLING'S layer-1 budget - the ESP32 commands 0 W after
# ~30 s without a refresh, so a transport fault on inverter A could zero a
# perfectly healthy inverter B.
#
# ⚠️ THE NUMBER IS DERIVED, NOT CHOSEN. `heartbeat_s`'s schema maximum is 25 s
# (config.yaml) and layer 1's veto is `validate.FIRMWARE_INGEST_VETO_S` (30 s),
# so the budget has to fit in what is left: 25 + 4 < 30. That is the whole
# argument, and test_inverter.py grades the inequality rather than the literal,
# so raising the heartbeat's schema ceiling takes this red instead of taking an
# inverter to zero. It is also below `hass.py`'s own 10 s HTTP timeout on
# purpose - we would rather abandon a write and re-issue it on the next tick
# (one second later, and `last_write` is only updated on an ACCEPTED write, so
# the re-issue happens by itself) than hold the control loop for ten seconds.
WRITE_BUDGET_S = 4.0
# --------------------------------------------------------------------------- #
# OPT-04: THE BACKGROUND TARIFF REFRESH, AND WHY IT IS NOT OPTIONAL
#
# `static_tariff.resolve_tariff()` may perform an HTTPS fetch. The control tick
# is what feeds the ESP32 heartbeat, and layer 1 commands 0 W if that heartbeat
# stops for ~30 s - so a supplier's TLS timeout on the tick is a SUPPLIER able
# to push this add-on into the failsafe. Every module in the price stack says
# so in its own words (static_tariff.static_plan, resolve_tariff, main's
# read_inputs) and this is the task those notes were waiting for.
#
# The tick therefore never awaits a fetch. It reads `self.tariff_plan`, an
# attribute this task assigns, and nothing else.
#
# ⚠️ THE CADENCE IS NOT A GUESS AT HOW OFTEN PRICES CHANGE. A day-ahead feed
# publishes ONCE, at 14-16 h CET, and §8.3's carried hours become firm at that
# moment; 30 minutes is short enough that the switch is picked up inside the
# hour it happens and long enough to be a polite 48 fetches a day. Nothing in
# the control law depends on it.
TARIFF_REFRESH_S = 1800.0
# ⚠️ AND THE AGE BOUND IS NOT THE SAME QUESTION. A refresh that FAILS still
# stores a plan - resolve_tariff() degrades to the §11.4 static fallback, which
# is provisional and refuses arbitrage on its own. This bound covers the other
# case: the refresh task itself dying, leaving a plan that was true hours ago
# standing as if it were current. Three missed refreshes is the threshold, for
# `p1.max_age_s`'s reason - one missed poll is not a fault, a run of them is.
# A failed read is not an observation, and neither is a stale one.
TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
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,
peak_db_path: str = "/data/tariff.db"):
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
# ⚠️ FIRST, BECAUSE THE STORE MIGRATION BELOW CAN LOG TO IT. §15's
# action log is how an operator finds out that a latch was re-armed on
# their behalf, and a guard that raises AttributeError on the way to
# saying so is a guard that stops the add-on booting.
self.events: list[str] = []
# --- DEV-02: THE ROSTER, BUILT BEFORE ANYTHING THAT READS A RATING ---
# ⚠️ EVERY PER-DEVICE VALUE IN THIS CONTROLLER LIVES ON ONE OF THESE
# OBJECTS AND NOWHERE ELSE. app/inverter.py carries the argument for
# each one, including the two it deliberately does NOT hold - a `Tuning`
# and an integrator, because §6.2's stability property is ONE integrator
# on P1 above the split and a second one regulating the same meter is
# the two-writers failure the arbiter exists to prevent.
#
# ⚠️ AND THE STORE MIGRATION RUNS HERE, BEFORE THE FIRST `Inverter` READS
# ITS LATCH. `Inverter.__init__` restores SAFETY-02 from
# `unit/<name>/sign_tripped`; on an install written before DEV-02 that
# key does not exist yet and the latch is under the flat one. Migrate
# first or every existing tripped install comes up ARMED, into the
# fault, with a green banner - which is the exact failure the latch was
# persisted to prevent, rebuilt by a key rename.
# SAFETY-02's pair, and since SAFETY-30 it is THE SITE COMPARISON's
# pair - which at the shipped roster of one is the same thing under a
# more careful name. It is what `Inverter.sign_wd` is armed with at N=1,
# byte for byte, and what the site detector is armed with at N>=2.
self.sign_cfg = sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("sign_disagree_w", 300)),
hold_s=float(opts.get("sign_disagree_s", 180)),
)
# SAFETY-30. THE PER-UNIT COMPARISON'S OWN PAIR, RE-DERIVED AT TWO
# INVERTERS AND NOT CARRIED ACROSS. `sign_disagree_w` / `sign_disagree_s`
# were measured on two SINGLE-INVERTER bus captures against a command
# opposed to THE METER; this comparison is a command against ONE UNIT'S
# OWN POWER, where the magnitudes, the transient and the worst
# legitimate episode are all different quantities. watchdog.py's
# SIGN_OPTION_DEFAULTS carries the campaign that produced these, and
# DECISIONS/SAFETY-30-per-unit-sign.md carries the tables.
self.unit_sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("unit_sign_disagree_w",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"])),
hold_s=float(opts.get("unit_sign_disagree_s",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"])),
)
# SAFETY-30. THE SITE COMPARISON'S OWN PAIR, USED ONLY AT N>=2. The
# threshold is the shipped 300 W - carried, because the argument behind
# it is about the meter's noise floor and the deadband, neither of which
# the roster touches - and the WINDOW is not, because the transient it
# rejects is the loop crossing zero at `slew_w` per cycle across a rail
# that IS the roster sum. Measured at both rosters on the same stimuli:
# 110 s at one inverter, 215 s at two, against a 180 s hold. See
# watchdog.SIGN_OPTION_DEFAULTS.
self.site_sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("site_sign_disagree_w",
SIGN_OPTION_DEFAULTS["site_sign_disagree_w"])),
hold_s=float(opts.get("site_sign_disagree_s",
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"])),
)
self.store_migration = migrate_unit_keys(store, UNIT_A)
self.inverters = build_roster(opts, store, sign_cfg)
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
# ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none
# of `soc_entity`, `batt_entity`, `batt_invert` or `max_w`, and an
# operator looking at the Configuration page sees all four with values
# in them. validate.py rule 9(e) refuses the one that is genuinely
# dangerous - a flat `setpoint_entity` naming an actuator nothing will
# write - and the rest cannot be refused (the schema gives them no blank
# value), so they are DISCLOSED instead: one line in §15's action log,
# at every start, naming the roster it built and the keys it ignored.
# Silence here is how "the site is running at half the rating I typed"
# takes a week to notice.
if roster_entries(opts):
self.log_event(
"roster: %d inverters from `inverters:` - %s. The flat "
"soc_entity / batt_entity / batt_invert / max_w keys are NOT "
"read in this shape; each entry carries its own."
% (len(self.inverters),
", ".join(f"{i.name} {i.max_w:.0f} W" for i in self.inverters)))
self.tuning = Tuning(
gain=float(opts.get("gain", 0.6)),
# ⚠️ THE SITE BOUND, SUMMED FROM THE ROSTER, NOT `opts["max_w"]`.
# This is the integrator bound and the output rail of the SITE loop,
# whose output is then SPLIT across the inverters - so the number it
# may reach is what the site can deliver, which is the sum of what
# each unit may be written. At the shipped roster of one that is
# `max_w` exactly, byte for byte; at N>=2 a per-unit `max_w` still
# binds each unit's own share through its own `supervised` claim, so
# nothing here can over-command a device.
max_w=self.site_max_w,
# ⚠️ SAFETY-39: THE FALLBACK IS 0 AND 0 MEANS "FOLLOW THE RAIL",
# which is what config.yaml ships and what `Tuning`'s own default
# is. It used to be 1000 - a third copy of a constant that had
# already gone stale against the rail in the other two - and rule
# 6's comment in validate.py documents what that costs when the
# copies disagree. `control.slew_limit_w()` is the one reader.
slew_w=float(opts.get("slew_w", 0) or 0),
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),
)
# ⚠️ CFG-03. EIGHT `opts.get(key, <literal>)` CALLS USED TO LIVE HERE,
# and two of those literals disagreed with config.yaml - 2500 against a
# shipped 2000, which is a config validate.py refuses. The read now
# belongs to `MaintConfig.from_options()`, next to the one in-code
# mirror of config.yaml it falls back to, so this file states no opinion
# at all about what a missing maintenance key means. Read
# maintenance.MAINT_OPTION_DEFAULTS before adding a ninth key: it goes
# in config.yaml's `options:` and `schema:` blocks and in that dict, and
# nowhere else.
# --- MAINT-05: ONE `Maintenance` PER ROSTER ENTRY --------------------
# ⚠️ AND THE MIGRATION RUNS FIRST, FOR THE REASON THE SAFETY-02 ONE
# DOES. These objects READ their history in their constructors' wake -
# `phase`, `last_completed` - so a history still sitting under the flat
# spelling when they are built is a pack that comes up believing it has
# never been recalibrated, on a site whose state file says otherwise.
# It targets `self.inverters[0].name` and NOT `UNIT_A`: the flat keys
# belonged to whichever pack the one FSM was driving, which is the first
# roster entry, and a declared `inverters:` roster names its own entries
# (`inverter.build_roster()` says so, and its fallback for a nameless
# entry is a POSITIONAL `inverter-1`, so `inverter-a` is not even the
# default there). Writing `UNIT_A` here would deposit an install's whole
# maintenance history under a name that is not on the roster at all.
#
# ⚠️ AND THAT SENTENCE USED TO BE A COMMENT NOTHING COULD GRADE, WHICH
# IS WHY THE ROSTER IS PASSED RATHER THAN A COUNT. Every two-pack
# fixture in the suite named its first entry `inverter-a`, so
# `self.inverters[0].name` and `UNIT_A` were the same string in every
# check that existed and swapping them left all 19 files green - while
# on a roster of `["garage", "shed"]` it silently loses the history and
# stamps the marker, so the next start skips the migration too.
# `migrate_maint_keys()` now grades the namespace it wrote against the
# names the machines read; test_maintenance.py sections 9 and 11 drive
# a `["garage", "shed"]` roster, which is what makes the mutation red
# instead of invisible.
self.maint_migration = migrate_maint_keys(
store, self.inverters[0].name, [inv.name for inv in self.inverters])
_LOG.info("maintenance history: %s", self.maint_migration.explain())
if not self.maint_migration.ok:
# Not a disarmed guard - history, not a latch - so it does not
# re-arm anything. It is still the difference between a pack that is
# recalibrated on schedule and one that silently is not, which is
# §12's opening sentence, so it is said where an operator looks.
self.log_event("maintenance history did NOT migrate cleanly: "
+ self.maint_migration.explain())
# ⚠️ ONE CONFIG, N MACHINES. The `maintenance_*` options describe the
# PROCEDURE - how hard to drain, how full to charge, how long to hold -
# and the procedure is the same on every pack in the roster; what is per
# pack is the SCHEDULE and the HISTORY, which live in the store. A
# per-entry config block would be a second shape for CFG-04's roster to
# carry and a fifth place for a `maintenance_charge_w` to disagree with
# config.yaml. If a site ever needs two different procedures, that is a
# roster-schema card and it starts here.
_maint_cfg = MaintConfig.from_options(opts)
_one_pack = len(self.inverters) <= 1
self.maints = [
Maintenance(
_maint_cfg, store,
# ⚠️ `None` AT A ROSTER OF ONE IS THE FLAT LAYOUT, NOT A MISSING
# NAME. §6.2's identity: one pack, one history, and the keys it
# has been written under since the add-on shipped. See
# `maintenance.UnitHistory` and `store.migrate_maint_keys()` -
# this is the line that keeps a one-inverter state file
# byte-identical.
unit=None if _one_pack else inv.name,
name=inv.name,
# The rollback view, and only ever the first pack's: the flat
# keys mean `inverters[0]` on every install that has them.
mirror_flat=(not _one_pack and i == 0))
for i, inv in enumerate(self.inverters)]
# SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own
# docstring has always said and what this Controller did not do. Each
# `Inverter` builds its own, restored from ITS OWN namespaced store keys,
# so a trip caused by B latches B and leaves A commanding.
#
# ⚠️ 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.
#
# ⚠️ AND THE LATCH IS GRADED HERE RATHER THAN TRUSTED, ON EVERY START. A
# latch that silently failed to move into its namespace - or that was
# taken by an OLDER add-on while this install was rolled back, which is
# the round trip store.py's rule 2 exists for - is a DISARMED SAFETY
# GUARD on an install whose state file still says `sign_tripped: true`.
# So the flat value is compared against what the roster actually came up
# with, and a disagreement is not a log line: it re-arms the latch by
# hand and shouts, because the fail-safe direction for a latch is ON.
self._grade_store_migration(store)
for inv in self.inverters:
if inv.sign_wd.tripped:
_LOG.error("SAFETY-02 was TRIPPED on %s before this restart and "
"stays tripped: %s. That 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.", inv.name,
inv.sign_wd.reason or "(reason not recorded)")
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
# ⚠️ ONE PER SITE, BESIDE THE ONE PER INVERTER, AND ARMED ONLY AT N>=2.
# The per-unit watchdogs above ask "is THIS actuator doing what I told
# it", graded on that unit's own measured power. This one asks "is the
# SITE driving the wrong way", graded on the SUM of the standing
# commands against P1 - the comparison SAFETY-02 shipped, re-scoped to
# the quantity it was always actually about.
#
# ⚠️ AT A ROSTER OF ONE IT IS CONSTRUCTED AND NEVER EVALUATED, and that
# is the whole compatibility promise of this card rather than an
# oversight. With one inverter the sum of the commands IS that
# inverter's command, so arming this would be arming a duplicate of
# `inverters[0].sign_wd`: two latches for one fault, two banners, and a
# false-positive record measured on ONE comparison being spent twice.
# `cycle()` is where that is enforced and says so at the line.
#
# Restored from the store like SAFETY-02's and SAFETY-31's latches, for
# the reason store.py gives: "restart the add-on" is the first thing an
# operator looking at a FAILSAFE banner tries, and a latch held only in
# memory brings the controller back armed, into the fault, unattended.
# FLAT keys, not namespaced - store.py carries that argument too.
self.site_sign_wd = SignWatchdog(
self.site_sign_cfg,
tripped=bool(store.data.get("site_sign_tripped", False)),
reason=str(store.data.get("site_sign_reason", "") or ""))
if self.site_sign_wd.tripped:
_LOG.error(
"SAFETY-30 (site) was TRIPPED before this restart and stays "
"tripped: %s. Every inverter is held at 0 W. Restarting does "
"not clear this - start control from the UI once you know why "
"the site's total command opposed the meter.",
self.site_sign_wd.reason or "(reason not recorded)")
# And the per-unit detectors are told which question they are asking,
# here as well as in `cycle()`. `checks()` and the status page read
# `inv.sign_wd.cfg` and can be called before the first cycle has run, so
# a roster of two would otherwise advertise the site thresholds on a
# detector that is about to be graded against a unit's own power.
self._arm_sign_watchdogs()
# 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-31. ONE PER SITE, and restored from the store like SAFETY-02's
# latch rather than defaulted like SAFETY-18's - the asymmetry is the
# same one the freeze watchdog's own note draws, read the other way.
# This one ACTS: a trip files a P_SAFETY SET that stops every inverter,
# so a restart that cleared it would come back up straight into the loop
# it had just stopped, which is SAFETY-02's measured three-minutes-at-a-
# time failure with a different quantity in front of it.
#
# ⚠️ SITE-LEVEL KEYS, NOT NAMESPACED. store.py carries the argument:
# circulating power is defined over the roster and is identically zero
# for any one unit, so there is no inverter to hang the latch on.
self.circ_wd = CirculationWatchdog(
CirculationConfig(
min_w=float(opts.get("circulation_w",
CIRC_OPTION_DEFAULTS["circulation_w"])),
hold_s=float(opts.get("circulation_hold_s",
CIRC_OPTION_DEFAULTS["circulation_hold_s"]))),
tripped=bool(store.data.get("circ_tripped", False)),
reason=str(store.data.get("circ_reason", "") or ""))
# SAFETY-34. Has the durable alarm been put back in front of the
# operator since this process started? `persistent_notification` is in
# memory and does NOT survive the Home Assistant restart that the store
# does, so the latch alone is not the alarm - re-raising it is.
# `run_control()` does that, once, before the first cycle.
self._circ_announced = False
if self.circ_wd.tripped:
_LOG.error(
"SAFETY-31 was TRIPPED before this restart and stays tripped: "
"%s. Every inverter is held at 0 W. Restarting does not clear "
"this - start control from the UI once you know why two "
"inverters were pushing against each other.",
self.circ_wd.reason or "(reason not recorded)")
# 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)
# TARIFF-01. The capacity-tariff peak: measured from the meter, banked
# in SQLite, restored across a restart. It is an INSTRUMENT - it files no
# claim and clamps nothing. SAFETY-07 is what will read it.
#
# ⚠️ NO NEW OPTION. The soft limit is `peak_cap_w`, already shipped,
# already validated, already read by control.peak_at_risk(). CFG-03
# tracks three places option defaults live; a fourth, for a limit that
# already exists, is the mistake the clamp watchdog declined to make ~30
# lines up. config.yaml and validate.py are untouched by this ticket.
#
# ⚠️ AND NO SECOND PEAK ENTITY. `peak_forecast_entity` is the existing
# HA-side delegation and it stays the input to maintenance_charge_floor():
# repointing that consumer at this measurement CHANGES WHAT MOVES ENERGY
# during a maintenance charge, which belongs to SAFETY-07 with its own
# acceptance, not to a measurement ticket. What this adds is the MEASURED
# MONTH PEAK - a different quantity from a forecast entity, published
# nowhere as a duplicate of it.
self.peak_store = PeakStore(peak_db_path)
self.peak = PeakTracker(self.peak_store,
cap_w=float(opts.get("peak_cap_w", 3500)))
self.peak.restore(self.p1.averager)
_LOG.info("capacity-tariff peak store: %s", self.peak.restore_state)
# SAFETY-07. The guard TARIFF-01 was built for. It is stateless by
# design (peakguard.py holds no history between calls), so the only
# state here is what an EDGE needs: what the last cycle concluded, so
# the action log and the notification fire on transitions rather than
# once per cycle for a quarter of an hour.
#
# ⚠️ THE CEILING HAS NO CONFIG OF ITS OWN, for the reason the clamp
# watchdog gives ~90 lines up and TARIFF-01 repeats: it is `peak_cap_w`,
# already shipped and already validated, and every other term of the
# projection is read off something the arbiter or the averager already
# owns.
#
# ⚠️ SAFETY-24'S FLOOR DOES HAVE ONE, AND IT COULD NOT NOT HAVE. How far
# down the guard may spend the pack for money is a number only the site
# knows, and refusing one set below the pack's own cut-out means knowing
# that cut-out too. So this card adds exactly two keys and reads their
# defaults from `peakguard.PEAK_OPTION_DEFAULTS` - TARIFF-02's mirror
# pattern - rather than typing literals here. THAT is what kept this
# card from widening: the divergence validate.py's rule 6 used to
# document (`maintenance_charge_w` 2000 in config.yaml, 2500 in this
# file) is exactly what a literal here would have repeated. CFG-03 has
# since closed that one too - `maintenance.MAINT_OPTION_DEFAULTS`,
# read through `MaintConfig.from_options()` - so the rule now holds for
# every option family this file builds: no numeric fallback in main.py
# that config.yaml has not shipped.
self.peak_guard = None
self._peak_engaged = False
self._peak_saturated = False
self._peak_floor_limited = False
self._maint_shape_suspended = False
# [(pack name, why)] for packs that are due and waiting their turn.
# Empty at a roster of one, where nothing can defer anything.
self._maint_deferred = []
# 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
# --- ARB-03: which actuators this site has ---------------------------
# ⚠️ THE ROSTER IS `self.inverters` AND `units` IS ITS NAMES, DERIVED.
# Every claim below is filed on an actuator, and `arbiter.actuator_for()`
# needs to know how many there are: at N=1 §6.2's identity holds - the
# fabricated grid reading and the battery setpoint are the same number -
# so the grid connection and the only inverter ARE one actuator and every
# claim routes to the site. With a second entry the per-inverter claims
# stop being site claims on their own, which is the seam ALLOC-01
# occupies and this card fills.
#
# ⚠️ IT IS A PROPERTY, NOT A FIELD, AND THAT IS THE POINT OF THE CARD.
# `self.units = (...)` was a tuple somebody could set beside a roster of
# one Inverter object, i.e. a site that claims two actuators and has one
# writer. Deriving it means the roster and the actuator list cannot
# disagree - to add an inverter you add an Inverter.
# 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"
# ALLOC-01. The allocation record from the last resolution, or None
# before the first cycle. ⚠️ IT IS KEPT SO THAT SOMETHING CAN GATE ON
# IT: SAFETY-28 puts the write path behind this record, and DEV-02 reads
# `shares` off it to drive a second inverter. It is REPLACED every cycle
# and never merged - a record from a tick whose envelopes have moved is
# the cached envelope this card exists to forbid, wearing a different
# hat.
self.allocation = None
# SAFETY-28. THE TICK THE RECORD ABOVE BELONGS TO, and the counter that
# mints it. It is incremented ONCE PER RESOLUTION - not once per loop
# iteration - and that distinction is load-bearing in both directions:
#
# * a NEW resolution makes the previous record stale IMMEDIATELY, so a
# record cannot be replayed into a later tick after the envelopes it
# describes have moved. That is the replay half of the gate.
# * a cycle that produced NO resolution does not bump it. The "inputs
# missing, not yet stale" hold above returns before the allocator
# runs precisely so that a single missed poll does not zero the
# inverter, and the heartbeat re-affirms `self.target` through the
# write path while it does. Bumping here would make that heartbeat
# unauthorised and drop the site to 0 W on the first dropped poll -
# turning a hold into the outage it exists to prevent.
#
# So the record is SUPERSEDED, never expired: it stands until the
# allocator produces its successor, and nothing else has moved in
# between that it does not still describe.
self.alloc_tick = 0
# What the last cycle's allocator dropped, so the log line above is
# edge-triggered. Starts as the empty tuple - nothing has been dropped
# yet and nothing has been said about it.
self._dropped_last = ()
# ⚠️ THE SITE MEASUREMENT, AND THE ONLY ONE LEFT ON THIS OBJECT. `soc`
# and `batt` were per-device scalars and now live on each `Inverter`;
# `grid` is P1, one meter, one connection, and belongs here.
self.grid = None
self.peak_fc = None
self.cheap = False
# TARIFF-03. One line of prose for the status page saying WHICH tariff
# window we are in and why it is or is not cheap - so "Cheap window:
# no" is an evidenced answer rather than a bare no.
self.tariff_words = "no static profile"
# OPT-04. The tariff plan the BACKGROUND task last resolved, and when.
# cycle() reads these two attributes and never awaits a fetch - see
# TARIFF_REFRESH_S. `None` means no refresh has completed yet, which is
# not "no prices" and certainly not "free": arbitrage.decide() reports
# it as its own reason and does nothing.
self.tariff_plan = None
self.tariff_at = None
self.arb = None
self._arb_action = None
self.inputs_bad_since = None
# SAFETY-03. What the loop's OWN bounds did last cycle - a
# (engaged, note) pair so the alarm can say WHICH bound bit without
# re-deriving it from a string. ⚠️ THIS ONE STAYS SITE-LEVEL, and it is
# the counterpart to `clamp_last` moving onto the Inverter: the integrator
# that wound is the SITE integrator, of which there is exactly one, while
# the value a clamp bit is a value written to ONE device.
self.loop_clamped: tuple[bool, str] = (False, "")
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:]
def _grade_store_migration(self, store) -> None:
"""DEV-02. Did SAFETY-02's latch actually survive the key rename?
⚠️ THIS IS THE HALF THE CARD CALLS RISKY, AND IT IS GRADED RATHER THAN
PERFORMED. Namespacing `sign_tripped` is right - flat, a trip on B
latches A - but a latch that silently fails to move is a DISARMED SAFETY
GUARD ON AN INSTALL THAT BELIEVES IT IS PROTECTED: the state file still
reads `sign_tripped: true`, the banner is green, and the controller
resumes commanding into the fault that tripped it. That is strictly
worse than never having persisted the latch, because now there is
evidence that says it is fine.
So the pre-DEV-02 flat value is compared against what the roster
ACTUALLY came up with, and a disagreement is not reported and survived -
the latch is re-armed by hand, from the flat value, and the store is
written so the next start does not need this branch. The fail-safe
direction for a latch is ON: a spurious trip costs a 0 W hold an
operator clears with one button, and a missing one costs whatever
SAFETY-02 was watching for.
⚠️ AND IT RUNS ON EVERY START, NOT ONLY ON THE ONE THAT MIGRATES. It
used to return early on `not rep.ran`, for a real reason: while nothing
cleared the flat key, an install that had ever tripped carried
`sign_tripped: true` in its file forever, and a guard reading it would
have re-armed the failsafe on every restart for the rest of the
install's life.
THAT GATE SWALLOWED THE EXACT ROUND TRIP store.py's RULE 2 EXISTS FOR,
and it is not hypothetical - a firmware version change with a rollback
path was performed on this site on 2026-09-02:
1. the install migrates -> `schema_version = 1`,
`unit/inverter-a/sign_tripped = False`
2. the operator DOWNGRADES, and SAFETY-02 trips inside the downgrade
window. The old code knows only the flat key, so the file gains
`sign_tripped: true` and nothing namespaced
3. the operator upgrades again. `rep.ran` is False - the store is
already at schema 1 - so BOTH the migration and this guard are
skipped, `Inverter.__init__` reads the namespaced key, and the
controller comes up with `sign_wd.tripped = False` while
/data/state.json says `sign_tripped: true`
Which is this module's own sentence, rebuilt by the version marker: the
file still says tripped, the banner is green, and the controller resumes
driving the wrong way.
The gate is gone because the reason for it is gone. `handle_action()`
now clears the flat keys alongside the namespaced ones when the operator
clears the latch, and the trip mirrors them, so the two spellings can
disagree in exactly one direction: FLAT SAYS TRIPPED AND NO UNIT CAME UP
TRIPPED means the flat key was written by a version that did not know
about namespaces - a downgrade-window trip - or a migration that lost
the latch. Both want the same answer, which is the one below.
"""
rep = self.store_migration
if rep.ran and not rep.ok:
_LOG.error("SAFETY-02: the per-inverter store migration did not "
"verify (%s). The latch is being re-derived from the "
"legacy keys.", rep.explain())
data = getattr(store, "data", {})
legacy_tripped = bool(data.get("sign_tripped", False))
if not legacy_tripped or not self.inverters:
return
# ⚠️ THE QUESTION IS "IS ANY NAMESPACED LATCH SET", AND IT IS ASKED OF
# THE STORE RATHER THAN OF THE ROSTER. At a roster of two a trip on B
# leaves A untripped, so re-arming A off a flat key that B's own trip
# mirrored would latch a healthy inverter on every restart - the
# flat-key defect this card removed, coming back through its own guard.
# And the STORE is the right place to ask: it holds every
# `unit/*/sign_tripped` this install has ever written, including one
# belonging to a unit that is not in TODAY's roster (a roster is
# configuration and it changes), whereas the roster only knows what is
# configured right now. The flat key carries new information only when
# nothing namespaced is latched at all.
namespaced_latched = any(
bool(v) for k, v in data.items()
if k.startswith("unit/") and k.endswith("/sign_tripped"))
if namespaced_latched or any(inv.sign_wd.tripped
for inv in self.inverters):
return
first = self.inverters[0]
# The one state this method exists for.
reason = str(data.get("sign_reason", "") or "")
first.sign_wd.tripped = True
first.sign_wd.reason = reason or (
"SIGN_DISAGREEMENT: latched before this version; the reason was "
"not carried across the per-inverter store migration")
first.store_set("sign_reason", first.sign_wd.reason)
first.store_set("sign_tripped", True)
_LOG.error(
"SAFETY-02 LATCH GUARD: this store carries a LATCHED "
"sign-disagreement failsafe under the pre-DEV-02 flat key and NO "
"inverter came up latched. Either the per-inverter migration lost "
"it, or it was written by an older add-on while this install was "
"rolled back. Both are a disarmed safety guard on an install whose "
"state file says it is protected, so the latch has been re-armed "
"on %s from the legacy value: %s. %s is held at 0 W until control "
"is started from the UI.",
first.name, first.sign_wd.reason, first.name)
self.log_event("FAILSAFE re-armed from the pre-DEV-02 store keys - "
+ first.sign_wd.reason)
# -- the roster, and the N=1 views of it ---------------------------------
@property
def units(self) -> tuple:
"""The actuator names, DERIVED from the roster. Never assignable.
See the note in `__init__`: a settable `units` is a site that can claim
two actuators while holding one writer, which is a half-driven site with
nothing to say so.
"""
return tuple(inv.name for inv in self.inverters)
@property
def maint_index(self) -> int:
"""Which roster slot holds the site's ONE maintenance slot right now.
The pack that is mid-cycle if there is one, otherwise the first - which
at a roster of one is always 0, always was, and is the whole of this
property's N=1 behaviour.
"""
for i, m in enumerate(self.maints):
if m.phase in POWER_MOVING:
return i
return 0
@property
def maint(self):
"""The `Maintenance` the site's one maintenance slot belongs to.
⚠️ MAINT-05 TURNED THIS FROM AN ATTRIBUTE INTO A VIEW, AND THE THING IT
IS A VIEW OF CHANGED WITH IT. There are now `self.maints` - one FSM per
roster entry, each with its own schedule, phase and history - and every
SITE-level surface that used to read `self.maint` (the `phase` MQTT
value, the status banner, the operator's start/abort buttons) wants THE
ONE THAT IS RECALIBRATING, not "the first one". `maintenance.arbitrate()`
guarantees at most one pack is ever out of IDLE, so "the one that is
recalibrating" is well defined; when none is, this is `maints[0]` and
the surfaces read exactly what they read before.
⚠️ AT A ROSTER OF ONE IT IS `maints[0]` ON EVERY TICK, in both branches,
which is the object that used to be assigned here. Nothing downstream
can tell the difference - that is AC 6, and test_maintenance.py part 9
asserts it rather than trusting this paragraph.
"""
return self.maints[self.maint_index]
@property
def maint_unit(self):
"""The pack the site's one maintenance slot is currently on.
⚠️ THIS USED TO BE `self.inverters[0]`, FLATLY, AND THE COMMENT THAT
STOOD HERE EXPLAINED WHY: there was exactly one `Maintenance`, its
history lived under the store's un-namespaced `phase` / `last_completed`
keys, so the pack it drove could only be the first in the roster. That
sentence is what MAINT-05 removed. There is now one FSM and one
namespaced history PER ROSTER ENTRY, so the pack being recalibrated is
whichever one holds the site's maintenance slot, and `maint_index` -
not the roster's order - is what answers it.
⚠️ AND `validate.py` RULE 6 MOVED WITH IT, exactly as its own comment
said it must ("when `maint_unit` stops being `inverters[0]`, this line
moves with it"). It graded `maintenance_charge_w` against the FIRST
entry's cap on the argument that the cycle never touches the others;
that argument is now false, so it grades against the weakest cap on the
roster, like rule 5. See the ⚠️ block there.
"""
return self.inverters[self.maint_index]
def _maint_button(self, now):
"""The pack an operator's one maintenance button acts on.
The pack holding the site's maintenance slot when one holds it, so
"abort" stops the cycle that is running and "start" restarts it -
exactly what both buttons meant while there was one pack, and the reason
this is not simply "the longest-waiting one". When every pack is idle it
IS the longest-waiting one, which is `arbitrate()`'s own clause 2, so a
forced start and a scheduled start choose the same pack rather than two
different ones.
⚠️ AT A ROSTER OF ONE BOTH BRANCHES ARE `maints[0]`. There is no third
case: `max()` over a one-element list is that element.
"""
if self.maints[self.maint_index].phase in POWER_MOVING:
return self.maints[self.maint_index]
return max(enumerate(self.maints),
key=lambda p: (p[1].waited_days(now), -p[0]))[1]
def inverter(self, name: str):
"""The roster entry called `name`, or None."""
for inv in self.inverters:
if inv.name == name:
return inv
return None
def _can_write(self, name: str) -> bool:
"""Is there a roster entry called `name` WITH a setpoint entity?"""
inv = self.inverter(name)
return inv is not None and inv.has_writer()
def _only(self):
"""The sole inverter, or an error naming why the caller cannot have one.
⚠️ THIS IS WHAT MAKES THE N=1 VIEWS BELOW SAFE. §6.2's identity - one
inverter means the site and the unit are the same thing - is true at N=1
and false at N>=2, so a view built on it must not quietly answer for
"the first one" once the roster grows. Every one of them refuses instead,
loudly, naming the per-instance attribute to use.
"""
if len(self.inverters) != 1:
raise AttributeError(
f"this site has {len(self.inverters)} inverters, so there is no "
f"single per-device value to read off the Controller. §6.2's "
f"identity holds only at N=1. Read it off the roster entry: "
f"`for inv in controller.inverters: inv.<attr>`")
return self.inverters[0]
# ⚠️ PROPERTIES, NOT FIELDS, AND EVERY ONE OF THEM IS A COMPATIBILITY VIEW.
# These are the scalars this card removed. They survive as read/write views
# of the SOLE inverter so that an N=1 install, and every replay in this
# repository that constructs one, is byte-identical - which is AC 8. They
# are properties rather than attributes for two reasons that are the card:
#
# * an assignment cannot re-create the scalar. `self.soc = 4` now writes
# inverter A's own SoC; there is no second copy to drift.
# * they REFUSE at N>=2 rather than answering for the first unit, so a
# consumer that has not been made per-instance fails on the line that
# reads it instead of grading inverter B against inverter A's number.
# That is the whole defect, and this is the tripwire on it.
#
# ⚠️ NOTHING INSIDE THIS FILE MAY READ THEM. main.py's own paths iterate the
# roster; test_inverter.py's grader scans this module's AST and goes red on
# a `self.<view>` inside any method of `Controller`. A view that main.py used
# would be the scalar back with a longer name.
@property
def unit(self) -> str:
return self._only().name
@property
def soc(self):
return self._only().soc
@soc.setter
def soc(self, value):
self._only().soc = value
@property
def batt(self):
return self._only().batt
@batt.setter
def batt(self, value):
self._only().batt = value
@property
def dev_min(self):
return self._only().dev_min
@dev_min.setter
def dev_min(self, value):
self._only().dev_min = value
@property
def dev_max(self):
return self._only().dev_max
@dev_max.setter
def dev_max(self, value):
self._only().dev_max = value
@property
def last_write(self):
return self._only().last_write
@last_write.setter
def last_write(self, value):
self._only().last_write = value
@property
def last_write_at(self):
return self._only().last_write_at
@property
def last_write_ok(self):
return self._only().last_write_ok
@property
def clamp_last(self):
return self._only().clamp_last
@property
def sign_wd(self):
return self._only().sign_wd
# -- SAFETY-30: which comparison each sign detector is making -------------
def _arm_sign_watchdogs(self) -> None:
"""Tell every sign detector which of SAFETY-30's two questions it asks.
⚠️ A PURE FUNCTION OF `len(self.inverters)`, AND THE ONLY PLACE THE
ROSTER DECIDES ANYTHING ABOUT THIS CHECK. Written as one method rather
than as a conditional at the two call sites for `loop_is_following()`'s
reason: the arrangement that does not keep two things in step is the one
where they are written down twice.
AT ONE INVERTER - §6.2's identity, and the whole of AC 8. The single
unit's detector is armed against THE METER with the shipped
`sign_disagree_w` / `sign_disagree_s`, which is what it has always been
armed against, and the site detector is not armed at all. A one-inverter
install therefore makes the same comparison, at the same thresholds,
writes the same reason string, latches under the same keys and files the
same `failsafe` claim as it did before this card.
AT TWO OR MORE - the identity is gone and the meter stops being a view
of any one actuator. Each unit's detector is armed against ITS OWN
MEASURED POWER (`Inverter.batt`, register 0x0518) under the re-derived
per-unit pair, and the site detector is armed against the meter under
the site pair. Two questions, two remedies, two latches.
⚠️ CALLED AGAIN AT THE TOP OF EVERY `cycle()`, not only from
`__init__`. The roster is re-read every cycle for liveness and for the
allocation, and this repository's two-inverter fixtures append an
`Inverter` AFTER the Controller is constructed - a detector frozen at
construction would then be graded against the meter at a roster of two,
which is the defect this card exists to remove, wearing a fixture's
name. `arm()` touches no timer and no latch, so calling it every cycle
cannot disturb an episode in progress.
"""
one = len(self.inverters) <= 1
for inv in self.inverters:
inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg,
AGAINST_METER if one else AGAINST_OWN_POWER)
self.site_sign_wd.arm(self.site_sign_cfg, AGAINST_METER)
# -- the site aggregates, each one named for the question it answers ------
# ⚠️ THESE ARE NOT "THE SCALARS WITH A SUM IN FRONT". Each one picks a
# DIFFERENT reduction, because the questions are different, and getting the
# reduction wrong is how a pack gets driven below its own floor. The
# reduction is written at the definition, with its reason, rather than
# inline at four call sites.
@property
def site_max_w(self) -> float:
"""The supervised bound on the SITE target: what the roster can deliver.
SUM. Each unit's own `supervised` claim still binds its own share, so
this cannot over-command a device; what it must not do is bound the site
loop at ONE unit's rating, which would waste the second inverter
entirely. At N=1 it is `max_w`, exactly.
"""
return float(sum(inv.max_w for inv in self.inverters))
@property
def site_rating_w(self) -> float:
"""The largest magnitude the SITE can actually be commanded, in W.
SUM of each unit's own `min(max_w, |dev_min|, |dev_max|)`. This is the
peak guard's `achievable_w` and the freeze detector's rail, and summing
the PER-UNIT conjunction rather than conjoining the sums is what makes
inverter B's rating bind inverter B (AC 4). A unit whose device range has
not been read yet contributes its `max_w`, which is what the shipped
single-inverter path already did with (None, None).
"""
return float(sum(inv.rating_w() for inv in self.inverters))
def site_dev_bound(self, which: str):
"""The roster's summed device floor / ceiling, or None if unknown.
`freeze.looks_frozen()` and `rail_unreachable()` take a device range and
SKIP a bound they were handed None for - "ESPHome is not up yet", not
"the device can do nothing". So a bound is summed only when EVERY unit
has answered; one silent unit makes the site bound unknown rather than
making it smaller, which is the same direction the shipped code took.
"""
vals = [getattr(inv, which) for inv in self.inverters]
if not vals or any(v is None for v in vals):
return None
return float(sum(float(v) for v in vals))
@property
def site_batt_w(self):
"""SUM. The battery power the SITE loop is regulating against.
⚠️ THIS IS THE FINDING ALLOC-01'S REVIEW HANDED THIS CARD, AND IT GOES
THE OPPOSITE WAY TO EVERYTHING ELSE HERE. `compute()`'s anti-windup test
is `abs(prev_w - actual_w) > saturation_w`, where `prev_w` is the SITE
target. Fed ONE inverter's power at a roster of two it compares a site
number against a unit number, trips the saturation freeze permanently and
leaves a standing grid error - measured on admin/goodwe's two-master rig
as up to 510 W of split-dependence at gain 0.2 and 250 W at gain 0.4,
with the allocator's shares summing to S in every one of those runs. The
counterfactual is the proof: with this sum every split is bit-identical
at every gain. See test_alloc.py part 8.
"""
vals = [inv.batt for inv in self.inverters]
if not vals or any(v is None for v in vals):
return None
return float(sum(float(v) for v in vals))
@property
def site_circulating_w(self):
"""SAFETY-31. `sum(|B_i|) - |sum(B_i)|` over MEASURED per-unit power.
⚠️ `inv.batt`, NOT `self.allocation`. THIS IS THE WHOLE CARD, and the
wrong version is the tempting one: the same formula over the allocation
record would be simpler, would never be None, and would be worth
nothing. ALLOC-01's sign-coherence gate (`allocator.REFUSE_SIGN`) already
refuses an allocation whose shares carry two signs, so a record-derived
sensor reads zero BY CONSTRUCTION, forever, and can only ever confirm
that the allocator agrees with itself. Every fault this card is for is a
divergence between what was commanded and what the hardware is doing - a
unit ignoring or lagging its share, a stale share still being executed,
or HW-01's power stage running its last instruction for 6 h 25 m with
the comms dead and the allocation record impeccable throughout.
Measured on the TEST-19 rig: with one unit wedged, the record stays
`ok` with both shares at -1250 W - a record-derived sensor reads 0 W -
while this one reads 2000 W.
`batt` became per-instance in DEV-02; before that there was one scalar
on the Controller and this property could not have been written.
None when any unit has not answered, and NOT zero - circulation.py says
why at length. The short version: zero would be this sensor reporting
health from an absence, which is the 2026-09-02 failure itself.
Identically 0.0 at the shipped roster of one (AC 5).
"""
return circulating_w([inv.batt for inv in self.inverters])
@property
def pack_floor_soc(self):
"""MINIMUM. The SoC that a discharge PERMISSION must be graded against.
SAFETY-24's floor asks "may this site spend the pack for money", and the
honest answer at N>=2 is the EMPTIEST pack: an average lets it be driven
below its own floor, which is the failure the option exists to prevent.
None if any unit did not answer - `peakguard.decide()` treats None as
floor-limited, which costs a shave rather than a pack.
⚠️ AND IT IS NOT THE RIGHT REDUCTION FOR A CHARGE CEILING, which is the
FULLEST pack. Nothing in the add-on files a per-pack charge ceiling yet
(arbitrage's `soc_ceiling` is the only consumer and it takes one number);
that is CFG-04/SPIKE-04's per-pack bound, and it is named here so the
next reader knows this reduction was chosen rather than assumed.
"""
vals = [inv.soc for inv in self.inverters]
if not vals or any(v is None for v in vals):
return None
return float(min(float(v) for v in vals))
@property
def packs(self) -> list:
"""SAFETY-32. Each pack as its own THREE numbers, in roster order.
`pack_floor_soc` above is the site's ONE verdict and stays exactly what
DEV-02 made it - the emptiest measured pack. This is the thing that
reduction cannot produce: by the time `min()` has answered, WHICH pack
the number came from and how far that pack was from ITS OWN cut-out have
both been thrown away, and two packs at 15 % against cut-outs of 10 % and
20 % have opposite answers that are indistinguishable afterwards.
⚠️ EVERY unit, live or not. Same reason as `inputs_ok`: a pack whose SoC
did not answer is a pack nothing can grade, and dropping it from this
list would let the site spend the packs that DID answer while one sat
unmeasured. `peakguard.discharge_permitted()` treats a None SoC as no
permission, which is where that must be decided.
"""
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
for inv in self.inverters]
@property
def inputs_ok(self) -> bool:
"""The site meter, and EVERY pack's own two readings.
⚠️ EVERY pack, not the live ones: a unit whose SoC did not answer is a
unit nothing can grade the maintenance FSM or the pack floor against, and
§11.1 rule 1 for a missing input is command zero and keep commanding.
Dropping it from the SPLIT instead would hand its share to a sibling
while the site went on regulating - see `Inverter.is_live()` on why
liveness is about the WRITE path and this is not.
"""
if self.grid is None or not self.inverters:
return False
return all(inv.soc is not None and inv.batt is not None
for inv in self.inverters)
# -- 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")))
# --- DEV-02: EACH INVERTER'S OWN TWO READINGS, FROM ITS OWN ENTITIES --
# ⚠️ CONCURRENTLY ACROSS UNITS, SEQUENTIALLY WITHIN ONE. Read serially,
# a unit whose Home Assistant entity is slow delays every unit after it
# on the path that feeds the ESP32 heartbeat - the same defect AC 6
# names for the WRITE path, one function up. Within a unit the order is
# unchanged (SoC, then battery power), so at the shipped roster of one
# this is the same two calls in the same order.
await asyncio.gather(*(self._read_unit(inv) for inv in self.inverters))
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)
self.tariff_words = "Home Assistant price entities"
else:
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
# The add-on has exactly one shiftable load in 0.5.x - the
# maintenance cycle's cheap-window grid top-up, ~15 lines below in
# cycle() - so this is where the window reaches something that moves
# energy. With `static_profile` empty this returns False and the
# behaviour is bit-for-bit what it has always been: "fixed-tariff
# site, never force a paid grid top-up, wait for sun".
#
# ⚠️ SYNCHRONOUS AND I/O-FREE, ON PURPOSE. The provider-backed
# resolver (static_tariff.resolve_tariff) can perform an HTTP fetch,
# and this coroutine is on the path that feeds the ESP32 heartbeat -
# a supplier's TLS timeout here would be a supplier able to push the
# add-on towards layer 1's 30 s veto. Generation from a static
# profile is pure arithmetic, so §2.3's "regenerate on the tick"
# applies and there is nothing to block on. Putting a fetched series
# on this path needs a BACKGROUND refresh and belongs with OPT-04.
#
# ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile
# alongside the two Home Assistant entities, because this `if` picks
# one and the loser is silent.
self.cheap, self.tariff_words = static_status(
o, datetime.now(timezone.utc))
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 _read_unit(self, inv) -> None:
"""One inverter's own SoC and battery power, from ITS OWN entities."""
inv.soc = await self.hass.number(inv.soc_entity)
inv.batt = await self.hass.number(inv.batt_entity, inv.batt_invert)
def write_refusal(self, value: float, inv=None) -> str | None:
"""Why `value` may NOT be written to `inv` right now, or None if it may.
--- SAFETY-28: THE WRITE PATH IS GATED ON THE ALLOCATION RECORD --------
specs §1.5 makes "any configuration in which two writers can reach one
actuator" a non-goal. Until this method that non-goal was enforced by
`len(validate.ACTUATOR_WRITE_KEYS) == 1` - there is one option key, so a
second writer is not expressible. True, and a statement about the SHAPE
OF THE OPTIONS SCHEMA rather than about the writes. Add CFG-04's
per-inverter config and there are two real writers with that length
still 1: the check stays green while the property it protects is gone.
That is this repository's named failure mode - a check that observes
something other than what it claims to observe - so the enforcement
moved here, onto the writes themselves.
The rule is: A NUMBER REACHES THE WIRE ONLY IF THE SINGLE ALLOCATOR
PLACED IT, ON THIS TICK, FOR THIS UNIT. A second writer with arithmetic
of its own cannot satisfy that - its number is not in any record - so it
has to file a Claim and go through `resolve()`, which is the one
allocator. "Exactly one allocator per shared resource" stops being a
convention somebody can forget and becomes a thing the write path
checks. `Allocation.authorises()` holds the per-record half of the
argument.
⚠️ 0 W IS ALWAYS AUTHORISED, AND THAT IS THE ANSWER TO "WHAT IF THE
ALLOCATOR IS THE THING THAT FAILED". This is not a convenience exit; it
is the one value whose correctness does not depend on a split existing:
* The hazard the non-goal names is N-times loop gain from BROADCAST -
handing every inverter the same target so each supplies it in full
(see allocator.py's opening argument, and TEST-19's measured
`N=2 gain 1.0` limit cycle). At S = 0 the broadcast and the correct
allocation COINCIDE: a_i = 1 for every unit gives every unit 0 W,
and 0 W is exactly its share. There is no over-command to make.
* It is the fail-toward-inaction direction §11.1 rule 1 already
names, and it is what layer 2 commands. `cycle()`'s two refusal
branches replace the resolution with a 0 W failsafe and then write
it through here like any other cycle; `shutdown()` and the
operator's stop button do the same. Every one of those runs while
the allocator has REFUSED - `Allocation.ok` is False and there are
no shares - so a gate that asked the allocator's permission to
command zero would deadlock the failsafe behind the very component
whose failure the failsafe exists to answer.
A gate that can block the safety write is worse than the hole it closes,
so it cannot block it: the test for zero is first and reads nothing.
⚠️ AND IT IS `value == 0.0`, THE REQUESTED NUMBER - not a `failsafe=True`
argument. A flag is an assertion by the caller, which is precisely what
a second writer would pass; the value is a fact about what is going to
be written. -0.0 == 0.0 in Python, and a nan never reaches here (the
caller has already turned it into 0.0), so the predicate is total.
⚠️ DEV-02: THE UNIT IS AN ARGUMENT, NOT THE CONTROLLER'S N=1 VIEW OF
IT. The record holds one share PER UNIT, so "did the allocator place
this number" is a question about a NAMED actuator. Asking it with the
first inverter's name at a roster of two would authorise A's share on
B's wire and refuse B's own - the site/unit confusion this card
removes, wearing the gate's name. `_write_unit()` always passes the
unit it is about; the default is for the N=1 callers and REFUSES at
N>=2 (see `_only()`) rather than answering for the first inverter.
"""
if value == 0.0:
return None
inv = self._only() if inv is None else inv
rec = self.allocation
if rec is None:
return ("no allocation record exists yet - nothing has authorised "
f"{value:+.0f} W on {inv.name}")
return rec.authorises(inv.name, value, self.alloc_tick)
async def write_setpoint(self, value: float, *, force: bool = False,
unit=None) -> None:
"""Put `value` on ONE inverter, or - with `unit=None` - on all of them.
⚠️ `unit=None` IS FOR A NUMBER THAT IS NOT A SHARE. The stop button,
`shutdown()` and `maint_abort()` all command 0 W to the whole site, and
0 W is the one value that means the same thing to every inverter. A
NON-ZERO value broadcast to a roster of two IS the ARCH-01 fault - each
unit supplies it in full and the loop gain at the meter becomes N times
the per-unit gain - so it is refused below rather than trusted to the
caller. The cycle writes SHARES, one unit at a time, through the
allocator.
⚠️ AND THE WRITES ARE CONCURRENT, EACH UNDER ITS OWN BUDGET. See
WRITE_BUDGET_S: one hung `hass.call()` on a serialised path used to eat
a sibling's layer-1 refresh window.
"""
if unit is None:
targets = [i for i in self.inverters]
if len(targets) > 1 and is_commandable(value) and float(value) != 0.0:
# Loud, and it commands zero rather than the value asked for -
# fail toward inaction, exactly as every other refusal here.
self.log_event(
f"refusing to broadcast {float(value):+.0f} W to "
f"{len(targets)} inverters - that is the ARCH-01 fault; "
f"commanding 0 W")
_LOG.error("write_setpoint() was asked to put %+.0f W on every "
"one of %d inverters. A site target handed to each "
"unit is supplied by EACH of them: the gain at the "
"meter becomes N times the per-unit gain. Only 0 W "
"may be written site-wide; shares come from the "
"allocator. Commanding 0 W.", float(value),
len(targets))
value = 0.0
else:
targets = [unit]
if not targets:
return
await asyncio.gather(*(self._write_unit(inv, value, force=force)
for inv in targets))
async def _write_unit(self, inv, value: float, *, force: bool = False) -> None:
entity = inv.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
# --- SAFETY-28: AND IT IS REFUSED HERE, NOT LOGGED SOMEWHERE ----------
# ⚠️ ABOVE THE CLAMP AND BELOW THE NON-FINITE COERCION, and both halves
# of that placement are decisions.
#
# Below the coercion, because a nan is loss of input rather than an
# unauthorised write: rule 1 has already turned it into 0 W, which this
# gate then authorises unconditionally, so a transport bug does not get
# a second, differently-worded refusal.
#
# Above the clamp, because the question is WHO CHOSE THIS NUMBER and the
# answer is only meaningful about the number that was asked for. The
# clamp then narrows what was authorised, always toward safety, and it
# stays the last thing between a bug and the hardware. Gating the
# CLAMPED value would be worse than useless: on every cycle the clamp
# engages, the emitted watts differ from the allocator's share by
# construction, so the gate would refuse exactly the cycles SAFETY-03
# exists for.
#
# The refusal is a SUBSTITUTION, not a return: 0 W goes on the wire in
# place of the number nothing stands behind. Returning instead would
# leave the inverter latched on its last command with the ESP32's
# heartbeat still being fed - holding an unauthorised setpoint quietly,
# which is the outcome this gate exists to prevent.
#
# ⚠️ AND IT IS ASKED ABOUT `inv`, NOT ABOUT THE SITE. The record holds
# one share per unit, so the question "did the single allocator place
# this number" is only answerable about a named actuator; DEV-02 made
# this method per-unit, and passing the first inverter here would
# authorise A's share on B's wire.
refusal = self.write_refusal(value, inv)
if refusal is not None:
self.log_event(
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W")
_LOG.error("SAFETY-28 REFUSED a write of %+.0f W to %s: %s. "
"Commanding 0 W instead. Every setpoint must be one the "
"allocator placed on THIS tick, for THIS unit - see "
"Controller.write_refusal().", value, inv.name, refusal)
value = 0.0
# ⚠️ THIS UNIT'S OWN THREE NUMBERS. `inv.max_w` is the supervised cap on
# what may be written to THIS setpoint entity and `inv.dev_min/dev_max`
# are what THIS device reported about itself - which is the whole of "the
# clamp for B uses B's numbers". Read off `self.o` these were one
# inverter's rating applied to every inverter.
result = clamp_to_rating(value, inv.max_w, inv.dev_min, inv.dev_max)
inv.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(f"{inv.name}: {result.describe()}")
value = result.applied_w
changed = (inv.last_write is None or value != inv.last_write)
if not (changed or force):
return
# ⚠️ BOUNDED, AND THE BUDGET IS AC 6. Without it one inverter's hung
# transport holds this coroutine - and therefore the whole control loop,
# and therefore every OTHER inverter's heartbeat - for as long as the
# HTTP stack allows. A write abandoned here is re-issued on the next
# tick by itself: `last_write` is updated only on an ACCEPTED write, so
# `changed` is still true.
try:
ok = await asyncio.wait_for(self.hass.set_number(entity, value),
timeout=WRITE_BUDGET_S)
except (asyncio.TimeoutError, TimeoutError):
ok = False
_LOG.error("the write of %+.0f W to %s (%s) did not complete in "
"%.0f s and was abandoned. It will be re-issued on the "
"next tick; no other inverter waited for it.",
value, inv.name, entity, WRITE_BUDGET_S)
self.log_event(f"{inv.name}: write timed out after "
f"{WRITE_BUDGET_S:.0f} s - re-issuing next tick")
now = datetime.now(timezone.utc)
inv.note_write(bool(ok), now)
if ok:
inv.last_write = value
inv.last_write_at = now
# -- the cycle -----------------------------------------------------------
def actuator_of(self, source: str, inv=None) -> str:
"""Which actuator a claim from `source` is filed on this cycle.
`inv` is the roster entry the claim is ABOUT, for a per-inverter source.
Left out it is the first inverter, which is what every per-inverter claim
meant while there was only one. ⚠️ A per-inverter claim built inside the
per-unit loop MUST pass it, or inverter B's rating is filed on inverter A
and the arbiter bounds the wrong share - the whole defect, wearing this
method's name.
⚠️ THE CLASSIFICATION IS NOT HERE. `arbiter.CLAIM_SCOPE` decides whether
a source is about the grid connection or about one pack, and it carries
the reasoning for all eight; this method only supplies the roster, which
is the one thing the arbiter cannot know. Routing a claim by hand -
writing `actuator=` at a construction site - bypasses the KeyError that
stops a ninth, unclassified source being filed, so do not.
"""
return actuator_for(source, (inv or self.inverters[0]).name, self.units)
def live_units(self) -> tuple:
"""Which inverters may be commanded THIS TICK, for the allocator.
⚠️ THE ALLOCATOR DROPS WHAT THIS DOES NOT LIST, and the survivors then
carry the whole site target. That is a real change in what the site can
deliver, so it is reported rather than absorbed - `Allocation.dropped`
carries it and the cycle logs the transition below. A wrong answer here
is a site target divided among fewer inverters than exist, which is why
it is a method with a reason and not a field somebody can set.
⚠️ AND IT IS ANSWERED PER TICK, NEVER CACHED. A master goes stale
between one cycle and the next; an envelope read once at construction
over-allocates into a unit that cannot take it.
⚠️ AND IT ANSWERS ONE QUESTION: CAN THIS CONTROLLER PUT A NUMBER ON
THIS INVERTER RIGHT NOW. `Inverter.is_live()` carries the two ways it
cannot - no `setpoint_entity`, so there is no writer at all; or every
write REJECTED for a whole `LINK_STALE_S` window, which is layer 1's own
veto period, i.e. the point at which the firmware is about to take the
inverter away from this controller anyway.
⚠️ IT IS DELIBERATELY NOT "ITS SENSORS ARE MISSING". A pack whose SoC did
not answer is a different fault with a different remedy - `inputs_ok`
already stops the whole SITE at P_SAFETY for that - and dropping the unit
from the SPLIT instead would hand its share to a sibling while the site
went on regulating, which is the opposite of what a missing measurement
should cause.
⚠️ AT A ROSTER OF ONE, ANSWERING FALSE IS A 0 W SITE. The allocator has
nobody left in the envelope set, refuses (REFUSE_EMPTY), and main.py
substitutes the failsafe. That is the honest answer and not a
regression: the only way to reach it is a setpoint entity that has been
REJECTING every write for 30 s, on which the previous code went on
computing setpoints for an inverter it could not reach while layer 1 was
already winding it down. It says so in the action log instead.
"""
now = datetime.now(timezone.utc)
live = []
for inv in self.inverters:
ok, why = inv.is_live(now)
if ok:
live.append(inv.name)
else:
# Level-logged at DEBUG only: `Allocation.dropped` is the
# operator-facing surface and cycle() edge-logs it. A line here
# every second would flush the 40-row action log of the context
# that explains it.
_LOG.debug("%s is not commandable this tick: %s", inv.name, why)
return tuple(live)
def liveness_notes(self) -> list:
"""Why each unit is or is not commandable, for the status page."""
now = datetime.now(timezone.utc)
return [n for n in (inv.link_note(now) for inv in self.inverters) if n]
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)
# ⚠️ EACH PACK ON ITS OWN READING, NOT "the SoC". §12's recalibration
# drains and refills ONE pack and grades ONE coulomb counter, so each
# state machine is ticked with the SoC of the inverter it belongs to. A
# minimum across packs would abort a cycle on a sibling's state, and an
# average would declare a pack full that is not. The pairing is
# positional and both lists are built from the roster in `__init__`, so
# a pack cannot be graded on its neighbour's meter.
#
# ⚠️ AND `arbitrate()` RUNS FIRST, ONCE, OVER ALL OF THEM. It is the
# site's one maintenance slot - see maintenance.py - and a pack that
# does not hold it is told so by name rather than left to discover it.
# Calling `tick()` without the answer this produced is the
# two-packs-draining-at-once defect with the interlock present but not
# wired, which is why the plan is built here and not inside the loop.
maint_plan = maint_arbitrate(self.maints, now)
maint_results = []
for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan):
res = machine.tick(now, inv.soc, blocked_by=blocked)
for msg in res.events:
self.log_event(msg)
maint_results.append(res)
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
# deferral is edge-logged (it lasts as long as the other pack's cycle),
# so by the time an operator asks "why has pack B not recalibrated" the
# line that said so has scrolled off. §12's whole failure mode is a
# schedule that silently stops firing; a pack that is due, idle and
# WAITING has to be able to say so at the moment it is looked at.
self._maint_deferred = [(m.name, r.deferred)
for m, r in zip(self.maints, maint_results)
if r.deferred]
# --- 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).
# ⚠️ ONE WATCHDOG PER INVERTER, EVALUATED ON THAT INVERTER'S OWN
# STANDING COMMAND. What the watchdog concludes is "THE UNIT I AM
# DRIVING is pushing power the wrong way", and that evidence cannot be
# pinned on a sibling this controller commanded differently.
# `inv.target` is that unit's share from the last resolution, which at
# N=1 is the site target itself.
#
# ⚠️ SAFETY-30: AND WHAT IT IS COMPARED AGAINST IS DECIDED BY THE ROSTER.
# This block used to hand every unit's detector `self.grid`. There is
# ONE P1, so at N>=2 that compares a number this controller put on ONE
# actuator against a measurement that answers for ALL of them, and it is
# wrong in both directions:
#
# FALSE TRIP inverter A commanded -1500 W while the meter reads
# +1500 W is evidence of nothing when inverter B is
# discharging 3000 W exactly as instructed. Nothing is
# faulty and the highest-value check in the safety table
# fires - on a record of zero false positives.
# MISSED the converse, and it is worse. A unit commanded +1500 W
# and MEASURED at -1500 W, beside a sibling at +1500 W,
# leaves P1 exactly where a healthy site leaves it. The
# sum destroys the evidence; no threshold recovers it.
#
# So at N>=2 each unit is graded against ITS OWN measured power -
# `inv.batt`, register 0x0518, per-instance since DEV-02 - which is the
# only instrument that can answer "is THIS actuator doing what I told
# it". At N=1 §6.2's identity holds, the meter IS the view of that one
# unit, and the comparison stays the meter, byte for byte. The site's
# own question has not gone away; it is asked below, once, of the sum.
# `_arm_sign_watchdogs()` states the choice and its docstring argues it.
self._arm_sign_watchdogs()
n_units = len(self.inverters)
standing = {}
for inv in self.inverters:
was_tripped = inv.sign_wd.tripped
stood = inv.last_write if inv.last_write is not None else inv.target
standing[inv.name] = stood
against = self.grid if n_units <= 1 else inv.batt
if inv.sign_wd.evaluate(stood, against, 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.
#
# ⚠️ AND BOTH KEYS ARE NAMESPACED TO THIS UNIT. Flat, a trip
# caused by B latched A across every restart until somebody
# pressed start, and one press cleared both. See store.py.
inv.store_set("sign_reason", inv.sign_wd.reason)
inv.store_set("sign_tripped", True)
# ⚠️ AND THE LEGACY FLAT KEYS ARE MIRRORED, WHICH IS THE OTHER
# HALF OF store.py's RULE 2. That rule keeps the flat keys "so a
# rollback still finds its latch", and it only ever covered the
# latch that existed AT MIGRATION TIME: a trip that happens
# AFTER the migration wrote nothing flat, so an operator who
# downgraded would find `sign_tripped: false` and come up
# DRIVING, into the fault. store.py used to claim the staleness
# could only fail toward a spurious latch; it could not, and
# this is what makes that sentence true. The pair is
# site-level on purpose - the version being rolled back TO
# drives exactly one actuator - and it is cleared by the same
# operator press that clears the namespaced ones.
self.store.set("sign_reason", inv.sign_wd.reason)
self.store.set("sign_tripped", True)
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason)
_LOG.error("SAFETY-02 TRIPPED on %s - %s. Commanding 0 W on "
"that inverter and holding it until control is "
"started again. This survives an add-on restart; "
"restarting will not clear it.",
inv.name, inv.sign_wd.reason)
# ⚠️ THE SITE'S OWN STANDING COMMAND, WHICH IS THE SUM OF THE UNITS'.
# SAFETY-07 and SAFETY-18 both grade a SITE quantity - the quarter-hour
# average at the grid connection, and a meter that has stopped moving
# while the site pushes near its rail - so what they need is the total
# this controller currently has standing on the wire, not one unit's
# share of it. At the shipped roster of one the two are the same number.
site_standing = float(sum(standing.values()))
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
# The comparison SAFETY-02 shipped, asked of the quantity it was always
# about: the TOTAL this controller has standing on the wire, against the
# meter that total is answerable for. What it catches is a fault no
# per-unit check can see, because every unit is faithfully executing its
# share of a site target that is itself pointing the wrong way - an
# inverted gain, an integrator wound past the rail, a strategy on the
# wrong side of the meter. That is the 2026-08-20 fault day, and it is
# the reason the site question survives rather than being replaced.
#
# ⚠️ ONLY AT N>=2, AND THIS BRANCH IS AC 8. At one inverter the sum of
# the commands IS that inverter's command and the loop above has already
# made exactly this comparison, at exactly these thresholds, on the
# detector whose latch, store keys, claim and banner an install has had
# since SAFETY-02 shipped. Arming this as well would be two latches for
# one fault, two banners, and the same false-positive record spent
# twice. A shipped install does not reach this block at all.
if n_units > 1:
was_site = self.site_sign_wd.tripped
if self.site_sign_wd.evaluate(site_standing, self.grid,
self.following, now) and not was_site:
# REASON FIRST, FLAG SECOND - the same atomicity ordering the
# per-unit trip uses above and SAFETY-31 uses below: a crash
# between the two must not persist a latch with no explanation
# for the operator who has to decide about clearing it.
#
# ⚠️ AND THE KEYS ARE FLAT, NOT NAMESPACED, AND NOT THE
# PRE-DEV-02 PAIR EITHER. `sign_tripped` / `sign_reason` are the
# ROLLBACK VIEW of a UNIT latch (store.py rule 2) and mirroring
# a site trip onto them would hand a downgraded add-on - which
# drives exactly one actuator - a latch taken on evidence about
# a roster it cannot have. `site_sign_*` is its own pair, for
# the same reason `circ_*` is.
self.store.set("site_sign_reason", self.site_sign_wd.reason)
self.store.set("site_sign_tripped", True)
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason)
_LOG.error("SAFETY-30 TRIPPED at the SITE - %s. That is the "
"SUM of every standing command opposing the meter, "
"which no single inverter is answerable for. "
"Commanding 0 W on every inverter and holding it "
"until control is started again. This survives an "
"add-on restart; restarting will not clear it.",
self.site_sign_wd.reason)
# --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON -----
# Evaluated EVERY cycle, unconditionally, above the "inputs missing but
# not yet stale" hold below. A run of held cycles would otherwise leave
# `over_since` untouched across the gap and the window would resume from
# a start time that predates it, which is the ordering bug the sign
# watchdog's own note describes.
#
# ⚠️ THE INPUT IS MEASURED POWER, WHICH IS THE ENTIRE POINT OF THE CARD.
# `site_circulating_w` reads each `Inverter.batt` - that unit's own
# meter, holding register 0x0518. It is NOT `self.allocation`, and the
# property's docstring carries the argument: ALLOC-01's sign-coherence
# gate already makes the allocation record incapable of expressing
# circulating power, so a record-derived sensor is zero by construction
# and detects nothing. What this catches is circulation the allocator
# NEVER COMMANDED - a unit ignoring or lagging its share, a stale share
# still being executed, or HW-01's wedged power stage.
circ_w = self.site_circulating_w
was_circ = self.circ_wd.tripped
if self.circ_wd.evaluate(circ_w, now) and not was_circ:
# REASON FIRST, FLAG SECOND, and each `set` writes the file - the
# same atomicity ordering SAFETY-02 uses two blocks up, and for the
# same reason: a crash between the two must not persist a latch with
# no explanation for the operator who has to decide about it.
self.store.set("circ_reason", self.circ_wd.reason)
self.store.set("circ_tripped", True)
self._circ_announced = False
self.log_event("CIRCULATING POWER - " + self.circ_wd.reason)
_LOG.error(
"SAFETY-31 TRIPPED - %s. Commanding 0 W on every inverter and "
"holding it until control is started again. This survives an "
"add-on restart AND a Home Assistant restart; restarting will "
"not clear it.", self.circ_wd.reason)
stale_after = float(self.o.get("stale_input_s", 15))
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
# does not become a fault; a LATCHED inverter must still reach the claim
# list below, because its `failsafe` SET is what parks it. One tripped
# unit therefore takes the whole roster past the hold, which is the
# conservative direction: every other unit's claims are re-filed too.
# ⚠️ AND SAFETY-30's SITE LATCH TAKES THE ROSTER PAST THE HOLD TOO, for
# the same reason: its `sign_site` SET is what parks every inverter, and
# a latched site that returned early here would leave each unit's last
# share standing for the heartbeat to re-emit - i.e. the trip would stop
# acting the moment a poll was missed.
if not self.inputs_ok and not any(i.sign_wd.tripped
for i in self.inverters) \
and not (n_units > 1 and self.site_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.
# ⚠️ PER-INVERTER, ONE PAIR PER UNIT, FROM THAT UNIT'S OWN NUMBERS.
# This loop is the difference DEV-02 makes to the claim list: the
# numbers are ONE inverter's own reported min/max and ONE inverter's own
# supervised cap, and there is no such thing as a site rating. Before
# this card there was one pair, read from one setpoint entity, filed
# once - so a second inverter's rating was never read and never bound.
# See arbiter.CLAIM_SCOPE.
for inv in self.inverters:
if inv.dev_min is not None and inv.dev_max is not None:
claims.append(Claim.limit("device", P_SAFETY, inv.dev_min, inv.dev_max, "rating",
actuator=self.actuator_of("device", inv)))
# `max_w` is the supervised cap on what may be WRITTEN to one
# setpoint entity (§11.3), not a bound on the grid connection.
claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w",
actuator=self.actuator_of("supervised", inv)))
# The SITE bound, for the site-level consumers below. It is the roster
# sum: what this site can deliver, not what one of its inverters can.
max_w = self.site_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.
#
# ⚠️ AND IT IS PER-INVERTER, THOUGH ITS EVIDENCE IS SITE-WIDE. The
# watchdog compares P1 - one meter, one site - against `standing`, THE
# COMMAND THIS CONTROLLER PUT ON THIS ACTUATOR, and what it concludes is
# "the unit I am driving is pushing power the wrong way". At N>=2 that
# evidence cannot be pinned on a sibling this controller never commanded,
# so a site-wide stop would park an inverter this watchdog never graded.
# See arbiter.CLAIM_SCOPE.
for inv in self.inverters:
if inv.sign_wd.tripped:
claims.append(Claim.set("failsafe", P_SAFETY, 0.0,
"sign disagreement",
actuator=self.actuator_of("failsafe", inv)))
# --- SAFETY-30: THE SITE SIGN TRIP, AND IT IS A SITE SET ------------
# ⚠️ THE TWIN OF THE LOOP ABOVE, NOT A DUPLICATE OF IT. That loop files
# one `failsafe` per unit on the evidence of THAT unit's own measured
# power, and parks that unit. This files ONE claim on the SITE, because
# what tripped is the SUM of every standing command against the meter -
# a fact about the grid connection, whose offending number is the site
# target and which no single inverter owns. Parking one unit on it would
# leave the rest of the roster pushing the same way with the guard
# satisfied. See arbiter.CLAIM_SCOPE, which classifies `sign_site` and
# `failsafe` side by side and says why they differ.
#
# ⚠️ AND THE `n_units > 1` GUARD IS NOT REDUNDANT, EVEN THOUGH `cycle()`
# never arms this detector at a roster of one. The latch is RESTORED
# FROM THE STORE, so an install that ran with two inverters, tripped,
# and was then reconfigured back to one would come up with
# `site_sign_tripped: true` and would otherwise file a claim on evidence
# about a roster it no longer has. Guarded, that install comes up
# driving - which is right: the fault it latched on is not a statement
# about the site it is now.
if n_units > 1 and self.site_sign_wd.tripped:
claims.append(Claim.set("sign_site", P_SAFETY, 0.0,
"site sign disagreement",
actuator=self.actuator_of("sign_site")))
# --- SAFETY-31: WHAT "ACTED ON" MEANS, AND WHAT IT HONESTLY CANNOT --
# ⚠️ A SITE SET AT P_SAFETY: STOP COMMANDING, EVERYWHERE. Read the next
# paragraph before changing it, because the obvious objection is right
# and is answered rather than ignored.
#
# THE OBJECTION. HW-01 established that commanding a wedged inverter
# achieves nothing - on 2026-09-02 the comms subsystem failed and the
# power stage went on executing its last instruction for 6 h 25 m,
# through every command this add-on sent it. If the cause of the
# circulation is a unit that has stopped listening, then a command is
# not a remedy and pretending otherwise is theatre.
#
# THE ANSWER, AND IT IS WHY THIS IS STILL AN ACTION RATHER THAN A
# NOTIFICATION. A circulating loop needs TWO participants. In every
# cause on this card's list, exactly one of them is a unit that has
# stopped listening and THE OTHER ONE IS THIS CONTROLLER - faithfully
# commanding the sibling to a share computed to sum with a number the
# wedged unit is not actually producing. Stopping the commanding half
# does two things a notification cannot:
#
# 1. IT HALVES THE LOOP. The unit that is still listening stops being
# driven into the one that is not, so the round-tripping stops on
# the side this controller owns. That is real energy and real
# cycles off a real pack, and it needs no cooperation from the
# wedged unit to take effect.
# 2. IT MAKES THE RESIDUE VISIBLE. This is the more important half.
# Circulating power hides because it NETS TO ZERO AT THE METER.
# With this controller's contribution removed, whatever the wedged
# unit is still doing stops being cancelled and appears at P1 as a
# plain net flow - where SAFETY-02's sign watchdog (which gates on
# `abs(grid_w) > 300` and could never open at a netted zero), the
# peak guard and the capacity tariff can all see it. The fault
# stops being invisible. Measured on the TEST-19 rig: the card's
# own scenario reads +0 W at the meter with 6000 W going round, and
# the shipped SignWatchdog's predicate is false on every one of
# those cycles.
#
# WHAT IT DOES NOT DO, SAID PLAINLY: it does not stop the wedged
# inverter. Nothing this add-on can send will. If the loop persists at
# the meter after this trip, the remaining actuator is a human at the
# isolator, which is what the notification says in those words.
#
# ⚠️ AND IT IS A SITE CLAIM. `arbiter.CLAIM_SCOPE` carries the argument:
# circulating power is defined over the roster and is identically zero
# for any one unit, so there is no attribution to file this per-unit on -
# and parking the wrong one of the two would leave the loop running with
# the guard satisfied.
if self.circ_wd.tripped:
claims.append(Claim.set("circulation", P_SAFETY, 0.0,
"circulating power",
actuator=self.actuator_of("circulation")))
# SITE, both of them, and the two reasons are why. "stopped" is the
# operator switching control off for the whole site; "inputs missing" is
# the SITE's own telemetry gone - the meter this controller regulates
# against. Neither is about a pack, and both must stop every inverter.
if not self.auto:
claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped",
actuator=self.actuator_of("safety")))
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",
actuator=self.actuator_of("safety")))
# --- SAFETY-07: the quarter-peak projection guard --------------------
# ONE `lo` limit at P_PEAK, and peakguard.py carries the whole argument.
# What is decided HERE and nowhere else is what the guard is allowed to
# see and what it is allowed to ask for:
#
# ⚠️ `frozen` IS EVALUATED ON THIS CYCLE'S STANDING COMMAND, NOT ON
# `self.freeze_wd.active`. The watchdog object is advanced at the END of
# cycle() (check_meter_frozen), so reading its level here would grade
# this quarter against the PREVIOUS cycle's conjunction - one cycle of
# lag, in a guard whose entire job is to act inside a 900 s quarter.
# `standing` is the value SAFETY-02 already computed above for exactly
# this meaning: the command on the wire that this meter reading is a
# response to. The predicate itself is freeze.py's shipped
# `looks_frozen()`, not a copy of it - see loop_is_following() on what a
# transcribed safety predicate costs.
#
# ⚠️ `achievable_w` BOUNDS THE FLOOR SO THIS CLAIM CANNOT CONTRADICT THE
# SAFETY LIMITS ABOVE. `lo > hi` in the arbiter is a CONTRADICTION and
# resolves to 0 W - the battery parked, in the one minute of the month it
# should be discharging hardest. maintenance_charge_w had to learn the
# same lesson (config.yaml, validate.py rule 6); this claim is built so
# the config cannot express it.
#
# ⚠️ AND IT IS FILED EVERY CYCLE, unconditionally, like SAFETY-02's. A
# claim is not sticky. Filed once and skipped next cycle, the floor
# lapses and the actuator goes silently back to whatever was pushing the
# peak. Which of the higher-priority claims outranks it is arbiter.py's
# business, not this block's - and the answer is already right: a
# maintenance DRAIN or HOLD owns the setpoint at P_MAINTENANCE and
# commands discharge or zero, neither of which can create offtake, while
# a maintenance CHARGE is only ever a P_MAINT_SHAPE limit (40) and is
# therefore bound by this floor. That is §9.1's "money outranks the
# maintenance schedule", now as a consequence of the priorities.
#
# ⚠️ AND IT IS NOT RUN AT ALL WITHOUT P1. `meter_source: off` is the
# legacy single-entity path: there is no telegram stream, so
# `p1.averager` has no open block, so the guard would be BLIND on every
# cycle forever - and blind means "no charging". That is not a
# conservative default, it is a controller that silently stops charging
# on every install that has not opted in to P1 yet; test_clamp.py's
# export case caught it doing exactly that. The billed quantity simply
# is not measured on that path, and the honest report is NOT COVERED -
# the same answer, for the same reason, that the status page already
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`.
self.peak_guard = None if not self.p1_enabled else peakguard.decide(
self.peak.project(self.p1.averager, now),
# ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is
# ALREADY removing from the meter, and the meter sees every
# inverter. One unit's power here would size the floor from a
# fraction of the discharge already happening.
batt_w=self.site_batt_w,
cap_w=float(self.o.get("peak_cap_w", 3500)),
reading=self.peak.peak(now),
# ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each
# unit contributes `min(its max_w, |its own device bounds|)`, so
# inverter B's rating bounds inverter B. Conjoining the SUMS instead
# would let one unit's device range cap the whole site.
achievable_w=self.site_rating_w,
# ⚠️ SAFETY-24. `pack_floor_soc` IS A MEASURED READING AND MAY BE None,
# and it is handed over as None rather than defaulted here. An early
# draft of the stage-5 loop read SoC as `| float(100)` and thereby
# invented a full battery (NOTES.md 4.4b); a full battery is
# precisely the value that disarms this floor. decide() treats None
# as floor-limited, which is the direction that costs a shave rather
# than the pack.
# ⚠️ THE EMPTIEST PACK, not an average - see `pack_floor_soc`. A
# floor that grades a discharge permission against the mean lets the
# emptier pack be driven below the level this option exists to
# protect. None stays None: `decide()` treats it as floor-limited,
# which costs a shave rather than a pack.
soc=self.pack_floor_soc,
# ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN
# CUT-OUTS. `peak_soc_floor` is still one site-wide EMS floor and is
# still read from that one key with CFG-03's one mirror behind it;
# what `binding_soc_floor()` adds is the conjunction with each pack's
# OWN `bms_soc_floor`, so the floor this site is graded against can
# never be below the deepest cut-out on the roster. MAXIMUM here
# pairs with the MINIMUM in `pack_floor_soc`: both reductions move
# the verdict towards refusing a shave and away from spending a pack.
# Under any config rule 8 accepts it IS the option, for every pack -
# asserted over the whole schema range in test_peakguard.py part 12
# rather than assumed, because "another rule refuses that" is a claim
# about rule ordering and sim/inject.py does not run rule 8 at all.
soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get(
"peak_soc_floor",
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))),
max_hold_s=self.p1.averager.max_hold_s,
# ⚠️ SAFETY-20. The device range goes in TOO, and this call site is
# the one that needed it most: `standing` is `last_write`, i.e. the
# value AFTER `clamp_to_rating()` applied `[dev_min, dev_max]`, so
# grading it against a rail derived from bare `max_w` compares a
# device-clipped number to a ceiling the device cannot reach.
frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None),
site_standing, max_w, self.freeze_wd.cfg,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max")),
)
# SITE, and peakguard.claim() says so on the object it builds. The
# quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION,
# which is what the capacity tariff bills; there is one quarter-hour
# peak per site, not one per pack. ⚠️ IT IS ALSO THE `lo` HALF OF
# SAFETY-25 §0c's table - a site `lo` at this stage, which is what the
# deferred loop reserve's per-inverter `hi` must never be able to cross.
peak_claim = (peakguard.claim(self.peak_guard)
if self.peak_guard is not None else None)
if peak_claim is not None:
claims.append(peak_claim)
# ⚠️ EDGE-LOGGED, not level-logged. The action log holds 40 lines; a
# guard that appended one every 15 s for a whole quarter would push the
# cause of the event off the end of the log that is meant to explain it.
# SAFETY-03 logs every engagement because a clamp engagement IS the
# count that matters there; here the count is the seconds, and the
# engagement's own reason line already carries them.
engaged_now = self.peak_guard is not None and self.peak_guard.engaged
if engaged_now != self._peak_engaged:
self._peak_engaged = engaged_now
if self.peak_guard is not None:
self.log_event(self.peak_guard.explain())
# --- OPT-04: price arbitrage (§10.1 priority 3) ----------------------
# ONE `set` claim at P_ARBITRAGE, and app/arbitrage.py carries the whole
# argument. What is decided HERE and nowhere else is what the strategy
# is allowed to see:
#
# ⚠️ IT IS PLACED AFTER THE PEAK BLOCK BECAUSE IT READS ITS CONCLUSION.
# `engaged_now` is this cycle's peak verdict, computed ~20 lines up.
# Evaluated before it, this would hand the strategy the PREVIOUS cycle's
# answer - one cycle of lag on the interlock that implements §9.1's
# "peak shaving outranks arbitrage", inside a 900 s quarter.
#
# ⚠️ AND THE THIRD STATE IS NOT A TIDINESS. Without P1 the guard is not
# CLEAR, it never RAN, and `None` says so. arbitrage.decide() refuses on
# it, because charging at max_w creates the billed quantity while
# nothing is measuring the quarter it lands in. `False` here would be
# this line asserting a measurement that was never taken - the defect
# `PeakReading` and `quarter_ceiling_w()` both exist to remove.
#
# ⚠️ THE THREE-STATE COLLAPSE LIVES IN arbitrage.guard_state(), NOT
# HERE, and that is not tidiness. It is the same lesson
# `shaping_suspended()` records: inline, this expression was reachable
# by no check, and mutating it back to a bare `engaged_now` left every
# suite in this repository green while a `meter_source: off` install
# traded blind. Read guard_state() before touching this line.
#
# ⚠️ NO FETCH ON THIS PATH. `fresh_tariff_plan()` is an attribute read
# and an age comparison; the HTTPS call lives in run_tariff_refresh().
# See TARIFF_REFRESH_S: a supplier's TLS timeout here would be a
# supplier able to push this add-on towards layer 1's 30 s veto.
#
# ⚠️ AND THE UNCERTAINTY SET IS NOT EMPTY BY DEFAULT. §11.4's
# no-weather row widens the threshold, and this add-on carries no
# weather forecast at all - §8.1's PV forecast is not built. Passing
# `frozenset()` would be main.py asserting a forecast is healthy when
# there is not one, which is exactly what static_tariff's `uncertainty`
# argument has no default in order to prevent.
self.arb = None
if arbitrage.enabled(self.o):
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
self.arb = arbitrage.decide(
self.fresh_tariff_plan(now), now,
peak_guard_active=arbitrage.guard_state(self.peak_guard),
# The emptiest pack again, and for the sell side that is the
# right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there
# is one `soc` argument - `pack_floor_soc` names that residual
# rather than hiding it; a per-pack charge ceiling is CFG-04's,
# and at the shipped roster of one the two reductions are the
# same number.
soc=self.pack_floor_soc,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
charge_w=max_w, discharge_w=max_w,
soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct)
# SITE, and arbitrage.claim() says so on the object it builds. It
# buys and sells at the grid against a day-ahead price: the price is
# a property of the connection and the energy crosses the meter.
arb_claim = arbitrage.claim(self.arb)
if arb_claim is not None:
claims.append(arb_claim)
# Edge-logged, for the peak guard's reason: the action log holds 40
# lines and a trade lasts a whole hour, so a line per cycle would
# flush the context that explains it. The transition is what an
# operator needs - "it started buying at 03:00", not 240 copies.
if self.arb.action != self._arb_action:
self._arb_action = self.arb.action
self.log_event(self.arb.explain())
# --- maintenance -----------------------------------------------------
# ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST STAYS SHORT.
# At most one machine is out of IDLE on any tick, so at most one of
# these branches fires and the claim list is the shape it has always
# been. The loop is not a widening of what maintenance may ask for; it
# is what makes the ONE claim it does file land on the pack that is
# actually being recalibrated, whichever roster slot that is.
for inv, result in zip(self.inverters, maint_results):
if result.owns_setpoint:
# PER-INVERTER. §12's monthly recalibration is a PACK procedure -
# drain THIS pack to its floor, charge THIS pack full,
# recalibrate THIS pack's coulomb counter. Two packs recalibrate
# on two schedules. See arbiter.CLAIM_SCOPE.
#
# ⚠️ FILED ON THE PACK WHOSE STATE MACHINE PRODUCED IT - `inv`,
# the roster entry this `result` was ticked from - and that is
# the whole of MAINT-05 at this line. It used to be
# `self.maint_unit`, which was `inverters[0]` flatly, because
# there was one FSM and its history had nowhere else to live.
# Pack B's drain filed on pack A is one pack being commanded for
# another's procedure while the pack that is actually draining
# stays under the loop: the site would move the right total and
# recalibrate nothing.
#
# ⚠️ AND IT STAYS `SCOPE_UNIT`. Two FSMs mean two claims on two
# actuators, which the allocator consumes as per-unit intervals;
# ARCH-01 is explicit that this must never become a site claim,
# because per-inverter constraints filed site-wide are the
# cross-inverter `lo > hi` contradiction at arbiter.py:114.
claims.append(Claim.set("maintenance", P_MAINTENANCE,
float(result.setpoint_w or 0.0), result.phase,
actuator=self.actuator_of("maintenance", inv)))
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.
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's
# claim is `lo >= 0`. Both are limits, arbiter.py applies EVERY
# limit at or above the winner's priority, and most restrictive
# wins - so a peak event during a maintenance charge window files
# `lo > hi`, which is a CONTRADICTION and resolves to 0 W. The
# battery would park in the exact quarter it should be shaving.
# Priority does not save it: clause 2 has no notion of a
# higher-priority limit overriding a lower one, and it should not -
# "most restrictive" is what makes the safety bounds unconditional.
#
# So the higher-priority claimant SUPPRESSES the lower one rather
# than out-voting it, which is what this branch already did for
# `peak_forecast_entity`. That entity is optional and empty on a
# default install, so until now the suspension could not fire on the
# site that needed it. `self.peak_guard` is measured from the meter
# and is always there.
#
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is
# not tidiness. It was inline, and a reviewer mutated it back to its
# historical broken form - re-gated on `peak_forecast_entity` alone
# - and every suite in this repository stayed green while a default
# install parked at 0 W in the quarter that sets the month's bill.
# Read shaping_suspended() before touching this: both terms are
# there, and why neither is sufficient alone.
#
# ⚠️ ONE SUSPENSION FLAG FOR THE SITE, NOT ONE PER PACK, AND THE
# INTERLOCK IS WHAT MAKES THAT TRUE. The quantity it edge-logs is
# a peak event at the grid connection, and `arbitrate()` allows
# at most one pack to be in CHARGE at a time, so this branch can
# run at most once per tick. Were two packs ever allowed to
# charge together, this flag would have to become per-pack - and
# so would the reasoning above it.
if peakguard.shaping_suspended(
self.peak_guard, self.peak_fc,
float(self.o.get("peak_cap_w", 3500))):
# Edge-logged: level-logging here would write one line per
# cycle for as long as the event lasts and flush the 40-line
# action log of the very context that explains it.
if not self._maint_shape_suspended:
self._maint_shape_suspended = True
self.log_event("peak at risk - maintenance charge shaping "
"suspended so the peak claim is not "
"contradicted")
else:
self._maint_shape_suspended = False
hi = 0.0
reason = "charge-only"
if self.cheap:
# ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ
# OF `self.o`. This line used to re-open the options dict
# with its own literal fallback, so the rate the shaping
# asked for and the rate the state machine drained at
# could be different numbers from the same install - and,
# with the key absent, they were. The Controller already
# parsed this once in __init__; asking the object that
# owns it cannot drift from it. Every pack's machine
# holds the same `MaintConfig` instance, so this is the
# rate the pack in front of us is charging at.
floor = maintenance_charge_floor(
self.maint.cfg.charge_w,
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
if floor > 0:
hi, reason = -floor, "cheap-window charge"
# PER-INVERTER, like the SET above and for the same reason -
# the shaping is about the pack being charged, which is `inv`.
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi,
reason=reason,
actuator=self.actuator_of("maintenance",
inv)))
# --- 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
# ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF.
# The anti-windup test inside compute() is
# `abs(prev_w - actual_w) > saturation_w`, and `prev_w` is the SITE
# target. Handed ONE inverter's power at a roster of two it compares
# a site number against a unit number, permanently trips the
# saturation freeze and leaves a standing grid error - measured on
# admin/goodwe's two-master rig at up to 510 W of split-dependence
# (gain 0.2; 250 W at 0.4, 50 W at 0.6) while the allocator's shares
# summed to S in every run. With the sum, every split is
# bit-identical at every gain the schema accepts. That is why the
# per-unit readings became per-instance and THIS one became site
# level: the two halves of §6.2's identity break in opposite
# directions. See `site_batt_w` and test_alloc.py part 8.
site_batt = self.site_batt_w
decision = compute(
prev_w=self.target,
grid_w=self.grid,
actual_w=site_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 "
f"{site_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 "")),
)
# SITE. compute() regulates `grid_w` - the P1 measurement - to
# zero. Its subject is the meter, and §6.2's architecture is ONE
# integrator on P1 whose output is then SPLIT. This is the claim the
# allocator exists to divide.
claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason,
actuator=self.actuator_of("loop")))
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, "")
# SAFETY-28. A new resolution is a new tick, and the previous record is
# stale from this line onwards. Incremented BEFORE resolve() so that the
# record resolve() builds is stamped with the tick it is for.
self.alloc_tick += 1
resolution = resolve(claims, units=self.units, live=self.live_units(),
tick=self.alloc_tick)
self.allocation = resolution.allocation
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
# which is then written like any other cycle at
# `write_setpoint(self.target)` below. Nothing is skipped. 0 W is the
# fail-toward-inaction direction: the setpoint is commanded to zero
# rather than left wherever the last cycle put it. They differ in WHO
# refused and therefore in what an operator should do about it, so they
# are two branches with two messages and not one with a vague one.
#
# ⚠️ AND NEITHER MAY BE LEFT UNGRADED. PR #40's reviewer replaced the
# first of these with `if False:` and the whole 17-file suite stayed
# green, because at the shipped roster of one it was unreachable.
# test_peakguard.py part 10h grades both, on real cycles.
#
# 1. THE ALLOCATOR REFUSED. Before ALLOC-01 this fired on every N>=2
# cycle, because there was no allocator and the obvious placeholder -
# hand the site target to each inverter - IS the ARCH-01 fault. There
# is an allocator now and that trigger is gone; the branch is not,
# because refusing is still right when no safe split exists at all: a
# mis-routed claim (so the number to divide may itself be wrong), no
# inverter left in the envelope set, or a split that could only be
# made by commanding one pack to charge while another discharges.
# `allocator.plan()` carries the argument for each.
if not resolution.allocated:
self.log_event("ARBITER: " + resolution.allocation.explain())
resolution = Resolution(0.0, "failsafe", None, False,
resolution.considered,
unit_bounds=resolution.unit_bounds,
shares={}, allocated=False,
scope_errors=resolution.scope_errors,
allocation=resolution.allocation,
winner_priority=resolution.winner_priority)
# 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT.
# ⚠️ RE-AIMED BY DEV-02, NOT DELETED, AND THE DIFFERENCE MATTERS.
# Under ALLOC-01 this fired whenever the split named any inverter but
# the single one this controller could write, because there was one
# `setpoint_entity`. There is a writer per inverter now, so that
# trigger is gone - and the INSTINCT is not, because a roster entry
# can still have no writer: an inverter configured with no
# `setpoint_entity` of its own is an inverter this controller cannot
# put a number on. Writing the site target to the ones it CAN reach
# would over-command them by exactly the missing unit's share -
# not the broadcast fault, but the same shape of over-command.
# Commanding a partial site is a worse instruction than commanding
# nothing, and "nothing" here means 0 W and says so.
#
# ⚠️ IT IS NOT THE SAME EVENT AS A DEAD LINK, WHICH IS WHY IT IS NOT
# FOLDED INTO `live_units()`. A unit whose writes are being rejected
# is DROPPED and its share is carried by the survivors - a real, and
# announced, change in what the site can deliver. A unit with no
# writer configured at all is a CONFIGURATION that cannot be
# delivered, and the site stops rather than silently running at part
# strength on a roster somebody wrote down wrong.
elif {u for u in resolution.shares if not self._can_write(u)}:
undeliverable = ", ".join(
sorted(u for u in resolution.shares if not self._can_write(u)))
self.log_event(
f"ARBITER: the site target was allocated across "
f"{len(resolution.shares)} inverters and this controller has no "
f"setpoint entity for {undeliverable} - refusing to command a "
f"partial site")
# ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE
# DEV-02 THAT IS LOAD-BEARING RATHER THAN TIDY. The write loop below
# commands each inverter `shares.get(name, 0.0)`, so carrying the
# split through a refusal would write the very numbers this branch
# just declined - and, worse, would write NOTHING at all when they
# equal what is already standing, because an unchanged value is not
# re-sent. The record the operator needs survives on
# `self.allocation`, which is assigned above and untouched here.
resolution = Resolution(0.0, "failsafe", None, False,
resolution.considered,
unit_bounds=resolution.unit_bounds,
shares={}, allocated=True,
scope_errors=resolution.scope_errors,
allocation=resolution.allocation,
winner_priority=resolution.winner_priority)
# 3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its
# share, which is a real change in what the site can deliver, and an
# operator who is not told reads a working site. Edge-triggered on
# the SET of dropped units, not level-triggered, because the action
# log is a log of events (§15) and a stale link would otherwise write
# a line every second for as long as it lasts.
_dropped = tuple(u for u, _why in (self.allocation.dropped
if self.allocation else ()))
if _dropped != self._dropped_last:
for _u, _why in (self.allocation.dropped if self.allocation else ()):
self.log_event(f"ALLOCATOR: {_u} dropped from the split "
f"({_why}) - the remaining inverter(s) now carry "
f"its share of the site target")
if self._dropped_last and not _dropped:
self.log_event("ALLOCATOR: every inverter is back in the split")
self._dropped_last = _dropped
if resolution.scope_errors:
# A claim reached the arbiter on an actuator its source's scope does
# not permit. It is a BUG in the filing site, not a state to survive:
# a per-inverter constraint in the site list is the `lo > hi` this
# card removes, and a site constraint on one unit is a bound the
# other unit never gets. Loud, every cycle, and it does not change
# the resolution - the arbiter already routed by `actuator`.
_LOG.error("ARBITER SCOPE: %s", "; ".join(resolution.scope_errors))
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()
# --- DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE ---------------------
# ⚠️ ITS OWN SHARE, NEVER THE SITE TARGET. `resolution.shares` is the
# allocator's answer and `sum(shares) + unmet_w == round(target_w)` is
# an integer identity, so handing each unit its entry is what makes the
# gain at the meter a CONVEX COMBINATION of the per-unit gains. Handing
# each unit `self.target` is the ARCH-01 fault: each supplies it in
# full, the gain becomes N times the per-unit gain, and on the
# two-master rig that is 4000 W peak-to-peak forever at gain 1.0 with
# not one guard firing.
#
# ⚠️ AT N=1 THIS IS BYTE-IDENTICAL. The allocator's pass-through returns
# `{inverter-a: target_w}` as an unrounded float, so the number written
# is `self.target` exactly, as it always was.
#
# ⚠️ AND A UNIT NOT IN `shares` IS COMMANDED 0 W, not left standing.
# That is the dropped-link case: the inverter holds its last command
# FOREVER, so "not in the split" must mean zero on the wire, not
# silence. The write is the same clamped, budgeted path as every other.
writes = []
for inv in self.inverters:
inv.target = float(resolution.shares.get(inv.name, 0.0))
writes.append(self.write_setpoint(inv.target, unit=inv))
await asyncio.gather(*writes)
# --- SAFETY-03, and its boundary is the WRITE ------------------------
# ⚠️ NOT THE SAME BOUNDARY AS SAFETY-18's BELOW, THOUGH THE CALLS ABUT.
# This one reads `self.clamp_last`, which is None until write_setpoint()
# has actually run, so it has to follow the write. The one below grades
# `self.target`, which is assigned further up - see check_output_clamp()
# and check_meter_frozen() for each. Two guards, two orderings, adjacent
# lines: do not "tidy" them into one rule.
#
# Moving it BELOW the other guards is harmless - nothing between here and
# the end of cycle() writes `self.clamp_last`. Moving it above the write
# is not, and test_clamp.py part 6 fails on exactly that: it grades the
# evidence, not the line number, so a rename or a move into a helper that
# still runs after the write keeps it green. (TEST-11.)
await self.check_output_clamp()
# --- SAFETY-18, and its boundary is the ASSIGNMENT --------------------
# ⚠️ THE BOUNDARY IS `self.target = resolution.target_w` ABOVE, NOT
# write_setpoint(). This comment used to say "the command that has just
# gone on the wire", which is wrong twice over and was corrected by
# TEST-06's reviewer: `check_meter_frozen()` grades `self.target` - the
# arbiter's resolved intent - and `write_setpoint()` clamps only a local
# `value` and can skip the write entirely, so that number may never have
# gone anywhere.
#
# What both terms of the conjunction must describe is the SAME moment:
# the command this cycle resolved, against how long the meter that chose
# it has been standing still. Evaluated before
# `self.target = resolution.target_w` it would grade the PREVIOUS
# cycle's command against THIS cycle's meter - the one combination that
# was true of neither moment. Moving it between the assignment and
# write_setpoint() is therefore harmless; moving it above the assignment
# is not, and test_freeze.py part 5 fails on exactly that.
await self.check_meter_frozen()
# --- SAFETY-31's alarm, and its boundary is the LATCH, not a number ---
# Unconstrained where the two above are not: it grades
# `self.circ_wd.tripped`, which was advanced near the top of this method
# beside SAFETY-02 and which nothing since has touched. It is called on
# EVERY cycle rather than only on the cycle that trips, because its
# second job is re-raising the alarm after a restart into an existing
# trip - see its docstring on why that shares one code path with the
# fresh trip instead of getting its own.
await self.check_circulating()
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------
# Its boundary is unconstrained: it grades `self.peak_guard`, which this
# method assigned above and nothing since has touched.
await self.check_peak_saturated()
# --- SAFETY-24's alarm, and it is the OTHER way the money gets spent --
# Same boundary argument as check_peak_saturated(): it grades
# `self.peak_guard`, which cycle() assigned above and nothing since has
# touched. Kept as its own method rather than folded into that one,
# because the two states are mutually exclusive and want OPPOSITE
# operator actions - saturation says "shed load, the inverter is already
# flat out"; this says "the pack is at its floor and this quarter is not
# being shaved at all".
await self.check_peak_floor_limited()
# --- TARIFF-01, and it is LAST on purpose -----------------------------
# Banking a quarter is bookkeeping about a moment that has already
# happened; a slow SD card must never sit between the meter and the
# setpoint. Its position is unconstrained in the way SAFETY-03's and
# SAFETY-18's are not - it grades neither `target` nor `standing` and
# reads only `self.p1`, which no line in this method writes.
self.peak.sync(self.p1)
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.
"""
# ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT. `clamp_last` moved
# onto the Inverter because a clamp on B says nothing about A; the
# ClampWatchdog stayed one per controller because clamp.py's own
# docstring says so - it is an anti-flood counter over an EVENT BUS, not
# a property of a device, and one notification per inverter per cycle is
# the flood it exists to remove. So: any unit that clamped is an
# engagement, and the reason NAMES the unit.
clamped_units = [inv for inv in self.inverters
if inv.clamp_last is not None and inv.clamp_last.engaged]
engaged_write = bool(clamped_units)
engaged_loop, loop_note = self.loop_clamped
engaged = engaged_write or engaged_loop
if engaged:
where = (", ".join(f"{inv.name}: {inv.clamp_last.describe()}"
for inv in clamped_units) if engaged_write
else f"{CLAMP_REASON}: {loop_note}, above the "
f"{self.site_max_w:.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_peak_saturated(self) -> None:
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter.
⚠️ AN ENGAGEMENT IS NOT AN ALARM, AND THE DIFFERENCE IS §10.3's DOCTRINE
READ CAREFULLY. "Treat an out-of-range computed setpoint as a bug signal
- clamp it, then alarm" is SAFETY-03's rule and it is right there: the
loop computing 14 768 W is a WINDING INTEGRATOR, a defect, and clipping
it silently is what let it keep climbing behind a dashboard full of legal
numbers. A peak floor above the inverter's rating is not a defect. It is
a house load bigger than the inverter - 2026-08-20 15:45 ran ~5900 W
against a 5000 W unit - and alarming on the ordinary case is how an
operator learns to dismiss the notification that matters. So an
engagement is an ordinary control action: it goes to the §15 action log
and the status page, and no further.
What DOES earn the operator's attention is saturation: the guard asked
for more discharge than the inverter can give, so the quarter is going
over the ceiling anyway and the controller has already done everything
available to it. That is the one state where the money is going to be
spent and a human is the only remaining actuator.
Edge-triggered, and de-asserting arms it again - the same shape as
FreezeWatchdog's `evaluate()`, and for the same reason: one notification
per episode, not one per cycle.
"""
g = self.peak_guard
saturated = bool(g is not None and g.saturated)
was, self._peak_saturated = self._peak_saturated, saturated
if not saturated or was:
return
self.log_event("PEAK GUARD SATURATED - " + g.reason)
_LOG.error(
"SAFETY-07 - %s. The guard is asking for everything the inverter "
"has and the quarter is still projected over the ceiling: this "
"quarter will set or raise the month's billed capacity peak. The "
"controller is STILL RUNNING and still shaving; there is nothing "
"further it can do. Shed load.", g.reason)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
# is not a frozen meter and is not an out-of-range setpoint.
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_peak_saturated",
"title": "GoodWe: the capacity-peak guard has run out of inverter",
"message": (
f"{g.reason}.\n\nThe battery is being commanded to everything "
f"it can give and the quarter is STILL projected over the "
f"{g.ceiling_w:.0f} W ceiling, so this quarter will set or raise "
f"the month's billed capaciteitstarief peak. This is not a "
f"controller fault - the load is simply larger than the "
f"inverter. Shedding load in the next few minutes is the only "
f"thing that changes the bill."),
})
async def check_peak_floor_limited(self) -> None:
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving.
⚠️ FAILING TO SHAVE MUST NOT BE SILENT, AND THAT IS THE WHOLE POINT.
The guard exists for one euro figure - the capaciteitstarief bills the
month's single worst quarter - so a cycle in which it CHOSE not to shave
is a cycle in which money is being spent on purpose. Under-shaving
quietly would leave the operator with a bill and a status page full of
green.
The distinction from check_peak_saturated() is the operator's action,
which is why these are two notifications and not one:
saturated the guard asked for everything the inverter has and it
was not enough. SHED LOAD - the controller has already
done all it can, and it is still discharging.
floor-limited the guard asked for NOTHING, because the pack is at or
under the EMS floor, or its SoC could not be read.
Nothing is being discharged for this quarter at all.
They cannot both be true: peakguard.decide()'s floor-limited branch
returns before the `achievable_w` clamp that sets `saturated`.
⚠️ SAFETY-25 - AND THE MESSAGE SAYS WHAT THE FLOOR IS NOT. Read on its
own, "the capacity-peak guard has run out of battery" invites exactly
one wrong inference: that the pack is now being held at
`peak_soc_floor`. It is not. `peak_soc_floor` bounds THIS GUARD only;
the grid-following loop has no SoC floor at all and, on the committed
2026-08-20 replay, is being granted discharge in EVERY cycle that raises
this notification. An operator who reads the alarm and relaxes because
"the EMS has a 20 % reserve" has been misled by us, so the alarm carries
the correction itself rather than leaving it to DOCS.md.
Edge-triggered, and de-asserting arms it again - FreezeWatchdog's shape,
for FreezeWatchdog's reason: one notification per episode, not one per
cycle for a quarter of an hour.
"""
g = self.peak_guard
limited = bool(g is not None and g.floor_limited)
was, self._peak_floor_limited = self._peak_floor_limited, limited
if not limited or was:
return
# No prefix: `reason` already opens with PEAK GUARD FLOOR-LIMITED, and
# a second copy is the kind of stutter that makes a 40-line action log
# read like boilerplate. The saturated path needs its prefix because its
# reason carries the word in the middle of a sentence, not at the front.
self.log_event(g.reason)
_LOG.error(
"SAFETY-24 - %s. The controller is STILL RUNNING and charging is "
"still blocked, but it is not discharging to hold this quarter, so "
"the quarter will set or raise the month's billed capacity peak. "
"Shed load, or lower peak_soc_floor if this pack can afford it.",
g.reason)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A battery at its
# energy floor is not a saturated inverter and is not a frozen meter,
# and an operator who has to dismiss three identical-looking cards
# learns to dismiss the one that mattered.
# ⚠️ SAFETY-25's sentence must render on the unreadable-SoC branch too,
# where `soc_floor` is a number but naming it would invite the reader to
# think the pack is AT it. `decide()` guards its own reason string the
# same way; a f-string that formats a None here would turn the one
# notification that matters into a traceback.
_at_floor = ("at the floor" if g.soc_floor is None
else f"at {float(g.soc_floor):.0f} %")
# ⚠️ SAFETY-32. WHICH PACK STOPPED THE SITE. `g.soc_floor` is the floor
# the decision was actually taken with, so this re-grades nothing and
# cannot disagree with the alarm it is printed inside. At a roster of one
# the sentence is omitted entirely and this notification is byte-for-byte
# what SAFETY-25 shipped - there is exactly one pack and naming it would
# be noise on every existing install.
_blocked = [name for name, why
in peakguard.pack_permissions(self.packs, g.soc_floor)
if why is not None]
_which = ("" if len(self.inverters) < 2 else
f"\n\nSAFETY-32 - WHICH PACK: this is graded per pack against "
f"that pack's own BMS cut-out (bms_soc_floor), and the "
f"pack(s) at or below their own floor right now are "
f"{', '.join(_blocked) if _blocked else '(none - see the reason above)'}"
f". The other packs on the roster are not the reason the guard "
f"stopped and raising their cut-out will not change this.")
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_peak_soc_floor",
"title": "GoodWe: the capacity-peak guard has run out of battery",
"message": (
f"{g.reason}.\n\nTHIS QUARTER WILL GO OVER THE "
f"{g.ceiling_w:.0f} W ceiling and will set or raise the "
f"month's billed capaciteitstarief peak, because the peak guard "
f"is not allowed to discharge any further: {g.soc_floor_reason}."
f" This is not a controller fault - it is the EMS-side energy "
f"floor (peak_soc_floor) doing what it is set to do, and it sits "
f"deliberately above the pack's own BMS cut-out. Charging stays "
f"blocked. Shedding load is the only thing that changes the bill "
f"now; if this pack can afford a deeper discharge, lower "
f"peak_soc_floor.\n\nSAFETY-25 - WHAT THIS FLOOR IS NOT: "
f"peak_soc_floor bounds THE PEAK GUARD ONLY. It is not a "
f"pack-wide reserve, and the battery is NOT being held "
f"{_at_floor} - ordinary self-consumption has no SoC "
f"floor at all and will go on discharging past it, down to the "
f"pack's own BMS cut-out. What has stopped is the guard buying "
f"you a lower bill, not the pack being spent." + _which),
})
async def check_circulating(self) -> None:
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart.
⚠️ THE LATCH IS NOT THE ALARM. `circ_tripped` in `/data/state.json`
survives everything, but nothing looks at a JSON file at 3 a.m. What the
operator sees is a `persistent_notification`, and THAT IS IN MEMORY: a
Home Assistant restart deletes it while the fault runs on. This is not a
hypothetical - it is precisely how the 2026-09-02 outage went unseen for
6 h 25 m with the detector correct the whole time (SAFETY-34).
So the durable half and the visible half are joined HERE, and the join
is `_circ_announced`: a per-PROCESS flag, deliberately not persisted.
The latch says "this site is tripped"; the flag says "this process has
put that in front of a human". A restart clears the flag and keeps the
latch, so the notification is RE-RAISED on the first cycle after every
add-on start - which is also the first cycle after a Home Assistant
restart, because the add-on is restarted with it.
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE. A fresh trip and a restart into
an existing trip raise the alarm through these same lines. Two paths
would be two chances for the restart one to rot unnoticed, and the
restart one is the case nobody exercises by hand.
"""
if not self.circ_wd.tripped or self._circ_announced:
return
# Set BEFORE the await. `hass.call()` can raise or hang, and a retry
# every cycle for a broker that is down would be an alarm storm on the
# event bus on top of a site that is already stopped. The latch is
# already durable and the log line above already fired; this surface is
# best-effort by design.
self._circ_announced = True
names = self.units
pos, neg = opposing([inv.batt for inv in self.inverters],
min_w=self.circ_wd.cfg.min_w / 2.0)
who = ""
if pos and neg:
who = ("\n\nRight now " + ", ".join(names[i] for i in pos)
+ " is discharging while " + ", ".join(names[i] for i in neg)
+ " charges.")
# A fixed notification_id, and a DIFFERENT one from every other alarm
# this add-on raises - check_meter_frozen() gives the reasoning. A
# circulating loop is not a frozen meter, is not an out-of-range
# setpoint and is not a saturated peak guard.
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_circulating_power",
"title": "GoodWe: the inverters are pushing against each other",
"message": (
f"{self.circ_wd.reason}{who}\n\nTHE METER CANNOT SEE THIS. "
f"Circulating power nets to zero at P1, so the site reads idle "
f"while the packs are cycled through two conversion stages "
f"continuously. Every other guard in this add-on is a consumer "
f"of that net figure, which is why none of them fired.\n\n"
f"CONTROL HAS BEEN STOPPED - every inverter is commanded 0 W "
f"and held there. That removes this controller as one of the "
f"two participants, and it makes anything still happening "
f"VISIBLE at the meter instead of cancelled at it.\n\n"
f"⚠️ IF THE GRID READING DOES NOT MOVE AFTER THIS, one inverter "
f"is no longer listening to this add-on and no command will "
f"reach it - that has happened twice on this hardware, once for "
f"6 h 25 m. The remaining actuator is a human at the isolator.\n"
f"\nThis alarm survives a Home Assistant restart. Restarting "
f"will not clear it; start control from the add-on UI once you "
f"know why two inverters were opposing."),
})
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)
# SITE, all three inputs: one meter, the SITE target the arbiter
# resolved, and the rail the SITE could stand at - which is the summed
# per-unit conjunction, so a small inverter B lowers the site rail rather
# than being graded against inverter A's.
fired = self.freeze_wd.evaluate(unchanged, self.target,
self.site_max_w,
datetime.now(timezone.utc),
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max"))
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 ---------------------------------------------------------------
# -- OPT-04: the background tariff refresh -------------------------------
def fresh_tariff_plan(self, now):
"""The stored plan if it is still current, else None. NEVER a stale one.
⚠️ THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for
`loop_is_following()`'s reason: a check has to be able to assert on the
shipped expression rather than on a transcription that drifts. Both
terms are required - a plan with no timestamp is one nothing stored, and
a timestamped plan older than TARIFF_MAX_AGE_S is a reading from a task
that has stopped answering.
"""
if self.tariff_plan is None or self.tariff_at is None:
return None
if (now - self.tariff_at).total_seconds() > TARIFF_MAX_AGE_S:
return None
return self.tariff_plan
async def run_tariff_refresh(self) -> None:
"""Resolve the tariff off the control path, forever. NEVER on the tick.
⚠️ IT CATCHES EVERYTHING AND KEEPS GOING, for MqttPublisher's reason:
a supplier's outage, a certificate expiry, a malformed feed - all of it
is pricing, and the battery does not care. What it must never do is
raise out of here and take the task down, because then the plan freezes
at its last value and looks current forever. That is what
`fresh_tariff_plan()`'s age bound is the backstop for, and this is the
half that keeps the task alive so the bound rarely matters.
⚠️ AND A FAILURE DOES NOT CLEAR THE PLAN. `resolve_tariff()` already has
a §11.4 answer for a dead feed - degrade to the static profile, mark it
provisional, arbitrage refuses - and that answer is better than the
absence of one. Zeroing the plan here would replace a coarse tariff with
no tariff, which is the opposite of what §11.4 asks for.
"""
horizon = float(price_option(self.o, "price_horizon_h"))
while not self.stopping:
now = datetime.now(timezone.utc)
try:
plan = await resolve_tariff(
self.o, now, now + timedelta(hours=horizon), now=now)
self.tariff_plan, self.tariff_at = plan, now
_LOG.info("tariff refreshed: %s", plan.words)
except Exception as err: # noqa: BLE001
_LOG.warning(
"tariff refresh failed (%s); keeping the plan resolved at "
"%s. It retires on its own after %.0f s and arbitrage "
"stops with it.", err, self.tariff_at, TARIFF_MAX_AGE_S)
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self.stop_event.wait(),
timeout=TARIFF_REFRESH_S)
async def run_control(self) -> None:
# ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4. There
# was one read of one `setpoint_entity` here, so a second inverter's
# rating was never read at all and its clamp, its freeze rail and the
# peak guard's `achievable_w` were all the first one's numbers.
# Concurrently, for `read_inputs()`'s reason: a slow ESPHome on one unit
# must not delay the controller's start on every other.
async def _limits(inv):
if inv.setpoint_entity:
inv.dev_min, inv.dev_max, _ = await self.hass.limits(
inv.setpoint_entity)
await asyncio.gather(*(_limits(inv) for inv in self.inverters))
entity = self.o.get("setpoint_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.
#
# ⚠️⚠️ AND THE `if entity:` THAT USED TO WRAP THE WHOLE BLOCK WAS A
# READER THAT FAILED PERMISSIVE - CFG-04's own hazard, in this file.
# `entity` is the FLAT `setpoint_entity`, which a per-inverter config
# leaves blank (validate rule 9(e) requires it), so on the very config
# this add-on now supports the entire re-check was SKIPPED: no
# inverter's `max_w` was ever graded against the device it is written
# to, and the skip was silent. The flat gate still guards the flat
# rules, because they read flat options and one device's range; the
# per-unit rule runs unconditionally, off the roster, and grades each
# unit against ITS OWN ESPHome. At one inverter `validate_unit_ranges()`
# skips the unit the flat call already graded, so nothing is reported
# twice and the N=1 path is unchanged.
problems = []
if entity:
_first = self.inverters[0]
problems += validate_startup_config(self.o, _first.dev_min,
_first.dev_max)
problems += validate_unit_ranges(self.inverters, skip_entity=entity)
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))
# ⚠️ 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.
#
# ⚠️ ONE DEADLINE PER INVERTER, AND THE REFRESHES GO OUT TOGETHER.
# This was ONE `last_beat` and ONE serialised write, so a hung
# `hass.call()` to inverter A consumed inverter B's ~30 s layer-1
# budget and the firmware zeroed a healthy sibling. Now each unit
# owns its own deadline, each write is dispatched CONCURRENTLY and
# each is bounded by WRITE_BUDGET_S - so the worst a hung A can do
# to B is delay B's NEXT beat by the budget, which is why the budget
# plus the schema's largest `heartbeat_s` has to stay under layer
# 1's veto. That inequality is graded, not asserted.
#
# ⚠️ AND EACH UNIT REFRESHES ITS OWN SHARE, `inv.target`, never the
# site target. A heartbeat that re-emitted the site number would be
# the broadcast fault arriving through the back door, once every
# `heartbeat_s`, on a path with no allocator in it.
await self.send_heartbeats(asyncio.get_running_loop().time(),
heartbeat)
self.publish()
await asyncio.sleep(1)
async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None:
"""Refresh every inverter whose own deadline has come. AC 6 lives here.
⚠️ IT IS A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` FOR ONE
REASON: the acceptance criterion is "a hung write to A cannot consume
B's layer-1 budget - verified by injecting the hang", and a check cannot
inject a hang into the middle of a `while not self.stopping` loop that
also sleeps, reads inputs and cycles. Inline, the only thing a test
could grade was its OWN gather of `write_setpoint()` calls - which is a
check on the fixture, and a serialised shipped loop survived it.
Measured: the mutation that replaced the concurrent dispatch with a
`for` loop passed the whole suite while this block was inline.
`loop_now` is the caller's monotonic clock, the same discipline every
watchdog in this add-on takes its clock by.
"""
due = [inv for inv in self.inverters
if loop_now - inv.last_beat >= heartbeat_s]
if not due:
return
# ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT. Moved after, a
# unit whose write hangs would have its deadline advanced late every
# time and would drift into being re-sent on every tick; and a unit
# whose write RAISED would never have it advanced at all.
for inv in due:
inv.last_beat = loop_now
# ⚠️ CONCURRENTLY. In series, every inverter behind a hung one waits out
# that one's whole budget before its refresh is even attempted - and
# before this card, with no budget at all, it waited out the HTTP
# stack's timeout. That is one inverter's transport fault pushing a
# healthy sibling towards layer 1's ~30 s veto.
await asyncio.gather(*(
self.write_setpoint(inv.target, force=True, unit=inv)
for inv in due))
def publish(self) -> None:
# ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE. These entities are already
# discovered and already on every dashboard (TEL-07 keeps their
# `unique_id`s), so `battery` stays the number the meter sees the
# battery doing - the SUM - and `soc` stays the one a floor is graded
# against - the EMPTIEST pack. At the shipped roster of one both are
# inverter A's own readings, unchanged. Per-inverter entities are
# TEL-07's, not a key rename here.
values = {
"setpoint": self.target,
"grid": self.grid,
"battery": self.site_batt_w,
"soc": self.pack_floor_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.
# SAFETY-30 adds the site sign latch to the same vocabulary, for
# the reason the note above gives: "failsafe" is additive to a
# sensor that already exists and is already on every dashboard, and
# a P_SAFETY stop that did not reach it would be a site at 0 W
# reporting "running".
"status": ("failsafe" if any(i.sign_wd.tripped
for i in self.inverters)
or (self.site_sign_wd.tripped
and len(self.inverters) > 1)
or self.circ_wd.tripped
else "running" if self.auto else "stopped"),
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
# `site_circulating_w`. `publish()` drops None values, so a cycle
# with a missing per-unit reading leaves the last figure standing
# rather than publishing a zero that would read as health.
"circulation": (None if self.site_circulating_w is None
else round(self.site_circulating_w)),
}
# ⚠️ 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)
# TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE.
#
# ⚠️ THERE IS NO `if len(self.inverters) > 1` HERE, DELIBERATELY.
# `announced_units()` answers `[]` at a roster of one, `zip` then yields
# nothing, and the values dict comes out byte-identical to what shipped -
# so the condition that decides what is ANNOUNCED is the same expression
# that decides what is PUBLISHED. Written as a second predicate, the two
# could disagree, and both directions are bad: an announced entity
# nothing feeds (the hazard `omit` exists for) or, at N=1, a published
# topic nothing announced, which AC 4 forbids outright.
#
# ⚠️ AND THE SITE ROWS ABOVE DO NOT MOVE. `battery` stays the SUM and
# `soc` stays the EMPTIEST pack; these are additions beside them, not a
# re-pointing of them, because those two ids are on the live install's
# dashboards and in its automations.
names = announced_units([inv.name for inv in self.inverters])
for inv, slug in zip(self.inverters, unit_slugs(names)):
values[unit_state_key(slug, "setpoint")] = inv.target
values[unit_state_key(slug, "battery")] = inv.batt
values[unit_state_key(slug, "soc")] = inv.soc
values[unit_state_key(slug, "sign_latch")] = (
"tripped" if inv.sign_wd.tripped else "clear")
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 on every inverter")
await self.command_site_zero()
self.mqtt.close()
async def command_site_zero(self) -> None:
"""Every inverter to 0 W, and every inverter's TARGET to 0 W with it.
⚠️ THE SECOND HALF IS NOT BOOKKEEPING, AND IT IS A HAZARD DEV-02
CREATED. Before this card the heartbeat re-emitted `self.target`, so
zeroing that one scalar was enough: the stop button set it and the next
refresh carried the zero. The heartbeat now re-emits `inv.target` - it
has to, or it would broadcast the site number - so a stop that zeroed
only `self.target` would leave each unit's LAST SHARE standing, and the
very next heartbeat (up to `heartbeat_s` later, and before any cycle has
run) would put it back on the wire. The operator would have pressed
stop, watched the inverter go to zero, and seen it resume seconds later.
Both must move together, which is why this is one method with a reason
and not two lines repeated at three call sites.
⚠️ AND 0 W IS THE ONE VALUE THAT MAY BE WRITTEN SITE-WIDE. It means the
same thing to every inverter; a non-zero site-wide write is the ARCH-01
broadcast fault, and `write_setpoint()` refuses it.
"""
self.target = 0.0
for inv in self.inverters:
inv.target = 0.0
await self.write_setpoint(0.0, force=True)
# -- UI ------------------------------------------------------------------
def checks(self, when: datetime | None = None) -> list:
"""The status rows. `when` is the reference time for anything billed by
the calendar - today's month when it is None, which is what the live
page means; `status_payload()` calls this with no argument.
⚠️ IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17). Every other peak
query already takes it - `PeakStore.peak()`, `PeakTracker.peak()`,
`headroom_w()`, `warning_frac()` - and the row built from them was the
one thing that could only be read against `datetime.now()`. A suite
that banks an August quarter then asks this page what the peak is goes
green in August and red in September, which is what happened at 00:00
on 2026-09-01: TARIFF-01's own section 10, red on a calendar boundary,
on a commit nobody had touched for a day.
"""
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"})
elif rail_unreachable(self.site_max_w,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max")):
# SAFETY-20, and THE SECOND REASON TO USE THIS ROW RATHER THAN A
# NEW MECHANISM. The transport case above and this one are the
# same statement - "the conjunction cannot be evaluated here" -
# and an operator does not need to learn two vocabularies for
# it. What differs is only which term is missing: there,
# `unchanged_s`; here, a rail the command could ever stand at.
out.append({"ok": False, "warn": True,
"text": f"freeze detector: NOT COVERED - "
+ rail_unreachable(
self.site_max_w,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max"))})
else:
# ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE
# USES, device range included. This row used to interpolate
# `rail_w(max_w)` while `looks_frozen()` graded a command the
# device had already clipped below it, so on any install whose
# inverter is smaller than its configured ceiling the page named
# a number the command could never reach and called it `armed`.
# Both the row and the predicate now read the same three inputs.
_rail = rail_w(self.site_max_w,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max"))
# And when the device is what set that number, SAY SO - the
# operator configured `max_w` and is owed the reason the guard
# is armed somewhere else.
# ⚠️ Either bound may be None on its own - `hass.limits()`
# reads `min` and `max` independently and returns None for
# whichever attribute is missing or unparseable - so this
# renders whatever is there rather than formatting a None.
_cap = ("" if _rail >= rail_w(self.site_max_w)
else " - below max_w because "
+ "; ".join(
f"{inv.setpoint_entity or inv.name} accepts "
f"only {inv.dev_min}...{inv.dev_max} W"
for inv in self.inverters))
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:.0f} W{_cap})"})
# TARIFF-01. The month's billed peak, and - the load-bearing half -
# a DIFFERENT row when the store could not answer. "no peak yet" is
# a normal state on the 1st; "unreadable" is a broken instrument, and
# a status page that spelled them the same way would let a 0 W read
# look like a quiet month right up until SAFETY-07 shaved nothing.
_pk = self.peak.peak(when)
if _pk.is_unreadable:
out.append({"ok": False, "warn": True,
"text": "capacity-tariff peak: " + _pk.explain()})
else:
# §9.1's "warning entity at 80 %", as a status row rather than
# a second entity - see the constructor on not doubling up.
_frac = self.peak.warning_frac(when) or 0.0
_ok = _frac < 0.8
out.append({"ok": _ok, "warn": not _ok,
"text": _pk.explain()
+ f" - {_frac * 100:.0f} % of the "
f"{self.peak.cap_w:.0f} W soft limit"
+ f"; {self.peak.recorded} quarter(s) banked"
+ f"; on start: {self.peak.restore_state}"})
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
# are opposite states and a page that spelled them the same way
# would show a green line over a guard that is switched off. `None`
# is a fourth state on purpose: before the first cycle the guard has
# concluded nothing, and saying "idle" then would be a claim about a
# decision that has not been taken.
_pg = self.peak_guard
if _pg is None:
out.append({"ok": True, "warn": True,
"text": "peak guard: no decision yet - the first "
"control cycle has not run"})
elif _pg.saturated:
out.append({"ok": False, "warn": False,
"text": "PEAK GUARD SATURATED - " + _pg.reason})
elif _pg.floor_limited:
# SAFETY-24. NOT ok, like saturation: in both states the money
# is going to be spent and a human is the only actuator left. A
# separate row from saturation because the two ask for opposite
# things - see check_peak_floor_limited().
#
# ⚠️ SAFETY-25. THE SCOPE SENTENCE IS PART OF THE ROW, NOT A
# DOCS FOOTNOTE. This is the one line an operator reads at the
# exact moment the guard stops discharging, and read alone it
# says "the battery is being held at peak_soc_floor" - which is
# false, and measurably so: on the committed evening replay the
# loop goes on discharging in EVERY cycle this row is showing.
# A floor that binds one consumer and not the other is worse
# than no floor if the page does not say which.
#
# ⚠️ AND IT CARRIES ITS OWN `banner`. status_payload() renders
# the FIRST not-ok/not-warn row as the page's headline, so
# without this the loudest element on the page would be a
# 550-character paragraph that the row three lines below
# repeats verbatim. The banner is the short form; the row keeps
# the whole sentence, because the row is where an operator
# reads what the floor does and does not bound. Shortening the
# ROW instead would be weakening AC 3.
out.append({"ok": False, "warn": False,
"banner": _pg.explain()
+ " - and peak_soc_floor bounds THE PEAK "
"GUARD ONLY (SAFETY-25, see below)",
"text": _pg.explain()
+ ". NOTE: peak_soc_floor bounds THE PEAK "
"GUARD ONLY - it is not a pack-wide "
"reserve, and ordinary self-consumption "
"has no SoC floor, so the battery may "
"keep discharging below this level down "
"to the BMS cut-out (SAFETY-25)"})
elif _pg.blind:
out.append({"ok": False, "warn": True, "text": _pg.explain()})
else:
out.append({"ok": not _pg.engaged, "warn": _pg.engaged,
"text": _pg.explain()})
else:
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point.
# SAFETY-07 is NOT COVERED on the legacy single-entity path - the
# quarter-hour average is measured from P1 telegrams and this path
# has none - and a row that only rendered where the guard runs would
# leave the one install that is unprotected showing nothing at all.
# Same answer, same reason, as the freeze detector's NOT COVERED row
# a few lines up.
out.append({"ok": False, "warn": True,
"text": f"peak guard: NOT COVERED on meter_source "
f"{o.get('meter_source')} - the quarter-hour "
f"average is measured from P1 telegrams and this "
f"path has none, so a runaway cannot be capped"})
# ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row on
# a site with two packs is a page that shows one pack and calls it the
# site. The label keeps its exact wording at a roster of one, because
# that is what an existing install reads.
rows = []
for inv in self.inverters:
tag = "" if len(self.inverters) == 1 else f" ({inv.name})"
rows.append((f"battery SoC{tag}", inv.soc, inv.soc_entity))
rows.append((f"battery power{tag}", inv.batt, inv.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}"})
for inv in self.inverters:
sp = inv.setpoint_entity
tag = "" if len(self.inverters) == 1 else f" [{inv.name}]"
if not sp:
out.append({"ok": False, "warn": False,
"text": f"setpoint entity not configured{tag}"})
elif inv.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 {inv.dev_min:g}…"
f"{inv.dev_max:g} W)"})
# DEV-02. Whether this controller can still put a number on each
# inverter, and it is reported in the ONE state that matters: a unit
# `live_units()` has stopped listing has had its share handed to its
# siblings, or - at a roster of one - has taken the site to 0 W. A
# dropped link that shows nothing on the page reads as a healthy site.
for note in self.liveness_notes():
out.append({"ok": False, "warn": "NOT COMMANDABLE" not in note,
"text": note})
# 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"})
for inv in self.inverters:
if inv.last_write_ok is False:
tag = "" if len(self.inverters) == 1 else f" ({inv.name})"
out.append({"ok": False, "warn": False,
"text": f"last write to the inverter{tag} was "
f"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.
# ⚠️ ONE ROW PER INVERTER, because there is one watchdog per inverter
# and a trip on B leaves A commanding. A single row would have to pick
# one of them to describe, and the honest answer at a roster of two is
# both. At a roster of one the wording is unchanged.
# ⚠️ AND SINCE SAFETY-30 THE ROW SAYS WHAT THE UNIT WAS COMPARED
# AGAINST, because at a roster of two that is no longer the meter and a
# row that still said "the command opposed the meter" would send an
# operator to look at P1 for a fault that is visible only on one
# inverter's own power sensor. `sign_wd.against` is the same string the
# trip sentence uses, so the two cannot drift apart.
for inv in self.inverters:
tag = "" if len(self.inverters) == 1 else f" [{inv.name}]"
# ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. At the
# shipped roster of one the two sentences below are the ones that
# have always been on the page, word for word - AC 8 is about what
# an operator sees as much as about what the detector computes, and
# "opposed a meter reading" would be a gratuitous change to a line
# somebody has learned to recognise. At N>=2 the noun is load-
# bearing: the row would otherwise send them to look at P1 for a
# fault that is visible only on one inverter's own power sensor.
vs = ("" if len(self.inverters) == 1
else f" against {inv.sign_wd.against}")
if inv.sign_wd.tripped:
what = ("the meter" if len(self.inverters) == 1
else inv.sign_wd.against)
out.append({"ok": False, "warn": False,
"text": f"SIGN-DISAGREEMENT FAILSAFE{tag} - "
+ inv.sign_wd.reason
+ f". The inverter is held at 0 W. Find out why the "
f"command opposed {what} before starting control "
"again; starting clears this. Restarting the "
"add-on does NOT clear it."})
else:
held = inv.sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False,
"text": f"sign watchdog{tag}: armed{vs} (trips after "
f"{inv.sign_wd.cfg.hold_s:.0f} s opposed, both over "
f"{inv.sign_wd.cfg.min_w:.0f} W)"
+ (f" - opposed for {held:.0f} s now" if held else "")})
# --- SAFETY-30: the SITE row -----------------------------------------
# ⚠️ ONLY AT N>=2, and the absence at N=1 is the honest report rather
# than a missing row. With one inverter the site comparison and the row
# above are the same sentence about the same actuator; printing it twice
# would advertise two guards where there is one, which is how an
# operator learns to skim the list.
if len(self.inverters) > 1:
if self.site_sign_wd.tripped:
out.append({"ok": False, "warn": False,
"text": "SITE SIGN-DISAGREEMENT FAILSAFE - "
+ self.site_sign_wd.reason
+ ". EVERY inverter is held at 0 W. This is the "
"SUM of the commands against the meter, so it is "
"not one inverter's fault to find - look at the "
"loop, the integrator and whatever won the "
"arbitration. Starting control clears this; "
"restarting the add-on does NOT."})
else:
_sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False,
"text": f"site sign watchdog: armed against the sum of "
f"the commands (trips after "
f"{self.site_sign_wd.cfg.hold_s:.0f} s opposed, "
f"both over "
f"{self.site_sign_wd.cfg.min_w:.0f} W)"
+ (f" - opposed for {_sheld:.0f} s now"
if _sheld else "")})
# --- SAFETY-31 -----------------------------------------------------
# ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and the row says
# so. There is one circulating figure for the site; naming an inverter
# here would invite the operator to clear "the other one".
_circ = self.site_circulating_w
if self.circ_wd.tripped:
out.append({"ok": False, "warn": False,
"text": "CIRCULATING POWER - " + self.circ_wd.reason
+ ". EVERY inverter is held at 0 W. The meter "
"cannot see this fault, which is why nothing "
"else reported it. Starting control clears "
"this; restarting the add-on does NOT, and "
"neither does restarting Home Assistant."})
elif len(self.inverters) <= 1:
# AC 5. At the shipped roster the quantity is identically zero for
# every value of every reading, so "armed" would be a claim to be
# watching something that cannot happen. Say what is true instead.
out.append({"ok": True, "warn": False,
"text": "circulating power: n/a at one inverter - "
"sum(|Bi|) - |sum(Bi)| is identically zero"})
else:
held = self.circ_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False,
"text": "circulation watchdog: armed (trips after "
f"{self.circ_wd.cfg.hold_s:.0f} s over "
f"{self.circ_wd.cfg.min_w:.0f} W)"
+ (f" - now {_circ:.0f} W" if _circ else "")
+ (f", opposed for {held:.0f} s" 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.circ_wd.tripped:
# ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are
# latched P_SAFETY stops, so the site is at 0 W either way and the
# banner can only carry one sentence. This one goes first because it
# is the fault the operator has NO OTHER WAY of learning about: a
# sign disagreement is visible at the meter and on every dashboard
# in the house, and circulating power is visible nowhere at all.
level, banner = "bad", ("CIRCULATING POWER — the inverters were "
"pushing against each other. Site held at "
"0 W.")
elif self.site_sign_wd.tripped and len(self.inverters) > 1:
# SAFETY-30's site half, ABOVE the per-unit rows below and below
# circulating power above, and the ordering is the same argument
# both times: the banner carries one sentence, so it carries the one
# that says the widest true thing. A site sign trip means EVERY
# inverter is parked and the fault is in what the site was asking
# for, not in one device - naming a unit here would send an operator
# to swap the wrong box.
level, banner = "bad", ("FAILSAFE — the site's total command "
"opposed the meter. Every inverter held at "
"0 W.")
elif any(i.sign_wd.tripped for i in self.inverters):
# Outranks "NOT READY": nothing is missing, something went wrong.
# ⚠️ THE SENTENCE IS UNCHANGED AT A ROSTER OF ONE, where "the meter"
# is what the unit was graded against. At N>=2 it is that unit's own
# power, so the banner says so - see checks() on why the noun is
# load-bearing rather than cosmetic.
level, banner = "bad", (
"FAILSAFE — the command opposed the meter. "
"Inverter held at 0 W." if len(self.inverters) == 1 else
"FAILSAFE — an inverter's command opposed its own measured "
"power. That inverter held at 0 W.")
elif bad:
# ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may
# need to say more to an operator reading the checks list than fits
# in the one line at the top of the page - SAFETY-25's floor-limited
# row is the case that forced this - and a headline that is a full
# paragraph, repeated verbatim by the row underneath it, is a
# headline nobody reads. Rows without the key are unaffected.
level, banner = "bad", f"NOT READY — {bad[0].get('banner') or bad[0]['text']}"
elif not self.auto:
level, banner = "warn", "Stopped — inverter commanded to 0 W"
elif self.maint.phase != IDLE:
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
# of one this is the string it has always been, character for
# character; at two, "Maintenance: drain" without a name is a
# banner an operator cannot act on, because the remedy is at one
# device and the site has two.
level, banner = "warn", f"Maintenance: {self.maint.phase}"
if len(self.inverters) > 1:
banner += f" ({self.maint_unit.name})"
else:
level, banner = "ok", "Running — holding grid at zero"
def w(v):
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
# ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. One date on a
# site with two schedules has to be the one that comes first, or the row
# says "2026-10-01" while a pack recalibrates tomorrow. `None` means
# "never run, due now" and has to win over any date, so it is tested for
# rather than sorted with. At a roster of one this is that pack's own
# date, unchanged.
_now = datetime.now(timezone.utc)
_dues = [m.next_due(_now) for m in self.maints]
next_due = None if any(d is None for d in _dues) else min(_dues)
rows = [
("Grid", w(self.grid)),
("Battery", w(self.site_batt_w)),
# SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is not
# derivable from. "Grid 0 W, Battery 0 W, Circulating 6000 W" is the
# whole card in three rows: both of the numbers an operator normally
# reads say the site is idle, and the third says 6 kW is going round
# between the packs. Only shown at N>=2 - at one inverter it is
# identically zero and a permanent "0 W" row would be noise.
*(() if len(self.inverters) <= 1
else ((("Circulating", w(self.site_circulating_w)),))),
("State of charge", "—" if self.pack_floor_soc is None
else f"{self.pack_floor_soc:g} %"),
("Commanded", w(self.target)),
("Why", self.reason),
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
else f"{self.maint.phase} ({self.maint_unit.name})"),
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
# page - where `_maint_deferred` is empty on every tick - carries
# exactly the rows it carried before.
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
for n, why in self._maint_deferred)),)
if self._maint_deferred else ()),
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
# OPT-04. The strategy's own sentence, not a re-derivation of it -
# every refusal it can make already carries a reason a human can
# act on, and "Arbitrage: no" would throw all of them away.
("Arbitrage", "off (arbitrage_enabled)" if self.arb is None
else self.arb.explain()),
]
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:
# ⚠️ 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.
#
# ⚠️ IT CLEARS EVERY INVERTER'S LATCH, AND SAYS SO PER INVERTER.
# One button is what the UI has and one button is what §15's
# banner tells the operator to press; what must not happen is a
# press that clears A while B stays latched with nothing on the
# page changing. Each latch is cleared under its OWN key, so an
# install that had only B tripped comes back with only B's
# history cleared.
for inv in self.inverters:
if not inv.sign_wd.tripped:
continue
inv.sign_wd.reset()
inv.store_set("sign_tripped", False)
inv.store_set("sign_reason", "")
self.log_event(f"sign-disagreement failsafe cleared by "
f"operator on {inv.name}")
# ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, UNCONDITIONALLY.
# This is what makes `flat True + namespaced False` MEAN
# something, and it is the reason `_grade_store_migration()` can
# run on every start instead of only on the start that migrates.
#
# Without it the flat key is a fossil that says `true` forever
# on any install that ever tripped, so a guard comparing against
# it would re-arm the failsafe on every restart for the rest of
# the install's life - which is why that guard used to be gated
# on `rep.ran`, and why the gate then swallowed the rollback
# ROUND TRIP: migrate, downgrade, trip during the downgrade
# window (the old code writes only the flat key), upgrade again
# -> `rep.ran` is False, the guard never runs, and the
# controller comes up UNLATCHED while /data/state.json says
# `sign_tripped: true`. That is store.py's own header sentence,
# rebuilt by the version marker.
#
# Cleared here, the two spellings can only disagree one way: the
# flat key is newer than the namespaced one, which means it was
# written by a version that did not know about namespaces - i.e.
# a trip inside a downgrade window. Unconditional rather than
# inside the loop because an operator pressing START is
# declaring the whole site clear, and the fossil belongs to no
# unit in particular.
self.store.set("sign_tripped", False)
self.store.set("sign_reason", "")
# SAFETY-30's SITE latch, cleared by the SAME press and for the
# same reason the per-unit ones are: the condition always goes
# away on its own, because the trip commands 0 W everywhere and
# a site commanding zero cannot oppose anything. Only a human
# who has read the banner can say the site is clear.
#
# ⚠️ UNCONDITIONAL, LIKE THE FLAT PAIR ABOVE, AND NOT GUARDED ON
# THE ROSTER. An install that ran with two inverters, tripped,
# and was then reconfigured back to one would otherwise carry
# `site_sign_tripped: true` in its file forever, with no roster
# that can reach the code which clears it - and would re-arm the
# moment a second inverter was configured again.
if self.site_sign_wd.tripped:
self.log_event("site sign-disagreement failsafe cleared "
"by operator")
self.site_sign_wd.reset()
self.store.set("site_sign_tripped", False)
self.store.set("site_sign_reason", "")
# SAFETY-31, cleared by the SAME press and for the same reason:
# the condition always goes away on its own, because the trip
# commands 0 W everywhere and two inverters at zero cannot
# oppose. Only a human who has read the banner can say the site
# is clear.
#
# ⚠️ AND `_circ_announced` GOES WITH IT. It is the "has a human
# been shown this" flag; leaving it True through a clear would
# mean the NEXT trip in this process latched, stopped the site,
# and raised no notification at all.
if self.circ_wd.tripped:
self.circ_wd.reset()
self.store.set("circ_tripped", False)
self.store.set("circ_reason", "")
self.log_event("circulating-power trip cleared by operator")
self._circ_announced = False
if not self.auto:
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero().
# Zeroing `self.target` alone would leave each unit's last share
# standing for its next heartbeat to re-emit, which an operator
# would read as the stop button not working.
await self.command_site_zero()
return {"auto": self.auto}
if what == "maint_start":
# ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
# ONE LABEL. `_maint_button()` answers it: the pack that holds the
# site's maintenance slot if one does - which is what "start"
# already meant at a roster of one, where pressing it mid-drain
# re-entered DRAIN - and otherwise the pack that has gone longest
# without a cycle, which is the same pack `arbitrate()` would have
# picked on its own. At N=1 both branches are `maints[0]` and this
# is the shipped call, byte for byte.
#
# A PER-PACK BUTTON IS A UI CARD, NOT THIS ONE. `web.py` posts a
# bare `maint_start` with no unit, and inventing a unit parameter
# here would ship half a surface: the page would still have one
# button and the API would have two meanings.
for msg in self._maint_button(now).force_start(now):
self.log_event(msg)
return {"phase": self.maint.phase}
if what == "maint_abort":
# Aborts the pack that is actually cycling - `_maint_button()` again
# - and then zeroes the SITE, which is what it always did.
for msg in self._maint_button(now).abort(now, "operator"):
self.log_event(msg)
await self.command_site_zero()
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",),
# TEL-07. ⚠️ THE NAMES, NOT A ROSTER. `build_roster()` here
# would construct a SECOND set of `Inverter`s off the same
# Store, minutes before `Controller.__init__` constructs the
# real ones; `roster_names()` is the naming rule on its own and
# is what `build_roster()` itself reads. At a roster of one this
# announces nothing new - see `announced_units()`.
units=roster_names(opts),
)
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())]
# OPT-04. The tariff refresh runs as its OWN long-lived task, for the
# reason P1 ingestion does and then some: `resolve_tariff()` may open an
# HTTPS connection to a supplier, and the control tick is what feeds the
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
#
# ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on
# reads the resolved plan - `cheap` still comes from static_status() on
# the tick, which is pure - so on a default install this task would be
# network traffic in aid of an attribute nobody reads.
if arbitrage.enabled(opts):
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
# 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()