MAINT-05: one recalibration per pack, and only one pack moves power at a time
checks / unit (pull_request) Successful in 4m48s

§12's monthly cycle drains a pack to empty and refills it to true its coulomb
counter. There was exactly one `Maintenance` and one un-namespaced history, so
at two packs only the first was ever recalibrated - and nothing prevented the
cycle draining pack A while the loop charged pack B, which is two power-moving
phases on one grid connection pulling against each other.

* ONE `Maintenance` PER ROSTER ENTRY, each with its own schedule, phase and
  history under `unit/<name>/...`. `store.migrate_maint_keys()` moves an
  install's flat history into the first pack's namespace under
  `migrate_unit_keys()`'s three rules - shared in code via `_move_flat_keys()`
  and `_verify_moves()`, not restated - on its own marker, because unlike
  DEV-02's it runs only where the roster has more than one pack. The flat keys
  are kept LIVE as the rollback view.
* ONE MAINTENANCE SLOT PER SITE (`maintenance.arbitrate()`). At most one pack
  in a power-moving phase - drain, charge AND hold. The holder is DERIVED from
  the packs' own stored phases, never persisted, so a restart mid-drain
  re-derives it. Several packs due in the same hour: the slot goes to the one
  that has gone longest without a completed cycle; roster order breaks a
  numeric tie and nothing else. Nothing preempts a cycle in flight.
* THE DEFERRED PACK IS NOT STARVED AND IS NOT A FAULT. A deferral does not
  stamp `last_start_attempt` and does not move the phase, so neither the 12 h
  drain nor the 36 h charge timeout is reachable by waiting.
* The claim is filed on the pack whose state machine produced it, and stays
  SCOPE_UNIT - ARCH-01's rule.
* `validate.py` rule 6 grades `maintenance_charge_w` against the WEAKEST cap on
  the roster now, as its own comment said it must once `maint_unit` stopped
  being `inverters[0]`.
* At a roster of one nothing changes, and it is asserted: no new store key, the
  migration does not run, the four cycle events are the shipped strings, the
  banner is the bare phase.
* RECORDED NOT FIXED: a drain pins its pack while the loop's target is a site
  claim, so below the pin the allocator refuses the split and the site parks at
  0 W. Pre-existing at 2d025ee. Pinned as a running check.

Verified: 19/19 suites green in the CI image; 17 mutations run through
tools/mutation_run.sh (bytecode purged), every one observed red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-04 13:38:29 +02:00
co-authored by Claude Opus 5
parent 2d025eebf7
commit 1aef19e995
9 changed files with 1980 additions and 169 deletions
+304 -107
View File
@@ -54,14 +54,16 @@ from .hass import HomeAssistant
# window an operator would look up here is the one the roster actually uses.
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries)
from .maintenance import IDLE, MaintConfig, Maintenance
from .maintenance import (
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
)
from .mqtt import MqttPublisher
from . import peakguard
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
from .store import migrate_unit_keys
from .store import migrate_maint_keys, migrate_unit_keys
from .validate import (ConfigRefused, refuse, validate_startup_config,
validate_unit_ranges)
from .watchdog import (
@@ -265,7 +267,53 @@ class Controller:
# maintenance.MAINT_OPTION_DEFAULTS before adding a ninth key: it goes
# in config.yaml's `options:` and `schema:` blocks and in that dict, and
# nowhere else.
self.maint = Maintenance(MaintConfig.from_options(opts), store)
# --- MAINT-05: ONE `Maintenance` PER ROSTER ENTRY --------------------
# ⚠️ AND THE MIGRATION RUNS FIRST, FOR THE REASON THE SAFETY-02 ONE
# DOES. These objects READ their history in their constructors' wake -
# `phase`, `last_completed` - so a history still sitting under the flat
# spelling when they are built is a pack that comes up believing it has
# never been recalibrated, on a site whose state file says otherwise.
# It targets `self.inverters[0].name` and NOT `UNIT_A`: the flat keys
# belonged to whichever pack the one FSM was driving, which is the first
# roster entry, and a declared `inverters:` roster names its own entries
# (`inverter.build_roster()` says so). Writing `UNIT_A` here would
# deposit an install's whole maintenance history under a name that is
# not on the roster at all.
self.maint_migration = migrate_maint_keys(
store, self.inverters[0].name, len(self.inverters))
_LOG.info("maintenance history: %s", self.maint_migration.explain())
if not self.maint_migration.ok:
# Not a disarmed guard - history, not a latch - so it does not
# re-arm anything. It is still the difference between a pack that is
# recalibrated on schedule and one that silently is not, which is
# §12's opening sentence, so it is said where an operator looks.
self.log_event("maintenance history did NOT migrate cleanly: "
+ self.maint_migration.explain())
# ⚠️ ONE CONFIG, N MACHINES. The `maintenance_*` options describe the
# PROCEDURE - how hard to drain, how full to charge, how long to hold -
# and the procedure is the same on every pack in the roster; what is per
# pack is the SCHEDULE and the HISTORY, which live in the store. A
# per-entry config block would be a second shape for CFG-04's roster to
# carry and a fifth place for a `maintenance_charge_w` to disagree with
# config.yaml. If a site ever needs two different procedures, that is a
# roster-schema card and it starts here.
_maint_cfg = MaintConfig.from_options(opts)
_one_pack = len(self.inverters) <= 1
self.maints = [
Maintenance(
_maint_cfg, store,
# ⚠️ `None` AT A ROSTER OF ONE IS THE FLAT LAYOUT, NOT A MISSING
# NAME. §6.2's identity: one pack, one history, and the keys it
# has been written under since the add-on shipped. See
# `maintenance.UnitHistory` and `store.migrate_maint_keys()` -
# this is the line that keeps a one-inverter state file
# byte-identical.
unit=None if _one_pack else inv.name,
name=inv.name,
# The rollback view, and only ever the first pack's: the flat
# keys mean `inverters[0]` on every install that has them.
mirror_flat=(not _one_pack and i == 0))
for i, inv in enumerate(self.inverters)]
# SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own
# docstring has always said and what this Controller did not do. Each
@@ -453,6 +501,9 @@ class Controller:
self._peak_saturated = False
self._peak_floor_limited = False
self._maint_shape_suspended = False
# [(pack name, why)] for packs that are due and waiting their turn.
# Empty at a roster of one, where nothing can defer anything.
self._maint_deferred = []
# Set by amain() once the transport is built, so the status page can show
# what only the transport knows (homewizard_local's unchanged_s). Stays
# None when P1 is off, or under a transport that has no such counter.
@@ -667,18 +718,79 @@ class Controller:
return tuple(inv.name for inv in self.inverters)
@property
def maint_unit(self):
"""The pack the ONE maintenance state machine is recalibrating.
def maint_index(self) -> int:
"""Which roster slot holds the site's ONE maintenance slot right now.
⚠️ NAMED RATHER THAN ASSUMED. This add-on holds exactly one
`Maintenance`, whose history lives under the store's un-namespaced
`phase` / `last_completed` keys, so the pack it drives is the first in
the roster. Two packs recalibrating on two schedules needs a second FSM
and a namespaced history of its own; what THIS card fixes is only that
the claim is filed on ONE actuator instead of on whichever one happened
to be asked. Read store.LEGACY_UNIT_KEYS on why those keys did not move.
The pack that is mid-cycle if there is one, otherwise the first - which
at a roster of one is always 0, always was, and is the whole of this
property's N=1 behaviour.
"""
return self.inverters[0]
for i, m in enumerate(self.maints):
if m.phase in POWER_MOVING:
return i
return 0
@property
def maint(self):
"""The `Maintenance` the site's one maintenance slot belongs to.
⚠️ MAINT-05 TURNED THIS FROM AN ATTRIBUTE INTO A VIEW, AND THE THING IT
IS A VIEW OF CHANGED WITH IT. There are now `self.maints` - one FSM per
roster entry, each with its own schedule, phase and history - and every
SITE-level surface that used to read `self.maint` (the `phase` MQTT
value, the status banner, the operator's start/abort buttons) wants THE
ONE THAT IS RECALIBRATING, not "the first one". `maintenance.arbitrate()`
guarantees at most one pack is ever out of IDLE, so "the one that is
recalibrating" is well defined; when none is, this is `maints[0]` and
the surfaces read exactly what they read before.
⚠️ AT A ROSTER OF ONE IT IS `maints[0]` ON EVERY TICK, in both branches,
which is the object that used to be assigned here. Nothing downstream
can tell the difference - that is AC 6, and test_maintenance.py part 9
asserts it rather than trusting this paragraph.
"""
return self.maints[self.maint_index]
@property
def maint_unit(self):
"""The pack the site's one maintenance slot is currently on.
⚠️ THIS USED TO BE `self.inverters[0]`, FLATLY, AND THE COMMENT THAT
STOOD HERE EXPLAINED WHY: there was exactly one `Maintenance`, its
history lived under the store's un-namespaced `phase` / `last_completed`
keys, so the pack it drove could only be the first in the roster. That
sentence is what MAINT-05 removed. There is now one FSM and one
namespaced history PER ROSTER ENTRY, so the pack being recalibrated is
whichever one holds the site's maintenance slot, and `maint_index` -
not the roster's order - is what answers it.
⚠️ AND `validate.py` RULE 6 MOVED WITH IT, exactly as its own comment
said it must ("when `maint_unit` stops being `inverters[0]`, this line
moves with it"). It graded `maintenance_charge_w` against the FIRST
entry's cap on the argument that the cycle never touches the others;
that argument is now false, so it grades against the weakest cap on the
roster, like rule 5. See the ⚠️ block there.
"""
return self.inverters[self.maint_index]
def _maint_button(self, now):
"""The pack an operator's one maintenance button acts on.
The pack holding the site's maintenance slot when one holds it, so
"abort" stops the cycle that is running and "start" restarts it -
exactly what both buttons meant while there was one pack, and the reason
this is not simply "the longest-waiting one". When every pack is idle it
IS the longest-waiting one, which is `arbitrate()`'s own clause 2, so a
forced start and a scheduled start choose the same pack rather than two
different ones.
⚠️ AT A ROSTER OF ONE BOTH BRANCHES ARE `maints[0]`. There is no third
case: `max()` over a one-element list is that element.
"""
if self.maints[self.maint_index].phase in POWER_MOVING:
return self.maints[self.maint_index]
return max(enumerate(self.maints),
key=lambda p: (p[1].waited_days(now), -p[0]))[1]
def inverter(self, name: str):
"""The roster entry called `name`, or None."""
@@ -1329,14 +1441,36 @@ class Controller:
"""
now = datetime.now(timezone.utc)
# ⚠️ THE PACK IT IS MAINTAINING, NOT "the SoC". §12's recalibration
# drains and refills ONE pack and grades ONE coulomb counter, so it is
# graded on that pack's own reading - see `maint_unit`. A minimum across
# packs would abort the cycle on a sibling's state, and an average would
# declare a pack full that is not.
result = self.maint.tick(now, self.maint_unit.soc)
for msg in result.events:
self.log_event(msg)
# ⚠️ EACH PACK ON ITS OWN READING, NOT "the SoC". §12's recalibration
# drains and refills ONE pack and grades ONE coulomb counter, so each
# state machine is ticked with the SoC of the inverter it belongs to. A
# minimum across packs would abort a cycle on a sibling's state, and an
# average would declare a pack full that is not. The pairing is
# positional and both lists are built from the roster in `__init__`, so
# a pack cannot be graded on its neighbour's meter.
#
# ⚠️ AND `arbitrate()` RUNS FIRST, ONCE, OVER ALL OF THEM. It is the
# site's one maintenance slot - see maintenance.py - and a pack that
# does not hold it is told so by name rather than left to discover it.
# Calling `tick()` without the answer this produced is the
# two-packs-draining-at-once defect with the interlock present but not
# wired, which is why the plan is built here and not inside the loop.
maint_plan = maint_arbitrate(self.maints, now)
maint_results = []
for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan):
res = machine.tick(now, inv.soc, blocked_by=blocked)
for msg in res.events:
self.log_event(msg)
maint_results.append(res)
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
# deferral is edge-logged (it lasts as long as the other pack's cycle),
# so by the time an operator asks "why has pack B not recalibrated" the
# line that said so has scrolled off. §12's whole failure mode is a
# schedule that silently stops firing; a pack that is due, idle and
# WAITING has to be able to say so at the moment it is looked at.
self._maint_deferred = [(m.name, r.deferred)
for m, r in zip(self.maints, maint_results)
if r.deferred]
# --- SAFETY-02, and it runs BEFORE the early return below -------------
# ⚠️ ORDER MATTERS HERE. If this were evaluated after the "inputs missing
@@ -1838,88 +1972,114 @@ class Controller:
self.log_event(self.arb.explain())
# --- maintenance -----------------------------------------------------
if result.owns_setpoint:
# PER-INVERTER. §12's monthly recalibration is a PACK procedure -
# drain THIS pack to its floor, charge THIS pack full, recalibrate
# THIS pack's coulomb counter. Two packs recalibrate on two
# schedules. See arbiter.CLAIM_SCOPE.
# ⚠️ FILED ON THE UNIT BEING MAINTAINED, WHICH IS THE FIRST ONE.
# §12's monthly recalibration is a PACK procedure and this add-on
# holds exactly ONE `Maintenance` state machine, so the pack it is
# maintaining is `self.maint_unit`. Two packs recalibrating on two
# schedules is a second FSM with its own namespaced history, which
# is a card of its own - what this card must not do is file one
# pack's procedure on every actuator.
claims.append(Claim.set("maintenance", P_MAINTENANCE,
float(result.setpoint_w or 0.0), result.phase,
actuator=self.actuator_of("maintenance",
self.maint_unit)))
self.sat_count = 0
elif result.charge_only:
# Money outranks the maintenance schedule: while the quarter-hour
# projection is over the cap, the charge-only shaping is simply not
# claimed, so the loop can discharge and shave the peak. When a
# dedicated peak-shaving strategy arrives it will claim SET at
# P_PEAK and outrank this limit without any code here changing.
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's claim
# is `lo >= 0`. Both are limits, arbiter.py applies EVERY limit at or
# above the winner's priority, and most restrictive wins - so a peak
# event during a maintenance charge window files `lo > hi`, which is
# a CONTRADICTION and resolves to 0 W. The battery would park in the
# exact quarter it should be shaving. Priority does not save it:
# clause 2 has no notion of a higher-priority limit overriding a
# lower one, and it should not - "most restrictive" is what makes the
# safety bounds unconditional.
#
# So the higher-priority claimant SUPPRESSES the lower one rather
# than out-voting it, which is what this branch already did for
# `peak_forecast_entity`. That entity is optional and empty on a
# default install, so until now the suspension could not fire on the
# site that needed it. `self.peak_guard` is measured from the meter
# and is always there.
#
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is not
# tidiness. It was inline, and a reviewer mutated it back to its
# historical broken form - re-gated on `peak_forecast_entity` alone -
# and every suite in this repository stayed green while a default
# install parked at 0 W in the quarter that sets the month's bill.
# Read shaping_suspended() before touching this: both terms are
# there, and why neither is sufficient alone.
if peakguard.shaping_suspended(
self.peak_guard, self.peak_fc,
float(self.o.get("peak_cap_w", 3500))):
# Edge-logged: level-logging here would write one line per cycle
# for as long as the event lasts and flush the 40-line action log
# of the very context that explains it.
if not self._maint_shape_suspended:
self._maint_shape_suspended = True
self.log_event("peak at risk - maintenance charge shaping "
"suspended so the peak claim is not "
"contradicted")
else:
self._maint_shape_suspended = False
hi = 0.0
reason = "charge-only"
if self.cheap:
# ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ
# OF `self.o`. This line used to re-open the options dict
# with its own literal fallback, so the rate the shaping
# asked for and the rate the state machine drained at could
# be different numbers from the same install - and, with the
# key absent, they were. The Controller already parsed this
# once in __init__; asking the object that owns it cannot
# drift from it.
floor = maintenance_charge_floor(
self.maint.cfg.charge_w,
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
if floor > 0:
hi, reason = -floor, "cheap-window charge"
# PER-INVERTER, like the SET above and for the same reason -
# the shaping is about the pack being charged.
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason,
actuator=self.actuator_of("maintenance",
self.maint_unit)))
# ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST STAYS SHORT.
# At most one machine is out of IDLE on any tick, so at most one of
# these branches fires and the claim list is the shape it has always
# been. The loop is not a widening of what maintenance may ask for; it
# is what makes the ONE claim it does file land on the pack that is
# actually being recalibrated, whichever roster slot that is.
for inv, result in zip(self.inverters, maint_results):
if result.owns_setpoint:
# PER-INVERTER. §12's monthly recalibration is a PACK procedure -
# drain THIS pack to its floor, charge THIS pack full,
# recalibrate THIS pack's coulomb counter. Two packs recalibrate
# on two schedules. See arbiter.CLAIM_SCOPE.
#
# ⚠️ FILED ON THE PACK WHOSE STATE MACHINE PRODUCED IT - `inv`,
# the roster entry this `result` was ticked from - and that is
# the whole of MAINT-05 at this line. It used to be
# `self.maint_unit`, which was `inverters[0]` flatly, because
# there was one FSM and its history had nowhere else to live.
# Pack B's drain filed on pack A is one pack being commanded for
# another's procedure while the pack that is actually draining
# stays under the loop: the site would move the right total and
# recalibrate nothing.
#
# ⚠️ AND IT STAYS `SCOPE_UNIT`. Two FSMs mean two claims on two
# actuators, which the allocator consumes as per-unit intervals;
# ARCH-01 is explicit that this must never become a site claim,
# because per-inverter constraints filed site-wide are the
# cross-inverter `lo > hi` contradiction at arbiter.py:114.
claims.append(Claim.set("maintenance", P_MAINTENANCE,
float(result.setpoint_w or 0.0), result.phase,
actuator=self.actuator_of("maintenance", inv)))
self.sat_count = 0
elif result.charge_only:
# Money outranks the maintenance schedule: while the quarter-hour
# projection is over the cap, the charge-only shaping is simply not
# claimed, so the loop can discharge and shave the peak. When a
# dedicated peak-shaving strategy arrives it will claim SET at
# P_PEAK and outrank this limit without any code here changing.
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's
# claim is `lo >= 0`. Both are limits, arbiter.py applies EVERY
# limit at or above the winner's priority, and most restrictive
# wins - so a peak event during a maintenance charge window files
# `lo > hi`, which is a CONTRADICTION and resolves to 0 W. The
# battery would park in the exact quarter it should be shaving.
# Priority does not save it: clause 2 has no notion of a
# higher-priority limit overriding a lower one, and it should not -
# "most restrictive" is what makes the safety bounds unconditional.
#
# So the higher-priority claimant SUPPRESSES the lower one rather
# than out-voting it, which is what this branch already did for
# `peak_forecast_entity`. That entity is optional and empty on a
# default install, so until now the suspension could not fire on the
# site that needed it. `self.peak_guard` is measured from the meter
# and is always there.
#
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is
# not tidiness. It was inline, and a reviewer mutated it back to its
# historical broken form - re-gated on `peak_forecast_entity` alone
# - and every suite in this repository stayed green while a default
# install parked at 0 W in the quarter that sets the month's bill.
# Read shaping_suspended() before touching this: both terms are
# there, and why neither is sufficient alone.
#
# ⚠️ ONE SUSPENSION FLAG FOR THE SITE, NOT ONE PER PACK, AND THE
# INTERLOCK IS WHAT MAKES THAT TRUE. The quantity it edge-logs is
# a peak event at the grid connection, and `arbitrate()` allows
# at most one pack to be in CHARGE at a time, so this branch can
# run at most once per tick. Were two packs ever allowed to
# charge together, this flag would have to become per-pack - and
# so would the reasoning above it.
if peakguard.shaping_suspended(
self.peak_guard, self.peak_fc,
float(self.o.get("peak_cap_w", 3500))):
# Edge-logged: level-logging here would write one line per
# cycle for as long as the event lasts and flush the 40-line
# action log of the very context that explains it.
if not self._maint_shape_suspended:
self._maint_shape_suspended = True
self.log_event("peak at risk - maintenance charge shaping "
"suspended so the peak claim is not "
"contradicted")
else:
self._maint_shape_suspended = False
hi = 0.0
reason = "charge-only"
if self.cheap:
# ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ
# OF `self.o`. This line used to re-open the options dict
# with its own literal fallback, so the rate the shaping
# asked for and the rate the state machine drained at
# could be different numbers from the same install - and,
# with the key absent, they were. The Controller already
# parsed this once in __init__; asking the object that
# owns it cannot drift from it. Every pack's machine
# holds the same `MaintConfig` instance, so this is the
# rate the pack in front of us is charging at.
floor = maintenance_charge_floor(
self.maint.cfg.charge_w,
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
if floor > 0:
hi, reason = -floor, "cheap-window charge"
# PER-INVERTER, like the SET above and for the same reason -
# the shaping is about the pack being charged, which is `inv`.
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi,
reason=reason,
actuator=self.actuator_of("maintenance",
inv)))
# --- the grid-following loop -----------------------------------------
if self.auto and self.inputs_ok:
@@ -3310,14 +3470,29 @@ class Controller:
elif not self.auto:
level, banner = "warn", "Stopped — inverter commanded to 0 W"
elif self.maint.phase != IDLE:
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
# of one this is the string it has always been, character for
# character; at two, "Maintenance: drain" without a name is a
# banner an operator cannot act on, because the remedy is at one
# device and the site has two.
level, banner = "warn", f"Maintenance: {self.maint.phase}"
if len(self.inverters) > 1:
banner += f" ({self.maint_unit.name})"
else:
level, banner = "ok", "Running — holding grid at zero"
def w(v):
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
next_due = self.maint.next_due(datetime.now(timezone.utc))
# ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. One date on a
# site with two schedules has to be the one that comes first, or the row
# says "2026-10-01" while a pack recalibrates tomorrow. `None` means
# "never run, due now" and has to win over any date, so it is tested for
# rather than sorted with. At a roster of one this is that pack's own
# date, unchanged.
_now = datetime.now(timezone.utc)
_dues = [m.next_due(_now) for m in self.maints]
next_due = None if any(d is None for d in _dues) else min(_dues)
rows = [
("Grid", w(self.grid)),
("Battery", w(self.site_batt_w)),
@@ -3333,7 +3508,14 @@ class Controller:
else f"{self.pack_floor_soc:g} %"),
("Commanded", w(self.target)),
("Why", self.reason),
("Maintenance phase", self.maint.phase),
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
else f"{self.maint.phase} ({self.maint_unit.name})"),
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
# page - where `_maint_deferred` is empty on every tick - carries
# exactly the rows it carried before.
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
for n, why in self._maint_deferred)),)
if self._maint_deferred else ()),
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
@@ -3445,11 +3627,26 @@ class Controller:
await self.command_site_zero()
return {"auto": self.auto}
if what == "maint_start":
for msg in self.maint.force_start(now):
# ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
# ONE LABEL. `_maint_button()` answers it: the pack that holds the
# site's maintenance slot if one does - which is what "start"
# already meant at a roster of one, where pressing it mid-drain
# re-entered DRAIN - and otherwise the pack that has gone longest
# without a cycle, which is the same pack `arbitrate()` would have
# picked on its own. At N=1 both branches are `maints[0]` and this
# is the shipped call, byte for byte.
#
# A PER-PACK BUTTON IS A UI CARD, NOT THIS ONE. `web.py` posts a
# bare `maint_start` with no unit, and inventing a unit parameter
# here would ship half a surface: the page would still have one
# button and the API would have two meanings.
for msg in self._maint_button(now).force_start(now):
self.log_event(msg)
return {"phase": self.maint.phase}
if what == "maint_abort":
for msg in self.maint.abort(now, "operator"):
# Aborts the pack that is actually cycling - `_maint_button()` again
# - and then zeroes the SITE, which is what it always did.
for msg in self._maint_button(now).abort(now, "operator"):
self.log_event(msg)
await self.command_site_zero()
return {"phase": self.maint.phase}