From 0381749e6fd61713767d4eab324daeabae77e5cd Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Sun, 29 Mar 2026 18:42:27 +0000 Subject: [PATCH] fix(interval_pool): fix DST spring-forward causing missing tomorrow intervals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _get_cached_intervals() used fixed-offset datetimes from fromisoformat() for iteration. When start and end boundaries span a DST transition (e.g., +01:00 CET → +02:00 CEST), the loop's end check compared UTC values, stopping 1 hour early on spring-forward days. This caused the last 4 quarter-hourly intervals of "tomorrow" to be missing, making the binary sensor "Tomorrow data available" show Off even when full data was present. Changed iteration to use naive local timestamps, matching the index key format (timezone stripped via [:19]). The end boundary comparison now works correctly regardless of DST transitions. Impact: Binary sensor "Tomorrow data available" now correctly shows On on DST spring-forward days. Affects all European users on the last Sunday of March each year. --- .../tibber_prices/interval_pool/manager.py | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/custom_components/tibber_prices/interval_pool/manager.py b/custom_components/tibber_prices/interval_pool/manager.py index bda7008..7d2a75f 100644 --- a/custom_components/tibber_prices/interval_pool/manager.py +++ b/custom_components/tibber_prices/interval_pool/manager.py @@ -464,17 +464,30 @@ class TibberPricesIntervalPool: start_time_dt = datetime.fromisoformat(start_time_iso) end_time_dt = datetime.fromisoformat(end_time_iso) + # CRITICAL: Use NAIVE local timestamps for iteration. + # + # Index keys are naive local timestamps (timezone stripped via [:19]). + # When start and end span a DST transition, they have different UTC offsets + # (e.g., start=+01:00 CET, end=+02:00 CEST). Using fixed-offset datetimes + # from fromisoformat() causes the loop to compare UTC values for the end + # boundary, ending 1 hour early on spring-forward days (or 1 hour late on + # fall-back days). + # + # By iterating in naive local time, we match the index key format exactly + # and the end boundary comparison works correctly regardless of DST. + current_naive = start_time_dt.replace(tzinfo=None) + end_naive = end_time_dt.replace(tzinfo=None) + # Use index to find intervals: iterate through expected timestamps result = [] - current_dt = start_time_dt # Determine interval step (15 min post-2025-10-01, 60 min pre) - resolution_change_dt = datetime(2025, 10, 1, tzinfo=start_time_dt.tzinfo) - interval_minutes = INTERVAL_QUARTER_HOURLY if current_dt >= resolution_change_dt else INTERVAL_HOURLY + resolution_change_naive = datetime(2025, 10, 1) # noqa: DTZ001 + interval_minutes = INTERVAL_QUARTER_HOURLY if current_naive >= resolution_change_naive else INTERVAL_HOURLY - while current_dt < end_time_dt: + while current_naive < end_naive: # Check if this timestamp exists in index (O(1) lookup) - current_dt_key = current_dt.isoformat()[:19] + current_dt_key = current_naive.isoformat()[:19] location = self._index.get(current_dt_key) if location is not None: @@ -487,10 +500,10 @@ class TibberPricesIntervalPool: result.append(dict(interval)) # Move to next expected interval - current_dt += timedelta(minutes=interval_minutes) + current_naive += timedelta(minutes=interval_minutes) # Handle resolution change boundary - if interval_minutes == INTERVAL_HOURLY and current_dt >= resolution_change_dt: + if interval_minutes == INTERVAL_HOURLY and current_naive >= resolution_change_naive: interval_minutes = INTERVAL_QUARTER_HOURLY _LOGGER_DETAILS.debug(