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 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),

View file

@ -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

View file

@ -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."""

View file

@ -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()

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
class BlueprintDataUpdateCoordinator(DataUpdateCoordinator):
class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching data from the API."""
config_entry: TibberPricesConfigEntry

View file

@ -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

View file

@ -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

View file

@ -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."""

View file

@ -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."
},