checks / unit (pull_request) Successful in 4m15s
PR #43 was rejected on two findings and had gone unmergeable behind SAFETY-28 and the ALLOC-01 fix. This is the merge commit; the conflict resolution and both fixes are below, because a merge that also changes behaviour has to say so. THE MERGE, AND WHAT IT COST =========================== Two conflicts in main.py, both where SAFETY-28 and this card touched the same method: * `write_setpoint()` split into a site entry point plus a per-unit `_write_unit()` here, while SAFETY-28 put its record gate in the old single-unit body. `write_refusal()` now takes the INVERTER it is about. Asking it with `self.unit` - as release/1.0 does - would authorise A's share on B's wire, and would also trip test_inverter.py part 1's AST scan, which goes red on any read of a Controller N=1 view inside Controller. * the SAFETY-28 gate sits above the clamp and below the non-finite coercion, exactly where release/1.0 put it, and is now asked about `inv`. Fallout the merge produced in the checks, re-aimed rather than relaxed: * every fixture that calls `write_setpoint()` DIRECTLY - the clamp probe, the hung-heartbeat experiment and its serialised counterfactual, the split deadline probe - now arms a real allocation record first. test_inverter.py grows an `arm()` built through the shipped `resolve()`, the same shape test_clamp.py's is: the per-unit envelopes are filed as real `supervised` claims and the site target is their sum, so the water-fill has exactly one answer and the fixture cannot quietly become its own allocator. * test_clamp.py's two-unit failsafe fixture assigned `c.units`, which DEV-02 made a derived property with no setter. It appends a real `Inverter`. BLOCKER 1 - AC 4's ARBITER-BOUND EVIDENCE WAS VACUOUS ===================================================== test_peakguard.py claimed "each unit's `supervised` bound is ITS OWN `max_w` - give inverter B a different rating and the claim filed on inverter B moves with it", and compared B's bound against `_b2.max_w`. `second_inverter()` DEFAULTED B's rating to `LIVE_MAX_W`, which is A's. IT COMPARED (-5000, 5000) AGAINST (-5000, 5000): a number against itself, unable to observe the failure it named. Reproduced: filing the per-unit `device`/`supervised` claims from `self.inverters[0]`'s numbers while leaving `actuator=self.actuator_of(..., inv)` alone left ALL 19 FILES GREEN. At a roster of two with A=5000/B=3000 that makes inverter B's planned envelope (-5000, 5000) instead of (-3000, 3000) - a 2000 W over-command the arbiter believes is supervised. It is a DIFFERENT defect from the actuator variant the static routing pin catches, and only the actuator variant was in the mutation table. * `second_inverter()` gives B 3000 W - a hypothesis, marked as one - for every fixture in the file, so no check there can compare B against A by accident. * the claim is asserted against LITERAL numbers rather than against an expression that can read the same object twice, for `supervised` AND for `device` (whose `dev` had the same defect), plus the envelope the ARBITER RESOLVED for B - a claim filed right and resolved wrong is the same defect one layer down. * a PRECONDITION check fails if the two ratings are ever made equal again. * the BOUND mutation is in the table, beside the actuator one, and was observed RED (3 checks in test_peakguard.py) via tools/mutation_run.sh. ⚠️ THIRD INSTANCE THIS SESSION of a check whose DATA cannot reach the failure it names - ARB-03's call-site scan saw 3 of 8 appends, ALLOC-01's replay generator saw 0 escapes where a widened one saw 39,991. MUTATION.md gains a section for it beside the stale-bytecode and masking ones, and the rule is written at the line: ANY CHECK ASSERTING "X DIFFERS FROM Y" MUST BE RUN ONCE WITH X AND Y ACTUALLY MADE DIFFERENT. BLOCKER 2 - THE LATCH WAS SILENTLY DISARMED BY THE ROLLBACK ROUND TRIP ====================================================================== store.py's rule 2 keeps the flat keys "so a rollback still finds its latch". The operator path that rule contemplates is a ROUND TRIP, and this card lost the latch on it: 1. install migrates -> schema_version=1, unit/inverter-a/... = False 2. operator DOWNGRADES; SAFETY-02 trips inside the downgrade window and the old add-on writes only the flat `sign_tripped: true` 3. operator upgrades again -> `rep.ran` is False, so `_grade_store_migration()` returned early, and `Inverter.__init__` read the namespaced key: False Observed: the controller came up with `sign_wd.tripped = False` while /data/state.json said `sign_tripped: true`. That is store.py's own header sentence - "the file still says sign_tripped: true, the banner is green, and the controller resumes driving the wrong way" - rebuilt by the version marker. A firmware version change with a rollback path was performed on this site on 2026-09-02, so this is not hypothetical. Three changes, each graded by a mutation observed RED: * THE OPERATOR'S CLEAR CLEARS BOTH SPELLINGS. That is what removes the "re-arm forever" objection the `rep.ran` gate existed to answer: with the fossil cleared, `flat True + namespaced False` is unambiguously a downgrade-window latch. * A TRIP MIRRORS THE FLAT PAIR. Rule 2 only ever covered the latch present AT MIGRATION TIME - nothing flat was written for a trip taken afterwards, so an operator who downgraded found `sign_tripped: false` and came up DRIVING. store.py claimed the staleness could only fail toward a SPURIOUS latch. It could not; that sentence is corrected rather than left standing. * THE GUARD RUNS ON EVERY START, and asks the STORE - not the roster - whether anything namespaced is latched. A mirrored trip on B must not re-arm a healthy A, and the store is the right place to ask because it holds every `unit/*/sign_tripped` this install has written, including one belonging to a unit that is not in today's roster. Graded by the three-step scenario itself, run end to end in test_inverter.py part 2, with all three preconditions asserted - including that the catching start did NOT migrate anything, which is what used to skip the guard. Part 2's idempotence check now PRESSES the button instead of hand-writing the store, because the state it used to construct is not one the shipped clear path can produce. Part 3 grades the mirror and the clear on a real roster of two. test_watchdog.py's "the flat key is NOT written" assertion is re-aimed rather than deleted: its argument was right about the READ and wrong about the WRITE. Nothing at runtime reads that key, and the one thing that does ignores it whenever any namespaced latch is set - which a sibling's trip always leaves. What its absence actually cost was the rollback. HANDOVER.md and DOCS.md said the flat key "is never read again", which was the instruction an operator clearing a trip by hand would follow. Both corrected: it is a maintained rollback view, it IS read on every start, and clearing a trip by hand means clearing both spellings. MUTATIONS THIS ROUND, each applied on a purged tree via tools/mutation_run.sh and observed RED: * per-unit `device`/`supervised` BOUNDS from `self.inverters[0]`, actuator left correct -> test_peakguard.py 10g * `_grade_store_migration()` gated on `rep.ran` -> test_inverter.py part 2 * the operator's clear leaving the flat keys set -> test_inverter.py part 2/3 * a trip not mirroring the flat pair -> test_inverter.py part 3 * the guard asking only `self.inverters[0]` -> test_inverter.py part 3 19/19 files green on the merged tree. No hardware was touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2971 lines
171 KiB
Python
2971 lines
171 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 .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
|
|
from .maintenance import IDLE, MaintConfig, Maintenance
|
|
from .mqtt import MqttPublisher
|
|
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_unit_keys
|
|
from .validate import ConfigRefused, refuse, validate_startup_config
|
|
from .watchdog import 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.
|
|
sign_cfg = SignWatchdogConfig(
|
|
min_w=float(opts.get("sign_disagree_w", 300)),
|
|
hold_s=float(opts.get("sign_disagree_s", 180)),
|
|
)
|
|
self.store_migration = migrate_unit_keys(store, UNIT_A)
|
|
self.inverters = build_roster(opts, store, sign_cfg)
|
|
|
|
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,
|
|
slew_w=float(opts.get("slew_w", 1000)),
|
|
deadband_w=float(opts.get("deadband_w", 15)),
|
|
target_grid_w=float(opts.get("target_grid_w", 0)),
|
|
step_w=int(opts.get("step_w", 10)),
|
|
saturation_w=float(opts.get("saturation_w", 500)),
|
|
saturation_cycles=int(opts.get("saturation_cycles", 3)),
|
|
# 0 / unset means "follow max_w", which is the recommended
|
|
# value. Read the note in control.py before raising it above
|
|
# max_w: every watt above the rail is unwind latency.
|
|
integrator_max_w=(float(opts["integrator_max_w"])
|
|
if opts.get("integrator_max_w") else None),
|
|
)
|
|
# ⚠️ 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.
|
|
self.maint = Maintenance(MaintConfig.from_options(opts), store)
|
|
|
|
# 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-18. The frozen-but-fresh meter detector - see freeze.py for why
|
|
# it is a conjunction and not an `unchanged_s` threshold, and for the
|
|
# measurements behind the default.
|
|
#
|
|
# ⚠️ NOT restored from the store, unlike SAFETY-02's latch, and the
|
|
# asymmetry is the point: this one does not latch because it does not
|
|
# act. A restart re-derives its state from the meter within one
|
|
# `meter_freeze_s` window, and until then the honest report is "not
|
|
# alarming yet" rather than an alarm nobody can clear.
|
|
self.freeze_wd = FreezeWatchdog(
|
|
FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300))))
|
|
|
|
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
|
|
# itself is the cheap half.
|
|
#
|
|
# ⚠️ NO CONFIG OF ITS OWN, on purpose. The bound it clamps to is `max_w`,
|
|
# which is already an option, already validated against the device's
|
|
# rating at startup, and already used by Tuning and the arbiter. Giving
|
|
# this a second key would be a fourth place a limit has to be kept in
|
|
# step (CFG-03 already tracks three) and, worse, would recreate exactly
|
|
# the two-numbers-that-disagree failure the §14 warning box describes.
|
|
# The anti-flood cadence is a module constant for the same reason
|
|
# freeze.RAIL_FRAC is: it describes the event bus, not the plant.
|
|
self.clamp_wd = ClampWatchdog()
|
|
|
|
# P1 ingestion (TEL-01). `meter_source: off` keeps the original
|
|
# single-entity meter_entity path, so an existing install is unchanged
|
|
# until it opts in.
|
|
self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)),
|
|
max_age_s=float(opts.get("meter_max_age_s", 30)))
|
|
self.p1_enabled = is_enabled(opts)
|
|
|
|
# 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
|
|
# 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_unit(self):
|
|
"""The pack the ONE maintenance state machine is recalibrating.
|
|
|
|
⚠️ NAMED RATHER THAN ASSUMED. This add-on holds exactly one
|
|
`Maintenance`, whose history lives under the store's un-namespaced
|
|
`phase` / `last_completed` keys, so the pack it drives is the first in
|
|
the roster. Two packs recalibrating on two schedules needs a second FSM
|
|
and a namespaced history of its own; what THIS card fixes is only that
|
|
the claim is filed on ONE actuator instead of on whichever one happened
|
|
to be asked. Read store.LEGACY_UNIT_KEYS on why those keys did not move.
|
|
"""
|
|
return self.inverters[0]
|
|
|
|
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
|
|
|
|
# -- 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 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 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)
|
|
|
|
# ⚠️ THE PACK IT IS MAINTAINING, NOT "the SoC". §12's recalibration
|
|
# drains and refills ONE pack and grades ONE coulomb counter, so it is
|
|
# graded on that pack's own reading - see `maint_unit`. A minimum across
|
|
# packs would abort the cycle on a sibling's state, and an average would
|
|
# declare a pack full that is not.
|
|
result = self.maint.tick(now, self.maint_unit.soc)
|
|
for msg in result.events:
|
|
self.log_event(msg)
|
|
|
|
# --- SAFETY-02, and it runs BEFORE the early return below -------------
|
|
# ⚠️ ORDER MATTERS HERE. If this were evaluated after the "inputs missing
|
|
# but not yet stale" hold, a run of held cycles would leave
|
|
# `disagree_since` untouched across the gap and the timer would resume
|
|
# from a start time that predates it. It is evaluated every cycle,
|
|
# unconditionally, so a missing meter CLEARS the window - see watchdog.py.
|
|
#
|
|
# The value compared is the command STANDING ON THE WIRE, which is what
|
|
# this cycle's fresh meter reading is a response to. `last_write` is that
|
|
# value after the device clamp and confirmed accepted; `target` covers
|
|
# the case where nothing has been written yet (and, deliberately, the
|
|
# case where the last write was REJECTED - the inverter is then still
|
|
# latched on `last_write`, which is exactly what `last_write` holds).
|
|
# ⚠️ ONE WATCHDOG PER INVERTER, EVALUATED ON THAT INVERTER'S OWN
|
|
# STANDING COMMAND. The meter is site-wide - there is one P1 - but 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.
|
|
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
|
|
if inv.sign_wd.evaluate(stood, self.grid, self.following, now) \
|
|
and not was_tripped:
|
|
# ⚠️ REASON FIRST, FLAG SECOND, and each `set` writes the file.
|
|
# The ordering is the whole atomicity story: a crash between the
|
|
# two persists a reason nothing reads, which is harmless, where
|
|
# the other order persists a latch with no explanation for the
|
|
# operator who has to decide whether to clear it.
|
|
#
|
|
# ⚠️ 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()))
|
|
|
|
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.
|
|
if not self.inputs_ok and not any(i.sign_wd.tripped
|
|
for i in self.inverters):
|
|
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)))
|
|
|
|
# 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,
|
|
soc_floor=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 -----------------------------------------------------
|
|
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 UNIT BEING MAINTAINED, WHICH IS THE FIRST ONE.
|
|
# §12's monthly recalibration is a PACK procedure and this add-on
|
|
# holds exactly ONE `Maintenance` state machine, so the pack it is
|
|
# maintaining is `self.maint_unit`. Two packs recalibrating on two
|
|
# schedules is a second FSM with its own namespaced history, which
|
|
# is a card of its own - what this card must not do is file one
|
|
# pack's procedure on every actuator.
|
|
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
|
float(result.setpoint_w or 0.0), result.phase,
|
|
actuator=self.actuator_of("maintenance",
|
|
self.maint_unit)))
|
|
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.
|
|
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.
|
|
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.
|
|
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason,
|
|
actuator=self.actuator_of("maintenance",
|
|
self.maint_unit)))
|
|
|
|
# --- 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-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} %")
|
|
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."),
|
|
})
|
|
|
|
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", "")
|
|
if 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.
|
|
# ⚠️ GRADED AGAINST THE FIRST UNIT'S RANGE, because `validate.py`
|
|
# grades the FLAT options - `max_w`, `setpoint_entity` - which are
|
|
# unit A's. A per-unit validator arrives with CFG-04's per-unit
|
|
# options; inventing one here would be this file asserting a schema
|
|
# for a config that does not exist yet.
|
|
_first = self.inverters[0]
|
|
problems = validate_startup_config(self.o, _first.dev_min,
|
|
_first.dev_max)
|
|
if problems:
|
|
refuse(problems)
|
|
self.refused = problems
|
|
self.log_event("REFUSED: " + problems[0])
|
|
# Signal, then return WITHOUT entering the loop. amain() wakes
|
|
# on this, runs the normal wind-down (which commands 0 W) and
|
|
# re-raises ConfigRefused afterwards, so the exit code is 78 and
|
|
# the inverter is left at zero rather than latched.
|
|
self.stop_event.set()
|
|
return
|
|
|
|
last_grid = object()
|
|
heartbeat = float(self.o.get("heartbeat_s", 10))
|
|
# ⚠️ 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.
|
|
"status": ("failsafe" if any(i.sign_wd.tripped
|
|
for i in self.inverters)
|
|
else "running" if self.auto else "stopped"),
|
|
}
|
|
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
|
|
# watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id
|
|
# note in mqtt.py; the composed name is not the object_id - and forces layer-1
|
|
# failsafe once it reaches max_age_s. With meter_source off there is no
|
|
# ingester feeding it, so published_age_s would be time-since-startup
|
|
# climbing without bound - i.e. every existing install would cross the
|
|
# threshold within 30 s and pin its inverter at 0 W forever. Publishing
|
|
# nothing leaves the entity non-existent, which is the status quo and
|
|
# what has_state() in the firmware is checking for.
|
|
if self.p1_enabled:
|
|
# Recomputed here, once a second, on purpose - see P1Ingest.
|
|
values["p1_age"] = round(self.p1.published_age_s, 1)
|
|
self.mqtt.publish(values)
|
|
|
|
async def shutdown(self) -> None:
|
|
"""Deterministic wind-down. Do not skip this."""
|
|
self.stopping = True
|
|
_LOG.info("shutting down - commanding 0 W 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.
|
|
for inv in self.inverters:
|
|
tag = "" if len(self.inverters) == 1 else f" [{inv.name}]"
|
|
if inv.sign_wd.tripped:
|
|
out.append({"ok": False, "warn": False,
|
|
"text": f"SIGN-DISAGREEMENT FAILSAFE{tag} - "
|
|
+ inv.sign_wd.reason
|
|
+ ". The inverter is held at 0 W. Find out why the "
|
|
"command opposed the meter before starting control "
|
|
"again; starting clears this. Restarting the "
|
|
"add-on does NOT clear it."})
|
|
else:
|
|
held = inv.sign_wd.held_s(datetime.now(timezone.utc))
|
|
out.append({"ok": True, "warn": False,
|
|
"text": f"sign watchdog{tag}: armed (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 "")})
|
|
|
|
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 any(i.sign_wd.tripped for i in self.inverters):
|
|
# Outranks "NOT READY": nothing is missing, something went wrong.
|
|
level, banner = "bad", ("FAILSAFE — the command opposed the meter. "
|
|
"Inverter held at 0 W.")
|
|
elif bad:
|
|
# ⚠️ `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:
|
|
level, banner = "warn", f"Maintenance: {self.maint.phase}"
|
|
else:
|
|
level, banner = "ok", "Running — holding grid at zero"
|
|
|
|
def w(v):
|
|
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
|
|
|
|
next_due = self.maint.next_due(datetime.now(timezone.utc))
|
|
rows = [
|
|
("Grid", w(self.grid)),
|
|
("Battery", w(self.site_batt_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),
|
|
("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", "")
|
|
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":
|
|
for msg in self.maint.force_start(now):
|
|
self.log_event(msg)
|
|
return {"phase": self.maint.phase}
|
|
if what == "maint_abort":
|
|
for msg in self.maint.abort(now, "operator"):
|
|
self.log_event(msg)
|
|
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",),
|
|
)
|
|
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()
|