feat(icons): add dynamic icons and colors for all sensor types

Implemented comprehensive dynamic icon and color system across all sensor types:

Price Sensors (5 sensors):
- Current/hour prices: Dynamic cash-family icons based on price level
  (cash-multiple/plus/cash/minus/remove)
- Next/previous: Static contextual icons (cash-fast, cash-refund, clock-fast)
- All have icon_color attribute for card-mod styling

Price Level Sensors (5 sensors):
- Dynamic gauge-family icons: gauge-empty → gauge-low → gauge → gauge-full → alert
- icon_color attribute with CSS variables (green/gray/orange/red)

Price Rating Sensors (5 sensors):
- Dynamic thumb-family icons: thumb-up → thumbs-up-down → thumb-down
- icon_color attribute for LOW/NORMAL/HIGH ratings

Volatility Sensors (4 sensors):
- Dynamic chart-family icons: chart-line-variant → chart-timeline-variant →
  chart-bar → chart-scatter-plot
- icon_color attribute for LOW/MODERATE/HIGH/VERY_HIGH levels

Trend Sensors (8 sensors):
- Dynamic trend icons: trending-up/down/neutral based on price movement
- icon_color attribute (red=rising, green=falling, gray=stable)

Binary Sensors (2 sensors):
- Best Price Period: piggy-bank (ON) / timer-sand or timer-sand-complete (OFF)
- Peak Price Period: alert-circle (ON) / shield-check or shield-check-outline (OFF)
- 6-hour lookahead window for intelligent OFF state icons
- icon_color attribute for all states

Technical implementation:
- PRICE_LEVEL_CASH_ICON_MAPPING in const.py for price sensor icons
- PRICE_SENSOR_ICON_MAPPING removed (static icons now in entity descriptions)
- Centralized icon logic in sensor.py icon property
- All color mappings use CSS variables for theme compatibility
- Binary sensors detect future periods within 6-hour window

Impact: Users now have visual indicators for all price-related states without
requiring card-mod. Optional card-mod styling available via icon_color attribute
for advanced customization. Icons update dynamically as price levels, ratings,
volatility, and trends change throughout the day.
This commit is contained in:
Julian Pawlowski 2025-11-14 11:31:25 +00:00
parent fe5af68f8e
commit c4f36d04de
3 changed files with 394 additions and 24 deletions

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from homeassistant.components.binary_sensor import (
@ -27,6 +28,8 @@ if TYPE_CHECKING:
from .data import TibberPricesConfigEntry
from .const import (
BINARY_SENSOR_COLOR_MAPPING,
BINARY_SENSOR_ICON_MAPPING,
CONF_EXTENDED_DESCRIPTIONS,
DEFAULT_EXTENDED_DESCRIPTIONS,
async_get_entity_description,
@ -36,6 +39,10 @@ from .const import (
MINUTES_PER_INTERVAL = 15
MIN_TOMORROW_INTERVALS_15MIN = 96
# Look-ahead window for future period detection (hours)
# Icons will show "waiting" state if a period starts within this window
PERIOD_LOOKAHEAD_HOURS = 6
ENTITY_DESCRIPTIONS = (
BinarySensorEntityDescription(
key="peak_price_period",
@ -434,6 +441,63 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
)
return None
@property
def icon(self) -> str | None:
"""Return the icon based on binary sensor state."""
key = self.entity_description.key
# Dynamic icons for best/peak price period sensors
if key in BINARY_SENSOR_ICON_MAPPING:
if self.is_on:
# Sensor is ON - use "on" icon
icon = BINARY_SENSOR_ICON_MAPPING[key].get("on")
else:
# Sensor is OFF - check if future periods exist
has_future_periods = self._has_future_periods()
if has_future_periods:
icon = BINARY_SENSOR_ICON_MAPPING[key].get("off")
else:
icon = BINARY_SENSOR_ICON_MAPPING[key].get("off_no_future")
if icon:
return icon
# For all other sensors, use static icon from entity description
return self.entity_description.icon
def _has_future_periods(self) -> bool:
"""
Check if there are periods starting within the next 6 hours.
Returns True if any period starts between now and PERIOD_LOOKAHEAD_HOURS from now.
This provides a practical planning horizon instead of hard midnight cutoff.
"""
if not self._attribute_getter:
return False
attrs = self._attribute_getter()
if not attrs or "periods" not in attrs:
return False
now = dt_util.now()
horizon = now + timedelta(hours=PERIOD_LOOKAHEAD_HOURS)
periods = attrs.get("periods", [])
# Check if any period starts within the look-ahead window
for period in periods:
start_str = period.get("start")
if start_str:
# Parse datetime if it's a string, otherwise use as-is
start_time = dt_util.parse_datetime(start_str) if isinstance(start_str, str) else start_str
if start_time:
start_time_local = dt_util.as_local(start_time)
# Period starts in the future but within our horizon
if now < start_time_local <= horizon:
return True
return False
@property
async def async_extra_state_attributes(self) -> dict | None:
"""Return additional state attributes asynchronously."""
@ -450,6 +514,14 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
clean_attrs = {k: v for k, v in dynamic_attrs.items() if not k.startswith("_")}
attributes.update(clean_attrs)
# Add icon_color for best/peak price period sensors
key = self.entity_description.key
if key in BINARY_SENSOR_COLOR_MAPPING:
state = "on" if self.is_on else "off"
color = BINARY_SENSOR_COLOR_MAPPING[key].get(state)
if color:
attributes["icon_color"] = color
# Add descriptions from the custom translations file
if self.entity_description.translation_key and self.hass is not None:
# Get user's language preference
@ -524,6 +596,14 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
clean_attrs = {k: v for k, v in dynamic_attrs.items() if not k.startswith("_")}
attributes.update(clean_attrs)
# Add icon_color for best/peak price period sensors
key = self.entity_description.key
if key in BINARY_SENSOR_COLOR_MAPPING:
state = "on" if self.is_on else "off"
color = BINARY_SENSOR_COLOR_MAPPING[key].get(state)
if color:
attributes["icon_color"] = color
# Add descriptions from the cache (non-blocking)
if self.entity_description.translation_key and self.hass is not None:
# Get user's language preference

View file

@ -248,6 +248,65 @@ PRICE_RATING_MAPPING = {
PRICE_RATING_HIGH: 1,
}
# Icon mapping for price levels (dynamic icons based on level)
PRICE_LEVEL_ICON_MAPPING = {
PRICE_LEVEL_VERY_CHEAP: "mdi:gauge-empty",
PRICE_LEVEL_CHEAP: "mdi:gauge-low",
PRICE_LEVEL_NORMAL: "mdi:gauge",
PRICE_LEVEL_EXPENSIVE: "mdi:gauge-full",
PRICE_LEVEL_VERY_EXPENSIVE: "mdi:alert",
}
# Color mapping for price levels (CSS variables for theme compatibility)
PRICE_LEVEL_COLOR_MAPPING = {
PRICE_LEVEL_VERY_CHEAP: "var(--success-color)",
PRICE_LEVEL_CHEAP: "var(--success-color)",
PRICE_LEVEL_NORMAL: "var(--state-icon-color)",
PRICE_LEVEL_EXPENSIVE: "var(--warning-color)",
PRICE_LEVEL_VERY_EXPENSIVE: "var(--error-color)",
}
# Icon mapping for current price sensors (dynamic icons based on price level)
# Used by current_price and current_hour_average sensors
# Icon shows price level (cheap/normal/expensive), icon_color reinforces with color
PRICE_LEVEL_CASH_ICON_MAPPING = {
PRICE_LEVEL_VERY_CHEAP: "mdi:cash-multiple", # Many coins (save a lot!)
PRICE_LEVEL_CHEAP: "mdi:cash-plus", # Cash with plus (good price)
PRICE_LEVEL_NORMAL: "mdi:cash", # Standard cash icon
PRICE_LEVEL_EXPENSIVE: "mdi:cash-minus", # Cash with minus (expensive)
PRICE_LEVEL_VERY_EXPENSIVE: "mdi:cash-remove", # Cash crossed out (very expensive)
}
# Icon mapping for price ratings (dynamic icons based on rating)
PRICE_RATING_ICON_MAPPING = {
PRICE_RATING_LOW: "mdi:thumb-up",
PRICE_RATING_NORMAL: "mdi:thumbs-up-down",
PRICE_RATING_HIGH: "mdi:thumb-down",
}
# Color mapping for price ratings (CSS variables for theme compatibility)
PRICE_RATING_COLOR_MAPPING = {
PRICE_RATING_LOW: "var(--success-color)",
PRICE_RATING_NORMAL: "var(--state-icon-color)",
PRICE_RATING_HIGH: "var(--error-color)",
}
# Icon mapping for volatility levels (dynamic icons based on volatility)
VOLATILITY_ICON_MAPPING = {
VOLATILITY_LOW: "mdi:chart-line-variant",
VOLATILITY_MODERATE: "mdi:chart-timeline-variant",
VOLATILITY_HIGH: "mdi:chart-bar",
VOLATILITY_VERY_HIGH: "mdi:chart-scatter-plot",
}
# Color mapping for volatility levels (CSS variables for theme compatibility)
VOLATILITY_COLOR_MAPPING = {
VOLATILITY_LOW: "var(--success-color)",
VOLATILITY_MODERATE: "var(--info-color)",
VOLATILITY_HIGH: "var(--warning-color)",
VOLATILITY_VERY_HIGH: "var(--error-color)",
}
# Mapping for comparing volatility levels (used for sorting)
VOLATILITY_MAPPING = {
VOLATILITY_LOW: 0,
@ -256,6 +315,33 @@ VOLATILITY_MAPPING = {
VOLATILITY_VERY_HIGH: 3,
}
# Icon mapping for binary sensors (dynamic icons based on state)
# Note: OFF state icons can vary based on whether future periods exist
BINARY_SENSOR_ICON_MAPPING = {
"best_price_period": {
"on": "mdi:piggy-bank",
"off": "mdi:timer-sand", # Has future periods
"off_no_future": "mdi:timer-sand-complete", # No future periods today
},
"peak_price_period": {
"on": "mdi:alert-circle",
"off": "mdi:shield-check", # Has future periods
"off_no_future": "mdi:shield-check-outline", # No future periods today
},
}
# Color mapping for binary sensors (CSS variables for theme compatibility)
BINARY_SENSOR_COLOR_MAPPING = {
"best_price_period": {
"on": "var(--success-color)",
"off": "var(--state-icon-color)",
},
"peak_price_period": {
"on": "var(--error-color)",
"off": "var(--state-icon-color)",
},
}
LOGGER = logging.getLogger(__package__)
# Path to custom translations directory

View file

@ -37,8 +37,15 @@ from .const import (
DEFAULT_PRICE_TREND_THRESHOLD_FALLING,
DEFAULT_PRICE_TREND_THRESHOLD_RISING,
DOMAIN,
PRICE_LEVEL_CASH_ICON_MAPPING,
PRICE_LEVEL_COLOR_MAPPING,
PRICE_LEVEL_ICON_MAPPING,
PRICE_LEVEL_MAPPING,
PRICE_RATING_COLOR_MAPPING,
PRICE_RATING_ICON_MAPPING,
PRICE_RATING_MAPPING,
VOLATILITY_COLOR_MAPPING,
VOLATILITY_ICON_MAPPING,
async_get_entity_description,
format_price_unit_minor,
get_entity_description,
@ -76,7 +83,7 @@ PRICE_SENSORS = (
key="current_price",
translation_key="current_price",
name="Current Electricity Price",
icon="mdi:cash",
icon="mdi:cash", # Dynamic: will show cash-multiple/plus/cash/minus/remove based on level
device_class=SensorDeviceClass.MONETARY,
suggested_display_precision=2,
),
@ -84,7 +91,7 @@ PRICE_SENSORS = (
key="next_interval_price",
translation_key="next_interval_price",
name="Next Price",
icon="mdi:clock-fast",
icon="mdi:cash-fast", # Static: motion lines indicate "coming soon"
device_class=SensorDeviceClass.MONETARY,
suggested_display_precision=2,
),
@ -92,7 +99,7 @@ PRICE_SENSORS = (
key="previous_interval_price",
translation_key="previous_interval_price",
name="Previous Electricity Price",
icon="mdi:history",
icon="mdi:cash-refund", # Static: arrow back indicates "past"
device_class=SensorDeviceClass.MONETARY,
entity_registry_enabled_default=False,
suggested_display_precision=2,
@ -101,7 +108,7 @@ PRICE_SENSORS = (
key="current_hour_average",
translation_key="current_hour_average",
name="Current Hour Average Price",
icon="mdi:cash",
icon="mdi:cash", # Dynamic: will show cash-multiple/plus/cash/minus/remove based on level
device_class=SensorDeviceClass.MONETARY,
suggested_display_precision=1,
),
@ -109,7 +116,7 @@ PRICE_SENSORS = (
key="next_hour_average",
translation_key="next_hour_average",
name="Next Hour Average Price",
icon="mdi:clock-fast",
icon="mdi:clock-fast", # Static: clock indicates "next time period"
device_class=SensorDeviceClass.MONETARY,
suggested_display_precision=1,
),
@ -1536,6 +1543,10 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
"interval_count": len(prices_to_analyze),
}
# Add icon_color for dynamic styling
if volatility in VOLATILITY_COLOR_MAPPING:
self._last_volatility_attributes["icon_color"] = VOLATILITY_COLOR_MAPPING[volatility]
# Add type-specific attributes
self._add_volatility_type_attributes(volatility_type, price_info, thresholds)
@ -1729,21 +1740,147 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
@property
def icon(self) -> str | None:
"""Return the icon based on sensor type and state."""
# Dynamic icons for trend sensors
if self.entity_description.key.startswith("price_trend_"):
match self.native_value:
case "rising":
return "mdi:trending-up"
case "falling":
return "mdi:trending-down"
case "stable":
return "mdi:trending-neutral"
case _:
# Fallback to static icon if value is None or unknown
return self.entity_description.icon
key = self.entity_description.key
value = self.native_value
# For all other sensors, use static icon from entity description
return self.entity_description.icon
# Try to get icon from various sources
icon = (
self._get_trend_icon(key, value)
or self._get_price_sensor_icon(key)
or self._get_level_sensor_icon(key, value)
or self._get_rating_sensor_icon(key, value)
or self._get_volatility_sensor_icon(key, value)
)
# Fall back to static icon from entity description
return icon or self.entity_description.icon
def _get_trend_icon(self, key: str, value: Any) -> str | None:
"""Get icon for trend sensors."""
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_price_sensor_icon(self, key: str) -> str | None:
"""
Get icon for current price sensors (dynamic based on price level).
Only current_price and current_hour_average have dynamic icons.
Other price sensors (next/previous) use static icons from entity description.
"""
# Only current price sensors get dynamic icons
if key == "current_price":
level = self._get_price_level_for_sensor(key)
if level:
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
elif key == "current_hour_average":
level = self._get_hour_level_for_sensor(key)
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(self, key: str, value: Any) -> str | None:
"""Get icon for price level sensors."""
if key not in [
"price_level",
"next_interval_price_level",
"previous_interval_price_level",
"current_hour_price_level",
"next_hour_price_level",
] or not isinstance(value, str):
return None
return PRICE_LEVEL_ICON_MAPPING.get(value.upper())
def _get_rating_sensor_icon(self, key: str, value: Any) -> str | None:
"""Get icon for price rating sensors."""
if key not in [
"price_rating",
"next_interval_price_rating",
"previous_interval_price_rating",
"current_hour_price_rating",
"next_hour_price_rating",
] or not isinstance(value, str):
return None
return PRICE_RATING_ICON_MAPPING.get(value.upper())
def _get_volatility_sensor_icon(self, 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_price_level_for_sensor(self, key: str) -> str | None:
"""Get the price level for a price sensor (current/next/previous interval)."""
if not self.coordinator.data:
return None
price_info = self.coordinator.data.get("priceInfo", {})
now = dt_util.now()
# Map sensor key to interval offset
offset_map = {
"current_price": 0,
"next_interval_price": 1,
"previous_interval_price": -1,
}
interval_offset = offset_map.get(key)
if interval_offset is None:
return None
target_time = now + timedelta(minutes=MINUTES_PER_INTERVAL * interval_offset)
interval_data = find_price_data_for_interval(price_info, target_time)
if not interval_data or "level" not in interval_data:
return None
return interval_data["level"]
def _get_hour_level_for_sensor(self, key: str) -> str | None:
"""Get the price level for an hour average sensor (current/next hour)."""
if not self.coordinator.data:
return None
# Map sensor key to hour offset
offset_map = {
"current_hour_average": 0,
"next_hour_average": 1,
}
hour_offset = offset_map.get(key)
if hour_offset is None:
return None
# Use the same logic as _get_rolling_hour_level_value
price_info = self.coordinator.data.get("priceInfo", {})
yesterday_prices = price_info.get("yesterday", [])
today_prices = price_info.get("today", [])
tomorrow_prices = price_info.get("tomorrow", [])
all_prices = yesterday_prices + today_prices + tomorrow_prices
if not all_prices:
return None
center_idx = self._find_rolling_hour_center_index(all_prices, hour_offset)
if center_idx is None:
return None
levels = self._collect_rolling_window_levels(all_prices, center_idx)
if not levels:
return None
return aggregate_price_levels(levels)
@property
async def async_extra_state_attributes(self) -> dict | None:
@ -1926,6 +2063,8 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
"current_hour_price_rating",
]
# Set timestamp and interval data based on sensor type
interval_data = None
if key in next_interval_sensors:
target_time = now + timedelta(minutes=MINUTES_PER_INTERVAL)
interval_data = find_price_data_for_interval(price_info, target_time)
@ -1935,21 +2074,48 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
interval_data = find_price_data_for_interval(price_info, target_time)
attributes["timestamp"] = interval_data["startsAt"] if interval_data else None
elif key in next_hour_sensors:
# For next hour sensors, show timestamp 1 hour ahead
target_time = now + timedelta(hours=1)
interval_data = find_price_data_for_interval(price_info, target_time)
attributes["timestamp"] = interval_data["startsAt"] if interval_data else None
elif key in current_hour_sensors:
# For current hour sensors, use current interval timestamp
current_interval_data = self._get_current_interval_data()
attributes["timestamp"] = current_interval_data["startsAt"] if current_interval_data else None
else:
# Default: use current interval timestamp
current_interval_data = self._get_current_interval_data()
attributes["timestamp"] = current_interval_data["startsAt"] if current_interval_data else None
# Add price level info for price level sensors
if key == "price_level":
# Add icon_color for price sensors (based on their price level)
if key in ["current_price", "next_interval_price", "previous_interval_price"]:
# For interval-based price sensors, get level from interval_data
if interval_data and "level" in interval_data:
level = interval_data["level"]
if level in PRICE_LEVEL_COLOR_MAPPING:
attributes["icon_color"] = PRICE_LEVEL_COLOR_MAPPING[level]
elif key in ["current_hour_average", "next_hour_average"]:
# For hour-based price sensors, get level from the corresponding level sensor
level = self._get_hour_level_for_sensor(key)
if level and level in PRICE_LEVEL_COLOR_MAPPING:
attributes["icon_color"] = PRICE_LEVEL_COLOR_MAPPING[level]
# Add price level attributes for all level sensors
self._add_level_attributes_for_sensor(attributes, key, interval_data)
# Add price rating attributes for all rating sensors
self._add_rating_attributes_for_sensor(attributes, key, interval_data)
def _add_level_attributes_for_sensor(self, attributes: dict, key: str, interval_data: dict | None) -> None:
"""Add price level attributes based on sensor type."""
# For interval-based level sensors (next/previous), use interval data
if key in ["next_interval_price_level", "previous_interval_price_level"]:
if interval_data and "level" in interval_data:
self._add_price_level_attributes(attributes, interval_data["level"])
# For hour-aggregated level sensors, use native_value
elif key in ["current_hour_price_level", "next_hour_price_level"]:
level_value = self.native_value
if level_value and isinstance(level_value, str):
self._add_price_level_attributes(attributes, level_value.upper())
# For current price level sensor
elif key == "price_level":
current_interval_data = self._get_current_interval_data()
if current_interval_data and "level" in current_interval_data:
self._add_price_level_attributes(attributes, current_interval_data["level"])
@ -1967,6 +2133,44 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
attributes["level_value"] = PRICE_LEVEL_MAPPING[level]
attributes["level_id"] = level
# Add icon_color for dynamic styling
if level in PRICE_LEVEL_COLOR_MAPPING:
attributes["icon_color"] = PRICE_LEVEL_COLOR_MAPPING[level]
def _add_rating_attributes_for_sensor(self, attributes: dict, key: str, interval_data: dict | None) -> None:
"""Add price rating attributes based on sensor type."""
# For interval-based rating sensors (next/previous), use interval data
if key in ["next_interval_price_rating", "previous_interval_price_rating"]:
if interval_data and "rating_level" in interval_data:
self._add_price_rating_attributes(attributes, interval_data["rating_level"])
# For hour-aggregated rating sensors, use native_value
elif key in ["current_hour_price_rating", "next_hour_price_rating"]:
rating_value = self.native_value
if rating_value and isinstance(rating_value, str):
self._add_price_rating_attributes(attributes, rating_value.upper())
# For current price rating sensor
elif key == "price_rating":
current_interval_data = self._get_current_interval_data()
if current_interval_data and "rating_level" in current_interval_data:
self._add_price_rating_attributes(attributes, current_interval_data["rating_level"])
def _add_price_rating_attributes(self, attributes: dict, rating: str) -> None:
"""
Add price rating specific attributes.
Args:
attributes: Dictionary to add attributes to
rating: The price rating value (e.g., LOW, NORMAL, HIGH)
"""
if rating in PRICE_RATING_MAPPING:
attributes["rating_value"] = PRICE_RATING_MAPPING[rating]
attributes["rating_id"] = rating
# Add icon_color for dynamic styling
if rating in PRICE_RATING_COLOR_MAPPING:
attributes["icon_color"] = PRICE_RATING_COLOR_MAPPING[rating]
def _find_price_timestamp(
self,
attributes: dict,