Router T241: the comments commit had put new rationale into MAIN-orchestration.md and cut comment lines that the note at56fc41fdid not hold. - DECISIONS/MAIN-orchestration.md is the56fc41ftext plus the one sentence that the Controller is split into app/main.py and app/ctl_*.py. - Comments whose content is not in a DECISIONS note at56fc41fare back in the code verbatim: MAINT-05 one config, the SAFETY-02, SAFETY-42/34, SAFETY-30 site half, SAFETY-31 and SAFETY-18 latch blocks in __init__, the _grade_store_migration docstring and comment, the trade_power_w docstring, the rows.py docstrings. - Kept: cuts of text already in the note, the SMA-10 / SAFETY-137 / SMA-2 pointers, the SAFETY-32 line fix. No code change: maint21_same_ast OK, maint20_same_code -OO same. Co-Authored-By: Claude <noreply@anthropic.com>
1318 lines
64 KiB
Python
1318 lines
64 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. The Controller keeps
|
|
__init__, its roster views and shutdown here; its other methods are in
|
|
ctl_inputs, ctl_writes, ctl_trade, ctl_cycle, ctl_alarms, ctl_status and
|
|
ctl_tasks (MAINT-21), the status-row functions in rows."""
|
|
|
|
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
|
|
# MAINT-21: the Controller's parts and the names they now define; every one
|
|
# stays importable from `app.main`. DECISIONS/MAIN-orchestration.md.
|
|
from .ctl_tasks import TasksMixin
|
|
from .ctl_status import StatusMixin
|
|
from .ctl_alarms import (
|
|
AlarmsMixin, HANDBACK_CYCLES, REANNOUNCE_S, ROLLING_BOUND_CYCLES,
|
|
ROLLING_S, SHORT_CYCLES, SHORT_SPAN_S,
|
|
)
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
|
StatusMixin, TasksMixin):
|
|
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. DECISIONS/MAIN-orchestration.md.
|
|
self.stop_event = stop
|
|
self.refused: list[str] | None = None
|
|
# First: the store migration below can log to it.
|
|
# DECISIONS/MAIN-orchestration.md.
|
|
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. The store
|
|
# migration runs here, BEFORE the first `Inverter` reads its SAFETY-02
|
|
# latch. 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 pair, re-derived at two inverters, not
|
|
# carried. DECISIONS/SAFETY-30-per-unit-sign.md.
|
|
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 pair, used only at N>=2.
|
|
# DECISIONS/MAIN-orchestration.md.
|
|
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: the migration gets `roster_names(opts)[0]`, never
|
|
# `UNIT_A`. 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: a declared roster ignores flat keys the UI still shows; say
|
|
# so once at every start. DECISIONS/MAIN-orchestration.md.
|
|
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"]`.
|
|
# DECISIONS/MAIN-orchestration.md.
|
|
max_w=self.site_max_w,
|
|
# SAFETY-39: 0 means "follow the rail"; `control.slew_limit_w()`
|
|
# is the one reader. DECISIONS/MAIN-orchestration.md.
|
|
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: `MaintConfig.from_options()` reads the options. MAINT-05:
|
|
# one `Maintenance` per roster entry; the history migration runs first,
|
|
# onto `self.inverters[0].name`, NOT `UNIT_A`.
|
|
# 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}"
|
|
|
|
# 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 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()
|
|
|
|
|
|
# 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).
|
|
# DECISIONS/SMA-2-driver-seam.md.
|
|
_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()
|