MAINT-10 review: warn when HA did not answer and TZ is UTC; test the 3 s budget
checks / unit (pull_request) Successful in 31m18s
infra-pin / fresh (pull_request) Successful in 1s

HA can take minutes to start (2026-10-05, 502 from the proxy). Then TZ holds,
which the Supervisor sets from HA's zone, so the hour stays right; only a TZ
of UTC there now logs a WARNING asking for a restart. A test pins the 3 s
budget on the zone request (a mutant without it survived).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-05 23:24:02 +02:00
co-authored by Claude Opus 5.5
parent 0b6b34f563
commit 1d28daf8a0
4 changed files with 35 additions and 3 deletions
@@ -13,8 +13,14 @@ peak guard outrank the drain.
Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup, 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 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 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 add-on restart. HA can still be starting when the add-on starts (502 from the
CI image does not, so the tests grade DST on `tariff._EUTimezone` too. proxy, 2026-10-05); then `TZ` holds. HA pushes its zone to the Supervisor
(hassio `push_config`), which stores it and sets it as the add-on's `TZ`
(`docker/app.py`, Supervisor 2026.08.0), so the hour stays right. Only a `TZ` of
UTC there logs a WARNING asking for a restart. No later re-resolve: the fallback
already is HA's zone, so the rare UTC case gets a warning rather than more code.
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 ## 2. DST
+2 -1
View File
@@ -1653,7 +1653,8 @@ help - see "Why the tuning is what it is".
**The start hour is local time.** The add-on reads Home Assistant's time zone **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, (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 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 one it used, and warns if it is UTC: then restart the add-on once Home Assistant
is up. 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 starts at the first hour after the jump. On the day they go back, a repeated
hour starts only once, in its first pass. With two inverters both batteries use hour starts only once, in its first pass. With two inverters both batteries use
the same local hour; one cycles at a time, and the other starts at that hour on the same local hour; one cycles at a time, and the other starts at that hour on
+10
View File
@@ -7267,6 +7267,10 @@ async def ha_time_zone(session, token: str) -> tuple:
return None, f"{type(err).__name__}: {text}" if text else type(err).__name__ return None, f"{type(err).__name__}: {text}" if text else type(err).__name__
_UTC_NAMES = {"UTC", "ETC/UTC", "GMT", "ETC/GMT", "UCT", "ETC/UCT",
"UNIVERSAL", "ETC/UNIVERSAL", "ZULU", "ETC/ZULU"}
async def maint_site_tz(session, token: str): async def maint_site_tz(session, token: str):
"""MAINT-10: the zone the maintenance start hour is read in, logged once.""" """MAINT-10: the zone the maintenance start hour is read in, logged once."""
zone, why = await ha_time_zone(session, token) zone, why = await ha_time_zone(session, token)
@@ -7274,6 +7278,12 @@ async def maint_site_tz(session, token: str):
if source == "ha": if source == "ha":
_LOG.info("Maintenance start hour is local time in %s, from Home " _LOG.info("Maintenance start hour is local time in %s, from Home "
"Assistant.", name) "Assistant.", name)
elif source == "tz" and name.strip().upper() in _UTC_NAMES:
# The Supervisor sets TZ to its copy of HA's zone, or to UTC if it has
# none: HA did not answer here, and UTC may not be the site's clock.
_LOG.warning("Maintenance start hour uses UTC, because Home Assistant "
"did not answer. Restart the add-on when it is up.")
_LOG.debug("maintenance time zone: %s", why)
elif source == "tz": elif source == "tz":
_LOG.info("Maintenance start hour is local time in %s, from the " _LOG.info("Maintenance start hour is local time in %s, from the "
"add-on's time zone. Home Assistant gave none: %s.", name, why) "add-on's time zone. Home Assistant gave none: %s.", name, why)
+15
View File
@@ -316,6 +316,7 @@ class _Session:
def get(self, url, **kw): def get(self, url, **kw):
self.asked.append(url) self.asked.append(url)
self.timeouts = getattr(self, "timeouts", []) + [kw.get("timeout")]
if url.endswith("/core/api/config"): if url.endswith("/core/api/config"):
return _Resp(self.zone_body) return _Resp(self.zone_body)
return _Resp({"data": {}}) return _Resp({"data": {}})
@@ -325,6 +326,9 @@ s = _Session({"time_zone": "Europe/Brussels"})
check("ha_time_zone() reads /core/api/config's time_zone", check("ha_time_zone() reads /core/api/config's time_zone",
asyncio.run(M.ha_time_zone(s, "tok")) == ("Europe/Brussels", "") asyncio.run(M.ha_time_zone(s, "tok")) == ("Europe/Brussels", "")
and s.asked == [M.CORE_API + "/config"]) and s.asked == [M.CORE_API + "/config"])
# Review: HA can take minutes to start (2026-10-05); the ask must not wait on it.
check("...with a time budget of at most 3 s, so a slow HA cannot hold up start",
s.timeouts == [M.DOCS_LINK_TIMEOUT_S] and 0 < M.DOCS_LINK_TIMEOUT_S <= 3)
check("...and a failure is None plus the reason, never a raise", check("...and a failure is None plus the reason, never a raise",
asyncio.run(M.ha_time_zone(_Session(OSError("down")), "tok")) asyncio.run(M.ha_time_zone(_Session(OSError("down")), "tok"))
== (None, "OSError: down") == (None, "OSError: down")
@@ -408,6 +412,17 @@ try:
check("amain(), neither: UTC, with a WARNING line", check("amain(), neither: UTC, with a WARNING line",
tz is UTC and any(lvl == "WARNING" and "uses UTC" in m tz is UTC and any(lvl == "WARNING" and "uses UTC" in m
for lvl, m in logs)) for lvl, m in logs))
# Review: HA still starting (502) and the Supervisor's TZ is UTC.
tz, lines, logs = amain_zone(OSError("502"), "UTC")
check("amain(), Home Assistant unavailable and TZ is UTC: a WARNING "
"that says to restart", tz is UTC
and [(lvl, m) for lvl, m in logs if "Maintenance start" in m]
== [("WARNING", "Maintenance start hour uses UTC, because Home "
"Assistant did not answer. Restart the add-on when it is up.")])
tz, lines, logs = amain_zone(OSError("502"), "America/New_York")
check("...but a real TZ is used without a warning",
not any(lvl == "WARNING" for lvl, m in logs
if "Maintenance start" in m))
finally: finally:
MT._load_zone = _real_load MT._load_zone = _real_load