Files
goodwe-addon/goodwe_controller/app/main.py
T
glenn schrooyenandClaude 58b6fa35f5
checks / unit (pull_request) Successful in 26m32s
infra-pin / fresh (pull_request) Successful in 1s
MAINT-21 round 2: keep the move pure - revert the added DECISIONS text, restore cut comments
Router T241: the comments commit had put new rationale into
MAIN-orchestration.md and cut comment lines that the note at 56fc41f did not
hold.

- DECISIONS/MAIN-orchestration.md is the 56fc41f text 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 at 56fc41f are 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>
2026-11-09 05:48:00 +01:00

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()