Keep the pending sunrise-off from being cancelled at midnight (#106) #107

Merged
claude-bot merged 1 commit from fix/pending-off-survives-reschedule into main 2026-09-01 16:47:21 +00:00
Contributor

Fixes #106. Iris never turned the lights off. They came on and dimmed on schedule, then stayed lit through the following day.

Cause

A lighting day runs sunset→sunrise, but the schedule was computed from the calendar date. _solar_times correctly returns the next day's sunrise as the off — so the 00:01 reschedule, recomputing for the new calendar date, produced an off ~24 hours out and, because every run writes the single off_job id with replace_existing=True, discarded the one due a few hours later.

on_job and dim_job are immune: their values always land on the same calendar day as the reschedule. off_job is the only one whose correct time belongs to the next day.

The failure was silent. A job that never runs writes no schedule_log row, and every job that did run reported success.

The fix — and why it isn't the one I first proposed

On the issue I recommended moving reschedule_daily off midnight to local noon. That would have been incomplete, and I only noticed while implementing it: a container restart between midnight and sunrise loses the pending off exactly the same way, because startup calls schedule_today_jobs() too. Moving the cron would have fixed the scheduled path and left the restart path broken — arguably worse, because it would have looked fixed.

So the fix goes where the wrong assumption is. New get_active_times() carries a still-pending off forward from the previous cycle:

on_time, dim_time, off_time = get_today_times(today)
_, _, previous_off = get_today_times(today - timedelta(days=1))
if previous_off > now:
    off_time = previous_off

get_today_times(for_date) is untouched — it's a well-tested pure date→times function, including the DST cases — so the cycle awareness sits above it.

/schedule/status uses get_active_times() too. Otherwise the UI would report tomorrow's sunrise while the pending job was today's.

Verification against real solar data

Dev site coordinates, real astral:

23:30 Sep 1  evening, cycle open       off=09-02 06:32 CDT
00:01 Sep 2  the nightly reschedule    off=09-02 06:32 CDT  <-- old code said 09-03 06:33
05:00 Sep 2  restart before sunrise    off=09-02 06:32 CDT  <-- old code said 09-03 06:33
12:00 Sep 2  cycle closed              off=09-03 06:33 CDT

Row 3 is the case the cron-move fix would have missed.

  • Backend: 291 passed (2 new regression tests covering both the after-midnight carry-forward and the correct advance once the cycle closes), ruff, ruff format, mypy clean.

Still to do before #7 closes

This needs one real unattended night on the dev deployment, with off_job present in schedule-log and the controller actually off after sunrise. The Home Assistant automation that masked the original failure has been disabled, so nothing external can stand in for it this time.

🤖 Generated with Claude Code

Fixes #106. **Iris never turned the lights off.** They came on and dimmed on schedule, then stayed lit through the following day. ## Cause A lighting day runs sunset→sunrise, but the schedule was computed from the calendar date. `_solar_times` correctly returns the *next* day's sunrise as the off — so the 00:01 reschedule, recomputing for the new calendar date, produced an off ~24 hours out and, because every run writes the single `off_job` id with `replace_existing=True`, **discarded the one due a few hours later**. `on_job` and `dim_job` are immune: their values always land on the same calendar day as the reschedule. `off_job` is the only one whose correct time belongs to the next day. The failure was silent. A job that never runs writes no `schedule_log` row, and every job that *did* run reported success. ## The fix — and why it isn't the one I first proposed On the issue I recommended moving `reschedule_daily` off midnight to local noon. **That would have been incomplete**, and I only noticed while implementing it: a container restart between midnight and sunrise loses the pending off exactly the same way, because startup calls `schedule_today_jobs()` too. Moving the cron would have fixed the scheduled path and left the restart path broken — arguably worse, because it would have looked fixed. So the fix goes where the wrong assumption is. New `get_active_times()` carries a still-pending off forward from the previous cycle: ```python on_time, dim_time, off_time = get_today_times(today) _, _, previous_off = get_today_times(today - timedelta(days=1)) if previous_off > now: off_time = previous_off ``` `get_today_times(for_date)` is untouched — it's a well-tested pure date→times function, including the DST cases — so the cycle awareness sits above it. `/schedule/status` uses `get_active_times()` too. Otherwise the UI would report tomorrow's sunrise while the pending job was today's. ## Verification against real solar data Dev site coordinates, real `astral`: ``` 23:30 Sep 1 evening, cycle open off=09-02 06:32 CDT 00:01 Sep 2 the nightly reschedule off=09-02 06:32 CDT <-- old code said 09-03 06:33 05:00 Sep 2 restart before sunrise off=09-02 06:32 CDT <-- old code said 09-03 06:33 12:00 Sep 2 cycle closed off=09-03 06:33 CDT ``` Row 3 is the case the cron-move fix would have missed. - Backend: **291 passed** (2 new regression tests covering both the after-midnight carry-forward and the correct advance once the cycle closes), `ruff`, `ruff format`, `mypy` clean. ## Still to do before #7 closes This needs one real unattended night on the dev deployment, with `off_job` present in `schedule-log` and the controller actually off after sunrise. The Home Assistant automation that masked the original failure has been disabled, so nothing external can stand in for it this time. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Keep the pending sunrise-off from being cancelled at midnight (#106)
All checks were successful
CI / Alembic migration check (pull_request) Successful in 1m2s
CI / Python lint & type-check (pull_request) Successful in 1m27s
CI / Frontend lint, test & build (pull_request) Successful in 1m41s
CI / Python tests (pull_request) Successful in 2m47s
CI / Docker build, health smoke & E2E (pull_request) Successful in 2m38s
328c6aaa75
Iris never turned the lights off. They came on and dimmed on schedule, then
stayed lit through the following day.

A lighting day runs sunset -> sunrise, but the schedule was computed from the
calendar date. `_solar_times` correctly returns the *next* day's sunrise as
the off, so the 00:01 reschedule -- recomputing for the new calendar date --
produced an off ~24h out and, because every run writes the single `off_job`
id with replace_existing=True, cancelled the one due a few hours later.

Found on the dev deployment: dim_job fired at 23:00, off_job never ran at
06:31, and no log row was written for it. The lights happened to be off
anyway, switched by an unrelated Home Assistant automation, which is what
made it look like the cycle had succeeded.

`get_active_times()` carries a still-pending off forward from the previous
cycle. The scheduler and /schedule/status both use it, so the reported off is
the one that will actually fire rather than a calendar-derived guess.

Note this deliberately does not take the fix I first proposed on the issue --
moving the reschedule off midnight. That would have covered the 00:01 job but
not a restart between midnight and sunrise, which loses the pending off the
same way. Verified against real solar data for the dev site: at both 00:01
and 05:00 the off stays at that morning's 06:32 rather than jumping to the
next day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch fix/pending-off-survives-reschedule 2026-09-01 16:47:21 +00:00
Sign in to join this conversation.
No description provided.