Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b06f1fad5f | ||
|
|
f4f72211a9 | ||
|
|
a45ed06c05 | ||
|
|
1ad4d3a5bb | ||
|
|
f863ecaa8c |
@@ -1,5 +1,137 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.12.0
|
||||||
|
|
||||||
|
**A MINOR BUMP FOR ONE NEW OPTION: `pv_entities` ([[TEL-12]]), for sites with
|
||||||
|
more than one PV inverter.** The option surface moves **76 keys -> 77**,
|
||||||
|
`options:` and `schema:` in step. 0.11.0's `pv_entity` takes one entity; a roof
|
||||||
|
with two or three inverters, each publishing its own power sensor into Home
|
||||||
|
Assistant, could only record one of them. `pv_entities` is a list, and the
|
||||||
|
Configuration tab renders it as a repeatable field, so you add your second and
|
||||||
|
third inverter there rather than in YAML.
|
||||||
|
|
||||||
|
⚠️ **UNSET, NOTHING CHANGES.** `pv_entities` ships as `[]`, which is what every
|
||||||
|
install that presses *Update* lands on, and empty means "use `pv_entity`" - so
|
||||||
|
an install with no PV entity still has no PV channel, and an install with one
|
||||||
|
`pv_entity` keeps exactly the single-entity channel 0.11.0 gave it. **No rule was
|
||||||
|
added to `validate.py`**, which mentions neither key, so **a stored options blob
|
||||||
|
that starts the add-on on 0.11.0 starts it on 0.12.0**. There is no
|
||||||
|
`SystemExit(78)` to plan around on this hop.
|
||||||
|
|
||||||
|
⚠️ **IT IS INSTRUMENTATION ONLY, NOT A CONTROL INPUT.** `app/control.py`,
|
||||||
|
`allocator.py`, `arbiter.py`, `peakguard.py` and the watchdogs are not in this
|
||||||
|
release's diff; the runtime files that move are `app/pv.py`, `app/main.py`,
|
||||||
|
`app/mqtt.py`, `config.yaml` and `translations/`. `tools/tel11_pv_trace.py`,
|
||||||
|
extended for this card, hashes the controller's decisions on 0.11.0 and on this
|
||||||
|
tree: the control document is **`846a10a5b89e…` with PV unset and with one
|
||||||
|
entity, on both trees** - the same hash TEL-11 recorded - and with a
|
||||||
|
three-entity roster it equals each grid world's unset document, including every
|
||||||
|
cell where one inverter is dead. Neither the total nor any partial total reaches
|
||||||
|
a decision. With PV unset or with one entity, the **telemetry** is byte-identical
|
||||||
|
to 0.11.0's as well.
|
||||||
|
|
||||||
|
Contents of this release, in merge order since 0.11.0 (`cccb2fa`):
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `f863eca` | TEL-12 - several PV inverters, and one absent member is an absent TOTAL **⚠️ NEW OPTIONAL OPTION, EMPTY ON EVERY EXISTING INSTALL** |
|
||||||
|
|
||||||
|
### TEL-12 — `pv_entities`: the PV channel for a roof with several inverters
|
||||||
|
|
||||||
|
**WHAT TO DO ON A SITE WITH MORE THAN ONE PV INVERTER.** List every inverter's
|
||||||
|
power sensor under `pv_entities`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
pv_entities:
|
||||||
|
- sensor.pv_east_power
|
||||||
|
- sensor.pv_west_power
|
||||||
|
- sensor.pv_garage_power
|
||||||
|
```
|
||||||
|
|
||||||
|
`sensor.goodwe_pv_power` then carries the **site total**. Each entity's own
|
||||||
|
`unit_of_measurement` is read, so a kW inverter beside a W one sums correctly;
|
||||||
|
a sensor with no unit or a non-power unit is refused by name on the status
|
||||||
|
page, as in 0.11.0.
|
||||||
|
|
||||||
|
⚠️ **`pv_entities` REPLACES `pv_entity` - IT DOES NOT ADD TO IT.** When the list
|
||||||
|
is set, `pv_entity` is not read at all. **If you are upgrading from 0.11.0 with
|
||||||
|
`pv_entity` already set, the inverter it names must also be in the list**, or it
|
||||||
|
drops out of the total. Put it in the list and it is **counted once**, not
|
||||||
|
twice - that is why the rule is replacement: under a union, the natural action
|
||||||
|
(put all your inverters in the new field) would double-count the one the old
|
||||||
|
key already names. While both keys are set the status page shows a caution
|
||||||
|
saying which one won - *"`pv_entities` is set, so `pv_entity` is IGNORED - the N
|
||||||
|
entities in the list are what is summed. Clear `pv_entity` to remove this
|
||||||
|
caution"*. It is a caution, never a refusal to start and never a NOT READY row.
|
||||||
|
A repeated id inside the list is also counted once.
|
||||||
|
|
||||||
|
⚠️ **IF ANY CONFIGURED PV ENTITY IS ABSENT, THE PUBLISHED TOTAL IS `unknown` -
|
||||||
|
NOT THE SUM OF THE ONES THAT ANSWERED.** Three inverters configured and one
|
||||||
|
`unavailable`: `sensor.goodwe_pv_power` goes to `unknown` and the recorder
|
||||||
|
stores a **gap**. The sum of the other two would be a smaller number
|
||||||
|
indistinguishable from a real one, and a week later it would read as *"there
|
||||||
|
was less sun than there was"* - the conclusion this channel exists to test
|
||||||
|
rather than to manufacture. **A gap in the history, never an understated
|
||||||
|
number.** The status page, read live, still gives you the diagnostic: `PV —`
|
||||||
|
plus a caution that says there is **no site total**, names the missing entity
|
||||||
|
and its reason, and lists what the others reported. An inverter honestly
|
||||||
|
reporting **0 W** is summed, not treated as absent - shading and nightfall are
|
||||||
|
real zeros.
|
||||||
|
|
||||||
|
| what happened | `sensor.goodwe_pv_power` | the status page |
|
||||||
|
|---|---|---|
|
||||||
|
| neither key set | the entity does not exist | no row |
|
||||||
|
| all three answer, 1200 W + 2.1 kW + 450 W | `3750.0` | `PV 3 750 W` |
|
||||||
|
| all three answer, one of them `0 W` | the sum of the two others | the sum |
|
||||||
|
| one (or more) `unavailable` / missing / bad unit | `unknown` - a recorder gap | `PV —` + a caution naming what is missing |
|
||||||
|
|
||||||
|
⚠️ **THE FIX FOR A MISSING INVERTER IS NOT A TEMPLATE SENSOR THAT SUMS THEM.**
|
||||||
|
You can still point `pv_entity` at one if you prefer, but a template sensor
|
||||||
|
summing an `unavailable` source yields the partial total silently - the same
|
||||||
|
defect one layer down, where this add-on cannot see it or report it.
|
||||||
|
|
||||||
|
**WHY A SECOND KEY, RATHER THAN MAKING `pv_entity` A LIST.** Because an 0.11.0
|
||||||
|
install that has `pv_entity` set would not start. Measured against Supervisor
|
||||||
|
**2026.08.0** by `tools/supervisor_shape_probe.py` §G: with `pv_entity` declared
|
||||||
|
as a list, the string an 0.11.0 install has persisted is refused -
|
||||||
|
`Invalid list for option 'pv_entity'` - and a shipped `[]` default does not
|
||||||
|
rescue it, because the Supervisor's merge keeps your stored string. The one
|
||||||
|
single-key spelling that accepts both shapes, `match(.*)?`, validates nothing
|
||||||
|
(it accepts the integer `417`) and renders as a plain text box, so you could not
|
||||||
|
add a second inverter in the UI. So `pv_entity` stays a string, and the same
|
||||||
|
probe shows an 0.11.0 options blob - `pv_entity` set, no `pv_entities` key at
|
||||||
|
all - validating against this version untouched. ⚠️ **Rolling back to 0.11.0 is
|
||||||
|
also safe**: a schema without `pv_entities` drops the key with a warning and
|
||||||
|
leaves `pv_entity` standing.
|
||||||
|
|
||||||
|
**Why it matters now:** [[OPT-10]] needs a week of PV history to attribute the
|
||||||
|
surplus that left the reference site while the pack had room, and that site has
|
||||||
|
several PV inverters. The week starts when the site total is recorded.
|
||||||
|
|
||||||
|
**Full account: `DECISIONS/TEL-12-several-pv-inverters.md`, with the operator's
|
||||||
|
version under "Several PV inverters" in `DOCS.md`.**
|
||||||
|
|
||||||
|
### If you are crossing from a version older than 0.10.0
|
||||||
|
|
||||||
|
⚠️ **THIS SECTION IS NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.**
|
||||||
|
Updating installs 0.12.0 in one step, so every validator rule added in
|
||||||
|
0.8.0, 0.9.0 and 0.10.0 arrives at the same moment. Two of them can refuse a
|
||||||
|
config nobody has edited: [[SAFETY-39]]'s **rule 10** (added in 0.8.0) against
|
||||||
|
0.7.0's shipped default `slew_w: 1000` - the ceiling is **555.6 W** on a 5000 W
|
||||||
|
rail - and [[TARIFF-07]]'s three withdrawn `price_provider` ids,
|
||||||
|
**`engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy`**, each still
|
||||||
|
refused by name at startup on 0.12.0. Neither is new here, and a site already on
|
||||||
|
0.10.0 or later has already met both. **The full account of both, including
|
||||||
|
what to set instead of each withdrawn id, is in `## 0.10.0`**, which withdrew
|
||||||
|
them and is not superseded by this section.
|
||||||
|
|
||||||
|
`tools/predeploy_check.py` checks your live options against this version's
|
||||||
|
validator before you press *Update*, and on this tree reports a real version
|
||||||
|
change from 0.11.0. ⚠️ **Pass it the bare options dict - the `data.options`
|
||||||
|
object - NOT the whole `ha apps info --raw-json` document.** Handed the wrapped
|
||||||
|
export it currently prints CLEARED while having checked nothing ([[SAFETY-60]],
|
||||||
|
open). Extract the options first, e.g. `jq '.data.options'`.
|
||||||
|
|
||||||
## 0.11.0
|
## 0.11.0
|
||||||
|
|
||||||
**A MINOR BUMP, AND ONE NEW OPTION IS THE WHOLE REASON FOR THE SECOND DIGIT:
|
**A MINOR BUMP, AND ONE NEW OPTION IS THE WHOLE REASON FOR THE SECOND DIGIT:
|
||||||
|
|||||||
@@ -2820,8 +2820,8 @@ a **copy** of the state file and reports the difference.
|
|||||||
--live-entities /w/live-entities.txt \
|
--live-entities /w/live-entities.txt \
|
||||||
--reverse --round-trip'
|
--reverse --round-trip'
|
||||||
|
|
||||||
Exit `0` clean · `1` at least one finding · `2` could not run. ⚠️ **`2` is not a
|
Exit `0` clean · `1` at least one finding · `2` could not run · `3` clean but
|
||||||
pass.**
|
degraded (`--no-live-entities`). ⚠️ **`2` and `3` are not a pass.**
|
||||||
|
|
||||||
### The owner steps, because this session could not take them
|
### The owner steps, because this session could not take them
|
||||||
|
|
||||||
@@ -2843,7 +2843,8 @@ check, **before** the roster is declared:
|
|||||||
|
|
||||||
2. **The options blob**, as `--before`. It is not reachable through the Home
|
2. **The options blob**, as `--before`. It is not reachable through the Home
|
||||||
Assistant REST API and no entity carries it; export it from the Supervisor,
|
Assistant REST API and no entity carries it; export it from the Supervisor,
|
||||||
the same way `tools/predeploy_check.py` needs it. Write the roster you intend
|
the same way `tools/predeploy_check.py` needs it: the bare options object,
|
||||||
|
not the `ha apps info` wrapper, which is refused. Write the roster you intend
|
||||||
to deploy into a second file and pass it as `--after`.
|
to deploy into a second file and pass it as `--after`.
|
||||||
|
|
||||||
3. **The entity list**, as `--live-entities` — one entity id per line:
|
3. **The entity list**, as `--live-entities` — one entity id per line:
|
||||||
@@ -2857,7 +2858,8 @@ check, **before** the roster is declared:
|
|||||||
hand back a clean bill of health for a half of the transition it did not
|
hand back a clean bill of health for a half of the transition it did not
|
||||||
check. If you genuinely have to run without it, pass `--no-live-entities`:
|
check. If you genuinely have to run without it, pass `--no-live-entities`:
|
||||||
the run then carries a standing banner in the report header **and** on the
|
the run then carries a standing banner in the report header **and** on the
|
||||||
exit line saying the orphan check was not graded. The asymmetry used to be
|
exit line saying the orphan check was not graded, and a clean result exits
|
||||||
|
3, not 0. The asymmetry used to be
|
||||||
the other way round — an unreadable file refused, a missing flag passed with
|
the other way round — an unreadable file refused, a missing flag passed with
|
||||||
exit 0 and no disclosure at all — and the owner runs this once, at
|
exit 0 and no disclosure at all — and the owner runs this once, at
|
||||||
commissioning, under time pressure.
|
commissioning, under time pressure.
|
||||||
@@ -3005,7 +3007,9 @@ Run it inside the container, so the validator half actually runs:
|
|||||||
--deployed-version 0.7.0 --options /w/live-options.json
|
--deployed-version 0.7.0 --options /w/live-options.json
|
||||||
|
|
||||||
Export `live-options.json` from the Supervisor first — the options blob is not
|
Export `live-options.json` from the Supervisor first — the options blob is not
|
||||||
reachable through the Home Assistant REST API, and no entity carries it.
|
reachable through the Home Assistant REST API, and no entity carries it. Pass the
|
||||||
|
bare object (`ha apps info <slug> --raw-json | jq .data.options`); the wrapped
|
||||||
|
output is refused.
|
||||||
|
|
||||||
**Exit codes.** `0` cleared · `1` REFUSED, do not deploy · `2` NOT CLEARED,
|
**Exit codes.** `0` cleared · `1` REFUSED, do not deploy · `2` NOT CLEARED,
|
||||||
meaning a check did not run. ⚠️ **2 is not a pass.** On a bare host without the
|
meaning a check did not run. ⚠️ **2 is not a pass.** On a bare host without the
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: GoodWe RS485 Controller
|
name: GoodWe RS485 Controller
|
||||||
version: "0.11.0"
|
version: "0.12.0"
|
||||||
slug: goodwe_controller
|
slug: goodwe_controller
|
||||||
description: >-
|
description: >-
|
||||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||||
|
|||||||
@@ -75,9 +75,10 @@ print("1. the harness's own preconditions")
|
|||||||
sys.path.insert(0, str(HERE / "tools"))
|
sys.path.insert(0, str(HERE / "tools"))
|
||||||
import rehearse_roster as RR # noqa: E402
|
import rehearse_roster as RR # noqa: E402
|
||||||
|
|
||||||
check("the tool refuses rather than passing when it cannot run: its three exit "
|
check("the tool refuses rather than passing when it cannot run: its four exit "
|
||||||
"codes are distinct and 2 is not 0",
|
"codes are distinct and only 0 is a pass",
|
||||||
(RR.EXIT_OK, RR.EXIT_FINDING, RR.EXIT_COULD_NOT_RUN) == (0, 1, 2))
|
(RR.EXIT_OK, RR.EXIT_FINDING, RR.EXIT_COULD_NOT_RUN,
|
||||||
|
RR.EXIT_DEGRADED) == (0, 1, 2, 3))
|
||||||
|
|
||||||
# ⚠️ THE ANCHOR. `app/mqtt.py` records `sensor.goodwe_p1_sample_age` as VERIFIED
|
# ⚠️ THE ANCHOR. `app/mqtt.py` records `sensor.goodwe_p1_sample_age` as VERIFIED
|
||||||
# against the live system on 2026-08-26, and the ESP32's ingest guard subscribes
|
# against the live system on 2026-08-26, and the ESP32's ingest guard subscribes
|
||||||
@@ -204,6 +205,10 @@ def mutant_tree(edits) -> str:
|
|||||||
shutil.copytree(FIX, dest / "tools" / "rehearsal", ignore=ignore)
|
shutil.copytree(FIX, dest / "tools" / "rehearsal", ignore=ignore)
|
||||||
shutil.copyfile(HERE / "tools" / "rehearse_roster.py",
|
shutil.copyfile(HERE / "tools" / "rehearse_roster.py",
|
||||||
dest / "tools" / "rehearse_roster.py")
|
dest / "tools" / "rehearse_roster.py")
|
||||||
|
# The input-shape gate (SAFETY-62) and the option names it checks against.
|
||||||
|
shutil.copyfile(HERE / "tools" / "predeploy_check.py",
|
||||||
|
dest / "tools" / "predeploy_check.py")
|
||||||
|
shutil.copyfile(HERE / "config.yaml", dest / "config.yaml")
|
||||||
for rel, old, new in edits:
|
for rel, old, new in edits:
|
||||||
path = dest / rel
|
path = dest / rel
|
||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
@@ -356,8 +361,9 @@ check("omitting --live-entities REFUSES with exit 2 rather than reporting a "
|
|||||||
_no_code == RR.EXIT_COULD_NOT_RUN and "REFUSED" in _no_out + _no_err)
|
_no_code == RR.EXIT_COULD_NOT_RUN and "REFUSED" in _no_out + _no_err)
|
||||||
|
|
||||||
_deg_code, _deg_out, _ = raw("--no-live-entities")
|
_deg_code, _deg_out, _ = raw("--no-live-entities")
|
||||||
check("...and the explicit opt-out RUNS, so the escape hatch exists",
|
check("...and the explicit opt-out RUNS, so the escape hatch exists - but a "
|
||||||
_deg_code in (RR.EXIT_OK, RR.EXIT_FINDING))
|
"clean degraded run is exit 3, never 0 (SAFETY-62)",
|
||||||
|
_deg_code == RR.EXIT_DEGRADED and "NO FINDINGS" in _deg_out)
|
||||||
check("⚠️ ...carrying a standing banner in the report HEADER and again in "
|
check("⚠️ ...carrying a standing banner in the report HEADER and again in "
|
||||||
"the closing block, because a report is skimmed from the bottom",
|
"the closing block, because a report is skimmed from the bottom",
|
||||||
_deg_out.count("DEGRADED RUN") >= 2
|
_deg_out.count("DEGRADED RUN") >= 2
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""SAFETY-60/62: the operator tools refuse input they cannot read. `python3 test_tool_inputs.py`
|
||||||
|
|
||||||
|
Each tool is driven as the operator runs it, first on good input (the accepted
|
||||||
|
baseline, MUTATION.md habit 7) and then on the shapes that used to pass.
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
import copy
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
sys.path.insert(0, str(HERE))
|
||||||
|
TOOLS = HERE / "tools"
|
||||||
|
FIX = TOOLS / "rehearsal"
|
||||||
|
CAPTURE = HERE / "scenarios" / "safety57-2026-09-21" / \
|
||||||
|
"bus-a-2026-09-21-1030-1110.log.gz"
|
||||||
|
TMP = tempfile.mkdtemp(prefix="test_tool_inputs.")
|
||||||
|
ENV = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, ok):
|
||||||
|
print(f" {'ok ' if ok else 'FAIL'} {name}")
|
||||||
|
if not ok:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def dump(name, obj=None, text=None):
|
||||||
|
p = Path(TMP) / name
|
||||||
|
p.write_text(text if text is not None else json.dumps(obj, indent=1),
|
||||||
|
encoding="utf-8")
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
|
||||||
|
def tool(script, *args):
|
||||||
|
r = subprocess.run([sys.executable, str(TOOLS / script), *args],
|
||||||
|
capture_output=True, text=True, env=ENV)
|
||||||
|
return r.returncode, r.stdout + r.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def ha_apps_info(options):
|
||||||
|
"""What `ha apps info <slug> --raw-json` prints: the options sit under data.options."""
|
||||||
|
text = {"en": ("Setpoint entity", "The ESPHome number carrying the setpoint."),
|
||||||
|
"nl": ("Setpoint-entiteit", "Het ESPHome-getal met het setpoint.")}
|
||||||
|
return {"result": "ok", "data": {
|
||||||
|
"name": "GoodWe RS485 Controller", "slug": "local_goodwe_controller",
|
||||||
|
"version": "0.12.0", "version_latest": "0.12.0", "state": "started",
|
||||||
|
"boot": "auto", "ingress": True, "hassio_api": True,
|
||||||
|
"options": copy.deepcopy(options),
|
||||||
|
"schema": [{"name": k, "required": True, "optional": False,
|
||||||
|
"type": "string"} for k in options],
|
||||||
|
"translations": {lang: {"configuration": {
|
||||||
|
"setpoint_entity": {"name": name, "description": desc},
|
||||||
|
"inverters": {"name": "Inverters", "description": "Roster.",
|
||||||
|
"fields": {"setpoint_entity": {
|
||||||
|
"name": name, "description": f"{desc} (per unit)"}}}}}
|
||||||
|
for lang, (name, desc) in text.items()}}}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("1. predeploy_check.py --options (SAFETY-60)")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# A deployed version that differs from the shipped one, so only the options half decides.
|
||||||
|
BARE = {"setpoint_entity": "number.goodwe_setpoint_w", "inverters": [],
|
||||||
|
"max_w": 5000, "slew_w": 0, "gain": 0.6, "target_grid_w": -10,
|
||||||
|
"deadband_w": 15, "meter_source": "off", "log_level": "info"}
|
||||||
|
NOREPO = tempfile.mkdtemp(prefix="norepo.", dir=TMP)
|
||||||
|
|
||||||
|
|
||||||
|
def gate(opts_path):
|
||||||
|
return tool("predeploy_check.py", "--deployed-version", "0.0.0-test",
|
||||||
|
"--repo", NOREPO, "--options", opts_path)
|
||||||
|
|
||||||
|
|
||||||
|
code, out = gate(dump("bare.json", BARE))
|
||||||
|
print(f" bare options -> exit {code}")
|
||||||
|
check("baseline: the bare options object is CLEARED (exit 0)",
|
||||||
|
code == 0 and "CLEARED for deploy" in out)
|
||||||
|
code, out = gate(dump("bare-bad.json", dict(BARE, slew_w=5000)))
|
||||||
|
check("baseline: the bare object with slew_w 5000 is REFUSED by the validator",
|
||||||
|
code == 1 and "slew_w is 5000 W" in out)
|
||||||
|
|
||||||
|
for label, doc in (("wrapped", ha_apps_info(BARE)),
|
||||||
|
("wrapped, slew_w 5000", ha_apps_info(dict(BARE, slew_w=5000)))):
|
||||||
|
code, out = gate(dump("wrapped.json", doc))
|
||||||
|
print(f" {label} -> exit {code}")
|
||||||
|
check(f"the `ha apps info` capture ({label}) is REFUSED, exit 1",
|
||||||
|
code == 1 and "CLEARED" not in out)
|
||||||
|
check("...naming the shape and the fix, not a validator rule",
|
||||||
|
"0 of 2 top-level key(s) recognised" in out and ".data.options" in out
|
||||||
|
and "VALIDATOR" not in out)
|
||||||
|
|
||||||
|
from app.validate import actuator_writers, validate_startup_config # noqa: E402
|
||||||
|
_w = ha_apps_info(dict(BARE, slew_w=5000))
|
||||||
|
print(f" (for reference: the validator alone, handed that capture, finds "
|
||||||
|
f"{len(validate_startup_config(_w))} problem(s) and "
|
||||||
|
f"{len(actuator_writers(_w))} 'actuators' - the gate has to refuse it)")
|
||||||
|
|
||||||
|
code, out = gate(dump("list.json", [BARE]))
|
||||||
|
check("a list-wrapped options object is REFUSED, naming the type",
|
||||||
|
code == 1 and "is a JSON list, not an object" in out)
|
||||||
|
code, out = gate(dump("mostly-foreign.json",
|
||||||
|
{"setpoint_entity": "number.x", "a": 1, "b": 2}))
|
||||||
|
check("an object with fewer than half its keys recognised is REFUSED",
|
||||||
|
code == 1 and "1 of 3 top-level key(s) recognised" in out)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n2. rehearse_roster.py --state/--before/--after (SAFETY-62)")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
sys.path.insert(0, str(TOOLS))
|
||||||
|
import rehearse_roster as RR # noqa: E402
|
||||||
|
|
||||||
|
STATE = FIX / "state-legacy.json"
|
||||||
|
LIVE = ["--live-entities", str(FIX / "live-entities-recorded.txt"),
|
||||||
|
"--reference", str(FIX / "announced-reference.txt")]
|
||||||
|
|
||||||
|
|
||||||
|
def rehearse(state=str(STATE), before=str(FIX / "flat.yaml"),
|
||||||
|
after=str(FIX / "roster.yaml"), extra=LIVE):
|
||||||
|
return tool("rehearse_roster.py", "--state", state, "--before", before,
|
||||||
|
"--after", after, *extra)
|
||||||
|
|
||||||
|
|
||||||
|
code, out = rehearse()
|
||||||
|
print(f" control -> exit {code}")
|
||||||
|
check("baseline: the control run reports its 2 findings, exit 1",
|
||||||
|
code == RR.EXIT_FINDING and "2 FINDING(S)" in out)
|
||||||
|
code, out = rehearse(state=str(FIX / "state-fresh.json"))
|
||||||
|
check("baseline: a fresh (comment-only) state file still runs clean, exit 0",
|
||||||
|
code == RR.EXIT_OK and "NO FINDINGS" in out)
|
||||||
|
|
||||||
|
raw = STATE.read_text(encoding="utf-8")
|
||||||
|
state = json.loads(raw)
|
||||||
|
cases = {
|
||||||
|
"wrapped": (dump("s-wrapped.json", {"result": "ok", "data": state}),
|
||||||
|
"0 of 2 top-level key(s) recognised"),
|
||||||
|
"truncated by 20 bytes": (dump("s-trunc.json", text=raw[:-20]),
|
||||||
|
"not a readable JSON state file"),
|
||||||
|
"list-wrapped": (dump("s-list.json", [state]),
|
||||||
|
"is a JSON list, not an object"),
|
||||||
|
}
|
||||||
|
for label, (path, words) in cases.items():
|
||||||
|
code, out = rehearse(state=path)
|
||||||
|
print(f" state {label} -> exit {code}")
|
||||||
|
check(f"state {label}: REFUSED with exit 2 and the reason",
|
||||||
|
code == RR.EXIT_COULD_NOT_RUN and words in out
|
||||||
|
and "NO FINDINGS" not in out)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_auto = RR.load_state(dump("s-auto.json", {"auto": True}))
|
||||||
|
except RR.Refused as err:
|
||||||
|
_auto = str(err)
|
||||||
|
check("a state holding only the Automatic toggle (`auto`, app/main.py) is "
|
||||||
|
"recognised as a store", _auto == {"auto": True})
|
||||||
|
|
||||||
|
flat = RR.load_options(str(FIX / "flat.yaml"))
|
||||||
|
roster = RR.load_options(str(FIX / "roster.yaml"))
|
||||||
|
for side, kw in (("--before", {"before": dump("b.json", ha_apps_info(flat))}),
|
||||||
|
("--after", {"after": dump("a.json", ha_apps_info(roster))})):
|
||||||
|
code, out = rehearse(**kw)
|
||||||
|
print(f" {side} wrapped -> exit {code}")
|
||||||
|
check(f"{side} as an `ha apps info` capture: REFUSED with exit 2",
|
||||||
|
code == RR.EXIT_COULD_NOT_RUN and ".data.options" in out
|
||||||
|
and "NO FINDINGS" not in out)
|
||||||
|
|
||||||
|
code, out = rehearse(after=str(FIX / "roster-inverter-a.yaml"),
|
||||||
|
extra=["--no-live-entities", "--reference",
|
||||||
|
str(FIX / "announced-reference.txt")])
|
||||||
|
check("a clean --no-live-entities run is exit 3, not 0",
|
||||||
|
code == RR.EXIT_DEGRADED and "NO FINDINGS" in out)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n3. alloc_sim.py - the split-invariance verdict reaches the exit code")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
spec = importlib.util.spec_from_file_location("_alloc_sim", TOOLS / "alloc_sim.py")
|
||||||
|
AS = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(AS)
|
||||||
|
|
||||||
|
|
||||||
|
class _Run:
|
||||||
|
"""One rig run, reduced to what `splits()` reads."""
|
||||||
|
|
||||||
|
def __init__(self, grid):
|
||||||
|
self.samples = [{"shares": [1000.0, 1000.0]}]
|
||||||
|
self._grid = grid
|
||||||
|
|
||||||
|
def grids(self):
|
||||||
|
return self._grid
|
||||||
|
|
||||||
|
def targets(self):
|
||||||
|
return [2000.0] * len(self._grid)
|
||||||
|
|
||||||
|
|
||||||
|
class _Sitesim:
|
||||||
|
def load_addon(self, *_):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def alloc_main(same):
|
||||||
|
"""`main(--splits)` with the rig stubbed; the real `splits()` grades the traces."""
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fake(_sitesim, _control, mode, *a, **kw):
|
||||||
|
seen.append(mode)
|
||||||
|
return _Run([0.0, 1.0] if same or len(seen) == 1 else [0.0, 2.0])
|
||||||
|
|
||||||
|
AS.find_sim = lambda: Path("/nonexistent-rig")
|
||||||
|
AS.load = lambda _sim: (None, _Sitesim(), None, None)
|
||||||
|
AS.run_site_feedback = fake
|
||||||
|
os.environ["GOODWE_ADDON_REPO"] = str(HERE.parent)
|
||||||
|
sys.argv = ["alloc_sim.py", "--splits"]
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buf):
|
||||||
|
rc = AS.main()
|
||||||
|
return rc, buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
rc, out = alloc_main(same=True)
|
||||||
|
check("baseline: identical traces HOLD, exit 0",
|
||||||
|
rc == 0 and "split-invariance HOLDS" in out)
|
||||||
|
rc, out = alloc_main(same=False)
|
||||||
|
print(f" differing traces -> exit {rc}")
|
||||||
|
check("differing traces FAIL, exit 1",
|
||||||
|
rc == 1 and "split-invariance FAILS" in out)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n4. decode_bus_capture.py - a file that is not a capture")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
code, out = tool("decode_bus_capture.py", str(CAPTURE))
|
||||||
|
check("baseline: the committed capture decodes, exit 0",
|
||||||
|
code == 0 and "writes 1200" in out and "REFUSED" not in out)
|
||||||
|
junk = dump("junk.log", text="hello\nthis is not a capture\n")
|
||||||
|
code, out = tool("decode_bus_capture.py", junk)
|
||||||
|
print(f" junk -> exit {code}")
|
||||||
|
check("a junk file is REFUSED, exit 1",
|
||||||
|
code == 1 and "REFUSED" in out and "0 of 2 line(s) decoded" in out)
|
||||||
|
code, out = tool("decode_bus_capture.py", dump("empty.log", text=""))
|
||||||
|
check("an empty file is REFUSED, exit 1",
|
||||||
|
code == 1 and "0 of 0 line(s) decoded" in out)
|
||||||
|
code, out = tool("decode_bus_capture.py", str(CAPTURE), junk)
|
||||||
|
check("several paths, as the docstring's glob passes them: each is decoded and "
|
||||||
|
"one bad file makes the run exit 1",
|
||||||
|
code == 1 and f"== {CAPTURE}" in out and f"== {junk}" in out)
|
||||||
|
code, out = tool("decode_bus_capture.py", str(CAPTURE), str(CAPTURE))
|
||||||
|
check("...and two good files exit 0", code == 0 and out.count("REFUSED") == 0)
|
||||||
|
|
||||||
|
shutil.rmtree(TMP, ignore_errors=True)
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(f"FAILED: {len(fails)}")
|
||||||
|
for f in fails:
|
||||||
|
print(f" - {f}")
|
||||||
|
sys.exit(1)
|
||||||
|
print("all tool-input checks passed")
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
python3 tools/alloc_sim.py --sweep # AC 3: gain to 1.0 at every split
|
python3 tools/alloc_sim.py --sweep # AC 3: gain to 1.0 at every split
|
||||||
python3 tools/alloc_sim.py --all
|
python3 tools/alloc_sim.py --all
|
||||||
|
|
||||||
|
Exit 0 only if every split-invariance run HOLDS; 1 if one FAILS; 2 if no rig.
|
||||||
|
|
||||||
# DEV-02: the split-invariance finding ALLOC-01's review handed on, and the
|
# DEV-02: the split-invariance finding ALLOC-01's review handed on, and the
|
||||||
# sensing change that closed it. The two rows are the evidence.
|
# sensing change that closed it. The two rows are the evidence.
|
||||||
python3 tools/alloc_sim.py --splits --gain 0.4 --feedback unit # 250 W apart
|
python3 tools/alloc_sim.py --splits --gain 0.4 --feedback unit # 250 W apart
|
||||||
@@ -279,13 +281,14 @@ def main():
|
|||||||
if a.table or a.all:
|
if a.table or a.all:
|
||||||
table(sitesim, control, watchdog, a.cycles, a.load)
|
table(sitesim, control, watchdog, a.cycles, a.load)
|
||||||
print()
|
print()
|
||||||
|
held = True
|
||||||
if a.splits or a.all:
|
if a.splits or a.all:
|
||||||
splits(sitesim, control, watchdog, a.cycles, a.load, a.gain,
|
held = splits(sitesim, control, watchdog, a.cycles, a.load, a.gain,
|
||||||
a.feedback)
|
a.feedback)
|
||||||
print()
|
print()
|
||||||
if a.sweep or a.all:
|
if a.sweep or a.all:
|
||||||
sweep(sitesim, control, watchdog, a.cycles, a.load)
|
sweep(sitesim, control, watchdog, a.cycles, a.load)
|
||||||
return 0
|
return 0 if held else 1
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -195,14 +195,37 @@ def _hms(t):
|
|||||||
return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:06.3f}"
|
return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:06.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def unreadable(stats):
|
||||||
|
"""Why a capture decoded to too little to trust, or None: most lines must be frames."""
|
||||||
|
framed = sum(stats[k] for k in ("requests", "responses", "writes",
|
||||||
|
"write_echoes"))
|
||||||
|
if framed > 0 and stats["lines"] - framed <= framed:
|
||||||
|
return None
|
||||||
|
return (f"{framed} of {stats['lines']} line(s) decoded as frames "
|
||||||
|
f"(unparsed {stats['unparsed']}, bad_crc {stats['bad_crc']}, "
|
||||||
|
f"unknown {stats['unknown']}) - not a capture this decoder can read")
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
def main(argv):
|
||||||
if len(argv) != 2:
|
"""Decode each capture named; exit 1 if any is unreadable, 2 on no argument."""
|
||||||
|
if len(argv) < 2:
|
||||||
print(__doc__.splitlines()[2].strip())
|
print(__doc__.splitlines()[2].strip())
|
||||||
return 2
|
return 2
|
||||||
events, stats = decode(argv[1])
|
rc = 0
|
||||||
print(f"== {argv[1]}")
|
for path in argv[1:]:
|
||||||
|
rc |= _report(path)
|
||||||
|
return rc
|
||||||
|
|
||||||
|
|
||||||
|
def _report(path):
|
||||||
|
events, stats = decode(path)
|
||||||
|
print(f"== {path}")
|
||||||
for k, v in stats.items():
|
for k, v in stats.items():
|
||||||
print(f" {k:14} {v}")
|
print(f" {k:14} {v}")
|
||||||
|
bad = unreadable(stats)
|
||||||
|
if bad:
|
||||||
|
print(f"REFUSED: {path}: {bad}")
|
||||||
|
return 1
|
||||||
for kind in ("setpoint_w", "batt_w", "soc_pct"):
|
for kind in ("setpoint_w", "batt_w", "soc_pct"):
|
||||||
rows = series(events, kind)
|
rows = series(events, kind)
|
||||||
if not rows:
|
if not rows:
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ and not at merge. The check belongs where the mistake is made.
|
|||||||
|
|
||||||
⚠️ WHAT THIS DOES NOT DO. It does not read the live box. The options blob is
|
⚠️ WHAT THIS DOES NOT DO. It does not read the live box. The options blob is
|
||||||
reachable only through the Supervisor API, which needs a supervisor token, so
|
reachable only through the Supervisor API, which needs a supervisor token, so
|
||||||
the operator exports it and passes `--options`. Without that flag the validator
|
the operator exports it and passes `--options` - the bare options object
|
||||||
half is SKIPPED and says so - a skipped check must never look like a passed one.
|
(`.data.options` of `ha apps info <slug> --raw-json`); any other shape is
|
||||||
|
refused. Without that flag the validator half is SKIPPED and says so - a
|
||||||
|
skipped check must never look like a passed one.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -79,6 +81,43 @@ def shipped_version(tree=ADDON):
|
|||||||
return m.group(1) if m else None
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def option_keys(addon=ADDON):
|
||||||
|
"""Top-level option names in `addon`'s config.yaml `options:`/`schema:` blocks."""
|
||||||
|
keys, section = set(), None
|
||||||
|
with open(os.path.join(addon, "config.yaml"), encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
if not line.strip() or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if not line[0].isspace():
|
||||||
|
section = line.split(":", 1)[0].strip()
|
||||||
|
continue
|
||||||
|
m = re.match(r" ([A-Za-z_][A-Za-z0-9_]*):", line)
|
||||||
|
if m and section in ("options", "schema"):
|
||||||
|
keys.add(m.group(1))
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def shape_problem(doc, known, what, allow_empty=False):
|
||||||
|
"""Why `doc` is not a document of `known` keys, or None (SAFETY-60/62).
|
||||||
|
|
||||||
|
Positive property: a JSON object, at least half of whose top-level keys
|
||||||
|
(`_`-prefixed comments aside) `known(key)` recognises.
|
||||||
|
"""
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
return f"{what} is a JSON {type(doc).__name__}, not an object"
|
||||||
|
keys = [k for k in doc if not str(k).startswith("_")]
|
||||||
|
hits = [k for k in keys if known(k)]
|
||||||
|
if (keys or allow_empty) and len(hits) * 2 >= len(keys):
|
||||||
|
return None
|
||||||
|
msg = (f"{what} is not the expected document: {len(hits)} of {len(keys)} "
|
||||||
|
f"top-level key(s) recognised {sorted(map(str, keys))[:8]}")
|
||||||
|
inner = doc.get("data")
|
||||||
|
if isinstance(inner, dict) and isinstance(inner.get("options"), dict):
|
||||||
|
msg += ("; this is `ha apps info --raw-json` output - pass its "
|
||||||
|
"`.data.options` instead")
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
def is_runtime(path):
|
def is_runtime(path):
|
||||||
return any(path == p or path.startswith(p) for p in RUNTIME)
|
return any(path == p or path.startswith(p) for p in RUNTIME)
|
||||||
|
|
||||||
@@ -256,6 +295,12 @@ def main():
|
|||||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
||||||
problems.append(f"--options file could not be read as JSON "
|
problems.append(f"--options file could not be read as JSON "
|
||||||
f"({type(exc).__name__}: {exc}); the config was NOT checked")
|
f"({type(exc).__name__}: {exc}); the config was NOT checked")
|
||||||
|
# SAFETY-60: the validator accepts any dict, so the shape is checked here.
|
||||||
|
bad = (shape_problem(opts, option_keys().__contains__, "--options")
|
||||||
|
if opts is not None else None)
|
||||||
|
if bad:
|
||||||
|
problems.append(f"{bad}; the config was NOT checked")
|
||||||
|
opts = None
|
||||||
if a.options and opts is not None:
|
if a.options and opts is not None:
|
||||||
try:
|
try:
|
||||||
from app.validate import validate_startup_config
|
from app.validate import validate_startup_config
|
||||||
@@ -281,7 +326,7 @@ def main():
|
|||||||
print(f" - {p}")
|
print(f" - {p}")
|
||||||
else:
|
else:
|
||||||
print(" live options pass this version's validator")
|
print(" live options pass this version's validator")
|
||||||
else:
|
elif not a.options:
|
||||||
skipped.append("--options was not given, so the live config was NEVER CHECKED "
|
skipped.append("--options was not given, so the live config was NEVER CHECKED "
|
||||||
"against this version's validator. A config refused at startup is "
|
"against this version's validator. A config refused at startup is "
|
||||||
"exit 78. Export the options and re-run before deploying.")
|
"exit 78. Export the options and re-run before deploying.")
|
||||||
|
|||||||
@@ -46,8 +46,11 @@ EXIT CODES
|
|||||||
1 ⭐ AT LEAST ONE OF THOSE HAPPENED. This is the code that must be
|
1 ⭐ AT LEAST ONE OF THOSE HAPPENED. This is the code that must be
|
||||||
reachable: `test_rehearse.py` mutates the shipped migration nine
|
reachable: `test_rehearse.py` mutates the shipped migration nine
|
||||||
different ways and requires this exit code for each.
|
different ways and requires this exit code for each.
|
||||||
2 the rehearsal could not be performed (bad file, unparseable config, the
|
2 the rehearsal could not be performed (bad file, unparseable config, a
|
||||||
entity-id model failed its own anchor). ⚠️ NOT A PASS.
|
state or options file of the wrong shape, the entity-id model failed
|
||||||
|
its own anchor). ⚠️ NOT A PASS.
|
||||||
|
3 no findings, but --no-live-entities: the orphan check was not graded
|
||||||
|
against the install. ⚠️ NOT A PASS.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -64,10 +67,12 @@ from app.inverter import UNIT_A, UNIT_STORE_KEYS, Inverter, roster_names # noqa
|
|||||||
from app.main import Controller # noqa: E402
|
from app.main import Controller # noqa: E402
|
||||||
from app import mqtt # noqa: E402
|
from app import mqtt # noqa: E402
|
||||||
from app.p1 import is_enabled # noqa: E402
|
from app.p1 import is_enabled # noqa: E402
|
||||||
from app.store import (DEFAULTS, MAINT_UNIT_KEYS, Store, # noqa: E402
|
from app.store import (DEFAULTS, MAINT_SCHEMA_KEY, MAINT_UNIT_KEYS, # noqa: E402
|
||||||
unit_key)
|
SCHEMA_KEY, Store, unit_key)
|
||||||
|
from predeploy_check import option_keys, shape_problem # noqa: E402
|
||||||
|
|
||||||
EXIT_OK, EXIT_FINDING, EXIT_COULD_NOT_RUN = 0, 1, 2
|
EXIT_OK, EXIT_FINDING, EXIT_COULD_NOT_RUN, EXIT_DEGRADED = 0, 1, 2, 3
|
||||||
|
ADDON = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
class Refused(Exception):
|
class Refused(Exception):
|
||||||
@@ -184,8 +189,30 @@ def load_options(path: str) -> dict:
|
|||||||
obj = _mini_yaml(text)
|
obj = _mini_yaml(text)
|
||||||
else:
|
else:
|
||||||
obj = yaml.safe_load(text)
|
obj = yaml.safe_load(text)
|
||||||
if not isinstance(obj, dict):
|
# SAFETY-62: a wrapped export is a dict too, so assert the keys are options.
|
||||||
raise Refused(f"{path}: options must be a mapping, got {type(obj).__name__}")
|
bad = shape_problem(obj, option_keys(str(ADDON)).__contains__, path)
|
||||||
|
if bad:
|
||||||
|
raise Refused(bad)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
_UNIT_PREFIX = unit_key("\0", "").split("\0")[0]
|
||||||
|
# "auto" is the Automatic toggle, written by app/main.py outside DEFAULTS.
|
||||||
|
_STORE_KEYS = {*DEFAULTS, *MAINT_UNIT_KEYS, SCHEMA_KEY, MAINT_SCHEMA_KEY, "auto"}
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(path: str) -> dict:
|
||||||
|
"""Refuse a state file `Store.load()` would silently start fresh on (SAFETY-62)."""
|
||||||
|
try:
|
||||||
|
obj = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
except ValueError as err:
|
||||||
|
raise Refused(f"{path}: not a readable JSON state file ({err}); "
|
||||||
|
"Store.load() would start fresh and grade an empty store")
|
||||||
|
bad = shape_problem(
|
||||||
|
obj, lambda k: k in _STORE_KEYS or str(k).startswith(_UNIT_PREFIX),
|
||||||
|
path, allow_empty=True)
|
||||||
|
if bad:
|
||||||
|
raise Refused(bad)
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
@@ -1061,6 +1088,7 @@ def main(argv=None) -> int:
|
|||||||
if args.live_entities else None)
|
if args.live_entities else None)
|
||||||
if not Path(args.state).is_file():
|
if not Path(args.state).is_file():
|
||||||
raise Refused(f"{args.state}: not a file")
|
raise Refused(f"{args.state}: not a file")
|
||||||
|
load_state(args.state)
|
||||||
except (OSError, Refused) as err:
|
except (OSError, Refused) as err:
|
||||||
print(f"REFUSED: {err}", file=sys.stderr)
|
print(f"REFUSED: {err}", file=sys.stderr)
|
||||||
return EXIT_COULD_NOT_RUN
|
return EXIT_COULD_NOT_RUN
|
||||||
@@ -1179,7 +1207,9 @@ def main(argv=None) -> int:
|
|||||||
print(" the install's own entity list. This result does not cover "
|
print(" the install's own entity list. This result does not cover "
|
||||||
"orphaned entities.")
|
"orphaned entities.")
|
||||||
print("=" * 78)
|
print("=" * 78)
|
||||||
return EXIT_FINDING if findings else EXIT_OK
|
if findings:
|
||||||
|
return EXIT_FINDING
|
||||||
|
return EXIT_DEGRADED if degraded else EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user