UX-08 2c: short refusals, error texts and option texts
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 2s

Validator refusals name the setting by its label and key, say what is
wrong and what to change, and keep the rejected value and limit. Option
names and descriptions are short in en and nl; citations moved to DOCS
"For installers" and the test_translations citation check now pins the
instruction on the tab and the record in DOCS. No rule, key, schema,
default or exit code changed. Allowlist 167 -> 43 (log lines only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
glenn schrooyen
2026-09-27 05:55:45 +02:00
co-authored by Claude Opus 5.5
parent e1d0970b23
commit 2c516a032c
26 changed files with 1026 additions and 1236 deletions
+31 -23
View File
@@ -47,6 +47,10 @@ from datetime import datetime, timezone
import aiohttp
from . import messages
_S = messages.setting # a refusal names the setting by its label (UX-08)
try:
import paho.mqtt.client as mqtt
except ImportError: # pragma: no cover - container always has it
@@ -180,7 +184,8 @@ def make_sample(source: str, import_w, export_w, *, phases: int,
# already applied a sign we are about to apply again - reject it rather
# than silently double-signing the control loop.
if imp < 0 or exp < 0:
raise P1Error(f"unsigned registers cannot be negative (import={imp:g} export={exp:g})")
raise P1Error(f"import and export readings cannot be negative "
f"(import={imp:g} export={exp:g})")
per_phase = per_phase_import = None
if phase_import_w is not None or phase_export_w is not None:
@@ -188,8 +193,8 @@ def make_sample(source: str, import_w, export_w, *, phases: int,
pe = list(phase_export_w or [0.0] * len(pi))
if len(pi) != phases or len(pe) != phases:
raise P1Error(
f"phase count mismatch: telegram has {len(pi)} import / {len(pe)} export "
f"phases, meter_phases is {phases}")
f"the meter sent {len(pi)} import and {len(pe)} export phases, "
f"but {_S('meter_phases')} is {phases}")
vals = [_watts(a, f"L{i + 1} import") - _watts(b, f"L{i + 1} export")
for i, (a, b) in enumerate(zip(pi, pe))]
per_phase = tuple(vals)
@@ -1160,37 +1165,37 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
exp = str(opts.get("p1_export_entity", "") or "").strip()
if not imp or not exp:
problems.append(
f"meter_source {SOURCE_HA} needs both p1_import_entity and "
f"p1_export_entity (got {imp or '<blank>'!r} / {exp or '<blank>'!r})")
f"{_S('meter_source')} is {SOURCE_HA!r}, so it needs "
f"{_S('p1_import_entity')} and {_S('p1_export_entity')}. They "
f"are {imp or '<blank>'!r} and {exp or '<blank>'!r}.")
elif source == SOURCE_HA_SIGNED:
net = str(opts.get("p1_net_entity", "") or "").strip()
phase_net = [str(e).strip() for e in
(opts.get("p1_phase_net_entities") or []) if str(e).strip()]
if not net:
problems.append(
f"meter_source {SOURCE_HA_SIGNED} needs p1_net_entity "
"(sensor.p1_sample_age_s would otherwise be announced with "
"nothing feeding it)")
f"{_S('meter_source')} is {SOURCE_HA_SIGNED!r}, so it needs "
f"{_S('p1_net_entity')}.")
if phase_net and len(phase_net) != phases:
problems.append(
f"p1_phase_net_entities has {len(phase_net)} entities but "
f"meter_phases is {phases} - every telegram would be rejected "
"on the phase-count check")
f"{_S('p1_phase_net_entities')} has {len(phase_net)} entities, "
f"but {_S('meter_phases')} is {phases}. Make them match.")
elif source == SOURCE_MQTT:
if not str(opts.get("meter_mqtt_topic", "") or "").strip():
problems.append(f"meter_source {SOURCE_MQTT} needs meter_mqtt_topic")
problems.append(f"{_S('meter_source')} is {SOURCE_MQTT!r}, so it "
f"needs {_S('meter_mqtt_topic')}.")
elif source == SOURCE_HOMEWIZARD:
host, port_s = homewizard_host_port(opts)
if not host:
problems.append(
f"meter_source {SOURCE_HOMEWIZARD} needs p1_host (the meter's "
"own address, e.g. 192.168.2.250)")
f"{_S('meter_source')} is {SOURCE_HOMEWIZARD!r}, so it needs "
f"{_S('p1_host')}, the meter's address on your network.")
if port_s:
try:
int(port_s)
except ValueError:
problems.append(f"p1_host {opts.get('p1_host')!r} has an "
"unparseable port")
problems.append(f"{_S('p1_host')} is {opts.get('p1_host')!r}, and "
"its port is not a number.")
try:
# ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a
# cadence of 0 is a misconfiguration that must be reported, not
@@ -1198,23 +1203,26 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
raw = opts.get("meter_poll_s", 5)
poll_s = float(5 if raw is None or raw == "" else raw)
except (TypeError, ValueError):
problems.append(f"meter_poll_s {opts.get('meter_poll_s')!r} is not a number")
problems.append(f"{_S('meter_poll_s')} is "
f"{opts.get('meter_poll_s')!r}, which is not a "
"number.")
else:
if poll_s <= 0:
problems.append("meter_poll_s must be positive")
problems.append(f"{_S('meter_poll_s')} must be above 0.")
# ⚠️ The age can never be fresher than the poll interval. At or past
# max_age_s every reading is stale before its successor arrives, so
# the controller would sit permanently on missing inputs while the
# meter is perfectly healthy - refuse it rather than ship that.
elif poll_s >= max_age_s:
problems.append(
f"meter_poll_s {poll_s:.1f} is not under meter_max_age_s "
f"{max_age_s:.1f} - every reading would go stale before the "
"next poll")
f"{_S('meter_poll_s')} is {poll_s:.1f}, not below "
f"{_S('meter_max_age_s')} at {max_age_s:.1f}. Poll more "
"often, or every reading goes stale.")
else:
problems.append(
f"meter_source {source!r} is not one of "
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD)))
f"{_S('meter_source')} is {source!r}. Choose one of: off, "
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD))
+ ".")
return problems