From 40ef27c367f9d736164f597d9480db5eb0dd5b93 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 8 Nov 2026 18:06:01 +0100 Subject: [PATCH] MAINT-20 part 2 r2: app/main.py comments, lines 5501-6000 (status_payload .. maint_site_tz) Co-Authored-By: Claude --- goodwe_controller/app/main.py | 148 ++++++++++++++++++++-------------- 1 file changed, 87 insertions(+), 61 deletions(-) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 63c4101..85c533c 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -5508,11 +5508,11 @@ class Controller: out.append({"ok": False, "warn": True, "id": "solar_forecast", "text": messages.row_forecast_gap( day, gone, len(ents))}) - # TEL-11 ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY, AND THAT IS A - # DELIBERATE DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. - # AND WHEN IT IS SET, A FAILED READ IS `ok=False, warn=True` - a - # CAUTION, never `bad`. - # (DECISIONS/MAIN-orchestration-archive.md) + # TEL-11. ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY (pure + # instrumentation: no warning spam). ⚠️ WHEN IT IS SET, A FAILED READ + # IS `ok=False, warn=True`, a CAUTION never `bad`: telemetry must not + # put a battery site into NOT READY. UX-08 replaced 0.11.0's sentences + # on purpose. (DECISIONS/MAIN-orchestration-archive.md) roster = self.pv_roster if roster: # UX-08: sources are numbered in the order of the settings list, @@ -5566,31 +5566,35 @@ class Controller: runs = [c for c in bad if c.get("runs")] bad = [c for c in bad if not c.get("runs")] running_alarm = waiting = False - # SAFETY-10, AND IT IS FIRST IN THE CASCADE "The battery is NOT being - # controlled" is the one sentence on this page that may never be - # displaced by another condition. - # AND IT CARRIES A STANDING LATCH RATHER THAN HIDING ONE. - # (DECISIONS/MAIN-orchestration-archive.md) + # SAFETY-10, AND IT IS FIRST IN THE CASCADE: "The battery is NOT being + # controlled" may never be displaced by another condition. ⚠️ AND IT + # CARRIES A STANDING LATCH RATHER THAN HIDING ONE: `shadow.banner()` + # names the latch and goes red for it, so nothing the cascade would + # have said is lost. (DECISIONS/MAIN-orchestration-archive.md) _shadow = shadow.banner(self.o, control_started=self.auto, latched=self.shadow_latches()) if _shadow is not None: level, banner = _shadow elif self.circ_wd.tripped: - # ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are - # latched P_SAFETY stops, so the site is at 0 W either way and the - # banner can only carry one sentence. + # ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision: both are + # latched P_SAFETY stops and the banner carries one sentence; this + # one goes first because it is the fault the operator has NO OTHER + # WAY of learning about (a sign disagreement is visible at the + # meter, circulating power nowhere). # (DECISIONS/MAIN-orchestration-archive.md) level, banner = "bad", messages.banner_circulating() elif self.site_sign_wd.tripped and len(self.inverters) > 1: # SAFETY-30's site half, ABOVE the per-unit rows below and below - # circulating power above, and the ordering is the same argument - # both times: the banner carries one sentence, so it carries the - # one that says the widest true thing. + # circulating power above: the banner carries one sentence, so the + # one that says the widest true thing. A site sign trip parks EVERY + # inverter and the fault is in what the site was asking for; naming + # a unit would send an operator to swap the wrong box. # (DECISIONS/MAIN-orchestration-archive.md) level, banner = "bad", messages.banner_site_direction() elif any(i.sign_wd.tripped for i in self.inverters): # Outranks "Not ready": nothing is missing, something went wrong. - # (DECISIONS/MAIN-orchestration-archive.md) + # At N>=2 the unit was graded against its own power, so the banner + # says so (see checks()). (DECISIONS/MAIN-orchestration-archive.md) level, banner = "bad", messages.banner_direction( len(self.inverters) > 1) elif bad: @@ -5609,14 +5613,16 @@ class Controller: runs[0]["text"]) elif any(i.delivery_wd.tripped for i in self.inverters): # ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE, - # WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE. - # BUT IT MUST DISPLACE "Running — holding grid at zero", AND THAT - # IS NOT COSMETIC. - # (DECISIONS/MAIN-orchestration-archive.md) + # WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE + # (the site is STILL RUNNING, one unit short). ⚠️ BUT IT MUST + # DISPLACE "Running - holding grid at zero": that sentence is a + # claim the site cannot make while a unit is absent. It sits BELOW + # "Stopped". (DECISIONS/MAIN-orchestration-archive.md) level, banner = "warn", messages.banner_not_delivering( [i.name for i in self.inverters if i.delivery_wd.tripped]) elif self.maint.phase != IDLE: - # ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. + # ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE: at N=1 the + # string is what it has always been, character for character. # (DECISIONS/MAIN-orchestration-archive.md) level, banner = "warn", messages.banner_maintenance( self.maint.phase, @@ -5627,9 +5633,9 @@ class Controller: def w(v): return "—" if v is None else f"{v:,.0f} W".replace(",", " ") - # ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. One date on a - # site with two schedules has to be the one that comes first, or the - # row says "2026-10-01" while a pack recalibrates tomorrow. + # ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. `None` means + # "never run, due now" and has to win over any date, so it is tested + # for rather than sorted with. At N=1 this is that pack's own date. # (DECISIONS/MAIN-orchestration-archive.md) _now = datetime.now(timezone.utc) _dues = [m.next_due(_now) for m in self.maints] @@ -5639,15 +5645,14 @@ class Controller: ("Grid", w(self.grid)), ("Battery", w(self.site_batt_w)), # SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is - # not derivable from. "Grid 0 W, Battery 0 W, Circulating 6000 W" - # is the whole card in three rows: both of the numbers an operator - # normally reads say the site is idle, and the third says 6 kW is - # going round between the packs. + # not derivable from ("Grid 0 W, Battery 0 W, Circulating 6000 W" + # is the whole card in three rows). Only shown at N>=2: at one + # inverter it is identically zero. # (DECISIONS/MAIN-orchestration-archive.md) *(() if len(self.inverters) <= 1 else ((("Circulating", w(self.site_circulating_w)),))), # TEL-11, and ONLY when a source is configured - see `checks()`. - # `w(None)` renders "—", which is how this row says "no reading" + # `w(None)` renders "-", which is how this row says "no reading" # rather than "0 W". (DECISIONS/MAIN-orchestration-archive.md) *(() if not self.pv_roster else ((("Solar production", w(self.pv_w)),))), @@ -5688,16 +5693,18 @@ class Controller: # PRICE-01: only while no price covers now. *((("Prices", messages.row_no_prices(*self._gap_args())),) if self.price_gap else ()), - # FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference - # between a 48-hour plan and a 12-hour one on a weekday morning. + # FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, the difference between a + # 48-hour plan and a 12-hour one on a weekday morning. Its own row: + # "Tariff" says what the plan IS, this says what it could still be + # filled from if the feed is late again. # (DECISIONS/MAIN-orchestration-archive.md) ("Price history", self.price_history.words), - # OPT-04. The strategy's own sentence, not a re-derivation of it - - # every refusal it can make already carries a reason a human can - # act on, and "Arbitrage: no" would throw all of them away. - # TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE UNDER A COMMENT - # THAT WAS FALSE, AND TARIFF-11 IS THAT CORRECTION. - # (DECISIONS/MAIN-orchestration-archive.md) + # OPT-04. The strategy's own sentence, not a re-derivation: every + # refusal it can make carries a reason a human can act on. ⚠️ + # TARIFF-11: `self.arb is None` is NOT "trading is off" (cycle() + # returns at the input hold first); `arbitrage_row()` reads the + # option, and `cycle_at`/`arb_at` make "no decision this cycle" an + # observation. (DECISIONS/MAIN-orchestration-archive.md) ("Arbitrage", arbitrage_row( on=arbitrage.enabled(self.o) or self.modes_on, decision=self.arb, @@ -5705,15 +5712,19 @@ class Controller: now=datetime.now(timezone.utc), why=self.why_words)), ] held = level == "bad" and not running_alarm - # UX-46. (DECISIONS/MAIN-orchestration-archive.md) + # UX-46. The page's language is chosen once, here, for the Home + # sentences, checks, rows that carry one and the banner; a plain str + # (no Dutch twin) stays as it is. + # (DECISIONS/MAIN-orchestration-archive.md) checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks] rows = [(k, messages.pick(v, lang)) for k, v in rows] banner = messages.pick(banner, lang) out = { "banner": banner, "level": level, "rows": rows, "checks": checks, "hint": self.hint, - # The action log itself, newest first. - # (DECISIONS/MAIN-orchestration-archive.md) + # The action log itself, newest first: not shown on the page (UX-08 + # 2d) but readable in the `status` JSON, since several events are + # written nowhere else. (DECISIONS/MAIN-orchestration-archive.md) "events": list(self.events), } # UX-13 P1: the Home screen's structured half. Read-only, and it may @@ -5759,8 +5770,11 @@ class Controller: hint=messages.hint("control_started" if self.auto else "control_stopped")) if self.auto: - # ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose. - # IT CLEARS EVERY INVERTER'S LATCH, AND SAYS SO PER INVERTER. + # ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose: + # it clears on a deliberate human start, not on the condition + # going away (the condition ALWAYS goes away, because the trip + # commands 0 W). ⚠️ IT CLEARS EVERY INVERTER'S LATCH, AND SAYS + # SO PER INVERTER, each under its OWN key. # (DECISIONS/MAIN-orchestration-archive.md) for inv in self.inverters: if not inv.sign_wd.tripped: @@ -5773,15 +5787,18 @@ class Controller: hint=messages.hint("stop_cleared", unit=inv.name)) # ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, - # UNCONDITIONALLY. (DECISIONS/MAIN-orchestration-archive.md) + # UNCONDITIONALLY: this is what makes `flat True + namespaced + # False` MEAN something, and why `_grade_store_migration()` can + # run on every start. Left behind, a flat key is a fossil that + # re-arms the failsafe on every restart. + # (DECISIONS/MAIN-orchestration-archive.md) self.store.set("sign_tripped", False) self.store.set("sign_reason", "") - # SAFETY-30's SITE latch, cleared by the SAME press and for the - # same reason the per-unit ones are: the condition always goes - # away on its own, because the trip commands 0 W everywhere and - # a site commanding zero cannot oppose anything. - # UNCONDITIONAL, LIKE THE FLAT PAIR ABOVE, AND NOT GUARDED ON - # THE ROSTER. + # SAFETY-30's SITE latch, cleared by the SAME press for the + # same reason (the trip commands 0 W, so the condition goes + # away on its own). ⚠️ UNCONDITIONAL, NOT GUARDED ON THE + # ROSTER: an install reconfigured back to one inverter would + # otherwise keep `site_sign_tripped: true` for ever. # (DECISIONS/MAIN-orchestration-archive.md) if self.site_sign_wd.tripped: self.log_event("site sign-disagreement failsafe cleared " @@ -5790,12 +5807,10 @@ class Controller: self.site_sign_wd.reset() self.store.set("site_sign_tripped", False) self.store.set("site_sign_reason", "") - # SAFETY-31, cleared by the SAME press and for the same reason: - # the condition always goes away on its own, because the trip - # commands 0 W everywhere and two inverters at zero cannot - # oppose. - # AND `_circ_announced` GOES WITH IT. - # (DECISIONS/MAIN-orchestration-archive.md) + # SAFETY-31, cleared by the SAME press and for the same reason. + # ⚠️ AND `_circ_announced` GOES WITH IT: left True, the NEXT + # trip in this process would latch, stop the site and raise no + # notification. (DECISIONS/MAIN-orchestration-archive.md) if self.circ_wd.tripped: self.circ_wd.reset() self.store.set("circ_tripped", False) @@ -5803,9 +5818,12 @@ class Controller: self.log_event("circulating-power trip cleared by operator", hint=messages.hint("site_stop_cleared")) self._circ_announced = False - # SAFETY-42, cleared by the SAME press, and for a reason that - # is NOT the one above. - # (DECISIONS/MAIN-orchestration-archive.md) + # SAFETY-42, cleared by the SAME press, for a reason that is + # NOT the one above: this latch commands nothing, so its + # evidence still stands and it could clear itself. It does not: + # a unit that stopped delivering and resumed is an INTERMITTENT + # fault. ⚠️ AND `_delivery_announced` GOES WITH IT, like + # `_circ_announced`. (DECISIONS/MAIN-orchestration-archive.md) for inv in self.inverters: if inv.delivery_wd.tripped: self.log_event(f"not-delivering report on {inv.name} " @@ -5829,7 +5847,11 @@ class Controller: return await self._maint_unit_action(what, unit, now) if what == "maint_start": # ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS - # ONE LABEL. (DECISIONS/MAIN-orchestration-archive.md) + # ONE LABEL: `_maint_button()` picks the pack that holds the + # maintenance slot, else the one longest without a cycle; at N=1 + # both are `maints[0]`. This is the bare request; a named battery + # is `_maint_unit_action()` (MAINT-11). + # (DECISIONS/MAIN-orchestration-archive.md) _m = self._maint_button(now) for msg in _m.force_start(now): self.log_event(msg, hint=messages.hint("maintenance", @@ -5874,7 +5896,10 @@ class ControlTaskDied(RuntimeError): def mode_announcement(opts, store) -> tuple: - """OPT-11 P2: `(modes, retract)` for the MQTT publisher at start-up. + """OPT-11 P2: `(modes, retract)` for the MQTT publisher at start-up. The + config topics announced are kept in the store, so turning modes off removes + exactly those (`mqtt.mode_configs()`); an install that never turned modes + on gets nothing, and its store is not written. (DECISIONS/MAIN-orchestration-archive.md)""" modes = packmode.MODES if packmode.enabled(opts) else () stored = store.data.get(MODE_CONFIGS_KEY) @@ -5907,6 +5932,7 @@ DOCS_LINK_TIMEOUT_S = 3 async def docs_link_facts(session, token: str) -> dict: """`{slug, ha_version}` from the Supervisor, each None when unreadable. + Asked together with a 3 s budget: a link may never hold up control start. (DECISIONS/MAIN-orchestration-archive.md)""" async def ask(url, field): """`(value, why)`: the field, or None and a short reason (UX-09)."""