From ef35f4b925e2db39a30e5f746b9f630ebc47e86c Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 8 Oct 2026 21:25:35 +0200 Subject: [PATCH] MAINT-14: accurate no-level wording per phase; name the limit that ended the charge Co-Authored-By: Claude Opus 5.5 --- .../MAINT-14-no-soc-in-maintenance.md | 3 ++- goodwe_controller/app/maintenance.py | 12 +++++++--- goodwe_controller/test_maint14.py | 23 +++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/goodwe_controller/DECISIONS/MAINT-14-no-soc-in-maintenance.md b/goodwe_controller/DECISIONS/MAINT-14-no-soc-in-maintenance.md index 2dd4c9d..0ffa494 100644 --- a/goodwe_controller/DECISIONS/MAINT-14-no-soc-in-maintenance.md +++ b/goodwe_controller/DECISIONS/MAINT-14-no-soc-in-maintenance.md @@ -60,7 +60,8 @@ allows discharge. - **HOLD with no SoC** still holds 0 W and, as before, does not end while the reading is missing: its time check sits behind the same early return. A HOLD - needs no level to end, so this is a candidate follow-up, not part of this fix. + needs no level to end, so this is a candidate follow-up, not part of this fix. It is card MAINT-15. The "no battery level" line in + HOLD says only that the hold waits for the level. - It does not tell the operator why the reading is missing (sensor, register, link). It does not reuse the stale-input gate; it is the cycle's own rule. - The charge itself is still driven by the control loop, which has its own diff --git a/goodwe_controller/app/maintenance.py b/goodwe_controller/app/maintenance.py index 8758185..fc7ecb5 100644 --- a/goodwe_controller/app/maintenance.py +++ b/goodwe_controller/app/maintenance.py @@ -563,8 +563,11 @@ class Maintenance: if (not self._nosoc_said and (now - self._nosoc_since).total_seconds() >= NOSOC_NOTE_MIN * 60): self._nosoc_said = True + tail = ("The hold waits for the level to come back." + if phase == HOLD else + f"The time limit of the {phase} still applies.") msg = (f"maintenance: no battery level for {NOSOC_NOTE_MIN} min. " - f"The time limit of the {phase} still applies.") + f"{tail}") _LOG.warning("%s", msg) events.append(msg) if phase == HOLD: @@ -587,9 +590,12 @@ class Maintenance: f"charge counts as done -> holding " f"{self.cfg.hold_min} min to balance") return MaintResult(HOLD, True, 0.0, False, events) + if elapsed > CHARGE_WALL_TIMEOUT_MIN: + lim = f"the {CHARGE_WALL_TIMEOUT_MIN // 1440}-day limit" + else: + lim = f"{trk['open_min'] / 60:.0f} h of charging time" return self._fail(now, "charge", - f"charge stuck, battery level unknown, after " - f"{trk['open_min'] / 60:.0f} h of charging time") + f"charge stuck, battery level unknown, after {lim}") return MaintResult(CHARGE, False, None, True, events) def abort(self, now: datetime, why: str) -> list: diff --git a/goodwe_controller/test_maint14.py b/goodwe_controller/test_maint14.py index 37bb1cc..7a6873e 100644 --- a/goodwe_controller/test_maint14.py +++ b/goodwe_controller/test_maint14.py @@ -240,6 +240,29 @@ m = in_charge() res, _, out, ev = run(m, T0, 5, None) check("a 5 min gap says nothing", ev == []) +print("5b. the words per phase") +m = in_charge() +m.tick(T0 + MIN, 100.0) +_, _, _, ev = run(m, T0 + 2 * MIN, 30, None) +note = [e for e in ev if "no battery level for" in e] +check("HOLD: the line exists and claims no time limit", + len(note) == 1 and "time limit" not in note[0] and "waits" in note[0]) +m = in_charge() +_, _, _, ev = run(m, T0, 30, None) +check("charge keeps its wording", any("time limit of the charge" in e for e in ev)) +m = in_charge() +run(m, T0, 10, 70.0) +_, _, out, ev = run(m, T0 + 10 * MIN, CHARGE_WALL_TIMEOUT_MIN + 30, None, + gate=False, step=5) +fail = [e for e in ev if "ABORT" in e] +check("7-day backstop: the reason names the 7-day limit, not '0 h'", + len(fail) == 1 and "7-day limit" in fail[0] and "0 h" not in fail[0]) +m = in_charge() +_, _, out, ev = run(m, T0, CHARGE_TIMEOUT_MIN + 10, None) +fail = [e for e in ev if "ABORT" in e] +check("charge-time limit: the reason gives the hours of charging time", + len(fail) == 1 and "36 h of charging time" in fail[0]) + print("6. restart while the SoC is missing") with tempfile.TemporaryDirectory() as d: path = os.path.join(d, "state.json")