diff --git a/goodwe_controller/DECISIONS/MAIN-orchestration-archive.md b/goodwe_controller/DECISIONS/MAIN-orchestration-archive.md index 504c123..06bda65 100644 --- a/goodwe_controller/DECISIONS/MAIN-orchestration-archive.md +++ b/goodwe_controller/DECISIONS/MAIN-orchestration-archive.md @@ -2583,3 +2583,182 @@ everything an operator needs, and a stack trace on top of it would say "this crashed" - the exact confusion AC3 is about. 78 is sysexits(3) EX_CONFIG, so even a script reading the exit status can tell a bad config from a bug. + +## Added at release/1.0 `16029d7` (SMA-2, SMA-10, SAFETY-128, SAFETY-137) + +Same form, keyed by the first line number in `app/main.py` at `16029d7`. + +### L143 SMA-10. The same bound for handing a unit back (`driver.release()`). I + +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. + +### L150 SMA-10 round 2. The one fallback write after a failed release (default + +SMA-10 round 2. The one fallback write after a failed release (default 0 W, +`Driver.release_fallback()`). Worst case for a unit whose release hangs: +RELEASE_BUDGET_S + FALLBACK_BUDGET_S = 6 s, against the Supervisor's 10 s. + +### L156 SAFETY-137. How long a LAST write (the Stop 0 W, the shutdown 0 W, a r + +SAFETY-137. How long a LAST write (the Stop 0 W, the shutdown 0 W, a release) +waits for a write already on its way to the same unit. Half the release +budget, as `_release_last` always waited. For a GoodWe unit that is +SETTLE_BUDGET_S + WRITE_BUDGET_S = 6 s at worst, which is HAND_BACK_WORST_S: +the wind-down is no slower than SMA-10's worst case. + +### L586 SMA-10. Which units hand back decides what the page says about Stop, + +SMA-10. Which units hand back decides what the page says about Stop, +restart and shadow mode. A GoodWe-only roster adds nothing. + +### L3152 SAFETY-128: update the answer gate; seconds the late verdict has + +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. + +### L3805 `inv`'s driver, wired to this Controller's Home Assistant on first use + +`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). + +### L3835 SMA-2. The driver reads, in the order this method always did: SoC, + +SMA-2. The driver reads, in the order this method always did: SoC, +then power. `extra` is whatever else the driver learned; nothing +consumes it yet. + +### L4074 Wait, at most `budget_s`, for the writes already on their way to + +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. + +### L4082 `release()`, after a write already on its way to `inv` has finished + +`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. + +### L4089 SMA-10. Give ONE unit back: exactly one `release()`, then nothing. + +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. + +### L4109 After a failure the next try waits one heartbeat, so a hung release + +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. + +### L4146 ⚠️ ONCE. The unit must not keep carrying its last command, but a + +⚠️ 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. + +### L4162 --- SMA-10: A UNIT THAT HANDS BACK IS NOT WRITTEN WHILE THE ADD-ON IS + +--- 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. + +### L4181 Control has the unit again: forget the hand-back, and any + +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). + +### L4302 --- SAFETY-137: THE 0 W THAT ENDS THINGS IS THE LAST WRITE THE UNIT + +--- 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. + +### L4328 SMA-10 / SAFETY-137: kept for every driver, so a release or a last + +SMA-10 / SAFETY-137: kept for every driver, so a release or a last +0 W can wait for it (`_await_inflight`). + +### L6948 Signal, then return WITHOUT entering the loop. amain() wakes + +Signal, then return WITHOUT entering the loop. amain() wakes +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. + +### L7222 SMA-10. The add-on is going away (Stop, shutdown, restart, task + +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. + +### L8207 SMA-10: the Stop button is the add-on going quiet, so a unit + +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. + +### L8254 SMA-10. `await awaitable`, but give up after `budget_s` and do NOT wai + +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. + +### L8434 SMA-2. The flat options are the one-inverter shape, so it is the + +SMA-2. The flat options are the one-inverter shape, so it is the +GoodWe driver's, asked without a Controller (none exists yet). diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 9b75f12..db5dcd1 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -2379,8 +2379,7 @@ class Controller: 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.""" + stood, or None. DECISIONS/SAFETY-128-late-readings.md.""" a = self.answer if self._now_mono is None: return None @@ -2937,12 +2936,9 @@ class Controller: 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). - """ + """`inv`'s driver, wired to this Controller's Home Assistant on first use + (SMA-2: bound here, so a unit added later and a swapped `self.hass` are + followed). DECISIONS/SMA-2-driver-seam.md.""" drv = inv.driver if not drv.bound(): drv.bind_getter(lambda: self.hass) @@ -3099,44 +3095,30 @@ class Controller: for inv in targets)) 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.""" + """Wait, at most `budget_s`, for writes already on their way to `inv`; one + still running is left to its own WRITE_BUDGET_S. SAFETY-137.""" 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.""" + """`release()` once any write in flight to `inv` has finished (at most + SETTLE_BUDGET_S): the release is the LAST thing the unit hears.""" 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. - """ + True when handed back. Never raises; a failure is retried on the next + idle write. ⚠️ `released` is set before the first await (one release per + unit, not two). ⚠️ It is not a write: no `note_write`, no `last_write`. + DECISIONS/SMA-10-two-idle-states.md.""" 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. + # After a failure the next try waits one heartbeat; Stop and shutdown + # (`force`) always try. now_m = time.monotonic() if not force and now_m < inv.release_retry_at: return False @@ -3171,9 +3153,8 @@ class Controller: 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. + # ⚠️ ONCE: a 0 W re-sent on every try would reset the inverter's own + # fallback timer for ever. inv.release_zero_sent = True try: if await _bounded(self._driver(inv).release_fallback(), @@ -3187,28 +3168,18 @@ class Controller: 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. + # SMA-10: a unit that hands back is released once and not written while + # the add-on is idle (shutdown, stopped, shadow). ⚠️ A safety stop is + # NOT idle: it falls through to the 0 W write. Control starting again + # re-arms the unit; `stopping` never does. + # DECISIONS/SMA-10-two-idle-states.md. 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). + # Control has the unit again: forget the hand-back and any + # failed streak. inv.released = False inv.last_write = None inv.release_fails = 0 @@ -3270,15 +3241,10 @@ 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. + # SAFETY-137: `last` (the Stop and shutdown 0 W) waits, bounded, for a + # write already on its way; once `stopping`, every other write is dropped + # here with no await before the write is made. + # DECISIONS/SAFETY-137-last-write-is-zero.md. if last: await self._await_inflight(inv, SETTLE_BUDGET_S) elif self.stopping: @@ -3294,8 +3260,7 @@ class Controller: # ACCEPTED write). try: _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`). + # SMA-10 / SAFETY-137: kept so a release or last 0 W can wait for it. _w = asyncio.ensure_future(_w) inv.inflight.add(_w) _w.add_done_callback(inv.inflight.discard) @@ -4834,11 +4799,8 @@ class Controller: self.refused = problems 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, 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. + # Signal and return WITHOUT entering the loop: amain() runs the + # wind-down (0 W, or a hand-back: SMA-10) and exits with code 78. self.stop_event.set() return @@ -4966,25 +4928,12 @@ class Controller: 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. - """ + """SMA-10. The add-on is going away (Stop, shutdown, restart, task death, + config refusal): units that hand back are given back, the rest held at + 0 W as `command_site_zero()` does. ⚠️ Safety stops and refusals do NOT + come here: they hold 0 W for EVERY unit. A roster with no hand-back unit + is `command_site_zero()` line for line. + DECISIONS/SMA-10-two-idle-states.md.""" if not any(inv.driver.hands_back for inv in self.inverters): await self.command_site_zero(last=True) return @@ -5722,10 +5671,8 @@ 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.""" + """SMA-10. `await awaitable`, abandoning it after `budget_s` without waiting + for it to wind down (`wait_for` would). Raises `asyncio.TimeoutError`.""" task = asyncio.ensure_future(awaitable) done, _ = await asyncio.wait({task}, timeout=budget_s) if not done: