From 8c61292acff7c47d35fd6714855b1258e1937a8b Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Sun, 2 Nov 2025 19:33:19 +0000 Subject: [PATCH] refactoring for QUARTER_HOURLY prices --- custom_components/tibber_prices/api.py | 132 ++++++++++-------- .../tibber_prices/binary_sensor.py | 19 ++- .../tibber_prices/config_flow.py | 68 ++++----- custom_components/tibber_prices/const.py | 6 +- .../tibber_prices/coordinator.py | 18 ++- custom_components/tibber_prices/services.py | 2 +- .../tibber_prices/translations/de.json | 8 +- .../tibber_prices/translations/en.json | 8 +- 8 files changed, 161 insertions(+), 100 deletions(-) diff --git a/custom_components/tibber_prices/api.py b/custom_components/tibber_prices/api.py index 0c3b3db..1cb7d5b 100644 --- a/custom_components/tibber_prices/api.py +++ b/custom_components/tibber_prices/api.py @@ -182,6 +182,7 @@ def _is_data_empty(data: dict, query_type: str) -> bool: elif query_type == "price_info": # Check for homes existence and non-emptiness before accessing + subscription = None if ( "viewer" not in data or "homes" not in data["viewer"] @@ -189,29 +190,34 @@ def _is_data_empty(data: dict, query_type: str) -> bool: or len(data["viewer"]["homes"]) == 0 or "currentSubscription" not in data["viewer"]["homes"][0] or data["viewer"]["homes"][0]["currentSubscription"] is None - or "priceInfo" not in data["viewer"]["homes"][0]["currentSubscription"] ): - _LOGGER.debug("Missing homes/currentSubscription/priceInfo in price_info check") + _LOGGER.debug("Missing homes/currentSubscription in price_info check") is_empty = True else: - price_info = data["viewer"]["homes"][0]["currentSubscription"]["priceInfo"] + subscription = data["viewer"]["homes"][0]["currentSubscription"] - # Check historical data (either range or yesterday) + # Check priceInfoRange (192 quarter-hourly intervals) has_historical = ( - "range" in price_info - and price_info["range"] is not None - and "edges" in price_info["range"] - and price_info["range"]["edges"] + "priceInfoRange" in subscription + and subscription["priceInfoRange"] is not None + and "edges" in subscription["priceInfoRange"] + and subscription["priceInfoRange"]["edges"] ) - # Check today's data - has_today = "today" in price_info and price_info["today"] is not None and len(price_info["today"]) > 0 + # Check priceInfo for today's data + has_price_info = "priceInfo" in subscription and subscription["priceInfo"] is not None + has_today = ( + has_price_info + and "today" in subscription["priceInfo"] + and subscription["priceInfo"]["today"] is not None + and len(subscription["priceInfo"]["today"]) > 0 + ) # Data is empty if we don't have historical data or today's data is_empty = not has_historical or not has_today _LOGGER.debug( - "Price info check - historical data historical: %s, today: %s, is_empty: %s", + "Price info check - priceInfoRange: %s, today: %s, is_empty: %s", bool(has_historical), bool(has_today), is_empty, @@ -233,34 +239,22 @@ def _is_data_empty(data: dict, query_type: str) -> bool: else: rating = data["viewer"]["homes"][0]["currentSubscription"]["priceRating"] - # Check threshold percentages - has_thresholds = ( - "thresholdPercentages" in rating - and rating["thresholdPercentages"] is not None - and "low" in rating["thresholdPercentages"] - and "high" in rating["thresholdPercentages"] + # Check rating entries + has_entries = ( + query_type in rating + and rating[query_type] is not None + and "entries" in rating[query_type] + and rating[query_type]["entries"] is not None + and len(rating[query_type]["entries"]) > 0 ) - if not has_thresholds: - _LOGGER.debug("Missing or invalid threshold percentages for %s rating", query_type) - is_empty = True - else: - # Check rating entries - has_entries = ( - query_type in rating - and rating[query_type] is not None - and "entries" in rating[query_type] - and rating[query_type]["entries"] is not None - and len(rating[query_type]["entries"]) > 0 - ) - is_empty = not has_entries - _LOGGER.debug( - "%s rating check - has_thresholds: %s, entries count: %d, is_empty: %s", - query_type, - has_thresholds, - len(rating[query_type]["entries"]) if has_entries else 0, - is_empty, - ) + is_empty = not has_entries + _LOGGER.debug( + "%s rating check - entries count: %d, is_empty: %s", + query_type, + len(rating[query_type]["entries"]) if has_entries else 0, + is_empty, + ) else: _LOGGER.debug("Unknown query type %s, treating as non-empty", query_type) is_empty = False @@ -280,19 +274,25 @@ def _prepare_headers(access_token: str) -> dict[str, str]: } -def _flatten_price_info(subscription: dict) -> dict: - """Transform and flatten priceInfo from full API data structure.""" +def _flatten_price_info(subscription: dict, currency: str | None = None) -> dict: + """ + Transform and flatten priceInfo from full API data structure. + + Now handles priceInfoRange (192 quarter-hourly intervals) separately from + priceInfo (today and tomorrow data). Currency is stored as a separate attribute. + """ price_info = subscription.get("priceInfo", {}) + price_info_range = subscription.get("priceInfoRange", {}) # Get today and yesterday dates using Home Assistant's dt_util today_local = dt_util.now().date() yesterday_local = today_local - timedelta(days=1) _LOGGER.debug("Processing data for yesterday's date: %s", yesterday_local) - # Transform edges data (extract yesterday's prices) - if "range" in price_info and "edges" in price_info["range"]: - edges = price_info["range"]["edges"] - yesterday_prices = [] + # Transform priceInfoRange edges data (extract yesterday's quarter-hourly prices) + yesterday_prices = [] + if "edges" in price_info_range: + edges = price_info_range["edges"] for edge in edges: if "node" not in edge: @@ -315,14 +315,12 @@ def _flatten_price_info(subscription: dict) -> dict: yesterday_prices.append(price_data) _LOGGER.debug("Found %d price entries for yesterday", len(yesterday_prices)) - # Replace the entire range object with yesterday prices - price_info["yesterday"] = yesterday_prices - del price_info["range"] return { - "yesterday": price_info.get("yesterday", []), + "yesterday": yesterday_prices, "today": price_info.get("today", []), "tomorrow": price_info.get("tomorrow", []), + "currency": currency, } @@ -344,7 +342,6 @@ def _flatten_price_rating(subscription: dict) -> dict: "hourly": hourly_entries, "daily": daily_entries, "monthly": monthly_entries, - "thresholdPercentages": price_rating.get("thresholdPercentages"), "currency": currency, } @@ -404,13 +401,23 @@ class TibberPricesApiClient: data = await self._api_wrapper( data={ "query": """ - {viewer{homes{id,currentSubscription{priceInfo{ - range(resolution:HOURLY,last:48){edges{node{ - startsAt total energy tax level - }}} - today{startsAt total energy tax level} - tomorrow{startsAt total energy tax level} - }}}}}""" + {viewer{homes{ + id + consumption(resolution:DAILY,last:1){ + pageInfo{currency} + } + currentSubscription{ + priceInfoRange(resolution:QUARTER_HOURLY,last:192){ + edges{node{ + startsAt total energy tax level + }} + } + priceInfo(resolution:QUARTER_HOURLY){ + today{startsAt total energy tax level} + tomorrow{startsAt total energy tax level} + } + } + }}}""" }, query_type=QueryType.PRICE_INFO, ) @@ -421,7 +428,17 @@ class TibberPricesApiClient: home_id = home.get("id") if home_id: if "currentSubscription" in home: - homes_data[home_id] = _flatten_price_info(home["currentSubscription"]) + # Extract currency from consumption data if available + currency = None + if home.get("consumption"): + page_info = home["consumption"].get("pageInfo") + if page_info: + currency = page_info.get("currency") + + homes_data[home_id] = _flatten_price_info( + home["currentSubscription"], + currency, + ) else: homes_data[home_id] = {} @@ -434,7 +451,6 @@ class TibberPricesApiClient: data={ "query": """ {viewer{homes{id,currentSubscription{priceRating{ - thresholdPercentages{low high} daily{ currency entries{time total energy tax difference level} @@ -463,7 +479,6 @@ class TibberPricesApiClient: data={ "query": """ {viewer{homes{id,currentSubscription{priceRating{ - thresholdPercentages{low high} hourly{ currency entries{time total energy tax difference level} @@ -492,7 +507,6 @@ class TibberPricesApiClient: data={ "query": """ {viewer{homes{id,currentSubscription{priceRating{ - thresholdPercentages{low high} monthly{ currency entries{time total energy tax difference level} diff --git a/custom_components/tibber_prices/binary_sensor.py b/custom_components/tibber_prices/binary_sensor.py index c9319d8..f59ab5d 100644 --- a/custom_components/tibber_prices/binary_sensor.py +++ b/custom_components/tibber_prices/binary_sensor.py @@ -112,7 +112,21 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): return None def _get_flex_option(self, option_key: str, default: float) -> float: - """Get a float option from config entry options or fallback to default. Accepts 0-100.""" + """ + Get a float option from config entry. + + Converts percentage values to decimal fractions. + - CONF_BEST_PRICE_FLEX: positive 0-100 → 0.0-1.0 + - CONF_PEAK_PRICE_FLEX: negative -100 to 0 → -1.0 to 0.0 + + Args: + option_key: The config key (CONF_BEST_PRICE_FLEX or CONF_PEAK_PRICE_FLEX) + default: Default value to use if not found + + Returns: + Value converted to decimal fraction (e.g., 5 → 0.05, -5 → -0.05) + + """ options = self.coordinator.config_entry.options data = self.coordinator.config_entry.data value = options.get(option_key, data.get(option_key, default)) @@ -407,7 +421,8 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): price = float(price_data["total"]) percent_diff = ((price - ref_price) / ref_price) * 100 if ref_price != 0 else 0.0 percent_diff = round(percent_diff, 2) - # For best price: percent_diff <= flex*100; for peak: percent_diff >= -flex*100 + # For best price (flex >= 0): percent_diff <= flex*100 (prices up to flex% above reference) + # For peak price (flex <= 0): percent_diff >= -flex*100 (prices up to |flex|% above reference) in_flex = percent_diff <= flex * 100 if not reverse_sort else percent_diff >= -flex * 100 # Split period if day or interval length changes if ( diff --git a/custom_components/tibber_prices/config_flow.py b/custom_components/tibber_prices/config_flow.py index f12a6f6..2a3be55 100644 --- a/custom_components/tibber_prices/config_flow.py +++ b/custom_components/tibber_prices/config_flow.py @@ -41,9 +41,13 @@ from .const import ( CONF_BEST_PRICE_FLEX, CONF_EXTENDED_DESCRIPTIONS, CONF_PEAK_PRICE_FLEX, + CONF_PRICE_RATING_THRESHOLD_HIGH, + CONF_PRICE_RATING_THRESHOLD_LOW, DEFAULT_BEST_PRICE_FLEX, DEFAULT_EXTENDED_DESCRIPTIONS, DEFAULT_PEAK_PRICE_FLEX, + DEFAULT_PRICE_RATING_THRESHOLD_HIGH, + DEFAULT_PRICE_RATING_THRESHOLD_LOW, DOMAIN, LOGGER, ) @@ -380,38 +384,6 @@ class TibberPricesSubentryFlowHandler(ConfigSubentryFlow): CONF_EXTENDED_DESCRIPTIONS, default=subentry.data.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS), ): BooleanSelector(), - vol.Optional( - CONF_BEST_PRICE_FLEX, - default=int( - subentry.data.get( - CONF_BEST_PRICE_FLEX, - DEFAULT_BEST_PRICE_FLEX, - ) - ), - ): NumberSelector( - NumberSelectorConfig( - min=0, - max=100, - step=1, - mode=NumberSelectorMode.SLIDER, - ), - ), - vol.Optional( - CONF_PEAK_PRICE_FLEX, - default=int( - subentry.data.get( - CONF_PEAK_PRICE_FLEX, - DEFAULT_PEAK_PRICE_FLEX, - ) - ), - ): NumberSelector( - NumberSelectorConfig( - min=0, - max=100, - step=1, - mode=NumberSelectorMode.SLIDER, - ), - ), } if user_input is not None: @@ -473,6 +445,38 @@ class TibberPricesOptionsFlowHandler(OptionsFlow): DEFAULT_PEAK_PRICE_FLEX, ) ), + ): NumberSelector( + NumberSelectorConfig( + min=-100, + max=0, + step=1, + mode=NumberSelectorMode.SLIDER, + ), + ), + vol.Optional( + CONF_PRICE_RATING_THRESHOLD_LOW, + default=int( + self.config_entry.options.get( + CONF_PRICE_RATING_THRESHOLD_LOW, + DEFAULT_PRICE_RATING_THRESHOLD_LOW, + ) + ), + ): NumberSelector( + NumberSelectorConfig( + min=-100, + max=0, + step=1, + mode=NumberSelectorMode.SLIDER, + ), + ), + vol.Optional( + CONF_PRICE_RATING_THRESHOLD_HIGH, + default=int( + self.config_entry.options.get( + CONF_PRICE_RATING_THRESHOLD_HIGH, + DEFAULT_PRICE_RATING_THRESHOLD_HIGH, + ) + ), ): NumberSelector( NumberSelectorConfig( min=0, diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index a8148c1..f75d871 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -17,6 +17,8 @@ DOMAIN = "tibber_prices" CONF_EXTENDED_DESCRIPTIONS = "extended_descriptions" CONF_BEST_PRICE_FLEX = "best_price_flex" CONF_PEAK_PRICE_FLEX = "peak_price_flex" +CONF_PRICE_RATING_THRESHOLD_LOW = "price_rating_threshold_low" +CONF_PRICE_RATING_THRESHOLD_HIGH = "price_rating_threshold_high" ATTRIBUTION = "Data provided by Tibber" @@ -24,7 +26,9 @@ ATTRIBUTION = "Data provided by Tibber" DEFAULT_NAME = "Tibber Price Information & Ratings" DEFAULT_EXTENDED_DESCRIPTIONS = False DEFAULT_BEST_PRICE_FLEX = 5 # 5% flexibility for best price (user-facing, percent) -DEFAULT_PEAK_PRICE_FLEX = 5 # 5% flexibility for peak price (user-facing, percent) +DEFAULT_PEAK_PRICE_FLEX = -5 # 5% flexibility for peak price (user-facing, percent) +DEFAULT_PRICE_RATING_THRESHOLD_LOW = -10 # Default rating threshold low percentage +DEFAULT_PRICE_RATING_THRESHOLD_HIGH = 10 # Default rating threshold high percentage # Home types HOME_TYPE_APARTMENT = "APARTMENT" diff --git a/custom_components/tibber_prices/coordinator.py b/custom_components/tibber_prices/coordinator.py index d4d9444..49ad2b1 100644 --- a/custom_components/tibber_prices/coordinator.py +++ b/custom_components/tibber_prices/coordinator.py @@ -23,7 +23,13 @@ from .api import ( TibberPricesApiClientCommunicationError, TibberPricesApiClientError, ) -from .const import DOMAIN +from .const import ( + CONF_PRICE_RATING_THRESHOLD_HIGH, + CONF_PRICE_RATING_THRESHOLD_LOW, + DEFAULT_PRICE_RATING_THRESHOLD_HIGH, + DEFAULT_PRICE_RATING_THRESHOLD_LOW, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @@ -268,6 +274,14 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): return {} return self._transform_data_for_main_entry(self._cached_price_data) + def _get_threshold_percentages(self) -> dict[str, int]: + """Get threshold percentages from config options.""" + options = self.config_entry.options or {} + return { + "low": options.get(CONF_PRICE_RATING_THRESHOLD_LOW, DEFAULT_PRICE_RATING_THRESHOLD_LOW), + "high": options.get(CONF_PRICE_RATING_THRESHOLD_HIGH, DEFAULT_PRICE_RATING_THRESHOLD_HIGH), + } + def _transform_data_for_main_entry(self, raw_data: dict[str, Any]) -> dict[str, Any]: """Transform raw data for main entry (aggregated view of all homes).""" # For main entry, we can show data from the first home as default @@ -290,6 +304,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): "hourly": {"entries": first_home_data.get("hourly_rating", [])}, "daily": {"entries": first_home_data.get("daily_rating", [])}, "monthly": {"entries": first_home_data.get("monthly_rating", [])}, + "thresholdPercentages": self._get_threshold_percentages(), } return { @@ -322,6 +337,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): "hourly": {"entries": home_data.get("hourly_rating", [])}, "daily": {"entries": home_data.get("daily_rating", [])}, "monthly": {"entries": home_data.get("monthly_rating", [])}, + "thresholdPercentages": self._get_threshold_percentages(), } return { diff --git a/custom_components/tibber_prices/services.py b/custom_components/tibber_prices/services.py index 338a760..95948c8 100644 --- a/custom_components/tibber_prices/services.py +++ b/custom_components/tibber_prices/services.py @@ -367,7 +367,7 @@ def _extract_price_data(data: dict) -> tuple[dict, dict, list, Any, Any]: price_rating_data = data.get("priceRating") or {} hourly_ratings = price_rating_data.get("hourly") or [] rating_threshold_percentages = price_rating_data.get("thresholdPercentages") - currency = price_rating_data.get("currency") + currency = price_info_data.get("currency") return price_info_data, price_rating_data, hourly_ratings, rating_threshold_percentages, currency diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index 3b87cce..c6238b9 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -75,7 +75,9 @@ "access_token": "API-Zugriffstoken", "extended_descriptions": "Erweiterte Beschreibungen in Entitätsattributen anzeigen", "best_price_flex": "Flexibilität für Bestpreis (%)", - "peak_price_flex": "Flexibilität für Spitzenpreis (%)" + "peak_price_flex": "Flexibilität für Spitzenpreis (%)", + "price_rating_threshold_low": "Preisbewertungs-Schwellenwert Niedrig (% vs. Durchschnitt)", + "price_rating_threshold_high": "Preisbewertungs-Schwellenwert Hoch (% vs. Durchschnitt)" }, "title": "Optionen für Tibber Preisinformationen & Bewertungen", "submit": "Optionen speichern" @@ -93,7 +95,9 @@ "entry_not_found": "Tibber Konfigurationseintrag nicht gefunden." }, "best_price_flex": "Bestpreis Flexibilität (%)", - "peak_price_flex": "Spitzenpreis Flexibilität (%)" + "peak_price_flex": "Spitzenpreis Flexibilität (%)", + "price_rating_threshold_low": "Niedriger Preis Schwellenwert (% zum gleitenden Durchschnitt)", + "price_rating_threshold_high": "Hoher Preis Schwellenwert (% zum gleitenden Durchschnitt)" }, "entity": { "sensor": { diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index 43db2cd..35f5253 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -75,7 +75,9 @@ "access_token": "API access token", "extended_descriptions": "Show extended descriptions in entity attributes", "best_price_flex": "Best Price Flexibility (%)", - "peak_price_flex": "Peak Price Flexibility (%)" + "peak_price_flex": "Peak Price Flexibility (%)", + "price_rating_threshold_low": "Price Rating Threshold Low (% vs trailing average)", + "price_rating_threshold_high": "Price Rating Threshold High (% vs trailing average)" }, "title": "Options for Tibber Price Information & Ratings", "submit": "Save Options" @@ -93,7 +95,9 @@ "entry_not_found": "Tibber configuration entry not found." }, "best_price_flex": "Best Price Flexibility (%)", - "peak_price_flex": "Peak Price Flexibility (%)" + "peak_price_flex": "Peak Price Flexibility (%)", + "price_rating_threshold_low": "Price Low Threshold (% to trailing average)", + "price_rating_threshold_high": "Price High Threshold (% to trailing average)" }, "entity": { "sensor": {