diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index d75e61a..63c4101 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -5002,41 +5002,60 @@ class Controller: "soc": self.pack_floor_soc, "phase": self.maint.phase, # ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing - # one. (DECISIONS/MAIN-orchestration-archive.md) + # one: "failsafe" is additive to a sensor already on every + # dashboard, and a P_SAFETY stop that did not reach it would be a + # site at 0 W reporting "running". + # (DECISIONS/MAIN-orchestration-archive.md) "status": ("failsafe" if any(i.sign_wd.tripped for i in self.inverters) or (self.site_sign_wd.tripped and len(self.inverters) > 1) or self.circ_wd.tripped # SAFETY-10: "shadow" is its own value and not a flavour - # of "stopped". + # of "stopped". ⚠️ BELOW `failsafe`: a latch that is up + # is up, and shadow does not clear it. # (DECISIONS/MAIN-orchestration-archive.md) else "shadow" if self.shadow else "running" if self.auto else "stopped"), # SAFETY-31. ⚠️ MEASURED, not the allocation record - see - # `site_circulating_w`. (DECISIONS/MAIN-orchestration-archive.md) + # `site_circulating_w`. `publish()` drops None values, so a missing + # per-unit reading leaves the last figure standing rather than + # publishing a zero that would read as health. + # (DECISIONS/MAIN-orchestration-archive.md) "circulation": (None if self.site_circulating_w is None else round(self.site_circulating_w)), - # TEL-10. (DECISIONS/MAIN-orchestration-archive.md) + # TEL-10. The loop's own three, written from the fields `cycle()` + # recorded and not re-derived (the page and the publisher run on + # their own tasks, and `self.target` has moved on). + # (DECISIONS/MAIN-orchestration-archive.md) "loop_i": round(self.loop_i_w, 1), "loop_lead": round(self.loop_i_lead, 1), "loop_reason": self.loop_reason, } - # TEL-11 ⚠️ THE KEY IS PRESENT WITH THE VALUE `None` WHEN THERE IS NO - # READING, AND ABSENT ENTIRELY WHEN THERE IS NO SOURCE. - # AND THE GATE IS `optional_keys()`, THE SAME EXPRESSION THE - # ANNOUNCEMENT USES. + # TEL-11. ⚠️ THE KEY IS PRESENT WITH `None` WHEN THERE IS NO READING + # AND ABSENT WHEN THERE IS NO SOURCE: two states + # (`mqtt.ABSENT_AS_UNKNOWN` makes the first an `unknown`, a gap in the + # recorder). TEL-12: still ONE number, the SITE total, None unless + # EVERY entity answered. ⚠️ THE GATE IS `optional_keys()`, THE SAME + # EXPRESSION THE ANNOUNCEMENT USES. # (DECISIONS/MAIN-orchestration-archive.md) if "pv" in optional_keys(self.o): values["pv"] = (None if self.pv_w is None else round(self.pv_w, 1)) - # ⚠️ ONLY when P1 ingestion is actually running. - # (DECISIONS/MAIN-orchestration-archive.md) + # ⚠️ ONLY when P1 ingestion is running: the ESP32's stale-input + # watchdog subscribes to sensor.goodwe_p1_sample_age and forces layer-1 + # failsafe at max_age_s, so with meter_source off a published age would + # climb unbounded and pin every existing install at 0 W within 30 s. + # Publishing nothing leaves the entity non-existent (firmware + # has_state()). (DECISIONS/MAIN-orchestration-archive.md) if self.p1_enabled: # Recomputed here, once a second, on purpose - see P1Ingest. values["p1_age"] = round(self.p1.published_age_s, 1) - # TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE. - # THERE IS NO `if len(self.inverters) > 1` HERE, DELIBERATELY. + # TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE: ⚠️ no `if + # len(self.inverters) > 1` here, deliberately: `announced_units()` + # answers [] at N=1, so what is announced and what is published come + # from one expression. ⚠️ The site rows do not move: `battery` stays + # the SUM and `soc` the EMPTIEST pack (live dashboards use those ids). # (DECISIONS/MAIN-orchestration-archive.md) names = announced_units([inv.name for inv in self.inverters]) for inv, slug in zip(self.inverters, unit_slugs(names)): @@ -5076,8 +5095,11 @@ class Controller: for inv in self.inverters)) async def command_site_zero(self, *, last: bool = False) -> None: - """Every inverter to 0 W, and every inverter's TARGET to 0 W with it. - THE SECOND HALF IS NOT BOOKKEEPING, AND IT IS A HAZARD DEV-02 CREATED. + """Every inverter to 0 W, and every inverter's TARGET with it. ⚠️ NOT + BOOKKEEPING (DEV-02): the heartbeat re-emits `inv.target`, so zeroing + only `self.target` lets the next heartbeat put each unit's last share + back on the wire. ⚠️ 0 W IS THE ONE VALUE THAT MAY BE WRITTEN SITE-WIDE + (`write_setpoint()` refuses others: ARCH-01). (DECISIONS/MAIN-orchestration-archive.md)""" self.target = 0.0 for inv in self.inverters: @@ -5088,8 +5110,8 @@ class Controller: def checks(self, when: datetime | None = None) -> list: """The status rows. `when` is the reference time for anything billed by the calendar - today's month when it is None, which is what the live - page means; `status_payload()` calls this with no argument. - IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17). + page means. ⚠️ IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17): a + suite that banks an August quarter went red at the September boundary. (DECISIONS/MAIN-orchestration-archive.md)""" o = self.o out = [] @@ -5143,7 +5165,10 @@ class Controller: "text": messages.row_meter( self.p1.net_w, age, self.p1.parse_errors, unchanged)}) - # SAFETY-18. (DECISIONS/MAIN-orchestration-archive.md) + # SAFETY-18. Reported in both states (a detector nobody can see is + # armed is not trusted when it fires). ok=False/warn=False so the + # banner goes red; it changes NOTHING about control (see + # check_meter_frozen()). (DECISIONS/MAIN-orchestration-archive.md) if self.freeze_wd.active: # The figures are in `freeze_wd.reason`, on the log and the # event list (check_meter_frozen()). @@ -5172,19 +5197,19 @@ class Controller: _rail = rail_w(self.site_max_w, dev_min=self.site_dev_bound("dev_min"), dev_max=self.site_dev_bound("dev_max")) - # And when the device is what set that number, SAY SO - the - # operator configured `max_w` and is owed the reason the guard - # is armed somewhere else. ⚠️ Either bound may be None on its - # own - `hass.limits()` reads `min` and `max` independently and - # returns None for whichever attribute is missing or - # unparseable - so this renders whatever is there rather than - # formatting a None. (DECISIONS/MAIN-orchestration-archive.md) + # And when the device set that number, SAY SO: the operator + # configured `max_w`. ⚠️ Either bound may be None on its own + # (`hass.limits()` reads min and max independently), so render + # what is there. (DECISIONS/MAIN-orchestration-archive.md) out.append({"ok": True, "warn": False, "id": "freeze_check", "text": messages.row_freeze_on( self.freeze_wd.cfg.hold_s, _rail, own_limit=_rail < rail_w(self.site_max_w))}) - # TARIFF-01. (DECISIONS/MAIN-orchestration-archive.md) + # TARIFF-01. The month's billed peak, and a DIFFERENT row when the + # store could not answer: "no peak yet" is normal on the 1st, + # "unreadable" is a broken instrument. + # (DECISIONS/MAIN-orchestration-archive.md) _pk = self.peak.peak(when) if _pk.is_unreadable: out.append({"ok": False, "warn": True, "id": "month_peak", @@ -5206,9 +5231,10 @@ class Controller: self.peak.recorded)}) # SAFETY-07. Three rows, not one, because "idle" and "cannot see" - # are opposite states and a page that spelled them the same way - # would show a green line over a guard that is switched off. - # (DECISIONS/MAIN-orchestration-archive.md) + # are opposite states and a page that spelled them the same would + # show a green line over a guard that is switched off. `None` is a + # fourth state on purpose: before the first cycle nothing has been + # concluded. (DECISIONS/MAIN-orchestration-archive.md) _pg = self.peak_guard if _pg is None: out.append({"ok": True, "warn": True, "id": "peak_guard", @@ -5220,9 +5246,12 @@ class Controller: elif _pg.floor_limited and ( not ((_pg.floor_w or 0.0) > 0.0 or _pg.within_aim) or self._peak_floor_limited): - # SAFETY-24. NOT ok, like saturation: in both states the money - # is going to be spent and a human is the only actuator left. - # SAFETY-25. + # SAFETY-24. NOT ok, like saturation (the money is being spent + # and a human is the only actuator left); a separate row + # because the remedy is opposite. ⚠️ SAFETY-25: the scope + # sentence (the floor binds the guard, not the loop) is part of + # the row, which carries its own short `banner`; shortening the + # ROW would weaken AC 3. # (DECISIONS/MAIN-orchestration-archive.md) out.append({"ok": False, "warn": False, "id": "peak_guard", "runs": True, @@ -5262,7 +5291,10 @@ class Controller: "text": _pg.explain()}) else: # ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the - # point. (DECISIONS/MAIN-orchestration-archive.md) + # point: SAFETY-07 is NOT COVERED on the legacy single-entity path + # (no telegrams), and a row that rendered only where the guard runs + # would leave the unprotected install showing nothing. + # (DECISIONS/MAIN-orchestration-archive.md) out.append({"ok": False, "warn": True, "id": "peak_guard", "text": messages.row_peak_guard_off()}) @@ -5288,9 +5320,11 @@ class Controller: out.append({"ok": False, "warn": False, "id": "sensor", "text": messages.row_sensor_unset(label, unit)}) elif value is None: - # ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED - # ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. - # AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER. + # ⚠️ SAFETY-56. THIS ROW ONCE ASSERTED A FALSE FACT ABOUT A + # NAMED ENTITY: `Freshness.reading()` returns None BY DESIGN + # while the gate withholds a numeric reading. ⚠️ FIXED BY + # SAYING LESS: the gate has its own surface (`stale_note()` in + # `liveness_notes()`). # (DECISIONS/MAIN-orchestration-archive.md) out.append({"ok": False, "warn": False, "id": "sensor", "text": messages.row_sensor_unusable(label, unit)}) @@ -5311,7 +5345,11 @@ class Controller: "text": messages.row_control(inv.dev_min, inv.dev_max, unit)}) - # DEV-02. (DECISIONS/MAIN-orchestration-archive.md) + # DEV-02. Whether this controller can still put a number on each + # inverter: a dropped link that shows nothing on the page reads as a + # healthy site. Red only for a unit no longer live; the rest amber. The + # same notes, in the same order, as `liveness_notes()`. + # (DECISIONS/MAIN-orchestration-archive.md) _now = datetime.now(timezone.utc) for inv in self.inverters: for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]), @@ -5324,7 +5362,11 @@ class Controller: "text": messages.row_readings_late( self.answer.shown_s, self._late_zero)}) - # SAFETY-03. (DECISIONS/MAIN-orchestration-archive.md) + # SAFETY-03. Red while an over-rating clamp is engaged (amber for the + # integrator bound, SAFETY-40), and the engagement COUNT stays shown + # after it clears. Each count is shown under ITS alarm's name, never + # the two summed (UX-07/UX-08). + # (DECISIONS/MAIN-orchestration-archive.md) _cw = self.clamp_wd if _cw.active and _cw.bug: out.append({"ok": False, "warn": False, "id": "power_too_high", @@ -5359,10 +5401,18 @@ class Controller: None if len(self.inverters) == 1 else inv.name)}) - # SAFETY-02. (DECISIONS/MAIN-orchestration-archive.md) + # SAFETY-02. Reported in both states. ⚠️ ONE ROW PER INVERTER: a trip + # on B leaves A commanding. Since SAFETY-30 the row says what the unit + # was compared against (`sign_wd.against`, the string the trip sentence + # uses); at N=1 the wording is unchanged. + # (DECISIONS/MAIN-orchestration-archive.md) for inv in self.inverters: unit = None if len(self.inverters) == 1 else inv.name - # ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. + # ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE: at N=1 the + # two sentences below are the ones that have always been on the + # page, word for word (AC 8); at N>=2 the noun is load-bearing, so + # the row does not send an operator to P1 for a fault visible only + # on one inverter's own power. # (DECISIONS/MAIN-orchestration-archive.md) if inv.sign_wd.tripped: out.append({"ok": False, "warn": False, "id": "direction", @@ -5373,8 +5423,9 @@ class Controller: "text": messages.row_direction_check( held, inv.sign_wd.cfg.hold_s, unit)}) - # SAFETY-30: the SITE row ⚠️ ONLY AT N>=2, and the absence at N=1 is - # the honest report rather than a missing row. + # SAFETY-30: the SITE row. ⚠️ ONLY AT N>=2, and the absence at N=1 is + # the honest report: with one inverter the site comparison and the row + # above are the same sentence about the same actuator. # (DECISIONS/MAIN-orchestration-archive.md) if len(self.inverters) > 1: if self.site_sign_wd.tripped: @@ -5386,8 +5437,10 @@ class Controller: "text": messages.row_site_direction_check( _sheld, self.site_sign_wd.cfg.hold_s)}) - # SAFETY-31 ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and - # the row says so. (DECISIONS/MAIN-orchestration-archive.md) + # SAFETY-31. ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and + # the row says so: there is one circulating figure for the site, and + # naming an inverter would invite clearing "the other one". + # (DECISIONS/MAIN-orchestration-archive.md) _circ = self.site_circulating_w if self.circ_wd.tripped: out.append({"ok": False, "warn": False, "id": "circulating", @@ -5405,9 +5458,12 @@ class Controller: "text": messages.row_circulating_check( _circ, held, self.circ_wd.cfg.hold_s)}) - # SAFETY-42 ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE - # ONE PLACE THE "reports, does not act" DECISION IS VISIBLE TO AN - # OPERATOR. (DECISIONS/MAIN-orchestration-archive.md) + # SAFETY-42. ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE + # ONE PLACE THE "reports, does not act" DECISION IS VISIBLE: every + # other latched row means the site is at 0 W, this one means it is + # STILL RUNNING and quietly short by one unit's share; `ok: False` with + # `warn: True` is this list's caution. + # (DECISIONS/MAIN-orchestration-archive.md) if len(self.inverters) > 1: for inv in self.inverters: if inv.delivery_wd.tripped: