add authentication

This commit is contained in:
Julian Pawlowski 2025-04-18 16:10:14 +00:00
parent ef38bf75b5
commit 73fa6c18a7
9 changed files with 137 additions and 61 deletions

View file

@ -10,13 +10,13 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING 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.helpers.aiohttp_client import async_get_clientsession
from homeassistant.loader import async_get_loaded_integration from homeassistant.loader import async_get_loaded_integration
from .api import TibberPricesApiClient from .api import TibberPricesApiClient
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
from .coordinator import BlueprintDataUpdateCoordinator from .coordinator import TibberPricesDataUpdateCoordinator
from .data import TibberPricesData from .data import TibberPricesData
if TYPE_CHECKING: if TYPE_CHECKING:
@ -37,7 +37,7 @@ async def async_setup_entry(
entry: TibberPricesConfigEntry, entry: TibberPricesConfigEntry,
) -> bool: ) -> bool:
"""Set up this integration using UI.""" """Set up this integration using UI."""
coordinator = BlueprintDataUpdateCoordinator( coordinator = TibberPricesDataUpdateCoordinator(
hass=hass, hass=hass,
logger=LOGGER, logger=LOGGER,
name=DOMAIN, name=DOMAIN,
@ -45,8 +45,7 @@ async def async_setup_entry(
) )
entry.runtime_data = TibberPricesData( entry.runtime_data = TibberPricesData(
client=TibberPricesApiClient( client=TibberPricesApiClient(
username=entry.data[CONF_USERNAME], access_token=entry.data[CONF_ACCESS_TOKEN],
password=entry.data[CONF_PASSWORD],
session=async_get_clientsession(hass), session=async_get_clientsession(hass),
), ),
integration=async_get_loaded_integration(hass, entry.domain), integration=async_get_loaded_integration(hass, entry.domain),

View file

@ -1,4 +1,4 @@
"""Sample API Client.""" """Tibber API Client."""
from __future__ import annotations from __future__ import annotations
@ -7,17 +7,26 @@ from typing import Any
import aiohttp import aiohttp
import async_timeout import async_timeout
from homeassistant.const import __version__ as ha_version
class TibberPricesApiClientError(Exception): class TibberPricesApiClientError(Exception):
"""Exception to indicate a general API error.""" """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( class TibberPricesApiClientCommunicationError(
TibberPricesApiClientError, TibberPricesApiClientError,
): ):
"""Exception to indicate a communication error.""" """Exception to indicate a communication error."""
TIMEOUT_ERROR = "Timeout error fetching information - {exception}"
CONNECTION_ERROR = "Error fetching information - {exception}"
class TibberPricesApiClientAuthenticationError( class TibberPricesApiClientAuthenticationError(
TibberPricesApiClientError, TibberPricesApiClientError,
@ -35,67 +44,143 @@ def _verify_response_or_raise(response: aiohttp.ClientResponse) -> None:
response.raise_for_status() 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: class TibberPricesApiClient:
"""Sample API Client.""" """Tibber API Client."""
def __init__( def __init__(
self, self,
username: str, access_token: str,
password: str,
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
) -> None: ) -> None:
"""Sample API Client.""" """Tibber API Client."""
self._username = username self._access_token = access_token
self._password = password
self._session = session 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: async def async_get_data(self) -> Any:
"""Get data from the API.""" """Get data from the API."""
return await self._api_wrapper( return await self._api_wrapper(
method="get", data={
url="https://jsonplaceholder.typicode.com/posts/1", "query": """
query {
viewer {
homes {
timeZone
currentSubscription {
status
}
}
}
}
""",
},
) )
async def async_set_title(self, value: str) -> Any: async def async_set_title(self, value: str) -> Any:
"""Get data from the API.""" """Get data from the API."""
return await self._api_wrapper( return await self._api_wrapper(
method="patch",
url="https://jsonplaceholder.typicode.com/posts/1",
data={"title": value}, data={"title": value},
headers={"Content-type": "application/json; charset=UTF-8"},
) )
async def _api_wrapper( async def _api_wrapper(
self, self,
method: str,
url: str,
data: dict | None = None, data: dict | None = None,
headers: dict | None = None, headers: dict | None = None,
) -> Any: ) -> Any:
"""Get information from the API.""" """Get information from the API."""
try: try:
async with async_timeout.timeout(10): 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( response = await self._session.request(
method=method, method="POST",
url=url, url="https://api.tibber.com/v1-beta/gql",
headers=headers, headers=headers,
json=data, json=data,
) )
_verify_response_or_raise(response) _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: except TimeoutError as exception:
msg = f"Timeout error fetching information - {exception}"
raise TibberPricesApiClientCommunicationError( raise TibberPricesApiClientCommunicationError(
msg, TibberPricesApiClientCommunicationError.TIMEOUT_ERROR.format(
exception=exception
)
) from exception ) from exception
except (aiohttp.ClientError, socket.gaierror) as exception: except (aiohttp.ClientError, socket.gaierror) as exception:
msg = f"Error fetching information - {exception}"
raise TibberPricesApiClientCommunicationError( raise TibberPricesApiClientCommunicationError(
msg, TibberPricesApiClientCommunicationError.CONNECTION_ERROR.format(
exception=exception
)
) from exception ) from exception
except TibberPricesApiClientAuthenticationError:
# Re-raise authentication errors directly
raise
except Exception as exception: # pylint: disable=broad-except except Exception as exception: # pylint: disable=broad-except
msg = f"Something really wrong happened! - {exception}"
raise TibberPricesApiClientError( raise TibberPricesApiClientError(
msg, TibberPricesApiClientError.GENERIC_ERROR.format(exception=exception)
) from exception ) from exception

View file

@ -16,7 +16,7 @@ if TYPE_CHECKING:
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import BlueprintDataUpdateCoordinator from .coordinator import TibberPricesDataUpdateCoordinator
from .data import TibberPricesConfigEntry from .data import TibberPricesConfigEntry
ENTITY_DESCRIPTIONS = ( ENTITY_DESCRIPTIONS = (
@ -48,7 +48,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
def __init__( def __init__(
self, self,
coordinator: BlueprintDataUpdateCoordinator, coordinator: TibberPricesDataUpdateCoordinator,
entity_description: BinarySensorEntityDescription, entity_description: BinarySensorEntityDescription,
) -> None: ) -> None:
"""Initialize the binary_sensor class.""" """Initialize the binary_sensor class."""

View file

@ -1,10 +1,10 @@
"""Adds config flow for Blueprint.""" """Adds config flow for tibber_prices."""
from __future__ import annotations from __future__ import annotations
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries 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 import selector
from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.aiohttp_client import async_create_clientsession
from slugify import slugify from slugify import slugify
@ -19,7 +19,7 @@ from .const import DOMAIN, LOGGER
class BlueprintFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class BlueprintFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Blueprint.""" """Config flow for tibber_prices."""
VERSION = 1 VERSION = 1
@ -31,10 +31,7 @@ class BlueprintFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
_errors = {} _errors = {}
if user_input is not None: if user_input is not None:
try: try:
await self._test_credentials( await self._test_credentials(access_token=user_input[CONF_ACCESS_TOKEN])
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
except TibberPricesApiClientAuthenticationError as exception: except TibberPricesApiClientAuthenticationError as exception:
LOGGER.warning(exception) LOGGER.warning(exception)
_errors["base"] = "auth" _errors["base"] = "auth"
@ -49,11 +46,11 @@ class BlueprintFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
## Do NOT use this in production code ## Do NOT use this in production code
## The unique_id should never be something that can change ## The unique_id should never be something that can change
## https://developers.home-assistant.io/docs/config_entries_config_flow_handler#unique-ids ## 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() self._abort_if_unique_id_configured()
return self.async_create_entry( return self.async_create_entry(
title=user_input[CONF_USERNAME], title=user_input[CONF_ACCESS_TOKEN],
data=user_input, data=user_input,
) )
@ -62,28 +59,24 @@ class BlueprintFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required( vol.Required(
CONF_USERNAME, CONF_ACCESS_TOKEN,
default=(user_input or {}).get(CONF_USERNAME, vol.UNDEFINED), default=(user_input or {}).get(
CONF_ACCESS_TOKEN, vol.UNDEFINED
),
): selector.TextSelector( ): selector.TextSelector(
selector.TextSelectorConfig( selector.TextSelectorConfig(
type=selector.TextSelectorType.TEXT, type=selector.TextSelectorType.TEXT,
), ),
), ),
vol.Required(CONF_PASSWORD): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.PASSWORD,
),
),
}, },
), ),
errors=_errors, errors=_errors,
) )
async def _test_credentials(self, username: str, password: str) -> None: async def _test_credentials(self, access_token: str) -> None:
"""Validate credentials.""" """Validate credentials."""
client = TibberPricesApiClient( client = TibberPricesApiClient(
username=username, access_token=access_token,
password=password,
session=async_create_clientsession(self.hass), session=async_create_clientsession(self.hass),
) )
await client.async_get_data() await client.async_test_connection()

View file

@ -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 # 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.""" """Class to manage fetching data from the API."""
config_entry: TibberPricesConfigEntry config_entry: TibberPricesConfigEntry

View file

@ -10,7 +10,7 @@ if TYPE_CHECKING:
from homeassistant.loader import Integration from homeassistant.loader import Integration
from .api import TibberPricesApiClient from .api import TibberPricesApiClient
from .coordinator import BlueprintDataUpdateCoordinator from .coordinator import TibberPricesDataUpdateCoordinator
type TibberPricesConfigEntry = ConfigEntry[TibberPricesData] type TibberPricesConfigEntry = ConfigEntry[TibberPricesData]
@ -21,5 +21,5 @@ class TibberPricesData:
"""Data for the Blueprint integration.""" """Data for the Blueprint integration."""
client: TibberPricesApiClient client: TibberPricesApiClient
coordinator: BlueprintDataUpdateCoordinator coordinator: TibberPricesDataUpdateCoordinator
integration: Integration integration: Integration

View file

@ -6,15 +6,15 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION from .const import ATTRIBUTION
from .coordinator import BlueprintDataUpdateCoordinator from .coordinator import TibberPricesDataUpdateCoordinator
class TibberPricesEntity(CoordinatorEntity[BlueprintDataUpdateCoordinator]): class TibberPricesEntity(CoordinatorEntity[TibberPricesDataUpdateCoordinator]):
"""BlueprintEntity class.""" """BlueprintEntity class."""
_attr_attribution = ATTRIBUTION _attr_attribution = ATTRIBUTION
def __init__(self, coordinator: BlueprintDataUpdateCoordinator) -> None: def __init__(self, coordinator: TibberPricesDataUpdateCoordinator) -> None:
"""Initialize.""" """Initialize."""
super().__init__(coordinator) super().__init__(coordinator)
self._attr_unique_id = coordinator.config_entry.entry_id self._attr_unique_id = coordinator.config_entry.entry_id

View file

@ -12,7 +12,7 @@ if TYPE_CHECKING:
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import BlueprintDataUpdateCoordinator from .coordinator import TibberPricesDataUpdateCoordinator
from .data import TibberPricesConfigEntry from .data import TibberPricesConfigEntry
ENTITY_DESCRIPTIONS = ( ENTITY_DESCRIPTIONS = (
@ -44,7 +44,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
def __init__( def __init__(
self, self,
coordinator: BlueprintDataUpdateCoordinator, coordinator: TibberPricesDataUpdateCoordinator,
entity_description: SensorEntityDescription, entity_description: SensorEntityDescription,
) -> None: ) -> None:
"""Initialize the sensor class.""" """Initialize the sensor class."""

View file

@ -4,13 +4,12 @@
"user": { "user": {
"description": "If you need help with the configuration have a look here: https://github.com/jpawlowski/hass.tibber_prices", "description": "If you need help with the configuration have a look here: https://github.com/jpawlowski/hass.tibber_prices",
"data": { "data": {
"username": "Username", "access_token": "Tibber Access Token"
"password": "Password"
} }
} }
}, },
"error": { "error": {
"auth": "Username/Password is wrong.", "auth": "Tibber Access Token is wrong.",
"connection": "Unable to connect to the server.", "connection": "Unable to connect to the server.",
"unknown": "Unknown error occurred." "unknown": "Unknown error occurred."
}, },