From 3b11c6721e0a386944e30c77bdb0bba39840ffec Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Sat, 22 Nov 2025 14:32:24 +0000 Subject: [PATCH] feat(types): add TypedDict documentation and BaseCalculator helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1.1 - TypedDict Documentation System: - Created sensor/types.py with 14 TypedDict classes documenting sensor attributes - Created binary_sensor/types.py with 3 TypedDict classes for binary sensors - Added Literal types (PriceLevel, PriceRating, VolatilityLevel, DataCompleteness) - Updated imports in sensor/attributes/__init__.py and binary_sensor/attributes.py - Changed function signatures to use dict[str, Any] for runtime flexibility - TypedDicts serve as IDE documentation, not runtime validation Phase 1.2 - BaseCalculator Improvements: - Added 8 smart data access methods to BaseCalculator: * get_intervals(day) - day-specific intervals with None-safety * intervals_today/tomorrow/yesterday - convenience properties * get_all_intervals() - combined yesterday+today+tomorrow * find_interval_at_offset(offset) - interval lookup with bounds checking * safe_get_from_interval(interval, key, default) - safe dict access * has_data() / has_price_info() - existence checks * get_day_intervals(day) - alias for consistency - Refactored 5 calculator files to use new helper methods: * daily_stat.py: -11 lines (coordinator_data checks, get_intervals usage) * interval.py: -18 lines (eliminated find_price_data_for_interval duplication) * rolling_hour.py: -3 lines (simplified interval collection) * volatility.py: -4 lines (eliminated price_info local variable) * window_24h.py: -2 lines (replaced coordinator_data check) * Total: -38 lines of duplicate code eliminated - Added noqa comment for lazy import (circular import avoidance) Type Duplication Resolution: - Identified duplication: Literal types in types.py vs string constants in const.py - Attempted solution: Derive constants from Literal types using typing.get_args() - Result: Circular import failure (const.py → sensor/types.py → sensor/__init__.py → const.py) - Final solution: Keep string constants as single source of truth - Added SYNC comments in all 3 files (const.py, sensor/types.py, binary_sensor/types.py) - Accept manual synchronization to avoid circular dependencies - Platform separation maintained (no cross-imports between sensor/ and binary_sensor/) Impact: Developers get IDE autocomplete and type hints for attribute dictionaries. Calculator code is more readable with fewer None-checks and clearer data access patterns. Type/constant duplication documented with sync requirements. --- .../tibber_prices/binary_sensor/attributes.py | 6 + .../tibber_prices/binary_sensor/types.py | 171 ++++++++++++ custom_components/tibber_prices/const.py | 11 +- .../sensor/attributes/__init__.py | 34 ++- .../tibber_prices/sensor/calculators/base.py | 123 +++++++++ .../sensor/calculators/daily_stat.py | 12 +- .../sensor/calculators/interval.py | 28 +- .../sensor/calculators/rolling_hour.py | 5 +- .../sensor/calculators/volatility.py | 8 +- .../sensor/calculators/window_24h.py | 2 +- .../tibber_prices/sensor/types.py | 261 ++++++++++++++++++ 11 files changed, 621 insertions(+), 40 deletions(-) create mode 100644 custom_components/tibber_prices/binary_sensor/types.py create mode 100644 custom_components/tibber_prices/sensor/types.py diff --git a/custom_components/tibber_prices/binary_sensor/attributes.py b/custom_components/tibber_prices/binary_sensor/attributes.py index 37c9750..c739821 100644 --- a/custom_components/tibber_prices/binary_sensor/attributes.py +++ b/custom_components/tibber_prices/binary_sensor/attributes.py @@ -6,6 +6,8 @@ from typing import TYPE_CHECKING from custom_components.tibber_prices.entity_utils import add_icon_color_attribute +# Import TypedDict definitions for documentation (not used in signatures) + if TYPE_CHECKING: from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService @@ -24,6 +26,8 @@ def get_tomorrow_data_available_attributes( """ Build attributes for tomorrow_data_available sensor. + Returns TomorrowDataAvailableAttributes structure. + Args: coordinator_data: Coordinator data dict time: TibberPricesTimeService instance @@ -65,6 +69,8 @@ def get_price_intervals_attributes( """ Build attributes for period-based sensors (best/peak price). + Returns PeriodAttributes structure. + All data is already calculated in the coordinator - we just need to: 1. Get period summaries from coordinator (already filtered and fully calculated) 2. Add the current timestamp diff --git a/custom_components/tibber_prices/binary_sensor/types.py b/custom_components/tibber_prices/binary_sensor/types.py new file mode 100644 index 0000000..b05f9a3 --- /dev/null +++ b/custom_components/tibber_prices/binary_sensor/types.py @@ -0,0 +1,171 @@ +""" +Type definitions for Tibber Prices binary sensor attributes. + +These TypedDict definitions serve as **documentation** of the attribute structure +for each binary sensor type. They enable IDE autocomplete and type checking when +working with attribute dictionaries. + +NOTE: In function signatures, we still use dict[str, Any] for flexibility, +but these TypedDict definitions document what keys and types are expected. + +IMPORTANT: PriceLevel and PriceRating types are duplicated here to avoid +cross-platform dependencies. Keep in sync with sensor/types.py. +""" + +from __future__ import annotations + +from typing import Literal, TypedDict + +# ============================================================================ +# Literal Type Definitions (Duplicated from sensor/types.py) +# ============================================================================ +# SYNC: Keep these in sync with: +# 1. sensor/types.py (Literal type definitions) +# 2. const.py (runtime string constants - single source of truth) +# +# const.py defines: +# - PRICE_LEVEL_VERY_CHEAP, PRICE_LEVEL_CHEAP, etc. +# - PRICE_RATING_LOW, PRICE_RATING_NORMAL, etc. +# +# These types are intentionally duplicated here to avoid cross-platform imports. +# Binary sensor attributes need these types for type safety without importing +# from sensor/ package (maintains platform separation). + +# Price level literals (shared with sensor platform - keep in sync!) +PriceLevel = Literal[ + "VERY_CHEAP", + "CHEAP", + "NORMAL", + "EXPENSIVE", + "VERY_EXPENSIVE", +] + +# Price rating literals (shared with sensor platform - keep in sync!) +PriceRating = Literal[ + "LOW", + "NORMAL", + "HIGH", +] + + +class BaseAttributes(TypedDict, total=False): + """ + Base attributes common to all binary sensors. + + All binary sensor attributes include at minimum: + - timestamp: ISO 8601 string indicating when the state/attributes are valid + - error: Optional error message if something went wrong + """ + + timestamp: str + error: str + + +class TomorrowDataAvailableAttributes(BaseAttributes, total=False): + """ + Attributes for tomorrow_data_available binary sensor. + + Indicates whether tomorrow's price data is available from Tibber API. + """ + + intervals_available: int # Number of intervals available for tomorrow + data_status: Literal["none", "partial", "full"] # Data completeness status + + +class PeriodSummary(TypedDict, total=False): + """ + Structure for period summary nested in period attributes. + + Each period summary contains all calculated information about one period. + """ + + # Time information (priority 1) + start: str # ISO 8601 timestamp of period start + end: str # ISO 8601 timestamp of period end + duration_minutes: int # Duration in minutes + + # Core decision attributes (priority 2) + level: PriceLevel # Price level classification + rating_level: PriceRating # Price rating classification + rating_difference_pct: float # Difference from daily average (%) + + # Price statistics (priority 3) + price_avg: float # Average price in period (minor currency) + price_min: float # Minimum price in period (minor currency) + price_max: float # Maximum price in period (minor currency) + price_spread: float # Price spread (max - min) + volatility: float # Price volatility within period + + # Price comparison (priority 4) + period_price_diff_from_daily_min: float # Difference from daily min (minor currency) + period_price_diff_from_daily_min_pct: float # Difference from daily min (%) + + # Detail information (priority 5) + period_interval_count: int # Number of intervals in period + period_position: int # Period position (1-based) + periods_total: int # Total number of periods + periods_remaining: int # Remaining periods after this one + + # Relaxation information (priority 6 - only if period was relaxed) + relaxation_active: bool # Whether this period was found via relaxation + relaxation_level: int # Relaxation level used (1-based) + relaxation_threshold_original_pct: float # Original flex threshold (%) + relaxation_threshold_applied_pct: float # Applied flex threshold after relaxation (%) + + +class PeriodAttributes(BaseAttributes, total=False): + """ + Attributes for period-based binary sensors (best_price_period, peak_price_period). + + These sensors indicate whether the current/next cheap/expensive period is active. + + Attributes follow priority ordering: + 1. Time information (timestamp, start, end, duration_minutes) + 2. Core decision attributes (level, rating_level, rating_difference_%) + 3. Price statistics (price_avg, price_min, price_max, price_spread, volatility) + 4. Price comparison (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%) + 5. Detail information (period_interval_count, period_position, periods_total, periods_remaining) + 6. Relaxation information (only if period was relaxed) + 7. Meta information (periods list) + """ + + # Time information (priority 1) - start/end refer to current/next period + start: str | None # ISO 8601 timestamp of current/next period start + end: str | None # ISO 8601 timestamp of current/next period end + duration_minutes: int # Duration of current/next period in minutes + + # Core decision attributes (priority 2) + level: PriceLevel # Price level of current/next period + rating_level: PriceRating # Price rating of current/next period + rating_difference_pct: float # Difference from daily average (%) + + # Price statistics (priority 3) + price_avg: float # Average price in current/next period (minor currency) + price_min: float # Minimum price in current/next period (minor currency) + price_max: float # Maximum price in current/next period (minor currency) + price_spread: float # Price spread (max - min) in current/next period + volatility: float # Price volatility within current/next period + + # Price comparison (priority 4) + period_price_diff_from_daily_min: float # Difference from daily min (minor currency) + period_price_diff_from_daily_min_pct: float # Difference from daily min (%) + + # Detail information (priority 5) + period_interval_count: int # Number of intervals in current/next period + period_position: int # Period position (1-based) + periods_total: int # Total number of periods found + periods_remaining: int # Remaining periods after current/next one + + # Relaxation information (priority 6 - only if period was relaxed) + relaxation_active: bool # Whether current/next period was found via relaxation + relaxation_level: int # Relaxation level used (1-based) + relaxation_threshold_original_pct: float # Original flex threshold (%) + relaxation_threshold_applied_pct: float # Applied flex threshold after relaxation (%) + + # Meta information (priority 7) + periods: list[PeriodSummary] # All periods found (sorted by start time) + + +# Union type for all binary sensor attributes (for documentation purposes) +# In actual code, use dict[str, Any] for flexibility +BinarySensorAttributes = TomorrowDataAvailableAttributes | PeriodAttributes diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index b0d1677..4dc12e4 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -211,7 +211,14 @@ def format_price_unit_minor(currency_code: str | None) -> str: return f"{minor_symbol}/{UnitOfPower.KILO_WATT}{UnitOfTime.HOURS}" -# Price level constants from Tibber API +# ============================================================================ +# Price Level, Rating, and Volatility Constants +# ============================================================================ +# IMPORTANT: These string constants are the single source of truth for +# valid enum values. The Literal types in sensor/types.py and binary_sensor/types.py +# should be kept in sync with these values manually. + +# Price level constants (from Tibber API) PRICE_LEVEL_VERY_CHEAP = "VERY_CHEAP" PRICE_LEVEL_CHEAP = "CHEAP" PRICE_LEVEL_NORMAL = "NORMAL" @@ -223,7 +230,7 @@ PRICE_RATING_LOW = "LOW" PRICE_RATING_NORMAL = "NORMAL" PRICE_RATING_HIGH = "HIGH" -# Price volatility levels (based on coefficient of variation: std_dev / mean * 100%) +# Price volatility level constants VOLATILITY_LOW = "LOW" VOLATILITY_MODERATE = "MODERATE" VOLATILITY_HIGH = "HIGH" diff --git a/custom_components/tibber_prices/sensor/attributes/__init__.py b/custom_components/tibber_prices/sensor/attributes/__init__.py index 13c948e..ec1ec9c 100644 --- a/custom_components/tibber_prices/sensor/attributes/__init__.py +++ b/custom_components/tibber_prices/sensor/attributes/__init__.py @@ -14,6 +14,22 @@ from custom_components.tibber_prices.entity_utils import ( add_description_attributes, add_icon_color_attribute, ) +from custom_components.tibber_prices.sensor.types import ( + DailyStatPriceAttributes, + DailyStatRatingAttributes, + FutureAttributes, + IntervalLevelAttributes, + # Import all types for re-export + IntervalPriceAttributes, + IntervalRatingAttributes, + LifecycleAttributes, + MetadataAttributes, + SensorAttributes, + TimingAttributes, + TrendAttributes, + VolatilityAttributes, + Window24hAttributes, +) if TYPE_CHECKING: from custom_components.tibber_prices.coordinator.core import ( @@ -34,6 +50,20 @@ from .volatility import add_volatility_type_attributes, get_prices_for_volatilit from .window_24h import add_average_price_attributes __all__ = [ + "DailyStatPriceAttributes", + "DailyStatRatingAttributes", + "FutureAttributes", + "IntervalLevelAttributes", + "IntervalPriceAttributes", + "IntervalRatingAttributes", + "LifecycleAttributes", + "MetadataAttributes", + # Type exports + "SensorAttributes", + "TimingAttributes", + "TrendAttributes", + "VolatilityAttributes", + "Window24hAttributes", "add_volatility_type_attributes", "build_extra_state_attributes", "build_sensor_attributes", @@ -47,7 +77,7 @@ def build_sensor_attributes( coordinator: TibberPricesDataUpdateCoordinator, native_value: Any, cached_data: dict, -) -> dict | None: +) -> dict[str, Any] | None: """ Build attributes for a sensor based on its key. @@ -175,7 +205,7 @@ def build_extra_state_attributes( # noqa: PLR0913 *, config_entry: TibberPricesConfigEntry, coordinator_data: dict, - sensor_attrs: dict | None = None, + sensor_attrs: dict[str, Any] | None = None, time: TibberPricesTimeService, ) -> dict[str, Any] | None: """ diff --git a/custom_components/tibber_prices/sensor/calculators/base.py b/custom_components/tibber_prices/sensor/calculators/base.py index d2e3038..c5a170f 100644 --- a/custom_components/tibber_prices/sensor/calculators/base.py +++ b/custom_components/tibber_prices/sensor/calculators/base.py @@ -69,3 +69,126 @@ class TibberPricesBaseCalculator: def currency(self) -> str: """Get currency code from price info.""" return self.price_info.get("currency", "EUR") + + # Smart data access methods with built-in None-safety + + def get_intervals(self, day: str) -> list[dict]: + """ + Get price intervals for a specific day with None-safety. + + Args: + day: Day key ("yesterday", "today", "tomorrow"). + + Returns: + List of interval dictionaries, empty list if unavailable. + + """ + if not self.coordinator_data: + return [] + return self.price_info.get(day, []) + + @property + def intervals_today(self) -> list[dict]: + """Get today's intervals with None-safety.""" + return self.get_intervals("today") + + @property + def intervals_tomorrow(self) -> list[dict]: + """Get tomorrow's intervals with None-safety.""" + return self.get_intervals("tomorrow") + + @property + def intervals_yesterday(self) -> list[dict]: + """Get yesterday's intervals with None-safety.""" + return self.get_intervals("yesterday") + + def get_all_intervals(self) -> list[dict]: + """ + Get all available intervals (yesterday + today + tomorrow). + + Returns: + Combined list of all interval dictionaries. + + """ + return [ + *self.intervals_yesterday, + *self.intervals_today, + *self.intervals_tomorrow, + ] + + def find_interval_at_offset(self, offset: int) -> dict | None: + """ + Find interval at given offset from current time with bounds checking. + + Args: + offset: Offset from current interval (0=current, 1=next, -1=previous). + + Returns: + Interval dictionary or None if out of bounds or unavailable. + + """ + if not self.coordinator_data: + return None + + from custom_components.tibber_prices.utils.price import ( # noqa: PLC0415 - avoid circular import + find_price_data_for_interval, + ) + + time = self.coordinator.time + target_time = time.get_interval_offset_time(offset) + return find_price_data_for_interval(self.price_info, target_time, time=time) + + def safe_get_from_interval( + self, + interval: dict[str, Any], + key: str, + default: Any = None, + ) -> Any: + """ + Safely get a value from an interval dictionary. + + Args: + interval: Interval dictionary. + key: Key to retrieve. + default: Default value if key not found. + + Returns: + Value from interval or default. + + """ + return interval.get(key, default) if interval else default + + def has_data(self) -> bool: + """ + Check if coordinator has any data available. + + Returns: + True if data is available, False otherwise. + + """ + return bool(self.coordinator_data) + + def has_price_info(self) -> bool: + """ + Check if price info is available in coordinator data. + + Returns: + True if price info exists, False otherwise. + + """ + return bool(self.price_info) + + def get_day_intervals(self, day: str) -> list[dict]: + """ + Get intervals for a specific day from coordinator data. + + This is an alias for get_intervals() with consistent naming. + + Args: + day: Day key ("yesterday", "today", "tomorrow"). + + Returns: + List of interval dictionaries, empty list if unavailable. + + """ + return self.get_intervals(day) diff --git a/custom_components/tibber_prices/sensor/calculators/daily_stat.py b/custom_components/tibber_prices/sensor/calculators/daily_stat.py index c8be508..1f14288 100644 --- a/custom_components/tibber_prices/sensor/calculators/daily_stat.py +++ b/custom_components/tibber_prices/sensor/calculators/daily_stat.py @@ -65,11 +65,9 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator): Price value in minor currency units (cents/øre), or None if unavailable. """ - if not self.coordinator_data: + if not self.has_data(): return None - price_info = self.price_info - # Get local midnight boundaries based on the requested day using TimeService time = self.coordinator.time local_midnight, local_midnight_next_day = time.get_day_boundaries(day) @@ -78,7 +76,7 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator): # that fall within the target day's local date boundaries price_intervals = [] for day_key in ["today", "tomorrow"]: - for price_data in price_info.get(day_key, []): + for price_data in self.get_intervals(day_key): starts_at = price_data.get("startsAt") # Already datetime in local timezone if not starts_at: continue @@ -131,11 +129,9 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator): Aggregated level/rating value (lowercase), or None if unavailable. """ - if not self.coordinator_data: + if not self.has_data(): return None - price_info = self.price_info - # Get local midnight boundaries based on the requested day using TimeService time = self.coordinator.time local_midnight, local_midnight_next_day = time.get_day_boundaries(day) @@ -144,7 +140,7 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator): # that fall within the target day's local date boundaries day_intervals = [] for day_key in ["yesterday", "today", "tomorrow"]: - for price_data in price_info.get(day_key, []): + for price_data in self.get_intervals(day_key): starts_at = price_data.get("startsAt") # Already datetime in local timezone if not starts_at: continue diff --git a/custom_components/tibber_prices/sensor/calculators/interval.py b/custom_components/tibber_prices/sensor/calculators/interval.py index ac53e48..15491ad 100644 --- a/custom_components/tibber_prices/sensor/calculators/interval.py +++ b/custom_components/tibber_prices/sensor/calculators/interval.py @@ -4,8 +4,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from custom_components.tibber_prices.utils.price import find_price_data_for_interval - from .base import TibberPricesBaseCalculator if TYPE_CHECKING: @@ -57,32 +55,27 @@ class TibberPricesIntervalCalculator(TibberPricesBaseCalculator): None if data unavailable. """ - if not self.coordinator_data: + if not self.has_data(): return None - price_info = self.price_info - time = self.coordinator.time - # Use TimeService to get interval offset time - target_time = time.get_interval_offset_time(interval_offset) - - interval_data = find_price_data_for_interval(price_info, target_time, time=time) + interval_data = self.find_interval_at_offset(interval_offset) if not interval_data: return None # Extract value based on type if value_type == "price": - price = interval_data.get("total") + price = self.safe_get_from_interval(interval_data, "total") if price is None: return None price = float(price) return price if in_euro else round(price * 100, 2) if value_type == "level": - level = interval_data.get("level") + level = self.safe_get_from_interval(interval_data, "level") return level.lower() if level else None # For rating: extract rating_level - rating = interval_data.get("rating_level") + rating = self.safe_get_from_interval(interval_data, "rating_level") return rating.lower() if rating else None def get_price_level_value(self) -> str | None: @@ -117,19 +110,16 @@ class TibberPricesIntervalCalculator(TibberPricesBaseCalculator): Rating level (lowercase), or None if unavailable. """ - if not self.coordinator_data or rating_type != "current": + if not self.has_data() or rating_type != "current": self._last_rating_difference = None self._last_rating_level = None return None - time = self.coordinator.time - now = time.now() - price_info = self.price_info - current_interval = find_price_data_for_interval(price_info, now, time=time) + current_interval = self.find_interval_at_offset(0) if current_interval: - rating_level = current_interval.get("rating_level") - difference = current_interval.get("difference") + rating_level = self.safe_get_from_interval(current_interval, "rating_level") + difference = self.safe_get_from_interval(current_interval, "difference") if rating_level is not None: self._last_rating_difference = float(difference) if difference is not None else None self._last_rating_level = rating_level diff --git a/custom_components/tibber_prices/sensor/calculators/rolling_hour.py b/custom_components/tibber_prices/sensor/calculators/rolling_hour.py index 1f39201..f3715c7 100644 --- a/custom_components/tibber_prices/sensor/calculators/rolling_hour.py +++ b/custom_components/tibber_prices/sensor/calculators/rolling_hour.py @@ -48,12 +48,11 @@ class TibberPricesRollingHourCalculator(TibberPricesBaseCalculator): - "rating": str (aggregated rating: "low", "normal", "high") """ - if not self.coordinator_data: + if not self.has_data(): return None # Get all available price data - price_info = self.price_info - all_prices = price_info.get("yesterday", []) + price_info.get("today", []) + price_info.get("tomorrow", []) + all_prices = self.get_all_intervals() if not all_prices: return None diff --git a/custom_components/tibber_prices/sensor/calculators/volatility.py b/custom_components/tibber_prices/sensor/calculators/volatility.py index 3c5989e..89f3ae0 100644 --- a/custom_components/tibber_prices/sensor/calculators/volatility.py +++ b/custom_components/tibber_prices/sensor/calculators/volatility.py @@ -51,11 +51,9 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator): Volatility level: "low", "moderate", "high", "very_high", or None if unavailable. """ - if not self.coordinator_data: + if not self.has_data(): return None - price_info = self.price_info - # Get volatility thresholds from config thresholds = { "threshold_moderate": self.config.get("volatility_threshold_moderate", 5.0), @@ -64,7 +62,7 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator): } # Get prices based on volatility type - prices_to_analyze = get_prices_for_volatility(volatility_type, price_info, time=self.coordinator.time) + prices_to_analyze = get_prices_for_volatility(volatility_type, self.price_info, time=self.coordinator.time) if not prices_to_analyze: return None @@ -96,7 +94,7 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator): # Add type-specific attributes add_volatility_type_attributes( - self._last_volatility_attributes, volatility_type, price_info, thresholds, time=self.coordinator.time + self._last_volatility_attributes, volatility_type, self.price_info, thresholds, time=self.coordinator.time ) # Return lowercase for ENUM device class diff --git a/custom_components/tibber_prices/sensor/calculators/window_24h.py b/custom_components/tibber_prices/sensor/calculators/window_24h.py index 1d2f439..1c0c08c 100644 --- a/custom_components/tibber_prices/sensor/calculators/window_24h.py +++ b/custom_components/tibber_prices/sensor/calculators/window_24h.py @@ -39,7 +39,7 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator): Price value in minor currency units (cents/øre), or None if unavailable. """ - if not self.coordinator_data: + if not self.has_data(): return None value = stat_func(self.coordinator_data, time=self.coordinator.time) diff --git a/custom_components/tibber_prices/sensor/types.py b/custom_components/tibber_prices/sensor/types.py new file mode 100644 index 0000000..f4a1dcc --- /dev/null +++ b/custom_components/tibber_prices/sensor/types.py @@ -0,0 +1,261 @@ +""" +Type definitions for Tibber Prices sensor attributes. + +These TypedDict definitions serve as **documentation** of the attribute structure +for each sensor type. They enable IDE autocomplete and type checking when working +with attribute dictionaries. + +NOTE: In function signatures, we still use dict[str, Any] for flexibility, +but these TypedDict definitions document what keys and types are expected. + +IMPORTANT: The Literal types defined here should be kept in sync with the +string constants in const.py, which are the single source of truth for runtime values. +""" + +from __future__ import annotations + +from typing import Literal, TypedDict + +# ============================================================================ +# Literal Type Definitions +# ============================================================================ +# SYNC: Keep these in sync with constants in const.py +# +# const.py defines the runtime constants (single source of truth): +# - PRICE_LEVEL_VERY_CHEAP, PRICE_LEVEL_CHEAP, etc. +# - PRICE_RATING_LOW, PRICE_RATING_NORMAL, etc. +# - VOLATILITY_LOW, VOLATILITY_MODERATE, etc. +# +# These Literal types should mirror those constants for type safety. + +# Price level literals (from Tibber API) +PriceLevel = Literal[ + "VERY_CHEAP", + "CHEAP", + "NORMAL", + "EXPENSIVE", + "VERY_EXPENSIVE", +] + +# Price rating literals (calculated values) +PriceRating = Literal[ + "LOW", + "NORMAL", + "HIGH", +] + +# Volatility level literals (based on coefficient of variation) +VolatilityLevel = Literal[ + "LOW", + "MODERATE", + "HIGH", + "VERY_HIGH", +] + +# Data completeness literals +DataCompleteness = Literal[ + "complete", + "partial_yesterday", + "partial_today", + "partial_tomorrow", + "missing_yesterday", + "missing_today", + "missing_tomorrow", +] + + +# ============================================================================ +# TypedDict Definitions +# ============================================================================ + + +class BaseAttributes(TypedDict, total=False): + """ + Base attributes common to all sensors. + + All sensor attributes include at minimum: + - timestamp: ISO 8601 string indicating when the state/attributes are valid + - error: Optional error message if something went wrong + """ + + timestamp: str + error: str + + +class IntervalPriceAttributes(BaseAttributes, total=False): + """ + Attributes for interval price sensors (current/next/previous). + + These sensors show price information for a specific 15-minute interval. + """ + + level_value: int # Numeric value for price level (1-5) + level_id: PriceLevel # String identifier for price level + icon_color: str # Optional icon color based on level + + +class IntervalLevelAttributes(BaseAttributes, total=False): + """ + Attributes for interval level sensors. + + These sensors show the price level classification for an interval. + """ + + icon_color: str # Icon color based on level + + +class IntervalRatingAttributes(BaseAttributes, total=False): + """ + Attributes for interval rating sensors. + + These sensors show the price rating (LOW/NORMAL/HIGH) for an interval. + """ + + rating_value: int # Numeric value for price rating (1-3) + rating_id: PriceRating # String identifier for price rating + icon_color: str # Optional icon color based on rating + + +class RollingHourAttributes(BaseAttributes, total=False): + """ + Attributes for rolling hour sensors. + + These sensors aggregate data across 5 intervals (2 before + current + 2 after). + """ + + icon_color: str # Optional icon color based on aggregated level + + +class DailyStatPriceAttributes(BaseAttributes, total=False): + """ + Attributes for daily statistics price sensors (min/max/avg). + + These sensors show price statistics for a full calendar day. + """ + + # No additional attributes for daily price stats beyond base + + +class DailyStatRatingAttributes(BaseAttributes, total=False): + """ + Attributes for daily statistics rating sensors. + + These sensors show rating statistics for a full calendar day. + """ + + diff_percent: str # Key is actually "diff_%" - percentage difference + level_id: PriceRating # Rating level identifier + level_value: int # Numeric rating value (1-3) + + +class Window24hAttributes(BaseAttributes, total=False): + """ + Attributes for 24-hour window sensors (trailing/leading). + + These sensors analyze price data across a 24-hour window from current time. + """ + + interval_count: int # Number of intervals in the window + + +class VolatilityAttributes(BaseAttributes, total=False): + """ + Attributes for volatility sensors. + + These sensors analyze price variation and spread across time periods. + """ + + today_spread: float # Price range for today (max - min) + today_volatility: str # Volatility level for today + interval_count_today: int # Number of intervals analyzed today + tomorrow_spread: float # Price range for tomorrow (max - min) + tomorrow_volatility: str # Volatility level for tomorrow + interval_count_tomorrow: int # Number of intervals analyzed tomorrow + + +class TrendAttributes(BaseAttributes, total=False): + """ + Attributes for trend sensors. + + These sensors analyze price trends and forecast future movements. + Trend attributes are complex and may vary based on trend type. + """ + + # Trend attributes are dynamic and vary by sensor type + # Keep flexible with total=False + + +class TimingAttributes(BaseAttributes, total=False): + """ + Attributes for period timing sensors (best_price/peak_price timing). + + These sensors track timing information for best/peak price periods. + """ + + icon_color: str # Icon color based on timing status + + +class FutureAttributes(BaseAttributes, total=False): + """ + Attributes for future forecast sensors. + + These sensors provide N-hour forecasts starting from next interval. + """ + + interval_count: int # Number of intervals in forecast + hours: int # Number of hours in forecast window + + +class LifecycleAttributes(BaseAttributes, total=False): + """ + Attributes for lifecycle/diagnostic sensors. + + These sensors provide system information and cache status. + """ + + cache_age: str # Human-readable cache age + cache_age_minutes: int # Cache age in minutes + cache_validity: str # Cache validity status + last_api_fetch: str # ISO 8601 timestamp of last API fetch + last_cache_update: str # ISO 8601 timestamp of last cache update + data_completeness: DataCompleteness # Data completeness status + yesterday_available: bool # Whether yesterday data exists + today_available: bool # Whether today data exists + tomorrow_available: bool # Whether tomorrow data exists + tomorrow_expected_after: str # Time when tomorrow data expected + next_api_poll: str # ISO 8601 timestamp of next API poll + next_midnight_turnover: str # ISO 8601 timestamp of next midnight turnover + updates_today: int # Number of API updates today + last_turnover: str # ISO 8601 timestamp of last midnight turnover + last_error: str # Last error message if any + + +class MetadataAttributes(BaseAttributes, total=False): + """ + Attributes for metadata sensors (home info, metering point). + + These sensors provide Tibber account and home metadata. + Metadata attributes vary by sensor type. + """ + + # Metadata attributes are dynamic and vary by sensor type + # Keep flexible with total=False + + +# Union type for all sensor attributes (for documentation purposes) +# In actual code, use dict[str, Any] for flexibility +SensorAttributes = ( + IntervalPriceAttributes + | IntervalLevelAttributes + | IntervalRatingAttributes + | RollingHourAttributes + | DailyStatPriceAttributes + | DailyStatRatingAttributes + | Window24hAttributes + | VolatilityAttributes + | TrendAttributes + | TimingAttributes + | FutureAttributes + | LifecycleAttributes + | MetadataAttributes +)