MAINT-10: maintenance start hour in local time, default 19:00

The start hour was compared with the UTC hour, so the shipped 10 started at
12:00 in a Belgian summer. It is now read in Home Assistant's time zone
(fallback: the container's TZ, then UTC, one log line says which), and the
default moves to 19. DST: a missing hour starts at the first hour after the
jump; a repeated hour starts only in its first pass. Stored options are not
rewritten: a saved 10 now means 10:00 local (DOCS, CHANGELOG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goodwe-agent
2026-09-29 22:52:05 +02:00
parent 35cdffe766
commit 02c0a3dd7b
13 changed files with 678 additions and 27 deletions
+6 -7
View File
@@ -20,7 +20,7 @@ from datetime import datetime, timedelta, timezone
from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE
from .maintenance import IDLE as M_IDLE, next_slot_start
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
from .price import Absence, Uncertainty, brussels
from .static_tariff import static_plan
@@ -384,8 +384,8 @@ def mode_runs(c, day0, day1, now, shadow):
def maintenance_runs(c, day0, day1, now, shadow):
"""The monthly battery cycle: under way now, or starting later today.
The cycle starts in the hour `maintenance_start_hour` names, read as the
control cycle reads it (UTC hour of `now`), shown in local time."""
The cycle starts in the hour `maintenance_start_hour` names, read in the
site's zone exactly as the control cycle reads it (MAINT-10)."""
runs, lines = [], []
waiting = []
for i, (inv, m) in enumerate(zip(c.inverters, c.maints)):
@@ -404,10 +404,9 @@ def maintenance_runs(c, day0, day1, now, shadow):
if not m.due(now) and (m.next_due(now) is None
or m.next_due(now) >= day1):
continue
start = now.replace(hour=cfg.start_hour, minute=0, second=0,
microsecond=0)
if start + timedelta(hours=1) <= now:
start += timedelta(days=1)
start = next_slot_start(now, cfg.start_hour, m.tz)
if start is None:
continue
due_at = m.next_due(now)
if due_at is not None and due_at > start:
continue
+40 -5
View File
@@ -29,6 +29,7 @@ import functools
import json
import logging
import math
import os
import signal
import time
from datetime import datetime, timedelta, timezone
@@ -64,7 +65,7 @@ from . import messages
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
)
from .hass import HomeAssistant
from .hass import CORE_API, HomeAssistant
# ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
# window an operator would look up here is the one the roster actually uses.
@@ -72,7 +73,7 @@ from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
DRAIN, IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
arbitrate as maint_arbitrate,
arbitrate as maint_arbitrate, site_zone,
)
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
MqttPublisher, announced_units, mode_configs,
@@ -402,7 +403,8 @@ def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
class Controller:
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
peak_db_path: str = "/data/tariff.db",
price_history_path: str = "/data/price_history.json"):
price_history_path: str = "/data/price_history.json",
site_tz=None):
# UX-15: a read-only HomeAssistant belongs to setup-only mode, which
# builds no controller. Refused here so no controller can hold one.
if getattr(hass, "read_only", False) is True:
@@ -643,7 +645,8 @@ class Controller:
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))
mirror_flat=(not _one_pack and i == 0),
tz=site_tz) # MAINT-10: the start hour's zone, from amain()
for i, inv in enumerate(self.inverters)]
# SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own
@@ -6575,6 +6578,37 @@ async def docs_link_facts(session, token: str) -> dict:
return out
async def ha_time_zone(session, token: str) -> tuple:
"""MAINT-10: `(zone, why)` - Home Assistant's configured time zone, or None
and a short reason. Same 3 s budget as the docs link: never holds up start."""
try:
async with session.get(f"{CORE_API}/config", timeout=DOCS_LINK_TIMEOUT_S,
headers={"Authorization": f"Bearer {token}"}) as r:
body = await r.json(content_type=None)
zone = body.get("time_zone") if isinstance(body, dict) else None
return (zone, "") if zone else (None, "no time_zone in its answer")
except Exception as err: # noqa: BLE001 - a fallback exists, never a stop
text = str(err).strip()
return None, f"{type(err).__name__}: {text}" if text else type(err).__name__
async def maint_site_tz(session, token: str):
"""MAINT-10: the zone the maintenance start hour is read in, logged once."""
zone, why = await ha_time_zone(session, token)
tz, name, source, why = site_zone(zone, why, os.environ.get("TZ"))
if source == "ha":
_LOG.info("Maintenance start hour is local time in %s, from Home "
"Assistant.", name)
elif source == "tz":
_LOG.info("Maintenance start hour is local time in %s, from the "
"add-on's time zone. Home Assistant gave none: %s.", name, why)
else:
_LOG.warning("Maintenance start hour uses UTC, because no time zone "
"was found. Set the time zone in Home Assistant.")
_LOG.debug("maintenance time zone: %s", why)
return tz
async def amain() -> None:
opts = load_options()
logging.basicConfig(
@@ -6684,7 +6718,8 @@ async def amain() -> None:
pub = _NoMqtt()
stop = asyncio.Event()
controller = Controller(opts, hass, store, pub, stop=stop)
controller = Controller(opts, hass, store, pub, stop=stop,
site_tz=await maint_site_tz(session, _tok))
if callable(getattr(pub, "bind", None)):
pub.bind(asyncio.get_running_loop(), controller.mqtt_command)
runner = await web.start(controller, port=INGRESS_PORT)
+74 -4
View File
@@ -34,7 +34,7 @@ simply not do.
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone, tzinfo
from .store import MAINT_UNIT_KEYS, unit_key
@@ -112,7 +112,9 @@ RESTART_COOLDOWN_MIN = 90
MAINT_OPTION_DEFAULTS = {
"maintenance_enabled": False,
"maintenance_interval_days": 28,
"maintenance_start_hour": 10,
# MAINT-10: a LOCAL hour (site_zone() below), 19:00 so the drain runs
# into the night rather than through the solar day.
"maintenance_start_hour": 19,
# ⚠️ 2000, NOT THE 2500 THIS SHIPPED AS, and config.yaml carries the
# argument: both rates must stay at or below `max_w` (2000) or the
# cheap-window charge shaping asks the arbiter for `hi = -charge_w` against
@@ -177,6 +179,69 @@ class MaintConfig:
)
# --------------------------------------------------------------------------- #
# MAINT-10: the start hour is local time. See DECISIONS/MAINT-10-local-start-hour.md.
# --------------------------------------------------------------------------- #
def _load_zone(name):
"""A tzinfo for an IANA name, or `(None, why)`."""
if not name or not isinstance(name, str):
return None, "none given"
try:
from zoneinfo import ZoneInfo
return ZoneInfo(name.strip()), ""
except Exception as err: # noqa: BLE001 - any failure
return None, f"{name!r} is not a known zone ({type(err).__name__})"
def site_zone(ha_zone, ha_why: str, env_tz) -> tuple:
"""`(tzinfo, name, source, why)`: Home Assistant's zone (source "ha"), else
the container's TZ ("tz"), else UTC ("utc"). `why` says what was missing."""
tz, why = _load_zone(ha_zone)
if tz is not None:
return tz, ha_zone, "ha", ""
ha_why = ha_why or why
tz, env_why = _load_zone(env_tz)
if tz is not None:
return tz, env_tz, "tz", ha_why
return timezone.utc, "UTC", "utc", f"Home Assistant: {ha_why}, TZ: {env_why}"
def in_start_slot(now: datetime, start_hour: int, tz: tzinfo) -> bool:
"""Is `now` inside today's one-hour start slot, read on the local clock?
Normally the slot is the local hour `start_hour`. On the day the clocks go
forward and that hour does not exist, it is the first hour after the jump.
On the day they go back and it happens twice, only the first one counts.
Worked from UTC instants only (`astimezone`), so it does not depend on how
a tzinfo resolves a wall time that is missing or doubled.
"""
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
here = now.astimezone(tz).replace(tzinfo=None)
before = (now - timedelta(hours=1)).astimezone(tz).replace(tzinfo=None)
if here.hour == start_hour:
# the second pass through a repeated hour: an hour ago read the same
return not (before.date() == here.date() and before.hour == start_hour)
slot = here.replace(hour=start_hour, minute=0, second=0, microsecond=0)
# the clock jumped over the start of the slot within the last hour
return before < slot and here >= slot + timedelta(hours=1)
def next_slot_start(now: datetime, start_hour: int, tz: tzinfo):
"""The start of the slot `now` is in, or else of the next one; None if
there is none within 50 h (no real zone does that)."""
step = timedelta(minutes=15)
t = now.replace(minute=now.minute - now.minute % 15, second=0,
microsecond=0) - timedelta(hours=1)
for _ in range(4 * 50):
if (in_start_slot(t, start_hour, tz)
and not in_start_slot(t - step, start_hour, tz)
and t + timedelta(hours=1) > now):
return t
t += step
return None
@dataclass
class MaintResult:
phase: str
@@ -256,8 +321,12 @@ class Maintenance:
"""
def __init__(self, cfg: MaintConfig, store, unit: str | None = None,
name: str = "", mirror_flat: bool = False):
name: str = "", mirror_flat: bool = False,
tz: tzinfo | None = None):
self.cfg = cfg
# MAINT-10: the zone `cfg.start_hour` is read in - main.py resolves it
# once at startup (`site_zone()`). UTC when nobody says, as before.
self.tz = tz or timezone.utc
# ⚠️ `self.store` IS THE VIEW, NOT THE Store. Everything in this class
# names an UNQUALIFIED key - "phase", "last_completed" - and the view is
# the one place that turns it into the key this pack owns. A raw
@@ -331,7 +400,8 @@ class Maintenance:
about who was asking - so the IDLE branch of `tick()` calls this one.
"""
return (self.phase == IDLE and self.cfg.enabled
and now.hour == self.cfg.start_hour and self.due(now)
and in_start_slot(now, self.cfg.start_hour, self.tz)
and self.due(now)
and self._cooled(now))
# -- operations ----------------------------------------------------------