From 3d31b679eb44113c0ae31c51ea4110232a6ee5dc Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 1 Nov 2026 03:33:54 +0100 Subject: [PATCH] MAINT-20 part 2: main.py: keep whole sentences, no truncated warnings; refresh archive Co-Authored-By: Claude --- .../DECISIONS/MAIN-orchestration-archive.md | 14 --- goodwe_controller/app/main.py | 87 ++++++++----------- 2 files changed, 35 insertions(+), 66 deletions(-) diff --git a/goodwe_controller/DECISIONS/MAIN-orchestration-archive.md b/goodwe_controller/DECISIONS/MAIN-orchestration-archive.md index b1bddc5..504c123 100644 --- a/goodwe_controller/DECISIONS/MAIN-orchestration-archive.md +++ b/goodwe_controller/DECISIONS/MAIN-orchestration-archive.md @@ -327,20 +327,6 @@ money-saving discharge only, and grading a pack against it explains a wedged unit away in a band an ordinary install has ten points of. -### L4619 REASON FIRST, FLAG SECOND, and each `set` writes the file - - -REASON FIRST, FLAG SECOND, and each `set` writes the file - -the same atomicity ordering the three trips above use: a crash -between the two must not persist a latch with no explanation -for the operator who has to decide about clearing it. - -⚠️ AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02 -HAS ONE. store.py's rule 2 keeps the flat keys "so a rollback -still finds its latch", and the version being rolled back TO -drives exactly ONE actuator - a roster at which this check is -inert by construction. A flat mirror would hand a downgraded -add-on a latch taken on evidence about a roster it cannot have. - ### L4650 ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 2ae7903..679ac67 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -946,8 +946,7 @@ class Controller: self._handback = None self._hidden = () self.stopping = False - # SAFETY-61. time.monotonic() at the end of the last run_control() - # pass. + # SAFETY-61. time.monotonic() at the end of the last run_control() pass. self.loop_pass_mono = None # helpers; SAFETY-52: a battery that refuses is asked for 0 W @@ -3374,15 +3373,16 @@ class Controller: if inv.delivery_wd.evaluate(_delinquent.get(inv.name), now) \ and not was_short: # REASON FIRST, FLAG SECOND, and each `set` writes the file - - # the same atomicity ordering the three trips above use: a - # crash between the two must not persist a latch with no - # explanation for the operator who has to decide about clearing - # it. - # AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02 HAS - # ONE. store.py's rule 2 keeps the flat keys "so a rollback + # the same atomicity ordering the three trips above use: a crash + # between the two must not persist a latch with no explanation + # for the operator who has to decide about clearing it. + # + # ⚠️ AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02 + # HAS ONE. store.py's rule 2 keeps the flat keys "so a rollback # still finds its latch", and the version being rolled back TO - # drives exactly ONE... - # (DECISIONS/MAIN-orchestration-archive.md) + # drives exactly ONE actuator - a roster at which this check is + # inert by construction. A flat mirror would hand a downgraded + # add-on a latch taken on evidence about a roster it cannot have. inv.store_set("delivery_reason", inv.delivery_wd.reason) inv.store_set("delivery_tripped", True) self._delivery_announced.discard(inv.name) @@ -3609,8 +3609,7 @@ class Controller: charge_w=charge_w, discharge_w=discharge_w, soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct) # SITE, and arbitrage.claim() says so on the object it builds. It - # buys and sells at the grid against a day-ahead price: the price - # is + # buys and sells at the grid against a day-ahead price: the price is # a property of the connection and the energy crosses the meter. arb_claim = arbitrage.claim(self.arb) if arb_claim is not None: @@ -3654,8 +3653,7 @@ class Controller: if peakguard.shaping_suspended( self.peak_guard, self.peak_fc, self.peak_aim_w()): # Edge-logged: level-logging here would write one line per - # cycle for as long as the event lasts and flush the - # 40-line + # cycle for as long as the event lasts and flush the 40-line # action log of the very context that explains it. if not self._maint_shape_suspended: self._maint_shape_suspended = True @@ -3677,10 +3675,8 @@ class Controller: floor = self._charge_headroom(floor, claims) if floor > 0: hi, reason = -floor, "cheap-window charge" - # PER-INVERTER, like the SET above and for the same reason - # - - # the shaping is about the pack being charged, which is - # `inv`. + # PER-INVERTER, like the SET above and for the same reason - + # the shaping is about the pack being charged, which is `inv`. claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason, actuator=self.actuator_of("maintenance", @@ -3747,8 +3743,7 @@ class Controller: reason="maintenance drain", actuator=self.actuator_of("drain"))) self._peak_over_drain(claims, maint_results) - # --- SAFETY-86: while a HOLD gives, the site stays at the peak floor - # -- + # --- SAFETY-86: while a HOLD gives, the site stays at the peak floor -- # So the loop never asks for watts the hold keeps back. hold_hi = self._peak_over_hold(claims, maint_results) if hold_hi is not None: @@ -3781,7 +3776,7 @@ class Controller: # The integrator has to track the same reality, and it is no longer # prev_w, so it needs saying out loud: if the arbiter did not give # the loop what it asked for last cycle, the loop's accumulated - # error... + # error belongs to a command that never happened. # (DECISIONS/MAIN-orchestration-archive.md) moved = self.loop_w is None or self.target != self.loop_w if moved: @@ -3806,8 +3801,7 @@ class Controller: # SAFETY-95: then a drain that ended, last (it reads the start # the others left). self._reseed_drain_end(maint_results, _live96) - # No probe while it is judged afresh (the loop does not hold the - # wire). + # No probe while it is judged afresh (the loop does not hold the wire). probe = False if rejudged else self._refusal_probe() if len(self.inverters) > 1: probe = any(_probing.values()) @@ -3914,8 +3908,7 @@ class Controller: self._loop_decision = None # SAFETY-28. A new resolution is a new tick, and the previous record is - # stale from this line onwards. Incremented BEFORE resolve() so that - # the + # stale from this line onwards. Incremented BEFORE resolve() so that the # record resolve() builds is stamped with the tick it is for. self.alloc_tick += 1 # ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. @@ -4059,8 +4052,7 @@ class Controller: # (DECISIONS/MAIN-orchestration-archive.md) await self.check_meter_frozen() - # --- PRICE-01: no price for now, said once and taken down once - # -------- + # --- PRICE-01: no price for now, said once and taken down once -------- # Not a safety check, so it is one of the cheap ones: an attribute test # and, on a change only, one Home Assistant call. await self.check_price_gap() @@ -4079,10 +4071,8 @@ class Controller: # has touched. (DECISIONS/MAIN-orchestration-archive.md) await self.check_not_delivering() - # --- SAFETY-07's alarm, and it is NOT the engagement - # ------------------ - # Its boundary is unconstrained: it grades `self.peak_guard`, which - # this + # --- SAFETY-07's alarm, and it is NOT the engagement ------------------ + # Its boundary is unconstrained: it grades `self.peak_guard`, which this # method assigned above and nothing since has touched. await self.check_peak_saturated() @@ -4312,9 +4302,6 @@ class Controller: async def check_peak_saturated(self) -> None: """SAFETY-07. Alarm ONLY when the guard has run out of inverter. - AN ENGAGEMENT IS NOT AN ALARM, AND THE DIFFERENCE IS §10.3's DOCTRINE - READ CAREFULLY. "Treat an out-of-range computed setpoint as a bug - signal - clamp it, then alarm" is SAFETY-03's rule and it is... (DECISIONS/MAIN-orchestration-archive.md)""" g = self.peak_guard batt, prev = self.site_batt_w, self._peak_batt_prev @@ -4360,8 +4347,7 @@ class Controller: self.log_event("PEAK_GUARD_SATURATED - " + g.reason, hint=messages.hint("peak_saturated")) # A fixed notification_id, and a DIFFERENT one from every other alarm - # here - check_meter_frozen() gives the reasoning. A saturated peak - # guard + # here - check_meter_frozen() gives the reasoning. A saturated peak guard # is not a frozen meter and is not an out-of-range setpoint. await self.hass.call("persistent_notification", "create", messages.peak_saturated( @@ -4518,8 +4504,7 @@ class Controller: 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 - # per-unit conjunction, so a small inverter B lowers the site rail - # rather + # per-unit conjunction, so a small inverter B lowers the site rail rather # than being graded against inverter A's. fired = self.freeze_wd.evaluate(unchanged, self.target, self.site_max_w, @@ -4646,8 +4631,7 @@ class Controller: while not self.stopping: await self.read_inputs() now_mono = asyncio.get_running_loop().time() - # A cycle is one meter update, exactly as on the reference install - # - + # A cycle is one meter update, exactly as on the reference install - # or the floor elapsing, whichever comes first. overdue = last_cycle is None or (now_mono - last_cycle) >= floor if self.grid != last_grid or overdue: @@ -4657,7 +4641,9 @@ class Controller: # 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... + # 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) await self.cycle() # ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence @@ -4675,7 +4661,9 @@ class Controller: """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... + 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)""" due = [inv for inv in self.inverters if loop_now - inv.last_beat >= heartbeat_s] @@ -4743,8 +4731,7 @@ class Controller: values[unit_state_key(slug, "soc")] = inv.soc values[unit_state_key(slug, "sign_latch")] = ( "tripped" if inv.sign_wd.tripped else "clear") - # OPT-11 P2: each battery's mode and switch; nothing while modes are - # off. + # OPT-11 P2: each battery's mode and switch; nothing while modes are off. values.update(self.mode_values()) self.mqtt.publish(values) @@ -4830,8 +4817,7 @@ class Controller: out.append({"ok": False, "warn": False, "id": "meter_frozen", "text": messages.row_meter_frozen()}) elif getattr(self.p1_source, "unchanged_s", None) is None: - # Not "armed". Every transport but homewizard_local is - # invisible + # Not "armed". Every transport but homewizard_local is invisible # to this check, and saying "armed" there would be a lie the # status page tells on behalf of a guard that cannot run. out.append({"ok": False, "warn": True, "id": "freeze_check", @@ -5110,8 +5096,7 @@ class Controller: "text": messages.row_estop_fitted()}) if not o.get("peak_forecast_entity"): - # ok=False + warn=True renders as a caution, not a pass. A check - # that + # ok=False + warn=True renders as a caution, not a pass. A check that # is both is a check nobody reads. out.append({"ok": False, "warn": True, "id": "peak_forecast", "text": messages.row_no_peak_forecast()}) @@ -5458,8 +5443,7 @@ class Controller: phase=_m.phase)) return {"phase": self.maint.phase} if what == "maint_abort": - # Aborts the pack that is actually cycling - `_maint_button()` - # again + # Aborts the pack that is actually cycling - `_maint_button()` again # - and then zeroes the SITE, which is what it always did. _m = self._maint_button(now) for msg in _m.abort(now, "operator"): @@ -5710,8 +5694,7 @@ async def amain() -> None: 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 + # 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.