diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 6e298b6..991442e 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- **MAINT-10: the maintenance start hour is now local time, and the default + is 19:00.** The hour used to be read as UTC. Your saved hour is kept, so a + saved 10 now starts at 10:00 local instead of 12:00 in summer. Set 19 for + the new evening start. See DOCS.md, Maintenance. + ## 0.12.0 **A MINOR BUMP FOR ONE NEW OPTION: `pv_entities` ([[TEL-12]]), for sites with diff --git a/goodwe_controller/DECISIONS/MAINT-10-local-start-hour.md b/goodwe_controller/DECISIONS/MAINT-10-local-start-hour.md new file mode 100644 index 0000000..f1e3c3f --- /dev/null +++ b/goodwe_controller/DECISIONS/MAINT-10-local-start-hour.md @@ -0,0 +1,51 @@ +# MAINT-10 — the maintenance start hour is local time, default 19:00 + +Owner decision 2026-09-29 (option A). `maintenance_start_hour` was compared +with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer and +11:00 in winter. It is now read on the site's local clock, and the default moves +to 19 so the drain runs into the evening and night rather than the solar day. + +Merges only after SAFETY-83 (peak guard outranks the drain at N=1): evening +drains meet higher house loads. + +## 1. Which zone + +Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup, +else the container's `TZ`, else UTC. One log line says which (WARNING only for +UTC). Resolved once per start: a zone change in HA takes effect on the next +add-on restart. The add-on image has tzdata (checked in `amd64-base:3.20`); the +CI image does not, so the tests grade DST on `tariff._EUTimezone` too. + +## 2. DST + +`maintenance.in_start_slot()` works from UTC instants only, so it does not +depend on how a tzinfo resolves a missing or doubled wall time: + +- normally the slot is the local hour `start_hour`; +- clocks forward and the hour does not exist: the first hour after the jump; +- clocks back and the hour happens twice: the first pass only. Without this an + aborted start (90 min cooldown) would start again in the 120 min repeated hour. + +Every day has exactly one 60 min slot for every hour 0-23: checked against +zoneinfo for Europe/Brussels, America/New_York, America/Santiago (midnight +transitions), Asia/Kathmandu (+05:45) and Australia/Lord_Howe (30 min DST) +over all of 2026. + +## 3. Existing installs + +The Supervisor merges the shipped defaults under the STORED options, and saving +the settings stores every key (the reference install's blob has all 76). So +almost every existing install keeps its stored 10, which now means 10:00 local +instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19. +The add-on does not rewrite a stored option: a silent migration would override +an operator's choice with no way to tell it apart from the old default. DOCS +and the CHANGELOG tell the owner to set 19 if they want the evening start. +`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0. + +## 4. What does not change + +Only the start decision (`Maintenance.wants_start`) and the Home screen's shown +start time read the zone. `test_maint10.py` part 9 runs the real Controller +with start hour 11 UTC against 13 Europe/Brussels (the same instant) and with +12 UTC against 14 local (not the start hour): every command, claim, reason and +log line is identical. A `Maintenance` built without a zone reads UTC, as before. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 8f20953..31b5bec 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -227,7 +227,7 @@ The label and description each setting shows on the Configuration tab, in tab or | Tariff | Trading charges at most (%) | `arbitrage_soc_ceiling` | Price trading will not charge the battery above this level. | | Maintenance | Run the monthly battery cycle | `maintenance_enabled` | Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default. | | Maintenance | Minimum days between cycles | `maintenance_interval_days` | The shortest gap between two maintenance cycles. | -| Maintenance | Start hour | `maintenance_start_hour` | The local hour, 0 to 23, at which a due cycle starts. | +| Maintenance | Start hour (local time) | `maintenance_start_hour` | The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00. | | Maintenance | Draw-down rate (W) | `maintenance_discharge_w` | How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer. | | Maintenance | Recharge rate (W) | `maintenance_charge_w` | How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start. | | Maintenance | Draw down to (%) | `maintenance_soc_floor` | The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose. | @@ -1625,13 +1625,27 @@ help - see "Why the tuning is what it is". |---|---|---| | `maintenance_enabled` | false | Enable the monthly cycle | | `maintenance_interval_days` | 28 | Minimum gap between cycles | -| `maintenance_start_hour` | 10 | Hour of day a due cycle begins | +| `maintenance_start_hour` | 19 | Local hour a due cycle begins (see below) | | `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower | | `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** | | `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys | | `maintenance_soc_target` | 99 | Charge target | | `maintenance_hold_min` | 120 | Hold at full so the BMS can balance | +**The start hour is local time.** The add-on reads Home Assistant's time zone +(Settings → System → General) at startup. If Home Assistant does not answer, +it uses the add-on's own time zone, and failing that UTC. The log says which +one it used. On the day the clocks go forward, a start hour that does not exist +starts at the first hour after the jump. On the day they go back, a repeated +hour starts only once, in its first pass. + +**Upgrading.** The hour used to be read as UTC, so the old default of 10 +started at 12:00 in a Belgian summer. The default is now 19 (19:00 local), but +it only reaches installs whose saved settings lack the hour. Saving the +settings stores every value, so most existing installs have 10 saved. They keep +10, and it now means 10:00 local. The add-on never changes a saved value: set +19 yourself for the evening start. *For installers:* DECISIONS/MAINT-10-local-start-hour.md. + ### Tariff (all optional) | option | meaning | diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index e37274f..6221d12 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -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 diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index b33519e..22f3aaf 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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) diff --git a/goodwe_controller/app/maintenance.py b/goodwe_controller/app/maintenance.py index 32fe91f..8710775 100644 --- a/goodwe_controller/app/maintenance.py +++ b/goodwe_controller/app/maintenance.py @@ -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 ---------------------------------------------------------- diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index 4c332f6..7c353cf 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -623,7 +623,8 @@ options: # contradiction. Changing one of these numbers now moves every consumer. maintenance_enabled: false maintenance_interval_days: 28 - maintenance_start_hour: 10 + # MAINT-10: a LOCAL hour, in Home Assistant's time zone. + maintenance_start_hour: 19 # ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather # than the 2500 they shipped as. The cheap-window charge shaping asks the diff --git a/goodwe_controller/test_home.py b/goodwe_controller/test_home.py index adfe652..29fcbe5 100644 --- a/goodwe_controller/test_home.py +++ b/goodwe_controller/test_home.py @@ -400,6 +400,15 @@ check("...with its duration from the battery's capacity and the cycle's rates " "and hold time", present(nth(_cyc)) and _cyc[0]["amount"].startswith("Takes about ") and _cyc[0]["end_h"] > _cyc[0]["start_h"] + 2) +# MAINT-10: with the site's zone set, the hour is read on the local clock. +from app.tariff import _EUTimezone # noqa: E402 +c_ml = make(dict(MAINT, maintenance_start_hour=14)) +for _mm in c_ml.maints: + _mm.tz = _EUTimezone() +_cyc_l = [r for r in status(c_ml)["plan"]["runs"] if r["kind"] == "cycle"] +check("MAINT-10: start hour 14 in the local zone is drawn at 14:00, the " + "instant the cycle starts", len(_cyc_l) == 1 + and field(nth(_cyc_l), "from") == "14:00") c_m.maints[0].store.set("phase", "drain") s_md = status(c_m) check("a cycle under way is drawn from now, and the battery's now line says " diff --git a/goodwe_controller/test_maint10.py b/goodwe_controller/test_maint10.py new file mode 100644 index 0000000..c2e712a --- /dev/null +++ b/goodwe_controller/test_maint10.py @@ -0,0 +1,446 @@ +"""MAINT-10: the maintenance start hour is LOCAL time. `python3 test_maint10.py` + +Graded here: the slot on ordinary days and on both DST change days, the zone +source and its fallbacks, the 19:00 default, the startup wiring, and that the +controller's commands are unchanged once the start instant is the same. +See DECISIONS/MAINT-10-local-start-hour.md. + +Mutating what this file grades? Read MUTATION.md first (stale bytecode). + +⚠️ The CI image has no tzdata, so every DST check runs on tariff.py's encoded +EU rule and, where zoneinfo works, on Europe/Brussels from tzdata as well. +""" + +import asyncio +import functools +import logging +import os +import re +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) + +import app.main as M # noqa: E402 +from app import maintenance as MT # noqa: E402 +from app.maintenance import ( # noqa: E402 + DRAIN, IDLE, MAINT_OPTION_DEFAULTS, MaintConfig, Maintenance, + in_start_slot, next_slot_start, site_zone, +) +from app.tariff import _EUTimezone # noqa: E402 + +HERE = Path(__file__).resolve().parent +UTC = timezone.utc +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +ZONES = [("encoded EU rule", _EUTimezone())] +try: + from zoneinfo import ZoneInfo + ZONES.append(("zoneinfo Europe/Brussels", ZoneInfo("Europe/Brussels"))) +except Exception as err: # noqa: BLE001 + print(f" zoneinfo unavailable here ({err}); graded on the EU rule only") + +SPRING = datetime(2026, 3, 29, tzinfo=UTC) # 02:00 CET -> 03:00 CEST +AUTUMN = datetime(2026, 10, 25, tzinfo=UTC) # 03:00 CEST -> 02:00 CET +SUMMER = datetime(2026, 7, 15, tzinfo=UTC) +WINTER = datetime(2026, 1, 15, tzinfo=UTC) + + +def slot_minutes(day_utc, hour, tz): + """UTC minutes in the slot, over the LOCAL day of that date.""" + t, out = day_utc - timedelta(hours=3), [] + while t < day_utc + timedelta(hours=27): + if (t.astimezone(tz).date() == day_utc.date() + and in_start_slot(t, hour, tz)): + out.append(t) + t += timedelta(minutes=1) + return out + + +def one_block(mins): + """`(start, minutes)` when the minutes form one contiguous run, else None.""" + if not mins: + return None + for a, b in zip(mins, mins[1:]): + if b - a != timedelta(minutes=1): + return None + return mins[0], len(mins) + + +# =========================================================================== # +print("1. UTC stays what it was: the slot IS `now.hour == start_hour`") +t, same = datetime(2026, 1, 1, tzinfo=UTC), True +while t < datetime(2027, 1, 1, tzinfo=UTC): + for h in range(24): + if in_start_slot(t, h, UTC) != (t.hour == h): + same = False + t += timedelta(minutes=37) +check("over a year of instants, every hour 0-23: identical to the old test", + same) +check("a naive `now` is read as UTC, as before", + in_start_slot(datetime(2026, 7, 1, 19, 5), 19, UTC) + and not in_start_slot(datetime(2026, 7, 1, 18, 5), 19, UTC)) + +# =========================================================================== # +print("2. local time: 19 means 19:00 on the site's clock") +for label, tz in ZONES: + s = one_block(slot_minutes(SUMMER, 19, tz)) + w = one_block(slot_minutes(WINTER, 19, tz)) + check(f"[{label}] summer: 17:00-18:00 UTC (CEST), one hour", + s == (SUMMER + timedelta(hours=17), 60)) + check(f"[{label}] winter: 18:00-19:00 UTC (CET), one hour", + w == (WINTER + timedelta(hours=18), 60)) + check(f"[{label}] the old reading is gone: 19:00 UTC is not the slot " + f"in summer", not in_start_slot(SUMMER + timedelta(hours=19, minutes=5), + 19, tz)) + +# =========================================================================== # +print("3. the DST change days, every start hour") +for label, tz in ZONES: + for name, day in (("spring", SPRING), ("autumn", AUTUMN)): + bad = [] + for h in range(24): + b = one_block(slot_minutes(day, h, tz)) + # the local day runs 23:00 UTC the day before to 22:00/23:00 UTC + if b is None or b[1] != 60: + bad.append((h, b)) + check(f"[{label}] {name}: every hour 0-23 gives ONE slot of exactly " + f"60 minutes that day {bad or ''}", not bad) + sp2 = one_block(slot_minutes(SPRING, 2, tz)) + check(f"[{label}] spring, hour 2 does not exist: it starts at 03:00 CEST " + f"(01:00 UTC), once", sp2 == (SPRING + timedelta(hours=1), 60)) + au2 = one_block(slot_minutes(AUTUMN, 2, tz)) + check(f"[{label}] autumn, hour 2 happens twice: only the first, 02:00 CEST " + f"(00:00 UTC)", au2 == (AUTUMN, 60)) + check(f"[{label}] autumn: the second 02:xx (CET) is not a slot", + not in_start_slot(AUTUMN + timedelta(hours=1, minutes=30), 2, tz)) + check(f"[{label}] autumn, hour 19 still 18:00 UTC (CET)", + one_block(slot_minutes(AUTUMN, 19, tz)) + == (AUTUMN + timedelta(hours=18), 60)) + check(f"[{label}] spring, hour 19 already 17:00 UTC (CEST)", + one_block(slot_minutes(SPRING, 19, tz)) + == (SPRING + timedelta(hours=17), 60)) + + +# =========================================================================== # +class FakeStore: + def __init__(self): + self.data = {"phase": IDLE, "phase_started": None, + "last_completed": None, "last_start_attempt": None} + + def set(self, k, v): + self.data[k] = v + + def set_time(self, k, when): + self.data[k] = when + + def get_time(self, k): + return self.data.get(k) + + def get(self, k, default=None): + return self.data.get(k, default) + + def save(self): + pass + + +def starts(day, hour, tz, *, abort_after=None, blocked=None): + """Tick a due pack each minute around a day; the UTC instants in that + LOCAL day at which it entered DRAIN. `abort_after` aborts each drain that many minutes in (interlock); + `blocked(t)` defers it.""" + m = Maintenance(MaintConfig(enabled=True, start_hour=hour), FakeStore(), + tz=tz) + out, since = [], None + t = day - timedelta(hours=2) + while t < day + timedelta(hours=26): + why = "" + if since is not None and abort_after is not None \ + and t - since >= timedelta(minutes=abort_after): + why = "a test interlock" + if blocked is not None and blocked(t): + why = "the other battery is cycling" + was = m.phase + m.tick(t, soc=80, blocked_by=why) + if m.phase == DRAIN and was != DRAIN: + out.append(t) + since = t + if m.phase == IDLE: + since = None + t += timedelta(minutes=1) + return [x for x in out if x.astimezone(tz).date() == day.date()] + + +print("4. the state machine: one start per day, DST days included") +for label, tz in ZONES: + check(f"[{label}] spring, hour 2: one start, at 01:00 UTC (03:00 CEST)", + starts(SPRING, 2, tz, abort_after=5) + == [SPRING + timedelta(hours=1)]) + # An aborted start waits RESTART_COOLDOWN_MIN (90). The repeated hour is + # 120 minutes long, so without the first-pass rule it would start again. + check(f"[{label}] autumn, hour 2, aborted after 5 min: NO second start " + f"in the repeated hour", + starts(AUTUMN, 2, tz, abort_after=5) == [AUTUMN]) + check(f"[{label}] autumn, hour 2, deferred for the whole first pass: it " + f"does not start in the second pass either", + starts(AUTUMN, 2, tz, blocked=lambda t: t < AUTUMN + timedelta(hours=1)) + == []) + check(f"[{label}] summer, hour 19: one start at 17:00 UTC", + starts(SUMMER, 19, tz, abort_after=5) + == [SUMMER + timedelta(hours=17)]) + +# =========================================================================== # +print("5. where the start is shown (homeview) is where it happens") +for label, tz in ZONES: + ok = True + for day in (SPRING, AUTUMN, SUMMER): + for h in (0, 2, 3, 10, 19, 23): + first = next(iter(slot_minutes(day, h, tz)), None) + if first is None: + continue + for probe in (first - timedelta(hours=5), first, + first + timedelta(minutes=59)): + if next_slot_start(probe, h, tz) != first: + ok = False + check(f"[{label}] next_slot_start() lands on the slot's first minute, " + f"from before it and from inside it", ok) + +# =========================================================================== # +print("6. which zone: Home Assistant, else the container's TZ, else UTC") +_FAKE = {"Europe/Brussels": ZONES[-1][1], "America/New_York": object(), + "UTC": UTC} +_real_load = MT._load_zone +MT._load_zone = lambda n: ((_FAKE[n], "") if n in _FAKE + else (None, f"{n!r} is not a known zone" + if n else "none given")) +try: + tz, name, src, _ = site_zone("Europe/Brussels", "", "America/New_York") + check("Home Assistant's zone wins over the container's TZ", + name == "Europe/Brussels" and src == "ha" + and tz is _FAKE["Europe/Brussels"]) + tz, name, src, why = site_zone(None, "ClientConnectorError", + "America/New_York") + check("Home Assistant unavailable: the container's TZ, and why", + name == "America/New_York" and src == "tz" + and why == "ClientConnectorError") + tz, name, src, why = site_zone("Mars/Base", "", "Europe/Brussels") + check("an unknown Home Assistant zone falls back too", + name == "Europe/Brussels" and src == "tz" and "Mars/Base" in why) + tz, name, src, why = site_zone(None, "no answer", None) + check("neither: UTC", tz is UTC and src == "utc" and "no answer" in why) +finally: + MT._load_zone = _real_load +if len(ZONES) > 1: + check("the real loader resolves a real name and refuses a made-up one", + MT._load_zone("Europe/Brussels")[0] is not None + and MT._load_zone("Mars/Base")[0] is None) +check("the real loader refuses an empty name without raising", + MT._load_zone("")[0] is None and MT._load_zone(None)[0] is None) + +# =========================================================================== # +print("7. the default is 19, an explicit value is honoured") +cfg_text = (HERE / "config.yaml").read_text() +opts_block = cfg_text.split("\nschema:")[0] +check("config.yaml options: ships 19", + re.search(r"^ maintenance_start_hour: 19$", opts_block, re.M) is not None) +check("config.yaml schema: still int(0,23)", + re.search(r"^ maintenance_start_hour: int\(0,23\)$", cfg_text, re.M) + is not None) +check("the in-code mirror says 19", + MAINT_OPTION_DEFAULTS["maintenance_start_hour"] == 19 + and MaintConfig().start_hour == 19 + and MaintConfig.from_options({}).start_hour == 19) +check("an explicit 10 stays 10 (a stored option is never rewritten)", + MaintConfig.from_options({"maintenance_start_hour": 10}).start_hour == 10) +_eu = ZONES[0][1] +_m10 = Maintenance(MaintConfig.from_options( + {"maintenance_enabled": True, "maintenance_start_hour": 10}), + FakeStore(), tz=_eu) +check("...and now means 10:00 local: 08:00 UTC in summer, not 10:00 UTC", + _m10.wants_start(SUMMER + timedelta(hours=8, minutes=1)) + and not _m10.wants_start(SUMMER + timedelta(hours=10, minutes=1))) +check("a Maintenance built with no zone reads UTC, as before", + Maintenance(MaintConfig(), FakeStore()).tz is UTC) + + +# =========================================================================== # +print("8. startup: Home Assistant's zone is asked for and reaches the machines") + + +class _Resp: + def __init__(self, body, status=200): + self.body, self.status = body, status + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def json(self, content_type=None): + if isinstance(self.body, Exception): + raise self.body + return self.body + + async def text(self): + return "{}" + + +class _Session: + def __init__(self, zone_body): + self.zone_body, self.asked = zone_body, [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + def get(self, url, **kw): + self.asked.append(url) + if url.endswith("/core/api/config"): + return _Resp(self.zone_body) + return _Resp({"data": {}}) + + +s = _Session({"time_zone": "Europe/Brussels"}) +check("ha_time_zone() reads /core/api/config's time_zone", + asyncio.run(M.ha_time_zone(s, "tok")) == ("Europe/Brussels", "") + and s.asked == [M.CORE_API + "/config"]) +check("...and a failure is None plus the reason, never a raise", + asyncio.run(M.ha_time_zone(_Session(OSError("down")), "tok")) + == (None, "OSError: down") + and asyncio.run(M.ha_time_zone(_Session({}), "tok"))[0] is None) + + +class _Built(Exception): + pass + + +def amain_zone(zone_body, env_tz): + """Run the real amain() up to the Controller; the tz its machines get.""" + got, logs = {}, [] + + class _Capture(logging.Handler): + def emit(self, rec): + logs.append((rec.levelname, rec.getMessage())) + + class _Hass: + async def limits(self, e): + return (-5000.0, 5000.0, 1.0) + + async def mqtt_service(self): + raise RuntimeError("no broker in this test") + + real = {k: getattr(M, k) for k in ("load_options", "HomeAssistant", + "validate_startup_config", + "mode_announcement", "Controller")} + real_session, real_store = M.aiohttp.ClientSession, None + import app.store + real_store = app.store.Store + + def _controller(opts, hass, store, pub, *, stop, site_tz=None, **kw): + got["tz"] = site_tz + raise _Built() + + M.load_options = lambda: {"setpoint_entity": "number.sp"} + M.HomeAssistant = lambda session, **kw: _Hass() + M.validate_startup_config = lambda *a, **k: [] + M.mode_announcement = lambda opts, store: ((), ()) + M.Controller = _controller + M.aiohttp.ClientSession = lambda *a, **k: _Session(zone_body) + app.store.Store = lambda *a, **k: object() + old_tz = os.environ.pop("TZ", None) + if env_tz: + os.environ["TZ"] = env_tz + h = _Capture() + logging.getLogger("goodwe").addHandler(h) + try: + asyncio.run(M.amain()) + except _Built: + pass + finally: + logging.getLogger("goodwe").removeHandler(h) + for k, v in real.items(): + setattr(M, k, v) + M.aiohttp.ClientSession = real_session + app.store.Store = real_store + os.environ.pop("TZ", None) + if old_tz is not None: + os.environ["TZ"] = old_tz + return got.get("tz"), [m for lvl, m in logs if "Maintenance start" in m], logs + + +MT._load_zone = lambda n: ((_FAKE[n], "") if n in _FAKE + else (None, "none given")) +try: + tz, lines, _ = amain_zone({"time_zone": "Europe/Brussels"}, "America/New_York") + check("amain(): Home Assistant's zone reaches the Controller", + tz is _FAKE["Europe/Brussels"]) + check("...logged once, naming the zone and the source", + lines == ["Maintenance start hour is local time in Europe/Brussels, " + "from Home Assistant."]) + tz, lines, _ = amain_zone(OSError("down"), "America/New_York") + check("amain(), Home Assistant unavailable: the container's TZ, said " + "plainly", tz is _FAKE["America/New_York"] + and lines == ["Maintenance start hour is local time in " + "America/New_York, from the add-on's time zone. Home " + "Assistant gave none: OSError: down."]) + tz, lines, logs = amain_zone(OSError("down"), None) + check("amain(), neither: UTC, with a WARNING line", + tz is UTC and any(lvl == "WARNING" and "uses UTC" in m + for lvl, m in logs)) +finally: + MT._load_zone = _real_load + + +# =========================================================================== # +print("9. nothing else in control moves: the same start instant, the same " + "commands") +import opt11_identity as rig # noqa: E402 +import safety78_identity as s78 # noqa: E402 + +# rig's clock starts 2026-09-20 11:00 UTC = 13:00 CEST. The same drain, +# asked for as UTC hour 11 (the old reading) and as local hour 13 (the new). + + +def rows_with(start_hour, tz): + real_drain, real_ctl = s78.DRAIN, rig.Controller + s78.DRAIN = {"maintenance_enabled": True, + "maintenance_start_hour": start_hour} + rig.Controller = functools.partial(real_ctl, site_tz=tz) + try: + # s78.run() wraps rig.Controller once more for its own tariff db. + return s78.run(1, dur=600.0)[0] + finally: + s78.DRAIN, rig.Controller = real_drain, real_ctl + + +_utc11 = rows_with(11, None) +_loc13 = rows_with(13, _eu) +check("precondition: the drain really starts in the UTC-11 run", + any("drain" in r[-1][4] for r in _utc11)) +check(f"start hour 11 UTC and 13 Europe/Brussels: every command, claim, " + f"reason and log line identical over {len(_utc11)} cycles", + _utc11 == _loc13) +_utc12 = rows_with(12, None) +_loc14 = rows_with(14, _eu) +check("precondition: no drain in the not-yet-due-hour run", + not any("drain" in r[-1][4] for r in _utc12)) +check("outside the start hour (12 UTC / 14 local): identical as well", + _utc12 == _loc14) +check("...and the zone matters: hour 11 read in Brussels does NOT start", + not any("drain" in r[-1][4] for r in rows_with(11, _eu))) + +print() +if fails: + print(f"{len(fails)} FAILED: {', '.join(fails)}") + sys.exit(1) +print("all checks passed") diff --git a/goodwe_controller/test_price_forecast.py b/goodwe_controller/test_price_forecast.py index 59761eb..7bb640e 100644 --- a/goodwe_controller/test_price_forecast.py +++ b/goodwe_controller/test_price_forecast.py @@ -716,10 +716,11 @@ check("the drain's commitment window is DERIVED from the phase machine's own " "timeout, so the gate and maintenance.py cannot drift apart", near(MAINTENANCE_DRAIN.commits_h, DRAIN_TIMEOUT_MIN / 60.0) and near(MAINTENANCE_CHARGE.commits_h, CHARGE_TIMEOUT_MIN / 60.0)) -# The shipped `maintenance_start_hour` is 10:00 local, so a drain started on -# schedule runs into the evening - hours a morning-only day-ahead print has not -# covered. That is the ticket's scenario and it is where the window is placed. -START = D0 + timedelta(hours=MaintConfig().start_hour) +# A drain started at 10:00 local runs into the evening - hours a morning-only +# day-ahead print has not covered. That is the ticket's scenario, so the hour is +# pinned here: 10 was the shipped default until MAINT-10 moved it to 19, and an +# install that stored 10 still starts then. +START = D0 + timedelta(hours=10) _hours = derived(lambda: committed_hours(START, MAINTENANCE_DRAIN)) check(f"...and it commits {len(_hours) if not isinstance(_hours, _Raised) else _hours} " f"whole hours, one per hour of the timeout", diff --git a/goodwe_controller/tools/supervisor_shape_probe.py b/goodwe_controller/tools/supervisor_shape_probe.py index 702dc73..a03026c 100644 --- a/goodwe_controller/tools/supervisor_shape_probe.py +++ b/goodwe_controller/tools/supervisor_shape_probe.py @@ -749,6 +749,24 @@ show("(d) GLOBAL `pv_invert: bool` validates too - it loses on SEMANTICS (one " "sign for a mixed roster), not on the schema", ok_d, "" if ok_d else out_d) +print("\n== I. MAINT-10: `maintenance_start_hour` now defaults to 19 ==") +_mk = "maintenance_start_hour" +ok_i, out_i = validate(defaults) +show("the shipped `options:` block validates and carries 19", + ok_i and out_i.get(_mk) == 19, "" if ok_i else out_i) +# What the Supervisor hands the add-on is deepmerge(options:, stored). +_stored10 = dict(defaults, **{_mk: 10}) +ok_i2, out_i2 = validate(MERGER.merge(copy.deepcopy(defaults), _stored10)) +show("a stored explicit 10 (what a blob saved before MAINT-10 holds) stays 10", + ok_i2 and out_i2.get(_mk) == 10, "" if ok_i2 else out_i2) +_absent = {k: v for k, v in defaults.items() if k != _mk} +ok_i3, out_i3 = validate(MERGER.merge(copy.deepcopy(defaults), _absent)) +show("a stored blob WITHOUT the key gets the new default, 19", + ok_i3 and out_i3.get(_mk) == 19, "" if ok_i3 else out_i3) +ok_i4, out_i4 = validate(dict(defaults, **{_mk: 24})) +show("the range is unchanged: 24 is refused (`at most 23`)", + (not ok_i4) and "at most 23" in str(out_i4), out_i4) + # ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL # and still succeeds, and a transcript is only evidence if a wrong answer is # distinguishable from a right one by something other than reading it. diff --git a/goodwe_controller/translations/en.yaml b/goodwe_controller/translations/en.yaml index db33d5b..e9bd5af 100644 --- a/goodwe_controller/translations/en.yaml +++ b/goodwe_controller/translations/en.yaml @@ -215,8 +215,8 @@ configuration: name: "Maintenance · Minimum days between cycles" description: "The shortest gap between two maintenance cycles." maintenance_start_hour: - name: "Maintenance · Start hour" - description: "The local hour, 0 to 23, at which a due cycle starts." + name: "Maintenance · Start hour (local time)" + description: "The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00." maintenance_discharge_w: name: "Maintenance · Draw-down rate (W)" description: "How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer." diff --git a/goodwe_controller/translations/nl.yaml b/goodwe_controller/translations/nl.yaml index e9b4e6d..1c72067 100644 --- a/goodwe_controller/translations/nl.yaml +++ b/goodwe_controller/translations/nl.yaml @@ -196,8 +196,8 @@ configuration: name: "Onderhoud · Minimum dagen tussen cycli" description: "De kortste tijd tussen twee onderhoudscycli." maintenance_start_hour: - name: "Onderhoud · Startuur" - description: "Het lokale uur, 0 tot 23, waarop een geplande cyclus begint." + name: "Onderhoud · Startuur (lokale tijd)" + description: "Het uur op je lokale klok, 0 tot 23, waarop een geplande cyclus begint. 19 is 19:00." maintenance_discharge_w: name: "Onderhoud · Ontlaadvermogen (W)" description: "Hoe snel de cyclus de batterij leegmaakt. Boven het vermogen van de omvormer wordt het daartoe beperkt, en duurt de cyclus gewoon langer."