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 <noreply@anthropic.com>
928 lines
53 KiB
Python
928 lines
53 KiB
Python
"""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
|