diff --git a/custom_components/tibber_prices/__init__.py b/custom_components/tibber_prices/__init__.py index 05b892a..3c08c53 100644 --- a/custom_components/tibber_prices/__init__.py +++ b/custom_components/tibber_prices/__init__.py @@ -10,13 +10,13 @@ from __future__ import annotations from datetime import timedelta from typing import TYPE_CHECKING -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.loader import async_get_loaded_integration from .api import TibberPricesApiClient from .const import DOMAIN, LOGGER -from .coordinator import BlueprintDataUpdateCoordinator +from .coordinator import TibberPricesDataUpdateCoordinator from .data import TibberPricesData if TYPE_CHECKING: @@ -37,7 +37,7 @@ async def async_setup_entry( entry: TibberPricesConfigEntry, ) -> bool: """Set up this integration using UI.""" - coordinator = BlueprintDataUpdateCoordinator( + coordinator = TibberPricesDataUpdateCoordinator( hass=hass, logger=LOGGER, name=DOMAIN, @@ -45,8 +45,7 @@ async def async_setup_entry( ) entry.runtime_data = TibberPricesData( client=TibberPricesApiClient( - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], + access_token=entry.data[CONF_ACCESS_TOKEN], session=async_get_clientsession(hass), ), integration=async_get_loaded_integration(hass, entry.domain), diff --git a/custom_components/tibber_prices/api.py b/custom_components/tibber_prices/api.py index 9c2923d..f724d4b 100644 --- a/custom_components/tibber_prices/api.py +++ b/custom_components/tibber_prices/api.py @@ -1,4 +1,4 @@ -"""Sample API Client.""" +"""Tibber API Client.""" from __future__ import annotations @@ -7,17 +7,26 @@ from typing import Any import aiohttp import async_timeout +from homeassistant.const import __version__ as ha_version class TibberPricesApiClientError(Exception): """Exception to indicate a general API error.""" + UNKNOWN_ERROR = "Unknown GraphQL error" + MALFORMED_ERROR = "Malformed GraphQL error: {error}" + GRAPHQL_ERROR = "GraphQL error: {message}" + GENERIC_ERROR = "Something went wrong! {exception}" + class TibberPricesApiClientCommunicationError( TibberPricesApiClientError, ): """Exception to indicate a communication error.""" + TIMEOUT_ERROR = "Timeout error fetching information - {exception}" + CONNECTION_ERROR = "Error fetching information - {exception}" + class TibberPricesApiClientAuthenticationError( TibberPricesApiClientError, @@ -35,67 +44,143 @@ def _verify_response_or_raise(response: aiohttp.ClientResponse) -> None: response.raise_for_status() +async def _verify_graphql_response(response_json: dict) -> None: + """ + Verify the GraphQL response for errors. + + GraphQL errors follow this structure: + { + "errors": [{ + "message": "error message", + "locations": [...], + "path": [...], + "extensions": { + "code": "ERROR_CODE" + } + }] + } + """ + if "errors" not in response_json: + return + + errors = response_json["errors"] + if not errors: + raise TibberPricesApiClientError(TibberPricesApiClientError.UNKNOWN_ERROR) + + error = errors[0] # Take first error + if not isinstance(error, dict): + raise TibberPricesApiClientError( + TibberPricesApiClientError.MALFORMED_ERROR.format(error=error) + ) + + message = error.get("message", "Unknown error") + extensions = error.get("extensions", {}) + + # Check for authentication errors first + if extensions.get("code") == "UNAUTHENTICATED": + raise TibberPricesApiClientAuthenticationError(message) + + # Handle all other GraphQL errors + raise TibberPricesApiClientError( + TibberPricesApiClientError.GRAPHQL_ERROR.format(message=message) + ) + + class TibberPricesApiClient: - """Sample API Client.""" + """Tibber API Client.""" def __init__( self, - username: str, - password: str, + access_token: str, session: aiohttp.ClientSession, ) -> None: - """Sample API Client.""" - self._username = username - self._password = password + """Tibber API Client.""" + self._access_token = access_token self._session = session + async def async_test_connection(self) -> Any: + """Test connection to the API.""" + return await self._api_wrapper( + data={ + "query": """ + query { + viewer { + name + } + } + """, + }, + ) + async def async_get_data(self) -> Any: """Get data from the API.""" return await self._api_wrapper( - method="get", - url="https://jsonplaceholder.typicode.com/posts/1", + data={ + "query": """ + query { + viewer { + homes { + timeZone + currentSubscription { + status + } + } + } + } + """, + }, ) async def async_set_title(self, value: str) -> Any: """Get data from the API.""" return await self._api_wrapper( - method="patch", - url="https://jsonplaceholder.typicode.com/posts/1", data={"title": value}, - headers={"Content-type": "application/json; charset=UTF-8"}, ) async def _api_wrapper( self, - method: str, - url: str, data: dict | None = None, headers: dict | None = None, ) -> Any: """Get information from the API.""" try: async with async_timeout.timeout(10): + headers = headers or {} + if headers.get("Authorization") is None: + headers["Authorization"] = f"Bearer {self._access_token}" + if headers.get("Accept") is None: + headers["Accept"] = "application/json" + if headers.get("User-Agent") is None: + headers["User-Agent"] = ( + f"HomeAssistant/{ha_version} (tibber_prices; +https://github.com/jpawlowski/hass.tibber_prices/)" + ) response = await self._session.request( - method=method, - url=url, + method="POST", + url="https://api.tibber.com/v1-beta/gql", headers=headers, json=data, ) _verify_response_or_raise(response) - return await response.json() + response_json = await response.json() + await _verify_graphql_response(response_json) + return response_json except TimeoutError as exception: - msg = f"Timeout error fetching information - {exception}" raise TibberPricesApiClientCommunicationError( - msg, + TibberPricesApiClientCommunicationError.TIMEOUT_ERROR.format( + exception=exception + ) ) from exception except (aiohttp.ClientError, socket.gaierror) as exception: - msg = f"Error fetching information - {exception}" raise TibberPricesApiClientCommunicationError( - msg, + TibberPricesApiClientCommunicationError.CONNECTION_ERROR.format( + exception=exception + ) ) from exception + except TibberPricesApiClientAuthenticationError: + # Re-raise authentication errors directly + raise except Exception as exception: # pylint: disable=broad-except - msg = f"Something really wrong happened! - {exception}" raise TibberPricesApiClientError( - msg, + TibberPricesApiClientError.GENERIC_ERROR.format(exception=exception) ) from exception diff --git a/custom_components/tibber_prices/binary_sensor.py b/custom_components/tibber_prices/binary_sensor.py index 47fa322..67bba64 100644 --- a/custom_components/tibber_prices/binary_sensor.py +++ b/custom_components/tibber_prices/binary_sensor.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback - from .coordinator import BlueprintDataUpdateCoordinator + from .coordinator import TibberPricesDataUpdateCoordinator from .data import TibberPricesConfigEntry ENTITY_DESCRIPTIONS = ( @@ -48,7 +48,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): def __init__( self, - coordinator: BlueprintDataUpdateCoordinator, + coordinator: TibberPricesDataUpdateCoordinator, entity_description: BinarySensorEntityDescription, ) -> None: """Initialize the binary_sensor class.""" diff --git a/custom_components/tibber_prices/config_flow.py b/custom_components/tibber_prices/config_flow.py index d86f3a5..28bd8dc 100644 --- a/custom_components/tibber_prices/config_flow.py +++ b/custom_components/tibber_prices/config_flow.py @@ -1,10 +1,10 @@ -"""Adds config flow for Blueprint.""" +"""Adds config flow for tibber_prices.""" from __future__ import annotations import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_create_clientsession from slugify import slugify @@ -19,7 +19,7 @@ from .const import DOMAIN, LOGGER class BlueprintFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): - """Config flow for Blueprint.""" + """Config flow for tibber_prices.""" VERSION = 1 @@ -31,10 +31,7 @@ class BlueprintFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _errors = {} if user_input is not None: try: - await self._test_credentials( - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - ) + await self._test_credentials(access_token=user_input[CONF_ACCESS_TOKEN]) except TibberPricesApiClientAuthenticationError as exception: LOGGER.warning(exception) _errors["base"] = "auth" @@ -49,11 +46,11 @@ class BlueprintFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ## Do NOT use this in production code ## The unique_id should never be something that can change ## https://developers.home-assistant.io/docs/config_entries_config_flow_handler#unique-ids - unique_id=slugify(user_input[CONF_USERNAME]) + unique_id=slugify(user_input[CONF_ACCESS_TOKEN]) ) self._abort_if_unique_id_configured() return self.async_create_entry( - title=user_input[CONF_USERNAME], + title=user_input[CONF_ACCESS_TOKEN], data=user_input, ) @@ -62,28 +59,24 @@ class BlueprintFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required( - CONF_USERNAME, - default=(user_input or {}).get(CONF_USERNAME, vol.UNDEFINED), + CONF_ACCESS_TOKEN, + default=(user_input or {}).get( + CONF_ACCESS_TOKEN, vol.UNDEFINED + ), ): selector.TextSelector( selector.TextSelectorConfig( type=selector.TextSelectorType.TEXT, ), ), - vol.Required(CONF_PASSWORD): selector.TextSelector( - selector.TextSelectorConfig( - type=selector.TextSelectorType.PASSWORD, - ), - ), }, ), errors=_errors, ) - async def _test_credentials(self, username: str, password: str) -> None: + async def _test_credentials(self, access_token: str) -> None: """Validate credentials.""" client = TibberPricesApiClient( - username=username, - password=password, + access_token=access_token, session=async_create_clientsession(self.hass), ) - await client.async_get_data() + await client.async_test_connection() diff --git a/custom_components/tibber_prices/coordinator.py b/custom_components/tibber_prices/coordinator.py index bcdae36..1cf39cc 100644 --- a/custom_components/tibber_prices/coordinator.py +++ b/custom_components/tibber_prices/coordinator.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: # https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities -class BlueprintDataUpdateCoordinator(DataUpdateCoordinator): +class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" config_entry: TibberPricesConfigEntry diff --git a/custom_components/tibber_prices/data.py b/custom_components/tibber_prices/data.py index 537fd67..2faf813 100644 --- a/custom_components/tibber_prices/data.py +++ b/custom_components/tibber_prices/data.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from homeassistant.loader import Integration from .api import TibberPricesApiClient - from .coordinator import BlueprintDataUpdateCoordinator + from .coordinator import TibberPricesDataUpdateCoordinator type TibberPricesConfigEntry = ConfigEntry[TibberPricesData] @@ -21,5 +21,5 @@ class TibberPricesData: """Data for the Blueprint integration.""" client: TibberPricesApiClient - coordinator: BlueprintDataUpdateCoordinator + coordinator: TibberPricesDataUpdateCoordinator integration: Integration diff --git a/custom_components/tibber_prices/entity.py b/custom_components/tibber_prices/entity.py index 8ae0e7b..ab2de5c 100644 --- a/custom_components/tibber_prices/entity.py +++ b/custom_components/tibber_prices/entity.py @@ -6,15 +6,15 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION -from .coordinator import BlueprintDataUpdateCoordinator +from .coordinator import TibberPricesDataUpdateCoordinator -class TibberPricesEntity(CoordinatorEntity[BlueprintDataUpdateCoordinator]): +class TibberPricesEntity(CoordinatorEntity[TibberPricesDataUpdateCoordinator]): """BlueprintEntity class.""" _attr_attribution = ATTRIBUTION - def __init__(self, coordinator: BlueprintDataUpdateCoordinator) -> None: + def __init__(self, coordinator: TibberPricesDataUpdateCoordinator) -> None: """Initialize.""" super().__init__(coordinator) self._attr_unique_id = coordinator.config_entry.entry_id diff --git a/custom_components/tibber_prices/sensor.py b/custom_components/tibber_prices/sensor.py index d4c6950..898061d 100644 --- a/custom_components/tibber_prices/sensor.py +++ b/custom_components/tibber_prices/sensor.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback - from .coordinator import BlueprintDataUpdateCoordinator + from .coordinator import TibberPricesDataUpdateCoordinator from .data import TibberPricesConfigEntry ENTITY_DESCRIPTIONS = ( @@ -44,7 +44,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): def __init__( self, - coordinator: BlueprintDataUpdateCoordinator, + coordinator: TibberPricesDataUpdateCoordinator, entity_description: SensorEntityDescription, ) -> None: """Initialize the sensor class.""" diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index 2770473..237e7ff 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -4,13 +4,12 @@ "user": { "description": "If you need help with the configuration have a look here: https://github.com/jpawlowski/hass.tibber_prices", "data": { - "username": "Username", - "password": "Password" + "access_token": "Tibber Access Token" } } }, "error": { - "auth": "Username/Password is wrong.", + "auth": "Tibber Access Token is wrong.", "connection": "Unable to connect to the server.", "unknown": "Unknown error occurred." },