Moves the methods named in the card and the constants only they read out of app/main.py unchanged. AlarmsMixin: the check_* alarms and the loop-alarm helpers. tools/maint21_same_ast.py: OK. Co-Authored-By: Claude <noreply@anthropic.com>
462 lines
24 KiB
Python
462 lines
24 KiB
Python
"""Controller methods that raise and clear the alarms.
|
|
|
|
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
|
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from . import allocator
|
|
from . import control as _ctl
|
|
from .circulation import opposing
|
|
from .clamp import (
|
|
REASON_CODE as CLAMP_REASON, delivery_short, shortfall_note,
|
|
write_clamp_note,
|
|
)
|
|
from . import messages
|
|
from .mqtt import unit_slugs
|
|
from . import peakguard
|
|
from .price import brussels
|
|
from .price_chain import fixed_tariff_beside_service
|
|
from .ctl_writes import REFUSED_SPAN_S
|
|
from .ctl_inputs import _mono
|
|
|
|
_LOG = logging.getLogger("goodwe")
|
|
|
|
|
|
# SAFETY-34: a Core-only restart wipes persistent_notification without
|
|
# restarting this add-on, so a latched alarm is re-asserted this often.
|
|
REANNOUNCE_S = 300.0
|
|
|
|
|
|
# SAFETY-85: a battery another owner set is graded against the least it was
|
|
# told over the plant's settle time, so a battery still settling is not short.
|
|
SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S
|
|
|
|
|
|
# SAFETY-119/121: after a hand-back the loop's bound is hidden for
|
|
# HANDBACK_CYCLES / SHORT_SPAN_S, and across hand-backs on at most
|
|
# ROLLING_BOUND_CYCLES cycles per ROLLING_S
|
|
# (DECISIONS/SAFETY-121-bound-every-quarter.md). Without a monotonic clock the
|
|
# budget never refills.
|
|
HANDBACK_CYCLES = 4
|
|
|
|
|
|
ROLLING_S = 900.0
|
|
|
|
|
|
ROLLING_BOUND_CYCLES = 11
|
|
|
|
|
|
class AlarmsMixin:
|
|
def _loop_alarm_input(self, resolution) -> None:
|
|
"""SAFETY-85. The loop's clamp counts only on a cycle the loop decided
|
|
(a rating may cut it). A battery another owner set to discharge counts
|
|
when it gave less than it was told for `saturation_cycles` cycles in a
|
|
row. DECISIONS/SAFETY-85-no-false-not-keeping-up.md"""
|
|
d = self._loop_decision
|
|
if d is None:
|
|
self._short_run = 0
|
|
self._asked_hist = {}
|
|
self._wire_lost, self._handback = False, None
|
|
return
|
|
unmet = self.allocation.unmet_w if self.allocation is not None else 0
|
|
cut = (resolution.bound_by or "").split("(")[0]
|
|
owns = (resolution.winner == "loop" and not d.held and not unmet
|
|
and (resolution.target_w == d.target_w
|
|
or cut in ("device", "supervised")))
|
|
# Graded against the least that stood on the wire (0 W in shadow
|
|
# mode) over SHORT_CYCLES cycles and SHORT_SPAN_S seconds.
|
|
now, asked = self._now_mono, {}
|
|
for inv in self.inverters:
|
|
h = self._asked_hist.setdefault(inv.name, [])
|
|
h.append((now, inv.standing_w()))
|
|
while len(h) > SHORT_CYCLES and (
|
|
now is None or h[0][0] is None
|
|
or now - h[0][0] > SHORT_SPAN_S):
|
|
h.pop(0)
|
|
asked[inv.name] = min(w for _t, w in h)
|
|
others = [inv for inv in self.inverters if not owns
|
|
or allocator.pin_w(resolution.unit_bounds.get(inv.name))
|
|
is not None]
|
|
short = [(inv, asked[inv.name], inv.batt) for inv in others
|
|
if inv.batt is not None
|
|
and delivery_short(asked[inv.name], inv.batt,
|
|
self.tuning.saturation_w)]
|
|
self._short_run = self._short_run + 1 if short else 0
|
|
settling = self._settling(owns, now)
|
|
if owns and self.loop_clamped[0] and not (
|
|
settling and not self.loop_clamped[2]):
|
|
return
|
|
engaged = self._short_run >= self.tuning.saturation_cycles
|
|
named = len(self.inverters) > 1
|
|
self.loop_clamped = (engaged, shortfall_note(
|
|
[(inv.name if named else None, a, g) for inv, a, g in short])
|
|
if engaged else "", False)
|
|
self.loop_clamp_facts = ()
|
|
|
|
def _settling(self, owns, now) -> bool:
|
|
"""SAFETY-119: does the loop's bound on this cycle count? Not within a
|
|
settle (HANDBACK_CYCLES cycles and SHORT_SPAN_S) after another owner
|
|
handed the wire back; across all hand-backs the bound is hidden on at
|
|
most ROLLING_BOUND_CYCLES cycles in any ROLLING_S.
|
|
DECISIONS/SAFETY-121-bound-every-quarter.md"""
|
|
def aged(t0, span):
|
|
return now is None or t0 is None or now - t0 >= span
|
|
|
|
if not owns:
|
|
self._wire_lost, self._handback = True, None
|
|
return False
|
|
if self._wire_lost:
|
|
self._wire_lost, self._handback = False, (now, 0)
|
|
if self._handback is None:
|
|
return False
|
|
tc, n = self._handback
|
|
n += 1
|
|
self._handback = (tc, n)
|
|
if aged(tc, SHORT_SPAN_S) and n >= HANDBACK_CYCLES:
|
|
self._handback = None
|
|
return False
|
|
if self.loop_clamped[0] and not self.loop_clamped[2]:
|
|
kept = tuple(t for t in self._hidden if now is None or (
|
|
t is not None and now - t < ROLLING_S))
|
|
if len(kept) >= ROLLING_BOUND_CYCLES:
|
|
self._hidden = kept
|
|
return False
|
|
self._hidden = kept + (now,)
|
|
return True
|
|
|
|
async def check_output_clamp(self) -> None:
|
|
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle. Runs
|
|
AFTER write_setpoint() (the clamp is arithmetic done inside the write).
|
|
⚠️ PUBLISHING IS NOT IN THE WRITE PATH: a stalled supervisor must never
|
|
delay or drop a frame. ⚠️ THIS IS NOT A TRIP: no claim, no latch, 0 W
|
|
is not commanded (§11.2: clamp and alarm); turning it into one is a
|
|
deliberate change. (DECISIONS/MAIN-orchestration-archive.md)"""
|
|
# ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT: `clamp_last` is on
|
|
# the Inverter, `ClampWatchdog` is one per controller (an anti-flood
|
|
# counter), so any unit that clamped is an engagement and the reason
|
|
# names it. (DECISIONS/MAIN-orchestration-archive.md)
|
|
clamped_units = [inv for inv in self.inverters
|
|
if inv.clamp_last is not None and inv.clamp_last.engaged]
|
|
engaged_write = bool(clamped_units)
|
|
engaged_loop, loop_note, loop_bug = self.loop_clamped
|
|
engaged = engaged_write or engaged_loop
|
|
# SAFETY-40: only an over-rating is a bug; the integrator bound is not.
|
|
bug = engaged_write or loop_bug
|
|
if engaged_write:
|
|
_named = len(self.inverters) > 1
|
|
reason = write_clamp_note([(inv.name if _named else None,
|
|
inv.clamp_last)
|
|
for inv in clamped_units])
|
|
facts = [messages.Clamp(inv.name if _named else None,
|
|
inv.clamp_last.requested_w,
|
|
inv.clamp_last.applied_w,
|
|
inv.clamp_last.bound)
|
|
for inv in clamped_units]
|
|
else:
|
|
reason = loop_note if engaged else ""
|
|
facts = self.loop_clamp_facts if engaged else ()
|
|
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc),
|
|
bug=bug, facts=facts)
|
|
|
|
# THE ACTION LOG IS COALESCED PER EPISODE: consecutive engagements
|
|
# update one row's count. ⚠️ NOT COSMETIC: the log keeps 40 rows and a
|
|
# winding loop clamps every cycle, so a row each flushed SAFETY-18's
|
|
# "METER FROZEN?" row. Here as well as in write_setpoint() because on a
|
|
# default install the actuator-side clamp is unreachable.
|
|
# (DECISIONS/MAIN-orchestration-archive.md)
|
|
if engaged and not engaged_write:
|
|
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
|
|
row = (f"{tag}: "
|
|
f"{messages.count(self.clamp_wd.engagements, 'engagement')}"
|
|
f" - {reason}")
|
|
if self.events and tag in self.events[0]:
|
|
stamp = self.events[0].split(" ", 1)[0]
|
|
self.events[0] = f"{stamp} {row}"
|
|
else:
|
|
self.log_event(row, hint=messages.hint(
|
|
"power_too_high" if bug else "not_keeping_up"))
|
|
if not fired:
|
|
return
|
|
if not self.clamp_wd.bug:
|
|
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
|
|
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
|
|
# it can never replace a BUG SIGNAL notice still on screen.
|
|
_LOG.warning("The battery gives less than asked: %s. Held back %s "
|
|
"so far.", messages.reason_code(self.clamp_wd.reason),
|
|
messages.times(self.clamp_wd.engagements))
|
|
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
|
|
_LOG.debug("A short hold after a large load or solar step is "
|
|
"normal. If it lasts, check that the battery follows "
|
|
"its commands.")
|
|
await self.hass.call("persistent_notification", "create",
|
|
messages.loop_bound().data())
|
|
return
|
|
_LOG.error(
|
|
"Power request too high, a software fault: %s. Cut back %s so "
|
|
"far, control runs on.", messages.reason_code(self.clamp_wd.reason),
|
|
messages.times(self.clamp_wd.engagements))
|
|
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
|
|
_LOG.debug(
|
|
"SAFETY-03: silently clipping this is how a winding integrator "
|
|
"reached 14 768 W on this project before. The clamp makes every "
|
|
"dashboard read a legal number while the thing producing it goes "
|
|
"on climbing. This is an alarm, not a trip.")
|
|
# A fixed notification_id, for the reason check_meter_frozen() gives -
|
|
# and a DIFFERENT one from every other alarm this add-on raises,
|
|
# because a clamp is not a frozen meter and neither is a saturation
|
|
# freeze. (DECISIONS/MAIN-orchestration-archive.md)
|
|
await self.hass.call("persistent_notification", "create",
|
|
messages.output_clamp(self.clamp_wd.facts,
|
|
shadow=self.shadow).data())
|
|
|
|
async def check_peak_saturated(self) -> None:
|
|
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter. ⚠️ An
|
|
engagement is not an alarm: a peak floor above the rating is a house
|
|
load bigger than the inverter, not a defect (unlike SAFETY-03's winding
|
|
integrator). SAFETY-81: `g.saturated` must agree with
|
|
`peakguard.load_too_high()` for `ALARM_HOLD_S`. Edge-triggered: one
|
|
notification per episode, at most once per quarter.
|
|
(DECISIONS/MAIN-orchestration-archive.md)"""
|
|
g = self.peak_guard
|
|
batt, prev = self.site_batt_w, self._peak_batt_prev
|
|
if batt is None:
|
|
# Unknown, not "fine": the hold below neither grows nor restarts.
|
|
return
|
|
self._peak_batt_prev = batt
|
|
if prev is not None:
|
|
batt = min(float(batt), float(prev))
|
|
p = self._peak_proj
|
|
# OPT-15 x SAFETY-81: until the new quarter's first sample the old
|
|
# projection read "no seconds left" (not high), which restarted the
|
|
# hold; a rolled one (battery modes on) is not graded then, so a
|
|
# persistent overload is still notified once per quarter.
|
|
high = bool(
|
|
g is not None and g.saturated and p is not None
|
|
and p.block_start is not None and not self._peak_rolled
|
|
and peakguard.load_too_high(
|
|
p, batt_w=batt, ceiling_w=g.ceiling_w,
|
|
achievable_w=self.site_rating_w))
|
|
# SAFETY-81: and for ALARM_HOLD_S, on the projection's clock.
|
|
saturated = False
|
|
if high:
|
|
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
|
|
if self._peak_high_since is None:
|
|
self._peak_high_since = t
|
|
saturated = ((t - self._peak_high_since).total_seconds()
|
|
>= peakguard.ALARM_HOLD_S)
|
|
else:
|
|
self._peak_high_since = None
|
|
was, self._peak_saturated = self._peak_saturated, saturated
|
|
if not saturated or was:
|
|
return
|
|
_LOG.error(
|
|
"Switch off large loads, the battery alone cannot keep this "
|
|
"15-minute period under the peak limit: %s.",
|
|
messages.reason_code(g.reason))
|
|
_LOG.debug("Evidence: %s.", g.reason)
|
|
# SAFETY-81: notified once per quarter; a later episode is logged only.
|
|
if p.block_start == self._peak_alarm_block:
|
|
return
|
|
self._peak_alarm_block = p.block_start
|
|
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
|
|
hint=messages.hint("peak_saturated"))
|
|
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
|
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
|
# is not a frozen meter and is not an out-of-range setpoint.
|
|
await self.hass.call("persistent_notification", "create",
|
|
messages.peak_saturated(
|
|
g.ceiling_w, shadow=self.shadow).data())
|
|
|
|
async def check_peak_floor_limited(self) -> None:
|
|
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving. ⚠️
|
|
FAILING TO SHAVE MUST NOT BE SILENT (the capacity tariff bills the
|
|
month's worst quarter). Not check_peak_saturated(): that is shed-load,
|
|
this is nothing discharged; never both. ⚠️ SAFETY-25: the message says
|
|
the floor binds this guard only; the loop has no SoC floor and goes on
|
|
discharging. Edge-triggered. (DECISIONS/MAIN-orchestration-archive.md)"""
|
|
g = self.peak_guard
|
|
limited = bool(g is not None and g.floor_limited)
|
|
if limited and (g.floor_w or 0.0) > 0.0:
|
|
limited = self._reserve_short(g)
|
|
else:
|
|
self._reserve_batt_prev = self._reserve_high_since = None
|
|
# SAFETY-126: nothing can give, so it alarms at once, but only
|
|
# when the quarter really misses the aim (`_peak_landing()`).
|
|
limited = limited and not g.within_aim
|
|
was, self._peak_floor_limited = self._peak_floor_limited, limited
|
|
if not limited or was:
|
|
return
|
|
# No prefix: `reason` already opens with PEAK_GUARD_FLOOR_LIMITED, and
|
|
# a second copy is the kind of stutter that makes a 40-line action log
|
|
# read like boilerplate. (DECISIONS/MAIN-orchestration-archive.md)
|
|
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
|
|
_LOG.error(
|
|
"Switch off large loads, the peak guard may not draw the battery "
|
|
"lower: %s.", messages.reason_code(g.reason))
|
|
_LOG.debug("Evidence: %s.", g.reason)
|
|
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
|
# here - check_meter_frozen() gives the reasoning. ⚠️ SAFETY-25's
|
|
# sentence (the reserve binds the peak guard only) is in
|
|
# messages.peak_reserve() on every branch, unreadable SoC included.
|
|
# (DECISIONS/MAIN-orchestration-archive.md)
|
|
await self.hass.call("persistent_notification", "create",
|
|
messages.peak_reserve(
|
|
g.ceiling_w, soc_unread=g.soc is None,
|
|
floor_unknown=(g.soc is not None
|
|
and g.soc_floor is None),
|
|
packs=(g.held if len(self.inverters) > 1
|
|
else ()),
|
|
others=(g.floor_w or 0.0) > 0.0).data())
|
|
|
|
def _reserve_short(self, g) -> bool:
|
|
"""SAFETY-87: the batteries that can still give, at full power, miss
|
|
the limit (`load_too_high()` on `g.able_w`), for ALARM_HOLD_S, as
|
|
SAFETY-81 grades 'Load too high'."""
|
|
p, batt = self._peak_proj, self.site_batt_w
|
|
if batt is None:
|
|
return self._peak_floor_limited
|
|
prev, self._reserve_batt_prev = self._reserve_batt_prev, batt
|
|
if prev is not None:
|
|
batt = min(float(batt), float(prev))
|
|
if not (p is not None and p.block_start is not None
|
|
and peakguard.load_too_high(p, batt_w=batt,
|
|
ceiling_w=g.ceiling_w,
|
|
achievable_w=g.able_w)):
|
|
self._reserve_high_since = None
|
|
return False
|
|
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
|
|
if self._reserve_high_since is None:
|
|
self._reserve_high_since = t
|
|
return ((t - self._reserve_high_since).total_seconds()
|
|
>= peakguard.ALARM_HOLD_S)
|
|
|
|
async def check_circulating(self) -> None:
|
|
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. ⚠️
|
|
THE LATCH IS NOT THE ALARM: the notification is in memory, so
|
|
`_circ_announced` (per-PROCESS, not persisted) makes a restart re-raise
|
|
it, and REANNOUNCE_S re-asserts it after a Core-only restart. ⚠️ ONE
|
|
CODE PATH for a fresh trip and a restart into one.
|
|
(DECISIONS/MAIN-orchestration-archive.md)"""
|
|
if not self.circ_wd.tripped:
|
|
return
|
|
if self._circ_announced and not self._reannounce_due("goodwe_circulating_power"):
|
|
return
|
|
# Set BEFORE the await. `hass.call()` can raise or hang, and a retry
|
|
# every cycle for a broker that is down would be an alarm storm on the
|
|
# event bus on top of a site that is already stopped.
|
|
# (DECISIONS/MAIN-orchestration-archive.md)
|
|
self._circ_announced = True
|
|
self._announced_mono["goodwe_circulating_power"] = _mono()
|
|
names = self.units
|
|
pos, neg = opposing([inv.batt for inv in self.inverters],
|
|
min_w=self.circ_wd.cfg.min_w / 2.0)
|
|
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
|
# this add-on raises - check_meter_frozen() gives the reasoning.
|
|
await self.hass.call("persistent_notification", "create",
|
|
messages.circulating(
|
|
[names[i] for i in pos] if pos and neg else (),
|
|
[names[i] for i in neg] if pos and neg else (),
|
|
).data())
|
|
|
|
def _reannounce_due(self, notification_id: str) -> bool:
|
|
"""SAFETY-34: has this latched alarm gone REANNOUNCE_S without being re-asserted?"""
|
|
t = self._announced_mono.get(notification_id)
|
|
return t is None or _mono() - t >= REANNOUNCE_S
|
|
|
|
async def check_not_delivering(self) -> None:
|
|
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart. ⚠️
|
|
THE LATCH IS NOT THE ALARM: the notification is in memory, so
|
|
`_delivery_announced` (a per-PROCESS set of unit names, not persisted)
|
|
makes a restart re-raise it, and REANNOUNCE_S re-asserts it. ⚠️ ONE
|
|
CODE PATH for a fresh trip and a restart into one. ⚠️ ONE ID PER UNIT
|
|
(`unit_slugs()`): two wedged inverters give two cards.
|
|
(DECISIONS/MAIN-orchestration-archive.md)"""
|
|
names = self.units
|
|
slugs = unit_slugs(names)
|
|
for inv, slug in zip(self.inverters, slugs):
|
|
nid = f"goodwe_not_delivering_{slug}"
|
|
if not inv.delivery_wd.tripped:
|
|
continue
|
|
if inv.name in self._delivery_announced and not self._reannounce_due(nid):
|
|
continue
|
|
# Added BEFORE the await, for `check_circulating()`'s reason:
|
|
# `hass.call()` can raise or hang, and a retry every cycle for a
|
|
# broker that is down would be an alarm storm on the event bus.
|
|
# (DECISIONS/MAIN-orchestration-archive.md)
|
|
self._delivery_announced.add(inv.name)
|
|
self._announced_mono[nid] = _mono()
|
|
await self.hass.call("persistent_notification", "create",
|
|
messages.not_delivering(inv.name, slug).data())
|
|
|
|
async def check_price_gap(self) -> None:
|
|
"""PRICE-01. One notification when no price covers now, gone when one
|
|
does. (DECISIONS/MAIN-orchestration-archive.md)"""
|
|
if self.price_gap and not self._price_gap_announced:
|
|
self._price_gap_announced = True
|
|
await self.hass.call("persistent_notification", "create",
|
|
messages.no_prices(*self._gap_args()).data())
|
|
elif not self.price_gap and self._price_gap_announced:
|
|
self._price_gap_announced = False
|
|
await self.hass.call("persistent_notification", "dismiss",
|
|
messages.prices_back().data())
|
|
|
|
async def check_fixed_note(self) -> None:
|
|
"""PRICE-01 G1. First cycle only: one INFO line when `static_profile`
|
|
sits beside `price_service` (the plan's last fallback, not a fault, so
|
|
no notification). (DECISIONS/MAIN-orchestration-archive.md)"""
|
|
if self._fixed_note_said:
|
|
return
|
|
self._fixed_note_said = True
|
|
if fixed_tariff_beside_service(self.o):
|
|
_LOG.info(messages.FIXED_TARIFF_LOG)
|
|
try:
|
|
await self.hass.call("persistent_notification", "dismiss",
|
|
messages.fixed_tariff_card_gone().data())
|
|
except Exception: # noqa: BLE001 - housekeeping never stops a cycle
|
|
_LOG.debug("Could not take down the old fixed-tariff card.")
|
|
|
|
def _gap_args(self) -> tuple:
|
|
"""`(since, now)` in Brussels time for the gap's words. `since` is None
|
|
when no price was ever published."""
|
|
now = brussels(datetime.now(timezone.utc))
|
|
if self.price_gap_since is None:
|
|
return None, now
|
|
return brussels(self.price_gap_since), now
|
|
|
|
async def check_meter_frozen(self) -> None:
|
|
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
|
Notification only: no claim, no latch, no 0 W (freeze.py carries the
|
|
reasoning). getattr, not an attribute access: `unchanged_s` exists only
|
|
on `homewizard_local`, and `p1_source` is None until amain() builds the
|
|
transport. (DECISIONS/MAIN-orchestration-archive.md)"""
|
|
unchanged = getattr(self.p1_source, "unchanged_s", None)
|
|
# SITE, all three inputs: one meter, the SITE target the arbiter
|
|
# resolved, and the rail the SITE could stand at - which is the summed
|
|
# per-unit conjunction, so a small inverter B lowers the site rail rather
|
|
# than being graded against inverter A's.
|
|
fired = self.freeze_wd.evaluate(unchanged, self.target,
|
|
self.site_max_w,
|
|
datetime.now(timezone.utc),
|
|
dev_min=self.site_dev_bound("dev_min"),
|
|
dev_max=self.site_dev_bound("dev_max"))
|
|
if not fired:
|
|
return
|
|
self.log_event("METER FROZEN? " + self.freeze_wd.reason,
|
|
hint=messages.hint("meter_frozen"))
|
|
_LOG.error(
|
|
"The meter shows the same value while the battery is asked for "
|
|
"full power, and control still follows it, so check the meter: "
|
|
"%s.", messages.reason_code(self.freeze_wd.reason))
|
|
_LOG.debug("Evidence: %s.", self.freeze_wd.reason)
|
|
_LOG.debug(
|
|
"SAFETY-18: nothing else stops this. The meter still answers, so "
|
|
"its age is fresh, the firmware watchdog is fed and the arbiter "
|
|
"only bounds the magnitude. Kept up for a quarter of an hour, it "
|
|
"sets the month's billed capacity peak.")
|
|
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
|
|
# rather than stacking. An operator who has to dismiss eleven identical
|
|
# notifications learns to dismiss the twelfth without reading it.
|
|
await self.hass.call("persistent_notification", "create",
|
|
messages.meter_frozen(unchanged).data())
|