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

Merged
admin merged 6 commits from maint-10-local-start-hour into release/1.0 2026-10-05 23:56:19 +02:00
Owner

Owner decision 2026-09-29 (option A): maintenance_start_hour is read in local time, and the default moves from 10 to 19. Brought up to date with release/1.0 (0.15.0 + SAFETY-90) by a normal merge.

What changes

  • Zone: Home Assistant's time_zone (GET /core/api/config, 3 s budget) at startup; else the container's TZ; else UTC (logged as WARNING). Resolved once per start.
  • Every pack: at two inverters both packs get the same zone; arbitrate() is unchanged, so the pack that waits starts at the local hour on a later day.
  • DST (maintenance.in_start_slot(), UTC instants only): clocks forward and the hour does not exist → the first hour after the jump. Clocks back and the hour repeats → first pass only.
  • Default 19 in config.yaml and MAINT_OPTION_DEFAULTS; schema unchanged int(0,23).
  • Home screen shows the start at the instant the cycle uses (next_slot_start()).
  • Settings text: EN "Start hour (local time)" — "The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00." NL "Startuur (lokale tijd)" — "Het uur op je lokale klok, 0 tot 23, waarop een geplande cyclus begint. 19 is 19:00."
  • DOCS (Maintenance), DECISIONS/MAINT-10-local-start-hour.md.

Release note draft (for the version bump; the version gate refuses an ## Unreleased block)

  • The maintenance start hour is now local time, and the default is 19:00 (MAINT-10). It used to be read as UTC. An existing install keeps its saved hour, and that hour now means local time: a saved 10 starts at 10:00, not 12:00 in summer. If you set an earlier hour to get an evening start (for example 17 for 19:00 in summer), set it to 19.

Live site: runs 17 as a stopgap for 19:00 summer; set it to 19 after deploy.

Tests (CI image, no tzdata: EU rule; plus zoneinfo Europe/Brussels with tzdata)

test_maint10.py: UTC identical to the old rule over a year; 19 → 17:00 UTC summer / 18:00 winter; every hour 0–23 on 29 Mar 2026, 28 Mar 2027 and 25 Oct 2026 gives one 60 min slot; one start per day with abort/deferral in the repeated hour; Home screen = slot start; zone order HA → TZ → UTC; default 19; real amain() wiring; real Controller identical for 11 UTC vs 13 Brussels at N=1 and N=2; N=2 two due packs across 24/25 Oct and 27/28 Mar start at the local hour on successive days, and a pack freed in the repeated hour does not start there.

Mutations (13, all killed; fresh copy each, CI image)

mutation reds
M1 start decision back to the UTC hour test_maint10 13
M2 slot ignores the zone test_maint10 24, test_home 1
M3 repeated hour: both passes count test_maint10 6
M4 missing hour: never starts test_maint10 6
M13 missing hour: starts an hour late test_maint10 6
M5 / M6 default left at 10 (mirror / config.yaml) test_maint10, test_maintenance, test_defaults_pin 1 each
M7 amain does not pass the zone test_maint10 4
M8 Controller drops the zone test_maint10 5
M9 TZ before HA test_maint10 5
M12 HA zone ignored test_maint10 4
M11 N=2: only the first pack gets the zone test_maint10 3
M10 Home screen shows the UTC hour test_home 1

Full suite on 0b6b34f in the CI image with admin/goodwe at the pin: 64/64 files pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr

Owner decision 2026-09-29 (option A): `maintenance_start_hour` is read in **local time**, and the default moves from 10 to **19**. Brought up to date with release/1.0 (0.15.0 + SAFETY-90) by a normal merge. ## What changes - **Zone**: Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup; else the container's `TZ`; else UTC (logged as WARNING). Resolved once per start. - **Every pack**: at two inverters both packs get the same zone; `arbitrate()` is unchanged, so the pack that waits starts at the local hour on a later day. - **DST** (`maintenance.in_start_slot()`, UTC instants only): clocks forward and the hour does not exist → the first hour after the jump. Clocks back and the hour repeats → first pass only. - **Default** 19 in `config.yaml` and `MAINT_OPTION_DEFAULTS`; schema unchanged `int(0,23)`. - **Home screen** shows the start at the instant the cycle uses (`next_slot_start()`). - **Settings text**: EN "Start hour (local time)" — "The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00." NL "Startuur (lokale tijd)" — "Het uur op je lokale klok, 0 tot 23, waarop een geplande cyclus begint. 19 is 19:00." - DOCS (Maintenance), DECISIONS/MAINT-10-local-start-hour.md. ## Release note draft (for the version bump; the version gate refuses an `## Unreleased` block) > - **The maintenance start hour is now local time, and the default is 19:00** ([[MAINT-10]]). It used to be read as UTC. An existing install keeps its saved hour, and that hour now means local time: a saved 10 starts at 10:00, not 12:00 in summer. If you set an earlier hour to get an evening start (for example 17 for 19:00 in summer), set it to 19. Live site: runs 17 as a stopgap for 19:00 summer; set it to 19 after deploy. ## Tests (CI image, no tzdata: EU rule; plus zoneinfo Europe/Brussels with tzdata) `test_maint10.py`: UTC identical to the old rule over a year; 19 → 17:00 UTC summer / 18:00 winter; every hour 0–23 on 29 Mar 2026, **28 Mar 2027** and **25 Oct 2026** gives one 60 min slot; one start per day with abort/deferral in the repeated hour; Home screen = slot start; zone order HA → TZ → UTC; default 19; real `amain()` wiring; real Controller identical for 11 UTC vs 13 Brussels at **N=1 and N=2**; **N=2** two due packs across 24/25 Oct and 27/28 Mar start at the local hour on successive days, and a pack freed in the repeated hour does not start there. ## Mutations (13, all killed; fresh copy each, CI image) | mutation | reds | |---|---| | M1 start decision back to the UTC hour | test_maint10 13 | | M2 slot ignores the zone | test_maint10 24, test_home 1 | | M3 repeated hour: both passes count | test_maint10 6 | | M4 missing hour: never starts | test_maint10 6 | | M13 missing hour: starts an hour late | test_maint10 6 | | M5 / M6 default left at 10 (mirror / config.yaml) | test_maint10, test_maintenance, test_defaults_pin 1 each | | M7 amain does not pass the zone | test_maint10 4 | | M8 Controller drops the zone | test_maint10 5 | | M9 TZ before HA | test_maint10 5 | | M12 HA zone ignored | test_maint10 4 | | M11 N=2: only the first pack gets the zone | test_maint10 3 | | M10 Home screen shows the UTC hour | test_home 1 | Full suite on 0b6b34f in the CI image with admin/goodwe at the pin: 64/64 files pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
admin added 3 commits 2026-09-29 23:09:30 +02: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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MAINT-10: DECISIONS says where the release note lives
checks / unit (pull_request) Successful in 23m8s
infra-pin / fresh (pull_request) Successful in 1s
2f5ab1f378
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
admin added 2 commits 2026-10-05 22:22:41 +02:00
Conflicts: main.py's maintenance import (base added CHARGE, HOLD; branch added
site_zone) - both kept. DOCS maintenance table: base's longer drain-rate row
kept, start hour row at 19 / local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MAINT-10: every pack at N=2, the next DST days, plainer upgrade note
checks / unit (pull_request) Successful in 31m20s
infra-pin / fresh (pull_request) Successful in 1s
0b6b34f563
test_maint10.py part 10: at two inverters both packs get the site zone, the
real Controller is identical for 11 UTC and 13 Brussels, and two due packs
across 24/25 Oct 2026 and 27/28 Mar 2027 start at the local hour on
successive days; a pack freed in the repeated hour does not start there.
Part 3 adds 28 Mar 2027. DOCS: an existing install keeps its saved hour, now
read as local time; an hour set to make up for UTC goes back to 19.
DECISIONS: the SAFETY-83 gate is met; section on two inverters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
admin added 1 commit 2026-10-05 23:24:05 +02:00
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
1d28daf8a0
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>
Author
Owner

MAINT-10 review, round 1 (reviewer)

Reviewed 0b6b34f; fixes pushed as 1d28daf.

Findings

F1. HA not up at add-on start (fixed in 1d28daf, partly refuted). The worry was that a 502 from the proxy at start (2026-10-05, HA took ~9 min) leaves the add-on on UTC for its whole life. On a Supervisor install it does not: HA pushes its zone to the Supervisor (core hassio/__init__.py push_config → set_options(timezone=…)), the Supervisor stores it and sets TZ on the add-on container (docker/app.py, ENV_TIME: self.sys_timezone, checked in supervisor 2026.08.0). So the fallback is HA's own zone. What was left: if that TZ is UTC (the Supervisor has no zone), the start shifts and the log only said INFO "local time in UTC". Fix: that case now logs a WARNING: "Maintenance start hour uses UTC, because Home Assistant did not answer. Restart the add-on when it is up." No later re-resolve: the fallback already is HA's zone, so a warning is enough for the rare UTC case. DOCS + DECISIONS §1 say this. Tests: part 8, TZ=UTC → that WARNING; TZ=New_York → no warning.

F2. The 3 s budget on the zone request was not tested (fixed in 1d28daf). My mutant R1, which drops timeout= from ha_time_zone(), survived. A slow HA could then hold up control start. A check now pins timeout == DOCS_LINK_TIMEOUT_S (≤ 3 s).

Checked, no finding

  • tzdata: amd64-base:3.20 has tzdata, and apk python3 there gives ZoneInfo("Europe/Brussels") the right answer (2026-10-25 01:30Z → 02:30+01:00). (The "no tzdata in the shipped image" notes in price.py/static_tariff.py are stale, but they are out of scope.)
  • DST, my probes with real zoneinfo, every minute, through arbitrate(), with a 70-min cycle:
    • 25 Oct 2026, hour 2: one slot, 00:00–01:00Z (first pass). Aborting every drain after 5 min does not restart in the second pass (cooldown would allow 01:35Z; the first-pass rule blocks it).
    • 28 Mar 2027, hour 2: 03:00 CEST (01:00Z), once.
    • Hour 19: 17:00Z/18:00Z.
    • At N=2 the second pack starts the next local day at the same hour.
    • All 24 hours give exactly one 60-min slot on both days.
    • next_slot_start gives the same instants.
  • Every pack: Controller passes tz=site_tz to every pack's Maintenance, and homeview uses m.tz for each pack. Nothing else reads start_hour (grep).
  • Upgrade / 17: the only readers are wants_start and homeview, so a stored 17 means 17:00 local, with nothing special about it. The DOCS and release text is true and plain.
  • Default 19: set in config.yaml and MAINT_OPTION_DEFAULTS. The tests that pin it are test_maint10, test_maintenance and test_defaults_pin, plus probe §I. test_price_forecast pins 10 for its own scenario, which is fine.
  • Merge 08fb123: --remerge-diff shows only the two conflicts, both resolved keeping both sides. The diff against the base is just the 12 MAINT-10 files. No test was removed or weakened.
  • UX-08: the EN/NL label and description are short and plain.

Mutations (mine, on 1d28daf content, CI image)

mutant result
R1 zone request without time budget killed (after F2; survived before)
R2 missing hour: >=→> (slot 1 min late) killed, test_maint10 7
R3 next_slot_start no look-back killed, test_maint10 1
R4 repeated hour: date test inverted killed, test_maint10 6
R5 amain drops the TZ fallback killed, test_maint10 3
R6 the new UTC warning dropped killed, test_maint10 1
R7 zone-less Maintenance defaults to EU local killed, 4 files

Merging once checks / unit is green on 1d28daf.

## MAINT-10 review, round 1 (reviewer) Reviewed 0b6b34f; fixes pushed as **1d28daf**. ### Findings **F1. HA not up at add-on start (fixed in 1d28daf, partly refuted).** The worry was that a 502 from the proxy at start (2026-10-05, HA took ~9 min) leaves the add-on on UTC for its whole life. On a Supervisor install it does not: HA pushes its zone to the Supervisor (core `hassio/__init__.py` `push_config` → `set_options(timezone=…)`), the Supervisor stores it and sets `TZ` on the add-on container (`docker/app.py`, `ENV_TIME: self.sys_timezone`, checked in supervisor 2026.08.0). So the fallback is HA's own zone. What was left: if that `TZ` is `UTC` (the Supervisor has no zone), the start shifts and the log only said INFO "local time in UTC". **Fix:** that case now logs a WARNING: "Maintenance start hour uses UTC, because Home Assistant did not answer. Restart the add-on when it is up." No later re-resolve: the fallback already is HA's zone, so a warning is enough for the rare UTC case. DOCS + DECISIONS §1 say this. Tests: part 8, TZ=UTC → that WARNING; TZ=New_York → no warning. **F2. The 3 s budget on the zone request was not tested (fixed in 1d28daf).** My mutant R1, which drops `timeout=` from `ha_time_zone()`, survived. A slow HA could then hold up control start. A check now pins `timeout == DOCS_LINK_TIMEOUT_S` (≤ 3 s). ### Checked, no finding - **tzdata:** `amd64-base:3.20` has tzdata, and apk `python3` there gives `ZoneInfo("Europe/Brussels")` the right answer (2026-10-25 01:30Z → 02:30+01:00). (The "no tzdata in the shipped image" notes in price.py/static_tariff.py are stale, but they are out of scope.) - **DST, my probes with real zoneinfo**, every minute, through `arbitrate()`, with a 70-min cycle: - 25 Oct 2026, hour 2: one slot, 00:00–01:00Z (first pass). Aborting every drain after 5 min does not restart in the second pass (cooldown would allow 01:35Z; the first-pass rule blocks it). - 28 Mar 2027, hour 2: 03:00 CEST (01:00Z), once. - Hour 19: 17:00Z/18:00Z. - At N=2 the second pack starts the next local day at the same hour. - All 24 hours give exactly one 60-min slot on both days. - `next_slot_start` gives the same instants. - **Every pack:** `Controller` passes `tz=site_tz` to every pack's `Maintenance`, and `homeview` uses `m.tz` for each pack. Nothing else reads `start_hour` (grep). - **Upgrade / 17:** the only readers are `wants_start` and `homeview`, so a stored 17 means 17:00 local, with nothing special about it. The DOCS and release text is true and plain. - **Default 19:** set in config.yaml and `MAINT_OPTION_DEFAULTS`. The tests that pin it are test_maint10, test_maintenance and test_defaults_pin, plus probe §I. test_price_forecast pins 10 for its own scenario, which is fine. - **Merge 08fb123:** `--remerge-diff` shows only the two conflicts, both resolved keeping both sides. The diff against the base is just the 12 MAINT-10 files. No test was removed or weakened. - **UX-08:** the EN/NL label and description are short and plain. ### Mutations (mine, on 1d28daf content, CI image) | | mutant | result | |---|---|---| | R1 | zone request without time budget | killed (after F2; survived before) | | R2 | missing hour: `>=`→`>` (slot 1 min late) | killed, test_maint10 7 | | R3 | `next_slot_start` no look-back | killed, test_maint10 1 | | R4 | repeated hour: date test inverted | killed, test_maint10 6 | | R5 | amain drops the TZ fallback | killed, test_maint10 3 | | R6 | the new UTC warning dropped | killed, test_maint10 1 | | R7 | zone-less `Maintenance` defaults to EU local | killed, 4 files | Merging once `checks / unit` is green on 1d28daf.
admin merged commit 1bf6b7f9a7 into release/1.0 2026-10-05 23:56:19 +02:00
Author
Owner

Review round 1 closed: merged

CI checks / unit (pull_request) passed on 1d28daf (run 1186, a real 31-minute run). Local run in goodwe-addon-checks:1 with admin/goodwe at the pin: 64/64 files pass. Merged into release/1.0 as 1bf6b7f, pinned to head_commit_id 1d28daf; the merge tree is the same as the head tree. Both review findings (F1, F2) are fixed. Not tagged and not deployed.

For the rig test: the startup log line, the 19:00 local start on the Home screen and in control, and the stored-hour upgrade (see the review report).

## Review round 1 closed: merged CI `checks / unit (pull_request)` passed on 1d28daf (run 1186, a real 31-minute run). Local run in `goodwe-addon-checks:1` with admin/goodwe at the pin: 64/64 files pass. Merged into `release/1.0` as **1bf6b7f**, pinned to `head_commit_id` 1d28daf; the merge tree is the same as the head tree. Both review findings (F1, F2) are fixed. Not tagged and not deployed. For the rig test: the startup log line, the 19:00 local start on the Home screen and in control, and the stored-hour upgrade (see the review report).
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#159