MAINT-13: a full pack that stops at 98-99 % no longer counts as a failed charge, and a failed cycle no longer drains again the next day.
Rules (constants in app/maintenance.py; no option added)
Charge complete at SoC >= target; or SoC >= target - 3 and no new SoC high for 60 min of allowed charging; or the charging time runs out at SoC >= target - 3. Then the usual hold.
A stuck drain (12 h) or a charge below target - 3 at timeout is a failure: 7 days back-off, one notification (EN + NL), no new cycle on that pack until then. The operator's force-start ignores it; a completed cycle ends it.
The 36 h charge timeout counts only minutes the price gate (self.cheap) was open. A 7 day wall-clock backstop covers a gate that never opens.
N=1 and N=2 (per-pack keys; the other pack may take the slot while one is backed off).
Why these numbers, the live figures, the cost, and the MAINT-12 interplay: DECISIONS/MAINT-13-full-pack-is-complete.md.
Strings
Title: Battery cycle did not finish / Batterijcyclus niet afgerond
EN: "The battery did not fill up, so its cycle stopped and tries again in 7 days." (drain: "did not empty")
NL: "De batterij raakte niet vol, dus de cyclus is gestopt en probeert het over 7 dagen opnieuw." (drain: "niet leeg")
Status row "Maintenance paused": "Waits until 2026-10-13 after a cycle that did not finish."
Tests: new test_maint13.py; test_maintenance.py (fixture gains last_failed, drain-abort pin gains the plain line) and test_safety93.py (B has finished its cycle, because A no longer restarts at once) adjusted.
Release type: no option change, so no config change for installers. Behaviour change: the first state file write after this adds last_failed (and charge_track while charging).
MAINT-13: a full pack that stops at 98-99 % no longer counts as a failed charge, and a failed cycle no longer drains again the next day.
**Rules** (constants in `app/maintenance.py`; no option added)
- Charge complete at SoC >= target; or SoC >= target - 3 and no new SoC high for 60 min of allowed charging; or the charging time runs out at SoC >= target - 3. Then the usual hold.
- A stuck drain (12 h) or a charge below target - 3 at timeout is a failure: 7 days back-off, one notification (EN + NL), no new cycle on that pack until then. The operator's force-start ignores it; a completed cycle ends it.
- The 36 h charge timeout counts only minutes the price gate (`self.cheap`) was open. A 7 day wall-clock backstop covers a gate that never opens.
- N=1 and N=2 (per-pack keys; the other pack may take the slot while one is backed off).
Why these numbers, the live figures, the cost, and the MAINT-12 interplay: `DECISIONS/MAINT-13-full-pack-is-complete.md`.
**Strings**
- Title: Battery cycle did not finish / Batterijcyclus niet afgerond
- EN: "The battery did not fill up, so its cycle stopped and tries again in 7 days." (drain: "did not empty")
- NL: "De batterij raakte niet vol, dus de cyclus is gestopt en probeert het over 7 dagen opnieuw." (drain: "niet leeg")
- Status row "Maintenance paused": "Waits until 2026-10-13 after a cycle that did not finish."
**Tests**: new `test_maint13.py`; `test_maintenance.py` (fixture gains `last_failed`, drain-abort pin gains the plain line) and `test_safety93.py` (B has finished its cycle, because A no longer restarts at once) adjusted.
**Release type**: no option change, so no config change for installers. Behaviour change: the first state file write after this adds `last_failed` (and `charge_track` while charging).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
A charge is complete at SoC >= target, or at >= target - 3 once the SoC has
made no new high for 60 min of allowed charging, or when the charging time
runs out at >= target - 3. The 36 h timeout counts only the minutes the price
gate let the pack charge (plus a 7 day wall-clock backstop). A cycle that
fails (stuck drain, charge below target - 3) backs off 7 days, tells the
operator once (EN + NL), and no cycle starts on that pack in that time unless
the operator forces one. State survives a restart; old state files load.
No option added. DECISIONS/MAINT-13-full-pack-is-complete.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- gate_open is true with no price gate set up, in a cheap window, or while the
pack is taking charge; the stall rule and timeout now work on a no-tariff site.
- reset the save timer on a phase change; exact 5 min gap-cap test.
- test_safety93: original timeout scenario kept (B finished) plus a case where a
due B takes the slot after A's failure.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
test_static_tariff forbids main.py reading `static_profile`; a small
static_gated() in static_tariff.py answers it, and the Controller keeps the
answer in `_price_gated` beside `cheap`.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
- A price-provider site with no fixed tariff is not a gate (its plan never
sets `cheap`); DECISIONS §3 says so and a test pins it.
- The fixed-tariff check is worked out once an hour, not every tick.
- test_maint13: run() steps really are `step` minutes; the long loops use
5-minute steps (the per-tick cap); wall backstop checked "not before".
- test_safety93: the drain bound is the rig's configured drain rate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MAINT-13 review (head reviewed: 6836f61, then my test/docs commit ea31289)
Verdict: correct and safe to merge once checks / unit (pull_request) is green on ea31289. I found no behaviour change to ask for.
Tests I ran myself. In goodwe-addon-checks:1 with --network none, one docker run per file, 8 at a time: 68/68 pass on 6836f61 (wall 519 s; the slowest was test_modes_p4 at 486 s). On ea31289 I re-ran test_maint13, test_docs, test_messages, test_translations, plus the 7 files that read DOCS.md. All pass.
Rules, checked against the code
Complete at target, or at target-3 or above after 60 gate-open min with no new high, or a timeout at target-3 or above. A timeout below target-3 is a failure. A failure sets last_failed, sends one notification (only on the tick that carries failed) and makes wants_start() false for 7 days. Force-start ignores it. A completed cycle ends it. Operator abort, interlock and Off are not failures. OK.
The timeout counts only gate-open minutes, at most 5 per tick, and has a 7-day wall-clock backstop. A pack with no SoC reading still never times out, but that was already true before this PR (the soc is None return comes before the timeout).
_charge_gate_open by site type:
No tariff, or a price provider with no fixed tariff: there is no gate, so the time counts in full, as before.
Fixed tariff, or HA price entities: the gate is open in cheap hours or while the battery is charging at 50 W or more (sun).
No battery reading: the gate counts as closed, and the wall backstop still ends the charge.
No path leads to a pack that can never finish. The only ways back to IDLE without a completion or a failure are operator abort, Off and interlock, and none of them comes back daily by itself.
State: charge_track is saved every 5 min and matched against phase_started, so a record from another charge is ignored. An old state file loads with the wall-clock minutes. last_failed is in MAINT_UNIT_KEYS. On an install that has already migrated (marker stamped), it is simply written under the unit key from now on.
N=2: the arbitration is unchanged and a backed-off pack frees the slot. SAFETY-93 keeps the original timeout scenario (with B finished) and adds the B-due case.
Scope calls
Drain timeout also backs off: accepted, and safe. A stuck drain now repeats weekly, not daily.
NL text only in the notification: fine.
The notification is lost on an HA restart. The status row stays while the wait lasts. That is acceptable for a warning, which is not latched like an alarm.
UX-08. Title, EN body, NL body, status row and log lines are short and plain. OK.
Findings (none blocking)
Low, main.py status row "Maintenance paused": at N=2 it shows the earliest date and does not say which battery waits. A follow-up could name the pack.
Low, DOCS.md "When the charge counts as done": the 7-day end was not mentioned. Fixed in ea31289 (one clause).
Test gaps found by my mutations: no check that a stale charge_track from another charge is ignored (R3), or that a clock step back adds nothing (R8). Two checks added in ea31289; both mutations are now killed.
Before re-enabling maintenance live: B's failure on 2026-10-06 happened before this code, so B has no last_failed. B is due and will start a drain at the next start hour once maintenance is enabled again.
My mutations (not in the dev's 39), each run on test_maint13 + test_maintenance:
#
mutation
result
R1
a flat SoC resets the stall wait (>= best)
killed
R2
target-3 itself is not near full (>)
killed
R3
stored charge record loaded without the since check
survived, then killed by the new check
R4
discharging pack counts as an open gate (sign flipped)
killed
R5
every failure reported as a charge failure
killed
R6
charge record saved every 120 min, not 5
killed
R7
_enter does not clear the stored record
survives; equivalent while the since check stands
R8
a clock step back is not floored at 0
survived, then killed by the new check
R9
status row shows the latest wait, not the earliest
survives (N=2 only; finding 1)
R10
a cheap window does not open a gated site
killed
R11
N=2 notification does not name the pack
killed
R12
an invalid fixed tariff counts as a gate
survives; low, because the safe direction (no gate) is the shipped one and startup validation refuses invalid profiles
R13
wall backstop 7 d -> 36 h
killed
R14
charging threshold 50 W -> 5000 W
killed
R15
back-off counted from last_start_attempt
killed
## MAINT-13 review (head reviewed: 6836f61, then my test/docs commit ea31289)
**Verdict: correct and safe to merge** once `checks / unit (pull_request)` is green on ea31289. I found no behaviour change to ask for.
**Tests I ran myself.** In `goodwe-addon-checks:1` with `--network none`, one `docker run` per file, 8 at a time: **68/68 pass** on 6836f61 (wall 519 s; the slowest was test_modes_p4 at 486 s). On ea31289 I re-ran test_maint13, test_docs, test_messages, test_translations, plus the 7 files that read DOCS.md. All pass.
**Rules, checked against the code**
- Complete at target, or at target-3 or above after 60 gate-open min with no new high, or a timeout at target-3 or above. A timeout below target-3 is a failure. A failure sets `last_failed`, sends one notification (only on the tick that carries `failed`) and makes `wants_start()` false for 7 days. Force-start ignores it. A completed cycle ends it. Operator abort, interlock and Off are not failures. OK.
- The timeout counts only gate-open minutes, at most 5 per tick, and has a 7-day wall-clock backstop. A pack with no SoC reading still never times out, but that was already true before this PR (the `soc is None` return comes before the timeout).
- `_charge_gate_open` by site type:
- No tariff, or a price provider with no fixed tariff: there is no gate, so the time counts in full, as before.
- Fixed tariff, or HA price entities: the gate is open in cheap hours or while the battery is charging at 50 W or more (sun).
- No battery reading: the gate counts as closed, and the wall backstop still ends the charge.
- No path leads to a pack that can never finish. The only ways back to IDLE without a completion or a failure are operator abort, Off and interlock, and none of them comes back daily by itself.
- State: `charge_track` is saved every 5 min and matched against `phase_started`, so a record from another charge is ignored. An old state file loads with the wall-clock minutes. `last_failed` is in `MAINT_UNIT_KEYS`. On an install that has already migrated (marker stamped), it is simply written under the unit key from now on.
- N=2: the arbitration is unchanged and a backed-off pack frees the slot. SAFETY-93 keeps the original timeout scenario (with B finished) and adds the B-due case.
**Scope calls**
- Drain timeout also backs off: accepted, and safe. A stuck drain now repeats weekly, not daily.
- NL text only in the notification: fine.
- The notification is lost on an HA restart. The status row stays while the wait lasts. That is acceptable for a warning, which is not latched like an alarm.
**UX-08.** Title, EN body, NL body, status row and log lines are short and plain. OK.
**Findings (none blocking)**
1. Low, `main.py` status row "Maintenance paused": at N=2 it shows the earliest date and does not say which battery waits. A follow-up could name the pack.
2. Low, `DOCS.md` "When the charge counts as done": the 7-day end was not mentioned. **Fixed in ea31289** (one clause).
3. Test gaps found by my mutations: no check that a stale `charge_track` from another charge is ignored (R3), or that a clock step back adds nothing (R8). **Two checks added in ea31289**; both mutations are now killed.
4. Before re-enabling maintenance live: B's failure on 2026-10-06 happened before this code, so B has no `last_failed`. B is due and will start a drain at the next start hour once maintenance is enabled again.
**My mutations (not in the dev's 39)**, each run on test_maint13 + test_maintenance:
| # | mutation | result |
|---|---|---|
| R1 | a flat SoC resets the stall wait (`>=` best) | killed |
| R2 | target-3 itself is not near full (`>`) | killed |
| R3 | stored charge record loaded without the `since` check | survived, then killed by the new check |
| R4 | discharging pack counts as an open gate (sign flipped) | killed |
| R5 | every failure reported as a charge failure | killed |
| R6 | charge record saved every 120 min, not 5 | killed |
| R7 | `_enter` does not clear the stored record | survives; equivalent while the `since` check stands |
| R8 | a clock step back is not floored at 0 | survived, then killed by the new check |
| R9 | status row shows the latest wait, not the earliest | survives (N=2 only; finding 1) |
| R10 | a cheap window does not open a gated site | killed |
| R11 | N=2 notification does not name the pack | killed |
| R12 | an invalid fixed tariff counts as a gate | survives; low, because the safe direction (no gate) is the shipped one and startup validation refuses invalid profiles |
| R13 | wall backstop 7 d -> 36 h | killed |
| R14 | charging threshold 50 W -> 5000 W | killed |
| R15 | back-off counted from `last_start_attempt` | killed |
admin
merged commit 8a17711f60 into release/1.02026-10-08 13:45:11 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
MAINT-13: a full pack that stops at 98-99 % no longer counts as a failed charge, and a failed cycle no longer drains again the next day.
Rules (constants in
app/maintenance.py; no option added)self.cheap) was open. A 7 day wall-clock backstop covers a gate that never opens.Why these numbers, the live figures, the cost, and the MAINT-12 interplay:
DECISIONS/MAINT-13-full-pack-is-complete.md.Strings
Tests: new
test_maint13.py;test_maintenance.py(fixture gainslast_failed, drain-abort pin gains the plain line) andtest_safety93.py(B has finished its cycle, because A no longer restarts at once) adjusted.Release type: no option change, so no config change for installers. Behaviour change: the first state file write after this adds
last_failed(andcharge_trackwhile charging).🤖 Generated with Claude Code
MAINT-13 review (head reviewed:
6836f61, then my test/docs commitea31289)Verdict: correct and safe to merge once
checks / unit (pull_request)is green onea31289. I found no behaviour change to ask for.Tests I ran myself. In
goodwe-addon-checks:1with--network none, onedocker runper file, 8 at a time: 68/68 pass on6836f61(wall 519 s; the slowest was test_modes_p4 at 486 s). Onea31289I re-ran test_maint13, test_docs, test_messages, test_translations, plus the 7 files that read DOCS.md. All pass.Rules, checked against the code
last_failed, sends one notification (only on the tick that carriesfailed) and makeswants_start()false for 7 days. Force-start ignores it. A completed cycle ends it. Operator abort, interlock and Off are not failures. OK.soc is Nonereturn comes before the timeout)._charge_gate_openby site type:charge_trackis saved every 5 min and matched againstphase_started, so a record from another charge is ignored. An old state file loads with the wall-clock minutes.last_failedis inMAINT_UNIT_KEYS. On an install that has already migrated (marker stamped), it is simply written under the unit key from now on.Scope calls
UX-08. Title, EN body, NL body, status row and log lines are short and plain. OK.
Findings (none blocking)
main.pystatus row "Maintenance paused": at N=2 it shows the earliest date and does not say which battery waits. A follow-up could name the pack.DOCS.md"When the charge counts as done": the 7-day end was not mentioned. Fixed inea31289(one clause).charge_trackfrom another charge is ignored (R3), or that a clock step back adds nothing (R8). Two checks added inea31289; both mutations are now killed.last_failed. B is due and will start a drain at the next start hour once maintenance is enabled again.My mutations (not in the dev's 39), each run on test_maint13 + test_maintenance:
>=best)>)sincecheck_enterdoes not clear the stored recordsincecheck standslast_start_attempt