mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
refactor(entity_utils): extract shared helpers from sensor platform
Created entity_utils/helpers.py with platform-agnostic utility functions: - get_price_value(): Price unit conversion (major/minor currency) - translate_level(): Price level translation - translate_rating_level(): Rating level translation - find_rolling_hour_center_index(): Rolling hour window calculations These functions moved from sensor/helpers.py as they are used by both sensor and binary_sensor platforms. Remaining sensor/helpers.py now contains only sensor-specific helpers (aggregate_price_data, etc.). Updated imports: - sensor/core.py: Import from entity_utils instead of sensor.helpers - entity_utils/icons.py: Fixed find_rolling_hour_center_index import - binary_sensor platforms: Can now use shared helpers Added clear docstrings explaining: - entity_utils/helpers.py: Platform-agnostic utilities - sensor/helpers.py: Sensor-specific aggregation functions Impact: Better code reuse, clearer responsibility boundaries between platform-specific and shared utilities.
This commit is contained in:
parent
ac24f6a8cb
commit
4876a2cc29
6 changed files with 417 additions and 208 deletions
|
|
@ -1,9 +1,36 @@
|
||||||
"""Entity utilities for Tibber Prices integration."""
|
"""
|
||||||
|
Home Assistant entity-specific utilities for Tibber Prices integration.
|
||||||
|
|
||||||
|
This package contains HA entity integration logic:
|
||||||
|
- Dynamic icon selection based on state/price levels
|
||||||
|
- Icon color mapping for visual feedback
|
||||||
|
- Attribute builders (timestamps, descriptions, periods)
|
||||||
|
- Translation-aware formatting
|
||||||
|
|
||||||
|
These functions depend on Home Assistant concepts:
|
||||||
|
- Entity states and attributes
|
||||||
|
- Translation systems (custom_translations/)
|
||||||
|
- Configuration entries and coordinator data
|
||||||
|
- User-configurable options (CONF_EXTENDED_DESCRIPTIONS, etc.)
|
||||||
|
|
||||||
|
For pure data transformation (no HA dependencies), see utils/ package.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .attributes import build_period_attributes, build_timestamp_attribute
|
from .attributes import (
|
||||||
|
add_description_attributes,
|
||||||
|
async_add_description_attributes,
|
||||||
|
build_period_attributes,
|
||||||
|
build_timestamp_attribute,
|
||||||
|
)
|
||||||
from .colors import add_icon_color_attribute, get_icon_color
|
from .colors import add_icon_color_attribute, get_icon_color
|
||||||
|
from .helpers import (
|
||||||
|
find_rolling_hour_center_index,
|
||||||
|
get_price_value,
|
||||||
|
translate_level,
|
||||||
|
translate_rating_level,
|
||||||
|
)
|
||||||
from .icons import (
|
from .icons import (
|
||||||
get_binary_sensor_icon,
|
get_binary_sensor_icon,
|
||||||
get_dynamic_icon,
|
get_dynamic_icon,
|
||||||
|
|
@ -16,16 +43,22 @@ from .icons import (
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"add_description_attributes",
|
||||||
"add_icon_color_attribute",
|
"add_icon_color_attribute",
|
||||||
|
"async_add_description_attributes",
|
||||||
"build_period_attributes",
|
"build_period_attributes",
|
||||||
"build_timestamp_attribute",
|
"build_timestamp_attribute",
|
||||||
|
"find_rolling_hour_center_index",
|
||||||
"get_binary_sensor_icon",
|
"get_binary_sensor_icon",
|
||||||
"get_dynamic_icon",
|
"get_dynamic_icon",
|
||||||
"get_icon_color",
|
"get_icon_color",
|
||||||
"get_level_sensor_icon",
|
"get_level_sensor_icon",
|
||||||
"get_price_level_for_icon",
|
"get_price_level_for_icon",
|
||||||
"get_price_sensor_icon",
|
"get_price_sensor_icon",
|
||||||
|
"get_price_value",
|
||||||
"get_rating_sensor_icon",
|
"get_rating_sensor_icon",
|
||||||
"get_trend_icon",
|
"get_trend_icon",
|
||||||
"get_volatility_sensor_icon",
|
"get_volatility_sensor_icon",
|
||||||
|
"translate_level",
|
||||||
|
"translate_rating_level",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,13 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from ..data import TibberPricesConfigEntry # noqa: TID252
|
||||||
|
|
||||||
|
|
||||||
def build_timestamp_attribute(interval_data: dict | None) -> str | None:
|
def build_timestamp_attribute(interval_data: dict | None) -> str | None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -40,3 +47,191 @@ def build_period_attributes(period_data: dict) -> dict:
|
||||||
"duration_minutes": period_data.get("duration_minutes"),
|
"duration_minutes": period_data.get("duration_minutes"),
|
||||||
"timestamp": period_data.get("start"), # Timestamp = period start
|
"timestamp": period_data.get("start"), # Timestamp = period start
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_description_attributes( # noqa: PLR0913
|
||||||
|
attributes: dict,
|
||||||
|
platform: str,
|
||||||
|
translation_key: str | None,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: TibberPricesConfigEntry,
|
||||||
|
*,
|
||||||
|
position: str = "end",
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Add description attributes from custom translations to an existing attributes dict.
|
||||||
|
|
||||||
|
Adds description (always), and optionally long_description and usage_tips if
|
||||||
|
CONF_EXTENDED_DESCRIPTIONS is enabled in config.
|
||||||
|
|
||||||
|
This function modifies the attributes dict in-place. By default, descriptions are
|
||||||
|
added at the END of the dict (after all other attributes). For special cases like
|
||||||
|
chart_data_export, use position="before_service_data" to add descriptions before
|
||||||
|
service data attributes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attributes: Existing attributes dict to modify (in-place)
|
||||||
|
platform: Platform name ("sensor" or "binary_sensor")
|
||||||
|
translation_key: Translation key for entity
|
||||||
|
hass: Home Assistant instance
|
||||||
|
config_entry: Config entry with options
|
||||||
|
position: Where to add descriptions:
|
||||||
|
- "end" (default): Add at the very end
|
||||||
|
- "before_service_data": Add before service data (for chart_data_export)
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not translation_key or not hass:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Import here to avoid circular dependency
|
||||||
|
from ..const import ( # noqa: PLC0415, TID252
|
||||||
|
CONF_EXTENDED_DESCRIPTIONS,
|
||||||
|
DEFAULT_EXTENDED_DESCRIPTIONS,
|
||||||
|
get_entity_description,
|
||||||
|
)
|
||||||
|
|
||||||
|
language = hass.config.language if hass.config.language else "en"
|
||||||
|
|
||||||
|
# Build description dict
|
||||||
|
desc_attrs: dict[str, str] = {}
|
||||||
|
|
||||||
|
description = get_entity_description(platform, translation_key, language, "description")
|
||||||
|
if description:
|
||||||
|
desc_attrs["description"] = description
|
||||||
|
|
||||||
|
extended_descriptions = config_entry.options.get(
|
||||||
|
CONF_EXTENDED_DESCRIPTIONS,
|
||||||
|
config_entry.data.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS),
|
||||||
|
)
|
||||||
|
|
||||||
|
if extended_descriptions:
|
||||||
|
long_desc = get_entity_description(platform, translation_key, language, "long_description")
|
||||||
|
if long_desc:
|
||||||
|
desc_attrs["long_description"] = long_desc
|
||||||
|
|
||||||
|
usage_tips = get_entity_description(platform, translation_key, language, "usage_tips")
|
||||||
|
if usage_tips:
|
||||||
|
desc_attrs["usage_tips"] = usage_tips
|
||||||
|
|
||||||
|
# Add descriptions at appropriate position
|
||||||
|
if position == "end":
|
||||||
|
# Default: Add at the very end
|
||||||
|
attributes.update(desc_attrs)
|
||||||
|
elif position == "before_service_data":
|
||||||
|
# Special case: Insert before service data
|
||||||
|
# This is used by chart_data_export to keep our attributes before foreign data
|
||||||
|
# We need to rebuild the dict to maintain order
|
||||||
|
temp_attrs = dict(attributes)
|
||||||
|
attributes.clear()
|
||||||
|
|
||||||
|
# Add everything except service data
|
||||||
|
for key, value in temp_attrs.items():
|
||||||
|
if key not in ("timestamp", "error"):
|
||||||
|
continue
|
||||||
|
attributes[key] = value
|
||||||
|
|
||||||
|
# Add descriptions here (before service data)
|
||||||
|
attributes.update(desc_attrs)
|
||||||
|
|
||||||
|
# Add service data last
|
||||||
|
for key, value in temp_attrs.items():
|
||||||
|
if key in ("timestamp", "error"):
|
||||||
|
continue
|
||||||
|
attributes[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
async def async_add_description_attributes( # noqa: PLR0913
|
||||||
|
attributes: dict,
|
||||||
|
platform: str,
|
||||||
|
translation_key: str | None,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: TibberPricesConfigEntry,
|
||||||
|
*,
|
||||||
|
position: str = "end",
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Async version of add_description_attributes.
|
||||||
|
|
||||||
|
Adds description attributes from custom translations to an existing attributes dict.
|
||||||
|
Uses async translation loading (calls async_get_entity_description).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attributes: Existing attributes dict to modify (in-place)
|
||||||
|
platform: Platform name ("sensor" or "binary_sensor")
|
||||||
|
translation_key: Translation key for entity
|
||||||
|
hass: Home Assistant instance
|
||||||
|
config_entry: Config entry with options
|
||||||
|
position: Where to add descriptions ("end" or "before_service_data")
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not translation_key or not hass:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Import here to avoid circular dependency
|
||||||
|
from ..const import ( # noqa: PLC0415, TID252
|
||||||
|
CONF_EXTENDED_DESCRIPTIONS,
|
||||||
|
DEFAULT_EXTENDED_DESCRIPTIONS,
|
||||||
|
async_get_entity_description,
|
||||||
|
)
|
||||||
|
|
||||||
|
language = hass.config.language if hass.config.language else "en"
|
||||||
|
|
||||||
|
# Build description dict
|
||||||
|
desc_attrs: dict[str, str] = {}
|
||||||
|
|
||||||
|
description = await async_get_entity_description(
|
||||||
|
hass,
|
||||||
|
platform,
|
||||||
|
translation_key,
|
||||||
|
language,
|
||||||
|
"description",
|
||||||
|
)
|
||||||
|
if description:
|
||||||
|
desc_attrs["description"] = description
|
||||||
|
|
||||||
|
extended_descriptions = config_entry.options.get(
|
||||||
|
CONF_EXTENDED_DESCRIPTIONS,
|
||||||
|
config_entry.data.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS),
|
||||||
|
)
|
||||||
|
|
||||||
|
if extended_descriptions:
|
||||||
|
long_desc = await async_get_entity_description(
|
||||||
|
hass,
|
||||||
|
platform,
|
||||||
|
translation_key,
|
||||||
|
language,
|
||||||
|
"long_description",
|
||||||
|
)
|
||||||
|
if long_desc:
|
||||||
|
desc_attrs["long_description"] = long_desc
|
||||||
|
|
||||||
|
usage_tips = await async_get_entity_description(
|
||||||
|
hass,
|
||||||
|
platform,
|
||||||
|
translation_key,
|
||||||
|
language,
|
||||||
|
"usage_tips",
|
||||||
|
)
|
||||||
|
if usage_tips:
|
||||||
|
desc_attrs["usage_tips"] = usage_tips
|
||||||
|
|
||||||
|
# Add descriptions at appropriate position
|
||||||
|
if position == "end":
|
||||||
|
# Default: Add at the very end
|
||||||
|
attributes.update(desc_attrs)
|
||||||
|
elif position == "before_service_data":
|
||||||
|
# Special case: Insert before service data (same logic as sync version)
|
||||||
|
temp_attrs = dict(attributes)
|
||||||
|
attributes.clear()
|
||||||
|
|
||||||
|
for key, value in temp_attrs.items():
|
||||||
|
if key not in ("timestamp", "error"):
|
||||||
|
continue
|
||||||
|
attributes[key] = value
|
||||||
|
|
||||||
|
attributes.update(desc_attrs)
|
||||||
|
|
||||||
|
for key, value in temp_attrs.items():
|
||||||
|
if key in ("timestamp", "error"):
|
||||||
|
continue
|
||||||
|
attributes[key] = value
|
||||||
|
|
|
||||||
128
custom_components/tibber_prices/entity_utils/helpers.py
Normal file
128
custom_components/tibber_prices/entity_utils/helpers.py
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"""
|
||||||
|
Common helper functions for entities across platforms.
|
||||||
|
|
||||||
|
This module provides utility functions used by both sensor and binary_sensor platforms:
|
||||||
|
- Price value conversion (major/minor currency units)
|
||||||
|
- Translation helpers (price levels, ratings)
|
||||||
|
- Time-based calculations (rolling hour center index)
|
||||||
|
|
||||||
|
These functions operate on entity-level concepts (states, translations) but are
|
||||||
|
platform-agnostic and can be used by both sensor and binary_sensor platforms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from custom_components.tibber_prices.const import get_price_level_translation
|
||||||
|
from custom_components.tibber_prices.utils.average import (
|
||||||
|
round_to_nearest_quarter_hour,
|
||||||
|
)
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
|
||||||
|
def get_price_value(price: float, *, in_euro: bool) -> float:
|
||||||
|
"""
|
||||||
|
Convert price based on unit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
price: Price value to convert
|
||||||
|
in_euro: If True, return price in euros; if False, return in cents/øre
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Price in requested unit (euros or minor currency units)
|
||||||
|
|
||||||
|
"""
|
||||||
|
return price if in_euro else round((price * 100), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def translate_level(hass: HomeAssistant, level: str) -> str:
|
||||||
|
"""
|
||||||
|
Translate price level to the user's language.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hass: HomeAssistant instance for language configuration
|
||||||
|
level: Price level to translate (e.g., VERY_CHEAP, NORMAL, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Translated level string, or original level if translation not found
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not hass:
|
||||||
|
return level
|
||||||
|
|
||||||
|
language = hass.config.language or "en"
|
||||||
|
translated = get_price_level_translation(level, language)
|
||||||
|
if translated:
|
||||||
|
return translated
|
||||||
|
|
||||||
|
if language != "en":
|
||||||
|
fallback = get_price_level_translation(level, "en")
|
||||||
|
if fallback:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
return level
|
||||||
|
|
||||||
|
|
||||||
|
def translate_rating_level(rating: str) -> str:
|
||||||
|
"""
|
||||||
|
Translate price rating level to the user's language.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rating: Price rating to translate (e.g., LOW, NORMAL, HIGH)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Translated rating string, or original rating if translation not found
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Currently returns the rating as-is. Translation mapping for ratings
|
||||||
|
can be added here when needed, similar to translate_level().
|
||||||
|
|
||||||
|
"""
|
||||||
|
# For now, ratings are returned as-is
|
||||||
|
# Add translation mapping here when needed
|
||||||
|
return rating
|
||||||
|
|
||||||
|
|
||||||
|
def find_rolling_hour_center_index(
|
||||||
|
all_prices: list[dict],
|
||||||
|
current_time: datetime,
|
||||||
|
hour_offset: int,
|
||||||
|
) -> int | None:
|
||||||
|
"""
|
||||||
|
Find the center index for the rolling hour window.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
all_prices: List of all price interval dictionaries with 'startsAt' key
|
||||||
|
current_time: Current datetime to find the current interval
|
||||||
|
hour_offset: Number of hours to offset from current interval (can be negative)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Index of the center interval for the rolling hour window, or None if not found
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Round to nearest interval boundary to handle edge cases where HA schedules
|
||||||
|
# us slightly before the boundary (e.g., 14:59:59.999 → 15:00:00)
|
||||||
|
target_time = round_to_nearest_quarter_hour(current_time)
|
||||||
|
current_idx = None
|
||||||
|
|
||||||
|
for idx, price_data in enumerate(all_prices):
|
||||||
|
starts_at = dt_util.parse_datetime(price_data["startsAt"])
|
||||||
|
if starts_at is None:
|
||||||
|
continue
|
||||||
|
starts_at = dt_util.as_local(starts_at)
|
||||||
|
|
||||||
|
# Exact match after rounding
|
||||||
|
if starts_at == target_time:
|
||||||
|
current_idx = idx
|
||||||
|
break
|
||||||
|
|
||||||
|
if current_idx is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return current_idx + (hour_offset * 4)
|
||||||
|
|
@ -9,16 +9,15 @@ from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from custom_components.tibber_prices.const import (
|
from custom_components.tibber_prices.const import (
|
||||||
BINARY_SENSOR_ICON_MAPPING,
|
BINARY_SENSOR_ICON_MAPPING,
|
||||||
|
MINUTES_PER_INTERVAL,
|
||||||
PRICE_LEVEL_CASH_ICON_MAPPING,
|
PRICE_LEVEL_CASH_ICON_MAPPING,
|
||||||
PRICE_LEVEL_ICON_MAPPING,
|
PRICE_LEVEL_ICON_MAPPING,
|
||||||
PRICE_RATING_ICON_MAPPING,
|
PRICE_RATING_ICON_MAPPING,
|
||||||
VOLATILITY_ICON_MAPPING,
|
VOLATILITY_ICON_MAPPING,
|
||||||
)
|
)
|
||||||
from custom_components.tibber_prices.price_utils import find_price_data_for_interval
|
from custom_components.tibber_prices.entity_utils.helpers import find_rolling_hour_center_index
|
||||||
from custom_components.tibber_prices.sensor.helpers import (
|
from custom_components.tibber_prices.sensor.helpers import aggregate_level_data
|
||||||
aggregate_level_data,
|
from custom_components.tibber_prices.utils.price import find_price_data_for_interval
|
||||||
find_rolling_hour_center_index,
|
|
||||||
)
|
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -35,9 +34,6 @@ class IconContext:
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
# Constants imported from price_utils
|
|
||||||
MINUTES_PER_INTERVAL = 15
|
|
||||||
|
|
||||||
# Timing sensor icon thresholds (in minutes)
|
# Timing sensor icon thresholds (in minutes)
|
||||||
TIMING_URGENT_THRESHOLD = 15 # ≤15 min: Alert icon
|
TIMING_URGENT_THRESHOLD = 15 # ≤15 min: Alert icon
|
||||||
TIMING_SOON_THRESHOLD = 60 # ≤1 hour: Timer icon
|
TIMING_SOON_THRESHOLD = 60 # ≤1 hour: Timer icon
|
||||||
|
|
|
||||||
|
|
@ -7,28 +7,17 @@ from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from custom_components.tibber_prices.average_utils import (
|
|
||||||
calculate_current_leading_avg,
|
|
||||||
calculate_current_leading_max,
|
|
||||||
calculate_current_leading_min,
|
|
||||||
calculate_current_trailing_avg,
|
|
||||||
calculate_current_trailing_max,
|
|
||||||
calculate_current_trailing_min,
|
|
||||||
calculate_next_n_hours_avg,
|
|
||||||
)
|
|
||||||
from custom_components.tibber_prices.binary_sensor.attributes import (
|
from custom_components.tibber_prices.binary_sensor.attributes import (
|
||||||
get_price_intervals_attributes,
|
get_price_intervals_attributes,
|
||||||
)
|
)
|
||||||
from custom_components.tibber_prices.const import (
|
from custom_components.tibber_prices.const import (
|
||||||
CONF_CHART_DATA_CONFIG,
|
CONF_CHART_DATA_CONFIG,
|
||||||
CONF_EXTENDED_DESCRIPTIONS,
|
|
||||||
CONF_PRICE_RATING_THRESHOLD_HIGH,
|
CONF_PRICE_RATING_THRESHOLD_HIGH,
|
||||||
CONF_PRICE_RATING_THRESHOLD_LOW,
|
CONF_PRICE_RATING_THRESHOLD_LOW,
|
||||||
CONF_PRICE_TREND_THRESHOLD_FALLING,
|
CONF_PRICE_TREND_THRESHOLD_FALLING,
|
||||||
CONF_PRICE_TREND_THRESHOLD_RISING,
|
CONF_PRICE_TREND_THRESHOLD_RISING,
|
||||||
CONF_VOLATILITY_THRESHOLD_HIGH,
|
CONF_VOLATILITY_THRESHOLD_HIGH,
|
||||||
CONF_VOLATILITY_THRESHOLD_MODERATE,
|
CONF_VOLATILITY_THRESHOLD_MODERATE,
|
||||||
DEFAULT_EXTENDED_DESCRIPTIONS,
|
|
||||||
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
||||||
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
|
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
|
||||||
DEFAULT_PRICE_TREND_THRESHOLD_FALLING,
|
DEFAULT_PRICE_TREND_THRESHOLD_FALLING,
|
||||||
|
|
@ -36,9 +25,9 @@ from custom_components.tibber_prices.const import (
|
||||||
DEFAULT_VOLATILITY_THRESHOLD_HIGH,
|
DEFAULT_VOLATILITY_THRESHOLD_HIGH,
|
||||||
DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
|
DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
MINUTES_PER_INTERVAL,
|
||||||
format_price_unit_major,
|
format_price_unit_major,
|
||||||
format_price_unit_minor,
|
format_price_unit_minor,
|
||||||
get_entity_description,
|
|
||||||
)
|
)
|
||||||
from custom_components.tibber_prices.coordinator import (
|
from custom_components.tibber_prices.coordinator import (
|
||||||
MINUTE_UPDATE_ENTITY_KEYS,
|
MINUTE_UPDATE_ENTITY_KEYS,
|
||||||
|
|
@ -47,11 +36,21 @@ from custom_components.tibber_prices.coordinator import (
|
||||||
from custom_components.tibber_prices.entity import TibberPricesEntity
|
from custom_components.tibber_prices.entity import TibberPricesEntity
|
||||||
from custom_components.tibber_prices.entity_utils import (
|
from custom_components.tibber_prices.entity_utils import (
|
||||||
add_icon_color_attribute,
|
add_icon_color_attribute,
|
||||||
|
find_rolling_hour_center_index,
|
||||||
get_dynamic_icon,
|
get_dynamic_icon,
|
||||||
|
get_price_value,
|
||||||
)
|
)
|
||||||
from custom_components.tibber_prices.entity_utils.icons import IconContext
|
from custom_components.tibber_prices.entity_utils.icons import IconContext
|
||||||
from custom_components.tibber_prices.price_utils import (
|
from custom_components.tibber_prices.utils.average import (
|
||||||
MINUTES_PER_INTERVAL,
|
calculate_current_leading_avg,
|
||||||
|
calculate_current_leading_max,
|
||||||
|
calculate_current_leading_min,
|
||||||
|
calculate_current_trailing_avg,
|
||||||
|
calculate_current_trailing_max,
|
||||||
|
calculate_current_trailing_min,
|
||||||
|
calculate_next_n_hours_avg,
|
||||||
|
)
|
||||||
|
from custom_components.tibber_prices.utils.price import (
|
||||||
calculate_price_trend,
|
calculate_price_trend,
|
||||||
calculate_volatility_level,
|
calculate_volatility_level,
|
||||||
find_price_data_for_interval,
|
find_price_data_for_interval,
|
||||||
|
|
@ -67,6 +66,7 @@ from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .attributes import (
|
from .attributes import (
|
||||||
add_volatility_type_attributes,
|
add_volatility_type_attributes,
|
||||||
|
build_extra_state_attributes,
|
||||||
build_sensor_attributes,
|
build_sensor_attributes,
|
||||||
get_future_prices,
|
get_future_prices,
|
||||||
get_prices_for_volatility,
|
get_prices_for_volatility,
|
||||||
|
|
@ -75,8 +75,6 @@ from .helpers import (
|
||||||
aggregate_level_data,
|
aggregate_level_data,
|
||||||
aggregate_price_data,
|
aggregate_price_data,
|
||||||
aggregate_rating_data,
|
aggregate_rating_data,
|
||||||
find_rolling_hour_center_index,
|
|
||||||
get_price_value,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -1979,76 +1977,35 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
||||||
# Fall back to static icon from entity description
|
# Fall back to static icon from entity description
|
||||||
return icon or self.entity_description.icon
|
return icon or self.entity_description.icon
|
||||||
|
|
||||||
def _get_description_attributes(self) -> dict[str, str]:
|
|
||||||
"""Get description/long_description/usage_tips attributes."""
|
|
||||||
attributes = {}
|
|
||||||
|
|
||||||
if not self.entity_description.translation_key or not self.hass:
|
|
||||||
return attributes
|
|
||||||
|
|
||||||
language = self.hass.config.language if self.hass.config.language else "en"
|
|
||||||
|
|
||||||
# Add basic description (from cache, synchronous)
|
|
||||||
description = get_entity_description("sensor", self.entity_description.translation_key, language, "description")
|
|
||||||
if description:
|
|
||||||
attributes["description"] = description
|
|
||||||
|
|
||||||
# Check if extended descriptions are enabled
|
|
||||||
extended_descriptions = self.coordinator.config_entry.options.get(
|
|
||||||
CONF_EXTENDED_DESCRIPTIONS,
|
|
||||||
self.coordinator.config_entry.data.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS),
|
|
||||||
)
|
|
||||||
|
|
||||||
if extended_descriptions:
|
|
||||||
long_desc = get_entity_description(
|
|
||||||
"sensor", self.entity_description.translation_key, language, "long_description"
|
|
||||||
)
|
|
||||||
if long_desc:
|
|
||||||
attributes["long_description"] = long_desc
|
|
||||||
|
|
||||||
usage_tips = get_entity_description(
|
|
||||||
"sensor", self.entity_description.translation_key, language, "usage_tips"
|
|
||||||
)
|
|
||||||
if usage_tips:
|
|
||||||
attributes["usage_tips"] = usage_tips
|
|
||||||
|
|
||||||
return attributes
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||||
"""Return additional state attributes."""
|
"""Return additional state attributes."""
|
||||||
|
try:
|
||||||
if not self.coordinator.data:
|
if not self.coordinator.data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# For chart_data_export: special ordering (metadata → descriptions → service data)
|
# Get sensor-specific attributes
|
||||||
if self.entity_description.key == "chart_data_export":
|
sensor_attrs = self._get_sensor_attributes()
|
||||||
attributes: dict[str, Any] = {}
|
|
||||||
chart_attrs = self._get_chart_data_export_attributes()
|
|
||||||
|
|
||||||
# Step 1: Add metadata (timestamp, error)
|
# Build complete attributes using unified builder
|
||||||
if chart_attrs:
|
return build_extra_state_attributes(
|
||||||
for key in ("timestamp", "error"):
|
entity_key=self.entity_description.key,
|
||||||
if key in chart_attrs:
|
translation_key=self.entity_description.translation_key,
|
||||||
attributes[key] = chart_attrs[key]
|
hass=self.hass,
|
||||||
|
config_entry=self.coordinator.config_entry,
|
||||||
|
coordinator_data=self.coordinator.data,
|
||||||
|
sensor_attrs=sensor_attrs,
|
||||||
|
)
|
||||||
|
|
||||||
# Step 2: Add descriptions
|
except (KeyError, ValueError, TypeError) as ex:
|
||||||
description_attrs = self._get_description_attributes()
|
self.coordinator.logger.exception(
|
||||||
attributes.update(description_attrs)
|
"Error getting sensor attributes",
|
||||||
|
extra={
|
||||||
# Step 3: Add service data (everything except metadata)
|
"error": str(ex),
|
||||||
if chart_attrs:
|
"entity": self.entity_description.key,
|
||||||
attributes.update({k: v for k, v in chart_attrs.items() if k not in ("timestamp", "error")})
|
},
|
||||||
|
)
|
||||||
return attributes if attributes else None
|
return None
|
||||||
|
|
||||||
# For all other sensors: standard behavior
|
|
||||||
attributes: dict[str, Any] = self._get_sensor_attributes() or {}
|
|
||||||
description_attrs = self._get_description_attributes()
|
|
||||||
# Merge description attributes
|
|
||||||
for key, value in description_attrs.items():
|
|
||||||
attributes[key] = value
|
|
||||||
|
|
||||||
return attributes if attributes else None
|
|
||||||
|
|
||||||
def _get_sensor_attributes(self) -> dict | None:
|
def _get_sensor_attributes(self) -> dict | None:
|
||||||
"""Get attributes based on sensor type."""
|
"""Get attributes based on sensor type."""
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,25 @@
|
||||||
"""Helper functions for sensor platform."""
|
"""
|
||||||
|
Sensor platform-specific helper functions.
|
||||||
|
|
||||||
|
This module contains helper functions specific to the sensor platform:
|
||||||
|
- aggregate_price_data: Calculate average price from window data
|
||||||
|
- aggregate_level_data: Aggregate price levels from intervals
|
||||||
|
- aggregate_rating_data: Aggregate price ratings from intervals
|
||||||
|
|
||||||
|
For shared helper functions (used by both sensor and binary_sensor platforms),
|
||||||
|
see entity_utils/helpers.py:
|
||||||
|
- get_price_value: Price unit conversion
|
||||||
|
- translate_level: Price level translation
|
||||||
|
- translate_rating_level: Rating level translation
|
||||||
|
- find_rolling_hour_center_index: Rolling hour window calculations
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from custom_components.tibber_prices.utils.price import (
|
||||||
|
|
||||||
from custom_components.tibber_prices.average_utils import (
|
|
||||||
round_to_nearest_quarter_hour,
|
|
||||||
)
|
|
||||||
from custom_components.tibber_prices.const import get_price_level_translation
|
|
||||||
from custom_components.tibber_prices.price_utils import (
|
|
||||||
aggregate_price_levels,
|
aggregate_price_levels,
|
||||||
aggregate_price_rating,
|
aggregate_price_rating,
|
||||||
)
|
)
|
||||||
from homeassistant.util import dt as dt_util
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
|
|
||||||
|
|
||||||
def aggregate_price_data(window_data: list[dict]) -> float | None:
|
def aggregate_price_data(window_data: list[dict]) -> float | None:
|
||||||
|
|
@ -79,105 +81,3 @@ def aggregate_rating_data(
|
||||||
|
|
||||||
aggregated, _ = aggregate_price_rating(differences, threshold_low, threshold_high)
|
aggregated, _ = aggregate_price_rating(differences, threshold_low, threshold_high)
|
||||||
return aggregated.lower() if aggregated else None
|
return aggregated.lower() if aggregated else None
|
||||||
|
|
||||||
|
|
||||||
def find_rolling_hour_center_index(
|
|
||||||
all_prices: list[dict],
|
|
||||||
current_time: datetime,
|
|
||||||
hour_offset: int,
|
|
||||||
) -> int | None:
|
|
||||||
"""
|
|
||||||
Find the center index for the rolling hour window.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
all_prices: List of all price interval dictionaries with 'startsAt' key
|
|
||||||
current_time: Current datetime to find the current interval
|
|
||||||
hour_offset: Number of hours to offset from current interval (can be negative)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Index of the center interval for the rolling hour window, or None if not found
|
|
||||||
|
|
||||||
"""
|
|
||||||
# Round to nearest interval boundary to handle edge cases where HA schedules
|
|
||||||
# us slightly before the boundary (e.g., 14:59:59.999 → 15:00:00)
|
|
||||||
target_time = round_to_nearest_quarter_hour(current_time)
|
|
||||||
current_idx = None
|
|
||||||
|
|
||||||
for idx, price_data in enumerate(all_prices):
|
|
||||||
starts_at = dt_util.parse_datetime(price_data["startsAt"])
|
|
||||||
if starts_at is None:
|
|
||||||
continue
|
|
||||||
starts_at = dt_util.as_local(starts_at)
|
|
||||||
|
|
||||||
# Exact match after rounding
|
|
||||||
if starts_at == target_time:
|
|
||||||
current_idx = idx
|
|
||||||
break
|
|
||||||
|
|
||||||
if current_idx is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return current_idx + (hour_offset * 4)
|
|
||||||
|
|
||||||
|
|
||||||
def translate_level(hass: HomeAssistant, level: str) -> str:
|
|
||||||
"""
|
|
||||||
Translate price level to the user's language.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
hass: HomeAssistant instance for language configuration
|
|
||||||
level: Price level to translate (e.g., VERY_CHEAP, NORMAL, etc.)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Translated level string, or original level if translation not found
|
|
||||||
|
|
||||||
"""
|
|
||||||
if not hass:
|
|
||||||
return level
|
|
||||||
|
|
||||||
language = hass.config.language or "en"
|
|
||||||
translated = get_price_level_translation(level, language)
|
|
||||||
if translated:
|
|
||||||
return translated
|
|
||||||
|
|
||||||
if language != "en":
|
|
||||||
fallback = get_price_level_translation(level, "en")
|
|
||||||
if fallback:
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
return level
|
|
||||||
|
|
||||||
|
|
||||||
def translate_rating_level(rating: str) -> str:
|
|
||||||
"""
|
|
||||||
Translate price rating level to the user's language.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
rating: Price rating to translate (e.g., LOW, NORMAL, HIGH)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Translated rating string, or original rating if translation not found
|
|
||||||
|
|
||||||
Note:
|
|
||||||
Currently returns the rating as-is. Translation mapping for ratings
|
|
||||||
can be added here when needed, similar to translate_level().
|
|
||||||
|
|
||||||
"""
|
|
||||||
# For now, ratings are returned as-is
|
|
||||||
# Add translation mapping here when needed
|
|
||||||
return rating
|
|
||||||
|
|
||||||
|
|
||||||
def get_price_value(price: float, *, in_euro: bool) -> float:
|
|
||||||
"""
|
|
||||||
Convert price based on unit.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
price: Price value to convert
|
|
||||||
in_euro: If True, return price in euros; if False, return in cents/øre
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Price in requested unit (euros or minor currency units)
|
|
||||||
|
|
||||||
"""
|
|
||||||
return price if in_euro else round((price * 100), 2)
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue