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