MAINT-11: start or stop the maintenance cycle per battery
checks / unit (pull_request) Successful in 38m46s
infra-pin / fresh (pull_request) Successful in 1s

maint_start and maint_abort take an optional unit. With two or more batteries
each Batteries card has Start cycle and Stop cycle buttons. One slot per site
stays: a start while a battery cycles is refused and names it (no queue). A
battery that is switched off, an unknown unit, and a stop on a battery that is
not cycling are refused with a plain message. A request with no unit is the
old call. The scheduler and the phase machine are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-13 21:37:36 +02:00
co-authored by Claude
parent 79dd0770f9
commit 21d8a944bc
9 changed files with 521 additions and 1 deletions
+41
View File
@@ -2777,6 +2777,43 @@ class Controller:
out["sell"] = pm.sell
return out
async def _maint_unit_action(self, what, unit, now) -> dict:
"""MAINT-11: start or abort the maintenance cycle of ONE named battery.
Returns `{"error", "code", "unit"}` for a refusal and changes nothing.
The site keeps its one maintenance slot: a start while any battery
holds it is refused (no queue), see DECISIONS/MAINT-11. The phase
machine is not touched beyond `force_start()` and `abort()`, the same
two calls the bare button makes.
"""
inv = next((i for i in self.inverters if i.name == unit), None)
if inv is None:
return self._maint_refusal("unit")
machine = self.maints[self.inverters.index(inv)]
if what == "maint_start":
holder = next((i for i, m in zip(self.inverters, self.maints)
if m.phase in POWER_MOVING), None)
if holder is not None:
return self._maint_refusal("busy", holder.name)
if self.modes_on and not self.pack_mode(inv).use:
return self._maint_refusal("off", inv.name)
for msg in machine.force_start(now):
self.log_event(msg, hint=messages.hint(
"maintenance", phase=machine.phase))
return {"unit": inv.name, "phase": machine.phase}
if machine.phase not in POWER_MOVING:
return self._maint_refusal("idle", inv.name)
for msg in machine.abort(now, "operator"):
self.log_event(msg, hint=messages.hint("maintenance",
phase=machine.phase))
await self.command_site_zero()
return {"unit": inv.name, "phase": machine.phase}
@staticmethod
def _maint_refusal(code, unit="") -> dict:
return {"error": messages.maint_refused(code, unit), "code": code,
"unit": unit}
def mode_values(self) -> dict:
"""OPT-11 P2: each battery's mode and switch, for their HA state
topics. Read from the store, so they are right after a restart. Empty
@@ -7513,6 +7550,10 @@ class Controller:
# would read as the stop button not working.
await self.command_site_zero()
return {"auto": self.auto}
if what in ("maint_start", "maint_abort") and unit is not None:
# MAINT-11: the operator named the battery. A request with no unit
# falls through to the branches below, unchanged.
return await self._maint_unit_action(what, unit, now)
if what == "maint_start":
# ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
# ONE LABEL. `_maint_button()` answers it: the pack that holds the
+15
View File
@@ -1022,6 +1022,21 @@ def pack_refused(why: str) -> str:
return PACK_REFUSED[why]
# MAINT-11: why a start or stop of ONE battery's cycle was refused. `{unit}` is
# the battery the sentence is about (for "busy", the one that is cycling). The
# page shows the same sentences in Dutch from app/ui_text, keyed by the code.
MAINT_REFUSED = {
"unit": "There is no battery with that name.",
"busy": "Only one battery can cycle at a time, and {unit} is cycling now.",
"off": "{unit} is switched off, so it cannot start a cycle.",
"idle": "{unit} is not running a cycle.",
}
def maint_refused(why: str, unit: str = "") -> str:
return MAINT_REFUSED[why].format(unit=unit)
def hint(key: str, **kw) -> str:
"""The plain words for one event. `w` is formatted as watts, `sw` as
watts with their sign (positive gives power, negative charges)."""
+28 -1
View File
@@ -336,6 +336,32 @@
finally { packBusy = false; }
await refresh(true);
}
// MAINT-11: start or stop ONE battery's cycle. Two or more batteries only;
// the add-on refuses a second cycle and says which battery is cycling.
async function cycleAct(what, b) {
if (packBusy) return;
packBusy = true;
try {
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: what, unit: b.name }) });
const j = await r.json().catch(() => ({}));
const own = j.code && t("batteries.cycle." + j.code);
if (!r.ok) toast(own ? say(own.description, { name: j.unit || b.name }) : (j.error || t("toast.failed")));
else toast(say(t("batteries.cycle.toast." + (what === "maint_start" ? "started" : "stopped")), { name: b.name }));
} catch (e) { toast(t("toast.failed")); }
finally { packBusy = false; }
await refresh(true);
}
function cycleButtons(b, i) {
const ask1 = (key, what) => () => {
const w = t("batteries.cycle." + key);
ask({ name: say(w.name, { name: b.name }), description: say(w.description, { name: b.name }), yes: w.yes },
() => cycleAct(what, b));
};
return h("div", { class: "cyc", style: "display:flex;gap:8px;flex-wrap:wrap;margin-top:10px" },
h("button", { type: "button", class: "btn", id: "bcs-" + i, onclick: ask1("start_dialog", "maint_start") }, t("batteries.cycle.start")),
h("button", { type: "button", class: "btn danger", id: "bca-" + i, onclick: ask1("abort_dialog", "maint_abort") }, t("batteries.cycle.abort")));
}
function onSwitch(b, i, where, lid) {
const sw = h("button", { type: "button", class: "switch", role: "switch", id: "bsw-" + where + i,
"aria-checked": String(b.use), "aria-labelledby": lid,
@@ -370,7 +396,8 @@
h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored),
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
h("dt", {}, t("batteries.today")), h("dd", {}, todayLines(b)),
h("dt", {}, t("batteries.why")), h("dd", { class: "muted" }, b.why || "–")));
h("dt", {}, t("batteries.why")), h("dd", { class: "muted" }, b.why || "–")),
S.batteries.length > 1 ? cycleButtons(b, i) : null);
}
function simpleRow(b, i) {
const lid = "bsname-" + i;
+1
View File
@@ -92,6 +92,7 @@
"off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
},
"today": "Today", "why": "Why",
"cycle": {"start": "Start cycle", "abort": "Stop cycle", "start_dialog": {"name": "Start a cycle on {name}?", "description": "{name} empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"}, "abort_dialog": {"name": "Stop the cycle on {name}?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}, "toast": {"started": "Cycle started on {name}.", "stopped": "Cycle stopped on {name}."}, "unit": {"description": "There is no battery with that name."}, "busy": {"description": "Only one battery can cycle at a time, and {name} is cycling now."}, "off": {"description": "{name} is switched off, so it cannot start a cycle."}, "idle": {"description": "{name} is not running a cycle."}},
"modes_off": {"name": "Battery modes are off"},
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.",
"solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."},
+1
View File
@@ -89,6 +89,7 @@
"off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."}
},
"today": "Vandaag", "why": "Waarom",
"cycle": {"start": "Cyclus starten", "abort": "Cyclus stoppen", "start_dialog": {"name": "Cyclus starten op {name}?", "description": "{name} loopt leeg, laadt weer op en blijft vol, wat enkele uren duurt.", "yes": "Cyclus starten"}, "abort_dialog": {"name": "Cyclus stoppen op {name}?", "description": "Elke omvormer gaat even naar 0 W, daarna gaat de regeling verder.", "yes": "Cyclus stoppen"}, "toast": {"started": "Cyclus gestart op {name}.", "stopped": "Cyclus gestopt op {name}."}, "unit": {"description": "Er is geen batterij met die naam."}, "busy": {"description": "Er kan maar één batterij tegelijk een cyclus doen, en {name} is nu bezig."}, "off": {"description": "{name} staat uit en kan dus geen cyclus starten."}, "idle": {"description": "{name} doet nu geen cyclus."}},
"modes_off": {"name": "Batterijmodi staan uit"},
"how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.",
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, en dekt het huis op elk uur."},
+10
View File
@@ -60,6 +60,8 @@ _LOG = logging.getLogger("goodwe.web")
# OPT-11: the per-battery actions, which name a battery and a value.
PACK_ACTIONS = ("pack_use", "pack_mode", "pack_sell")
# MAINT-11: start or stop one battery's maintenance cycle (the unit is optional).
MAINT_ACTIONS = ("maint_start", "maint_abort")
# UX-13 P1: the page lives in app/static/ as three files - index.html,
# app.css, app.js - plus the page words from app/ui_text/<lang>.json (English
@@ -163,6 +165,14 @@ def build_app(controller, selfcfg=None) -> web.Application:
return web.json_response({"ok": False,
"error": result["error"]},
status=400)
elif what in MAINT_ACTIONS and body.get("unit") is not None:
# MAINT-11: one battery's cycle. No unit = the old bare call below.
result = await controller.handle_action(what, unit=body["unit"])
if isinstance(result, dict) and "error" in result:
return web.json_response(
{"ok": False, "error": result["error"],
"code": result["code"], "unit": result["unit"]},
status=409 if result["code"] != "unit" else 400)
else:
result = await controller.handle_action(what)
if result is None: