None of the three is a bug in the shipped code. They are gaps in what the
suite could detect, and the next person to touch freeze.py had less
protection than the passing count implied.
1. WHERE check_meter_frozen() runs in cycle(). Moving it to the top left
the whole file green. Pinned by CONSEQUENCE, not by source order, so a
refactor that keeps the semantics stays green:
- the alarm must land on the SAME cycle the command reaches the rail
(at the top it grades the previous command against this cycle's
meter and arrives a cycle late);
- the dead-poll-task composition: poll task dies, stale_input_s is
crossed, the arbiter commands 0 W, and on THAT cycle the alarm is
already clear - because the detector ran after the 0 W write.
wired() now returns a per-cycle trace, which is what makes "on which
cycle" an observation rather than a hope.
2. The NOT COVERED status row. The old check only asserted that an
uncovered transport is SILENT, which a page claiming `armed` on a guard
that cannot run also satisfies. The string is now asserted in both
directions: the uncovered row must say NOT COVERED, name the
meter_source and homewizard_local, and warn; the covered row must still
say armed, or "does not say armed" would be satisfied by deleting the
armed branch.
3. meter_freeze_s. Pinned in test_validate.py by MAINT-04's regex
technique (no PyYAML in the image), and in test_freeze.py by its
consequences: the shipped value drives the replays, must stay inside
the 900 s billing quarter, and 3600/hold_s must stay inside the shipped
deadband. A Controller built with the key ABSENT must land on the same
number, which pins main.py's fallback without touching CFG-03.
All three demonstrated red before being accepted green, and every new
check was mutated to confirm it can fail; one candidate assertion ("no new
notification while parked at 0 W") was DELETED because no mutation could
break it, and replaced by the exact notification count across a revived
transport, which can.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
545 lines
28 KiB
Python
545 lines
28 KiB
Python
"""Runnable check for the startup gate. `python3 test_validate.py`
|
|
|
|
Two halves, and the second is the one that matters. The first asserts that the
|
|
three refusable configurations are refused. The second asserts that everything
|
|
on validate.py's "deliberately not refused" list still STARTS - because the way
|
|
a validator dies is not by missing a fault, it is by refusing something harmless
|
|
until somebody deletes the call.
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
|
|
from app.validate import (
|
|
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
|
|
validate_startup_config,
|
|
)
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
def base(**over):
|
|
"""A configuration that must start. Everything else is this, spoilt."""
|
|
# ⚠️ The maintenance rates are named EXPLICITLY, at the values config.yaml
|
|
# ships (MAINT-04). They are not decoration: with the key absent, rule 6
|
|
# falls back to main.py's 2500 W, which against this 2000 W ceiling is a
|
|
# genuine refusal - so a base() without them would be a config that does
|
|
# not start, and every check below would be measuring the wrong thing.
|
|
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
|
"meter_source": "off",
|
|
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
|
|
o.update(over)
|
|
return o
|
|
|
|
|
|
def why(problems, needle):
|
|
return any(needle in p for p in problems)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the baseline must start, or nothing below means anything")
|
|
check("a good config with a 5000 W device is accepted",
|
|
validate_startup_config(base(), -5000, 5000) == [])
|
|
check("...and with no device reachable yet, too",
|
|
validate_startup_config(base(), None, None) == [])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("1. max_w against the device's own rating")
|
|
# ⚠️ THE ORIGINAL BUG. main.py used to log 'the device wins' and run anyway,
|
|
# with write_setpoint() clamping, so the configured ceiling was never the real
|
|
# one and the only evidence had scrolled off the log.
|
|
p = validate_startup_config(base(max_w=8000), -5000, 5000)
|
|
check("max_w above the device maximum is refused", len(p) == 1)
|
|
check("...and the message names the configured value", why(p, "8000"))
|
|
check("...and the device's value", why(p, "5000"))
|
|
check("...and says which one wins", why(p, "THE DEVICE WINS"))
|
|
check("max_w exactly at the device maximum is fine",
|
|
validate_startup_config(base(max_w=5000), -5000, 5000) == [])
|
|
check("max_w under it is fine",
|
|
validate_startup_config(base(max_w=2000), -5000, 5000) == [])
|
|
# The charge side is a separate rail and an asymmetric device is legal.
|
|
p = validate_startup_config(base(max_w=4000), -2500, 5000)
|
|
check("a max_w the device cannot take on the CHARGE side is refused too",
|
|
len(p) == 1 and why(p, "charge side"))
|
|
check("...naming the device's floor", why(p, "-2500"))
|
|
# ⚠️ Not guessed at: the entity may simply not have booted yet. run_control()
|
|
# re-runs this the moment the real rating arrives.
|
|
check("an unreachable device skips the range check rather than assuming",
|
|
validate_startup_config(base(max_w=8000), None, None) == [])
|
|
check("a non-numeric max_w is refused",
|
|
why(validate_startup_config(base(max_w="lots"), -5000, 5000), "not a number"))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("2. an actuator to write to")
|
|
# A controller with no actuator: write_setpoint() returns on the blank entity,
|
|
# so no rating is read, no heartbeat is sent, layer 1 pins the inverter at 0 W,
|
|
# and the add-on looks perfectly healthy the whole time.
|
|
for blank in ("", " ", None):
|
|
p = validate_startup_config(base(setpoint_entity=blank), None, None)
|
|
check(f"setpoint_entity {blank!r} is refused", len(p) == 1)
|
|
check("...and the message says why it is dangerous rather than untidy",
|
|
why(validate_startup_config(base(setpoint_entity=""), None, None),
|
|
"heartbeat"))
|
|
# specs §1.5: two writers on one actuator is not expressible in this schema.
|
|
check("there is exactly one way to reach the actuator",
|
|
ACTUATOR_WRITE_KEYS == ("setpoint_entity",))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("3. a meter that can never read")
|
|
# ⚠️ These predicates belong to p1.validate_meter_options(), which build_source()
|
|
# also uses. Asserted here to prove the gate actually consults it - one entry
|
|
# point, not two.
|
|
check("meter_source off asks nothing of the P1 keys",
|
|
validate_startup_config(base(meter_source="off"), -5000, 5000) == [])
|
|
check("homewizard_local with no p1_host is refused",
|
|
why(validate_startup_config(base(meter_source="homewizard_local"),
|
|
-5000, 5000), "p1_host"))
|
|
check("...and with a host it is accepted",
|
|
validate_startup_config(base(meter_source="homewizard_local",
|
|
p1_host="192.168.2.250"), -5000, 5000) == [])
|
|
check("a poll cadence at meter_max_age_s is refused at STARTUP, not just disabled",
|
|
why(validate_startup_config(base(meter_source="homewizard_local",
|
|
p1_host="m", meter_poll_s=30,
|
|
meter_max_age_s=30), -5000, 5000),
|
|
"meter_max_age_s"))
|
|
check("ha_signed with no p1_net_entity is refused",
|
|
why(validate_startup_config(base(meter_source="ha_signed"), -5000, 5000),
|
|
"p1_net_entity"))
|
|
check("a phase list disagreeing with meter_phases is refused",
|
|
why(validate_startup_config(base(meter_source="ha_signed",
|
|
p1_net_entity="sensor.n", meter_phases=3,
|
|
p1_phase_net_entities=["a", "b"]),
|
|
-5000, 5000), "phase-count"))
|
|
check("mqtt_p1 with no topic is refused",
|
|
why(validate_startup_config(base(meter_source="mqtt_p1"), -5000, 5000),
|
|
"meter_mqtt_topic"))
|
|
check("ha_dsmr with only half its entities is refused",
|
|
why(validate_startup_config(base(meter_source="ha_dsmr",
|
|
p1_import_entity="sensor.i"), -5000, 5000),
|
|
"p1_export_entity"))
|
|
check("a misspelt meter_source is refused rather than silently ignored",
|
|
why(validate_startup_config(base(meter_source="ha_signd"), -5000, 5000),
|
|
"is not one of"))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("4. meter_max_age_s against the firmware's compile-time veto")
|
|
# ⚠️ The schema allows int(5,300); the firmware's threshold is baked in at 30 s
|
|
# and wins silently. Same shape as the max_w case: a configured bound overridden
|
|
# by a lower layer, with nothing saying so.
|
|
hw = dict(meter_source="homewizard_local", p1_host="m")
|
|
p = validate_startup_config(base(**hw, meter_max_age_s=120), -5000, 5000)
|
|
check("a meter_max_age_s above the firmware veto is refused", len(p) == 1)
|
|
check("...naming the configured value", why(p, "120"))
|
|
check("...naming the firmware's value", why(p, "30"))
|
|
check("...and saying which one wins", why(p, "THE FIRMWARE WINS"))
|
|
check("the tested pair 30/30 is accepted",
|
|
validate_startup_config(base(**hw, meter_max_age_s=30), -5000, 5000) == [])
|
|
check("one second above it is refused",
|
|
validate_startup_config(base(**hw, meter_max_age_s=31), -5000, 5000) != [])
|
|
# Below the veto the add-on gives up FIRST, which is fail-safe and visible on
|
|
# its own status page. Only above is the silent case.
|
|
check("below the veto is fine - the add-on being stricter is not a conflict",
|
|
validate_startup_config(base(**hw, meter_max_age_s=10,
|
|
meter_poll_s=4), -5000, 5000) == [])
|
|
# ⚠️ Scope: meter_max_age_s is read by nothing but P1Ingest, and the veto keys
|
|
# off a sensor only published when ingestion runs. With the meter off it governs
|
|
# nothing, so refusing would be pure trivia.
|
|
check("with meter_source off the number governs nothing and is not refused",
|
|
validate_startup_config(base(meter_source="off", meter_max_age_s=300),
|
|
-5000, 5000) == [])
|
|
check("the constant is the firmware's, not a rounded guess",
|
|
FIRMWARE_INGEST_VETO_S == 30.0)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("6. a maintenance charge the ceiling can actually accommodate (MAINT-04)")
|
|
# ⚠️ THE SHIPPED BUG. config.yaml shipped max_w 2000 against
|
|
# maintenance_charge_w 2500, so the cheap-window shaping asked for hi=-2500
|
|
# against the supervised lo=-2000 - lo > hi, arbiter clause 3, 0 W. Maintenance
|
|
# silently never charged in a cheap window on a default install.
|
|
p = validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
|
|
-5000, 5000)
|
|
check("maintenance_charge_w above max_w is refused", len(p) == 1)
|
|
check("...and the message names the charge rate", why(p, "2500"))
|
|
check("...and the ceiling", why(p, "2000"))
|
|
check("...and says which one wins", why(p, "THE CEILING WINS"))
|
|
# ⚠️ Equality is REACHABLE, not a near miss: lo=-2000, hi=-2000 is the single
|
|
# point -2000 W. Refusing it would be exactly the trivia that gets a validator
|
|
# deleted, and it is the boundary the rule is written `>` for.
|
|
check("maintenance_charge_w exactly at max_w starts",
|
|
validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
|
|
-5000, 5000) == [])
|
|
check("below it starts",
|
|
validate_startup_config(base(max_w=2000, maintenance_charge_w=1500),
|
|
-5000, 5000) == [])
|
|
# Not scoped to maintenance_enabled: the pair is contradictory either way, and
|
|
# scoping would leave the trap armed for the one action - ticking the box - that
|
|
# cannot be expected to change what a charge rate means.
|
|
check("...and it is refused with maintenance_enabled off, as shipped",
|
|
why(validate_startup_config(
|
|
base(max_w=2000, maintenance_charge_w=2500,
|
|
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
|
|
|
|
# ⚠️ THE SIBLING, CHECKED RATHER THAN ASSUMED. The shaping is NOT symmetric:
|
|
# drain files Claim.set at P_MAINTENANCE and owns the actuator, so the ceiling
|
|
# CLAMPS it (+2000 W) instead of contradicting it, and the arbiter's explain()
|
|
# names the clamp every cycle. Loud, and it still moves energy - so warned, not
|
|
# refused. If someone later "fixes the asymmetry" by making this a refusal,
|
|
# this check goes red and they have to justify it.
|
|
check("maintenance_discharge_w above max_w WARNS but starts",
|
|
validate_startup_config(base(max_w=2000, maintenance_discharge_w=2500),
|
|
-5000, 5000) == [])
|
|
|
|
# The demonstration the rule is derived from, asserted rather than described.
|
|
from app.arbiter import ( # noqa: E402
|
|
Claim, P_LOOP, P_MAINTENANCE, P_MAINT_SHAPE, P_SAFETY, resolve,
|
|
)
|
|
|
|
def _charge(max_w, charge_w):
|
|
return resolve([Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"),
|
|
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-charge_w,
|
|
reason="cheap-window charge"),
|
|
Claim.set("loop", P_LOOP, 500.0, "grid")])
|
|
|
|
def _drain(max_w, disch_w):
|
|
return resolve([Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"),
|
|
Claim.set("maintenance", P_MAINTENANCE, disch_w, "drain"),
|
|
Claim.set("loop", P_LOOP, 500.0, "grid")])
|
|
|
|
r = _charge(2000, 2500)
|
|
check("arbiter: the refused pair really does emit 0 W",
|
|
r.target_w == 0.0 and r.contradiction)
|
|
r = _charge(2000, 2000)
|
|
check("arbiter: the permitted equality really does charge",
|
|
r.target_w == -2000.0 and not r.contradiction)
|
|
r = _charge(3000, 2500)
|
|
check("arbiter: raising max_w above the rate charges at the rate",
|
|
r.target_w == -2500.0 and not r.contradiction)
|
|
r = _drain(2000, 2500)
|
|
check("arbiter: the drain SET is clamped, not contradicted",
|
|
r.target_w == 2000.0 and not r.contradiction)
|
|
check("...and the clamp names itself, which is why it is not refused",
|
|
"supervised" in (r.bound_by or "") and "limited by" in r.explain())
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
|
|
# ⚠️ Shipping a default the validator refuses would be worse than the bug it
|
|
# refuses. Read config.yaml itself rather than a transcription of it - a
|
|
# transcription is what lets the two drift apart silently. Regex, not PyYAML:
|
|
# the add-on image has no PyYAML (Dockerfile installs py3-aiohttp/py3-paho-mqtt
|
|
# only) and a check that skips on ImportError is not a check.
|
|
import os # noqa: E402
|
|
import re # noqa: E402
|
|
|
|
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
|
with open(_CFG, encoding="utf-8") as _fh:
|
|
_txt = _fh.read()
|
|
_shipped = {k: float(v) for k, v in re.findall(
|
|
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w): "
|
|
r"(-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
|
check("config.yaml really was read (all four defaults found)",
|
|
set(_shipped) == {"max_w", "maintenance_charge_w",
|
|
"maintenance_discharge_w", "sign_disagree_w"})
|
|
check(f"the shipped defaults are accepted {_shipped}",
|
|
validate_startup_config(base(**_shipped), -5000, 5000) == [])
|
|
check("...and the shipped charge rate is at or under the shipped ceiling",
|
|
_shipped["maintenance_charge_w"] <= _shipped["max_w"])
|
|
check("...and the shipped discharge rate too, so it warns about nothing",
|
|
_shipped["maintenance_discharge_w"] <= _shipped["max_w"])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the shipped meter_freeze_s, pinned (TEST-06 / AC3)")
|
|
# ⚠️ WHY A NUMBER GETS ITS OWN CHECK. `meter_freeze_s: 300` is the threshold
|
|
# SAFETY-18's entire argument is about, and before this it was pinned NOWHERE:
|
|
# 300 -> 3600 left test_freeze.py AND this file green, because the schema
|
|
# accepts int(60,3600) and no check ever read the shipped value. The schema
|
|
# bounds what is CONFIGURABLE; this pins what is SHIPPED, which is a different
|
|
# question and the one the docs answer.
|
|
#
|
|
# Read by regex like _shipped above, for the same reason: no PyYAML in the
|
|
# image, and a check that skips on ImportError is not a check.
|
|
_freeze_s = [float(v) for v in re.findall(
|
|
r"^ meter_freeze_s: (\d+(?:\.\d+)?)\s*$", _txt, re.M)]
|
|
_freeze_schema = re.findall(r"^ meter_freeze_s: int\((\d+),(\d+)\)\s*$",
|
|
_txt, re.M)
|
|
check(f"config.yaml carries exactly one meter_freeze_s default {_freeze_s}",
|
|
len(_freeze_s) == 1)
|
|
check("...and exactly one schema range for it", len(_freeze_schema) == 1)
|
|
_hold_s = _freeze_s[0] if _freeze_s else None
|
|
_lo, _hi = ((float(_freeze_schema[0][0]), float(_freeze_schema[0][1]))
|
|
if _freeze_schema else (None, None))
|
|
check("the shipped meter_freeze_s is still the measured 300 s that freeze.py, "
|
|
"config.yaml's own comment and test_freeze.py are all written at",
|
|
_hold_s == 300.0)
|
|
check(f"...and it is inside its own schema range ({_lo}-{_hi} s)",
|
|
None not in (_lo, _hi, _hold_s) and _lo <= _hold_s <= _hi)
|
|
|
|
# The two properties the number has to have, DERIVED rather than restated - so
|
|
# they go red on a value that is merely schema-legal. Both come straight from
|
|
# config.yaml's own comment on the key.
|
|
#
|
|
# upper: the alert must land inside the 900 s quarter that bills the capacity
|
|
# tariff, or the notification arrives after the money is spent;
|
|
# lower: the slowest a HEALTHY meter can tick a 1 Wh register and still look
|
|
# unchanged for hold_s is 3600/hold_s W, and that must stay inside the
|
|
# deadband or the conjunction's two terms stop being mutually
|
|
# exclusive (freeze.py, "WHERE hold_s COMES FROM").
|
|
_QUARTER_S = 900.0
|
|
_shipped_deadband = [float(v) for v in re.findall(
|
|
r"^ deadband_w: (\d+(?:\.\d+)?)\s*$", _txt, re.M)]
|
|
check(f"config.yaml carries exactly one deadband_w default {_shipped_deadband}",
|
|
len(_shipped_deadband) == 1)
|
|
_db = _shipped_deadband[0] if _shipped_deadband else None
|
|
_slowest_healthy_w = 3600.0 / _hold_s if _hold_s else None
|
|
print(f" shipped meter_freeze_s {_hold_s} s: alert inside the "
|
|
f"{_QUARTER_S:.0f} s billing quarter, and a healthy meter slow enough to "
|
|
f"look unchanged for it is {_slowest_healthy_w} W against a "
|
|
f"{_db} W deadband")
|
|
check(f"...the alarm still lands inside the {_QUARTER_S:.0f} s billing quarter",
|
|
_hold_s is not None and _hold_s < _QUARTER_S)
|
|
check("...and a healthy meter that looks unchanged for it is still inside the "
|
|
"deadband, so the two terms stay mutually exclusive",
|
|
_slowest_healthy_w is not None and _db is not None
|
|
and _slowest_healthy_w < _db)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the KNOWN cross-file default divergence, pinned rather than commented")
|
|
# ⚠️ WHAT THIS GUARDS, AND WHY IT ASSERTS THE BUG RATHER THAN THE FIX.
|
|
# config.yaml and validate.py now default maintenance_charge_w to 2000, while
|
|
# main.py's two `opts.get(..., 2500)` fallbacks and MaintConfig's dataclass
|
|
# default still say 2500. So an options dict with the key ABSENT is ACCEPTED by
|
|
# rule 6 at 2000 and then built by main.py at 2500 - which is a contradiction:
|
|
#
|
|
# validate_startup_config -> [] (accepted)
|
|
# MaintConfig().charge_w -> 2500.0
|
|
# arbiter -> 0.0 W, contradiction=True
|
|
#
|
|
# That is the bug rule 6 exists to catch, passing rule 6. It is unreachable on a
|
|
# Supervisor install (every key is always supplied from config.yaml) but fully
|
|
# reachable from sim/inject.py and from tests - which is exactly where anyone
|
|
# debugging maintenance works.
|
|
#
|
|
# The fix is ONE SOURCE OF TRUTH for option defaults. That is a bigger change
|
|
# than this ticket and it lands in main.py, which another ticket is live in - so
|
|
# the divergence is RECORDED here instead of closed. The check therefore passes
|
|
# on the mismatch and FAILS when either side moves, in EITHER direction:
|
|
#
|
|
# - config.yaml moves -> FAIL: re-decide what the fallback should be
|
|
# - main.py is fixed to match -> FAIL: the divergence is gone, DELETE this
|
|
# check and the ⚠️ block in validate.py
|
|
#
|
|
# A comment cannot do that, and "a comment where a check belonged" is this
|
|
# repo's most repeated defect.
|
|
_APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app")
|
|
|
|
def _read(*parts):
|
|
with open(os.path.join(*parts), encoding="utf-8") as fh:
|
|
return fh.read()
|
|
|
|
# Every literal fallback for this key in main.py, whatever the call shape.
|
|
_main_falls = [float(m) for m in re.findall(
|
|
r'\.get\(\s*"maintenance_charge_w"\s*,\s*(-?\d+(?:\.\d+)?)\s*\)',
|
|
_read(_APP, "main.py"))]
|
|
# ...and MaintConfig's own dataclass default, which is the one that bites when
|
|
# main.py is bypassed entirely (a Maintenance built straight from defaults).
|
|
_dc_falls = [float(m) for m in re.findall(
|
|
r"^\s*charge_w:\s*float\s*=\s*(-?\d+(?:\.\d+)?)\s*$",
|
|
_read(_APP, "maintenance.py"), re.M)]
|
|
|
|
check(f"main.py's fallbacks are still found and still 2500 {_main_falls}",
|
|
len(_main_falls) == 2 and set(_main_falls) == {2500.0})
|
|
check(f"MaintConfig's default is still found and still 2500 {_dc_falls}",
|
|
len(_dc_falls) == 1 and set(_dc_falls) == {2500.0})
|
|
check(f"...and config.yaml still ships 2000, so the divergence is UNCHANGED "
|
|
f"(shipped {_shipped['maintenance_charge_w']:.0f}, code 2500)",
|
|
_shipped["maintenance_charge_w"] == 2000.0)
|
|
|
|
# ⚠️ And the consequence itself, through the REAL code rather than through the
|
|
# numbers above - so that if someone "fixes" this by changing a literal without
|
|
# understanding it, the thing that goes red is the behaviour, not the transcript.
|
|
from app.maintenance import MaintConfig # noqa: E402
|
|
|
|
_absent = {k: v for k, v in base().items() if k != "maintenance_charge_w"}
|
|
_built = MaintConfig().charge_w
|
|
check("with the key ABSENT the validator accepts...",
|
|
validate_startup_config(_absent, -5000, 5000) == [])
|
|
check(f"...while the code would build {_built:.0f} W against a "
|
|
f"{_absent['max_w']:.0f} W ceiling...", _built > _absent["max_w"])
|
|
_r = _charge(_absent["max_w"], _built)
|
|
check("...which the arbiter resolves to 0 W as a contradiction - the hole, "
|
|
"asserted so it cannot widen unnoticed",
|
|
_r.target_w == 0.0 and _r.contradiction)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("max_w must be a positive magnitude")
|
|
# Schema-blocked on a real install, but validate_startup_config() is also
|
|
# sim/inject.py's entry point and can be handed any dict at all.
|
|
for bad in (0, -1, -2000):
|
|
p = validate_startup_config(base(max_w=bad), -5000, 5000)
|
|
check(f"max_w {bad} is refused", len(p) == 1 and why(p, "must be positive"))
|
|
check("...and the message explains it would read as a trip, not a mistake",
|
|
why(validate_startup_config(base(max_w=0), -5000, 5000), "contradiction"))
|
|
# ⚠️ SAFETY-02 changed what this case has to say, and the change is real rather
|
|
# than bookkeeping: with a 1 W ceiling the default 300 W sign-disagreement
|
|
# threshold is unreachable, so rule 5 refuses - correctly, because the watchdog
|
|
# would report itself armed while being incapable of firing. What this check is
|
|
# FOR is "a positive max_w is not itself a refusal", so it now names a sign
|
|
# threshold the ceiling can actually reach, and the interaction gets its own
|
|
# check below.
|
|
check("the smallest sane max_w still starts",
|
|
validate_startup_config(base(max_w=1, sign_disagree_w=0.5,
|
|
maintenance_charge_w=1), -5000, 5000) == [])
|
|
check("...but that same 1 W ceiling with the DEFAULT sign threshold is refused",
|
|
why(validate_startup_config(base(max_w=1), -5000, 5000), "never fire"))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("what must NOT be refused - a validator that fires on trivia gets deleted")
|
|
# DOCS.md: the arbiter's `supervised` limit still binds the wire to +-max_w, so
|
|
# the only cost of the extra headroom is unwind latency after saturation.
|
|
check("integrator_max_w above max_w warns but starts",
|
|
validate_startup_config(base(max_w=2000, integrator_max_w=9000),
|
|
-5000, 5000) == [])
|
|
check("integrator_max_w 0 (follow max_w) starts",
|
|
validate_startup_config(base(integrator_max_w=0), -5000, 5000) == [])
|
|
# Fail-safe AND already a red NOT READY banner naming the entity: a refusal on
|
|
# top of that adds nothing an operator can act on.
|
|
check("a missing soc_entity is left to the readiness banner",
|
|
validate_startup_config(base(soc_entity=""), -5000, 5000) == [])
|
|
check("a missing batt_entity likewise",
|
|
validate_startup_config(base(batt_entity=""), -5000, 5000) == [])
|
|
# The transient case. ESPHome is routinely not up when the add-on boots, and
|
|
# refusing here would turn a device that arrives late into a restart loop.
|
|
check("a setpoint entity that is configured but not yet found starts",
|
|
validate_startup_config(base(), None, None) == [])
|
|
check("meter_poll_s with thin but non-zero headroom starts",
|
|
validate_startup_config(base(meter_source="homewizard_local", p1_host="m",
|
|
meter_poll_s=20, meter_max_age_s=30),
|
|
-5000, 5000) == [])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the refusal must be legible as a refusal, not as a crash")
|
|
p = validate_startup_config(base(max_w=8000, setpoint_entity=""), -5000, 5000)
|
|
check("several problems are all reported, not just the first", len(p) >= 1)
|
|
refuse(p) # prints the banner; the eye is the assertion for its wording
|
|
err = ConfigRefused(p)
|
|
check("ConfigRefused carries every problem", err.problems == p)
|
|
check("...and str() names one of them", str(err) == p[0])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the LATE refusal, when the device only shows up after the gate has run")
|
|
# ⚠️ This path had no test at all and no live demonstration - the review's
|
|
# catch. ESPHome is routinely not up when the add-on boots, so hass.limits()
|
|
# returns (None, None), the range checks are skipped, and run_control() is the
|
|
# first place the device's real rating is ever known. Everything below is about
|
|
# what happens when it turns out to conflict.
|
|
from app.main import Controller # noqa: E402
|
|
|
|
|
|
class _LateHass:
|
|
"""Reports the range only run_control() gets to see."""
|
|
|
|
def __init__(self, lo, hi):
|
|
self.lo, self.hi = lo, hi
|
|
self.writes = []
|
|
|
|
async def limits(self, entity):
|
|
return (self.lo, self.hi, 1.0)
|
|
|
|
async def number(self, *a, **k):
|
|
return 0.0
|
|
|
|
async def set_number(self, entity, value):
|
|
self.writes.append(value)
|
|
return True
|
|
|
|
|
|
class _Store2:
|
|
data = {}
|
|
|
|
def set(self, *a):
|
|
pass
|
|
|
|
|
|
class _Pub2:
|
|
enabled = False
|
|
|
|
def publish(self, *a, **k):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
def late(opts_over, lo, hi):
|
|
"""Drive run_control() against a device that only reports (lo, hi) now.
|
|
|
|
⚠️ Returns whether run_control() EXITED ON ITS OWN. That is the assertion
|
|
that matters: on a refusal it must return without entering the while loop,
|
|
and on a clean config it must not return at all. So the clean case is stopped
|
|
from outside after it has had time to write, rather than waited on - a
|
|
wait_for() here would just time out, which is the correct behaviour dressed
|
|
up as a failure.
|
|
"""
|
|
async def go():
|
|
stop = asyncio.Event()
|
|
hass = _LateHass(lo, hi)
|
|
# A device narrower than the shipped 5000 W schema ceiling is exactly
|
|
# the case that cannot be reached through the Supervisor on the live
|
|
# install, so it is reached here instead.
|
|
c = Controller(base(**opts_over), hass, _Store2(), _Pub2(), stop=stop)
|
|
c.auto = True
|
|
task = asyncio.create_task(c.run_control())
|
|
for _ in range(30): # up to ~3 s
|
|
if task.done():
|
|
break
|
|
await asyncio.sleep(0.1)
|
|
exited = task.done()
|
|
if not exited:
|
|
c.stopping = True
|
|
await asyncio.wait_for(task, timeout=5)
|
|
return c, stop, hass, exited
|
|
return asyncio.run(go())
|
|
|
|
c, stop, hass, exited = late({"max_w": 5000}, -3600.0, 3600.0)
|
|
check("a device narrower than max_w, discovered late, is refused",
|
|
c.refused is not None and len(c.refused) == 1)
|
|
check("...naming both values", why(c.refused, "5000") and why(c.refused, "3600"))
|
|
check("...signalling amain to wind down", stop.is_set())
|
|
check("...and returning instead of looping forever", exited)
|
|
# The whole point: run_control() must not fall through into the while loop. A
|
|
# single write here would mean it commanded the inverter under a config it had
|
|
# just declared unusable.
|
|
check("...having never entered the control loop, so it wrote nothing",
|
|
hass.writes == [])
|
|
check("...and it is recorded in the event log an operator reads",
|
|
any("REFUSED" in e for e in c.events))
|
|
|
|
# The same path when there is no conflict: it must NOT refuse, and it must
|
|
# actually regulate - asserted by a write reaching the device, not by absence.
|
|
c2, stop2, hass2, exited2 = late({"max_w": 2000}, -5000.0, 5000.0)
|
|
check("a late device that agrees does not trip the re-check", c2.refused is None)
|
|
check("...and the control loop runs, writing to the device", hass2.writes != [])
|
|
check("...and does not signal a shutdown", not stop2.is_set() and not exited2)
|
|
|
|
# ⚠️ THE FOOTGUN THE REVIEW FOUND. `stop` used to default to None, so this
|
|
# construction was legal and the refusal path then returned into a live process
|
|
# with no control loop, no exit and no further output.
|
|
try:
|
|
Controller(base(), _LateHass(-5000.0, 5000.0), _Store2(), _Pub2())
|
|
check("a Controller with no stop Event is rejected at construction", False)
|
|
except TypeError:
|
|
check("a Controller with no stop Event is rejected at construction", True)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
|
sys.exit(1)
|
|
print("all checks passed")
|