refactor(coordinator): centralize time operations through TimeService

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.
This commit is contained in:
Julian Pawlowski 2025-11-19 18:36:12 +00:00
parent b3f91a67ce
commit 625bc222ca
48 changed files with 1737 additions and 680 deletions

View file

@ -7,12 +7,10 @@ import logging
import re import re
import socket import socket
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import TYPE_CHECKING, Any
import aiohttp import aiohttp
from homeassistant.util import dt as dt_util
from .exceptions import ( from .exceptions import (
TibberPricesApiClientAuthenticationError, TibberPricesApiClientAuthenticationError,
TibberPricesApiClientCommunicationError, TibberPricesApiClientCommunicationError,
@ -28,6 +26,9 @@ from .helpers import (
) )
from .queries import QueryType from .queries import QueryType
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TimeService
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -45,7 +46,8 @@ class TibberPricesApiClient:
self._session = session self._session = session
self._version = version self._version = version
self._request_semaphore = asyncio.Semaphore(2) # Max 2 concurrent requests 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._min_request_interval = timedelta(seconds=1) # Min 1 second between requests
self._max_retries = 5 self._max_retries = 5
self._retry_delay = 2 # Base retry delay in seconds self._retry_delay = 2 # Base retry delay in seconds
@ -208,6 +210,7 @@ class TibberPricesApiClient:
homes_data[home_id] = flatten_price_info( homes_data[home_id] = flatten_price_info(
home["currentSubscription"], home["currentSubscription"],
currency, currency,
time=self.time,
) )
else: else:
_LOGGER.debug( _LOGGER.debug(
@ -444,7 +447,9 @@ class TibberPricesApiClient:
) -> Any: ) -> Any:
"""Handle a single API request with rate limiting.""" """Handle a single API request with rate limiting."""
async with self._request_semaphore: async with self._request_semaphore:
now = dt_util.now() # 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 time_since_last_request = now - self._last_request_time
if time_since_last_request < self._min_request_interval: if time_since_last_request < self._min_request_interval:
sleep_time = (self._min_request_interval - time_since_last_request).total_seconds() sleep_time = (self._min_request_interval - time_since_last_request).total_seconds()
@ -454,7 +459,8 @@ class TibberPricesApiClient:
) )
await asyncio.sleep(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( return await self._make_request(
headers, headers,
data or {}, data or {},

View file

@ -7,11 +7,12 @@ from datetime import timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from homeassistant.const import __version__ as ha_version from homeassistant.const import __version__ as ha_version
from homeassistant.util import dt as dt_util
if TYPE_CHECKING: if TYPE_CHECKING:
import aiohttp import aiohttp
from custom_components.tibber_prices.coordinator.time_service import TimeService
from .queries import QueryType from .queries import QueryType
from .exceptions import ( 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. 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 = subscription.get("priceInfo", {})
price_info_range = subscription.get("priceInfoRange", {}) price_info_range = subscription.get("priceInfoRange", {})
# Get today and yesterday dates using Home Assistant's dt_util # Get today and yesterday dates using TimeService
today_local = dt_util.now().date() today_local = time.now().date()
yesterday_local = today_local - timedelta(days=1) yesterday_local = today_local - timedelta(days=1)
_LOGGER.debug("Processing data for yesterday's date: %s", yesterday_local) _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 continue
price_data = edge["node"] price_data = edge["node"]
# Parse timestamp using dt_util for proper timezone handling # Parse timestamp using TimeService for proper timezone handling
starts_at = dt_util.parse_datetime(price_data["startsAt"]) starts_at = time.get_interval_time(price_data)
if starts_at is None: if starts_at is None:
_LOGGER.debug("Could not parse timestamp: %s", price_data["startsAt"]) _LOGGER.debug("Could not parse timestamp: %s", price_data["startsAt"])
continue continue
# Convert to local timezone
starts_at = dt_util.as_local(starts_at)
price_date = starts_at.date() price_date = starts_at.date()
# Only include prices from yesterday # Only include prices from yesterday

View file

@ -5,8 +5,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute 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: if TYPE_CHECKING:
from datetime import datetime from datetime import datetime
@ -14,15 +15,18 @@ if TYPE_CHECKING:
from custom_components.tibber_prices.data import TibberPricesConfigEntry from custom_components.tibber_prices.data import TibberPricesConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .definitions import MIN_TOMORROW_INTERVALS_15MIN
def get_tomorrow_data_available_attributes(
def get_tomorrow_data_available_attributes(coordinator_data: dict) -> dict | None: coordinator_data: dict,
*,
time: TimeService,
) -> dict | None:
""" """
Build attributes for tomorrow_data_available sensor. Build attributes for tomorrow_data_available sensor.
Args: Args:
coordinator_data: Coordinator data dict coordinator_data: Coordinator data dict
time: TimeService instance
Returns: Returns:
Attributes dict with intervals_available and data_status 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", []) tomorrow_prices = price_info.get("tomorrow", [])
interval_count = len(tomorrow_prices) 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: if interval_count == 0:
status = "none" status = "none"
elif interval_count == MIN_TOMORROW_INTERVALS_15MIN: elif interval_count == expected_intervals:
status = "full" status = "full"
else: else:
status = "partial" status = "partial"
@ -51,6 +59,7 @@ def get_tomorrow_data_available_attributes(coordinator_data: dict) -> dict | Non
def get_price_intervals_attributes( def get_price_intervals_attributes(
coordinator_data: dict, coordinator_data: dict,
*, *,
time: TimeService,
reverse_sort: bool, reverse_sort: bool,
) -> dict | None: ) -> dict | None:
""" """
@ -63,6 +72,7 @@ def get_price_intervals_attributes(
Args: Args:
coordinator_data: Coordinator data dict coordinator_data: Coordinator data dict
time: TimeService instance (required)
reverse_sort: True for peak_price (highest first), False for best_price (lowest first) reverse_sort: True for peak_price (highest first), False for best_price (lowest first)
Returns: Returns:
@ -70,7 +80,7 @@ def get_price_intervals_attributes(
""" """
if not coordinator_data: if not coordinator_data:
return build_no_periods_result() return build_no_periods_result(time=time)
# Get precomputed period summaries from coordinator # Get precomputed period summaries from coordinator
periods_data = coordinator_data.get("periods", {}) periods_data = coordinator_data.get("periods", {})
@ -78,21 +88,20 @@ def get_price_intervals_attributes(
period_data = periods_data.get(period_type) period_data = periods_data.get(period_type)
if not period_data: if not period_data:
return build_no_periods_result() return build_no_periods_result(time=time)
period_summaries = period_data.get("periods", []) period_summaries = period_data.get("periods", [])
if not period_summaries: 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 # Find current or next period based on current time
now = dt_util.now()
current_period = None current_period = None
# First pass: find currently active period # First pass: find currently active period
for period in period_summaries: for period in period_summaries:
start = period.get("start") start = period.get("start")
end = period.get("end") 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 current_period = period
break break
@ -100,15 +109,15 @@ def get_price_intervals_attributes(
if not current_period: if not current_period:
for period in period_summaries: for period in period_summaries:
start = period.get("start") start = period.get("start")
if start and start > now: if start and time.is_in_future(start):
current_period = period current_period = period
break break
# Build final attributes # 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). 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 # Calculate timestamp: current time rounded down to last quarter hour
now = dt_util.now() now = time.now()
current_minute = (now.minute // 15) * 15 current_minute = (now.minute // 15) * 15
timestamp = now.replace(minute=current_minute, second=0, microsecond=0) 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( def build_final_attributes_simple(
current_period: dict | None, current_period: dict | None,
period_summaries: list[dict], period_summaries: list[dict],
*,
time: TimeService,
) -> dict: ) -> dict:
""" """
Build the final attributes dictionary from coordinator's period summaries. Build the final attributes dictionary from coordinator's period summaries.
@ -226,12 +237,13 @@ def build_final_attributes_simple(
Args: Args:
current_period: The current or next period (already complete from coordinator) current_period: The current or next period (already complete from coordinator)
period_summaries: All period summaries from coordinator period_summaries: All period summaries from coordinator
time: TimeService instance (required)
Returns: Returns:
Complete attributes dict with all fields Complete attributes dict with all fields
""" """
now = dt_util.now() now = time.now()
current_minute = (now.minute // 15) * 15 current_minute = (now.minute // 15) * 15
timestamp = now.replace(minute=current_minute, second=0, microsecond=0) 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, translation_key: str | None,
hass: HomeAssistant, hass: HomeAssistant,
*, *,
time: TimeService,
config_entry: TibberPricesConfigEntry, config_entry: TibberPricesConfigEntry,
sensor_attrs: dict | None = None, sensor_attrs: dict | None = None,
is_on: bool | 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") entity_key: Entity key (e.g., "best_price_period")
translation_key: Translation key for entity translation_key: Translation key for entity
hass: Home Assistant instance hass: Home Assistant instance
time: TimeService instance (required)
config_entry: Config entry with options (keyword-only) config_entry: Config entry with options (keyword-only)
sensor_attrs: Sensor-specific attributes (keyword-only) sensor_attrs: Sensor-specific attributes (keyword-only)
is_on: Binary sensor state (keyword-only) is_on: Binary sensor state (keyword-only)
Returns: Returns:
Complete attributes dict with descriptions Complete attributes dict with descriptions (synchronous)
""" """
# Calculate default timestamp: current time rounded to nearest quarter hour # 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 # 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 # Individual sensors can override this via sensor_attrs if needed
now = dt_util.now() now = time.now()
default_timestamp = round_to_nearest_quarter_hour(now) default_timestamp = time.round_to_nearest_quarter(now)
attributes = { attributes = {
"timestamp": default_timestamp.isoformat(), "timestamp": default_timestamp,
} }
# Add sensor-specific attributes (may override timestamp) # Add sensor-specific attributes (may override timestamp)
@ -335,6 +349,7 @@ def build_sync_extra_state_attributes( # noqa: PLR0913
translation_key: str | None, translation_key: str | None,
hass: HomeAssistant, hass: HomeAssistant,
*, *,
time: TimeService,
config_entry: TibberPricesConfigEntry, config_entry: TibberPricesConfigEntry,
sensor_attrs: dict | None = None, sensor_attrs: dict | None = None,
is_on: bool | 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") entity_key: Entity key (e.g., "best_price_period")
translation_key: Translation key for entity translation_key: Translation key for entity
hass: Home Assistant instance hass: Home Assistant instance
time: TimeService instance (required)
config_entry: Config entry with options (keyword-only) config_entry: Config entry with options (keyword-only)
sensor_attrs: Sensor-specific attributes (keyword-only) sensor_attrs: Sensor-specific attributes (keyword-only)
is_on: Binary sensor state (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 # 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 # 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 # Individual sensors can override this via sensor_attrs if needed
now = dt_util.now() now = time.now()
default_timestamp = round_to_nearest_quarter_hour(now) default_timestamp = time.round_to_nearest_quarter(now)
attributes = { attributes = {
"timestamp": default_timestamp.isoformat(), "timestamp": default_timestamp,
} }
# Add sensor-specific attributes (may override timestamp) # Add sensor-specific attributes (may override timestamp)

View file

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from custom_components.tibber_prices.coordinator import TIME_SENSITIVE_ENTITY_KEYS from custom_components.tibber_prices.coordinator import TIME_SENSITIVE_ENTITY_KEYS
@ -13,7 +12,6 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription, BinarySensorEntityDescription,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.util import dt as dt_util
from .attributes import ( from .attributes import (
build_async_extra_state_attributes, build_async_extra_state_attributes,
@ -21,10 +19,7 @@ from .attributes import (
get_price_intervals_attributes, get_price_intervals_attributes,
get_tomorrow_data_available_attributes, get_tomorrow_data_available_attributes,
) )
from .definitions import ( from .definitions import PERIOD_LOOKAHEAD_HOURS
MIN_TOMORROW_INTERVALS_15MIN,
PERIOD_LOOKAHEAD_HOURS,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@ -32,6 +27,7 @@ if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator import ( from custom_components.tibber_prices.coordinator import (
TibberPricesDataUpdateCoordinator, TibberPricesDataUpdateCoordinator,
) )
from custom_components.tibber_prices.coordinator.time_service import TimeService
class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
@ -69,8 +65,17 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
self._time_sensitive_remove_listener = None self._time_sensitive_remove_listener = None
@callback @callback
def _handle_time_sensitive_update(self) -> None: def _handle_time_sensitive_update(self, time_service: TimeService) -> None:
"""Handle time-sensitive update from coordinator.""" """
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() self.async_write_ha_state()
def _get_value_getter(self) -> Callable | None: 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.""" """Return True if the current time is within a best price period."""
if not self.coordinator.data: if not self.coordinator.data:
return None 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: if not attrs:
return False # Should not happen, but safety fallback return False # Should not happen, but safety fallback
start = attrs.get("start") start = attrs.get("start")
end = attrs.get("end") end = attrs.get("end")
if not start or not end: if not start or not end:
return False # No period found = sensor is off return False # No period found = sensor is off
now = dt_util.now() time = self.coordinator.time
return start <= now < end return time.is_time_in_period(start, end)
def _peak_price_state(self) -> bool | None: def _peak_price_state(self) -> bool | None:
"""Return True if the current time is within a peak price period.""" """Return True if the current time is within a peak price period."""
if not self.coordinator.data: if not self.coordinator.data:
return None 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: if not attrs:
return False # Should not happen, but safety fallback return False # Should not happen, but safety fallback
start = attrs.get("start") start = attrs.get("start")
end = attrs.get("end") end = attrs.get("end")
if not start or not end: if not start or not end:
return False # No period found = sensor is off return False # No period found = sensor is off
now = dt_util.now() time = self.coordinator.time
return start <= now < end return time.is_time_in_period(start, end)
def _tomorrow_data_available_state(self) -> bool | None: def _tomorrow_data_available_state(self) -> bool | None:
"""Return True if tomorrow's data is fully available, False if not, None if unknown.""" """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", {}) price_info = self.coordinator.data.get("priceInfo", {})
tomorrow_prices = price_info.get("tomorrow", []) tomorrow_prices = price_info.get("tomorrow", [])
interval_count = len(tomorrow_prices) 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 return True
if interval_count == 0: if interval_count == 0:
return False return False
@ -175,7 +185,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
def _get_tomorrow_data_available_attributes(self) -> dict | None: def _get_tomorrow_data_available_attributes(self) -> dict | None:
"""Return attributes for tomorrow_data_available binary sensor.""" """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: def _get_sensor_attributes(self) -> dict | None:
""" """
@ -187,9 +197,9 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
key = self.entity_description.key key = self.entity_description.key
if key == "peak_price_period": 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": 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": if key == "tomorrow_data_available":
return self._get_tomorrow_data_available_attributes() return self._get_tomorrow_data_available_attributes()
@ -249,21 +259,18 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
if not attrs or "periods" not in attrs: if not attrs or "periods" not in attrs:
return False return False
now = dt_util.now() time = self.coordinator.time
horizon = now + timedelta(hours=PERIOD_LOOKAHEAD_HOURS)
periods = attrs.get("periods", []) periods = attrs.get("periods", [])
# Check if any period starts within the look-ahead window # Check if any period starts within the look-ahead window
for period in periods: for period in periods:
start_str = period.get("start") start_str = period.get("start")
if start_str: if start_str:
# Parse datetime if it's a string, otherwise use as-is # Already datetime object (periods come from coordinator.data)
start_time = dt_util.parse_datetime(start_str) if isinstance(start_str, str) else start_str 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 # Period starts in the future but within our horizon
if now < start_time_local <= horizon: if start_time and time.is_time_within_horizon(start_time, hours=PERIOD_LOOKAHEAD_HOURS):
return True return True
return False return False
@ -286,6 +293,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
config_entry=self.coordinator.config_entry, config_entry=self.coordinator.config_entry,
sensor_attrs=sensor_attrs, sensor_attrs=sensor_attrs,
is_on=self.is_on, is_on=self.is_on,
time=self.coordinator.time,
) )
except (KeyError, ValueError, TypeError) as ex: except (KeyError, ValueError, TypeError) as ex:
@ -316,6 +324,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
config_entry=self.coordinator.config_entry, config_entry=self.coordinator.config_entry,
sensor_attrs=sensor_attrs, sensor_attrs=sensor_attrs,
is_on=self.is_on, is_on=self.is_on,
time=self.coordinator.time,
) )
except (KeyError, ValueError, TypeError) as ex: except (KeyError, ValueError, TypeError) as ex:

View file

@ -8,9 +8,6 @@ from homeassistant.components.binary_sensor import (
) )
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
# Constants
MIN_TOMORROW_INTERVALS_15MIN = 96
# Look-ahead window for future period detection (hours) # Look-ahead window for future period detection (hours)
# Icons will show "waiting" state if a period starts within this window # Icons will show "waiting" state if a period starts within this window
PERIOD_LOOKAHEAD_HOURS = 6 PERIOD_LOOKAHEAD_HOURS = 6

View file

@ -18,9 +18,6 @@ from homeassistant.core import HomeAssistant
DOMAIN = "tibber_prices" DOMAIN = "tibber_prices"
# Time constants
MINUTES_PER_INTERVAL = 15 # Tibber uses 15-minute intervals for price data
# Configuration keys # Configuration keys
CONF_EXTENDED_DESCRIPTIONS = "extended_descriptions" CONF_EXTENDED_DESCRIPTIONS = "extended_descriptions"
CONF_BEST_PRICE_FLEX = "best_price_flex" CONF_BEST_PRICE_FLEX = "best_price_flex"

View file

@ -22,10 +22,12 @@ from .constants import (
TIME_SENSITIVE_ENTITY_KEYS, TIME_SENSITIVE_ENTITY_KEYS,
) )
from .core import TibberPricesDataUpdateCoordinator from .core import TibberPricesDataUpdateCoordinator
from .time_service import TimeService
__all__ = [ __all__ = [
"MINUTE_UPDATE_ENTITY_KEYS", "MINUTE_UPDATE_ENTITY_KEYS",
"STORAGE_VERSION", "STORAGE_VERSION",
"TIME_SENSITIVE_ENTITY_KEYS", "TIME_SENSITIVE_ENTITY_KEYS",
"TibberPricesDataUpdateCoordinator", "TibberPricesDataUpdateCoordinator",
"TimeService",
] ]

View file

@ -5,13 +5,13 @@ from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING, Any, NamedTuple from typing import TYPE_CHECKING, Any, NamedTuple
from homeassistant.util import dt as dt_util
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import datetime from datetime import datetime
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from .time_service import TimeService
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -28,6 +28,8 @@ class CacheData(NamedTuple):
async def load_cache( async def load_cache(
store: Store, store: Store,
log_prefix: str, log_prefix: str,
*,
time: TimeService,
) -> CacheData: ) -> CacheData:
"""Load cached data from storage.""" """Load cached data from storage."""
try: try:
@ -42,11 +44,11 @@ async def load_cache(
last_midnight_check = None last_midnight_check = None
if last_price_update_str := stored.get("last_price_update"): 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"): 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"): 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) _LOGGER.debug("%s Cache loaded successfully", log_prefix)
return CacheData( return CacheData(
@ -94,6 +96,8 @@ async def store_cache(
def is_cache_valid( def is_cache_valid(
cache_data: CacheData, cache_data: CacheData,
log_prefix: str, log_prefix: str,
*,
time: TimeService,
) -> bool: ) -> bool:
""" """
Validate if cached price data is still current. 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: if cache_data.price_data is None or cache_data.last_price_update is None:
return False return False
current_local_date = dt_util.as_local(dt_util.now()).date() current_local_date = time.as_local(time.now()).date()
last_update_local_date = dt_util.as_local(cache_data.last_price_update).date() last_update_local_date = time.as_local(cache_data.last_price_update).date()
if current_local_date != last_update_local_date: if current_local_date != last_update_local_date:
_LOGGER.debug( _LOGGER.debug(

View file

@ -11,7 +11,6 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import date, datetime from datetime import date, datetime
@ -39,6 +38,7 @@ from .data_fetching import DataFetcher
from .data_transformation import DataTransformer from .data_transformation import DataTransformer
from .listeners import ListenerManager from .listeners import ListenerManager
from .periods import PeriodCalculator from .periods import PeriodCalculator
from .time_service import TimeService
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -134,6 +134,12 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Track if this is the main entry (first one created) # Track if this is the main entry (first one created)
self._is_main_entry = not self._has_existing_main_coordinator() 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 # Initialize helper modules
self._listener_manager = ListenerManager(hass, self._log_prefix) self._listener_manager = ListenerManager(hass, self._log_prefix)
self._data_fetcher = DataFetcher( self._data_fetcher = DataFetcher(
@ -141,11 +147,13 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
store=self._store, store=self._store,
log_prefix=self._log_prefix, log_prefix=self._log_prefix,
user_update_interval=timedelta(days=1), user_update_interval=timedelta(days=1),
time=self.time,
) )
self._data_transformer = DataTransformer( self._data_transformer = DataTransformer(
config_entry=config_entry, config_entry=config_entry,
log_prefix=self._log_prefix, log_prefix=self._log_prefix,
perform_turnover_fn=self._perform_midnight_turnover, perform_turnover_fn=self._perform_midnight_turnover,
time=self.time,
) )
self._period_calculator = PeriodCalculator( self._period_calculator = PeriodCalculator(
config_entry=config_entry, 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) return self._listener_manager.async_add_time_sensitive_listener(update_callback)
@callback @callback
def _async_update_time_sensitive_listeners(self) -> None: def _async_update_time_sensitive_listeners(self, time_service: TimeService) -> None:
"""Update all time-sensitive entities without triggering a full coordinator update.""" """
self._listener_manager.async_update_time_sensitive_listeners() 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 @callback
def async_add_minute_update_listener(self, update_callback: CALLBACK_TYPE) -> CALLBACK_TYPE: 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) return self._listener_manager.async_add_minute_update_listener(update_callback)
@callback @callback
def _async_update_minute_listeners(self) -> None: def _async_update_minute_listeners(self, time_service: TimeService) -> None:
"""Update all minute-update entities without triggering a full coordinator update.""" """
self._listener_manager.async_update_minute_listeners() 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 @callback
def _handle_quarter_hour_refresh(self, _now: datetime | None = None) -> None: 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). 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. 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()) self._log("debug", "[Timer #2] Quarter-hour refresh triggered at %s", now.isoformat())
# Check if midnight has passed since last check # Check if midnight has passed since last check
@ -251,28 +287,38 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
else: else:
# Regular quarter-hour refresh - only update time-sensitive entities # Regular quarter-hour refresh - only update time-sensitive entities
# (Midnight turnover was either not needed, or already done by Timer #1) # (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 @callback
def _handle_minute_refresh(self, _now: datetime | None = None) -> None: 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 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: without async/await overhead because it performs only fast, non-blocking operations:
- Listener notifications for timing sensors (remaining_minutes, progress) - Listener notifications for timing sensors (remaining_minutes, progress)
NO I/O operations (no API calls, no file operations), so no need for async def. 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. 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) # Create LOCAL TimeService with fresh reference time for this 30-second refresh
self._log("debug", "[Timer #3] Minute refresh for timing sensors") # 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.) # 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: 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") 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 # Load cache if not already loaded
if self._cached_price_data is None and self._cached_user_data is None: if self._cached_price_data is None and self._cached_user_data is None:
await self._load_cache() await self._load_cache()
current_time = dt_util.utcnow()
# Initialize midnight check on first run # Initialize midnight check on first run
if self._last_midnight_check is None: if self._last_midnight_check is None:
self._last_midnight_check = current_time self._last_midnight_check = current_time
@ -514,7 +568,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
Updated price_info with rotated day data 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: async def _store_cache(self) -> None:
"""Store cache data.""" """Store cache data."""
@ -593,13 +647,13 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return True return True
# Check for midnight turnover # 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() current_date = now_local.date()
if self._last_midnight_check is None: if self._last_midnight_check is None:
return True 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() last_check_date = last_check_local.date()
if current_date != last_check_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]: 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).""" """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 # Return cached transformed data if no retransformation needed
if not self._should_retransform_data(current_time) and self._cached_transformed_data is not None: 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]: def _transform_data_for_subentry(self, main_data: dict[str, Any]) -> dict[str, Any]:
"""Transform main coordinator data for subentry (home-specific view).""" """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 # Return cached transformed data if no retransformation needed
if not self._should_retransform_data(current_time) and self._cached_transformed_data is not None: 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: if not price_info:
return None return None
now = dt_util.now() now = self.time.now()
return find_price_data_for_interval(price_info, now) return find_price_data_for_interval(price_info, now, time=self.time)
def get_all_intervals(self) -> list[dict[str, Any]]: def get_all_intervals(self) -> list[dict[str, Any]]:
"""Get all price intervals (today + tomorrow).""" """Get all price intervals (today + tomorrow)."""
@ -697,7 +751,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def refresh_user_data(self) -> bool: async def refresh_user_data(self) -> bool:
"""Force refresh of user data and return True if data was updated.""" """Force refresh of user data and return True if data was updated."""
try: try:
current_time = dt_util.utcnow() current_time = self.time.now()
self._log("info", "Forcing user data refresh (bypassing cache)") self._log("info", "Forcing user data refresh (bypassing cache)")
# Force update by calling API directly (bypass cache check) # Force update by calling API directly (bypass cache check)

View file

@ -5,9 +5,11 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import secrets import secrets
from datetime import timedelta
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from datetime import timedelta
from custom_components.tibber_prices.api import ( from custom_components.tibber_prices.api import (
TibberPricesApiClientAuthenticationError, TibberPricesApiClientAuthenticationError,
TibberPricesApiClientCommunicationError, TibberPricesApiClientCommunicationError,
@ -16,7 +18,6 @@ from custom_components.tibber_prices.api import (
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.helpers.update_coordinator import UpdateFailed
from homeassistant.util import dt as dt_util
from . import cache, helpers from . import cache, helpers
from .constants import TOMORROW_DATA_CHECK_HOUR, TOMORROW_DATA_RANDOM_DELAY_MAX 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 custom_components.tibber_prices.api import TibberPricesApiClient
from .time_service import TimeService
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -39,12 +42,14 @@ class DataFetcher:
store: Any, store: Any,
log_prefix: str, log_prefix: str,
user_update_interval: timedelta, user_update_interval: timedelta,
time: TimeService,
) -> None: ) -> None:
"""Initialize the data fetcher.""" """Initialize the data fetcher."""
self.api = api self.api = api
self._store = store self._store = store
self._log_prefix = log_prefix self._log_prefix = log_prefix
self._user_update_interval = user_update_interval self._user_update_interval = user_update_interval
self.time = time
# Cached data # Cached data
self._cached_price_data: dict[str, Any] | None = None self._cached_price_data: dict[str, Any] | None = None
@ -59,15 +64,19 @@ class DataFetcher:
async def load_cache(self) -> None: async def load_cache(self) -> None:
"""Load cached data from storage.""" """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_price_data = cache_data.price_data
self._cached_user_data = cache_data.user_data self._cached_user_data = cache_data.user_data
self._last_price_update = cache_data.last_price_update self._last_price_update = cache_data.last_price_update
self._last_user_update = cache_data.last_user_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 # 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._log("info", "Cached price data is from a previous day, clearing cache to fetch fresh data")
self._cached_price_data = None self._cached_price_data = None
self._last_price_update = None self._last_price_update = None
@ -128,8 +137,10 @@ class DataFetcher:
self._log("debug", "API update needed: No last price update timestamp") self._log("debug", "API update needed: No last price update timestamp")
return True return True
now_local = dt_util.as_local(current_time) # Get tomorrow's date using TimeService
tomorrow_date = (now_local + timedelta(days=1)).date() _, 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 # Check if after 13:00 and tomorrow data is missing or invalid
if ( if (
@ -154,12 +165,12 @@ class DataFetcher:
"""Check if tomorrow data is missing or invalid.""" """Check if tomorrow data is missing or invalid."""
return helpers.needs_tomorrow_data(self._cached_price_data, tomorrow_date) 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).""" """Fetch data for all homes (main coordinator only)."""
if not configured_home_ids: if not configured_home_ids:
self._log("warning", "No configured homes found - cannot fetch price data") self._log("warning", "No configured homes found - cannot fetch price data")
return { return {
"timestamp": dt_util.utcnow(), "timestamp": current_time,
"homes": {}, "homes": {},
} }
@ -186,7 +197,7 @@ class DataFetcher:
) )
return { return {
"timestamp": dt_util.utcnow(), "timestamp": current_time,
"homes": all_homes_data, "homes": all_homes_data,
} }
@ -216,8 +227,10 @@ class DataFetcher:
await asyncio.sleep(delay) await asyncio.sleep(delay)
self._log("debug", "Fetching fresh price data from API") self._log("debug", "Fetching fresh price data from API")
raw_data = await self.fetch_all_homes_data(configured_home_ids) raw_data = await self.fetch_all_homes_data(configured_home_ids, current_time)
# Cache the data # 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._cached_price_data = raw_data
self._last_price_update = current_time self._last_price_update = current_time
await self.store_cache() await self.store_cache()
@ -268,7 +281,7 @@ class DataFetcher:
Updated price_info with rotated day data 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 @property
def cached_price_data(self) -> dict[str, Any] | None: def cached_price_data(self) -> dict[str, Any] | None:

View file

@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices import const as _const from custom_components.tibber_prices import const as _const
from custom_components.tibber_prices.utils.price import enrich_price_info_with_differences from custom_components.tibber_prices.utils.price import enrich_price_info_with_differences
from homeassistant.util import dt as dt_util
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@ -15,6 +14,8 @@ if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from .time_service import TimeService
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -26,11 +27,13 @@ class DataTransformer:
config_entry: ConfigEntry, config_entry: ConfigEntry,
log_prefix: str, log_prefix: str,
perform_turnover_fn: Callable[[dict[str, Any]], dict[str, Any]], perform_turnover_fn: Callable[[dict[str, Any]], dict[str, Any]],
time: TimeService,
) -> None: ) -> None:
"""Initialize the data transformer.""" """Initialize the data transformer."""
self.config_entry = config_entry self.config_entry = config_entry
self._log_prefix = log_prefix self._log_prefix = log_prefix
self._perform_turnover_fn = perform_turnover_fn self._perform_turnover_fn = perform_turnover_fn
self.time = time
# Transformation cache # Transformation cache
self._cached_transformed_data: dict[str, Any] | None = None self._cached_transformed_data: dict[str, Any] | None = None
@ -122,13 +125,13 @@ class DataTransformer:
return True return True
# Check for midnight turnover # 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() current_date = now_local.date()
if self._last_midnight_check is None: if self._last_midnight_check is None:
return True 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() last_check_date = last_check_local.date()
if current_date != last_check_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]: 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).""" """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 # Return cached transformed data if no retransformation needed
if not self._should_retransform_data(current_time) and self._cached_transformed_data is not None: 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]: 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).""" """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 # Return cached transformed data if no retransformation needed
if not self._should_retransform_data(current_time) and self._cached_transformed_data is not None: if not self._should_retransform_data(current_time) and self._cached_transformed_data is not None:

View file

@ -2,17 +2,20 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from homeassistant.util import dt as dt_util
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import date from datetime import date
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .time_service import TimeService
from custom_components.tibber_prices.const import DOMAIN from custom_components.tibber_prices.const import DOMAIN
_LOGGER = logging.getLogger(__name__)
def get_configured_home_ids(hass: HomeAssistant) -> set[str]: def get_configured_home_ids(hass: HomeAssistant) -> set[str]:
"""Get all home_ids that have active config entries (main + subentries).""" """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 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.""" """Check if tomorrow data is missing or invalid."""
if not cached_price_data or "homes" not in cached_price_data: if not cached_price_data or "homes" not in cached_price_data:
return False return False
# Use provided TimeService or create new one
for home_data in cached_price_data["homes"].values(): for home_data in cached_price_data["homes"].values():
price_info = home_data.get("price_info", {}) price_info = home_data.get("price_info", {})
tomorrow_prices = price_info.get("tomorrow", []) 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) # Check if tomorrow data is actually for tomorrow (validate date)
first_price = tomorrow_prices[0] first_price = tomorrow_prices[0]
if starts_at := first_price.get("startsAt"): if starts_at := first_price.get("startsAt"): # Already datetime in local timezone
price_time = dt_util.parse_datetime(starts_at) price_date = starts_at.date()
if price_time:
price_date = dt_util.as_local(price_time).date()
if price_date != tomorrow_date: if price_date != tomorrow_date:
return True return True
return False 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. Perform midnight turnover on price data.
@ -63,12 +69,15 @@ def perform_midnight_turnover(price_info: dict[str, Any]) -> dict[str, Any]:
Args: Args:
price_info: The price info dict with 'today', 'tomorrow', 'yesterday' keys price_info: The price info dict with 'today', 'tomorrow', 'yesterday' keys
time: TimeService instance (required)
Returns: Returns:
Updated price_info with rotated day data 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 # Extract current data
today_prices = price_info.get("today", []) today_prices = price_info.get("today", [])
@ -77,11 +86,9 @@ def perform_midnight_turnover(price_info: dict[str, Any]) -> dict[str, Any]:
# Check if any of today's prices are from the previous day # Check if any of today's prices are from the previous day
prices_need_rotation = False prices_need_rotation = False
if today_prices: if today_prices:
first_today_price_str = today_prices[0].get("startsAt") first_today_price = today_prices[0].get("startsAt") # Already datetime in local timezone
if first_today_price_str: if first_today_price:
first_today_price_time = dt_util.parse_datetime(first_today_price_str) first_today_price_date = first_today_price.date()
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 prices_need_rotation = first_today_price_date < current_local_date
if prices_need_rotation: if prices_need_rotation:
@ -92,4 +99,43 @@ def perform_midnight_turnover(price_info: dict[str, Any]) -> dict[str, Any]:
"currency": price_info.get("currency", "EUR"), "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 return price_info

View file

@ -15,6 +15,8 @@ if TYPE_CHECKING:
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .time_service import TimeService
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -64,10 +66,16 @@ class ListenerManager:
return remove_listener return remove_listener
@callback @callback
def async_update_time_sensitive_listeners(self) -> None: def async_update_time_sensitive_listeners(self, time_service: TimeService) -> None:
"""Update all time-sensitive entities without triggering a full coordinator update.""" """
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: for update_callback in self._time_sensitive_listeners:
update_callback() update_callback(time_service)
self._log( self._log(
"debug", "debug",
@ -97,14 +105,20 @@ class ListenerManager:
return remove_listener return remove_listener
@callback @callback
def async_update_minute_listeners(self) -> None: def async_update_minute_listeners(self, time_service: TimeService) -> None:
"""Update all minute-update entities without triggering a full coordinator update.""" """
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: for update_callback in self._minute_update_listeners:
update_callback() update_callback(time_service)
self._log( self._log(
"debug", "debug",
"Updated %d minute-update entities", "Updated %d timing entities (30-second update)",
len(self._minute_update_listeners), len(self._minute_update_listeners),
) )
@ -139,25 +153,24 @@ class ListenerManager:
self, self,
handler_callback: CALLBACK_TYPE, handler_callback: CALLBACK_TYPE,
) -> None: ) -> None:
"""Schedule minute-by-minute entity refresh for timing sensors.""" """Schedule 30-second entity refresh for timing sensors."""
# Cancel any existing timer # Cancel any existing timer
if self._minute_timer_cancel: if self._minute_timer_cancel:
self._minute_timer_cancel() self._minute_timer_cancel()
self._minute_timer_cancel = None self._minute_timer_cancel = None
# Use Home Assistant's async_track_utc_time_change to trigger every minute # Trigger every 30 seconds (:00 and :30) to keep sensor values in sync with
# HA may schedule us a few milliseconds before/after the exact minute boundary. # Home Assistant's frontend relative time display ("in X minutes").
# Our timing calculations are based on dt_util.now() which gives the actual current time, # The timing calculator uses rounded minute values that match HA's rounding behavior.
# so small scheduling variations don't affect accuracy.
self._minute_timer_cancel = async_track_utc_time_change( self._minute_timer_cancel = async_track_utc_time_change(
self.hass, self.hass,
handler_callback, handler_callback,
second=0, # Trigger at :XX:00 (HA handles scheduling tolerance) second=[0, 30], # Trigger at :XX:00 and :XX:30
) )
self._log( self._log(
"debug", "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: def check_midnight_crossed(self, now: datetime) -> bool:

View file

@ -33,7 +33,6 @@ from .types import (
INDENT_L3, INDENT_L3,
INDENT_L4, INDENT_L4,
INDENT_L5, INDENT_L5,
MINUTES_PER_INTERVAL,
IntervalCriteria, IntervalCriteria,
PeriodConfig, PeriodConfig,
PeriodData, PeriodData,
@ -48,7 +47,6 @@ __all__ = [
"INDENT_L3", "INDENT_L3",
"INDENT_L4", "INDENT_L4",
"INDENT_L5", "INDENT_L5",
"MINUTES_PER_INTERVAL",
"IntervalCriteria", "IntervalCriteria",
"PeriodConfig", "PeriodConfig",
"PeriodData", "PeriodData",

View file

@ -5,6 +5,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TimeService
from .types import PeriodConfig from .types import PeriodConfig
from .outlier_filtering import ( from .outlier_filtering import (
@ -31,6 +33,7 @@ def calculate_periods(
all_prices: list[dict], all_prices: list[dict],
*, *,
config: PeriodConfig, config: PeriodConfig,
time: TimeService,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Calculate price periods (best or peak) from price data. 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 all_prices: All price data points from yesterday/today/tomorrow
config: Period configuration containing reverse_sort, flex, min_distance_from_avg, config: Period configuration containing reverse_sort, flex, min_distance_from_avg,
min_period_length, threshold_low, and threshold_high min_period_length, threshold_low, and threshold_high
time: TimeService instance (required)
Returns: Returns:
Dict with: Dict with:
@ -88,7 +92,7 @@ def calculate_periods(
all_prices_sorted = sorted(all_prices, key=lambda p: p["startsAt"]) all_prices_sorted = sorted(all_prices, key=lambda p: p["startsAt"])
# Step 1: Split by day and calculate averages # 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) # Step 2: Calculate reference prices (min or max per day)
ref_prices = calculate_reference_prices(intervals_by_day, reverse_sort=reverse_sort) ref_prices = calculate_reference_prices(intervals_by_day, reverse_sort=reverse_sort)
@ -115,19 +119,20 @@ def calculate_periods(
reverse_sort=reverse_sort, reverse_sort=reverse_sort,
level_filter=config.level_filter, level_filter=config.level_filter,
gap_count=config.gap_count, gap_count=config.gap_count,
time=time,
) )
# Step 4: Filter by minimum length # 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 # 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 # 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) # 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) # Step 8: Extract lightweight period summaries (no full price data)
# Note: Filtering for current/future is done here based on end date, # Note: Filtering for current/future is done here based on end date,
@ -145,6 +150,7 @@ def calculate_periods(
all_prices_sorted, all_prices_sorted,
price_context, price_context,
thresholds, thresholds,
time=time,
) )
return { return {

View file

@ -3,20 +3,20 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import date, timedelta from typing import TYPE_CHECKING, Any
from typing import Any
from custom_components.tibber_prices.const import PRICE_LEVEL_MAPPING 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 ( from .level_filtering import (
apply_level_filter, apply_level_filter,
check_interval_criteria, check_interval_criteria,
) )
from .types import ( from .types import IntervalCriteria
MINUTES_PER_INTERVAL,
IntervalCriteria,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -24,16 +24,17 @@ _LOGGER = logging.getLogger(__name__)
INDENT_L0 = "" # Entry point / main function 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.""" """Split intervals by day and calculate average price per day."""
intervals_by_day: dict[date, list[dict]] = {} intervals_by_day: dict[date, list[dict]] = {}
avg_price_by_day: dict[date, float] = {} avg_price_by_day: dict[date, float] = {}
for price_data in all_prices: 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: if dt is None:
continue continue
dt = dt_util.as_local(dt)
date_key = dt.date() date_key = dt.date()
intervals_by_day.setdefault(date_key, []).append(price_data) 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 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], all_prices: list[dict],
price_context: dict[str, Any], price_context: dict[str, Any],
*, *,
reverse_sort: bool, reverse_sort: bool,
level_filter: str | None = None, level_filter: str | None = None,
gap_count: int = 0, gap_count: int = 0,
time: TimeService,
) -> list[list[dict]]: ) -> list[list[dict]]:
""" """
Build periods, allowing periods to cross midnight (day boundary). 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) reverse_sort: True for peak price (high prices), False for best price (low prices)
level_filter: Level filter string ("cheap", "expensive", "any", None) level_filter: Level filter string ("cheap", "expensive", "any", None)
gap_count: Number of allowed consecutive intervals deviating by exactly 1 level step gap_count: Number of allowed consecutive intervals deviating by exactly 1 level step
time: TimeService instance (required)
""" """
ref_prices = price_context["ref_prices"] 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 intervals_filtered_by_level = 0
for price_data in all_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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at)
date_key = starts_at.date() date_key = starts_at.date()
# Use smoothed price for criteria checks (flex/distance) # 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 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.""" """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] 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.""" """Add interval_end to each interval in-place."""
interval_duration = time.get_interval_duration()
for period in periods: for period in periods:
for interval in period: for interval in period:
start = interval.get("interval_start") start = interval.get("interval_start")
if 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. 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 yesterday
- Periods that ended exactly at midnight today (they're completely in the past) - Periods that ended exactly at midnight today (they're completely in the past)
""" """
now = dt_util.now() now = time.now()
today = now.date() today = now.date()
midnight_today = dt_util.start_of_local_day(now) midnight_today = time.start_of_local_day(now)
filtered = [] filtered = []
for period in periods: for period in periods:
@ -238,7 +243,7 @@ def filter_periods_by_end_date(periods: list[list[dict]]) -> list[list[dict]]:
continue continue
# Keep if period ends in the future # Keep if period ends in the future
if period_end > now: if time.is_in_future(period_end):
filtered.append(period) filtered.append(period)
continue continue

View file

@ -3,11 +3,12 @@
from __future__ import annotations from __future__ import annotations
import logging 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__) _LOGGER = logging.getLogger(__name__)
@ -17,7 +18,7 @@ INDENT_L1 = " " # Nested logic / loop iterations
INDENT_L2 = " " # Deeper nesting 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. 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() last_date = last_start.date()
next_date = next_start.date() next_date = next_start.date()
# If they are 15 minutes apart and on different days (crossing midnight) # If they are one interval apart and on different days (crossing midnight)
if time_diff == timedelta(minutes=MINUTES_PER_INTERVAL) and next_date > last_date: if time_diff == time.get_interval_duration() and next_date > last_date:
# Merge the two periods # Merge the two periods
merged_period = current_period + next_period merged_period = current_period + next_period
merged.append(merged_period) merged.append(merged_period)
@ -61,7 +62,7 @@ def merge_adjacent_periods_at_midnight(periods: list[list[dict]]) -> list[list[d
return merged 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. Recalculate period metadata after merging periods.
@ -73,13 +74,14 @@ def recalculate_period_metadata(periods: list[dict]) -> None:
Args: Args:
periods: List of period summary dicts (mutated in-place) periods: List of period summary dicts (mutated in-place)
time: TimeService instance (required)
""" """
if not periods: if not periods:
return return
# Sort periods chronologically by start time # 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 # Update metadata for all periods
total_periods = len(periods) total_periods = len(periods)

View file

@ -7,20 +7,18 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import datetime from datetime import datetime
from custom_components.tibber_prices.coordinator.time_service import TimeService
from .types import ( from .types import (
PeriodData, PeriodData,
PeriodStatistics, PeriodStatistics,
ThresholdConfig, ThresholdConfig,
) )
from custom_components.tibber_prices.utils.price import ( from custom_components.tibber_prices.utils.price import (
aggregate_period_levels, aggregate_period_levels,
aggregate_period_ratings, aggregate_period_ratings,
calculate_volatility_level, calculate_volatility_level,
) )
from homeassistant.util import dt as dt_util
from .types import MINUTES_PER_INTERVAL
def calculate_period_price_diff( def calculate_period_price_diff(
@ -139,7 +137,7 @@ def build_period_summary_dict(
# 1. Time information (when does this apply?) # 1. Time information (when does this apply?)
"start": period_data.start_time, "start": period_data.start_time,
"end": period_data.end_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?) # 2. Core decision attributes (what should I do?)
"level": stats.aggregated_level, "level": stats.aggregated_level,
"rating_level": stats.aggregated_rating, "rating_level": stats.aggregated_rating,
@ -179,6 +177,8 @@ def extract_period_summaries(
all_prices: list[dict], all_prices: list[dict],
price_context: dict[str, Any], price_context: dict[str, Any],
thresholds: ThresholdConfig, thresholds: ThresholdConfig,
*,
time: TimeService,
) -> list[dict]: ) -> list[dict]:
""" """
Extract complete period summaries with all aggregated attributes. 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) 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 price_context: Dictionary with ref_prices and avg_prices per day
thresholds: Threshold configuration for calculations thresholds: Threshold configuration for calculations
time: TimeService instance (required)
""" """
from .types import ( # noqa: PLC0415 - Avoid circular import 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 # Build lookup dictionary for full price data by timestamp
price_lookup: dict[str, dict] = {} price_lookup: dict[str, dict] = {}
for price_data in all_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: if starts_at:
starts_at = dt_util.as_local(starts_at)
price_lookup[starts_at.isoformat()] = price_data price_lookup[starts_at.isoformat()] = price_data
summaries = [] summaries = []

View file

@ -9,9 +9,9 @@ if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from datetime import date 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 ( from .period_merging import (
recalculate_period_metadata, recalculate_period_metadata,
@ -54,24 +54,25 @@ def group_periods_by_day(periods: list[dict]) -> dict[date, list[dict]]:
return periods_by_day 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). Group price intervals by the day they belong to (today and future only).
Args: Args:
all_prices: List of price dicts with "startsAt" timestamp all_prices: List of price dicts with "startsAt" timestamp
time: TimeService instance (required)
Returns: Returns:
Dict mapping date to list of price intervals for that day (only today and future) 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]] = {} prices_by_day: dict[date, list[dict]] = {}
for price in all_prices: 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: if starts_at:
price_date = dt_util.as_local(starts_at).date() price_date = starts_at.date()
# Only include today and future days # Only include today and future days
if price_date >= today: if price_date >= today:
prices_by_day.setdefault(price_date, []).append(price) 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 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. 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 periods: List of period summary dicts
min_periods: Minimum number of periods required per day min_periods: Minimum number of periods required per day
all_prices: All available price intervals (used to determine which days have data) all_prices: All available price intervals (used to determine which days have data)
time: TimeService instance (required)
Returns: Returns:
True if every day with price data has at least min_periods, False otherwise 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 return False # No periods at all, continue relaxation
# Get all days that have price data (today and future only, not yesterday) # 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() available_days = set()
for price in all_prices: for price in all_prices:
starts_at = dt_util.parse_datetime(price["startsAt"]) starts_at = time.get_interval_time(price)
if starts_at: 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) # Only count today and future days (not yesterday)
if price_date >= today: if price_date >= today:
available_days.add(price_date) available_days.add(price_date)
@ -169,6 +173,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
relaxation_step_pct: int, relaxation_step_pct: int,
max_relaxation_attempts: int, max_relaxation_attempts: int,
should_show_callback: Callable[[str | None], bool], should_show_callback: Callable[[str | None], bool],
time: TimeService,
) -> tuple[dict[str, Any], dict[str, Any]]: ) -> tuple[dict[str, Any], dict[str, Any]]:
""" """
Calculate periods with optional per-day filter relaxation. 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 should_show_callback: Callback function(level_override) -> bool
Returns True if periods should be shown with given filter overrides. Pass None Returns True if periods should be shown with given filter overrides. Pass None
to use original configured filter values. to use original configured filter values.
time: TimeService instance (required)
Returns: Returns:
Tuple of (periods_result, relaxation_metadata): 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) # 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: if not prices_by_day:
# No price data for today/future # 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 # 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"] day_periods = day_result["periods"]
standalone_count = len([p for p in day_periods if not p.get("is_extension")]) 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, should_show_callback=should_show_callback,
baseline_periods=day_periods, baseline_periods=day_periods,
day_label=str(day), day_label=str(day),
time=time,
) )
all_periods.extend(day_relaxed_result["periods"]) 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"]) all_periods.sort(key=lambda p: p["start"])
# Recalculate metadata for combined periods # Recalculate metadata for combined periods
recalculate_period_metadata(all_periods) recalculate_period_metadata(all_periods, time=time)
# Build combined result # Build combined result
if all_periods: 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], should_show_callback: Callable[[str | None], bool],
baseline_periods: list[dict], baseline_periods: list[dict],
day_label: str, day_label: str,
*,
time: TimeService,
) -> tuple[dict[str, Any], dict[str, Any]]: ) -> tuple[dict[str, Any], dict[str, Any]]:
""" """
Run comprehensive relaxation for a single day. 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. Returns True if periods should be shown with given overrides.
baseline_periods: Periods found with normal filters baseline_periods: Periods found with normal filters
day_label: Label for logging (e.g., "2025-11-11") day_label: Label for logging (e.g., "2025-11-11")
time: TimeService instance (required)
Returns: Returns:
Tuple of (periods_result, metadata) for this day 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, flex=new_flex,
level_filter=level_filter_value, 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"] new_periods = relaxed_result["periods"]
# Build relaxation level label BEFORE marking 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, baseline_standalone,
standalone_count, standalone_count,
) )
recalculate_period_metadata(merged) recalculate_period_metadata(merged, time=time)
result = relaxed_result.copy() result = relaxed_result.copy()
result["periods"] = merged result["periods"] = merged
return result, {"phases_used": phases_used} return result, {"phases_used": phases_used}
@ -541,7 +551,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
new_standalone, new_standalone,
) )
recalculate_period_metadata(accumulated_periods) recalculate_period_metadata(accumulated_periods, time=time)
if relaxed_result: if relaxed_result:
result = relaxed_result.copy() result = relaxed_result.copy()

View file

@ -13,7 +13,6 @@ from custom_components.tibber_prices.const import (
DEFAULT_VOLATILITY_THRESHOLD_HIGH, DEFAULT_VOLATILITY_THRESHOLD_HIGH,
DEFAULT_VOLATILITY_THRESHOLD_MODERATE, DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
MINUTES_PER_INTERVAL, # noqa: F401 - Re-exported for period handler modules
) )
# Log indentation levels for visual hierarchy # Log indentation levels for visual hierarchy

View file

@ -12,6 +12,9 @@ from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices import const as _const 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 ( from .period_handlers import (
PeriodConfig, PeriodConfig,
calculate_periods_with_relaxation, calculate_periods_with_relaxation,
@ -34,6 +37,7 @@ class PeriodCalculator:
"""Initialize the period calculator.""" """Initialize the period calculator."""
self.config_entry = config_entry self.config_entry = config_entry
self._log_prefix = log_prefix 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: dict[str, dict[str, Any]] | None = None
self._config_cache_valid = False self._config_cache_valid = False
@ -336,7 +340,7 @@ class PeriodCalculator:
_const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH, _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( sub_sequences = self.split_at_gap_clusters(
today_intervals, today_intervals,
@ -627,6 +631,7 @@ class PeriodCalculator:
reverse_sort=False, reverse_sort=False,
level_override=lvl, level_override=lvl,
), ),
time=self.time,
) )
else: else:
best_periods = { best_periods = {
@ -699,6 +704,7 @@ class PeriodCalculator:
reverse_sort=True, reverse_sort=True,
level_override=lvl, level_override=lvl,
), ),
time=self.time,
) )
else: else:
peak_periods = { peak_periods = {

View file

@ -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)

View file

@ -15,14 +15,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import get_price_level_translation 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: if TYPE_CHECKING:
from datetime import datetime from datetime import datetime
from custom_components.tibber_prices.coordinator.time_service import TimeService
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -93,6 +90,8 @@ def find_rolling_hour_center_index(
all_prices: list[dict], all_prices: list[dict],
current_time: datetime, current_time: datetime,
hour_offset: int, hour_offset: int,
*,
time: TimeService,
) -> int | None: ) -> int | None:
""" """
Find the center index for the rolling hour window. 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 all_prices: List of all price interval dictionaries with 'startsAt' key
current_time: Current datetime to find the current interval current_time: Current datetime to find the current interval
hour_offset: Number of hours to offset from current interval (can be negative) hour_offset: Number of hours to offset from current interval (can be negative)
time: TimeService instance (required)
Returns: Returns:
Index of the center interval for the rolling hour window, or None if not found 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 # 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) # 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 current_idx = None
for idx, price_data in enumerate(all_prices): 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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at)
# Exact match after rounding # Exact match after rounding
if starts_at == target_time: if starts_at == target_time:

View file

@ -7,9 +7,11 @@ from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING, 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 ( from custom_components.tibber_prices.const import (
BINARY_SENSOR_ICON_MAPPING, BINARY_SENSOR_ICON_MAPPING,
MINUTES_PER_INTERVAL,
PRICE_LEVEL_CASH_ICON_MAPPING, PRICE_LEVEL_CASH_ICON_MAPPING,
PRICE_LEVEL_ICON_MAPPING, PRICE_LEVEL_ICON_MAPPING,
PRICE_RATING_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.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.sensor.helpers import aggregate_level_data
from custom_components.tibber_prices.utils.price import find_price_data_for_interval 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 @dataclass
@ -29,6 +33,7 @@ class IconContext:
coordinator_data: dict | None = None coordinator_data: dict | None = None
has_future_periods_callback: Callable[[], bool] | None = None has_future_periods_callback: Callable[[], bool] | None = None
period_is_active_callback: Callable[[], bool] | None = None period_is_active_callback: Callable[[], bool] | None = None
time: TimeService | None = None
if TYPE_CHECKING: if TYPE_CHECKING:
@ -70,7 +75,7 @@ def get_dynamic_icon(
return ( return (
get_trend_icon(key, value) get_trend_icon(key, value)
or get_timing_sensor_icon(key, value, period_is_active_callback=ctx.period_is_active_callback) 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_level_sensor_icon(key, value)
or get_rating_sensor_icon(key, value) or get_rating_sensor_icon(key, value)
or get_volatility_sensor_icon(key, value) or get_volatility_sensor_icon(key, value)
@ -164,7 +169,12 @@ def get_timing_sensor_icon(
return None 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). 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: Args:
key: Entity description key key: Entity description key
coordinator_data: Coordinator data for price level lookups coordinator_data: Coordinator data for price level lookups
time: TimeService instance (required for determining current interval)
Returns: Returns:
Icon string or None if not a current price sensor 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 return None
# Only current price sensors get dynamic icons # Only current price sensors get dynamic icons
if key == "current_interval_price": 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: if level:
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper()) return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
elif key == "next_interval_price": elif key == "next_interval_price":
# For next interval, use the next interval price level to determine icon # 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: if level:
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper()) return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
elif key == "current_hour_average_price": elif key == "current_hour_average_price":
# For current hour average, use the current hour price level to determine icon # 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: if level:
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper()) return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
elif key == "next_hour_average_price": elif key == "next_hour_average_price":
# For next hour average, use the next hour price level to determine icon # 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: if level:
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper()) return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
@ -288,6 +300,7 @@ def get_price_level_for_icon(
coordinator_data: dict, coordinator_data: dict,
*, *,
interval_offset: int | None = None, interval_offset: int | None = None,
time: TimeService,
) -> str | None: ) -> str | None:
""" """
Get the price level for icon determination. Get the price level for icon determination.
@ -297,6 +310,7 @@ def get_price_level_for_icon(
Args: Args:
coordinator_data: Coordinator data coordinator_data: Coordinator data
interval_offset: Interval offset (0=current, 1=next, -1=previous) interval_offset: Interval offset (0=current, 1=next, -1=previous)
time: TimeService instance (required)
Returns: Returns:
Price level string or None if not found Price level string or None if not found
@ -306,11 +320,11 @@ def get_price_level_for_icon(
return None return None
price_info = coordinator_data.get("priceInfo", {}) price_info = coordinator_data.get("priceInfo", {})
now = dt_util.now() now = time.now()
# Interval-based lookup # Interval-based lookup
target_time = now + timedelta(minutes=MINUTES_PER_INTERVAL * interval_offset) target_time = now + timedelta(minutes=_INTERVAL_MINUTES * 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 or "level" not in interval_data: if not interval_data or "level" not in interval_data:
return None return None
@ -322,6 +336,7 @@ def get_rolling_hour_price_level_for_icon(
coordinator_data: dict, coordinator_data: dict,
*, *,
hour_offset: int = 0, hour_offset: int = 0,
time: TimeService,
) -> str | None: ) -> str | None:
""" """
Get the aggregated price level for rolling hour icon determination. Get the aggregated price level for rolling hour icon determination.
@ -334,6 +349,7 @@ def get_rolling_hour_price_level_for_icon(
Args: Args:
coordinator_data: Coordinator data coordinator_data: Coordinator data
hour_offset: Hour offset (0=current hour, 1=next hour) hour_offset: Hour offset (0=current hour, 1=next hour)
time: TimeService instance (required)
Returns: Returns:
Aggregated price level string or None if not found Aggregated price level string or None if not found
@ -349,8 +365,8 @@ def get_rolling_hour_price_level_for_icon(
return None return None
# Find center index using the same helper function as the sensor platform # Find center index using the same helper function as the sensor platform
now = dt_util.now() now = time.now()
center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset) center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset, time=time)
if center_idx is None: if center_idx is None:
return None return None

View file

@ -14,13 +14,12 @@ from custom_components.tibber_prices.entity_utils import (
add_description_attributes, add_description_attributes,
add_icon_color_attribute, 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: if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.core import ( from custom_components.tibber_prices.coordinator.core import (
TibberPricesDataUpdateCoordinator, TibberPricesDataUpdateCoordinator,
) )
from custom_components.tibber_prices.coordinator.time_service import TimeService
from custom_components.tibber_prices.data import TibberPricesConfigEntry from custom_components.tibber_prices.data import TibberPricesConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -63,6 +62,7 @@ def build_sensor_attributes(
Dictionary of attributes or None if no attributes should be added Dictionary of attributes or None if no attributes should be added
""" """
time = coordinator.time
if not coordinator.data: if not coordinator.data:
return None return None
@ -95,6 +95,7 @@ def build_sensor_attributes(
coordinator=coordinator, coordinator=coordinator,
native_value=native_value, native_value=native_value,
cached_data=cached_data, cached_data=cached_data,
time=time,
) )
elif key in [ elif key in [
"trailing_price_average", "trailing_price_average",
@ -104,9 +105,9 @@ def build_sensor_attributes(
"leading_price_min", "leading_price_min",
"leading_price_max", "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_"): 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( elif any(
pattern in key pattern in key
for pattern in [ for pattern in [
@ -127,11 +128,12 @@ def build_sensor_attributes(
attributes=attributes, attributes=attributes,
key=key, key=key,
cached_data=cached_data, cached_data=cached_data,
time=time,
) )
elif key == "price_forecast": 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): 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 # 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: 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, config_entry: TibberPricesConfigEntry,
coordinator_data: dict, coordinator_data: dict,
sensor_attrs: dict | None = None, sensor_attrs: dict | None = None,
time: TimeService | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
""" """
Build extra state attributes for sensors. 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) config_entry: Config entry with options (keyword-only)
coordinator_data: Coordinator data dict (keyword-only) coordinator_data: Coordinator data dict (keyword-only)
sensor_attrs: Sensor-specific attributes (keyword-only) sensor_attrs: Sensor-specific attributes (keyword-only)
time: TimeService instance (optional, creates new if not provided)
Returns: Returns:
Complete attributes dict or None if no data available 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 # Calculate default timestamp: current time rounded to nearest quarter hour
# This ensures all sensors have a consistent reference time for when calculations were made # 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 # Individual sensors can override this if they need a different timestamp
now = dt_util.now() now = time.now()
default_timestamp = round_to_nearest_quarter_hour(now) default_timestamp = time.round_to_nearest_quarter(now)
# Special handling for chart_data_export: metadata → descriptions → service data # Special handling for chart_data_export: metadata → descriptions → service data
if entity_key == "chart_data_export": if entity_key == "chart_data_export":
attributes: dict[str, Any] = { attributes: dict[str, Any] = {
"timestamp": default_timestamp.isoformat(), "timestamp": default_timestamp,
} }
# Step 1: Add metadata (timestamp + error if present) # 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 return attributes if attributes else None
# For all other sensors: standard behavior # For all other sensors: standard behavior
# Start with default timestamp # Start with default timestamp (datetime object - HA serializes automatically)
attributes: dict[str, Any] = { attributes: dict[str, Any] = {
"timestamp": default_timestamp.isoformat(), "timestamp": default_timestamp,
} }
# Add sensor-specific attributes (may override timestamp) # Add sensor-specific attributes (may override timestamp)

View file

@ -2,24 +2,28 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import PRICE_RATING_MAPPING from custom_components.tibber_prices.const import PRICE_RATING_MAPPING
from homeassistant.const import PERCENTAGE 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.""" """Get midnight timestamp for a given day sensor key."""
now = dt_util.now() # Determine which day based on sensor key
local_midnight = dt_util.start_of_local_day(now)
if key.startswith("yesterday") or key == "average_price_yesterday": 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": 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: def _get_day_key_from_sensor_key(key: str) -> str:
@ -60,6 +64,8 @@ def add_statistics_attributes(
attributes: dict, attributes: dict,
key: str, key: str,
cached_data: dict, cached_data: dict,
*,
time: TimeService,
) -> None: ) -> None:
""" """
Add attributes for statistics and rating sensors. Add attributes for statistics and rating sensors.
@ -68,13 +74,14 @@ def add_statistics_attributes(
attributes: Dictionary to add attributes to attributes: Dictionary to add attributes to
key: The sensor entity key key: The sensor entity key
cached_data: Dictionary containing cached sensor data cached_data: Dictionary containing cached sensor data
time: TimeService instance (required)
""" """
# Data timestamp sensor - shows API fetch time # Data timestamp sensor - shows API fetch time
if key == "data_timestamp": if key == "data_timestamp":
latest_timestamp = cached_data.get("data_timestamp") latest_timestamp = cached_data.get("data_timestamp")
if latest_timestamp: if latest_timestamp:
attributes["timestamp"] = latest_timestamp.isoformat() attributes["timestamp"] = latest_timestamp
return return
# Current interval price rating - add rating attributes # 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 average sensors - show midnight to indicate whole day
daily_avg_sensors = {"average_price_today", "average_price_tomorrow"} daily_avg_sensors = {"average_price_today", "average_price_tomorrow"}
if key in daily_avg_sensors: if key in daily_avg_sensors:
attributes["timestamp"] = _get_day_midnight_timestamp(key) attributes["timestamp"] = _get_day_midnight_timestamp(key, time=time)
return return
# Daily aggregated level/rating sensors - show midnight to indicate whole day # Daily aggregated level/rating sensors - show midnight to indicate whole day
@ -118,7 +125,7 @@ def add_statistics_attributes(
"tomorrow_price_rating", "tomorrow_price_rating",
} }
if key in daily_aggregated_sensors: if key in daily_aggregated_sensors:
attributes["timestamp"] = _get_day_midnight_timestamp(key) attributes["timestamp"] = _get_day_midnight_timestamp(key, time=time)
return return
# All other statistics sensors - keep default timestamp (when calculation was made) # All other statistics sensors - keep default timestamp (when calculation was made)

View file

@ -2,16 +2,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime
from typing import TYPE_CHECKING, Any 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: if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.core import ( from custom_components.tibber_prices.coordinator.core import (
TibberPricesDataUpdateCoordinator, TibberPricesDataUpdateCoordinator,
) )
from custom_components.tibber_prices.coordinator.time_service import TimeService
# Constants # Constants
MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals) 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, attributes: dict,
key: str, key: str,
coordinator: TibberPricesDataUpdateCoordinator, coordinator: TibberPricesDataUpdateCoordinator,
*,
time: TimeService,
) -> None: ) -> None:
""" """
Add attributes for next N hours average price sensors. Add attributes for next N hours average price sensors.
@ -29,21 +29,17 @@ def add_next_avg_attributes(
attributes: Dictionary to add attributes to attributes: Dictionary to add attributes to
key: The sensor entity key key: The sensor entity key
coordinator: The data update coordinator coordinator: The data update coordinator
time: TimeService instance (required)
""" """
now = dt_util.now()
# Extract hours from sensor key (e.g., "next_avg_3h" -> 3) # Extract hours from sensor key (e.g., "next_avg_3h" -> 3)
try: try:
hours = int(key.replace("next_avg_", "").replace("h", "")) hours = int(key.split("_")[-1].replace("h", ""))
except (ValueError, AttributeError): except (ValueError, AttributeError):
return return
# Get next interval start time (this is where the calculation begins) # Use TimeService to get the N-hour window starting from next interval
next_interval_start = now + timedelta(minutes=MINUTES_PER_INTERVAL) next_interval_start, window_end = time.get_next_n_hours_window(hours)
# Calculate the end of the time window
window_end = next_interval_start + timedelta(hours=hours)
# Get all price intervals # Get all price intervals
price_info = coordinator.data.get("priceInfo", {}) price_info = coordinator.data.get("priceInfo", {})
@ -57,10 +53,9 @@ def add_next_avg_attributes(
# Find all intervals in the window # Find all intervals in the window
intervals_in_window = [] intervals_in_window = []
for price_data in all_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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at)
if next_interval_start <= starts_at < window_end: if next_interval_start <= starts_at < window_end:
intervals_in_window.append(price_data) intervals_in_window.append(price_data)
@ -74,6 +69,8 @@ def add_next_avg_attributes(
def add_price_forecast_attributes( def add_price_forecast_attributes(
attributes: dict, attributes: dict,
coordinator: TibberPricesDataUpdateCoordinator, coordinator: TibberPricesDataUpdateCoordinator,
*,
time: TimeService,
) -> None: ) -> None:
""" """
Add forecast attributes for the price forecast sensor. Add forecast attributes for the price forecast sensor.
@ -81,9 +78,10 @@ def add_price_forecast_attributes(
Args: Args:
attributes: Dictionary to add attributes to attributes: Dictionary to add attributes to
coordinator: The data update coordinator 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: if not future_prices:
attributes["intervals"] = [] attributes["intervals"] = []
attributes["intervals_by_hour"] = [] attributes["intervals_by_hour"] = []
@ -100,14 +98,18 @@ def add_price_forecast_attributes(
# Group by hour for easier consumption in dashboards # Group by hour for easier consumption in dashboards
hours: dict[str, Any] = {} hours: dict[str, Any] = {}
for interval in future_prices: 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") hour_key = starts_at.strftime("%Y-%m-%d %H")
if hour_key not in hours: if hour_key not in hours:
hours[hour_key] = { hours[hour_key] = {
"hour": starts_at.hour, "hour": starts_at.hour,
"day": interval["day"], "day": interval["day"],
"date": starts_at.date().isoformat(), "date": starts_at.date(),
"intervals": [], "intervals": [],
"min_price": None, "min_price": None,
"max_price": None, "max_price": None,
@ -161,6 +163,8 @@ def add_price_forecast_attributes(
def get_future_prices( def get_future_prices(
coordinator: TibberPricesDataUpdateCoordinator, coordinator: TibberPricesDataUpdateCoordinator,
max_intervals: int | None = None, max_intervals: int | None = None,
*,
time: TimeService,
) -> list[dict] | None: ) -> list[dict] | None:
""" """
Get future price data for multiple upcoming intervals. Get future price data for multiple upcoming intervals.
@ -168,6 +172,7 @@ def get_future_prices(
Args: Args:
coordinator: The data update coordinator coordinator: The data update coordinator
max_intervals: Maximum number of future intervals to return max_intervals: Maximum number of future intervals to return
time: TimeService instance (required)
Returns: Returns:
List of upcoming price intervals with timestamps and prices List of upcoming price intervals with timestamps and prices
@ -185,8 +190,6 @@ def get_future_prices(
if not all_prices: if not all_prices:
return None return None
now = dt_util.now()
# Initialize the result list # Initialize the result list
future_prices = [] future_prices = []
@ -195,18 +198,18 @@ def get_future_prices(
for day_key in ["today", "tomorrow"]: for day_key in ["today", "tomorrow"]:
for price_data in price_info.get(day_key, []): 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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at) interval_end = starts_at + time.get_interval_duration()
interval_end = starts_at + timedelta(minutes=MINUTES_PER_INTERVAL)
if starts_at > now: # Use TimeService to check if interval is in future
if time.is_in_future(starts_at):
future_prices.append( future_prices.append(
{ {
"interval_start": starts_at.isoformat(), "interval_start": starts_at,
"interval_end": interval_end.isoformat(), "interval_end": interval_end,
"price": float(price_data["total"]), "price": float(price_data["total"]),
"price_minor": round(float(price_data["total"]) * 100, 2), "price_minor": round(float(price_data["total"]) * 100, 2),
"level": price_data.get("level", "NORMAL"), "level": price_data.get("level", "NORMAL"),

View file

@ -6,28 +6,29 @@ from datetime import timedelta
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.const import ( from custom_components.tibber_prices.const import (
MINUTES_PER_INTERVAL,
PRICE_LEVEL_MAPPING, PRICE_LEVEL_MAPPING,
PRICE_RATING_MAPPING, PRICE_RATING_MAPPING,
) )
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute 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 custom_components.tibber_prices.utils.price import find_price_data_for_interval
from homeassistant.util import dt as dt_util
if TYPE_CHECKING: if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.core import ( from custom_components.tibber_prices.coordinator.core import (
TibberPricesDataUpdateCoordinator, TibberPricesDataUpdateCoordinator,
) )
from custom_components.tibber_prices.coordinator.time_service import TimeService
from .metadata import get_current_interval_data from .metadata import get_current_interval_data
def add_current_interval_price_attributes( def add_current_interval_price_attributes( # noqa: PLR0913
attributes: dict, attributes: dict,
key: str, key: str,
coordinator: TibberPricesDataUpdateCoordinator, coordinator: TibberPricesDataUpdateCoordinator,
native_value: Any, native_value: Any,
cached_data: dict, cached_data: dict,
*,
time: TimeService,
) -> None: ) -> None:
""" """
Add attributes for current interval price sensors. Add attributes for current interval price sensors.
@ -38,10 +39,11 @@ def add_current_interval_price_attributes(
coordinator: The data update coordinator coordinator: The data update coordinator
native_value: The current native value of the sensor native_value: The current native value of the sensor
cached_data: Dictionary containing cached sensor data cached_data: Dictionary containing cached sensor data
time: TimeService instance (required)
""" """
price_info = coordinator.data.get("priceInfo", {}) if coordinator.data else {} 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 # Determine which interval to use based on sensor type
next_interval_sensors = [ next_interval_sensors = [
@ -70,28 +72,28 @@ def add_current_interval_price_attributes(
# For current interval sensors, keep the default platform timestamp (calculation time) # For current interval sensors, keep the default platform timestamp (calculation time)
interval_data = None interval_data = None
if key in next_interval_sensors: if key in next_interval_sensors:
target_time = now + timedelta(minutes=MINUTES_PER_INTERVAL) target_time = time.get_next_interval_start()
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 NEXT interval's startsAt (when that interval starts) # Override timestamp with the NEXT interval's startsAt (when that interval starts)
if interval_data: if interval_data:
attributes["timestamp"] = interval_data["startsAt"] attributes["timestamp"] = interval_data["startsAt"]
elif key in previous_interval_sensors: elif key in previous_interval_sensors:
target_time = now - timedelta(minutes=MINUTES_PER_INTERVAL) target_time = time.get_interval_offset_time(-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 PREVIOUS interval's startsAt # Override timestamp with the PREVIOUS interval's startsAt
if interval_data: if interval_data:
attributes["timestamp"] = interval_data["startsAt"] attributes["timestamp"] = interval_data["startsAt"]
elif key in next_hour_sensors: elif key in next_hour_sensors:
target_time = now + timedelta(hours=1) 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 # Override timestamp with the center of the next rolling hour window
if interval_data: if interval_data:
attributes["timestamp"] = interval_data["startsAt"] attributes["timestamp"] = interval_data["startsAt"]
elif key in current_hour_sensors: 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 # Keep default timestamp (when calculation was made) for current hour sensors
else: 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 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 # Keep default timestamp (current calculation time) for current interval sensors
@ -114,6 +116,7 @@ def add_current_interval_price_attributes(
interval_data=interval_data, interval_data=interval_data,
coordinator=coordinator, coordinator=coordinator,
native_value=native_value, native_value=native_value,
time=time,
) )
# Add price rating attributes for all rating sensors # Add price rating attributes for all rating sensors
@ -123,15 +126,18 @@ def add_current_interval_price_attributes(
interval_data=interval_data, interval_data=interval_data,
coordinator=coordinator, coordinator=coordinator,
native_value=native_value, native_value=native_value,
time=time,
) )
def add_level_attributes_for_sensor( def add_level_attributes_for_sensor( # noqa: PLR0913
attributes: dict, attributes: dict,
key: str, key: str,
interval_data: dict | None, interval_data: dict | None,
coordinator: TibberPricesDataUpdateCoordinator, coordinator: TibberPricesDataUpdateCoordinator,
native_value: Any, native_value: Any,
*,
time: TimeService,
) -> None: ) -> None:
""" """
Add price level attributes based on sensor type. 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 interval_data: Interval data for next/previous sensors
coordinator: The data update coordinator coordinator: The data update coordinator
native_value: The current native value of the sensor native_value: The current native value of the sensor
time: TimeService instance (required)
""" """
# For interval-based level sensors (next/previous), use interval data # 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()) add_price_level_attributes(attributes, level_value.upper())
# For current price level sensor # For current price level sensor
elif key == "current_interval_price_level": 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: if current_interval_data and "level" in current_interval_data:
add_price_level_attributes(attributes, current_interval_data["level"]) 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) 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, attributes: dict,
key: str, key: str,
interval_data: dict | None, interval_data: dict | None,
coordinator: TibberPricesDataUpdateCoordinator, coordinator: TibberPricesDataUpdateCoordinator,
native_value: Any, native_value: Any,
*,
time: TimeService,
) -> None: ) -> None:
""" """
Add price rating attributes based on sensor type. 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 interval_data: Interval data for next/previous sensors
coordinator: The data update coordinator coordinator: The data update coordinator
native_value: The current native value of the sensor native_value: The current native value of the sensor
time: TimeService instance (required)
""" """
# For interval-based rating sensors (next/previous), use interval data # 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()) add_price_rating_attributes(attributes, rating_value.upper())
# For current price rating sensor # For current price rating sensor
elif key == "current_interval_price_rating": 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: if current_interval_data and "rating_level" in current_interval_data:
add_price_rating_attributes(attributes, current_interval_data["rating_level"]) add_price_rating_attributes(attributes, current_interval_data["rating_level"])

View file

@ -5,31 +5,34 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from custom_components.tibber_prices.utils.price import find_price_data_for_interval from custom_components.tibber_prices.utils.price import find_price_data_for_interval
from homeassistant.util import dt as dt_util
if TYPE_CHECKING: if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.core import ( from custom_components.tibber_prices.coordinator.core import (
TibberPricesDataUpdateCoordinator, TibberPricesDataUpdateCoordinator,
) )
from custom_components.tibber_prices.coordinator.time_service import TimeService
def get_current_interval_data( def get_current_interval_data(
coordinator: TibberPricesDataUpdateCoordinator, coordinator: TibberPricesDataUpdateCoordinator,
*,
time: TimeService,
) -> dict | None: ) -> dict | None:
""" """
Get the current price interval data. Get current interval's price data.
Args: Args:
coordinator: The data update coordinator coordinator: The data update coordinator
time: TimeService instance (required)
Returns: Returns:
Current interval data dict, or None if unavailable Current interval data or None if not found
""" """
if not coordinator.data: if not coordinator.data:
return None return None
price_info = coordinator.data.get("priceInfo", {}) 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)

View file

@ -2,10 +2,15 @@
from __future__ import annotations 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 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: def _is_timing_or_volatility_sensor(key: str) -> bool:
@ -29,24 +34,27 @@ def add_period_timing_attributes(
attributes: dict, attributes: dict,
key: str, key: str,
state_value: Any = None, state_value: Any = None,
*,
time: TimeService,
) -> None: ) -> None:
""" """
Add timestamp and icon_color attributes for best_price/peak_price timing sensors. Add timestamp and icon_color attributes for best_price/peak_price timing sensors.
The timestamp indicates when the sensor value was calculated: The timestamp indicates when the sensor value was calculated:
- Quarter-hour sensors (end_time, next_start_time): Timestamp of current 15-min interval - Quarter-hour sensors (end_time, next_start_time): Rounded to 15-min boundary (:00, :15, :30, :45)
- Minute-update sensors (remaining_minutes, progress, next_in_minutes): Current minute with :00 seconds - 30-second update sensors (remaining_minutes, progress, next_in_minutes): Current time with seconds
Args: Args:
attributes: Dictionary to add attributes to attributes: Dictionary to add attributes to
key: The sensor entity key (e.g., "best_price_end_time") key: The sensor entity key (e.g., "best_price_end_time")
state_value: Current sensor value for icon_color calculation 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")) is_quarter_hour_sensor = key.endswith(("_end_time", "_next_start_time"))
now = dt_util.now() now = time.now()
if is_quarter_hour_sensor: if is_quarter_hour_sensor:
# Quarter-hour sensors: Use timestamp of current 15-minute interval # Quarter-hour sensors: Use timestamp of current 15-minute interval
@ -54,11 +62,12 @@ def add_period_timing_attributes(
minute = (now.minute // 15) * 15 minute = (now.minute // 15) * 15
timestamp = now.replace(minute=minute, second=0, microsecond=0) timestamp = now.replace(minute=minute, second=0, microsecond=0)
else: else:
# Minute-update sensors: Use current minute with :00 seconds # 30-second update sensors: Round to nearest 30-second boundary (:00 or :30)
# This ensures clean timestamps despite timer fluctuations # Timer triggers at :00 and :30, so round current time to these boundaries
timestamp = now.replace(second=0, microsecond=0) 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 for dynamic styling
add_icon_color_attribute(attributes, key=key, state_value=state_value) add_icon_color_attribute(attributes, key=key, state_value=state_value)

View file

@ -2,7 +2,10 @@
from __future__ import annotations 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 .timing import add_period_timing_attributes
from .volatility import add_volatility_attributes from .volatility import add_volatility_attributes
@ -13,12 +16,14 @@ def _add_timing_or_volatility_attributes(
key: str, key: str,
cached_data: dict, cached_data: dict,
native_value: Any = None, native_value: Any = None,
*,
time: TimeService,
) -> None: ) -> None:
"""Add attributes for timing or volatility sensors.""" """Add attributes for timing or volatility sensors."""
if key.endswith("_volatility"): 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: 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: def _add_cached_trend_attributes(attributes: dict, key: str, cached_data: dict) -> None:

View file

@ -3,14 +3,19 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING
from custom_components.tibber_prices.utils.price import calculate_volatility_level 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( def add_volatility_attributes(
attributes: dict, attributes: dict,
cached_data: dict, cached_data: dict,
*,
time: TimeService, # noqa: ARG001
) -> None: ) -> None:
""" """
Add attributes for volatility sensors. Add attributes for volatility sensors.
@ -18,6 +23,7 @@ def add_volatility_attributes(
Args: Args:
attributes: Dictionary to add attributes to attributes: Dictionary to add attributes to
cached_data: Dictionary containing cached sensor data cached_data: Dictionary containing cached sensor data
time: TimeService instance (required)
""" """
if cached_data.get("volatility_attributes"): if cached_data.get("volatility_attributes"):
@ -27,6 +33,8 @@ def add_volatility_attributes(
def get_prices_for_volatility( def get_prices_for_volatility(
volatility_type: str, volatility_type: str,
price_info: dict, price_info: dict,
*,
time: TimeService,
) -> list[float]: ) -> list[float]:
""" """
Get price list for volatility calculation based on type. Get price list for volatility calculation based on type.
@ -34,6 +42,7 @@ def get_prices_for_volatility(
Args: Args:
volatility_type: One of "today", "tomorrow", "next_24h", "today_tomorrow" volatility_type: One of "today", "tomorrow", "next_24h", "today_tomorrow"
price_info: Price information dictionary from coordinator data price_info: Price information dictionary from coordinator data
time: TimeService instance (required)
Returns: Returns:
List of prices to analyze List of prices to analyze
@ -47,18 +56,17 @@ def get_prices_for_volatility(
if volatility_type == "next_24h": if volatility_type == "next_24h":
# Rolling 24h from now # Rolling 24h from now
now = dt_util.now() now = time.now()
end_time = now + timedelta(hours=24) end_time = now + timedelta(hours=24)
prices = [] prices = []
for day_key in ["today", "tomorrow"]: for day_key in ["today", "tomorrow"]:
for price_data in price_info.get(day_key, []): 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: if starts_at is None:
continue 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"])) prices.append(float(price_data["total"]))
return prices return prices
@ -79,6 +87,8 @@ def add_volatility_type_attributes(
volatility_type: str, volatility_type: str,
price_info: dict, price_info: dict,
thresholds: dict, thresholds: dict,
*,
time: TimeService,
) -> None: ) -> None:
""" """
Add type-specific attributes for volatility sensors. Add type-specific attributes for volatility sensors.
@ -88,6 +98,7 @@ def add_volatility_type_attributes(
volatility_type: Type of volatility calculation volatility_type: Type of volatility calculation
price_info: Price information dictionary from coordinator data price_info: Price information dictionary from coordinator data
thresholds: Volatility thresholds configuration thresholds: Volatility thresholds configuration
time: TimeService instance (required)
""" """
# Add timestamp for calendar day volatility sensors (midnight of the day) # 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) volatility_attributes["interval_count_tomorrow"] = len(tomorrow_prices)
elif volatility_type == "next_24h": elif volatility_type == "next_24h":
# Add time window info # Add time window info
now = dt_util.now() now = time.now()
volatility_attributes["timestamp"] = now.isoformat() volatility_attributes["timestamp"] = now

View file

@ -2,15 +2,13 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
if TYPE_CHECKING: if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.core import ( from custom_components.tibber_prices.coordinator.core import (
TibberPricesDataUpdateCoordinator, 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: 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, attributes: dict,
key: str, key: str,
coordinator: TibberPricesDataUpdateCoordinator, coordinator: TibberPricesDataUpdateCoordinator,
*,
time: TimeService,
) -> None: ) -> None:
""" """
Add attributes for trailing and leading average/min/max price sensors. 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 attributes: Dictionary to add attributes to
key: The sensor entity key key: The sensor entity key
coordinator: The data update coordinator coordinator: The data update coordinator
time: TimeService instance (required)
""" """
now = dt_util.now()
# Determine if this is trailing or leading # Determine if this is trailing or leading
is_trailing = "trailing" in key is_trailing = "trailing" in key
@ -69,13 +68,11 @@ def add_average_price_attributes(
if not all_prices: if not all_prices:
return return
# Calculate the time window # Calculate the time window using TimeService
if is_trailing: if is_trailing:
window_start = now - timedelta(hours=24) window_start, window_end = time.get_trailing_window(hours=24)
window_end = now
else: else:
window_start = now window_start, window_end = time.get_leading_window(hours=24)
window_end = now + timedelta(hours=24)
# Find all intervals in the window # Find all intervals in the window
intervals_in_window = [] intervals_in_window = []
@ -83,10 +80,9 @@ def add_average_price_attributes(
is_min_max_sensor = "min" in key or "max" in key is_min_max_sensor = "min" in key or "max" in key
for price_data in all_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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at)
if window_start <= starts_at < window_end: if window_start <= starts_at < window_end:
intervals_in_window.append(price_data) intervals_in_window.append(price_data)

View file

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import ( from custom_components.tibber_prices.const import (
@ -16,7 +15,6 @@ from custom_components.tibber_prices.sensor.helpers import (
aggregate_level_data, aggregate_level_data,
aggregate_rating_data, aggregate_rating_data,
) )
from homeassistant.util import dt as dt_util
from .base import BaseCalculator from .base import BaseCalculator
@ -72,28 +70,19 @@ class DailyStatCalculator(BaseCalculator):
price_info = self.price_info price_info = self.price_info
# Get local midnight boundaries based on the requested day # Get local midnight boundaries based on the requested day using TimeService
local_midnight = dt_util.as_local(dt_util.start_of_local_day(dt_util.now())) time = self.coordinator.time
if day == "tomorrow": local_midnight, local_midnight_next_day = time.get_day_boundaries(day)
local_midnight = local_midnight + timedelta(days=1)
local_midnight_next_day = local_midnight + timedelta(days=1)
# Collect all prices and their intervals from both today and tomorrow data # Collect all prices and their intervals from both today and tomorrow data
# that fall within the target day's local date boundaries # that fall within the target day's local date boundaries
price_intervals = [] price_intervals = []
for day_key in ["today", "tomorrow"]: for day_key in ["today", "tomorrow"]:
for price_data in price_info.get(day_key, []): for price_data in price_info.get(day_key, []):
starts_at_str = price_data.get("startsAt") starts_at = price_data.get("startsAt") # Already datetime in local timezone
if not starts_at_str: if not starts_at:
continue 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 # Include price if it starts within the target day's local date boundaries
if local_midnight <= starts_at < local_midnight_next_day: if local_midnight <= starts_at < local_midnight_next_day:
total_price = price_data.get("total") total_price = price_data.get("total")
@ -147,30 +136,19 @@ class DailyStatCalculator(BaseCalculator):
price_info = self.price_info price_info = self.price_info
# Get local midnight boundaries based on the requested day # Get local midnight boundaries based on the requested day using TimeService
local_midnight = dt_util.as_local(dt_util.start_of_local_day(dt_util.now())) time = self.coordinator.time
if day == "tomorrow": local_midnight, local_midnight_next_day = time.get_day_boundaries(day)
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)
# Collect all intervals from both today and tomorrow data # Collect all intervals from both today and tomorrow data
# that fall within the target day's local date boundaries # that fall within the target day's local date boundaries
day_intervals = [] day_intervals = []
for day_key in ["yesterday", "today", "tomorrow"]: for day_key in ["yesterday", "today", "tomorrow"]:
for price_data in price_info.get(day_key, []): for price_data in price_info.get(day_key, []):
starts_at_str = price_data.get("startsAt") starts_at = price_data.get("startsAt") # Already datetime in local timezone
if not starts_at_str: if not starts_at:
continue 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 # Include interval if it starts within the target day's local date boundaries
if local_midnight <= starts_at < local_midnight_next_day: if local_midnight <= starts_at < local_midnight_next_day:
day_intervals.append(price_data) day_intervals.append(price_data)

View file

@ -2,12 +2,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING 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 custom_components.tibber_prices.utils.price import find_price_data_for_interval
from homeassistant.util import dt as dt_util
from .base import BaseCalculator from .base import BaseCalculator
@ -64,10 +61,11 @@ class IntervalCalculator(BaseCalculator):
return None return None
price_info = self.price_info price_info = self.price_info
now = dt_util.now() time = self.coordinator.time
target_time = now + timedelta(minutes=MINUTES_PER_INTERVAL * interval_offset) # 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: if not interval_data:
return None return None
@ -124,9 +122,10 @@ class IntervalCalculator(BaseCalculator):
self._last_rating_level = None self._last_rating_level = None
return None return None
now = dt_util.now() time = self.coordinator.time
now = time.now()
price_info = self.price_info 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: if current_interval:
rating_level = current_interval.get("rating_level") rating_level = current_interval.get("rating_level")

View file

@ -14,7 +14,6 @@ from custom_components.tibber_prices.sensor.helpers import (
aggregate_price_data, aggregate_price_data,
aggregate_rating_data, aggregate_rating_data,
) )
from homeassistant.util import dt as dt_util
from .base import BaseCalculator from .base import BaseCalculator
@ -60,8 +59,9 @@ class RollingHourCalculator(BaseCalculator):
return None return None
# Find center index for the rolling window # Find center index for the rolling window
now = dt_util.now() time = self.coordinator.time
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: if center_idx is None:
return None return None

View file

@ -16,11 +16,8 @@ The calculator provides smart defaults:
from datetime import datetime 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 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 return 0 if value_type in ("remaining_minutes", "progress", "next_in_minutes") else None
period_summaries = period_data["periods"] period_summaries = period_data["periods"]
now = dt_util.now() time = self.coordinator.time
now = time.now()
# Find current, previous and next periods # Find current, previous and next periods
current_period = self._find_active_period(period_summaries, now) current_period = self._find_active_period(period_summaries)
previous_period = self._find_previous_period(period_summaries, now) previous_period = self._find_previous_period(period_summaries)
next_period = self._find_next_period(period_summaries, now, skip_current=bool(current_period)) next_period = self._find_next_period(period_summaries, skip_current=bool(current_period))
# Delegate to specific calculators # Delegate to specific calculators
return self._calculate_timing_value(value_type, current_period, previous_period, next_period, now) 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), "period_duration": lambda: self._calc_period_duration(current_period, next_period),
"next_start_time": lambda: next_period.get("start") if next_period else None, "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), "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) calculator = calculators.get(value_type)
return calculator() if calculator else None return calculator() if calculator else None
def _find_active_period(self, periods: list, now: datetime) -> dict | None: def _find_active_period(self, periods: list) -> dict | None:
"""Find currently active period.""" """
Find currently active period.
Args:
periods: List of period dictionaries
Returns:
Currently active period or None
"""
time = self.coordinator.time
for period in periods: for period in periods:
start = period.get("start") start = period.get("start")
end = period.get("end") 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 period
return None return None
def _find_previous_period(self, periods: list, now: datetime) -> dict | None: def _find_previous_period(self, periods: list) -> 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] 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: if not past_periods:
return None return None
@ -134,20 +152,20 @@ class TimingCalculator(BaseCalculator):
past_periods.sort(key=lambda p: p["end"], reverse=True) past_periods.sort(key=lambda p: p["end"], reverse=True)
return past_periods[0] 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. Find next future period.
Args: Args:
periods: List of period dictionaries periods: List of period dictionaries
now: Current time
skip_current: If True, skip the first future period (to get next-next) skip_current: If True, skip the first future period (to get next-next)
Returns: Returns:
Next period dict or None if no future periods 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: if not future_periods:
return None return None
@ -163,21 +181,47 @@ class TimingCalculator(BaseCalculator):
return None return None
def _calc_remaining_minutes(self, period: dict, now: datetime) -> float: def _calc_remaining_minutes(self, period: dict) -> int:
"""Calculate minutes until period ends.""" """
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") end = period.get("end")
if not end: if not end:
return 0 return 0
delta = end - now return time.minutes_until_rounded(end)
return max(0, delta.total_seconds() / 60)
def _calc_next_in_minutes(self, period: dict, now: datetime) -> float: def _calc_next_in_minutes(self, period: dict) -> int:
"""Calculate minutes until period starts.""" """
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") start = period.get("start")
if not start: if not start:
return 0 return 0
delta = start - now return time.minutes_until_rounded(start)
return max(0, delta.total_seconds() / 60)
def _calc_period_duration(self, current_period: dict | None, next_period: dict | None) -> float | None: def _calc_period_duration(self, current_period: dict | None, next_period: dict | None) -> float | None:
""" """

View file

@ -12,16 +12,14 @@ Caching strategy:
- Current trend + next change: Cached centrally for 60s to avoid duplicate calculations - 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 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.average import calculate_next_n_hours_avg
from custom_components.tibber_prices.utils.price import ( from custom_components.tibber_prices.utils.price import (
calculate_price_trend, calculate_price_trend,
find_price_data_for_interval, find_price_data_for_interval,
) )
from homeassistant.util import dt as dt_util
from .base import BaseCalculator from .base import BaseCalculator
@ -89,16 +87,16 @@ class TrendCalculator(BaseCalculator):
return None return None
current_interval_price = float(current_interval["total"]) 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: if current_starts_at is None:
return None return None
current_starts_at = dt_util.as_local(current_starts_at)
# Get next interval timestamp (basis for calculation) # 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 # 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: if future_avg is None:
return None return None
@ -113,7 +111,7 @@ class TrendCalculator(BaseCalculator):
today_prices = price_info.get("today", []) today_prices = price_info.get("today", [])
tomorrow_prices = price_info.get("tomorrow", []) tomorrow_prices = price_info.get("tomorrow", [])
all_intervals = today_prices + tomorrow_prices 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 # Calculate trend with volatility-adaptive thresholds
trend_state, diff_pct = calculate_price_trend( 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 # Store attributes in sensor-specific dictionary AND cache the trend value
self._trend_attributes = { self._trend_attributes = {
"timestamp": next_interval_start.isoformat(), "timestamp": next_interval_start,
f"trend_{hours}h_%": round(diff_pct, 1), f"trend_{hours}h_%": round(diff_pct, 1),
f"next_{hours}h_avg": round(future_avg * 100, 2), f"next_{hours}h_avg": round(future_avg * 100, 2),
"interval_count": hours * 4, "interval_count": lookahead_intervals,
"threshold_rising": threshold_rising, "threshold_rising": threshold_rising,
"threshold_falling": threshold_falling, "threshold_falling": threshold_falling,
"icon_color": icon_color, "icon_color": icon_color,
@ -259,18 +257,19 @@ class TrendCalculator(BaseCalculator):
return None return None
# Calculate which intervals belong to the later half # 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 first_half_intervals = total_intervals // 2
later_half_start = next_interval_start + timedelta(minutes=MINUTES_PER_INTERVAL * first_half_intervals) interval_duration = time.get_interval_duration()
later_half_end = next_interval_start + timedelta(minutes=MINUTES_PER_INTERVAL * total_intervals) 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 # Collect prices in the later half
later_prices = [] later_prices = []
for price_data in all_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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at)
if later_half_start <= starts_at < later_half_end: if later_half_start <= starts_at < later_half_end:
price = price_data.get("total") price = price_data.get("total")
@ -296,7 +295,8 @@ class TrendCalculator(BaseCalculator):
trend_cache_duration_seconds = 60 # Cache for 1 minute trend_cache_duration_seconds = 60 # Cache for 1 minute
# Check if we have a valid cache # Check if we have a valid cache
now = dt_util.now() time = self.coordinator.time
now = time.now()
if ( if (
self._trend_calculation_cache is not None self._trend_calculation_cache is not None
and self._trend_calculation_timestamp 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", {}) price_info = self.coordinator.data.get("priceInfo", {})
all_intervals = price_info.get("today", []) + price_info.get("tomorrow", []) 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: if not all_intervals or not current_interval:
return None return None
current_interval_start = dt_util.parse_datetime(current_interval["startsAt"]) current_interval_start = time.get_interval_time(current_interval)
current_interval_start = dt_util.as_local(current_interval_start) if current_interval_start else None
if not current_interval_start: if not current_interval_start:
return None return None
@ -380,14 +379,15 @@ class TrendCalculator(BaseCalculator):
# Calculate duration of current trend # Calculate duration of current trend
trend_duration_minutes = None trend_duration_minutes = None
if trend_start_time: if trend_start_time:
duration = now - trend_start_time time = self.coordinator.time
trend_duration_minutes = int(duration.total_seconds() / 60) # Duration is negative of minutes_until (time in the past)
trend_duration_minutes = -int(time.minutes_until(trend_start_time))
# Calculate minutes until change # Calculate minutes until change
minutes_until_change = None minutes_until_change = None
if next_change_time: if next_change_time:
time_diff = next_change_time - now time = self.coordinator.time
minutes_until_change = int(time_diff.total_seconds() / 60) minutes_until_change = int(time.minutes_until(next_change_time))
result = { result = {
"current_trend_state": current_trend_state, "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: 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.""" """Find the index of current interval in all_intervals list."""
time = self.coordinator.time
for idx, interval in enumerate(all_intervals): for idx, interval in enumerate(all_intervals):
interval_start = dt_util.parse_datetime(interval["startsAt"]) interval_start = time.get_interval_time(interval)
if interval_start and dt_util.as_local(interval_start) == current_interval_start: if interval_start and interval_start == current_interval_start:
return idx return idx
return None return None
@ -577,15 +578,15 @@ class TrendCalculator(BaseCalculator):
intervals_in_3h = 12 # 3 hours = 12 intervals @ 15min each intervals_in_3h = 12 # 3 hours = 12 intervals @ 15min each
# Scan backward to find when trend changed TO current state # 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): for i in range(current_index - 1, max(-1, current_index - 97), -1):
if i < 0: if i < 0:
break break
interval = all_intervals[i] interval = all_intervals[i]
interval_start = dt_util.parse_datetime(interval["startsAt"]) interval_start = time.get_interval_time(interval)
if not interval_start: if not interval_start:
continue continue
interval_start = dt_util.as_local(interval_start)
# Calculate trend at this past interval # Calculate trend at this past interval
future_intervals = all_intervals[i + 1 : i + intervals_in_3h + 1] future_intervals = all_intervals[i + 1 : i + intervals_in_3h + 1]
@ -617,9 +618,9 @@ class TrendCalculator(BaseCalculator):
if trend_state != current_trend_state: if trend_state != current_trend_state:
# Found the change point - the NEXT interval is where current trend started # Found the change point - the NEXT interval is where current trend started
next_interval = all_intervals[i + 1] 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: 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 # Reached data boundary - current trend extends beyond available data
return None, None return None, None
@ -642,6 +643,7 @@ class TrendCalculator(BaseCalculator):
Timestamp of next trend change, or None if no change in next 24h 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 intervals_in_3h = 12 # 3 hours = 12 intervals @ 15min each
current_index = scan_params["current_index"] current_index = scan_params["current_index"]
current_trend_state = scan_params["current_trend_state"] 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))): for i in range(current_index + 1, min(current_index + 97, len(all_intervals))):
interval = all_intervals[i] interval = all_intervals[i]
interval_start = dt_util.parse_datetime(interval["startsAt"]) interval_start = time.get_interval_time(interval)
if not interval_start: if not interval_start:
continue continue
interval_start = dt_util.as_local(interval_start)
# Skip if this interval is in the past # Skip if this interval is in the past
if interval_start <= now: 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 # We want to find ANY change from current state, including changes to/from stable
if trend_state != current_trend_state: if trend_state != current_trend_state:
# Store details for attributes # Store details for attributes
time_diff = interval_start - now time = self.coordinator.time
minutes_until = int(time_diff.total_seconds() / 60) minutes_until = int(time.minutes_until(interval_start))
self._trend_change_attributes = { self._trend_change_attributes = {
"direction": trend_state, "direction": trend_state,

View file

@ -64,7 +64,7 @@ class VolatilityCalculator(BaseCalculator):
} }
# Get prices based on volatility type # 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: if not prices_to_analyze:
return None return None
@ -95,7 +95,9 @@ class VolatilityCalculator(BaseCalculator):
add_icon_color_attribute(self._last_volatility_attributes, key="volatility", state_value=volatility) add_icon_color_attribute(self._last_volatility_attributes, key="volatility", state_value=volatility)
# Add type-specific attributes # 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 lowercase for ENUM device class
return volatility.lower() return volatility.lower()

View file

@ -42,7 +42,7 @@ class Window24hCalculator(BaseCalculator):
if not self.coordinator_data: if not self.coordinator_data:
return None return None
value = stat_func(self.coordinator_data) value = stat_func(self.coordinator_data, time=self.coordinator.time)
if value is None: if value is None:
return None return None

View file

@ -118,7 +118,7 @@ def build_chart_data_attributes(
""" """
# Build base attributes with metadata FIRST # Build base attributes with metadata FIRST
attributes: dict[str, object] = { 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 # Add error message if service call failed

View file

@ -2,7 +2,7 @@
from __future__ import annotations 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 typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.binary_sensor.attributes import ( 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 ( from custom_components.tibber_prices.entity_utils import (
add_icon_color_attribute, add_icon_color_attribute,
find_rolling_hour_center_index, find_rolling_hour_center_index,
get_dynamic_icon,
get_price_value, 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 ( from custom_components.tibber_prices.utils.average import (
calculate_next_n_hours_avg, calculate_next_n_hours_avg,
) )
@ -42,7 +41,6 @@ from homeassistant.components.sensor import (
) )
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.util import dt as dt_util
from .attributes import ( from .attributes import (
add_volatility_type_attributes, add_volatility_type_attributes,
@ -75,10 +73,10 @@ if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator import ( from custom_components.tibber_prices.coordinator import (
TibberPricesDataUpdateCoordinator, TibberPricesDataUpdateCoordinator,
) )
from custom_components.tibber_prices.coordinator.time_service import TimeService
HOURS_IN_DAY = 24 HOURS_IN_DAY = 24
LAST_HOUR_OF_DAY = 23 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) 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 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 self._minute_update_remove_listener = None
@callback @callback
def _handle_time_sensitive_update(self) -> None: def _handle_time_sensitive_update(self, time_service: TimeService) -> None:
"""Handle time-sensitive update from coordinator.""" """
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 # Clear cached trend values on time-sensitive updates
if self.entity_description.key.startswith("price_trend_"): if self.entity_description.key.startswith("price_trend_"):
self._trend_calculator.clear_trend_cache() self._trend_calculator.clear_trend_cache()
@ -159,8 +166,17 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
self.async_write_ha_state() self.async_write_ha_state()
@callback @callback
def _handle_minute_update(self) -> None: def _handle_minute_update(self, time_service: TimeService) -> None:
"""Handle minute-by-minute update from coordinator.""" """
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() self.async_write_ha_state()
@callback @callback
@ -235,8 +251,9 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
return None return None
# Find center index for the rolling window # Find center index for the rolling window
now = dt_util.now() time = self.coordinator.time
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: if center_idx is None:
return None return None
@ -288,28 +305,21 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
price_info = self.coordinator.data.get("priceInfo", {}) price_info = self.coordinator.data.get("priceInfo", {})
# Get local midnight boundaries based on the requested day # Get TimeService from coordinator
local_midnight = dt_util.as_local(dt_util.start_of_local_day(dt_util.now())) time = self.coordinator.time
if day == "tomorrow":
local_midnight = local_midnight + timedelta(days=1) # Get local midnight boundaries based on the requested day using TimeService
local_midnight_next_day = local_midnight + timedelta(days=1) local_midnight, local_midnight_next_day = time.get_day_boundaries(day)
# Collect all prices and their intervals from both today and tomorrow data # Collect all prices and their intervals from both today and tomorrow data
# that fall within the target day's local date boundaries # that fall within the target day's local date boundaries
price_intervals = [] price_intervals = []
for day_key in ["today", "tomorrow"]: for day_key in ["today", "tomorrow"]:
for price_data in price_info.get(day_key, []): for price_data in price_info.get(day_key, []):
starts_at_str = price_data.get("startsAt") starts_at = price_data.get("startsAt") # Already datetime in local timezone
if not starts_at_str: if not starts_at:
continue 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 # Include price if it starts within the target day's local date boundaries
if local_midnight <= starts_at < local_midnight_next_day: if local_midnight <= starts_at < local_midnight_next_day:
total_price = price_data.get("total") total_price = price_data.get("total")
@ -363,30 +373,19 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
price_info = self.coordinator.data.get("priceInfo", {}) price_info = self.coordinator.data.get("priceInfo", {})
# Get local midnight boundaries based on the requested day # Get local midnight boundaries based on the requested day using TimeService
local_midnight = dt_util.as_local(dt_util.start_of_local_day(dt_util.now())) time = self.coordinator.time
if day == "tomorrow": local_midnight, local_midnight_next_day = time.get_day_boundaries(day)
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)
# Collect all intervals from both today and tomorrow data # Collect all intervals from both today and tomorrow data
# that fall within the target day's local date boundaries # that fall within the target day's local date boundaries
day_intervals = [] day_intervals = []
for day_key in ["yesterday", "today", "tomorrow"]: for day_key in ["yesterday", "today", "tomorrow"]:
for price_data in price_info.get(day_key, []): for price_data in price_info.get(day_key, []):
starts_at_str = price_data.get("startsAt") starts_at = price_data.get("startsAt") # Already datetime in local timezone
if not starts_at_str: if not starts_at:
continue 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 # Include interval if it starts within the target day's local date boundaries
if local_midnight <= starts_at < local_midnight_next_day: if local_midnight <= starts_at < local_midnight_next_day:
day_intervals.append(price_data) 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 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: if avg_price is None:
return None return None
@ -490,7 +489,16 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
return round(avg_price * 100, 2) return round(avg_price * 100, 2)
def _get_data_timestamp(self) -> datetime | None: 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: if not self.coordinator.data:
return None return None
@ -499,11 +507,14 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
for day in ["today", "tomorrow"]: for day in ["today", "tomorrow"]:
for price_data in price_info.get(day, []): for price_data in price_info.get(day, []):
timestamp = datetime.fromisoformat(price_data["startsAt"]) starts_at = price_data.get("startsAt") # Already datetime in local timezone
if not latest_timestamp or timestamp > latest_timestamp: if not starts_at:
latest_timestamp = timestamp 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: def _get_volatility_value(self, *, volatility_type: str) -> str | None:
""" """
@ -529,7 +540,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
} }
# Get prices based on volatility type # 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: if not prices_to_analyze:
return None return None
@ -560,7 +571,9 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
add_icon_color_attribute(self._last_volatility_attributes, key="volatility", state_value=volatility) add_icon_color_attribute(self._last_volatility_attributes, key="volatility", state_value=volatility)
# Add type-specific attributes # 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 lowercase for ENUM device class
return volatility.lower() return volatility.lower()
@ -572,7 +585,9 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
# Add method to get future price intervals # Add method to get future price intervals
def _get_price_forecast_value(self) -> str | None: def _get_price_forecast_value(self) -> str | None:
"""Get the highest or lowest price status for the price forecast entity.""" """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: if not future_prices:
return "No forecast data available" return "No forecast data available"
@ -726,29 +741,30 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
"""Check if the current time is within a best price period.""" """Check if the current time is within a best price period."""
if not self.coordinator.data: if not self.coordinator.data:
return False 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: if not attrs:
return False return False
start = attrs.get("start") start = attrs.get("start")
end = attrs.get("end") end = attrs.get("end")
if not start or not end: if not start or not end:
return False return False
now = dt_util.now() time = self.coordinator.time
now = time.now()
return start <= now < end return start <= now < end
def _is_peak_price_period_active(self) -> bool: def _is_peak_price_period_active(self) -> bool:
"""Check if the current time is within a peak price period.""" """Check if the current time is within a peak price period."""
if not self.coordinator.data: if not self.coordinator.data:
return False 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: if not attrs:
return False return False
start = attrs.get("start") start = attrs.get("start")
end = attrs.get("end") end = attrs.get("end")
if not start or not end: if not start or not end:
return False return False
now = dt_util.now() time = self.coordinator.time
return start <= now < end return time.is_current_interval(start, end)
@property @property
def icon(self) -> str | None: def icon(self) -> str | None:
@ -790,6 +806,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
context=IconContext( context=IconContext(
coordinator_data=self.coordinator.data, coordinator_data=self.coordinator.data,
period_is_active_callback=period_is_active_callback, period_is_active_callback=period_is_active_callback,
time=self.coordinator.time,
), ),
) )
@ -806,6 +823,8 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
# Get sensor-specific attributes # Get sensor-specific attributes
sensor_attrs = self._get_sensor_attributes() sensor_attrs = self._get_sensor_attributes()
time = self.coordinator.time
# Build complete attributes using unified builder # Build complete attributes using unified builder
return build_extra_state_attributes( return build_extra_state_attributes(
entity_key=self.entity_description.key, entity_key=self.entity_description.key,
@ -814,6 +833,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
config_entry=self.coordinator.config_entry, config_entry=self.coordinator.config_entry,
coordinator_data=self.coordinator.data, coordinator_data=self.coordinator.data,
sensor_attrs=sensor_attrs, sensor_attrs=sensor_attrs,
time=time,
) )
except (KeyError, ValueError, TypeError) as ex: except (KeyError, ValueError, TypeError) as ex:
@ -891,7 +911,8 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
config_entry=self.coordinator.config_entry, config_entry=self.coordinator.config_entry,
) )
self._chart_data_response = response 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 self._chart_data_error = error
# Trigger state update after refresh # Trigger state update after refresh
self.async_write_ha_state() self.async_write_ha_state()

View file

@ -21,12 +21,14 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING 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.entity_utils.helpers import get_price_value
from custom_components.tibber_prices.utils.price import ( from custom_components.tibber_prices.utils.price import (
aggregate_price_levels, aggregate_price_levels,
aggregate_price_rating, aggregate_price_rating,
) )
from homeassistant.util import dt as dt_util
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@ -132,6 +134,7 @@ def get_hourly_price_value(
*, *,
hour_offset: int, hour_offset: int,
in_euro: bool, in_euro: bool,
time: TimeService,
) -> float | None: ) -> float | None:
""" """
Get price for current hour or with offset. 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 price_info: Price information dict with 'today' and 'tomorrow' keys
hour_offset: Hour offset from current time (positive=future, negative=past) 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) in_euro: If True, return price in major currency (EUR), else minor (cents/øre)
time: TimeService instance (required)
Returns: Returns:
Price value, or None if not found Price value, or None if not found
""" """
# Use HomeAssistant's dt_util to get the current time in the user's timezone # Use TimeService to get the current time in the user's timezone
now = dt_util.now() now = time.now()
# Calculate the exact target datetime (not just the hour) # Calculate the exact target datetime (not just the hour)
# This properly handles day boundaries # This properly handles day boundaries
@ -162,12 +166,11 @@ def get_hourly_price_value(
for price_data in price_info.get(day_key, []): for price_data in price_info.get(day_key, []):
# Parse the timestamp and convert to local time # 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: if starts_at is None:
continue continue
# Make sure it's in the local timezone for proper comparison # 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 # Compare using both hour and date for accuracy
if starts_at.hour == target_hour and starts_at.date() == target_date: 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 # This is a fallback for potential edge cases
other_day_key = "today" if day_key == "tomorrow" else "tomorrow" other_day_key = "today" if day_key == "tomorrow" else "tomorrow"
for price_data in price_info.get(other_day_key, []): 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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at)
if starts_at.hour == target_hour and starts_at.date() == target_date: if starts_at.hour == target_hour and starts_at.date() == target_date:
return get_price_value(float(price_data["total"]), in_euro=in_euro) return get_price_value(float(price_data["total"]), in_euro=in_euro)

View file

@ -43,7 +43,6 @@ from custom_components.tibber_prices.const import (
PRICE_RATING_NORMAL, PRICE_RATING_NORMAL,
) )
from homeassistant.exceptions import ServiceValidationError 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 .formatters import aggregate_hourly_exact, get_period_data, normalize_level_filter, normalize_rating_level_filter
from .helpers import get_entry_and_data from .helpers import get_entry_and_data
@ -227,7 +226,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
current = start current = start
while current < end: while current < end:
period_timestamps.add(current.isoformat()) 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) # Collect all timestamps if insert_nulls='all' (needed to insert NULLs for missing filter matches)
all_timestamps = set() 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) last_value = last_interval.get(filter_field)
if last_start_time and last_price is not None and last_value in filter_values: if last_start_time and last_price is not None and last_value in filter_values:
# Parse timestamp and calculate midnight of next day # Timestamp is already datetime in local timezone
last_dt = dt_util.parse_datetime(last_start_time) last_dt = last_start_time # Already datetime object
if last_dt: if last_dt:
last_dt = dt_util.as_local(last_dt)
# Calculate next day at 00:00 # Calculate next day at 00:00
next_day = last_dt.replace(hour=0, minute=0, second=0, microsecond=0) next_day = last_dt.replace(hour=0, minute=0, second=0, microsecond=0)
next_day = next_day + timedelta(days=1) next_day = next_day + timedelta(days=1)
@ -483,6 +481,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
day_prices, day_prices,
start_time_field, start_time_field,
price_field, price_field,
coordinator=coordinator,
use_minor_currency=minor_currency, use_minor_currency=minor_currency,
round_decimals=round_decimals, round_decimals=round_decimals,
include_level=include_level, include_level=include_level,

View file

@ -28,7 +28,6 @@ from custom_components.tibber_prices.const import (
get_translation, get_translation,
) )
from custom_components.tibber_prices.sensor.helpers import aggregate_level_data, aggregate_rating_data 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: 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, start_time_field: str,
price_field: str, price_field: str,
*, *,
coordinator: Any,
use_minor_currency: bool = False, use_minor_currency: bool = False,
round_decimals: int | None = None, round_decimals: int | None = None,
include_level: bool = False, include_level: bool = False,
@ -75,6 +75,7 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915
intervals: List of 15-minute price intervals intervals: List of 15-minute price intervals
start_time_field: Custom name for start time field start_time_field: Custom name for start time field
price_field: Custom name for price field price_field: Custom name for price field
coordinator: Data update coordinator instance (required)
use_minor_currency: Convert to minor currency units (cents/øre) use_minor_currency: Convert to minor currency units (cents/øre)
round_decimals: Optional decimal rounding round_decimals: Optional decimal rounding
include_level: Include aggregated level field include_level: Include aggregated level field
@ -108,8 +109,9 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915
i += 1 i += 1
continue continue
# Parse the timestamp # Get timestamp (already datetime in local timezone)
start_time = dt_util.parse_datetime(start_time_str) time = coordinator.time
start_time = start_time_str # Already datetime object
if not start_time: if not start_time:
i += 1 i += 1
continue continue
@ -119,10 +121,11 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915
i += 1 i += 1
continue 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_intervals = []
hour_interval_data = [] # Complete interval data for aggregation functions 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): if i + j < len(intervals):
interval = intervals[i + j] interval = intervals[i + j]
@ -180,8 +183,8 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915
hourly_data.append(data_point) hourly_data.append(data_point)
# Move to next hour (skip 4 intervals) # Move to next hour (skip intervals_per_hour)
i += 4 i += time.minutes_to_intervals(60)
return hourly_data return hourly_data
@ -260,14 +263,11 @@ def get_period_data( # noqa: PLR0913, PLR0912, PLR0915
price_info = coordinator.data.get("priceInfo", {}) price_info = coordinator.data.get("priceInfo", {})
day_prices = price_info.get(day, []) day_prices = price_info.get(day, [])
if day_prices: if day_prices:
# Extract date from first interval # Extract date from first interval (already datetime in local timezone)
first_interval = day_prices[0] first_interval = day_prices[0]
starts_at = first_interval.get("startsAt") starts_at = first_interval.get("startsAt") # Already datetime object
if starts_at: if starts_at:
dt = dt_util.parse_datetime(starts_at) allowed_dates.add(starts_at.date())
if dt:
dt = dt_util.as_local(dt)
allowed_dates.add(dt.date())
# Filter periods to those within allowed dates # Filter periods to those within allowed dates
for period in period_summaries: for period in period_summaries:

View file

@ -24,7 +24,6 @@ from .average import (
calculate_current_trailing_max, calculate_current_trailing_max,
calculate_current_trailing_min, calculate_current_trailing_min,
calculate_next_n_hours_avg, calculate_next_n_hours_avg,
round_to_nearest_quarter_hour,
) )
from .price import ( from .price import (
aggregate_period_levels, aggregate_period_levels,
@ -59,5 +58,4 @@ __all__ = [
"calculate_volatility_level", "calculate_volatility_level",
"enrich_price_info_with_differences", "enrich_price_info_with_differences",
"find_price_data_for_interval", "find_price_data_for_interval",
"round_to_nearest_quarter_hour",
] ]

View file

@ -3,73 +3,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TimeService
# 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)
def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime) -> float: 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: Args:
all_prices: List of all price data (yesterday, today, tomorrow combined) all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate average for interval_start: Start time of the interval to calculate average for
time: TimeService instance (required)
Returns: Returns:
Average price for the 24 hours preceding the interval (not including the interval itself) 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 # Filter prices within the 24-hour window
prices_in_window = [] prices_in_window = []
for price_data in all_prices: 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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window (not including the current interval's end) # Include intervals that start within the window (not including the current interval's end)
if window_start <= starts_at < window_end: if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"])) 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: Args:
all_prices: List of all price data (yesterday, today, tomorrow combined) all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate average for interval_start: Start time of the interval to calculate average for
time: TimeService instance (required)
Returns: Returns:
Average price for up to 24 hours following the interval (including the interval itself) 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 # Filter prices within the 24-hour window
prices_in_window = [] prices_in_window = []
for price_data in all_prices: 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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window # Include intervals that start within the window
if window_start <= starts_at < window_end: if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"])) 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 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. Calculate the trailing 24-hour average for the current time.
Args: Args:
coordinator_data: The coordinator data containing priceInfo coordinator_data: The coordinator data containing priceInfo
time: TimeService instance (required)
Returns: Returns:
Current trailing 24-hour average price, or None if unavailable 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: if not all_prices:
return None return None
now = dt_util.now() now = time.now()
return calculate_trailing_24h_avg(all_prices, 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. Calculate the leading 24-hour average for the current time.
Args: Args:
coordinator_data: The coordinator data containing priceInfo coordinator_data: The coordinator data containing priceInfo
time: TimeService instance (required)
Returns: Returns:
Current leading 24-hour average price, or None if unavailable 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: if not all_prices:
return None return None
now = dt_util.now() now = time.now()
return calculate_leading_24h_avg(all_prices, 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. Calculate trailing 24-hour minimum price for a given interval.
Args: Args:
all_prices: List of all price data (yesterday, today, tomorrow combined) all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate minimum for interval_start: Start time of the interval to calculate minimum for
time: TimeService instance (required)
Returns: Returns:
Minimum price for the 24 hours preceding the interval (not including the interval itself) 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 # Filter prices within the 24-hour window
prices_in_window = [] prices_in_window = []
for price_data in all_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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window (not including the current interval's end) # Include intervals that start within the window (not including the current interval's end)
if window_start <= starts_at < window_end: if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"])) 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 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. Calculate trailing 24-hour maximum price for a given interval.
Args: Args:
all_prices: List of all price data (yesterday, today, tomorrow combined) all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate maximum for interval_start: Start time of the interval to calculate maximum for
time: TimeService instance (required)
Returns: Returns:
Maximum price for the 24 hours preceding the interval (not including the interval itself) 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 # Filter prices within the 24-hour window
prices_in_window = [] prices_in_window = []
for price_data in all_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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window (not including the current interval's end) # Include intervals that start within the window (not including the current interval's end)
if window_start <= starts_at < window_end: if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"])) 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 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. Calculate leading 24-hour minimum price for a given interval.
Args: Args:
all_prices: List of all price data (yesterday, today, tomorrow combined) all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate minimum for interval_start: Start time of the interval to calculate minimum for
time: TimeService instance (required)
Returns: Returns:
Minimum price for up to 24 hours following the interval (including the interval itself) 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 # Filter prices within the 24-hour window
prices_in_window = [] prices_in_window = []
for price_data in all_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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window # Include intervals that start within the window
if window_start <= starts_at < window_end: if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"])) 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 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. Calculate leading 24-hour maximum price for a given interval.
Args: Args:
all_prices: List of all price data (yesterday, today, tomorrow combined) all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate maximum for interval_start: Start time of the interval to calculate maximum for
time: TimeService instance (required)
Returns: Returns:
Maximum price for up to 24 hours following the interval (including the interval itself) 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 # Filter prices within the 24-hour window
prices_in_window = [] prices_in_window = []
for price_data in all_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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window # Include intervals that start within the window
if window_start <= starts_at < window_end: if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"])) 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 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. Calculate the trailing 24-hour minimum for the current time.
Args: Args:
coordinator_data: The coordinator data containing priceInfo coordinator_data: The coordinator data containing priceInfo
time: TimeService instance (required)
Returns: Returns:
Current trailing 24-hour minimum price, or None if unavailable 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: if not all_prices:
return None return None
now = dt_util.now() now = time.now()
return calculate_trailing_24h_min(all_prices, 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. Calculate the trailing 24-hour maximum for the current time.
Args: Args:
coordinator_data: The coordinator data containing priceInfo coordinator_data: The coordinator data containing priceInfo
time: TimeService instance (required)
Returns: Returns:
Current trailing 24-hour maximum price, or None if unavailable 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: if not all_prices:
return None return None
now = dt_util.now() now = time.now()
return calculate_trailing_24h_max(all_prices, 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. Calculate the leading 24-hour minimum for the current time.
Args: Args:
coordinator_data: The coordinator data containing priceInfo coordinator_data: The coordinator data containing priceInfo
time: TimeService instance (required)
Returns: Returns:
Current leading 24-hour minimum price, or None if unavailable 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: if not all_prices:
return None return None
now = dt_util.now() now = time.now()
return calculate_leading_24h_min(all_prices, 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. Calculate the leading 24-hour maximum for the current time.
Args: Args:
coordinator_data: The coordinator data containing priceInfo coordinator_data: The coordinator data containing priceInfo
time: TimeService instance (required)
Returns: Returns:
Current leading 24-hour maximum price, or None if unavailable 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: if not all_prices:
return None return None
now = dt_util.now() now = time.now()
return calculate_leading_24h_max(all_prices, 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. 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: Args:
coordinator_data: The coordinator data containing priceInfo coordinator_data: The coordinator data containing priceInfo
hours: Number of hours to look ahead (1, 2, 3, 4, 5, 6, 8, 12, etc.) hours: Number of hours to look ahead (1, 2, 3, 4, 5, 6, 8, 12, etc.)
time: TimeService instance (required)
Returns: Returns:
Average price for the next N hours, or None if insufficient data 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: if not all_prices:
return None return None
now = dt_util.now()
# Find the current interval index # Find the current interval index
current_idx = None current_idx = None
for idx, price_data in enumerate(all_prices): 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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at) interval_end = starts_at + time.get_interval_duration()
interval_end = starts_at + timedelta(minutes=15)
if starts_at <= now < interval_end: if time.is_current_interval(starts_at, interval_end):
current_idx = idx current_idx = idx
break break
if current_idx is None: if current_idx is None:
return None return None
# Calculate how many 15-minute intervals are in N hours # Calculate how many intervals are in N hours
intervals_needed = hours * 4 # 4 intervals per hour intervals_needed = time.minutes_to_intervals(hours * 60)
# Collect prices starting from NEXT interval (current_idx + 1) # Collect prices starting from NEXT interval (current_idx + 1)
prices_in_window = [] prices_in_window = []
for offset in range(1, intervals_needed + 1): for offset in range(1, intervals_needed + 1):
idx = current_idx + offset idx = current_idx + offset
if idx < len(all_prices): if idx >= len(all_prices):
# Not enough future data available
break
price = all_prices[idx].get("total") price = all_prices[idx].get("total")
if price is not None: if price is not None:
prices_in_window.append(float(price)) prices_in_window.append(float(price))
else:
# Not enough future data available
break
# 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)
# 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 if no data at all
if not prices_in_window:
return None return None
# Return average (prefer full period, but allow graceful degradation)
return sum(prices_in_window) / len(prices_in_window)

View file

@ -5,7 +5,10 @@ from __future__ import annotations
import logging import logging
import statistics import statistics
from datetime import datetime, timedelta 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 ( from custom_components.tibber_prices.const import (
DEFAULT_VOLATILITY_THRESHOLD_HIGH, DEFAULT_VOLATILITY_THRESHOLD_HIGH,
@ -19,9 +22,6 @@ from custom_components.tibber_prices.const import (
VOLATILITY_MODERATE, VOLATILITY_MODERATE,
VOLATILITY_VERY_HIGH, VOLATILITY_VERY_HIGH,
) )
from homeassistant.util import dt as dt_util
from .average import round_to_nearest_quarter_hour
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -131,18 +131,10 @@ def calculate_trailing_average_for_interval(
matching_prices = [] matching_prices = []
for price_data in all_prices: for price_data in all_prices:
starts_at_str = price_data.get("startsAt") price_time = price_data.get("startsAt") # Already datetime object in local timezone
if not starts_at_str: if not price_time:
continue 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 # Check if this price falls within our lookback window
# Include prices that start >= lookback_start and start < interval_start # Include prices that start >= lookback_start and start < interval_start
if lookback_start <= price_time < 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") day_label: Label for logging ("today" or "tomorrow")
""" """
starts_at_str = price_interval.get("startsAt") starts_at = price_interval.get("startsAt") # Already datetime object in local timezone
if not starts_at_str: if not starts_at:
return 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") current_interval_price = price_interval.get("total")
if current_interval_price is None: if current_interval_price is None:
@ -295,6 +281,7 @@ def enrich_price_info_with_differences(
price_info: Dictionary with 'yesterday', 'today', 'tomorrow' keys price_info: Dictionary with 'yesterday', 'today', 'tomorrow' keys
threshold_low: Low threshold percentage for rating_level (defaults to -10) threshold_low: Low threshold percentage for rating_level (defaults to -10)
threshold_high: High threshold percentage for rating_level (defaults to 10) threshold_high: High threshold percentage for rating_level (defaults to 10)
time: TimeService instance (required)
Returns: Returns:
Updated price_info dict with 'difference' and 'rating_level' added Updated price_info dict with 'difference' and 'rating_level' added
@ -333,13 +320,19 @@ def enrich_price_info_with_differences(
return price_info 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. Find the price data for a specific 15-minute interval timestamp.
Args: Args:
price_info: The price info dictionary from Tibber API price_info: The price info dictionary from Tibber API
target_time: The target timestamp to find price data for target_time: The target timestamp to find price data for
time: TimeService instance (required)
Returns: Returns:
Price data dict if found, None otherwise 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 # 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) # 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"] search_days = [day_key, "tomorrow" if day_key == "today" else "today"]
for search_day in search_days: for search_day in search_days:
@ -358,11 +351,10 @@ def find_price_data_for_interval(price_info: Any, target_time: datetime) -> dict
continue continue
for price_data in day_prices: 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: if starts_at is None:
continue continue
starts_at = dt_util.as_local(starts_at)
# Exact match after rounding # Exact match after rounding
if starts_at == rounded_time and starts_at.date() == rounded_time.date(): if starts_at == rounded_time and starts_at.date() == rounded_time.date():
return price_data return price_data