SAFETY-34: re-assert latched alarms after a Core-only restart (#128)
infra-pin / fresh (push) Failing after 1s
infra-pin / fresh (push) Failing after 1s
This commit was merged in pull request #128.
This commit is contained in:
@@ -113,6 +113,12 @@ __all__ = ["UNIT_A", "Controller", "amain", "main"]
|
|||||||
# the re-issue happens by itself) than hold the control loop for ten seconds.
|
# the re-issue happens by itself) than hold the control loop for ten seconds.
|
||||||
WRITE_BUDGET_S = 4.0
|
WRITE_BUDGET_S = 4.0
|
||||||
|
|
||||||
|
# SAFETY-34: a Core-only restart wipes persistent_notification and does NOT
|
||||||
|
# restart this add-on (measured on TEST-23), so a latched alarm is re-asserted
|
||||||
|
# this often. Why: goodwe docs/DECISIONS.md, SAFETY-34.
|
||||||
|
REANNOUNCE_S = 300.0
|
||||||
|
_mono = time.monotonic # patched by the checks; asyncio keeps the real one
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# OPT-04: THE BACKGROUND TARIFF REFRESH, AND WHY IT IS NOT OPTIONAL
|
# OPT-04: THE BACKGROUND TARIFF REFRESH, AND WHY IT IS NOT OPTIONAL
|
||||||
#
|
#
|
||||||
@@ -691,6 +697,9 @@ class Controller:
|
|||||||
# does, so the latch alone is not the alarm - re-raising it is.
|
# does, so the latch alone is not the alarm - re-raising it is.
|
||||||
# `run_control()` does that, once, before the first cycle.
|
# `run_control()` does that, once, before the first cycle.
|
||||||
self._circ_announced = False
|
self._circ_announced = False
|
||||||
|
# SAFETY-34: when each latched alarm was last put in front of HA, by
|
||||||
|
# notification_id - see REANNOUNCE_S.
|
||||||
|
self._announced_mono = {}
|
||||||
if self.circ_wd.tripped:
|
if self.circ_wd.tripped:
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"SAFETY-31 was TRIPPED before this restart and stays tripped: "
|
"SAFETY-31 was TRIPPED before this restart and stays tripped: "
|
||||||
@@ -3943,15 +3952,17 @@ class Controller:
|
|||||||
The latch says "this site is tripped"; the flag says "this process has
|
The latch says "this site is tripped"; the flag says "this process has
|
||||||
put that in front of a human". A restart clears the flag and keeps the
|
put that in front of a human". A restart clears the flag and keeps the
|
||||||
latch, so the notification is RE-RAISED on the first cycle after every
|
latch, so the notification is RE-RAISED on the first cycle after every
|
||||||
add-on start - which is also the first cycle after a Home Assistant
|
add-on start. A Core-only restart does NOT restart the add-on, so it is
|
||||||
restart, because the add-on is restarted with it.
|
also re-asserted every REANNOUNCE_S (SAFETY-34).
|
||||||
|
|
||||||
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE. A fresh trip and a restart into
|
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE. A fresh trip and a restart into
|
||||||
an existing trip raise the alarm through these same lines. Two paths
|
an existing trip raise the alarm through these same lines. Two paths
|
||||||
would be two chances for the restart one to rot unnoticed, and the
|
would be two chances for the restart one to rot unnoticed, and the
|
||||||
restart one is the case nobody exercises by hand.
|
restart one is the case nobody exercises by hand.
|
||||||
"""
|
"""
|
||||||
if not self.circ_wd.tripped or self._circ_announced:
|
if not self.circ_wd.tripped:
|
||||||
|
return
|
||||||
|
if self._circ_announced and not self._reannounce_due("goodwe_circulating_power"):
|
||||||
return
|
return
|
||||||
# Set BEFORE the await. `hass.call()` can raise or hang, and a retry
|
# 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
|
# every cycle for a broker that is down would be an alarm storm on the
|
||||||
@@ -3959,6 +3970,7 @@ class Controller:
|
|||||||
# already durable and the log line above already fired; this surface is
|
# already durable and the log line above already fired; this surface is
|
||||||
# best-effort by design.
|
# best-effort by design.
|
||||||
self._circ_announced = True
|
self._circ_announced = True
|
||||||
|
self._announced_mono["goodwe_circulating_power"] = _mono()
|
||||||
names = self.units
|
names = self.units
|
||||||
pos, neg = opposing([inv.batt for inv in self.inverters],
|
pos, neg = opposing([inv.batt for inv in self.inverters],
|
||||||
min_w=self.circ_wd.cfg.min_w / 2.0)
|
min_w=self.circ_wd.cfg.min_w / 2.0)
|
||||||
@@ -3993,6 +4005,11 @@ class Controller:
|
|||||||
f"know why two inverters were opposing."),
|
f"know why two inverters were opposing."),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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:
|
async def check_not_delivering(self) -> None:
|
||||||
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart.
|
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart.
|
||||||
|
|
||||||
@@ -4012,8 +4029,8 @@ class Controller:
|
|||||||
not persisted. The latch says "this unit is tripped"; membership says
|
not persisted. The latch says "this unit is tripped"; membership says
|
||||||
"this process has put that in front of a human". A restart empties the
|
"this process has put that in front of a human". A restart empties the
|
||||||
set and keeps the latch, so the notification is RE-RAISED on the first
|
set and keeps the latch, so the notification is RE-RAISED on the first
|
||||||
cycle after every add-on start - which is also the first cycle after a
|
cycle after every add-on start, and re-asserted every REANNOUNCE_S
|
||||||
Home Assistant restart, because the add-on is restarted with it.
|
because a Core-only restart does not restart the add-on (SAFETY-34).
|
||||||
|
|
||||||
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE - `check_circulating()`'s
|
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE - `check_circulating()`'s
|
||||||
argument. A fresh trip and a restart into an existing trip raise the
|
argument. A fresh trip and a restart into an existing trip raise the
|
||||||
@@ -4031,7 +4048,10 @@ class Controller:
|
|||||||
names = self.units
|
names = self.units
|
||||||
slugs = unit_slugs(names)
|
slugs = unit_slugs(names)
|
||||||
for inv, slug in zip(self.inverters, slugs):
|
for inv, slug in zip(self.inverters, slugs):
|
||||||
if not inv.delivery_wd.tripped or inv.name in self._delivery_announced:
|
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
|
continue
|
||||||
# Added BEFORE the await, for `check_circulating()`'s reason:
|
# Added BEFORE the await, for `check_circulating()`'s reason:
|
||||||
# `hass.call()` can raise or hang, and a retry every cycle for a
|
# `hass.call()` can raise or hang, and a retry every cycle for a
|
||||||
@@ -4039,9 +4059,10 @@ class Controller:
|
|||||||
# latch is already durable and the log line already fired; this
|
# latch is already durable and the log line already fired; this
|
||||||
# surface is best-effort by design.
|
# surface is best-effort by design.
|
||||||
self._delivery_announced.add(inv.name)
|
self._delivery_announced.add(inv.name)
|
||||||
|
self._announced_mono[nid] = _mono()
|
||||||
peers = ", ".join(n for n in names if n != inv.name) or "no sibling"
|
peers = ", ".join(n for n in names if n != inv.name) or "no sibling"
|
||||||
await self.hass.call("persistent_notification", "create", {
|
await self.hass.call("persistent_notification", "create", {
|
||||||
"notification_id": f"goodwe_not_delivering_{slug}",
|
"notification_id": nid,
|
||||||
"title": f"GoodWe: {inv.name} is not delivering its share",
|
"title": f"GoodWe: {inv.name} is not delivering its share",
|
||||||
"message": (
|
"message": (
|
||||||
f"{inv.delivery_wd.reason}\n\nWHY NOTHING ELSE SAID SO. A "
|
f"{inv.delivery_wd.reason}\n\nWHY NOTHING ELSE SAID SO. A "
|
||||||
|
|||||||
@@ -1195,8 +1195,8 @@ async def _restart():
|
|||||||
c1 = _ctrl(h1, store=shared)
|
c1 = _ctrl(h1, store=shared)
|
||||||
await _drive(c1)
|
await _drive(c1)
|
||||||
# A NEW process, a NEW HomeAssistant object (so the in-memory notification
|
# A NEW process, a NEW HomeAssistant object (so the in-memory notification
|
||||||
# is gone with it), the SAME durable file. That is what a Home Assistant
|
# is gone with it), the SAME durable file: an ADD-ON restart. A Core-only
|
||||||
# restart is from this add-on's point of view: the add-on goes with it.
|
# restart keeps the process; graded in part 8b (SAFETY-34).
|
||||||
h2 = _DeliveryHass(b_soc=50.0)
|
h2 = _DeliveryHass(b_soc=50.0)
|
||||||
c2 = _ctrl(h2, store=shared)
|
c2 = _ctrl(h2, store=shared)
|
||||||
await c2.check_not_delivering()
|
await c2.check_not_delivering()
|
||||||
@@ -1250,6 +1250,40 @@ check("the operator's START press is the only way out, and it clears the "
|
|||||||
and _rs.data.get(unit_key("inverter-b", "delivery_reason")) == ""
|
and _rs.data.get(unit_key("inverter-b", "delivery_reason")) == ""
|
||||||
and _r2._delivery_announced == set())
|
and _r2._delivery_announced == set())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("8b. SAFETY-34: a Core-only restart - same add-on process, HA's memory wiped")
|
||||||
|
import app.main as _main_mod # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
async def _core_restart():
|
||||||
|
shared = _MemStore()
|
||||||
|
c = _ctrl(_DeliveryHass(b_soc=50.0), store=shared)
|
||||||
|
await _drive(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
_c = asyncio.run(_core_restart())
|
||||||
|
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
|
||||||
|
_main_mod._mono = lambda: _clock[0]
|
||||||
|
try:
|
||||||
|
_c.hass = _DeliveryHass(b_soc=50.0)
|
||||||
|
asyncio.run(_c.check_not_delivering())
|
||||||
|
_early = [x for x in _c.hass.calls
|
||||||
|
if "not_delivering" in str(x[2].get("notification_id", ""))]
|
||||||
|
_clock[0] += _main_mod.REANNOUNCE_S
|
||||||
|
asyncio.run(_c.check_not_delivering())
|
||||||
|
_late = [x for x in _c.hass.calls
|
||||||
|
if "not_delivering" in str(x[2].get("notification_id", ""))]
|
||||||
|
finally:
|
||||||
|
_main_mod._mono = _real_mono
|
||||||
|
check("SAFETY-34: the latch tripped in this process",
|
||||||
|
_c.inverters[1].delivery_wd.tripped)
|
||||||
|
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise",
|
||||||
|
len(_early) == 0)
|
||||||
|
check("SAFETY-34: after REANNOUNCE_S the SAME process re-asserts the card, "
|
||||||
|
"because a Core-only restart wiped it and did not restart the add-on",
|
||||||
|
len(_late) == 1 and "inverter_b" in _late[0][2]["notification_id"])
|
||||||
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
|
|||||||
@@ -1055,8 +1055,8 @@ async def _circ_restart():
|
|||||||
await a.read_inputs()
|
await a.read_inputs()
|
||||||
await a.cycle()
|
await a.cycle()
|
||||||
await asyncio.sleep(0.25)
|
await asyncio.sleep(0.25)
|
||||||
# THE RESTART. Same store, brand new Controller and Hass - which is exactly
|
# THE RESTART. Same store, brand new Controller and Hass - an ADD-ON
|
||||||
# what a Home Assistant restart does to this add-on. The house has stopped
|
# restart. A Core-only restart keeps the process; graded below (SAFETY-34). The house has stopped
|
||||||
# circulating in the meantime, so nothing in the new process's own
|
# circulating in the meantime, so nothing in the new process's own
|
||||||
# measurements could re-derive the trip.
|
# measurements could re-derive the trip.
|
||||||
h2 = _CircHass(a_w=0.0, b_w=0.0)
|
h2 = _CircHass(a_w=0.0, b_w=0.0)
|
||||||
@@ -1116,6 +1116,28 @@ check("...and it does not raise the same card on every cycle - a per-PROCESS "
|
|||||||
"this' a different question from 'is this site tripped'",
|
"this' a different question from 'is this site tripped'",
|
||||||
len(_notes2) == 1)
|
len(_notes2) == 1)
|
||||||
|
|
||||||
|
# SAFETY-34: a Core-ONLY restart. The add-on process lives on (measured on
|
||||||
|
# TEST-23), HA's memory is wiped, so the SAME process must re-assert the card.
|
||||||
|
import app.main as _main_mod # noqa: E402
|
||||||
|
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
|
||||||
|
_main_mod._mono = lambda: _clock[0]
|
||||||
|
try:
|
||||||
|
_h3 = _CircHass(a_w=0.0, b_w=0.0)
|
||||||
|
_b.hass = _h3
|
||||||
|
asyncio.run(_b.check_circulating())
|
||||||
|
_early = [c for c in _h3.calls if c[0] == "persistent_notification"]
|
||||||
|
_clock[0] += _main_mod.REANNOUNCE_S
|
||||||
|
asyncio.run(_b.check_circulating())
|
||||||
|
_late = [c for c in _h3.calls if c[0] == "persistent_notification"]
|
||||||
|
finally:
|
||||||
|
_main_mod._mono = _real_mono
|
||||||
|
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise - no "
|
||||||
|
"storm on the event bus", len(_early) == 0)
|
||||||
|
check("SAFETY-34: a Core-only restart wipes the card and the add-on process "
|
||||||
|
"survives it, so after REANNOUNCE_S the SAME process re-asserts it",
|
||||||
|
len(_late) == 1
|
||||||
|
and _late[0][2]["notification_id"] == "goodwe_circulating_power")
|
||||||
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND")
|
print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND")
|
||||||
|
|||||||
Reference in New Issue
Block a user