From 625bc222ca38c23d984c31ccd06545e88286d5ca Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Wed, 19 Nov 2025 18:36:12 +0000 Subject: [PATCH] refactor(coordinator): centralize time operations through TimeService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce TimeService as single source of truth for all datetime operations, replacing direct dt_util calls throughout the codebase. This establishes consistent time context across update cycles and enables future time-travel testing capability. Core changes: - NEW: coordinator/time_service.py with timezone-aware datetime API - Coordinator now creates TimeService per update cycle, passes to calculators - Timer callbacks (#2, #3) inject TimeService into entity update flow - All sensor calculators receive TimeService via coordinator reference - Attribute builders accept time parameter for timestamp calculations Key patterns replaced: - dt_util.now() → time.now() (single reference time per cycle) - dt_util.parse_datetime() + as_local() → time.get_interval_time() - Manual interval arithmetic → time.get_interval_offset_time() - Manual day boundaries → time.get_day_boundaries() - round_to_nearest_quarter_hour() → time.round_to_nearest_quarter() Import cleanup: - Removed dt_util imports from ~30 files (calculators, attributes, utils) - Restricted dt_util to 3 modules: time_service.py (operations), api/client.py (rate limiting), entity_utils/icons.py (cosmetic updates) - datetime/timedelta only for TYPE_CHECKING (type hints) or duration arithmetic Interval resolution abstraction: - Removed hardcoded MINUTES_PER_INTERVAL constant from 10+ files - New methods: time.minutes_to_intervals(), time.get_interval_duration() - Supports future 60-minute resolution (legacy data) via TimeService config Timezone correctness: - API timestamps (startsAt) already localized by data transformation - TimeService operations preserve HA user timezone throughout - DST transitions handled via get_expected_intervals_for_day() (future use) Timestamp ordering preserved: - Attribute builders generate default timestamp (rounded quarter) - Sensors override when needed (next interval, daily midnight, etc.) - Platform ensures timestamp stays FIRST in attribute dict Timer integration: - Timer #2 (quarter-hour): Creates TimeService, calls _handle_time_sensitive_update(time) - Timer #3 (30-second): Creates TimeService, calls _handle_minute_update(time) - Consistent time reference for all entities in same update batch Time-travel readiness: - TimeService.with_reference_time() enables time injection (not yet used) - All calculations use time.now() → easy to simulate past/future states - Foundation for debugging period calculations with historical data Impact: Eliminates timestamp drift within update cycles (previously 60+ independent dt_util.now() calls could differ by milliseconds). Establishes architecture for time-based testing and debugging features. --- custom_components/tibber_prices/api/client.py | 34 +- .../tibber_prices/api/helpers.py | 15 +- .../tibber_prices/binary_sensor/attributes.py | 62 +- .../tibber_prices/binary_sensor/core.py | 63 +- .../binary_sensor/definitions.py | 3 - custom_components/tibber_prices/const.py | 3 - .../tibber_prices/coordinator/__init__.py | 2 + .../tibber_prices/coordinator/cache.py | 18 +- .../tibber_prices/coordinator/core.py | 104 ++- .../coordinator/data_fetching.py | 37 +- .../coordinator/data_transformation.py | 13 +- .../tibber_prices/coordinator/helpers.py | 80 +- .../tibber_prices/coordinator/listeners.py | 41 +- .../coordinator/period_handlers/__init__.py | 2 - .../coordinator/period_handlers/core.py | 16 +- .../period_handlers/period_building.py | 47 +- .../period_handlers/period_merging.py | 18 +- .../period_handlers/period_statistics.py | 14 +- .../coordinator/period_handlers/relaxation.py | 42 +- .../coordinator/period_handlers/types.py | 1 - .../tibber_prices/coordinator/periods.py | 8 +- .../tibber_prices/coordinator/time_service.py | 788 ++++++++++++++++++ .../tibber_prices/entity_utils/helpers.py | 13 +- .../tibber_prices/entity_utils/icons.py | 44 +- .../sensor/attributes/__init__.py | 26 +- .../sensor/attributes/daily_stat.py | 31 +- .../tibber_prices/sensor/attributes/future.py | 53 +- .../sensor/attributes/interval.py | 40 +- .../sensor/attributes/metadata.py | 13 +- .../tibber_prices/sensor/attributes/timing.py | 29 +- .../tibber_prices/sensor/attributes/trend.py | 11 +- .../sensor/attributes/volatility.py | 25 +- .../sensor/attributes/window_24h.py | 20 +- .../sensor/calculators/daily_stat.py | 42 +- .../sensor/calculators/interval.py | 15 +- .../sensor/calculators/rolling_hour.py | 6 +- .../sensor/calculators/timing.py | 102 ++- .../tibber_prices/sensor/calculators/trend.py | 67 +- .../sensor/calculators/volatility.py | 6 +- .../sensor/calculators/window_24h.py | 2 +- .../tibber_prices/sensor/chart_data.py | 2 +- .../tibber_prices/sensor/core.py | 133 +-- .../tibber_prices/sensor/helpers.py | 16 +- .../tibber_prices/services/chartdata.py | 9 +- .../tibber_prices/services/formatters.py | 26 +- .../tibber_prices/utils/__init__.py | 2 - .../tibber_prices/utils/average.py | 227 +++-- .../tibber_prices/utils/price.py | 46 +- 48 files changed, 1737 insertions(+), 680 deletions(-) create mode 100644 custom_components/tibber_prices/coordinator/time_service.py diff --git a/custom_components/tibber_prices/api/client.py b/custom_components/tibber_prices/api/client.py index 006407b..64fc8f5 100644 --- a/custom_components/tibber_prices/api/client.py +++ b/custom_components/tibber_prices/api/client.py @@ -7,12 +7,10 @@ import logging import re import socket from datetime import timedelta -from typing import Any +from typing import TYPE_CHECKING, Any import aiohttp -from homeassistant.util import dt as dt_util - from .exceptions import ( TibberPricesApiClientAuthenticationError, TibberPricesApiClientCommunicationError, @@ -28,6 +26,9 @@ from .helpers import ( ) from .queries import QueryType +if TYPE_CHECKING: + from custom_components.tibber_prices.coordinator.time_service import TimeService + _LOGGER = logging.getLogger(__name__) @@ -45,7 +46,8 @@ class TibberPricesApiClient: self._session = session self._version = version self._request_semaphore = asyncio.Semaphore(2) # Max 2 concurrent requests - self._last_request_time = dt_util.now() + self.time: TimeService | None = None # Set externally by coordinator + self._last_request_time = None # Set on first request self._min_request_interval = timedelta(seconds=1) # Min 1 second between requests self._max_retries = 5 self._retry_delay = 2 # Base retry delay in seconds @@ -208,6 +210,7 @@ class TibberPricesApiClient: homes_data[home_id] = flatten_price_info( home["currentSubscription"], currency, + time=self.time, ) else: _LOGGER.debug( @@ -444,17 +447,20 @@ class TibberPricesApiClient: ) -> Any: """Handle a single API request with rate limiting.""" async with self._request_semaphore: - now = dt_util.now() - time_since_last_request = now - self._last_request_time - if time_since_last_request < self._min_request_interval: - sleep_time = (self._min_request_interval - time_since_last_request).total_seconds() - _LOGGER.debug( - "Rate limiting: waiting %s seconds before next request", - sleep_time, - ) - await asyncio.sleep(sleep_time) + # Rate limiting: ensure minimum interval between requests + if self.time and self._last_request_time: + now = self.time.now() + time_since_last_request = now - self._last_request_time + if time_since_last_request < self._min_request_interval: + sleep_time = (self._min_request_interval - time_since_last_request).total_seconds() + _LOGGER.debug( + "Rate limiting: waiting %s seconds before next request", + sleep_time, + ) + await asyncio.sleep(sleep_time) - self._last_request_time = dt_util.now() + if self.time: + self._last_request_time = self.time.now() return await self._make_request( headers, data or {}, diff --git a/custom_components/tibber_prices/api/helpers.py b/custom_components/tibber_prices/api/helpers.py index fc64614..5e363c5 100644 --- a/custom_components/tibber_prices/api/helpers.py +++ b/custom_components/tibber_prices/api/helpers.py @@ -7,11 +7,12 @@ from datetime import timedelta from typing import TYPE_CHECKING from homeassistant.const import __version__ as ha_version -from homeassistant.util import dt as dt_util if TYPE_CHECKING: import aiohttp + from custom_components.tibber_prices.coordinator.time_service import TimeService + from .queries import QueryType from .exceptions import ( @@ -251,7 +252,7 @@ def prepare_headers(access_token: str, version: str) -> dict[str, str]: } -def flatten_price_info(subscription: dict, currency: str | None = None) -> dict: +def flatten_price_info(subscription: dict, currency: str | None = None, *, time: TimeService) -> dict: """ Transform and flatten priceInfo from full API data structure. @@ -261,8 +262,8 @@ def flatten_price_info(subscription: dict, currency: str | None = None) -> dict: price_info = subscription.get("priceInfo", {}) price_info_range = subscription.get("priceInfoRange", {}) - # Get today and yesterday dates using Home Assistant's dt_util - today_local = dt_util.now().date() + # Get today and yesterday dates using TimeService + today_local = time.now().date() yesterday_local = today_local - timedelta(days=1) _LOGGER.debug("Processing data for yesterday's date: %s", yesterday_local) @@ -277,14 +278,12 @@ def flatten_price_info(subscription: dict, currency: str | None = None) -> dict: continue price_data = edge["node"] - # Parse timestamp using dt_util for proper timezone handling - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + # Parse timestamp using TimeService for proper timezone handling + starts_at = time.get_interval_time(price_data) if starts_at is None: _LOGGER.debug("Could not parse timestamp: %s", price_data["startsAt"]) continue - # Convert to local timezone - starts_at = dt_util.as_local(starts_at) price_date = starts_at.date() # Only include prices from yesterday diff --git a/custom_components/tibber_prices/binary_sensor/attributes.py b/custom_components/tibber_prices/binary_sensor/attributes.py index ccb964f..759606b 100644 --- a/custom_components/tibber_prices/binary_sensor/attributes.py +++ b/custom_components/tibber_prices/binary_sensor/attributes.py @@ -5,8 +5,9 @@ from __future__ import annotations from typing import TYPE_CHECKING from custom_components.tibber_prices.entity_utils import add_icon_color_attribute -from custom_components.tibber_prices.utils.average import round_to_nearest_quarter_hour -from homeassistant.util import dt as dt_util + +if TYPE_CHECKING: + from custom_components.tibber_prices.coordinator.time_service import TimeService if TYPE_CHECKING: from datetime import datetime @@ -14,15 +15,18 @@ if TYPE_CHECKING: from custom_components.tibber_prices.data import TibberPricesConfigEntry from homeassistant.core import HomeAssistant -from .definitions import MIN_TOMORROW_INTERVALS_15MIN - -def get_tomorrow_data_available_attributes(coordinator_data: dict) -> dict | None: +def get_tomorrow_data_available_attributes( + coordinator_data: dict, + *, + time: TimeService, +) -> dict | None: """ Build attributes for tomorrow_data_available sensor. Args: coordinator_data: Coordinator data dict + time: TimeService instance Returns: Attributes dict with intervals_available and data_status @@ -35,9 +39,13 @@ def get_tomorrow_data_available_attributes(coordinator_data: dict) -> dict | Non tomorrow_prices = price_info.get("tomorrow", []) interval_count = len(tomorrow_prices) + # Get expected intervals for tomorrow (handles DST) + tomorrow_date = time.get_local_date(offset_days=1) + expected_intervals = time.get_expected_intervals_for_day(tomorrow_date) + if interval_count == 0: status = "none" - elif interval_count == MIN_TOMORROW_INTERVALS_15MIN: + elif interval_count == expected_intervals: status = "full" else: status = "partial" @@ -51,6 +59,7 @@ def get_tomorrow_data_available_attributes(coordinator_data: dict) -> dict | Non def get_price_intervals_attributes( coordinator_data: dict, *, + time: TimeService, reverse_sort: bool, ) -> dict | None: """ @@ -63,6 +72,7 @@ def get_price_intervals_attributes( Args: coordinator_data: Coordinator data dict + time: TimeService instance (required) reverse_sort: True for peak_price (highest first), False for best_price (lowest first) Returns: @@ -70,7 +80,7 @@ def get_price_intervals_attributes( """ if not coordinator_data: - return build_no_periods_result() + return build_no_periods_result(time=time) # Get precomputed period summaries from coordinator periods_data = coordinator_data.get("periods", {}) @@ -78,21 +88,20 @@ def get_price_intervals_attributes( period_data = periods_data.get(period_type) if not period_data: - return build_no_periods_result() + return build_no_periods_result(time=time) period_summaries = period_data.get("periods", []) if not period_summaries: - return build_no_periods_result() + return build_no_periods_result(time=time) # Find current or next period based on current time - now = dt_util.now() current_period = None # First pass: find currently active period for period in period_summaries: start = period.get("start") end = period.get("end") - if start and end and start <= now < end: + if start and end and time.is_current_interval(start, end): current_period = period break @@ -100,15 +109,15 @@ def get_price_intervals_attributes( if not current_period: for period in period_summaries: start = period.get("start") - if start and start > now: + if start and time.is_in_future(start): current_period = period break # Build final attributes - return build_final_attributes_simple(current_period, period_summaries) + return build_final_attributes_simple(current_period, period_summaries, time=time) -def build_no_periods_result() -> dict: +def build_no_periods_result(*, time: TimeService) -> dict: """ Build result when no periods exist (not filtered, just none available). @@ -117,7 +126,7 @@ def build_no_periods_result() -> dict: """ # Calculate timestamp: current time rounded down to last quarter hour - now = dt_util.now() + now = time.now() current_minute = (now.minute // 15) * 15 timestamp = now.replace(minute=current_minute, second=0, microsecond=0) @@ -204,6 +213,8 @@ def add_relaxation_attributes(attributes: dict, current_period: dict) -> None: def build_final_attributes_simple( current_period: dict | None, period_summaries: list[dict], + *, + time: TimeService, ) -> dict: """ Build the final attributes dictionary from coordinator's period summaries. @@ -226,12 +237,13 @@ def build_final_attributes_simple( Args: current_period: The current or next period (already complete from coordinator) period_summaries: All period summaries from coordinator + time: TimeService instance (required) Returns: Complete attributes dict with all fields """ - now = dt_util.now() + now = time.now() current_minute = (now.minute // 15) * 15 timestamp = now.replace(minute=current_minute, second=0, microsecond=0) @@ -274,6 +286,7 @@ async def build_async_extra_state_attributes( # noqa: PLR0913 translation_key: str | None, hass: HomeAssistant, *, + time: TimeService, config_entry: TibberPricesConfigEntry, sensor_attrs: dict | None = None, is_on: bool | None = None, @@ -287,22 +300,23 @@ async def build_async_extra_state_attributes( # noqa: PLR0913 entity_key: Entity key (e.g., "best_price_period") translation_key: Translation key for entity hass: Home Assistant instance + time: TimeService instance (required) config_entry: Config entry with options (keyword-only) sensor_attrs: Sensor-specific attributes (keyword-only) is_on: Binary sensor state (keyword-only) Returns: - Complete attributes dict with descriptions + Complete attributes dict with descriptions (synchronous) """ # Calculate default timestamp: current time rounded to nearest quarter hour # This ensures all binary sensors have a consistent reference time for when calculations were made # Individual sensors can override this via sensor_attrs if needed - now = dt_util.now() - default_timestamp = round_to_nearest_quarter_hour(now) + now = time.now() + default_timestamp = time.round_to_nearest_quarter(now) attributes = { - "timestamp": default_timestamp.isoformat(), + "timestamp": default_timestamp, } # Add sensor-specific attributes (may override timestamp) @@ -335,6 +349,7 @@ def build_sync_extra_state_attributes( # noqa: PLR0913 translation_key: str | None, hass: HomeAssistant, *, + time: TimeService, config_entry: TibberPricesConfigEntry, sensor_attrs: dict | None = None, is_on: bool | None = None, @@ -348,6 +363,7 @@ def build_sync_extra_state_attributes( # noqa: PLR0913 entity_key: Entity key (e.g., "best_price_period") translation_key: Translation key for entity hass: Home Assistant instance + time: TimeService instance (required) config_entry: Config entry with options (keyword-only) sensor_attrs: Sensor-specific attributes (keyword-only) is_on: Binary sensor state (keyword-only) @@ -359,11 +375,11 @@ def build_sync_extra_state_attributes( # noqa: PLR0913 # Calculate default timestamp: current time rounded to nearest quarter hour # This ensures all binary sensors have a consistent reference time for when calculations were made # Individual sensors can override this via sensor_attrs if needed - now = dt_util.now() - default_timestamp = round_to_nearest_quarter_hour(now) + now = time.now() + default_timestamp = time.round_to_nearest_quarter(now) attributes = { - "timestamp": default_timestamp.isoformat(), + "timestamp": default_timestamp, } # Add sensor-specific attributes (may override timestamp) diff --git a/custom_components/tibber_prices/binary_sensor/core.py b/custom_components/tibber_prices/binary_sensor/core.py index 6044577..b7312bd 100644 --- a/custom_components/tibber_prices/binary_sensor/core.py +++ b/custom_components/tibber_prices/binary_sensor/core.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import timedelta from typing import TYPE_CHECKING from custom_components.tibber_prices.coordinator import TIME_SENSITIVE_ENTITY_KEYS @@ -13,7 +12,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import callback -from homeassistant.util import dt as dt_util from .attributes import ( build_async_extra_state_attributes, @@ -21,10 +19,7 @@ from .attributes import ( get_price_intervals_attributes, get_tomorrow_data_available_attributes, ) -from .definitions import ( - MIN_TOMORROW_INTERVALS_15MIN, - PERIOD_LOOKAHEAD_HOURS, -) +from .definitions import PERIOD_LOOKAHEAD_HOURS if TYPE_CHECKING: from collections.abc import Callable @@ -32,6 +27,7 @@ if TYPE_CHECKING: from custom_components.tibber_prices.coordinator import ( TibberPricesDataUpdateCoordinator, ) + from custom_components.tibber_prices.coordinator.time_service import TimeService class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): @@ -69,8 +65,17 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): self._time_sensitive_remove_listener = None @callback - def _handle_time_sensitive_update(self) -> None: - """Handle time-sensitive update from coordinator.""" + def _handle_time_sensitive_update(self, time_service: TimeService) -> None: + """ + Handle time-sensitive update from coordinator. + + Args: + time_service: TimeService instance with reference time for this update cycle + + """ + # Store TimeService from Timer #2 for calculations during this update cycle + self.coordinator.time = time_service + self.async_write_ha_state() def _get_value_getter(self) -> Callable | None: @@ -92,29 +97,29 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): """Return True if the current time is within a best price period.""" if not self.coordinator.data: return None - attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=False) + attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=False, time=self.coordinator.time) if not attrs: return False # Should not happen, but safety fallback start = attrs.get("start") end = attrs.get("end") if not start or not end: return False # No period found = sensor is off - now = dt_util.now() - return start <= now < end + time = self.coordinator.time + return time.is_time_in_period(start, end) def _peak_price_state(self) -> bool | None: """Return True if the current time is within a peak price period.""" if not self.coordinator.data: return None - attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=True) + attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=True, time=self.coordinator.time) if not attrs: return False # Should not happen, but safety fallback start = attrs.get("start") end = attrs.get("end") if not start or not end: return False # No period found = sensor is off - now = dt_util.now() - return start <= now < end + time = self.coordinator.time + return time.is_time_in_period(start, end) def _tomorrow_data_available_state(self) -> bool | None: """Return True if tomorrow's data is fully available, False if not, None if unknown.""" @@ -123,7 +128,12 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): price_info = self.coordinator.data.get("priceInfo", {}) tomorrow_prices = price_info.get("tomorrow", []) interval_count = len(tomorrow_prices) - if interval_count == MIN_TOMORROW_INTERVALS_15MIN: + + # Get expected intervals for tomorrow (handles DST) + tomorrow_date = self.coordinator.time.get_local_date(offset_days=1) + expected_intervals = self.coordinator.time.get_expected_intervals_for_day(tomorrow_date) + + if interval_count == expected_intervals: return True if interval_count == 0: return False @@ -175,7 +185,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): def _get_tomorrow_data_available_attributes(self) -> dict | None: """Return attributes for tomorrow_data_available binary sensor.""" - return get_tomorrow_data_available_attributes(self.coordinator.data) + return get_tomorrow_data_available_attributes(self.coordinator.data, time=self.coordinator.time) def _get_sensor_attributes(self) -> dict | None: """ @@ -187,9 +197,9 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): key = self.entity_description.key if key == "peak_price_period": - return get_price_intervals_attributes(self.coordinator.data, reverse_sort=True) + return get_price_intervals_attributes(self.coordinator.data, reverse_sort=True, time=self.coordinator.time) if key == "best_price_period": - return get_price_intervals_attributes(self.coordinator.data, reverse_sort=False) + return get_price_intervals_attributes(self.coordinator.data, reverse_sort=False, time=self.coordinator.time) if key == "tomorrow_data_available": return self._get_tomorrow_data_available_attributes() @@ -249,22 +259,19 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): if not attrs or "periods" not in attrs: return False - now = dt_util.now() - horizon = now + timedelta(hours=PERIOD_LOOKAHEAD_HOURS) + time = self.coordinator.time periods = attrs.get("periods", []) # Check if any period starts within the look-ahead window for period in periods: start_str = period.get("start") if start_str: - # Parse datetime if it's a string, otherwise use as-is - start_time = dt_util.parse_datetime(start_str) if isinstance(start_str, str) else start_str + # Already datetime object (periods come from coordinator.data) + start_time = start_str if not isinstance(start_str, str) else time.parse_datetime(start_str) - if start_time: - start_time_local = dt_util.as_local(start_time) - # Period starts in the future but within our horizon - if now < start_time_local <= horizon: - return True + # Period starts in the future but within our horizon + if start_time and time.is_time_within_horizon(start_time, hours=PERIOD_LOOKAHEAD_HOURS): + return True return False @@ -286,6 +293,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): config_entry=self.coordinator.config_entry, sensor_attrs=sensor_attrs, is_on=self.is_on, + time=self.coordinator.time, ) except (KeyError, ValueError, TypeError) as ex: @@ -316,6 +324,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): config_entry=self.coordinator.config_entry, sensor_attrs=sensor_attrs, is_on=self.is_on, + time=self.coordinator.time, ) except (KeyError, ValueError, TypeError) as ex: diff --git a/custom_components/tibber_prices/binary_sensor/definitions.py b/custom_components/tibber_prices/binary_sensor/definitions.py index 248aecc..450aec8 100644 --- a/custom_components/tibber_prices/binary_sensor/definitions.py +++ b/custom_components/tibber_prices/binary_sensor/definitions.py @@ -8,9 +8,6 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory -# Constants -MIN_TOMORROW_INTERVALS_15MIN = 96 - # Look-ahead window for future period detection (hours) # Icons will show "waiting" state if a period starts within this window PERIOD_LOOKAHEAD_HOURS = 6 diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index 9ea8120..67cb55d 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -18,9 +18,6 @@ from homeassistant.core import HomeAssistant DOMAIN = "tibber_prices" -# Time constants -MINUTES_PER_INTERVAL = 15 # Tibber uses 15-minute intervals for price data - # Configuration keys CONF_EXTENDED_DESCRIPTIONS = "extended_descriptions" CONF_BEST_PRICE_FLEX = "best_price_flex" diff --git a/custom_components/tibber_prices/coordinator/__init__.py b/custom_components/tibber_prices/coordinator/__init__.py index 50fa061..9b1ee9f 100644 --- a/custom_components/tibber_prices/coordinator/__init__.py +++ b/custom_components/tibber_prices/coordinator/__init__.py @@ -22,10 +22,12 @@ from .constants import ( TIME_SENSITIVE_ENTITY_KEYS, ) from .core import TibberPricesDataUpdateCoordinator +from .time_service import TimeService __all__ = [ "MINUTE_UPDATE_ENTITY_KEYS", "STORAGE_VERSION", "TIME_SENSITIVE_ENTITY_KEYS", "TibberPricesDataUpdateCoordinator", + "TimeService", ] diff --git a/custom_components/tibber_prices/coordinator/cache.py b/custom_components/tibber_prices/coordinator/cache.py index 239680c..c74065a 100644 --- a/custom_components/tibber_prices/coordinator/cache.py +++ b/custom_components/tibber_prices/coordinator/cache.py @@ -5,13 +5,13 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any, NamedTuple -from homeassistant.util import dt as dt_util - if TYPE_CHECKING: from datetime import datetime from homeassistant.helpers.storage import Store + from .time_service import TimeService + _LOGGER = logging.getLogger(__name__) @@ -28,6 +28,8 @@ class CacheData(NamedTuple): async def load_cache( store: Store, log_prefix: str, + *, + time: TimeService, ) -> CacheData: """Load cached data from storage.""" try: @@ -42,11 +44,11 @@ async def load_cache( last_midnight_check = None if last_price_update_str := stored.get("last_price_update"): - last_price_update = dt_util.parse_datetime(last_price_update_str) + last_price_update = time.parse_datetime(last_price_update_str) if last_user_update_str := stored.get("last_user_update"): - last_user_update = dt_util.parse_datetime(last_user_update_str) + last_user_update = time.parse_datetime(last_user_update_str) if last_midnight_check_str := stored.get("last_midnight_check"): - last_midnight_check = dt_util.parse_datetime(last_midnight_check_str) + last_midnight_check = time.parse_datetime(last_midnight_check_str) _LOGGER.debug("%s Cache loaded successfully", log_prefix) return CacheData( @@ -94,6 +96,8 @@ async def store_cache( def is_cache_valid( cache_data: CacheData, log_prefix: str, + *, + time: TimeService, ) -> bool: """ Validate if cached price data is still current. @@ -107,8 +111,8 @@ def is_cache_valid( if cache_data.price_data is None or cache_data.last_price_update is None: return False - current_local_date = dt_util.as_local(dt_util.now()).date() - last_update_local_date = dt_util.as_local(cache_data.last_price_update).date() + current_local_date = time.as_local(time.now()).date() + last_update_local_date = time.as_local(cache_data.last_price_update).date() if current_local_date != last_update_local_date: _LOGGER.debug( diff --git a/custom_components/tibber_prices/coordinator/core.py b/custom_components/tibber_prices/coordinator/core.py index ab259cb..f965f63 100644 --- a/custom_components/tibber_prices/coordinator/core.py +++ b/custom_components/tibber_prices/coordinator/core.py @@ -11,7 +11,6 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util if TYPE_CHECKING: from datetime import date, datetime @@ -39,6 +38,7 @@ from .data_fetching import DataFetcher from .data_transformation import DataTransformer from .listeners import ListenerManager from .periods import PeriodCalculator +from .time_service import TimeService _LOGGER = logging.getLogger(__name__) @@ -134,6 +134,12 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Track if this is the main entry (first one created) self._is_main_entry = not self._has_existing_main_coordinator() + # Initialize time service (single source of truth for datetime operations) + self.time = TimeService() + + # Set time on API client (needed for rate limiting) + self.api.time = self.time + # Initialize helper modules self._listener_manager = ListenerManager(hass, self._log_prefix) self._data_fetcher = DataFetcher( @@ -141,11 +147,13 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): store=self._store, log_prefix=self._log_prefix, user_update_interval=timedelta(days=1), + time=self.time, ) self._data_transformer = DataTransformer( config_entry=config_entry, log_prefix=self._log_prefix, perform_turnover_fn=self._perform_midnight_turnover, + time=self.time, ) self._period_calculator = PeriodCalculator( config_entry=config_entry, @@ -197,9 +205,15 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): return self._listener_manager.async_add_time_sensitive_listener(update_callback) @callback - def _async_update_time_sensitive_listeners(self) -> None: - """Update all time-sensitive entities without triggering a full coordinator update.""" - self._listener_manager.async_update_time_sensitive_listeners() + def _async_update_time_sensitive_listeners(self, time_service: TimeService) -> None: + """ + Update all time-sensitive entities without triggering a full coordinator update. + + Args: + time_service: TimeService instance with reference time for this update cycle + + """ + self._listener_manager.async_update_time_sensitive_listeners(time_service) @callback def async_add_minute_update_listener(self, update_callback: CALLBACK_TYPE) -> CALLBACK_TYPE: @@ -216,9 +230,15 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): return self._listener_manager.async_add_minute_update_listener(update_callback) @callback - def _async_update_minute_listeners(self) -> None: - """Update all minute-update entities without triggering a full coordinator update.""" - self._listener_manager.async_update_minute_listeners() + def _async_update_minute_listeners(self, time_service: TimeService) -> None: + """ + Update all minute-update entities without triggering a full coordinator update. + + Args: + time_service: TimeService instance with reference time for this update cycle + + """ + self._listener_manager.async_update_minute_listeners(time_service) @callback def _handle_quarter_hour_refresh(self, _now: datetime | None = None) -> None: @@ -235,7 +255,23 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): This is triggered at exact quarter-hour boundaries (:00, :15, :30, :45). Does NOT fetch new data - only updates entity states based on existing cached data. """ - now = dt_util.now() + # Create LOCAL TimeService with fresh reference time for this refresh + # Each timer has its own TimeService instance - no shared state between timers + # This timer updates 30+ time-sensitive entities at quarter-hour boundaries + # (Timer #3 handles timing entities separately - no overlap) + time_service = TimeService() + now = time_service.now() + + # Update shared coordinator time (used by Timer #1 and other operations) + # This is safe because we're in a @callback (synchronous event loop) + self.time = time_service + + # Update helper modules with fresh TimeService instance + self.api.time = time_service + self._data_fetcher.time = time_service + self._data_transformer.time = time_service + self._period_calculator.time = time_service + self._log("debug", "[Timer #2] Quarter-hour refresh triggered at %s", now.isoformat()) # Check if midnight has passed since last check @@ -251,28 +287,38 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): else: # Regular quarter-hour refresh - only update time-sensitive entities # (Midnight turnover was either not needed, or already done by Timer #1) - self._async_update_time_sensitive_listeners() + # Pass local time_service to entities (not self.time which could be overwritten) + self._async_update_time_sensitive_listeners(time_service) @callback def _handle_minute_refresh(self, _now: datetime | None = None) -> None: """ - Handle minute-by-minute entity refresh for timing sensors (Timer #3). + Handle 30-second entity refresh for timing sensors (Timer #3). This is a SYNCHRONOUS callback (decorated with @callback) - it runs in the event loop without async/await overhead because it performs only fast, non-blocking operations: - Listener notifications for timing sensors (remaining_minutes, progress) NO I/O operations (no API calls, no file operations), so no need for async def. - Runs every minute, so performance is critical - sync callbacks are faster. + Runs every 30 seconds to keep sensor values in sync with HA frontend display. - This runs every minute to update countdown/progress sensors. + This runs every 30 seconds to update countdown/progress sensors. + Timing calculations use rounded minutes matching HA's relative time display. Does NOT fetch new data - only updates entity states based on existing cached data. """ - # Only log at debug level to avoid log spam (this runs every minute) - self._log("debug", "[Timer #3] Minute refresh for timing sensors") + # Create LOCAL TimeService with fresh reference time for this 30-second refresh + # Each timer has its own TimeService instance - no shared state between timers + # Timer #2 updates 30+ time-sensitive entities (prices, levels, timestamps) + # Timer #3 updates 6 timing entities (remaining_minutes, progress, next_in_minutes) + # NO overlap - entities are registered with either Timer #2 OR Timer #3, never both + time_service = TimeService() + + # Only log at debug level to avoid log spam (this runs every 30 seconds) + self._log("debug", "[Timer #3] 30-second refresh for timing sensors") # Update only minute-update entities (remaining_minutes, progress, etc.) - self._async_update_minute_listeners() + # Pass local time_service to entities (not self.time which could be overwritten) + self._async_update_minute_listeners(time_service) def _check_midnight_turnover_needed(self, now: datetime) -> bool: """ @@ -400,12 +446,20 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """ self._log("debug", "[Timer #1] DataUpdateCoordinator check triggered") + # Create TimeService with fresh reference time for this update cycle + self.time = TimeService() + current_time = self.time.now() + + # Update helper modules with fresh TimeService instance + self.api.time = self.time + self._data_fetcher.time = self.time + self._data_transformer.time = self.time + self._period_calculator.time = self.time + # Load cache if not already loaded if self._cached_price_data is None and self._cached_user_data is None: await self._load_cache() - current_time = dt_util.utcnow() - # Initialize midnight check on first run if self._last_midnight_check is None: self._last_midnight_check = current_time @@ -514,7 +568,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): Updated price_info with rotated day data """ - return helpers.perform_midnight_turnover(price_info) + return helpers.perform_midnight_turnover(price_info, time=self.time) async def _store_cache(self) -> None: """Store cache data.""" @@ -593,13 +647,13 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): return True # Check for midnight turnover - now_local = dt_util.as_local(current_time) + now_local = self.time.as_local(current_time) current_date = now_local.date() if self._last_midnight_check is None: return True - last_check_local = dt_util.as_local(self._last_midnight_check) + last_check_local = self.time.as_local(self._last_midnight_check) last_check_date = last_check_local.date() if current_date != last_check_date: @@ -610,7 +664,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): def _transform_data_for_main_entry(self, raw_data: dict[str, Any]) -> dict[str, Any]: """Transform raw data for main entry (aggregated view of all homes).""" - current_time = dt_util.now() + current_time = self.time.now() # Return cached transformed data if no retransformation needed if not self._should_retransform_data(current_time) and self._cached_transformed_data is not None: @@ -635,7 +689,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): def _transform_data_for_subentry(self, main_data: dict[str, Any]) -> dict[str, Any]: """Transform main coordinator data for subentry (home-specific view).""" - current_time = dt_util.now() + current_time = self.time.now() # Return cached transformed data if no retransformation needed if not self._should_retransform_data(current_time) and self._cached_transformed_data is not None: @@ -681,8 +735,8 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): if not price_info: return None - now = dt_util.now() - return find_price_data_for_interval(price_info, now) + now = self.time.now() + return find_price_data_for_interval(price_info, now, time=self.time) def get_all_intervals(self) -> list[dict[str, Any]]: """Get all price intervals (today + tomorrow).""" @@ -697,7 +751,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def refresh_user_data(self) -> bool: """Force refresh of user data and return True if data was updated.""" try: - current_time = dt_util.utcnow() + current_time = self.time.now() self._log("info", "Forcing user data refresh (bypassing cache)") # Force update by calling API directly (bypass cache check) diff --git a/custom_components/tibber_prices/coordinator/data_fetching.py b/custom_components/tibber_prices/coordinator/data_fetching.py index 1e3431c..6d6b2f3 100644 --- a/custom_components/tibber_prices/coordinator/data_fetching.py +++ b/custom_components/tibber_prices/coordinator/data_fetching.py @@ -5,9 +5,11 @@ from __future__ import annotations import asyncio import logging import secrets -from datetime import timedelta from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + from datetime import timedelta + from custom_components.tibber_prices.api import ( TibberPricesApiClientAuthenticationError, TibberPricesApiClientCommunicationError, @@ -16,7 +18,6 @@ from custom_components.tibber_prices.api import ( from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import UpdateFailed -from homeassistant.util import dt as dt_util from . import cache, helpers from .constants import TOMORROW_DATA_CHECK_HOUR, TOMORROW_DATA_RANDOM_DELAY_MAX @@ -27,6 +28,8 @@ if TYPE_CHECKING: from custom_components.tibber_prices.api import TibberPricesApiClient + from .time_service import TimeService + _LOGGER = logging.getLogger(__name__) @@ -39,12 +42,14 @@ class DataFetcher: store: Any, log_prefix: str, user_update_interval: timedelta, + time: TimeService, ) -> None: """Initialize the data fetcher.""" self.api = api self._store = store self._log_prefix = log_prefix self._user_update_interval = user_update_interval + self.time = time # Cached data self._cached_price_data: dict[str, Any] | None = None @@ -59,15 +64,19 @@ class DataFetcher: async def load_cache(self) -> None: """Load cached data from storage.""" - cache_data = await cache.load_cache(self._store, self._log_prefix) + cache_data = await cache.load_cache(self._store, self._log_prefix, time=self.time) self._cached_price_data = cache_data.price_data self._cached_user_data = cache_data.user_data self._last_price_update = cache_data.last_price_update self._last_user_update = cache_data.last_user_update + # Parse timestamps if we loaded price data from cache + if self._cached_price_data: + self._cached_price_data = helpers.parse_all_timestamps(self._cached_price_data, time=self.time) + # Validate cache: check if price data is from a previous day - if not cache.is_cache_valid(cache_data, self._log_prefix): + if not cache.is_cache_valid(cache_data, self._log_prefix, time=self.time): self._log("info", "Cached price data is from a previous day, clearing cache to fetch fresh data") self._cached_price_data = None self._last_price_update = None @@ -128,8 +137,10 @@ class DataFetcher: self._log("debug", "API update needed: No last price update timestamp") return True - now_local = dt_util.as_local(current_time) - tomorrow_date = (now_local + timedelta(days=1)).date() + # Get tomorrow's date using TimeService + _, tomorrow_midnight = self.time.get_day_boundaries("today") + tomorrow_date = tomorrow_midnight.date() + now_local = self.time.as_local(current_time) # Check if after 13:00 and tomorrow data is missing or invalid if ( @@ -154,12 +165,12 @@ class DataFetcher: """Check if tomorrow data is missing or invalid.""" return helpers.needs_tomorrow_data(self._cached_price_data, tomorrow_date) - async def fetch_all_homes_data(self, configured_home_ids: set[str]) -> dict[str, Any]: + async def fetch_all_homes_data(self, configured_home_ids: set[str], current_time: datetime) -> dict[str, Any]: """Fetch data for all homes (main coordinator only).""" if not configured_home_ids: self._log("warning", "No configured homes found - cannot fetch price data") return { - "timestamp": dt_util.utcnow(), + "timestamp": current_time, "homes": {}, } @@ -186,7 +197,7 @@ class DataFetcher: ) return { - "timestamp": dt_util.utcnow(), + "timestamp": current_time, "homes": all_homes_data, } @@ -216,8 +227,10 @@ class DataFetcher: await asyncio.sleep(delay) self._log("debug", "Fetching fresh price data from API") - raw_data = await self.fetch_all_homes_data(configured_home_ids) - # Cache the data + raw_data = await self.fetch_all_homes_data(configured_home_ids, current_time) + # Parse timestamps immediately after API fetch + raw_data = helpers.parse_all_timestamps(raw_data, time=self.time) + # Cache the data (now with datetime objects) self._cached_price_data = raw_data self._last_price_update = current_time await self.store_cache() @@ -268,7 +281,7 @@ class DataFetcher: Updated price_info with rotated day data """ - return helpers.perform_midnight_turnover(price_info) + return helpers.perform_midnight_turnover(price_info, time=self.time) @property def cached_price_data(self) -> dict[str, Any] | None: diff --git a/custom_components/tibber_prices/coordinator/data_transformation.py b/custom_components/tibber_prices/coordinator/data_transformation.py index 4daaa94..debd514 100644 --- a/custom_components/tibber_prices/coordinator/data_transformation.py +++ b/custom_components/tibber_prices/coordinator/data_transformation.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any from custom_components.tibber_prices import const as _const from custom_components.tibber_prices.utils.price import enrich_price_info_with_differences -from homeassistant.util import dt as dt_util if TYPE_CHECKING: from collections.abc import Callable @@ -15,6 +14,8 @@ if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry + from .time_service import TimeService + _LOGGER = logging.getLogger(__name__) @@ -26,11 +27,13 @@ class DataTransformer: config_entry: ConfigEntry, log_prefix: str, perform_turnover_fn: Callable[[dict[str, Any]], dict[str, Any]], + time: TimeService, ) -> None: """Initialize the data transformer.""" self.config_entry = config_entry self._log_prefix = log_prefix self._perform_turnover_fn = perform_turnover_fn + self.time = time # Transformation cache self._cached_transformed_data: dict[str, Any] | None = None @@ -122,13 +125,13 @@ class DataTransformer: return True # Check for midnight turnover - now_local = dt_util.as_local(current_time) + now_local = self.time.as_local(current_time) current_date = now_local.date() if self._last_midnight_check is None: return True - last_check_local = dt_util.as_local(self._last_midnight_check) + last_check_local = self.time.as_local(self._last_midnight_check) last_check_date = last_check_local.date() if current_date != last_check_date: @@ -139,7 +142,7 @@ class DataTransformer: def transform_data_for_main_entry(self, raw_data: dict[str, Any]) -> dict[str, Any]: """Transform raw data for main entry (aggregated view of all homes).""" - current_time = dt_util.now() + current_time = self.time.now() # Return cached transformed data if no retransformation needed if not self._should_retransform_data(current_time) and self._cached_transformed_data is not None: @@ -198,7 +201,7 @@ class DataTransformer: def transform_data_for_subentry(self, main_data: dict[str, Any], home_id: str) -> dict[str, Any]: """Transform main coordinator data for subentry (home-specific view).""" - current_time = dt_util.now() + current_time = self.time.now() # Return cached transformed data if no retransformation needed if not self._should_retransform_data(current_time) and self._cached_transformed_data is not None: diff --git a/custom_components/tibber_prices/coordinator/helpers.py b/custom_components/tibber_prices/coordinator/helpers.py index 8b8a03f..424f022 100644 --- a/custom_components/tibber_prices/coordinator/helpers.py +++ b/custom_components/tibber_prices/coordinator/helpers.py @@ -2,17 +2,20 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING, Any -from homeassistant.util import dt as dt_util - if TYPE_CHECKING: from datetime import date from homeassistant.core import HomeAssistant + from .time_service import TimeService + from custom_components.tibber_prices.const import DOMAIN +_LOGGER = logging.getLogger(__name__) + def get_configured_home_ids(hass: HomeAssistant) -> set[str]: """Get all home_ids that have active config entries (main + subentries).""" @@ -26,11 +29,16 @@ def get_configured_home_ids(hass: HomeAssistant) -> set[str]: return home_ids -def needs_tomorrow_data(cached_price_data: dict[str, Any] | None, tomorrow_date: date) -> bool: +def needs_tomorrow_data( + cached_price_data: dict[str, Any] | None, + tomorrow_date: date, +) -> bool: """Check if tomorrow data is missing or invalid.""" if not cached_price_data or "homes" not in cached_price_data: return False + # Use provided TimeService or create new one + for home_data in cached_price_data["homes"].values(): price_info = home_data.get("price_info", {}) tomorrow_prices = price_info.get("tomorrow", []) @@ -41,17 +49,15 @@ def needs_tomorrow_data(cached_price_data: dict[str, Any] | None, tomorrow_date: # Check if tomorrow data is actually for tomorrow (validate date) first_price = tomorrow_prices[0] - if starts_at := first_price.get("startsAt"): - price_time = dt_util.parse_datetime(starts_at) - if price_time: - price_date = dt_util.as_local(price_time).date() - if price_date != tomorrow_date: - return True + if starts_at := first_price.get("startsAt"): # Already datetime in local timezone + price_date = starts_at.date() + if price_date != tomorrow_date: + return True return False -def perform_midnight_turnover(price_info: dict[str, Any]) -> dict[str, Any]: +def perform_midnight_turnover(price_info: dict[str, Any], *, time: TimeService) -> dict[str, Any]: """ Perform midnight turnover on price data. @@ -63,12 +69,15 @@ def perform_midnight_turnover(price_info: dict[str, Any]) -> dict[str, Any]: Args: price_info: The price info dict with 'today', 'tomorrow', 'yesterday' keys + time: TimeService instance (required) Returns: Updated price_info with rotated day data """ - current_local_date = dt_util.as_local(dt_util.now()).date() + # Use provided TimeService or create new one + + current_local_date = time.now().date() # Extract current data today_prices = price_info.get("today", []) @@ -77,12 +86,10 @@ def perform_midnight_turnover(price_info: dict[str, Any]) -> dict[str, Any]: # Check if any of today's prices are from the previous day prices_need_rotation = False if today_prices: - first_today_price_str = today_prices[0].get("startsAt") - if first_today_price_str: - first_today_price_time = dt_util.parse_datetime(first_today_price_str) - if first_today_price_time: - first_today_price_date = dt_util.as_local(first_today_price_time).date() - prices_need_rotation = first_today_price_date < current_local_date + first_today_price = today_prices[0].get("startsAt") # Already datetime in local timezone + if first_today_price: + first_today_price_date = first_today_price.date() + prices_need_rotation = first_today_price_date < current_local_date if prices_need_rotation: return { @@ -92,4 +99,43 @@ def perform_midnight_turnover(price_info: dict[str, Any]) -> dict[str, Any]: "currency": price_info.get("currency", "EUR"), } + # No rotation needed, return original + return price_info + + +def parse_all_timestamps(price_data: dict[str, Any], *, time: TimeService) -> dict[str, Any]: + """ + Parse all API timestamp strings to datetime objects. + + This is the SINGLE place where we convert API strings to datetime objects. + After this, all code works with datetime objects, not strings. + + Performance: ~200 timestamps parsed ONCE instead of multiple times per update cycle. + + Args: + price_data: Raw API data with string timestamps + time: TimeService for parsing + + Returns: + Same structure but with datetime objects instead of strings + + """ + if not price_data or "homes" not in price_data: + return price_data + + # Process each home + for home_data in price_data["homes"].values(): + price_info = home_data.get("price_info", {}) + + # Process each day's intervals + for day_key in ["yesterday", "today", "tomorrow"]: + intervals = price_info.get(day_key, []) + for interval in intervals: + if (starts_at_str := interval.get("startsAt")) and isinstance(starts_at_str, str): + # Parse once, convert to local timezone, store as datetime object + interval["startsAt"] = time.parse_and_localize(starts_at_str) + # If already datetime (e.g., from cache), skip parsing + + return price_data + return price_info diff --git a/custom_components/tibber_prices/coordinator/listeners.py b/custom_components/tibber_prices/coordinator/listeners.py index 974564d..b354d67 100644 --- a/custom_components/tibber_prices/coordinator/listeners.py +++ b/custom_components/tibber_prices/coordinator/listeners.py @@ -15,6 +15,8 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant + from .time_service import TimeService + _LOGGER = logging.getLogger(__name__) @@ -64,10 +66,16 @@ class ListenerManager: return remove_listener @callback - def async_update_time_sensitive_listeners(self) -> None: - """Update all time-sensitive entities without triggering a full coordinator update.""" + def async_update_time_sensitive_listeners(self, time_service: TimeService) -> None: + """ + Update all time-sensitive entities without triggering a full coordinator update. + + Args: + time_service: TimeService instance with reference time for this update cycle + + """ for update_callback in self._time_sensitive_listeners: - update_callback() + update_callback(time_service) self._log( "debug", @@ -97,14 +105,20 @@ class ListenerManager: return remove_listener @callback - def async_update_minute_listeners(self) -> None: - """Update all minute-update entities without triggering a full coordinator update.""" + def async_update_minute_listeners(self, time_service: TimeService) -> None: + """ + Update all minute-update entities without triggering a full coordinator update. + + Args: + time_service: TimeService instance with reference time for this update cycle + + """ for update_callback in self._minute_update_listeners: - update_callback() + update_callback(time_service) self._log( "debug", - "Updated %d minute-update entities", + "Updated %d timing entities (30-second update)", len(self._minute_update_listeners), ) @@ -139,25 +153,24 @@ class ListenerManager: self, handler_callback: CALLBACK_TYPE, ) -> None: - """Schedule minute-by-minute entity refresh for timing sensors.""" + """Schedule 30-second entity refresh for timing sensors.""" # Cancel any existing timer if self._minute_timer_cancel: self._minute_timer_cancel() self._minute_timer_cancel = None - # Use Home Assistant's async_track_utc_time_change to trigger every minute - # HA may schedule us a few milliseconds before/after the exact minute boundary. - # Our timing calculations are based on dt_util.now() which gives the actual current time, - # so small scheduling variations don't affect accuracy. + # Trigger every 30 seconds (:00 and :30) to keep sensor values in sync with + # Home Assistant's frontend relative time display ("in X minutes"). + # The timing calculator uses rounded minute values that match HA's rounding behavior. self._minute_timer_cancel = async_track_utc_time_change( self.hass, handler_callback, - second=0, # Trigger at :XX:00 (HA handles scheduling tolerance) + second=[0, 30], # Trigger at :XX:00 and :XX:30 ) self._log( "debug", - "Scheduled minute-by-minute refresh for timing sensors (second=0)", + "Scheduled 30-second refresh for timing sensors (second=[0, 30])", ) def check_midnight_crossed(self, now: datetime) -> bool: diff --git a/custom_components/tibber_prices/coordinator/period_handlers/__init__.py b/custom_components/tibber_prices/coordinator/period_handlers/__init__.py index deca6c3..2d3c1ed 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/__init__.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/__init__.py @@ -33,7 +33,6 @@ from .types import ( INDENT_L3, INDENT_L4, INDENT_L5, - MINUTES_PER_INTERVAL, IntervalCriteria, PeriodConfig, PeriodData, @@ -48,7 +47,6 @@ __all__ = [ "INDENT_L3", "INDENT_L4", "INDENT_L5", - "MINUTES_PER_INTERVAL", "IntervalCriteria", "PeriodConfig", "PeriodData", diff --git a/custom_components/tibber_prices/coordinator/period_handlers/core.py b/custom_components/tibber_prices/coordinator/period_handlers/core.py index f5c0d7d..057b05b 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/core.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/core.py @@ -5,6 +5,8 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from custom_components.tibber_prices.coordinator.time_service import TimeService + from .types import PeriodConfig from .outlier_filtering import ( @@ -31,6 +33,7 @@ def calculate_periods( all_prices: list[dict], *, config: PeriodConfig, + time: TimeService, ) -> dict[str, Any]: """ Calculate price periods (best or peak) from price data. @@ -50,6 +53,7 @@ def calculate_periods( all_prices: All price data points from yesterday/today/tomorrow config: Period configuration containing reverse_sort, flex, min_distance_from_avg, min_period_length, threshold_low, and threshold_high + time: TimeService instance (required) Returns: Dict with: @@ -88,7 +92,7 @@ def calculate_periods( all_prices_sorted = sorted(all_prices, key=lambda p: p["startsAt"]) # Step 1: Split by day and calculate averages - intervals_by_day, avg_price_by_day = split_intervals_by_day(all_prices_sorted) + intervals_by_day, avg_price_by_day = split_intervals_by_day(all_prices_sorted, time=time) # Step 2: Calculate reference prices (min or max per day) ref_prices = calculate_reference_prices(intervals_by_day, reverse_sort=reverse_sort) @@ -115,19 +119,20 @@ def calculate_periods( reverse_sort=reverse_sort, level_filter=config.level_filter, gap_count=config.gap_count, + time=time, ) # Step 4: Filter by minimum length - raw_periods = filter_periods_by_min_length(raw_periods, min_period_length) + raw_periods = filter_periods_by_min_length(raw_periods, min_period_length, time=time) # Step 5: Merge adjacent periods at midnight - raw_periods = merge_adjacent_periods_at_midnight(raw_periods) + raw_periods = merge_adjacent_periods_at_midnight(raw_periods, time=time) # Step 6: Add interval ends - add_interval_ends(raw_periods) + add_interval_ends(raw_periods, time=time) # Step 7: Filter periods by end date (keep periods ending today or later) - raw_periods = filter_periods_by_end_date(raw_periods) + raw_periods = filter_periods_by_end_date(raw_periods, time=time) # Step 8: Extract lightweight period summaries (no full price data) # Note: Filtering for current/future is done here based on end date, @@ -145,6 +150,7 @@ def calculate_periods( all_prices_sorted, price_context, thresholds, + time=time, ) return { diff --git a/custom_components/tibber_prices/coordinator/period_handlers/period_building.py b/custom_components/tibber_prices/coordinator/period_handlers/period_building.py index 4fa76a1..f360f45 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/period_building.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/period_building.py @@ -3,20 +3,20 @@ from __future__ import annotations import logging -from datetime import date, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any from custom_components.tibber_prices.const import PRICE_LEVEL_MAPPING -from homeassistant.util import dt as dt_util + +if TYPE_CHECKING: + from datetime import date + + from custom_components.tibber_prices.coordinator.time_service import TimeService from .level_filtering import ( apply_level_filter, check_interval_criteria, ) -from .types import ( - MINUTES_PER_INTERVAL, - IntervalCriteria, -) +from .types import IntervalCriteria _LOGGER = logging.getLogger(__name__) @@ -24,16 +24,17 @@ _LOGGER = logging.getLogger(__name__) INDENT_L0 = "" # Entry point / main function -def split_intervals_by_day(all_prices: list[dict]) -> tuple[dict[date, list[dict]], dict[date, float]]: +def split_intervals_by_day( + all_prices: list[dict], *, time: TimeService +) -> tuple[dict[date, list[dict]], dict[date, float]]: """Split intervals by day and calculate average price per day.""" intervals_by_day: dict[date, list[dict]] = {} avg_price_by_day: dict[date, float] = {} for price_data in all_prices: - dt = dt_util.parse_datetime(price_data["startsAt"]) + dt = time.get_interval_time(price_data) if dt is None: continue - dt = dt_util.as_local(dt) date_key = dt.date() intervals_by_day.setdefault(date_key, []).append(price_data) @@ -52,13 +53,14 @@ def calculate_reference_prices(intervals_by_day: dict[date, list[dict]], *, reve return ref_prices -def build_periods( # noqa: PLR0915 - Complex period building logic requires many statements +def build_periods( # noqa: PLR0913, PLR0915 - Complex period building logic requires many arguments and statements all_prices: list[dict], price_context: dict[str, Any], *, reverse_sort: bool, level_filter: str | None = None, gap_count: int = 0, + time: TimeService, ) -> list[list[dict]]: """ Build periods, allowing periods to cross midnight (day boundary). @@ -73,6 +75,7 @@ def build_periods( # noqa: PLR0915 - Complex period building logic requires man reverse_sort: True for peak price (high prices), False for best price (low prices) level_filter: Level filter string ("cheap", "expensive", "any", None) gap_count: Number of allowed consecutive intervals deviating by exactly 1 level step + time: TimeService instance (required) """ ref_prices = price_context["ref_prices"] @@ -108,10 +111,9 @@ def build_periods( # noqa: PLR0915 - Complex period building logic requires man intervals_filtered_by_level = 0 for price_data in all_prices: - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) date_key = starts_at.date() # Use smoothed price for criteria checks (flex/distance) @@ -194,22 +196,25 @@ def build_periods( # noqa: PLR0915 - Complex period building logic requires man return periods -def filter_periods_by_min_length(periods: list[list[dict]], min_period_length: int) -> list[list[dict]]: +def filter_periods_by_min_length( + periods: list[list[dict]], min_period_length: int, *, time: TimeService +) -> list[list[dict]]: """Filter periods to only include those meeting the minimum length requirement.""" - min_intervals = min_period_length // MINUTES_PER_INTERVAL + min_intervals = time.minutes_to_intervals(min_period_length) return [period for period in periods if len(period) >= min_intervals] -def add_interval_ends(periods: list[list[dict]]) -> None: +def add_interval_ends(periods: list[list[dict]], *, time: TimeService) -> None: """Add interval_end to each interval in-place.""" + interval_duration = time.get_interval_duration() for period in periods: for interval in period: start = interval.get("interval_start") if start: - interval["interval_end"] = start + timedelta(minutes=MINUTES_PER_INTERVAL) + interval["interval_end"] = start + interval_duration -def filter_periods_by_end_date(periods: list[list[dict]]) -> list[list[dict]]: +def filter_periods_by_end_date(periods: list[list[dict]], *, time: TimeService) -> list[list[dict]]: """ Filter periods to keep only relevant ones for today and tomorrow. @@ -221,9 +226,9 @@ def filter_periods_by_end_date(periods: list[list[dict]]) -> list[list[dict]]: - Periods that ended yesterday - Periods that ended exactly at midnight today (they're completely in the past) """ - now = dt_util.now() + now = time.now() today = now.date() - midnight_today = dt_util.start_of_local_day(now) + midnight_today = time.start_of_local_day(now) filtered = [] for period in periods: @@ -238,7 +243,7 @@ def filter_periods_by_end_date(periods: list[list[dict]]) -> list[list[dict]]: continue # Keep if period ends in the future - if period_end > now: + if time.is_in_future(period_end): filtered.append(period) continue diff --git a/custom_components/tibber_prices/coordinator/period_handlers/period_merging.py b/custom_components/tibber_prices/coordinator/period_handlers/period_merging.py index 9ba1069..c09214f 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/period_merging.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/period_merging.py @@ -3,11 +3,12 @@ from __future__ import annotations import logging -from datetime import datetime, timedelta +from typing import TYPE_CHECKING -from homeassistant.util import dt as dt_util +if TYPE_CHECKING: + from datetime import datetime -from .types import MINUTES_PER_INTERVAL + from custom_components.tibber_prices.coordinator.time_service import TimeService _LOGGER = logging.getLogger(__name__) @@ -17,7 +18,7 @@ INDENT_L1 = " " # Nested logic / loop iterations INDENT_L2 = " " # Deeper nesting -def merge_adjacent_periods_at_midnight(periods: list[list[dict]]) -> list[list[dict]]: +def merge_adjacent_periods_at_midnight(periods: list[list[dict]], *, time: TimeService) -> list[list[dict]]: """ Merge adjacent periods that meet at midnight. @@ -46,8 +47,8 @@ def merge_adjacent_periods_at_midnight(periods: list[list[dict]]) -> list[list[d last_date = last_start.date() next_date = next_start.date() - # If they are 15 minutes apart and on different days (crossing midnight) - if time_diff == timedelta(minutes=MINUTES_PER_INTERVAL) and next_date > last_date: + # If they are one interval apart and on different days (crossing midnight) + if time_diff == time.get_interval_duration() and next_date > last_date: # Merge the two periods merged_period = current_period + next_period merged.append(merged_period) @@ -61,7 +62,7 @@ def merge_adjacent_periods_at_midnight(periods: list[list[dict]]) -> list[list[d return merged -def recalculate_period_metadata(periods: list[dict]) -> None: +def recalculate_period_metadata(periods: list[dict], *, time: TimeService) -> None: """ Recalculate period metadata after merging periods. @@ -73,13 +74,14 @@ def recalculate_period_metadata(periods: list[dict]) -> None: Args: periods: List of period summary dicts (mutated in-place) + time: TimeService instance (required) """ if not periods: return # Sort periods chronologically by start time - periods.sort(key=lambda p: p.get("start") or dt_util.now()) + periods.sort(key=lambda p: p.get("start") or time.now()) # Update metadata for all periods total_periods = len(periods) diff --git a/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py b/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py index e9ea078..d8f24e6 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py @@ -7,20 +7,18 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from datetime import datetime + from custom_components.tibber_prices.coordinator.time_service import TimeService + from .types import ( PeriodData, PeriodStatistics, ThresholdConfig, ) - from custom_components.tibber_prices.utils.price import ( aggregate_period_levels, aggregate_period_ratings, calculate_volatility_level, ) -from homeassistant.util import dt as dt_util - -from .types import MINUTES_PER_INTERVAL def calculate_period_price_diff( @@ -139,7 +137,7 @@ def build_period_summary_dict( # 1. Time information (when does this apply?) "start": period_data.start_time, "end": period_data.end_time, - "duration_minutes": period_data.period_length * MINUTES_PER_INTERVAL, + "duration_minutes": period_data.period_length * 15, # period_length is in intervals # 2. Core decision attributes (what should I do?) "level": stats.aggregated_level, "rating_level": stats.aggregated_rating, @@ -179,6 +177,8 @@ def extract_period_summaries( all_prices: list[dict], price_context: dict[str, Any], thresholds: ThresholdConfig, + *, + time: TimeService, ) -> list[dict]: """ Extract complete period summaries with all aggregated attributes. @@ -199,6 +199,7 @@ def extract_period_summaries( all_prices: All price data from the API (enriched with level, difference, rating_level) price_context: Dictionary with ref_prices and avg_prices per day thresholds: Threshold configuration for calculations + time: TimeService instance (required) """ from .types import ( # noqa: PLC0415 - Avoid circular import @@ -209,9 +210,8 @@ def extract_period_summaries( # Build lookup dictionary for full price data by timestamp price_lookup: dict[str, dict] = {} for price_data in all_prices: - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at: - starts_at = dt_util.as_local(starts_at) price_lookup[starts_at.isoformat()] = price_data summaries = [] diff --git a/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py b/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py index 97c5be1..e4f45cb 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py @@ -9,9 +9,9 @@ if TYPE_CHECKING: from collections.abc import Callable from datetime import date - from .types import PeriodConfig + from custom_components.tibber_prices.coordinator.time_service import TimeService -from homeassistant.util import dt as dt_util + from .types import PeriodConfig from .period_merging import ( recalculate_period_metadata, @@ -54,24 +54,25 @@ def group_periods_by_day(periods: list[dict]) -> dict[date, list[dict]]: return periods_by_day -def group_prices_by_day(all_prices: list[dict]) -> dict[date, list[dict]]: +def group_prices_by_day(all_prices: list[dict], *, time: TimeService) -> dict[date, list[dict]]: """ Group price intervals by the day they belong to (today and future only). Args: all_prices: List of price dicts with "startsAt" timestamp + time: TimeService instance (required) Returns: Dict mapping date to list of price intervals for that day (only today and future) """ - today = dt_util.now().date() + today = time.now().date() prices_by_day: dict[date, list[dict]] = {} for price in all_prices: - starts_at = dt_util.parse_datetime(price["startsAt"]) + starts_at = price["startsAt"] # Already datetime in local timezone if starts_at: - price_date = dt_util.as_local(starts_at).date() + price_date = starts_at.date() # Only include today and future days if price_date >= today: prices_by_day.setdefault(price_date, []).append(price) @@ -79,7 +80,9 @@ def group_prices_by_day(all_prices: list[dict]) -> dict[date, list[dict]]: return prices_by_day -def check_min_periods_per_day(periods: list[dict], min_periods: int, all_prices: list[dict]) -> bool: +def check_min_periods_per_day( + periods: list[dict], min_periods: int, all_prices: list[dict], *, time: TimeService +) -> bool: """ Check if minimum periods requirement is met for each day individually. @@ -90,6 +93,7 @@ def check_min_periods_per_day(periods: list[dict], min_periods: int, all_prices: periods: List of period summary dicts min_periods: Minimum number of periods required per day all_prices: All available price intervals (used to determine which days have data) + time: TimeService instance (required) Returns: True if every day with price data has at least min_periods, False otherwise @@ -99,12 +103,12 @@ def check_min_periods_per_day(periods: list[dict], min_periods: int, all_prices: return False # No periods at all, continue relaxation # Get all days that have price data (today and future only, not yesterday) - today = dt_util.now().date() + today = time.now().date() available_days = set() for price in all_prices: - starts_at = dt_util.parse_datetime(price["startsAt"]) + starts_at = time.get_interval_time(price) if starts_at: - price_date = dt_util.as_local(starts_at).date() + price_date = starts_at.date() # Only count today and future days (not yesterday) if price_date >= today: available_days.add(price_date) @@ -169,6 +173,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax relaxation_step_pct: int, max_relaxation_attempts: int, should_show_callback: Callable[[str | None], bool], + time: TimeService, ) -> tuple[dict[str, Any], dict[str, Any]]: """ Calculate periods with optional per-day filter relaxation. @@ -194,6 +199,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax should_show_callback: Callback function(level_override) -> bool Returns True if periods should be shown with given filter overrides. Pass None to use original configured filter values. + time: TimeService instance (required) Returns: Tuple of (periods_result, relaxation_metadata): @@ -265,7 +271,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax ) # Group prices by day (for both relaxation enabled/disabled) - prices_by_day = group_prices_by_day(all_prices) + prices_by_day = group_prices_by_day(all_prices, time=time) if not prices_by_day: # No price data for today/future @@ -300,7 +306,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax ) # Calculate baseline periods for this day - day_result = calculate_periods(day_prices, config=config) + day_result = calculate_periods(day_prices, config=config, time=time) day_periods = day_result["periods"] standalone_count = len([p for p in day_periods if not p.get("is_extension")]) @@ -343,6 +349,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax should_show_callback=should_show_callback, baseline_periods=day_periods, day_label=str(day), + time=time, ) all_periods.extend(day_relaxed_result["periods"]) @@ -358,7 +365,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax all_periods.sort(key=lambda p: p["start"]) # Recalculate metadata for combined periods - recalculate_period_metadata(all_periods) + recalculate_period_metadata(all_periods, time=time) # Build combined result if all_periods: @@ -391,6 +398,8 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day should_show_callback: Callable[[str | None], bool], baseline_periods: list[dict], day_label: str, + *, + time: TimeService, ) -> tuple[dict[str, Any], dict[str, Any]]: """ Run comprehensive relaxation for a single day. @@ -415,6 +424,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day Returns True if periods should be shown with given overrides. baseline_periods: Periods found with normal filters day_label: Label for logging (e.g., "2025-11-11") + time: TimeService instance (required) Returns: Tuple of (periods_result, metadata) for this day @@ -475,7 +485,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day flex=new_flex, level_filter=level_filter_value, ) - relaxed_result = calculate_periods(day_prices, config=relaxed_config) + relaxed_result = calculate_periods(day_prices, config=relaxed_config, time=time) new_periods = relaxed_result["periods"] # Build relaxation level label BEFORE marking periods @@ -522,7 +532,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day baseline_standalone, standalone_count, ) - recalculate_period_metadata(merged) + recalculate_period_metadata(merged, time=time) result = relaxed_result.copy() result["periods"] = merged return result, {"phases_used": phases_used} @@ -541,7 +551,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day new_standalone, ) - recalculate_period_metadata(accumulated_periods) + recalculate_period_metadata(accumulated_periods, time=time) if relaxed_result: result = relaxed_result.copy() diff --git a/custom_components/tibber_prices/coordinator/period_handlers/types.py b/custom_components/tibber_prices/coordinator/period_handlers/types.py index 05992a0..40a02fb 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/types.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/types.py @@ -13,7 +13,6 @@ from custom_components.tibber_prices.const import ( DEFAULT_VOLATILITY_THRESHOLD_HIGH, DEFAULT_VOLATILITY_THRESHOLD_MODERATE, DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, - MINUTES_PER_INTERVAL, # noqa: F401 - Re-exported for period handler modules ) # Log indentation levels for visual hierarchy diff --git a/custom_components/tibber_prices/coordinator/periods.py b/custom_components/tibber_prices/coordinator/periods.py index a40291c..0ec8b58 100644 --- a/custom_components/tibber_prices/coordinator/periods.py +++ b/custom_components/tibber_prices/coordinator/periods.py @@ -12,6 +12,9 @@ from typing import TYPE_CHECKING, Any from custom_components.tibber_prices import const as _const +if TYPE_CHECKING: + from custom_components.tibber_prices.coordinator.time_service import TimeService + from .period_handlers import ( PeriodConfig, calculate_periods_with_relaxation, @@ -34,6 +37,7 @@ class PeriodCalculator: """Initialize the period calculator.""" self.config_entry = config_entry self._log_prefix = log_prefix + self.time: TimeService # Set by coordinator before first use self._config_cache: dict[str, dict[str, Any]] | None = None self._config_cache_valid = False @@ -336,7 +340,7 @@ class PeriodCalculator: _const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH, ) - min_period_intervals = min_period_minutes // 15 + min_period_intervals = self.time.minutes_to_intervals(min_period_minutes) sub_sequences = self.split_at_gap_clusters( today_intervals, @@ -627,6 +631,7 @@ class PeriodCalculator: reverse_sort=False, level_override=lvl, ), + time=self.time, ) else: best_periods = { @@ -699,6 +704,7 @@ class PeriodCalculator: reverse_sort=True, level_override=lvl, ), + time=self.time, ) else: peak_periods = { diff --git a/custom_components/tibber_prices/coordinator/time_service.py b/custom_components/tibber_prices/coordinator/time_service.py new file mode 100644 index 0000000..a27fccf --- /dev/null +++ b/custom_components/tibber_prices/coordinator/time_service.py @@ -0,0 +1,788 @@ +""" +TimeService - Centralized time management for Tibber Prices integration. + +This service provides: +1. Single source of truth for current time +2. Timezone-aware operations (respects HA user timezone) +3. Domain-specific datetime methods (intervals, boundaries, horizons) +4. Time-travel capability (inject simulated time for testing) + +All datetime operations MUST go through TimeService to ensure: +- Consistent time across update cycles +- Proper timezone handling (local time, not UTC) +- Testability (mock time in one place) +- Future time-travel feature support +""" + +from __future__ import annotations + +import math +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from homeassistant.util import dt as dt_util + +if TYPE_CHECKING: + from datetime import date + +# ============================================================================= +# CRITICAL: This is the ONLY module allowed to import dt_util for operations! +# ============================================================================= +# +# Other modules may import dt_util ONLY in these cases: +# 1. api/client.py - Rate limiting (non-critical, cosmetic) +# 2. entity_utils/icons.py - Icon updates (cosmetic, independent) +# +# All business logic MUST use TimeService instead. +# ============================================================================= + +# Constants (private - use TimeService methods instead) +_DEFAULT_INTERVAL_MINUTES = 15 # Tibber uses 15-minute intervals +_INTERVALS_PER_HOUR = 60 // _DEFAULT_INTERVAL_MINUTES # 4 +_INTERVALS_PER_DAY = 24 * _INTERVALS_PER_HOUR # 96 + +# Rounding tolerance for boundary detection (±2 seconds) +_BOUNDARY_TOLERANCE_SECONDS = 2 + + +class TimeService: + """ + Centralized time service for Tibber Prices integration. + + Provides timezone-aware datetime operations with consistent time context. + All times are in user's Home Assistant local timezone. + + Features: + - Single source of truth for "now" per update cycle + - Domain-specific methods (intervals, periods, boundaries) + - Time-travel support (inject simulated time) + - Timezone-safe (all operations respect HA user timezone) + + Usage: + # Create service with current time + time_service = TimeService() + + # Get consistent "now" throughout update cycle + now = time_service.now() + + # Domain-specific operations + current_interval_start = time_service.get_current_interval_start() + next_interval = time_service.get_interval_offset_time(1) + midnight = time_service.get_local_midnight() + """ + + def __init__(self, reference_time: datetime | None = None) -> None: + """ + Initialize TimeService with reference time. + + Args: + reference_time: Optional fixed time for this context. + If None, uses actual current time. + For time-travel: pass simulated time here. + + """ + self._reference_time = reference_time or dt_util.now() + + # ========================================================================= + # Low-Level API: Direct dt_util wrappers + # ========================================================================= + + def now(self) -> datetime: + """ + Get current reference time in user's local timezone. + + Returns same value throughout the lifetime of this TimeService instance. + This ensures consistent time across all calculations in an update cycle. + + Returns: + Timezone-aware datetime in user's HA local timezone. + + """ + return self._reference_time + + def get_rounded_now(self) -> datetime: + """ + Get current reference time rounded to nearest 15-minute boundary. + + Convenience method that combines now() + round_to_nearest_quarter(). + Use this when you need the current interval timestamp for calculations. + + Returns: + Current reference time rounded to :00, :15, :30, or :45 + + Examples: + If now is 14:59:58 → returns 15:00:00 + If now is 14:59:30 → returns 14:45:00 + If now is 15:00:01 → returns 15:00:00 + + """ + return self.round_to_nearest_quarter() + + def as_local(self, dt: datetime) -> datetime: + """ + Convert datetime to user's local timezone. + + Args: + dt: Timezone-aware datetime (any timezone). + + Returns: + Same moment in time, converted to user's local timezone. + + """ + return dt_util.as_local(dt) + + def parse_datetime(self, dt_str: str) -> datetime | None: + """ + Parse ISO 8601 datetime string. + + Args: + dt_str: ISO 8601 formatted string (e.g., "2025-11-19T13:00:00+00:00"). + + Returns: + Timezone-aware datetime, or None if parsing fails. + + """ + return dt_util.parse_datetime(dt_str) + + def parse_and_localize(self, dt_str: str) -> datetime | None: + """ + Parse ISO string and convert to user's local timezone. + + Combines parse_datetime() + as_local() in one call. + Use this for API timestamps that need immediate localization. + + Args: + dt_str: ISO 8601 formatted string (e.g., "2025-11-19T13:00:00+00:00"). + + Returns: + Timezone-aware datetime in user's local timezone, or None if parsing fails. + + """ + parsed = self.parse_datetime(dt_str) + return self.as_local(parsed) if parsed else None + + def start_of_local_day(self, dt: datetime | None = None) -> datetime: + """ + Get midnight (00:00) of the given datetime in user's local timezone. + + Args: + dt: Reference datetime. If None, uses reference_time. + + Returns: + Midnight (start of day) in user's local timezone. + + """ + target = dt if dt is not None else self._reference_time + return dt_util.start_of_local_day(target) + + # ========================================================================= + # High-Level API: Domain-Specific Methods + # ========================================================================= + + # ------------------------------------------------------------------------- + # Interval Data Extraction + # ------------------------------------------------------------------------- + + def get_interval_time(self, interval: dict) -> datetime | None: + """ + Extract and parse interval timestamp from API data. + + Handles common pattern: parse "startsAt" + convert to local timezone. + Replaces repeated parse_datetime() + as_local() pattern. + + Args: + interval: Price interval dict with "startsAt" field (ISO string or datetime object) + + Returns: + Localized datetime or None if parsing/conversion fails + + """ + starts_at = interval.get("startsAt") + if not starts_at: + return None + + # If already a datetime object (parsed from cache), return as-is + if isinstance(starts_at, datetime): + return starts_at + + # Otherwise parse the string + return self.parse_and_localize(starts_at) + + # ------------------------------------------------------------------------- + # Time Comparison Helpers + # ------------------------------------------------------------------------- + + def is_in_past(self, dt: datetime) -> bool: + """ + Check if datetime is before reference time (now). + + Args: + dt: Datetime to check + + Returns: + True if dt < now() + + """ + return dt < self.now() + + def is_in_future(self, dt: datetime) -> bool: + """ + Check if datetime is after or equal to reference time (now). + + Args: + dt: Datetime to check + + Returns: + True if dt >= now() + + """ + return dt >= self.now() + + def is_current_interval(self, start: datetime, end: datetime) -> bool: + """ + Check if reference time (now) falls within interval [start, end). + + Args: + start: Interval start time (inclusive) + end: Interval end time (exclusive) + + Returns: + True if start <= now() < end + + """ + now = self.now() + return start <= now < end + + def is_in_day(self, dt: datetime, day: str) -> bool: + """ + Check if datetime falls within specified calendar day. + + Args: + dt: Datetime to check (should be localized) + day: "yesterday", "today", or "tomorrow" + + Returns: + True if dt is within day boundaries + + """ + start, end = self.get_day_boundaries(day) + return start <= dt < end + + # ------------------------------------------------------------------------- + # Duration Calculations + # ------------------------------------------------------------------------- + + def get_hours_until(self, future_time: datetime) -> float: + """ + Calculate hours from reference time (now) until future_time. + + Args: + future_time: Future datetime + + Returns: + Hours (can be negative if in past, decimal for partial hours) + + """ + delta = future_time - self.now() + return delta.total_seconds() / 3600 + + def get_local_date(self, offset_days: int = 0) -> date: + """ + Get date for day at offset from reference date. + + Convenience method to replace repeated time.now().date() or + time.get_local_midnight(n).date() patterns. + + Args: + offset_days: Days to offset. + 0 = today, 1 = tomorrow, -1 = yesterday, etc. + + Returns: + Date object in user's local timezone. + + Examples: + get_local_date() → today's date + get_local_date(1) → tomorrow's date + get_local_date(-1) → yesterday's date + + """ + target_datetime = self._reference_time + timedelta(days=offset_days) + return target_datetime.date() + + def is_time_in_period(self, start: datetime, end: datetime, check_time: datetime | None = None) -> bool: + """ + Check if time falls within period [start, end). + + Args: + start: Period start time (inclusive) + end: Period end time (exclusive) + check_time: Time to check. If None, uses reference time (now). + + Returns: + True if start <= check_time < end + + Examples: + # Check if now is in period: + is_time_in_period(period_start, period_end) + + # Check if specific time is in period: + is_time_in_period(window_start, window_end, some_timestamp) + + """ + t = check_time if check_time is not None else self.now() + return start <= t < end + + def is_time_within_horizon(self, target_time: datetime, hours: int) -> bool: + """ + Check if target time is in future within specified hour horizon. + + Combines two common checks: + 1. Is target_time in the future? (target_time > now) + 2. Is target_time within N hours? (target_time <= now + N hours) + + Args: + target_time: Time to check + hours: Lookahead horizon in hours + + Returns: + True if now < target_time <= now + hours + + Examples: + # Check if period starts within next 6 hours: + is_time_within_horizon(period_start, hours=6) + + # Check if event happens within next 24 hours: + is_time_within_horizon(event_time, hours=24) + + """ + now = self.now() + horizon = now + timedelta(hours=hours) + return now < target_time <= horizon + + def hours_since(self, past_time: datetime) -> float: + """ + Calculate hours from past_time until reference time (now). + + Args: + past_time: Past datetime + + Returns: + Hours (can be negative if in future, decimal for partial hours) + + """ + delta = self.now() - past_time + return delta.total_seconds() / 3600 + + def minutes_until(self, future_time: datetime) -> float: + """ + Calculate minutes from reference time (now) until future_time. + + Args: + future_time: Future datetime + + Returns: + Minutes (can be negative if in past, decimal for partial minutes) + + """ + delta = future_time - self.now() + return delta.total_seconds() / 60 + + def minutes_until_rounded(self, future_time: datetime | str) -> int: + """ + Calculate ROUNDED minutes from reference time (now) until future_time. + + Uses standard rounding (0.5 rounds up) to match Home Assistant frontend + relative time display. This ensures sensor values match what users see + in the UI ("in X minutes"). + + Args: + future_time: Future datetime or ISO string to parse + + Returns: + Rounded minutes (negative if in past) + + Examples: + 44.2 minutes → 44 + 44.5 minutes → 45 (rounds up, like HA frontend) + 44.7 minutes → 45 + + """ + # Parse string if needed + if isinstance(future_time, str): + parsed = self.parse_and_localize(future_time) + if not parsed: + return 0 + future_time = parsed + + delta = future_time - self.now() + seconds = delta.total_seconds() + + # Standard rounding: 0.5 rounds up (matches HA frontend behavior) + # Using math.floor + 0.5 instead of Python's round() which uses banker's rounding + return math.floor(seconds / 60 + 0.5) + + # ------------------------------------------------------------------------- + # Interval Operations (15-minute grid) + # ------------------------------------------------------------------------- + + def get_interval_duration(self) -> timedelta: + """ + Get duration of one interval. + + Returns: + Timedelta representing interval length (15 minutes for Tibber). + + """ + return timedelta(minutes=_DEFAULT_INTERVAL_MINUTES) + + def minutes_to_intervals(self, minutes: int) -> int: + """ + Convert minutes to number of intervals. + + Args: + minutes: Number of minutes to convert. + + Returns: + Number of intervals (rounded down). + + Examples: + 15 minutes → 1 interval + 30 minutes → 2 intervals + 45 minutes → 3 intervals + 60 minutes → 4 intervals + + """ + return minutes // _DEFAULT_INTERVAL_MINUTES + + def round_to_nearest_quarter(self, dt: datetime | None = None) -> datetime: + """ + Round datetime to nearest 15-minute boundary with smart tolerance. + + Handles HA scheduling jitter: if within ±2 seconds of boundary, + round to that boundary. Otherwise, floor to current interval. + + Args: + dt: Datetime to round. If None, uses reference_time. + + Returns: + Datetime rounded to nearest quarter-hour boundary. + + Examples: + 14:59:58 → 15:00:00 (within 2s of boundary) + 14:59:30 → 14:45:00 (not within 2s, stay in current) + 15:00:01 → 15:00:00 (within 2s of boundary) + + """ + target = dt if dt is not None else self._reference_time + + # Calculate total seconds in day + total_seconds = target.hour * 3600 + target.minute * 60 + target.second + target.microsecond / 1_000_000 + + # Find current interval boundaries + interval_index = int(total_seconds // (_DEFAULT_INTERVAL_MINUTES * 60)) + interval_start_seconds = interval_index * _DEFAULT_INTERVAL_MINUTES * 60 + + next_interval_index = (interval_index + 1) % _INTERVALS_PER_DAY + next_interval_start_seconds = next_interval_index * _DEFAULT_INTERVAL_MINUTES * 60 + + # Distance to boundaries + distance_to_current = total_seconds - interval_start_seconds + if next_interval_index == 0: # Midnight wrap + distance_to_next = (24 * 3600) - total_seconds + else: + distance_to_next = next_interval_start_seconds - total_seconds + + # Apply tolerance: if within 2 seconds of a boundary, round to it + if distance_to_current <= _BOUNDARY_TOLERANCE_SECONDS: + # Near current interval start → use it + rounded_seconds = interval_start_seconds + elif distance_to_next <= _BOUNDARY_TOLERANCE_SECONDS: + # Near next interval start → use it + rounded_seconds = next_interval_start_seconds + else: + # Not near any boundary → floor to current interval + rounded_seconds = interval_start_seconds + + # Handle midnight wrap + if rounded_seconds >= 24 * 3600: + rounded_seconds = 0 + + # Build rounded datetime + hours = int(rounded_seconds // 3600) + minutes = int((rounded_seconds % 3600) // 60) + + return target.replace(hour=hours, minute=minutes, second=0, microsecond=0) + + def get_current_interval_start(self) -> datetime: + """ + Get start time of current 15-minute interval. + + Returns: + Datetime at start of current interval (rounded down). + + Example: + Reference time 14:37:23 → returns 14:30:00 + + """ + return self.round_to_nearest_quarter(self._reference_time) + + def get_next_interval_start(self) -> datetime: + """ + Get start time of next 15-minute interval. + + Returns: + Datetime at start of next interval. + + Example: + Reference time 14:37:23 → returns 14:45:00 + + """ + return self.get_interval_offset_time(1) + + def get_interval_offset_time(self, offset: int = 0) -> datetime: + """ + Get start time of interval at offset from current. + + Args: + offset: Number of intervals to offset. + 0 = current, 1 = next, -1 = previous, etc. + + Returns: + Datetime at start of target interval. + + Examples: + offset=0 → current interval (14:30:00) + offset=1 → next interval (14:45:00) + offset=-1 → previous interval (14:15:00) + + """ + current_start = self.get_current_interval_start() + delta = timedelta(minutes=_DEFAULT_INTERVAL_MINUTES * offset) + return current_start + delta + + # ------------------------------------------------------------------------- + # Day Boundaries (midnight-to-midnight windows) + # ------------------------------------------------------------------------- + + def get_local_midnight(self, offset_days: int = 0) -> datetime: + """ + Get midnight (00:00) for day at offset from reference date. + + Args: + offset_days: Days to offset. + 0 = today, 1 = tomorrow, -1 = yesterday, etc. + + Returns: + Midnight (start of day) in user's local timezone. + + Examples: + offset_days=0 → today 00:00 + offset_days=1 → tomorrow 00:00 + offset_days=-1 → yesterday 00:00 + + """ + target_date = self._reference_time.date() + timedelta(days=offset_days) + target_datetime = datetime.combine(target_date, datetime.min.time()) + return dt_util.as_local(target_datetime) + + def get_day_boundaries(self, day: str = "today") -> tuple[datetime, datetime]: + """ + Get start and end times for a day (midnight to midnight). + + Args: + day: Day identifier ("day_before_yesterday", "yesterday", "today", "tomorrow"). + + Returns: + Tuple of (start_time, end_time) for the day. + start_time: midnight (00:00:00) of that day + end_time: midnight (00:00:00) of next day (exclusive boundary) + + Examples: + day="today" → (today 00:00, tomorrow 00:00) + day="yesterday" → (yesterday 00:00, today 00:00) + + """ + day_map = { + "day_before_yesterday": -2, + "yesterday": -1, + "today": 0, + "tomorrow": 1, + } + + if day not in day_map: + msg = f"Invalid day: {day}. Must be one of {list(day_map.keys())}" + raise ValueError(msg) + + offset = day_map[day] + start = self.get_local_midnight(offset) + end = self.get_local_midnight(offset + 1) # Next day's midnight + + return start, end + + def get_expected_intervals_for_day(self, day_date: date | None = None) -> int: + """ + Calculate expected number of 15-minute intervals for a day. + + Handles DST transitions: + - Normal day: 96 intervals (24 hours * 4) + - Spring forward (lose 1 hour): 92 intervals (23 hours * 4) + - Fall back (gain 1 hour): 100 intervals (25 hours * 4) + + Args: + day_date: Date to check. If None, uses reference date. + + Returns: + Expected number of 15-minute intervals for that day. + + """ + target_date = day_date if day_date is not None else self._reference_time.date() + + # Get midnight of target day and next day in local timezone + # + # IMPORTANT: We cannot use dt_util.start_of_local_day() here due to TWO issues: + # + # Issue 1 - pytz LMT Bug: + # dt_util.start_of_local_day() uses: datetime.combine(date, time(), tzinfo=tz) + # With pytz, this triggers the "Local Mean Time" bug - using historical timezone + # offsets from before standard timezones were established (e.g., +00:53 for Berlin + # instead of +01:00/+02:00). Both timestamps get the same wrong offset, making + # duration calculations incorrect for DST transitions. + # + # Issue 2 - Python datetime Subtraction Ignores Timezone Offsets: + # Even with correct offsets (e.g., via zoneinfo): + # start = 2025-03-30 00:00+01:00 (= 2025-03-29 23:00 UTC) + # end = 2025-03-31 00:00+02:00 (= 2025-03-30 22:00 UTC) + # end - start = 1 day = 24 hours (WRONG!) + # + # Python's datetime subtraction uses naive date/time difference, ignoring that + # timezone offsets changed between the two timestamps. The real UTC duration is + # 23 hours (Spring Forward) or 25 hours (Fall Back). + # + # Solution: + # 1. Use timezone.localize() (pytz) or replace(tzinfo=tz) (zoneinfo) to get + # correct timezone-aware datetimes with proper offsets + # 2. Convert to UTC before calculating duration to account for offset changes + # + # This ensures DST transitions are correctly handled: + # - Spring Forward: 23 hours (92 intervals) + # - Fall Back: 25 hours (100 intervals) + # - Normal day: 24 hours (96 intervals) + # + tz = self._reference_time.tzinfo # Get timezone from reference time + + # Create naive datetimes for midnight of target and next day + start_naive = datetime.combine(target_date, datetime.min.time()) + next_day = target_date + timedelta(days=1) + end_naive = datetime.combine(next_day, datetime.min.time()) + + # Localize to get correct DST offset for each date + if hasattr(tz, "localize"): + # pytz timezone - use localize() to handle DST correctly + start_midnight_local = tz.localize(start_naive) + end_midnight_local = tz.localize(end_naive) + else: + # zoneinfo or other timezone - can use replace directly + start_midnight_local = start_naive.replace(tzinfo=tz) + end_midnight_local = end_naive.replace(tzinfo=tz) + + # Calculate actual duration via UTC to handle timezone offset changes correctly + # Direct subtraction (end - start) would ignore DST offset changes and always + # return 24 hours, even on Spring Forward (23h) or Fall Back (25h) days + start_utc = start_midnight_local.astimezone(dt_util.UTC) + end_utc = end_midnight_local.astimezone(dt_util.UTC) + duration = end_utc - start_utc + hours = duration.total_seconds() / 3600 + + # Convert to intervals (4 per hour for 15-minute intervals) + return int(hours * _INTERVALS_PER_HOUR) + + # ------------------------------------------------------------------------- + # Time Windows (relative to current interval) + # ------------------------------------------------------------------------- + + def get_trailing_window(self, hours: int = 24) -> tuple[datetime, datetime]: + """ + Get trailing time window ending at current interval. + + Args: + hours: Window size in hours (default 24). + + Returns: + Tuple of (start_time, end_time) for trailing window. + start_time: current interval - hours + end_time: current interval start (exclusive) + + Example: + Current interval: 14:30 + hours=24 → (yesterday 14:30, today 14:30) + + """ + end = self.get_current_interval_start() + start = end - timedelta(hours=hours) + return start, end + + def get_leading_window(self, hours: int = 24) -> tuple[datetime, datetime]: + """ + Get leading time window starting at current interval. + + Args: + hours: Window size in hours (default 24). + + Returns: + Tuple of (start_time, end_time) for leading window. + start_time: current interval start + end_time: current interval + hours (exclusive) + + Example: + Current interval: 14:30 + hours=24 → (today 14:30, tomorrow 14:30) + + """ + start = self.get_current_interval_start() + end = start + timedelta(hours=hours) + return start, end + + def get_next_n_hours_window(self, hours: int) -> tuple[datetime, datetime]: + """ + Get window for next N hours starting from NEXT interval. + + Args: + hours: Window size in hours. + + Returns: + Tuple of (start_time, end_time). + start_time: next interval start + end_time: next interval start + hours (exclusive) + + Example: + Current interval: 14:30 + hours=3 → (14:45, 17:45) + + """ + start = self.get_interval_offset_time(1) # Next interval + end = start + timedelta(hours=hours) + return start, end + + # ------------------------------------------------------------------------- + # Time-Travel Support + # ------------------------------------------------------------------------- + + def with_reference_time(self, new_time: datetime) -> TimeService: + """ + Create new TimeService with different reference time. + + Used for time-travel testing: inject simulated "now". + + Args: + new_time: New reference time. + + Returns: + New TimeService instance with updated reference time. + + Example: + # Simulate being at 14:30 on 2025-11-19 + simulated_time = datetime(2025, 11, 19, 14, 30) + future_service = time_service.with_reference_time(simulated_time) + + """ + return TimeService(reference_time=new_time) diff --git a/custom_components/tibber_prices/entity_utils/helpers.py b/custom_components/tibber_prices/entity_utils/helpers.py index 478fc7d..59c047c 100644 --- a/custom_components/tibber_prices/entity_utils/helpers.py +++ b/custom_components/tibber_prices/entity_utils/helpers.py @@ -15,14 +15,11 @@ from __future__ import annotations from typing import TYPE_CHECKING from custom_components.tibber_prices.const import get_price_level_translation -from custom_components.tibber_prices.utils.average import ( - round_to_nearest_quarter_hour, -) -from homeassistant.util import dt as dt_util if TYPE_CHECKING: from datetime import datetime + from custom_components.tibber_prices.coordinator.time_service import TimeService from homeassistant.core import HomeAssistant @@ -93,6 +90,8 @@ def find_rolling_hour_center_index( all_prices: list[dict], current_time: datetime, hour_offset: int, + *, + time: TimeService, ) -> int | None: """ Find the center index for the rolling hour window. @@ -101,6 +100,7 @@ def find_rolling_hour_center_index( all_prices: List of all price interval dictionaries with 'startsAt' key current_time: Current datetime to find the current interval hour_offset: Number of hours to offset from current interval (can be negative) + time: TimeService instance (required) Returns: Index of the center interval for the rolling hour window, or None if not found @@ -108,14 +108,13 @@ def find_rolling_hour_center_index( """ # Round to nearest interval boundary to handle edge cases where HA schedules # us slightly before the boundary (e.g., 14:59:59.999 → 15:00:00) - target_time = round_to_nearest_quarter_hour(current_time) + target_time = time.round_to_nearest_quarter(current_time) current_idx = None for idx, price_data in enumerate(all_prices): - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) # Exact match after rounding if starts_at == target_time: diff --git a/custom_components/tibber_prices/entity_utils/icons.py b/custom_components/tibber_prices/entity_utils/icons.py index 4f0c336..a4bcc67 100644 --- a/custom_components/tibber_prices/entity_utils/icons.py +++ b/custom_components/tibber_prices/entity_utils/icons.py @@ -7,9 +7,11 @@ from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + from custom_components.tibber_prices.coordinator.time_service import TimeService + from custom_components.tibber_prices.const import ( BINARY_SENSOR_ICON_MAPPING, - MINUTES_PER_INTERVAL, PRICE_LEVEL_CASH_ICON_MAPPING, PRICE_LEVEL_ICON_MAPPING, PRICE_RATING_ICON_MAPPING, @@ -18,7 +20,9 @@ from custom_components.tibber_prices.const import ( from custom_components.tibber_prices.entity_utils.helpers import find_rolling_hour_center_index from custom_components.tibber_prices.sensor.helpers import aggregate_level_data from custom_components.tibber_prices.utils.price import find_price_data_for_interval -from homeassistant.util import dt as dt_util + +# Icon update logic uses timedelta directly (cosmetic, independent - allowed per AGENTS.md) +_INTERVAL_MINUTES = 15 # Tibber's 15-minute intervals @dataclass @@ -29,6 +33,7 @@ class IconContext: coordinator_data: dict | None = None has_future_periods_callback: Callable[[], bool] | None = None period_is_active_callback: Callable[[], bool] | None = None + time: TimeService | None = None if TYPE_CHECKING: @@ -70,7 +75,7 @@ def get_dynamic_icon( return ( get_trend_icon(key, value) or get_timing_sensor_icon(key, value, period_is_active_callback=ctx.period_is_active_callback) - or get_price_sensor_icon(key, ctx.coordinator_data) + or get_price_sensor_icon(key, ctx.coordinator_data, time=ctx.time) or get_level_sensor_icon(key, value) or get_rating_sensor_icon(key, value) or get_volatility_sensor_icon(key, value) @@ -164,7 +169,12 @@ def get_timing_sensor_icon( return None -def get_price_sensor_icon(key: str, coordinator_data: dict | None) -> str | None: +def get_price_sensor_icon( + key: str, + coordinator_data: dict | None, + *, + time: TimeService | None, +) -> str | None: """ Get icon for current price sensors (dynamic based on price level). @@ -175,32 +185,34 @@ def get_price_sensor_icon(key: str, coordinator_data: dict | None) -> str | None Args: key: Entity description key coordinator_data: Coordinator data for price level lookups + time: TimeService instance (required for determining current interval) Returns: Icon string or None if not a current price sensor """ - if not coordinator_data: + # Early exit if coordinator_data or time not available + if not coordinator_data or time is None: return None # Only current price sensors get dynamic icons if key == "current_interval_price": - level = get_price_level_for_icon(coordinator_data, interval_offset=0) + level = get_price_level_for_icon(coordinator_data, interval_offset=0, time=time) if level: return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper()) elif key == "next_interval_price": # For next interval, use the next interval price level to determine icon - level = get_price_level_for_icon(coordinator_data, interval_offset=1) + level = get_price_level_for_icon(coordinator_data, interval_offset=1, time=time) if level: return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper()) elif key == "current_hour_average_price": # For current hour average, use the current hour price level to determine icon - level = get_rolling_hour_price_level_for_icon(coordinator_data, hour_offset=0) + level = get_rolling_hour_price_level_for_icon(coordinator_data, hour_offset=0, time=time) if level: return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper()) elif key == "next_hour_average_price": # For next hour average, use the next hour price level to determine icon - level = get_rolling_hour_price_level_for_icon(coordinator_data, hour_offset=1) + level = get_rolling_hour_price_level_for_icon(coordinator_data, hour_offset=1, time=time) if level: return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper()) @@ -288,6 +300,7 @@ def get_price_level_for_icon( coordinator_data: dict, *, interval_offset: int | None = None, + time: TimeService, ) -> str | None: """ Get the price level for icon determination. @@ -297,6 +310,7 @@ def get_price_level_for_icon( Args: coordinator_data: Coordinator data interval_offset: Interval offset (0=current, 1=next, -1=previous) + time: TimeService instance (required) Returns: Price level string or None if not found @@ -306,11 +320,11 @@ def get_price_level_for_icon( return None price_info = coordinator_data.get("priceInfo", {}) - now = dt_util.now() + now = time.now() # Interval-based lookup - target_time = now + timedelta(minutes=MINUTES_PER_INTERVAL * interval_offset) - interval_data = find_price_data_for_interval(price_info, target_time) + target_time = now + timedelta(minutes=_INTERVAL_MINUTES * interval_offset) + interval_data = find_price_data_for_interval(price_info, target_time, time=time) if not interval_data or "level" not in interval_data: return None @@ -322,6 +336,7 @@ def get_rolling_hour_price_level_for_icon( coordinator_data: dict, *, hour_offset: int = 0, + time: TimeService, ) -> str | None: """ Get the aggregated price level for rolling hour icon determination. @@ -334,6 +349,7 @@ def get_rolling_hour_price_level_for_icon( Args: coordinator_data: Coordinator data hour_offset: Hour offset (0=current hour, 1=next hour) + time: TimeService instance (required) Returns: Aggregated price level string or None if not found @@ -349,8 +365,8 @@ def get_rolling_hour_price_level_for_icon( return None # Find center index using the same helper function as the sensor platform - now = dt_util.now() - center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset) + now = time.now() + center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset, time=time) if center_idx is None: return None diff --git a/custom_components/tibber_prices/sensor/attributes/__init__.py b/custom_components/tibber_prices/sensor/attributes/__init__.py index 3a6c9f7..613fae7 100644 --- a/custom_components/tibber_prices/sensor/attributes/__init__.py +++ b/custom_components/tibber_prices/sensor/attributes/__init__.py @@ -14,13 +14,12 @@ from custom_components.tibber_prices.entity_utils import ( add_description_attributes, add_icon_color_attribute, ) -from custom_components.tibber_prices.utils.average import round_to_nearest_quarter_hour -from homeassistant.util import dt as dt_util if TYPE_CHECKING: from custom_components.tibber_prices.coordinator.core import ( TibberPricesDataUpdateCoordinator, ) + from custom_components.tibber_prices.coordinator.time_service import TimeService from custom_components.tibber_prices.data import TibberPricesConfigEntry from homeassistant.core import HomeAssistant @@ -63,6 +62,7 @@ def build_sensor_attributes( Dictionary of attributes or None if no attributes should be added """ + time = coordinator.time if not coordinator.data: return None @@ -95,6 +95,7 @@ def build_sensor_attributes( coordinator=coordinator, native_value=native_value, cached_data=cached_data, + time=time, ) elif key in [ "trailing_price_average", @@ -104,9 +105,9 @@ def build_sensor_attributes( "leading_price_min", "leading_price_max", ]: - add_average_price_attributes(attributes=attributes, key=key, coordinator=coordinator) + add_average_price_attributes(attributes=attributes, key=key, coordinator=coordinator, time=time) elif key.startswith("next_avg_"): - add_next_avg_attributes(attributes=attributes, key=key, coordinator=coordinator) + add_next_avg_attributes(attributes=attributes, key=key, coordinator=coordinator, time=time) elif any( pattern in key for pattern in [ @@ -127,11 +128,12 @@ def build_sensor_attributes( attributes=attributes, key=key, cached_data=cached_data, + time=time, ) elif key == "price_forecast": - add_price_forecast_attributes(attributes=attributes, coordinator=coordinator) + add_price_forecast_attributes(attributes=attributes, coordinator=coordinator, time=time) elif _is_timing_or_volatility_sensor(key): - _add_timing_or_volatility_attributes(attributes, key, cached_data, native_value) + _add_timing_or_volatility_attributes(attributes, key, cached_data, native_value, time=time) # For current_interval_price_level, add the original level as attribute if key == "current_interval_price_level" and cached_data.get("last_price_level") is not None: @@ -169,6 +171,7 @@ def build_extra_state_attributes( # noqa: PLR0913 config_entry: TibberPricesConfigEntry, coordinator_data: dict, sensor_attrs: dict | None = None, + time: TimeService | None = None, ) -> dict[str, Any] | None: """ Build extra state attributes for sensors. @@ -186,6 +189,7 @@ def build_extra_state_attributes( # noqa: PLR0913 config_entry: Config entry with options (keyword-only) coordinator_data: Coordinator data dict (keyword-only) sensor_attrs: Sensor-specific attributes (keyword-only) + time: TimeService instance (optional, creates new if not provided) Returns: Complete attributes dict or None if no data available @@ -197,13 +201,13 @@ def build_extra_state_attributes( # noqa: PLR0913 # Calculate default timestamp: current time rounded to nearest quarter hour # This ensures all sensors have a consistent reference time for when calculations were made # Individual sensors can override this if they need a different timestamp - now = dt_util.now() - default_timestamp = round_to_nearest_quarter_hour(now) + now = time.now() + default_timestamp = time.round_to_nearest_quarter(now) # Special handling for chart_data_export: metadata → descriptions → service data if entity_key == "chart_data_export": attributes: dict[str, Any] = { - "timestamp": default_timestamp.isoformat(), + "timestamp": default_timestamp, } # Step 1: Add metadata (timestamp + error if present) @@ -232,9 +236,9 @@ def build_extra_state_attributes( # noqa: PLR0913 return attributes if attributes else None # For all other sensors: standard behavior - # Start with default timestamp + # Start with default timestamp (datetime object - HA serializes automatically) attributes: dict[str, Any] = { - "timestamp": default_timestamp.isoformat(), + "timestamp": default_timestamp, } # Add sensor-specific attributes (may override timestamp) diff --git a/custom_components/tibber_prices/sensor/attributes/daily_stat.py b/custom_components/tibber_prices/sensor/attributes/daily_stat.py index 8c63d0e..0c63a2e 100644 --- a/custom_components/tibber_prices/sensor/attributes/daily_stat.py +++ b/custom_components/tibber_prices/sensor/attributes/daily_stat.py @@ -2,24 +2,28 @@ from __future__ import annotations -from datetime import timedelta +from typing import TYPE_CHECKING from custom_components.tibber_prices.const import PRICE_RATING_MAPPING from homeassistant.const import PERCENTAGE -from homeassistant.util import dt as dt_util + +if TYPE_CHECKING: + from custom_components.tibber_prices.coordinator.time_service import TimeService -def _get_day_midnight_timestamp(key: str) -> str: +def _get_day_midnight_timestamp(key: str, *, time: TimeService) -> str: """Get midnight timestamp for a given day sensor key.""" - now = dt_util.now() - local_midnight = dt_util.start_of_local_day(now) - + # Determine which day based on sensor key if key.startswith("yesterday") or key == "average_price_yesterday": - local_midnight = local_midnight - timedelta(days=1) + day = "yesterday" elif key.startswith("tomorrow") or key == "average_price_tomorrow": - local_midnight = local_midnight + timedelta(days=1) + day = "tomorrow" + else: + day = "today" - return local_midnight.isoformat() + # Use TimeService to get midnight for that day + local_midnight, _ = time.get_day_boundaries(day) + return local_midnight def _get_day_key_from_sensor_key(key: str) -> str: @@ -60,6 +64,8 @@ def add_statistics_attributes( attributes: dict, key: str, cached_data: dict, + *, + time: TimeService, ) -> None: """ Add attributes for statistics and rating sensors. @@ -68,13 +74,14 @@ def add_statistics_attributes( attributes: Dictionary to add attributes to key: The sensor entity key cached_data: Dictionary containing cached sensor data + time: TimeService instance (required) """ # Data timestamp sensor - shows API fetch time if key == "data_timestamp": latest_timestamp = cached_data.get("data_timestamp") if latest_timestamp: - attributes["timestamp"] = latest_timestamp.isoformat() + attributes["timestamp"] = latest_timestamp return # Current interval price rating - add rating attributes @@ -105,7 +112,7 @@ def add_statistics_attributes( # Daily average sensors - show midnight to indicate whole day daily_avg_sensors = {"average_price_today", "average_price_tomorrow"} if key in daily_avg_sensors: - attributes["timestamp"] = _get_day_midnight_timestamp(key) + attributes["timestamp"] = _get_day_midnight_timestamp(key, time=time) return # Daily aggregated level/rating sensors - show midnight to indicate whole day @@ -118,7 +125,7 @@ def add_statistics_attributes( "tomorrow_price_rating", } if key in daily_aggregated_sensors: - attributes["timestamp"] = _get_day_midnight_timestamp(key) + attributes["timestamp"] = _get_day_midnight_timestamp(key, time=time) return # All other statistics sensors - keep default timestamp (when calculation was made) diff --git a/custom_components/tibber_prices/sensor/attributes/future.py b/custom_components/tibber_prices/sensor/attributes/future.py index 829d3af..443e6fc 100644 --- a/custom_components/tibber_prices/sensor/attributes/future.py +++ b/custom_components/tibber_prices/sensor/attributes/future.py @@ -2,16 +2,14 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime from typing import TYPE_CHECKING, Any -from custom_components.tibber_prices.const import MINUTES_PER_INTERVAL -from homeassistant.util import dt as dt_util - if TYPE_CHECKING: from custom_components.tibber_prices.coordinator.core import ( TibberPricesDataUpdateCoordinator, ) + from custom_components.tibber_prices.coordinator.time_service import TimeService # Constants MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals) @@ -21,6 +19,8 @@ def add_next_avg_attributes( attributes: dict, key: str, coordinator: TibberPricesDataUpdateCoordinator, + *, + time: TimeService, ) -> None: """ Add attributes for next N hours average price sensors. @@ -29,21 +29,17 @@ def add_next_avg_attributes( attributes: Dictionary to add attributes to key: The sensor entity key coordinator: The data update coordinator + time: TimeService instance (required) """ - now = dt_util.now() - # Extract hours from sensor key (e.g., "next_avg_3h" -> 3) try: - hours = int(key.replace("next_avg_", "").replace("h", "")) + hours = int(key.split("_")[-1].replace("h", "")) except (ValueError, AttributeError): return - # Get next interval start time (this is where the calculation begins) - next_interval_start = now + timedelta(minutes=MINUTES_PER_INTERVAL) - - # Calculate the end of the time window - window_end = next_interval_start + timedelta(hours=hours) + # Use TimeService to get the N-hour window starting from next interval + next_interval_start, window_end = time.get_next_n_hours_window(hours) # Get all price intervals price_info = coordinator.data.get("priceInfo", {}) @@ -57,10 +53,9 @@ def add_next_avg_attributes( # Find all intervals in the window intervals_in_window = [] for price_data in all_prices: - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) if next_interval_start <= starts_at < window_end: intervals_in_window.append(price_data) @@ -74,6 +69,8 @@ def add_next_avg_attributes( def add_price_forecast_attributes( attributes: dict, coordinator: TibberPricesDataUpdateCoordinator, + *, + time: TimeService, ) -> None: """ Add forecast attributes for the price forecast sensor. @@ -81,9 +78,10 @@ def add_price_forecast_attributes( Args: attributes: Dictionary to add attributes to coordinator: The data update coordinator + time: TimeService instance (required) """ - future_prices = get_future_prices(coordinator, max_intervals=MAX_FORECAST_INTERVALS) + future_prices = get_future_prices(coordinator, max_intervals=MAX_FORECAST_INTERVALS, time=time) if not future_prices: attributes["intervals"] = [] attributes["intervals_by_hour"] = [] @@ -100,14 +98,18 @@ def add_price_forecast_attributes( # Group by hour for easier consumption in dashboards hours: dict[str, Any] = {} for interval in future_prices: - starts_at = datetime.fromisoformat(interval["interval_start"]) + # interval_start is already a datetime object (from coordinator data) + starts_at = interval["interval_start"] + if not isinstance(starts_at, datetime): + # Fallback: parse if it's still a string (shouldn't happen) + starts_at = datetime.fromisoformat(starts_at) hour_key = starts_at.strftime("%Y-%m-%d %H") if hour_key not in hours: hours[hour_key] = { "hour": starts_at.hour, "day": interval["day"], - "date": starts_at.date().isoformat(), + "date": starts_at.date(), "intervals": [], "min_price": None, "max_price": None, @@ -161,6 +163,8 @@ def add_price_forecast_attributes( def get_future_prices( coordinator: TibberPricesDataUpdateCoordinator, max_intervals: int | None = None, + *, + time: TimeService, ) -> list[dict] | None: """ Get future price data for multiple upcoming intervals. @@ -168,6 +172,7 @@ def get_future_prices( Args: coordinator: The data update coordinator max_intervals: Maximum number of future intervals to return + time: TimeService instance (required) Returns: List of upcoming price intervals with timestamps and prices @@ -185,8 +190,6 @@ def get_future_prices( if not all_prices: return None - now = dt_util.now() - # Initialize the result list future_prices = [] @@ -195,18 +198,18 @@ def get_future_prices( for day_key in ["today", "tomorrow"]: for price_data in price_info.get(day_key, []): - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) - interval_end = starts_at + timedelta(minutes=MINUTES_PER_INTERVAL) + interval_end = starts_at + time.get_interval_duration() - if starts_at > now: + # Use TimeService to check if interval is in future + if time.is_in_future(starts_at): future_prices.append( { - "interval_start": starts_at.isoformat(), - "interval_end": interval_end.isoformat(), + "interval_start": starts_at, + "interval_end": interval_end, "price": float(price_data["total"]), "price_minor": round(float(price_data["total"]) * 100, 2), "level": price_data.get("level", "NORMAL"), diff --git a/custom_components/tibber_prices/sensor/attributes/interval.py b/custom_components/tibber_prices/sensor/attributes/interval.py index 91458f3..26106ed 100644 --- a/custom_components/tibber_prices/sensor/attributes/interval.py +++ b/custom_components/tibber_prices/sensor/attributes/interval.py @@ -6,28 +6,29 @@ from datetime import timedelta from typing import TYPE_CHECKING, Any from custom_components.tibber_prices.const import ( - MINUTES_PER_INTERVAL, PRICE_LEVEL_MAPPING, PRICE_RATING_MAPPING, ) from custom_components.tibber_prices.entity_utils import add_icon_color_attribute from custom_components.tibber_prices.utils.price import find_price_data_for_interval -from homeassistant.util import dt as dt_util if TYPE_CHECKING: from custom_components.tibber_prices.coordinator.core import ( TibberPricesDataUpdateCoordinator, ) + from custom_components.tibber_prices.coordinator.time_service import TimeService from .metadata import get_current_interval_data -def add_current_interval_price_attributes( +def add_current_interval_price_attributes( # noqa: PLR0913 attributes: dict, key: str, coordinator: TibberPricesDataUpdateCoordinator, native_value: Any, cached_data: dict, + *, + time: TimeService, ) -> None: """ Add attributes for current interval price sensors. @@ -38,10 +39,11 @@ def add_current_interval_price_attributes( coordinator: The data update coordinator native_value: The current native value of the sensor cached_data: Dictionary containing cached sensor data + time: TimeService instance (required) """ price_info = coordinator.data.get("priceInfo", {}) if coordinator.data else {} - now = dt_util.now() + now = time.now() # Determine which interval to use based on sensor type next_interval_sensors = [ @@ -70,28 +72,28 @@ def add_current_interval_price_attributes( # For current interval sensors, keep the default platform timestamp (calculation time) interval_data = None if key in next_interval_sensors: - target_time = now + timedelta(minutes=MINUTES_PER_INTERVAL) - interval_data = find_price_data_for_interval(price_info, target_time) + target_time = time.get_next_interval_start() + interval_data = find_price_data_for_interval(price_info, target_time, time=time) # Override timestamp with the NEXT interval's startsAt (when that interval starts) if interval_data: attributes["timestamp"] = interval_data["startsAt"] elif key in previous_interval_sensors: - target_time = now - timedelta(minutes=MINUTES_PER_INTERVAL) - interval_data = find_price_data_for_interval(price_info, target_time) + target_time = time.get_interval_offset_time(-1) + interval_data = find_price_data_for_interval(price_info, target_time, time=time) # Override timestamp with the PREVIOUS interval's startsAt if interval_data: attributes["timestamp"] = interval_data["startsAt"] elif key in next_hour_sensors: target_time = now + timedelta(hours=1) - interval_data = find_price_data_for_interval(price_info, target_time) + interval_data = find_price_data_for_interval(price_info, target_time, time=time) # Override timestamp with the center of the next rolling hour window if interval_data: attributes["timestamp"] = interval_data["startsAt"] elif key in current_hour_sensors: - current_interval_data = get_current_interval_data(coordinator) + current_interval_data = get_current_interval_data(coordinator, time=time) # Keep default timestamp (when calculation was made) for current hour sensors else: - current_interval_data = get_current_interval_data(coordinator) + current_interval_data = get_current_interval_data(coordinator, time=time) interval_data = current_interval_data # Use current_interval_data as interval_data for current_interval_price # Keep default timestamp (current calculation time) for current interval sensors @@ -114,6 +116,7 @@ def add_current_interval_price_attributes( interval_data=interval_data, coordinator=coordinator, native_value=native_value, + time=time, ) # Add price rating attributes for all rating sensors @@ -123,15 +126,18 @@ def add_current_interval_price_attributes( interval_data=interval_data, coordinator=coordinator, native_value=native_value, + time=time, ) -def add_level_attributes_for_sensor( +def add_level_attributes_for_sensor( # noqa: PLR0913 attributes: dict, key: str, interval_data: dict | None, coordinator: TibberPricesDataUpdateCoordinator, native_value: Any, + *, + time: TimeService, ) -> None: """ Add price level attributes based on sensor type. @@ -142,6 +148,7 @@ def add_level_attributes_for_sensor( interval_data: Interval data for next/previous sensors coordinator: The data update coordinator native_value: The current native value of the sensor + time: TimeService instance (required) """ # For interval-based level sensors (next/previous), use interval data @@ -155,7 +162,7 @@ def add_level_attributes_for_sensor( add_price_level_attributes(attributes, level_value.upper()) # For current price level sensor elif key == "current_interval_price_level": - current_interval_data = get_current_interval_data(coordinator) + current_interval_data = get_current_interval_data(coordinator, time=time) if current_interval_data and "level" in current_interval_data: add_price_level_attributes(attributes, current_interval_data["level"]) @@ -177,12 +184,14 @@ def add_price_level_attributes(attributes: dict, level: str) -> None: add_icon_color_attribute(attributes, key="price_level", state_value=level) -def add_rating_attributes_for_sensor( +def add_rating_attributes_for_sensor( # noqa: PLR0913 attributes: dict, key: str, interval_data: dict | None, coordinator: TibberPricesDataUpdateCoordinator, native_value: Any, + *, + time: TimeService, ) -> None: """ Add price rating attributes based on sensor type. @@ -193,6 +202,7 @@ def add_rating_attributes_for_sensor( interval_data: Interval data for next/previous sensors coordinator: The data update coordinator native_value: The current native value of the sensor + time: TimeService instance (required) """ # For interval-based rating sensors (next/previous), use interval data @@ -206,7 +216,7 @@ def add_rating_attributes_for_sensor( add_price_rating_attributes(attributes, rating_value.upper()) # For current price rating sensor elif key == "current_interval_price_rating": - current_interval_data = get_current_interval_data(coordinator) + current_interval_data = get_current_interval_data(coordinator, time=time) if current_interval_data and "rating_level" in current_interval_data: add_price_rating_attributes(attributes, current_interval_data["rating_level"]) diff --git a/custom_components/tibber_prices/sensor/attributes/metadata.py b/custom_components/tibber_prices/sensor/attributes/metadata.py index e381338..218233b 100644 --- a/custom_components/tibber_prices/sensor/attributes/metadata.py +++ b/custom_components/tibber_prices/sensor/attributes/metadata.py @@ -5,31 +5,34 @@ from __future__ import annotations from typing import TYPE_CHECKING from custom_components.tibber_prices.utils.price import find_price_data_for_interval -from homeassistant.util import dt as dt_util if TYPE_CHECKING: from custom_components.tibber_prices.coordinator.core import ( TibberPricesDataUpdateCoordinator, ) + from custom_components.tibber_prices.coordinator.time_service import TimeService def get_current_interval_data( coordinator: TibberPricesDataUpdateCoordinator, + *, + time: TimeService, ) -> dict | None: """ - Get the current price interval data. + Get current interval's price data. Args: coordinator: The data update coordinator + time: TimeService instance (required) Returns: - Current interval data dict, or None if unavailable + Current interval data or None if not found """ if not coordinator.data: return None price_info = coordinator.data.get("priceInfo", {}) - now = dt_util.now() + now = time.now() - return find_price_data_for_interval(price_info, now) + return find_price_data_for_interval(price_info, now, time=time) diff --git a/custom_components/tibber_prices/sensor/attributes/timing.py b/custom_components/tibber_prices/sensor/attributes/timing.py index 8f56d50..f61583a 100644 --- a/custom_components/tibber_prices/sensor/attributes/timing.py +++ b/custom_components/tibber_prices/sensor/attributes/timing.py @@ -2,10 +2,15 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from custom_components.tibber_prices.entity_utils import add_icon_color_attribute -from homeassistant.util import dt as dt_util + +if TYPE_CHECKING: + from custom_components.tibber_prices.coordinator.time_service import TimeService + +# Timer #3 triggers every 30 seconds +TIMER_30_SEC_BOUNDARY = 30 def _is_timing_or_volatility_sensor(key: str) -> bool: @@ -29,24 +34,27 @@ def add_period_timing_attributes( attributes: dict, key: str, state_value: Any = None, + *, + time: TimeService, ) -> None: """ Add timestamp and icon_color attributes for best_price/peak_price timing sensors. The timestamp indicates when the sensor value was calculated: - - Quarter-hour sensors (end_time, next_start_time): Timestamp of current 15-min interval - - Minute-update sensors (remaining_minutes, progress, next_in_minutes): Current minute with :00 seconds + - Quarter-hour sensors (end_time, next_start_time): Rounded to 15-min boundary (:00, :15, :30, :45) + - 30-second update sensors (remaining_minutes, progress, next_in_minutes): Current time with seconds Args: attributes: Dictionary to add attributes to key: The sensor entity key (e.g., "best_price_end_time") state_value: Current sensor value for icon_color calculation + time: TimeService instance (required) """ - # Determine if this is a quarter-hour or minute-update sensor + # Determine if this is a quarter-hour or 30-second update sensor is_quarter_hour_sensor = key.endswith(("_end_time", "_next_start_time")) - now = dt_util.now() + now = time.now() if is_quarter_hour_sensor: # Quarter-hour sensors: Use timestamp of current 15-minute interval @@ -54,11 +62,12 @@ def add_period_timing_attributes( minute = (now.minute // 15) * 15 timestamp = now.replace(minute=minute, second=0, microsecond=0) else: - # Minute-update sensors: Use current minute with :00 seconds - # This ensures clean timestamps despite timer fluctuations - timestamp = now.replace(second=0, microsecond=0) + # 30-second update sensors: Round to nearest 30-second boundary (:00 or :30) + # Timer triggers at :00 and :30, so round current time to these boundaries + second = 0 if now.second < TIMER_30_SEC_BOUNDARY else TIMER_30_SEC_BOUNDARY + timestamp = now.replace(second=second, microsecond=0) - attributes["timestamp"] = timestamp.isoformat() + attributes["timestamp"] = timestamp # Add icon_color for dynamic styling add_icon_color_attribute(attributes, key=key, state_value=state_value) diff --git a/custom_components/tibber_prices/sensor/attributes/trend.py b/custom_components/tibber_prices/sensor/attributes/trend.py index a3efd14..28fd495 100644 --- a/custom_components/tibber_prices/sensor/attributes/trend.py +++ b/custom_components/tibber_prices/sensor/attributes/trend.py @@ -2,7 +2,10 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from custom_components.tibber_prices.coordinator.time_service import TimeService from .timing import add_period_timing_attributes from .volatility import add_volatility_attributes @@ -13,12 +16,14 @@ def _add_timing_or_volatility_attributes( key: str, cached_data: dict, native_value: Any = None, + *, + time: TimeService, ) -> None: """Add attributes for timing or volatility sensors.""" if key.endswith("_volatility"): - add_volatility_attributes(attributes=attributes, cached_data=cached_data) + add_volatility_attributes(attributes=attributes, cached_data=cached_data, time=time) else: - add_period_timing_attributes(attributes=attributes, key=key, state_value=native_value) + add_period_timing_attributes(attributes=attributes, key=key, state_value=native_value, time=time) def _add_cached_trend_attributes(attributes: dict, key: str, cached_data: dict) -> None: diff --git a/custom_components/tibber_prices/sensor/attributes/volatility.py b/custom_components/tibber_prices/sensor/attributes/volatility.py index ce0f7a4..760ec55 100644 --- a/custom_components/tibber_prices/sensor/attributes/volatility.py +++ b/custom_components/tibber_prices/sensor/attributes/volatility.py @@ -3,14 +3,19 @@ from __future__ import annotations from datetime import timedelta +from typing import TYPE_CHECKING from custom_components.tibber_prices.utils.price import calculate_volatility_level -from homeassistant.util import dt as dt_util + +if TYPE_CHECKING: + from custom_components.tibber_prices.coordinator.time_service import TimeService def add_volatility_attributes( attributes: dict, cached_data: dict, + *, + time: TimeService, # noqa: ARG001 ) -> None: """ Add attributes for volatility sensors. @@ -18,6 +23,7 @@ def add_volatility_attributes( Args: attributes: Dictionary to add attributes to cached_data: Dictionary containing cached sensor data + time: TimeService instance (required) """ if cached_data.get("volatility_attributes"): @@ -27,6 +33,8 @@ def add_volatility_attributes( def get_prices_for_volatility( volatility_type: str, price_info: dict, + *, + time: TimeService, ) -> list[float]: """ Get price list for volatility calculation based on type. @@ -34,6 +42,7 @@ def get_prices_for_volatility( Args: volatility_type: One of "today", "tomorrow", "next_24h", "today_tomorrow" price_info: Price information dictionary from coordinator data + time: TimeService instance (required) Returns: List of prices to analyze @@ -47,18 +56,17 @@ def get_prices_for_volatility( if volatility_type == "next_24h": # Rolling 24h from now - now = dt_util.now() + now = time.now() end_time = now + timedelta(hours=24) prices = [] for day_key in ["today", "tomorrow"]: for price_data in price_info.get(day_key, []): - starts_at = dt_util.parse_datetime(price_data.get("startsAt")) + starts_at = price_data.get("startsAt") # Already datetime in local timezone if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) - if now <= starts_at < end_time and "total" in price_data: + if time.is_in_future(starts_at) and starts_at < end_time and "total" in price_data: prices.append(float(price_data["total"])) return prices @@ -79,6 +87,8 @@ def add_volatility_type_attributes( volatility_type: str, price_info: dict, thresholds: dict, + *, + time: TimeService, ) -> None: """ Add type-specific attributes for volatility sensors. @@ -88,6 +98,7 @@ def add_volatility_type_attributes( volatility_type: Type of volatility calculation price_info: Price information dictionary from coordinator data thresholds: Volatility thresholds configuration + time: TimeService instance (required) """ # Add timestamp for calendar day volatility sensors (midnight of the day) @@ -124,5 +135,5 @@ def add_volatility_type_attributes( volatility_attributes["interval_count_tomorrow"] = len(tomorrow_prices) elif volatility_type == "next_24h": # Add time window info - now = dt_util.now() - volatility_attributes["timestamp"] = now.isoformat() + now = time.now() + volatility_attributes["timestamp"] = now diff --git a/custom_components/tibber_prices/sensor/attributes/window_24h.py b/custom_components/tibber_prices/sensor/attributes/window_24h.py index 291119b..ce01203 100644 --- a/custom_components/tibber_prices/sensor/attributes/window_24h.py +++ b/custom_components/tibber_prices/sensor/attributes/window_24h.py @@ -2,15 +2,13 @@ from __future__ import annotations -from datetime import timedelta from typing import TYPE_CHECKING -from homeassistant.util import dt as dt_util - if TYPE_CHECKING: from custom_components.tibber_prices.coordinator.core import ( TibberPricesDataUpdateCoordinator, ) + from custom_components.tibber_prices.coordinator.time_service import TimeService def _update_extreme_interval(extreme_interval: dict | None, price_data: dict, key: str) -> dict: @@ -44,6 +42,8 @@ def add_average_price_attributes( attributes: dict, key: str, coordinator: TibberPricesDataUpdateCoordinator, + *, + time: TimeService, ) -> None: """ Add attributes for trailing and leading average/min/max price sensors. @@ -52,10 +52,9 @@ def add_average_price_attributes( attributes: Dictionary to add attributes to key: The sensor entity key coordinator: The data update coordinator + time: TimeService instance (required) """ - now = dt_util.now() - # Determine if this is trailing or leading is_trailing = "trailing" in key @@ -69,13 +68,11 @@ def add_average_price_attributes( if not all_prices: return - # Calculate the time window + # Calculate the time window using TimeService if is_trailing: - window_start = now - timedelta(hours=24) - window_end = now + window_start, window_end = time.get_trailing_window(hours=24) else: - window_start = now - window_end = now + timedelta(hours=24) + window_start, window_end = time.get_leading_window(hours=24) # Find all intervals in the window intervals_in_window = [] @@ -83,10 +80,9 @@ def add_average_price_attributes( is_min_max_sensor = "min" in key or "max" in key for price_data in all_prices: - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) if window_start <= starts_at < window_end: intervals_in_window.append(price_data) diff --git a/custom_components/tibber_prices/sensor/calculators/daily_stat.py b/custom_components/tibber_prices/sensor/calculators/daily_stat.py index 9e4219d..76db591 100644 --- a/custom_components/tibber_prices/sensor/calculators/daily_stat.py +++ b/custom_components/tibber_prices/sensor/calculators/daily_stat.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import timedelta from typing import TYPE_CHECKING from custom_components.tibber_prices.const import ( @@ -16,7 +15,6 @@ from custom_components.tibber_prices.sensor.helpers import ( aggregate_level_data, aggregate_rating_data, ) -from homeassistant.util import dt as dt_util from .base import BaseCalculator @@ -72,28 +70,19 @@ class DailyStatCalculator(BaseCalculator): price_info = self.price_info - # Get local midnight boundaries based on the requested day - local_midnight = dt_util.as_local(dt_util.start_of_local_day(dt_util.now())) - if day == "tomorrow": - local_midnight = local_midnight + timedelta(days=1) - local_midnight_next_day = local_midnight + timedelta(days=1) + # Get local midnight boundaries based on the requested day using TimeService + time = self.coordinator.time + local_midnight, local_midnight_next_day = time.get_day_boundaries(day) # Collect all prices and their intervals from both today and tomorrow data # that fall within the target day's local date boundaries price_intervals = [] for day_key in ["today", "tomorrow"]: for price_data in price_info.get(day_key, []): - starts_at_str = price_data.get("startsAt") - if not starts_at_str: + starts_at = price_data.get("startsAt") # Already datetime in local timezone + if not starts_at: continue - starts_at = dt_util.parse_datetime(starts_at_str) - if starts_at is None: - continue - - # Convert to local timezone for comparison - starts_at = dt_util.as_local(starts_at) - # Include price if it starts within the target day's local date boundaries if local_midnight <= starts_at < local_midnight_next_day: total_price = price_data.get("total") @@ -147,30 +136,19 @@ class DailyStatCalculator(BaseCalculator): price_info = self.price_info - # Get local midnight boundaries based on the requested day - local_midnight = dt_util.as_local(dt_util.start_of_local_day(dt_util.now())) - if day == "tomorrow": - local_midnight = local_midnight + timedelta(days=1) - elif day == "yesterday": - local_midnight = local_midnight - timedelta(days=1) - local_midnight_next_day = local_midnight + timedelta(days=1) + # Get local midnight boundaries based on the requested day using TimeService + time = self.coordinator.time + local_midnight, local_midnight_next_day = time.get_day_boundaries(day) # Collect all intervals from both today and tomorrow data # that fall within the target day's local date boundaries day_intervals = [] for day_key in ["yesterday", "today", "tomorrow"]: for price_data in price_info.get(day_key, []): - starts_at_str = price_data.get("startsAt") - if not starts_at_str: + starts_at = price_data.get("startsAt") # Already datetime in local timezone + if not starts_at: continue - starts_at = dt_util.parse_datetime(starts_at_str) - if starts_at is None: - continue - - # Convert to local timezone for comparison - starts_at = dt_util.as_local(starts_at) - # Include interval if it starts within the target day's local date boundaries if local_midnight <= starts_at < local_midnight_next_day: day_intervals.append(price_data) diff --git a/custom_components/tibber_prices/sensor/calculators/interval.py b/custom_components/tibber_prices/sensor/calculators/interval.py index 7c747c5..f07c554 100644 --- a/custom_components/tibber_prices/sensor/calculators/interval.py +++ b/custom_components/tibber_prices/sensor/calculators/interval.py @@ -2,12 +2,9 @@ from __future__ import annotations -from datetime import timedelta from typing import TYPE_CHECKING -from custom_components.tibber_prices.const import MINUTES_PER_INTERVAL from custom_components.tibber_prices.utils.price import find_price_data_for_interval -from homeassistant.util import dt as dt_util from .base import BaseCalculator @@ -64,10 +61,11 @@ class IntervalCalculator(BaseCalculator): return None price_info = self.price_info - now = dt_util.now() - target_time = now + timedelta(minutes=MINUTES_PER_INTERVAL * interval_offset) + time = self.coordinator.time + # Use TimeService to get interval offset time + target_time = time.get_interval_offset_time(interval_offset) - interval_data = find_price_data_for_interval(price_info, target_time) + interval_data = find_price_data_for_interval(price_info, target_time, time=time) if not interval_data: return None @@ -124,9 +122,10 @@ class IntervalCalculator(BaseCalculator): self._last_rating_level = None return None - now = dt_util.now() + time = self.coordinator.time + now = time.now() price_info = self.price_info - current_interval = find_price_data_for_interval(price_info, now) + current_interval = find_price_data_for_interval(price_info, now, time=time) if current_interval: rating_level = current_interval.get("rating_level") diff --git a/custom_components/tibber_prices/sensor/calculators/rolling_hour.py b/custom_components/tibber_prices/sensor/calculators/rolling_hour.py index 0ad5060..d419710 100644 --- a/custom_components/tibber_prices/sensor/calculators/rolling_hour.py +++ b/custom_components/tibber_prices/sensor/calculators/rolling_hour.py @@ -14,7 +14,6 @@ from custom_components.tibber_prices.sensor.helpers import ( aggregate_price_data, aggregate_rating_data, ) -from homeassistant.util import dt as dt_util from .base import BaseCalculator @@ -60,8 +59,9 @@ class RollingHourCalculator(BaseCalculator): return None # Find center index for the rolling window - now = dt_util.now() - center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset) + time = self.coordinator.time + now = time.now() + center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset, time=time) if center_idx is None: return None diff --git a/custom_components/tibber_prices/sensor/calculators/timing.py b/custom_components/tibber_prices/sensor/calculators/timing.py index f5acfff..a63a672 100644 --- a/custom_components/tibber_prices/sensor/calculators/timing.py +++ b/custom_components/tibber_prices/sensor/calculators/timing.py @@ -10,17 +10,14 @@ This module handles all timing-related calculations for period-based sensors: The calculator provides smart defaults: - Active period → show current period timing -- No active → show next period timing -- No more periods → 0 for numeric values, None for timestamps + - No active → show next period timing + - No more periods → 0 for numeric values, None for timestamps """ from datetime import datetime -from homeassistant.util import dt as dt_util +from .base import BaseCalculator # Constants -from .base import BaseCalculator - -# Constants PROGRESS_GRACE_PERIOD_SECONDS = 60 # Show 100% for 1 minute after period ends @@ -80,12 +77,13 @@ class TimingCalculator(BaseCalculator): return 0 if value_type in ("remaining_minutes", "progress", "next_in_minutes") else None period_summaries = period_data["periods"] - now = dt_util.now() + time = self.coordinator.time + now = time.now() # Find current, previous and next periods - current_period = self._find_active_period(period_summaries, now) - previous_period = self._find_previous_period(period_summaries, now) - next_period = self._find_next_period(period_summaries, now, skip_current=bool(current_period)) + current_period = self._find_active_period(period_summaries) + previous_period = self._find_previous_period(period_summaries) + next_period = self._find_next_period(period_summaries, skip_current=bool(current_period)) # Delegate to specific calculators return self._calculate_timing_value(value_type, current_period, previous_period, next_period, now) @@ -106,26 +104,46 @@ class TimingCalculator(BaseCalculator): ), "period_duration": lambda: self._calc_period_duration(current_period, next_period), "next_start_time": lambda: next_period.get("start") if next_period else None, - "remaining_minutes": lambda: (self._calc_remaining_minutes(current_period, now) if current_period else 0), + "remaining_minutes": lambda: (self._calc_remaining_minutes(current_period) if current_period else 0), "progress": lambda: self._calc_progress_with_grace_period(current_period, previous_period, now), - "next_in_minutes": lambda: (self._calc_next_in_minutes(next_period, now) if next_period else None), + "next_in_minutes": lambda: (self._calc_next_in_minutes(next_period) if next_period else None), } calculator = calculators.get(value_type) return calculator() if calculator else None - def _find_active_period(self, periods: list, now: datetime) -> dict | None: - """Find currently active period.""" + def _find_active_period(self, periods: list) -> dict | None: + """ + Find currently active period. + + Args: + periods: List of period dictionaries + + Returns: + Currently active period or None + + """ + time = self.coordinator.time for period in periods: start = period.get("start") end = period.get("end") - if start and end and start <= now < end: + if start and end and time.is_current_interval(start, end): return period return None - def _find_previous_period(self, periods: list, now: datetime) -> dict | None: - """Find the most recent period that has already ended.""" - past_periods = [p for p in periods if p.get("end") and p.get("end") <= now] + def _find_previous_period(self, periods: list) -> dict | None: + """ + Find the most recent period that has already ended. + + Args: + periods: List of period dictionaries + + Returns: + Most recent past period or None + + """ + time = self.coordinator.time + past_periods = [p for p in periods if p.get("end") and time.is_in_past(p["end"])] if not past_periods: return None @@ -134,20 +152,20 @@ class TimingCalculator(BaseCalculator): past_periods.sort(key=lambda p: p["end"], reverse=True) return past_periods[0] - def _find_next_period(self, periods: list, now: datetime, *, skip_current: bool = False) -> dict | None: + def _find_next_period(self, periods: list, *, skip_current: bool = False) -> dict | None: """ Find next future period. Args: periods: List of period dictionaries - now: Current time skip_current: If True, skip the first future period (to get next-next) Returns: Next period dict or None if no future periods """ - future_periods = [p for p in periods if p.get("start") and p.get("start") > now] + time = self.coordinator.time + future_periods = [p for p in periods if p.get("start") and time.is_in_future(p["start"])] if not future_periods: return None @@ -163,21 +181,47 @@ class TimingCalculator(BaseCalculator): return None - def _calc_remaining_minutes(self, period: dict, now: datetime) -> float: - """Calculate minutes until period ends.""" + def _calc_remaining_minutes(self, period: dict) -> int: + """ + Calculate ROUNDED minutes until period ends. + + Uses standard rounding (0.5 rounds up) to match Home Assistant frontend + relative time display. This ensures sensor values match what users see + in the UI ("in X minutes"). + + Args: + period: Period dictionary + + Returns: + Rounded minutes until period ends (matches HA frontend display) + + """ + time = self.coordinator.time end = period.get("end") if not end: return 0 - delta = end - now - return max(0, delta.total_seconds() / 60) + return time.minutes_until_rounded(end) - def _calc_next_in_minutes(self, period: dict, now: datetime) -> float: - """Calculate minutes until period starts.""" + def _calc_next_in_minutes(self, period: dict) -> int: + """ + Calculate ROUNDED minutes until next period starts. + + Uses standard rounding (0.5 rounds up) to match Home Assistant frontend + relative time display. This ensures sensor values match what users see + in the UI ("in X minutes"). + + Args: + period: Period dictionary + + Returns: + Rounded minutes until period starts (matches HA frontend display) + + """ + time = self.coordinator.time start = period.get("start") if not start: return 0 - delta = start - now - return max(0, delta.total_seconds() / 60) + return time.minutes_until_rounded(start) def _calc_period_duration(self, current_period: dict | None, next_period: dict | None) -> float | None: """ diff --git a/custom_components/tibber_prices/sensor/calculators/trend.py b/custom_components/tibber_prices/sensor/calculators/trend.py index 0cf40c2..bc92643 100644 --- a/custom_components/tibber_prices/sensor/calculators/trend.py +++ b/custom_components/tibber_prices/sensor/calculators/trend.py @@ -12,16 +12,14 @@ Caching strategy: - Current trend + next change: Cached centrally for 60s to avoid duplicate calculations """ -from datetime import datetime, timedelta +from datetime import datetime from typing import TYPE_CHECKING, Any -from custom_components.tibber_prices.const import MINUTES_PER_INTERVAL from custom_components.tibber_prices.utils.average import calculate_next_n_hours_avg from custom_components.tibber_prices.utils.price import ( calculate_price_trend, find_price_data_for_interval, ) -from homeassistant.util import dt as dt_util from .base import BaseCalculator @@ -89,16 +87,16 @@ class TrendCalculator(BaseCalculator): return None current_interval_price = float(current_interval["total"]) - current_starts_at = dt_util.parse_datetime(current_interval["startsAt"]) + time = self.coordinator.time + current_starts_at = time.get_interval_time(current_interval) if current_starts_at is None: return None - current_starts_at = dt_util.as_local(current_starts_at) # Get next interval timestamp (basis for calculation) - next_interval_start = current_starts_at + timedelta(minutes=MINUTES_PER_INTERVAL) + next_interval_start = time.get_next_interval_start() # Get future average price - future_avg = calculate_next_n_hours_avg(self.coordinator.data, hours) + future_avg = calculate_next_n_hours_avg(self.coordinator.data, hours, time=self.coordinator.time) if future_avg is None: return None @@ -113,7 +111,7 @@ class TrendCalculator(BaseCalculator): today_prices = price_info.get("today", []) tomorrow_prices = price_info.get("tomorrow", []) all_intervals = today_prices + tomorrow_prices - lookahead_intervals = hours * 4 # Convert hours to 15-minute intervals + lookahead_intervals = self.coordinator.time.minutes_to_intervals(hours * 60) # Calculate trend with volatility-adaptive thresholds trend_state, diff_pct = calculate_price_trend( @@ -137,10 +135,10 @@ class TrendCalculator(BaseCalculator): # Store attributes in sensor-specific dictionary AND cache the trend value self._trend_attributes = { - "timestamp": next_interval_start.isoformat(), + "timestamp": next_interval_start, f"trend_{hours}h_%": round(diff_pct, 1), f"next_{hours}h_avg": round(future_avg * 100, 2), - "interval_count": hours * 4, + "interval_count": lookahead_intervals, "threshold_rising": threshold_rising, "threshold_falling": threshold_falling, "icon_color": icon_color, @@ -259,18 +257,19 @@ class TrendCalculator(BaseCalculator): return None # Calculate which intervals belong to the later half - total_intervals = hours * 4 + time = self.coordinator.time + total_intervals = time.minutes_to_intervals(hours * 60) first_half_intervals = total_intervals // 2 - later_half_start = next_interval_start + timedelta(minutes=MINUTES_PER_INTERVAL * first_half_intervals) - later_half_end = next_interval_start + timedelta(minutes=MINUTES_PER_INTERVAL * total_intervals) + interval_duration = time.get_interval_duration() + later_half_start = next_interval_start + (interval_duration * first_half_intervals) + later_half_end = next_interval_start + (interval_duration * total_intervals) # Collect prices in the later half later_prices = [] for price_data in all_prices: - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) if later_half_start <= starts_at < later_half_end: price = price_data.get("total") @@ -296,7 +295,8 @@ class TrendCalculator(BaseCalculator): trend_cache_duration_seconds = 60 # Cache for 1 minute # Check if we have a valid cache - now = dt_util.now() + time = self.coordinator.time + now = time.now() if ( self._trend_calculation_cache is not None and self._trend_calculation_timestamp is not None @@ -310,13 +310,12 @@ class TrendCalculator(BaseCalculator): price_info = self.coordinator.data.get("priceInfo", {}) all_intervals = price_info.get("today", []) + price_info.get("tomorrow", []) - current_interval = find_price_data_for_interval(price_info, now) + current_interval = find_price_data_for_interval(price_info, now, time=time) if not all_intervals or not current_interval: return None - current_interval_start = dt_util.parse_datetime(current_interval["startsAt"]) - current_interval_start = dt_util.as_local(current_interval_start) if current_interval_start else None + current_interval_start = time.get_interval_time(current_interval) if not current_interval_start: return None @@ -380,14 +379,15 @@ class TrendCalculator(BaseCalculator): # Calculate duration of current trend trend_duration_minutes = None if trend_start_time: - duration = now - trend_start_time - trend_duration_minutes = int(duration.total_seconds() / 60) + time = self.coordinator.time + # Duration is negative of minutes_until (time in the past) + trend_duration_minutes = -int(time.minutes_until(trend_start_time)) # Calculate minutes until change minutes_until_change = None if next_change_time: - time_diff = next_change_time - now - minutes_until_change = int(time_diff.total_seconds() / 60) + time = self.coordinator.time + minutes_until_change = int(time.minutes_until(next_change_time)) result = { "current_trend_state": current_trend_state, @@ -546,9 +546,10 @@ class TrendCalculator(BaseCalculator): def _find_current_interval_index(self, all_intervals: list, current_interval_start: datetime) -> int | None: """Find the index of current interval in all_intervals list.""" + time = self.coordinator.time for idx, interval in enumerate(all_intervals): - interval_start = dt_util.parse_datetime(interval["startsAt"]) - if interval_start and dt_util.as_local(interval_start) == current_interval_start: + interval_start = time.get_interval_time(interval) + if interval_start and interval_start == current_interval_start: return idx return None @@ -577,15 +578,15 @@ class TrendCalculator(BaseCalculator): intervals_in_3h = 12 # 3 hours = 12 intervals @ 15min each # Scan backward to find when trend changed TO current state + time = self.coordinator.time for i in range(current_index - 1, max(-1, current_index - 97), -1): if i < 0: break interval = all_intervals[i] - interval_start = dt_util.parse_datetime(interval["startsAt"]) + interval_start = time.get_interval_time(interval) if not interval_start: continue - interval_start = dt_util.as_local(interval_start) # Calculate trend at this past interval future_intervals = all_intervals[i + 1 : i + intervals_in_3h + 1] @@ -617,9 +618,9 @@ class TrendCalculator(BaseCalculator): if trend_state != current_trend_state: # Found the change point - the NEXT interval is where current trend started next_interval = all_intervals[i + 1] - trend_start = dt_util.parse_datetime(next_interval["startsAt"]) + trend_start = time.get_interval_time(next_interval) if trend_start: - return dt_util.as_local(trend_start), trend_state + return trend_start, trend_state # Reached data boundary - current trend extends beyond available data return None, None @@ -642,6 +643,7 @@ class TrendCalculator(BaseCalculator): Timestamp of next trend change, or None if no change in next 24h """ + time = self.coordinator.time intervals_in_3h = 12 # 3 hours = 12 intervals @ 15min each current_index = scan_params["current_index"] current_trend_state = scan_params["current_trend_state"] @@ -650,10 +652,9 @@ class TrendCalculator(BaseCalculator): for i in range(current_index + 1, min(current_index + 97, len(all_intervals))): interval = all_intervals[i] - interval_start = dt_util.parse_datetime(interval["startsAt"]) + interval_start = time.get_interval_time(interval) if not interval_start: continue - interval_start = dt_util.as_local(interval_start) # Skip if this interval is in the past if interval_start <= now: @@ -689,8 +690,8 @@ class TrendCalculator(BaseCalculator): # We want to find ANY change from current state, including changes to/from stable if trend_state != current_trend_state: # Store details for attributes - time_diff = interval_start - now - minutes_until = int(time_diff.total_seconds() / 60) + time = self.coordinator.time + minutes_until = int(time.minutes_until(interval_start)) self._trend_change_attributes = { "direction": trend_state, diff --git a/custom_components/tibber_prices/sensor/calculators/volatility.py b/custom_components/tibber_prices/sensor/calculators/volatility.py index b817c69..69a0b52 100644 --- a/custom_components/tibber_prices/sensor/calculators/volatility.py +++ b/custom_components/tibber_prices/sensor/calculators/volatility.py @@ -64,7 +64,7 @@ class VolatilityCalculator(BaseCalculator): } # Get prices based on volatility type - prices_to_analyze = get_prices_for_volatility(volatility_type, price_info) + prices_to_analyze = get_prices_for_volatility(volatility_type, price_info, time=self.coordinator.time) if not prices_to_analyze: return None @@ -95,7 +95,9 @@ class VolatilityCalculator(BaseCalculator): add_icon_color_attribute(self._last_volatility_attributes, key="volatility", state_value=volatility) # Add type-specific attributes - add_volatility_type_attributes(self._last_volatility_attributes, volatility_type, price_info, thresholds) + add_volatility_type_attributes( + self._last_volatility_attributes, volatility_type, price_info, thresholds, time=self.coordinator.time + ) # Return lowercase for ENUM device class return volatility.lower() diff --git a/custom_components/tibber_prices/sensor/calculators/window_24h.py b/custom_components/tibber_prices/sensor/calculators/window_24h.py index f2f8c41..6f2e7fa 100644 --- a/custom_components/tibber_prices/sensor/calculators/window_24h.py +++ b/custom_components/tibber_prices/sensor/calculators/window_24h.py @@ -42,7 +42,7 @@ class Window24hCalculator(BaseCalculator): if not self.coordinator_data: return None - value = stat_func(self.coordinator_data) + value = stat_func(self.coordinator_data, time=self.coordinator.time) if value is None: return None diff --git a/custom_components/tibber_prices/sensor/chart_data.py b/custom_components/tibber_prices/sensor/chart_data.py index aa643d5..77ffba5 100644 --- a/custom_components/tibber_prices/sensor/chart_data.py +++ b/custom_components/tibber_prices/sensor/chart_data.py @@ -118,7 +118,7 @@ def build_chart_data_attributes( """ # Build base attributes with metadata FIRST attributes: dict[str, object] = { - "timestamp": chart_data_last_update.isoformat() if chart_data_last_update else None, + "timestamp": chart_data_last_update, } # Add error message if service call failed diff --git a/custom_components/tibber_prices/sensor/core.py b/custom_components/tibber_prices/sensor/core.py index 6ff6ca1..6179076 100644 --- a/custom_components/tibber_prices/sensor/core.py +++ b/custom_components/tibber_prices/sensor/core.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime # noqa: TC003 - Used at runtime for _get_data_timestamp() from typing import TYPE_CHECKING, Any from custom_components.tibber_prices.binary_sensor.attributes import ( @@ -25,10 +25,9 @@ from custom_components.tibber_prices.entity import TibberPricesEntity from custom_components.tibber_prices.entity_utils import ( add_icon_color_attribute, find_rolling_hour_center_index, - get_dynamic_icon, get_price_value, ) -from custom_components.tibber_prices.entity_utils.icons import IconContext +from custom_components.tibber_prices.entity_utils.icons import IconContext, get_dynamic_icon from custom_components.tibber_prices.utils.average import ( calculate_next_n_hours_avg, ) @@ -42,7 +41,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import callback -from homeassistant.util import dt as dt_util from .attributes import ( add_volatility_type_attributes, @@ -75,10 +73,10 @@ if TYPE_CHECKING: from custom_components.tibber_prices.coordinator import ( TibberPricesDataUpdateCoordinator, ) + from custom_components.tibber_prices.coordinator.time_service import TimeService HOURS_IN_DAY = 24 LAST_HOUR_OF_DAY = 23 -INTERVALS_PER_HOUR = 4 # 15-minute intervals MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals) MIN_HOURS_FOR_LATER_HALF = 3 # Minimum hours needed to calculate later half average @@ -148,8 +146,17 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): self._minute_update_remove_listener = None @callback - def _handle_time_sensitive_update(self) -> None: - """Handle time-sensitive update from coordinator.""" + def _handle_time_sensitive_update(self, time_service: TimeService) -> None: + """ + Handle time-sensitive update from coordinator. + + Args: + time_service: TimeService instance with reference time for this update cycle + + """ + # Store TimeService from Timer #2 for calculations during this update cycle + self.coordinator.time = time_service + # Clear cached trend values on time-sensitive updates if self.entity_description.key.startswith("price_trend_"): self._trend_calculator.clear_trend_cache() @@ -159,8 +166,17 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): self.async_write_ha_state() @callback - def _handle_minute_update(self) -> None: - """Handle minute-by-minute update from coordinator.""" + def _handle_minute_update(self, time_service: TimeService) -> None: + """ + Handle minute-by-minute update from coordinator. + + Args: + time_service: TimeService instance with reference time for this update cycle + + """ + # Store TimeService from Timer #3 for calculations during this update cycle + self.coordinator.time = time_service + self.async_write_ha_state() @callback @@ -235,8 +251,9 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): return None # Find center index for the rolling window - now = dt_util.now() - center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset) + time = self.coordinator.time + now = time.now() + center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset, time=time) if center_idx is None: return None @@ -288,28 +305,21 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): price_info = self.coordinator.data.get("priceInfo", {}) - # Get local midnight boundaries based on the requested day - local_midnight = dt_util.as_local(dt_util.start_of_local_day(dt_util.now())) - if day == "tomorrow": - local_midnight = local_midnight + timedelta(days=1) - local_midnight_next_day = local_midnight + timedelta(days=1) + # Get TimeService from coordinator + time = self.coordinator.time + + # Get local midnight boundaries based on the requested day using TimeService + local_midnight, local_midnight_next_day = time.get_day_boundaries(day) # Collect all prices and their intervals from both today and tomorrow data # that fall within the target day's local date boundaries price_intervals = [] for day_key in ["today", "tomorrow"]: for price_data in price_info.get(day_key, []): - starts_at_str = price_data.get("startsAt") - if not starts_at_str: + starts_at = price_data.get("startsAt") # Already datetime in local timezone + if not starts_at: continue - starts_at = dt_util.parse_datetime(starts_at_str) - if starts_at is None: - continue - - # Convert to local timezone for comparison - starts_at = dt_util.as_local(starts_at) - # Include price if it starts within the target day's local date boundaries if local_midnight <= starts_at < local_midnight_next_day: total_price = price_data.get("total") @@ -363,30 +373,19 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): price_info = self.coordinator.data.get("priceInfo", {}) - # Get local midnight boundaries based on the requested day - local_midnight = dt_util.as_local(dt_util.start_of_local_day(dt_util.now())) - if day == "tomorrow": - local_midnight = local_midnight + timedelta(days=1) - elif day == "yesterday": - local_midnight = local_midnight - timedelta(days=1) - local_midnight_next_day = local_midnight + timedelta(days=1) + # Get local midnight boundaries based on the requested day using TimeService + time = self.coordinator.time + local_midnight, local_midnight_next_day = time.get_day_boundaries(day) # Collect all intervals from both today and tomorrow data # that fall within the target day's local date boundaries day_intervals = [] for day_key in ["yesterday", "today", "tomorrow"]: for price_data in price_info.get(day_key, []): - starts_at_str = price_data.get("startsAt") - if not starts_at_str: + starts_at = price_data.get("startsAt") # Already datetime in local timezone + if not starts_at: continue - starts_at = dt_util.parse_datetime(starts_at_str) - if starts_at is None: - continue - - # Convert to local timezone for comparison - starts_at = dt_util.as_local(starts_at) - # Include interval if it starts within the target day's local date boundaries if local_midnight <= starts_at < local_midnight_next_day: day_intervals.append(price_data) @@ -482,7 +481,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): Average price in minor currency units (e.g., cents), or None if unavailable """ - avg_price = calculate_next_n_hours_avg(self.coordinator.data, hours) + avg_price = calculate_next_n_hours_avg(self.coordinator.data, hours, time=self.coordinator.time) if avg_price is None: return None @@ -490,7 +489,16 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): return round(avg_price * 100, 2) def _get_data_timestamp(self) -> datetime | None: - """Get the latest data timestamp.""" + """ + Get the latest data timestamp from price data. + + Returns timezone-aware datetime of the most recent price interval. + Home Assistant automatically displays TIMESTAMP sensors in user's timezone. + + Returns: + Latest interval timestamp (timezone-aware), or None if no data available. + + """ if not self.coordinator.data: return None @@ -499,11 +507,14 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): for day in ["today", "tomorrow"]: for price_data in price_info.get(day, []): - timestamp = datetime.fromisoformat(price_data["startsAt"]) - if not latest_timestamp or timestamp > latest_timestamp: - latest_timestamp = timestamp + starts_at = price_data.get("startsAt") # Already datetime in local timezone + if not starts_at: + continue + if not latest_timestamp or starts_at > latest_timestamp: + latest_timestamp = starts_at - return dt_util.as_utc(latest_timestamp) if latest_timestamp else None + # Return timezone-aware datetime (HA handles timezone display automatically) + return latest_timestamp def _get_volatility_value(self, *, volatility_type: str) -> str | None: """ @@ -529,7 +540,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): } # Get prices based on volatility type - prices_to_analyze = get_prices_for_volatility(volatility_type, price_info) + prices_to_analyze = get_prices_for_volatility(volatility_type, price_info, time=self.coordinator.time) if not prices_to_analyze: return None @@ -560,7 +571,9 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): add_icon_color_attribute(self._last_volatility_attributes, key="volatility", state_value=volatility) # Add type-specific attributes - add_volatility_type_attributes(self._last_volatility_attributes, volatility_type, price_info, thresholds) + add_volatility_type_attributes( + self._last_volatility_attributes, volatility_type, price_info, thresholds, time=self.coordinator.time + ) # Return lowercase for ENUM device class return volatility.lower() @@ -572,7 +585,9 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): # Add method to get future price intervals def _get_price_forecast_value(self) -> str | None: """Get the highest or lowest price status for the price forecast entity.""" - future_prices = get_future_prices(self.coordinator, max_intervals=MAX_FORECAST_INTERVALS) + future_prices = get_future_prices( + self.coordinator, max_intervals=MAX_FORECAST_INTERVALS, time=self.coordinator.time + ) if not future_prices: return "No forecast data available" @@ -726,29 +741,30 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): """Check if the current time is within a best price period.""" if not self.coordinator.data: return False - attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=False) + attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=False, time=self.coordinator.time) if not attrs: return False start = attrs.get("start") end = attrs.get("end") if not start or not end: return False - now = dt_util.now() + time = self.coordinator.time + now = time.now() return start <= now < end def _is_peak_price_period_active(self) -> bool: """Check if the current time is within a peak price period.""" if not self.coordinator.data: return False - attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=True) + attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=True, time=self.coordinator.time) if not attrs: return False start = attrs.get("start") end = attrs.get("end") if not start or not end: return False - now = dt_util.now() - return start <= now < end + time = self.coordinator.time + return time.is_current_interval(start, end) @property def icon(self) -> str | None: @@ -790,6 +806,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): context=IconContext( coordinator_data=self.coordinator.data, period_is_active_callback=period_is_active_callback, + time=self.coordinator.time, ), ) @@ -806,6 +823,8 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): # Get sensor-specific attributes sensor_attrs = self._get_sensor_attributes() + time = self.coordinator.time + # Build complete attributes using unified builder return build_extra_state_attributes( entity_key=self.entity_description.key, @@ -814,6 +833,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): config_entry=self.coordinator.config_entry, coordinator_data=self.coordinator.data, sensor_attrs=sensor_attrs, + time=time, ) except (KeyError, ValueError, TypeError) as ex: @@ -891,7 +911,8 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): config_entry=self.coordinator.config_entry, ) self._chart_data_response = response - self._chart_data_last_update = dt_util.now() + time = self.coordinator.time + self._chart_data_last_update = time.now() self._chart_data_error = error # Trigger state update after refresh self.async_write_ha_state() diff --git a/custom_components/tibber_prices/sensor/helpers.py b/custom_components/tibber_prices/sensor/helpers.py index d1f750e..79e9b9c 100644 --- a/custom_components/tibber_prices/sensor/helpers.py +++ b/custom_components/tibber_prices/sensor/helpers.py @@ -21,12 +21,14 @@ from __future__ import annotations from datetime import timedelta from typing import TYPE_CHECKING +if TYPE_CHECKING: + from custom_components.tibber_prices.coordinator.time_service import TimeService + from custom_components.tibber_prices.entity_utils.helpers import get_price_value from custom_components.tibber_prices.utils.price import ( aggregate_price_levels, aggregate_price_rating, ) -from homeassistant.util import dt as dt_util if TYPE_CHECKING: from collections.abc import Callable @@ -132,6 +134,7 @@ def get_hourly_price_value( *, hour_offset: int, in_euro: bool, + time: TimeService, ) -> float | None: """ Get price for current hour or with offset. @@ -143,13 +146,14 @@ def get_hourly_price_value( price_info: Price information dict with 'today' and 'tomorrow' keys hour_offset: Hour offset from current time (positive=future, negative=past) in_euro: If True, return price in major currency (EUR), else minor (cents/øre) + time: TimeService instance (required) Returns: Price value, or None if not found """ - # Use HomeAssistant's dt_util to get the current time in the user's timezone - now = dt_util.now() + # Use TimeService to get the current time in the user's timezone + now = time.now() # Calculate the exact target datetime (not just the hour) # This properly handles day boundaries @@ -162,12 +166,11 @@ def get_hourly_price_value( for price_data in price_info.get(day_key, []): # Parse the timestamp and convert to local time - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at is None: continue # Make sure it's in the local timezone for proper comparison - starts_at = dt_util.as_local(starts_at) # Compare using both hour and date for accuracy if starts_at.hour == target_hour and starts_at.date() == target_date: @@ -177,11 +180,10 @@ def get_hourly_price_value( # This is a fallback for potential edge cases other_day_key = "today" if day_key == "tomorrow" else "tomorrow" for price_data in price_info.get(other_day_key, []): - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) if starts_at.hour == target_hour and starts_at.date() == target_date: return get_price_value(float(price_data["total"]), in_euro=in_euro) diff --git a/custom_components/tibber_prices/services/chartdata.py b/custom_components/tibber_prices/services/chartdata.py index 4643550..3ac0ba6 100644 --- a/custom_components/tibber_prices/services/chartdata.py +++ b/custom_components/tibber_prices/services/chartdata.py @@ -43,7 +43,6 @@ from custom_components.tibber_prices.const import ( PRICE_RATING_NORMAL, ) from homeassistant.exceptions import ServiceValidationError -from homeassistant.util import dt as dt_util from .formatters import aggregate_hourly_exact, get_period_data, normalize_level_filter, normalize_rating_level_filter from .helpers import get_entry_and_data @@ -227,7 +226,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091 current = start while current < end: period_timestamps.add(current.isoformat()) - current = current + timedelta(minutes=15) + current = current + coordinator.time.get_interval_duration() # Collect all timestamps if insert_nulls='all' (needed to insert NULLs for missing filter matches) all_timestamps = set() @@ -374,10 +373,9 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091 last_value = last_interval.get(filter_field) if last_start_time and last_price is not None and last_value in filter_values: - # Parse timestamp and calculate midnight of next day - last_dt = dt_util.parse_datetime(last_start_time) + # Timestamp is already datetime in local timezone + last_dt = last_start_time # Already datetime object if last_dt: - last_dt = dt_util.as_local(last_dt) # Calculate next day at 00:00 next_day = last_dt.replace(hour=0, minute=0, second=0, microsecond=0) next_day = next_day + timedelta(days=1) @@ -483,6 +481,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091 day_prices, start_time_field, price_field, + coordinator=coordinator, use_minor_currency=minor_currency, round_decimals=round_decimals, include_level=include_level, diff --git a/custom_components/tibber_prices/services/formatters.py b/custom_components/tibber_prices/services/formatters.py index d136b78..504cecc 100644 --- a/custom_components/tibber_prices/services/formatters.py +++ b/custom_components/tibber_prices/services/formatters.py @@ -28,7 +28,6 @@ from custom_components.tibber_prices.const import ( get_translation, ) from custom_components.tibber_prices.sensor.helpers import aggregate_level_data, aggregate_rating_data -from homeassistant.util import dt as dt_util def normalize_level_filter(value: list[str] | None) -> list[str] | None: @@ -50,6 +49,7 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915 start_time_field: str, price_field: str, *, + coordinator: Any, use_minor_currency: bool = False, round_decimals: int | None = None, include_level: bool = False, @@ -75,6 +75,7 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915 intervals: List of 15-minute price intervals start_time_field: Custom name for start time field price_field: Custom name for price field + coordinator: Data update coordinator instance (required) use_minor_currency: Convert to minor currency units (cents/øre) round_decimals: Optional decimal rounding include_level: Include aggregated level field @@ -108,8 +109,9 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915 i += 1 continue - # Parse the timestamp - start_time = dt_util.parse_datetime(start_time_str) + # Get timestamp (already datetime in local timezone) + time = coordinator.time + start_time = start_time_str # Already datetime object if not start_time: i += 1 continue @@ -119,10 +121,11 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915 i += 1 continue - # Collect 4 intervals for this hour (with optional filtering) + # Collect intervals for this hour (with optional filtering) + intervals_per_hour = time.minutes_to_intervals(60) hour_intervals = [] hour_interval_data = [] # Complete interval data for aggregation functions - for j in range(4): + for j in range(intervals_per_hour): if i + j < len(intervals): interval = intervals[i + j] @@ -180,8 +183,8 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915 hourly_data.append(data_point) - # Move to next hour (skip 4 intervals) - i += 4 + # Move to next hour (skip intervals_per_hour) + i += time.minutes_to_intervals(60) return hourly_data @@ -260,14 +263,11 @@ def get_period_data( # noqa: PLR0913, PLR0912, PLR0915 price_info = coordinator.data.get("priceInfo", {}) day_prices = price_info.get(day, []) if day_prices: - # Extract date from first interval + # Extract date from first interval (already datetime in local timezone) first_interval = day_prices[0] - starts_at = first_interval.get("startsAt") + starts_at = first_interval.get("startsAt") # Already datetime object if starts_at: - dt = dt_util.parse_datetime(starts_at) - if dt: - dt = dt_util.as_local(dt) - allowed_dates.add(dt.date()) + allowed_dates.add(starts_at.date()) # Filter periods to those within allowed dates for period in period_summaries: diff --git a/custom_components/tibber_prices/utils/__init__.py b/custom_components/tibber_prices/utils/__init__.py index 5ee6323..060e125 100644 --- a/custom_components/tibber_prices/utils/__init__.py +++ b/custom_components/tibber_prices/utils/__init__.py @@ -24,7 +24,6 @@ from .average import ( calculate_current_trailing_max, calculate_current_trailing_min, calculate_next_n_hours_avg, - round_to_nearest_quarter_hour, ) from .price import ( aggregate_period_levels, @@ -59,5 +58,4 @@ __all__ = [ "calculate_volatility_level", "enrich_price_info_with_differences", "find_price_data_for_interval", - "round_to_nearest_quarter_hour", ] diff --git a/custom_components/tibber_prices/utils/average.py b/custom_components/tibber_prices/utils/average.py index 23f1053..a106dc5 100644 --- a/custom_components/tibber_prices/utils/average.py +++ b/custom_components/tibber_prices/utils/average.py @@ -3,73 +3,10 @@ from __future__ import annotations from datetime import datetime, timedelta +from typing import TYPE_CHECKING -from homeassistant.util import dt as dt_util - -# Constants -INTERVALS_PER_DAY = 96 # 24 hours * 4 intervals per hour - - -def round_to_nearest_quarter_hour(dt: datetime) -> datetime: - """ - Round datetime to nearest 15-minute boundary with smart tolerance. - - This handles edge cases where HA schedules us slightly before the boundary - (e.g., 14:59:59.500), while avoiding premature rounding during normal operation. - - Strategy: - - If within ±2 seconds of a boundary → round to that boundary - - Otherwise → floor to current interval start - - Examples: - - 14:59:57.999 → 15:00:00 (within 2s of boundary) - - 14:59:59.999 → 15:00:00 (within 2s of boundary) - - 14:59:30.000 → 14:45:00 (NOT within 2s, stay in current) - - 15:00:00.000 → 15:00:00 (exact boundary) - - 15:00:01.500 → 15:00:00 (within 2s of boundary) - - Args: - dt: Datetime to round - - Returns: - Datetime rounded to appropriate 15-minute boundary - - """ - # Calculate current interval start (floor) - total_seconds = dt.hour * 3600 + dt.minute * 60 + dt.second + dt.microsecond / 1_000_000 - interval_index = int(total_seconds // (15 * 60)) # Floor division - interval_start_seconds = interval_index * 15 * 60 - - # Calculate next interval start - next_interval_index = (interval_index + 1) % INTERVALS_PER_DAY - next_interval_start_seconds = next_interval_index * 15 * 60 - - # Distance to current interval start and next interval start - distance_to_current = total_seconds - interval_start_seconds - if next_interval_index == 0: # Midnight wrap - distance_to_next = (24 * 3600) - total_seconds - else: - distance_to_next = next_interval_start_seconds - total_seconds - - # Tolerance: If within 2 seconds of a boundary, snap to it - boundary_tolerance_seconds = 2.0 - - if distance_to_next <= boundary_tolerance_seconds: - # Very close to next boundary → use next interval - target_interval_index = next_interval_index - elif distance_to_current <= boundary_tolerance_seconds: - # Very close to current boundary (shouldn't happen in practice, but handle it) - target_interval_index = interval_index - else: - # Normal case: stay in current interval - target_interval_index = interval_index - - # Convert back to time - target_minutes = target_interval_index * 15 - target_hour = int(target_minutes // 60) - target_minute = int(target_minutes % 60) - - return dt.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0) +if TYPE_CHECKING: + from custom_components.tibber_prices.coordinator.time_service import TimeService def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime) -> float: @@ -79,6 +16,7 @@ def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime) Args: all_prices: List of all price data (yesterday, today, tomorrow combined) interval_start: Start time of the interval to calculate average for + time: TimeService instance (required) Returns: Average price for the 24 hours preceding the interval (not including the interval itself) @@ -91,10 +29,9 @@ def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime) # Filter prices within the 24-hour window prices_in_window = [] for price_data in all_prices: - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = price_data["startsAt"] # Already datetime object in local timezone if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) # Include intervals that start within the window (not including the current interval's end) if window_start <= starts_at < window_end: prices_in_window.append(float(price_data["total"])) @@ -112,6 +49,7 @@ def calculate_leading_24h_avg(all_prices: list[dict], interval_start: datetime) Args: all_prices: List of all price data (yesterday, today, tomorrow combined) interval_start: Start time of the interval to calculate average for + time: TimeService instance (required) Returns: Average price for up to 24 hours following the interval (including the interval itself) @@ -124,10 +62,9 @@ def calculate_leading_24h_avg(all_prices: list[dict], interval_start: datetime) # Filter prices within the 24-hour window prices_in_window = [] for price_data in all_prices: - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = price_data["startsAt"] # Already datetime object in local timezone if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) # Include intervals that start within the window if window_start <= starts_at < window_end: prices_in_window.append(float(price_data["total"])) @@ -138,12 +75,17 @@ def calculate_leading_24h_avg(all_prices: list[dict], interval_start: datetime) return 0.0 -def calculate_current_trailing_avg(coordinator_data: dict) -> float | None: +def calculate_current_trailing_avg( + coordinator_data: dict, + *, + time: TimeService, +) -> float | None: """ Calculate the trailing 24-hour average for the current time. Args: coordinator_data: The coordinator data containing priceInfo + time: TimeService instance (required) Returns: Current trailing 24-hour average price, or None if unavailable @@ -161,16 +103,21 @@ def calculate_current_trailing_avg(coordinator_data: dict) -> float | None: if not all_prices: return None - now = dt_util.now() + now = time.now() return calculate_trailing_24h_avg(all_prices, now) -def calculate_current_leading_avg(coordinator_data: dict) -> float | None: +def calculate_current_leading_avg( + coordinator_data: dict, + *, + time: TimeService, +) -> float | None: """ Calculate the leading 24-hour average for the current time. Args: coordinator_data: The coordinator data containing priceInfo + time: TimeService instance (required) Returns: Current leading 24-hour average price, or None if unavailable @@ -188,17 +135,23 @@ def calculate_current_leading_avg(coordinator_data: dict) -> float | None: if not all_prices: return None - now = dt_util.now() + now = time.now() return calculate_leading_24h_avg(all_prices, now) -def calculate_trailing_24h_min(all_prices: list[dict], interval_start: datetime) -> float: +def calculate_trailing_24h_min( + all_prices: list[dict], + interval_start: datetime, + *, + time: TimeService, +) -> float: """ Calculate trailing 24-hour minimum price for a given interval. Args: all_prices: List of all price data (yesterday, today, tomorrow combined) interval_start: Start time of the interval to calculate minimum for + time: TimeService instance (required) Returns: Minimum price for the 24 hours preceding the interval (not including the interval itself) @@ -211,10 +164,9 @@ def calculate_trailing_24h_min(all_prices: list[dict], interval_start: datetime) # Filter prices within the 24-hour window prices_in_window = [] for price_data in all_prices: - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) # Include intervals that start within the window (not including the current interval's end) if window_start <= starts_at < window_end: prices_in_window.append(float(price_data["total"])) @@ -225,13 +177,19 @@ def calculate_trailing_24h_min(all_prices: list[dict], interval_start: datetime) return 0.0 -def calculate_trailing_24h_max(all_prices: list[dict], interval_start: datetime) -> float: +def calculate_trailing_24h_max( + all_prices: list[dict], + interval_start: datetime, + *, + time: TimeService, +) -> float: """ Calculate trailing 24-hour maximum price for a given interval. Args: all_prices: List of all price data (yesterday, today, tomorrow combined) interval_start: Start time of the interval to calculate maximum for + time: TimeService instance (required) Returns: Maximum price for the 24 hours preceding the interval (not including the interval itself) @@ -244,10 +202,9 @@ def calculate_trailing_24h_max(all_prices: list[dict], interval_start: datetime) # Filter prices within the 24-hour window prices_in_window = [] for price_data in all_prices: - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) # Include intervals that start within the window (not including the current interval's end) if window_start <= starts_at < window_end: prices_in_window.append(float(price_data["total"])) @@ -258,13 +215,19 @@ def calculate_trailing_24h_max(all_prices: list[dict], interval_start: datetime) return 0.0 -def calculate_leading_24h_min(all_prices: list[dict], interval_start: datetime) -> float: +def calculate_leading_24h_min( + all_prices: list[dict], + interval_start: datetime, + *, + time: TimeService, +) -> float: """ Calculate leading 24-hour minimum price for a given interval. Args: all_prices: List of all price data (yesterday, today, tomorrow combined) interval_start: Start time of the interval to calculate minimum for + time: TimeService instance (required) Returns: Minimum price for up to 24 hours following the interval (including the interval itself) @@ -277,10 +240,9 @@ def calculate_leading_24h_min(all_prices: list[dict], interval_start: datetime) # Filter prices within the 24-hour window prices_in_window = [] for price_data in all_prices: - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) # Include intervals that start within the window if window_start <= starts_at < window_end: prices_in_window.append(float(price_data["total"])) @@ -291,13 +253,19 @@ def calculate_leading_24h_min(all_prices: list[dict], interval_start: datetime) return 0.0 -def calculate_leading_24h_max(all_prices: list[dict], interval_start: datetime) -> float: +def calculate_leading_24h_max( + all_prices: list[dict], + interval_start: datetime, + *, + time: TimeService, +) -> float: """ Calculate leading 24-hour maximum price for a given interval. Args: all_prices: List of all price data (yesterday, today, tomorrow combined) interval_start: Start time of the interval to calculate maximum for + time: TimeService instance (required) Returns: Maximum price for up to 24 hours following the interval (including the interval itself) @@ -310,10 +278,9 @@ def calculate_leading_24h_max(all_prices: list[dict], interval_start: datetime) # Filter prices within the 24-hour window prices_in_window = [] for price_data in all_prices: - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) # Include intervals that start within the window if window_start <= starts_at < window_end: prices_in_window.append(float(price_data["total"])) @@ -324,12 +291,17 @@ def calculate_leading_24h_max(all_prices: list[dict], interval_start: datetime) return 0.0 -def calculate_current_trailing_min(coordinator_data: dict) -> float | None: +def calculate_current_trailing_min( + coordinator_data: dict, + *, + time: TimeService, +) -> float | None: """ Calculate the trailing 24-hour minimum for the current time. Args: coordinator_data: The coordinator data containing priceInfo + time: TimeService instance (required) Returns: Current trailing 24-hour minimum price, or None if unavailable @@ -347,16 +319,21 @@ def calculate_current_trailing_min(coordinator_data: dict) -> float | None: if not all_prices: return None - now = dt_util.now() - return calculate_trailing_24h_min(all_prices, now) + now = time.now() + return calculate_trailing_24h_min(all_prices, now, time=time) -def calculate_current_trailing_max(coordinator_data: dict) -> float | None: +def calculate_current_trailing_max( + coordinator_data: dict, + *, + time: TimeService, +) -> float | None: """ Calculate the trailing 24-hour maximum for the current time. Args: coordinator_data: The coordinator data containing priceInfo + time: TimeService instance (required) Returns: Current trailing 24-hour maximum price, or None if unavailable @@ -374,16 +351,21 @@ def calculate_current_trailing_max(coordinator_data: dict) -> float | None: if not all_prices: return None - now = dt_util.now() - return calculate_trailing_24h_max(all_prices, now) + now = time.now() + return calculate_trailing_24h_max(all_prices, now, time=time) -def calculate_current_leading_min(coordinator_data: dict) -> float | None: +def calculate_current_leading_min( + coordinator_data: dict, + *, + time: TimeService, +) -> float | None: """ Calculate the leading 24-hour minimum for the current time. Args: coordinator_data: The coordinator data containing priceInfo + time: TimeService instance (required) Returns: Current leading 24-hour minimum price, or None if unavailable @@ -401,16 +383,21 @@ def calculate_current_leading_min(coordinator_data: dict) -> float | None: if not all_prices: return None - now = dt_util.now() - return calculate_leading_24h_min(all_prices, now) + now = time.now() + return calculate_leading_24h_min(all_prices, now, time=time) -def calculate_current_leading_max(coordinator_data: dict) -> float | None: +def calculate_current_leading_max( + coordinator_data: dict, + *, + time: TimeService, +) -> float | None: """ Calculate the leading 24-hour maximum for the current time. Args: coordinator_data: The coordinator data containing priceInfo + time: TimeService instance (required) Returns: Current leading 24-hour maximum price, or None if unavailable @@ -428,11 +415,16 @@ def calculate_current_leading_max(coordinator_data: dict) -> float | None: if not all_prices: return None - now = dt_util.now() - return calculate_leading_24h_max(all_prices, now) + now = time.now() + return calculate_leading_24h_max(all_prices, now, time=time) -def calculate_next_n_hours_avg(coordinator_data: dict, hours: int) -> float | None: +def calculate_next_n_hours_avg( + coordinator_data: dict, + hours: int, + *, + time: TimeService, +) -> float | None: """ Calculate average price for the next N hours starting from the next interval. @@ -442,6 +434,7 @@ def calculate_next_n_hours_avg(coordinator_data: dict, hours: int) -> float | No Args: coordinator_data: The coordinator data containing priceInfo hours: Number of hours to look ahead (1, 2, 3, 4, 5, 6, 8, 12, etc.) + time: TimeService instance (required) Returns: Average price for the next N hours, or None if insufficient data @@ -459,46 +452,38 @@ def calculate_next_n_hours_avg(coordinator_data: dict, hours: int) -> float | No if not all_prices: return None - now = dt_util.now() - # Find the current interval index current_idx = None for idx, price_data in enumerate(all_prices): - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) - interval_end = starts_at + timedelta(minutes=15) + interval_end = starts_at + time.get_interval_duration() - if starts_at <= now < interval_end: + if time.is_current_interval(starts_at, interval_end): current_idx = idx break if current_idx is None: return None - # Calculate how many 15-minute intervals are in N hours - intervals_needed = hours * 4 # 4 intervals per hour + # Calculate how many intervals are in N hours + intervals_needed = time.minutes_to_intervals(hours * 60) # Collect prices starting from NEXT interval (current_idx + 1) prices_in_window = [] for offset in range(1, intervals_needed + 1): idx = current_idx + offset - if idx < len(all_prices): - price = all_prices[idx].get("total") - if price is not None: - prices_in_window.append(float(price)) - else: + if idx >= len(all_prices): # Not enough future data available break + price = all_prices[idx].get("total") + if price is not None: + prices_in_window.append(float(price)) - # Only return average if we have data for the full requested period - if len(prices_in_window) >= intervals_needed: - return sum(prices_in_window) / len(prices_in_window) + # Return None if no data at all + if not prices_in_window: + return None - # If we don't have enough data for full period, return what we have - # (allows graceful degradation when tomorrow's data isn't available yet) - if prices_in_window: - return sum(prices_in_window) / len(prices_in_window) - - return None + # Return average (prefer full period, but allow graceful degradation) + return sum(prices_in_window) / len(prices_in_window) diff --git a/custom_components/tibber_prices/utils/price.py b/custom_components/tibber_prices/utils/price.py index acc54ae..28208ac 100644 --- a/custom_components/tibber_prices/utils/price.py +++ b/custom_components/tibber_prices/utils/price.py @@ -5,7 +5,10 @@ from __future__ import annotations import logging import statistics from datetime import datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from custom_components.tibber_prices.coordinator.time_service import TimeService from custom_components.tibber_prices.const import ( DEFAULT_VOLATILITY_THRESHOLD_HIGH, @@ -19,9 +22,6 @@ from custom_components.tibber_prices.const import ( VOLATILITY_MODERATE, VOLATILITY_VERY_HIGH, ) -from homeassistant.util import dt as dt_util - -from .average import round_to_nearest_quarter_hour _LOGGER = logging.getLogger(__name__) @@ -131,18 +131,10 @@ def calculate_trailing_average_for_interval( matching_prices = [] for price_data in all_prices: - starts_at_str = price_data.get("startsAt") - if not starts_at_str: + price_time = price_data.get("startsAt") # Already datetime object in local timezone + if not price_time: continue - # Parse the timestamp - price_time = dt_util.parse_datetime(starts_at_str) - if price_time is None: - continue - - # Convert to local timezone for comparison - price_time = dt_util.as_local(price_time) - # Check if this price falls within our lookback window # Include prices that start >= lookback_start and start < interval_start if lookback_start <= price_time < interval_start: @@ -244,15 +236,9 @@ def _process_price_interval( day_label: Label for logging ("today" or "tomorrow") """ - starts_at_str = price_interval.get("startsAt") - if not starts_at_str: + starts_at = price_interval.get("startsAt") # Already datetime object in local timezone + if not starts_at: return - - starts_at = dt_util.parse_datetime(starts_at_str) - if starts_at is None: - return - - starts_at = dt_util.as_local(starts_at) current_interval_price = price_interval.get("total") if current_interval_price is None: @@ -295,6 +281,7 @@ def enrich_price_info_with_differences( price_info: Dictionary with 'yesterday', 'today', 'tomorrow' keys threshold_low: Low threshold percentage for rating_level (defaults to -10) threshold_high: High threshold percentage for rating_level (defaults to 10) + time: TimeService instance (required) Returns: Updated price_info dict with 'difference' and 'rating_level' added @@ -333,13 +320,19 @@ def enrich_price_info_with_differences( return price_info -def find_price_data_for_interval(price_info: Any, target_time: datetime) -> dict | None: +def find_price_data_for_interval( + price_info: Any, + target_time: datetime, + *, + time: TimeService, +) -> dict | None: """ Find the price data for a specific 15-minute interval timestamp. Args: price_info: The price info dictionary from Tibber API target_time: The target timestamp to find price data for + time: TimeService instance (required) Returns: Price data dict if found, None otherwise @@ -347,9 +340,9 @@ def find_price_data_for_interval(price_info: Any, target_time: datetime) -> dict """ # Round to nearest quarter-hour to handle edge cases where we're called # slightly before the boundary (e.g., 14:59:59.999 → 15:00:00) - rounded_time = round_to_nearest_quarter_hour(target_time) + rounded_time = time.round_to_nearest_quarter(target_time) - day_key = "tomorrow" if rounded_time.date() > dt_util.now().date() else "today" + day_key = "tomorrow" if rounded_time.date() > time.now().date() else "today" search_days = [day_key, "tomorrow" if day_key == "today" else "today"] for search_day in search_days: @@ -358,11 +351,10 @@ def find_price_data_for_interval(price_info: Any, target_time: datetime) -> dict continue for price_data in day_prices: - starts_at = dt_util.parse_datetime(price_data["startsAt"]) + starts_at = time.get_interval_time(price_data) if starts_at is None: continue - starts_at = dt_util.as_local(starts_at) # Exact match after rounding if starts_at == rounded_time and starts_at.date() == rounded_time.date(): return price_data