UX-10 review: the line as logged gets the red-alarm shape, clamp lines at 30 words, plain HA 400
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s

Part 6 checked the quoted reason alone, so title + reason + tails logged
as 4+ sentences. It now also checks the whole line (at most 3 sentences,
none over 30 words); 25 lines failed and are rewritten (one-sentence
wrappers, two-sentence circulation and peak reasons, STALE_READING's
fixed limits on a DEBUG line). The clamp lines for one inverter are
pinned at 30 words with OUTPUT_CLAMP and the numbers kept. Home
Assistant's bare '400: Bad Request' while it loads becomes one plain
line; still WARNING, throttled, a failed write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 12:08:38 +02:00
co-authored by Claude Opus 5.5
parent f1a660881c
commit e0b578756f
13 changed files with 231 additions and 98 deletions
+105 -2
View File
@@ -862,7 +862,7 @@ DATA = {
# hass.py: the _moan() wrapper and what its callers pass
"hass.py:msg", "hass.py:*args", "hass.py:entity_id", "hass.py:err",
"hass.py:obj", "hass.py:raw", "hass.py:domain", "hass.py:service",
"hass.py:resp.status", "hass.py:body[:200]", "hass.py:what",
"hass.py:resp.status", "hass.py:said", "hass.py:entity", "hass.py:what",
# main.py
"main.py:'cancelled' if task.cancelled() else repr(exc) if exc else 'returned'",
"main.py:EXIT_CONTROL_DIED", "main.py:first.name", "main.py:OPTIONS_PATH",
@@ -975,7 +975,9 @@ def _clamp_texts():
i_w=4500.0)
write = [(n, _clamp.clamp_to_rating(w, 5000.0, -2500.0, 2500.0))
for n, w in (("inverter-a", 3000.0), ("inverter-b", -7412.0))]
# One inverter is not named (main.py passes None), several are.
return [_clamp.loop_clamp_note(over), _clamp.loop_clamp_note(at),
_clamp.write_clamp_note([(None, write[0][1])]),
_clamp.write_clamp_note(write[:1]), _clamp.write_clamp_note(write)]
@@ -1098,6 +1100,20 @@ def reason_problems(text):
return out + line_problems(text)
def logged_shape(line):
"""UX-10 review: the line as logged, wrapper and reason together, has the
red-alarm shape too. The reason alone passing is not enough: the 2d-style
"title: reason. tail. tail." line read as 4 sentences with a 3-sentence
reason inside it."""
out = []
if sentences(line) > 3:
out.append(f"over 3 sentences ({sentences(line)})")
long = [x for x in re.split(r"(?<=[.!?])\s+", line) if len(words(x)) > 30]
if long:
out.append(f"a sentence over 30 words ({len(words(long[0]))})")
return out
def line_problems(text):
"""What no WARNING+ line may carry, whatever its length."""
out = []
@@ -1145,7 +1161,7 @@ def scan_log_lines(files, text=None, data=None):
lambda m: next(slots, m.group(0)), template)
line = PLACEHOLDER.sub("X", line).replace("\0", sample)
p += [f"as logged: {x}" for x in line_problems(line)
if x not in p]
+ logged_shape(line) if x not in p]
if p:
bad.append((key, sample, p))
unknown = sorted(seen - set(data) - set(text))
@@ -1174,6 +1190,36 @@ check(f"no WARNING+ line puts a plural noun after a bare count, which reads "
f"'1 times' (use messages.times() and friends; found: {_counts})",
not _counts)
# UX-10 review: the two clamp lines the owner quoted, as logged for one
# inverter, fit the red shape the tight way: at most 3 sentences and 30 words
# in all, code and numbers kept. The real literals from main.py, filled with
# the real notes.
def _clamp_line(opening):
for n in ast.walk(dict(_FILES)["main.py"]):
if isinstance(n, ast.Call) and LOG_CALL.search(ast.unparse(n.func)) \
and n.args and (render(n.args[0]) or "").startswith(opening):
return n.args[0].value
return None
_over, _at, _w1, _w1n, _w2 = _clamp_texts()
_clamp_lines = {
"error, loop": (_clamp_line("Power request too high"), _over),
"error, write": (_clamp_line("Power request too high"), _w1),
"warning, loop": (_clamp_line("The battery gives less than asked"), _at),
}
_clamp_shown = {k: (f % (r, messages.times(1)) if f else None)
for k, (f, r) in _clamp_lines.items()}
for k, v in _clamp_shown.items():
print(f" {k}: {v!r} ({len(words(v or ''))} words)")
check("the clamp lines for one inverter are at most 3 sentences and 30 words, "
"and keep OUTPUT_CLAMP and the numbers",
all(v and sentences(v) <= 3 and len(words(v)) <= 30
and "OUTPUT_CLAMP" in v and "1 time so far" in v
for v in _clamp_shown.values())
and all(x in _clamp_shown["error, loop"]
for x in ("+9000 W", "+5000 W", "+2000 W", "software fault")))
# Mutations: the scan catches what the 2d scan missed.
_OLD_CLAMP = ("OUTPUT_CLAMP: the loop computed +2148 W, over the 2000 W rating, "
"and it was clamped to +2000 W; the loop's command this cycle was "
@@ -1197,6 +1243,13 @@ check("...and '1 times', '(s)', a semicolon and a doubled full stop in a "
"Cut back to +2000 W.. Only this.",
"index 0, 1, .... Those are left out.",
"moved to one part: . Problems")))
check("...and so is the 2d-style line, a 2-sentence reason inside a title and "
"two tails, which reads as 4 sentences although the reason passes",
logged_shape("Power request too high: OUTPUT_CLAMP: cut to +5000 W. The "
"command was +1000 W. Control is still running. Cut back "
"1 time so far.")
and not reason_problems("OUTPUT_CLAMP: cut to +5000 W. The command was "
"+1000 W"))
check("...while '1 s' and '1 time' are fine",
not line_problems("Held for 1 s, cut back 1 time so far."))
# INFO lines are not held to the length rule, but the 2d test found "§8.3",
@@ -1317,6 +1370,56 @@ check("...and the library's own text is kept, at DEBUG",
and not any("8:1000" in m or "Bad Gateway" in m for m in _warn + _info))
class _Resp400:
def __init__(self, body):
self.status, self.reason, self._body = 400, "Bad Request", body
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def text(self):
return self._body
class _Session400:
def __init__(self, body):
self.body = body
def post(self, *a, **k):
return _Resp400(self.body)
def _refused(body):
ha = _hass.HomeAssistant(_Session400(body), token="t")
ok = []
recs = _captured("goodwe.hass", lambda: ok.append(asyncio.run(
ha.call("number", "set_value", {"entity_id": "number.sp", "value": 1}))))
return (ok[0] if ok else None), [r.getMessage() for r in recs
if r.levelno == logging.WARNING]
def _one(res):
"""(write result, the one WARNING line) or a named absence that fails."""
ok, lines = res
return ok, (lines[0] if len(lines) == 1 else f"<{len(lines)} lines>")
_bare_ok, _bare = _one(_refused("400: Bad Request"))
_full_ok, _full = _one(_refused(
'{"message": "Value 6000 for number.sp is outside valid range"}'))
check(f"a write Home Assistant refuses while it loads is one plain WARNING, "
f"still a failed write, without the empty '400: Bad Request' ({_bare!r})",
_bare_ok is False
and _bare.startswith("Home Assistant refused number.set_value for "
"number.sp (error 400, no details).")
and "Bad Request" not in _bare)
check(f"...while a body that says more is still quoted ({_full!r})",
_full_ok is False and "outside valid range" in _full)
class _WS:
def __init__(self, kind):
self.kind = kind