Files
goodwe-addon/goodwe_controller/app/main.py
T
glenn schrooyenandClaude 3939211176 MAINT-21: app/ctl_cycle.py - Controller.cycle, whole
Moves the methods named in the card and the constants only they read out of app/main.py unchanged. CycleMixin: cycle() and _shadow_settle(). cycle() is not cut up. tools/maint21_same_ast.py: OK.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 00:29:52 +01:00

2878 lines
153 KiB
Python

"""GoodWe RS485 Controller add-on - entry point and orchestration.
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 the figure
to an ESP32 that puts it on the inverter's RS485 EMS port. Monthly it runs a
full low->full cycle so the BMS can balance cells and recalibrate.
THE ONE FACT BEHIND EVERY FAILSAFE: the inverter holds the last command it
understood FOREVER (measured: 5 kW of discharge held for 113 s after a
controller died). So when in doubt this process stops writing and the hardware
takes the battery to zero. Never "hold the last value to be safe".
layer 1 the ESP32 watchdog: no refresh for ~30 s -> 0 W, and it KEEPS
WRITING it. This add-on's heartbeat feeds it.
layer 2 wind-down before a firmware update, in the ESP32.
layer 3 the optional RS485 e-stop: 0 W after 30 s of bus silence; the only
cover for this add-on's host dying.
Per-section reasoning: DECISIONS/MAIN-orchestration.md."""
import asyncio
import contextlib
import dataclasses
import functools
import json
import logging
import math
import os
import signal
import time
from datetime import datetime, timedelta, timezone
import aiohttp
from . import arbitrage
from .arbiter import (
ACTUATOR_SITE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_MODE, P_PEAK,
P_SAFETY, SET, Claim, Resolution, actuator_for, pre_resolve, resolve,
)
from . import allocator
from . import dayplan
from . import modetrade
from . import packmode
from . import priceruns
from . import solarfc
from . import control as _ctl
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from .delivery import (
DELIVERY_OPTION_DEFAULTS, DeliveryConfig, UnitDelivery,
account as delivery_account, delinquent,
)
from .circulation import (
CIRC_OPTION_DEFAULTS, CirculationConfig, CirculationWatchdog,
circulating_w, opposing,
)
from .clamp import (
REASON_CODE as CLAMP_REASON, ClampWatchdog,
clamp_to_rating, delivery_short, is_commandable, loop_clamp_note,
shortfall_note, write_clamp_note,
)
from . import freshness
from . import lateness
from . import dayview, homeview
from .daycurve import SolarToday
from . import handback
from . import messages
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
)
from .drivers import GoodWeEsphomeDriver
from .hass import CORE_API, OUTAGE, HomeAssistant
# `LINK_STALE_S` is re-exported deliberately: `live_units()` and two checks
# read `app.main.LINK_STALE_S`.
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance,
FAIL_BACKOFF_DAYS, MaintResult, arbitrate as maint_arbitrate, site_zone,
)
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
MqttPublisher, announced_units, mode_configs,
optional_keys, parse_command, unit_slugs, unit_state_key)
from . import peakguard
from . import packshare
from . import pv as pv_read
from . import shadow
from . import uitext
from .p1 import P1Ingest, build_source, is_enabled
from .price import Absence, Uncertainty, brussels, price_option, provider_configured
from .price import required_spread as price_required_spread
from .price import uncertainty_of as price_uncertainty_of
from .price_chain import (PriceWatch, fixed_tariff_beside_service, gate_for,
uses_chain)
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_gated, static_status
from .tariff import PeakStore, PeakTracker, next_quarter
from .store import migrate_maint_keys, migrate_unit_keys
from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
validate_startup_config, validate_unit_ranges)
from .watchdog import (
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
SignWatchdog, SignWatchdogConfig, loop_is_following,
)
from . import setuponly
from . import web
from .ctl_cycle import CycleMixin, _refused_interval
from .ctl_trade import (
TradeMixin, CHARGE_LATCH_GRACE_S, CHARGE_LATCH_KEY, EDGE_HOLD_S,
HOUSE_PROFILE_KEY, MAINT_CHARGING_W, REF_CHEAP_KEEP, _night_limit,
pack_energy_of,
)
from .ctl_writes import (
WritesMixin, FALLBACK_BUDGET_S, REFUSED_PROGRESS_W, REFUSED_SPAN_S,
RELEASE_BUDGET_S, SETTLE_BUDGET_S, WRITE_BUDGET_S, _bounded,
)
from .ctl_inputs import (
InputsMixin, FC_HOLD_KEY, FORECAST_READ_S, METER_QUIET_REPEAT_S,
TARIFF_MAX_AGE_S, TARIFF_REFRESH_S, _mono,
)
from .rows import arbitrage_row, tariff_plan_row, why_key, why_raised
OPTIONS_PATH = "/data/options.json"
INGRESS_PORT = 8099
_LOG = logging.getLogger("goodwe")
# `UNIT_A` lives in app/inverter.py and is re-exported here because the roster
# does and four checks read `app.main.UNIT_A`. `build_roster()` is the one
# function CFG-04 changes.
__all__ = ["UNIT_A", "Controller", "amain", "main"]
HAND_BACK_WORST_S = RELEASE_BUDGET_S + FALLBACK_BUDGET_S
# SAFETY-34: a Core-only restart wipes persistent_notification without
# restarting this add-on, so a latched alarm is re-asserted this often.
REANNOUNCE_S = 300.0
def tariff_refresh_wanted(opts: dict) -> bool:
"""Does this install need the background tariff refresh? (TARIFF-10,
TARIFF-24) Yes with a price feed, trading armed, or battery modes on. It
decides whether prices are looked up, never whether energy moves.
DECISIONS/TARIFF-10-fetching-is-not-trading.md and
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md."""
return (provider_configured(opts) or arbitrage.enabled(opts)
or packmode.enabled(opts))
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 {}
def _load_latch(data, now=None):
"""`(until, {name: level %}, born)` from the store, or None if it is not a
well-formed latch or has ended (OPT-34)."""
try:
until = datetime.fromisoformat(data["until"])
born = datetime.fromisoformat(data.get("born") or data["until"])
levels = {str(n): float(v) for n, v in data["levels"].items()}
except (TypeError, KeyError, ValueError, AttributeError):
return None
if until.tzinfo is None or born.tzinfo is None or not levels or not all(
math.isfinite(v) and 0.0 <= v <= 100.0 for v in levels.values()):
return None
if now is not None and now >= until:
return None
return until, levels, born
# SAFETY-85: a battery another owner set is graded against the least it was
# told over the plant's settle time, so a battery still settling is not short.
SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S
# SAFETY-119/121: after a hand-back the loop's bound is hidden for
# HANDBACK_CYCLES / SHORT_SPAN_S, and across hand-backs on at most
# ROLLING_BOUND_CYCLES cycles per ROLLING_S
# (DECISIONS/SAFETY-121-bound-every-quarter.md). Without a monotonic clock the
# budget never refills.
HANDBACK_CYCLES = 4
ROLLING_S = 900.0
ROLLING_BOUND_CYCLES = 11
class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin):
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
peak_db_path: str = "/data/tariff.db",
price_history_path: str = "/data/price_history.json",
site_tz=None):
# UX-15: a read-only HomeAssistant belongs to setup-only mode, which
# builds no controller.
if getattr(hass, "read_only", False) is True:
raise ValueError("a controller cannot run on a read-only "
"HomeAssistant (setup-only mode)")
self.o = opts
self.hass = hass
self.store = store
self.mqtt = mqtt_pub
# REQUIRED and keyword-only: a default `stop=None` made a Controller
# that cannot stop itself (inert but looks healthy on the late-refusal
# path in run_control()). DECISIONS/MAIN-orchestration.md.
self.stop_event = stop
self.refused: list[str] | None = None
# First, because the store migration below can log to it: 15's action
# log is how an operator learns a latch was re-armed, and an
# AttributeError here would stop the add-on booting.
self.events: list[str] = []
# UX-08: the last event in plain words, for the page. See log_event().
self.hint = ""
# DEV-02: the roster, built before anything reads a rating; every
# per-device value lives on these objects (app/inverter.py), but a
# `Tuning` and the integrator are deliberately NOT per device (6.2: one
# integrator on P1 above the split). The store migration runs here,
# BEFORE the first `Inverter` reads its SAFETY-02 latch, else a tripped
# install comes up ARMED with a green banner.
# DECISIONS/MAIN-orchestration.md.
self.sign_cfg = sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("sign_disagree_w", 300)),
hold_s=float(opts.get("sign_disagree_s", 180)),
)
# SAFETY-30: the per-unit comparison's own pair, re-derived at two
# inverters, not carried across (a command against one unit's own power
# is a different quantity). DECISIONS/SAFETY-30-per-unit-sign.md;
# watchdog.SIGN_OPTION_DEFAULTS.
self.unit_sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("unit_sign_disagree_w",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"])),
hold_s=float(opts.get("unit_sign_disagree_s",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"])),
)
# SAFETY-30: the site comparison's own pair, used only at N>=2. The 300
# W threshold is carried (meter noise and deadband); the window is not
# (110 s at one inverter, 215 s at two, against a 180 s hold). See
# watchdog.SIGN_OPTION_DEFAULTS.
self.site_sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("site_sign_disagree_w",
SIGN_OPTION_DEFAULTS["site_sign_disagree_w"])),
hold_s=float(opts.get("site_sign_disagree_s",
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"])),
)
# SAFETY-42: the window a unit may be short of its share before it is
# named. No second key: the watts half is `Tuning.saturation_w`, read
# at runtime (`delivery.MIN_W_IS_SATURATION_W`, CFG-03).
self.delivery_cfg = delivery_cfg = DeliveryConfig(
hold_s=float(opts.get("delivery_hold_s",
DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])),
)
# SAFETY-44: hand the migration the names the machines read,
# `roster_names(opts)[0]`, never `UNIT_A` (on a declared roster the
# latch landed under `unit/inverter-a/`, on no roster, and stamped the
# marker). `roster_names()` is `[UNIT_A]` on the flat shape, so today's
# path is byte-identical. DECISIONS/MAIN-orchestration.md.
_roster_names = roster_names(opts)
self.store_migration = migrate_unit_keys(
store, _roster_names[0], _roster_names)
self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg)
# SMA-10: which units hand back decides what the page says about Stop,
# restart and shadow mode (DECISIONS/SMA-10-two-idle-states.md).
handback.set_roster(handback.units_of(self.inverters))
uitext.set_idle(handback.overlay(handback.current()))
# CFG-04: say which shape is in force. A declared `inverters:` roster
# ignores `soc_entity`, `batt_entity`, `batt_invert` and `max_w`, which
# the UI still shows; validate.py rule 9(e) refuses only the dangerous
# one, so the rest are DISCLOSED in one action-log line at every start.
if roster_entries(opts):
self.log_event(
"roster: %d inverters from `inverters:` - %s. The flat "
"soc_entity / batt_entity / batt_invert / max_w keys are NOT "
"read in this shape; each entry carries its own."
% (len(self.inverters),
", ".join(f"{i.name} {i.max_w:.0f} W" for i in self.inverters)),
hint=messages.hint("roster", n=len(self.inverters)))
self.tuning = Tuning(
gain=float(opts.get("gain", 0.6)),
# The SITE bound is summed from the roster, not `opts["max_w"]`: it
# is the integrator bound and rail of the site loop, split across
# inverters. Identical to `max_w` at N=1; a per-unit `max_w` still
# binds each share through its `supervised` claim.
max_w=self.site_max_w,
# SAFETY-39: the fallback is 0, meaning "follow the rail"
# (config.yaml and `Tuning`). It was 1000, a stale third copy.
# `control.slew_limit_w()` is the one reader.
slew_w=float(opts.get("slew_w", 0) or 0),
deadband_w=float(opts.get("deadband_w", 15)),
target_grid_w=float(opts.get("target_grid_w", 0)),
step_w=int(opts.get("step_w", 10)),
saturation_w=float(opts.get("saturation_w", 500)),
saturation_cycles=int(opts.get("saturation_cycles", 3)),
# 0 / unset means "follow max_w", 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: no `opts.get(key, <literal>)` for maintenance here;
# `MaintConfig.from_options()` reads them next to
# `maintenance.MAINT_OPTION_DEFAULTS`; a new key goes in config.yaml
# `options:`/`schema:` and that dict only. MAINT-05: one `Maintenance`
# per roster entry; the history migration runs first (else a pack
# believes it was never recalibrated) and targets
# `self.inverters[0].name`, NOT `UNIT_A`; the roster is passed so
# `migrate_maint_keys()` grades the namespace it wrote.
# DECISIONS/MAIN-orchestration.md.
self.maint_migration = migrate_maint_keys(
store, self.inverters[0].name, [inv.name for inv in self.inverters])
_LOG.info("maintenance history: %s", self.maint_migration.explain())
if not self.maint_migration.ok:
# Not a disarmed guard (history, not a latch), but it is the
# difference between a pack recalibrated on schedule and one
# silently not (12), so it is said where an operator looks.
self.log_event("maintenance history did not move over cleanly: "
+ self.maint_migration.explain(),
hint=messages.hint("maint_history"))
# MAINT-05: one config, N machines: the `maintenance_*` options
# describe the PROCEDURE (same on every pack); the SCHEDULE and HISTORY
# are per pack, in the store. A per-entry config block would be a
# second shape for CFG-04 and a fifth place for `maintenance_charge_w`
# to disagree with config.yaml; two procedures would be a roster-schema
# card.
_maint_cfg = MaintConfig.from_options(opts)
_one_pack = len(self.inverters) <= 1
self.maints = [
Maintenance(
_maint_cfg, store,
# `None` at a roster of one is the FLAT layout, not a missing
# name (6.2 identity): it keeps a one-inverter state file
# byte-identical (`maintenance.UnitHistory`,
# `store.migrate_maint_keys()`).
unit=None if _one_pack else inv.name,
name=inv.name,
# The rollback view, and only ever the first pack's: the flat
# keys mean `inverters[0]` on every install that has them.
mirror_flat=(not _one_pack and i == 0),
tz=site_tz) # MAINT-10: the start hour's zone, from amain()
for i, inv in enumerate(self.inverters)]
# SAFETY-02: one watchdog per actuator, each restored from ITS OWN
# namespaced store keys (a trip on B latches B, A keeps commanding).
# Restored, because a latch held only in memory is cleared by the
# restart an operator tries first. Graded on every start: a
# disagreement with the flat value re-arms by hand and shouts (the
# fail-safe direction is ON). DECISIONS/MAIN-orchestration.md.
self._grade_store_migration(store)
for inv in self.inverters:
if inv.sign_wd.tripped:
# UX-11: the plain sentence and the code; the saved reason with
# figures is on the DEBUG line.
_LOG.error("%s stays at 0 W after the restart because its "
"power went the wrong way, so find out why before "
"you start control: %s.", inv.name,
messages.reason_code(inv.sign_wd.reason))
_LOG.debug("Evidence for %s: %s.", inv.name,
inv.sign_wd.reason or "(reason not recorded)")
# UX-11: `events` starts empty and DEBUG is off by default, so
# the saved figures go on the action log too.
saved = inv.sign_wd.reason or messages.NO_REASON_SAVED
self.log_event(f"FAILSAFE {inv.name}, saved before the "
f"restart: {saved}",
hint=messages.hint("stop_restored",
unit=inv.name))
# SAFETY-42 + SAFETY-34: a latch that does NOT act, unlike SAFETY-02's
# and SAFETY-31's (which command 0 W). It is restored so the ALARM
# survives (2026-09-02: an outage unseen for 6 h 25 m with the detector
# correct); `delivery.py` argues reporting rather than acting.
# `_delivery_announced` is per process (as `_circ_announced`): a
# restart clears the flag and keeps the latch.
self._delivery_announced = set()
for inv in self.inverters:
if inv.delivery_wd.tripped:
_LOG.error(
"%s is still marked as not delivering after the restart "
"while control keeps running, and only stopping and "
"starting control clears this: %s.",
inv.name, messages.reason_code(inv.delivery_wd.reason))
_LOG.debug("Evidence for %s: %s.", inv.name,
inv.delivery_wd.reason or "(reason not recorded)")
saved = inv.delivery_wd.reason or messages.NO_REASON_SAVED
self.log_event(f"NOT DELIVERING {inv.name}, saved before the "
f"restart: {saved}",
hint=messages.hint("not_delivering",
unit=inv.name))
# SAFETY-30: the SITE half of the sign check, one per site, armed only
# at N>=2: the SUM of the standing commands against P1. At a roster of
# one it is constructed and never evaluated (arming it would duplicate
# `inverters[0].sign_wd`: two latches, two banners, a false-positive
# record spent twice); `cycle()` enforces it. Restored from the store
# with FLAT keys (store.py). DECISIONS/SAFETY-30-per-unit-sign.md.
self.site_sign_wd = SignWatchdog(
self.site_sign_cfg,
tripped=bool(store.data.get("site_sign_tripped", False)),
reason=str(store.data.get("site_sign_reason", "") or ""))
if self.site_sign_wd.tripped:
_LOG.error(
"After the restart every inverter stays at 0 W, as the total "
"power went the wrong way, so find out why before starting "
"control: %s.", messages.reason_code(self.site_sign_wd.reason))
_LOG.debug("Evidence for the site: %s.",
self.site_sign_wd.reason or "(reason not recorded)")
self.log_event("FAILSAFE site, saved before the restart: "
+ (self.site_sign_wd.reason
or messages.NO_REASON_SAVED),
hint=messages.hint("site_direction"))
# The per-unit detectors are told which question they ask here as well
# as in `cycle()`: `checks()` and the status page read
# `inv.sign_wd.cfg` before the first cycle.
self._arm_sign_watchdogs()
# SAFETY-18: the frozen-but-fresh meter detector (freeze.py). NOT
# restored from the store: it does not act, so it does not latch; a
# restart re-derives it within one `meter_freeze_s`.
self.freeze_wd = FreezeWatchdog(
FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300))))
# SAFETY-31: one per site, RESTORED (unlike SAFETY-18): a trip files a
# P_SAFETY SET that stops every inverter, and a cleared latch would
# restart straight into the loop it stopped. SITE-level keys, not
# namespaced: circulating power is defined over the roster (store.py).
self.circ_wd = CirculationWatchdog(
CirculationConfig(
min_w=float(opts.get("circulation_w",
CIRC_OPTION_DEFAULTS["circulation_w"])),
hold_s=float(opts.get("circulation_hold_s",
CIRC_OPTION_DEFAULTS["circulation_hold_s"]))),
tripped=bool(store.data.get("circ_tripped", False)),
reason=str(store.data.get("circ_reason", "") or ""))
# SAFETY-34: has the durable alarm been put back in front of the
# operator since this process started? `persistent_notification` does
# not survive the HA restart the store does; `run_control()` re-raises
# it once before the first cycle.
self._circ_announced = False
# SAFETY-34: when each latched alarm was last put in front of HA, by
# notification_id (REANNOUNCE_S).
self._announced_mono = {}
if self.circ_wd.tripped:
_LOG.error(
"After the restart every inverter stays at 0 W, as the "
"inverters worked against each other, so find out why before "
"starting control: %s.",
messages.reason_code(self.circ_wd.reason))
_LOG.debug("Evidence for the site: %s.",
self.circ_wd.reason or "(reason not recorded)")
self.log_event("CIRCULATING POWER, saved before the restart: "
+ (self.circ_wd.reason
or messages.NO_REASON_SAVED),
hint=messages.hint("circulating"))
# SAFETY-03: the output clamp's alarm (clamp.py). No config of its own:
# the bound is `max_w` (already validated against the device rating),
# and a second key would be another place a limit must be kept in step
# (CFG-03). The anti-flood cadence is a constant, like
# freeze.RAIL_FRAC.
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. An INSTRUMENT: it files no
# claim and clamps nothing (SAFETY-07 reads it). No new option
# (`peak_cap_w`) and no second peak entity: `peak_forecast_entity`
# stays the input to maintenance_charge_floor(), and repointing it
# would change what moves energy in a maintenance charge.
# DECISIONS/SAFETY-07-peak-guard.md.
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("Month peak store: %s", self.peak.restore_state)
# FCST-04: the 8.3 carry-forward store (published prices of the last
# few Brussels days) so unprinted hours can be filled from yesterday
# and FLAGGED. An instrument, not a strategy: carrying widens what the
# optimiser SEES, never what it may act on. ⚠️ The windowing DOES move
# energy: hours ALREADY ELAPSED leave the spread, so a past cheap night
# no longer sets the day's cheapest hour; that caveat must always
# travel with the guarantee (`resolve_tariff()`, DOCS.md, CHANGELOG).
# DECISIONS/FCST-04-the-day-that-is-carried.md.
self.price_history = PriceHistory(price_history_path)
_LOG.info("Price history: %s", self.price_history.words)
# SAFETY-07: the guard is stateless (peakguard.py); the only state here
# is what an EDGE needs. The ceiling has no config of its own
# (`peak_cap_w`); SAFETY-24's floor has exactly two keys, defaults from
# `peakguard.PEAK_OPTION_DEFAULTS`: no numeric fallback in main.py that
# config.yaml has not shipped. DECISIONS/SAFETY-07-peak-guard.md.
self.peak_guard = None
# SAFETY-79: the guard aims this far under `peak_cap_w`; the bill and
# every report keep the real cap (`self.peak.cap_w`).
self.peak_margin_w = float(opts.get(
"peak_margin_w", peakguard.PEAK_OPTION_DEFAULTS["peak_margin_w"]))
self._peak_engaged = False
self._peak_saturated = False
# SAFETY-81: what the alarm is graded on, beside the guard's decision.
self._peak_proj = None
self._peak_batt_prev = None
# SAFETY-90: what a meter gap is counted at (`_fill_peak_gaps()`).
self._gap_seen_w = None
self._gap_w = None
self._gap_block = None
self._gap_filled_s = 0.0
self._gap_held_ws = 0.0
self._gap_pre = None
self._unseen = None
# A held sample TARIFF-01 restored from before this restart: the
# averager credits it max_hold_s into the restart, at 0 W batteries.
_held = self.p1.averager.held_since
self._run_pre = (None if _held is None
else (_held, self.p1.averager.held_net_w))
self._unseen_held_ws = 0.0
self.peak_gap_s = 0.0
self.peak_unmeasured_s = 0.0
self._peak_alarm_block = None
self._peak_high_since = None
self._peak_rolled = False # OPT-15
self._peak_floor_limited = False
# SAFETY-87: the reserve alarm's hold, as SAFETY-81's above.
self._reserve_batt_prev = None
self._reserve_high_since = None
# SAFETY-126: `_peak_landing()`'s last battery reading and latch.
self._land_batt_prev = None
self._peak_needed = False
self._maint_shape_suspended = False
# OPT-11: battery modes, off by default (SAFETY-10 section 1). Each
# battery's mode and on/off switch live in the store, not in options,
# so a change applies at once (`pack_use`, `pack_mode`).
self.modes_on = packmode.enabled(opts)
# The units whose mode fault is logged already (edge-logged).
self._mode_faults = set()
# OPT-11 P3: the price runs (`priceruns.RunPlan` or None), the key they
# were built for, this tick's roles (`modetrade.Tick`), batteries that
# left a run keyed by the run, and whether last tick filed a mode trade
# (a run edge reseeds).
self.mode_runs = None
self._runs_key = None
# TARIFF-24: the end of the run that was under way while a forecast
# was read; until then losing the forecast does not widen the gap.
self._wx_free_to = None
self.mode_tick = None
# UX-53: why the trade gate refused a grid charge this tick (`peak`,
# `not_worth`, `not_covered`), or None when it allowed one. Home reads
# it; nothing in control does.
self.mode_gate = None
self._run_done = {}
self._mode_trade_was = False
self._charge_blocked_q = None
self._charge_gap_q = None # OPT-15: an unmeasured stop's quarter
self._ref_cheap = None
# OPT-15: a run edge the loop holds at E: (E, step sign, until, the
# last battery reading).
self._edge = None
# OPT-11 P4/OPT-17: the solar forecast and day plan, only with battery
# modes on AND a forecast for today set; each day may name several
# entities, summed (`solarfc.entities()`); `fc_entity` is the first,
# None means no forecast.
self.fc_entities = (solarfc.entities(opts) if self.modes_on
else ())
self.fc_tomorrow_entities = solarfc.entities(opts, tomorrow=True)
self.fc_entity = self.fc_entities[0] if self.fc_entities else None
# OPT-17: the members that are not usable, as `solarfc.read_sum()`
# gives them, for the status page and the action log.
self.solar_fc_gaps = ()
self.solar_trust_pct = opts.get("solar_trust_pct",
dayplan.DEFAULT_TRUST_PCT)
self.solar_fc = None
self.solar_fc_why = (solarfc.NOT_SET if self.fc_entity is None
else solarfc.MISSING)
self._fc_read_mono = None
self.fc_hold = solarfc.Hold(
(getattr(store, "data", None) or {}).get(FC_HOLD_KEY))
self.day_plan = None
self._plan_targets = None
# OPT-34: `(until, {name: level %})` of a night charge under way.
self._charge_latch = _load_latch(
(getattr(store, "data", None) or {}).get(CHARGE_LATCH_KEY),
datetime.now(timezone.utc))
self.house_profile = None
if self.fc_entity is not None:
self.house_profile = dayplan.HouseProfile(
(getattr(store, "data", None) or {}).get(HOUSE_PROFILE_KEY))
# OPT-11 P2: (topic, reason) of MQTT commands refused and logged once.
self._mqtt_refused = set()
self._mqtt_refused_more = 0
# [(pack name, why)] for packs due and waiting their turn. Empty at a
# roster of one.
self._maint_deferred = []
# SAFETY-78: names of draining packs that cannot give power now, so the
# "waits" line logs once.
self._drain_waits = set()
# SAFETY-91: last cycle's held-back batteries (`_held_back()`).
self._held_was = frozenset()
# SAFETY-93: the batteries placed in their maintenance charge last
# cycle, and those whose charge ended since the loop last ran.
self._charging_was = frozenset()
self._charge_ended = frozenset()
# SAFETY-95: the same for a maintenance drain.
self._draining_was = frozenset()
self._drain_ended = frozenset()
# SAFETY-95: batteries settling after their drain ended; meanwhile
# the loop's error counts every battery at its command.
self._drain_tail = set()
# SAFETY-103: per battery, the range its power can be in since its
# reading last changed (that reading and every command since), and
# the house point held while a drain's tail settles.
self._read_span = {}
self._drain_house = None
# SAFETY-96: last cycle's commandable batteries, this cycle's start
# point per battery where a reseed moved it, and batteries settling
# after a step the loop did not make ({name: since, monotonic}).
self._live_was = None
self._loop_start = {}
self._settle = {}
# Set by amain() once the transport is built, so the status page can
# show what only the transport knows (homewizard_local's unchanged_s).
# None when P1 is off or the transport has no such counter.
# (DECISIONS/MAIN-orchestration-archive.md)
self.p1_source = None
# ARB-03: which actuators this site has. The roster is `self.inverters`
# and `units` is its names, DERIVED (a property, not a field): a
# settable tuple beside a roster of one Inverter would be two actuators
# and one writer. At N=1 (6.2) the grid connection and the only
# inverter are ONE actuator and every claim routes to the site; a
# second entry makes per-inverter claims stop being site claims (the
# ALLOC-01 seam). DECISIONS/MAIN-orchestration.md.
# live state
self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
# SAFETY-10: what the decision path reached on the last shadow cycle,
# in W, or None. Kept apart from `self.target` (STANDING ON THE WIRE:
# held at `shadow.WIRE_W`, re-seeded each shadow cycle): different
# facts, and folding them would show an operator a number nothing is
# doing.
self.shadow_would_w = None
# SAFETY-47: was the number above SIZED FROM THE BILLED-QUARTER METER
# on the cycle it was recorded? Recorded where the decision was taken,
# not re-derived at render (the page runs on its own task). False, not
# None, before the first shadow cycle: it is a claim about a number.
self.shadow_sized_from_meter = False
# Edge state for the action log. `_shadow_was` is None until the first
# cycle so an install that comes up IN shadow mode says so; coming up
# LIVE is silent (`_shadow_settle()`).
self._shadow_was = None
self._shadow_winner = None
self.target = 0.0
self.sat_count = 0
self.i_w = 0.0 # the loop's integrator, carried between cycles
# SAFETY-52: the direction the battery refuses (+1 / -1 / 0), carried
# like `i_w`, and when the refusal (or its last probe) started.
self.batt_refused = 0
self.batt_refused_at = None
self._now_mono = None
self._refusal_auto = False
# TEL-10: what the last cycle's loop did, for publication; seeded to
# the values the "stopped or blind" branch of `cycle()` writes.
self.loop_i_w = 0.0
self.loop_i_lead = 0.0
self.loop_reason = "not-running"
self.loop_w = None # what the loop asked for last cycle, or None
# TEL-11: the site's PV production is INSTRUMENTATION and nothing reads
# it: not `inputs_ok`, the allocator, the arbiter, a claim or a
# watchdog. Wiring it in would change behaviour and destroy the
# measurement (`tools/tel11_pv_trace.py`). `None` is a reading that
# does not exist and is seeded as one (`app/pv.py`).
self.pv_w = None
# Why there is no reading, in words the page prints, or on a good read
# the UNIT the value came in. Never empty.
self.pv_why = ("not configured" if not self.pv_roster
else "not read yet")
# TEL-12: every configured entity's own answer `(entity, watts, why)`
# in roster order, so `checks()` can name the missing sensor. Empty
# before the first read.
self.pv_parts = []
# Did the grid-following loop win the LAST arbitration? SAFETY-02 asks
# it about the command on the wire, so it is recorded at arbitration.
# Starts False.
self.following = False
self.reason = "starting"
self.why_words = "Starting"
# UX-11: has this meter episode's WARNING been logged? See read_inputs.
self._meter_quiet_logged = False
# ...when the last WARNING was, and whether this episode logged one.
self._meter_quiet_warned = None
self._meter_quiet_loud = False
# ALLOC-01: the allocation record from the last resolution, or None.
# REPLACED every cycle, never merged (a record from a tick whose
# envelopes moved is a cached envelope). SAFETY-28 gates the write path
# on it and DEV-02 reads `shares` off it.
self.allocation = None
# SAFETY-28: the tick the record above belongs to, incremented ONCE PER
# RESOLUTION, not per loop iteration. A cycle with NO resolution (the
# "inputs missing, not yet stale" hold) does not bump it: the heartbeat
# re-affirms `self.target` meanwhile, and bumping would make that
# heartbeat unauthorised and drop the site to 0 W. The record is
# SUPERSEDED, never expired. DECISIONS/MAIN-orchestration.md.
self.alloc_tick = 0
# What the last cycle's allocator dropped, so the log line is
# edge-triggered. Starts empty.
self._dropped_last = ()
# OPT-09: the last cycle's `unmet_w`, so its line is edge-triggered (a
# second-by-second line would flush the 40-row action log). 0 is "every
# watt placed", the boot state.
self._unmet_last = 0
# The site measurement, the only one left on this object: `soc` and
# `batt` live on each `Inverter`; `grid` is P1, one meter, one
# connection.
self.grid = None
self.peak_fc = None
# SAFETY-118: the unit fault last logged for the peak forecast (None =
# no fault), so the warning comes once and again only if it changes.
self._peak_fc_fault = None
self.cheap = False
self._price_gated = False # MAINT-13: is a price gate set up
# PRICE-01: which step of the price chain the prices come from (logged
# once per change), the last published series (so a gap can say since
# when), and the gap itself: True while a refresh has finished and no
# published price covers now. Notified once per gap, taken down when it
# ends.
self.price_watch = PriceWatch()
self._last_published = None
self.price_gap = False
self.price_gap_since = None
self._price_gap_announced = False
self._fixed_note_said = False
self._gated_key = None # MAINT-13: (options, hour) of the cache
# 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.
self.tariff_words = "No fixed tariff set"
# OPT-04: the tariff plan the BACKGROUND task last resolved, and when.
# cycle() reads these and never awaits a fetch (TARIFF_REFRESH_S).
# `None` means no refresh has completed: not "no prices" and certainly
# not "free".
self.tariff_plan = None
self.tariff_at = None
self.arb = None
# TARIFF-11: WHEN the standing arbitrage decision was taken, or None.
# `self.arb is None` does NOT mean "trading is off": it also means
# cycle() returned before the step, so `self.arb` is None (startup) or
# the PREVIOUS cycle's decision (input hold). See `arbitrage_row()`.
self.arb_at = None
# SAFETY-45/47: the billed-quarter charging headroom the arbitrage step
# was handed, or None. CARRIED, not recomputed for
# `arbitrage.sized_from_meter()`: `self.peak_guard` is a different
# cycle's by the time the page renders.
self.arb_headroom_w = None
# SAFETY-125: a grid charge sized to the quarter's room this cycle.
self.room_charge = False
# UX-13 P1: today's prices as the Home chart has seen them. Display
# only; nothing reads it back into a decision.
self.home_prices = homeview.DayPrices()
# UX-54: the site's measured solar per hour, for the Forecast tab.
# In memory, display only; nothing reads it back into a decision.
self.solar_today = SolarToday()
# TARIFF-11: when the most recent cycle() STARTED. `arb_at < cycle_at`
# is the derived statement "the latest cycle did not reach the
# arbitrage step".
self.cycle_at = None
self._arb_action = None
self.inputs_bad_since = None
self.answer = lateness.Answer() # SAFETY-128
self._late_logged = self._late_zero = False
# SAFETY-03: what the loop's OWN bounds did last cycle, `(engaged,
# note, over_rating)` (SAFETY-40 splits the last out). Site-level: the
# integrator that wound is the one SITE integrator; a clamp bit a value
# written to ONE device (`clamp_last` is on the Inverter).
self.loop_clamped: tuple[bool, str, bool] = (False, "", False)
# UX-08: the numbers of a loop-side over-rating, for the notification.
self.loop_clamp_facts: tuple = ()
# SAFETY-85: this cycle's loop Decision, each battery's recent standing
# commands, and how many cycles in a row a battery set by another owner
# gave less than asked.
self._loop_decision = None
self._asked_hist: dict = {}
self._short_run = 0
# SAFETY-119: did another owner set the wire last cycle; the window
# after a hand-back (monotonic s, loop cycles in a row); monotonic s of
# the bound cycles hidden in the last ROLLING_S (a stop does not clear
# them).
self._wire_lost = False
self._handback = None
self._hidden = ()
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
def log_event(self, msg: str, hint: str | None = None) -> None:
"""`msg` goes on the action log, the add-on's own technical record.
`hint` is `messages.hint()`'s plain words for the same event, and is
what the page shows under the buttons (UX-08)."""
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
self.events.insert(0, f"{stamp} {msg}")
del self.events[40:]
if hint is not None:
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
def _grade_store_migration(self, store) -> None:
"""DEV-02. Did SAFETY-02's latch actually survive the key rename?
Graded, not performed: a latch that silently fails to move is a
DISARMED SAFETY GUARD on an install that believes it is protected. The
pre-DEV-02 flat value is compared with what the roster came up with; a
disagreement re-arms the latch by hand (the fail-safe direction is ON).
It runs on EVERY start, not only the migrating one: gating on `rep.ran`
swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip
in the window, upgrade; performed on this site 2026-09-02).
DECISIONS/MAIN-orchestration.md."""
rep = self.store_migration
if rep.ran and not rep.ok:
_LOG.error("The saved safety stops did not copy over cleanly, so "
"they are read again from the older saved state: %s.",
rep.explain())
data = getattr(store, "data", {})
legacy_tripped = bool(data.get("sign_tripped", False))
if not legacy_tripped or not self.inverters:
return
# The question is "is any namespaced latch set THAT A MACHINE READS"
# (SAFETY-44), asked of the STORE's keys, not the roster's objects:
# re-arming A off a flat key that B's trip mirrored would latch a
# healthy inverter on every restart. Three cases: on the roster, stop;
# off-roster named `UNIT_A` (the broken migration's fingerprint),
# repair onto `inverters[0]`; off-roster, other name: stop unless a
# stranded `UNIT_A` latch is present. This guard is the ONLY repair for
# sites upgraded before the fix. Residual: a first unit really named
# `inverter-a`, latched and removed, is re-armed on `inverters[0]`
# (conservative). DECISIONS/MAIN-orchestration.md.
on_roster = {inv.name for inv in self.inverters}
stranded, on_roster_latched, other_off_roster = [], False, False
for k, v in data.items():
if not (k.startswith("unit/") and k.endswith("/sign_tripped")):
continue
if not bool(v):
continue
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1:
# split through the writer's own spelling.
unit = k.split("/")[1]
if unit in on_roster:
on_roster_latched = True
elif unit == UNIT_A:
stranded.append(k)
else:
other_off_roster = True
if on_roster_latched or any(inv.sign_wd.tripped
for inv in self.inverters):
return
if other_off_roster and not stranded:
# A removed unit's own latch and no misdirected one: the flat key
# is that unit's mirror, so re-arming `inverters[0]` from it would
# latch a healthy pack.
return
if stranded:
_LOG.error(
"A saved safety stop belongs to %s, which is not in the "
"inverter list %s, so it is set again on the first inverter.",
", ".join(sorted(stranded)), sorted(on_roster))
first = self.inverters[0]
# The one state this method exists for.
reason = str(data.get("sign_reason", "") or "")
first.sign_wd.tripped = True
first.sign_wd.reason = reason or (
"SIGN_DISAGREEMENT: latched before this version; the reason was "
"not carried across the per-inverter store migration")
first.store_set("sign_reason", first.sign_wd.reason)
first.store_set("sign_tripped", True)
_LOG.error(
"%s is set to 0 W again because an older saved safety stop was "
"found, so find out why before you start control: %s.",
first.name, messages.reason_code(first.sign_wd.reason))
_LOG.debug("Evidence for %s: %s.", first.name, first.sign_wd.reason)
_LOG.debug(
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
"sign-disagreement failsafe is latched and no inverter came up "
"latched. Either the per-inverter migration lost it, or an older "
"add-on wrote it during a rollback. Both would be a disarmed "
"guard, so the latch is re-armed on %s.", first.name)
self.log_event("FAILSAFE re-armed from the pre-DEV-02 store keys - "
+ first.sign_wd.reason,
hint=messages.hint("stop_restored", unit=first.name))
# the roster, and the N=1 views of it
@property
def units(self) -> tuple:
"""The actuator names, DERIVED from the roster. Never assignable (see
ARB-03 in `__init__`)."""
return tuple(inv.name for inv in self.inverters)
@property
def maint_index(self) -> int:
"""Which roster slot holds the site's ONE maintenance slot: the pack
mid-cycle if any, otherwise the first (always 0 at a roster of one)."""
for i, m in enumerate(self.maints):
if m.phase in POWER_MOVING:
return i
return 0
@property
def maint(self):
"""The `Maintenance` the site's one maintenance slot belongs to
(MAINT-05): a VIEW over `self.maints` (one FSM per roster entry).
Site-level surfaces (`phase` MQTT value, banner, start/abort
buttons) want the one that is recalibrating;
`maintenance.arbitrate()` guarantees at most one is out of IDLE.
None active gives `maints[0]`, as at a roster of one on every tick
(AC 6, test_maintenance.py part 9)."""
return self.maints[self.maint_index]
@property
def maint_unit(self):
"""The pack the site's one maintenance slot is currently on: whichever
holds the slot, by `maint_index`, not roster order (MAINT-05 removed
the flat `inverters[0]`). validate.py rule 6 moved with it: it
grades `maintenance_charge_w` against the weakest cap on the roster,
like rule 5."""
return self.inverters[self.maint_index]
def _maint_button(self, now):
"""The pack an operator's one maintenance button acts on: the pack
holding the slot, so "abort" stops the running cycle and "start"
restarts it; when all are idle, the longest-waiting (`arbitrate()`
clause 2), so forced and scheduled starts choose the same pack."""
if self.maints[self.maint_index].phase in POWER_MOVING:
return self.maints[self.maint_index]
return max(enumerate(self.maints),
key=lambda p: (p[1].waited_days(now), -p[0]))[1]
def inverter(self, name: str):
"""The roster entry called `name`, or None."""
for inv in self.inverters:
if inv.name == name:
return inv
return None
def _can_write(self, name: str) -> bool:
"""Is there a roster entry called `name` WITH a setpoint entity?"""
inv = self.inverter(name)
return inv is not None and inv.has_writer()
def _only(self):
"""The sole inverter, or an error naming why the caller cannot have
one. 6.2's identity (one inverter means site and unit are the same
thing) is false at N>=2, so every N=1 view refuses loudly instead of
answering for "the first one"."""
if len(self.inverters) != 1:
raise AttributeError(
f"this site has {len(self.inverters)} inverters, so there is no "
f"single per-device value to read off the Controller. §6.2's "
f"identity holds only at N=1. Read it off the roster entry: "
f"`for inv in controller.inverters: inv.<attr>`")
return self.inverters[0]
# Compatibility views, as properties not fields: the scalars removed by
# ARB-03/DEV-02 survive as read/write views of the SOLE inverter so an N=1
# install and every replay stay byte-identical (AC 8), and they REFUSE at
# N>=2 so a consumer not yet made per-instance fails on the line that reads
# it. NOTHING INSIDE THIS FILE MAY READ THEM: test_inverter.py scans this
# module's AST and goes red on a `self.<view>` inside any `Controller`
# method.
@property
def unit(self) -> str:
return self._only().name
@property
def soc(self):
return self._only().soc
@soc.setter
def soc(self, value):
self._only().soc = value
@property
def batt(self):
return self._only().batt
@batt.setter
def batt(self, value):
self._only().batt = value
@property
def dev_min(self):
return self._only().dev_min
@dev_min.setter
def dev_min(self, value):
self._only().dev_min = value
@property
def dev_max(self):
return self._only().dev_max
@dev_max.setter
def dev_max(self, value):
self._only().dev_max = value
@property
def last_write(self):
return self._only().last_write
@last_write.setter
def last_write(self, value):
self._only().last_write = value
@property
def last_write_at(self):
return self._only().last_write_at
@property
def last_write_ok(self):
return self._only().last_write_ok
@property
def clamp_last(self):
return self._only().clamp_last
@property
def sign_wd(self):
return self._only().sign_wd
# SAFETY-30: which comparison each sign detector is making
def _arm_sign_watchdogs(self) -> None:
"""Tell every sign detector which of SAFETY-30's two questions it asks;
a pure function of `len(self.inverters)`. At ONE inverter (6.2, AC 8)
the unit's detector is armed against THE METER with the shipped pair
and the site detector is not armed. At TWO OR MORE each unit is armed
against ITS OWN measured power (`Inverter.batt`) under the per-unit
pair, and the site detector against the meter under the site pair.
Called at the top of EVERY `cycle()` (fixtures append an `Inverter`
after construction); `arm()` touches no timer or latch.
DECISIONS/MAIN-orchestration.md."""
one = len(self.inverters) <= 1
for inv in self.inverters:
inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg,
AGAINST_METER if one else AGAINST_OWN_POWER)
self.site_sign_wd.arm(self.site_sign_cfg, AGAINST_METER)
# The site aggregates, each named for the question it answers. Each picks a
# DIFFERENT reduction (the wrong one drives a pack below its own floor);
# the reason is written at each definition.
@property
def site_max_w(self) -> float:
"""The supervised bound on the SITE target: what the roster can
deliver. SUM: each unit's own `supervised` claim still binds its
share, and bounding the site loop at ONE unit's rating would waste
the second inverter. `max_w` exactly at N=1."""
return float(sum(inv.max_w for inv in self.inverters))
@property
def site_rating_w(self) -> float:
"""The largest magnitude the SITE can be commanded, in W: SUM of each
unit's own `min(max_w, |dev_min|, |dev_max|)` (the peak guard's
`achievable_w` and the freeze detector's rail). Summing the per-unit
conjunction makes B's rating bind B (AC 4). An unread device range
contributes `max_w`."""
return float(sum(inv.rating_w() for inv in self.inverters))
def site_dev_bound(self, which: str):
"""The roster's summed device floor / ceiling, or None if unknown.
`freeze.looks_frozen()` and `rail_unreachable()` SKIP a bound handed
None, so a bound is summed only when EVERY unit has answered: one
silent unit makes it unknown, not smaller."""
vals = [getattr(inv, which) for inv in self.inverters]
if not vals or any(v is None for v in vals):
return None
return float(sum(float(v) for v in vals))
@property
def site_batt_w(self):
"""SUM. The battery power the SITE loop regulates against. Opposite to
the rest here (ALLOC-01 review): `compute()`'s anti-windup test
`abs(prev_w - actual_w) > saturation_w` compares the SITE target, so
one unit's power at N=2 trips the saturation freeze permanently (up
to 510 W of split-dependence at gain 0.2 on the two-master rig; with
the sum every split is bit-identical, test_alloc.py part 8)."""
vals = [inv.batt for inv in self.inverters]
if not vals or any(v is None for v in vals):
return None
return float(sum(float(v) for v in vals))
@property
def site_circulating_w(self):
"""SAFETY-31. `sum(|B_i|) - |sum(B_i)|` over MEASURED per-unit power:
`inv.batt`, NOT `self.allocation`. The allocation record already has
sign-coherence (`REFUSE_SIGN`), so a record-derived sensor reads
zero by construction and can only confirm the allocator agrees with
itself; every fault this card covers is a divergence between command
and hardware (TEST-19 rig: one unit wedged, record `ok` at -1250 W
each, record-derived 0 W, this 2000 W; HW-01's 6 h 25 m). None when
any unit has not answered, never zero (circulation.py): zero would
report health from an absence. 0.0 at a roster of one (AC 5)."""
return circulating_w([inv.batt for inv in self.inverters])
@property
def pack_floor_soc(self):
"""MINIMUM. The SoC a discharge PERMISSION is graded against: the
EMPTIEST pack (an average lets one be driven below its floor). None
if any unit did not answer (`peakguard.decide()` reads None as
floor-limited). Not the right reduction for a charge ceiling (the
FULLEST pack); nothing files a per-pack ceiling yet
(CFG-04/SPIKE-04)."""
vals = [inv.soc for inv in self.inverters]
if not vals or any(v is None for v in vals):
return None
return float(min(float(v) for v in vals))
@property
def packs(self) -> list:
"""SAFETY-32. Each pack as its own THREE numbers in roster order,
beside `pack_floor_soc` (the site's one verdict, the emptiest pack).
EVERY unit, live or not, as `inputs_ok` does."""
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
for inv in self.inverters]
async def mqtt_command(self, topic, payload, retained) -> None:
"""OPT-11 P2: one command from a battery's HA switch or mode list,
through handle_action(). A retained command is never applied (the store
already holds the last choice). Never raises: MQTT must not reach
control. (DECISIONS/MAIN-orchestration-archive.md)"""
try:
slug, action, value, why = parse_command(
topic, payload, retained, packmode.MODES)
if why is None:
names = [inv.name for inv in self.inverters]
unit = dict(zip(unit_slugs(names), names)).get(slug)
result = await self.handle_action(action, unit=unit,
value=value)
if not (isinstance(result, dict) and "error" in result):
self.mqtt.publish(self.mode_values())
return
why = result["error"]
else:
why = messages.pack_refused(why)
# Printable only: a topic is sender-chosen, so no log injection.
topic_s = "".join(c if c.isprintable() else "?"
for c in str(topic)[:128])
_LOG.debug("Home Assistant command %s %s refused: %s", topic_s,
repr(payload)[:48], why)
if (topic_s, why) in self._mqtt_refused \
or len(self._mqtt_refused) >= 32:
# Not logged again, but counted: 1, 10, 100... stay visible.
self._mqtt_refused_more += 1
more = self._mqtt_refused_more
if str(more).rstrip("0") == "1":
_LOG.warning("Home Assistant commands still ignored: %d "
"more so far.", more)
return
self._mqtt_refused.add((topic_s, why))
_LOG.warning("Home Assistant command on %s ignored: %s", topic_s,
why)
except Exception: # noqa: BLE001
_LOG.exception("Home Assistant command could not be handled")
@property
def why_words(self) -> str:
"""The page's "Why" line. While control waits for a missing reading
the age is counted when the page asks (UX-10), not frozen at the age
the last cycle saw."""
if self._why_waiting_since is not None:
return messages.why_waiting(max(0.0, (
datetime.now(timezone.utc)
- self._why_waiting_since).total_seconds()))
return self._why_words
@why_words.setter
def why_words(self, text: str) -> None:
self._why_words = text
self._why_waiting_since = None
def _loop_alarm_input(self, resolution) -> None:
"""SAFETY-85. The loop's clamp counts only on a cycle the loop decided
(a rating may cut it). A battery another owner set to discharge counts
when it gave less than it was told for `saturation_cycles` cycles in a
row. DECISIONS/SAFETY-85-no-false-not-keeping-up.md"""
d = self._loop_decision
if d is None:
self._short_run = 0
self._asked_hist = {}
self._wire_lost, self._handback = False, None
return
unmet = self.allocation.unmet_w if self.allocation is not None else 0
cut = (resolution.bound_by or "").split("(")[0]
owns = (resolution.winner == "loop" and not d.held and not unmet
and (resolution.target_w == d.target_w
or cut in ("device", "supervised")))
# Graded against the least that stood on the wire (0 W in shadow
# mode) over SHORT_CYCLES cycles and SHORT_SPAN_S seconds.
now, asked = self._now_mono, {}
for inv in self.inverters:
h = self._asked_hist.setdefault(inv.name, [])
h.append((now, inv.standing_w()))
while len(h) > SHORT_CYCLES and (
now is None or h[0][0] is None
or now - h[0][0] > SHORT_SPAN_S):
h.pop(0)
asked[inv.name] = min(w for _t, w in h)
others = [inv for inv in self.inverters if not owns
or allocator.pin_w(resolution.unit_bounds.get(inv.name))
is not None]
short = [(inv, asked[inv.name], inv.batt) for inv in others
if inv.batt is not None
and delivery_short(asked[inv.name], inv.batt,
self.tuning.saturation_w)]
self._short_run = self._short_run + 1 if short else 0
settling = self._settling(owns, now)
if owns and self.loop_clamped[0] and not (
settling and not self.loop_clamped[2]):
return
engaged = self._short_run >= self.tuning.saturation_cycles
named = len(self.inverters) > 1
self.loop_clamped = (engaged, shortfall_note(
[(inv.name if named else None, a, g) for inv, a, g in short])
if engaged else "", False)
self.loop_clamp_facts = ()
def _settling(self, owns, now) -> bool:
"""SAFETY-119: does the loop's bound on this cycle count? Not within a
settle (HANDBACK_CYCLES cycles and SHORT_SPAN_S) after another owner
handed the wire back; across all hand-backs the bound is hidden on at
most ROLLING_BOUND_CYCLES cycles in any ROLLING_S.
DECISIONS/SAFETY-121-bound-every-quarter.md"""
def aged(t0, span):
return now is None or t0 is None or now - t0 >= span
if not owns:
self._wire_lost, self._handback = True, None
return False
if self._wire_lost:
self._wire_lost, self._handback = False, (now, 0)
if self._handback is None:
return False
tc, n = self._handback
n += 1
self._handback = (tc, n)
if aged(tc, SHORT_SPAN_S) and n >= HANDBACK_CYCLES:
self._handback = None
return False
if self.loop_clamped[0] and not self.loop_clamped[2]:
kept = tuple(t for t in self._hidden if now is None or (
t is not None and now - t < ROLLING_S))
if len(kept) >= ROLLING_BOUND_CYCLES:
self._hidden = kept
return False
self._hidden = kept + (now,)
return True
async def check_output_clamp(self) -> None:
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle. Runs
AFTER write_setpoint() (the clamp is arithmetic done inside the write).
⚠️ PUBLISHING IS NOT IN THE WRITE PATH: a stalled supervisor must never
delay or drop a frame. ⚠️ THIS IS NOT A TRIP: no claim, no latch, 0 W
is not commanded (§11.2: clamp and alarm); turning it into one is a
deliberate change. (DECISIONS/MAIN-orchestration-archive.md)"""
# ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT: `clamp_last` is on
# the Inverter, `ClampWatchdog` is one per controller (an anti-flood
# counter), so any unit that clamped is an engagement and the reason
# names it. (DECISIONS/MAIN-orchestration-archive.md)
clamped_units = [inv for inv in self.inverters
if inv.clamp_last is not None and inv.clamp_last.engaged]
engaged_write = bool(clamped_units)
engaged_loop, loop_note, loop_bug = self.loop_clamped
engaged = engaged_write or engaged_loop
# SAFETY-40: only an over-rating is a bug; the integrator bound is not.
bug = engaged_write or loop_bug
if engaged_write:
_named = len(self.inverters) > 1
reason = write_clamp_note([(inv.name if _named else None,
inv.clamp_last)
for inv in clamped_units])
facts = [messages.Clamp(inv.name if _named else None,
inv.clamp_last.requested_w,
inv.clamp_last.applied_w,
inv.clamp_last.bound)
for inv in clamped_units]
else:
reason = loop_note if engaged else ""
facts = self.loop_clamp_facts if engaged else ()
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc),
bug=bug, facts=facts)
# THE ACTION LOG IS COALESCED PER EPISODE: consecutive engagements
# update one row's count. ⚠️ NOT COSMETIC: the log keeps 40 rows and a
# winding loop clamps every cycle, so a row each flushed SAFETY-18's
# "METER FROZEN?" row. Here as well as in write_setpoint() because on a
# default install the actuator-side clamp is unreachable.
# (DECISIONS/MAIN-orchestration-archive.md)
if engaged and not engaged_write:
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
row = (f"{tag}: "
f"{messages.count(self.clamp_wd.engagements, 'engagement')}"
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, hint=messages.hint(
"power_too_high" if bug else "not_keeping_up"))
if not fired:
return
if not self.clamp_wd.bug:
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
# it can never replace a BUG SIGNAL notice still on screen.
_LOG.warning("The battery gives less than asked: %s. Held back %s "
"so far.", messages.reason_code(self.clamp_wd.reason),
messages.times(self.clamp_wd.engagements))
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
_LOG.debug("A short hold after a large load or solar step is "
"normal. If it lasts, check that the battery follows "
"its commands.")
await self.hass.call("persistent_notification", "create",
messages.loop_bound().data())
return
_LOG.error(
"Power request too high, a software fault: %s. Cut back %s so "
"far, control runs on.", messages.reason_code(self.clamp_wd.reason),
messages.times(self.clamp_wd.engagements))
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
_LOG.debug(
"SAFETY-03: 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. This is an alarm, not a trip.")
# 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. (DECISIONS/MAIN-orchestration-archive.md)
await self.hass.call("persistent_notification", "create",
messages.output_clamp(self.clamp_wd.facts,
shadow=self.shadow).data())
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: a peak floor above the rating is a house
load bigger than the inverter, not a defect (unlike SAFETY-03's winding
integrator). SAFETY-81: `g.saturated` must agree with
`peakguard.load_too_high()` for `ALARM_HOLD_S`. Edge-triggered: one
notification per episode, at most once per quarter.
(DECISIONS/MAIN-orchestration-archive.md)"""
g = self.peak_guard
batt, prev = self.site_batt_w, self._peak_batt_prev
if batt is None:
# Unknown, not "fine": the hold below neither grows nor restarts.
return
self._peak_batt_prev = batt
if prev is not None:
batt = min(float(batt), float(prev))
p = self._peak_proj
# OPT-15 x SAFETY-81: until the new quarter's first sample the old
# projection read "no seconds left" (not high), which restarted the
# hold; a rolled one (battery modes on) is not graded then, so a
# persistent overload is still notified once per quarter.
high = bool(
g is not None and g.saturated and p is not None
and p.block_start is not None and not self._peak_rolled
and peakguard.load_too_high(
p, batt_w=batt, ceiling_w=g.ceiling_w,
achievable_w=self.site_rating_w))
# SAFETY-81: and for ALARM_HOLD_S, on the projection's clock.
saturated = False
if high:
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
if self._peak_high_since is None:
self._peak_high_since = t
saturated = ((t - self._peak_high_since).total_seconds()
>= peakguard.ALARM_HOLD_S)
else:
self._peak_high_since = None
was, self._peak_saturated = self._peak_saturated, saturated
if not saturated or was:
return
_LOG.error(
"Switch off large loads, the battery alone cannot keep this "
"15-minute period under the peak limit: %s.",
messages.reason_code(g.reason))
_LOG.debug("Evidence: %s.", g.reason)
# SAFETY-81: notified once per quarter; a later episode is logged only.
if p.block_start == self._peak_alarm_block:
return
self._peak_alarm_block = p.block_start
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
hint=messages.hint("peak_saturated"))
# 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",
messages.peak_saturated(
g.ceiling_w, shadow=self.shadow).data())
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 (the capacity tariff bills the
month's worst quarter). Not check_peak_saturated(): that is shed-load,
this is nothing discharged; never both. ⚠️ SAFETY-25: the message says
the floor binds this guard only; the loop has no SoC floor and goes on
discharging. Edge-triggered. (DECISIONS/MAIN-orchestration-archive.md)"""
g = self.peak_guard
limited = bool(g is not None and g.floor_limited)
if limited and (g.floor_w or 0.0) > 0.0:
limited = self._reserve_short(g)
else:
self._reserve_batt_prev = self._reserve_high_since = None
# SAFETY-126: nothing can give, so it alarms at once, but only
# when the quarter really misses the aim (`_peak_landing()`).
limited = limited and not g.within_aim
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. (DECISIONS/MAIN-orchestration-archive.md)
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
_LOG.error(
"Switch off large loads, the peak guard may not draw the battery "
"lower: %s.", messages.reason_code(g.reason))
_LOG.debug("Evidence: %s.", g.reason)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. ⚠️ SAFETY-25's
# sentence (the reserve binds the peak guard only) is in
# messages.peak_reserve() on every branch, unreadable SoC included.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.hass.call("persistent_notification", "create",
messages.peak_reserve(
g.ceiling_w, soc_unread=g.soc is None,
floor_unknown=(g.soc is not None
and g.soc_floor is None),
packs=(g.held if len(self.inverters) > 1
else ()),
others=(g.floor_w or 0.0) > 0.0).data())
def _reserve_short(self, g) -> bool:
"""SAFETY-87: the batteries that can still give, at full power, miss
the limit (`load_too_high()` on `g.able_w`), for ALARM_HOLD_S, as
SAFETY-81 grades 'Load too high'."""
p, batt = self._peak_proj, self.site_batt_w
if batt is None:
return self._peak_floor_limited
prev, self._reserve_batt_prev = self._reserve_batt_prev, batt
if prev is not None:
batt = min(float(batt), float(prev))
if not (p is not None and p.block_start is not None
and peakguard.load_too_high(p, batt_w=batt,
ceiling_w=g.ceiling_w,
achievable_w=g.able_w)):
self._reserve_high_since = None
return False
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
if self._reserve_high_since is None:
self._reserve_high_since = t
return ((t - self._reserve_high_since).total_seconds()
>= peakguard.ALARM_HOLD_S)
async def check_circulating(self) -> None:
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. ⚠️
THE LATCH IS NOT THE ALARM: the notification is in memory, so
`_circ_announced` (per-PROCESS, not persisted) makes a restart re-raise
it, and REANNOUNCE_S re-asserts it after a Core-only restart. ⚠️ ONE
CODE PATH for a fresh trip and a restart into one.
(DECISIONS/MAIN-orchestration-archive.md)"""
if not self.circ_wd.tripped:
return
if self._circ_announced and not self._reannounce_due("goodwe_circulating_power"):
return
# Set BEFORE the await. `hass.call()` can raise or hang, and a retry
# every cycle for a broker that is down would be an alarm storm on the
# event bus on top of a site that is already stopped.
# (DECISIONS/MAIN-orchestration-archive.md)
self._circ_announced = True
self._announced_mono["goodwe_circulating_power"] = _mono()
names = self.units
pos, neg = opposing([inv.batt for inv in self.inverters],
min_w=self.circ_wd.cfg.min_w / 2.0)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# this add-on raises - check_meter_frozen() gives the reasoning.
await self.hass.call("persistent_notification", "create",
messages.circulating(
[names[i] for i in pos] if pos and neg else (),
[names[i] for i in neg] if pos and neg else (),
).data())
def _reannounce_due(self, notification_id: str) -> bool:
"""SAFETY-34: has this latched alarm gone REANNOUNCE_S without being re-asserted?"""
t = self._announced_mono.get(notification_id)
return t is None or _mono() - t >= REANNOUNCE_S
async def check_not_delivering(self) -> None:
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart. ⚠️
THE LATCH IS NOT THE ALARM: the notification is in memory, so
`_delivery_announced` (a per-PROCESS set of unit names, not persisted)
makes a restart re-raise it, and REANNOUNCE_S re-asserts it. ⚠️ ONE
CODE PATH for a fresh trip and a restart into one. ⚠️ ONE ID PER UNIT
(`unit_slugs()`): two wedged inverters give two cards.
(DECISIONS/MAIN-orchestration-archive.md)"""
names = self.units
slugs = unit_slugs(names)
for inv, slug in zip(self.inverters, slugs):
nid = f"goodwe_not_delivering_{slug}"
if not inv.delivery_wd.tripped:
continue
if inv.name in self._delivery_announced and not self._reannounce_due(nid):
continue
# Added BEFORE the await, for `check_circulating()`'s reason:
# `hass.call()` can raise or hang, and a retry every cycle for a
# broker that is down would be an alarm storm on the event bus.
# (DECISIONS/MAIN-orchestration-archive.md)
self._delivery_announced.add(inv.name)
self._announced_mono[nid] = _mono()
await self.hass.call("persistent_notification", "create",
messages.not_delivering(inv.name, slug).data())
async def check_price_gap(self) -> None:
"""PRICE-01. One notification when no price covers now, gone when one
does. (DECISIONS/MAIN-orchestration-archive.md)"""
if self.price_gap and not self._price_gap_announced:
self._price_gap_announced = True
await self.hass.call("persistent_notification", "create",
messages.no_prices(*self._gap_args()).data())
elif not self.price_gap and self._price_gap_announced:
self._price_gap_announced = False
await self.hass.call("persistent_notification", "dismiss",
messages.prices_back().data())
async def check_fixed_note(self) -> None:
"""PRICE-01 G1. First cycle only: one INFO line when `static_profile`
sits beside `price_service` (the plan's last fallback, not a fault, so
no notification). (DECISIONS/MAIN-orchestration-archive.md)"""
if self._fixed_note_said:
return
self._fixed_note_said = True
if fixed_tariff_beside_service(self.o):
_LOG.info(messages.FIXED_TARIFF_LOG)
try:
await self.hass.call("persistent_notification", "dismiss",
messages.fixed_tariff_card_gone().data())
except Exception: # noqa: BLE001 - housekeeping never stops a cycle
_LOG.debug("Could not take down the old fixed-tariff card.")
def _gap_args(self) -> tuple:
"""`(since, now)` in Brussels time for the gap's words. `since` is None
when no price was ever published."""
now = brussels(datetime.now(timezone.utc))
if self.price_gap_since is None:
return None, now
return brussels(self.price_gap_since), now
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). getattr, not an attribute access: `unchanged_s` exists only
on `homewizard_local`, and `p1_source` is None until amain() builds the
transport. (DECISIONS/MAIN-orchestration-archive.md)"""
unchanged = getattr(self.p1_source, "unchanged_s", None)
# SITE, all three inputs: one meter, the SITE target the arbiter
# resolved, and the rail the SITE could stand at - which is the summed
# per-unit conjunction, so a small inverter B lowers the site rail rather
# than being graded against inverter A's.
fired = self.freeze_wd.evaluate(unchanged, self.target,
self.site_max_w,
datetime.now(timezone.utc),
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max"))
if not fired:
return
self.log_event("METER FROZEN? " + self.freeze_wd.reason,
hint=messages.hint("meter_frozen"))
_LOG.error(
"The meter shows the same value while the battery is asked for "
"full power, and control still follows it, so check the meter: "
"%s.", messages.reason_code(self.freeze_wd.reason))
_LOG.debug("Evidence: %s.", self.freeze_wd.reason)
_LOG.debug(
"SAFETY-18: nothing else stops this. The meter still answers, so "
"its age is fresh, the firmware watchdog is fed and the arbiter "
"only bounds the magnitude. Kept up for a quarter of an hour, it "
"sets the month's billed capacity peak.")
# ⚠️ 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",
messages.meter_frozen(unchanged).data())
# -- 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.
(DECISIONS/MAIN-orchestration-archive.md)"""
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: raising out of here would
freeze the plan, looking current for ever (`fresh_tariff_plan()`'s age
bound is the backstop). ⚠️ A FAILURE DOES NOT CLEAR THE PLAN
(TARIFF-12): a degraded plan replaces a standing observation only once
it has aged out; a §11.4 fallback never replaces a fresh provider plan.
⚠️ `tariff_at` DOES NOT MOVE ON A KEPT PLAN.
(DECISIONS/MAIN-orchestration-archive.md)"""
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,
history=self.price_history, watch=self.price_watch)
standing = self.fresh_tariff_plan(now)
# Three terms, and each of them is the only false one in some
# check: the feed answered (replace), nothing current stands
# (take the degradation - this is what makes the age bound
# govern), or what stands is itself a degradation (replace).
keep = (not plan.from_feed
and standing is not None and standing.from_feed)
if keep:
_LOG.warning(
"The price update was incomplete, so the plan from %s "
"is kept until it is %s old. The update held %s.",
messages.when(brussels(self.tariff_at)),
messages.duration(TARIFF_MAX_AGE_S), plan.words)
else:
self.tariff_plan, self.tariff_at = plan, now
_LOG.info("tariff refreshed: %s", plan.words)
except Exception as err: # noqa: BLE001
# ⚠️ NOT THE OUTAGE PATH - see the docstring: `load_prices()`
# returns on every failure a site can have, so what reaches
# here is unexpected, and the plan is kept on the same terms.
# (DECISIONS/MAIN-orchestration-archive.md)
_LOG.warning(
"The price update failed (%s), so the plan from %s is kept "
"until it is %s old, and trading stops then.",
err, messages.when(brussels(self.tariff_at)),
messages.duration(TARIFF_MAX_AGE_S))
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self.stop_event.wait(),
timeout=TARIFF_REFRESH_S)
async def run_control(self) -> None:
# ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4 (one
# read of one `setpoint_entity` left a second inverter's clamp, freeze
# rail and `achievable_w` on the first one's numbers). Concurrently: a
# slow ESPHome on one unit must not delay the start on the others.
# (DECISIONS/MAIN-orchestration-archive.md)
async def _limits(inv):
if inv.driver.configured():
inv.dev_min, inv.dev_max = await self._driver(inv).limits()
await asyncio.gather(*(_limits(inv) for inv in self.inverters))
entity = self.o.get("setpoint_entity", "")
# ⚠️ THIS USED TO WARN AND CARRY ON, the bug CFG-01 exists to remove: a
# max_w above the device's rating was silently clamped. amain() ran
# this gate already; it runs AGAIN because ESPHome is often not up at
# boot (limits None) and this is the first moment the real rating is
# known; a conflict refuses through the normal shutdown (0 W). ⚠️ THE
# `if entity:` THAT WRAPPED THIS BLOCK FAILED PERMISSIVE (CFG-04's
# hazard): a per-inverter config leaves the flat `setpoint_entity`
# blank, so the re-check was skipped; the per-unit rule now runs
# unconditionally. (DECISIONS/MAIN-orchestration-archive.md)
problems = []
if entity:
_first = self.inverters[0]
problems += validate_startup_config(self.o, _first.dev_min,
_first.dev_max)
problems += validate_unit_ranges(self.inverters, skip_entity=entity)
if problems:
refuse(problems)
self.refused = problems
self.log_event("REFUSED: " + problems[0],
hint=messages.hint("refused"))
# Signal and return WITHOUT entering the loop: amain() runs the
# wind-down (0 W, or a hand-back: SMA-10) and exits with code 78.
self.stop_event.set()
return
last_grid = object()
heartbeat = float(self.o.get("heartbeat_s", 10))
# ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not
# a second scheduler: every cycle resets it, so on a healthy meter it
# never elapses and the per-cycle gain is unchanged. It exists because
# a meter that repeats a value would run NO cycles, and the `inputs
# missing -> 0 W` claim lives inside cycle(). NOT a freeze detector. ⚠️
# THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN: it sits under
# validate.FIRMWARE_INGEST_VETO_S (30 s) and `meter_max_age_s` and
# moves with the firmware's veto; the schema minimum of 5 s is the
# reference meter's interval and deserves a second look.
# (DECISIONS/MAIN-orchestration-archive.md)
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: in P1 mode past
# `meter_max_age_s` (30 s) p1.net_w is None ("inputs missing").
# ⚠️ TRANSPORT-CONDITIONAL: with `meter_source: off`,
# read_inputs() has NO age gate, so a quiet entity reads as
# current; the floor does not make it fresh. The integrator
# advances per CYCLE, so bounding the gap bounds the
# accumulation. (DECISIONS/MAIN-orchestration-archive.md)
await self.cycle()
# ⚠️ The heartbeat is not an optimisation: the ESP32 zeroes the
# inverter after ~30 s of silence, and refreshing the SAME value
# proves we are alive. ⚠️ ONE DEADLINE PER INVERTER, refreshed
# concurrently, each bounded by WRITE_BUDGET_S (budget plus the
# largest `heartbeat_s` stays under layer 1's veto: graded); each
# unit refreshes its own `inv.target`, never the site target.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.send_heartbeats(asyncio.get_running_loop().time(),
heartbeat)
self.publish()
self.loop_pass_mono = time.monotonic()
await asyncio.sleep(1)
def publish(self) -> None:
# ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE: `battery` stays the SUM and
# `soc` the EMPTIEST pack (their `unique_id`s are on live dashboards);
# per-inverter entities are TEL-07's.
# (DECISIONS/MAIN-orchestration-archive.md)
values = {
"setpoint": self.target,
"grid": self.grid,
"battery": self.site_batt_w,
"soc": self.pack_floor_soc,
"phase": self.maint.phase,
# ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing
# one: "failsafe" is additive to a sensor already on every
# dashboard, and a P_SAFETY stop that did not reach it would be a
# site at 0 W reporting "running".
# (DECISIONS/MAIN-orchestration-archive.md)
"status": ("failsafe" if any(i.sign_wd.tripped
for i in self.inverters)
or (self.site_sign_wd.tripped
and len(self.inverters) > 1)
or self.circ_wd.tripped
# SAFETY-10: "shadow" is its own value and not a flavour
# of "stopped". ⚠️ BELOW `failsafe`: a latch that is up
# is up, and shadow does not clear it.
# (DECISIONS/MAIN-orchestration-archive.md)
else "shadow" if self.shadow
else "running" if self.auto else "stopped"),
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
# `site_circulating_w`. `publish()` drops None values, so a missing
# per-unit reading leaves the last figure standing rather than
# publishing a zero that would read as health.
# (DECISIONS/MAIN-orchestration-archive.md)
"circulation": (None if self.site_circulating_w is None
else round(self.site_circulating_w)),
# TEL-10. The loop's own three, written from the fields `cycle()`
# recorded and not re-derived (the page and the publisher run on
# their own tasks, and `self.target` has moved on).
# (DECISIONS/MAIN-orchestration-archive.md)
"loop_i": round(self.loop_i_w, 1),
"loop_lead": round(self.loop_i_lead, 1),
"loop_reason": self.loop_reason,
}
# TEL-11. ⚠️ THE KEY IS PRESENT WITH `None` WHEN THERE IS NO READING
# AND ABSENT WHEN THERE IS NO SOURCE: two states
# (`mqtt.ABSENT_AS_UNKNOWN` makes the first an `unknown`, a gap in the
# recorder). TEL-12: still ONE number, the SITE total, None unless
# EVERY entity answered. ⚠️ THE GATE IS `optional_keys()`, THE SAME
# EXPRESSION THE ANNOUNCEMENT USES.
# (DECISIONS/MAIN-orchestration-archive.md)
if "pv" in optional_keys(self.o):
values["pv"] = (None if self.pv_w is None
else round(self.pv_w, 1))
# ⚠️ ONLY when P1 ingestion is running: the ESP32's stale-input
# watchdog subscribes to sensor.goodwe_p1_sample_age and forces layer-1
# failsafe at max_age_s, so with meter_source off a published age would
# climb unbounded and pin every existing install at 0 W within 30 s.
# Publishing nothing leaves the entity non-existent (firmware
# has_state()). (DECISIONS/MAIN-orchestration-archive.md)
if self.p1_enabled:
# Recomputed here, once a second, on purpose - see P1Ingest.
values["p1_age"] = round(self.p1.published_age_s, 1)
# TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE: ⚠️ no `if
# len(self.inverters) > 1` here, deliberately: `announced_units()`
# answers [] at N=1, so what is announced and what is published come
# from one expression. ⚠️ The site rows do not move: `battery` stays
# the SUM and `soc` the EMPTIEST pack (live dashboards use those ids).
# (DECISIONS/MAIN-orchestration-archive.md)
names = announced_units([inv.name for inv in self.inverters])
for inv, slug in zip(self.inverters, unit_slugs(names)):
values[unit_state_key(slug, "setpoint")] = inv.target
values[unit_state_key(slug, "battery")] = inv.batt
values[unit_state_key(slug, "soc")] = inv.soc
values[unit_state_key(slug, "sign_latch")] = (
"tripped" if inv.sign_wd.tripped else "clear")
# OPT-11 P2: each battery's mode and switch; nothing while modes are off.
values.update(self.mode_values())
self.mqtt.publish(values)
async def shutdown(self) -> None:
"""Deterministic wind-down. Do not skip this."""
self.stopping = True
_LOG.info(handback.log_shutdown(handback.current())
or "shutting down - commanding 0 W on every inverter")
await self.command_site_release()
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. ⚠️ IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17): a
suite that banks an August quarter went red at the September boundary.
(DECISIONS/MAIN-orchestration-archive.md)"""
o = self.o
out = []
# SAFETY-61. First, so a stalled loop is the headline: the heartbeat is
# sent from this loop, so past layer 1's veto the inverter is at 0 W.
if self.loop_pass_mono is None:
out.append({"ok": True, "warn": False, "id": "controller",
"text": messages.row_controller_starting()})
else:
_pass_age = time.monotonic() - self.loop_pass_mono
if _pass_age > FIRMWARE_INGEST_VETO_S:
out.append({"ok": False, "warn": False, "id": "controller",
"text": messages.row_controller_stalled(
_pass_age, FIRMWARE_INGEST_VETO_S)})
else:
out.append({"ok": True, "warn": False, "id": "controller",
"text": messages.row_controller_ran(_pass_age)})
# SAFETY-112. The headline unless control has stalled: while the
# Supervisor gives 502 (Home Assistant not yet RUNNING) no reading or
# write gets through, and the rows below that need it follow from it.
if OUTAGE.waiting():
out.insert(1 if out[0]["ok"] is False else 0,
{"ok": False, "warn": False, "id": "home_assistant",
"banner": messages.banner_ha_waiting(),
"text": messages.row_ha_waiting()})
if self.p1_enabled:
age = self.p1.published_age_s
if (self.p1.stale and self.p1.age_s() is None
and age <= self.p1.max_age_s):
# UX-10: no reading yet since the start, still inside the
# limit. Amber, not red: it is not a fault yet, but control
# waits for it. Red at 0 s contradicted the limit the row
# prints. (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": True, "id": "meter",
"text": messages.row_meter_waiting(
self.p1.max_age_s)})
elif self.p1.stale:
# The last error is on the add-on log's WARNING line (p1.py).
out.append({"ok": False, "warn": False, "id": "meter",
"text": messages.row_meter_stale(
age, self.p1.max_age_s)})
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).
# (DECISIONS/MAIN-orchestration-archive.md)
unchanged = getattr(self.p1_source, "unchanged_s", None)
out.append({"ok": True, "warn": False, "id": "meter",
"text": messages.row_meter(
self.p1.net_w, age, self.p1.parse_errors,
unchanged)})
# SAFETY-18. Reported in both states (a detector nobody can see is
# armed is not trusted when it fires). ok=False/warn=False so the
# banner goes red; it changes NOTHING about control (see
# check_meter_frozen()). (DECISIONS/MAIN-orchestration-archive.md)
if self.freeze_wd.active:
# The figures are in `freeze_wd.reason`, on the log and the
# event list (check_meter_frozen()).
out.append({"ok": False, "warn": False, "id": "meter_frozen",
"text": messages.row_meter_frozen()})
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, "id": "freeze_check",
"text": messages.row_freeze_off("source")})
elif rail_unreachable(self.site_max_w,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max")):
# SAFETY-20: the same row as the transport case above (the
# check cannot be evaluated here); only the missing term
# differs. (DECISIONS/MAIN-orchestration-archive.md)
_no_rating = (not self.site_max_w
or not is_commandable(self.site_max_w))
out.append({"ok": False, "warn": True, "id": "freeze_check",
"text": messages.row_freeze_off(
"rating" if _no_rating else "range")})
else:
# ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE
# USES, device range included.
# (DECISIONS/MAIN-orchestration-archive.md)
_rail = rail_w(self.site_max_w,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max"))
# And when the device set that number, SAY SO: the operator
# configured `max_w`. ⚠️ Either bound may be None on its own
# (`hass.limits()` reads min and max independently), so render
# what is there. (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": True, "warn": False, "id": "freeze_check",
"text": messages.row_freeze_on(
self.freeze_wd.cfg.hold_s, _rail,
own_limit=_rail < rail_w(self.site_max_w))})
# TARIFF-01. The month's billed peak, and a DIFFERENT row when the
# store could not answer: "no peak yet" is normal on the 1st,
# "unreadable" is a broken instrument.
# (DECISIONS/MAIN-orchestration-archive.md)
_pk = self.peak.peak(when)
if _pk.is_unreadable:
out.append({"ok": False, "warn": True, "id": "month_peak",
"text": messages.row_month_peak_unreadable()})
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, "id": "month_peak",
"text": (messages.row_month_peak_none()
if not _pk.is_observed else
messages.row_month_peak(
_pk.peak_w,
brussels(_pk.quarter_start),
_frac))})
out.append({"ok": True, "warn": False, "id": "periods_measured",
"text": messages.row_periods_measured(
self.peak.recorded)})
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
# are opposite states and a page that spelled them the same would
# show a green line over a guard that is switched off. `None` is a
# fourth state on purpose: before the first cycle nothing has been
# concluded. (DECISIONS/MAIN-orchestration-archive.md)
_pg = self.peak_guard
if _pg is None:
out.append({"ok": True, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_waiting()})
elif _pg.saturated and not _pg.within_aim:
out.append({"ok": False, "warn": False, "id": "peak_guard",
"runs": True,
"text": messages.row_peak_saturated(_pg.ceiling_w)})
elif _pg.floor_limited and (
not ((_pg.floor_w or 0.0) > 0.0 or _pg.within_aim)
or self._peak_floor_limited):
# SAFETY-24. NOT ok, like saturation (the money is being spent
# and a human is the only actuator left); a separate row
# because the remedy is opposite. ⚠️ SAFETY-25: the scope
# sentence (the floor binds the guard, not the loop) is part of
# the row, which carries its own short `banner`; shortening the
# ROW would weaken AC 3.
# (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": False, "id": "peak_guard",
"runs": True,
"banner": messages.banner_peak_reserve(),
"text": messages.row_peak_reserve(
_pg.ceiling_w, soc_unread=_pg.soc is None,
floor_unknown=(_pg.soc is not None
and _pg.soc_floor is None),
packs=(_pg.held if len(self.inverters) > 1
else ()),
others=(_pg.floor_w or 0.0) > 0.0)})
elif _pg.blind:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_blind(
peakguard.blind_kind(_pg.blind))})
elif self.peak_unmeasured_s > 0.0:
# SAFETY-90: the start of this quarter came before the first
# reading, and the guard counts it as high use.
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_unmeasured()})
elif _pg.engaged:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_on(
_pg.floor_w or 0.0, _pg.ceiling_w,
shadow=self.shadow)})
elif _pg.projected_w is not None:
# SAFETY-125: "under" only when it is; over the limit at this
# rate, a grid charge is cut back to the quarter's room.
out.append({"ok": True, "warn": False, "id": "peak_guard",
"text": messages.row_peak_guard_idle(
_pg.projected_w, _pg.ceiling_w,
month_peak=(_pg.ceiling_from
== peakguard.CEILING_MONTH_PEAK),
cut=self.room_charge and not self.shadow)})
else:
out.append({"ok": True, "warn": False, "id": "peak_guard",
"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
# (no telegrams), and a row that rendered only where the guard runs
# would leave the unprotected install showing nothing.
# (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_off()})
# ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row
# on a site with two packs is a page that shows one pack and calls it
# the site. (DECISIONS/MAIN-orchestration-archive.md)
rows = []
for inv in self.inverters:
unit = None if len(self.inverters) == 1 else inv.name
rows.append((messages.LABEL_SOC, unit, inv.soc,
inv.driver.soc_source()))
rows.append((messages.LABEL_BATTERY, unit, inv.batt,
inv.driver.batt_source()))
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, (messages.LABEL_GRID, None, self.grid,
o.get("meter_entity")))
# UX-08: the rows name the setting, never the entity id (STYLE.md).
for label, unit, value, entity in rows:
if not entity:
out.append({"ok": False, "warn": False, "id": "sensor",
"text": messages.row_sensor_unset(label, unit)})
elif value is None:
# ⚠️ SAFETY-56. THIS ROW ONCE ASSERTED A FALSE FACT ABOUT A
# NAMED ENTITY: `Freshness.reading()` returns None BY DESIGN
# while the gate withholds a numeric reading. ⚠️ FIXED BY
# SAYING LESS: the gate has its own surface (`stale_note()` in
# `liveness_notes()`).
# (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": False, "id": "sensor",
"text": messages.row_sensor_unusable(label, unit)})
else:
out.append({"ok": True, "warn": False, "id": "sensor",
"text": messages.row_sensor(label, value, unit)})
for inv in self.inverters:
unit = None if len(self.inverters) == 1 else inv.name
if not inv.driver.configured():
out.append({"ok": False, "warn": False, "id": "control",
"text": messages.row_control_unset(unit)})
elif inv.dev_max is None:
out.append({"ok": False, "warn": False, "id": "control",
"text": messages.row_control_missing(unit)})
else:
out.append({"ok": True, "warn": False, "id": "control",
"text": messages.row_control(inv.dev_min,
inv.dev_max, unit)})
# DEV-02. Whether this controller can still put a number on each
# inverter: a dropped link that shows nothing on the page reads as a
# healthy site. Red only for a unit no longer live; the rest amber. The
# same notes, in the same order, as `liveness_notes()`.
# (DECISIONS/MAIN-orchestration-archive.md)
_now = datetime.now(timezone.utc)
for inv in self.inverters:
for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]),
(inv.stale_note(), False),
(inv.refusal_note(), False)):
if note:
out.append({"ok": False, "warn": not red, "text": note})
if self.answer.late_seen: # SAFETY-128
out.append({"ok": False, "warn": True, "id": "late",
"text": messages.row_readings_late(
self.answer.shown_s, self._late_zero)})
# SAFETY-03. Red while an over-rating clamp is engaged (amber for the
# integrator bound, SAFETY-40), and the engagement COUNT stays shown
# after it clears. Each count is shown under ITS alarm's name, never
# the two summed (UX-07/UX-08).
# (DECISIONS/MAIN-orchestration-archive.md)
_cw = self.clamp_wd
if _cw.active and _cw.bug:
out.append({"ok": False, "warn": False, "id": "power_too_high",
"banner": messages.banner_power_too_high(),
"text": messages.row_power_too_high(
_cw.facts, _cw.over_rating, shadow=self.shadow)})
elif _cw.active:
# SAFETY-40: the integrator bound alone is amber, not red.
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
"text": messages.row_not_keeping_up(
_cw.bound_episodes)})
# No over-rating in THIS episode (`bug` would be set), so any count
# is from an earlier one: keep that software-fault evidence shown.
if _cw.over_rating:
out.append({"ok": False, "warn": True, "id": "power_too_high",
"text": messages.row_before(
messages.T_OUTPUT_CLAMP, _cw.over_rating)})
else:
if _cw.over_rating:
out.append({"ok": False, "warn": True, "id": "power_too_high",
"text": messages.row_before(
messages.T_OUTPUT_CLAMP, _cw.over_rating)})
if _cw.bound_episodes:
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
"text": messages.row_before(
messages.T_LOOP_BOUND, _cw.bound_episodes)})
for inv in self.inverters:
if inv.last_write_ok is False:
out.append({"ok": False, "warn": False, "id": "write",
"text": messages.row_write_refused(
None if len(self.inverters) == 1
else inv.name)})
# SAFETY-02. Reported in both states. ⚠️ ONE ROW PER INVERTER: a trip
# on B leaves A commanding. Since SAFETY-30 the row says what the unit
# was compared against (`sign_wd.against`, the string the trip sentence
# uses); at N=1 the wording is unchanged.
# (DECISIONS/MAIN-orchestration-archive.md)
for inv in self.inverters:
unit = None if len(self.inverters) == 1 else inv.name
# ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE: at N=1 the
# two sentences below are the ones that have always been on the
# page, word for word (AC 8); at N>=2 the noun is load-bearing, so
# the row does not send an operator to P1 for a fault visible only
# on one inverter's own power.
# (DECISIONS/MAIN-orchestration-archive.md)
if inv.sign_wd.tripped:
out.append({"ok": False, "warn": False, "id": "direction",
"text": messages.row_direction_trip(unit)})
else:
held = inv.sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, "id": "direction",
"text": messages.row_direction_check(
held, inv.sign_wd.cfg.hold_s, unit)})
# SAFETY-30: the SITE row. ⚠️ ONLY AT N>=2, and the absence at N=1 is
# the honest report: with one inverter the site comparison and the row
# above are the same sentence about the same actuator.
# (DECISIONS/MAIN-orchestration-archive.md)
if len(self.inverters) > 1:
if self.site_sign_wd.tripped:
out.append({"ok": False, "warn": False, "id": "site_direction",
"text": messages.row_site_direction_trip()})
else:
_sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, "id": "site_direction",
"text": messages.row_site_direction_check(
_sheld, self.site_sign_wd.cfg.hold_s)})
# SAFETY-31. ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and
# the row says so: there is one circulating figure for the site, and
# naming an inverter would invite clearing "the other one".
# (DECISIONS/MAIN-orchestration-archive.md)
_circ = self.site_circulating_w
if self.circ_wd.tripped:
out.append({"ok": False, "warn": False, "id": "circulating",
"text": messages.row_circulating_trip()})
elif len(self.inverters) <= 1:
# AC 5. At the shipped roster the quantity is identically zero for
# every value of every reading, so "armed" would be a claim to be
# watching something that cannot happen. Say what is true instead.
out.append({"ok": True, "warn": False, "id": "circulating",
"text": messages.row_one_inverter(
messages.CHECK_CIRCULATING)})
else:
held = self.circ_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, "id": "circulating",
"text": messages.row_circulating_check(
_circ, held, self.circ_wd.cfg.hold_s)})
# SAFETY-42. ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE
# ONE PLACE THE "reports, does not act" DECISION IS VISIBLE: every
# other latched row means the site is at 0 W, this one means it is
# STILL RUNNING and quietly short by one unit's share; `ok: False` with
# `warn: True` is this list's caution.
# (DECISIONS/MAIN-orchestration-archive.md)
if len(self.inverters) > 1:
for inv in self.inverters:
if inv.delivery_wd.tripped:
out.append({"ok": False, "warn": True, "id": "delivery",
"text": messages.row_not_delivering(inv.name)})
else:
_dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, "id": "delivery",
"text": messages.row_delivery_check(
inv.name, _dheld,
inv.delivery_wd.cfg.hold_s)})
else:
# AC 5: at one inverter this check has nothing to find, so it is
# not called "armed" (as `circulating power: n/a` above).
# (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": True, "warn": False, "id": "delivery",
"text": messages.row_one_inverter("Delivery check")})
if not o.get("estop_fitted", False):
out.append({"ok": False, "warn": True, "id": "estop",
"text": messages.row_no_estop()})
else:
out.append({"ok": True, "warn": False, "id": "estop",
"text": messages.row_estop_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, "id": "peak_forecast",
"text": messages.row_no_peak_forecast()})
elif getattr(self, "_peak_fc_fault", None) is not None:
out.append({"ok": False, "warn": True, "id": "peak_forecast",
"text": messages.row_peak_forecast_unit(
self._peak_fc_fault)})
# OPT-17: a forecast summed from several entities names, by position,
# each member with no usable reading. A caution: control goes on.
gaps = getattr(self, "solar_fc_gaps", ())
for day, ents in (("today", getattr(self, "fc_entities", ())),
("tomorrow",
getattr(self, "fc_tomorrow_entities", ()))):
gone = [i for d, i, _e, _w in gaps if d == day]
if gone:
out.append({"ok": False, "warn": True, "id": "solar_forecast",
"text": messages.row_forecast_gap(
day, gone, len(ents))})
# TEL-11. ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY (pure
# instrumentation: no warning spam). ⚠️ WHEN IT IS SET, A FAILED READ
# IS `ok=False, warn=True`, a CAUTION never `bad`: telemetry must not
# put a battery site into NOT READY. UX-08 replaced 0.11.0's sentences
# on purpose. (DECISIONS/MAIN-orchestration-archive.md)
roster = self.pv_roster
if roster:
# UX-08: sources are numbered in the order of the settings list,
# never named by entity id (STYLE.md). The per-source figures and
# units are what `pv.total()` returns; they stay in `pv_parts`.
flip = pv_read.inverted(self.o)
n_src = len(roster)
if self.pv_w is None and n_src == 1:
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_none(self.pv_why)})
elif self.pv_w is None:
# ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN
# THOSE WORDS - and which sources are missing, by position.
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_partial(
[i + 1 for i, (_e, watt, _y)
in enumerate(self.pv_parts) if watt is None],
n_src)})
else:
out.append({"ok": True, "warn": False, "id": "pv",
"text": messages.row_pv(
self.pv_w, n_src,
n_src == 1 and roster[0] in flip)})
# ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
# `entities()` makes `pv_entities` REPLACE `pv_entity`, which is
# the rule an operator upgrading from 0.11.0 is most likely to trip
# over. (DECISIONS/MAIN-orchestration-archive.md)
if pv_read.entity_is_ignored(self.o):
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_single_ignored()})
# TEL-14: a sign that looks reversed, per entity rather than on the
# total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3.
for i, (ent, watt, _y) in enumerate(self.pv_parts):
if watt is not None and watt < pv_read.NEGATIVE_W:
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_negative(
i + 1, n_src, watt, ent in flip)})
_inv_list = [str(x or "").strip()
for x in (self.o.get("pv_invert_entities") or [])]
for ent in pv_read.stray_inverts(self.o):
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_stray(
_inv_list.index(ent) + 1)})
return out
def status_payload(self, lang: str = "en") -> dict:
checks = self.checks()
bad = [c for c in checks if not c["ok"] and not c["warn"]]
# SAFETY-87: a red row with `runs` (the peak alarms) is an alarm while
# control goes on, so it neither reads "Not ready" nor holds the page.
runs = [c for c in bad if c.get("runs")]
bad = [c for c in bad if not c.get("runs")]
running_alarm = waiting = False
# SAFETY-10, AND IT IS FIRST IN THE CASCADE: "The battery is NOT being
# controlled" may never be displaced by another condition. ⚠️ AND IT
# CARRIES A STANDING LATCH RATHER THAN HIDING ONE: `shadow.banner()`
# names the latch and goes red for it, so nothing the cascade would
# have said is lost. (DECISIONS/MAIN-orchestration-archive.md)
_shadow = shadow.banner(self.o, control_started=self.auto,
latched=self.shadow_latches())
if _shadow is not None:
level, banner = _shadow
elif self.circ_wd.tripped:
# ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision: both are
# latched P_SAFETY stops and the banner carries one sentence; this
# one goes first because it is the fault the operator has NO OTHER
# WAY of learning about (a sign disagreement is visible at the
# meter, circulating power nowhere).
# (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "bad", messages.banner_circulating()
elif self.site_sign_wd.tripped and len(self.inverters) > 1:
# SAFETY-30's site half, ABOVE the per-unit rows below and below
# circulating power above: the banner carries one sentence, so the
# one that says the widest true thing. A site sign trip parks EVERY
# inverter and the fault is in what the site was asking for; naming
# a unit would send an operator to swap the wrong box.
# (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "bad", messages.banner_site_direction()
elif any(i.sign_wd.tripped for i in self.inverters):
# Outranks "Not ready": nothing is missing, something went wrong.
# At N>=2 the unit was graded against its own power, so the banner
# says so (see checks()). (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "bad", messages.banner_direction(
len(self.inverters) > 1)
elif bad:
# ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE: a row may
# say more than fits the headline (SAFETY-25's floor row).
# (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "bad", messages.banner_not_ready(bad[0])
# SAFETY-112: nothing to do but wait, so Home says that instead
# of "Needs you now". Only when it IS the headline: a safety stop
# above outranks it and still needs a person.
waiting = bad[0].get("id") == "home_assistant"
elif not self.auto:
level, banner = "warn", messages.banner_stopped()
elif runs:
level, running_alarm = "bad", True
banner = runs[0].get("banner") or messages.headline_bi(
runs[0]["text"])
elif any(i.delivery_wd.tripped for i in self.inverters):
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE
# (the site is STILL RUNNING, one unit short). ⚠️ BUT IT MUST
# DISPLACE "Running - holding grid at zero": that sentence is a
# claim the site cannot make while a unit is absent. It sits BELOW
# "Stopped". (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "warn", messages.banner_not_delivering(
[i.name for i in self.inverters if i.delivery_wd.tripped])
elif self.maint.phase != IDLE:
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE: at N=1 the
# string is what it has always been, character for character.
# (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "warn", messages.banner_maintenance(
self.maint.phase,
self.maint_unit.name if len(self.inverters) > 1 else "")
else:
level, banner = "ok", messages.banner_running()
def w(v):
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
# ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. `None` means
# "never run, due now" and has to win over any date, so it is tested
# for rather than sorted with. At N=1 this is that pack's own date.
# (DECISIONS/MAIN-orchestration-archive.md)
_now = datetime.now(timezone.utc)
_dues = [m.next_due(_now) for m in self.maints]
_waits = [u for u in (m.backoff_until(_now) for m in self.maints) if u]
next_due = None if any(d is None for d in _dues) else min(_dues)
rows = [
("Grid", w(self.grid)),
("Battery", w(self.site_batt_w)),
# SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is
# not derivable from ("Grid 0 W, Battery 0 W, Circulating 6000 W"
# is the whole card in three rows). Only shown at N>=2: at one
# inverter it is identically zero.
# (DECISIONS/MAIN-orchestration-archive.md)
*(() if len(self.inverters) <= 1
else ((("Circulating", w(self.site_circulating_w)),))),
# TEL-11, and ONLY when a source is configured - see `checks()`.
# `w(None)` renders "-", which is how this row says "no reading"
# rather than "0 W". (DECISIONS/MAIN-orchestration-archive.md)
*(() if not self.pv_roster else ((("Solar production",
w(self.pv_w)),))),
("Battery level", "—" if self.pack_floor_soc is None
else f"{self.pack_floor_soc:g} %"),
("Power request", w(self.target)),
# SAFETY-10, and it sits BETWEEN the two rows it reconciles.
# "Commanded 0 W" above and a "Why" below that explains a trade is
# the exact pair an operator would otherwise have to guess at; this
# row is what makes the two of them consistent, and it is rendered
# in EVERY state - including `off`, where it says the battery IS
# being controlled - because a surface whose ABSENCE is how you
# learn something is a surface you have to already know about.
("Shadow mode", shadow.words(
self.o, control_started=self.auto,
would_w=self.shadow_would_w,
# SAFETY-47. THE FLAG THE CYCLE RECORDED, NOT ONE DERIVED HERE
# - the page runs on its own task and `self.peak_guard` has
# moved on. See `Controller.__init__`.
sized_from_meter=self.shadow_sized_from_meter,
reason=self.why_words)),
("Why", self.why_words),
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
else f"{self.maint.phase} ({self.maint_unit.name})"),
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
# page - where `_maint_deferred` is empty on every tick - carries
# exactly the rows it carried before.
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
for n, why in self._maint_deferred)),)
if self._maint_deferred else ()),
# MAINT-13: only while a failed cycle waits, so a site with nothing
# failed carries exactly the rows it carried before.
*((("Maintenance paused", messages.row_maint_waits(
min(_waits).strftime("%Y-%m-%d"))),) if _waits else ()),
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
# PRICE-01: only while no price covers now.
*((("Prices", messages.row_no_prices(*self._gap_args())),)
if self.price_gap else ()),
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, the difference between a
# 48-hour plan and a 12-hour one on a weekday morning. Its own row:
# "Tariff" says what the plan IS, this says what it could still be
# filled from if the feed is late again.
# (DECISIONS/MAIN-orchestration-archive.md)
("Price history", self.price_history.words),
# OPT-04. The strategy's own sentence, not a re-derivation: every
# refusal it can make carries a reason a human can act on. ⚠️
# TARIFF-11: `self.arb is None` is NOT "trading is off" (cycle()
# returns at the input hold first); `arbitrage_row()` reads the
# option, and `cycle_at`/`arb_at` make "no decision this cycle" an
# observation. (DECISIONS/MAIN-orchestration-archive.md)
("Arbitrage", arbitrage_row(
on=arbitrage.enabled(self.o) or self.modes_on,
decision=self.arb,
decided_at=self.arb_at, cycle_at=self.cycle_at,
now=datetime.now(timezone.utc), why=self.why_words)),
]
held = level == "bad" and not running_alarm
# UX-46. The page's language is chosen once, here, for the Home
# sentences, checks, rows that carry one and the banner; a plain str
# (no Dutch twin) stays as it is.
# (DECISIONS/MAIN-orchestration-archive.md)
checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks]
rows = [(k, messages.pick(v, lang)) for k, v in rows]
banner = messages.pick(banner, lang)
out = {
"banner": banner, "level": level, "rows": rows, "checks": checks,
"hint": self.hint,
# The action log itself, newest first: not shown on the page (UX-08
# 2d) but readable in the `status` JSON, since several events are
# written nowhere else. (DECISIONS/MAIN-orchestration-archive.md)
"events": list(self.events),
}
# UX-13 P1: the Home screen's structured half. Read-only, and it may
# never cost the page its rows: a fault here is logged and the old
# fields are served alone (the page then shows the installer details).
try:
out.update(homeview.build(self, level=level, checks=checks,
lang=lang, held=held, waiting=waiting))
except Exception: # noqa: BLE001
# Once per process at ERROR: the page polls every 3 s.
if not getattr(self, "_home_failed", False):
self._home_failed = True
_LOG.exception("Home view could not be built, so the page "
"shows the plain status rows only")
else:
_LOG.debug("Home view failed again", exc_info=True)
out["home_error"] = True
return out
def forecast_payload(self, lang: str = "en") -> dict:
"""UX-54: the Forecast tab's data. Built on request, from what the
control cycle already holds; no Home Assistant call. Today's blocks are
Home's own, so `status_payload()` is built first and read."""
try:
return dayview.build(self, self.status_payload(lang=lang),
datetime.now(timezone.utc), lang)
except Exception: # noqa: BLE001
if not getattr(self, "_forecast_failed", False):
self._forecast_failed = True
_LOG.exception("The Forecast tab could not be built")
else:
_LOG.debug("The Forecast tab failed again", exc_info=True)
return {"ok": False}
async def handle_action(self, what: str, *, unit=None, value=None):
now = datetime.now(timezone.utc)
if what in ("pack_use", "pack_mode", "pack_sell"):
return self._pack_action(what, unit, value, now)
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",
hint=messages.hint("control_started" if self.auto
else "control_stopped"))
if self.auto:
# ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose:
# it clears on a deliberate human start, not on the condition
# going away (the condition ALWAYS goes away, because the trip
# commands 0 W). ⚠️ IT CLEARS EVERY INVERTER'S LATCH, AND SAYS
# SO PER INVERTER, each under its OWN key.
# (DECISIONS/MAIN-orchestration-archive.md)
for inv in self.inverters:
if not inv.sign_wd.tripped:
continue
inv.sign_wd.reset()
inv.store_set("sign_tripped", False)
inv.store_set("sign_reason", "")
self.log_event(f"sign-disagreement failsafe cleared by "
f"operator on {inv.name}",
hint=messages.hint("stop_cleared",
unit=inv.name))
# ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM,
# UNCONDITIONALLY: this is what makes `flat True + namespaced
# False` MEAN something, and why `_grade_store_migration()` can
# run on every start. Left behind, a flat key is a fossil that
# re-arms the failsafe on every restart.
# (DECISIONS/MAIN-orchestration-archive.md)
self.store.set("sign_tripped", False)
self.store.set("sign_reason", "")
# SAFETY-30's SITE latch, cleared by the SAME press for the
# same reason (the trip commands 0 W, so the condition goes
# away on its own). ⚠️ UNCONDITIONAL, NOT GUARDED ON THE
# ROSTER: an install reconfigured back to one inverter would
# otherwise keep `site_sign_tripped: true` for ever.
# (DECISIONS/MAIN-orchestration-archive.md)
if self.site_sign_wd.tripped:
self.log_event("site sign-disagreement failsafe cleared "
"by operator",
hint=messages.hint("site_stop_cleared"))
self.site_sign_wd.reset()
self.store.set("site_sign_tripped", False)
self.store.set("site_sign_reason", "")
# SAFETY-31, cleared by the SAME press and for the same reason.
# ⚠️ AND `_circ_announced` GOES WITH IT: left True, the NEXT
# trip in this process would latch, stop the site and raise no
# notification. (DECISIONS/MAIN-orchestration-archive.md)
if self.circ_wd.tripped:
self.circ_wd.reset()
self.store.set("circ_tripped", False)
self.store.set("circ_reason", "")
self.log_event("circulating-power trip cleared by operator",
hint=messages.hint("site_stop_cleared"))
self._circ_announced = False
# SAFETY-42, cleared by the SAME press, for a reason that is
# NOT the one above: this latch commands nothing, so its
# evidence still stands and it could clear itself. It does not:
# a unit that stopped delivering and resumed is an INTERMITTENT
# fault. ⚠️ AND `_delivery_announced` GOES WITH IT, like
# `_circ_announced`. (DECISIONS/MAIN-orchestration-archive.md)
for inv in self.inverters:
if inv.delivery_wd.tripped:
self.log_event(f"not-delivering report on {inv.name} "
f"cleared by operator",
hint=messages.hint(
"not_delivering_cleared",
unit=inv.name))
inv.delivery_wd.reset()
inv.store_set("delivery_tripped", False)
inv.store_set("delivery_reason", "")
self._delivery_announced = set()
if not self.auto:
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see
# command_site_zero(). SMA-10: Stop gives hand-back units back.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.command_site_release()
return {"auto": self.auto}
if what in ("maint_start", "maint_abort") and unit is not None:
# MAINT-11: the operator named the battery. A request with no unit
# falls through to the branches below, unchanged.
return await self._maint_unit_action(what, unit, now)
if what == "maint_start":
# ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
# ONE LABEL: `_maint_button()` picks the pack that holds the
# maintenance slot, else the one longest without a cycle; at N=1
# both are `maints[0]`. This is the bare request; a named battery
# is `_maint_unit_action()` (MAINT-11).
# (DECISIONS/MAIN-orchestration-archive.md)
_m = self._maint_button(now)
for msg in _m.force_start(now):
self.log_event(msg, hint=messages.hint("maintenance",
phase=_m.phase))
return {"phase": self.maint.phase}
if what == "maint_abort":
# Aborts the pack that is actually cycling - `_maint_button()` again
# - and then zeroes the SITE, which is what it always did.
_m = self._maint_button(now)
for msg in _m.abort(now, "operator"):
self.log_event(msg, hint=messages.hint("maintenance",
phase=_m.phase))
await self.command_site_zero()
return {"phase": self.maint.phase}
return None
# SAFETY-61. Exit status when the control task dies; 70 is sysexits(3)
# EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md.
EXIT_CONTROL_DIED = 70
class ControlTaskDied(RuntimeError):
"""The control task ended while nothing had asked it to stop."""
def mode_announcement(opts, store) -> tuple:
"""OPT-11 P2: `(modes, retract)` for the MQTT publisher at start-up. The
config topics announced are kept in the store, so turning modes off removes
exactly those (`mqtt.mode_configs()`); an install that never turned modes
on gets nothing, and its store is not written.
(DECISIONS/MAIN-orchestration-archive.md)"""
modes = packmode.MODES if packmode.enabled(opts) else ()
stored = store.data.get(MODE_CONFIGS_KEY)
keep, retract = mode_configs(roster_names(opts), modes, stored)
if keep and keep != stored:
store.set(MODE_CONFIGS_KEY, keep)
return modes, retract
def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
died: list) -> None:
"""Done-callback on the control task: log its death now and wake amain()
to wind down and exit (SAFETY-61, see its DECISIONS record)."""
if stop.is_set() or controller.stopping:
return # the normal wind-down, or run_control()'s refusal path
exc = None if task.cancelled() else task.exception()
_LOG.critical("Control stopped unexpectedly (%s), so %s and the add-on "
"exits with code %d.",
"cancelled" if task.cancelled() else
(repr(exc) if exc else "returned"),
handback.task_died_state(handback.current()),
EXIT_CONTROL_DIED,
exc_info=exc)
died.append(exc)
stop.set()
DOCS_LINK_TIMEOUT_S = 3
async def docs_link_facts(session, token: str) -> dict:
"""`{slug, ha_version}` from the Supervisor, each None when unreadable.
Asked together with a 3 s budget: a link may never hold up control start.
(DECISIONS/MAIN-orchestration-archive.md)"""
async def ask(url, field):
"""`(value, why)`: the field, or None and a short reason (UX-09)."""
try:
async with session.get(url, timeout=DOCS_LINK_TIMEOUT_S, headers={
"Authorization": f"Bearer {token}"}) as r:
body = await r.json(content_type=None)
data = body.get("data", body) if isinstance(body, dict) else {}
value = data.get(field) if isinstance(data, dict) else None
return value, (None if value else f"no {field} in its answer")
except Exception as err: # noqa: BLE001 - a link, never a stop
# A timeout's own text is empty, so the type is always named.
text = str(err).strip()
return None, (f"{type(err).__name__}: {text}" if text
else type(err).__name__)
(slug, why_slug), (ha, why_ha) = await asyncio.gather(
ask("http://supervisor/addons/self/info", "slug"),
ask("http://supervisor/info", "homeassistant"))
out = {"slug": slug, "ha_version": ha}
# ONE line, whatever happened: the link is a convenience, not a fault.
if slug and ha:
_LOG.info("Documentation link: add-on %s, Home Assistant %s", slug, ha)
else:
missed = [f"{what} ({why})" for what, why in
(("add-on name", why_slug), ("Home Assistant version", why_ha))
if why]
_LOG.info("Documentation link: the Supervisor gave no %s, so the "
"link is guessed or left out.", " and no ".join(missed))
return out
async def ha_time_zone(session, token: str) -> tuple:
"""MAINT-10: `(zone, why)` - Home Assistant's configured time zone, or None
and a short reason. Same 3 s budget as the docs link: never holds up start."""
try:
async with session.get(f"{CORE_API}/config", timeout=DOCS_LINK_TIMEOUT_S,
headers={"Authorization": f"Bearer {token}"}) as r:
body = await r.json(content_type=None)
zone = body.get("time_zone") if isinstance(body, dict) else None
return (zone, "") if zone else (None, "no time_zone in its answer")
except Exception as err: # noqa: BLE001 - a fallback exists, never a stop
text = str(err).strip()
return None, f"{type(err).__name__}: {text}" if text else type(err).__name__
_UTC_NAMES = {"UTC", "ETC/UTC", "GMT", "ETC/GMT", "UCT", "ETC/UCT",
"UNIVERSAL", "ETC/UNIVERSAL", "ZULU", "ETC/ZULU"}
async def maint_site_tz(session, token: str):
"""MAINT-10: the zone the maintenance start hour is read in, logged once."""
zone, why = await ha_time_zone(session, token)
tz, name, source, why = site_zone(zone, why, os.environ.get("TZ"))
if source == "ha":
_LOG.info("Maintenance start hour is local time in %s, from Home "
"Assistant.", name)
elif source == "tz" and name.strip().upper() in _UTC_NAMES:
# The Supervisor sets TZ to its copy of HA's zone, or to UTC if it has
# none: HA did not answer here, and UTC may not be the site's clock.
_LOG.warning("Maintenance start hour uses UTC, because Home Assistant "
"did not answer. Restart the add-on when it is up.")
_LOG.debug("maintenance time zone: %s", why)
elif source == "tz":
_LOG.info("Maintenance start hour is local time in %s, from the "
"add-on's time zone. Home Assistant gave none: %s.", name, why)
else:
_LOG.warning("Maintenance start hour uses UTC, because no time zone "
"was found. Set the time zone in Home Assistant.")
_LOG.debug("maintenance time zone: %s", why)
return tz
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.
# (DECISIONS/MAIN-orchestration-archive.md)
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)
# UX-09: the Details link in every notification needs this add-on's
# slug and Home Assistant's version (see messages.docs_url()); both are
# best effort. (DECISIONS/MAIN-orchestration-archive.md)
messages.set_site(**await docs_link_facts(session, _tok))
# ⚠️ THE STARTUP GATE (CFG-01). Nothing below this line may run on a
# configuration validate.py rejects (specs §1.5). It sits HERE, before
# the broker, web server and any task; (None, None) from an ESPHome not
# yet up is not a refusal (run_control() re-checks).
# (DECISIONS/MAIN-orchestration-archive.md)
# SMA-2: the flat options are the GoodWe driver's (no Controller yet).
_sp = str(opts.get("setpoint_entity", "") or "").strip()
_drv = GoodWeEsphomeDriver(setpoint_entity=_sp).bind(hass)
_dev_min = _dev_max = None
if _sp:
_dev_min, _dev_max = await _drv.limits()
_LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max)
_problems = validate_startup_config(opts, _dev_min, _dev_max)
# UX-15: a new install names no inverter. When that is the ONLY refusal
# it serves the setup wizard in setup-only mode, which builds nothing
# that commands and returns when stopped; any other refusal still exits
# 78. DECISIONS/UX-15-setup-only-mode.md
if _problems and setuponly.applies(opts, _problems):
await setuponly.serve(
opts, HomeAssistant(session, read_only=True), store,
port=INGRESS_PORT)
return
if _problems:
refuse(_problems)
raise ConfigRefused(_problems)
# ⚠️ Status publishing must NEVER be able to stop the controller:
# caught broadly and on purpose (a paho 1.x/2.x change once crashed the
# add-on and took the control loop with it).
# (DECISIONS/MAIN-orchestration-archive.md)
broker = None
try:
# OPT-11 P2: each battery's switch and mode list, only while
# battery modes are on (`mode_announcement()`).
_modes, _retract = mode_announcement(opts, store)
broker = await hass.mqtt_service()
pub = MqttPublisher(
broker.get("host") if broker else None,
broker.get("port", 1883) if broker else 1883,
broker.get("username") if broker else None,
broker.get("password") if broker else None,
omit=() if is_enabled(opts) else ("p1_age",),
# TEL-11. ⚠️ THE SHIPPED PREDICATE, NOT A SECOND READING OF THE
# OPTION - `Controller.publish()` gates the row on this same
# call; an install with no PV roster announces exactly what it
# did before. (DECISIONS/MAIN-orchestration-archive.md)
include=optional_keys(opts),
# TEL-07. ⚠️ THE NAMES, NOT A ROSTER: `build_roster()` here
# would build a SECOND set of `Inverter`s off the Store;
# `roster_names()` is the naming rule on its own.
# (DECISIONS/MAIN-orchestration-archive.md)
units=roster_names(opts),
modes=_modes,
retract=_retract,
)
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,
site_tz=await maint_site_tz(session, _tok))
if callable(getattr(pub, "bind", None)):
pub.bind(asyncio.get_running_loop(), controller.mqtt_command)
runner = await web.start(controller, port=INGRESS_PORT)
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, stop.set)
# SAFETY-61: the control task's death ends the process, after the
# wind-down below. See DECISIONS/SAFETY-61-control-task-death.md.
died: list = []
control = asyncio.create_task(controller.run_control())
control.add_done_callback(functools.partial(
control_task_done, controller=controller, stop=stop, died=died))
tasks = [control]
# OPT-04. The tariff refresh runs as its OWN task (`resolve_tariff()`
# may fetch over HTTPS, and the tick feeds the ESP32 heartbeat).
# Started for a provider, armed trading or battery modes
# (`tariff_refresh_wanted()`). ⚠️ IT DOES NOT ARM ANYTHING:
# `arbitrage.enabled()` in cycle() is the only gate on trading.
# (DECISIONS/MAIN-orchestration-archive.md)
if tariff_refresh_wanted(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:
if task is control and died:
continue # already logged by control_task_done()
with contextlib.suppress(asyncio.CancelledError):
await task
await runner.cleanup()
_LOG.info("stopped")
if died:
raise ControlTaskDied(died[0])
# 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() printed says
# everything. 78 is sysexits EX_CONFIG, so a script can tell a bad
# config from a bug. (DECISIONS/MAIN-orchestration-archive.md)
raise SystemExit(78)
except ControlTaskDied:
# No second traceback: control_task_done() logged it at the death.
raise SystemExit(EXIT_CONTROL_DIED)
if __name__ == "__main__":
main()