SMA-10: two idle states, hold 0 W or hand the inverter back
A driver now says whether its release() hands the inverter back (Driver.hands_back; GoodWe is False, so release() stays write(0) and the Controller never calls it). command_site_release() sits next to command_site_zero(): the Stop button and shutdown() (so SIGTERM, restart, control task death and a late config refusal) hand a hand-back unit back, once, and zero every other unit as before. Safety stops and refusals still hold 0 W for every unit. Shadow mode releases a hand-back unit. After a release nothing is written to the unit (heartbeat and cycle included) until control starts again. release() is bounded by RELEASE_BUDGET_S (= the write budget), runs concurrently per unit, and a raising or hanging release on one unit does not stop the others. The "set to 0 W" sentences for Stop, restart, shadow mode and the stopped banner are built from the roster (app/handback.py, EN and NL, words from the driver); a GoodWe-only roster keeps every old literal. A handed-back unit shows "Back under <its control>" on Home. Existing test edited: test_messages.py (two classification entries for the CRITICAL line that moved into a roster builder, and the new release bound). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
57c8d1b912
commit
a1be9c48b3
+154
-11
@@ -67,6 +67,7 @@ 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,
|
||||
@@ -89,6 +90,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
|
||||
@@ -138,6 +140,13 @@ __all__ = ["UNIT_A", "Controller", "amain", "main"]
|
||||
# the re-issue happens by itself) than hold the control loop for ten seconds.
|
||||
WRITE_BUDGET_S = 4.0
|
||||
|
||||
# SMA-10. The same bound for handing a unit back (`driver.release()`). It is the
|
||||
# time the shutdown path already allows one 0 W write, and the units are
|
||||
# released CONCURRENTLY, so one slow or hung unit costs the wind-down no more
|
||||
# than a hung GoodWe write does today: the Supervisor's kill window is not lost
|
||||
# to it. A release still running at the bound is abandoned, not awaited.
|
||||
RELEASE_BUDGET_S = WRITE_BUDGET_S
|
||||
|
||||
# SAFETY-34: a Core-only restart wipes persistent_notification and does NOT
|
||||
# restart this add-on (measured on TEST-23), so a latched alarm is re-asserted
|
||||
# this often. Why: goodwe docs/DECISIONS.md, SAFETY-34.
|
||||
@@ -561,6 +570,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. A GoodWe-only roster adds nothing.
|
||||
handback.set_roster(handback.units_of(self.inverters))
|
||||
uitext.set_idle(handback.overlay(handback.current()))
|
||||
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
|
||||
# ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none
|
||||
# of `soc_entity`, `batt_entity`, `batt_invert` or `max_w`, and an
|
||||
@@ -4043,9 +4056,82 @@ class Controller:
|
||||
await asyncio.gather(*(self._write_unit(inv, value, force=force)
|
||||
for inv in targets))
|
||||
|
||||
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."""
|
||||
pending = [t for t in inv.inflight if not t.done()]
|
||||
if pending:
|
||||
await asyncio.wait(pending, timeout=RELEASE_BUDGET_S / 2)
|
||||
return await self._driver(inv).release()
|
||||
|
||||
async def _hand_back(self, inv) -> 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
|
||||
inv.released = True
|
||||
try:
|
||||
ok = bool(await _bounded(self._release_last(inv),
|
||||
RELEASE_BUDGET_S))
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
ok = False
|
||||
_LOG.error("Handing back %s took over %.0f s and was dropped. It "
|
||||
"is tried again.", inv.name, RELEASE_BUDGET_S)
|
||||
except Exception as err: # noqa: BLE001 - one unit never stops the rest
|
||||
ok = False
|
||||
_LOG.error("Handing back %s failed: %s. It is tried again.",
|
||||
inv.name, err)
|
||||
if ok:
|
||||
inv.last_write = None
|
||||
self.log_event(f"{inv.name}: handed back",
|
||||
hint=messages.hint("handed_back", unit=inv.name))
|
||||
else:
|
||||
inv.released = False
|
||||
self.log_event(f"{inv.name}: hand-back failed",
|
||||
hint=messages.hint("hand_back_failed", unit=inv.name))
|
||||
return ok
|
||||
|
||||
async def _write_unit(self, inv, value: float, *, force: 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:
|
||||
inv.released = False
|
||||
inv.last_write = None
|
||||
entity = inv.driver.actuator_id() # for the log line below
|
||||
|
||||
# --- SAFETY-03: THE CLAMP, AND IT IS ON THE ONLY PATH TO THE WIRE -----
|
||||
@@ -4169,8 +4255,13 @@ class Controller:
|
||||
# tick by itself: `last_write` is updated only on an ACCEPTED write, so
|
||||
# `changed` is still true.
|
||||
try:
|
||||
ok = await asyncio.wait_for(self._driver(inv).write(value),
|
||||
timeout=WRITE_BUDGET_S)
|
||||
_w = self._driver(inv).write(value)
|
||||
if inv.driver.hands_back:
|
||||
# SMA-10: kept, so a release can wait for it (`_release_last`)
|
||||
_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 "
|
||||
@@ -6786,9 +6877,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
|
||||
|
||||
@@ -7052,10 +7144,42 @@ 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_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()
|
||||
return
|
||||
self.target = 0.0
|
||||
for inv in self.inverters:
|
||||
inv.target = 0.0
|
||||
await asyncio.gather(*(
|
||||
self._hand_back(inv) if inv.driver.hands_back
|
||||
else self._write_unit(inv, 0.0, force=True)
|
||||
for inv in self.inverters))
|
||||
|
||||
async def command_site_zero(self) -> None:
|
||||
"""Every inverter to 0 W, and every inverter's TARGET to 0 W with it.
|
||||
|
||||
@@ -8011,7 +8135,9 @@ class Controller:
|
||||
# Zeroing `self.target` alone would leave each unit's last share
|
||||
# standing for its next heartbeat to re-emit, which an operator
|
||||
# would read as the stop button not working.
|
||||
await self.command_site_zero()
|
||||
# SMA-10: the Stop button is the add-on going quiet, so a unit
|
||||
# that hands back is given back; every other unit is zeroed.
|
||||
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
|
||||
@@ -8055,6 +8181,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."""
|
||||
|
||||
@@ -8081,10 +8222,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()
|
||||
|
||||
Reference in New Issue
Block a user