mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-28 01:26:49 +00:00
Compare commits
No commits in common. "db02f262b6521ee43f91e89b9cb7a91166a22e35" and "752a0c5dbc0620a2f3eb5bb5e652ae7dc4531aab" have entirely different histories.
db02f262b6
...
752a0c5dbc
12 changed files with 175 additions and 346 deletions
|
|
@ -7,7 +7,6 @@ from typing import TYPE_CHECKING
|
||||||
from custom_components.tibber_prices.const import get_display_unit_factor
|
from custom_components.tibber_prices.const import get_display_unit_factor
|
||||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||||
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.sensor.attributes.metadata import _find_current_segment_in_data
|
|
||||||
|
|
||||||
# Constants for price display conversion
|
# Constants for price display conversion
|
||||||
_SUBUNIT_FACTOR = 100 # Conversion factor for subunit currency (ct/øre)
|
_SUBUNIT_FACTOR = 100 # Conversion factor for subunit currency (ct/øre)
|
||||||
|
|
@ -30,7 +29,8 @@ def get_current_phase_type(coordinator_data: dict, *, time: TibberPricesTimeServ
|
||||||
"""
|
"""
|
||||||
Return the type of the currently active intra-day price phase.
|
Return the type of the currently active intra-day price phase.
|
||||||
|
|
||||||
Delegates to the shared segment finder in sensor/attributes/metadata.py.
|
Walks today's segments and returns the type ("rising", "falling", or "flat")
|
||||||
|
of the last segment whose start time is ≤ now.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
coordinator_data: The coordinator's data dict.
|
coordinator_data: The coordinator's data dict.
|
||||||
|
|
@ -42,39 +42,32 @@ def get_current_phase_type(coordinator_data: dict, *, time: TibberPricesTimeServ
|
||||||
"""
|
"""
|
||||||
if not coordinator_data:
|
if not coordinator_data:
|
||||||
return None
|
return None
|
||||||
current_index, segments = _find_current_segment_in_data(coordinator_data, time=time)
|
|
||||||
if current_index is None or segments is None:
|
|
||||||
return None
|
|
||||||
return segments[current_index].get("type")
|
|
||||||
|
|
||||||
|
day_patterns = coordinator_data.get("dayPatterns")
|
||||||
def get_phase_attributes(coordinator_data: dict, *, time: TibberPricesTimeService) -> dict | None:
|
if not day_patterns:
|
||||||
"""
|
|
||||||
Build start/end attributes for in_*_price_phase binary sensors.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
coordinator_data: The coordinator's data dict.
|
|
||||||
time: TibberPricesTimeService instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with start and end timestamps, or None if unavailable.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if not coordinator_data:
|
|
||||||
return None
|
|
||||||
current_index, segments = _find_current_segment_in_data(coordinator_data, time=time)
|
|
||||||
if current_index is None or segments is None:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
segment = segments[current_index]
|
today_data = day_patterns.get("today")
|
||||||
attrs: dict = {}
|
if not today_data:
|
||||||
|
return None
|
||||||
|
|
||||||
if start := segment.get("start"):
|
segments: list[dict] | None = today_data.get("segments")
|
||||||
attrs["start"] = start
|
if not segments:
|
||||||
if end := segment.get("end"):
|
return None
|
||||||
attrs["end"] = end
|
|
||||||
|
|
||||||
return attrs or None
|
from homeassistant.util.dt import parse_datetime # noqa: PLC0415
|
||||||
|
|
||||||
|
now = time.now()
|
||||||
|
current_type: str | None = None
|
||||||
|
for segment in segments:
|
||||||
|
seg_start_str: str | None = segment.get("start")
|
||||||
|
if not seg_start_str:
|
||||||
|
continue
|
||||||
|
seg_start = parse_datetime(seg_start_str)
|
||||||
|
if seg_start is not None and now >= seg_start:
|
||||||
|
current_type = segment.get("type")
|
||||||
|
|
||||||
|
return current_type
|
||||||
|
|
||||||
|
|
||||||
def get_tomorrow_data_available_attributes(
|
def get_tomorrow_data_available_attributes(
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ from .attributes import (
|
||||||
build_async_extra_state_attributes,
|
build_async_extra_state_attributes,
|
||||||
build_sync_extra_state_attributes,
|
build_sync_extra_state_attributes,
|
||||||
get_current_phase_type,
|
get_current_phase_type,
|
||||||
get_phase_attributes,
|
|
||||||
get_price_intervals_attributes,
|
get_price_intervals_attributes,
|
||||||
get_tomorrow_data_available_attributes,
|
get_tomorrow_data_available_attributes,
|
||||||
)
|
)
|
||||||
|
|
@ -317,9 +316,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
||||||
if key == "tomorrow_data_available":
|
if key == "tomorrow_data_available":
|
||||||
return self._get_tomorrow_data_available_attributes()
|
return self._get_tomorrow_data_available_attributes()
|
||||||
|
|
||||||
if key in ("in_rising_price_phase", "in_falling_price_phase", "in_flat_price_phase"):
|
|
||||||
return get_phase_attributes(self.coordinator.data, time=self.coordinator.time)
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
|
|
|
||||||
|
|
@ -268,10 +268,8 @@ class TibberPricesIntervalPoolFetcher:
|
||||||
"""
|
"""
|
||||||
Fetch missing intervals from API.
|
Fetch missing intervals from API.
|
||||||
|
|
||||||
Makes API calls per missing range, but skips redundant calls when a
|
Makes one API call per missing range. Uses routing logic to select
|
||||||
previous fetch already returned intervals covering subsequent ranges.
|
the optimal endpoint (PRICE_INFO vs PRICE_INFO_RANGE).
|
||||||
This is common for the PRICE_INFO endpoint which returns ALL available
|
|
||||||
intervals (~384) regardless of the requested range.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_client: TibberPricesApiClient instance for API calls.
|
api_client: TibberPricesApiClient instance for API calls.
|
||||||
|
|
@ -292,26 +290,9 @@ class TibberPricesIntervalPoolFetcher:
|
||||||
from custom_components.tibber_prices.interval_pool.routing import get_price_intervals_for_range # noqa: PLC0415
|
from custom_components.tibber_prices.interval_pool.routing import get_price_intervals_for_range # noqa: PLC0415
|
||||||
|
|
||||||
fetch_time_iso = dt_util.now().isoformat()
|
fetch_time_iso = dt_util.now().isoformat()
|
||||||
all_fetched_intervals: list[list[dict[str, Any]]] = []
|
all_fetched_intervals = []
|
||||||
|
|
||||||
# Collect startsAt values from all fetched intervals to detect overlap
|
|
||||||
fetched_starts_at: set[str] = set()
|
|
||||||
|
|
||||||
for idx, (missing_start_iso, missing_end_iso) in enumerate(missing_ranges, start=1):
|
for idx, (missing_start_iso, missing_end_iso) in enumerate(missing_ranges, start=1):
|
||||||
# Check if a previous fetch already covered this range
|
|
||||||
if fetched_starts_at and self._range_covered_by_fetched(
|
|
||||||
missing_start_iso, missing_end_iso, fetched_starts_at
|
|
||||||
):
|
|
||||||
_LOGGER_DETAILS.debug(
|
|
||||||
"Range %s to %s already covered by previous fetch for home %s, skipping API call (%d/%d)",
|
|
||||||
missing_start_iso,
|
|
||||||
missing_end_iso,
|
|
||||||
self._home_id,
|
|
||||||
idx,
|
|
||||||
len(missing_ranges),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
_LOGGER_DETAILS.debug(
|
_LOGGER_DETAILS.debug(
|
||||||
"Fetching from Tibber API (%d/%d) for home %s: range %s to %s",
|
"Fetching from Tibber API (%d/%d) for home %s: range %s to %s",
|
||||||
idx,
|
idx,
|
||||||
|
|
@ -336,10 +317,6 @@ class TibberPricesIntervalPoolFetcher:
|
||||||
|
|
||||||
all_fetched_intervals.append(fetched_intervals)
|
all_fetched_intervals.append(fetched_intervals)
|
||||||
|
|
||||||
# Track which timestamps we've fetched for overlap detection
|
|
||||||
for interval in fetched_intervals:
|
|
||||||
fetched_starts_at.add(interval["startsAt"][:19])
|
|
||||||
|
|
||||||
_LOGGER_DETAILS.debug(
|
_LOGGER_DETAILS.debug(
|
||||||
"Received %d intervals from Tibber API for home %s",
|
"Received %d intervals from Tibber API for home %s",
|
||||||
len(fetched_intervals),
|
len(fetched_intervals),
|
||||||
|
|
@ -351,30 +328,3 @@ class TibberPricesIntervalPoolFetcher:
|
||||||
on_intervals_fetched(fetched_intervals, fetch_time_iso)
|
on_intervals_fetched(fetched_intervals, fetch_time_iso)
|
||||||
|
|
||||||
return all_fetched_intervals
|
return all_fetched_intervals
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _range_covered_by_fetched(
|
|
||||||
start_iso: str,
|
|
||||||
end_iso: str,
|
|
||||||
fetched_starts_at: set[str],
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Check if a missing range is already covered by previously fetched intervals.
|
|
||||||
|
|
||||||
A range is considered covered if at least one fetched interval falls within
|
|
||||||
[start, end). This is a conservative check — even partial overlap means the
|
|
||||||
API response likely included data for this range.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start_iso: Start of the missing range (ISO format).
|
|
||||||
end_iso: End of the missing range (ISO format).
|
|
||||||
fetched_starts_at: Set of normalized startsAt strings from previous fetches.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if the range is already covered.
|
|
||||||
|
|
||||||
"""
|
|
||||||
start_normalized = start_iso[:19]
|
|
||||||
end_normalized = end_iso[:19]
|
|
||||||
|
|
||||||
return any(start_normalized <= ts < end_normalized for ts in fetched_starts_at)
|
|
||||||
|
|
|
||||||
|
|
@ -93,19 +93,6 @@ class TibberPricesIntervalPoolGarbageCollector:
|
||||||
empty_removed,
|
empty_removed,
|
||||||
self._home_id,
|
self._home_id,
|
||||||
)
|
)
|
||||||
elif dead_count > 0:
|
|
||||||
# _cleanup_dead_intervals compacted group["intervals"] lists in-place,
|
|
||||||
# shifting the positions of surviving intervals. _remove_empty_groups
|
|
||||||
# only rebuilds the index when it removes completely-empty groups.
|
|
||||||
# If no groups became empty, the index still holds stale interval_index
|
|
||||||
# values that now point past the end of the compacted lists, causing
|
|
||||||
# an IndexError in _get_cached_intervals. Rebuild the index here to
|
|
||||||
# keep it consistent with the compacted groups.
|
|
||||||
self._index.rebuild(fetch_groups)
|
|
||||||
_LOGGER_DETAILS.debug(
|
|
||||||
"GC rebuilt index after dead interval cleanup for home %s",
|
|
||||||
self._home_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Phase 2: Count total intervals after cleanup
|
# Phase 2: Count total intervals after cleanup
|
||||||
total_intervals = self._cache.count_total_intervals()
|
total_intervals = self._cache.count_total_intervals()
|
||||||
|
|
|
||||||
|
|
@ -725,20 +725,13 @@ class TibberPricesIntervalPool:
|
||||||
if intervals_to_touch:
|
if intervals_to_touch:
|
||||||
self._touch_intervals(intervals_to_touch, fetch_time_dt)
|
self._touch_intervals(intervals_to_touch, fetch_time_dt)
|
||||||
|
|
||||||
# Run GC after touch even if no new intervals — touching creates dead
|
if not new_intervals:
|
||||||
# intervals in old fetch groups that should be cleaned up promptly.
|
if intervals_to_touch:
|
||||||
if intervals_to_touch and not new_intervals:
|
_LOGGER_DETAILS.debug(
|
||||||
gc_changed_data = self._gc.run_gc()
|
"All %d intervals already cached for home %s (touched only)",
|
||||||
|
len(intervals),
|
||||||
_LOGGER_DETAILS.debug(
|
self._home_id,
|
||||||
"All %d intervals already cached for home %s (touched only, GC ran: %s)",
|
)
|
||||||
len(intervals),
|
|
||||||
self._home_id,
|
|
||||||
gc_changed_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (intervals_to_touch or gc_changed_data) and self._hass is not None and self._entry_id is not None:
|
|
||||||
self._schedule_debounced_save()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Sort new intervals by startsAt
|
# Sort new intervals by startsAt
|
||||||
|
|
|
||||||
|
|
@ -11,54 +11,6 @@ if TYPE_CHECKING:
|
||||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
|
||||||
|
|
||||||
def _find_current_segment_in_data(
|
|
||||||
coordinator_data: dict,
|
|
||||||
*,
|
|
||||||
time: TibberPricesTimeService,
|
|
||||||
) -> tuple[int, list[dict[str, Any]]] | tuple[None, None]:
|
|
||||||
"""
|
|
||||||
Find the currently active segment in today's day pattern data.
|
|
||||||
|
|
||||||
Shared helper for all phase-related attribute/value lookups.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
coordinator_data: Coordinator's data dict (must not be None).
|
|
||||||
time: TibberPricesTimeService instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (current_index, segments) or (None, None) if unavailable.
|
|
||||||
|
|
||||||
"""
|
|
||||||
day_patterns = coordinator_data.get("dayPatterns")
|
|
||||||
if not day_patterns:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
today_data: dict[str, Any] | None = day_patterns.get("today")
|
|
||||||
if not today_data:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
segments: list[dict[str, Any]] | None = today_data.get("segments")
|
|
||||||
if not segments:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
from homeassistant.util.dt import parse_datetime # noqa: PLC0415
|
|
||||||
|
|
||||||
now = time.now()
|
|
||||||
current_index: int | None = None
|
|
||||||
for i, segment in enumerate(segments):
|
|
||||||
seg_start_str: str | None = segment.get("start")
|
|
||||||
if not seg_start_str:
|
|
||||||
continue
|
|
||||||
seg_start = parse_datetime(seg_start_str)
|
|
||||||
if seg_start is not None and now >= seg_start:
|
|
||||||
current_index = i
|
|
||||||
|
|
||||||
if current_index is None:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
return current_index, segments
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_interval_data(
|
def get_current_interval_data(
|
||||||
coordinator: TibberPricesDataUpdateCoordinator,
|
coordinator: TibberPricesDataUpdateCoordinator,
|
||||||
*,
|
*,
|
||||||
|
|
@ -179,8 +131,31 @@ def get_current_price_phase_attributes(
|
||||||
if not coordinator.data:
|
if not coordinator.data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
current_index, segments = _find_current_segment_in_data(coordinator.data, time=time)
|
day_patterns = coordinator.data.get("dayPatterns")
|
||||||
if current_index is None or segments is None:
|
if not day_patterns:
|
||||||
|
return None
|
||||||
|
|
||||||
|
today_data: dict[str, Any] | None = day_patterns.get("today")
|
||||||
|
if not today_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
segments: list[dict[str, Any]] | None = today_data.get("segments")
|
||||||
|
if not segments:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from homeassistant.util.dt import parse_datetime # noqa: PLC0415
|
||||||
|
|
||||||
|
now = time.now()
|
||||||
|
current_index: int | None = None
|
||||||
|
for i, segment in enumerate(segments):
|
||||||
|
seg_start_str: str | None = segment.get("start")
|
||||||
|
if not seg_start_str:
|
||||||
|
continue
|
||||||
|
seg_start = parse_datetime(seg_start_str)
|
||||||
|
if seg_start is not None and now >= seg_start:
|
||||||
|
current_index = i
|
||||||
|
|
||||||
|
if current_index is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
seg = segments[current_index]
|
seg = segments[current_index]
|
||||||
|
|
@ -205,8 +180,8 @@ def get_next_price_phase_attributes(
|
||||||
"""
|
"""
|
||||||
Build attributes for the next_price_phase sensor.
|
Build attributes for the next_price_phase sensor.
|
||||||
|
|
||||||
Returns details of the segment that follows the currently active one.
|
Returns details of the segment that follows the currently active one,
|
||||||
If today's current segment is the last, falls back to tomorrow's first segment.
|
including its start time (useful for scheduling automations).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
coordinator: The data update coordinator.
|
coordinator: The data update coordinator.
|
||||||
|
|
@ -219,42 +194,41 @@ def get_next_price_phase_attributes(
|
||||||
if not coordinator.data:
|
if not coordinator.data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
current_index, segments = _find_current_segment_in_data(coordinator.data, time=time)
|
|
||||||
if current_index is None or segments is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Next segment in today
|
|
||||||
if current_index + 1 < len(segments):
|
|
||||||
next_seg = segments[current_index + 1]
|
|
||||||
attrs: dict[str, Any] = {
|
|
||||||
"start": next_seg.get("start"),
|
|
||||||
"end": next_seg.get("end"),
|
|
||||||
"price_min": next_seg.get("price_min"),
|
|
||||||
"price_max": next_seg.get("price_max"),
|
|
||||||
"price_mean": next_seg.get("price_mean"),
|
|
||||||
"segment_index": current_index + 1,
|
|
||||||
"segment_count": len(segments),
|
|
||||||
}
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
# Fall back to tomorrow's first segment
|
|
||||||
day_patterns = coordinator.data.get("dayPatterns")
|
day_patterns = coordinator.data.get("dayPatterns")
|
||||||
if not day_patterns:
|
if not day_patterns:
|
||||||
return None
|
return None
|
||||||
tomorrow_data = day_patterns.get("tomorrow")
|
|
||||||
if not tomorrow_data:
|
today_data: dict[str, Any] | None = day_patterns.get("today")
|
||||||
|
if not today_data:
|
||||||
return None
|
return None
|
||||||
tomorrow_segments: list[dict[str, Any]] = tomorrow_data.get("segments", [])
|
|
||||||
if not tomorrow_segments:
|
segments: list[dict[str, Any]] | None = today_data.get("segments")
|
||||||
|
if not segments:
|
||||||
return None
|
return None
|
||||||
next_seg = tomorrow_segments[0]
|
|
||||||
return {
|
from homeassistant.util.dt import parse_datetime # noqa: PLC0415
|
||||||
|
|
||||||
|
now = time.now()
|
||||||
|
current_index: int | None = None
|
||||||
|
for i, segment in enumerate(segments):
|
||||||
|
seg_start_str: str | None = segment.get("start")
|
||||||
|
if not seg_start_str:
|
||||||
|
continue
|
||||||
|
seg_start = parse_datetime(seg_start_str)
|
||||||
|
if seg_start is not None and now >= seg_start:
|
||||||
|
current_index = i
|
||||||
|
|
||||||
|
if current_index is None or current_index + 1 >= len(segments):
|
||||||
|
return None
|
||||||
|
|
||||||
|
next_seg = segments[current_index + 1]
|
||||||
|
attrs: dict[str, Any] = {
|
||||||
"start": next_seg.get("start"),
|
"start": next_seg.get("start"),
|
||||||
"end": next_seg.get("end"),
|
"end": next_seg.get("end"),
|
||||||
"price_min": next_seg.get("price_min"),
|
"price_min": next_seg.get("price_min"),
|
||||||
"price_max": next_seg.get("price_max"),
|
"price_max": next_seg.get("price_max"),
|
||||||
"price_mean": next_seg.get("price_mean"),
|
"price_mean": next_seg.get("price_mean"),
|
||||||
"segment_index": 0,
|
"segment_index": current_index + 1,
|
||||||
"segment_count": len(tomorrow_segments),
|
"segment_count": len(segments),
|
||||||
"is_tomorrow": True,
|
|
||||||
}
|
}
|
||||||
|
return attrs
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,6 @@ if TYPE_CHECKING:
|
||||||
# Timer #3 triggers every 30 seconds
|
# Timer #3 triggers every 30 seconds
|
||||||
TIMER_30_SEC_BOUNDARY = 30
|
TIMER_30_SEC_BOUNDARY = 30
|
||||||
|
|
||||||
# Phase timing sensor keys — allocated once at module level
|
|
||||||
_PHASE_TIMING_KEYS = frozenset(
|
|
||||||
{
|
|
||||||
"current_price_phase_end_time",
|
|
||||||
"current_price_phase_remaining_minutes",
|
|
||||||
"current_price_phase_duration",
|
|
||||||
"current_price_phase_progress",
|
|
||||||
"next_rising_phase_start_time",
|
|
||||||
"next_falling_phase_start_time",
|
|
||||||
"next_flat_phase_start_time",
|
|
||||||
"next_rising_phase_in_minutes",
|
|
||||||
"next_falling_phase_in_minutes",
|
|
||||||
"next_flat_phase_in_minutes",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _hours_to_minutes(state_value: Any) -> int | None:
|
def _hours_to_minutes(state_value: Any) -> int | None:
|
||||||
"""Convert hour-based state back to rounded minutes for attributes."""
|
"""Convert hour-based state back to rounded minutes for attributes."""
|
||||||
|
|
@ -50,6 +34,20 @@ def _is_timing_or_volatility_sensor(key: str) -> bool:
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
# price phase timing sensors
|
# price phase timing sensors
|
||||||
|
_PHASE_TIMING_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"current_price_phase_end_time",
|
||||||
|
"current_price_phase_remaining_minutes",
|
||||||
|
"current_price_phase_duration",
|
||||||
|
"current_price_phase_progress",
|
||||||
|
"next_rising_phase_start_time",
|
||||||
|
"next_falling_phase_start_time",
|
||||||
|
"next_flat_phase_start_time",
|
||||||
|
"next_rising_phase_in_minutes",
|
||||||
|
"next_falling_phase_in_minutes",
|
||||||
|
"next_flat_phase_in_minutes",
|
||||||
|
}
|
||||||
|
)
|
||||||
return key in _PHASE_TIMING_KEYS
|
return key in _PHASE_TIMING_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,44 +149,80 @@ class TibberPricesMetadataCalculator(TibberPricesBaseCalculator):
|
||||||
"rising", "falling", or "flat", or None if data is unavailable.
|
"rising", "falling", or "flat", or None if data is unavailable.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
current_index, segments = self._find_current_segment()
|
if not self.coordinator.data:
|
||||||
if current_index is None or segments is None:
|
|
||||||
return None
|
return None
|
||||||
return segments[current_index].get("type")
|
|
||||||
|
day_patterns = self.coordinator.data.get("dayPatterns")
|
||||||
|
if not day_patterns:
|
||||||
|
return None
|
||||||
|
|
||||||
|
today_data = day_patterns.get("today")
|
||||||
|
if not today_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
segments: list[dict] | None = today_data.get("segments")
|
||||||
|
if not segments:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from homeassistant.util.dt import parse_datetime # noqa: PLC0415
|
||||||
|
|
||||||
|
now = self.coordinator.time.now()
|
||||||
|
current_segment: dict | None = None
|
||||||
|
for segment in segments:
|
||||||
|
seg_start_str: str | None = segment.get("start")
|
||||||
|
if not seg_start_str:
|
||||||
|
continue
|
||||||
|
seg_start = parse_datetime(seg_start_str)
|
||||||
|
if seg_start is not None and now >= seg_start:
|
||||||
|
current_segment = segment
|
||||||
|
|
||||||
|
if current_segment is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return current_segment.get("type")
|
||||||
|
|
||||||
def get_next_price_phase_value(self) -> str | None:
|
def get_next_price_phase_value(self) -> str | None:
|
||||||
"""
|
"""
|
||||||
Get the next intra-day price phase (rising / falling / flat).
|
Get the next intra-day price phase (rising / falling / flat).
|
||||||
|
|
||||||
Finds the monotone segment that starts after the current segment.
|
Finds the monotone segment in today's day-pattern that starts after
|
||||||
If the current segment is the last of today, falls back to the first
|
the current segment and returns its type string.
|
||||||
segment of tomorrow (if available).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
"rising", "falling", or "flat", or None if no next segment exists.
|
"rising", "falling", or "flat", or None if no next segment exists.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
current_index, segments = self._find_current_segment()
|
|
||||||
if current_index is None or segments is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Next segment in today
|
|
||||||
if current_index + 1 < len(segments):
|
|
||||||
return segments[current_index + 1].get("type")
|
|
||||||
|
|
||||||
# Fall back to tomorrow's first segment
|
|
||||||
if not self.coordinator.data:
|
if not self.coordinator.data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
day_patterns = self.coordinator.data.get("dayPatterns")
|
day_patterns = self.coordinator.data.get("dayPatterns")
|
||||||
if not day_patterns:
|
if not day_patterns:
|
||||||
return None
|
return None
|
||||||
tomorrow_data = day_patterns.get("tomorrow")
|
|
||||||
if not tomorrow_data:
|
today_data = day_patterns.get("today")
|
||||||
|
if not today_data:
|
||||||
return None
|
return None
|
||||||
tomorrow_segments: list[dict] = tomorrow_data.get("segments", [])
|
|
||||||
if not tomorrow_segments:
|
segments: list[dict] | None = today_data.get("segments")
|
||||||
|
if not segments:
|
||||||
return None
|
return None
|
||||||
return tomorrow_segments[0].get("type")
|
|
||||||
|
from homeassistant.util.dt import parse_datetime # noqa: PLC0415
|
||||||
|
|
||||||
|
now = self.coordinator.time.now()
|
||||||
|
current_index: int | None = None
|
||||||
|
for i, segment in enumerate(segments):
|
||||||
|
seg_start_str: str | None = segment.get("start")
|
||||||
|
if not seg_start_str:
|
||||||
|
continue
|
||||||
|
seg_start = parse_datetime(seg_start_str)
|
||||||
|
if seg_start is not None and now >= seg_start:
|
||||||
|
current_index = i
|
||||||
|
|
||||||
|
if current_index is None or current_index + 1 >= len(segments):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return segments[current_index + 1].get("type")
|
||||||
|
|
||||||
def _find_current_segment(self) -> tuple[int, list[dict]] | tuple[None, None]:
|
def _find_current_segment(self) -> tuple[int, list[dict]] | tuple[None, None]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1045,12 +1045,6 @@ DAY_PATTERN_SENSORS = (
|
||||||
state_class=None,
|
state_class=None,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
)
|
|
||||||
|
|
||||||
# 8b. PRICE PHASE SENSORS (current/next intra-day price phase classification)
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
PRICE_PHASE_SENSORS = (
|
|
||||||
SensorEntityDescription(
|
SensorEntityDescription(
|
||||||
key="current_price_phase",
|
key="current_price_phase",
|
||||||
translation_key="current_price_phase",
|
translation_key="current_price_phase",
|
||||||
|
|
@ -1071,7 +1065,7 @@ PRICE_PHASE_SENSORS = (
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 8c. PRICE PHASE TIMING SENSORS (current phase duration/progress + next-phase-by-type)
|
# 8b. PRICE PHASE TIMING SENSORS (current phase duration/progress + next-phase-by-type)
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
#
|
#
|
||||||
# When current phase is active:
|
# When current phase is active:
|
||||||
|
|
@ -1376,7 +1370,6 @@ ENTITY_DESCRIPTIONS = (
|
||||||
*BEST_PRICE_TIMING_SENSORS,
|
*BEST_PRICE_TIMING_SENSORS,
|
||||||
*PEAK_PRICE_TIMING_SENSORS,
|
*PEAK_PRICE_TIMING_SENSORS,
|
||||||
*DAY_PATTERN_SENSORS,
|
*DAY_PATTERN_SENSORS,
|
||||||
*PRICE_PHASE_SENSORS,
|
|
||||||
*PRICE_PHASE_TIMING_SENSORS,
|
*PRICE_PHASE_TIMING_SENSORS,
|
||||||
*DIAGNOSTIC_SENSORS,
|
*DIAGNOSTIC_SENSORS,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -219,25 +219,13 @@ explanations of each sensor's purpose, attributes, and automation examples.
|
||||||
| <span id="ref-current_interval_price_rank_today" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`current_interval_price_rank_today` | Current Price Rank (Today) | Aktueller Preisrang (heute) | Aktuell prisrang (i dag) | Huidige prijsrang (vandaag) | Aktuellt prisrang (idag) | ✅ |
|
| <span id="ref-current_interval_price_rank_today" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`current_interval_price_rank_today` | Current Price Rank (Today) | Aktueller Preisrang (heute) | Aktuell prisrang (i dag) | Huidige prijsrang (vandaag) | Aktuellt prisrang (idag) | ✅ |
|
||||||
| <span id="ref-current_interval_price_rank_today_tomorrow" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`current_interval_price_rank_today_tomorrow` | Current Price Rank (Today+Tomorrow) | Aktueller Preisrang (heute+morgen) | Aktuell prisrang (i dag+i morgen) | Huidige prijsrang (vandaag+morgen) | Aktuellt prisrang (idag+imorgon) | ❌ |
|
| <span id="ref-current_interval_price_rank_today_tomorrow" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`current_interval_price_rank_today_tomorrow` | Current Price Rank (Today+Tomorrow) | Aktueller Preisrang (heute+morgen) | Aktuell prisrang (i dag+i morgen) | Huidige prijsrang (vandaag+morgen) | Aktuellt prisrang (idag+imorgon) | ❌ |
|
||||||
| <span id="ref-current_interval_price_rank_tomorrow" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`current_interval_price_rank_tomorrow` | Current Price Rank (Tomorrow) | Aktueller Preisrang (morgen) | Aktuell prisrang (i morgen) | Huidige prijsrang (morgen) | Aktuellt prisrang (imorgon) | ❌ |
|
| <span id="ref-current_interval_price_rank_tomorrow" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`current_interval_price_rank_tomorrow` | Current Price Rank (Tomorrow) | Aktueller Preisrang (morgen) | Aktuell prisrang (i morgen) | Huidige prijsrang (morgen) | Aktuellt prisrang (imorgon) | ❌ |
|
||||||
| <span id="ref-current_price_phase" class="entity-anchor"></span>`current_price_phase` | Current Price Phase | Aktuelle Preisphase | Gjeldende Prisfase | Huidige Prijsfase | Aktuell Prisfas | ✅ |
|
|
||||||
| <span id="ref-current_price_phase_duration" class="entity-anchor" data-refs="sensors-price-phases#current-phase-timing-4-sensors"></span>`current_price_phase_duration` | Current Phase Duration | Current Phase Duration | Current Phase Duration | Current Phase Duration | Current Phase Duration | ❌ |
|
|
||||||
| <span id="ref-current_price_phase_end_time" class="entity-anchor" data-refs="sensors-price-phases#current-phase-timing-4-sensors"></span>`current_price_phase_end_time` | Current Phase End Time | Current Phase End Time | Current Phase End Time | Current Phase End Time | Current Phase End Time | ✅ |
|
|
||||||
| <span id="ref-current_price_phase_progress" class="entity-anchor" data-refs="sensors-price-phases#current-phase-timing-4-sensors"></span>`current_price_phase_progress` | Current Phase Progress | Current Phase Progress | Current Phase Progress | Current Phase Progress | Current Phase Progress | ❌ |
|
|
||||||
| <span id="ref-current_price_phase_remaining_minutes" class="entity-anchor" data-refs="sensors-price-phases#current-phase-timing-4-sensors"></span>`current_price_phase_remaining_minutes` | Current Phase Remaining | Current Phase Remaining | Current Phase Remaining | Current Phase Remaining | Current Phase Remaining | ✅ |
|
|
||||||
| <span id="ref-day_pattern_today" class="entity-anchor"></span>`day_pattern_today` | Today's Price Pattern | Preismuster Heute | Prismønster i dag | Prijspatroon Vandaag | Prismönster Idag | ✅ |
|
| <span id="ref-day_pattern_today" class="entity-anchor"></span>`day_pattern_today` | Today's Price Pattern | Preismuster Heute | Prismønster i dag | Prijspatroon Vandaag | Prismönster Idag | ✅ |
|
||||||
| <span id="ref-day_pattern_tomorrow" class="entity-anchor"></span>`day_pattern_tomorrow` | Tomorrow's Price Pattern | Preismuster Morgen | Prismønster i morgen | Prijspatroon Morgen | Prismönster Imorgon | ❌ |
|
| <span id="ref-day_pattern_tomorrow" class="entity-anchor"></span>`day_pattern_tomorrow` | Tomorrow's Price Pattern | Preismuster Morgen | Prismønster i morgen | Prijspatroon Morgen | Prismönster Imorgon | ❌ |
|
||||||
| <span id="ref-day_pattern_yesterday" class="entity-anchor"></span>`day_pattern_yesterday` | Yesterday's Price Pattern | Preismuster Gestern | Prismønster i går | Prijspatroon Gisteren | Prismönster Igår | ❌ |
|
| <span id="ref-day_pattern_yesterday" class="entity-anchor"></span>`day_pattern_yesterday` | Yesterday's Price Pattern | Preismuster Gestern | Prismønster i går | Prijspatroon Gisteren | Prismönster Igår | ❌ |
|
||||||
| <span id="ref-next_falling_phase_in_minutes" class="entity-anchor" data-refs="sensors-price-phases#next-phase-by-type-6-sensors"></span>`next_falling_phase_in_minutes` | Time to Next Falling Phase | Time to Next Falling Phase | Time to Next Falling Phase | Time to Next Falling Phase | Time to Next Falling Phase | ❌ |
|
|
||||||
| <span id="ref-next_falling_phase_start_time" class="entity-anchor" data-refs="sensors-price-phases#next-phase-by-type-6-sensors"></span>`next_falling_phase_start_time` | Next Falling Phase Start | Next Falling Phase Start | Next Falling Phase Start | Next Falling Phase Start | Next Falling Phase Start | ❌ |
|
|
||||||
| <span id="ref-next_flat_phase_in_minutes" class="entity-anchor" data-refs="sensors-price-phases#next-phase-by-type-6-sensors"></span>`next_flat_phase_in_minutes` | Time to Next Flat Phase | Time to Next Flat Phase | Time to Next Flat Phase | Time to Next Flat Phase | Time to Next Flat Phase | ❌ |
|
|
||||||
| <span id="ref-next_flat_phase_start_time" class="entity-anchor" data-refs="sensors-price-phases#next-phase-by-type-6-sensors"></span>`next_flat_phase_start_time` | Next Flat Phase Start | Next Flat Phase Start | Next Flat Phase Start | Next Flat Phase Start | Next Flat Phase Start | ❌ |
|
|
||||||
| <span id="ref-next_hour_price_rank_today" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`next_hour_price_rank_today` | ⌀ Hourly Price Next Rank (Today) | ⌀ Stündlicher Preisrang Nächste (heute) | ⌀ Timesprisrang neste (i dag) | ⌀ Uurlijkse prijsrang volgende (vandaag) | ⌀ Timprisrang nästa (idag) | ❌ |
|
| <span id="ref-next_hour_price_rank_today" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`next_hour_price_rank_today` | ⌀ Hourly Price Next Rank (Today) | ⌀ Stündlicher Preisrang Nächste (heute) | ⌀ Timesprisrang neste (i dag) | ⌀ Uurlijkse prijsrang volgende (vandaag) | ⌀ Timprisrang nästa (idag) | ❌ |
|
||||||
| <span id="ref-next_hour_price_rank_today_tomorrow" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`next_hour_price_rank_today_tomorrow` | ⌀ Hourly Price Next Rank (Today+Tomorrow) | ⌀ Stündlicher Preisrang Nächste (heute+morgen) | ⌀ Timesprisrang neste (i dag+i morgen) | ⌀ Uurlijkse prijsrang volgende (vandaag+morgen) | ⌀ Timprisrang nästa (idag+imorgon) | ❌ |
|
| <span id="ref-next_hour_price_rank_today_tomorrow" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`next_hour_price_rank_today_tomorrow` | ⌀ Hourly Price Next Rank (Today+Tomorrow) | ⌀ Stündlicher Preisrang Nächste (heute+morgen) | ⌀ Timesprisrang neste (i dag+i morgen) | ⌀ Uurlijkse prijsrang volgende (vandaag+morgen) | ⌀ Timprisrang nästa (idag+imorgon) | ❌ |
|
||||||
| <span id="ref-next_interval_price_rank_today" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`next_interval_price_rank_today` | Next Price Rank (Today) | Nächster Preisrang (heute) | Neste prisrang (i dag) | Volgende prijsrang (vandaag) | Nästa prisrang (idag) | ❌ |
|
| <span id="ref-next_interval_price_rank_today" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`next_interval_price_rank_today` | Next Price Rank (Today) | Nächster Preisrang (heute) | Neste prisrang (i dag) | Volgende prijsrang (vandaag) | Nästa prisrang (idag) | ❌ |
|
||||||
| <span id="ref-next_interval_price_rank_today_tomorrow" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`next_interval_price_rank_today_tomorrow` | Next Price Rank (Today+Tomorrow) | Nächster Preisrang (heute+morgen) | Neste prisrang (i dag+i morgen) | Volgende prijsrang (vandaag+morgen) | Nästa prisrang (idag+imorgon) | ❌ |
|
| <span id="ref-next_interval_price_rank_today_tomorrow" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`next_interval_price_rank_today_tomorrow` | Next Price Rank (Today+Tomorrow) | Nächster Preisrang (heute+morgen) | Neste prisrang (i dag+i morgen) | Volgende prijsrang (vandaag+morgen) | Nästa prisrang (idag+imorgon) | ❌ |
|
||||||
| <span id="ref-next_price_phase" class="entity-anchor"></span>`next_price_phase` | Next Price Phase | Nächste Preisphase | Neste Prisfase | Volgende Prijsfase | Nästa Prisfas | ✅ |
|
|
||||||
| <span id="ref-next_rising_phase_in_minutes" class="entity-anchor" data-refs="sensors-price-phases#next-phase-by-type-6-sensors"></span>`next_rising_phase_in_minutes` | Time to Next Rising Phase | Time to Next Rising Phase | Time to Next Rising Phase | Time to Next Rising Phase | Time to Next Rising Phase | ❌ |
|
|
||||||
| <span id="ref-next_rising_phase_start_time" class="entity-anchor" data-refs="sensors-price-phases#next-phase-by-type-6-sensors"></span>`next_rising_phase_start_time` | Next Rising Phase Start | Next Rising Phase Start | Next Rising Phase Start | Next Rising Phase Start | Next Rising Phase Start | ❌ |
|
|
||||||
| <span id="ref-previous_interval_price_rank_today" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`previous_interval_price_rank_today` | Previous Price Rank (Today) | Vorheriger Preisrang (heute) | Forrige prisrang (i dag) | Vorige prijsrang (vandaag) | Förra prisrang (idag) | ❌ |
|
| <span id="ref-previous_interval_price_rank_today" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`previous_interval_price_rank_today` | Previous Price Rank (Today) | Vorheriger Preisrang (heute) | Forrige prisrang (i dag) | Vorige prijsrang (vandaag) | Förra prisrang (idag) | ❌ |
|
||||||
| <span id="ref-previous_interval_price_rank_today_tomorrow" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`previous_interval_price_rank_today_tomorrow` | Previous Price Rank (Today+Tomorrow) | Vorheriger Preisrang (heute+morgen) | Forrige prisrang (i dag+i morgen) | Vorige prijsrang (vandaag+morgen) | Förra prisrang (idag+imorgon) | ❌ |
|
| <span id="ref-previous_interval_price_rank_today_tomorrow" class="entity-anchor" data-refs="sensors-volatility#available-sensors"></span>`previous_interval_price_rank_today_tomorrow` | Previous Price Rank (Today+Tomorrow) | Vorheriger Preisrang (heute+morgen) | Forrige prisrang (i dag+i morgen) | Vorige prijsrang (vandaag+morgen) | Förra prisrang (idag+imorgon) | ❌ |
|
||||||
## Binary Sensors
|
## Binary Sensors
|
||||||
|
|
@ -253,15 +241,6 @@ explanations of each sensor's purpose, attributes, and automation examples.
|
||||||
| <span id="ref-tomorrow_data_available" class="entity-anchor"></span>`tomorrow_data_available` | Tomorrow's Data Available | Morgige Daten verfügbar | Morgendagens data tilgjengelig | Morgen Gegevens Beschikbaar | Morgondagens data tillgänglig | ✅ |
|
| <span id="ref-tomorrow_data_available" class="entity-anchor"></span>`tomorrow_data_available` | Tomorrow's Data Available | Morgige Daten verfügbar | Morgendagens data tilgjengelig | Morgen Gegevens Beschikbaar | Morgondagens data tillgänglig | ✅ |
|
||||||
| <span id="ref-has_ventilation_system" class="entity-anchor"></span>`has_ventilation_system` | Has Ventilation System | Hat Lüftungsanlage | Har ventilasjonsanlegg | Heeft Ventilatiesysteem | Har ventilationssystem | ❌ |
|
| <span id="ref-has_ventilation_system" class="entity-anchor"></span>`has_ventilation_system` | Has Ventilation System | Hat Lüftungsanlage | Har ventilasjonsanlegg | Heeft Ventilatiesysteem | Har ventilationssystem | ❌ |
|
||||||
| <span id="ref-realtime_consumption_enabled" class="entity-anchor"></span>`realtime_consumption_enabled` | Realtime Consumption Enabled | Echtzeitverbrauch aktiviert | Sanntidsforbruk aktivert | Realtime Verbruik Ingeschakeld | Realtidsförbrukning aktiverad | ❌ |
|
| <span id="ref-realtime_consumption_enabled" class="entity-anchor"></span>`realtime_consumption_enabled` | Realtime Consumption Enabled | Echtzeitverbrauch aktiviert | Sanntidsforbruk aktivert | Realtime Verbruik Ingeschakeld | Realtidsförbrukning aktiverad | ❌ |
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
|
|
||||||
| Entity ID suffix | 🇬🇧 English | 🇩🇪 Deutsch | 🇳🇴 Norsk | 🇳🇱 Nederlands | 🇸🇪 Svenska | Default |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| <span id="ref-in_falling_price_phase" class="entity-anchor" data-refs="sensors-price-phases#binary-phase-sensors"></span>`in_falling_price_phase` | In Falling Price Phase | In Falling Price Phase | In Falling Price Phase | In Falling Price Phase | In Falling Price Phase | ✅ |
|
|
||||||
| <span id="ref-in_flat_price_phase" class="entity-anchor" data-refs="sensors-price-phases#binary-phase-sensors"></span>`in_flat_price_phase` | In Flat Price Phase | In Flat Price Phase | In Flat Price Phase | In Flat Price Phase | In Flat Price Phase | ✅ |
|
|
||||||
| <span id="ref-in_rising_price_phase" class="entity-anchor" data-refs="sensors-price-phases#binary-phase-sensors"></span>`in_rising_price_phase` | In Rising Price Phase | In Rising Price Phase | In Rising Price Phase | In Rising Price Phase | In Rising Price Phase | ✅ |
|
|
||||||
## Number Entities (Configuration Overrides)
|
## Number Entities (Configuration Overrides)
|
||||||
|
|
||||||
> These entities allow runtime adjustment of period calculation parameters without changing the integration configuration. All are **disabled by default**.
|
> These entities allow runtime adjustment of period calculation parameters without changing the integration configuration. All are **disabled by default**.
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ USE_AI="${USE_AI:-true}"
|
||||||
GITHUB_REPO="${GITHUB_REPOSITORY:-jpawlowski/hass.tibber_prices}"
|
GITHUB_REPO="${GITHUB_REPOSITORY:-jpawlowski/hass.tibber_prices}"
|
||||||
RELEASE_NOTES_COMPACT_DIFF="${RELEASE_NOTES_COMPACT_DIFF:-true}"
|
RELEASE_NOTES_COMPACT_DIFF="${RELEASE_NOTES_COMPACT_DIFF:-true}"
|
||||||
RELEASE_NOTES_DIFF_MAX_BYTES="${RELEASE_NOTES_DIFF_MAX_BYTES:-8000}"
|
RELEASE_NOTES_DIFF_MAX_BYTES="${RELEASE_NOTES_DIFF_MAX_BYTES:-8000}"
|
||||||
RELEASE_NOTES_CLIFF_FILTER_PATHS="${RELEASE_NOTES_CLIFF_FILTER_PATHS:-false}"
|
RELEASE_NOTES_CLIFF_FILTER_PATHS="${RELEASE_NOTES_CLIFF_FILTER_PATHS:-true}"
|
||||||
RELEASE_NOTES_CLIFF_SINGLE_RELEASE="${RELEASE_NOTES_CLIFF_SINGLE_RELEASE:-true}"
|
RELEASE_NOTES_CLIFF_SINGLE_RELEASE="${RELEASE_NOTES_CLIFF_SINGLE_RELEASE:-true}"
|
||||||
RELEASE_NOTES_TRAILER_SKIP_FILTER="${RELEASE_NOTES_TRAILER_SKIP_FILTER:-true}"
|
RELEASE_NOTES_TRAILER_SKIP_FILTER="${RELEASE_NOTES_TRAILER_SKIP_FILTER:-true}"
|
||||||
|
|
||||||
|
|
@ -546,11 +546,8 @@ EOF
|
||||||
|
|
||||||
CLIFF_CMD=(git-cliff --config "$CLIFF_CONFIG")
|
CLIFF_CMD=(git-cliff --config "$CLIFF_CONFIG")
|
||||||
|
|
||||||
# Restrict changelog to user-facing integration files.
|
# Restrict changelog to user-facing integration files by default.
|
||||||
# DISABLED by default: git-cliff ≤2.12.0 --include-path ignores the commit
|
# Toggle with: RELEASE_NOTES_CLIFF_FILTER_PATHS=false
|
||||||
# range and generates a full changelog instead of the requested tag range.
|
|
||||||
# The commit_parsers in cliff.toml already filter non-user-facing types/scopes.
|
|
||||||
# Toggle with: RELEASE_NOTES_CLIFF_FILTER_PATHS=true
|
|
||||||
if is_truthy "$RELEASE_NOTES_CLIFF_FILTER_PATHS"; then
|
if is_truthy "$RELEASE_NOTES_CLIFF_FILTER_PATHS"; then
|
||||||
CLIFF_CMD+=(--include-path "custom_components/tibber_prices/**")
|
CLIFF_CMD+=(--include-path "custom_components/tibber_prices/**")
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -104,17 +104,16 @@ class TestTouchOperations:
|
||||||
original_id = id(first_interval_original)
|
original_id = id(first_interval_original)
|
||||||
|
|
||||||
# Second fetch: Touch same intervals
|
# Second fetch: Touch same intervals
|
||||||
# GC now runs after touch-only paths, removing the old (dead) group
|
|
||||||
pool._add_intervals(sample_intervals, fetch_time_2) # noqa: SLF001
|
pool._add_intervals(sample_intervals, fetch_time_2) # noqa: SLF001
|
||||||
|
|
||||||
# Re-fetch groups (GC compacted: old group removed, touch group remains)
|
# Re-fetch groups (list may have changed)
|
||||||
fetch_groups = pool._cache.get_fetch_groups() # noqa: SLF001
|
fetch_groups = pool._cache.get_fetch_groups() # noqa: SLF001
|
||||||
|
|
||||||
# After touch + GC: only the touch group survives (old group fully dead → removed)
|
# Verify: Now we have 2 fetch groups
|
||||||
assert len(fetch_groups) == 1
|
assert len(fetch_groups) == 2
|
||||||
|
|
||||||
# Get reference to first interval from the surviving touch group
|
# Get reference to first interval from TOUCH group
|
||||||
first_interval_touched = fetch_groups[0]["intervals"][0]
|
first_interval_touched = fetch_groups[1]["intervals"][0]
|
||||||
touched_id = id(first_interval_touched)
|
touched_id = id(first_interval_touched)
|
||||||
|
|
||||||
# CRITICAL: Should be SAME object (same memory address)
|
# CRITICAL: Should be SAME object (same memory address)
|
||||||
|
|
@ -147,13 +146,13 @@ class TestTouchOperations:
|
||||||
assert index_entry is not None
|
assert index_entry is not None
|
||||||
assert index_entry["fetch_group_index"] == 0
|
assert index_entry["fetch_group_index"] == 0
|
||||||
|
|
||||||
# Second fetch (touch) — GC runs after, compacting old group away
|
# Second fetch (touch)
|
||||||
pool._add_intervals(sample_intervals, fetch_time_2) # noqa: SLF001
|
pool._add_intervals(sample_intervals, fetch_time_2) # noqa: SLF001
|
||||||
|
|
||||||
# After touch + GC: old group removed, touch group is now group 0
|
# Verify index now points to group 1 (touch group)
|
||||||
index_entry = pool._index.get(first_key) # noqa: SLF001
|
index_entry = pool._index.get(first_key) # noqa: SLF001
|
||||||
assert index_entry is not None
|
assert index_entry is not None
|
||||||
assert index_entry["fetch_group_index"] == 0, "Index should point to surviving touch group"
|
assert index_entry["fetch_group_index"] == 1, "Index should point to touch group"
|
||||||
|
|
||||||
|
|
||||||
class TestGarbageCollection:
|
class TestGarbageCollection:
|
||||||
|
|
@ -244,70 +243,6 @@ class TestGarbageCollection:
|
||||||
assert len(fetch_groups) == 1, "Empty fetch group should be removed"
|
assert len(fetch_groups) == 1, "Empty fetch group should be removed"
|
||||||
assert len(fetch_groups[0]["intervals"]) == 4
|
assert len(fetch_groups[0]["intervals"]) == 4
|
||||||
|
|
||||||
def test_gc_rebuilds_index_after_dead_interval_cleanup_no_empty_groups(
|
|
||||||
self,
|
|
||||||
pool: TibberPricesIntervalPool,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Regression test for Issue #118 (IndexError for brand-new Tibber users).
|
|
||||||
|
|
||||||
Scenario: GC compacts a fetch group in-place (removes dead intervals at the
|
|
||||||
BEGINNING of the list), shifting surviving intervals to lower positions.
|
|
||||||
If no groups become completely empty, _remove_empty_groups does NOT rebuild
|
|
||||||
the index, leaving stale interval_index values that point past the end of
|
|
||||||
the compacted list → IndexError in _get_cached_intervals.
|
|
||||||
|
|
||||||
The fix: after dead interval cleanup without full group removal, explicitly
|
|
||||||
rebuild the index so surviving interval positions match the compacted list.
|
|
||||||
"""
|
|
||||||
# Step 1: Add 5 intervals (hours 0-4) → group 0
|
|
||||||
# Index: h0→(0,0), h1→(0,1), h2→(0,2), h3→(0,3), h4→(0,4)
|
|
||||||
initial_intervals = [
|
|
||||||
{
|
|
||||||
"startsAt": datetime(2025, 11, 25, h, 0, 0, tzinfo=UTC).isoformat(),
|
|
||||||
"total": 10.0 + h,
|
|
||||||
}
|
|
||||||
for h in range(5)
|
|
||||||
]
|
|
||||||
pool._add_intervals(initial_intervals, "2025-11-25T09:00:00+00:00") # noqa: SLF001
|
|
||||||
|
|
||||||
assert pool._cache.count_total_intervals() == 5 # noqa: SLF001
|
|
||||||
assert pool._index.count() == 5 # noqa: SLF001
|
|
||||||
|
|
||||||
# Step 2: Re-fetch h0, h1 (touch) + add new h5 → group 1
|
|
||||||
# - h0, h1 become dead in group 0 (index moves them to group 1)
|
|
||||||
# - h5 is new → added to group 1
|
|
||||||
# - h2, h3, h4 survive in group 0 at positions 2, 3, 4 (stale after GC)
|
|
||||||
second_fetch = [
|
|
||||||
{
|
|
||||||
"startsAt": datetime(2025, 11, 25, h, 0, 0, tzinfo=UTC).isoformat(),
|
|
||||||
"total": 10.0 + h,
|
|
||||||
}
|
|
||||||
for h in [0, 1, 5] # touch h0, h1; add new h5
|
|
||||||
]
|
|
||||||
pool._add_intervals(second_fetch, "2025-11-25T09:15:00+00:00") # noqa: SLF001
|
|
||||||
|
|
||||||
# GC ran (h5 was new): group 0 compacted from 5 → 3 intervals [h2, h3, h4]
|
|
||||||
# WITHOUT FIX: index has h2→(0,2), h3→(0,3), h4→(0,4) but group 0 only has 3 items
|
|
||||||
# WITH FIX: index rebuilt → h2→(0,0), h3→(0,1), h4→(0,2)
|
|
||||||
assert pool._cache.count_total_intervals() == 6 # h0,h1,h5 in group1 + h2,h3,h4 in group0 # noqa: SLF001
|
|
||||||
assert pool._index.count() == 6 # noqa: SLF001
|
|
||||||
|
|
||||||
# Step 3: Read all 6 intervals via the index — must NOT raise IndexError
|
|
||||||
start_iso = datetime(2025, 11, 25, 0, 0, 0, tzinfo=UTC).isoformat()
|
|
||||||
end_iso = datetime(2025, 11, 25, 6, 0, 0, tzinfo=UTC).isoformat()
|
|
||||||
|
|
||||||
# This calls _get_cached_intervals which looks up each timestamp in the index
|
|
||||||
# and accesses the interval by fetch_group_index + interval_index.
|
|
||||||
# Stale interval_index values (pointing past end of compacted list) → IndexError.
|
|
||||||
result = pool._get_cached_intervals(start_iso, end_iso) # noqa: SLF001
|
|
||||||
|
|
||||||
assert len(result) == 6, f"Expected 6 intervals, got {len(result)}"
|
|
||||||
|
|
||||||
# Verify correct values (spot-check h2, h3, h4 which were in the compacted group)
|
|
||||||
totals = {r["total"] for r in result}
|
|
||||||
assert totals == {10.0, 11.0, 12.0, 13.0, 14.0, 15.0}, f"Unexpected totals: {totals}"
|
|
||||||
|
|
||||||
|
|
||||||
class TestSerialization:
|
class TestSerialization:
|
||||||
"""Test serialization excludes dead intervals."""
|
"""Test serialization excludes dead intervals."""
|
||||||
|
|
@ -469,15 +404,14 @@ class TestIntervalIdentityPreservation:
|
||||||
# Collect memory addresses of intervals in original group
|
# Collect memory addresses of intervals in original group
|
||||||
original_ids = [id(interval) for interval in fetch_groups[0]["intervals"]]
|
original_ids = [id(interval) for interval in fetch_groups[0]["intervals"]]
|
||||||
|
|
||||||
# Second fetch (touch) — GC runs after, compacting old group away
|
# Second fetch (touch)
|
||||||
pool._add_intervals(sample_intervals, "2025-11-25T10:15:00+01:00") # noqa: SLF001
|
pool._add_intervals(sample_intervals, "2025-11-25T10:15:00+01:00") # noqa: SLF001
|
||||||
|
|
||||||
# Re-fetch groups (after GC: only touch group survives at index 0)
|
# Re-fetch groups
|
||||||
fetch_groups = pool._cache.get_fetch_groups() # noqa: SLF001
|
fetch_groups = pool._cache.get_fetch_groups() # noqa: SLF001
|
||||||
assert len(fetch_groups) == 1
|
|
||||||
|
|
||||||
# Collect memory addresses of intervals in surviving touch group
|
# Collect memory addresses of intervals in touch group
|
||||||
touched_ids = [id(interval) for interval in fetch_groups[0]["intervals"]]
|
touched_ids = [id(interval) for interval in fetch_groups[1]["intervals"]]
|
||||||
|
|
||||||
# CRITICAL: All memory addresses should be identical (same objects)
|
# CRITICAL: All memory addresses should be identical (same objects)
|
||||||
assert original_ids == touched_ids, "Touch should preserve interval identity (memory addresses)"
|
assert original_ids == touched_ids, "Touch should preserve interval identity (memory addresses)"
|
||||||
|
|
@ -485,10 +419,9 @@ class TestIntervalIdentityPreservation:
|
||||||
# Third fetch (touch again)
|
# Third fetch (touch again)
|
||||||
pool._add_intervals(sample_intervals, "2025-11-25T10:30:00+01:00") # noqa: SLF001
|
pool._add_intervals(sample_intervals, "2025-11-25T10:30:00+01:00") # noqa: SLF001
|
||||||
|
|
||||||
# Re-fetch groups (again only 1 group after GC)
|
# Re-fetch groups
|
||||||
fetch_groups = pool._cache.get_fetch_groups() # noqa: SLF001
|
fetch_groups = pool._cache.get_fetch_groups() # noqa: SLF001
|
||||||
assert len(fetch_groups) == 1
|
|
||||||
|
|
||||||
# New touch group should also reference the SAME original objects
|
# New touch group should also reference the SAME original objects
|
||||||
touched_ids_2 = [id(interval) for interval in fetch_groups[0]["intervals"]]
|
touched_ids_2 = [id(interval) for interval in fetch_groups[2]["intervals"]]
|
||||||
assert original_ids == touched_ids_2, "Multiple touches should preserve original identity"
|
assert original_ids == touched_ids_2, "Multiple touches should preserve original identity"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue