"""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