1. notes_of() could not tell this guard's alarm from anybody else's. It selected by SERVICE, so against #12's app/ (which adds goodwe_output_clamp) eight assertions went red, AC1's core check among them. Blanket id-keying is not the repair and was measured, not guessed: selector vs #12's app/ id renamed by service (as committed) 8 red 1 red by id alone 3 red 6 red by id or title (shipped) 3 red 1 red all_notes_of() is the whole surface; notes_of() is this guard's, recognised by the id it should carry OR the title it writes - the `or` is what keeps the one assertion whose subject IS the id able to fail on its own. The three surface-wide claims stay surface-wide, each with a comment naming which surface it is about and why the next guard's author must decide rather than narrow them. They are the three that go red against #12, which is the correct signal. 2. The ordering comment named the wrong boundary. N3 - moving the call to immediately before write_setpoint() - is green and correctly so: self.target is assigned two lines earlier, write_setpoint() clamps only a local and can skip the write entirely. So the invariant is "after `self.target = resolution.target_w`", and the detector grades pre-clamp INTENT, not the wire. Corrected in test_freeze.py and in main.py's own note above the call (comment-only; I merge second and own the conflict). N8 - moving the call above the assignment - is RED 4. 3. The schema range's own bounds are now pinned. int(60,3600) -> int(1,86400) survived green: an operator could set meter_freeze_s: 86400 and the guard is dead with nothing red. Both bounds are re-derived rather than restated. 4. find_hwsim() crashed the whole suite on a checkout one level below the root (HERE.parents[1] -> IndexError, reproduced at C:/goodwe_controller). Guarded. And part 3 now names its transport in the summary: the rig hwsim and the stub are not equivalent - the rig path failed 1 run in 3 - so a green log has to say which one backed it. Also: the "revived transport re-rails" check is labelled a PRECONDITION like its sibling; freeze_rows() records that flipping p1_enabled exercises the row's content and never its gate; and the note about the deleted assertion is corrected - it was REDUNDANT, not undecidable. The reviewer falsified it in one line (move the hass.call above `if not fired: return`, RED 9). "I could not construct a falsifier, therefore it cannot fail" was exactly the inference this file exists to distrust. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
575 lines
30 KiB
Python
575 lines
30 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)
|
|
|
|
# ⚠️ AND THE RANGE'S OWN BOUNDS. "300 sits inside the range" is worth nothing
|
|
# while the range itself is unpinned: widened to `int(1,86400)` an operator can
|
|
# configure `meter_freeze_s: 86400` and the guard is dead for a day at a time,
|
|
# with every check in this repo still green. That mutation survived the first
|
|
# cut of this PR and is what this block closes. Both numbers are argued for in
|
|
# config.yaml's own comment and in freeze.py, and each is re-derived here:
|
|
#
|
|
# 60 s the floor freeze.py calls NOT A RECOMMENDATION - there a healthy
|
|
# meter can look unchanged at 60 W, four times the shipped deadband, so
|
|
# the conjunction's two terms stop being mutually exclusive and only
|
|
# the margin is left;
|
|
# 3600 s the ceiling - already four times the billing quarter, so the top of
|
|
# the range has given up detecting a freeze inside the quarter that
|
|
# bills it. It is the last bound between a supported configuration and
|
|
# a disabled guard, and moving it is a decision rather than a tidy-up.
|
|
print(f" schema range {_lo}-{_hi} s: at the floor a healthy meter that looks "
|
|
f"unchanged is "
|
|
+ (f"{3600.0 / _lo:.0f} W" if _lo else "?")
|
|
+ f" against a {_db} W deadband; the ceiling is "
|
|
+ (f"{_hi / _QUARTER_S:.1f}x" if _hi else "?")
|
|
+ " the billing quarter")
|
|
check(f"the schema range itself is still the argued 60-3600 s (found "
|
|
f"{_lo}-{_hi})", (_lo, _hi) == (60.0, 3600.0))
|
|
check("...a floor that gives up the mutual exclusion and keeps only the "
|
|
"margin, which is why config.yaml calls it not a recommendation",
|
|
None not in (_lo, _db) and 3600.0 / _lo > _db)
|
|
check("...and a ceiling already past the billing quarter, so no value the "
|
|
"schema still accepts can alarm inside the quarter that bills it",
|
|
_hi is not None and _hi > _QUARTER_S)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
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")
|