Merge release/1.0 (16029d7) into MAINT-20-2
Nine conflicts in app/main.py, all in comments around new code: every code line is release/1.0's, every comment follows the part 2 rule. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+308
-31
@@ -59,12 +59,15 @@ from .clamp import (
|
||||
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`.
|
||||
@@ -81,6 +84,7 @@ 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
|
||||
@@ -119,6 +123,21 @@ __all__ = ["UNIT_A", "Controller", "amain", "main"]
|
||||
# on an ACCEPTED write). DECISIONS/MAIN-orchestration.md.
|
||||
WRITE_BUDGET_S = 4.0
|
||||
|
||||
# SMA-10: the bound for handing a unit back (`driver.release()`); units are
|
||||
# released concurrently and one still running at the bound is abandoned.
|
||||
# DECISIONS/SMA-10-two-idle-states.md.
|
||||
RELEASE_BUDGET_S = WRITE_BUDGET_S
|
||||
|
||||
# SMA-10: the one fallback write after a failed release (default 0 W). Worst
|
||||
# case RELEASE_BUDGET_S + FALLBACK_BUDGET_S = 6 s, inside the Supervisor's 10 s.
|
||||
FALLBACK_BUDGET_S = 2.0
|
||||
HAND_BACK_WORST_S = RELEASE_BUDGET_S + FALLBACK_BUDGET_S
|
||||
|
||||
# SAFETY-137: how long a LAST write waits for a write already on its way to the
|
||||
# same unit; half the release budget, so a GoodWe unit's worst case is
|
||||
# HAND_BACK_WORST_S. DECISIONS/SAFETY-137-last-write-is-zero.md.
|
||||
SETTLE_BUDGET_S = RELEASE_BUDGET_S / 2
|
||||
|
||||
# SAFETY-34: a Core-only restart wipes persistent_notification without
|
||||
# restarting this add-on, so a latched alarm is re-asserted this often.
|
||||
REANNOUNCE_S = 300.0
|
||||
@@ -405,6 +424,10 @@ class Controller:
|
||||
self.store_migration = migrate_unit_keys(
|
||||
store, _roster_names[0], _roster_names)
|
||||
self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg)
|
||||
# SMA-10: which units hand back decides what the page says about Stop,
|
||||
# restart and shadow mode (DECISIONS/SMA-10-two-idle-states.md).
|
||||
handback.set_roster(handback.units_of(self.inverters))
|
||||
uitext.set_idle(handback.overlay(handback.current()))
|
||||
# CFG-04: say which shape is in force. A declared `inverters:` roster
|
||||
# ignores `soc_entity`, `batt_entity`, `batt_invert` and `max_w`, which
|
||||
# the UI still shows; validate.py rule 9(e) refuses only the dangerous
|
||||
@@ -925,6 +948,8 @@ class Controller:
|
||||
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
|
||||
@@ -1708,7 +1733,9 @@ class Controller:
|
||||
else self.house_profile.estimator(),
|
||||
priceruns.keep_started(self.mode_runs, runs, now), now, packs,
|
||||
trust_pct=self.solar_trust_pct, floor_pct=floor_pct,
|
||||
ceiling_pct=ceiling_pct)
|
||||
ceiling_pct=ceiling_pct, aim_w=self.peak_aim_w(),
|
||||
slots=priceruns.firm_slots(plan.read.points, now),
|
||||
spread=required)
|
||||
self.day_plan = dp
|
||||
if dp is None:
|
||||
self._plan_targets = None
|
||||
@@ -2350,6 +2377,36 @@ class Controller:
|
||||
return (decision if self._peak_needed
|
||||
else dataclasses.replace(decision, within_aim=True))
|
||||
|
||||
def _readings_late(self, standing_w):
|
||||
"""SAFETY-128: update the answer gate; seconds the late verdict has
|
||||
stood, or None. Readings found late are logged once per episode.
|
||||
DECISIONS/SAFETY-128-late-readings.md."""
|
||||
a = self.answer
|
||||
if self._now_mono is None:
|
||||
return None
|
||||
a.observe(self._now_mono, standing_w, self.site_batt_w,
|
||||
grid_w=self.grid, exempt=bool(self.batt_refused) or any(
|
||||
i.refused or (
|
||||
i.batt is not None
|
||||
and abs(i.standing_w()) >= lateness.ANSWER_W
|
||||
and abs(i.batt - i.idle_w) < lateness.ANSWER_W)
|
||||
for i in self.inverters),
|
||||
idle_w=self._site_idle_w())
|
||||
if a.late_seen != self._late_logged:
|
||||
self._late_logged = a.late_seen
|
||||
if a.late_seen:
|
||||
self.log_event("readings late - holding",
|
||||
hint=messages.hint("readings_late"))
|
||||
_LOG.warning("Home Assistant readings arrive late, so control "
|
||||
"holds the batteries: READINGS_LATE.")
|
||||
_LOG.debug("READINGS_LATE: the readings show a power request "
|
||||
"%.0f s after it is sent.", a.delay_s)
|
||||
else:
|
||||
self.log_event("readings on time", hint=messages.hint(
|
||||
"readings_back"))
|
||||
_LOG.info("Home Assistant readings are on time again.")
|
||||
return a.late_s(self._now_mono)
|
||||
|
||||
def _peak_reserve(self) -> frozenset:
|
||||
"""SAFETY-87: the batteries that give nothing for the peak this
|
||||
cycle: under their reserve while the others carry a peak floor."""
|
||||
@@ -2867,6 +2924,10 @@ class Controller:
|
||||
else:
|
||||
_LOG.debug("Meter readings are back.")
|
||||
|
||||
self.answer.watch(now_mono, [(inv.name, inv.standing_w(), inv.batt)
|
||||
for inv in self.inverters], # SAFETY-128
|
||||
sum(inv.standing_w() for inv in self.inverters),
|
||||
self.grid)
|
||||
if self.inputs_ok:
|
||||
if self.inputs_bad_since is not None:
|
||||
self.log_event("inputs recovered",
|
||||
@@ -2875,14 +2936,26 @@ class Controller:
|
||||
elif self.inputs_bad_since is None:
|
||||
self.inputs_bad_since = datetime.now(timezone.utc)
|
||||
|
||||
def _driver(self, inv):
|
||||
"""`inv`'s driver, wired to this Controller's Home Assistant on first use.
|
||||
|
||||
SMA-2. Bound HERE rather than when the roster is built, so a unit added
|
||||
to `self.inverters` afterwards is wired too, and the driver follows
|
||||
whatever `self.hass` is at the time (a check may swap it).
|
||||
"""
|
||||
drv = inv.driver
|
||||
if not drv.bound():
|
||||
drv.bind_getter(lambda: self.hass)
|
||||
return drv
|
||||
|
||||
async def _read_unit(self, inv, now_mono: float) -> None:
|
||||
"""One inverter's own SoC and battery power, from ITS OWN entities.
|
||||
SAFETY-37: the POWER reading goes through the freshness gate and the
|
||||
SoC does not, deliberately: `batt` feeds SAFETY-30's per-unit sign
|
||||
check and the delivery check, which a frozen register would fool.
|
||||
The standing command is asked of the unit, not recomputed."""
|
||||
inv.soc = await self.hass.number(inv.soc_entity)
|
||||
raw = await self.hass.number(inv.batt_entity, inv.batt_invert)
|
||||
# SMA-2: the driver reads SoC, then power, as this method always did.
|
||||
inv.soc, raw, _extra = await self._driver(inv).read()
|
||||
was_stale = inv.batt_fresh.stale
|
||||
was_releases = inv.batt_fresh.releases
|
||||
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
|
||||
@@ -2993,11 +3066,11 @@ class Controller:
|
||||
return rec.authorises(inv.name, value, self.alloc_tick)
|
||||
|
||||
async def write_setpoint(self, value: float, *, force: bool = False,
|
||||
unit=None) -> None:
|
||||
unit=None, last: bool = False) -> None:
|
||||
"""Put `value` on ONE inverter, or with `unit=None` on all. `unit=None`
|
||||
is for a number that is not a share (stop button, `shutdown()`,
|
||||
`maint_abort()`: 0 W means the same to every inverter). Writes are
|
||||
concurrent, each under its own budget (DEV-02)."""
|
||||
concurrent, each under its own budget (DEV-02). `last`: SAFETY-137."""
|
||||
if unit is None:
|
||||
targets = [i for i in self.inverters]
|
||||
if len(targets) > 1 and is_commandable(value) and float(value) != 0.0:
|
||||
@@ -3021,13 +3094,127 @@ class Controller:
|
||||
targets = [unit]
|
||||
if not targets:
|
||||
return
|
||||
await asyncio.gather(*(self._write_unit(inv, value, force=force)
|
||||
await asyncio.gather(*(self._write_unit(inv, value, force=force,
|
||||
last=last)
|
||||
for inv in targets))
|
||||
|
||||
async def _write_unit(self, inv, value: float, *, force: bool = False) -> None:
|
||||
entity = inv.setpoint_entity
|
||||
if not entity:
|
||||
async def _await_inflight(self, inv, budget_s: float) -> None:
|
||||
"""Wait, at most `budget_s`, for the writes already on their way to
|
||||
`inv` (SAFETY-137: every driver, GoodWe too). A write still running at
|
||||
the bound is left alone, not cancelled: its own WRITE_BUDGET_S ends it."""
|
||||
pending = [t for t in inv.inflight if not t.done()]
|
||||
if pending:
|
||||
await asyncio.wait(pending, timeout=budget_s)
|
||||
|
||||
async def _release_last(self, inv) -> bool:
|
||||
"""`release()`, after a write already on its way to `inv` has finished
|
||||
(at most half the budget is spent waiting for it), so the release is the
|
||||
LAST thing the unit hears and a late write cannot undo it."""
|
||||
await self._await_inflight(inv, SETTLE_BUDGET_S)
|
||||
return await self._driver(inv).release()
|
||||
|
||||
async def _hand_back(self, inv, *, force: bool = False) -> bool:
|
||||
"""SMA-10. Give ONE unit back: exactly one `release()`, then nothing.
|
||||
|
||||
True when the unit is handed back (now, or already). Never raises: a
|
||||
driver that raises or hangs is logged and the unit is tried again on the
|
||||
next idle write, and it cannot stop a sibling being released or zeroed.
|
||||
|
||||
⚠️ `released` IS SET BEFORE THE FIRST AWAIT. A second caller (the
|
||||
heartbeat while the Stop button is mid-release) then sees it and does
|
||||
nothing, so a unit gets one `release()` and not two. If the release
|
||||
did not take, the flag goes back down and the unit stays un-handed
|
||||
until a later idle write tries again.
|
||||
|
||||
⚠️ IT IS NOT A WRITE: no `note_write`, no `last_write`, so a hand-back
|
||||
cannot be read as a link that answered. `last_write` is cleared so the
|
||||
first write after control starts again is always sent.
|
||||
"""
|
||||
if inv.released:
|
||||
return True
|
||||
if not inv.driver.configured():
|
||||
return False
|
||||
# After a failure the next try waits one heartbeat, so a hung release
|
||||
# costs a cycle its 4 s once per heartbeat, not on every cycle. The
|
||||
# Stop button and shutdown (`force`) always try.
|
||||
now_m = time.monotonic()
|
||||
if not force and now_m < inv.release_retry_at:
|
||||
return False
|
||||
inv.released = True
|
||||
why = ""
|
||||
try:
|
||||
ok = bool(await _bounded(self._release_last(inv),
|
||||
RELEASE_BUDGET_S))
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
ok, why = False, f"took over {RELEASE_BUDGET_S:.0f} s"
|
||||
except Exception as err: # noqa: BLE001 - one unit never stops the rest
|
||||
ok, why = False, f"{type(err).__name__}: {err}"
|
||||
if ok:
|
||||
inv.last_write = None
|
||||
if inv.release_fails:
|
||||
_LOG.info("Handing back %s worked again after %d failed "
|
||||
"tries.", inv.name, inv.release_fails)
|
||||
inv.release_fails = 0
|
||||
inv.release_retry_at = 0.0
|
||||
inv.release_zero_sent = False
|
||||
self.log_event(f"{inv.name}: handed back",
|
||||
hint=messages.hint("handed_back", unit=inv.name))
|
||||
return True
|
||||
inv.released = False
|
||||
inv.release_fails += 1
|
||||
inv.release_retry_at = now_m + float(self.o.get("heartbeat_s", 10))
|
||||
_LOG.debug("Handing back %s failed (try %d): %s", inv.name,
|
||||
inv.release_fails, why)
|
||||
if inv.release_fails == 1: # one line per failure streak
|
||||
_LOG.error("Handing back %s failed, so it is sent 0 W once and "
|
||||
"handing back is tried again.", inv.name)
|
||||
self.log_event(f"{inv.name}: hand-back failed",
|
||||
hint=messages.hint("hand_back_failed", unit=inv.name))
|
||||
if not inv.release_zero_sent:
|
||||
# ⚠️ ONCE. The unit must not keep carrying its last command, but a
|
||||
# 0 W re-sent on every try would reset the inverter's own fallback
|
||||
# timer for ever. The driver says what the one write is.
|
||||
inv.release_zero_sent = True
|
||||
try:
|
||||
if await _bounded(self._driver(inv).release_fallback(),
|
||||
FALLBACK_BUDGET_S):
|
||||
inv.last_write = 0.0
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOG.debug("The fallback write to %s failed: %r", inv.name, err)
|
||||
return False
|
||||
|
||||
async def _write_unit(self, inv, value: float, *, force: bool = False,
|
||||
last: bool = False) -> None:
|
||||
if not inv.driver.configured():
|
||||
return
|
||||
# --- SMA-10: A UNIT THAT HANDS BACK IS NOT WRITTEN WHILE THE ADD-ON IS
|
||||
# IDLE. Idle is: shutting down, control stopped, or shadow mode. The
|
||||
# unit is released once and then nothing is written to it - not the
|
||||
# heartbeat, not the cycle's "stopped" 0 W - so its own controller keeps
|
||||
# it. This is the passive half; `command_site_release()` is the active
|
||||
# one, for the moment the add-on goes away.
|
||||
#
|
||||
# ⚠️ A SAFETY STOP IS NOT IDLE. It happens while control runs (`auto`,
|
||||
# not shadow), so it falls through to the 0 W write below: the add-on's
|
||||
# own decision to hold 0 W is never turned into a hand-back.
|
||||
#
|
||||
# ⚠️ AND CONTROL STARTING AGAIN RE-ARMS THE UNIT: the flag drops and
|
||||
# `last_write` is cleared, so the first write goes out whatever it is.
|
||||
# `stopping` never re-arms: a shutdown in progress stays handed back.
|
||||
if inv.driver.hands_back:
|
||||
if self.stopping or self.shadow or not self.auto:
|
||||
await self._hand_back(inv)
|
||||
return
|
||||
if inv.released or inv.release_fails or inv.release_zero_sent:
|
||||
# Control has the unit again: forget the hand-back, and any
|
||||
# failed streak with it, so the next Stop starts clean (its own
|
||||
# one 0 W, its own error line and event).
|
||||
inv.released = False
|
||||
inv.last_write = None
|
||||
inv.release_fails = 0
|
||||
inv.release_retry_at = 0.0
|
||||
inv.release_zero_sent = False
|
||||
entity = inv.driver.actuator_id() # for the log line below
|
||||
|
||||
# SAFETY-03: the clamp is on the ONLY path to the wire (cycle,
|
||||
# heartbeat, stop button, shutdown(); no optimizer, maintenance or
|
||||
@@ -3083,6 +3270,21 @@ class Controller:
|
||||
value, inv.name, shadow.WIRE_W)
|
||||
value = shadow.WIRE_W
|
||||
|
||||
# --- SAFETY-137: THE 0 W THAT ENDS THINGS IS THE LAST WRITE THE UNIT
|
||||
# HEARS. `last` is the Stop 0 W and the shutdown 0 W (and nothing else).
|
||||
# It first waits, bounded, for a write already on its way to this unit,
|
||||
# so that write cannot land after it. And once shutdown has begun every
|
||||
# other write (cycle, heartbeat, a safety stop) is dropped here: the
|
||||
# check sits right before the write is created with no await between,
|
||||
# so a write that starts after it cannot exist, and one that started
|
||||
# before is in `inflight` for the wait above. The idle gate for a
|
||||
# hand-back unit is higher up and does the same for those.
|
||||
if last:
|
||||
await self._await_inflight(inv, SETTLE_BUDGET_S)
|
||||
elif self.stopping:
|
||||
_LOG.debug("Shutting down, so %+.0f W to %s is not sent.",
|
||||
value, inv.name)
|
||||
return
|
||||
changed = (inv.last_write is None or value != inv.last_write)
|
||||
if not (changed or force):
|
||||
return
|
||||
@@ -3091,8 +3293,13 @@ class Controller:
|
||||
# abandoned write is re-issued next tick (`last_write` moves only on an
|
||||
# ACCEPTED write).
|
||||
try:
|
||||
ok = await asyncio.wait_for(self.hass.set_number(entity, value),
|
||||
timeout=WRITE_BUDGET_S)
|
||||
_w = self._driver(inv).write(value)
|
||||
# SMA-10 / SAFETY-137: kept for every driver, so a release or a last
|
||||
# 0 W can wait for it (`_await_inflight`).
|
||||
_w = asyncio.ensure_future(_w)
|
||||
inv.inflight.add(_w)
|
||||
_w.add_done_callback(inv.inflight.discard)
|
||||
ok = await asyncio.wait_for(_w, timeout=WRITE_BUDGET_S)
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
ok = False
|
||||
_LOG.error("Sending %+.0f W to %s (%s) took over %.0f s and was "
|
||||
@@ -3403,6 +3610,8 @@ class Controller:
|
||||
"away a partly delivering unit's output.")
|
||||
|
||||
stale_after = float(self.o.get("stale_input_s", 15))
|
||||
late_s = self._readings_late(site_standing) # SAFETY-128
|
||||
self._late_zero = late_s is not None and late_s >= stale_after
|
||||
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed
|
||||
# poll does not become a fault; a LATCHED inverter must still reach the
|
||||
# claim list below, because its `failsafe` SET is what parks it.
|
||||
@@ -3420,6 +3629,13 @@ class Controller:
|
||||
self.why_words = messages.why_waiting(bad_for)
|
||||
self._why_waiting_since = now - timedelta(seconds=bad_for)
|
||||
return
|
||||
if late_s is not None and late_s < stale_after and self.inputs_ok \
|
||||
and (self.auto or self.shadow) \
|
||||
and not any(i.sign_wd.tripped for i in self.inverters) \
|
||||
and not (n_units > 1 and self.site_sign_wd.tripped):
|
||||
self.reason = f"readings late {late_s:.0f}s" # SAFETY-128: hold
|
||||
self.why_words = messages.why_late()
|
||||
return
|
||||
|
||||
claims = []
|
||||
|
||||
@@ -3491,6 +3707,9 @@ class Controller:
|
||||
hint=messages.hint("inputs_missing"))
|
||||
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing",
|
||||
actuator=self.actuator_of("safety")))
|
||||
elif late_s is not None:
|
||||
claims.append(Claim.set("safety", P_SAFETY, 0.0, "readings late",
|
||||
actuator=self.actuator_of("safety")))
|
||||
|
||||
# SAFETY-07: the quarter-peak projection guard ONE `lo` limit at
|
||||
# P_PEAK, and peakguard.py carries the whole argument.
|
||||
@@ -3550,6 +3769,8 @@ class Controller:
|
||||
self.peak_guard = self._peak_on_able(self.peak_guard,
|
||||
maint_results)
|
||||
self.peak_guard = self._peak_landing(self.peak_guard)
|
||||
self.peak_guard = peakguard.cap_floor( # SAFETY-128
|
||||
self.peak_guard, self.answer.peak_cap(site_standing))
|
||||
# SITE, and peakguard.claim() says so on the object it builds.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
peak_claim = (peakguard.claim(self.peak_guard)
|
||||
@@ -3844,6 +4065,7 @@ class Controller:
|
||||
if len(self.inverters) == 1 else None),
|
||||
# SAFETY-97: "gives nothing" is centred on the idle reading.
|
||||
idle_w=self._site_idle_w(),
|
||||
hold=self.answer.hold, hold_w=self.answer.hold_w, # SAFETY-128
|
||||
# OPT-11: None unless battery modes are on. SAFETY-85: or while
|
||||
# maintenance holds a battery at N>=2, so the loop does not
|
||||
# wind past what the others can give and jump when it ends.
|
||||
@@ -4591,9 +4813,8 @@ class Controller:
|
||||
# ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
async def _limits(inv):
|
||||
if inv.setpoint_entity:
|
||||
inv.dev_min, inv.dev_max, _ = await self.hass.limits(
|
||||
inv.setpoint_entity)
|
||||
if inv.driver.configured():
|
||||
inv.dev_min, inv.dev_max = await self._driver(inv).limits()
|
||||
await asyncio.gather(*(_limits(inv) for inv in self.inverters))
|
||||
entity = self.o.get("setpoint_entity", "")
|
||||
# ⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01 exists
|
||||
@@ -4614,9 +4835,10 @@ class Controller:
|
||||
self.log_event("REFUSED: " + problems[0],
|
||||
hint=messages.hint("refused"))
|
||||
# Signal, then return WITHOUT entering the loop. amain() wakes
|
||||
# on this, runs the normal wind-down (which commands 0 W) and
|
||||
# re-raises ConfigRefused afterwards, so the exit code is 78 and
|
||||
# the inverter is left at zero rather than latched.
|
||||
# on this, runs the normal wind-down (which commands 0 W, or hands
|
||||
# back a unit whose driver does: SMA-10) and re-raises
|
||||
# ConfigRefused afterwards, so the exit code is 78 and the
|
||||
# inverter is left at zero rather than latched.
|
||||
self.stop_event.set()
|
||||
return
|
||||
|
||||
@@ -4738,18 +4960,50 @@ class Controller:
|
||||
async def shutdown(self) -> None:
|
||||
"""Deterministic wind-down. Do not skip this."""
|
||||
self.stopping = True
|
||||
_LOG.info("shutting down - commanding 0 W on every inverter")
|
||||
await self.command_site_zero()
|
||||
_LOG.info(handback.log_shutdown(handback.current())
|
||||
or "shutting down - commanding 0 W on every inverter")
|
||||
await self.command_site_release()
|
||||
self.mqtt.close()
|
||||
|
||||
async def command_site_zero(self) -> None:
|
||||
async def command_site_release(self) -> None:
|
||||
"""SMA-10. The add-on is going away (Stop, shutdown, restart, task
|
||||
death, a config refusal): every unit that hands back is given back, and
|
||||
every other unit is held at 0 W exactly as `command_site_zero()` does.
|
||||
|
||||
⚠️ THE SAFETY STOPS AND REFUSALS DO NOT COME HERE. A sign trip, a
|
||||
refused write, a missing reading, another writer: those are the add-on's
|
||||
own decisions and hold 0 W through `write(0)` for EVERY unit, handed-back
|
||||
drivers included. `command_site_zero()` is that, and it is unchanged. A
|
||||
unit the add-on has just declared it cannot trust must not be handed to
|
||||
something else that will then do what it likes with the battery.
|
||||
|
||||
⚠️ A ROSTER WITH NO HAND-BACK UNIT IS `command_site_zero()`, line for
|
||||
line: GoodWe's release is its 0 W write, so Stop keeps today's write
|
||||
and today's heartbeat.
|
||||
|
||||
⚠️ ONE UNIT'S FAILURE IS ITS OWN. `_hand_back()` never raises and is
|
||||
bounded by RELEASE_BUDGET_S; the units run concurrently, so the whole
|
||||
call takes no longer than its slowest bound.
|
||||
"""
|
||||
if not any(inv.driver.hands_back for inv in self.inverters):
|
||||
await self.command_site_zero(last=True)
|
||||
return
|
||||
self.target = 0.0
|
||||
for inv in self.inverters:
|
||||
inv.target = 0.0
|
||||
await asyncio.gather(*(
|
||||
self._hand_back(inv, force=True) if inv.driver.hands_back
|
||||
else self._write_unit(inv, 0.0, force=True, last=True)
|
||||
for inv in self.inverters))
|
||||
|
||||
async def command_site_zero(self, *, last: bool = False) -> None:
|
||||
"""Every inverter to 0 W, and every inverter's TARGET to 0 W with it.
|
||||
THE SECOND HALF IS NOT BOOKKEEPING, AND IT IS A HAZARD DEV-02 CREATED.
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
self.target = 0.0
|
||||
for inv in self.inverters:
|
||||
inv.target = 0.0
|
||||
await self.write_setpoint(0.0, force=True)
|
||||
await self.write_setpoint(0.0, force=True, last=last)
|
||||
|
||||
# -- UI ------------------------------------------------------------------
|
||||
def checks(self, when: datetime | None = None) -> list:
|
||||
@@ -4939,9 +5193,10 @@ class Controller:
|
||||
rows = []
|
||||
for inv in self.inverters:
|
||||
unit = None if len(self.inverters) == 1 else inv.name
|
||||
rows.append((messages.LABEL_SOC, unit, inv.soc, inv.soc_entity))
|
||||
rows.append((messages.LABEL_SOC, unit, inv.soc,
|
||||
inv.driver.soc_source()))
|
||||
rows.append((messages.LABEL_BATTERY, unit, inv.batt,
|
||||
inv.batt_entity))
|
||||
inv.driver.batt_source()))
|
||||
if not self.p1_enabled:
|
||||
# In P1 mode the check above replaces this one; leaving both in
|
||||
# would report "no entity configured" for a meter_entity that is
|
||||
@@ -4965,9 +5220,8 @@ class Controller:
|
||||
"text": messages.row_sensor(label, value, unit)})
|
||||
|
||||
for inv in self.inverters:
|
||||
sp = inv.setpoint_entity
|
||||
unit = None if len(self.inverters) == 1 else inv.name
|
||||
if not sp:
|
||||
if not inv.driver.configured():
|
||||
out.append({"ok": False, "warn": False, "id": "control",
|
||||
"text": messages.row_control_unset(unit)})
|
||||
elif inv.dev_max is None:
|
||||
@@ -4986,6 +5240,10 @@ class Controller:
|
||||
(inv.refusal_note(), False)):
|
||||
if note:
|
||||
out.append({"ok": False, "warn": not red, "text": note})
|
||||
if self.answer.late_seen: # SAFETY-128
|
||||
out.append({"ok": False, "warn": True, "id": "late",
|
||||
"text": messages.row_readings_late(
|
||||
self.answer.shown_s, self._late_zero)})
|
||||
|
||||
# SAFETY-03. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_cw = self.clamp_wd
|
||||
@@ -5426,9 +5684,9 @@ class Controller:
|
||||
self._delivery_announced = set()
|
||||
if not self.auto:
|
||||
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see
|
||||
# command_site_zero().
|
||||
# command_site_zero(). SMA-10: Stop gives hand-back units back.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await self.command_site_zero()
|
||||
await self.command_site_release()
|
||||
return {"auto": self.auto}
|
||||
if what in ("maint_start", "maint_abort") and unit is not None:
|
||||
# MAINT-11: the operator named the battery. A request with no unit
|
||||
@@ -5463,6 +5721,21 @@ MAINT_CHARGING_W = 50.0
|
||||
EXIT_CONTROL_DIED = 70
|
||||
|
||||
|
||||
async def _bounded(awaitable, budget_s: float):
|
||||
"""SMA-10. `await awaitable`, but give up after `budget_s` and do NOT wait
|
||||
for it to wind down. `asyncio.wait_for` waits for the cancelled task to
|
||||
finish, so a driver that ignores its cancellation would hold the whole
|
||||
shutdown; this abandons it. Raises `asyncio.TimeoutError` at the bound."""
|
||||
task = asyncio.ensure_future(awaitable)
|
||||
done, _ = await asyncio.wait({task}, timeout=budget_s)
|
||||
if not done:
|
||||
task.cancel()
|
||||
# a result or error that arrives later has nobody to read it
|
||||
task.add_done_callback(lambda t: t.cancelled() or t.exception())
|
||||
raise asyncio.TimeoutError
|
||||
return task.result()
|
||||
|
||||
|
||||
class ControlTaskDied(RuntimeError):
|
||||
"""The control task ended while nothing had asked it to stop."""
|
||||
|
||||
@@ -5485,10 +5758,12 @@ def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
|
||||
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 every inverter is "
|
||||
"set to 0 W and the add-on exits with code %d.",
|
||||
_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"), EXIT_CONTROL_DIED,
|
||||
(repr(exc) if exc else "returned"),
|
||||
handback.task_died_state(handback.current()),
|
||||
EXIT_CONTROL_DIED,
|
||||
exc_info=exc)
|
||||
died.append(exc)
|
||||
stop.set()
|
||||
@@ -5611,10 +5886,12 @@ async def amain() -> None:
|
||||
# configuration validate.py rejects - specs §1.5: a config the system
|
||||
# cannot honour is refused at startup, not survived at runtime.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
# SMA-2: the flat options are the GoodWe driver's (no Controller yet).
|
||||
_sp = str(opts.get("setpoint_entity", "") or "").strip()
|
||||
_drv = GoodWeEsphomeDriver(setpoint_entity=_sp).bind(hass)
|
||||
_dev_min = _dev_max = None
|
||||
if _sp:
|
||||
_dev_min, _dev_max, _ = await hass.limits(_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
|
||||
|
||||
Reference in New Issue
Block a user