Compare commits

...

6 commits

Author SHA1 Message Date
Julian Pawlowski
db02f262b6 perf(interval_pool): skip redundant API calls when prior fetch covers range
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
The PRICE_INFO endpoint returns all available intervals (~384) regardless
of the requested range. When fetching multiple missing ranges, subsequent
calls are redundant if the first response already covers them.

After each fetch, track returned timestamps and skip ranges that are
already covered by previously fetched data.

Impact: Reduces redundant Tibber API calls, especially after restarts or
cache invalidation when multiple gaps exist in the interval pool.
2026-04-17 12:00:57 +00:00
Julian Pawlowski
361498b7f5 perf(interval_pool): run GC after touch-only interval updates
Touch operations create dead intervals in old fetch groups, but GC only
ran when new intervals were added. Dead intervals accumulated until
the next fetch with genuinely new data.

Now run GC after touch-only paths and schedule a save if data changed.

Impact: Reduces memory usage by cleaning up stale fetch groups promptly
instead of letting dead intervals accumulate between API fetches.
2026-04-17 12:00:48 +00:00
Julian Pawlowski
ebcb9cfe77 fix(interval_pool): rebuild index after dead interval cleanup without empty groups
Cherry-pick of v0.30.1 hotfix (ec3bc9f). When _cleanup_dead_intervals
compacted group lists but no groups became fully empty, the index retained
stale interval_index values pointing past compacted list ends, causing
IndexError in _get_cached_intervals.

Now rebuilds the index whenever dead intervals are removed, even if no
groups are deleted.

Includes regression test for Issue #118 and updated touch operation tests
to reflect that GC now runs immediately after touch-only paths.

Closes #118

Impact: Eliminates IndexError crash for users with brand-new Tibber accounts
that have limited price history, where partial group compaction was most likely.
2026-04-17 12:00:37 +00:00
Julian Pawlowski
6b4c46a305 chore(scripts): disable git-cliff --include-path by default
git-cliff ≤2.12.0 --include-path ignores the commit range and generates
a full changelog. The commit_parsers in cliff.toml already filter
non-user-facing types/scopes, making path filtering redundant.

Changed RELEASE_NOTES_CLIFF_FILTER_PATHS default from true to false.

User-Impact: none
2026-04-17 11:59:34 +00:00
Julian Pawlowski
c85f4991ab feat(sensors): add new price phase and duration sensors
Introduce additional sensors for current price phase, phase duration, and upcoming phase timings to enhance user visibility of pricing dynamics.

Impact: Users can now monitor current price phases and their durations, improving decision-making based on real-time pricing information.
2026-04-17 08:52:36 +00:00
Julian Pawlowski
c3173a16d6 refactor(attributes): streamline phase type retrieval and attribute building
Consolidate logic for determining current price phase and associated attributes by introducing shared helper functions. This enhances code maintainability and reduces duplication across components.

Impact: Improved clarity and efficiency in price phase handling for users.
2026-04-17 08:52:17 +00:00
12 changed files with 346 additions and 175 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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]:
"""

View file

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

View file

@ -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**.

View file

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

View file

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