From a010ccd290d0509d60f8f0d4654447cec8aeaaa0 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Mon, 6 Apr 2026 14:08:27 +0000 Subject: [PATCH] fix(interval_pool): use tz-aware datetime comparison at resolution boundary RESOLUTION_CHANGE_ISO was a naive ISO string ("2025-10-01T00:00:00"). _split_at_resolution_boundary compared it against timezone-aware interval strings via plain string ordering, which is unreliable when the strings carry different UTC offsets (e.g. +02:00 vs +00:00). More critically, the naive string was then split into ranges such as ("...", "2025-10-01T00:00:00") which were parsed back to naive datetime objects in fetch_missing_ranges. When routing.py then compared those naive objects against the tz-aware boundary datetime, Python raised TypeError: can't compare offset-naive and offset-aware datetimes. Fix: - Remove RESOLUTION_CHANGE_ISO; derive the boundary ISO string at runtime from RESOLUTION_CHANGE_DATETIME.isoformat(), which produces the UTC-normalised string "2025-10-01T00:00:00+00:00". - Rewrite _split_at_resolution_boundary to parse each range's start/end to datetime objects, normalise any defensively-naive values to UTC, and compare against the RESOLUTION_CHANGE_DATETIME constant directly. - Use the tz-aware boundary_iso string as the split point so downstream fromisoformat() calls always return tz-aware datetime objects. Impact: Ranges spanning 2025-10-01T00:00:00 UTC are now split correctly regardless of the UTC offset carried by the original interval strings, and no TypeError is raised when routing.py compares the boundary endpoints to its own tz-aware boundary calculation. --- .../tibber_prices/interval_pool/fetcher.py | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/custom_components/tibber_prices/interval_pool/fetcher.py b/custom_components/tibber_prices/interval_pool/fetcher.py index 84f4ecf..31a4c0e 100644 --- a/custom_components/tibber_prices/interval_pool/fetcher.py +++ b/custom_components/tibber_prices/interval_pool/fetcher.py @@ -20,9 +20,7 @@ _LOGGER = logging.getLogger(__name__) _LOGGER_DETAILS = logging.getLogger(__name__ + ".details") # Resolution change date (hourly before, quarter-hourly after) -# Use UTC for constant - timezone adjusted at runtime when comparing RESOLUTION_CHANGE_DATETIME = datetime(2025, 10, 1, tzinfo=UTC) -RESOLUTION_CHANGE_ISO = "2025-10-01T00:00:00" # Interval lengths in minutes INTERVAL_HOURLY = 60 @@ -224,20 +222,33 @@ class TibberPricesIntervalPoolFetcher: """ split_ranges = [] + boundary = RESOLUTION_CHANGE_DATETIME + boundary_iso = boundary.isoformat() for start_iso, end_iso in ranges: + start_dt = datetime.fromisoformat(start_iso) + end_dt = datetime.fromisoformat(end_iso) + + # Normalise to UTC for a timezone-aware comparison. The boundary is + # stored in UTC; naive strings (which should not appear here) are + # treated as UTC defensively. + if start_dt.tzinfo is None: + start_dt = start_dt.replace(tzinfo=UTC) + if end_dt.tzinfo is None: + end_dt = end_dt.replace(tzinfo=UTC) + # Check if range crosses the boundary - if start_iso < RESOLUTION_CHANGE_ISO < end_iso: + if start_dt < boundary < end_dt: # Split into two ranges: before and after boundary - split_ranges.append((start_iso, RESOLUTION_CHANGE_ISO)) - split_ranges.append((RESOLUTION_CHANGE_ISO, end_iso)) + split_ranges.append((start_iso, boundary_iso)) + split_ranges.append((boundary_iso, end_iso)) _LOGGER_DETAILS.debug( - "Split range at resolution boundary: (%s, %s) → (%s, %s) + (%s, %s)", + "Split range at resolution boundary: (%s, %s) -> (%s, %s) + (%s, %s)", start_iso, end_iso, start_iso, - RESOLUTION_CHANGE_ISO, - RESOLUTION_CHANGE_ISO, + boundary_iso, + boundary_iso, end_iso, ) else: