diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 7e43d71..d75e61a 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -4529,10 +4529,11 @@ class Controller: fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc), bug=bug, facts=facts) - # THE ACTION LOG, AND IT IS COALESCED PER EPISODE Every engagement is - # recorded, but consecutive ones are recorded by UPDATING one row's - # count rather than inserting a row each. - # THAT IS NOT COSMETIC, AND IT WAS FOUND THE HARD WAY. + # THE ACTION LOG IS COALESCED PER EPISODE: consecutive engagements + # update one row's count. ⚠️ NOT COSMETIC: the log keeps 40 rows and a + # winding loop clamps every cycle, so a row each flushed SAFETY-18's + # "METER FROZEN?" row. Here as well as in write_setpoint() because on a + # default install the actuator-side clamp is unreachable. # (DECISIONS/MAIN-orchestration-archive.md) if engaged and not engaged_write: tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}" @@ -4580,7 +4581,12 @@ class Controller: shadow=self.shadow).data()) async def check_peak_saturated(self) -> None: - """SAFETY-07. Alarm ONLY when the guard has run out of inverter. + """SAFETY-07. Alarm ONLY when the guard has run out of inverter. ⚠️ An + engagement is not an alarm: a peak floor above the rating is a house + load bigger than the inverter, not a defect (unlike SAFETY-03's winding + integrator). SAFETY-81: `g.saturated` must agree with + `peakguard.load_too_high()` for `ALARM_HOLD_S`. Edge-triggered: one + notification per episode, at most once per quarter. (DECISIONS/MAIN-orchestration-archive.md)""" g = self.peak_guard batt, prev = self.site_batt_w, self._peak_batt_prev @@ -4633,9 +4639,12 @@ class Controller: g.ceiling_w, shadow=self.shadow).data()) async def check_peak_floor_limited(self) -> None: - """SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving. - FAILING TO SHAVE MUST NOT BE SILENT, AND THAT IS THE WHOLE POINT. - (DECISIONS/MAIN-orchestration-archive.md)""" + """SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving. ⚠️ + FAILING TO SHAVE MUST NOT BE SILENT (the capacity tariff bills the + month's worst quarter). Not check_peak_saturated(): that is shed-load, + this is nothing discharged; never both. ⚠️ SAFETY-25: the message says + the floor binds this guard only; the loop has no SoC floor and goes on + discharging. Edge-triggered. (DECISIONS/MAIN-orchestration-archive.md)""" g = self.peak_guard limited = bool(g is not None and g.floor_limited) if limited and (g.floor_w or 0.0) > 0.0: @@ -4691,8 +4700,11 @@ class Controller: >= peakguard.ALARM_HOLD_S) async def check_circulating(self) -> None: - """SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. - THE LATCH IS NOT THE ALARM. + """SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. ⚠️ + THE LATCH IS NOT THE ALARM: the notification is in memory, so + `_circ_announced` (per-PROCESS, not persisted) makes a restart re-raise + it, and REANNOUNCE_S re-asserts it after a Core-only restart. ⚠️ ONE + CODE PATH for a fresh trip and a restart into one. (DECISIONS/MAIN-orchestration-archive.md)""" if not self.circ_wd.tripped: return @@ -4721,8 +4733,12 @@ class Controller: return t is None or _mono() - t >= REANNOUNCE_S async def check_not_delivering(self) -> None: - """SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart. - THE LATCH IS NOT THE ALARM, and this guard has nothing BUT the alarm. + """SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart. ⚠️ + THE LATCH IS NOT THE ALARM: the notification is in memory, so + `_delivery_announced` (a per-PROCESS set of unit names, not persisted) + makes a restart re-raise it, and REANNOUNCE_S re-asserts it. ⚠️ ONE + CODE PATH for a fresh trip and a restart into one. ⚠️ ONE ID PER UNIT + (`unit_slugs()`): two wedged inverters give two cards. (DECISIONS/MAIN-orchestration-archive.md)""" names = self.units slugs = unit_slugs(names) @@ -4754,7 +4770,9 @@ class Controller: messages.prices_back().data()) async def check_fixed_note(self) -> None: - """PRICE-01 G1. (DECISIONS/MAIN-orchestration-archive.md)""" + """PRICE-01 G1. First cycle only: one INFO line when `static_profile` + sits beside `price_service` (the plan's last fallback, not a fault, so + no notification). (DECISIONS/MAIN-orchestration-archive.md)""" if self._fixed_note_said: return self._fixed_note_said = True @@ -4776,10 +4794,10 @@ class Controller: async def check_meter_frozen(self) -> None: """SAFETY-18. Alarm on a meter that answers forever without changing. - getattr, not an attribute access: `unchanged_s` exists only on - `homewizard_local`, and `p1_source` is None until amain() builds the - transport. - (DECISIONS/MAIN-orchestration-archive.md)""" + Notification only: no claim, no latch, no 0 W (freeze.py carries the + reasoning). getattr, not an attribute access: `unchanged_s` exists only + on `homewizard_local`, and `p1_source` is None until amain() builds the + transport. (DECISIONS/MAIN-orchestration-archive.md)""" unchanged = getattr(self.p1_source, "unchanged_s", None) # SITE, all three inputs: one meter, the SITE target the arbiter # resolved, and the rail the SITE could stand at - which is the summed @@ -4814,8 +4832,7 @@ class Controller: # -- OPT-04: the background tariff refresh ------------------------------- def fresh_tariff_plan(self, now): """The stored plan if it is still current, else None. NEVER a stale - one. - THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for + one. THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for `loop_is_following()`'s reason: a check has to be able to assert on the shipped expression rather than on a transcription that drifts. (DECISIONS/MAIN-orchestration-archive.md)""" @@ -4827,9 +4844,12 @@ class Controller: async def run_tariff_refresh(self) -> None: """Resolve the tariff off the control path, forever. NEVER on the tick. - IT CATCHES EVERYTHING AND KEEPS GOING, for MqttPublisher's reason: a - supplier's outage, a certificate expiry, a malformed feed - all of it - is pricing, and the battery does not care. + ⚠️ IT CATCHES EVERYTHING AND KEEPS GOING: raising out of here would + freeze the plan, looking current for ever (`fresh_tariff_plan()`'s age + bound is the backstop). ⚠️ A FAILURE DOES NOT CLEAR THE PLAN + (TARIFF-12): a degraded plan replaces a standing observation only once + it has aged out; a §11.4 fallback never replaces a fresh provider plan. + ⚠️ `tariff_at` DOES NOT MOVE ON A KEPT PLAN. (DECISIONS/MAIN-orchestration-archive.md)""" horizon = float(price_option(self.o, "price_horizon_h")) while not self.stopping: @@ -4855,7 +4875,9 @@ class Controller: self.tariff_plan, self.tariff_at = plan, now _LOG.info("tariff refreshed: %s", plan.words) except Exception as err: # noqa: BLE001 - # ⚠️ NOT THE OUTAGE PATH - see the docstring. + # ⚠️ NOT THE OUTAGE PATH - see the docstring: `load_prices()` + # returns on every failure a site can have, so what reaches + # here is unexpected, and the plan is kept on the same terms. # (DECISIONS/MAIN-orchestration-archive.md) _LOG.warning( "The price update failed (%s), so the plan from %s is kept " @@ -4867,19 +4889,25 @@ class Controller: timeout=TARIFF_REFRESH_S) async def run_control(self) -> None: - # ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4. + # ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4 (one + # read of one `setpoint_entity` left a second inverter's clamp, freeze + # rail and `achievable_w` on the first one's numbers). Concurrently: a + # slow ESPHome on one unit must not delay the start on the others. # (DECISIONS/MAIN-orchestration-archive.md) async def _limits(inv): 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 - # to remove: a max_w above the device's rating meant write_setpoint() - # silently clamped and the configured ceiling was never the real one. - # ⚠️ AND THE `if entity:` THAT USED TO WRAP THE WHOLE BLOCK WAS A - # READER THAT FAILED PERMISSIVE - CFG-04's own hazard, in this file. - # (DECISIONS/MAIN-orchestration-archive.md) + # ⚠️ THIS USED TO WARN AND CARRY ON, the bug CFG-01 exists to remove: a + # max_w above the device's rating was silently clamped. amain() ran + # this gate already; it runs AGAIN because ESPHome is often not up at + # boot (limits None) and this is the first moment the real rating is + # known; a conflict refuses through the normal shutdown (0 W). ⚠️ THE + # `if entity:` THAT WRAPPED THIS BLOCK FAILED PERMISSIVE (CFG-04's + # hazard): a per-inverter config leaves the flat `setpoint_entity` + # blank, so the re-check was skipped; the per-unit rule now runs + # unconditionally. (DECISIONS/MAIN-orchestration-archive.md) problems = [] if entity: _first = self.inverters[0] @@ -4899,8 +4927,14 @@ class Controller: last_grid = object() heartbeat = float(self.o.get("heartbeat_s", 10)) # ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not - # a second scheduler running alongside the arrival trigger. - # THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN. + # a second scheduler: every cycle resets it, so on a healthy meter it + # never elapses and the per-cycle gain is unchanged. It exists because + # a meter that repeats a value would run NO cycles, and the `inputs + # missing -> 0 W` claim lives inside cycle(). NOT a freeze detector. ⚠️ + # THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN: it sits under + # validate.FIRMWARE_INGEST_VETO_S (30 s) and `meter_max_age_s` and + # moves with the firmware's veto; the schema minimum of 5 s is the + # reference meter's interval and deserves a second look. # (DECISIONS/MAIN-orchestration-archive.md) floor = float(self.o.get("cycle_floor_s", 15)) last_cycle = None @@ -4913,19 +4947,20 @@ class Controller: if self.grid != last_grid or overdue: last_grid = self.grid last_cycle = now_mono - # ⚠️ The FULL cycle, not a degraded one. - # THAT GUARANTEE IS TRANSPORT-CONDITIONAL, and saying so - # matters more than the guarantee: with `meter_source: off`, - # read_inputs() takes self.grid straight from hass.number() - # with NO age gate at all, so a Home Assistant entity that has - # gone quiet reads as a current value on that path with or - # without this change. - # (DECISIONS/MAIN-orchestration-archive.md) + # ⚠️ The FULL cycle, not a degraded one: in P1 mode past + # `meter_max_age_s` (30 s) p1.net_w is None ("inputs missing"). + # ⚠️ TRANSPORT-CONDITIONAL: with `meter_source: off`, + # read_inputs() has NO age gate, so a quiet entity reads as + # current; the floor does not make it fresh. The integrator + # advances per CYCLE, so bounding the gap bounds the + # accumulation. (DECISIONS/MAIN-orchestration-archive.md) await self.cycle() - # ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence - # longer than ~30 s as "the controller is gone" and zeroes the - # inverter. Refreshing the SAME value is what proves we are alive. - # ONE DEADLINE PER INVERTER, AND THE REFRESHES GO OUT TOGETHER. + # ⚠️ The heartbeat is not an optimisation: the ESP32 zeroes the + # inverter after ~30 s of silence, and refreshing the SAME value + # proves we are alive. ⚠️ ONE DEADLINE PER INVERTER, refreshed + # concurrently, each bounded by WRITE_BUDGET_S (budget plus the + # largest `heartbeat_s` stays under layer 1's veto: graded); each + # unit refreshes its own `inv.target`, never the site target. # (DECISIONS/MAIN-orchestration-archive.md) await self.send_heartbeats(asyncio.get_running_loop().time(), heartbeat) @@ -4935,27 +4970,30 @@ class Controller: async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None: """Refresh every inverter whose own deadline has come. AC 6 lives here. - IT IS A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` FOR ONE - REASON: the acceptance criterion is "a hung write to A cannot consume - B's layer-1 budget - verified by injecting the hang", and a check - cannot inject a hang into the middle of a `while not self.stopping` - loop that also sleeps, reads inputs and cycles. - (DECISIONS/MAIN-orchestration-archive.md)""" + ⚠️ A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` so a check + can inject a hang: "a hung write to A cannot consume B's layer-1 + budget"; inline, the mutation to a serial `for` loop passed the whole + suite. (DECISIONS/MAIN-orchestration-archive.md)""" due = [inv for inv in self.inverters if loop_now - inv.last_beat >= heartbeat_s] if not due: return - # ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT. - # (DECISIONS/MAIN-orchestration-archive.md) + # ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT: after, a + # hanging write drifts into being re-sent every tick and a raising one + # never advances. (DECISIONS/MAIN-orchestration-archive.md) for inv in due: inv.last_beat = loop_now - # ⚠️ CONCURRENTLY. (DECISIONS/MAIN-orchestration-archive.md) + # ⚠️ CONCURRENTLY: in series, every inverter behind a hung one waits + # out its whole budget, pushing a healthy sibling towards layer 1's ~30 + # s veto. (DECISIONS/MAIN-orchestration-archive.md) await asyncio.gather(*( self.write_setpoint(inv.target, force=True, unit=inv) for inv in due)) def publish(self) -> None: - # ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE. + # ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE: `battery` stays the SUM and + # `soc` the EMPTIEST pack (their `unique_id`s are on live dashboards); + # per-inverter entities are TEL-07's. # (DECISIONS/MAIN-orchestration-archive.md) values = { "setpoint": self.target,