From dd94351278897b612dea95820b2edbd791e02ca0 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Wed, 23 Apr 2025 16:42:31 +0000 Subject: [PATCH] refactoring --- .devcontainer/devcontainer.json | 142 ++-- custom_components/tibber_prices/api.py | 13 +- .../tibber_prices/binary_sensor.py | 120 ++-- .../tibber_prices/config_flow.py | 4 + .../tibber_prices/coordinator.py | 648 ++++++++++-------- custom_components/tibber_prices/data.py | 7 +- custom_components/tibber_prices/entity.py | 2 +- custom_components/tibber_prices/manifest.json | 1 - custom_components/tibber_prices/sensor.py | 442 +++++++----- .../tibber_prices/translations/de.json | 103 --- .../tibber_prices/translations/en.json | 180 +++-- hacs.json | 6 +- scripts/json_schemas/manifest_schema.json | 391 +++++++++++ scripts/json_schemas/translation_schema.json | 51 ++ 14 files changed, 1317 insertions(+), 793 deletions(-) delete mode 100644 custom_components/tibber_prices/translations/de.json create mode 100644 scripts/json_schemas/manifest_schema.json create mode 100644 scripts/json_schemas/translation_schema.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 11d462a..d05aa90 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,72 +1,74 @@ { - "name": "jpawlowski/hass.tibber_prices", - "image": "mcr.microsoft.com/devcontainers/python:3.13", - "postCreateCommand": "scripts/setup", - "containerEnv": { - "PYTHONASYNCIODEBUG": "1" - }, - "forwardPorts": [ - 8123 - ], - "portsAttributes": { - "8123": { - "label": "Home Assistant", - "onAutoForward": "notify" - } - }, - "customizations": { - "vscode": { - "extensions": [ - "charliermarsh.ruff", - "github.vscode-pull-request-github", - "ms-python.python", - "ms-python.pylint", - "ms-python.vscode-pylance", - "ryanluker.vscode-coverage-gutters", - "visualstudioexptteam.vscodeintellicode", - "redhat.vscode-yaml", - "esbenp.prettier-vscode", - "github.copilot", - "ms-vscode-remote.remote-containers" - ], - "settings": { - "editor.tabSize": 4, - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.eol": "\n", - "files.trimTrailingWhitespace": true, - "python.analysis.typeCheckingMode": "basic", - "python.analysis.autoImportCompletions": true, - "python.defaultInterpreterPath": "/usr/local/bin/python", - "pylint.importStrategy": "fromEnvironment", - "python.terminal.activateEnvInCurrentTerminal": true, - "python.testing.pytestArgs": [ - "--no-cov" - ], - "yaml.customTags": [ - "!input scalar", - "!secret scalar", - "!include_dir_named scalar", - "!include_dir_list scalar", - "!include_dir_merge_list scalar", - "!include_dir_merge_named scalar" - ], - "[python]": { - "editor.defaultFormatter": "charliermarsh.ruff" - } - } - } - }, - "remoteUser": "vscode", - "features": { - "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/devcontainers-extra/features/apt-packages:1": { - "packages": [ - "ffmpeg", - "libturbojpeg0", - "libpcap-dev" - ] - } + "name": "jpawlowski/hass.tibber_prices", + "image": "mcr.microsoft.com/devcontainers/python:3.13", + "postCreateCommand": "scripts/setup", + "containerEnv": { + "PYTHONASYNCIODEBUG": "1" + }, + "forwardPorts": [8123], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" } -} \ No newline at end of file + }, + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "github.vscode-pull-request-github", + "ms-python.python", + "ms-python.pylint", + "ms-python.vscode-pylance", + "ryanluker.vscode-coverage-gutters", + "visualstudioexptteam.vscodeintellicode", + "redhat.vscode-yaml", + "esbenp.prettier-vscode", + "github.copilot", + "ms-vscode-remote.remote-containers" + ], + "settings": { + "editor.tabSize": 4, + "editor.formatOnPaste": false, + "editor.formatOnSave": false, + "editor.formatOnType": false, + "files.eol": "\n", + "files.trimTrailingWhitespace": true, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.autoImportCompletions": true, + "python.defaultInterpreterPath": "/usr/local/bin/python", + "pylint.importStrategy": "fromEnvironment", + "python.terminal.activateEnvInCurrentTerminal": true, + "python.testing.pytestArgs": ["--no-cov"], + "yaml.customTags": [ + "!input scalar", + "!secret scalar", + "!include_dir_named scalar", + "!include_dir_list scalar", + "!include_dir_merge_list scalar", + "!include_dir_merge_named scalar" + ], + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "json.schemas": [ + { + "fileMatch": ["homeassistant/components/*/manifest.json"], + "url": "${containerWorkspaceFolder}/scripts/json_schemas/manifest_schema.json" + }, + { + "fileMatch": ["homeassistant/components/*/translations/*.json"], + "url": "${containerWorkspaceFolder}/scripts/json_schemas/translation_schema.json" + } + ] + } + } + }, + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers-extra/features/apt-packages:1": { + "packages": ["ffmpeg", "libturbojpeg0", "libpcap-dev"] + } + } +} diff --git a/custom_components/tibber_prices/api.py b/custom_components/tibber_prices/api.py index 9158b0d..af0bb46 100644 --- a/custom_components/tibber_prices/api.py +++ b/custom_components/tibber_prices/api.py @@ -108,7 +108,8 @@ async def _verify_graphql_response(response_json: dict) -> None: def _is_data_empty(data: dict, query_type: str) -> bool: - """Check if the response data is empty or incomplete. + """ + Check if the response data is empty or incomplete. For price info: - Must have either range/edges or yesterday data @@ -154,7 +155,8 @@ def _is_data_empty(data: dict, query_type: str) -> bool: is_empty = not has_historical or not has_today _LOGGER.debug( - "Price info check - historical data (range: %s, yesterday: %s), today: %s, is_empty: %s", + "Price info check - historical data " + "(range: %s, yesterday: %s), today: %s, is_empty: %s", bool(has_range), bool(has_yesterday), bool(has_today), @@ -173,7 +175,9 @@ def _is_data_empty(data: dict, query_type: str) -> bool: and "high" in rating["thresholdPercentages"] ) if not has_thresholds: - _LOGGER.debug("Missing or invalid threshold percentages for %s rating", query_type) + _LOGGER.debug( + "Missing or invalid threshold percentages for %s rating", query_type + ) return True # Check rating entries @@ -196,10 +200,11 @@ def _is_data_empty(data: dict, query_type: str) -> bool: return is_empty _LOGGER.debug("Unknown query type %s, treating as non-empty", query_type) - return False except (KeyError, IndexError, TypeError) as error: _LOGGER.debug("Error checking data emptiness: %s", error) return True + else: + return False def _prepare_headers(access_token: str) -> dict[str, str]: diff --git a/custom_components/tibber_prices/binary_sensor.py b/custom_components/tibber_prices/binary_sensor.py index 7c17a80..741e3a1 100644 --- a/custom_components/tibber_prices/binary_sensor.py +++ b/custom_components/tibber_prices/binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime from typing import TYPE_CHECKING from homeassistant.components.binary_sensor import ( @@ -12,7 +12,6 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory -from .const import NAME, DOMAIN from .entity import TibberPricesEntity if TYPE_CHECKING: @@ -46,7 +45,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, + _hass: HomeAssistant, entry: TibberPricesConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: @@ -71,59 +70,61 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): """Initialize the binary_sensor class.""" super().__init__(coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{entity_description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{entity_description.key}" + ) + + def _get_current_price_data(self) -> tuple[list[float], float] | None: + """Get current price data if available.""" + if not ( + self.coordinator.data + and ( + today_prices := self.coordinator.data["data"]["viewer"]["homes"][0][ + "currentSubscription" + ]["priceInfo"].get("today", []) + ) + ): + return None + + now = datetime.now(tz=UTC).astimezone() + current_hour_data = next( + ( + price_data + for price_data in today_prices + if datetime.fromisoformat(price_data["startsAt"]).hour == now.hour + ), + None, + ) + if not current_hour_data: + return None + + prices = [float(price["total"]) for price in today_prices] + prices.sort() + return prices, float(current_hour_data["total"]) @property def is_on(self) -> bool | None: """Return true if the binary_sensor is on.""" try: - if not self.coordinator.data: + price_data = self._get_current_price_data() + if not price_data: return None - subscription = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"] - price_info = subscription["priceInfo"] - - now = datetime.now() - current_hour_data = None - today_prices = price_info.get("today", []) - - if not today_prices: - return None - - # Find current hour's data - for price_data in today_prices: - starts_at = datetime.fromisoformat(price_data["startsAt"]) - if starts_at.hour == now.hour: - current_hour_data = price_data - break - - if not current_hour_data: - return None - - if self.entity_description.key == "peak_hour": - # Consider it a peak hour if the price is in the top 20% of today's prices - prices = [float(price["total"]) for price in today_prices] - prices.sort() - threshold_index = int(len(prices) * 0.8) - peak_threshold = prices[threshold_index] - return float(current_hour_data["total"]) >= peak_threshold - - elif self.entity_description.key == "best_price_hour": - # Consider it a best price hour if the price is in the bottom 20% of today's prices - prices = [float(price["total"]) for price in today_prices] - prices.sort() - threshold_index = int(len(prices) * 0.2) - best_threshold = prices[threshold_index] - return float(current_hour_data["total"]) <= best_threshold - - elif self.entity_description.key == "connection": - # Check if we have valid current data - return bool(current_hour_data) - - return None + prices, current_price = price_data + match self.entity_description.key: + case "peak_hour": + threshold_index = int(len(prices) * 0.8) + return current_price >= prices[threshold_index] + case "best_price_hour": + threshold_index = int(len(prices) * 0.2) + return current_price <= prices[threshold_index] + case "connection": + return True + case _: + return None except (KeyError, ValueError, TypeError) as ex: - self.coordinator.logger.error( + self.coordinator.logger.exception( "Error getting binary sensor state", extra={ "error": str(ex), @@ -139,20 +140,28 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): if not self.coordinator.data: return None - subscription = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"] + subscription = self.coordinator.data["data"]["viewer"]["homes"][0][ + "currentSubscription" + ] price_info = subscription["priceInfo"] - attributes = {} if self.entity_description.key in ["peak_hour", "best_price_hour"]: today_prices = price_info.get("today", []) if today_prices: - prices = [(datetime.fromisoformat(price["startsAt"]).hour, float(price["total"])) - for price in today_prices] + prices = [ + ( + datetime.fromisoformat(price["startsAt"]).hour, + float(price["total"]), + ) + for price in today_prices + ] if self.entity_description.key == "peak_hour": # Get top 5 peak hours - peak_hours = sorted(prices, key=lambda x: x[1], reverse=True)[:5] + peak_hours = sorted(prices, key=lambda x: x[1], reverse=True)[ + :5 + ] attributes["peak_hours"] = [ {"hour": hour, "price": price} for hour, price in peak_hours ] @@ -162,11 +171,12 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): attributes["best_price_hours"] = [ {"hour": hour, "price": price} for hour, price in best_hours ] - - return attributes if attributes else None + return attributes + else: + return None except (KeyError, ValueError, TypeError) as ex: - self.coordinator.logger.error( + self.coordinator.logger.exception( "Error getting binary sensor attributes", extra={ "error": str(ex), diff --git a/custom_components/tibber_prices/config_flow.py b/custom_components/tibber_prices/config_flow.py index 27c6020..57999c2 100644 --- a/custom_components/tibber_prices/config_flow.py +++ b/custom_components/tibber_prices/config_flow.py @@ -35,6 +35,10 @@ class TibberPricesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return TibberPricesOptionsFlowHandler(config_entry) + def is_matching(self, other_flow: dict) -> bool: + """Return True if match_dict matches this flow.""" + return bool(other_flow.get("domain") == DOMAIN) + async def async_step_user( self, user_input: dict | None = None, diff --git a/custom_components/tibber_prices/coordinator.py b/custom_components/tibber_prices/coordinator.py index 1d3fae7..50a3f8c 100644 --- a/custom_components/tibber_prices/coordinator.py +++ b/custom_components/tibber_prices/coordinator.py @@ -1,31 +1,28 @@ """Coordinator for fetching Tibber price data.""" + from __future__ import annotations -import asyncio -import random -from datetime import datetime, timedelta import logging +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, Final, TypedDict, cast +import homeassistant.util.dt as dt_util from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.event import async_track_time_change from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util from .api import ( - TibberPricesApiClient, - TibberPricesApiClientError, TibberPricesApiClientAuthenticationError, TibberPricesApiClientCommunicationError, + TibberPricesApiClientError, ) -from .const import ( - DOMAIN, - LOGGER, -) +from .const import DOMAIN, LOGGER if TYPE_CHECKING: + import asyncio + from .data import TibberPricesConfigEntry _LOGGER = logging.getLogger(__name__) @@ -36,6 +33,8 @@ RANDOM_DELAY_MAX_MINUTES: Final = 120 # Maximum random delay in minutes NO_DATA_ERROR_MSG: Final = "No data available" STORAGE_VERSION: Final = 1 UPDATE_INTERVAL: Final = timedelta(days=1) # Both price and rating data update daily +UPDATE_FAILED_MSG: Final = "Update failed" +AUTH_FAILED_MSG: Final = "Authentication failed" class TibberPricesPriceInfo(TypedDict): @@ -75,7 +74,9 @@ def _raise_no_data() -> None: @callback -def _get_latest_timestamp_from_prices(price_data: TibberPricesData | None) -> datetime | None: +def _get_latest_timestamp_from_prices( + price_data: TibberPricesData | None, +) -> datetime | None: """Get the latest timestamp from price data.""" if not price_data or "data" not in price_data: return None @@ -90,7 +91,9 @@ def _get_latest_timestamp_from_prices(price_data: TibberPricesData | None) -> da for price in today_prices: if starts_at := price.get("startsAt"): timestamp = dt_util.parse_datetime(starts_at) - if timestamp and (not latest_timestamp or timestamp > latest_timestamp): + if timestamp and ( + not latest_timestamp or timestamp > latest_timestamp + ): latest_timestamp = timestamp # Check tomorrow's prices @@ -98,17 +101,21 @@ def _get_latest_timestamp_from_prices(price_data: TibberPricesData | None) -> da for price in tomorrow_prices: if starts_at := price.get("startsAt"): timestamp = dt_util.parse_datetime(starts_at) - if timestamp and (not latest_timestamp or timestamp > latest_timestamp): + if timestamp and ( + not latest_timestamp or timestamp > latest_timestamp + ): latest_timestamp = timestamp - return latest_timestamp - except (KeyError, IndexError, TypeError): return None + else: + return latest_timestamp @callback -def _get_latest_timestamp_from_rating(rating_data: TibberPricesData | None) -> datetime | None: +def _get_latest_timestamp_from_rating( + rating_data: TibberPricesData | None, +) -> datetime | None: """Get the latest timestamp from rating data.""" if not rating_data or "data" not in rating_data: return None @@ -124,13 +131,14 @@ def _get_latest_timestamp_from_rating(rating_data: TibberPricesData | None) -> d for entry in rating_entries: if time := entry.get("time"): timestamp = dt_util.parse_datetime(time) - if timestamp and (not latest_timestamp or timestamp > latest_timestamp): + if timestamp and ( + not latest_timestamp or timestamp > latest_timestamp + ): latest_timestamp = timestamp - - return latest_timestamp - except (KeyError, IndexError, TypeError): return None + else: + return latest_timestamp # https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities @@ -181,14 +189,18 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) for listener in self._remove_update_listeners: listener() - async def _async_handle_midnight_rotation(self, _now: datetime | None = None) -> None: + async def _async_handle_midnight_rotation( + self, _now: datetime | None = None + ) -> None: """Handle data rotation at midnight.""" if not self._cached_price_data: return try: LOGGER.debug("Starting midnight data rotation") - subscription = self._cached_price_data["data"]["viewer"]["homes"][0]["currentSubscription"] + subscription = self._cached_price_data["data"]["viewer"]["homes"][0][ + "currentSubscription" + ] price_info = subscription["priceInfo"] # Move today's data to yesterday @@ -209,9 +221,39 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) # Trigger an update to refresh the entities await self.async_request_refresh() - except Exception as ex: + except (KeyError, TypeError, ValueError) as ex: LOGGER.error("Error during midnight data rotation: %s", ex) + @callback + def _recover_timestamp( + self, + data: TibberPricesData | None, + stored_timestamp: str | None, + rating_type: str | None = None, + ) -> datetime | None: + """Recover timestamp from data or stored value.""" + if stored_timestamp: + return dt_util.parse_datetime(stored_timestamp) + + if not data: + return None + + if rating_type: + timestamp = self._get_latest_timestamp_from_rating_type(data, rating_type) + else: + timestamp = _get_latest_timestamp_from_prices(data) + + if timestamp: + LOGGER.debug( + "Recovered %s timestamp from data: %s", + rating_type or "price", + timestamp, + ) + else: + return None + + return timestamp + async def _async_initialize(self) -> None: """Load stored data.""" stored = await self._store.async_load() @@ -219,71 +261,44 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) if stored: # Load cached data - self._cached_price_data = cast(TibberPricesData, stored.get("price_data")) - self._cached_rating_data_hourly = cast(TibberPricesData, stored.get("rating_data_hourly")) - self._cached_rating_data_daily = cast(TibberPricesData, stored.get("rating_data_daily")) - self._cached_rating_data_monthly = cast(TibberPricesData, stored.get("rating_data_monthly")) + self._cached_price_data = cast("TibberPricesData", stored.get("price_data")) + self._cached_rating_data_hourly = cast( + "TibberPricesData", stored.get("rating_data_hourly") + ) + self._cached_rating_data_daily = cast( + "TibberPricesData", stored.get("rating_data_daily") + ) + self._cached_rating_data_monthly = cast( + "TibberPricesData", stored.get("rating_data_monthly") + ) - # Get timestamps from the actual data first - latest_price_timestamp = None - latest_hourly_timestamp = None - latest_daily_timestamp = None - latest_monthly_timestamp = None - - if self._cached_price_data: - latest_price_timestamp = _get_latest_timestamp_from_prices(self._cached_price_data) - if latest_price_timestamp and not stored.get("last_price_update"): - self._last_price_update = latest_price_timestamp - LOGGER.debug("Recovered price update timestamp from data: %s", self._last_price_update) - - if self._cached_rating_data_hourly: - latest_hourly_timestamp = self._get_latest_timestamp_from_rating_type( - self._cached_rating_data_hourly, "hourly" - ) - if latest_hourly_timestamp and not stored.get("last_rating_update_hourly"): - self._last_rating_update_hourly = latest_hourly_timestamp - LOGGER.debug("Recovered hourly rating timestamp from data: %s", self._last_rating_update_hourly) - - if self._cached_rating_data_daily: - latest_daily_timestamp = self._get_latest_timestamp_from_rating_type( - self._cached_rating_data_daily, "daily" - ) - if latest_daily_timestamp and not stored.get("last_rating_update_daily"): - self._last_rating_update_daily = latest_daily_timestamp - LOGGER.debug("Recovered daily rating timestamp from data: %s", self._last_rating_update_daily) - - if self._cached_rating_data_monthly: - latest_monthly_timestamp = self._get_latest_timestamp_from_rating_type( - self._cached_rating_data_monthly, "monthly" - ) - if latest_monthly_timestamp and not stored.get("last_rating_update_monthly"): - self._last_rating_update_monthly = latest_monthly_timestamp - LOGGER.debug("Recovered monthly rating timestamp from data: %s", self._last_rating_update_monthly) - - # Then load stored timestamps if they exist - if last_price := stored.get("last_price_update"): - self._last_price_update = dt_util.parse_datetime(last_price) - if last_rating_hourly := stored.get("last_rating_update_hourly"): - self._last_rating_update_hourly = dt_util.parse_datetime(last_rating_hourly) - if last_rating_daily := stored.get("last_rating_update_daily"): - self._last_rating_update_daily = dt_util.parse_datetime(last_rating_daily) - if last_rating_monthly := stored.get("last_rating_update_monthly"): - self._last_rating_update_monthly = dt_util.parse_datetime(last_rating_monthly) + # Recover timestamps + self._last_price_update = self._recover_timestamp( + self._cached_price_data, stored.get("last_price_update") + ) + self._last_rating_update_hourly = self._recover_timestamp( + self._cached_rating_data_hourly, + stored.get("last_rating_update_hourly"), + "hourly", + ) + self._last_rating_update_daily = self._recover_timestamp( + self._cached_rating_data_daily, + stored.get("last_rating_update_daily"), + "daily", + ) + self._last_rating_update_monthly = self._recover_timestamp( + self._cached_rating_data_monthly, + stored.get("last_rating_update_monthly"), + "monthly", + ) LOGGER.debug( "Loaded stored cache data - " - "Price update: %s (latest data: %s), " - "Rating hourly: %s (latest data: %s), " - "daily: %s (latest data: %s), " - "monthly: %s (latest data: %s)", + "Price update: %s, Rating hourly: %s, daily: %s, monthly: %s", self._last_price_update, - latest_price_timestamp, self._last_rating_update_hourly, - latest_hourly_timestamp, self._last_rating_update_daily, - latest_daily_timestamp, self._last_rating_update_monthly, - latest_monthly_timestamp, ) async def _async_refresh_hourly(self, *_: Any) -> None: @@ -291,7 +306,8 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) await self.async_refresh() async def _async_update_data(self) -> TibberPricesData: - """Fetch new state data for the coordinator. + """ + Fetch new state data for the coordinator. This method will: 1. Initialize cached data if none exists @@ -304,6 +320,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) try: current_time = dt_util.now() + result = None # If force update requested, fetch all data if self._force_update: @@ -317,81 +334,104 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) "hourly": self._last_rating_update_hourly, "daily": self._last_rating_update_daily, "monthly": self._last_rating_update_monthly, - } - } + }, + }, ) self._force_update = False # Reset force update flag - return await self._fetch_all_data() - - # Check if we need to update based on conditions - should_update_price = self._should_update_price_data(current_time) - should_update_hourly = self._should_update_rating_type( - current_time, self._cached_rating_data_hourly, self._last_rating_update_hourly, "hourly" - ) - should_update_daily = self._should_update_rating_type( - current_time, self._cached_rating_data_daily, self._last_rating_update_daily, "daily" - ) - should_update_monthly = self._should_update_rating_type( - current_time, self._cached_rating_data_monthly, self._last_rating_update_monthly, "monthly" - ) - - if any([should_update_price, should_update_hourly, should_update_daily, should_update_monthly]): - LOGGER.debug( - "Updating data based on conditions", - extra={ - "update_price": should_update_price, - "update_hourly": should_update_hourly, - "update_daily": should_update_daily, - "update_monthly": should_update_monthly, - } - ) - return await self._fetch_all_data() - - # Use cached data if no update needed - if self._cached_price_data is not None: - LOGGER.debug("Using cached data") - return self._merge_all_cached_data() - - # If we have no cached data and no updates needed, fetch new data - LOGGER.debug("No cached data available, fetching new data") - return await self._fetch_all_data() + result = await self._fetch_all_data() + else: + result = await self._handle_conditional_update(current_time) except TibberPricesApiClientAuthenticationError as exception: LOGGER.error( "Authentication failed", - extra={"error": str(exception), "error_type": "auth_failed"} - ) - raise ConfigEntryAuthFailed("Authentication failed while fetching data") from exception - except TibberPricesApiClientCommunicationError as exception: - LOGGER.error( - "API communication error", - extra={"error": str(exception), "error_type": "communication_error"} + extra={"error": str(exception), "error_type": "auth_failed"}, ) + raise ConfigEntryAuthFailed(AUTH_FAILED_MSG) from exception + except ( + TibberPricesApiClientCommunicationError, + TibberPricesApiClientError, + Exception, + ) as exception: + if isinstance(exception, TibberPricesApiClientCommunicationError): + LOGGER.error( + "API communication error", + extra={ + "error": str(exception), + "error_type": "communication_error", + }, + ) + elif isinstance(exception, TibberPricesApiClientError): + LOGGER.error( + "API client error", + extra={"error": str(exception), "error_type": "client_error"}, + ) + else: + LOGGER.exception( + "Unexpected error", + extra={"error": str(exception), "error_type": "unexpected"}, + ) + if self._cached_price_data is not None: LOGGER.info("Using cached data as fallback") return self._merge_all_cached_data() - raise UpdateFailed(f"Error communicating with API: {exception}") from exception - except TibberPricesApiClientError as exception: - LOGGER.error( - "API client error", - extra={"error": str(exception), "error_type": "client_error"} + raise UpdateFailed(UPDATE_FAILED_MSG) from exception + else: + return result + + async def _handle_conditional_update( + self, current_time: datetime + ) -> TibberPricesData: + """Handle conditional update based on update conditions.""" + should_update_price = self._should_update_price_data(current_time) + should_update_hourly = self._should_update_rating_type( + current_time, + self._cached_rating_data_hourly, + self._last_rating_update_hourly, + "hourly", + ) + should_update_daily = self._should_update_rating_type( + current_time, + self._cached_rating_data_daily, + self._last_rating_update_daily, + "daily", + ) + should_update_monthly = self._should_update_rating_type( + current_time, + self._cached_rating_data_monthly, + self._last_rating_update_monthly, + "monthly", + ) + + if any( + [ + should_update_price, + should_update_hourly, + should_update_daily, + should_update_monthly, + ] + ): + LOGGER.debug( + "Updating data based on conditions", + extra={ + "update_price": should_update_price, + "update_hourly": should_update_hourly, + "update_daily": should_update_daily, + "update_monthly": should_update_monthly, + }, ) - if self._cached_price_data is not None: - LOGGER.info("Using cached data as fallback") - return self._merge_all_cached_data() - raise UpdateFailed(f"Error fetching data: {exception}") from exception - except Exception as exception: - LOGGER.exception( - "Unexpected error", - extra={"error": str(exception), "error_type": "unexpected"} - ) - if self._cached_price_data is not None: - LOGGER.info("Using cached data as fallback") - return self._merge_all_cached_data() - raise UpdateFailed(f"Unexpected error: {exception}") from exception + return await self._fetch_all_data() + + if self._cached_price_data is not None: + LOGGER.debug("Using cached data") + return self._merge_all_cached_data() + + LOGGER.debug("No cached data available, fetching new data") + return await self._fetch_all_data() async def _fetch_all_data(self) -> TibberPricesData: - """Fetch all data from the API without checking update conditions. + """ + Fetch all data from the API without checking update conditions. This method will: 1. Fetch all required data (price and rating data) @@ -402,11 +442,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) current_time = dt_util.now() new_data = { "price_data": None, - "rating_data": { - "hourly": None, - "daily": None, - "monthly": None - } + "rating_data": {"hourly": None, "daily": None, "monthly": None}, } # First fetch all data without updating cache @@ -426,7 +462,9 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) except TibberPricesApiClientError as ex: LOGGER.error("Failed to fetch price data: %s", ex) if self._cached_price_data is not None: - LOGGER.info("Using cached data as fallback after price data fetch failure") + LOGGER.info( + "Using cached data as fallback after price data fetch failure" + ) return self._merge_all_cached_data() raise @@ -439,22 +477,30 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) _raise_no_data() # Only update cache if we have valid data - self._cached_price_data = cast(TibberPricesData, new_data["price_data"]) + self._cached_price_data = cast("TibberPricesData", new_data["price_data"]) self._last_price_update = current_time # Update rating data cache only for types that were successfully fetched for rating_type, rating_data in new_data["rating_data"].items(): if rating_data is not None: if rating_type == "hourly": - self._cached_rating_data_hourly = cast(TibberPricesData, rating_data) + self._cached_rating_data_hourly = cast( + "TibberPricesData", rating_data + ) self._last_rating_update_hourly = current_time elif rating_type == "daily": - self._cached_rating_data_daily = cast(TibberPricesData, rating_data) + self._cached_rating_data_daily = cast( + "TibberPricesData", rating_data + ) self._last_rating_update_daily = current_time else: # monthly - self._cached_rating_data_monthly = cast(TibberPricesData, rating_data) + self._cached_rating_data_monthly = cast( + "TibberPricesData", rating_data + ) self._last_rating_update_monthly = current_time - LOGGER.debug("Updated %s rating data cache at %s", rating_type, current_time) + LOGGER.debug( + "Updated %s rating data cache at %s", rating_type, current_time + ) # Store the updated cache await self._store_cache() @@ -467,20 +513,33 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) """Store cache data.""" # Recover any missing timestamps from the data if self._cached_price_data and not self._last_price_update: - latest_timestamp = _get_latest_timestamp_from_prices(self._cached_price_data) + latest_timestamp = _get_latest_timestamp_from_prices( + self._cached_price_data + ) if latest_timestamp: self._last_price_update = latest_timestamp - LOGGER.debug("Setting missing price update timestamp to: %s", self._last_price_update) + LOGGER.debug( + "Setting missing price update timestamp to: %s", + self._last_price_update, + ) rating_types = { - "hourly": (self._cached_rating_data_hourly, self._last_rating_update_hourly), + "hourly": ( + self._cached_rating_data_hourly, + self._last_rating_update_hourly, + ), "daily": (self._cached_rating_data_daily, self._last_rating_update_daily), - "monthly": (self._cached_rating_data_monthly, self._last_rating_update_monthly), + "monthly": ( + self._cached_rating_data_monthly, + self._last_rating_update_monthly, + ), } for rating_type, (cached_data, last_update) in rating_types.items(): if cached_data and not last_update: - latest_timestamp = self._get_latest_timestamp_from_rating_type(cached_data, rating_type) + latest_timestamp = self._get_latest_timestamp_from_rating_type( + cached_data, rating_type + ) if latest_timestamp: if rating_type == "hourly": self._last_rating_update_hourly = latest_timestamp @@ -488,21 +547,34 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) self._last_rating_update_daily = latest_timestamp else: # monthly self._last_rating_update_monthly = latest_timestamp - LOGGER.debug("Setting missing %s rating timestamp to: %s", rating_type, latest_timestamp) + LOGGER.debug( + "Setting missing %s rating timestamp to: %s", + rating_type, + latest_timestamp, + ) data = { "price_data": self._cached_price_data, "rating_data_hourly": self._cached_rating_data_hourly, "rating_data_daily": self._cached_rating_data_daily, "rating_data_monthly": self._cached_rating_data_monthly, - "last_price_update": self._last_price_update.isoformat() if self._last_price_update else None, - "last_rating_update_hourly": self._last_rating_update_hourly.isoformat() if self._last_rating_update_hourly else None, - "last_rating_update_daily": self._last_rating_update_daily.isoformat() if self._last_rating_update_daily else None, - "last_rating_update_monthly": self._last_rating_update_monthly.isoformat() if self._last_rating_update_monthly else None, + "last_price_update": self._last_price_update.isoformat() + if self._last_price_update + else None, + "last_rating_update_hourly": self._last_rating_update_hourly.isoformat() + if self._last_rating_update_hourly + else None, + "last_rating_update_daily": self._last_rating_update_daily.isoformat() + if self._last_rating_update_daily + else None, + "last_rating_update_monthly": self._last_rating_update_monthly.isoformat() + if self._last_rating_update_monthly + else None, } - LOGGER.debug("Storing cache data with timestamps: %s", { - k: v for k, v in data.items() if k.startswith("last_") - }) + LOGGER.debug( + "Storing cache data with timestamps: %s", + {k: v for k, v in data.items() if k.startswith("last_")}, + ) await self._store.async_save(data) @callback @@ -514,7 +586,9 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) return True # Get the latest timestamp from our price data - latest_price_timestamp = _get_latest_timestamp_from_prices(self._cached_price_data) + latest_price_timestamp = _get_latest_timestamp_from_prices( + self._cached_price_data + ) if not latest_price_timestamp: LOGGER.debug("No valid timestamp found in price data, update needed") return True @@ -522,11 +596,16 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) # If we have price data but no last_update timestamp, set it if not self._last_price_update: self._last_price_update = latest_price_timestamp - LOGGER.debug("Setting missing price update timestamp in check: %s", self._last_price_update) + LOGGER.debug( + "Setting missing price update timestamp in check: %s", + self._last_price_update, + ) # Check if we're in the update window (13:00-15:00) current_hour = current_time.hour - in_update_window = PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour <= PRICE_UPDATE_RANDOM_MAX_HOUR + in_update_window = ( + PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour <= PRICE_UPDATE_RANDOM_MAX_HOUR + ) # Get tomorrow's date at midnight tomorrow = (current_time + timedelta(days=1)).replace( @@ -536,7 +615,8 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) # If we're in the update window and don't have tomorrow's complete data if in_update_window and latest_price_timestamp < tomorrow: LOGGER.debug( - "In update window (%d:00) and latest price timestamp (%s) is before tomorrow, update needed", + "In update window (%d:00) and latest price timestamp (%s) " + "is before tomorrow, update needed", current_hour, latest_price_timestamp, ) @@ -571,13 +651,19 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) """Check if specific rating type should be updated.""" # If no cached data, we definitely need an update if cached_data is None: - LOGGER.debug("No cached %s rating data available, update needed", rating_type) + LOGGER.debug( + "No cached %s rating data available, update needed", rating_type + ) return True # Get the latest timestamp from our rating data - latest_timestamp = self._get_latest_timestamp_from_rating_type(cached_data, rating_type) + latest_timestamp = self._get_latest_timestamp_from_rating_type( + cached_data, rating_type + ) if not latest_timestamp: - LOGGER.debug("No valid timestamp found in %s rating data, update needed", rating_type) + LOGGER.debug( + "No valid timestamp found in %s rating data, update needed", rating_type + ) return True # If we have rating data but no last_update timestamp, set it @@ -588,64 +674,50 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) self._last_rating_update_daily = latest_timestamp else: # monthly self._last_rating_update_monthly = latest_timestamp - LOGGER.debug("Setting missing %s rating timestamp in check: %s", rating_type, latest_timestamp) + LOGGER.debug( + "Setting missing %s rating timestamp in check: %s", + rating_type, + latest_timestamp, + ) last_update = latest_timestamp current_hour = current_time.hour - in_update_window = PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour <= PRICE_UPDATE_RANDOM_MAX_HOUR + in_update_window = ( + PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour <= PRICE_UPDATE_RANDOM_MAX_HOUR + ) + should_update = False if rating_type == "monthly": - # For monthly ratings: - # 1. Check if we have data for the current month - # 2. Update more frequently as the data changes throughout the month - current_month_start = current_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - - if latest_timestamp < current_month_start: - LOGGER.debug( - "Monthly rating data is from previous month (%s), update needed", - latest_timestamp, - ) - return True - - # Update monthly data daily to get updated calculations - if last_update and current_time - last_update >= timedelta(days=1): - LOGGER.debug( - "More than 24 hours since last monthly rating update (%s), update needed for latest calculations", - last_update, - ) - return True + current_month_start = current_time.replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + should_update = latest_timestamp < current_month_start or ( + last_update and current_time - last_update >= timedelta(days=1) + ) else: - # For hourly and daily ratings: - # Get tomorrow's date at midnight tomorrow = (current_time + timedelta(days=1)).replace( hour=0, minute=0, second=0, microsecond=0 ) + should_update = ( + in_update_window and latest_timestamp < tomorrow + ) or current_time - last_update >= UPDATE_INTERVAL - # If we're in the update window and don't have tomorrow's data - if in_update_window and latest_timestamp < tomorrow: - LOGGER.debug( - "In update window and %s rating data (%s) is before tomorrow, update needed", - rating_type, - latest_timestamp, - ) - return True + if should_update: + LOGGER.debug( + "Update needed for %s rating data - Last update: %s, Latest data: %s", + rating_type, + last_update, + latest_timestamp, + ) + else: + LOGGER.debug( + "No %s rating update needed - Last update: %s, Latest data: %s", + rating_type, + last_update, + latest_timestamp, + ) - # If it's been more than 24 hours since our last update - if current_time - last_update >= UPDATE_INTERVAL: - LOGGER.debug( - "More than 24 hours since last %s rating update (%s), update needed", - rating_type, - last_update, - ) - return True - - LOGGER.debug( - "No %s rating update needed - Last update: %s, Latest data: %s", - rating_type, - last_update, - latest_timestamp, - ) - return False + return should_update @callback def _is_price_update_window(self, current_hour: int) -> bool: @@ -665,10 +737,14 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) try: # Try to access data in the transformed structure first try: - price_info = data["viewer"]["homes"][0]["currentSubscription"]["priceInfo"] + price_info = data["viewer"]["homes"][0]["currentSubscription"][ + "priceInfo" + ] except KeyError: # If that fails, try the raw data structure - price_info = data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"] + price_info = data["data"]["viewer"]["homes"][0]["currentSubscription"][ + "priceInfo" + ] # Ensure we have all required fields extracted_price_info = { @@ -676,35 +752,34 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) "tomorrow": price_info.get("tomorrow", []), "yesterday": price_info.get("yesterday", []), } - - return { - "data": { - "viewer": { - "homes": [{ - "currentSubscription": { - "priceInfo": extracted_price_info - } - }] - } - } - } except (KeyError, IndexError) as ex: LOGGER.error("Error extracting price data: %s", ex) return { "data": { "viewer": { - "homes": [{ - "currentSubscription": { - "priceInfo": { - "today": [], - "tomorrow": [], - "yesterday": [], + "homes": [ + { + "currentSubscription": { + "priceInfo": { + "today": [], + "tomorrow": [], + "yesterday": [], + } } } - }] + ] } } } + return { + "data": { + "viewer": { + "homes": [ + {"currentSubscription": {"priceInfo": extracted_price_info}} + ] + } + } + } @callback def _get_latest_timestamp_from_rating_type( @@ -715,21 +790,21 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) return None try: - subscription = rating_data["data"]["viewer"]["homes"][0]["currentSubscription"] + subscription = rating_data["data"]["viewer"]["homes"][0][ + "currentSubscription" + ] price_rating = subscription["priceRating"] - latest_timestamp = None + result = None if rating_entries := price_rating.get(rating_type, {}).get("entries", []): for entry in rating_entries: if time := entry.get("time"): timestamp = dt_util.parse_datetime(time) - if timestamp and (not latest_timestamp or timestamp > latest_timestamp): - latest_timestamp = timestamp - - return latest_timestamp - + if timestamp and (not result or timestamp > result): + result = timestamp except (KeyError, IndexError, TypeError): return None + return result async def _get_rating_data_for_type(self, rating_type: str) -> dict: """Get fresh rating data for a specific type.""" @@ -748,7 +823,9 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) except KeyError: try: # If that fails, try the raw data structure - rating = data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceRating"] + rating = data["data"]["viewer"]["homes"][0]["currentSubscription"][ + "priceRating" + ] except KeyError as ex: LOGGER.error("Failed to extract rating data: %s", ex) raise TibberPricesApiClientError( @@ -756,29 +833,50 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) query_type=rating_type ) ) from ex - - return { - "data": { - "viewer": { - "homes": [ - { - "currentSubscription": { - "priceRating": { - "thresholdPercentages": rating["thresholdPercentages"], - rating_type: rating[rating_type], + else: + return { + "data": { + "viewer": { + "homes": [ + { + "currentSubscription": { + "priceRating": { + "thresholdPercentages": rating[ + "thresholdPercentages" + ], + rating_type: rating[rating_type], + } + } + } + ] + } + } + } + else: + return { + "data": { + "viewer": { + "homes": [ + { + "currentSubscription": { + "priceRating": { + "thresholdPercentages": rating[ + "thresholdPercentages" + ], + rating_type: rating[rating_type], + } } } - } - ] + ] + } } } - } @callback def _merge_all_cached_data(self) -> TibberPricesData: """Merge all cached data.""" if not self._cached_price_data: - return cast(TibberPricesData, {}) + return cast("TibberPricesData", {}) # Start with price info subscription = { @@ -786,8 +884,8 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) "currentSubscription" ]["priceInfo"], "priceRating": { - "thresholdPercentages": None, # Will be set from any available rating data - } + "thresholdPercentages": None, + }, } # Add rating data if available @@ -799,19 +897,27 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) for rating_type, data in rating_data.items(): if data and "data" in data: - rating = data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceRating"] + rating = data["data"]["viewer"]["homes"][0]["currentSubscription"][ + "priceRating" + ] # Set thresholdPercentages from any available rating data if not subscription["priceRating"]["thresholdPercentages"]: - subscription["priceRating"]["thresholdPercentages"] = rating["thresholdPercentages"] + subscription["priceRating"]["thresholdPercentages"] = rating[ + "thresholdPercentages" + ] # Add the specific rating type data subscription["priceRating"][rating_type] = rating[rating_type] - return cast(TibberPricesData, {"data": {"viewer": {"homes": [{"currentSubscription": subscription}]}}}) + return cast( + "TibberPricesData", + {"data": {"viewer": {"homes": [{"currentSubscription": subscription}]}}}, + ) async def async_request_refresh(self) -> None: - """Request an immediate refresh of the data. + """ + Request an immediate refresh of the data. This method will: 1. Set the force update flag to trigger a fresh data fetch @@ -824,4 +930,4 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData]) def _transform_api_response(self, data: dict[str, Any]) -> TibberPricesData: """Transform API response to coordinator data format.""" - return cast(TibberPricesData, data) + return cast("TibberPricesData", data) diff --git a/custom_components/tibber_prices/data.py b/custom_components/tibber_prices/data.py index ab249dd..2916b37 100644 --- a/custom_components/tibber_prices/data.py +++ b/custom_components/tibber_prices/data.py @@ -13,9 +13,6 @@ if TYPE_CHECKING: from .coordinator import TibberPricesDataUpdateCoordinator -type TibberPricesConfigEntry = ConfigEntry[TibberPricesData] - - @dataclass class TibberPricesData: """Data for the tibber_prices integration.""" @@ -23,3 +20,7 @@ class TibberPricesData: client: TibberPricesApiClient coordinator: TibberPricesDataUpdateCoordinator integration: Integration + + +if TYPE_CHECKING: + type TibberPricesConfigEntry = ConfigEntry[TibberPricesData] diff --git a/custom_components/tibber_prices/entity.py b/custom_components/tibber_prices/entity.py index f103d28..b331f44 100644 --- a/custom_components/tibber_prices/entity.py +++ b/custom_components/tibber_prices/entity.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, DOMAIN, NAME +from .const import ATTRIBUTION, DOMAIN from .coordinator import TibberPricesDataUpdateCoordinator diff --git a/custom_components/tibber_prices/manifest.json b/custom_components/tibber_prices/manifest.json index 5fcba5e..2844276 100644 --- a/custom_components/tibber_prices/manifest.json +++ b/custom_components/tibber_prices/manifest.json @@ -6,7 +6,6 @@ ], "config_flow": true, "documentation": "https://github.com/jpawlowski/hass.tibber_prices", - "icon": "mdi:chart-line", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues", "version": "0.1.0" diff --git a/custom_components/tibber_prices/sensor.py b/custom_components/tibber_prices/sensor.py index 15e8b36..ae9c855 100644 --- a/custom_components/tibber_prices/sensor.py +++ b/custom_components/tibber_prices/sensor.py @@ -2,19 +2,17 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime from typing import TYPE_CHECKING from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, - SensorStateClass, ) -from homeassistant.const import EntityCategory +from homeassistant.const import CURRENCY_EURO, EntityCategory from homeassistant.util import dt as dt_util -from .const import DOMAIN from .entity import TibberPricesEntity if TYPE_CHECKING: @@ -25,7 +23,7 @@ if TYPE_CHECKING: from .data import TibberPricesConfigEntry PRICE_UNIT = "ct/kWh" -CURRENCY_EURO = "EUR/kWh" +HOURS_IN_DAY = 24 # Main price sensors that users will typically use in automations PRICE_SENSORS = ( @@ -36,7 +34,7 @@ PRICE_SENSORS = ( icon="mdi:currency-eur", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement=CURRENCY_EURO, - entity_registry_enabled_default=False, # Hidden by default as it's mainly for the Energy Dashboard + entity_registry_enabled_default=False, ), SensorEntityDescription( key="current_price", @@ -53,7 +51,7 @@ PRICE_SENSORS = ( icon="mdi:currency-eur-off", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement=CURRENCY_EURO, - entity_registry_enabled_default=False, # Hidden by default as it's mainly for the Energy Dashboard + entity_registry_enabled_default=False, ), SensorEntityDescription( key="next_hour_price", @@ -80,7 +78,7 @@ STATISTICS_SENSORS = ( icon="mdi:currency-eur", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement=CURRENCY_EURO, - entity_registry_enabled_default=False, # Hidden by default as it's mainly for the Energy Dashboard + entity_registry_enabled_default=False, ), SensorEntityDescription( key="lowest_price_today", @@ -97,7 +95,7 @@ STATISTICS_SENSORS = ( icon="mdi:currency-eur", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement=CURRENCY_EURO, - entity_registry_enabled_default=False, # Hidden by default as it's mainly for the Energy Dashboard + entity_registry_enabled_default=False, ), SensorEntityDescription( key="highest_price_today", @@ -114,7 +112,7 @@ STATISTICS_SENSORS = ( icon="mdi:currency-eur", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement=CURRENCY_EURO, - entity_registry_enabled_default=False, # Hidden by default as it's mainly for the Energy Dashboard + entity_registry_enabled_default=False, ), SensorEntityDescription( key="average_price_today", @@ -180,7 +178,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, + _hass: HomeAssistant, entry: TibberPricesConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: @@ -205,224 +203,298 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): """Initialize the sensor class.""" super().__init__(coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{entity_description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{entity_description.key}" + ) self._attr_has_entity_name = True + def _get_current_hour_data(self) -> dict | None: + """Get the price data for the current hour.""" + if not self.coordinator.data: + return None + now = datetime.now(tz=UTC).astimezone() + price_info = self.coordinator.data["data"]["viewer"]["homes"][0][ + "currentSubscription" + ]["priceInfo"] + for price_data in price_info.get("today", []): + starts_at = datetime.fromisoformat(price_data["startsAt"]) + if starts_at.hour == now.hour: + return price_data + return None + + def _get_price_value(self, price: float) -> float: + """Convert price based on unit.""" + return ( + price * 100 + if self.entity_description.native_unit_of_measurement == "ct/kWh" + else price + ) + + def _get_price_sensor_value(self) -> float | None: + """Handle price sensor values.""" + if not self.coordinator.data: + return None + + subscription = self.coordinator.data["data"]["viewer"]["homes"][0][ + "currentSubscription" + ] + price_info = subscription["priceInfo"] + now = datetime.now(tz=UTC).astimezone() + current_hour_data = self._get_current_hour_data() + + key = self.entity_description.key + if key in ["current_price", "current_price_eur"]: + if not current_hour_data: + return None + return ( + self._get_price_value(float(current_hour_data["total"])) + if key == "current_price" + else float(current_hour_data["total"]) + ) + + if key in ["next_hour_price", "next_hour_price_eur"]: + next_hour = (now.hour + 1) % 24 + for price_data in price_info.get("today", []): + starts_at = datetime.fromisoformat(price_data["startsAt"]) + if starts_at.hour == next_hour: + return ( + self._get_price_value(float(price_data["total"])) + if key == "next_hour_price" + else float(price_data["total"]) + ) + return None + + return None + + def _get_statistics_value(self) -> float | None: + """Handle statistics sensor values.""" + if not self.coordinator.data: + return None + + price_info = self.coordinator.data["data"]["viewer"]["homes"][0][ + "currentSubscription" + ]["priceInfo"] + today_prices = price_info.get("today", []) + if not today_prices: + return None + + key = self.entity_description.key + prices = [float(price["total"]) for price in today_prices] + + if key in ["lowest_price_today", "lowest_price_today_eur"]: + value = min(prices) + elif key in ["highest_price_today", "highest_price_today_eur"]: + value = max(prices) + elif key in ["average_price_today", "average_price_today_eur"]: + value = sum(prices) / len(prices) + else: + return None + + return self._get_price_value(value) if key.endswith("today") else value + + def _get_rating_value(self) -> float | None: + """Handle rating sensor values.""" + if not self.coordinator.data: + return None + + def check_hourly(entry: dict) -> bool: + return datetime.fromisoformat(entry["time"]).hour == now.hour + + def check_daily(entry: dict) -> bool: + return datetime.fromisoformat(entry["time"]).date() == now.date() + + def check_monthly(entry: dict) -> bool: + dt = datetime.fromisoformat(entry["time"]) + return dt.month == now.month and dt.year == now.year + + subscription = self.coordinator.data["data"]["viewer"]["homes"][0][ + "currentSubscription" + ] + price_rating = subscription.get("priceRating", {}) or {} + now = datetime.now(tz=UTC).astimezone() + + key = self.entity_description.key + if key == "hourly_rating": + rating_data = price_rating.get("hourly", {}) + entries = rating_data.get("entries", []) if rating_data else [] + time_match = check_hourly + elif key == "daily_rating": + rating_data = price_rating.get("daily", {}) + entries = rating_data.get("entries", []) if rating_data else [] + time_match = check_daily + elif key == "monthly_rating": + rating_data = price_rating.get("monthly", {}) + entries = rating_data.get("entries", []) if rating_data else [] + time_match = check_monthly + else: + return None + + for entry in entries: + if time_match(entry): + return round(float(entry["difference"]) * 100, 1) + return None + + def _get_diagnostic_value(self) -> datetime | str | None: + """Handle diagnostic sensor values.""" + if not self.coordinator.data: + return None + + price_info = self.coordinator.data["data"]["viewer"]["homes"][0][ + "currentSubscription" + ]["priceInfo"] + key = self.entity_description.key + + if key == "data_timestamp": + latest_timestamp = None + for day in ["today", "tomorrow"]: + for price_data in price_info.get(day, []): + timestamp = datetime.fromisoformat(price_data["startsAt"]) + if not latest_timestamp or timestamp > latest_timestamp: + latest_timestamp = timestamp + return dt_util.as_utc(latest_timestamp) if latest_timestamp else None + + if key == "tomorrow_data_available": + tomorrow_prices = price_info.get("tomorrow", []) + if not tomorrow_prices: + return "No" + return "Yes" if len(tomorrow_prices) == HOURS_IN_DAY else "Partial" + + return None + @property def native_value(self) -> float | str | datetime | None: """Return the native value of the sensor.""" + result = None try: - if not self.coordinator.data: - return None - - subscription = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"] - price_info = subscription["priceInfo"] - price_rating = subscription.get("priceRating") or {} - - # Get current hour's data - now = datetime.now() - current_hour_data = None - for price_data in price_info.get("today", []): - starts_at = datetime.fromisoformat(price_data["startsAt"]) - if starts_at.hour == now.hour: - current_hour_data = price_data - break - - # Helper function to convert price based on unit - def get_price_value(price: float) -> float: - if self.entity_description.native_unit_of_measurement == "ct/kWh": - return price * 100 - return price - - if self.entity_description.key == "current_price": - return get_price_value(float(current_hour_data["total"])) if current_hour_data else None - elif self.entity_description.key == "current_price_eur": - return float(current_hour_data["total"]) if current_hour_data else None - - elif self.entity_description.key == "next_hour_price": - next_hour = (now.hour + 1) % 24 - for price_data in price_info.get("today", []): - starts_at = datetime.fromisoformat(price_data["startsAt"]) - if starts_at.hour == next_hour: - return get_price_value(float(price_data["total"])) - return None - elif self.entity_description.key == "next_hour_price_eur": - next_hour = (now.hour + 1) % 24 - for price_data in price_info.get("today", []): - starts_at = datetime.fromisoformat(price_data["startsAt"]) - if starts_at.hour == next_hour: - return float(price_data["total"]) - return None - - elif self.entity_description.key == "lowest_price_today": - today_prices = price_info.get("today", []) - if not today_prices: - return None - return get_price_value(min(float(price["total"]) for price in today_prices)) - elif self.entity_description.key == "lowest_price_today_eur": - today_prices = price_info.get("today", []) - if not today_prices: - return None - return min(float(price["total"]) for price in today_prices) - - elif self.entity_description.key == "highest_price_today": - today_prices = price_info.get("today", []) - if not today_prices: - return None - return get_price_value(max(float(price["total"]) for price in today_prices)) - elif self.entity_description.key == "highest_price_today_eur": - today_prices = price_info.get("today", []) - if not today_prices: - return None - return max(float(price["total"]) for price in today_prices) - - elif self.entity_description.key == "average_price_today": - today_prices = price_info.get("today", []) - if not today_prices: - return None - avg = sum(float(price["total"]) for price in today_prices) / len(today_prices) - return get_price_value(avg) - elif self.entity_description.key == "average_price_today_eur": - today_prices = price_info.get("today", []) - if not today_prices: - return None - return sum(float(price["total"]) for price in today_prices) / len(today_prices) - - elif self.entity_description.key == "price_level": - return current_hour_data["level"] if current_hour_data else None - - elif self.entity_description.key == "hourly_rating": - hourly = price_rating.get("hourly", {}) - entries = hourly.get("entries", []) if hourly else [] - if not entries: - return None - for entry in entries: - starts_at = datetime.fromisoformat(entry["time"]) - if starts_at.hour == now.hour: - return round(float(entry["difference"]) * 100, 1) - return None - - elif self.entity_description.key == "daily_rating": - daily = price_rating.get("daily", {}) - entries = daily.get("entries", []) if daily else [] - if not entries: - return None - for entry in entries: - starts_at = datetime.fromisoformat(entry["time"]) - if starts_at.date() == now.date(): - return round(float(entry["difference"]) * 100, 1) - return None - - elif self.entity_description.key == "monthly_rating": - monthly = price_rating.get("monthly", {}) - entries = monthly.get("entries", []) if monthly else [] - if not entries: - return None - for entry in entries: - starts_at = datetime.fromisoformat(entry["time"]) - if starts_at.month == now.month and starts_at.year == now.year: - return round(float(entry["difference"]) * 100, 1) - return None - - elif self.entity_description.key == "data_timestamp": - # Return the latest timestamp from any data we have - latest_timestamp = None - - # Check today's data - for price_data in price_info.get("today", []): - timestamp = datetime.fromisoformat(price_data["startsAt"]) - if not latest_timestamp or timestamp > latest_timestamp: - latest_timestamp = timestamp - - # Check tomorrow's data - for price_data in price_info.get("tomorrow", []): - timestamp = datetime.fromisoformat(price_data["startsAt"]) - if not latest_timestamp or timestamp > latest_timestamp: - latest_timestamp = timestamp - - return dt_util.as_utc(latest_timestamp) if latest_timestamp else None - - elif self.entity_description.key == "tomorrow_data_available": - tomorrow_prices = price_info.get("tomorrow", []) - if not tomorrow_prices: - return "No" - # Check if we have a full day of data (24 hours) - return "Yes" if len(tomorrow_prices) == 24 else "Partial" - - return None + if self.coordinator.data: + key = self.entity_description.key + current_hour_data = self._get_current_hour_data() + if key == "price_level": + result = current_hour_data["level"] if current_hour_data else None + elif key in [ + "current_price", + "current_price_eur", + "next_hour_price", + "next_hour_price_eur", + ]: + result = self._get_price_sensor_value() + elif "price_today" in key: + result = self._get_statistics_value() + elif "rating" in key: + result = self._get_rating_value() + elif key in ["data_timestamp", "tomorrow_data_available"]: + result = self._get_diagnostic_value() + else: + result = None + else: + result = None except (KeyError, ValueError, TypeError) as ex: - self.coordinator.logger.error( + self.coordinator.logger.exception( "Error getting sensor value", extra={ "error": str(ex), "entity": self.entity_description.key, }, ) - return None + result = None + return result @property - def extra_state_attributes(self) -> dict | None: + def extra_state_attributes(self) -> dict | None: # noqa: PLR0912 """Return additional state attributes.""" try: if not self.coordinator.data: return None - subscription = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"] + subscription = self.coordinator.data["data"]["viewer"]["homes"][0][ + "currentSubscription" + ] price_info = subscription["priceInfo"] attributes = {} # Get current hour's data for timestamp - now = datetime.now() - current_hour_data = None - for price_data in price_info.get("today", []): - starts_at = datetime.fromisoformat(price_data["startsAt"]) - if starts_at.hour == now.hour: - current_hour_data = price_data - break + now = datetime.now(tz=UTC).astimezone() + current_hour_data = self._get_current_hour_data() if self.entity_description.key in ["current_price", "current_price_eur"]: - attributes["timestamp"] = current_hour_data["startsAt"] if current_hour_data else None - elif self.entity_description.key in ["next_hour_price", "next_hour_price_eur"]: + attributes["timestamp"] = ( + current_hour_data["startsAt"] if current_hour_data else None + ) + + if self.entity_description.key in [ + "next_hour_price", + "next_hour_price_eur", + ]: next_hour = (now.hour + 1) % 24 for price_data in price_info.get("today", []): starts_at = datetime.fromisoformat(price_data["startsAt"]) if starts_at.hour == next_hour: attributes["timestamp"] = price_data["startsAt"] break - elif self.entity_description.key == "price_level": - attributes["timestamp"] = current_hour_data["startsAt"] if current_hour_data else None - elif self.entity_description.key == "lowest_price_today": - attributes["timestamp"] = price_info.get("today", [{}])[0].get("startsAt") - elif self.entity_description.key == "highest_price_today": - attributes["timestamp"] = price_info.get("today", [{}])[0].get("startsAt") - elif self.entity_description.key == "average_price_today": - attributes["timestamp"] = price_info.get("today", [{}])[0].get("startsAt") - elif self.entity_description.key == "hourly_rating": - attributes["timestamp"] = current_hour_data["startsAt"] if current_hour_data else None - elif self.entity_description.key == "daily_rating": - attributes["timestamp"] = price_info.get("today", [{}])[0].get("startsAt") - elif self.entity_description.key == "monthly_rating": - attributes["timestamp"] = price_info.get("today", [{}])[0].get("startsAt") - elif self.entity_description.key == "data_timestamp": - attributes["timestamp"] = price_info.get("today", [{}])[0].get("startsAt") - elif self.entity_description.key == "tomorrow_data_available": - attributes["timestamp"] = price_info.get("today", [{}])[0].get("startsAt") + + if self.entity_description.key == "price_level": + attributes["timestamp"] = ( + current_hour_data["startsAt"] if current_hour_data else None + ) + + if self.entity_description.key == "lowest_price_today": + attributes["timestamp"] = price_info.get("today", [{}])[0].get( + "startsAt" + ) + + if self.entity_description.key == "highest_price_today": + attributes["timestamp"] = price_info.get("today", [{}])[0].get( + "startsAt" + ) + + if self.entity_description.key == "average_price_today": + attributes["timestamp"] = price_info.get("today", [{}])[0].get( + "startsAt" + ) + + if self.entity_description.key == "hourly_rating": + attributes["timestamp"] = ( + current_hour_data["startsAt"] if current_hour_data else None + ) + + if self.entity_description.key == "daily_rating": + attributes["timestamp"] = price_info.get("today", [{}])[0].get( + "startsAt" + ) + + if self.entity_description.key == "monthly_rating": + attributes["timestamp"] = price_info.get("today", [{}])[0].get( + "startsAt" + ) + + if self.entity_description.key == "data_timestamp": + attributes["timestamp"] = price_info.get("today", [{}])[0].get( + "startsAt" + ) + + if self.entity_description.key == "tomorrow_data_available": + attributes["timestamp"] = price_info.get("today", [{}])[0].get( + "startsAt" + ) # Add translated description if self.hass is not None: - key = f"entity.sensor.{self.entity_description.translation_key}.description" - language_config = getattr(self.hass.config, 'language', None) + base_key = "entity.sensor" + key = ( + f"{base_key}.{self.entity_description.translation_key}.description" + ) + language_config = getattr(self.hass.config, "language", None) if isinstance(language_config, dict): description = language_config.get(key) if description is not None: attributes["description"] = description - return attributes if attributes else None + return attributes if attributes else None # noqa: TRY300 except (KeyError, ValueError, TypeError) as ex: - self.coordinator.logger.error( + self.coordinator.logger.exception( "Error getting sensor attributes", extra={ "error": str(ex), diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json deleted file mode 100644 index 68b43ac..0000000 --- a/custom_components/tibber_prices/translations/de.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "config": { - "step": { - "user": { - "description": "Wenn Sie Hilfe bei der Konfiguration benötigen, schauen Sie hier: https://github.com/jpawlowski/hass.tibber_prices", - "data": { - "access_token": "Tibber Zugangstoken" - } - } - }, - "error": { - "auth": "Der Tibber Zugangstoken ist ungültig.", - "connection": "Keine Verbindung zu Tibber möglich. Bitte überprüfen Sie Ihre Internetverbindung.", - "unknown": "Ein unerwarteter Fehler ist aufgetreten. Bitte prüfen Sie die Logs für Details." - }, - "abort": { - "already_configured": "Dieser Eintrag ist bereits konfiguriert.", - "entry_not_found": "Tibber-Konfigurationseintrag nicht gefunden." - } - }, - "options": { - "step": { - "init": { - "title": "Tibber-Konfiguration aktualisieren", - "description": "Aktualisieren Sie Ihren Tibber-API-Zugangstoken. Wenn Sie einen neuen Token benötigen, können Sie diesen unter https://developer.tibber.com/settings/access-token generieren", - "data": { - "access_token": "Tibber Zugangstoken" - } - } - }, - "error": { - "auth": "Der Tibber Zugangstoken ist ungültig.", - "connection": "Keine Verbindung zu Tibber möglich. Bitte überprüfen Sie Ihre Internetverbindung.", - "unknown": "Ein unerwarteter Fehler ist aufgetreten. Bitte prüfen Sie die Logs für Details.", - "different_account": "Der neue Zugangstoken gehört zu einem anderen Tibber-Konto. Bitte verwenden Sie einen Token desselben Kontos oder erstellen Sie eine neue Konfiguration für das andere Konto." - }, - "abort": { - "entry_not_found": "Tibber-Konfigurationseintrag nicht gefunden." - } - }, - "entity": { - "sensor": { - "current_price": { - "name": "Aktueller Preis", - "description": "Der aktuelle Strompreis dieser Stunde inklusive Steuern" - }, - "next_hour_price": { - "name": "Preis nächste Stunde", - "description": "Der Strompreis der nächsten Stunde inklusive Steuern" - }, - "price_level": { - "name": "Preisniveau", - "description": "Aktueller Preisindikator (SEHR_GÜNSTIG, GÜNSTIG, NORMAL, TEUER, SEHR_TEUER)" - }, - "lowest_price_today": { - "name": "Niedrigster Preis heute", - "description": "Der niedrigste Strompreis des aktuellen Tages" - }, - "highest_price_today": { - "name": "Höchster Preis heute", - "description": "Der höchste Strompreis des aktuellen Tages" - }, - "average_price_today": { - "name": "Durchschnittspreis heute", - "description": "Der durchschnittliche Strompreis des aktuellen Tages" - }, - "hourly_rating": { - "name": "Stündliche Preisbewertung", - "description": "Preisvergleich mit historischen Daten für die aktuelle Stunde (prozentuale Abweichung)" - }, - "daily_rating": { - "name": "Tägliche Preisbewertung", - "description": "Preisvergleich mit historischen Daten für den aktuellen Tag (prozentuale Abweichung)" - }, - "monthly_rating": { - "name": "Monatliche Preisbewertung", - "description": "Preisvergleich mit historischen Daten für den aktuellen Monat (prozentuale Abweichung)" - }, - "data_timestamp": { - "name": "Letzte Datenaktualisierung", - "description": "Zeitstempel der zuletzt von Tibber empfangenen Preisdaten" - }, - "tomorrow_data_available": { - "name": "Daten für morgen verfügbar", - "description": "Zeigt an, ob Preisdaten für morgen verfügbar sind (Ja/Nein/Teilweise)" - } - }, - "binary_sensor": { - "peak_hour": { - "name": "Spitzenstunde", - "description": "Zeigt an, ob die aktuelle Stunde zu den 20% teuersten Stunden des Tages gehört" - }, - "best_price_hour": { - "name": "Beste Preisstunde", - "description": "Zeigt an, ob die aktuelle Stunde zu den 20% günstigsten Stunden des Tages gehört" - }, - "connection": { - "name": "Verbindungsstatus", - "description": "Zeigt an, ob aktuelle gültige Preisdaten von Tibber vorliegen" - } - } - } -} \ No newline at end of file diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index d7a8723..7552582 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -1,103 +1,89 @@ { - "config": { - "step": { - "user": { - "description": "If you need help with the configuration have a look here: https://github.com/jpawlowski/hass.tibber_prices", - "data": { - "access_token": "Tibber Access Token" - } - } - }, - "error": { - "auth": "The Tibber Access Token is invalid.", - "connection": "Unable to connect to Tibber. Please check your internet connection.", - "unknown": "An unexpected error occurred. Please check the logs for details." - }, - "abort": { - "already_configured": "This entry is already configured.", - "entry_not_found": "Tibber configuration entry not found." + "config": { + "step": { + "user": { + "description": "If you need help with the configuration have a look here: https://github.com/jpawlowski/hass.tibber_prices", + "data": { + "access_token": "Tibber Access Token" } + } }, - "options": { - "step": { - "init": { - "title": "Update Tibber Configuration", - "description": "Update your Tibber API access token. If you need a new token, you can generate one at https://developer.tibber.com/settings/access-token", - "data": { - "access_token": "Tibber Access Token" - } - } - }, - "error": { - "auth": "The Tibber Access Token is invalid.", - "connection": "Unable to connect to Tibber. Please check your internet connection.", - "unknown": "An unexpected error occurred. Please check the logs for details.", - "different_account": "The new access token belongs to a different Tibber account. Please use a token from the same account or create a new configuration for the other account." - }, - "abort": { - "entry_not_found": "Tibber configuration entry not found." - } + "error": { + "auth": "The Tibber Access Token is invalid.", + "connection": "Unable to connect to Tibber. Please check your internet connection.", + "unknown": "An unexpected error occurred. Please check the logs for details." }, - "entity": { - "sensor": { - "current_price": { - "name": "Current Price", - "description": "The current hour's electricity price including taxes" - }, - "next_hour_price": { - "name": "Next Hour Price", - "description": "The next hour's electricity price including taxes" - }, - "price_level": { - "name": "Price Level", - "description": "Current price level indicator (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)" - }, - "lowest_price_today": { - "name": "Lowest Price Today", - "description": "The lowest electricity price for the current day" - }, - "highest_price_today": { - "name": "Highest Price Today", - "description": "The highest electricity price for the current day" - }, - "average_price_today": { - "name": "Average Price Today", - "description": "The average electricity price for the current day" - }, - "hourly_rating": { - "name": "Hourly Price Rating", - "description": "Price comparison with historical data for the current hour (percentage difference)" - }, - "daily_rating": { - "name": "Daily Price Rating", - "description": "Price comparison with historical data for the current day (percentage difference)" - }, - "monthly_rating": { - "name": "Monthly Price Rating", - "description": "Price comparison with historical data for the current month (percentage difference)" - }, - "data_timestamp": { - "name": "Last Data Update", - "description": "Timestamp of the most recent price data received from Tibber" - }, - "tomorrow_data_available": { - "name": "Tomorrow's Data Available", - "description": "Indicates if price data for tomorrow is available (Yes/No/Partial)" - } - }, - "binary_sensor": { - "peak_hour": { - "name": "Peak Hour", - "description": "Indicates if the current hour is in the top 20% most expensive hours of the day" - }, - "best_price_hour": { - "name": "Best Price Hour", - "description": "Indicates if the current hour is in the bottom 20% cheapest hours of the day" - }, - "connection": { - "name": "Connection Status", - "description": "Indicates if we have valid current price data from Tibber" - } - } + "abort": { + "already_configured": "This entry is already configured.", + "entry_not_found": "Tibber configuration entry not found." } + }, + "options": { + "step": { + "init": { + "title": "Update Tibber Configuration", + "description": "Update your Tibber API access token. If you need a new token, you can generate one at https://developer.tibber.com/settings/access-token", + "data": { + "access_token": "Tibber Access Token" + } + } + }, + "error": { + "auth": "The Tibber Access Token is invalid.", + "connection": "Unable to connect to Tibber. Please check your internet connection.", + "unknown": "An unexpected error occurred. Please check the logs for details.", + "different_account": "The new access token belongs to a different Tibber account. Please use a token from the same account or create a new configuration for the other account." + }, + "abort": { + "entry_not_found": "Tibber configuration entry not found." + } + }, + "entity": { + "sensor": { + "current_price": { + "name": "Current Price" + }, + "next_hour_price": { + "name": "Next Hour Price" + }, + "price_level": { + "name": "Price Level" + }, + "lowest_price_today": { + "name": "Lowest Price Today" + }, + "highest_price_today": { + "name": "Highest Price Today" + }, + "average_price_today": { + "name": "Average Price Today" + }, + "hourly_rating": { + "name": "Hourly Price Rating" + }, + "daily_rating": { + "name": "Daily Price Rating" + }, + "monthly_rating": { + "name": "Monthly Price Rating" + }, + "data_timestamp": { + "name": "Last Data Update" + }, + "tomorrow_data_available": { + "name": "Tomorrow's Data Available" + } + }, + "binary_sensor": { + "peak_hour": { + "name": "Peak Hour" + }, + "best_price_hour": { + "name": "Best Price Hour" + }, + "connection": { + "name": "Connection Status" + } + } + } } \ No newline at end of file diff --git a/hacs.json b/hacs.json index 22ae3d6..64cc279 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { - "name": "Tibber Price Information & Ratings", - "homeassistant": "2025.4.2", - "hacs": "2.0.1" + "name": "Tibber Price Information & Ratings", + "homeassistant": "2025.4.2", + "hacs": "2.0.1" } \ No newline at end of file diff --git a/scripts/json_schemas/manifest_schema.json b/scripts/json_schemas/manifest_schema.json new file mode 100644 index 0000000..7349f12 --- /dev/null +++ b/scripts/json_schemas/manifest_schema.json @@ -0,0 +1,391 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Home Assistant integration manifest", + "description": "The manifest for a Home Assistant integration", + "type": "object", + "if": { + "properties": { "integration_type": { "const": "virtual" } }, + "required": ["integration_type"] + }, + "then": { + "oneOf": [ + { + "properties": { + "domain": { + "description": "The domain identifier of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#domain", + "examples": ["mobile_app"], + "type": "string", + "pattern": "[0-9a-z_]+" + }, + "name": { + "description": "The friendly name of the integration.", + "type": "string" + }, + "integration_type": { + "description": "The integration type.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-type", + "const": "virtual" + }, + "iot_standards": { + "description": "The IoT standards which supports devices or services of this virtual integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#iot-standards", + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "enum": ["homekit", "zigbee", "zwave"] + } + } + }, + "additionalProperties": false, + "required": ["domain", "name", "integration_type", "iot_standards"] + }, + { + "properties": { + "domain": { + "description": "The domain identifier of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#domain", + "examples": ["mobile_app"], + "type": "string", + "pattern": "[0-9a-z_]+" + }, + "name": { + "description": "The friendly name of the integration.", + "type": "string" + }, + "integration_type": { + "description": "The integration type.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-type", + "const": "virtual" + }, + "supported_by": { + "description": "The integration which supports devices or services of this virtual integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#supported-by", + "type": "string" + } + }, + "additionalProperties": false, + "required": ["domain", "name", "integration_type", "supported_by"] + } + ] + }, + "else": { + "properties": { + "domain": { + "description": "The domain identifier of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#domain", + "examples": ["mobile_app"], + "type": "string", + "pattern": "[0-9a-z_]+" + }, + "name": { + "description": "The friendly name of the integration.", + "type": "string" + }, + "integration_type": { + "description": "The integration type.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-type", + "type": "string", + "default": "hub", + "enum": [ + "device", + "entity", + "hardware", + "helper", + "hub", + "service", + "system" + ] + }, + "config_flow": { + "description": "Whether the integration is configurable from the UI.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#config-flow", + "type": "boolean" + }, + "mqtt": { + "description": "A list of topics to subscribe for the discovery of devices via MQTT.\nThis requires to specify \"mqtt\" in either the \"dependencies\" or \"after_dependencies\".\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#mqtt", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "zeroconf": { + "description": "A list containing service domains to search for devices to discover via Zeroconf. Items can either be strings, which discovers all devices in the specific service domain, and/or objects which include filters. (useful for generic service domains like _http._tcp.local.)\nA device is discovered if it matches one of the items, but inside the individual item all properties have to be matched.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#zeroconf", + "type": "array", + "minItems": 1, + "items": { + "anyOf": [ + { + "type": "string", + "pattern": "^.*\\.local\\.$", + "description": "Service domain to search for devices." + }, + { + "type": "object", + "properties": { + "type": { + "description": "The service domain to search for devices.", + "examples": ["_http._tcp.local."], + "type": "string", + "pattern": "^.*\\.local\\.$" + }, + "name": { + "description": "The name or name pattern of the devices to filter.", + "type": "string" + }, + "properties": { + "description": "The properties of the Zeroconf advertisement to filter.", + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + "ssdp": { + "description": "A list of matchers to find devices discoverable via SSDP/UPnP. In order to be discovered, the device has to match all properties of any of the matchers.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#ssdp", + "type": "array", + "minItems": 1, + "items": { + "description": "A matcher for the SSDP discovery.", + "type": "object", + "properties": { + "st": { + "type": "string" + }, + "deviceType": { + "type": "string" + }, + "manufacturer": { + "type": "string" + }, + "modelDescription": { + "type": "string" + } + }, + "additionalProperties": { "type": "string" } + } + }, + "bluetooth": { + "description": "A list of matchers to find devices discoverable via Bluetooth. In order to be discovered, the device has to match all properties of any of the matchers.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#bluetooth", + "type": "array", + "minItems": 1, + "items": { + "description": "A matcher for the bluetooth discovery", + "type": "object", + "properties": { + "connectable": { + "description": "Whether the device needs to be connected to or it works with just advertisement data.", + "type": "boolean" + }, + "local_name": { + "description": "The name or a name pattern of the device to match.", + "type": "string", + "pattern": "^([^*]+|[^*]{3,}[*].*)$" + }, + "service_uuid": { + "description": "The 128-bit service data UUID to match.", + "type": "string", + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + }, + "service_data_uuid": { + "description": "The 16-bit service data UUID to match, converted into the corresponding 128-bit UUID by replacing the 3rd and 4th byte of `00000000-0000-1000-8000-00805f9b34fb` with the 16-bit UUID.", + "examples": ["0000fd3d-0000-1000-8000-00805f9b34fb"], + "type": "string", + "pattern": "0000[0-9a-f]{4}-0000-1000-8000-00805f9b34fb" + }, + "manufacturer_id": { + "description": "The Manufacturer ID to match.", + "type": "integer" + }, + "manufacturer_data_start": { + "description": "The start bytes of the manufacturer data to match.", + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + }, + "additionalProperties": false + }, + "uniqueItems": true + }, + "homekit": { + "description": "A list of model names to find devices which are discoverable via HomeKit. A device is discovered if the model name of the device starts with any of the specified model names.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#homekit", + "type": "object", + "properties": { + "models": { + "description": "The model names to search for.", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "required": ["models"], + "additionalProperties": false + }, + "dhcp": { + "description": "A list of matchers to find devices discoverable via DHCP. In order to be discovered, the device has to match all properties of any of the matchers.\nYou can specify an item with \"registered_devices\" set to true to check for devices with MAC addresses specified in the device registry.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#dhcp", + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "registered_devices": { + "description": "Whether the MAC addresses of devices in the device registry should be used for discovery, useful if the discovery is used to update the IP address of already registered devices.", + "const": true + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "hostname": { + "description": "The hostname or hostname pattern to match.", + "type": "string" + }, + "macaddress": { + "description": "The MAC address or MAC address pattern to match.", + "type": "string", + "maxLength": 12 + } + }, + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + "usb": { + "description": "A list of matchers to find devices discoverable via USB. In order to be discovered, the device has to match all properties of any of the matchers.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#usb", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "vid": { + "description": "The vendor ID to match.", + "type": "string", + "pattern": "[0-9A-F]{4}" + }, + "pid": { + "description": "The product ID to match.", + "type": "string", + "pattern": "[0-9A-F]{4}" + }, + "description": { + "description": "The USB device description to match.", + "type": "string" + }, + "manufacturer": { + "description": "The manufacturer to match.", + "type": "string" + }, + "serial_number": { + "description": "The serial number to match.", + "type": "string" + }, + "known_devices": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "documentation": { + "description": "The website containing the documentation for the integration. It has to be in the format \"https://www.home-assistant.io/integrations/[domain]\"\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#documentation", + "type": "string", + "pattern": "^https://www.home-assistant.io/integrations/[0-9a-z_]+$", + "format": "uri" + }, + "quality_scale": { + "description": "The quality scale of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-quality-scale", + "type": "string", + "enum": ["bronze", "silver", "gold", "platinum", "internal", "legacy"] + }, + "requirements": { + "description": "The PyPI package requirements for the integration. The package has to be pinned to a specific version.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#requirements", + "type": "array", + "items": { + "type": "string", + "pattern": ".+==.+" + }, + "uniqueItems": true + }, + "dependencies": { + "description": "A list of integrations which need to be loaded before this integration can be set up.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#dependencies", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "after_dependencies": { + "description": "A list of integrations which need to be loaded before this integration is set up when it is configured. The integration will still be set up when the \"after_dependencies\" are not configured.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#after-dependencies", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "codeowners": { + "description": "A list of GitHub usernames or GitHub team names of the integration owners.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#code-owners", + "type": "array", + "minItems": 0, + "items": { + "type": "string", + "pattern": "^@.+$" + }, + "uniqueItems": true + }, + "loggers": { + "description": "A list of logger names used by the requirements.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#loggers", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "disabled": { + "description": "The reason for the integration being disabled.", + "type": "string" + }, + "iot_class": { + "description": "The IoT class of the integration, describing how the integration connects to the device or service.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#iot-class", + "type": "string", + "enum": [ + "assumed_state", + "cloud_polling", + "cloud_push", + "local_polling", + "local_push", + "calculated" + ] + }, + "single_config_entry": { + "description": "Whether the integration only supports a single config entry.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#single-config-entry-only", + "const": true + } + }, + "additionalProperties": false, + "required": ["domain", "name", "codeowners", "documentation"], + "dependencies": { + "mqtt": { + "anyOf": [ + { "required": ["dependencies"] }, + { "required": ["after_dependencies"] } + ] + } + } + } +} diff --git a/scripts/json_schemas/translation_schema.json b/scripts/json_schemas/translation_schema.json new file mode 100644 index 0000000..a47ae45 --- /dev/null +++ b/scripts/json_schemas/translation_schema.json @@ -0,0 +1,51 @@ +{ + "title": "Component Title", + "config": { + "step": { + "user": { + "title": "Step Title", + "description": "Step Description", + "data": { + "field_name": "Field Label" + } + } + }, + "error": { + "error_key": "Error Message" + }, + "abort": { + "abort_key": "Abort Message" + } + }, + "entity": { + "sensor": { + "sensor_key": { + "name": "Sensor Name", + "state": { + "state_key": "State Translation" + } + } + }, + "binary_sensor": { + "sensor_key": { + "name": "Binary Sensor Name", + "state": { + "on": "On State Translation", + "off": "Off State Translation" + } + } + } + }, + "services": { + "service_name": { + "name": "Service Name", + "description": "Service Description", + "fields": { + "field_name": { + "name": "Field Name", + "description": "Field Description" + } + } + } + } +}