The 0 W of shutdown and of the Stop button now waits (bounded, 2 s) for a write already on its way to the same unit, for every driver, GoodWe included. Once shutdown has begun, cycle, heartbeat and safety-stop writes are dropped right before the write is created. One shared wait (_await_inflight) serves the 0 W and SMA-10's _release_last. Co-Authored-By: Claude <noreply@anthropic.com>
1214 lines
48 KiB
Python
1214 lines
48 KiB
Python
"""SMA-10: two idle states, hold 0 W or hand the inverter back. `python3 test_sma10_two_idle_states.py`
|
|
|
|
A unit whose driver says `hands_back` is given back (`release()`) when the add-on
|
|
goes away and in shadow mode; every safety stop and refusal still holds 0 W. A
|
|
GoodWe unit never sees a changed byte.
|
|
|
|
1. GoodWe only: every sentence this card builds from the roster is the literal
|
|
it was before the card, in English and Dutch, and the page words are the
|
|
files as they were.
|
|
2. The sentences for a mixed and a hand-back-only roster, English and Dutch,
|
|
printed, held to STYLE.md, and taken from the DRIVER'S words.
|
|
3. A real Controller with a fake hand-back unit (and a GoodWe unit): Stop,
|
|
shutdown, a safety stop, Start, shadow mode, the per-unit state, the call
|
|
sites, one unit's release raising or hanging.
|
|
4. The real main() in a child process: SIGTERM, the control task's death, a late
|
|
config refusal (exit 78) and a startup refusal (exit 78, no driver exists).
|
|
|
|
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
|
The mutants this file was run against are listed at the end.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE / "tools"))
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# the child: the real main() with the outside world faked (as test_control_task)
|
|
# --------------------------------------------------------------------------- #
|
|
CHILD_BASE = {
|
|
"meter_entity": "sensor.grid", "meter_source": "off", "gain": 0.6,
|
|
"slew_w": 0, "deadband_w": 15, "target_grid_w": -10, "step_w": 10,
|
|
"heartbeat_s": 10, "stale_input_s": 15, "cycle_floor_s": 1,
|
|
"auto_start": True, "shadow_mode": "off",
|
|
"inverters": [
|
|
dict(name="inverter-a", setpoint_entity="number.sp_a",
|
|
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
|
|
max_w=2000, bms_soc_floor=10),
|
|
dict(name="inverter-b", driver="hbfake", host="10.9.9.9", port=502,
|
|
unit_id=3, max_w=2000, bms_soc_floor=10)]}
|
|
DEADLINE_S = 40
|
|
|
|
|
|
def make_hb_driver(event=None):
|
|
from app.drivers import Driver, Probe
|
|
|
|
class HBDriver(Driver):
|
|
"""A unit that hands back. Calls are kept; `mode` decides how its
|
|
release() behaves: ok, false, raise or hang."""
|
|
name = "hbfake"
|
|
writer_key = "host"
|
|
brand = "Fake"
|
|
hands_back = True
|
|
words = {"back": "SMA control", "back_nl": "de sturing van SMA"}
|
|
|
|
def __init__(self, host="", port=502, unit_id=1, rating=2000.0):
|
|
self.host, self.port, self.unit_id = host, port, unit_id
|
|
self.rating, self.power = rating, 0.0
|
|
self.calls = []
|
|
self.mode = "ok"
|
|
self.write_ok = True
|
|
self.write_delay = 0.0
|
|
|
|
@classmethod
|
|
def from_entry(cls, entry):
|
|
return cls(entry.get("host", ""), entry.get("port", 502),
|
|
entry.get("unit_id", 1))
|
|
|
|
@classmethod
|
|
def actuator_id_of(cls, entry):
|
|
host = str(entry.get("host") or "").strip()
|
|
return f"{host}:{entry.get('port', 502)}:{entry.get('unit_id', 1)}" \
|
|
if host else ""
|
|
|
|
def configured(self):
|
|
return bool(self.host)
|
|
|
|
def actuator_id(self):
|
|
return f"{self.host}:{self.port}:{self.unit_id}"
|
|
|
|
def soc_source(self):
|
|
return "fake-soc"
|
|
|
|
def batt_source(self):
|
|
return "fake-power"
|
|
|
|
async def limits(self):
|
|
return -self.rating, self.rating
|
|
|
|
async def read(self):
|
|
return 60.0, self.power, {}
|
|
|
|
async def write(self, w):
|
|
self.calls.append(("write", w))
|
|
if event:
|
|
event("fake", "write", float(w))
|
|
if self.write_delay:
|
|
await asyncio.sleep(self.write_delay)
|
|
self.calls.append(("write_done", w))
|
|
if self.write_ok:
|
|
self.power = w
|
|
return self.write_ok
|
|
|
|
async def release(self):
|
|
self.calls.append(("release", None))
|
|
if event:
|
|
event("fake", "release")
|
|
if self.mode == "raise":
|
|
raise RuntimeError("release exploded")
|
|
if self.mode == "false":
|
|
return False
|
|
if self.mode == "hang":
|
|
await asyncio.sleep(3600)
|
|
return True
|
|
|
|
async def probe(self):
|
|
return Probe(-self.rating, self.rating, 60.0, self.power, {})
|
|
|
|
return HBDriver
|
|
|
|
|
|
def child(scenario):
|
|
import aiohttp
|
|
import app.main as M
|
|
import app.store
|
|
import app.web
|
|
from app import drivers
|
|
|
|
def event(*e):
|
|
print("EV " + json.dumps(list(e)), flush=True)
|
|
|
|
drivers.REGISTRY["hbfake"] = make_hb_driver(event)
|
|
drivers.SELECTABLE = drivers.SELECTABLE + ("hbfake",)
|
|
t0 = time.monotonic()
|
|
|
|
class FakeHass:
|
|
def __init__(self):
|
|
self.limit_calls = 0
|
|
|
|
async def limits(self, entity):
|
|
self.limit_calls += 1
|
|
if scenario == "refuse": # a rating below max_w, once read
|
|
return -1000.0, 1000.0, 1.0
|
|
return -5000.0, 5000.0, 1.0
|
|
|
|
async def number(self, entity, invert=False):
|
|
if entity == "sensor.grid":
|
|
return 600.0 + (int(time.monotonic() - t0) % 2)
|
|
if entity == "sensor.soc_a":
|
|
return 50.0
|
|
if entity == "sensor.batt_a":
|
|
return 0.0
|
|
return None
|
|
|
|
async def state(self, entity):
|
|
return None
|
|
|
|
async def set_number(self, entity, value):
|
|
event("goodwe", "write", float(value))
|
|
return True
|
|
|
|
async def call(self, *a, **k):
|
|
return True
|
|
|
|
async def mqtt_service(self):
|
|
raise RuntimeError("no broker in this test")
|
|
|
|
class _Store:
|
|
data = {}
|
|
|
|
def get(self, key, default=None):
|
|
return default
|
|
|
|
def set(self, *a, **k):
|
|
pass
|
|
|
|
class _Runner:
|
|
async def cleanup(self):
|
|
event("cleanup")
|
|
|
|
async def _web_start(controller, port=8099, selfcfg=None):
|
|
return _Runner()
|
|
|
|
class _NoProbeSession:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
def get(self, *a, **k):
|
|
raise OSError("no network in this test")
|
|
|
|
opts = json.loads(json.dumps(CHILD_BASE))
|
|
if scenario == "badcfg":
|
|
opts["slew_w"] = -5 # refused at the startup gate
|
|
M.load_options = lambda: dict(opts)
|
|
M.HomeAssistant = lambda session: FakeHass()
|
|
app.store.Store = _Store
|
|
app.web.start = _web_start
|
|
aiohttp.ClientSession = _NoProbeSession
|
|
|
|
real_cycle = M.Controller.cycle
|
|
n = {"cycles": 0}
|
|
|
|
async def cycle(self):
|
|
n["cycles"] += 1
|
|
if scenario == "fault" and n["cycles"] == 4:
|
|
event("fault")
|
|
raise RuntimeError("injected SMA-10 fault")
|
|
await real_cycle(self)
|
|
if scenario == "sigterm" and n["cycles"] == 4:
|
|
event("sigterm")
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
|
|
M.Controller.cycle = cycle
|
|
M.main()
|
|
|
|
|
|
def run_child(scenario):
|
|
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
|
|
try:
|
|
p = subprocess.run([sys.executable, "-B", __file__, "--child", scenario],
|
|
cwd=str(HERE), env=env, capture_output=True,
|
|
text=True, timeout=DEADLINE_S)
|
|
except subprocess.TimeoutExpired as e:
|
|
rc, out, err = None, e.stdout or "", e.stderr or ""
|
|
else:
|
|
rc, out, err = p.returncode, p.stdout, p.stderr
|
|
out = out.decode() if isinstance(out, bytes) else out
|
|
err = err.decode() if isinstance(err, bytes) else err
|
|
return rc, err, [json.loads(ln[3:]) for ln in out.splitlines()
|
|
if ln.startswith("EV ")]
|
|
|
|
|
|
if len(sys.argv) == 3 and sys.argv[1] == "--child":
|
|
child(sys.argv[2])
|
|
sys.exit(0)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
from app import drivers, handback, homeview, messages, uitext # noqa: E402
|
|
from app import main as M # noqa: E402
|
|
from app.drivers import GoodWeEsphomeDriver, REGISTRY # noqa: E402
|
|
from app.main import Controller # noqa: E402
|
|
from app.store import DEFAULTS # noqa: E402
|
|
from style_rules import BANNED, SHOUT, sentences, words # noqa: E402
|
|
|
|
HB = make_hb_driver()
|
|
REGISTRY["hbfake"] = HB
|
|
|
|
|
|
class OtherHB(HB):
|
|
"""A second make that hands back, with other words: the sentences must
|
|
come from the driver, not from a hard-coded brand."""
|
|
name = "otherhb"
|
|
words = {"back": "Zeta control", "back_nl": "de sturing van Zeta"}
|
|
|
|
|
|
REGISTRY["otherhb"] = OtherHB
|
|
|
|
|
|
class MemStore:
|
|
def __init__(self):
|
|
self.data = dict(DEFAULTS)
|
|
|
|
def set(self, k, v):
|
|
self.data[k] = v
|
|
|
|
def get_time(self, *a):
|
|
return None
|
|
|
|
def set_time(self, *a):
|
|
pass
|
|
|
|
|
|
class Pub:
|
|
enabled = False
|
|
|
|
def __init__(self):
|
|
self.last = {}
|
|
|
|
def publish(self, values):
|
|
self.last = dict(values)
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
class LogHass:
|
|
def __init__(self):
|
|
self.log = []
|
|
self.numbers = {"sensor.grid": 3000.0, "sensor.soc_a": 50.0,
|
|
"sensor.batt_a": 0.0}
|
|
|
|
async def number(self, entity, invert=False):
|
|
self.log.append(("number", entity, invert))
|
|
return self.numbers.get(entity)
|
|
|
|
async def limits(self, entity):
|
|
self.log.append(("limits", entity, None))
|
|
return (-3000.0, 3000.0, 1.0)
|
|
|
|
async def set_number(self, entity, value):
|
|
self.log.append(("set_number", entity, value))
|
|
return True
|
|
|
|
async def state(self, entity):
|
|
return None
|
|
|
|
async def call(self, *a):
|
|
return True
|
|
|
|
def writes(self):
|
|
return [v for m, e, v in self.log if m == "set_number"]
|
|
|
|
|
|
def run(coro):
|
|
return asyncio.run(coro)
|
|
|
|
|
|
_real_time = M.time
|
|
|
|
|
|
class _FakeTime:
|
|
"""A clock the test moves; everything else is the real `time`."""
|
|
t = 1000.0
|
|
|
|
def monotonic(self):
|
|
return self.t
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(_real_time, name)
|
|
|
|
|
|
FT = _FakeTime()
|
|
|
|
|
|
def gw(name="inverter-a", suffix="a"):
|
|
return dict(name=name, setpoint_entity=f"number.sp_{suffix}",
|
|
soc_entity=f"sensor.soc_{suffix}",
|
|
batt_entity=f"sensor.batt_{suffix}", max_w=3000,
|
|
bms_soc_floor=10)
|
|
|
|
|
|
def fk(name="inverter-b", driver="hbfake", host="10.9.9.9"):
|
|
return dict(name=name, driver=driver, host=host, port=502, unit_id=3,
|
|
max_w=2000, bms_soc_floor=10)
|
|
|
|
|
|
def site(inverters, auto=True, **over):
|
|
opts = dict(meter_source="off", meter_entity="sensor.grid", max_w=5000,
|
|
gain=0.6, slew_w=0, deadband_w=15, target_grid_w=0, step_w=10,
|
|
heartbeat_s=10, stale_input_s=15, shadow_mode="off",
|
|
inverters=inverters)
|
|
opts.update(over)
|
|
hass = LogHass()
|
|
pub = Pub()
|
|
c = Controller(opts, hass, MemStore(), pub, stop=asyncio.Event())
|
|
for inv in c.inverters:
|
|
inv.dev_min, inv.dev_max = -3000.0, 3000.0
|
|
c.auto = auto
|
|
return c, hass, pub
|
|
|
|
|
|
def fakes(c):
|
|
return [i.driver for i in c.inverters if isinstance(i.driver, HB)]
|
|
|
|
|
|
def n_calls(drv, what):
|
|
return len([1 for w, _a in drv.calls if w == what])
|
|
|
|
|
|
def writes_of(drv):
|
|
return [a for w, a in drv.calls if w == "write"]
|
|
|
|
|
|
async def cycles(c, n=4):
|
|
for _ in range(n):
|
|
await c.read_inputs()
|
|
await c.cycle()
|
|
|
|
|
|
# =============================================================================
|
|
print("SMA-10 - two idle states")
|
|
print()
|
|
print("1. a GoodWe-only roster: every sentence is what it was before this card")
|
|
|
|
BASE = {
|
|
"en": {
|
|
"stop": "Every inverter is set to 0 W until you start control again.",
|
|
"restart": "Every inverter is set to 0 W while the add-on restarts, and "
|
|
"then the new settings apply.",
|
|
"shadow_restart": "Every inverter is set to 0 W while the add-on "
|
|
"restarts, and shadow mode keeps them at 0 W after "
|
|
"that.",
|
|
"setup_shadow": "The add-on restarts with these settings, and shadow "
|
|
"mode keeps every inverter at 0 W.",
|
|
"attn": "The add-on decides everything, but every inverter stays at "
|
|
"0 W.",
|
|
"ctl_shadow": "Shadow mode: every inverter stays at 0 W.",
|
|
"ctl_stopped": "Control is stopped. Every inverter is at 0 W.",
|
|
"banner_stopped": "Stopped — the inverters are set to 0 W",
|
|
"banner_shadow": "Shadow mode: the battery is not controlled. "
|
|
"Inverters stay at 0 W.",
|
|
"banner_shadow_latched": "Shadow mode: battery not controlled. A "
|
|
"safety stop stays on, see below.",
|
|
"hint": "Shadow mode on, inverters stay at 0 W",
|
|
},
|
|
"nl": {
|
|
"stop": "Elke omvormer gaat naar 0 W tot u de regeling opnieuw start.",
|
|
"restart": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, "
|
|
"daarna gelden de nieuwe instellingen.",
|
|
"shadow_restart": "Elke omvormer gaat naar 0 W terwijl de add-on "
|
|
"herstart, en de schaduwmodus houdt ze daarna op "
|
|
"0 W.",
|
|
"setup_shadow": "De add-on herstart met deze instellingen, en de "
|
|
"schaduwmodus houdt elke omvormer op 0 W.",
|
|
"attn": "De add-on beslist alles, maar elke omvormer blijft op 0 W.",
|
|
"ctl_shadow": "Schaduwmodus: elke omvormer blijft op 0 W.",
|
|
"ctl_stopped": "De regeling is gestopt. Elke omvormer staat op 0 W.",
|
|
"banner_stopped": "Gestopt — de omvormers staan op 0 W",
|
|
"banner_shadow": "Schaduwmodus: de batterij wordt niet geregeld. "
|
|
"Omvormers blijven op 0 W.",
|
|
"banner_shadow_latched": "Schaduwmodus: batterij niet geregeld. Een "
|
|
"veiligheidsstop blijft aan, zie hieronder.",
|
|
},
|
|
}
|
|
CLAMP_SHADOW = [
|
|
"Shadow mode is on, so every inverter stays at 0 W.",
|
|
"Shadow mode is on, so the inverter stays at 0 W.",
|
|
"and shadow mode keeps every inverter at 0 W.",
|
|
]
|
|
|
|
|
|
def page_sentences(lang):
|
|
"""The nine page sentences this card moves, as the page gets them."""
|
|
r = uitext.raw(lang)
|
|
s, hw = r["settings"], r["home_words"]
|
|
return {"stop": r["home"]["dialog"]["stop"]["description"],
|
|
"restart": s["dialog"]["description"],
|
|
"shadow_restart": s["setup"]["dialog"]["description"],
|
|
"setup_shadow": s["setup"]["dialog_setup"]["description"],
|
|
"attn": hw["attn_shadow_body"], "ctl_shadow": hw["control"]["shadow"],
|
|
"ctl_stopped": hw["control"]["stopped"]}
|
|
|
|
|
|
cg, _hg, _pg = site([gw("inverter-a", "a"), gw("inverter-b", "b")])
|
|
check("a GoodWe-only Controller leaves the roster with no hand-back unit",
|
|
not handback.active(handback.current()) and uitext.generation() > 0)
|
|
for lang in ("en", "nl"):
|
|
got = page_sentences(lang)
|
|
check(f"[{lang}] the seven page sentences are the literals from before",
|
|
all(got[k] == BASE[lang][k] for k in got))
|
|
on_disk = json.loads((HERE / "app" / "ui_text" / f"{lang}.json")
|
|
.read_text(encoding="utf-8"))
|
|
check(f"[{lang}] the whole page word file is the file on disk, byte for "
|
|
f"byte as data", uitext.raw(lang) == on_disk)
|
|
homeview._LANG[0] = "en"
|
|
check("[en] homeview reads the same control line",
|
|
homeview.control_text(False, False) == BASE["en"]["ctl_stopped"]
|
|
and homeview.control_text(True, True) == BASE["en"]["ctl_shadow"]
|
|
and homeview.attn_shadow_body() == BASE["en"]["attn"])
|
|
homeview._LANG[0] = "nl"
|
|
check("[nl] homeview reads the same control line",
|
|
homeview.control_text(False, False) == BASE["nl"]["ctl_stopped"]
|
|
and homeview.control_text(True, True) == BASE["nl"]["ctl_shadow"]
|
|
and homeview.attn_shadow_body() == BASE["nl"]["attn"])
|
|
homeview._LANG[0] = "en"
|
|
for key, fn in (("banner_stopped", messages.banner_stopped),
|
|
("banner_shadow", lambda: messages.banner_shadow(False)),
|
|
("banner_shadow_latched", lambda: messages.banner_shadow(True))):
|
|
t = fn()
|
|
check(f"{key}: English and Dutch are the literals from before",
|
|
str(t) == BASE["en"][key] and t.nl == BASE["nl"][key])
|
|
check("hint shadow_on is the literal from before",
|
|
messages.hint("shadow_on") == BASE["en"]["hint"])
|
|
bodies = " | ".join([messages.output_clamp((), shadow=True).body,
|
|
messages.output_clamp(
|
|
[messages.Clamp("a", 5, 4, "rating")],
|
|
shadow=True).body,
|
|
messages.output_clamp(
|
|
[messages.Clamp("a", 5, 4, "rating"),
|
|
messages.Clamp("b", 5, 4, "rating")],
|
|
shadow=True).body])
|
|
check("the three shadow-mode output-clamp sentences are the literals from "
|
|
"before", all(x in bodies for x in CLAMP_SHADOW))
|
|
_logs = []
|
|
|
|
|
|
class _Cap(logging.Handler):
|
|
def emit(self, rec):
|
|
_logs.append(rec.getMessage())
|
|
|
|
|
|
_h = _Cap()
|
|
logging.getLogger("goodwe").addHandler(_h)
|
|
logging.getLogger("goodwe").setLevel(logging.DEBUG)
|
|
_LOG_NAMES = [n for n in logging.root.manager.loggerDict
|
|
if n.startswith("goodwe")]
|
|
run(cg.shutdown())
|
|
check("the shutdown INFO line is the literal from before",
|
|
"shutting down - commanding 0 W on every inverter" in _logs)
|
|
_ev = asyncio.Event()
|
|
_t = type("T", (), {"cancelled": lambda s: False,
|
|
"exception": lambda s: RuntimeError("x")})()
|
|
M.control_task_done(_t, controller=type("C", (), {"stopping": False})(),
|
|
stop=_ev, died=[])
|
|
check("the control-task CRITICAL line is the literal from before",
|
|
any(m == "Control stopped unexpectedly (RuntimeError('x')), so every "
|
|
"inverter is set to 0 W and the add-on exits with code 70."
|
|
for m in _logs))
|
|
logging.getLogger("goodwe").removeHandler(_h)
|
|
|
|
# =============================================================================
|
|
print()
|
|
print("2. the sentences for a mixed and a hand-back-only roster")
|
|
|
|
|
|
def units(*inv):
|
|
return handback.units_of(site(list(inv))[0].inverters)
|
|
|
|
|
|
MIXED = units(gw("inverter-a", "a"), fk("inverter-b"))
|
|
ALLHB = units(fk("inverter-a"), fk("inverter-b", host="10.9.9.8"))
|
|
ONEHB = units(fk("inverter-b"))
|
|
TWO_MAKES = units(gw("inverter-a", "a"), fk("inverter-b"),
|
|
fk("inverter-c", "otherhb", "10.9.9.7"))
|
|
SHAPES = (("mixed (GoodWe + hand-back)", MIXED),
|
|
("hand-back only (two)", ALLHB), ("hand-back only (one)", ONEHB),
|
|
("GoodWe + two makes", TWO_MAKES))
|
|
ROWS = (("stop dialog", handback.stop_dialog),
|
|
("restart dialog", handback.restart_dialog),
|
|
("restart dialog, shadow", handback.restart_dialog_shadow),
|
|
("setup dialog, shadow", handback.setup_dialog_shadow),
|
|
("control line, stopped", handback.control_stopped),
|
|
("control line, shadow", handback.control_shadow),
|
|
("attention body, shadow", handback.attn_shadow_body))
|
|
problems = []
|
|
for label, us in SHAPES:
|
|
print(f" --- {label}")
|
|
for what, fn in ROWS:
|
|
for lang in ("en", "nl"):
|
|
t = fn(us, lang)
|
|
print(f" {lang} {what}: {t}")
|
|
if len(words(t)) > 25 or sentences(t) > 2 or BANNED.search(t) \
|
|
or SHOUT.search(t.replace("SMA", "S")):
|
|
if not (what.startswith("control line") and sentences(t) == 2):
|
|
problems.append((label, lang, what, t))
|
|
b = handback.banner_stopped(us)
|
|
print(f" en banner stopped: {b}")
|
|
print(f" nl banner stopped: {b.nl}")
|
|
if len(words(str(b))) > 12 or len(words(b.nl)) > 12:
|
|
problems.append((label, "banner", str(b), b.nl))
|
|
print(f" en hint shadow on: {handback.hint_shadow_on(us)}")
|
|
check("every sentence is plain and short (STYLE.md: 25 words, banner 12, no "
|
|
"banned words)", not problems)
|
|
if problems:
|
|
print(" " + repr(problems))
|
|
m_en = handback.stop_dialog(MIXED, "en")
|
|
m_nl = handback.stop_dialog(MIXED, "nl")
|
|
check("EN stop dialog, mixed roster, is the card's sentence",
|
|
m_en == "Inverter-a is set to 0 W and inverter-b goes back to SMA "
|
|
"control until you start control again.")
|
|
check("NL stop dialog, mixed roster, is the card's sentence",
|
|
m_nl == "Inverter-a gaat naar 0 W en inverter-b gaat terug naar de "
|
|
"sturing van SMA tot u de regeling opnieuw start.")
|
|
check("hand-back only: 'The inverters go back to SMA control ...'",
|
|
handback.stop_dialog(ALLHB, "en") ==
|
|
"The inverters go back to SMA control until you start control again."
|
|
and handback.stop_dialog(ONEHB, "en") ==
|
|
"The inverter goes back to SMA control until you start control again."
|
|
and handback.stop_dialog(ALLHB, "nl") ==
|
|
"De omvormers gaan terug naar de sturing van SMA tot u de regeling "
|
|
"opnieuw start.")
|
|
check("banner (mixed) is the card's: 'Stopped — inverter-a at 0 W, inverter-b "
|
|
"under SMA control'",
|
|
str(handback.banner_stopped(MIXED)) ==
|
|
"Stopped — inverter-a at 0 W, inverter-b under SMA control"
|
|
and handback.banner_stopped(MIXED).nl ==
|
|
"Gestopt — inverter-a op 0 W, inverter-b onder de sturing van SMA")
|
|
ZETA = units(gw(), fk("inverter-c", "otherhb"))
|
|
check("the words come from the DRIVER: another make's words, never 'SMA'",
|
|
handback.stop_dialog(ZETA, "en") == "Inverter-a is set to 0 W and "
|
|
"inverter-c goes back to Zeta control until you start control again."
|
|
and "de sturing van Zeta" in handback.stop_dialog(ZETA, "nl")
|
|
and "SMA" not in handback.stop_dialog(ZETA, "en")
|
|
and "SMA" not in str(handback.banner_stopped(ZETA)))
|
|
check("two makes in one roster: the sentence says 'its own control' rather "
|
|
"than run past 25 words",
|
|
"go back to their own control" in handback.restart_dialog(TWO_MAKES, "en")
|
|
and "SMA" not in handback.restart_dialog(TWO_MAKES, "en")
|
|
and "Zeta" not in handback.restart_dialog(TWO_MAKES, "en"))
|
|
bare = type("D", (), {"hands_back": True, "words": {}})()
|
|
bare_inv = type("I", (), {"name": "x", "driver": bare})()
|
|
check("a hand-back driver with no words gets a plain brand-free phrase",
|
|
handback.stop_dialog((handback.unit_of(bare_inv),), "en") ==
|
|
"The inverter goes back to its own control until you start control "
|
|
"again.")
|
|
# NL default "de eigen sturing" (PR #239 review comment 2495): same article as
|
|
# the generic clause in handback._clause.
|
|
check("NL: a hand-back driver with no words goes back to 'de eigen sturing'",
|
|
handback._BACK_NL == "de eigen sturing"
|
|
and handback.stop_dialog((handback.unit_of(bare_inv),), "nl") ==
|
|
"De omvormer gaat terug naar de eigen sturing tot u de regeling "
|
|
"opnieuw start.")
|
|
long_roster = tuple(handback.Unit(f"inverter-{c}", i % 2 == 1, "SMA control",
|
|
"de sturing van SMA")
|
|
for i, c in enumerate("abcdefg"))
|
|
lb = handback.banner_stopped(long_roster)
|
|
check("a long roster falls back to counts and the banner stays 12 words",
|
|
len(words(str(lb))) <= 12 and len(words(lb.nl)) <= 12
|
|
and "3 under SMA control" in str(lb))
|
|
print(f" long roster banner: {lb} / {lb.nl}")
|
|
|
|
# the Controller builds the page from its own roster
|
|
cm, hm, pm = site([gw("inverter-a", "a"), fk("inverter-b")])
|
|
check("a Controller with a hand-back unit puts the sentences in the page words",
|
|
page_sentences("en")["stop"] == m_en
|
|
and page_sentences("nl")["stop"] == m_nl
|
|
and "SMA control" in page_sentences("en")["ctl_stopped"]
|
|
and messages.banner_stopped() == handback.banner_stopped(MIXED)
|
|
and messages.hint("shadow_on") == handback.hint_shadow_on(MIXED))
|
|
from app import web # noqa: E402
|
|
html = web.page()
|
|
check("...and the assembled page carries them (and not the old stop sentence)",
|
|
m_en in html and BASE["en"]["stop"] not in html and m_nl in html)
|
|
homeview._LANG[0] = "nl"
|
|
check("homeview (nl) says the same in its control line",
|
|
homeview.control_text(False, False) == page_sentences("nl")["ctl_stopped"])
|
|
homeview._LANG[0] = "en"
|
|
check("the shadow banner and the clamp notice no longer say every inverter "
|
|
"stays at 0 W",
|
|
"0 W" not in str(messages.banner_shadow(False))
|
|
and "stays at 0 W and inverter-b stays under SMA control" in
|
|
messages.output_clamp((), shadow=True).body)
|
|
print(f" shadow banner: {messages.banner_shadow(False)}")
|
|
print(f" shadow clamp: {messages.output_clamp((), shadow=True).body}")
|
|
|
|
# =============================================================================
|
|
print()
|
|
print("3. a real Controller with a fake hand-back unit")
|
|
|
|
|
|
async def hb_pause():
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
# --- 3a. Stop: one release; GoodWe 0 W + heartbeat; no later write -----------
|
|
c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")])
|
|
f = fakes(c)[0]
|
|
spy = {"zero": 0, "release": 0}
|
|
_z, _r = c.command_site_zero, c.command_site_release
|
|
|
|
|
|
async def spy_zero():
|
|
spy["zero"] += 1
|
|
await _z()
|
|
|
|
|
|
async def spy_release():
|
|
spy["release"] += 1
|
|
await _r()
|
|
|
|
|
|
c.command_site_zero, c.command_site_release = spy_zero, spy_release
|
|
run(cycles(c, 5))
|
|
check("precondition: both units were being written a non-zero share while "
|
|
"control ran", any(v for v in writes_of(f)) and any(h.writes()))
|
|
n_f = len(writes_of(f))
|
|
n_g = len(h.writes())
|
|
|
|
|
|
async def stop_then_beat():
|
|
await c.handle_action("auto_toggle") # auto True -> False: Stop
|
|
out = {}
|
|
out["after_stop_f"] = len(f.calls)
|
|
for k in range(4): # heartbeats well apart
|
|
await c.send_heartbeats(10_000.0 + 100 * k, 10.0)
|
|
await cycles(c, 3) # and the cycle's own writes
|
|
return out
|
|
|
|
|
|
r = run(stop_then_beat())
|
|
check("STOP: the button calls command_site_release(), not command_site_zero()",
|
|
spy["release"] == 1 and spy["zero"] == 0)
|
|
check("STOP: the fake unit got exactly one release() and nothing after it",
|
|
n_calls(f, "release") == 1 and f.calls[-1] == ("release", None)
|
|
and len(writes_of(f)) == n_f)
|
|
check("STOP: ...no write at all from the heartbeat or the cycle, after it",
|
|
len(f.calls) == r["after_stop_f"] and c.inverters[1].released)
|
|
gw_after = h.writes()[n_g:]
|
|
check("STOP: the GoodWe unit got 0 W, and its heartbeat and cycle go on "
|
|
"writing 0 W (today's behaviour)",
|
|
gw_after and set(gw_after) == {0.0} and len(gw_after) >= 2)
|
|
check("STOP: the MQTT status word is unchanged: 'stopped'",
|
|
(c.publish(), p.last.get("status"))[1] == "stopped")
|
|
|
|
# --- 3b. shutdown after Stop: still one release ------------------------------
|
|
run(c.shutdown())
|
|
check("SHUTDOWN after Stop: still exactly one release(); GoodWe got its 0 W",
|
|
n_calls(f, "release") == 1 and h.writes()[-1] == 0.0
|
|
and spy["release"] == 2 and spy["zero"] == 0)
|
|
|
|
# --- 3c. shutdown from running, then a late heartbeat ------------------------
|
|
c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")])
|
|
f = fakes(c)[0]
|
|
run(cycles(c, 5))
|
|
n_f = len(f.calls)
|
|
|
|
|
|
async def down_then_beat():
|
|
await c.shutdown()
|
|
last = h.writes()[-1]
|
|
await c.send_heartbeats(50_000.0, 10.0)
|
|
await cycles(c, 2) # (the real loop has ended by now)
|
|
return last
|
|
|
|
|
|
gw_last = run(down_then_beat())
|
|
check("SHUTDOWN while running: one release() for the fake unit, no write "
|
|
"after it (heartbeat and cycle included)",
|
|
n_calls(f, "release") == 1 and f.calls[n_f:] == [("release", None)])
|
|
check("SHUTDOWN while running: the GoodWe unit is at 0 W",
|
|
gw_last == 0.0)
|
|
|
|
# --- 3d. a safety stop on the same unit: 0 W written, never released ---------
|
|
c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")])
|
|
f = fakes(c)[0]
|
|
run(cycles(c, 5))
|
|
fb = c.inverters[1]
|
|
|
|
|
|
async def safety():
|
|
c.circ_wd.tripped, c.circ_wd.reason = True, "test" # circulating power
|
|
await c.cycle()
|
|
await c.cycle()
|
|
|
|
|
|
n0 = len(f.calls)
|
|
precondition = writes_of(f)[-1] != 0.0
|
|
run(safety())
|
|
check("SAFETY STOP (circulating power): the fake unit, which was carrying "
|
|
"power, is written 0 W and its release() is never called",
|
|
precondition and writes_of(f)[-1] == 0.0 and n_calls(f, "release") == 0
|
|
and not fb.released and len(f.calls) > n0)
|
|
|
|
|
|
async def nonfinite():
|
|
await c._write_unit(fb, float("nan"), force=True)
|
|
|
|
|
|
c.allocation = None
|
|
run(nonfinite())
|
|
check("SAFETY STOP (non-finite value): 0 W on the fake unit, no release()",
|
|
writes_of(f)[-1] == 0.0 and n_calls(f, "release") == 0)
|
|
|
|
|
|
async def refused_write():
|
|
await c._write_unit(fb, 777.0, force=True) # nothing authorised this
|
|
|
|
|
|
c.allocation = None
|
|
run(refused_write())
|
|
check("SAFETY STOP (write refused): an unauthorised 777 W becomes 0 W on the "
|
|
"fake unit; release() is never called",
|
|
writes_of(f)[-1] == 0.0 and n_calls(f, "release") == 0)
|
|
run(c.command_site_zero())
|
|
check("the maintenance abort path (command_site_zero) holds 0 W on the fake "
|
|
"unit too while control runs, with no release()",
|
|
writes_of(f)[-1] == 0.0 and n_calls(f, "release") == 0)
|
|
|
|
# --- 3e. Start writes again ---------------------------------------------------
|
|
c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")])
|
|
f = fakes(c)[0]
|
|
run(cycles(c, 4))
|
|
run(c.handle_action("auto_toggle")) # Stop
|
|
check("precondition: handed back after Stop",
|
|
c.inverters[1].released and n_calls(f, "release") == 1)
|
|
n0 = len(writes_of(f))
|
|
run(c.handle_action("auto_toggle")) # Start
|
|
run(cycles(c, 5))
|
|
check("START: control writes to the unit again, and it is no longer "
|
|
"'released'", len(writes_of(f)) > n0 and not c.inverters[1].released
|
|
and any(v != 0.0 for v in writes_of(f)[n0:]))
|
|
run(c.handle_action("auto_toggle")) # Stop again
|
|
check("...and a second Stop hands it back again (one release() per Stop)",
|
|
n_calls(f, "release") == 2)
|
|
|
|
# --- 3e2. a write already on its way is finished BEFORE the release ----------
|
|
c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")])
|
|
f = fakes(c)[0]
|
|
f.write_delay = 0.6
|
|
|
|
|
|
async def race():
|
|
fb = c.inverters[1]
|
|
t = asyncio.ensure_future(c._write_unit(fb, 0.0, force=True))
|
|
await asyncio.sleep(0.1) # the write is on its way
|
|
c.auto = False # Stop
|
|
await c.command_site_release()
|
|
await t
|
|
|
|
|
|
run(race())
|
|
print(f" calls on the unit: {[w for w, _a in f.calls]}")
|
|
check("RACE: a write on its way when Stop is pressed finishes first and the "
|
|
"release() is the LAST thing the unit hears",
|
|
[w for w, _a in f.calls] == ["write", "write_done", "release"]
|
|
and c.inverters[1].released)
|
|
|
|
# --- 3f. shadow mode ----------------------------------------------------------
|
|
c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")], shadow_mode="on")
|
|
f = fakes(c)[0]
|
|
run(cycles(c, 5))
|
|
run(c.send_heartbeats(20_000.0, 10.0))
|
|
check("SHADOW: the fake hand-back unit is released once and never written",
|
|
n_calls(f, "release") == 1 and n_calls(f, "write") == 0
|
|
and c.shadow)
|
|
check("SHADOW: the GoodWe unit is held at 0 W as today",
|
|
h.writes() and set(h.writes()) == {0.0})
|
|
c2, h2, p2 = site([gw("inverter-a", "a"), gw("inverter-b", "b")],
|
|
shadow_mode="on")
|
|
run(cycles(c2, 5))
|
|
run(c2.send_heartbeats(20_000.0, 10.0))
|
|
check("SHADOW, GoodWe only: unchanged, 0 W on both units",
|
|
h2.writes() and set(h2.writes()) == {0.0})
|
|
c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")], auto=False,
|
|
shadow_mode="auto")
|
|
f = fakes(c)[0]
|
|
run(cycles(c, 3))
|
|
check("SHADOW (auto, control stopped): released once, no writes",
|
|
n_calls(f, "release") == 1 and n_calls(f, "write") == 0)
|
|
|
|
# --- 3g. mixed/hand-back-only rosters at the edges ---------------------------
|
|
c, h, p = site([fk("inverter-a"), fk("inverter-b", host="10.9.9.8")])
|
|
fa, fb2 = fakes(c)
|
|
run(cycles(c, 4))
|
|
run(c.handle_action("auto_toggle"))
|
|
run(c.send_heartbeats(30_000.0, 10.0))
|
|
check("HAND-BACK ONLY roster: each unit released once, no write after, and "
|
|
"Home Assistant was asked about nothing but the meter",
|
|
n_calls(fa, "release") == 1 and n_calls(fb2, "release") == 1
|
|
and fa.calls[-1] == ("release", None)
|
|
and fb2.calls[-1] == ("release", None)
|
|
and not h.writes())
|
|
|
|
# --- 3h. one unit's release() raising, failing or hanging --------------------
|
|
print(" (the next checks wait for the release bound)")
|
|
c, h, p = site([gw("inverter-a", "a"), fk("inverter-b", host="10.1.1.1"),
|
|
fk("inverter-c", host="10.1.1.2"),
|
|
fk("inverter-d", host="10.1.1.3"),
|
|
fk("inverter-e", host="10.1.1.4")])
|
|
fb_raise, fc_hang, fd_ok, ff_false = fakes(c)
|
|
fb_raise.mode, fc_hang.mode, ff_false.mode = "raise", "hang", "false"
|
|
run(cycles(c, 3))
|
|
c.auto = False # what the Stop button does
|
|
t0 = time.monotonic()
|
|
run(c.command_site_release())
|
|
took = time.monotonic() - t0
|
|
print(f" command_site_release() with one raising, one hanging and one "
|
|
f"failing unit: {took:.2f} s "
|
|
f"(release {M.RELEASE_BUDGET_S:.1f} s + fallback {M.FALLBACK_BUDGET_S:.1f}"
|
|
f" s = {M.HAND_BACK_WORST_S:.1f} s at most)")
|
|
check("the bound: it returns within RELEASE_BUDGET_S + FALLBACK_BUDGET_S plus "
|
|
"a margin, however many units hang (they run together)",
|
|
took < M.HAND_BACK_WORST_S + 1.5)
|
|
check("the healthy unit was released (once) and the GoodWe unit zeroed, "
|
|
"though three others raised, failed or hung",
|
|
n_calls(fd_ok, "release") == 1 and c.inverters[3].released
|
|
and h.writes()[-1] == 0.0)
|
|
check("the units that raised, failed or hung are NOT marked handed back, so "
|
|
"they are tried again", not any(c.inverters[i].released
|
|
for i in (1, 2, 4)))
|
|
fb_raise.mode = "ok"
|
|
FT.t = _real_time.monotonic() + 11.0 # one heartbeat later
|
|
M.time = FT
|
|
run(c.send_heartbeats(60_000.0, 10.0))
|
|
M.time = _real_time
|
|
check("...and the next idle write (a heartbeat later) retries the one that "
|
|
"raised (now answers): released, and the healthy unit is not released a "
|
|
"second time",
|
|
n_calls(fb_raise, "release") == 2 and c.inverters[1].released
|
|
and n_calls(fd_ok, "release") == 1)
|
|
c, h, p = site([gw("inverter-a", "a"), fk("inverter-b"),
|
|
fk("inverter-c", host="10.1.1.2")])
|
|
fb_h, fc_ok = fakes(c)
|
|
fb_h.mode = "hang"
|
|
t0 = time.monotonic()
|
|
run(c.shutdown())
|
|
took = time.monotonic() - t0
|
|
check("shutdown() with one unit hanging finishes inside the bound, the other "
|
|
"unit is released and the GoodWe unit at 0 W",
|
|
took < M.HAND_BACK_WORST_S + 1.5 and n_calls(fc_ok, "release") == 1
|
|
and h.writes()[-1] == 0.0)
|
|
|
|
# --- 3h2. a failed release: one 0 W, retries at heartbeat pace, logged once ---
|
|
print(" (a failed release: the unit carries 1120 W)")
|
|
_cap = []
|
|
|
|
|
|
class _Cap2(logging.Handler):
|
|
def emit(self, rec):
|
|
_cap.append((rec.levelno, rec.getMessage()))
|
|
|
|
|
|
_h2 = _Cap2()
|
|
logging.getLogger("goodwe").addHandler(_h2)
|
|
|
|
|
|
def failing_site(mode):
|
|
c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")])
|
|
f = fakes(c)[0]
|
|
run(cycles(c, 5))
|
|
f.power = 1120.0 # the unit carries 1120 W
|
|
f.mode = mode
|
|
return c, h, f
|
|
|
|
|
|
for mode in ("false", "raise", "hang"):
|
|
c, h, f = failing_site(mode)
|
|
n_w = len(writes_of(f))
|
|
t0 = time.monotonic()
|
|
run(c.handle_action("auto_toggle")) # Stop
|
|
took = time.monotonic() - t0
|
|
print(f" Stop, release {mode}: {took:.2f} s; calls after: "
|
|
f"{[x for x in f.calls[-4:]]}")
|
|
check(f"FAILED RELEASE ({mode}): the unit that carried 1120 W gets exactly "
|
|
f"one 0 W write after the release, inside the bound",
|
|
writes_of(f)[n_w:] == [0.0] and n_calls(f, "release") == 1
|
|
and took < M.HAND_BACK_WORST_S + 1.5
|
|
and not c.inverters[1].released)
|
|
run(c.send_heartbeats(10_000.0, 10.0))
|
|
run(cycles(c, 3))
|
|
check(f"FAILED RELEASE ({mode}): heartbeats and cycles in the same moment "
|
|
f"add no write and no retry",
|
|
writes_of(f)[n_w:] == [0.0] and n_calls(f, "release") == 1)
|
|
|
|
# the cadence and the log, over N failed retries on a clock we move
|
|
c, h, f = failing_site("false")
|
|
M.time = FT
|
|
FT.t = 5000.0
|
|
_cap.clear()
|
|
run(c.handle_action("auto_toggle")) # Stop: try 1 (forced)
|
|
n_w = len(writes_of(f))
|
|
N = 6
|
|
for k in range(N):
|
|
for step in range(5): # idle writes inside a heartbeat
|
|
FT.t += 1.9
|
|
run(c.send_heartbeats(20_000.0 + FT.t, 10.0))
|
|
run(cycles(c, 1))
|
|
FT.t += 1.0 # the heartbeat is due
|
|
run(c.send_heartbeats(40_000.0 + FT.t, 10.0))
|
|
print(f" {N} heartbeats: release() called {n_calls(f, 'release')} times, "
|
|
f"0 W writes after the first: {writes_of(f)[n_w:]}")
|
|
check("CADENCE: one try at Stop plus one per heartbeat, not one per idle "
|
|
"write", n_calls(f, "release") == 1 + N)
|
|
check("ONE 0 W only, however many retries fail: the inverter's fallback timer "
|
|
"is not reset", writes_of(f)[n_w - 1:] == [0.0])
|
|
errs = [m for lv, m in _cap if lv >= logging.ERROR and "inverter-b" in m]
|
|
evs = [e for e in c.events if "hand-back failed" in str(e)]
|
|
check(f"LOG: {N + 1} failed tries make ONE error line and ONE action-log event",
|
|
len(errs) == 1 and len(evs) == 1)
|
|
f.mode = "ok"
|
|
FT.t += 11.0
|
|
run(c.send_heartbeats(90_000.0, 10.0))
|
|
for _ in range(3):
|
|
FT.t += 11.0
|
|
run(c.send_heartbeats(95_000.0 + FT.t, 10.0))
|
|
inf = [m for lv, m in _cap if "worked again" in m]
|
|
check("RECOVERY: logged once ('worked again after 7 failed tries'), released, "
|
|
"and no further release() calls",
|
|
len(inf) == 1 and "7 failed" in inf[0] and c.inverters[1].released
|
|
and n_calls(f, "release") == 2 + N
|
|
and len([e for e in c.events if "inverter-b: handed back" in str(e)]) == 1)
|
|
run(c.handle_action("auto_toggle")) # Start
|
|
run(cycles(c, 3))
|
|
f.mode = "false"
|
|
_cap.clear()
|
|
run(c.handle_action("auto_toggle")) # Stop, fails again
|
|
check("a NEW failure streak is logged again, once",
|
|
len([1 for lv, m in _cap if lv >= logging.ERROR]) == 1)
|
|
M.time = _real_time
|
|
|
|
# shutdown with a failed release: the one 0 W still goes out inside the bound
|
|
c, h, f = failing_site("hang")
|
|
n_w = len(writes_of(f))
|
|
t0 = time.monotonic()
|
|
run(c.shutdown())
|
|
took = time.monotonic() - t0
|
|
print(f" shutdown, release hangs: {took:.2f} s (worst {M.HAND_BACK_WORST_S:.1f} s)")
|
|
check("SHUTDOWN with a failed release: the one 0 W is sent, inside the bound",
|
|
writes_of(f)[n_w:] == [0.0] and took < M.HAND_BACK_WORST_S + 1.5)
|
|
check("a driver's own fallback replaces the 0 W (the hook), not the add-on",
|
|
"release_fallback" in dir(HB) and HB.release_fallback is
|
|
drivers.Driver.release_fallback)
|
|
|
|
# a failed streak that Start interrupts WITHOUT a recovery: the next Stop is a
|
|
# new streak with its own one 0 W, its own error line and its own event
|
|
c, h, f = failing_site("false")
|
|
_cap.clear()
|
|
run(c.handle_action("auto_toggle")) # Stop, release fails
|
|
w1 = writes_of(f).count(0.0)
|
|
run(c.handle_action("auto_toggle")) # Start, no recovery
|
|
iv = c.inverters[1]
|
|
run(cycles(c, 5))
|
|
nz = [w for w in writes_of(f) if w]
|
|
check("START after a failed release: control writes non-zero again and the "
|
|
"failed-streak state is cleared",
|
|
nz and writes_of(f)[-1] != 0.0 and iv.release_fails == 0
|
|
and not iv.release_zero_sent and iv.release_retry_at == 0.0)
|
|
n_before = len(writes_of(f))
|
|
run(c.handle_action("auto_toggle")) # Stop, release fails again
|
|
after = writes_of(f)[n_before:]
|
|
errs = [m for lv, m in _cap if lv >= logging.ERROR and "inverter-b" in m]
|
|
evs = [e for e in c.events if "hand-back failed" in str(e)]
|
|
check("SECOND STREAK (Start came between, no recovery): the unit gets its own "
|
|
"one 0 W write",
|
|
after == [0.0] and w1 == 1)
|
|
check("SECOND STREAK: a second error line and a second action-log event",
|
|
len(errs) == 2 and len(evs) == 2)
|
|
run(c.send_heartbeats(70_000.0, 10.0))
|
|
check("...and still only the one 0 W in that streak",
|
|
writes_of(f)[n_before:] == [0.0])
|
|
|
|
# the hook: a driver's own fallback replaces the 0 W, and _hand_back does not
|
|
# call write(0) itself
|
|
class HookHB(HB):
|
|
name = "hookhb"
|
|
|
|
async def release_fallback(self):
|
|
self.calls.append(("fallback", None))
|
|
return await self.write(5.0) # a non-zero safe value
|
|
|
|
|
|
REGISTRY["hookhb"] = HookHB
|
|
c, h, p = site([gw("inverter-a", "a"), fk("inverter-b", "hookhb")])
|
|
f = fakes(c)[0]
|
|
run(cycles(c, 4))
|
|
f.mode = "false"
|
|
n0 = len(f.calls)
|
|
run(c.handle_action("auto_toggle"))
|
|
tail = [x for x in f.calls[n0:] if x[0] in ("release", "fallback", "write")]
|
|
print(f" a driver with its own fallback: {tail}")
|
|
check("HOOK: after a failed release the driver's release_fallback() is called "
|
|
"once, its own value (5 W) is the only write, and the add-on does not "
|
|
"write 0 W itself",
|
|
tail == [("release", None), ("fallback", None), ("write", 5.0)])
|
|
logging.getLogger("goodwe").removeHandler(_h2)
|
|
|
|
# --- 3i. per-unit state and the other counts ---------------------------------
|
|
c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")])
|
|
run(cycles(c, 4))
|
|
rows = {r["name"]: r for r in homeview.battery_rows(c, False, True, False)}
|
|
check("before Stop neither unit says it is handed back",
|
|
"handed_back" not in rows["inverter-a"]
|
|
and "handed_back" not in rows["inverter-b"])
|
|
run(c.handle_action("auto_toggle"))
|
|
homeview._LANG[0] = "en"
|
|
rows = {r["name"]: r for r in homeview.battery_rows(c, False, False, False)}
|
|
print(f" per-unit state after Stop: inverter-a: {rows['inverter-a']['now']!r}"
|
|
f"; inverter-b: {rows['inverter-b']['now']!r}")
|
|
check("PER-UNIT STATE: the handed-back unit says so in its driver's words "
|
|
"(not '0 W'); the GoodWe unit does not change",
|
|
rows["inverter-b"].get("handed_back") is True
|
|
and rows["inverter-b"]["now"] == "Back under SMA control"
|
|
and "handed_back" not in rows["inverter-a"]
|
|
and "0 W" in rows["inverter-a"]["now"])
|
|
homeview._LANG[0] = "nl"
|
|
rows = {r["name"]: r for r in homeview.battery_rows(c, False, False, False)}
|
|
check("PER-UNIT STATE (nl)",
|
|
rows["inverter-b"]["now"] == "Terug onder de sturing van SMA")
|
|
homeview._LANG[0] = "en"
|
|
check("the MQTT status word is 'stopped' as before",
|
|
(c.publish(), p.last.get("status"))[1] == "stopped")
|
|
check("the log says which unit was handed back",
|
|
any("inverter-b: handed back" in str(e) for e in c.events))
|
|
check("the two new Last-event hints exist and are plain",
|
|
messages.hint("handed_back", unit="inverter-b").endswith("to it")
|
|
and messages.hint("hand_back_failed", unit="inverter-b")
|
|
.endswith("trying again"))
|
|
|
|
# --- 3j. the call sites -------------------------------------------------------
|
|
src = (HERE / "app" / "main.py").read_text(encoding="utf-8")
|
|
ss = src.splitlines()
|
|
|
|
|
|
def calls_in(name):
|
|
"""Line numbers of every `await self.<name>(...)` in main.py (SAFETY-137:
|
|
the all-GoodWe branch passes `last=True`)."""
|
|
return [i + 1 for i, ln in enumerate(ss) if f"await self.{name}(" in ln]
|
|
|
|
|
|
rel, zer = calls_in("command_site_release"), calls_in("command_site_zero")
|
|
print(f" main.py call sites: command_site_release at lines {rel}; "
|
|
f"command_site_zero at lines {zer}")
|
|
check("main.py: the Stop button and shutdown() call command_site_release(); "
|
|
"the two maintenance-abort paths keep command_site_zero() (a third call "
|
|
"is the all-GoodWe branch inside command_site_release itself)",
|
|
len(rel) == 2 and len(zer) == 3
|
|
and "async def command_site_release" in "\n".join(ss[zer[1] - 30:zer[1]]))
|
|
|
|
# =============================================================================
|
|
print()
|
|
print("4. the real main() in a child process")
|
|
|
|
|
|
def events_of(ev, who, what):
|
|
return [e for e in ev if e[0] == who and e[1] == what]
|
|
|
|
|
|
rc, err, ev = run_child("sigterm")
|
|
print(f" SIGTERM: exit status {rc}; fake events "
|
|
f"{[e[1:] for e in ev if e[0] == 'fake']}")
|
|
i_sig = ev.index(["sigterm"]) if ["sigterm"] in ev else None
|
|
after = ev[i_sig + 1:] if i_sig is not None else []
|
|
check("SIGTERM: the process ends cleanly (0)", rc == 0)
|
|
check("SIGTERM: the fake unit is released exactly once, after the signal, and "
|
|
"written nothing after that",
|
|
len(events_of(after, "fake", "release")) == 1
|
|
and [e for e in after if e[0] == "fake"][-1] == ["fake", "release"]
|
|
and ["cleanup"] in after)
|
|
check("SIGTERM: the GoodWe unit gets 0 W on the way out",
|
|
[e for e in after if e[0] == "goodwe"][-1] == ["goodwe", "write", 0.0])
|
|
check("SIGTERM: the fake unit was written before the signal (the test is not "
|
|
"vacuous)", len(events_of(ev[:i_sig], "fake", "write")) >= 1)
|
|
check("SIGTERM: the log says what happened to each unit",
|
|
"shutting down - commanding 0 W on inverter-a and handing back "
|
|
"inverter-b to SMA control" in err)
|
|
|
|
rc, err, ev = run_child("fault")
|
|
i_f = ev.index(["fault"]) if ["fault"] in ev else None
|
|
after = ev[i_f + 1:] if i_f is not None else []
|
|
print(f" task death: exit status {rc}; after the fault "
|
|
f"{[e[1:] for e in after if e[0] in ('fake', 'goodwe')]}")
|
|
check("TASK DEATH: exit status 70", rc == 70)
|
|
check("TASK DEATH: the fake unit is released exactly once and nothing is "
|
|
"written to it after; the GoodWe unit is at 0 W",
|
|
len(events_of(after, "fake", "release")) == 1
|
|
and [e for e in after if e[0] == "fake"][-1] == ["fake", "release"]
|
|
and [e for e in after if e[0] == "goodwe"][-1] == ["goodwe", "write", 0.0])
|
|
check("TASK DEATH: the CRITICAL line says 0 W for one and hand-back for the "
|
|
"other",
|
|
"so inverter-a is set to 0 W and inverter-b is handed back and the "
|
|
"add-on exits with code 70" in err)
|
|
|
|
rc, err, ev = run_child("refuse")
|
|
print(f" late config refusal: exit status {rc}; events "
|
|
f"{[e[1:] for e in ev if e[0] in ('fake', 'goodwe')]}")
|
|
check("CONFIG REFUSAL after the drivers exist: exit status 78, the fake unit "
|
|
"released exactly once, never written",
|
|
rc == 78 and len(events_of(ev, "fake", "release")) == 1
|
|
and not events_of(ev, "fake", "write"))
|
|
check("CONFIG REFUSAL: the GoodWe unit is at 0 W",
|
|
events_of(ev, "goodwe", "write")
|
|
and events_of(ev, "goodwe", "write")[-1] == ["goodwe", "write", 0.0])
|
|
|
|
rc, err, ev = run_child("badcfg")
|
|
print(f" startup refusal: exit status {rc}; events {ev}")
|
|
check("CONFIG REFUSAL before any driver exists: exit 78 as today, and nothing "
|
|
"is released or written", rc == 78 and not ev)
|
|
|
|
# =============================================================================
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED:")
|
|
for n in fails:
|
|
print(" -", n)
|
|
sys.exit(1)
|
|
print("all passed")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MUTANTS this file was run against (one edit each to a copy of the tree; every
|
|
# one turns it red). The two the card names are the first two.
|
|
# a. a safety stop (a 0 W write while control runs) calls release(): the
|
|
# idle test in Controller._write_unit gains `or value == 0.0`.
|
|
# b. the heartbeat (and the cycle) write to a released unit: the `return`
|
|
# after the idle hand-back in _write_unit is removed.
|
|
# c. the unit is released twice (Stop, then shutdown): the `if inv.released:
|
|
# return True` guard in _hand_back is removed.
|
|
# d. the Stop button calls command_site_zero() instead of the release.
|
|
# e. shutdown() calls command_site_zero() instead of the release.
|
|
# f. shadow mode is not idle for a hand-back unit.
|
|
# g. Start does not re-arm a released unit.
|
|
# h. the release is awaited without a bound (a hanging unit holds shutdown).
|
|
# i. a raising release() is not caught (it stops the others).
|
|
# j. a GoodWe unit is "released" instead of zeroed on Stop.
|
|
# k. the release does not wait for a write on its way (it is not the last).
|
|
# l. the hand-back words are hard-coded "SMA control", not the driver's.
|
|
# m. a GoodWe-only roster gets a changed banner.
|
|
# n. (round 2) the fallback 0 W is sent on every failed try.
|
|
# o. (round 2) a failed release is retried on every idle write.
|
|
# p. (round 2) every failed try is logged.
|
|
# q. (round 2) no fallback 0 W after a failed release.
|
|
# r. (round 3) Start does not clear a failed streak.
|
|
# s. (round 3) _hand_back writes 0 W itself instead of calling the hook.
|