mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
Compare commits
6 commits
752a0c5dbc
...
db02f262b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db02f262b6 | ||
|
|
361498b7f5 | ||
|
|
ebcb9cfe77 | ||
|
|
6b4c46a305 | ||
|
|
c85f4991ab | ||
|
|
c3173a16d6 |
12 changed files with 346 additions and 175 deletions
|
|
@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
|
|||
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.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
|
||||
_SUBUNIT_FACTOR = 100 # Conversion factor for subunit currency (ct/øre)
|
||||
|
|
@ -29,8 +30,7 @@ def get_current_phase_type(coordinator_data: dict, *, time: TibberPricesTimeServ
|
|||
"""
|
||||
Return the type of the currently active intra-day price phase.
|
||||
|
||||
Walks today's segments and returns the type ("rising", "falling", or "flat")
|
||||
of the last segment whose start time is ≤ now.
|
||||
Delegates to the shared segment finder in sensor/attributes/metadata.py.
|
||||
|
||||
Args:
|
||||
coordinator_data: The coordinator's data dict.
|
||||
|
|
@ -42,32 +42,39 @@ def get_current_phase_type(coordinator_data: dict, *, time: TibberPricesTimeServ
|
|||
"""
|
||||
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 segments[current_index].get("type")
|
||||
|
||||
day_patterns = coordinator_data.get("dayPatterns")
|
||||
if not day_patterns:
|
||||
|
||||
def get_phase_attributes(coordinator_data: dict, *, time: TibberPricesTimeService) -> dict | None:
|
||||
"""
|
||||
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
|
||||
|
||||
today_data = day_patterns.get("today")
|
||||
if not today_data:
|
||||
return None
|
||||
segment = segments[current_index]
|
||||
attrs: dict = {}
|
||||
|
||||
segments: list[dict] | None = today_data.get("segments")
|
||||
if not segments:
|
||||
return None
|
||||
if start := segment.get("start"):
|
||||
attrs["start"] = start
|
||||
if end := segment.get("end"):
|
||||
attrs["end"] = end
|
||||
|
||||
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
|
||||
return attrs or None
|
||||
|
||||
|
||||
def get_tomorrow_data_available_attributes(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from .attributes import (
|
|||
build_async_extra_state_attributes,
|
||||
build_sync_extra_state_attributes,
|
||||
get_current_phase_type,
|
||||
get_phase_attributes,
|
||||
get_price_intervals_attributes,
|
||||
get_tomorrow_data_available_attributes,
|
||||
)
|
||||
|
|
@ -316,6 +317,9 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
|||
if key == "tomorrow_data_available":
|
||||
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
|
||||
|
||||
@callback
|
||||
|
|
|
|||
|
|
@ -268,8 +268,10 @@ class TibberPricesIntervalPoolFetcher:
|
|||
"""
|
||||
Fetch missing intervals from API.
|
||||
|
||||
Makes one API call per missing range. Uses routing logic to select
|
||||
the optimal endpoint (PRICE_INFO vs PRICE_INFO_RANGE).
|
||||
Makes API calls per missing range, but skips redundant calls when a
|
||||
previous fetch already returned intervals covering subsequent ranges.
|
||||
This is common for the PRICE_INFO endpoint which returns ALL available
|
||||
intervals (~384) regardless of the requested range.
|
||||
|
||||
Args:
|
||||
api_client: TibberPricesApiClient instance for API calls.
|
||||
|
|
@ -290,9 +292,26 @@ class TibberPricesIntervalPoolFetcher:
|
|||
from custom_components.tibber_prices.interval_pool.routing import get_price_intervals_for_range # noqa: PLC0415
|
||||
|
||||
fetch_time_iso = dt_util.now().isoformat()
|
||||
all_fetched_intervals = []
|
||||
all_fetched_intervals: list[list[dict[str, Any]]] = []
|
||||
|
||||
# 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):
|
||||
# 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(
|
||||
"Fetching from Tibber API (%d/%d) for home %s: range %s to %s",
|
||||
idx,
|
||||
|
|
@ -317,6 +336,10 @@ class TibberPricesIntervalPoolFetcher:
|
|||
|
||||
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(
|
||||
"Received %d intervals from Tibber API for home %s",
|
||||
len(fetched_intervals),
|
||||
|
|
@ -328,3 +351,30 @@ class TibberPricesIntervalPoolFetcher:
|
|||
on_intervals_fetched(fetched_intervals, fetch_time_iso)
|
||||
|
||||
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,6 +93,19 @@ class TibberPricesIntervalPoolGarbageCollector:
|
|||
empty_removed,
|
||||
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
|
||||
total_intervals = self._cache.count_total_intervals()
|
||||
|
|
|
|||
|
|
@ -725,13 +725,20 @@ class TibberPricesIntervalPool:
|
|||
if intervals_to_touch:
|
||||
self._touch_intervals(intervals_to_touch, fetch_time_dt)
|
||||
|
||||
if not new_intervals:
|
||||
if intervals_to_touch:
|
||||
_LOGGER_DETAILS.debug(
|
||||
"All %d intervals already cached for home %s (touched only)",
|
||||
len(intervals),
|
||||
self._home_id,
|
||||
)
|
||||
# Run GC after touch even if no new intervals — touching creates dead
|
||||
# intervals in old fetch groups that should be cleaned up promptly.
|
||||
if intervals_to_touch and not new_intervals:
|
||||
gc_changed_data = self._gc.run_gc()
|
||||
|
||||
_LOGGER_DETAILS.debug(
|
||||
"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
|
||||
|
||||
# Sort new intervals by startsAt
|
||||
|
|
|
|||
|
|
@ -11,6 +11,54 @@ if TYPE_CHECKING:
|
|||
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(
|
||||
coordinator: TibberPricesDataUpdateCoordinator,
|
||||
*,
|
||||
|
|
@ -131,31 +179,8 @@ def get_current_price_phase_attributes(
|
|||
if not coordinator.data:
|
||||
return None
|
||||
|
||||
day_patterns = coordinator.data.get("dayPatterns")
|
||||
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:
|
||||
current_index, segments = _find_current_segment_in_data(coordinator.data, time=time)
|
||||
if current_index is None or segments is None:
|
||||
return None
|
||||
|
||||
seg = segments[current_index]
|
||||
|
|
@ -180,8 +205,8 @@ def get_next_price_phase_attributes(
|
|||
"""
|
||||
Build attributes for the next_price_phase sensor.
|
||||
|
||||
Returns details of the segment that follows the currently active one,
|
||||
including its start time (useful for scheduling automations).
|
||||
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.
|
||||
|
||||
Args:
|
||||
coordinator: The data update coordinator.
|
||||
|
|
@ -194,41 +219,42 @@ def get_next_price_phase_attributes(
|
|||
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
|
||||
|
||||
# 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")
|
||||
if not day_patterns:
|
||||
return None
|
||||
|
||||
today_data: dict[str, Any] | None = day_patterns.get("today")
|
||||
if not today_data:
|
||||
tomorrow_data = day_patterns.get("tomorrow")
|
||||
if not tomorrow_data:
|
||||
return None
|
||||
|
||||
segments: list[dict[str, Any]] | None = today_data.get("segments")
|
||||
if not segments:
|
||||
tomorrow_segments: list[dict[str, Any]] = tomorrow_data.get("segments", [])
|
||||
if not tomorrow_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 or current_index + 1 >= len(segments):
|
||||
return None
|
||||
|
||||
next_seg = segments[current_index + 1]
|
||||
attrs: dict[str, Any] = {
|
||||
next_seg = tomorrow_segments[0]
|
||||
return {
|
||||
"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),
|
||||
"segment_index": 0,
|
||||
"segment_count": len(tomorrow_segments),
|
||||
"is_tomorrow": True,
|
||||
}
|
||||
return attrs
|
||||
|
|
|
|||
|
|
@ -12,6 +12,22 @@ if TYPE_CHECKING:
|
|||
# Timer #3 triggers every 30 seconds
|
||||
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:
|
||||
"""Convert hour-based state back to rounded minutes for attributes."""
|
||||
|
|
@ -34,20 +50,6 @@ def _is_timing_or_volatility_sensor(key: str) -> bool:
|
|||
):
|
||||
return True
|
||||
# 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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -149,80 +149,44 @@ class TibberPricesMetadataCalculator(TibberPricesBaseCalculator):
|
|||
"rising", "falling", or "flat", or None if data is unavailable.
|
||||
|
||||
"""
|
||||
if not self.coordinator.data:
|
||||
current_index, segments = self._find_current_segment()
|
||||
if current_index is None or segments is None:
|
||||
return None
|
||||
|
||||
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")
|
||||
return segments[current_index].get("type")
|
||||
|
||||
def get_next_price_phase_value(self) -> str | None:
|
||||
"""
|
||||
Get the next intra-day price phase (rising / falling / flat).
|
||||
|
||||
Finds the monotone segment in today's day-pattern that starts after
|
||||
the current segment and returns its type string.
|
||||
Finds the monotone segment that starts after the current segment.
|
||||
If the current segment is the last of today, falls back to the first
|
||||
segment of tomorrow (if available).
|
||||
|
||||
Returns:
|
||||
"rising", "falling", or "flat", or None if no next segment exists.
|
||||
|
||||
"""
|
||||
if not self.coordinator.data:
|
||||
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:
|
||||
return None
|
||||
day_patterns = self.coordinator.data.get("dayPatterns")
|
||||
if not day_patterns:
|
||||
return None
|
||||
|
||||
today_data = day_patterns.get("today")
|
||||
if not today_data:
|
||||
tomorrow_data = day_patterns.get("tomorrow")
|
||||
if not tomorrow_data:
|
||||
return None
|
||||
|
||||
segments: list[dict] | None = today_data.get("segments")
|
||||
if not segments:
|
||||
tomorrow_segments: list[dict] = tomorrow_data.get("segments", [])
|
||||
if not tomorrow_segments:
|
||||
return None
|
||||
|
||||
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")
|
||||
return tomorrow_segments[0].get("type")
|
||||
|
||||
def _find_current_segment(self) -> tuple[int, list[dict]] | tuple[None, None]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1045,6 +1045,12 @@ DAY_PATTERN_SENSORS = (
|
|||
state_class=None,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
# 8b. PRICE PHASE SENSORS (current/next intra-day price phase classification)
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
PRICE_PHASE_SENSORS = (
|
||||
SensorEntityDescription(
|
||||
key="current_price_phase",
|
||||
translation_key="current_price_phase",
|
||||
|
|
@ -1065,7 +1071,7 @@ DAY_PATTERN_SENSORS = (
|
|||
),
|
||||
)
|
||||
|
||||
# 8b. PRICE PHASE TIMING SENSORS (current phase duration/progress + next-phase-by-type)
|
||||
# 8c. PRICE PHASE TIMING SENSORS (current phase duration/progress + next-phase-by-type)
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# When current phase is active:
|
||||
|
|
@ -1370,6 +1376,7 @@ ENTITY_DESCRIPTIONS = (
|
|||
*BEST_PRICE_TIMING_SENSORS,
|
||||
*PEAK_PRICE_TIMING_SENSORS,
|
||||
*DAY_PATTERN_SENSORS,
|
||||
*PRICE_PHASE_SENSORS,
|
||||
*PRICE_PHASE_TIMING_SENSORS,
|
||||
*DIAGNOSTIC_SENSORS,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -219,13 +219,25 @@ 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_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_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_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-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_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_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_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
|
||||
|
|
@ -241,6 +253,15 @@ 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-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 | ❌ |
|
||||
|
||||
### 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)
|
||||
|
||||
> 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}"
|
||||
RELEASE_NOTES_COMPACT_DIFF="${RELEASE_NOTES_COMPACT_DIFF:-true}"
|
||||
RELEASE_NOTES_DIFF_MAX_BYTES="${RELEASE_NOTES_DIFF_MAX_BYTES:-8000}"
|
||||
RELEASE_NOTES_CLIFF_FILTER_PATHS="${RELEASE_NOTES_CLIFF_FILTER_PATHS:-true}"
|
||||
RELEASE_NOTES_CLIFF_FILTER_PATHS="${RELEASE_NOTES_CLIFF_FILTER_PATHS:-false}"
|
||||
RELEASE_NOTES_CLIFF_SINGLE_RELEASE="${RELEASE_NOTES_CLIFF_SINGLE_RELEASE:-true}"
|
||||
RELEASE_NOTES_TRAILER_SKIP_FILTER="${RELEASE_NOTES_TRAILER_SKIP_FILTER:-true}"
|
||||
|
||||
|
|
@ -546,8 +546,11 @@ EOF
|
|||
|
||||
CLIFF_CMD=(git-cliff --config "$CLIFF_CONFIG")
|
||||
|
||||
# Restrict changelog to user-facing integration files by default.
|
||||
# Toggle with: RELEASE_NOTES_CLIFF_FILTER_PATHS=false
|
||||
# Restrict changelog to user-facing integration files.
|
||||
# DISABLED by default: git-cliff ≤2.12.0 --include-path ignores the commit
|
||||
# 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
|
||||
CLIFF_CMD+=(--include-path "custom_components/tibber_prices/**")
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -104,16 +104,17 @@ class TestTouchOperations:
|
|||
original_id = id(first_interval_original)
|
||||
|
||||
# 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
|
||||
|
||||
# Re-fetch groups (list may have changed)
|
||||
# Re-fetch groups (GC compacted: old group removed, touch group remains)
|
||||
fetch_groups = pool._cache.get_fetch_groups() # noqa: SLF001
|
||||
|
||||
# Verify: Now we have 2 fetch groups
|
||||
assert len(fetch_groups) == 2
|
||||
# After touch + GC: only the touch group survives (old group fully dead → removed)
|
||||
assert len(fetch_groups) == 1
|
||||
|
||||
# Get reference to first interval from TOUCH group
|
||||
first_interval_touched = fetch_groups[1]["intervals"][0]
|
||||
# Get reference to first interval from the surviving touch group
|
||||
first_interval_touched = fetch_groups[0]["intervals"][0]
|
||||
touched_id = id(first_interval_touched)
|
||||
|
||||
# CRITICAL: Should be SAME object (same memory address)
|
||||
|
|
@ -146,13 +147,13 @@ class TestTouchOperations:
|
|||
assert index_entry is not None
|
||||
assert index_entry["fetch_group_index"] == 0
|
||||
|
||||
# Second fetch (touch)
|
||||
# Second fetch (touch) — GC runs after, compacting old group away
|
||||
pool._add_intervals(sample_intervals, fetch_time_2) # noqa: SLF001
|
||||
|
||||
# Verify index now points to group 1 (touch group)
|
||||
# After touch + GC: old group removed, touch group is now group 0
|
||||
index_entry = pool._index.get(first_key) # noqa: SLF001
|
||||
assert index_entry is not None
|
||||
assert index_entry["fetch_group_index"] == 1, "Index should point to touch group"
|
||||
assert index_entry["fetch_group_index"] == 0, "Index should point to surviving touch group"
|
||||
|
||||
|
||||
class TestGarbageCollection:
|
||||
|
|
@ -243,6 +244,70 @@ class TestGarbageCollection:
|
|||
assert len(fetch_groups) == 1, "Empty fetch group should be removed"
|
||||
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:
|
||||
"""Test serialization excludes dead intervals."""
|
||||
|
|
@ -404,14 +469,15 @@ class TestIntervalIdentityPreservation:
|
|||
# Collect memory addresses of intervals in original group
|
||||
original_ids = [id(interval) for interval in fetch_groups[0]["intervals"]]
|
||||
|
||||
# Second fetch (touch)
|
||||
# Second fetch (touch) — GC runs after, compacting old group away
|
||||
pool._add_intervals(sample_intervals, "2025-11-25T10:15:00+01:00") # noqa: SLF001
|
||||
|
||||
# Re-fetch groups
|
||||
# Re-fetch groups (after GC: only touch group survives at index 0)
|
||||
fetch_groups = pool._cache.get_fetch_groups() # noqa: SLF001
|
||||
assert len(fetch_groups) == 1
|
||||
|
||||
# Collect memory addresses of intervals in touch group
|
||||
touched_ids = [id(interval) for interval in fetch_groups[1]["intervals"]]
|
||||
# Collect memory addresses of intervals in surviving touch group
|
||||
touched_ids = [id(interval) for interval in fetch_groups[0]["intervals"]]
|
||||
|
||||
# CRITICAL: All memory addresses should be identical (same objects)
|
||||
assert original_ids == touched_ids, "Touch should preserve interval identity (memory addresses)"
|
||||
|
|
@ -419,9 +485,10 @@ class TestIntervalIdentityPreservation:
|
|||
# Third fetch (touch again)
|
||||
pool._add_intervals(sample_intervals, "2025-11-25T10:30:00+01:00") # noqa: SLF001
|
||||
|
||||
# Re-fetch groups
|
||||
# Re-fetch groups (again only 1 group after GC)
|
||||
fetch_groups = pool._cache.get_fetch_groups() # noqa: SLF001
|
||||
assert len(fetch_groups) == 1
|
||||
|
||||
# New touch group should also reference the SAME original objects
|
||||
touched_ids_2 = [id(interval) for interval in fetch_groups[2]["intervals"]]
|
||||
touched_ids_2 = [id(interval) for interval in fetch_groups[0]["intervals"]]
|
||||
assert original_ids == touched_ids_2, "Multiple touches should preserve original identity"
|
||||
|
|
|
|||
Loading…
Reference in a new issue