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

View file

@ -7,11 +7,12 @@ from datetime import timedelta
from typing import TYPE_CHECKING
from homeassistant.const import __version__ as ha_version
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
import aiohttp
from custom_components.tibber_prices.coordinator.time_service import TimeService
from .queries import QueryType
from .exceptions import (
@ -251,7 +252,7 @@ def prepare_headers(access_token: str, version: str) -> dict[str, str]:
}
def flatten_price_info(subscription: dict, currency: str | None = None) -> dict:
def flatten_price_info(subscription: dict, currency: str | None = None, *, time: TimeService) -> dict:
"""
Transform and flatten priceInfo from full API data structure.
@ -261,8 +262,8 @@ def flatten_price_info(subscription: dict, currency: str | None = None) -> dict:
price_info = subscription.get("priceInfo", {})
price_info_range = subscription.get("priceInfoRange", {})
# Get today and yesterday dates using Home Assistant's dt_util
today_local = dt_util.now().date()
# Get today and yesterday dates using TimeService
today_local = time.now().date()
yesterday_local = today_local - timedelta(days=1)
_LOGGER.debug("Processing data for yesterday's date: %s", yesterday_local)
@ -277,14 +278,12 @@ def flatten_price_info(subscription: dict, currency: str | None = None) -> dict:
continue
price_data = edge["node"]
# Parse timestamp using dt_util for proper timezone handling
starts_at = dt_util.parse_datetime(price_data["startsAt"])
# Parse timestamp using TimeService for proper timezone handling
starts_at = time.get_interval_time(price_data)
if starts_at is None:
_LOGGER.debug("Could not parse timestamp: %s", price_data["startsAt"])
continue
# Convert to local timezone
starts_at = dt_util.as_local(starts_at)
price_date = starts_at.date()
# Only include prices from yesterday

View file

@ -5,8 +5,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute
from custom_components.tibber_prices.utils.average import round_to_nearest_quarter_hour
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TimeService
if TYPE_CHECKING:
from datetime import datetime
@ -14,15 +15,18 @@ if TYPE_CHECKING:
from custom_components.tibber_prices.data import TibberPricesConfigEntry
from homeassistant.core import HomeAssistant
from .definitions import MIN_TOMORROW_INTERVALS_15MIN
def get_tomorrow_data_available_attributes(coordinator_data: dict) -> dict | None:
def get_tomorrow_data_available_attributes(
coordinator_data: dict,
*,
time: TimeService,
) -> dict | None:
"""
Build attributes for tomorrow_data_available sensor.
Args:
coordinator_data: Coordinator data dict
time: TimeService instance
Returns:
Attributes dict with intervals_available and data_status
@ -35,9 +39,13 @@ def get_tomorrow_data_available_attributes(coordinator_data: dict) -> dict | Non
tomorrow_prices = price_info.get("tomorrow", [])
interval_count = len(tomorrow_prices)
# Get expected intervals for tomorrow (handles DST)
tomorrow_date = time.get_local_date(offset_days=1)
expected_intervals = time.get_expected_intervals_for_day(tomorrow_date)
if interval_count == 0:
status = "none"
elif interval_count == MIN_TOMORROW_INTERVALS_15MIN:
elif interval_count == expected_intervals:
status = "full"
else:
status = "partial"
@ -51,6 +59,7 @@ def get_tomorrow_data_available_attributes(coordinator_data: dict) -> dict | Non
def get_price_intervals_attributes(
coordinator_data: dict,
*,
time: TimeService,
reverse_sort: bool,
) -> dict | None:
"""
@ -63,6 +72,7 @@ def get_price_intervals_attributes(
Args:
coordinator_data: Coordinator data dict
time: TimeService instance (required)
reverse_sort: True for peak_price (highest first), False for best_price (lowest first)
Returns:
@ -70,7 +80,7 @@ def get_price_intervals_attributes(
"""
if not coordinator_data:
return build_no_periods_result()
return build_no_periods_result(time=time)
# Get precomputed period summaries from coordinator
periods_data = coordinator_data.get("periods", {})
@ -78,21 +88,20 @@ def get_price_intervals_attributes(
period_data = periods_data.get(period_type)
if not period_data:
return build_no_periods_result()
return build_no_periods_result(time=time)
period_summaries = period_data.get("periods", [])
if not period_summaries:
return build_no_periods_result()
return build_no_periods_result(time=time)
# Find current or next period based on current time
now = dt_util.now()
current_period = None
# First pass: find currently active period
for period in period_summaries:
start = period.get("start")
end = period.get("end")
if start and end and start <= now < end:
if start and end and time.is_current_interval(start, end):
current_period = period
break
@ -100,15 +109,15 @@ def get_price_intervals_attributes(
if not current_period:
for period in period_summaries:
start = period.get("start")
if start and start > now:
if start and time.is_in_future(start):
current_period = period
break
# Build final attributes
return build_final_attributes_simple(current_period, period_summaries)
return build_final_attributes_simple(current_period, period_summaries, time=time)
def build_no_periods_result() -> dict:
def build_no_periods_result(*, time: TimeService) -> dict:
"""
Build result when no periods exist (not filtered, just none available).
@ -117,7 +126,7 @@ def build_no_periods_result() -> dict:
"""
# Calculate timestamp: current time rounded down to last quarter hour
now = dt_util.now()
now = time.now()
current_minute = (now.minute // 15) * 15
timestamp = now.replace(minute=current_minute, second=0, microsecond=0)
@ -204,6 +213,8 @@ def add_relaxation_attributes(attributes: dict, current_period: dict) -> None:
def build_final_attributes_simple(
current_period: dict | None,
period_summaries: list[dict],
*,
time: TimeService,
) -> dict:
"""
Build the final attributes dictionary from coordinator's period summaries.
@ -226,12 +237,13 @@ def build_final_attributes_simple(
Args:
current_period: The current or next period (already complete from coordinator)
period_summaries: All period summaries from coordinator
time: TimeService instance (required)
Returns:
Complete attributes dict with all fields
"""
now = dt_util.now()
now = time.now()
current_minute = (now.minute // 15) * 15
timestamp = now.replace(minute=current_minute, second=0, microsecond=0)
@ -274,6 +286,7 @@ async def build_async_extra_state_attributes( # noqa: PLR0913
translation_key: str | None,
hass: HomeAssistant,
*,
time: TimeService,
config_entry: TibberPricesConfigEntry,
sensor_attrs: dict | None = None,
is_on: bool | None = None,
@ -287,22 +300,23 @@ async def build_async_extra_state_attributes( # noqa: PLR0913
entity_key: Entity key (e.g., "best_price_period")
translation_key: Translation key for entity
hass: Home Assistant instance
time: TimeService instance (required)
config_entry: Config entry with options (keyword-only)
sensor_attrs: Sensor-specific attributes (keyword-only)
is_on: Binary sensor state (keyword-only)
Returns:
Complete attributes dict with descriptions
Complete attributes dict with descriptions (synchronous)
"""
# Calculate default timestamp: current time rounded to nearest quarter hour
# This ensures all binary sensors have a consistent reference time for when calculations were made
# Individual sensors can override this via sensor_attrs if needed
now = dt_util.now()
default_timestamp = round_to_nearest_quarter_hour(now)
now = time.now()
default_timestamp = time.round_to_nearest_quarter(now)
attributes = {
"timestamp": default_timestamp.isoformat(),
"timestamp": default_timestamp,
}
# Add sensor-specific attributes (may override timestamp)
@ -335,6 +349,7 @@ def build_sync_extra_state_attributes( # noqa: PLR0913
translation_key: str | None,
hass: HomeAssistant,
*,
time: TimeService,
config_entry: TibberPricesConfigEntry,
sensor_attrs: dict | None = None,
is_on: bool | None = None,
@ -348,6 +363,7 @@ def build_sync_extra_state_attributes( # noqa: PLR0913
entity_key: Entity key (e.g., "best_price_period")
translation_key: Translation key for entity
hass: Home Assistant instance
time: TimeService instance (required)
config_entry: Config entry with options (keyword-only)
sensor_attrs: Sensor-specific attributes (keyword-only)
is_on: Binary sensor state (keyword-only)
@ -359,11 +375,11 @@ def build_sync_extra_state_attributes( # noqa: PLR0913
# Calculate default timestamp: current time rounded to nearest quarter hour
# This ensures all binary sensors have a consistent reference time for when calculations were made
# Individual sensors can override this via sensor_attrs if needed
now = dt_util.now()
default_timestamp = round_to_nearest_quarter_hour(now)
now = time.now()
default_timestamp = time.round_to_nearest_quarter(now)
attributes = {
"timestamp": default_timestamp.isoformat(),
"timestamp": default_timestamp,
}
# Add sensor-specific attributes (may override timestamp)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,6 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from datetime import date, datetime
@ -39,6 +38,7 @@ from .data_fetching import DataFetcher
from .data_transformation import DataTransformer
from .listeners import ListenerManager
from .periods import PeriodCalculator
from .time_service import TimeService
_LOGGER = logging.getLogger(__name__)
@ -134,6 +134,12 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Track if this is the main entry (first one created)
self._is_main_entry = not self._has_existing_main_coordinator()
# Initialize time service (single source of truth for datetime operations)
self.time = TimeService()
# Set time on API client (needed for rate limiting)
self.api.time = self.time
# Initialize helper modules
self._listener_manager = ListenerManager(hass, self._log_prefix)
self._data_fetcher = DataFetcher(
@ -141,11 +147,13 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
store=self._store,
log_prefix=self._log_prefix,
user_update_interval=timedelta(days=1),
time=self.time,
)
self._data_transformer = DataTransformer(
config_entry=config_entry,
log_prefix=self._log_prefix,
perform_turnover_fn=self._perform_midnight_turnover,
time=self.time,
)
self._period_calculator = PeriodCalculator(
config_entry=config_entry,
@ -197,9 +205,15 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return self._listener_manager.async_add_time_sensitive_listener(update_callback)
@callback
def _async_update_time_sensitive_listeners(self) -> None:
"""Update all time-sensitive entities without triggering a full coordinator update."""
self._listener_manager.async_update_time_sensitive_listeners()
def _async_update_time_sensitive_listeners(self, time_service: TimeService) -> None:
"""
Update all time-sensitive entities without triggering a full coordinator update.
Args:
time_service: TimeService instance with reference time for this update cycle
"""
self._listener_manager.async_update_time_sensitive_listeners(time_service)
@callback
def async_add_minute_update_listener(self, update_callback: CALLBACK_TYPE) -> CALLBACK_TYPE:
@ -216,9 +230,15 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return self._listener_manager.async_add_minute_update_listener(update_callback)
@callback
def _async_update_minute_listeners(self) -> None:
"""Update all minute-update entities without triggering a full coordinator update."""
self._listener_manager.async_update_minute_listeners()
def _async_update_minute_listeners(self, time_service: TimeService) -> None:
"""
Update all minute-update entities without triggering a full coordinator update.
Args:
time_service: TimeService instance with reference time for this update cycle
"""
self._listener_manager.async_update_minute_listeners(time_service)
@callback
def _handle_quarter_hour_refresh(self, _now: datetime | None = None) -> None:
@ -235,7 +255,23 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
This is triggered at exact quarter-hour boundaries (:00, :15, :30, :45).
Does NOT fetch new data - only updates entity states based on existing cached data.
"""
now = dt_util.now()
# Create LOCAL TimeService with fresh reference time for this refresh
# Each timer has its own TimeService instance - no shared state between timers
# This timer updates 30+ time-sensitive entities at quarter-hour boundaries
# (Timer #3 handles timing entities separately - no overlap)
time_service = TimeService()
now = time_service.now()
# Update shared coordinator time (used by Timer #1 and other operations)
# This is safe because we're in a @callback (synchronous event loop)
self.time = time_service
# Update helper modules with fresh TimeService instance
self.api.time = time_service
self._data_fetcher.time = time_service
self._data_transformer.time = time_service
self._period_calculator.time = time_service
self._log("debug", "[Timer #2] Quarter-hour refresh triggered at %s", now.isoformat())
# Check if midnight has passed since last check
@ -251,28 +287,38 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
else:
# Regular quarter-hour refresh - only update time-sensitive entities
# (Midnight turnover was either not needed, or already done by Timer #1)
self._async_update_time_sensitive_listeners()
# Pass local time_service to entities (not self.time which could be overwritten)
self._async_update_time_sensitive_listeners(time_service)
@callback
def _handle_minute_refresh(self, _now: datetime | None = None) -> None:
"""
Handle minute-by-minute entity refresh for timing sensors (Timer #3).
Handle 30-second entity refresh for timing sensors (Timer #3).
This is a SYNCHRONOUS callback (decorated with @callback) - it runs in the event loop
without async/await overhead because it performs only fast, non-blocking operations:
- Listener notifications for timing sensors (remaining_minutes, progress)
NO I/O operations (no API calls, no file operations), so no need for async def.
Runs every minute, so performance is critical - sync callbacks are faster.
Runs every 30 seconds to keep sensor values in sync with HA frontend display.
This runs every minute to update countdown/progress sensors.
This runs every 30 seconds to update countdown/progress sensors.
Timing calculations use rounded minutes matching HA's relative time display.
Does NOT fetch new data - only updates entity states based on existing cached data.
"""
# Only log at debug level to avoid log spam (this runs every minute)
self._log("debug", "[Timer #3] Minute refresh for timing sensors")
# Create LOCAL TimeService with fresh reference time for this 30-second refresh
# Each timer has its own TimeService instance - no shared state between timers
# Timer #2 updates 30+ time-sensitive entities (prices, levels, timestamps)
# Timer #3 updates 6 timing entities (remaining_minutes, progress, next_in_minutes)
# NO overlap - entities are registered with either Timer #2 OR Timer #3, never both
time_service = TimeService()
# Only log at debug level to avoid log spam (this runs every 30 seconds)
self._log("debug", "[Timer #3] 30-second refresh for timing sensors")
# Update only minute-update entities (remaining_minutes, progress, etc.)
self._async_update_minute_listeners()
# Pass local time_service to entities (not self.time which could be overwritten)
self._async_update_minute_listeners(time_service)
def _check_midnight_turnover_needed(self, now: datetime) -> bool:
"""
@ -400,12 +446,20 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""
self._log("debug", "[Timer #1] DataUpdateCoordinator check triggered")
# Create TimeService with fresh reference time for this update cycle
self.time = TimeService()
current_time = self.time.now()
# Update helper modules with fresh TimeService instance
self.api.time = self.time
self._data_fetcher.time = self.time
self._data_transformer.time = self.time
self._period_calculator.time = self.time
# Load cache if not already loaded
if self._cached_price_data is None and self._cached_user_data is None:
await self._load_cache()
current_time = dt_util.utcnow()
# Initialize midnight check on first run
if self._last_midnight_check is None:
self._last_midnight_check = current_time
@ -514,7 +568,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
Updated price_info with rotated day data
"""
return helpers.perform_midnight_turnover(price_info)
return helpers.perform_midnight_turnover(price_info, time=self.time)
async def _store_cache(self) -> None:
"""Store cache data."""
@ -593,13 +647,13 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return True
# Check for midnight turnover
now_local = dt_util.as_local(current_time)
now_local = self.time.as_local(current_time)
current_date = now_local.date()
if self._last_midnight_check is None:
return True
last_check_local = dt_util.as_local(self._last_midnight_check)
last_check_local = self.time.as_local(self._last_midnight_check)
last_check_date = last_check_local.date()
if current_date != last_check_date:
@ -610,7 +664,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
def _transform_data_for_main_entry(self, raw_data: dict[str, Any]) -> dict[str, Any]:
"""Transform raw data for main entry (aggregated view of all homes)."""
current_time = dt_util.now()
current_time = self.time.now()
# Return cached transformed data if no retransformation needed
if not self._should_retransform_data(current_time) and self._cached_transformed_data is not None:
@ -635,7 +689,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
def _transform_data_for_subentry(self, main_data: dict[str, Any]) -> dict[str, Any]:
"""Transform main coordinator data for subentry (home-specific view)."""
current_time = dt_util.now()
current_time = self.time.now()
# Return cached transformed data if no retransformation needed
if not self._should_retransform_data(current_time) and self._cached_transformed_data is not None:
@ -681,8 +735,8 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not price_info:
return None
now = dt_util.now()
return find_price_data_for_interval(price_info, now)
now = self.time.now()
return find_price_data_for_interval(price_info, now, time=self.time)
def get_all_intervals(self) -> list[dict[str, Any]]:
"""Get all price intervals (today + tomorrow)."""
@ -697,7 +751,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def refresh_user_data(self) -> bool:
"""Force refresh of user data and return True if data was updated."""
try:
current_time = dt_util.utcnow()
current_time = self.time.now()
self._log("info", "Forcing user data refresh (bypassing cache)")
# Force update by calling API directly (bypass cache check)

View file

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

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.utils.price import enrich_price_info_with_differences
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from collections.abc import Callable
@ -15,6 +14,8 @@ if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from .time_service import TimeService
_LOGGER = logging.getLogger(__name__)
@ -26,11 +27,13 @@ class DataTransformer:
config_entry: ConfigEntry,
log_prefix: str,
perform_turnover_fn: Callable[[dict[str, Any]], dict[str, Any]],
time: TimeService,
) -> None:
"""Initialize the data transformer."""
self.config_entry = config_entry
self._log_prefix = log_prefix
self._perform_turnover_fn = perform_turnover_fn
self.time = time
# Transformation cache
self._cached_transformed_data: dict[str, Any] | None = None
@ -122,13 +125,13 @@ class DataTransformer:
return True
# Check for midnight turnover
now_local = dt_util.as_local(current_time)
now_local = self.time.as_local(current_time)
current_date = now_local.date()
if self._last_midnight_check is None:
return True
last_check_local = dt_util.as_local(self._last_midnight_check)
last_check_local = self.time.as_local(self._last_midnight_check)
last_check_date = last_check_local.date()
if current_date != last_check_date:
@ -139,7 +142,7 @@ class DataTransformer:
def transform_data_for_main_entry(self, raw_data: dict[str, Any]) -> dict[str, Any]:
"""Transform raw data for main entry (aggregated view of all homes)."""
current_time = dt_util.now()
current_time = self.time.now()
# Return cached transformed data if no retransformation needed
if not self._should_retransform_data(current_time) and self._cached_transformed_data is not None:
@ -198,7 +201,7 @@ class DataTransformer:
def transform_data_for_subentry(self, main_data: dict[str, Any], home_id: str) -> dict[str, Any]:
"""Transform main coordinator data for subentry (home-specific view)."""
current_time = dt_util.now()
current_time = self.time.now()
# Return cached transformed data if no retransformation needed
if not self._should_retransform_data(current_time) and self._cached_transformed_data is not None:

View file

@ -2,17 +2,20 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from datetime import date
from homeassistant.core import HomeAssistant
from .time_service import TimeService
from custom_components.tibber_prices.const import DOMAIN
_LOGGER = logging.getLogger(__name__)
def get_configured_home_ids(hass: HomeAssistant) -> set[str]:
"""Get all home_ids that have active config entries (main + subentries)."""
@ -26,11 +29,16 @@ def get_configured_home_ids(hass: HomeAssistant) -> set[str]:
return home_ids
def needs_tomorrow_data(cached_price_data: dict[str, Any] | None, tomorrow_date: date) -> bool:
def needs_tomorrow_data(
cached_price_data: dict[str, Any] | None,
tomorrow_date: date,
) -> bool:
"""Check if tomorrow data is missing or invalid."""
if not cached_price_data or "homes" not in cached_price_data:
return False
# Use provided TimeService or create new one
for home_data in cached_price_data["homes"].values():
price_info = home_data.get("price_info", {})
tomorrow_prices = price_info.get("tomorrow", [])
@ -41,17 +49,15 @@ def needs_tomorrow_data(cached_price_data: dict[str, Any] | None, tomorrow_date:
# Check if tomorrow data is actually for tomorrow (validate date)
first_price = tomorrow_prices[0]
if starts_at := first_price.get("startsAt"):
price_time = dt_util.parse_datetime(starts_at)
if price_time:
price_date = dt_util.as_local(price_time).date()
if starts_at := first_price.get("startsAt"): # Already datetime in local timezone
price_date = starts_at.date()
if price_date != tomorrow_date:
return True
return False
def perform_midnight_turnover(price_info: dict[str, Any]) -> dict[str, Any]:
def perform_midnight_turnover(price_info: dict[str, Any], *, time: TimeService) -> dict[str, Any]:
"""
Perform midnight turnover on price data.
@ -63,12 +69,15 @@ def perform_midnight_turnover(price_info: dict[str, Any]) -> dict[str, Any]:
Args:
price_info: The price info dict with 'today', 'tomorrow', 'yesterday' keys
time: TimeService instance (required)
Returns:
Updated price_info with rotated day data
"""
current_local_date = dt_util.as_local(dt_util.now()).date()
# Use provided TimeService or create new one
current_local_date = time.now().date()
# Extract current data
today_prices = price_info.get("today", [])
@ -77,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
prices_need_rotation = False
if today_prices:
first_today_price_str = today_prices[0].get("startsAt")
if first_today_price_str:
first_today_price_time = dt_util.parse_datetime(first_today_price_str)
if first_today_price_time:
first_today_price_date = dt_util.as_local(first_today_price_time).date()
first_today_price = today_prices[0].get("startsAt") # Already datetime in local timezone
if first_today_price:
first_today_price_date = first_today_price.date()
prices_need_rotation = first_today_price_date < current_local_date
if prices_need_rotation:
@ -92,4 +99,43 @@ def perform_midnight_turnover(price_info: dict[str, Any]) -> dict[str, Any]:
"currency": price_info.get("currency", "EUR"),
}
# No rotation needed, return original
return price_info
def parse_all_timestamps(price_data: dict[str, Any], *, time: TimeService) -> dict[str, Any]:
"""
Parse all API timestamp strings to datetime objects.
This is the SINGLE place where we convert API strings to datetime objects.
After this, all code works with datetime objects, not strings.
Performance: ~200 timestamps parsed ONCE instead of multiple times per update cycle.
Args:
price_data: Raw API data with string timestamps
time: TimeService for parsing
Returns:
Same structure but with datetime objects instead of strings
"""
if not price_data or "homes" not in price_data:
return price_data
# Process each home
for home_data in price_data["homes"].values():
price_info = home_data.get("price_info", {})
# Process each day's intervals
for day_key in ["yesterday", "today", "tomorrow"]:
intervals = price_info.get(day_key, [])
for interval in intervals:
if (starts_at_str := interval.get("startsAt")) and isinstance(starts_at_str, str):
# Parse once, convert to local timezone, store as datetime object
interval["startsAt"] = time.parse_and_localize(starts_at_str)
# If already datetime (e.g., from cache), skip parsing
return price_data
return price_info

View file

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

View file

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

View file

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

View file

@ -3,20 +3,20 @@
from __future__ import annotations
import logging
from datetime import date, timedelta
from typing import Any
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.const import PRICE_LEVEL_MAPPING
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from datetime import date
from custom_components.tibber_prices.coordinator.time_service import TimeService
from .level_filtering import (
apply_level_filter,
check_interval_criteria,
)
from .types import (
MINUTES_PER_INTERVAL,
IntervalCriteria,
)
from .types import IntervalCriteria
_LOGGER = logging.getLogger(__name__)
@ -24,16 +24,17 @@ _LOGGER = logging.getLogger(__name__)
INDENT_L0 = "" # Entry point / main function
def split_intervals_by_day(all_prices: list[dict]) -> tuple[dict[date, list[dict]], dict[date, float]]:
def split_intervals_by_day(
all_prices: list[dict], *, time: TimeService
) -> tuple[dict[date, list[dict]], dict[date, float]]:
"""Split intervals by day and calculate average price per day."""
intervals_by_day: dict[date, list[dict]] = {}
avg_price_by_day: dict[date, float] = {}
for price_data in all_prices:
dt = dt_util.parse_datetime(price_data["startsAt"])
dt = time.get_interval_time(price_data)
if dt is None:
continue
dt = dt_util.as_local(dt)
date_key = dt.date()
intervals_by_day.setdefault(date_key, []).append(price_data)
@ -52,13 +53,14 @@ def calculate_reference_prices(intervals_by_day: dict[date, list[dict]], *, reve
return ref_prices
def build_periods( # noqa: PLR0915 - Complex period building logic requires many statements
def build_periods( # noqa: PLR0913, PLR0915 - Complex period building logic requires many arguments and statements
all_prices: list[dict],
price_context: dict[str, Any],
*,
reverse_sort: bool,
level_filter: str | None = None,
gap_count: int = 0,
time: TimeService,
) -> list[list[dict]]:
"""
Build periods, allowing periods to cross midnight (day boundary).
@ -73,6 +75,7 @@ def build_periods( # noqa: PLR0915 - Complex period building logic requires man
reverse_sort: True for peak price (high prices), False for best price (low prices)
level_filter: Level filter string ("cheap", "expensive", "any", None)
gap_count: Number of allowed consecutive intervals deviating by exactly 1 level step
time: TimeService instance (required)
"""
ref_prices = price_context["ref_prices"]
@ -108,10 +111,9 @@ def build_periods( # noqa: PLR0915 - Complex period building logic requires man
intervals_filtered_by_level = 0
for price_data in all_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
starts_at = time.get_interval_time(price_data)
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
date_key = starts_at.date()
# Use smoothed price for criteria checks (flex/distance)
@ -194,22 +196,25 @@ def build_periods( # noqa: PLR0915 - Complex period building logic requires man
return periods
def filter_periods_by_min_length(periods: list[list[dict]], min_period_length: int) -> list[list[dict]]:
def filter_periods_by_min_length(
periods: list[list[dict]], min_period_length: int, *, time: TimeService
) -> list[list[dict]]:
"""Filter periods to only include those meeting the minimum length requirement."""
min_intervals = min_period_length // MINUTES_PER_INTERVAL
min_intervals = time.minutes_to_intervals(min_period_length)
return [period for period in periods if len(period) >= min_intervals]
def add_interval_ends(periods: list[list[dict]]) -> None:
def add_interval_ends(periods: list[list[dict]], *, time: TimeService) -> None:
"""Add interval_end to each interval in-place."""
interval_duration = time.get_interval_duration()
for period in periods:
for interval in period:
start = interval.get("interval_start")
if start:
interval["interval_end"] = start + timedelta(minutes=MINUTES_PER_INTERVAL)
interval["interval_end"] = start + interval_duration
def filter_periods_by_end_date(periods: list[list[dict]]) -> list[list[dict]]:
def filter_periods_by_end_date(periods: list[list[dict]], *, time: TimeService) -> list[list[dict]]:
"""
Filter periods to keep only relevant ones for today and tomorrow.
@ -221,9 +226,9 @@ def filter_periods_by_end_date(periods: list[list[dict]]) -> list[list[dict]]:
- Periods that ended yesterday
- Periods that ended exactly at midnight today (they're completely in the past)
"""
now = dt_util.now()
now = time.now()
today = now.date()
midnight_today = dt_util.start_of_local_day(now)
midnight_today = time.start_of_local_day(now)
filtered = []
for period in periods:
@ -238,7 +243,7 @@ def filter_periods_by_end_date(periods: list[list[dict]]) -> list[list[dict]]:
continue
# Keep if period ends in the future
if period_end > now:
if time.is_in_future(period_end):
filtered.append(period)
continue

View file

@ -3,11 +3,12 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from datetime import datetime
from .types import MINUTES_PER_INTERVAL
from custom_components.tibber_prices.coordinator.time_service import TimeService
_LOGGER = logging.getLogger(__name__)
@ -17,7 +18,7 @@ INDENT_L1 = " " # Nested logic / loop iterations
INDENT_L2 = " " # Deeper nesting
def merge_adjacent_periods_at_midnight(periods: list[list[dict]]) -> list[list[dict]]:
def merge_adjacent_periods_at_midnight(periods: list[list[dict]], *, time: TimeService) -> list[list[dict]]:
"""
Merge adjacent periods that meet at midnight.
@ -46,8 +47,8 @@ def merge_adjacent_periods_at_midnight(periods: list[list[dict]]) -> list[list[d
last_date = last_start.date()
next_date = next_start.date()
# If they are 15 minutes apart and on different days (crossing midnight)
if time_diff == timedelta(minutes=MINUTES_PER_INTERVAL) and next_date > last_date:
# If they are one interval apart and on different days (crossing midnight)
if time_diff == time.get_interval_duration() and next_date > last_date:
# Merge the two periods
merged_period = current_period + next_period
merged.append(merged_period)
@ -61,7 +62,7 @@ def merge_adjacent_periods_at_midnight(periods: list[list[dict]]) -> list[list[d
return merged
def recalculate_period_metadata(periods: list[dict]) -> None:
def recalculate_period_metadata(periods: list[dict], *, time: TimeService) -> None:
"""
Recalculate period metadata after merging periods.
@ -73,13 +74,14 @@ def recalculate_period_metadata(periods: list[dict]) -> None:
Args:
periods: List of period summary dicts (mutated in-place)
time: TimeService instance (required)
"""
if not periods:
return
# Sort periods chronologically by start time
periods.sort(key=lambda p: p.get("start") or dt_util.now())
periods.sort(key=lambda p: p.get("start") or time.now())
# Update metadata for all periods
total_periods = len(periods)

View file

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

View file

@ -9,9 +9,9 @@ if TYPE_CHECKING:
from collections.abc import Callable
from datetime import date
from .types import PeriodConfig
from custom_components.tibber_prices.coordinator.time_service import TimeService
from homeassistant.util import dt as dt_util
from .types import PeriodConfig
from .period_merging import (
recalculate_period_metadata,
@ -54,24 +54,25 @@ def group_periods_by_day(periods: list[dict]) -> dict[date, list[dict]]:
return periods_by_day
def group_prices_by_day(all_prices: list[dict]) -> dict[date, list[dict]]:
def group_prices_by_day(all_prices: list[dict], *, time: TimeService) -> dict[date, list[dict]]:
"""
Group price intervals by the day they belong to (today and future only).
Args:
all_prices: List of price dicts with "startsAt" timestamp
time: TimeService instance (required)
Returns:
Dict mapping date to list of price intervals for that day (only today and future)
"""
today = dt_util.now().date()
today = time.now().date()
prices_by_day: dict[date, list[dict]] = {}
for price in all_prices:
starts_at = dt_util.parse_datetime(price["startsAt"])
starts_at = price["startsAt"] # Already datetime in local timezone
if starts_at:
price_date = dt_util.as_local(starts_at).date()
price_date = starts_at.date()
# Only include today and future days
if price_date >= today:
prices_by_day.setdefault(price_date, []).append(price)
@ -79,7 +80,9 @@ def group_prices_by_day(all_prices: list[dict]) -> dict[date, list[dict]]:
return prices_by_day
def check_min_periods_per_day(periods: list[dict], min_periods: int, all_prices: list[dict]) -> bool:
def check_min_periods_per_day(
periods: list[dict], min_periods: int, all_prices: list[dict], *, time: TimeService
) -> bool:
"""
Check if minimum periods requirement is met for each day individually.
@ -90,6 +93,7 @@ def check_min_periods_per_day(periods: list[dict], min_periods: int, all_prices:
periods: List of period summary dicts
min_periods: Minimum number of periods required per day
all_prices: All available price intervals (used to determine which days have data)
time: TimeService instance (required)
Returns:
True if every day with price data has at least min_periods, False otherwise
@ -99,12 +103,12 @@ def check_min_periods_per_day(periods: list[dict], min_periods: int, all_prices:
return False # No periods at all, continue relaxation
# Get all days that have price data (today and future only, not yesterday)
today = dt_util.now().date()
today = time.now().date()
available_days = set()
for price in all_prices:
starts_at = dt_util.parse_datetime(price["startsAt"])
starts_at = time.get_interval_time(price)
if starts_at:
price_date = dt_util.as_local(starts_at).date()
price_date = starts_at.date()
# Only count today and future days (not yesterday)
if price_date >= today:
available_days.add(price_date)
@ -169,6 +173,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
relaxation_step_pct: int,
max_relaxation_attempts: int,
should_show_callback: Callable[[str | None], bool],
time: TimeService,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""
Calculate periods with optional per-day filter relaxation.
@ -194,6 +199,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
should_show_callback: Callback function(level_override) -> bool
Returns True if periods should be shown with given filter overrides. Pass None
to use original configured filter values.
time: TimeService instance (required)
Returns:
Tuple of (periods_result, relaxation_metadata):
@ -265,7 +271,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
)
# Group prices by day (for both relaxation enabled/disabled)
prices_by_day = group_prices_by_day(all_prices)
prices_by_day = group_prices_by_day(all_prices, time=time)
if not prices_by_day:
# No price data for today/future
@ -300,7 +306,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
)
# Calculate baseline periods for this day
day_result = calculate_periods(day_prices, config=config)
day_result = calculate_periods(day_prices, config=config, time=time)
day_periods = day_result["periods"]
standalone_count = len([p for p in day_periods if not p.get("is_extension")])
@ -343,6 +349,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
should_show_callback=should_show_callback,
baseline_periods=day_periods,
day_label=str(day),
time=time,
)
all_periods.extend(day_relaxed_result["periods"])
@ -358,7 +365,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
all_periods.sort(key=lambda p: p["start"])
# Recalculate metadata for combined periods
recalculate_period_metadata(all_periods)
recalculate_period_metadata(all_periods, time=time)
# Build combined result
if all_periods:
@ -391,6 +398,8 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
should_show_callback: Callable[[str | None], bool],
baseline_periods: list[dict],
day_label: str,
*,
time: TimeService,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""
Run comprehensive relaxation for a single day.
@ -415,6 +424,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
Returns True if periods should be shown with given overrides.
baseline_periods: Periods found with normal filters
day_label: Label for logging (e.g., "2025-11-11")
time: TimeService instance (required)
Returns:
Tuple of (periods_result, metadata) for this day
@ -475,7 +485,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
flex=new_flex,
level_filter=level_filter_value,
)
relaxed_result = calculate_periods(day_prices, config=relaxed_config)
relaxed_result = calculate_periods(day_prices, config=relaxed_config, time=time)
new_periods = relaxed_result["periods"]
# Build relaxation level label BEFORE marking periods
@ -522,7 +532,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
baseline_standalone,
standalone_count,
)
recalculate_period_metadata(merged)
recalculate_period_metadata(merged, time=time)
result = relaxed_result.copy()
result["periods"] = merged
return result, {"phases_used": phases_used}
@ -541,7 +551,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
new_standalone,
)
recalculate_period_metadata(accumulated_periods)
recalculate_period_metadata(accumulated_periods, time=time)
if relaxed_result:
result = relaxed_result.copy()

View file

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

View file

@ -12,6 +12,9 @@ from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices import const as _const
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TimeService
from .period_handlers import (
PeriodConfig,
calculate_periods_with_relaxation,
@ -34,6 +37,7 @@ class PeriodCalculator:
"""Initialize the period calculator."""
self.config_entry = config_entry
self._log_prefix = log_prefix
self.time: TimeService # Set by coordinator before first use
self._config_cache: dict[str, dict[str, Any]] | None = None
self._config_cache_valid = False
@ -336,7 +340,7 @@ class PeriodCalculator:
_const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
)
min_period_intervals = min_period_minutes // 15
min_period_intervals = self.time.minutes_to_intervals(min_period_minutes)
sub_sequences = self.split_at_gap_clusters(
today_intervals,
@ -627,6 +631,7 @@ class PeriodCalculator:
reverse_sort=False,
level_override=lvl,
),
time=self.time,
)
else:
best_periods = {
@ -699,6 +704,7 @@ class PeriodCalculator:
reverse_sort=True,
level_override=lvl,
),
time=self.time,
)
else:
peak_periods = {

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 custom_components.tibber_prices.const import get_price_level_translation
from custom_components.tibber_prices.utils.average import (
round_to_nearest_quarter_hour,
)
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from datetime import datetime
from custom_components.tibber_prices.coordinator.time_service import TimeService
from homeassistant.core import HomeAssistant
@ -93,6 +90,8 @@ def find_rolling_hour_center_index(
all_prices: list[dict],
current_time: datetime,
hour_offset: int,
*,
time: TimeService,
) -> int | None:
"""
Find the center index for the rolling hour window.
@ -101,6 +100,7 @@ def find_rolling_hour_center_index(
all_prices: List of all price interval dictionaries with 'startsAt' key
current_time: Current datetime to find the current interval
hour_offset: Number of hours to offset from current interval (can be negative)
time: TimeService instance (required)
Returns:
Index of the center interval for the rolling hour window, or None if not found
@ -108,14 +108,13 @@ def find_rolling_hour_center_index(
"""
# Round to nearest interval boundary to handle edge cases where HA schedules
# us slightly before the boundary (e.g., 14:59:59.999 → 15:00:00)
target_time = round_to_nearest_quarter_hour(current_time)
target_time = time.round_to_nearest_quarter(current_time)
current_idx = None
for idx, price_data in enumerate(all_prices):
starts_at = dt_util.parse_datetime(price_data["startsAt"])
starts_at = time.get_interval_time(price_data)
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
# Exact match after rounding
if starts_at == target_time:

View file

@ -7,9 +7,11 @@ from dataclasses import dataclass
from datetime import timedelta
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TimeService
from custom_components.tibber_prices.const import (
BINARY_SENSOR_ICON_MAPPING,
MINUTES_PER_INTERVAL,
PRICE_LEVEL_CASH_ICON_MAPPING,
PRICE_LEVEL_ICON_MAPPING,
PRICE_RATING_ICON_MAPPING,
@ -18,7 +20,9 @@ from custom_components.tibber_prices.const import (
from custom_components.tibber_prices.entity_utils.helpers import find_rolling_hour_center_index
from custom_components.tibber_prices.sensor.helpers import aggregate_level_data
from custom_components.tibber_prices.utils.price import find_price_data_for_interval
from homeassistant.util import dt as dt_util
# Icon update logic uses timedelta directly (cosmetic, independent - allowed per AGENTS.md)
_INTERVAL_MINUTES = 15 # Tibber's 15-minute intervals
@dataclass
@ -29,6 +33,7 @@ class IconContext:
coordinator_data: dict | None = None
has_future_periods_callback: Callable[[], bool] | None = None
period_is_active_callback: Callable[[], bool] | None = None
time: TimeService | None = None
if TYPE_CHECKING:
@ -70,7 +75,7 @@ def get_dynamic_icon(
return (
get_trend_icon(key, value)
or get_timing_sensor_icon(key, value, period_is_active_callback=ctx.period_is_active_callback)
or get_price_sensor_icon(key, ctx.coordinator_data)
or get_price_sensor_icon(key, ctx.coordinator_data, time=ctx.time)
or get_level_sensor_icon(key, value)
or get_rating_sensor_icon(key, value)
or get_volatility_sensor_icon(key, value)
@ -164,7 +169,12 @@ def get_timing_sensor_icon(
return None
def get_price_sensor_icon(key: str, coordinator_data: dict | None) -> str | None:
def get_price_sensor_icon(
key: str,
coordinator_data: dict | None,
*,
time: TimeService | None,
) -> str | None:
"""
Get icon for current price sensors (dynamic based on price level).
@ -175,32 +185,34 @@ def get_price_sensor_icon(key: str, coordinator_data: dict | None) -> str | None
Args:
key: Entity description key
coordinator_data: Coordinator data for price level lookups
time: TimeService instance (required for determining current interval)
Returns:
Icon string or None if not a current price sensor
"""
if not coordinator_data:
# Early exit if coordinator_data or time not available
if not coordinator_data or time is None:
return None
# Only current price sensors get dynamic icons
if key == "current_interval_price":
level = get_price_level_for_icon(coordinator_data, interval_offset=0)
level = get_price_level_for_icon(coordinator_data, interval_offset=0, time=time)
if level:
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
elif key == "next_interval_price":
# For next interval, use the next interval price level to determine icon
level = get_price_level_for_icon(coordinator_data, interval_offset=1)
level = get_price_level_for_icon(coordinator_data, interval_offset=1, time=time)
if level:
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
elif key == "current_hour_average_price":
# For current hour average, use the current hour price level to determine icon
level = get_rolling_hour_price_level_for_icon(coordinator_data, hour_offset=0)
level = get_rolling_hour_price_level_for_icon(coordinator_data, hour_offset=0, time=time)
if level:
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
elif key == "next_hour_average_price":
# For next hour average, use the next hour price level to determine icon
level = get_rolling_hour_price_level_for_icon(coordinator_data, hour_offset=1)
level = get_rolling_hour_price_level_for_icon(coordinator_data, hour_offset=1, time=time)
if level:
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
@ -288,6 +300,7 @@ def get_price_level_for_icon(
coordinator_data: dict,
*,
interval_offset: int | None = None,
time: TimeService,
) -> str | None:
"""
Get the price level for icon determination.
@ -297,6 +310,7 @@ def get_price_level_for_icon(
Args:
coordinator_data: Coordinator data
interval_offset: Interval offset (0=current, 1=next, -1=previous)
time: TimeService instance (required)
Returns:
Price level string or None if not found
@ -306,11 +320,11 @@ def get_price_level_for_icon(
return None
price_info = coordinator_data.get("priceInfo", {})
now = dt_util.now()
now = time.now()
# Interval-based lookup
target_time = now + timedelta(minutes=MINUTES_PER_INTERVAL * interval_offset)
interval_data = find_price_data_for_interval(price_info, target_time)
target_time = now + timedelta(minutes=_INTERVAL_MINUTES * interval_offset)
interval_data = find_price_data_for_interval(price_info, target_time, time=time)
if not interval_data or "level" not in interval_data:
return None
@ -322,6 +336,7 @@ def get_rolling_hour_price_level_for_icon(
coordinator_data: dict,
*,
hour_offset: int = 0,
time: TimeService,
) -> str | None:
"""
Get the aggregated price level for rolling hour icon determination.
@ -334,6 +349,7 @@ def get_rolling_hour_price_level_for_icon(
Args:
coordinator_data: Coordinator data
hour_offset: Hour offset (0=current hour, 1=next hour)
time: TimeService instance (required)
Returns:
Aggregated price level string or None if not found
@ -349,8 +365,8 @@ def get_rolling_hour_price_level_for_icon(
return None
# Find center index using the same helper function as the sensor platform
now = dt_util.now()
center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset)
now = time.now()
center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset, time=time)
if center_idx is None:
return None

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,10 @@
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TimeService
from .timing import add_period_timing_attributes
from .volatility import add_volatility_attributes
@ -13,12 +16,14 @@ def _add_timing_or_volatility_attributes(
key: str,
cached_data: dict,
native_value: Any = None,
*,
time: TimeService,
) -> None:
"""Add attributes for timing or volatility sensors."""
if key.endswith("_volatility"):
add_volatility_attributes(attributes=attributes, cached_data=cached_data)
add_volatility_attributes(attributes=attributes, cached_data=cached_data, time=time)
else:
add_period_timing_attributes(attributes=attributes, key=key, state_value=native_value)
add_period_timing_attributes(attributes=attributes, key=key, state_value=native_value, time=time)
def _add_cached_trend_attributes(attributes: dict, key: str, cached_data: dict) -> None:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,17 +10,14 @@ This module handles all timing-related calculations for period-based sensors:
The calculator provides smart defaults:
- Active period show current period timing
- No active show next period timing
- No more periods 0 for numeric values, None for timestamps
- No active show next period timing
- No more periods 0 for numeric values, None for timestamps
"""
from datetime import datetime
from homeassistant.util import dt as dt_util
from .base import BaseCalculator # Constants
from .base import BaseCalculator
# Constants
PROGRESS_GRACE_PERIOD_SECONDS = 60 # Show 100% for 1 minute after period ends
@ -80,12 +77,13 @@ class TimingCalculator(BaseCalculator):
return 0 if value_type in ("remaining_minutes", "progress", "next_in_minutes") else None
period_summaries = period_data["periods"]
now = dt_util.now()
time = self.coordinator.time
now = time.now()
# Find current, previous and next periods
current_period = self._find_active_period(period_summaries, now)
previous_period = self._find_previous_period(period_summaries, now)
next_period = self._find_next_period(period_summaries, now, skip_current=bool(current_period))
current_period = self._find_active_period(period_summaries)
previous_period = self._find_previous_period(period_summaries)
next_period = self._find_next_period(period_summaries, skip_current=bool(current_period))
# Delegate to specific calculators
return self._calculate_timing_value(value_type, current_period, previous_period, next_period, now)
@ -106,26 +104,46 @@ class TimingCalculator(BaseCalculator):
),
"period_duration": lambda: self._calc_period_duration(current_period, next_period),
"next_start_time": lambda: next_period.get("start") if next_period else None,
"remaining_minutes": lambda: (self._calc_remaining_minutes(current_period, now) if current_period else 0),
"remaining_minutes": lambda: (self._calc_remaining_minutes(current_period) if current_period else 0),
"progress": lambda: self._calc_progress_with_grace_period(current_period, previous_period, now),
"next_in_minutes": lambda: (self._calc_next_in_minutes(next_period, now) if next_period else None),
"next_in_minutes": lambda: (self._calc_next_in_minutes(next_period) if next_period else None),
}
calculator = calculators.get(value_type)
return calculator() if calculator else None
def _find_active_period(self, periods: list, now: datetime) -> dict | None:
"""Find currently active period."""
def _find_active_period(self, periods: list) -> dict | None:
"""
Find currently active period.
Args:
periods: List of period dictionaries
Returns:
Currently active period or None
"""
time = self.coordinator.time
for period in periods:
start = period.get("start")
end = period.get("end")
if start and end and start <= now < end:
if start and end and time.is_current_interval(start, end):
return period
return None
def _find_previous_period(self, periods: list, now: datetime) -> dict | None:
"""Find the most recent period that has already ended."""
past_periods = [p for p in periods if p.get("end") and p.get("end") <= now]
def _find_previous_period(self, periods: list) -> dict | None:
"""
Find the most recent period that has already ended.
Args:
periods: List of period dictionaries
Returns:
Most recent past period or None
"""
time = self.coordinator.time
past_periods = [p for p in periods if p.get("end") and time.is_in_past(p["end"])]
if not past_periods:
return None
@ -134,20 +152,20 @@ class TimingCalculator(BaseCalculator):
past_periods.sort(key=lambda p: p["end"], reverse=True)
return past_periods[0]
def _find_next_period(self, periods: list, now: datetime, *, skip_current: bool = False) -> dict | None:
def _find_next_period(self, periods: list, *, skip_current: bool = False) -> dict | None:
"""
Find next future period.
Args:
periods: List of period dictionaries
now: Current time
skip_current: If True, skip the first future period (to get next-next)
Returns:
Next period dict or None if no future periods
"""
future_periods = [p for p in periods if p.get("start") and p.get("start") > now]
time = self.coordinator.time
future_periods = [p for p in periods if p.get("start") and time.is_in_future(p["start"])]
if not future_periods:
return None
@ -163,21 +181,47 @@ class TimingCalculator(BaseCalculator):
return None
def _calc_remaining_minutes(self, period: dict, now: datetime) -> float:
"""Calculate minutes until period ends."""
def _calc_remaining_minutes(self, period: dict) -> int:
"""
Calculate ROUNDED minutes until period ends.
Uses standard rounding (0.5 rounds up) to match Home Assistant frontend
relative time display. This ensures sensor values match what users see
in the UI ("in X minutes").
Args:
period: Period dictionary
Returns:
Rounded minutes until period ends (matches HA frontend display)
"""
time = self.coordinator.time
end = period.get("end")
if not end:
return 0
delta = end - now
return max(0, delta.total_seconds() / 60)
return time.minutes_until_rounded(end)
def _calc_next_in_minutes(self, period: dict, now: datetime) -> float:
"""Calculate minutes until period starts."""
def _calc_next_in_minutes(self, period: dict) -> int:
"""
Calculate ROUNDED minutes until next period starts.
Uses standard rounding (0.5 rounds up) to match Home Assistant frontend
relative time display. This ensures sensor values match what users see
in the UI ("in X minutes").
Args:
period: Period dictionary
Returns:
Rounded minutes until period starts (matches HA frontend display)
"""
time = self.coordinator.time
start = period.get("start")
if not start:
return 0
delta = start - now
return max(0, delta.total_seconds() / 60)
return time.minutes_until_rounded(start)
def _calc_period_duration(self, current_period: dict | None, next_period: dict | None) -> float | None:
"""

View file

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

View file

@ -64,7 +64,7 @@ class VolatilityCalculator(BaseCalculator):
}
# Get prices based on volatility type
prices_to_analyze = get_prices_for_volatility(volatility_type, price_info)
prices_to_analyze = get_prices_for_volatility(volatility_type, price_info, time=self.coordinator.time)
if not prices_to_analyze:
return None
@ -95,7 +95,9 @@ class VolatilityCalculator(BaseCalculator):
add_icon_color_attribute(self._last_volatility_attributes, key="volatility", state_value=volatility)
# Add type-specific attributes
add_volatility_type_attributes(self._last_volatility_attributes, volatility_type, price_info, thresholds)
add_volatility_type_attributes(
self._last_volatility_attributes, volatility_type, price_info, thresholds, time=self.coordinator.time
)
# Return lowercase for ENUM device class
return volatility.lower()

View file

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

View file

@ -118,7 +118,7 @@ def build_chart_data_attributes(
"""
# Build base attributes with metadata FIRST
attributes: dict[str, object] = {
"timestamp": chart_data_last_update.isoformat() if chart_data_last_update else None,
"timestamp": chart_data_last_update,
}
# Add error message if service call failed

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import datetime # noqa: TC003 - Used at runtime for _get_data_timestamp()
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.binary_sensor.attributes import (
@ -25,10 +25,9 @@ from custom_components.tibber_prices.entity import TibberPricesEntity
from custom_components.tibber_prices.entity_utils import (
add_icon_color_attribute,
find_rolling_hour_center_index,
get_dynamic_icon,
get_price_value,
)
from custom_components.tibber_prices.entity_utils.icons import IconContext
from custom_components.tibber_prices.entity_utils.icons import IconContext, get_dynamic_icon
from custom_components.tibber_prices.utils.average import (
calculate_next_n_hours_avg,
)
@ -42,7 +41,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import callback
from homeassistant.util import dt as dt_util
from .attributes import (
add_volatility_type_attributes,
@ -75,10 +73,10 @@ if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator import (
TibberPricesDataUpdateCoordinator,
)
from custom_components.tibber_prices.coordinator.time_service import TimeService
HOURS_IN_DAY = 24
LAST_HOUR_OF_DAY = 23
INTERVALS_PER_HOUR = 4 # 15-minute intervals
MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals)
MIN_HOURS_FOR_LATER_HALF = 3 # Minimum hours needed to calculate later half average
@ -148,8 +146,17 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
self._minute_update_remove_listener = None
@callback
def _handle_time_sensitive_update(self) -> None:
"""Handle time-sensitive update from coordinator."""
def _handle_time_sensitive_update(self, time_service: TimeService) -> None:
"""
Handle time-sensitive update from coordinator.
Args:
time_service: TimeService instance with reference time for this update cycle
"""
# Store TimeService from Timer #2 for calculations during this update cycle
self.coordinator.time = time_service
# Clear cached trend values on time-sensitive updates
if self.entity_description.key.startswith("price_trend_"):
self._trend_calculator.clear_trend_cache()
@ -159,8 +166,17 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
self.async_write_ha_state()
@callback
def _handle_minute_update(self) -> None:
"""Handle minute-by-minute update from coordinator."""
def _handle_minute_update(self, time_service: TimeService) -> None:
"""
Handle minute-by-minute update from coordinator.
Args:
time_service: TimeService instance with reference time for this update cycle
"""
# Store TimeService from Timer #3 for calculations during this update cycle
self.coordinator.time = time_service
self.async_write_ha_state()
@callback
@ -235,8 +251,9 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
return None
# Find center index for the rolling window
now = dt_util.now()
center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset)
time = self.coordinator.time
now = time.now()
center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset, time=time)
if center_idx is None:
return None
@ -288,28 +305,21 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
price_info = self.coordinator.data.get("priceInfo", {})
# Get local midnight boundaries based on the requested day
local_midnight = dt_util.as_local(dt_util.start_of_local_day(dt_util.now()))
if day == "tomorrow":
local_midnight = local_midnight + timedelta(days=1)
local_midnight_next_day = local_midnight + timedelta(days=1)
# Get TimeService from coordinator
time = self.coordinator.time
# Get local midnight boundaries based on the requested day using TimeService
local_midnight, local_midnight_next_day = time.get_day_boundaries(day)
# Collect all prices and their intervals from both today and tomorrow data
# that fall within the target day's local date boundaries
price_intervals = []
for day_key in ["today", "tomorrow"]:
for price_data in price_info.get(day_key, []):
starts_at_str = price_data.get("startsAt")
if not starts_at_str:
starts_at = price_data.get("startsAt") # Already datetime in local timezone
if not starts_at:
continue
starts_at = dt_util.parse_datetime(starts_at_str)
if starts_at is None:
continue
# Convert to local timezone for comparison
starts_at = dt_util.as_local(starts_at)
# Include price if it starts within the target day's local date boundaries
if local_midnight <= starts_at < local_midnight_next_day:
total_price = price_data.get("total")
@ -363,30 +373,19 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
price_info = self.coordinator.data.get("priceInfo", {})
# Get local midnight boundaries based on the requested day
local_midnight = dt_util.as_local(dt_util.start_of_local_day(dt_util.now()))
if day == "tomorrow":
local_midnight = local_midnight + timedelta(days=1)
elif day == "yesterday":
local_midnight = local_midnight - timedelta(days=1)
local_midnight_next_day = local_midnight + timedelta(days=1)
# Get local midnight boundaries based on the requested day using TimeService
time = self.coordinator.time
local_midnight, local_midnight_next_day = time.get_day_boundaries(day)
# Collect all intervals from both today and tomorrow data
# that fall within the target day's local date boundaries
day_intervals = []
for day_key in ["yesterday", "today", "tomorrow"]:
for price_data in price_info.get(day_key, []):
starts_at_str = price_data.get("startsAt")
if not starts_at_str:
starts_at = price_data.get("startsAt") # Already datetime in local timezone
if not starts_at:
continue
starts_at = dt_util.parse_datetime(starts_at_str)
if starts_at is None:
continue
# Convert to local timezone for comparison
starts_at = dt_util.as_local(starts_at)
# Include interval if it starts within the target day's local date boundaries
if local_midnight <= starts_at < local_midnight_next_day:
day_intervals.append(price_data)
@ -482,7 +481,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
Average price in minor currency units (e.g., cents), or None if unavailable
"""
avg_price = calculate_next_n_hours_avg(self.coordinator.data, hours)
avg_price = calculate_next_n_hours_avg(self.coordinator.data, hours, time=self.coordinator.time)
if avg_price is None:
return None
@ -490,7 +489,16 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
return round(avg_price * 100, 2)
def _get_data_timestamp(self) -> datetime | None:
"""Get the latest data timestamp."""
"""
Get the latest data timestamp from price data.
Returns timezone-aware datetime of the most recent price interval.
Home Assistant automatically displays TIMESTAMP sensors in user's timezone.
Returns:
Latest interval timestamp (timezone-aware), or None if no data available.
"""
if not self.coordinator.data:
return None
@ -499,11 +507,14 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
for day in ["today", "tomorrow"]:
for price_data in price_info.get(day, []):
timestamp = datetime.fromisoformat(price_data["startsAt"])
if not latest_timestamp or timestamp > latest_timestamp:
latest_timestamp = timestamp
starts_at = price_data.get("startsAt") # Already datetime in local timezone
if not starts_at:
continue
if not latest_timestamp or starts_at > latest_timestamp:
latest_timestamp = starts_at
return dt_util.as_utc(latest_timestamp) if latest_timestamp else None
# Return timezone-aware datetime (HA handles timezone display automatically)
return latest_timestamp
def _get_volatility_value(self, *, volatility_type: str) -> str | None:
"""
@ -529,7 +540,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
}
# Get prices based on volatility type
prices_to_analyze = get_prices_for_volatility(volatility_type, price_info)
prices_to_analyze = get_prices_for_volatility(volatility_type, price_info, time=self.coordinator.time)
if not prices_to_analyze:
return None
@ -560,7 +571,9 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
add_icon_color_attribute(self._last_volatility_attributes, key="volatility", state_value=volatility)
# Add type-specific attributes
add_volatility_type_attributes(self._last_volatility_attributes, volatility_type, price_info, thresholds)
add_volatility_type_attributes(
self._last_volatility_attributes, volatility_type, price_info, thresholds, time=self.coordinator.time
)
# Return lowercase for ENUM device class
return volatility.lower()
@ -572,7 +585,9 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
# Add method to get future price intervals
def _get_price_forecast_value(self) -> str | None:
"""Get the highest or lowest price status for the price forecast entity."""
future_prices = get_future_prices(self.coordinator, max_intervals=MAX_FORECAST_INTERVALS)
future_prices = get_future_prices(
self.coordinator, max_intervals=MAX_FORECAST_INTERVALS, time=self.coordinator.time
)
if not future_prices:
return "No forecast data available"
@ -726,29 +741,30 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
"""Check if the current time is within a best price period."""
if not self.coordinator.data:
return False
attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=False)
attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=False, time=self.coordinator.time)
if not attrs:
return False
start = attrs.get("start")
end = attrs.get("end")
if not start or not end:
return False
now = dt_util.now()
time = self.coordinator.time
now = time.now()
return start <= now < end
def _is_peak_price_period_active(self) -> bool:
"""Check if the current time is within a peak price period."""
if not self.coordinator.data:
return False
attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=True)
attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=True, time=self.coordinator.time)
if not attrs:
return False
start = attrs.get("start")
end = attrs.get("end")
if not start or not end:
return False
now = dt_util.now()
return start <= now < end
time = self.coordinator.time
return time.is_current_interval(start, end)
@property
def icon(self) -> str | None:
@ -790,6 +806,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
context=IconContext(
coordinator_data=self.coordinator.data,
period_is_active_callback=period_is_active_callback,
time=self.coordinator.time,
),
)
@ -806,6 +823,8 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
# Get sensor-specific attributes
sensor_attrs = self._get_sensor_attributes()
time = self.coordinator.time
# Build complete attributes using unified builder
return build_extra_state_attributes(
entity_key=self.entity_description.key,
@ -814,6 +833,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
config_entry=self.coordinator.config_entry,
coordinator_data=self.coordinator.data,
sensor_attrs=sensor_attrs,
time=time,
)
except (KeyError, ValueError, TypeError) as ex:
@ -891,7 +911,8 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
config_entry=self.coordinator.config_entry,
)
self._chart_data_response = response
self._chart_data_last_update = dt_util.now()
time = self.coordinator.time
self._chart_data_last_update = time.now()
self._chart_data_error = error
# Trigger state update after refresh
self.async_write_ha_state()

View file

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

View file

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

View file

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

View file

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

View file

@ -3,73 +3,10 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
# Constants
INTERVALS_PER_DAY = 96 # 24 hours * 4 intervals per hour
def round_to_nearest_quarter_hour(dt: datetime) -> datetime:
"""
Round datetime to nearest 15-minute boundary with smart tolerance.
This handles edge cases where HA schedules us slightly before the boundary
(e.g., 14:59:59.500), while avoiding premature rounding during normal operation.
Strategy:
- If within ±2 seconds of a boundary round to that boundary
- Otherwise floor to current interval start
Examples:
- 14:59:57.999 15:00:00 (within 2s of boundary)
- 14:59:59.999 15:00:00 (within 2s of boundary)
- 14:59:30.000 14:45:00 (NOT within 2s, stay in current)
- 15:00:00.000 15:00:00 (exact boundary)
- 15:00:01.500 15:00:00 (within 2s of boundary)
Args:
dt: Datetime to round
Returns:
Datetime rounded to appropriate 15-minute boundary
"""
# Calculate current interval start (floor)
total_seconds = dt.hour * 3600 + dt.minute * 60 + dt.second + dt.microsecond / 1_000_000
interval_index = int(total_seconds // (15 * 60)) # Floor division
interval_start_seconds = interval_index * 15 * 60
# Calculate next interval start
next_interval_index = (interval_index + 1) % INTERVALS_PER_DAY
next_interval_start_seconds = next_interval_index * 15 * 60
# Distance to current interval start and next interval start
distance_to_current = total_seconds - interval_start_seconds
if next_interval_index == 0: # Midnight wrap
distance_to_next = (24 * 3600) - total_seconds
else:
distance_to_next = next_interval_start_seconds - total_seconds
# Tolerance: If within 2 seconds of a boundary, snap to it
boundary_tolerance_seconds = 2.0
if distance_to_next <= boundary_tolerance_seconds:
# Very close to next boundary → use next interval
target_interval_index = next_interval_index
elif distance_to_current <= boundary_tolerance_seconds:
# Very close to current boundary (shouldn't happen in practice, but handle it)
target_interval_index = interval_index
else:
# Normal case: stay in current interval
target_interval_index = interval_index
# Convert back to time
target_minutes = target_interval_index * 15
target_hour = int(target_minutes // 60)
target_minute = int(target_minutes % 60)
return dt.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0)
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TimeService
def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime) -> float:
@ -79,6 +16,7 @@ def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime)
Args:
all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate average for
time: TimeService instance (required)
Returns:
Average price for the 24 hours preceding the interval (not including the interval itself)
@ -91,10 +29,9 @@ def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime)
# Filter prices within the 24-hour window
prices_in_window = []
for price_data in all_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
starts_at = price_data["startsAt"] # Already datetime object in local timezone
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window (not including the current interval's end)
if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"]))
@ -112,6 +49,7 @@ def calculate_leading_24h_avg(all_prices: list[dict], interval_start: datetime)
Args:
all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate average for
time: TimeService instance (required)
Returns:
Average price for up to 24 hours following the interval (including the interval itself)
@ -124,10 +62,9 @@ def calculate_leading_24h_avg(all_prices: list[dict], interval_start: datetime)
# Filter prices within the 24-hour window
prices_in_window = []
for price_data in all_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
starts_at = price_data["startsAt"] # Already datetime object in local timezone
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window
if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"]))
@ -138,12 +75,17 @@ def calculate_leading_24h_avg(all_prices: list[dict], interval_start: datetime)
return 0.0
def calculate_current_trailing_avg(coordinator_data: dict) -> float | None:
def calculate_current_trailing_avg(
coordinator_data: dict,
*,
time: TimeService,
) -> float | None:
"""
Calculate the trailing 24-hour average for the current time.
Args:
coordinator_data: The coordinator data containing priceInfo
time: TimeService instance (required)
Returns:
Current trailing 24-hour average price, or None if unavailable
@ -161,16 +103,21 @@ def calculate_current_trailing_avg(coordinator_data: dict) -> float | None:
if not all_prices:
return None
now = dt_util.now()
now = time.now()
return calculate_trailing_24h_avg(all_prices, now)
def calculate_current_leading_avg(coordinator_data: dict) -> float | None:
def calculate_current_leading_avg(
coordinator_data: dict,
*,
time: TimeService,
) -> float | None:
"""
Calculate the leading 24-hour average for the current time.
Args:
coordinator_data: The coordinator data containing priceInfo
time: TimeService instance (required)
Returns:
Current leading 24-hour average price, or None if unavailable
@ -188,17 +135,23 @@ def calculate_current_leading_avg(coordinator_data: dict) -> float | None:
if not all_prices:
return None
now = dt_util.now()
now = time.now()
return calculate_leading_24h_avg(all_prices, now)
def calculate_trailing_24h_min(all_prices: list[dict], interval_start: datetime) -> float:
def calculate_trailing_24h_min(
all_prices: list[dict],
interval_start: datetime,
*,
time: TimeService,
) -> float:
"""
Calculate trailing 24-hour minimum price for a given interval.
Args:
all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate minimum for
time: TimeService instance (required)
Returns:
Minimum price for the 24 hours preceding the interval (not including the interval itself)
@ -211,10 +164,9 @@ def calculate_trailing_24h_min(all_prices: list[dict], interval_start: datetime)
# Filter prices within the 24-hour window
prices_in_window = []
for price_data in all_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
starts_at = time.get_interval_time(price_data)
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window (not including the current interval's end)
if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"]))
@ -225,13 +177,19 @@ def calculate_trailing_24h_min(all_prices: list[dict], interval_start: datetime)
return 0.0
def calculate_trailing_24h_max(all_prices: list[dict], interval_start: datetime) -> float:
def calculate_trailing_24h_max(
all_prices: list[dict],
interval_start: datetime,
*,
time: TimeService,
) -> float:
"""
Calculate trailing 24-hour maximum price for a given interval.
Args:
all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate maximum for
time: TimeService instance (required)
Returns:
Maximum price for the 24 hours preceding the interval (not including the interval itself)
@ -244,10 +202,9 @@ def calculate_trailing_24h_max(all_prices: list[dict], interval_start: datetime)
# Filter prices within the 24-hour window
prices_in_window = []
for price_data in all_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
starts_at = time.get_interval_time(price_data)
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window (not including the current interval's end)
if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"]))
@ -258,13 +215,19 @@ def calculate_trailing_24h_max(all_prices: list[dict], interval_start: datetime)
return 0.0
def calculate_leading_24h_min(all_prices: list[dict], interval_start: datetime) -> float:
def calculate_leading_24h_min(
all_prices: list[dict],
interval_start: datetime,
*,
time: TimeService,
) -> float:
"""
Calculate leading 24-hour minimum price for a given interval.
Args:
all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate minimum for
time: TimeService instance (required)
Returns:
Minimum price for up to 24 hours following the interval (including the interval itself)
@ -277,10 +240,9 @@ def calculate_leading_24h_min(all_prices: list[dict], interval_start: datetime)
# Filter prices within the 24-hour window
prices_in_window = []
for price_data in all_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
starts_at = time.get_interval_time(price_data)
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window
if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"]))
@ -291,13 +253,19 @@ def calculate_leading_24h_min(all_prices: list[dict], interval_start: datetime)
return 0.0
def calculate_leading_24h_max(all_prices: list[dict], interval_start: datetime) -> float:
def calculate_leading_24h_max(
all_prices: list[dict],
interval_start: datetime,
*,
time: TimeService,
) -> float:
"""
Calculate leading 24-hour maximum price for a given interval.
Args:
all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate maximum for
time: TimeService instance (required)
Returns:
Maximum price for up to 24 hours following the interval (including the interval itself)
@ -310,10 +278,9 @@ def calculate_leading_24h_max(all_prices: list[dict], interval_start: datetime)
# Filter prices within the 24-hour window
prices_in_window = []
for price_data in all_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
starts_at = time.get_interval_time(price_data)
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window
if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"]))
@ -324,12 +291,17 @@ def calculate_leading_24h_max(all_prices: list[dict], interval_start: datetime)
return 0.0
def calculate_current_trailing_min(coordinator_data: dict) -> float | None:
def calculate_current_trailing_min(
coordinator_data: dict,
*,
time: TimeService,
) -> float | None:
"""
Calculate the trailing 24-hour minimum for the current time.
Args:
coordinator_data: The coordinator data containing priceInfo
time: TimeService instance (required)
Returns:
Current trailing 24-hour minimum price, or None if unavailable
@ -347,16 +319,21 @@ def calculate_current_trailing_min(coordinator_data: dict) -> float | None:
if not all_prices:
return None
now = dt_util.now()
return calculate_trailing_24h_min(all_prices, now)
now = time.now()
return calculate_trailing_24h_min(all_prices, now, time=time)
def calculate_current_trailing_max(coordinator_data: dict) -> float | None:
def calculate_current_trailing_max(
coordinator_data: dict,
*,
time: TimeService,
) -> float | None:
"""
Calculate the trailing 24-hour maximum for the current time.
Args:
coordinator_data: The coordinator data containing priceInfo
time: TimeService instance (required)
Returns:
Current trailing 24-hour maximum price, or None if unavailable
@ -374,16 +351,21 @@ def calculate_current_trailing_max(coordinator_data: dict) -> float | None:
if not all_prices:
return None
now = dt_util.now()
return calculate_trailing_24h_max(all_prices, now)
now = time.now()
return calculate_trailing_24h_max(all_prices, now, time=time)
def calculate_current_leading_min(coordinator_data: dict) -> float | None:
def calculate_current_leading_min(
coordinator_data: dict,
*,
time: TimeService,
) -> float | None:
"""
Calculate the leading 24-hour minimum for the current time.
Args:
coordinator_data: The coordinator data containing priceInfo
time: TimeService instance (required)
Returns:
Current leading 24-hour minimum price, or None if unavailable
@ -401,16 +383,21 @@ def calculate_current_leading_min(coordinator_data: dict) -> float | None:
if not all_prices:
return None
now = dt_util.now()
return calculate_leading_24h_min(all_prices, now)
now = time.now()
return calculate_leading_24h_min(all_prices, now, time=time)
def calculate_current_leading_max(coordinator_data: dict) -> float | None:
def calculate_current_leading_max(
coordinator_data: dict,
*,
time: TimeService,
) -> float | None:
"""
Calculate the leading 24-hour maximum for the current time.
Args:
coordinator_data: The coordinator data containing priceInfo
time: TimeService instance (required)
Returns:
Current leading 24-hour maximum price, or None if unavailable
@ -428,11 +415,16 @@ def calculate_current_leading_max(coordinator_data: dict) -> float | None:
if not all_prices:
return None
now = dt_util.now()
return calculate_leading_24h_max(all_prices, now)
now = time.now()
return calculate_leading_24h_max(all_prices, now, time=time)
def calculate_next_n_hours_avg(coordinator_data: dict, hours: int) -> float | None:
def calculate_next_n_hours_avg(
coordinator_data: dict,
hours: int,
*,
time: TimeService,
) -> float | None:
"""
Calculate average price for the next N hours starting from the next interval.
@ -442,6 +434,7 @@ def calculate_next_n_hours_avg(coordinator_data: dict, hours: int) -> float | No
Args:
coordinator_data: The coordinator data containing priceInfo
hours: Number of hours to look ahead (1, 2, 3, 4, 5, 6, 8, 12, etc.)
time: TimeService instance (required)
Returns:
Average price for the next N hours, or None if insufficient data
@ -459,46 +452,38 @@ def calculate_next_n_hours_avg(coordinator_data: dict, hours: int) -> float | No
if not all_prices:
return None
now = dt_util.now()
# Find the current interval index
current_idx = None
for idx, price_data in enumerate(all_prices):
starts_at = dt_util.parse_datetime(price_data["startsAt"])
starts_at = time.get_interval_time(price_data)
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
interval_end = starts_at + timedelta(minutes=15)
interval_end = starts_at + time.get_interval_duration()
if starts_at <= now < interval_end:
if time.is_current_interval(starts_at, interval_end):
current_idx = idx
break
if current_idx is None:
return None
# Calculate how many 15-minute intervals are in N hours
intervals_needed = hours * 4 # 4 intervals per hour
# Calculate how many intervals are in N hours
intervals_needed = time.minutes_to_intervals(hours * 60)
# Collect prices starting from NEXT interval (current_idx + 1)
prices_in_window = []
for offset in range(1, intervals_needed + 1):
idx = current_idx + offset
if idx < len(all_prices):
if idx >= len(all_prices):
# Not enough future data available
break
price = all_prices[idx].get("total")
if price is not None:
prices_in_window.append(float(price))
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 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 statistics
from datetime import datetime, timedelta
from typing import Any
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TimeService
from custom_components.tibber_prices.const import (
DEFAULT_VOLATILITY_THRESHOLD_HIGH,
@ -19,9 +22,6 @@ from custom_components.tibber_prices.const import (
VOLATILITY_MODERATE,
VOLATILITY_VERY_HIGH,
)
from homeassistant.util import dt as dt_util
from .average import round_to_nearest_quarter_hour
_LOGGER = logging.getLogger(__name__)
@ -131,18 +131,10 @@ def calculate_trailing_average_for_interval(
matching_prices = []
for price_data in all_prices:
starts_at_str = price_data.get("startsAt")
if not starts_at_str:
price_time = price_data.get("startsAt") # Already datetime object in local timezone
if not price_time:
continue
# Parse the timestamp
price_time = dt_util.parse_datetime(starts_at_str)
if price_time is None:
continue
# Convert to local timezone for comparison
price_time = dt_util.as_local(price_time)
# Check if this price falls within our lookback window
# Include prices that start >= lookback_start and start < interval_start
if lookback_start <= price_time < interval_start:
@ -244,15 +236,9 @@ def _process_price_interval(
day_label: Label for logging ("today" or "tomorrow")
"""
starts_at_str = price_interval.get("startsAt")
if not starts_at_str:
starts_at = price_interval.get("startsAt") # Already datetime object in local timezone
if not starts_at:
return
starts_at = dt_util.parse_datetime(starts_at_str)
if starts_at is None:
return
starts_at = dt_util.as_local(starts_at)
current_interval_price = price_interval.get("total")
if current_interval_price is None:
@ -295,6 +281,7 @@ def enrich_price_info_with_differences(
price_info: Dictionary with 'yesterday', 'today', 'tomorrow' keys
threshold_low: Low threshold percentage for rating_level (defaults to -10)
threshold_high: High threshold percentage for rating_level (defaults to 10)
time: TimeService instance (required)
Returns:
Updated price_info dict with 'difference' and 'rating_level' added
@ -333,13 +320,19 @@ def enrich_price_info_with_differences(
return price_info
def find_price_data_for_interval(price_info: Any, target_time: datetime) -> dict | None:
def find_price_data_for_interval(
price_info: Any,
target_time: datetime,
*,
time: TimeService,
) -> dict | None:
"""
Find the price data for a specific 15-minute interval timestamp.
Args:
price_info: The price info dictionary from Tibber API
target_time: The target timestamp to find price data for
time: TimeService instance (required)
Returns:
Price data dict if found, None otherwise
@ -347,9 +340,9 @@ def find_price_data_for_interval(price_info: Any, target_time: datetime) -> dict
"""
# Round to nearest quarter-hour to handle edge cases where we're called
# slightly before the boundary (e.g., 14:59:59.999 → 15:00:00)
rounded_time = round_to_nearest_quarter_hour(target_time)
rounded_time = time.round_to_nearest_quarter(target_time)
day_key = "tomorrow" if rounded_time.date() > dt_util.now().date() else "today"
day_key = "tomorrow" if rounded_time.date() > time.now().date() else "today"
search_days = [day_key, "tomorrow" if day_key == "today" else "today"]
for search_day in search_days:
@ -358,11 +351,10 @@ def find_price_data_for_interval(price_info: Any, target_time: datetime) -> dict
continue
for price_data in day_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
starts_at = time.get_interval_time(price_data)
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
# Exact match after rounding
if starts_at == rounded_time and starts_at.date() == rounded_time.date():
return price_data