From 66952beb53bc8a9fba0ff096d8831233f6615060 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 9 Nov 2026 00:30:00 +0100 Subject: [PATCH] MAINT-21: app/ctl_status.py - Controller methods that build the status and MQTT output Moves the methods named in the card and the constants only they read out of app/main.py unchanged. StatusMixin: checks, status_payload, forecast_payload, publish, mqtt_command, handle_action, why_words. tools/maint21_same_ast.py: OK. Co-Authored-By: Claude --- goodwe_controller/app/ctl_status.py | 927 ++++++++++++++++++++++++++++ goodwe_controller/app/main.py | 902 +-------------------------- 2 files changed, 930 insertions(+), 899 deletions(-) create mode 100644 goodwe_controller/app/ctl_status.py diff --git a/goodwe_controller/app/ctl_status.py b/goodwe_controller/app/ctl_status.py new file mode 100644 index 0000000..74ad3f3 --- /dev/null +++ b/goodwe_controller/app/ctl_status.py @@ -0,0 +1,927 @@ +"""Controller methods that build the status page, rows and MQTT output. + +Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md.""" + +import logging +import time +from datetime import datetime, timezone + +from . import arbitrage +from . import packmode +from .clamp import is_commandable +from . import dayview, homeview +from . import messages +from .freeze import rail_unreachable, rail_w +from .hass import OUTAGE +from .maintenance import IDLE +from .mqtt import ( + announced_units, optional_keys, parse_command, unit_slugs, unit_state_key, +) +from . import peakguard +from . import pv as pv_read +from . import shadow +from .price import brussels +from .validate import FIRMWARE_INGEST_VETO_S +from .rows import arbitrage_row + +_LOG = logging.getLogger("goodwe") + + +class StatusMixin: + async def mqtt_command(self, topic, payload, retained) -> None: + """OPT-11 P2: one command from a battery's HA switch or mode list, + through handle_action(). A retained command is never applied (the store + already holds the last choice). Never raises: MQTT must not reach + control. (DECISIONS/MAIN-orchestration-archive.md)""" + try: + slug, action, value, why = parse_command( + topic, payload, retained, packmode.MODES) + if why is None: + names = [inv.name for inv in self.inverters] + unit = dict(zip(unit_slugs(names), names)).get(slug) + result = await self.handle_action(action, unit=unit, + value=value) + if not (isinstance(result, dict) and "error" in result): + self.mqtt.publish(self.mode_values()) + return + why = result["error"] + else: + why = messages.pack_refused(why) + # Printable only: a topic is sender-chosen, so no log injection. + topic_s = "".join(c if c.isprintable() else "?" + for c in str(topic)[:128]) + _LOG.debug("Home Assistant command %s %s refused: %s", topic_s, + repr(payload)[:48], why) + if (topic_s, why) in self._mqtt_refused \ + or len(self._mqtt_refused) >= 32: + # Not logged again, but counted: 1, 10, 100... stay visible. + self._mqtt_refused_more += 1 + more = self._mqtt_refused_more + if str(more).rstrip("0") == "1": + _LOG.warning("Home Assistant commands still ignored: %d " + "more so far.", more) + return + self._mqtt_refused.add((topic_s, why)) + _LOG.warning("Home Assistant command on %s ignored: %s", topic_s, + why) + except Exception: # noqa: BLE001 + _LOG.exception("Home Assistant command could not be handled") + + @property + def why_words(self) -> str: + """The page's "Why" line. While control waits for a missing reading + the age is counted when the page asks (UX-10), not frozen at the age + the last cycle saw.""" + if self._why_waiting_since is not None: + return messages.why_waiting(max(0.0, ( + datetime.now(timezone.utc) + - self._why_waiting_since).total_seconds())) + return self._why_words + + @why_words.setter + def why_words(self, text: str) -> None: + self._why_words = text + self._why_waiting_since = None + + def publish(self) -> None: + # ⚠️ 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, + "grid": self.grid, + "battery": self.site_batt_w, + "soc": self.pack_floor_soc, + "phase": self.maint.phase, + # ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing + # 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". ⚠️ 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`. `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. 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 `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 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: ⚠️ 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)): + values[unit_state_key(slug, "setpoint")] = inv.target + values[unit_state_key(slug, "battery")] = inv.batt + 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. + values.update(self.mode_values()) + self.mqtt.publish(values) + + # -- UI ------------------------------------------------------------------ + 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. ⚠️ 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 = [] + # SAFETY-61. First, so a stalled loop is the headline: the heartbeat is + # sent from this loop, so past layer 1's veto the inverter is at 0 W. + if self.loop_pass_mono is None: + out.append({"ok": True, "warn": False, "id": "controller", + "text": messages.row_controller_starting()}) + else: + _pass_age = time.monotonic() - self.loop_pass_mono + if _pass_age > FIRMWARE_INGEST_VETO_S: + out.append({"ok": False, "warn": False, "id": "controller", + "text": messages.row_controller_stalled( + _pass_age, FIRMWARE_INGEST_VETO_S)}) + else: + out.append({"ok": True, "warn": False, "id": "controller", + "text": messages.row_controller_ran(_pass_age)}) + # SAFETY-112. The headline unless control has stalled: while the + # Supervisor gives 502 (Home Assistant not yet RUNNING) no reading or + # write gets through, and the rows below that need it follow from it. + if OUTAGE.waiting(): + out.insert(1 if out[0]["ok"] is False else 0, + {"ok": False, "warn": False, "id": "home_assistant", + "banner": messages.banner_ha_waiting(), + "text": messages.row_ha_waiting()}) + if self.p1_enabled: + age = self.p1.published_age_s + if (self.p1.stale and self.p1.age_s() is None + and age <= self.p1.max_age_s): + # UX-10: no reading yet since the start, still inside the + # limit. Amber, not red: it is not a fault yet, but control + # waits for it. Red at 0 s contradicted the limit the row + # prints. (DECISIONS/MAIN-orchestration-archive.md) + out.append({"ok": False, "warn": True, "id": "meter", + "text": messages.row_meter_waiting( + self.p1.max_age_s)}) + elif self.p1.stale: + # The last error is on the add-on log's WARNING line (p1.py). + out.append({"ok": False, "warn": False, "id": "meter", + "text": messages.row_meter_stale( + age, self.p1.max_age_s)}) + else: + # ⚠️ unchanged_s is REPORTED, never thresholded and never + # folded into the age - see HomeWizardLocalSource.unchanged_s + # for why (at the converged -10 W this controller aims for, a 1 + # Wh register needs ~6 minutes to move, so any limit + # false-trips at the exact operating point we target). + # (DECISIONS/MAIN-orchestration-archive.md) + unchanged = getattr(self.p1_source, "unchanged_s", None) + out.append({"ok": True, "warn": False, "id": "meter", + "text": messages.row_meter( + self.p1.net_w, age, self.p1.parse_errors, + unchanged)}) + # 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()). + 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 + # 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", + "text": messages.row_freeze_off("source")}) + elif rail_unreachable(self.site_max_w, + dev_min=self.site_dev_bound("dev_min"), + dev_max=self.site_dev_bound("dev_max")): + # SAFETY-20: the same row as the transport case above (the + # check cannot be evaluated here); only the missing term + # differs. (DECISIONS/MAIN-orchestration-archive.md) + _no_rating = (not self.site_max_w + or not is_commandable(self.site_max_w)) + out.append({"ok": False, "warn": True, "id": "freeze_check", + "text": messages.row_freeze_off( + "rating" if _no_rating else "range")}) + else: + # ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE + # USES, device range included. + # (DECISIONS/MAIN-orchestration-archive.md) + _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 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. 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", + "text": messages.row_month_peak_unreadable()}) + else: + # §9.1's "warning entity at 80 %", as a status row rather than + # a second entity - see the constructor on not doubling up. + _frac = self.peak.warning_frac(when) or 0.0 + _ok = _frac < 0.8 + out.append({"ok": _ok, "warn": not _ok, "id": "month_peak", + "text": (messages.row_month_peak_none() + if not _pk.is_observed else + messages.row_month_peak( + _pk.peak_w, + brussels(_pk.quarter_start), + _frac))}) + out.append({"ok": True, "warn": False, "id": "periods_measured", + "text": messages.row_periods_measured( + 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 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", + "text": messages.row_peak_guard_waiting()}) + elif _pg.saturated and not _pg.within_aim: + out.append({"ok": False, "warn": False, "id": "peak_guard", + "runs": True, + "text": messages.row_peak_saturated(_pg.ceiling_w)}) + 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 (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, + "banner": messages.banner_peak_reserve(), + "text": messages.row_peak_reserve( + _pg.ceiling_w, soc_unread=_pg.soc is None, + floor_unknown=(_pg.soc is not None + and _pg.soc_floor is None), + packs=(_pg.held if len(self.inverters) > 1 + else ()), + others=(_pg.floor_w or 0.0) > 0.0)}) + elif _pg.blind: + out.append({"ok": False, "warn": True, "id": "peak_guard", + "text": messages.row_peak_guard_blind( + peakguard.blind_kind(_pg.blind))}) + elif self.peak_unmeasured_s > 0.0: + # SAFETY-90: the start of this quarter came before the first + # reading, and the guard counts it as high use. + out.append({"ok": False, "warn": True, "id": "peak_guard", + "text": messages.row_peak_guard_unmeasured()}) + elif _pg.engaged: + out.append({"ok": False, "warn": True, "id": "peak_guard", + "text": messages.row_peak_guard_on( + _pg.floor_w or 0.0, _pg.ceiling_w, + shadow=self.shadow)}) + elif _pg.projected_w is not None: + # SAFETY-125: "under" only when it is; over the limit at this + # rate, a grid charge is cut back to the quarter's room. + out.append({"ok": True, "warn": False, "id": "peak_guard", + "text": messages.row_peak_guard_idle( + _pg.projected_w, _pg.ceiling_w, + month_peak=(_pg.ceiling_from + == peakguard.CEILING_MONTH_PEAK), + cut=self.room_charge and not self.shadow)}) + else: + out.append({"ok": True, "warn": False, "id": "peak_guard", + "text": _pg.explain()}) + else: + # ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the + # 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()}) + + # ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row + # on a site with two packs is a page that shows one pack and calls it + # the site. (DECISIONS/MAIN-orchestration-archive.md) + rows = [] + for inv in self.inverters: + unit = None if len(self.inverters) == 1 else inv.name + rows.append((messages.LABEL_SOC, unit, inv.soc, + inv.driver.soc_source())) + rows.append((messages.LABEL_BATTERY, unit, inv.batt, + inv.driver.batt_source())) + if not self.p1_enabled: + # In P1 mode the check above replaces this one; leaving both in + # would report "no entity configured" for a meter_entity that is + # correctly unused, i.e. a permanent false NOT READY. + rows.insert(0, (messages.LABEL_GRID, None, self.grid, + o.get("meter_entity"))) + # UX-08: the rows name the setting, never the entity id (STYLE.md). + for label, unit, value, entity in rows: + if not entity: + out.append({"ok": False, "warn": False, "id": "sensor", + "text": messages.row_sensor_unset(label, unit)}) + elif value is None: + # ⚠️ 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)}) + else: + out.append({"ok": True, "warn": False, "id": "sensor", + "text": messages.row_sensor(label, value, unit)}) + + for inv in self.inverters: + unit = None if len(self.inverters) == 1 else inv.name + if not inv.driver.configured(): + out.append({"ok": False, "warn": False, "id": "control", + "text": messages.row_control_unset(unit)}) + elif inv.dev_max is None: + out.append({"ok": False, "warn": False, "id": "control", + "text": messages.row_control_missing(unit)}) + else: + out.append({"ok": True, "warn": False, "id": "control", + "text": messages.row_control(inv.dev_min, + inv.dev_max, unit)}) + + # 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]), + (inv.stale_note(), False), + (inv.refusal_note(), False)): + if note: + out.append({"ok": False, "warn": not red, "text": note}) + if self.answer.late_seen: # SAFETY-128 + out.append({"ok": False, "warn": True, "id": "late", + "text": messages.row_readings_late( + self.answer.shown_s, self._late_zero)}) + + # 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", + "banner": messages.banner_power_too_high(), + "text": messages.row_power_too_high( + _cw.facts, _cw.over_rating, shadow=self.shadow)}) + elif _cw.active: + # SAFETY-40: the integrator bound alone is amber, not red. + out.append({"ok": False, "warn": True, "id": "not_keeping_up", + "text": messages.row_not_keeping_up( + _cw.bound_episodes)}) + # No over-rating in THIS episode (`bug` would be set), so any count + # is from an earlier one: keep that software-fault evidence shown. + if _cw.over_rating: + out.append({"ok": False, "warn": True, "id": "power_too_high", + "text": messages.row_before( + messages.T_OUTPUT_CLAMP, _cw.over_rating)}) + else: + if _cw.over_rating: + out.append({"ok": False, "warn": True, "id": "power_too_high", + "text": messages.row_before( + messages.T_OUTPUT_CLAMP, _cw.over_rating)}) + if _cw.bound_episodes: + out.append({"ok": False, "warn": True, "id": "not_keeping_up", + "text": messages.row_before( + messages.T_LOOP_BOUND, _cw.bound_episodes)}) + + for inv in self.inverters: + if inv.last_write_ok is False: + out.append({"ok": False, "warn": False, "id": "write", + "text": messages.row_write_refused( + None if len(self.inverters) == 1 + else inv.name)}) + + # 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: 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", + "text": messages.row_direction_trip(unit)}) + else: + held = inv.sign_wd.held_s(datetime.now(timezone.utc)) + out.append({"ok": True, "warn": False, "id": "direction", + "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: 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: + out.append({"ok": False, "warn": False, "id": "site_direction", + "text": messages.row_site_direction_trip()}) + else: + _sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc)) + out.append({"ok": True, "warn": False, "id": "site_direction", + "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: 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", + "text": messages.row_circulating_trip()}) + elif len(self.inverters) <= 1: + # AC 5. At the shipped roster the quantity is identically zero for + # every value of every reading, so "armed" would be a claim to be + # watching something that cannot happen. Say what is true instead. + out.append({"ok": True, "warn": False, "id": "circulating", + "text": messages.row_one_inverter( + messages.CHECK_CIRCULATING)}) + else: + held = self.circ_wd.held_s(datetime.now(timezone.utc)) + out.append({"ok": True, "warn": False, "id": "circulating", + "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: 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: + out.append({"ok": False, "warn": True, "id": "delivery", + "text": messages.row_not_delivering(inv.name)}) + else: + _dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc)) + out.append({"ok": True, "warn": False, "id": "delivery", + "text": messages.row_delivery_check( + inv.name, _dheld, + inv.delivery_wd.cfg.hold_s)}) + else: + # AC 5: at one inverter this check has nothing to find, so it is + # not called "armed" (as `circulating power: n/a` above). + # (DECISIONS/MAIN-orchestration-archive.md) + out.append({"ok": True, "warn": False, "id": "delivery", + "text": messages.row_one_inverter("Delivery check")}) + + if not o.get("estop_fitted", False): + out.append({"ok": False, "warn": True, "id": "estop", + "text": messages.row_no_estop()}) + else: + out.append({"ok": True, "warn": False, "id": "estop", + "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 + # is both is a check nobody reads. + out.append({"ok": False, "warn": True, "id": "peak_forecast", + "text": messages.row_no_peak_forecast()}) + elif getattr(self, "_peak_fc_fault", None) is not None: + out.append({"ok": False, "warn": True, "id": "peak_forecast", + "text": messages.row_peak_forecast_unit( + self._peak_fc_fault)}) + # OPT-17: a forecast summed from several entities names, by position, + # each member with no usable reading. A caution: control goes on. + gaps = getattr(self, "solar_fc_gaps", ()) + for day, ents in (("today", getattr(self, "fc_entities", ())), + ("tomorrow", + getattr(self, "fc_tomorrow_entities", ()))): + gone = [i for d, i, _e, _w in gaps if d == day] + if gone: + 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 (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, + # never named by entity id (STYLE.md). The per-source figures and + # units are what `pv.total()` returns; they stay in `pv_parts`. + flip = pv_read.inverted(self.o) + n_src = len(roster) + if self.pv_w is None and n_src == 1: + out.append({"ok": False, "warn": True, "id": "pv", + "text": messages.row_pv_none(self.pv_why)}) + elif self.pv_w is None: + # ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN + # THOSE WORDS - and which sources are missing, by position. + out.append({"ok": False, "warn": True, "id": "pv", + "text": messages.row_pv_partial( + [i + 1 for i, (_e, watt, _y) + in enumerate(self.pv_parts) if watt is None], + n_src)}) + else: + out.append({"ok": True, "warn": False, "id": "pv", + "text": messages.row_pv( + self.pv_w, n_src, + n_src == 1 and roster[0] in flip)}) + # ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s + # `entities()` makes `pv_entities` REPLACE `pv_entity`, which is + # the rule an operator upgrading from 0.11.0 is most likely to trip + # over. (DECISIONS/MAIN-orchestration-archive.md) + if pv_read.entity_is_ignored(self.o): + out.append({"ok": False, "warn": True, "id": "pv", + "text": messages.row_pv_single_ignored()}) + # TEL-14: a sign that looks reversed, per entity rather than on the + # total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3. + for i, (ent, watt, _y) in enumerate(self.pv_parts): + if watt is not None and watt < pv_read.NEGATIVE_W: + out.append({"ok": False, "warn": True, "id": "pv", + "text": messages.row_pv_negative( + i + 1, n_src, watt, ent in flip)}) + _inv_list = [str(x or "").strip() + for x in (self.o.get("pv_invert_entities") or [])] + for ent in pv_read.stray_inverts(self.o): + out.append({"ok": False, "warn": True, "id": "pv", + "text": messages.row_pv_stray( + _inv_list.index(ent) + 1)}) + return out + + def status_payload(self, lang: str = "en") -> dict: + checks = self.checks() + bad = [c for c in checks if not c["ok"] and not c["warn"]] + # SAFETY-87: a red row with `runs` (the peak alarms) is an alarm while + # control goes on, so it neither reads "Not ready" nor holds the page. + 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" 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 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: 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. + # 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: + # ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE: a row may + # say more than fits the headline (SAFETY-25's floor row). + # (DECISIONS/MAIN-orchestration-archive.md) + level, banner = "bad", messages.banner_not_ready(bad[0]) + # SAFETY-112: nothing to do but wait, so Home says that instead + # of "Needs you now". Only when it IS the headline: a safety stop + # above outranks it and still needs a person. + waiting = bad[0].get("id") == "home_assistant" + elif not self.auto: + level, banner = "warn", messages.banner_stopped() + elif runs: + level, running_alarm = "bad", True + banner = runs[0].get("banner") or messages.headline_bi( + 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 + # (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: 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, + self.maint_unit.name if len(self.inverters) > 1 else "") + else: + level, banner = "ok", messages.banner_running() + + def w(v): + return "—" if v is None else f"{v:,.0f} W".replace(",", " ") + + # ⚠️ 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] + _waits = [u for u in (m.backoff_until(_now) for m in self.maints) if u] + next_due = None if any(d is None for d in _dues) else min(_dues) + rows = [ + ("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). 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" + # rather than "0 W". (DECISIONS/MAIN-orchestration-archive.md) + *(() if not self.pv_roster else ((("Solar production", + w(self.pv_w)),))), + ("Battery level", "—" if self.pack_floor_soc is None + else f"{self.pack_floor_soc:g} %"), + ("Power request", w(self.target)), + # SAFETY-10, and it sits BETWEEN the two rows it reconciles. + # "Commanded 0 W" above and a "Why" below that explains a trade is + # the exact pair an operator would otherwise have to guess at; this + # row is what makes the two of them consistent, and it is rendered + # in EVERY state - including `off`, where it says the battery IS + # being controlled - because a surface whose ABSENCE is how you + # learn something is a surface you have to already know about. + ("Shadow mode", shadow.words( + self.o, control_started=self.auto, + would_w=self.shadow_would_w, + # SAFETY-47. THE FLAG THE CYCLE RECORDED, NOT ONE DERIVED HERE + # - the page runs on its own task and `self.peak_guard` has + # moved on. See `Controller.__init__`. + sized_from_meter=self.shadow_sized_from_meter, + reason=self.why_words)), + ("Why", self.why_words), + ("Maintenance phase", self.maint.phase if len(self.inverters) <= 1 + else f"{self.maint.phase} ({self.maint_unit.name})"), + # ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status + # page - where `_maint_deferred` is empty on every tick - carries + # exactly the rows it carried before. + *((("Maintenance waiting", "; ".join(f"{n}: {why}" + for n, why in self._maint_deferred)),) + if self._maint_deferred else ()), + # MAINT-13: only while a failed cycle waits, so a site with nothing + # failed carries exactly the rows it carried before. + *((("Maintenance paused", messages.row_maint_waits( + min(_waits).strftime("%Y-%m-%d"))),) if _waits else ()), + ("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")), + ("Cheap window", "yes" if self.cheap else "no"), + ("Tariff", self.tariff_words), + # 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, 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: 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, + decided_at=self.arb_at, cycle_at=self.cycle_at, + now=datetime.now(timezone.utc), why=self.why_words)), + ] + held = level == "bad" and not running_alarm + # 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: 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 + # never cost the page its rows: a fault here is logged and the old + # fields are served alone (the page then shows the installer details). + try: + out.update(homeview.build(self, level=level, checks=checks, + lang=lang, held=held, waiting=waiting)) + except Exception: # noqa: BLE001 + # Once per process at ERROR: the page polls every 3 s. + if not getattr(self, "_home_failed", False): + self._home_failed = True + _LOG.exception("Home view could not be built, so the page " + "shows the plain status rows only") + else: + _LOG.debug("Home view failed again", exc_info=True) + out["home_error"] = True + return out + + def forecast_payload(self, lang: str = "en") -> dict: + """UX-54: the Forecast tab's data. Built on request, from what the + control cycle already holds; no Home Assistant call. Today's blocks are + Home's own, so `status_payload()` is built first and read.""" + try: + return dayview.build(self, self.status_payload(lang=lang), + datetime.now(timezone.utc), lang) + except Exception: # noqa: BLE001 + if not getattr(self, "_forecast_failed", False): + self._forecast_failed = True + _LOG.exception("The Forecast tab could not be built") + else: + _LOG.debug("The Forecast tab failed again", exc_info=True) + return {"ok": False} + + async def handle_action(self, what: str, *, unit=None, value=None): + now = datetime.now(timezone.utc) + if what in ("pack_use", "pack_mode", "pack_sell"): + return self._pack_action(what, unit, value, now) + if what == "auto_toggle": + self.auto = not self.auto + self.store.set("auto", self.auto) + self.log_event("control started" if self.auto else "control stopped", + 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 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: + continue + inv.sign_wd.reset() + inv.store_set("sign_tripped", False) + inv.store_set("sign_reason", "") + self.log_event(f"sign-disagreement failsafe cleared by " + f"operator on {inv.name}", + hint=messages.hint("stop_cleared", + unit=inv.name)) + # ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, + # 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 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 " + "by operator", + hint=messages.hint("site_stop_cleared")) + 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. + # ⚠️ 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) + self.store.set("circ_reason", "") + 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, 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} " + f"cleared by operator", + hint=messages.hint( + "not_delivering_cleared", + unit=inv.name)) + inv.delivery_wd.reset() + inv.store_set("delivery_tripped", False) + inv.store_set("delivery_reason", "") + self._delivery_announced = set() + if not self.auto: + # ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see + # command_site_zero(). SMA-10: Stop gives hand-back units back. + # (DECISIONS/MAIN-orchestration-archive.md) + await self.command_site_release() + return {"auto": self.auto} + if what in ("maint_start", "maint_abort") and unit is not None: + # MAINT-11: the operator named the battery. A request with no unit + # falls through to the branches below, unchanged. + 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: `_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", + phase=_m.phase)) + return {"phase": self.maint.phase} + if what == "maint_abort": + # 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"): + self.log_event(msg, hint=messages.hint("maintenance", + phase=_m.phase)) + await self.command_site_zero() + return {"phase": self.maint.phase} + return None diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 524a3a2..9af64f2 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -103,6 +103,7 @@ from .watchdog import ( ) from . import setuponly from . import web +from .ctl_status import StatusMixin from .ctl_alarms import ( AlarmsMixin, HANDBACK_CYCLES, REANNOUNCE_S, ROLLING_BOUND_CYCLES, ROLLING_S, SHORT_CYCLES, SHORT_SPAN_S, @@ -172,7 +173,8 @@ def _load_latch(data, now=None): return until, levels, born -class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin): +class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, + StatusMixin): def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop, peak_db_path: str = "/data/tariff.db", price_history_path: str = "/data/price_history.json", @@ -1106,61 +1108,6 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin): return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor) for inv in self.inverters] - async def mqtt_command(self, topic, payload, retained) -> None: - """OPT-11 P2: one command from a battery's HA switch or mode list, - through handle_action(). A retained command is never applied (the store - already holds the last choice). Never raises: MQTT must not reach - control. (DECISIONS/MAIN-orchestration-archive.md)""" - try: - slug, action, value, why = parse_command( - topic, payload, retained, packmode.MODES) - if why is None: - names = [inv.name for inv in self.inverters] - unit = dict(zip(unit_slugs(names), names)).get(slug) - result = await self.handle_action(action, unit=unit, - value=value) - if not (isinstance(result, dict) and "error" in result): - self.mqtt.publish(self.mode_values()) - return - why = result["error"] - else: - why = messages.pack_refused(why) - # Printable only: a topic is sender-chosen, so no log injection. - topic_s = "".join(c if c.isprintable() else "?" - for c in str(topic)[:128]) - _LOG.debug("Home Assistant command %s %s refused: %s", topic_s, - repr(payload)[:48], why) - if (topic_s, why) in self._mqtt_refused \ - or len(self._mqtt_refused) >= 32: - # Not logged again, but counted: 1, 10, 100... stay visible. - self._mqtt_refused_more += 1 - more = self._mqtt_refused_more - if str(more).rstrip("0") == "1": - _LOG.warning("Home Assistant commands still ignored: %d " - "more so far.", more) - return - self._mqtt_refused.add((topic_s, why)) - _LOG.warning("Home Assistant command on %s ignored: %s", topic_s, - why) - except Exception: # noqa: BLE001 - _LOG.exception("Home Assistant command could not be handled") - - @property - def why_words(self) -> str: - """The page's "Why" line. While control waits for a missing reading - the age is counted when the page asks (UX-10), not frozen at the age - the last cycle saw.""" - if self._why_waiting_since is not None: - return messages.why_waiting(max(0.0, ( - datetime.now(timezone.utc) - - self._why_waiting_since).total_seconds())) - return self._why_words - - @why_words.setter - def why_words(self, text: str) -> None: - self._why_words = text - self._why_waiting_since = None - # -- tasks --------------------------------------------------------------- # -- OPT-04: the background tariff refresh ------------------------------- def fresh_tariff_plan(self, now): @@ -1301,84 +1248,6 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin): self.loop_pass_mono = time.monotonic() await asyncio.sleep(1) - def publish(self) -> None: - # ⚠️ 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, - "grid": self.grid, - "battery": self.site_batt_w, - "soc": self.pack_floor_soc, - "phase": self.maint.phase, - # ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing - # 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". ⚠️ 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`. `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. 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 `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 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: ⚠️ 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)): - values[unit_state_key(slug, "setpoint")] = inv.target - values[unit_state_key(slug, "battery")] = inv.batt - 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. - values.update(self.mode_values()) - self.mqtt.publish(values) - async def shutdown(self) -> None: """Deterministic wind-down. Do not skip this.""" self.stopping = True @@ -1387,771 +1256,6 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin): await self.command_site_release() self.mqtt.close() - # -- UI ------------------------------------------------------------------ - 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. ⚠️ 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 = [] - # SAFETY-61. First, so a stalled loop is the headline: the heartbeat is - # sent from this loop, so past layer 1's veto the inverter is at 0 W. - if self.loop_pass_mono is None: - out.append({"ok": True, "warn": False, "id": "controller", - "text": messages.row_controller_starting()}) - else: - _pass_age = time.monotonic() - self.loop_pass_mono - if _pass_age > FIRMWARE_INGEST_VETO_S: - out.append({"ok": False, "warn": False, "id": "controller", - "text": messages.row_controller_stalled( - _pass_age, FIRMWARE_INGEST_VETO_S)}) - else: - out.append({"ok": True, "warn": False, "id": "controller", - "text": messages.row_controller_ran(_pass_age)}) - # SAFETY-112. The headline unless control has stalled: while the - # Supervisor gives 502 (Home Assistant not yet RUNNING) no reading or - # write gets through, and the rows below that need it follow from it. - if OUTAGE.waiting(): - out.insert(1 if out[0]["ok"] is False else 0, - {"ok": False, "warn": False, "id": "home_assistant", - "banner": messages.banner_ha_waiting(), - "text": messages.row_ha_waiting()}) - if self.p1_enabled: - age = self.p1.published_age_s - if (self.p1.stale and self.p1.age_s() is None - and age <= self.p1.max_age_s): - # UX-10: no reading yet since the start, still inside the - # limit. Amber, not red: it is not a fault yet, but control - # waits for it. Red at 0 s contradicted the limit the row - # prints. (DECISIONS/MAIN-orchestration-archive.md) - out.append({"ok": False, "warn": True, "id": "meter", - "text": messages.row_meter_waiting( - self.p1.max_age_s)}) - elif self.p1.stale: - # The last error is on the add-on log's WARNING line (p1.py). - out.append({"ok": False, "warn": False, "id": "meter", - "text": messages.row_meter_stale( - age, self.p1.max_age_s)}) - else: - # ⚠️ unchanged_s is REPORTED, never thresholded and never - # folded into the age - see HomeWizardLocalSource.unchanged_s - # for why (at the converged -10 W this controller aims for, a 1 - # Wh register needs ~6 minutes to move, so any limit - # false-trips at the exact operating point we target). - # (DECISIONS/MAIN-orchestration-archive.md) - unchanged = getattr(self.p1_source, "unchanged_s", None) - out.append({"ok": True, "warn": False, "id": "meter", - "text": messages.row_meter( - self.p1.net_w, age, self.p1.parse_errors, - unchanged)}) - # 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()). - 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 - # 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", - "text": messages.row_freeze_off("source")}) - elif rail_unreachable(self.site_max_w, - dev_min=self.site_dev_bound("dev_min"), - dev_max=self.site_dev_bound("dev_max")): - # SAFETY-20: the same row as the transport case above (the - # check cannot be evaluated here); only the missing term - # differs. (DECISIONS/MAIN-orchestration-archive.md) - _no_rating = (not self.site_max_w - or not is_commandable(self.site_max_w)) - out.append({"ok": False, "warn": True, "id": "freeze_check", - "text": messages.row_freeze_off( - "rating" if _no_rating else "range")}) - else: - # ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE - # USES, device range included. - # (DECISIONS/MAIN-orchestration-archive.md) - _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 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. 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", - "text": messages.row_month_peak_unreadable()}) - else: - # §9.1's "warning entity at 80 %", as a status row rather than - # a second entity - see the constructor on not doubling up. - _frac = self.peak.warning_frac(when) or 0.0 - _ok = _frac < 0.8 - out.append({"ok": _ok, "warn": not _ok, "id": "month_peak", - "text": (messages.row_month_peak_none() - if not _pk.is_observed else - messages.row_month_peak( - _pk.peak_w, - brussels(_pk.quarter_start), - _frac))}) - out.append({"ok": True, "warn": False, "id": "periods_measured", - "text": messages.row_periods_measured( - 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 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", - "text": messages.row_peak_guard_waiting()}) - elif _pg.saturated and not _pg.within_aim: - out.append({"ok": False, "warn": False, "id": "peak_guard", - "runs": True, - "text": messages.row_peak_saturated(_pg.ceiling_w)}) - 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 (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, - "banner": messages.banner_peak_reserve(), - "text": messages.row_peak_reserve( - _pg.ceiling_w, soc_unread=_pg.soc is None, - floor_unknown=(_pg.soc is not None - and _pg.soc_floor is None), - packs=(_pg.held if len(self.inverters) > 1 - else ()), - others=(_pg.floor_w or 0.0) > 0.0)}) - elif _pg.blind: - out.append({"ok": False, "warn": True, "id": "peak_guard", - "text": messages.row_peak_guard_blind( - peakguard.blind_kind(_pg.blind))}) - elif self.peak_unmeasured_s > 0.0: - # SAFETY-90: the start of this quarter came before the first - # reading, and the guard counts it as high use. - out.append({"ok": False, "warn": True, "id": "peak_guard", - "text": messages.row_peak_guard_unmeasured()}) - elif _pg.engaged: - out.append({"ok": False, "warn": True, "id": "peak_guard", - "text": messages.row_peak_guard_on( - _pg.floor_w or 0.0, _pg.ceiling_w, - shadow=self.shadow)}) - elif _pg.projected_w is not None: - # SAFETY-125: "under" only when it is; over the limit at this - # rate, a grid charge is cut back to the quarter's room. - out.append({"ok": True, "warn": False, "id": "peak_guard", - "text": messages.row_peak_guard_idle( - _pg.projected_w, _pg.ceiling_w, - month_peak=(_pg.ceiling_from - == peakguard.CEILING_MONTH_PEAK), - cut=self.room_charge and not self.shadow)}) - else: - out.append({"ok": True, "warn": False, "id": "peak_guard", - "text": _pg.explain()}) - else: - # ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the - # 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()}) - - # ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row - # on a site with two packs is a page that shows one pack and calls it - # the site. (DECISIONS/MAIN-orchestration-archive.md) - rows = [] - for inv in self.inverters: - unit = None if len(self.inverters) == 1 else inv.name - rows.append((messages.LABEL_SOC, unit, inv.soc, - inv.driver.soc_source())) - rows.append((messages.LABEL_BATTERY, unit, inv.batt, - inv.driver.batt_source())) - if not self.p1_enabled: - # In P1 mode the check above replaces this one; leaving both in - # would report "no entity configured" for a meter_entity that is - # correctly unused, i.e. a permanent false NOT READY. - rows.insert(0, (messages.LABEL_GRID, None, self.grid, - o.get("meter_entity"))) - # UX-08: the rows name the setting, never the entity id (STYLE.md). - for label, unit, value, entity in rows: - if not entity: - out.append({"ok": False, "warn": False, "id": "sensor", - "text": messages.row_sensor_unset(label, unit)}) - elif value is None: - # ⚠️ 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)}) - else: - out.append({"ok": True, "warn": False, "id": "sensor", - "text": messages.row_sensor(label, value, unit)}) - - for inv in self.inverters: - unit = None if len(self.inverters) == 1 else inv.name - if not inv.driver.configured(): - out.append({"ok": False, "warn": False, "id": "control", - "text": messages.row_control_unset(unit)}) - elif inv.dev_max is None: - out.append({"ok": False, "warn": False, "id": "control", - "text": messages.row_control_missing(unit)}) - else: - out.append({"ok": True, "warn": False, "id": "control", - "text": messages.row_control(inv.dev_min, - inv.dev_max, unit)}) - - # 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]), - (inv.stale_note(), False), - (inv.refusal_note(), False)): - if note: - out.append({"ok": False, "warn": not red, "text": note}) - if self.answer.late_seen: # SAFETY-128 - out.append({"ok": False, "warn": True, "id": "late", - "text": messages.row_readings_late( - self.answer.shown_s, self._late_zero)}) - - # 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", - "banner": messages.banner_power_too_high(), - "text": messages.row_power_too_high( - _cw.facts, _cw.over_rating, shadow=self.shadow)}) - elif _cw.active: - # SAFETY-40: the integrator bound alone is amber, not red. - out.append({"ok": False, "warn": True, "id": "not_keeping_up", - "text": messages.row_not_keeping_up( - _cw.bound_episodes)}) - # No over-rating in THIS episode (`bug` would be set), so any count - # is from an earlier one: keep that software-fault evidence shown. - if _cw.over_rating: - out.append({"ok": False, "warn": True, "id": "power_too_high", - "text": messages.row_before( - messages.T_OUTPUT_CLAMP, _cw.over_rating)}) - else: - if _cw.over_rating: - out.append({"ok": False, "warn": True, "id": "power_too_high", - "text": messages.row_before( - messages.T_OUTPUT_CLAMP, _cw.over_rating)}) - if _cw.bound_episodes: - out.append({"ok": False, "warn": True, "id": "not_keeping_up", - "text": messages.row_before( - messages.T_LOOP_BOUND, _cw.bound_episodes)}) - - for inv in self.inverters: - if inv.last_write_ok is False: - out.append({"ok": False, "warn": False, "id": "write", - "text": messages.row_write_refused( - None if len(self.inverters) == 1 - else inv.name)}) - - # 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: 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", - "text": messages.row_direction_trip(unit)}) - else: - held = inv.sign_wd.held_s(datetime.now(timezone.utc)) - out.append({"ok": True, "warn": False, "id": "direction", - "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: 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: - out.append({"ok": False, "warn": False, "id": "site_direction", - "text": messages.row_site_direction_trip()}) - else: - _sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc)) - out.append({"ok": True, "warn": False, "id": "site_direction", - "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: 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", - "text": messages.row_circulating_trip()}) - elif len(self.inverters) <= 1: - # AC 5. At the shipped roster the quantity is identically zero for - # every value of every reading, so "armed" would be a claim to be - # watching something that cannot happen. Say what is true instead. - out.append({"ok": True, "warn": False, "id": "circulating", - "text": messages.row_one_inverter( - messages.CHECK_CIRCULATING)}) - else: - held = self.circ_wd.held_s(datetime.now(timezone.utc)) - out.append({"ok": True, "warn": False, "id": "circulating", - "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: 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: - out.append({"ok": False, "warn": True, "id": "delivery", - "text": messages.row_not_delivering(inv.name)}) - else: - _dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc)) - out.append({"ok": True, "warn": False, "id": "delivery", - "text": messages.row_delivery_check( - inv.name, _dheld, - inv.delivery_wd.cfg.hold_s)}) - else: - # AC 5: at one inverter this check has nothing to find, so it is - # not called "armed" (as `circulating power: n/a` above). - # (DECISIONS/MAIN-orchestration-archive.md) - out.append({"ok": True, "warn": False, "id": "delivery", - "text": messages.row_one_inverter("Delivery check")}) - - if not o.get("estop_fitted", False): - out.append({"ok": False, "warn": True, "id": "estop", - "text": messages.row_no_estop()}) - else: - out.append({"ok": True, "warn": False, "id": "estop", - "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 - # is both is a check nobody reads. - out.append({"ok": False, "warn": True, "id": "peak_forecast", - "text": messages.row_no_peak_forecast()}) - elif getattr(self, "_peak_fc_fault", None) is not None: - out.append({"ok": False, "warn": True, "id": "peak_forecast", - "text": messages.row_peak_forecast_unit( - self._peak_fc_fault)}) - # OPT-17: a forecast summed from several entities names, by position, - # each member with no usable reading. A caution: control goes on. - gaps = getattr(self, "solar_fc_gaps", ()) - for day, ents in (("today", getattr(self, "fc_entities", ())), - ("tomorrow", - getattr(self, "fc_tomorrow_entities", ()))): - gone = [i for d, i, _e, _w in gaps if d == day] - if gone: - 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 (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, - # never named by entity id (STYLE.md). The per-source figures and - # units are what `pv.total()` returns; they stay in `pv_parts`. - flip = pv_read.inverted(self.o) - n_src = len(roster) - if self.pv_w is None and n_src == 1: - out.append({"ok": False, "warn": True, "id": "pv", - "text": messages.row_pv_none(self.pv_why)}) - elif self.pv_w is None: - # ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN - # THOSE WORDS - and which sources are missing, by position. - out.append({"ok": False, "warn": True, "id": "pv", - "text": messages.row_pv_partial( - [i + 1 for i, (_e, watt, _y) - in enumerate(self.pv_parts) if watt is None], - n_src)}) - else: - out.append({"ok": True, "warn": False, "id": "pv", - "text": messages.row_pv( - self.pv_w, n_src, - n_src == 1 and roster[0] in flip)}) - # ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s - # `entities()` makes `pv_entities` REPLACE `pv_entity`, which is - # the rule an operator upgrading from 0.11.0 is most likely to trip - # over. (DECISIONS/MAIN-orchestration-archive.md) - if pv_read.entity_is_ignored(self.o): - out.append({"ok": False, "warn": True, "id": "pv", - "text": messages.row_pv_single_ignored()}) - # TEL-14: a sign that looks reversed, per entity rather than on the - # total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3. - for i, (ent, watt, _y) in enumerate(self.pv_parts): - if watt is not None and watt < pv_read.NEGATIVE_W: - out.append({"ok": False, "warn": True, "id": "pv", - "text": messages.row_pv_negative( - i + 1, n_src, watt, ent in flip)}) - _inv_list = [str(x or "").strip() - for x in (self.o.get("pv_invert_entities") or [])] - for ent in pv_read.stray_inverts(self.o): - out.append({"ok": False, "warn": True, "id": "pv", - "text": messages.row_pv_stray( - _inv_list.index(ent) + 1)}) - return out - - def status_payload(self, lang: str = "en") -> dict: - checks = self.checks() - bad = [c for c in checks if not c["ok"] and not c["warn"]] - # SAFETY-87: a red row with `runs` (the peak alarms) is an alarm while - # control goes on, so it neither reads "Not ready" nor holds the page. - 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" 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 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: 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. - # 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: - # ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE: a row may - # say more than fits the headline (SAFETY-25's floor row). - # (DECISIONS/MAIN-orchestration-archive.md) - level, banner = "bad", messages.banner_not_ready(bad[0]) - # SAFETY-112: nothing to do but wait, so Home says that instead - # of "Needs you now". Only when it IS the headline: a safety stop - # above outranks it and still needs a person. - waiting = bad[0].get("id") == "home_assistant" - elif not self.auto: - level, banner = "warn", messages.banner_stopped() - elif runs: - level, running_alarm = "bad", True - banner = runs[0].get("banner") or messages.headline_bi( - 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 - # (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: 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, - self.maint_unit.name if len(self.inverters) > 1 else "") - else: - level, banner = "ok", messages.banner_running() - - def w(v): - return "—" if v is None else f"{v:,.0f} W".replace(",", " ") - - # ⚠️ 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] - _waits = [u for u in (m.backoff_until(_now) for m in self.maints) if u] - next_due = None if any(d is None for d in _dues) else min(_dues) - rows = [ - ("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). 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" - # rather than "0 W". (DECISIONS/MAIN-orchestration-archive.md) - *(() if not self.pv_roster else ((("Solar production", - w(self.pv_w)),))), - ("Battery level", "—" if self.pack_floor_soc is None - else f"{self.pack_floor_soc:g} %"), - ("Power request", w(self.target)), - # SAFETY-10, and it sits BETWEEN the two rows it reconciles. - # "Commanded 0 W" above and a "Why" below that explains a trade is - # the exact pair an operator would otherwise have to guess at; this - # row is what makes the two of them consistent, and it is rendered - # in EVERY state - including `off`, where it says the battery IS - # being controlled - because a surface whose ABSENCE is how you - # learn something is a surface you have to already know about. - ("Shadow mode", shadow.words( - self.o, control_started=self.auto, - would_w=self.shadow_would_w, - # SAFETY-47. THE FLAG THE CYCLE RECORDED, NOT ONE DERIVED HERE - # - the page runs on its own task and `self.peak_guard` has - # moved on. See `Controller.__init__`. - sized_from_meter=self.shadow_sized_from_meter, - reason=self.why_words)), - ("Why", self.why_words), - ("Maintenance phase", self.maint.phase if len(self.inverters) <= 1 - else f"{self.maint.phase} ({self.maint_unit.name})"), - # ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status - # page - where `_maint_deferred` is empty on every tick - carries - # exactly the rows it carried before. - *((("Maintenance waiting", "; ".join(f"{n}: {why}" - for n, why in self._maint_deferred)),) - if self._maint_deferred else ()), - # MAINT-13: only while a failed cycle waits, so a site with nothing - # failed carries exactly the rows it carried before. - *((("Maintenance paused", messages.row_maint_waits( - min(_waits).strftime("%Y-%m-%d"))),) if _waits else ()), - ("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")), - ("Cheap window", "yes" if self.cheap else "no"), - ("Tariff", self.tariff_words), - # 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, 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: 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, - decided_at=self.arb_at, cycle_at=self.cycle_at, - now=datetime.now(timezone.utc), why=self.why_words)), - ] - held = level == "bad" and not running_alarm - # 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: 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 - # never cost the page its rows: a fault here is logged and the old - # fields are served alone (the page then shows the installer details). - try: - out.update(homeview.build(self, level=level, checks=checks, - lang=lang, held=held, waiting=waiting)) - except Exception: # noqa: BLE001 - # Once per process at ERROR: the page polls every 3 s. - if not getattr(self, "_home_failed", False): - self._home_failed = True - _LOG.exception("Home view could not be built, so the page " - "shows the plain status rows only") - else: - _LOG.debug("Home view failed again", exc_info=True) - out["home_error"] = True - return out - - def forecast_payload(self, lang: str = "en") -> dict: - """UX-54: the Forecast tab's data. Built on request, from what the - control cycle already holds; no Home Assistant call. Today's blocks are - Home's own, so `status_payload()` is built first and read.""" - try: - return dayview.build(self, self.status_payload(lang=lang), - datetime.now(timezone.utc), lang) - except Exception: # noqa: BLE001 - if not getattr(self, "_forecast_failed", False): - self._forecast_failed = True - _LOG.exception("The Forecast tab could not be built") - else: - _LOG.debug("The Forecast tab failed again", exc_info=True) - return {"ok": False} - - async def handle_action(self, what: str, *, unit=None, value=None): - now = datetime.now(timezone.utc) - if what in ("pack_use", "pack_mode", "pack_sell"): - return self._pack_action(what, unit, value, now) - if what == "auto_toggle": - self.auto = not self.auto - self.store.set("auto", self.auto) - self.log_event("control started" if self.auto else "control stopped", - 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 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: - continue - inv.sign_wd.reset() - inv.store_set("sign_tripped", False) - inv.store_set("sign_reason", "") - self.log_event(f"sign-disagreement failsafe cleared by " - f"operator on {inv.name}", - hint=messages.hint("stop_cleared", - unit=inv.name)) - # ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, - # 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 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 " - "by operator", - hint=messages.hint("site_stop_cleared")) - 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. - # ⚠️ 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) - self.store.set("circ_reason", "") - 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, 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} " - f"cleared by operator", - hint=messages.hint( - "not_delivering_cleared", - unit=inv.name)) - inv.delivery_wd.reset() - inv.store_set("delivery_tripped", False) - inv.store_set("delivery_reason", "") - self._delivery_announced = set() - if not self.auto: - # ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see - # command_site_zero(). SMA-10: Stop gives hand-back units back. - # (DECISIONS/MAIN-orchestration-archive.md) - await self.command_site_release() - return {"auto": self.auto} - if what in ("maint_start", "maint_abort") and unit is not None: - # MAINT-11: the operator named the battery. A request with no unit - # falls through to the branches below, unchanged. - 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: `_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", - phase=_m.phase)) - return {"phase": self.maint.phase} - if what == "maint_abort": - # 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"): - self.log_event(msg, hint=messages.hint("maintenance", - phase=_m.phase)) - await self.command_site_zero() - return {"phase": self.maint.phase} - return None - # SAFETY-61. Exit status when the control task dies; 70 is sysexits(3) # EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md.