hass.tibber_prices/custom_components/tibber_prices/entity_utils/icons.py
Julian Pawlowski 3e6bcf2345 fix(sensor): synchronize current_interval_price_base with current_interval_price
Fixed inconsistency between "Current Electricity Price" and "Current Electricity Price
(Energy Dashboard)" sensors that were showing different prices and icons.

Changes:
- Add current_interval_price_base to TIME_SENSITIVE_ENTITY_KEYS so it updates at
  quarter-hour boundaries instead of only on API polls. This ensures both sensors
  update synchronously when a new 15-minute interval starts.
- Use interval_data["startsAt"] as timestamp for current interval price sensors
  (both variants) instead of rounded calculation time. This prevents timestamp
  divergence when sensors update at slightly different times.
- Include current_interval_price_base in icon color attribute mapping so both
  sensors display the same dynamic cash icon based on current price level.
- Include current_interval_price_base in dynamic icon function so it gets the
  correct icon based on current price level (VERY_CHEAP/CHEAP/NORMAL/EXPENSIVE).

Impact: Both sensors now show identical prices, timestamps, and icons as intended.
They update synchronously at interval boundaries (00, 15, 30, 45 minutes) and
correctly represent the Energy Dashboard compatible variant without lag or
inconsistencies.
2025-12-26 16:23:05 +00:00

385 lines
13 KiB
Python

"""Icon utilities for Tibber Prices entities."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from custom_components.tibber_prices.const import (
BINARY_SENSOR_ICON_MAPPING,
PRICE_LEVEL_CASH_ICON_MAPPING,
PRICE_LEVEL_ICON_MAPPING,
PRICE_RATING_ICON_MAPPING,
VOLATILITY_ICON_MAPPING,
)
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
from custom_components.tibber_prices.entity_utils.helpers import find_rolling_hour_center_index
from custom_components.tibber_prices.sensor.helpers import aggregate_level_data
from custom_components.tibber_prices.utils.price import find_price_data_for_interval
# Icon update logic uses timedelta directly (cosmetic, independent - allowed per AGENTS.md)
_INTERVAL_MINUTES = 15 # Tibber's 15-minute intervals
@dataclass
class TibberPricesIconContext:
"""Context data for dynamic icon selection."""
is_on: bool | None = None
coordinator_data: dict | None = None
has_future_periods_callback: Callable[[], bool] | None = None
period_is_active_callback: Callable[[], bool] | None = None
time: TibberPricesTimeService | None = None
if TYPE_CHECKING:
from collections.abc import Callable
# Timing sensor icon thresholds (in minutes)
TIMING_URGENT_THRESHOLD = 15 # ≤15 min: Alert icon
TIMING_SOON_THRESHOLD = 60 # ≤1 hour: Timer icon
TIMING_MEDIUM_THRESHOLD = 180 # ≤3 hours: Sand timer icon
# >3 hours: Outline timer icon
# Progress sensor constants
PROGRESS_MAX = 100 # Maximum progress value (100%)
def get_dynamic_icon(
key: str,
value: Any,
*,
context: TibberPricesIconContext | None = None,
) -> str | None:
"""
Get dynamic icon based on sensor state.
Unified function for both sensor and binary_sensor platforms.
Args:
key: Entity description key
value: Native value of the sensor
context: Optional context with is_on state, coordinator_data, and callbacks
Returns:
Icon string or None if no dynamic icon applies
"""
ctx = context or TibberPricesIconContext()
# Try various icon sources in order
return (
get_trend_icon(key, value)
or get_timing_sensor_icon(key, value, period_is_active_callback=ctx.period_is_active_callback)
or get_price_sensor_icon(key, ctx.coordinator_data, time=ctx.time)
or get_level_sensor_icon(key, value)
or get_rating_sensor_icon(key, value)
or get_volatility_sensor_icon(key, value)
or get_binary_sensor_icon(key, is_on=ctx.is_on, has_future_periods_callback=ctx.has_future_periods_callback)
)
def get_trend_icon(key: str, value: Any) -> str | None:
"""Get icon for trend sensors."""
# Handle next_price_trend_change TIMESTAMP sensor differently
# (icon based on attributes, not value which is a timestamp)
if key == "next_price_trend_change":
return None # Will be handled by sensor's icon property using attributes
if not key.startswith("price_trend_") or not isinstance(value, str):
return None
trend_icons = {
"rising": "mdi:trending-up",
"falling": "mdi:trending-down",
"stable": "mdi:trending-neutral",
}
return trend_icons.get(value)
def get_timing_sensor_icon(
key: str,
value: Any,
*,
period_is_active_callback: Callable[[], bool] | None = None,
) -> str | None:
"""
Get dynamic icon for best_price/peak_price timing sensors.
Progress sensors: Different icons based on period state
- No period: mdi:help-circle-outline (Unknown/gray)
- Waiting (0%, period not active): mdi:timer-pause-outline (paused/waiting)
- Active (0%, period running): mdi:circle-outline (just started)
- Progress 1-99%: mdi:circle-slice-1 to mdi:circle-slice-7
- Complete (100%): mdi:circle-slice-8
Remaining/Next-in sensors: Different timer icons based on time remaining
Timestamp sensors: Static icons (handled by entity description)
Args:
key: Entity description key
value: Sensor value (percentage for progress, minutes for countdown)
period_is_active_callback: Callback to check if related period is currently active
Returns:
Icon string or None if not a timing sensor with dynamic icon
"""
# Unknown state: Show help icon for all timing sensors
if value is None and key.startswith(("best_price_", "peak_price_")):
return "mdi:help-circle-outline"
# Progress sensors: Circle-slice icons for visual progress indication
# mdi:circle-slice-N where N represents filled portions (1=12.5%, 8=100%)
if key.endswith("_progress") and isinstance(value, (int, float)):
# Special handling for 0%: Distinguish between waiting and active
if value <= 0:
# Check if period is currently active via callback
is_active = (
period_is_active_callback()
if (period_is_active_callback and callable(period_is_active_callback))
else True
)
# Period just started (0% but running) vs waiting for next
return "mdi:circle-outline" if is_active else "mdi:timer-pause-outline"
# Calculate slice based on progress percentage
slice_num = 8 if value >= PROGRESS_MAX else min(7, max(1, int((value / PROGRESS_MAX) * 8)))
return f"mdi:circle-slice-{slice_num}"
# Remaining/Next-in minutes sensors: Timer icons based on urgency thresholds
if key.endswith(("_remaining_minutes", "_next_in_minutes")) and isinstance(value, (int, float)):
# Map time remaining to appropriate timer icon
urgency_map = [
(0, "mdi:timer-off-outline"), # Exactly 0 minutes
(TIMING_URGENT_THRESHOLD, "mdi:timer-alert"), # < 15 min: urgent
(TIMING_SOON_THRESHOLD, "mdi:timer"), # < 60 min: soon
(TIMING_MEDIUM_THRESHOLD, "mdi:timer-sand"), # < 180 min: medium
]
for threshold, icon in urgency_map:
if value <= threshold:
return icon
return "mdi:timer-outline" # >= 180 min: far away
# Timestamp sensors use static icons from entity description
return None
def get_price_sensor_icon(
key: str,
coordinator_data: dict | None,
*,
time: TibberPricesTimeService | None,
) -> str | None:
"""
Get icon for current price sensors (dynamic based on price level).
Dynamic icons for: current_interval_price, next_interval_price,
current_hour_average_price, next_hour_average_price
Other price sensors (previous interval) use static icons from entity description.
Args:
key: Entity description key
coordinator_data: Coordinator data for price level lookups
time: TibberPricesTimeService instance (required for determining current interval)
Returns:
Icon string or None if not a current price sensor
"""
# Early exit if coordinator_data or time not available
if not coordinator_data or time is None:
return None
# Only current price sensors get dynamic icons
if key in ("current_interval_price", "current_interval_price_base"):
level = get_price_level_for_icon(coordinator_data, interval_offset=0, time=time)
if level:
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
elif key == "next_interval_price":
# For next interval, use the next interval price level to determine icon
level = get_price_level_for_icon(coordinator_data, interval_offset=1, time=time)
if level:
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
elif key == "current_hour_average_price":
# For current hour average, use the current hour price level to determine icon
level = get_rolling_hour_price_level_for_icon(coordinator_data, hour_offset=0, time=time)
if level:
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
elif key == "next_hour_average_price":
# For next hour average, use the next hour price level to determine icon
level = get_rolling_hour_price_level_for_icon(coordinator_data, hour_offset=1, time=time)
if level:
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
# For all other price sensors, let entity description handle the icon
return None
def get_level_sensor_icon(key: str, value: Any) -> str | None:
"""Get icon for price level sensors."""
if key not in [
"current_interval_price_level",
"next_interval_price_level",
"previous_interval_price_level",
"current_hour_price_level",
"next_hour_price_level",
"yesterday_price_level",
"today_price_level",
"tomorrow_price_level",
] or not isinstance(value, str):
return None
return PRICE_LEVEL_ICON_MAPPING.get(value.upper())
def get_rating_sensor_icon(key: str, value: Any) -> str | None:
"""Get icon for price rating sensors."""
if key not in [
"current_interval_price_rating",
"next_interval_price_rating",
"previous_interval_price_rating",
"current_hour_price_rating",
"next_hour_price_rating",
"yesterday_price_rating",
"today_price_rating",
"tomorrow_price_rating",
] or not isinstance(value, str):
return None
return PRICE_RATING_ICON_MAPPING.get(value.upper())
def get_volatility_sensor_icon(key: str, value: Any) -> str | None:
"""Get icon for volatility sensors."""
if not key.endswith("_volatility") or not isinstance(value, str):
return None
return VOLATILITY_ICON_MAPPING.get(value.upper())
def get_binary_sensor_icon(
key: str,
*,
is_on: bool | None,
has_future_periods_callback: Callable[[], bool] | None = None,
) -> str | None:
"""
Get icon for binary sensors with dynamic state-based icons.
Args:
key: Entity description key
is_on: Binary sensor state
has_future_periods_callback: Callback to check if future periods exist
Returns:
Icon string or None if not a binary sensor with dynamic icons
"""
if key not in BINARY_SENSOR_ICON_MAPPING or is_on is None:
return None
if is_on:
# Sensor is ON - use "on" icon
return BINARY_SENSOR_ICON_MAPPING[key].get("on")
# Sensor is OFF - check if future periods exist
has_future_periods = has_future_periods_callback() if has_future_periods_callback else False
if has_future_periods:
return BINARY_SENSOR_ICON_MAPPING[key].get("off")
return BINARY_SENSOR_ICON_MAPPING[key].get("off_no_future")
def get_price_level_for_icon(
coordinator_data: dict,
*,
interval_offset: int | None = None,
time: TibberPricesTimeService,
) -> str | None:
"""
Get the price level for icon determination.
Supports interval-based lookups (current/next/previous interval).
Args:
coordinator_data: Coordinator data
interval_offset: Interval offset (0=current, 1=next, -1=previous)
time: TibberPricesTimeService instance (required)
Returns:
Price level string or None if not found
"""
if not coordinator_data or interval_offset is None:
return None
now = time.now()
# Interval-based lookup
target_time = now + timedelta(minutes=_INTERVAL_MINUTES * interval_offset)
interval_data = find_price_data_for_interval(coordinator_data, target_time, time=time)
if not interval_data or "level" not in interval_data:
return None
return interval_data["level"]
def get_rolling_hour_price_level_for_icon(
coordinator_data: dict,
*,
hour_offset: int = 0,
time: TibberPricesTimeService,
) -> str | None:
"""
Get the aggregated price level for rolling hour icon determination.
Uses the same logic as the sensor platform: 5-interval rolling window
(2 before + center + 2 after) to determine the price level.
This ensures icon calculation matches the actual sensor value calculation.
Args:
coordinator_data: Coordinator data
hour_offset: Hour offset (0=current hour, 1=next hour)
time: TibberPricesTimeService instance (required)
Returns:
Aggregated price level string or None if not found
"""
if not coordinator_data:
return None
# Get all intervals (yesterday, today, tomorrow) via helper
all_prices = get_intervals_for_day_offsets(coordinator_data, [-1, 0, 1])
if not all_prices:
return None
# Find center index using the same helper function as the sensor platform
now = time.now()
center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset, time=time)
if center_idx is None:
return None
# Collect data from 5-interval window (-2, -1, 0, +1, +2) - same as sensor platform
window_data = []
for offset in range(-2, 3):
idx = center_idx + offset
if 0 <= idx < len(all_prices):
window_data.append(all_prices[idx])
if not window_data:
return None
# Use the same aggregation function as the sensor platform
return aggregate_level_data(window_data)