Compare commits

...

37 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
9defa5248b available
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-19 09:59:18 +01:00
Daniel Hjelseth Høyer
6934737258 Merge branch 'dev' into tibber_data 2025-12-19 09:58:28 +01:00
Daniel Hjelseth Høyer
9e0fb2ca0e Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-16 06:10:37 +01:00
Daniel Hjelseth Høyer
da75c36e0e Merge branch 'dev' into tibber_data 2025-12-15 06:39:44 +01:00
Daniel Hjelseth Høyer
b604f26d16 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-14 19:37:22 +01:00
Daniel Hjelseth Høyer
80dfa73ebc Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-14 07:49:28 +01:00
Daniel Hjelseth Høyer
bc41c12fad Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:28 +01:00
Daniel Hjelseth Høyer
4ed9b54d60 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:28 +01:00
Daniel Hjelseth Høyer
072f80d847 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:28 +01:00
Daniel Hjelseth Høyer
7316b8c4e1 test
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:28 +01:00
Daniel Hjelseth Høyer
2d877eea79 Update homeassistant/components/tibber/sensor.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-12 13:49:27 +01:00
Daniel Hjelseth Høyer
b99d4cdeca tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:27 +01:00
Daniel Hjelseth Høyer
4665cbe949 adjust available state
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:27 +01:00
Daniel Hjelseth Høyer
9db0ab3afe config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:27 +01:00
Daniel Hjelseth Høyer
60b4baf852 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:27 +01:00
Daniel Hjelseth Høyer
82495606fd config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:27 +01:00
Daniel Hjelseth Høyer
f9e7e61df0 fix strings
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:27 +01:00
Daniel Hjelseth Høyer
ef96e7f47c config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:26 +01:00
Daniel Hjelseth Høyer
84b54f1546 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:26 +01:00
Daniel Hjelseth Høyer
dd9b86734f config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:26 +01:00
Daniel Hjelseth Høyer
6b52566614 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:26 +01:00
Daniel Hjelseth Høyer
76aa60a804 test
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:25 +01:00
Daniel Hjelseth Høyer
8d068374aa test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:25 +01:00
Daniel Hjelseth Høyer
1cea6285a3 test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:25 +01:00
Daniel Hjelseth Høyer
9dac4b35b9 test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:25 +01:00
Daniel Hjelseth Høyer
4329a10e17 test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:25 +01:00
Daniel Hjelseth Høyer
2270f353d6 tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:25 +01:00
Daniel Hjelseth Høyer
fe03355717 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:25 +01:00
Daniel Hjelseth Høyer
2141181ccc Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:24 +01:00
Daniel Hjelseth Høyer
203d6b8e88 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:24 +01:00
Daniel Hjelseth Høyer
3a3cf25eca Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:24 +01:00
Daniel Hjelseth Høyer
772571dbff Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:24 +01:00
Daniel Hjelseth Høyer
f4395aaa38 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:24 +01:00
Daniel Hjelseth Høyer
8056cfc9f4 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:24 +01:00
Daniel Hjelseth Høyer
1d576dca3c Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:24 +01:00
Daniel Hjelseth Høyer
1d3cef96a9 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:23 +01:00
Daniel Hjelseth Høyer
0797ace385 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:23 +01:00
19 changed files with 1224 additions and 191 deletions

View File

@@ -1,20 +1,36 @@
"""Support for Tibber.""" """Support for Tibber."""
from __future__ import annotations
from dataclasses import dataclass, field
import logging import logging
import aiohttp import aiohttp
from aiohttp.client_exceptions import ClientError, ClientResponseError
import tibber import tibber
from tibber import data_api as tibber_data_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util, ssl as ssl_util from homeassistant.util import dt as dt_util, ssl as ssl_util
from .const import DATA_HASS_CONFIG, DOMAIN from .const import (
AUTH_IMPLEMENTATION,
CONF_LEGACY_ACCESS_TOKEN,
DATA_HASS_CONFIG,
DOMAIN,
TibberConfigEntry,
)
from .coordinator import TibberDataAPICoordinator
from .services import async_setup_services from .services import async_setup_services
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
@@ -24,6 +40,33 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@dataclass
class TibberRuntimeData:
"""Runtime data for Tibber API entries."""
tibber_connection: tibber.Tibber
session: OAuth2Session
data_api_coordinator: TibberDataAPICoordinator | None = field(default=None)
_client: tibber_data_api.TibberDataAPI | None = None
async def async_get_client(
self, hass: HomeAssistant
) -> tibber_data_api.TibberDataAPI:
"""Return an authenticated Tibber Data API client."""
await self.session.async_ensure_token_valid()
token = self.session.token
access_token = token.get(CONF_ACCESS_TOKEN)
if not access_token:
raise ConfigEntryAuthFailed("Access token missing from OAuth session")
if self._client is None:
self._client = tibber_data_api.TibberDataAPI(
access_token,
websession=async_get_clientsession(hass),
)
self._client.set_access_token(access_token)
return self._client
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Tibber component.""" """Set up the Tibber component."""
@@ -34,16 +77,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bool:
"""Set up a config entry.""" """Set up a config entry."""
# Added in 2026.1 to migrate existing users to OAuth2 (Tibber Data API).
# Can be removed after 2026.5
if AUTH_IMPLEMENTATION not in entry.data:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="data_api_reauth_required",
)
tibber_connection = tibber.Tibber( tibber_connection = tibber.Tibber(
access_token=entry.data[CONF_ACCESS_TOKEN], access_token=entry.data[CONF_LEGACY_ACCESS_TOKEN],
websession=async_get_clientsession(hass), websession=async_get_clientsession(hass),
time_zone=dt_util.get_default_time_zone(), time_zone=dt_util.get_default_time_zone(),
ssl=ssl_util.get_default_context(), ssl=ssl_util.get_default_context(),
) )
hass.data[DOMAIN] = tibber_connection
async def _close(event: Event) -> None: async def _close(event: Event) -> None:
await tibber_connection.rt_disconnect() await tibber_connection.rt_disconnect()
@@ -52,7 +102,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try: try:
await tibber_connection.update_info() await tibber_connection.update_info()
except ( except (
TimeoutError, TimeoutError,
aiohttp.ClientError, aiohttp.ClientError,
@@ -65,17 +114,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except tibber.FatalHttpExceptionError: except tibber.FatalHttpExceptionError:
return False return False
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauthentication required"
) from err
raise ConfigEntryNotReady from err
except ClientError as err:
raise ConfigEntryNotReady from err
entry.runtime_data = TibberRuntimeData(
tibber_connection=tibber_connection,
session=session,
)
coordinator = TibberDataAPICoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data.data_api_coordinator = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_unload_entry(
hass: HomeAssistant, config_entry: TibberConfigEntry
) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms( if unload_ok := await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS config_entry, PLATFORMS
) ):
if unload_ok: await config_entry.runtime_data.tibber_connection.rt_disconnect()
tibber_connection = hass.data[DOMAIN]
await tibber_connection.rt_disconnect()
return unload_ok return unload_ok

View File

@@ -0,0 +1,15 @@
"""Application credentials platform for Tibber."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
AUTHORIZE_URL = "https://thewall.tibber.com/connect/authorize"
TOKEN_URL = "https://thewall.tibber.com/connect/token"
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server for Tibber Data API."""
return AuthorizationServer(
authorize_url=AUTHORIZE_URL,
token_url=TOKEN_URL,
)

View File

@@ -2,80 +2,164 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any from typing import Any
import aiohttp import aiohttp
import tibber import tibber
from tibber import data_api as tibber_data_api
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import DOMAIN from .const import CONF_LEGACY_ACCESS_TOKEN, DATA_API_DEFAULT_SCOPES, DOMAIN
DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) DATA_SCHEMA = vol.Schema({vol.Required(CONF_LEGACY_ACCESS_TOKEN): str})
ERR_TIMEOUT = "timeout" ERR_TIMEOUT = "timeout"
ERR_CLIENT = "cannot_connect" ERR_CLIENT = "cannot_connect"
ERR_TOKEN = "invalid_access_token" ERR_TOKEN = "invalid_access_token"
TOKEN_URL = "https://developer.tibber.com/settings/access-token" TOKEN_URL = "https://developer.tibber.com/settings/access-token"
_LOGGER = logging.getLogger(__name__)
class TibberConfigFlow(ConfigFlow, domain=DOMAIN):
class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle a config flow for Tibber integration.""" """Handle a config flow for Tibber integration."""
VERSION = 1 VERSION = 1
DOMAIN = DOMAIN
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self._access_token: str | None = None
self._title = ""
@property
def logger(self) -> logging.Logger:
"""Return the logger."""
return _LOGGER
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data appended to the authorize URL."""
return {
**super().extra_authorize_data,
"scope": " ".join(DATA_API_DEFAULT_SCOPES),
}
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
if user_input is None:
self._async_abort_entries_match() data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
if user_input is not None:
access_token = user_input[CONF_ACCESS_TOKEN].replace(" ", "")
tibber_connection = tibber.Tibber(
access_token=access_token,
websession=async_get_clientsession(self.hass),
) )
errors = {} return self.async_show_form(
step_id=SOURCE_USER,
data_schema=data_schema,
description_placeholders={"url": TOKEN_URL},
errors={},
)
self._access_token = user_input[CONF_LEGACY_ACCESS_TOKEN].replace(" ", "")
tibber_connection = tibber.Tibber(
access_token=self._access_token,
websession=async_get_clientsession(self.hass),
)
self._title = tibber_connection.name or "Tibber"
errors: dict[str, str] = {}
try: try:
await tibber_connection.update_info() await tibber_connection.update_info()
except TimeoutError: except TimeoutError:
errors[CONF_ACCESS_TOKEN] = ERR_TIMEOUT errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TIMEOUT
except tibber.InvalidLoginError: except tibber.InvalidLoginError:
errors[CONF_ACCESS_TOKEN] = ERR_TOKEN errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TOKEN
except ( except (
aiohttp.ClientError, aiohttp.ClientError,
tibber.RetryableHttpExceptionError, tibber.RetryableHttpExceptionError,
tibber.FatalHttpExceptionError, tibber.FatalHttpExceptionError,
): ):
errors[CONF_ACCESS_TOKEN] = ERR_CLIENT errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_CLIENT
if errors: if errors:
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
)
return self.async_show_form( return self.async_show_form(
step_id="user", step_id=SOURCE_USER,
data_schema=DATA_SCHEMA, data_schema=data_schema,
description_placeholders={"url": TOKEN_URL}, description_placeholders={"url": TOKEN_URL},
errors=errors, errors=errors,
) )
unique_id = tibber_connection.user_id await self.async_set_unique_id(tibber_connection.user_id)
await self.async_set_unique_id(unique_id)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
self._abort_if_unique_id_mismatch(
reason="wrong_account",
description_placeholders={"title": reauth_entry.title},
)
else:
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
return self.async_create_entry( return await self.async_step_pick_implementation()
title=tibber_connection.name,
data={CONF_ACCESS_TOKEN: access_token}, async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle a reauth flow."""
reauth_entry = self._get_reauth_entry()
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
self._title = reauth_entry.title
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication by reusing the user step."""
reauth_entry = self._get_reauth_entry()
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
self._title = reauth_entry.title
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
)
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Finalize the OAuth flow and create the config entry."""
if self._access_token is None:
return self.async_abort(reason="missing_configuration")
data[CONF_LEGACY_ACCESS_TOKEN] = self._access_token
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
data_api_client = tibber_data_api.TibberDataAPI(
access_token,
websession=async_get_clientsession(self.hass),
) )
return self.async_show_form( try:
step_id="user", await data_api_client.get_userinfo()
data_schema=DATA_SCHEMA, except (aiohttp.ClientError, TimeoutError):
description_placeholders={"url": TOKEN_URL}, return self.async_abort(reason="cannot_connect")
errors={},
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
reauth_entry,
data=data,
title=self._title,
) )
return self.async_create_entry(title=self._title, data=data)

View File

@@ -1,5 +1,34 @@
"""Constants for Tibber integration.""" """Constants for Tibber integration."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
if TYPE_CHECKING:
from . import TibberRuntimeData
type TibberConfigEntry = ConfigEntry[TibberRuntimeData]
CONF_LEGACY_ACCESS_TOKEN = CONF_ACCESS_TOKEN
AUTH_IMPLEMENTATION = "auth_implementation"
DATA_HASS_CONFIG = "tibber_hass_config" DATA_HASS_CONFIG = "tibber_hass_config"
DOMAIN = "tibber" DOMAIN = "tibber"
MANUFACTURER = "Tibber" MANUFACTURER = "Tibber"
DATA_API_DEFAULT_SCOPES = [
"openid",
"profile",
"email",
"offline_access",
"data-api-user-read",
"data-api-chargers-read",
"data-api-energy-systems-read",
"data-api-homes-read",
"data-api-thermostats-read",
"data-api-vehicles-read",
"data-api-inverters-read",
]

View File

@@ -4,9 +4,11 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import cast from typing import TYPE_CHECKING, cast
from aiohttp.client_exceptions import ClientError
import tibber import tibber
from tibber.data_api import TibberDataAPI, TibberDevice
from homeassistant.components.recorder import get_instance from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import ( from homeassistant.components.recorder.models import (
@@ -19,15 +21,18 @@ from homeassistant.components.recorder.statistics import (
get_last_statistics, get_last_statistics,
statistics_during_period, statistics_during_period,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter from homeassistant.util.unit_conversion import EnergyConverter
from .const import DOMAIN from .const import DOMAIN
if TYPE_CHECKING:
from .const import TibberConfigEntry
FIVE_YEARS = 5 * 365 * 24 FIVE_YEARS = 5 * 365 * 24
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -36,12 +41,12 @@ _LOGGER = logging.getLogger(__name__)
class TibberDataCoordinator(DataUpdateCoordinator[None]): class TibberDataCoordinator(DataUpdateCoordinator[None]):
"""Handle Tibber data and insert statistics.""" """Handle Tibber data and insert statistics."""
config_entry: ConfigEntry config_entry: TibberConfigEntry
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: TibberConfigEntry,
tibber_connection: tibber.Tibber, tibber_connection: tibber.Tibber,
) -> None: ) -> None:
"""Initialize the data handler.""" """Initialize the data handler."""
@@ -187,3 +192,64 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
unit_of_measurement=unit, unit_of_measurement=unit,
) )
async_add_external_statistics(self.hass, metadata, statistics) async_add_external_statistics(self.hass, metadata, statistics)
class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
"""Fetch and cache Tibber Data API device capabilities."""
config_entry: TibberConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: TibberConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN} Data API",
update_interval=timedelta(minutes=1),
config_entry=entry,
)
self._runtime_data = entry.runtime_data
self.sensors_by_device: dict[str, dict[str, tibber.data_api.Sensor]] = {}
def _build_sensor_lookup(self, devices: dict[str, TibberDevice]) -> None:
"""Build sensor lookup dict for efficient access."""
self.sensors_by_device = {
device_id: {sensor.id: sensor for sensor in device.sensors}
for device_id, device in devices.items()
}
def get_sensor(
self, device_id: str, sensor_id: str
) -> tibber.data_api.Sensor | None:
"""Get a sensor by device and sensor ID."""
if device_sensors := self.sensors_by_device.get(device_id):
return device_sensors.get(sensor_id)
return None
async def _async_get_client(self) -> TibberDataAPI:
"""Get the Tibber Data API client with error handling."""
try:
return await self._runtime_data.async_get_client(self.hass)
except ConfigEntryAuthFailed:
raise
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
raise UpdateFailed(
f"Unable to create Tibber Data API client: {err}"
) from err
async def _async_setup(self) -> None:
"""Initial load of Tibber Data API devices."""
client = await self._async_get_client()
devices = await client.get_all_devices()
self._build_sensor_lookup(devices)
async def _async_update_data(self) -> dict[str, TibberDevice]:
"""Fetch the latest device capabilities from the Tibber Data API."""
client = await self._async_get_client()
devices: dict[str, TibberDevice] = await client.update_devices()
self._build_sensor_lookup(devices)
return devices

View File

@@ -4,21 +4,22 @@ from __future__ import annotations
from typing import Any from typing import Any
import aiohttp
import tibber import tibber
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from .const import DOMAIN from .const import TibberConfigEntry
async def async_get_config_entry_diagnostics( async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry hass: HomeAssistant, config_entry: TibberConfigEntry
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
tibber_connection: tibber.Tibber = hass.data[DOMAIN]
return { runtime = config_entry.runtime_data
result: dict[str, Any] = {
"homes": [ "homes": [
{ {
"last_data_timestamp": home.last_data_timestamp, "last_data_timestamp": home.last_data_timestamp,
@@ -27,6 +28,38 @@ async def async_get_config_entry_diagnostics(
"last_cons_data_timestamp": home.last_cons_data_timestamp, "last_cons_data_timestamp": home.last_cons_data_timestamp,
"country": home.country, "country": home.country,
} }
for home in tibber_connection.get_homes(only_active=False) for home in runtime.tibber_connection.get_homes(only_active=False)
] ]
} }
devices: dict[str, Any] = {}
error: str | None = None
try:
coordinator = runtime.data_api_coordinator
if coordinator is not None:
devices = coordinator.data
except ConfigEntryAuthFailed:
error = "Authentication failed"
except TimeoutError:
error = "Timeout error"
except aiohttp.ClientError:
error = "Client error"
except tibber.InvalidLoginError:
error = "Invalid login"
except tibber.RetryableHttpExceptionError as err:
error = f"Retryable HTTP error ({err.status})"
except tibber.FatalHttpExceptionError as err:
error = f"Fatal HTTP error ({err.status})"
result["error"] = error
result["devices"] = [
{
"id": device.id,
"name": device.name,
"brand": device.brand,
"model": device.model,
}
for device in devices.values()
]
return result

View File

@@ -3,9 +3,9 @@
"name": "Tibber", "name": "Tibber",
"codeowners": ["@danielhiversen"], "codeowners": ["@danielhiversen"],
"config_flow": true, "config_flow": true,
"dependencies": ["recorder"], "dependencies": ["application_credentials", "recorder"],
"documentation": "https://www.home-assistant.io/integrations/tibber", "documentation": "https://www.home-assistant.io/integrations/tibber",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["tibber"], "loggers": ["tibber"],
"requirements": ["pyTibber==0.32.2"] "requirements": ["pyTibber==0.33.1"]
} }

View File

@@ -2,28 +2,25 @@
from __future__ import annotations from __future__ import annotations
from tibber import Tibber
from homeassistant.components.notify import ( from homeassistant.components.notify import (
ATTR_TITLE_DEFAULT, ATTR_TITLE_DEFAULT,
NotifyEntity, NotifyEntity,
NotifyEntityFeature, NotifyEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN from .const import DOMAIN, TibberConfigEntry
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the Tibber notification entity.""" """Set up the Tibber notification entity."""
async_add_entities([TibberNotificationEntity(entry.entry_id)]) async_add_entities([TibberNotificationEntity(entry)])
class TibberNotificationEntity(NotifyEntity): class TibberNotificationEntity(NotifyEntity):
@@ -33,13 +30,14 @@ class TibberNotificationEntity(NotifyEntity):
_attr_name = DOMAIN _attr_name = DOMAIN
_attr_icon = "mdi:message-flash" _attr_icon = "mdi:message-flash"
def __init__(self, unique_id: str) -> None: def __init__(self, entry: TibberConfigEntry) -> None:
"""Initialize Tibber notify entity.""" """Initialize Tibber notify entity."""
self._attr_unique_id = unique_id self._attr_unique_id = entry.entry_id
self._entry = entry
async def async_send_message(self, message: str, title: str | None = None) -> None: async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to Tibber devices.""" """Send a message to Tibber devices."""
tibber_connection: Tibber = self.hass.data[DOMAIN] tibber_connection = self._entry.runtime_data.tibber_connection
try: try:
await tibber_connection.send_notification( await tibber_connection.send_notification(
title or ATTR_TITLE_DEFAULT, message title or ATTR_TITLE_DEFAULT, message

View File

@@ -10,7 +10,8 @@ from random import randrange
from typing import Any from typing import Any
import aiohttp import aiohttp
import tibber from tibber import FatalHttpExceptionError, RetryableHttpExceptionError, TibberHome
from tibber.data_api import TibberDevice
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@@ -27,6 +28,7 @@ from homeassistant.const import (
UnitOfElectricCurrent, UnitOfElectricCurrent,
UnitOfElectricPotential, UnitOfElectricPotential,
UnitOfEnergy, UnitOfEnergy,
UnitOfLength,
UnitOfPower, UnitOfPower,
) )
from homeassistant.core import Event, HomeAssistant, callback from homeassistant.core import Event, HomeAssistant, callback
@@ -41,8 +43,8 @@ from homeassistant.helpers.update_coordinator import (
) )
from homeassistant.util import Throttle, dt as dt_util from homeassistant.util import Throttle, dt as dt_util
from .const import DOMAIN, MANUFACTURER from .const import DOMAIN, MANUFACTURER, TibberConfigEntry
from .coordinator import TibberDataCoordinator from .coordinator import TibberDataAPICoordinator, TibberDataCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -260,14 +262,65 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
) )
DATA_API_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="storage.stateOfCharge",
translation_key="storage_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="storage.targetStateOfCharge",
translation_key="storage_target_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="range.remaining",
translation_key="range_remaining",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
SensorEntityDescription(
key="charging.current.max",
translation_key="charging_current_max",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="charging.current.offlineFallback",
translation_key="charging_current_offline_fallback",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the Tibber sensor.""" """Set up the Tibber sensor."""
tibber_connection = hass.data[DOMAIN] _setup_data_api_sensors(entry, async_add_entities)
await _async_setup_graphql_sensors(hass, entry, async_add_entities)
async def _async_setup_graphql_sensors(
hass: HomeAssistant,
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber sensor."""
tibber_connection = entry.runtime_data.tibber_connection
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
@@ -280,7 +333,11 @@ async def async_setup_entry(
except TimeoutError as err: except TimeoutError as err:
_LOGGER.error("Timeout connecting to Tibber home: %s ", err) _LOGGER.error("Timeout connecting to Tibber home: %s ", err)
raise PlatformNotReady from err raise PlatformNotReady from err
except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err: except (
RetryableHttpExceptionError,
FatalHttpExceptionError,
aiohttp.ClientError,
) as err:
_LOGGER.error("Error connecting to Tibber home: %s ", err) _LOGGER.error("Error connecting to Tibber home: %s ", err)
raise PlatformNotReady from err raise PlatformNotReady from err
@@ -325,7 +382,72 @@ async def async_setup_entry(
device_entry.id, new_identifiers={(DOMAIN, home.home_id)} device_entry.id, new_identifiers={(DOMAIN, home.home_id)}
) )
async_add_entities(entities, True) async_add_entities(entities)
def _setup_data_api_sensors(
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors backed by the Tibber Data API."""
coordinator = entry.runtime_data.data_api_coordinator
if coordinator is None:
return
entities: list[TibberDataAPISensor] = []
api_sensors = {sensor.key: sensor for sensor in DATA_API_SENSORS}
for device in coordinator.data.values():
for sensor in device.sensors:
description: SensorEntityDescription | None = api_sensors.get(sensor.id)
if description is None:
_LOGGER.debug(
"Sensor %s not found in DATA_API_SENSORS, skipping", sensor
)
continue
entities.append(
TibberDataAPISensor(
coordinator, device, description, sensor.description
)
)
async_add_entities(entities)
class TibberDataAPISensor(CoordinatorEntity[TibberDataAPICoordinator], SensorEntity):
"""Representation of a Tibber Data API capability sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: TibberDataAPICoordinator,
device: TibberDevice,
entity_description: SensorEntityDescription,
name: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._device_id: str = device.id
self.entity_description = entity_description
self._attr_name = name
self._attr_unique_id = f"{device.external_id}_{self.entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.external_id)},
name=device.name,
manufacturer=device.brand,
model=device.model,
)
@property
def native_value(self) -> StateType:
"""Return the value reported by the device."""
sensors = self.coordinator.sensors_by_device.get(self._device_id, {})
sensor = sensors.get(self.entity_description.key)
return sensor.value if sensor else None
class TibberSensor(SensorEntity): class TibberSensor(SensorEntity):
@@ -333,9 +455,7 @@ class TibberSensor(SensorEntity):
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__( def __init__(self, *args: Any, tibber_home: TibberHome, **kwargs: Any) -> None:
self, *args: Any, tibber_home: tibber.TibberHome, **kwargs: Any
) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._tibber_home = tibber_home self._tibber_home = tibber_home
@@ -366,7 +486,7 @@ class TibberSensorElPrice(TibberSensor):
_attr_state_class = SensorStateClass.MEASUREMENT _attr_state_class = SensorStateClass.MEASUREMENT
_attr_translation_key = "electricity_price" _attr_translation_key = "electricity_price"
def __init__(self, tibber_home: tibber.TibberHome) -> None: def __init__(self, tibber_home: TibberHome) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(tibber_home=tibber_home) super().__init__(tibber_home=tibber_home)
self._last_updated: datetime.datetime | None = None self._last_updated: datetime.datetime | None = None
@@ -443,7 +563,7 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]):
def __init__( def __init__(
self, self,
tibber_home: tibber.TibberHome, tibber_home: TibberHome,
coordinator: TibberDataCoordinator, coordinator: TibberDataCoordinator,
entity_description: SensorEntityDescription, entity_description: SensorEntityDescription,
) -> None: ) -> None:
@@ -470,7 +590,7 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"])
def __init__( def __init__(
self, self,
tibber_home: tibber.TibberHome, tibber_home: TibberHome,
description: SensorEntityDescription, description: SensorEntityDescription,
initial_state: float, initial_state: float,
coordinator: TibberRtDataCoordinator, coordinator: TibberRtDataCoordinator,
@@ -532,7 +652,7 @@ class TibberRtEntityCreator:
def __init__( def __init__(
self, self,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
tibber_home: tibber.TibberHome, tibber_home: TibberHome,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Initialize the data handler.""" """Initialize the data handler."""
@@ -618,7 +738,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None], add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None],
tibber_home: tibber.TibberHome, tibber_home: TibberHome,
) -> None: ) -> None:
"""Initialize the data handler.""" """Initialize the data handler."""
self._add_sensor_callback = add_sensor_callback self._add_sensor_callback = add_sensor_callback

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import datetime as dt import datetime as dt
from datetime import datetime from datetime import datetime
from typing import Any, Final from typing import TYPE_CHECKING, Any, Final
import voluptuous as vol import voluptuous as vol
@@ -20,6 +20,9 @@ from homeassistant.util import dt as dt_util
from .const import DOMAIN from .const import DOMAIN
if TYPE_CHECKING:
from .const import TibberConfigEntry
PRICE_SERVICE_NAME = "get_prices" PRICE_SERVICE_NAME = "get_prices"
ATTR_START: Final = "start" ATTR_START: Final = "start"
ATTR_END: Final = "end" ATTR_END: Final = "end"
@@ -33,7 +36,13 @@ SERVICE_SCHEMA: Final = vol.Schema(
async def __get_prices(call: ServiceCall) -> ServiceResponse: async def __get_prices(call: ServiceCall) -> ServiceResponse:
tibber_connection = call.hass.data[DOMAIN] entries: list[TibberConfigEntry] = call.hass.config_entries.async_entries(DOMAIN)
if not entries:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_config_entry",
)
tibber_connection = entries[0].runtime_data.tibber_connection
start = __get_date(call.data.get(ATTR_START), "start") start = __get_date(call.data.get(ATTR_START), "start")
end = __get_date(call.data.get(ATTR_END), "end") end = __get_date(call.data.get(ATTR_END), "end")
@@ -57,7 +66,7 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse:
selected_data = [ selected_data = [
price price
for price in price_data for price in price_data
if start <= dt.datetime.fromisoformat(price["start_time"]) < end if start <= dt.datetime.fromisoformat(str(price["start_time"])) < end
] ]
tibber_prices[home_nickname] = selected_data tibber_prices[home_nickname] = selected_data

View File

@@ -1,7 +1,11 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]" "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "The connected account does not match {title}. Sign in with the same Tibber account and try again."
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,6 +13,10 @@
"timeout": "[%key:common::config_flow::error::timeout_connect%]" "timeout": "[%key:common::config_flow::error::timeout_connect%]"
}, },
"step": { "step": {
"reauth_confirm": {
"description": "Reconnect your Tibber account to refresh access.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": { "user": {
"data": { "data": {
"access_token": "[%key:common::config_flow::data::access_token%]" "access_token": "[%key:common::config_flow::data::access_token%]"
@@ -40,6 +48,12 @@
"average_power": { "average_power": {
"name": "Average power" "name": "Average power"
}, },
"charging_current_max": {
"name": "Maximum charging current"
},
"charging_current_offline_fallback": {
"name": "Offline fallback charging current"
},
"current_l1": { "current_l1": {
"name": "Current L1" "name": "Current L1"
}, },
@@ -88,9 +102,18 @@
"power_production": { "power_production": {
"name": "Power production" "name": "Power production"
}, },
"range_remaining": {
"name": "Remaining range"
},
"signal_strength": { "signal_strength": {
"name": "Signal strength" "name": "Signal strength"
}, },
"storage_state_of_charge": {
"name": "Storage state of charge"
},
"storage_target_state_of_charge": {
"name": "Storage target state of charge"
},
"voltage_phase1": { "voltage_phase1": {
"name": "Voltage phase1" "name": "Voltage phase1"
}, },
@@ -103,9 +126,18 @@
} }
}, },
"exceptions": { "exceptions": {
"data_api_reauth_required": {
"message": "Reconnect Tibber so Home Assistant can enable the new Tibber Data API features."
},
"invalid_date": { "invalid_date": {
"message": "Invalid datetime provided {date}" "message": "Invalid datetime provided {date}"
}, },
"no_config_entry": {
"message": "No Tibber integration configured"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"send_message_timeout": { "send_message_timeout": {
"message": "Timeout sending message with Tibber" "message": "Timeout sending message with Tibber"
} }

View File

@@ -38,6 +38,7 @@ APPLICATION_CREDENTIALS = [
"smartthings", "smartthings",
"spotify", "spotify",
"tesla_fleet", "tesla_fleet",
"tibber",
"twitch", "twitch",
"volvo", "volvo",
"watts", "watts",

2
requirements_all.txt generated
View File

@@ -1864,7 +1864,7 @@ pyRFXtrx==0.31.1
pySDCP==1 pySDCP==1
# homeassistant.components.tibber # homeassistant.components.tibber
pyTibber==0.32.2 pyTibber==0.33.1
# homeassistant.components.dlink # homeassistant.components.dlink
pyW215==0.8.0 pyW215==0.8.0

View File

@@ -1592,7 +1592,7 @@ pyHomee==1.3.8
pyRFXtrx==0.31.1 pyRFXtrx==0.31.1
# homeassistant.components.tibber # homeassistant.components.tibber
pyTibber==0.32.2 pyTibber==0.33.1
# homeassistant.components.dlink # homeassistant.components.dlink
pyW215==0.8.0 pyW215==0.8.0

View File

@@ -4,21 +4,68 @@ from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
import pytest import pytest
import tibber
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.recorder import Recorder from homeassistant.components.recorder import Recorder
from homeassistant.components.tibber.const import DOMAIN from homeassistant.components.tibber import TibberRuntimeData
from homeassistant.components.tibber.const import AUTH_IMPLEMENTATION, DOMAIN
from homeassistant.components.tibber.coordinator import TibberDataAPICoordinator
from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
def create_tibber_device(
device_id: str = "device-id",
external_id: str = "external-id",
name: str = "Test Device",
brand: str = "Tibber",
model: str = "Gen1",
value: float | None = 72.0,
home_id: str = "home-id",
) -> tibber.data_api.TibberDevice:
"""Create a fake Tibber Data API device."""
device_data = {
"id": device_id,
"externalId": external_id,
"info": {
"name": name,
"brand": brand,
"model": model,
},
"capabilities": [
{
"id": "storage.stateOfCharge",
"value": value,
"description": "State of charge",
"unit": "%",
},
{
"id": "unknown.sensor.id",
"value": None,
"description": "Unknown",
"unit": "",
},
],
}
return tibber.data_api.TibberDevice(device_data, home_id=home_id)
@pytest.fixture @pytest.fixture
def config_entry(hass: HomeAssistant) -> MockConfigEntry: def config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Tibber config entry.""" """Tibber config entry."""
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={CONF_ACCESS_TOKEN: "token"}, data={
CONF_ACCESS_TOKEN: "token",
AUTH_IMPLEMENTATION: DOMAIN,
},
unique_id="tibber", unique_id="tibber",
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
@@ -39,8 +86,88 @@ async def mock_tibber_setup(
tibber_mock.name = PropertyMock(return_value=title) tibber_mock.name = PropertyMock(return_value=title)
tibber_mock.send_notification = AsyncMock() tibber_mock.send_notification = AsyncMock()
tibber_mock.rt_disconnect = AsyncMock() tibber_mock.rt_disconnect = AsyncMock()
tibber_mock.get_homes = MagicMock(return_value=[])
with patch("tibber.Tibber", return_value=tibber_mock): session_mock = MagicMock()
session_mock.async_ensure_token_valid = AsyncMock()
session_mock.token = {CONF_ACCESS_TOKEN: "test-token"}
implementation_mock = MagicMock()
data_api_client_mock = MagicMock()
data_api_client_mock.get_all_devices = AsyncMock(return_value={})
data_api_client_mock.update_devices = AsyncMock(return_value={})
with (
patch("tibber.Tibber", return_value=tibber_mock),
patch(
"homeassistant.components.tibber.async_get_config_entry_implementation",
return_value=implementation_mock,
),
patch(
"homeassistant.components.tibber.OAuth2Session",
return_value=session_mock,
),
patch(
"homeassistant.components.tibber.coordinator.TibberDataAPICoordinator._async_get_client",
return_value=data_api_client_mock,
),
):
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
yield tibber_mock yield tibber_mock
@pytest.fixture
async def setup_credentials(recorder_mock: Recorder, hass: HomeAssistant) -> None:
"""Set up application credentials for the OAuth flow."""
assert await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential("test-client-id", "test-client-secret"),
DOMAIN,
)
@pytest.fixture
def data_api_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Create a Data API Tibber config entry."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_ACCESS_TOKEN: "token"},
unique_id="data-api",
)
entry.add_to_hass(hass)
return entry
def create_mock_runtime(
async_get_client: AsyncMock | None = None,
tibber_connection: MagicMock | None = None,
coordinator_data: dict | None = None,
) -> TibberRuntimeData:
"""Create a mock TibberRuntimeData.
Args:
async_get_client: Optional async mock for getting the Data API client.
tibber_connection: Optional mock for the GraphQL connection.
coordinator_data: Optional data dict for the coordinator.
"""
session = MagicMock()
session.async_ensure_token_valid = AsyncMock()
session.token = {CONF_ACCESS_TOKEN: "test-token"}
coordinator = MagicMock(spec=TibberDataAPICoordinator)
coordinator.data = coordinator_data if coordinator_data is not None else {}
coordinator.sensors_by_device = {}
runtime = MagicMock(spec=TibberRuntimeData)
runtime.session = session
runtime.tibber_connection = tibber_connection or MagicMock()
runtime.tibber_connection.get_homes = MagicMock(return_value=[])
runtime.data_api_coordinator = coordinator
runtime.async_get_client = async_get_client or AsyncMock()
return runtime

View File

@@ -1,7 +1,9 @@
"""Tests for Tibber config flow.""" """Tests for Tibber config flow."""
from asyncio import TimeoutError import builtins
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from http import HTTPStatus
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import parse_qs, urlparse
from aiohttp import ClientError from aiohttp import ClientError
import pytest import pytest
@@ -13,16 +15,22 @@ from tibber import (
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.recorder import Recorder from homeassistant.components.recorder import Recorder
from homeassistant.components.tibber.application_credentials import TOKEN_URL
from homeassistant.components.tibber.config_flow import ( from homeassistant.components.tibber.config_flow import (
DATA_API_DEFAULT_SCOPES,
ERR_CLIENT, ERR_CLIENT,
ERR_TIMEOUT, ERR_TIMEOUT,
ERR_TOKEN, ERR_TOKEN,
) )
from homeassistant.components.tibber.const import DOMAIN from homeassistant.components.tibber.const import AUTH_IMPLEMENTATION, DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
@pytest.fixture(name="tibber_setup", autouse=True) @pytest.fixture(name="tibber_setup", autouse=True)
def tibber_setup_fixture(): def tibber_setup_fixture():
@@ -31,6 +39,22 @@ def tibber_setup_fixture():
yield yield
def _mock_tibber(
*,
user_id: str = "unique_user_id",
title: str = "Mock Name",
update_side_effect: Exception | None = None,
) -> MagicMock:
"""Return a mocked Tibber GraphQL client."""
tibber_mock = MagicMock()
tibber_mock.user_id = user_id
tibber_mock.name = title
tibber_mock.update_info = AsyncMock()
if update_side_effect is not None:
tibber_mock.update_info.side_effect = update_side_effect
return tibber_mock
async def test_show_config_form(recorder_mock: Recorder, hass: HomeAssistant) -> None: async def test_show_config_form(recorder_mock: Recorder, hass: HomeAssistant) -> None:
"""Test show configuration form.""" """Test show configuration form."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@@ -41,62 +65,35 @@ async def test_show_config_form(recorder_mock: Recorder, hass: HomeAssistant) ->
assert result["step_id"] == "user" assert result["step_id"] == "user"
async def test_create_entry(recorder_mock: Recorder, hass: HomeAssistant) -> None:
"""Test create entry from user input."""
test_data = {
CONF_ACCESS_TOKEN: "valid",
}
unique_user_id = "unique_user_id"
title = "title"
tibber_mock = MagicMock()
type(tibber_mock).update_info = AsyncMock(return_value=True)
type(tibber_mock).user_id = PropertyMock(return_value=unique_user_id)
type(tibber_mock).name = PropertyMock(return_value=title)
with patch("tibber.Tibber", return_value=tibber_mock):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == title
assert result["data"] == test_data
@pytest.mark.parametrize( @pytest.mark.parametrize(
("exception", "expected_error"), ("exception", "expected_error"),
[ [
(TimeoutError, ERR_TIMEOUT), (builtins.TimeoutError(), ERR_TIMEOUT),
(ClientError, ERR_CLIENT), (ClientError(), ERR_CLIENT),
(InvalidLoginError(401), ERR_TOKEN), (InvalidLoginError(401), ERR_TOKEN),
(RetryableHttpExceptionError(503), ERR_CLIENT), (RetryableHttpExceptionError(503), ERR_CLIENT),
(FatalHttpExceptionError(404), ERR_CLIENT), (FatalHttpExceptionError(404), ERR_CLIENT),
], ],
) )
async def test_create_entry_exceptions( async def test_graphql_step_exceptions(
recorder_mock: Recorder, hass: HomeAssistant, exception, expected_error recorder_mock: Recorder,
hass: HomeAssistant,
exception: Exception,
expected_error: str,
) -> None: ) -> None:
"""Test create entry from user input.""" """Validate GraphQL errors are surfaced."""
test_data = {
CONF_ACCESS_TOKEN: "valid",
}
unique_user_id = "unique_user_id"
title = "title"
tibber_mock = MagicMock()
type(tibber_mock).update_info = AsyncMock(side_effect=exception)
type(tibber_mock).user_id = PropertyMock(return_value=unique_user_id)
type(tibber_mock).name = PropertyMock(return_value=title)
with patch("tibber.Tibber", return_value=tibber_mock):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data DOMAIN, context={"source": config_entries.SOURCE_USER}
)
tibber_mock = _mock_tibber(update_side_effect=exception)
with patch("tibber.Tibber", return_value=tibber_mock):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "invalid"}
) )
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"][CONF_ACCESS_TOKEN] == expected_error assert result["errors"][CONF_ACCESS_TOKEN] == expected_error
@@ -104,13 +101,203 @@ async def test_flow_entry_already_exists(
recorder_mock: Recorder, hass: HomeAssistant, config_entry recorder_mock: Recorder, hass: HomeAssistant, config_entry
) -> None: ) -> None:
"""Test user input for config_entry that already exists.""" """Test user input for config_entry that already exists."""
test_data = {
CONF_ACCESS_TOKEN: "valid",
}
with patch("tibber.Tibber.update_info", return_value=None):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data DOMAIN, context={"source": config_entries.SOURCE_USER}
)
tibber_mock = _mock_tibber(user_id="tibber")
with patch("tibber.Tibber", return_value=tibber_mock):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "valid"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_reauth_flow_steps(
recorder_mock: Recorder,
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test the reauth flow goes through reauth_confirm to user step."""
reauth_flow = await config_entry.start_reauth_flow(hass)
assert reauth_flow["type"] is FlowResultType.FORM
assert reauth_flow["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(reauth_flow["flow_id"])
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
reauth_flow["flow_id"],
user_input={},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
async def test_oauth_create_entry_missing_configuration(
recorder_mock: Recorder,
hass: HomeAssistant,
) -> None:
"""Abort OAuth finalize if GraphQL step did not run."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
handler = hass.config_entries.flow._progress[result["flow_id"]]
flow_result = await handler.async_oauth_create_entry(
{CONF_TOKEN: {CONF_ACCESS_TOKEN: "rest-token"}}
)
assert flow_result["type"] is FlowResultType.ABORT
assert flow_result["reason"] == "missing_configuration"
async def test_oauth_create_entry_cannot_connect_userinfo(
recorder_mock: Recorder,
hass: HomeAssistant,
) -> None:
"""Abort OAuth finalize when Data API userinfo cannot be retrieved."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
handler = hass.config_entries.flow._progress[result["flow_id"]]
handler._access_token = "graphql-token"
data_api_client = MagicMock()
data_api_client.get_userinfo = AsyncMock(side_effect=ClientError())
with patch(
"homeassistant.components.tibber.config_flow.tibber_data_api.TibberDataAPI",
return_value=data_api_client,
):
flow_result = await handler.async_oauth_create_entry(
{CONF_TOKEN: {CONF_ACCESS_TOKEN: "rest-token"}}
)
assert flow_result["type"] is FlowResultType.ABORT
assert flow_result["reason"] == "cannot_connect"
async def test_data_api_requires_credentials(
recorder_mock: Recorder, hass: HomeAssistant
) -> None:
"""Abort when OAuth credentials are missing."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
tibber_mock = _mock_tibber()
with patch("tibber.Tibber", return_value=tibber_mock):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "valid"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "missing_credentials"
@pytest.mark.usefixtures("setup_credentials", "current_request_with_host")
async def test_data_api_extra_authorize_scope(
recorder_mock: Recorder, hass: HomeAssistant
) -> None:
"""Ensure the OAuth implementation requests Tibber scopes."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
tibber_mock = _mock_tibber()
with patch("tibber.Tibber", return_value=tibber_mock):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "valid"}
)
handler = hass.config_entries.flow._progress[result["flow_id"]]
assert handler.extra_authorize_data["scope"] == " ".join(DATA_API_DEFAULT_SCOPES)
@pytest.mark.usefixtures("setup_credentials", "current_request_with_host")
async def test_full_flow_success(
recorder_mock: Recorder,
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test configuring Tibber via GraphQL + OAuth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
tibber_mock = _mock_tibber()
with patch("tibber.Tibber", return_value=tibber_mock):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "graphql-token"}
)
assert result["type"] is FlowResultType.EXTERNAL_STEP
authorize_url = result["url"]
state = parse_qs(urlparse(authorize_url).query)["state"][0]
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == HTTPStatus.OK
aioclient_mock.post(
TOKEN_URL,
json={
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"token_type": "bearer",
"expires_in": 3600,
},
)
data_api_client = MagicMock()
data_api_client.get_userinfo = AsyncMock(return_value={"name": "Mock Name"})
with patch(
"homeassistant.components.tibber.config_flow.tibber_data_api.TibberDataAPI",
return_value=data_api_client,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY
data = result["data"]
assert data[CONF_TOKEN]["access_token"] == "mock-access-token"
assert data[CONF_ACCESS_TOKEN] == "graphql-token"
assert data[AUTH_IMPLEMENTATION] == DOMAIN
assert result["title"] == "Mock Name"
async def test_data_api_abort_when_already_configured(
recorder_mock: Recorder, hass: HomeAssistant
) -> None:
"""Ensure only a single Data API entry can be configured."""
existing_entry = MockConfigEntry(
domain=DOMAIN,
data={
AUTH_IMPLEMENTATION: DOMAIN,
CONF_TOKEN: {"access_token": "existing"},
CONF_ACCESS_TOKEN: "stored-graphql",
},
unique_id="unique_user_id",
title="Existing",
)
existing_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
tibber_mock = _mock_tibber()
with patch("tibber.Tibber", return_value=tibber_mock):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "new-token"}
) )
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT

View File

@@ -0,0 +1,121 @@
"""Tests for the Tibber Data API coordinator and sensors."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import aiohttp
import pytest
import tibber
from homeassistant.components.tibber.coordinator import TibberDataAPICoordinator
from homeassistant.components.tibber.sensor import (
TibberDataAPISensor,
_setup_data_api_sensors,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import UpdateFailed
from .conftest import create_mock_runtime, create_tibber_device
from tests.common import MockConfigEntry
async def test_data_api_setup_adds_entities(
hass: HomeAssistant,
data_api_entry: MockConfigEntry,
) -> None:
"""Ensure Data API sensors are created and coordinator refreshes data."""
client = MagicMock()
client.get_all_devices = AsyncMock(
return_value={"device-id": create_tibber_device(value=72.0)}
)
client.update_devices = AsyncMock(
return_value={"device-id": create_tibber_device(value=83.0)}
)
async_get_client = AsyncMock(return_value=client)
runtime = create_mock_runtime(async_get_client=async_get_client)
data_api_entry.runtime_data = runtime
data_api_entry.mock_state(hass, ConfigEntryState.SETUP_IN_PROGRESS)
coordinator = TibberDataAPICoordinator(hass, data_api_entry)
await coordinator.async_config_entry_first_refresh()
runtime.data_api_coordinator = coordinator
added_entities: list[TibberDataAPISensor] = []
def async_add_entities(entities: list[TibberDataAPISensor]) -> None:
added_entities.extend(entities)
_setup_data_api_sensors(data_api_entry, async_add_entities)
assert async_get_client.await_count == 2
client.get_all_devices.assert_awaited_once()
client.update_devices.assert_awaited_once()
assert len(added_entities) == 1
sensor = added_entities[0]
assert sensor.entity_description.key == "storage.stateOfCharge"
assert sensor.native_value == 83.0
assert sensor.available
sensor.coordinator.data = {}
sensor.coordinator.sensors_by_device = {}
assert sensor.native_value is None
assert not sensor.available
async def test_data_api_coordinator_first_refresh_failure(
hass: HomeAssistant, data_api_entry: MockConfigEntry
) -> None:
"""Ensure network failures during setup raise ConfigEntryNotReady."""
async_get_client = AsyncMock(side_effect=aiohttp.ClientError("boom"))
runtime = create_mock_runtime(async_get_client=async_get_client)
data_api_entry.runtime_data = runtime
coordinator = TibberDataAPICoordinator(hass, data_api_entry)
data_api_entry.mock_state(hass, ConfigEntryState.SETUP_IN_PROGRESS)
with pytest.raises(ConfigEntryNotReady):
await coordinator.async_config_entry_first_refresh()
assert isinstance(coordinator.last_exception, UpdateFailed)
async def test_data_api_coordinator_first_refresh_auth_failed(
hass: HomeAssistant, data_api_entry: MockConfigEntry
) -> None:
"""Ensure auth failures during setup propagate."""
async_get_client = AsyncMock(side_effect=ConfigEntryAuthFailed("invalid"))
runtime = create_mock_runtime(async_get_client=async_get_client)
data_api_entry.runtime_data = runtime
coordinator = TibberDataAPICoordinator(hass, data_api_entry)
data_api_entry.mock_state(hass, ConfigEntryState.SETUP_IN_PROGRESS)
with pytest.raises(ConfigEntryAuthFailed):
await coordinator.async_config_entry_first_refresh()
@pytest.mark.parametrize(
"exception",
[
aiohttp.ClientError("err"),
TimeoutError(),
tibber.UserAgentMissingError("err"),
],
)
async def test_data_api_coordinator_update_failures(
hass: HomeAssistant, data_api_entry: MockConfigEntry, exception: Exception
) -> None:
"""Ensure update failures are wrapped in UpdateFailed."""
async_get_client = AsyncMock(side_effect=exception)
runtime = create_mock_runtime(async_get_client=async_get_client)
data_api_entry.runtime_data = runtime
coordinator = TibberDataAPICoordinator(hass, data_api_entry)
with pytest.raises(UpdateFailed):
await coordinator._async_update_data()

View File

@@ -1,13 +1,23 @@
"""Test the Netatmo diagnostics.""" """Test the Tibber diagnostics."""
from unittest.mock import patch from unittest.mock import MagicMock
import aiohttp
import pytest
from syrupy.assertion import SnapshotAssertion
import tibber
from homeassistant.components.recorder import Recorder from homeassistant.components.recorder import Recorder
from homeassistant.components.tibber.diagnostics import (
async_get_config_entry_diagnostics,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.exceptions import ConfigEntryAuthFailed
from .conftest import create_mock_runtime, create_tibber_device
from .test_common import mock_get_homes from .test_common import mock_get_homes
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator from tests.typing import ClientSessionGenerator
@@ -16,41 +26,93 @@ async def test_entry_diagnostics(
recorder_mock: Recorder, recorder_mock: Recorder,
hass: HomeAssistant, hass: HomeAssistant,
hass_client: ClientSessionGenerator, hass_client: ClientSessionGenerator,
config_entry, config_entry: MockConfigEntry,
mock_tibber_setup: MagicMock,
snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test config entry diagnostics.""" """Test config entry diagnostics."""
with patch( tibber_mock = mock_tibber_setup
"tibber.Tibber.update_info", tibber_mock.get_homes.return_value = []
return_value=None,
):
assert await async_setup_component(hass, "tibber", {})
await hass.async_block_till_done() config_entry.runtime_data.data_api_coordinator.data = {}
config_entry.runtime_data.data_api_coordinator.sensors_by_device = {}
with patch(
"tibber.Tibber.get_homes",
return_value=[],
):
result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
assert result == snapshot(name="empty")
assert result == { tibber_mock.get_homes.side_effect = mock_get_homes
"homes": [],
result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
assert result == snapshot(name="with_homes")
async def test_data_api_diagnostics_no_data(
recorder_mock: Recorder,
hass: HomeAssistant,
data_api_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test Data API diagnostics when coordinator has no data."""
data_api_entry.runtime_data = create_mock_runtime()
result = await async_get_config_entry_diagnostics(hass, data_api_entry)
assert result == snapshot
async def test_data_api_diagnostics_with_devices(
recorder_mock: Recorder,
hass: HomeAssistant,
data_api_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test Data API diagnostics with successful device retrieval."""
devices = {
"device-1": create_tibber_device(
device_id="device-1",
name="Device 1",
brand="Tibber",
model="Test Model",
),
"device-2": create_tibber_device(
device_id="device-2",
name="Device 2",
brand="Tibber",
model="Test Model",
),
} }
with patch( data_api_entry.runtime_data = create_mock_runtime(coordinator_data=devices)
"tibber.Tibber.get_homes",
side_effect=mock_get_homes,
):
result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
assert result == { result = await async_get_config_entry_diagnostics(hass, data_api_entry)
"homes": [ assert result == snapshot
{
"last_data_timestamp": "2016-01-01T12:48:57",
"has_active_subscription": True, @pytest.mark.parametrize(
"has_real_time_consumption": False, ("exception", "expected_error"),
"last_cons_data_timestamp": "2016-01-01T12:44:57", [
"country": "NO", (ConfigEntryAuthFailed("Auth failed"), "Authentication failed"),
} (TimeoutError(), "Timeout error"),
(aiohttp.ClientError("Connection error"), "Client error"),
(tibber.InvalidLoginError(401), "Invalid login"),
(tibber.RetryableHttpExceptionError(503), "Retryable HTTP error (503)"),
(tibber.FatalHttpExceptionError(404), "Fatal HTTP error (404)"),
], ],
} )
async def test_data_api_diagnostics_exceptions(
recorder_mock: Recorder,
hass: HomeAssistant,
data_api_entry: MockConfigEntry,
exception: Exception,
expected_error: str,
) -> None:
"""Test Data API diagnostics with various exception scenarios."""
runtime = create_mock_runtime()
type(runtime.data_api_coordinator).data = property(
lambda self: (_ for _ in ()).throw(exception)
)
data_api_entry.runtime_data = runtime
result = await async_get_config_entry_diagnostics(hass, data_api_entry)
assert result["error"] == expected_error
assert result["devices"] == []

View File

@@ -1,11 +1,17 @@
"""Test loading of the Tibber config entry.""" """Test loading of the Tibber config entry."""
from unittest.mock import MagicMock from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.recorder import Recorder from homeassistant.components.recorder import Recorder
from homeassistant.components.tibber import DOMAIN from homeassistant.components.tibber import DOMAIN, TibberRuntimeData, async_setup_entry
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from tests.common import MockConfigEntry
async def test_entry_unload( async def test_entry_unload(
@@ -19,3 +25,69 @@ async def test_entry_unload(
mock_tibber_setup.rt_disconnect.assert_called_once() mock_tibber_setup.rt_disconnect.assert_called_once()
await hass.async_block_till_done(wait_background_tasks=True) await hass.async_block_till_done(wait_background_tasks=True)
assert entry.state is ConfigEntryState.NOT_LOADED assert entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.usefixtures("recorder_mock")
async def test_data_api_runtime_creates_client(hass: HomeAssistant) -> None:
"""Ensure the data API runtime creates and caches the client."""
session = MagicMock()
session.async_ensure_token_valid = AsyncMock()
session.token = {CONF_ACCESS_TOKEN: "access-token"}
runtime = TibberRuntimeData(
session=session,
tibber_connection=MagicMock(),
)
with patch(
"homeassistant.components.tibber.tibber_data_api.TibberDataAPI"
) as mock_client_cls:
mock_client = MagicMock()
mock_client.set_access_token = MagicMock()
mock_client_cls.return_value = mock_client
client = await runtime.async_get_client(hass)
mock_client_cls.assert_called_once_with("access-token", websession=ANY)
session.async_ensure_token_valid.assert_awaited_once()
mock_client.set_access_token.assert_called_once_with("access-token")
assert client is mock_client
mock_client.set_access_token.reset_mock()
session.async_ensure_token_valid.reset_mock()
cached_client = await runtime.async_get_client(hass)
mock_client_cls.assert_called_once()
session.async_ensure_token_valid.assert_awaited_once()
mock_client.set_access_token.assert_called_once_with("access-token")
assert cached_client is client
@pytest.mark.usefixtures("recorder_mock")
async def test_data_api_runtime_missing_token_raises(hass: HomeAssistant) -> None:
"""Ensure missing tokens trigger reauthentication."""
session = MagicMock()
session.async_ensure_token_valid = AsyncMock()
session.token = {}
runtime = TibberRuntimeData(
session=session,
tibber_connection=MagicMock(),
)
with pytest.raises(ConfigEntryAuthFailed):
await runtime.async_get_client(hass)
session.async_ensure_token_valid.assert_awaited_once()
async def test_setup_requires_data_api_reauth(hass: HomeAssistant) -> None:
"""Ensure legacy entries trigger reauth to configure Data API."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_ACCESS_TOKEN: "legacy-token"},
unique_id="legacy",
)
with pytest.raises(ConfigEntryAuthFailed):
await async_setup_entry(hass, entry)