checks / unit (pull_request) Successful in 4m19s
specs §1.5 makes "any configuration in which two writers can reach one
actuator" a non-goal. That non-goal was enforced by counting config keys:
validate.py's ACTUATOR_WRITE_KEYS = ("setpoint_entity",), consumed at rule 1,
with a comment stating the guarantee outright - "there is exactly one such
key, so a second writer is not expressible in the options schema at all".
That was true and it graded THE SHAPE OF THE OPTIONS SCHEMA, not the writes.
Add CFG-04's per-inverter config - a roster of inverters each with its own
setpoint_entity - and there are two real writers with len() of that tuple
still equal to 1. The check stays green while the property it protects is
gone. sim/inject.py's two_writers scenario asserted on the same tuple, so it
went green too. That is this repository's named failure mode: a check that
observes something other than what it claims to observe.
Demonstrated rather than argued. test_validate.py part 2 now constructs the
hazardous per-inverter config, asserts that the OLD proxy is still green on
it, and then asserts the new check refuses it. Against release/1.0's
validator the updated sim scenario reports: "validate_startup_config()
ACCEPTED a configuration in which 3 declarations reach the ONE actuator
number.goodwe_setpoint ... the old proxy reports 1 write key(s) on this same
config and is therefore GREEN on it".
THE WRITE PATH IS NOW GATED ON THE RECORD
=========================================
Controller.write_refusal() asks Allocation.authorises() whether the single
allocator placed exactly this number, for this unit, on this tick.
write_setpoint() refuses anything else. A second writer with arithmetic of
its own cannot satisfy that - its number appears in no 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 something the write path checks.
The refusal is a SUBSTITUTION, not a return: 0 W goes on the wire in place of
the number nothing stands behind. Returning would leave the inverter latched
on its last command with the ESP32 heartbeat still being fed - holding an
unauthorised setpoint quietly, which is the outcome the gate exists to stop.
AC 1 is "refused at the write path, not merely logged", and test_clamp.py
part 7a grades the device's writes, not the log.
The gate sits BELOW the non-finite coercion and ABOVE the clamp. Below,
because a nan is loss of input and rule 1 has already turned it into 0 W.
Above, because the question is who chose this number, and the answer is only
meaningful about the number that was asked for - gating the CLAMPED value
would refuse exactly the cycles SAFETY-03 exists for, since a clamped write
differs from the allocator's share by construction. Mutation M7 moves the
gate below the clamp and turns nine checks red, part 6c among them.
A STALE RECORD CANNOT BE REPLAYED
=================================
Every Allocation now carries the `tick` it was made for. It is handed IN by
the caller - plan(..., tick=) - rather than counted inside the allocator,
because test_alloc.py part 10 grades that the module holds no state at all,
and a serial number incremented in there would be the cached-across-ticks
thing the envelope rule forbids wearing a different hat.
Controller.alloc_tick advances ONCE PER RESOLUTION, not once per loop
iteration, and both halves are load-bearing:
* a new allocation makes the previous record stale immediately, so a record
whose envelopes have moved cannot authorise a write. That is the replay
half (AC 2), graded by capturing a real record, running another real
cycle, putting the old one back and observing refusal.
* the "inputs missing, not yet stale" hold returns BEFORE the allocator
runs, exactly so one dropped meter reading does not zero the inverter,
and the heartbeat re-affirms self.target through this same gate while it
does. A tick incremented per loop iteration would refuse that heartbeat
and turn the hold into the outage it exists to prevent. Graded.
An UNSTAMPED record (tick None) authorises nothing: "I cannot say when I was
made" fails closed rather than reading as tick zero.
THE FAILSAFE CANNOT DEADLOCK BEHIND THE GATE, AND HERE IS WHY
=============================================================
write_setpoint() is also how layer 2 commands 0 W. cycle()'s two refusal
branches substitute a 0 W failsafe and write it like any other cycle;
shutdown() and the operator's stop button do the same. EVERY ONE OF THOSE
RUNS WHILE THE ALLOCATOR HAS REFUSED - ok is False and there are no shares -
so a gate that asked the allocator's permission to command zero would block
the safety write behind the very component whose failure the safety write
answers. A gate that can do that is worse than the hole it closes.
Resolved by authorising 0 W unconditionally, and on the VALUE rather than on
a failsafe=True flag: a flag is an assertion by the caller, which is exactly
what a second writer would pass; the value is a fact about what will be
written. The argument that this is safe and not merely convenient is that the
hazard §1.5 names is N-times loop gain from BROADCAST - handing every inverter
the same target so each supplies it in full (allocator.py's opening argument;
TEST-19 measured a permanent 4000 W p2p limit cycle at gain 1.0 with no guard
firing). At S = 0 the broadcast and the correct allocation COINCIDE: a_i = 1
for every unit gives every unit 0 W, which is exactly its share. There is no
over-command to make at zero. Said at the line, in write_refusal().
test_clamp.py part 7c drives it end to end through the real cycle() and the
real REFUSE_EMPTY branch: the allocator refuses every tick, the device still
sees 0 W, and the action log says ARBITER: REFUSED rather than WRITE REFUSED
so the two failures stay distinguishable to whoever has to debug them.
unmet_w IS STILL NOT ok
=======================
The gate reads ok and the unit's share and never unmet_w. allocator.py:179
says why: an allocation that could not place every watt is still safe to
write, and fusing them would let one saturated inverter park the whole site.
Graded on a record with 3800 W unmet and every share legal - mutation M5 adds
`or self.unmet_w` to the gate and that check goes red.
THE CONFIG CHECK, REPLACED RATHER THAN DELETED
==============================================
actuator_writers() counts WRITERS PER ACTUATOR instead of option keys. It
walks the options TREE rather than looking for a container called `inverters`,
because CFG-04 has not chosen that name yet and a census that guessed wrong
would go green while two writers existed - the same defect one step along.
Entity ids are folded to lower case so a second writer cannot be smuggled in
as Number.X versus number.x.
Two inverters with two entities are ACCEPTED - that is what the per-inverter
roster is for, and a validator that refuses the feature it guards is one
somebody switches off. Two declarations aimed at ONE entity are refused,
naming the actuator and every writer pointed at it. That includes the case an
author will meet first - keeping setpoint_entity as A's and listing A again -
because "they happen to be the same entity, so it is fine" is precisely the
reasoning that makes two writers invisible.
ACTUATOR_WRITE_KEYS survives ONLY as the enumeration a census needs, with its
guarantee comment deleted; nothing asserts on its length any more. Keeping the
name is also what stops admin/goodwe's current two_writers scenario failing on
an AttributeError between the two merges.
WHAT NEITHER CHECK OBSERVES, SAID OUT LOUD
==========================================
The census sees CONFIGURED writers. A second write path added in CODE - a
hass.set_number() call next to some new strategy - declares nothing and is
invisible to it, exactly as it was invisible to the tuple. That half is not
closed at startup and cannot be; it is closed at runtime by the record gate,
which does not care what the options look like or who called the method. The
two checks are deliberately different kinds, and only the runtime one survives
a schema this file has not seen yet.
EVIDENCE
========
* Full 18-file suite green on a purged tree (tools/mutation_run.sh).
* Verified by breaking it - seven mutations, each on a purged tree, each
reverted and re-verified:
M1 remove the gate -> test_clamp red (4 checks)
M2 make the zero exit an amnesty -> test_clamp red (12 checks)
M3 drop the tick comparison -> test_clamp red (5 checks)
M4 delete the census refusal -> test_validate red (3 checks)
M5 fuse unmet_w into ok -> test_clamp red (1 check)
M6 freeze the tick on the live path -> test_clamp red (2 checks)
M7 move the gate below the clamp -> test_clamp red (9 checks)
* AC 5: no existing check was weakened. The two edits to test_clamp.py are
fixtures, not assertions - parts 5 and 6c call write_setpoint() directly
with a number nothing allocated, which is now refused, so they say where
their number came from. Every assertion in both parts is unchanged, and
part 7f asserts that across part 3's sixty real cycles the gate refused
zero writes.
* admin/goodwe's TEST-19 norig scenarios (planner_binding, two_master_plant,
two_master_broadcast) still pass against this tree - they import the
add-on's control law across the repo boundary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2136 lines
123 KiB
Python
2136 lines
123 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, ClampResult, ClampWatchdog, clamp_to_rating,
|
|
is_commandable,
|
|
)
|
|
from .freeze import (
|
|
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
|
|
)
|
|
from .hass import HomeAssistant
|
|
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 .validate import ConfigRefused, refuse, validate_startup_config
|
|
from .watchdog import SignWatchdog, SignWatchdogConfig, loop_is_following
|
|
from . import web
|
|
|
|
OPTIONS_PATH = "/data/options.json"
|
|
_LOG = logging.getLogger("goodwe")
|
|
|
|
# The one inverter this add-on drives. ⚠️ A NAME, NOT A CONFIGURATION. It exists
|
|
# so that a per-inverter claim has an actuator to be filed ON, which is what
|
|
# makes the site/unit split something a check can grade rather than a comment.
|
|
# ALLOC-01 replaces this with a real roster when inverter B arrives.
|
|
UNIT_A = "inverter-a"
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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
|
|
|
|
self.tuning = Tuning(
|
|
gain=float(opts.get("gain", 0.6)),
|
|
max_w=float(opts.get("max_w", 2000)),
|
|
slew_w=float(opts.get("slew_w", 1000)),
|
|
deadband_w=float(opts.get("deadband_w", 15)),
|
|
target_grid_w=float(opts.get("target_grid_w", 0)),
|
|
step_w=int(opts.get("step_w", 10)),
|
|
saturation_w=float(opts.get("saturation_w", 500)),
|
|
saturation_cycles=int(opts.get("saturation_cycles", 3)),
|
|
# 0 / unset means "follow max_w", which is the recommended
|
|
# value. Read the note in control.py before raising it above
|
|
# max_w: every watt above the rail is unwind latency.
|
|
integrator_max_w=(float(opts["integrator_max_w"])
|
|
if opts.get("integrator_max_w") else None),
|
|
)
|
|
# ⚠️ 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, one actuator, one cadence - see watchdog.py.
|
|
#
|
|
# ⚠️ RESTORED FROM THE STORE, and that is the difference between a latch
|
|
# and a pause. A trip held only in memory is cleared by the one action an
|
|
# operator staring at a FAILSAFE banner is most likely to take - restart
|
|
# the add-on - and the controller then comes back armed, into the fault,
|
|
# with nobody involved. Restored here, the banner and the 0 W survive the
|
|
# restart and only `auto_toggle` clears them.
|
|
self.sign_wd = SignWatchdog(
|
|
SignWatchdogConfig(
|
|
min_w=float(opts.get("sign_disagree_w", 300)),
|
|
hold_s=float(opts.get("sign_disagree_s", 180)),
|
|
),
|
|
tripped=bool(store.data.get("sign_tripped", False)),
|
|
reason=str(store.data.get("sign_reason", "") or ""),
|
|
)
|
|
if self.sign_wd.tripped:
|
|
_LOG.error("SAFETY-02 was TRIPPED before this restart and stays "
|
|
"tripped: %s. The inverter is held at 0 W. Restarting the "
|
|
"add-on does not clear this - start control from the UI "
|
|
"once you know why the command opposed the meter.",
|
|
self.sign_wd.reason or "(reason not recorded)")
|
|
|
|
# SAFETY-18. The frozen-but-fresh meter detector - see freeze.py for why
|
|
# it is a conjunction and not an `unchanged_s` threshold, and for the
|
|
# measurements behind the default.
|
|
#
|
|
# ⚠️ NOT restored from the store, unlike SAFETY-02's latch, and the
|
|
# asymmetry is the point: this one does not latch because it does not
|
|
# act. A restart re-derives its state from the meter within one
|
|
# `meter_freeze_s` window, and until then the honest report is "not
|
|
# alarming yet" rather than an alarm nobody can clear.
|
|
self.freeze_wd = FreezeWatchdog(
|
|
FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300))))
|
|
|
|
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
|
|
# itself is the cheap half.
|
|
#
|
|
# ⚠️ NO CONFIG OF ITS OWN, on purpose. The bound it clamps to is `max_w`,
|
|
# which is already an option, already validated against the device's
|
|
# rating at startup, and already used by Tuning and the arbiter. Giving
|
|
# this a second key would be a fourth place a limit has to be kept in
|
|
# step (CFG-03 already tracks three) and, worse, would recreate exactly
|
|
# the two-numbers-that-disagree failure the §14 warning box describes.
|
|
# The anti-flood cadence is a module constant for the same reason
|
|
# freeze.RAIL_FRAC is: it describes the event bus, not the plant.
|
|
self.clamp_wd = ClampWatchdog()
|
|
|
|
# P1 ingestion (TEL-01). `meter_source: off` keeps the original
|
|
# single-entity meter_entity path, so an existing install is unchanged
|
|
# until it opts in.
|
|
self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)),
|
|
max_age_s=float(opts.get("meter_max_age_s", 30)))
|
|
self.p1_enabled = is_enabled(opts)
|
|
|
|
# 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 ---------------------------
|
|
# ⚠️ ONE INVERTER, AND THE ROSTER IS WRITTEN DOWN RATHER THAN ASSUMED.
|
|
# 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. The day this tuple gains a second entry, the
|
|
# per-inverter claims stop being site claims on their own, which is
|
|
# exactly the seam ALLOC-01 occupies.
|
|
#
|
|
# ⚠️ INVERTER B DOES NOT EXIST YET. This is not a configuration option
|
|
# and must not be turned into one here; ALLOC-01 owns the roster, the
|
|
# weights and the split. Naming the unit is all ARB-03 needs, and it is
|
|
# what makes a mis-routed claim something a check can see.
|
|
self.unit = UNIT_A
|
|
self.units = (UNIT_A,)
|
|
|
|
# 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 = ()
|
|
self.grid = self.soc = self.batt = 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.last_write = None
|
|
self.last_write_at = None
|
|
self.last_write_ok = None
|
|
self.inputs_bad_since = None
|
|
self.dev_min = self.dev_max = None
|
|
# SAFETY-03. What the last write's clamp did, and what the loop's own
|
|
# bounds did last cycle. `loop_clamped` is a (engaged, note) pair so the
|
|
# alarm can say WHICH bound bit without re-deriving it from a string.
|
|
self.clamp_last: ClampResult | None = None
|
|
self.loop_clamped: tuple[bool, str] = (False, "")
|
|
self.events: list[str] = []
|
|
self.stopping = False
|
|
|
|
# -- helpers -------------------------------------------------------------
|
|
def log_event(self, msg: str) -> None:
|
|
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|
self.events.insert(0, f"{stamp} {msg}")
|
|
del self.events[40:]
|
|
|
|
@property
|
|
def inputs_ok(self) -> bool:
|
|
return None not in (self.grid, self.soc, self.batt)
|
|
|
|
# -- io ------------------------------------------------------------------
|
|
async def read_inputs(self) -> None:
|
|
o = self.o
|
|
if self.p1_enabled:
|
|
# ⚠️ P1 is the only authoritative measurement of what the utility
|
|
# sees (§5.1). When it is stale this is None, which falls into the
|
|
# existing "inputs missing -> command 0 W" path below. There is
|
|
# deliberately NO fallback to an inverter-side figure: the
|
|
# inverter's own AC power correlates 0.998 with battery power and
|
|
# 0.09 with the real meter, so a controller that failed over to it
|
|
# would be regulating against its own output.
|
|
self.grid = self.p1.net_w
|
|
else:
|
|
self.grid = await self.hass.number(o.get("meter_entity", ""),
|
|
bool(o.get("meter_invert")))
|
|
self.soc = await self.hass.number(o.get("soc_entity", ""))
|
|
self.batt = await self.hass.number(o.get("batt_entity", ""),
|
|
bool(o.get("batt_invert")))
|
|
if o.get("peak_forecast_entity"):
|
|
raw = await self.hass.number(o["peak_forecast_entity"])
|
|
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
|
|
self.peak_fc = None if raw is None else (raw * 1000 if abs(raw) < 50 else raw)
|
|
else:
|
|
self.peak_fc = None
|
|
|
|
now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity")
|
|
if now_e and avg_e:
|
|
now_p = await self.hass.number(now_e)
|
|
avg_p = await self.hass.number(avg_e)
|
|
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
|
|
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)
|
|
|
|
def write_refusal(self, value: float) -> str | None:
|
|
"""Why `value` may NOT be written 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.
|
|
"""
|
|
if value == 0.0:
|
|
return None
|
|
rec = self.allocation
|
|
if rec is None:
|
|
return ("no allocation record exists yet - nothing has authorised "
|
|
f"{value:+.0f} W")
|
|
return rec.authorises(self.unit, value, self.alloc_tick)
|
|
|
|
async def write_setpoint(self, value: float, *, force: bool = False) -> None:
|
|
entity = self.o.get("setpoint_entity", "")
|
|
if not entity:
|
|
return
|
|
|
|
# --- SAFETY-03: THE CLAMP, AND IT IS ON THE ONLY PATH TO THE WIRE -----
|
|
# Every write in this add-on comes through here - the cycle, the
|
|
# heartbeat, the operator's stop button and shutdown() - so no optimizer,
|
|
# maintenance or manual path can route around it. That placement is the
|
|
# first acceptance criterion, and it is why the guard is not in
|
|
# control.py: a guard the control law owns is a guard every other claimer
|
|
# bypasses.
|
|
#
|
|
# ⚠️ A NON-FINITE VALUE IS LOSS OF INPUT, NOT AN OUT-OF-RANGE ONE. §11.1
|
|
# rule 1: command zero and keep commanding. It must not reach the clamp
|
|
# arithmetic - see is_commandable() for what a nan does to it - and it
|
|
# must not be counted as a clamp engagement, or a transport bug lands in
|
|
# the winding-loop counter.
|
|
if not is_commandable(value):
|
|
self.log_event(f"non-finite setpoint {value!r} - commanding 0 W")
|
|
_LOG.error("refusing to write a non-finite setpoint %r - commanding "
|
|
"0 W and continuing to command. This is a bug upstream, "
|
|
"not a limit.", value)
|
|
value = 0.0
|
|
|
|
# --- 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.
|
|
refusal = self.write_refusal(value)
|
|
if refusal is not None:
|
|
self.log_event(f"WRITE REFUSED {value:+.0f} W - commanding 0 W")
|
|
_LOG.error("SAFETY-28 REFUSED a write of %+.0f W: %s. Commanding "
|
|
"0 W instead. Every setpoint must be one the allocator "
|
|
"placed on THIS tick - see Controller.write_refusal().",
|
|
value, refusal)
|
|
value = 0.0
|
|
|
|
result = clamp_to_rating(value, float(self.o.get("max_w", 2000)),
|
|
self.dev_min, self.dev_max)
|
|
self.clamp_last = result
|
|
if result.engaged:
|
|
# ⚠️ EVERY engagement, never deduplicated: this is the action log
|
|
# (§15), and it is a different surface from the notification, which
|
|
# IS deduplicated. The count is what says how hard the loop pushed;
|
|
# the notification only says that it did.
|
|
self.log_event(result.describe())
|
|
value = result.applied_w
|
|
|
|
changed = (self.last_write is None or value != self.last_write)
|
|
if not (changed or force):
|
|
return
|
|
ok = await self.hass.set_number(entity, value)
|
|
self.last_write_ok = ok
|
|
if ok:
|
|
self.last_write = value
|
|
self.last_write_at = datetime.now(timezone.utc)
|
|
|
|
# -- the cycle -----------------------------------------------------------
|
|
def actuator_of(self, source: str) -> str:
|
|
"""Which actuator a claim from `source` is filed on this cycle.
|
|
|
|
⚠️ 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, self.unit, 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.
|
|
|
|
Today the roster is one inverter and THIS CONTROLLER IS THAT
|
|
INVERTER'S ONLY LINK, so "its master is stale" has no expression
|
|
separate from "this controller is blind" - and that case never reaches
|
|
the allocator with anything to divide, because `safety` files a SITE set
|
|
at P_SAFETY and every share resolves to 0 W. So the whole roster is
|
|
live, and saying that here is what makes the seam real rather than
|
|
notional. DEV-02 gives each inverter its own link and its own freshness,
|
|
and replaces the body of this method with them.
|
|
"""
|
|
return self.units
|
|
|
|
async def cycle(self) -> None:
|
|
"""Gather claims, resolve precedence, write the winner.
|
|
|
|
Nothing here decides who wins - arbiter.py does, by one rule. This
|
|
method's only job is to state honestly what each strategy wants.
|
|
"""
|
|
now = datetime.now(timezone.utc)
|
|
|
|
result = self.maint.tick(now, self.soc)
|
|
for msg in result.events:
|
|
self.log_event(msg)
|
|
|
|
# --- SAFETY-02, and it runs BEFORE the early return below -------------
|
|
# ⚠️ ORDER MATTERS HERE. If this were evaluated after the "inputs missing
|
|
# but not yet stale" hold, a run of held cycles would leave
|
|
# `disagree_since` untouched across the gap and the timer would resume
|
|
# from a start time that predates it. It is evaluated every cycle,
|
|
# unconditionally, so a missing meter CLEARS the window - see watchdog.py.
|
|
#
|
|
# The value compared is the command STANDING ON THE WIRE, which is what
|
|
# this cycle's fresh meter reading is a response to. `last_write` is that
|
|
# value after the device clamp and confirmed accepted; `target` covers
|
|
# the case where nothing has been written yet (and, deliberately, the
|
|
# case where the last write was REJECTED - the inverter is then still
|
|
# latched on `last_write`, which is exactly what `last_write` holds).
|
|
was_tripped = self.sign_wd.tripped
|
|
standing = self.last_write if self.last_write is not None else self.target
|
|
if self.sign_wd.evaluate(standing, self.grid, self.following, now) \
|
|
and not was_tripped:
|
|
# ⚠️ REASON FIRST, FLAG SECOND, and each `set` writes the file. The
|
|
# ordering is the whole atomicity story: a crash between the two
|
|
# persists a reason nothing reads, which is harmless, where the other
|
|
# order persists a latch with no explanation for the operator who has
|
|
# to decide whether to clear it.
|
|
self.store.set("sign_reason", self.sign_wd.reason)
|
|
self.store.set("sign_tripped", True)
|
|
self.log_event("FAILSAFE " + self.sign_wd.reason)
|
|
_LOG.error("SAFETY-02 TRIPPED - %s. Commanding 0 W and holding it "
|
|
"until control is started again. This survives an add-on "
|
|
"restart; restarting will not clear it.",
|
|
self.sign_wd.reason)
|
|
|
|
stale_after = float(self.o.get("stale_input_s", 15))
|
|
if not self.inputs_ok and not self.sign_wd.tripped:
|
|
bad_for = (now - self.inputs_bad_since).total_seconds() if self.inputs_bad_since else 0.0
|
|
if bad_for < stale_after:
|
|
# A single missed poll is not a fault. Hold, keep the heartbeat
|
|
# going, and give it a few seconds to come back.
|
|
self.reason = f"inputs missing {bad_for:.0f}s"
|
|
return
|
|
|
|
claims = []
|
|
|
|
# --- safety limits: these bind EVERY strategy, always ----------------
|
|
# Defence in depth: write_setpoint() clamps to the device range too. The
|
|
# duplication is deliberate - one of them is the policy, the other is the
|
|
# last thing between a bug and the hardware.
|
|
if self.dev_min is not None and self.dev_max is not None:
|
|
# PER-INVERTER. The numbers are ONE inverter's own reported
|
|
# min/max; a second unit has a second rating and there is no such
|
|
# thing as a site rating. See arbiter.CLAIM_SCOPE.
|
|
claims.append(Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max, "rating",
|
|
actuator=self.actuator_of("device")))
|
|
max_w = float(self.o.get("max_w", 2000))
|
|
# PER-INVERTER, for the same reason as `device`: `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, -max_w, max_w, "max_w",
|
|
actuator=self.actuator_of("supervised")))
|
|
|
|
# --- 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.
|
|
if self.sign_wd.tripped:
|
|
claims.append(Claim.set("failsafe", P_SAFETY, 0.0,
|
|
"sign disagreement",
|
|
actuator=self.actuator_of("failsafe")))
|
|
|
|
# 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`.
|
|
dev_top = self.dev_max if self.dev_max is not None else max_w
|
|
self.peak_guard = None if not self.p1_enabled else peakguard.decide(
|
|
self.peak.project(self.p1.averager, now),
|
|
batt_w=self.batt,
|
|
cap_w=float(self.o.get("peak_cap_w", 3500)),
|
|
reading=self.peak.peak(now),
|
|
achievable_w=min(max_w, abs(float(dev_top))),
|
|
# ⚠️ SAFETY-24. `self.soc` IS THE 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.
|
|
soc=self.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),
|
|
standing, max_w, self.freeze_wd.cfg,
|
|
dev_min=self.dev_min, dev_max=self.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),
|
|
soc=self.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.
|
|
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
|
float(result.setpoint_w or 0.0), result.phase,
|
|
actuator=self.actuator_of("maintenance")))
|
|
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")))
|
|
|
|
# --- the grid-following loop -----------------------------------------
|
|
if self.auto and self.inputs_ok:
|
|
# ⚠️ prev is the ARBITER's last output, not the loop's own last
|
|
# wish. If something outranked the loop, that is what the hardware
|
|
# actually did, and the controller must track reality or it jumps
|
|
# the moment it regains control.
|
|
#
|
|
# ⚠️ The integrator has to track the same reality, and it is no
|
|
# longer prev_w, so it needs saying out loud: if the arbiter did not
|
|
# give the loop what it asked for last cycle, the loop's
|
|
# accumulated error belongs to a command that never happened.
|
|
# Re-seed from what the hardware was actually told. This is the
|
|
# failsafe case too - every layer-1 stop resolves to 0 W, so
|
|
# entering failsafe re-seeds the integrator to zero and the first
|
|
# cycle after release starts from zero instead of dumping the whole
|
|
# stale period as power.
|
|
if self.loop_w is None or self.target != self.loop_w:
|
|
self.i_w = self.target
|
|
decision = compute(
|
|
prev_w=self.target,
|
|
grid_w=self.grid,
|
|
actual_w=self.batt,
|
|
tuning=self.tuning,
|
|
sat_count=self.sat_count,
|
|
i_w=self.i_w,
|
|
)
|
|
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
|
|
self.log_event(f"saturation freeze ({self.target:.0f} W vs {self.batt:.0f} W)")
|
|
self.sat_count = decision.sat_count
|
|
self.i_w = decision.i_w
|
|
self.loop_w = decision.target_w
|
|
# --- SAFETY-03 ---------------------------------------------------
|
|
# ⚠️ READ THE BOOLEANS, NOT `decision.reason`. On a default install
|
|
# the integrator bound is what bites and the output clamp is
|
|
# unreachable (control.py says so), and `reason` is then overwritten
|
|
# by whichever of slew-limiting or the saturation freeze ran after
|
|
# it - so a string test is silent on the two cases a winding loop
|
|
# most often presents as. These flags are set where the clamp
|
|
# happens and nothing downstream touches them.
|
|
self.loop_clamped = (
|
|
decision.i_clamped or decision.clamped,
|
|
(f"the loop asked for {decision.i_w:+.0f} W"
|
|
+ (" (integrator bound)" if decision.i_clamped else "")
|
|
+ (" (output bound)" if decision.clamped else "")),
|
|
)
|
|
# 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.
|
|
# ⚠️ THIS GUARD IS THE PRICE OF MAKING allocate() WORK, and without
|
|
# it ALLOC-01 would be a REGRESSION rather than a fix. There is ONE
|
|
# `setpoint_entity` and ONE inverter-power sensor in this add-on, so
|
|
# this controller can put a number on exactly one inverter. Give it a
|
|
# roster of two and a valid split, and writing `self.target` - the
|
|
# SITE target - to its single entity would hand one inverter the
|
|
# whole site's number while a second sits uncommanded: not the
|
|
# broadcast fault, but the same shape of over-command, and the
|
|
# allocator's own arithmetic says the share is half that. Commanding
|
|
# a partial site is a worse instruction than commanding nothing, and
|
|
# "nothing" here means 0 W and says so.
|
|
#
|
|
# ⚠️ AT THE SHIPPED ROSTER OF ONE THIS IS BYTE-IDENTICAL TO BEFORE.
|
|
# `shares` is `{inverter-a: target_w}` - §6.2's N=1 identity, the
|
|
# pass-through - so the set below is empty and nothing happens.
|
|
# DEV-02 gives this controller a writer per inverter and deletes the
|
|
# branch; until it does, the branch is what stops ALLOC-01 shipping a
|
|
# half-driven site.
|
|
elif set(resolution.shares) - {self.unit}:
|
|
undeliverable = ", ".join(sorted(set(resolution.shares) - {self.unit}))
|
|
self.log_event(
|
|
f"ARBITER: the site target was allocated across "
|
|
f"{len(resolution.shares)} inverters and this controller drives "
|
|
f"only {self.unit} - no writer for {undeliverable}, refusing to "
|
|
f"command a partial site (DEV-02)")
|
|
resolution = Resolution(0.0, "failsafe", None, False,
|
|
resolution.considered,
|
|
unit_bounds=resolution.unit_bounds,
|
|
shares=resolution.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()
|
|
await self.write_setpoint(self.target)
|
|
|
|
# --- 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.
|
|
"""
|
|
engaged_write = bool(self.clamp_last and self.clamp_last.engaged)
|
|
engaged_loop, loop_note = self.loop_clamped
|
|
engaged = engaged_write or engaged_loop
|
|
if engaged:
|
|
where = (self.clamp_last.describe() if engaged_write
|
|
else f"{CLAMP_REASON}: {loop_note}, above the "
|
|
f"{float(self.o.get('max_w', 2000)):.0f} W rating, and "
|
|
f"was clamped to {self.target:+.0f} W")
|
|
reason = (f"{where}. This is a BUG SIGNAL, not a limit being used: "
|
|
f"something upstream computed a setpoint the actuator "
|
|
f"cannot deliver.")
|
|
else:
|
|
reason = ""
|
|
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc))
|
|
|
|
# --- THE ACTION LOG, AND IT IS COALESCED PER EPISODE ------------------
|
|
# Every engagement is recorded, but consecutive ones are recorded by
|
|
# UPDATING one row's count rather than inserting a row each.
|
|
#
|
|
# ⚠️ THAT IS NOT COSMETIC, AND IT WAS FOUND THE HARD WAY. `log_event()`
|
|
# keeps 40 rows; a winding loop clamps on every cycle, so a row per
|
|
# engagement flushes the whole ring within 40 cycles - and the first
|
|
# thing it evicted, in test_freeze.py, was SAFETY-18's "METER FROZEN?"
|
|
# row. A guard whose log drowns out the other guards' logs has moved the
|
|
# flood rather than removed it. The count here is `clamp_wd.engagements`,
|
|
# so nothing is lost: the row says exactly how many, and the number is
|
|
# read off the counter rather than off the number of rows.
|
|
#
|
|
# This lives here rather than only in write_setpoint() because on a
|
|
# default install the actuator-side clamp is unreachable - compute() has
|
|
# already bounded the integrator - so the only engagements that exist are
|
|
# loop-side ones. Measured: 60 cycles of the §10.3 runaway produce 0
|
|
# write-path engagements and 2 loop-side ones.
|
|
if engaged and not engaged_write:
|
|
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
|
|
row = (f"{tag}: {self.clamp_wd.engagements} engagement(s) - "
|
|
f"{reason}")
|
|
if self.events and tag in self.events[0]:
|
|
stamp = self.events[0].split(" ", 1)[0]
|
|
self.events[0] = f"{stamp} {row}"
|
|
else:
|
|
self.log_event(row)
|
|
if not fired:
|
|
return
|
|
_LOG.error(
|
|
"SAFETY-03 - %s Silently clipping this is how a winding integrator "
|
|
"reached 14 768 W on this project before: the clamp makes every "
|
|
"dashboard read a legal number while the thing producing it goes on "
|
|
"climbing. The command IS clamped and the loop is STILL RUNNING - "
|
|
"this is an alarm, not a trip. Engagements so far: %d.",
|
|
self.clamp_wd.reason, self.clamp_wd.engagements)
|
|
# A fixed notification_id, for the reason check_meter_frozen() gives -
|
|
# and a DIFFERENT one from every other alarm this add-on raises, because
|
|
# a clamp is not a frozen meter and neither is a saturation freeze.
|
|
await self.hass.call("persistent_notification", "create", {
|
|
"notification_id": "goodwe_output_clamp",
|
|
"title": "GoodWe: a computed setpoint exceeded the rating",
|
|
"message": (
|
|
f"{self.clamp_wd.reason}\n\nThe command has been clamped and "
|
|
f"the controller is STILL RUNNING. This is NOT the inverter "
|
|
f"failing to follow a legal command (that is the saturation "
|
|
f"freeze, which is normal and does not alarm) - the value asked "
|
|
f"for was itself out of range."),
|
|
})
|
|
|
|
async def check_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)
|
|
fired = self.freeze_wd.evaluate(unchanged, self.target,
|
|
float(self.o.get("max_w", 2000)),
|
|
datetime.now(timezone.utc),
|
|
dev_min=self.dev_min,
|
|
dev_max=self.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:
|
|
entity = self.o.get("setpoint_entity", "")
|
|
if entity:
|
|
self.dev_min, self.dev_max, _ = await self.hass.limits(entity)
|
|
# ⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01
|
|
# exists to remove: a max_w above the device's rating meant
|
|
# write_setpoint() silently clamped and the configured ceiling was
|
|
# never the real one.
|
|
#
|
|
# amain() already ran this gate before anything started. It runs
|
|
# AGAIN here for one case the gate cannot cover: ESPHome is often
|
|
# not up yet when the add-on boots, so hass.limits() returned
|
|
# (None, None) and the range checks were skipped rather than
|
|
# guessed at. This is the first moment the device's real rating is
|
|
# known. If it conflicts, refuse now - stopping deterministically
|
|
# through the normal shutdown, which commands 0 W on the way out.
|
|
problems = validate_startup_config(self.o, self.dev_min, self.dev_max)
|
|
if problems:
|
|
refuse(problems)
|
|
self.refused = problems
|
|
self.log_event("REFUSED: " + problems[0])
|
|
# Signal, then return WITHOUT entering the loop. amain() wakes
|
|
# on this, runs the normal wind-down (which commands 0 W) and
|
|
# re-raises ConfigRefused afterwards, so the exit code is 78 and
|
|
# the inverter is left at zero rather than latched.
|
|
self.stop_event.set()
|
|
return
|
|
|
|
last_grid = object()
|
|
heartbeat = float(self.o.get("heartbeat_s", 10))
|
|
last_beat = 0.0
|
|
# ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not a
|
|
# second scheduler running alongside the arrival trigger. Every cycle
|
|
# resets it, whichever trigger fired, so on a healthy changing meter it
|
|
# never elapses and not one existing cycle moves - and therefore no
|
|
# change to the per-cycle gain the tuning was measured against.
|
|
#
|
|
# That is true twice over, and the second reason is the sturdier one:
|
|
# the combined cycle rate is max(arrival rate, 1/floor), never the sum.
|
|
# At the defaults 1/15 s is strictly slower than the ~1/5 s arrival
|
|
# rate, so even a floor that raced the arrivals could not push the loop
|
|
# past its nominal healthy rate. The re-tune is unreachable by
|
|
# construction, not merely by the reset. ⚠️ That argument holds only
|
|
# while floor > the meter's update interval - which is why the schema
|
|
# minimum of 5 s deserves the second look validate.py has not given it
|
|
# yet: 5 s IS the reference meter's interval, i.e. the one configurable
|
|
# value that would double the cycle rate.
|
|
#
|
|
# Why it has to exist, and it is not only about latency: `self.grid !=
|
|
# last_grid` alone means a meter that repeats a value runs NO control
|
|
# cycles at all. The heartbeat below keeps re-emitting the last command
|
|
# and the firmware keeps putting it on the wire, so every indicator
|
|
# reads healthy - and, worse, the `inputs missing -> 0 W` claim lives
|
|
# INSIDE cycle(), so on a repeating meter THAT SAFETY CLAIM CANNOT FIRE.
|
|
# Demonstrated in test_loop.py: constant meter, SoC entity dies at t=4 s,
|
|
# and the pre-fix controller holds 220 W through the whole outage with
|
|
# the status line still reading "loop -> 220 W". Layer 1 does not cover
|
|
# it on a `meter_source: off` install either - no P1 age sensor is
|
|
# published, so the firmware has nothing to veto on. Measured on the rig
|
|
# during TEL-05: 70 s at a literally constant 350.0 W, 14 arrivals, zero
|
|
# cycles.
|
|
#
|
|
# This is NOT a freeze detector, and deliberately so - see
|
|
# HomeWizardLocalSource.unchanged_s for why one cannot be built here (at
|
|
# the converged -10 W target a 1 Wh register needs ~6 minutes to move,
|
|
# so any freeze threshold false-trips at the exact operating point we
|
|
# run at). Nothing here decides whether a repeated reading is true. It
|
|
# only stops "the number is the same" from meaning "do not think".
|
|
#
|
|
# ⚠️ THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN. It is under
|
|
# validate.FIRMWARE_INGEST_VETO_S (30 s) and under meter_max_age_s on
|
|
# purpose, so the loop re-decides at least twice inside any window in
|
|
# which layer 1 might otherwise be the only thing deciding. If the
|
|
# firmware's veto ever moves, this default moves with it - it is not
|
|
# independently justified at 15.
|
|
floor = float(self.o.get("cycle_floor_s", 15))
|
|
last_cycle = None
|
|
while not self.stopping:
|
|
await self.read_inputs()
|
|
now_mono = asyncio.get_running_loop().time()
|
|
# A cycle is one meter update, exactly as on the reference install -
|
|
# or the floor elapsing, whichever comes first.
|
|
overdue = last_cycle is None or (now_mono - last_cycle) >= floor
|
|
if self.grid != last_grid or overdue:
|
|
last_grid = self.grid
|
|
last_cycle = now_mono
|
|
# ⚠️ The FULL cycle, not a degraded one. A floor cycle runs the
|
|
# same law on the same inputs as an arrival cycle, because in P1
|
|
# mode the freshness question is already answered upstream and
|
|
# answered harder: past `meter_max_age_s` (30 s) p1.net_w is
|
|
# None, which is "inputs missing", which becomes a P_SAFETY 0 W
|
|
# claim. So a floor cycle cannot run the PI on a 60 s old
|
|
# figure, because at 60 s there is no figure.
|
|
#
|
|
# ⚠️ THAT GUARANTEE IS TRANSPORT-CONDITIONAL, and saying so
|
|
# matters more than the guarantee: with `meter_source: off`,
|
|
# read_inputs() takes self.grid straight from hass.number() with
|
|
# NO age gate at all, so a Home Assistant entity that has gone
|
|
# quiet reads as a current value on that path with or without
|
|
# this change. Running the full law is still the right choice
|
|
# there - the alternative is a degraded second law reasoning
|
|
# about an age nobody measured - but the floor does not make
|
|
# that path fresh, and nothing here should be read as claiming
|
|
# it does. And the integrator
|
|
# advances per CYCLE, not per second (control.py), so bounding
|
|
# the gap bounds the accumulation: at most one extra
|
|
# gain x error per floor interval, under the same
|
|
# integrator_max_w as every other cycle. A second, gentler
|
|
# control law for floor cycles would be a second set of
|
|
# dynamics nobody can test on this hardware.
|
|
await self.cycle()
|
|
# ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence
|
|
# longer than ~30 s as "the controller is gone" and zeroes the
|
|
# inverter. Refreshing the SAME value is what proves we are alive.
|
|
loop_now = asyncio.get_running_loop().time()
|
|
if loop_now - last_beat >= heartbeat:
|
|
last_beat = loop_now
|
|
await self.write_setpoint(self.target, force=True)
|
|
self.publish()
|
|
await asyncio.sleep(1)
|
|
|
|
def publish(self) -> None:
|
|
values = {
|
|
"setpoint": self.target,
|
|
"grid": self.grid,
|
|
"battery": self.batt,
|
|
"soc": self.soc,
|
|
"phase": self.maint.phase,
|
|
# ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing
|
|
# one. IF-01/IF-02 own `ems/event/failsafe` and the `failsafe active`
|
|
# binary sensor and neither is built yet; inventing a bespoke topic
|
|
# here would be a surface to migrate later. This sensor is already
|
|
# discovered, already on every dashboard, and "failsafe" is
|
|
# additive to its vocabulary rather than a change to it.
|
|
"status": ("failsafe" if self.sign_wd.tripped
|
|
else "running" if self.auto else "stopped"),
|
|
}
|
|
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
|
|
# watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id
|
|
# note in mqtt.py; the composed name is not the object_id - and forces layer-1
|
|
# failsafe once it reaches max_age_s. With meter_source off there is no
|
|
# ingester feeding it, so published_age_s would be time-since-startup
|
|
# climbing without bound - i.e. every existing install would cross the
|
|
# threshold within 30 s and pin its inverter at 0 W forever. Publishing
|
|
# nothing leaves the entity non-existent, which is the status quo and
|
|
# what has_state() in the firmware is checking for.
|
|
if self.p1_enabled:
|
|
# Recomputed here, once a second, on purpose - see P1Ingest.
|
|
values["p1_age"] = round(self.p1.published_age_s, 1)
|
|
self.mqtt.publish(values)
|
|
|
|
async def shutdown(self) -> None:
|
|
"""Deterministic wind-down. Do not skip this."""
|
|
self.stopping = True
|
|
_LOG.info("shutting down - commanding 0 W")
|
|
await self.write_setpoint(0.0, force=True)
|
|
self.mqtt.close()
|
|
|
|
# -- UI ------------------------------------------------------------------
|
|
def checks(self, 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(o.get("max_w", 2000),
|
|
dev_min=self.dev_min, dev_max=self.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(
|
|
o.get("max_w", 2000),
|
|
dev_min=self.dev_min,
|
|
dev_max=self.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(o.get("max_w", 2000),
|
|
dev_min=self.dev_min, dev_max=self.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(o.get("max_w", 2000))
|
|
else f" - below max_w because "
|
|
f"{o.get('setpoint_entity') or 'the setpoint device'}"
|
|
f" accepts only {self.dev_min}...{self.dev_max} W")
|
|
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"})
|
|
|
|
rows = [("battery SoC", self.soc, o.get("soc_entity")),
|
|
("battery power", self.batt, o.get("batt_entity"))]
|
|
if not self.p1_enabled:
|
|
# In P1 mode the check above replaces this one; leaving both in
|
|
# would report "no entity configured" for a meter_entity that is
|
|
# correctly unused, i.e. a permanent false NOT READY.
|
|
rows.insert(0, ("grid power", self.grid, o.get("meter_entity")))
|
|
for label, value, entity in rows:
|
|
if not entity:
|
|
out.append({"ok": False, "warn": False, "text": f"{label}: no entity configured"})
|
|
elif value is None:
|
|
out.append({"ok": False, "warn": False,
|
|
"text": f"{label}: {entity} is missing or not numeric"})
|
|
else:
|
|
out.append({"ok": True, "warn": False, "text": f"{label}: {entity} = {value:g}"})
|
|
|
|
sp = o.get("setpoint_entity")
|
|
if not sp:
|
|
out.append({"ok": False, "warn": False, "text": "setpoint entity not configured"})
|
|
elif self.dev_max is None:
|
|
out.append({"ok": False, "warn": False,
|
|
"text": f"setpoint entity {sp} not found"})
|
|
else:
|
|
out.append({"ok": True, "warn": False,
|
|
"text": f"setpoint {sp} (range {self.dev_min:g}…{self.dev_max:g} W)"})
|
|
|
|
# SAFETY-03. Red while a clamp is engaged, and the engagement COUNT is
|
|
# shown even after it clears - a loop that wound once and stopped is
|
|
# still a bug, and a row that vanishes with the condition takes the
|
|
# evidence with it. Reported only when there is something to report:
|
|
# unlike the freeze detector this guard has no "not covered" state, it
|
|
# runs on every write on every transport.
|
|
if self.clamp_wd.active:
|
|
out.append({"ok": False, "warn": False,
|
|
"text": "OUTPUT CLAMP ENGAGED - " + self.clamp_wd.reason
|
|
+ f" Engagements: {self.clamp_wd.engagements}."})
|
|
elif self.clamp_wd.engagements:
|
|
out.append({"ok": False, "warn": True,
|
|
"text": f"output clamp: not engaged now, but it has "
|
|
f"engaged {self.clamp_wd.engagements} time(s) in "
|
|
f"{self.clamp_wd.episodes} episode(s) since "
|
|
f"start - see the event log"})
|
|
|
|
if self.last_write_ok is False:
|
|
out.append({"ok": False, "warn": False,
|
|
"text": "last write to the inverter was REJECTED - check the log"})
|
|
|
|
# SAFETY-02. Reported in both states: a watchdog nobody can see is armed
|
|
# is a watchdog nobody trusts when it fires.
|
|
if self.sign_wd.tripped:
|
|
out.append({"ok": False, "warn": False,
|
|
"text": "SIGN-DISAGREEMENT FAILSAFE - " + self.sign_wd.reason
|
|
+ ". The inverter is held at 0 W. Find out why the "
|
|
"command opposed the meter before starting control "
|
|
"again; starting clears this. Restarting the "
|
|
"add-on does NOT clear it."})
|
|
else:
|
|
held = self.sign_wd.held_s(datetime.now(timezone.utc))
|
|
out.append({"ok": True, "warn": False,
|
|
"text": f"sign watchdog: armed (trips after "
|
|
f"{self.sign_wd.cfg.hold_s:.0f} s opposed, both over "
|
|
f"{self.sign_wd.cfg.min_w:.0f} W)"
|
|
+ (f" - opposed for {held:.0f} s now" if held else "")})
|
|
|
|
if not o.get("estop_fitted", False):
|
|
out.append({"ok": False, "warn": True,
|
|
"text": "no e-stop fitted: if this host dies the battery stays "
|
|
"latched at its last command"})
|
|
else:
|
|
out.append({"ok": True, "warn": False, "text": "e-stop fitted"})
|
|
|
|
if not o.get("peak_forecast_entity"):
|
|
# ok=False + warn=True renders as a caution, not a pass. A check that
|
|
# is both is a check nobody reads.
|
|
out.append({"ok": False, "warn": True,
|
|
"text": "no peak forecast: maintenance charging is not capacity-capped"})
|
|
return out
|
|
|
|
def status_payload(self) -> dict:
|
|
checks = self.checks()
|
|
bad = [c for c in checks if not c["ok"] and not c["warn"]]
|
|
if self.sign_wd.tripped:
|
|
# Outranks "NOT READY": nothing is missing, something went wrong.
|
|
level, banner = "bad", ("FAILSAFE — the command opposed the meter. "
|
|
"Inverter held at 0 W.")
|
|
elif bad:
|
|
# ⚠️ `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.batt)),
|
|
("State of charge", "—" if self.soc is None else f"{self.soc:g} %"),
|
|
("Commanded", w(self.target)),
|
|
("Why", self.reason),
|
|
("Maintenance phase", self.maint.phase),
|
|
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
|
("Cheap window", "yes" if self.cheap else "no"),
|
|
("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 and self.sign_wd.tripped:
|
|
# ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose.
|
|
# It clears on a deliberate human start, not on the condition
|
|
# going away - the condition ALWAYS goes away, because the trip
|
|
# commands 0 W and a 0 W command cannot disagree with anything.
|
|
# An operator pressing start has, at minimum, looked at the
|
|
# banner that says why.
|
|
self.sign_wd.reset()
|
|
self.store.set("sign_tripped", False)
|
|
self.store.set("sign_reason", "")
|
|
self.log_event("sign-disagreement failsafe cleared by operator")
|
|
if not self.auto:
|
|
self.target = 0.0
|
|
await self.write_setpoint(0.0, force=True)
|
|
return {"auto": self.auto}
|
|
if what == "maint_start":
|
|
for msg in self.maint.force_start(now):
|
|
self.log_event(msg)
|
|
return {"phase": self.maint.phase}
|
|
if what == "maint_abort":
|
|
for msg in self.maint.abort(now, "operator"):
|
|
self.log_event(msg)
|
|
self.target = 0.0
|
|
await self.write_setpoint(0.0, force=True)
|
|
return {"phase": self.maint.phase}
|
|
return None
|
|
|
|
|
|
async def amain() -> None:
|
|
opts = load_options()
|
|
logging.basicConfig(
|
|
level=getattr(logging, str(opts.get("log_level", "info")).upper(), logging.INFO),
|
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
)
|
|
_LOG.info("GoodWe RS485 Controller starting")
|
|
|
|
from .store import Store
|
|
store = Store()
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
hass = HomeAssistant(session)
|
|
|
|
# Startup self-check: prove we can actually reach the Core API before
|
|
# anything tries to control an inverter with it. A 401 here is a
|
|
# permissions/token problem, not a configuration mistake, and saying so
|
|
# explicitly saves an installer from re-checking entity ids for an hour.
|
|
import os as _os
|
|
_tok = _os.environ.get("SUPERVISOR_TOKEN", "")
|
|
_LOG.info("supervisor token: %s (%d chars); env has: %s",
|
|
"present" if _tok else "MISSING", len(_tok),
|
|
",".join(sorted(k for k in _os.environ if "TOKEN" in k.upper())) or "none")
|
|
try:
|
|
async with session.get("http://supervisor/core/api/",
|
|
headers={"Authorization": f"Bearer {_tok}"},
|
|
timeout=10) as _r:
|
|
_LOG.info("core api probe: HTTP %s %s", _r.status, (await _r.text())[:80])
|
|
except Exception as _e: # noqa: BLE001
|
|
_LOG.error("core api probe failed: %s", _e)
|
|
|
|
# ⚠️ THE STARTUP GATE (CFG-01). Nothing below this line may run on a
|
|
# configuration validate.py rejects - specs §1.5: a config the system
|
|
# cannot honour is refused at startup, not survived at runtime.
|
|
#
|
|
# It sits HERE, before the broker, before the web server and before any
|
|
# task exists, so a refusal cannot leave half a controller running with
|
|
# a UI in front of it. Reading the device range is the one thing the
|
|
# validator cannot do for itself; (None, None) when ESPHome has not come
|
|
# up yet is not a refusal - run_control() re-checks once the real rating
|
|
# is known. See validate.py for where the line is drawn and why.
|
|
_sp = str(opts.get("setpoint_entity", "") or "").strip()
|
|
_dev_min = _dev_max = None
|
|
if _sp:
|
|
_dev_min, _dev_max, _ = await hass.limits(_sp)
|
|
_LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max)
|
|
_problems = validate_startup_config(opts, _dev_min, _dev_max)
|
|
if _problems:
|
|
refuse(_problems)
|
|
raise ConfigRefused(_problems)
|
|
|
|
# ⚠️ Status publishing must NEVER be able to stop the controller. A
|
|
# broken broker, a missing library, an API change in paho - all of it is
|
|
# observability, and the battery does not care. Caught broadly and on
|
|
# purpose: this crashed the add-on once already (paho 1.x vs 2.x) and
|
|
# took the control loop down with it.
|
|
broker = None
|
|
try:
|
|
broker = await hass.mqtt_service()
|
|
pub = MqttPublisher(
|
|
broker.get("host") if broker else None,
|
|
broker.get("port", 1883) if broker else 1883,
|
|
broker.get("username") if broker else None,
|
|
broker.get("password") if broker else None,
|
|
omit=() if is_enabled(opts) else ("p1_age",),
|
|
)
|
|
except Exception as err: # noqa: BLE001
|
|
_LOG.warning("MQTT unavailable (%s) - continuing without status entities", err)
|
|
|
|
class _NoMqtt:
|
|
enabled = False
|
|
def publish(self, *a, **k): pass
|
|
def close(self): pass
|
|
|
|
pub = _NoMqtt()
|
|
|
|
stop = asyncio.Event()
|
|
controller = Controller(opts, hass, store, pub, stop=stop)
|
|
runner = await web.start(controller, port=8099)
|
|
|
|
loop = asyncio.get_running_loop()
|
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
with contextlib.suppress(NotImplementedError):
|
|
loop.add_signal_handler(sig, stop.set)
|
|
|
|
tasks = [asyncio.create_task(controller.run_control())]
|
|
|
|
# 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()
|