diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 424b35b963b..6bb5c33ceb8 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -1,20 +1,36 @@ """Support for Tibber.""" +from __future__ import annotations + +from dataclasses import dataclass, field import logging import aiohttp +from aiohttp.client_exceptions import ClientError, ClientResponseError 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.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.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.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 PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] @@ -24,6 +40,33 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _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: """Set up the Tibber component.""" @@ -34,16 +77,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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.""" + # Added in 2026.1 to migrate existing users to OAuth2 (Tibber Data API). + # Can be removed after 2026.7 + if AUTH_IMPLEMENTATION not in entry.data: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="data_api_reauth_required", + ) + tibber_connection = tibber.Tibber( - access_token=entry.data[CONF_ACCESS_TOKEN], + access_token=entry.data[CONF_LEGACY_ACCESS_TOKEN], websession=async_get_clientsession(hass), time_zone=dt_util.get_default_time_zone(), ssl=ssl_util.get_default_context(), ) - hass.data[DOMAIN] = tibber_connection async def _close(event: Event) -> None: await tibber_connection.rt_disconnect() @@ -52,7 +102,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await tibber_connection.update_info() - except ( TimeoutError, aiohttp.ClientError, @@ -65,17 +114,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except tibber.FatalHttpExceptionError: 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 -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_ok = await hass.config_entries.async_unload_platforms( + if unload_ok := await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS - ) - if unload_ok: - tibber_connection = hass.data[DOMAIN] - await tibber_connection.rt_disconnect() + ): + await config_entry.runtime_data.tibber_connection.rt_disconnect() return unload_ok diff --git a/homeassistant/components/tibber/application_credentials.py b/homeassistant/components/tibber/application_credentials.py new file mode 100644 index 00000000000..c52beb126ab --- /dev/null +++ b/homeassistant/components/tibber/application_credentials.py @@ -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, + ) diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index 2d4df5107a2..bc8173312c6 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -2,80 +2,164 @@ from __future__ import annotations +from collections.abc import Mapping +import logging from typing import Any import aiohttp import tibber +from tibber import data_api as tibber_data_api import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN 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_CLIENT = "cannot_connect" ERR_TOKEN = "invalid_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.""" 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( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - - self._async_abort_entries_match() - - 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), + if user_input is None: + data_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""} ) - errors = {} + return self.async_show_form( + step_id=SOURCE_USER, + data_schema=data_schema, + description_placeholders={"url": TOKEN_URL}, + errors={}, + ) - try: - await tibber_connection.update_info() - except TimeoutError: - errors[CONF_ACCESS_TOKEN] = ERR_TIMEOUT - except tibber.InvalidLoginError: - errors[CONF_ACCESS_TOKEN] = ERR_TOKEN - except ( - aiohttp.ClientError, - tibber.RetryableHttpExceptionError, - tibber.FatalHttpExceptionError, - ): - errors[CONF_ACCESS_TOKEN] = ERR_CLIENT + 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" - if errors: - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - description_placeholders={"url": TOKEN_URL}, - errors=errors, - ) + errors: dict[str, str] = {} + try: + await tibber_connection.update_info() + except TimeoutError: + errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TIMEOUT + except tibber.InvalidLoginError: + errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TOKEN + except ( + aiohttp.ClientError, + tibber.RetryableHttpExceptionError, + tibber.FatalHttpExceptionError, + ): + errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_CLIENT - unique_id = tibber_connection.user_id - await self.async_set_unique_id(unique_id) + 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( + step_id=SOURCE_USER, + data_schema=data_schema, + description_placeholders={"url": TOKEN_URL}, + errors=errors, + ) + + await self.async_set_unique_id(tibber_connection.user_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() - return self.async_create_entry( - title=tibber_connection.name, - data={CONF_ACCESS_TOKEN: access_token}, + return await self.async_step_pick_implementation() + + 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), + ) + + try: + await data_api_client.get_userinfo() + except (aiohttp.ClientError, TimeoutError): + return self.async_abort(reason="cannot_connect") + + 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_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - description_placeholders={"url": TOKEN_URL}, - errors={}, - ) + return self.async_create_entry(title=self._title, data=data) diff --git a/homeassistant/components/tibber/const.py b/homeassistant/components/tibber/const.py index a35fa89c40f..8a856bb95c4 100644 --- a/homeassistant/components/tibber/const.py +++ b/homeassistant/components/tibber/const.py @@ -1,5 +1,34 @@ """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" DOMAIN = "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", +] diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 2e420957c43..84fac8237c0 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -4,9 +4,11 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import cast +from typing import TYPE_CHECKING, cast +from aiohttp.client_exceptions import ClientError import tibber +from tibber.data_api import TibberDataAPI, TibberDevice from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import ( @@ -19,15 +21,18 @@ from homeassistant.components.recorder.statistics import ( get_last_statistics, statistics_during_period, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import EnergyConverter from .const import DOMAIN +if TYPE_CHECKING: + from .const import TibberConfigEntry + FIVE_YEARS = 5 * 365 * 24 _LOGGER = logging.getLogger(__name__) @@ -36,12 +41,12 @@ _LOGGER = logging.getLogger(__name__) class TibberDataCoordinator(DataUpdateCoordinator[None]): """Handle Tibber data and insert statistics.""" - config_entry: ConfigEntry + config_entry: TibberConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TibberConfigEntry, tibber_connection: tibber.Tibber, ) -> None: """Initialize the data handler.""" @@ -187,3 +192,64 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): unit_of_measurement=unit, ) 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 diff --git a/homeassistant/components/tibber/diagnostics.py b/homeassistant/components/tibber/diagnostics.py index 2306aac23e1..9c8f9ff5ae8 100644 --- a/homeassistant/components/tibber/diagnostics.py +++ b/homeassistant/components/tibber/diagnostics.py @@ -4,21 +4,18 @@ from __future__ import annotations from typing import Any -import tibber - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import TibberConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: TibberConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - tibber_connection: tibber.Tibber = hass.data[DOMAIN] - return { + runtime = config_entry.runtime_data + result: dict[str, Any] = { "homes": [ { "last_data_timestamp": home.last_data_timestamp, @@ -27,6 +24,24 @@ async def async_get_config_entry_diagnostics( "last_cons_data_timestamp": home.last_cons_data_timestamp, "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 = ( + runtime.data_api_coordinator.data + if runtime.data_api_coordinator is not None + else {} + ) or {} + + result["devices"] = [ + { + "id": device.id, + "name": device.name, + "brand": device.brand, + "model": device.model, + } + for device in devices.values() + ] + + return result diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 0844915daa4..3e8e0246f1c 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -3,9 +3,9 @@ "name": "Tibber", "codeowners": ["@danielhiversen"], "config_flow": true, - "dependencies": ["recorder"], + "dependencies": ["application_credentials", "recorder"], "documentation": "https://www.home-assistant.io/integrations/tibber", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.32.2"] + "requirements": ["pyTibber==0.33.1"] } diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index 5a10d8e0890..b5e54a23b76 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -2,28 +2,25 @@ from __future__ import annotations -from tibber import Tibber - from homeassistant.components.notify import ( ATTR_TITLE_DEFAULT, NotifyEntity, NotifyEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN +from .const import DOMAIN, TibberConfigEntry async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TibberConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tibber notification entity.""" - async_add_entities([TibberNotificationEntity(entry.entry_id)]) + async_add_entities([TibberNotificationEntity(entry)]) class TibberNotificationEntity(NotifyEntity): @@ -33,13 +30,14 @@ class TibberNotificationEntity(NotifyEntity): _attr_name = DOMAIN _attr_icon = "mdi:message-flash" - def __init__(self, unique_id: str) -> None: + def __init__(self, entry: TibberConfigEntry) -> None: """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: """Send a message to Tibber devices.""" - tibber_connection: Tibber = self.hass.data[DOMAIN] + tibber_connection = self._entry.runtime_data.tibber_connection try: await tibber_connection.send_notification( title or ATTR_TITLE_DEFAULT, message diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index b087ef406a1..857f01c6a6a 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -10,7 +10,8 @@ from random import randrange from typing import Any import aiohttp -import tibber +from tibber import FatalHttpExceptionError, RetryableHttpExceptionError, TibberHome +from tibber.data_api import TibberDevice from homeassistant.components.sensor import ( SensorDeviceClass, @@ -27,6 +28,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfLength, UnitOfPower, ) 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 .const import DOMAIN, MANUFACTURER -from .coordinator import TibberDataCoordinator +from .const import DOMAIN, MANUFACTURER, TibberConfigEntry +from .coordinator import TibberDataAPICoordinator, TibberDataCoordinator _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( hass: HomeAssistant, - entry: ConfigEntry, + entry: TibberConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """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) device_registry = dr.async_get(hass) @@ -280,7 +333,11 @@ async def async_setup_entry( except TimeoutError as err: _LOGGER.error("Timeout connecting to Tibber home: %s ", 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) raise PlatformNotReady from err @@ -325,7 +382,67 @@ async def async_setup_entry( 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)) + 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, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self._device_id: str = device.id + self.entity_description = entity_description + self._attr_translation_key = entity_description.translation_key + + 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): @@ -333,9 +450,7 @@ class TibberSensor(SensorEntity): _attr_has_entity_name = True - def __init__( - self, *args: Any, tibber_home: tibber.TibberHome, **kwargs: Any - ) -> None: + def __init__(self, *args: Any, tibber_home: TibberHome, **kwargs: Any) -> None: """Initialize the sensor.""" super().__init__(*args, **kwargs) self._tibber_home = tibber_home @@ -366,7 +481,7 @@ class TibberSensorElPrice(TibberSensor): _attr_state_class = SensorStateClass.MEASUREMENT _attr_translation_key = "electricity_price" - def __init__(self, tibber_home: tibber.TibberHome) -> None: + def __init__(self, tibber_home: TibberHome) -> None: """Initialize the sensor.""" super().__init__(tibber_home=tibber_home) self._last_updated: datetime.datetime | None = None @@ -443,7 +558,7 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]): def __init__( self, - tibber_home: tibber.TibberHome, + tibber_home: TibberHome, coordinator: TibberDataCoordinator, entity_description: SensorEntityDescription, ) -> None: @@ -470,7 +585,7 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]) def __init__( self, - tibber_home: tibber.TibberHome, + tibber_home: TibberHome, description: SensorEntityDescription, initial_state: float, coordinator: TibberRtDataCoordinator, @@ -532,7 +647,7 @@ class TibberRtEntityCreator: def __init__( self, async_add_entities: AddConfigEntryEntitiesCallback, - tibber_home: tibber.TibberHome, + tibber_home: TibberHome, entity_registry: er.EntityRegistry, ) -> None: """Initialize the data handler.""" @@ -618,7 +733,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en hass: HomeAssistant, config_entry: ConfigEntry, add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None], - tibber_home: tibber.TibberHome, + tibber_home: TibberHome, ) -> None: """Initialize the data handler.""" self._add_sensor_callback = add_sensor_callback diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py index d5bb3fd4854..cbe90ddda64 100644 --- a/homeassistant/components/tibber/services.py +++ b/homeassistant/components/tibber/services.py @@ -4,7 +4,7 @@ from __future__ import annotations import datetime as dt from datetime import datetime -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final import voluptuous as vol @@ -20,6 +20,9 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN +if TYPE_CHECKING: + from .const import TibberConfigEntry + PRICE_SERVICE_NAME = "get_prices" ATTR_START: Final = "start" ATTR_END: Final = "end" @@ -33,7 +36,13 @@ SERVICE_SCHEMA: Final = vol.Schema( 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") end = __get_date(call.data.get(ATTR_END), "end") @@ -57,7 +66,7 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse: selected_data = [ price 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 diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index 8bb6cb9f08f..b0fd693891e 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -1,7 +1,11 @@ { "config": { "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": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -9,6 +13,10 @@ "timeout": "[%key:common::config_flow::error::timeout_connect%]" }, "step": { + "reauth_confirm": { + "description": "Reconnect your Tibber account to refresh access.", + "title": "[%key:common::config_flow::title::reauth%]" + }, "user": { "data": { "access_token": "[%key:common::config_flow::data::access_token%]" @@ -40,6 +48,12 @@ "average_power": { "name": "Average power" }, + "charging_current_max": { + "name": "Maximum allowed charge current" + }, + "charging_current_offline_fallback": { + "name": "Fallback current if charger goes offline" + }, "current_l1": { "name": "Current L1" }, @@ -88,9 +102,18 @@ "power_production": { "name": "Power production" }, + "range_remaining": { + "name": "Estimated remaining driving range" + }, "signal_strength": { "name": "Signal strength" }, + "storage_state_of_charge": { + "name": "State of charge" + }, + "storage_target_state_of_charge": { + "name": "Target state of charge" + }, "voltage_phase1": { "name": "Voltage phase1" }, @@ -103,9 +126,18 @@ } }, "exceptions": { + "data_api_reauth_required": { + "message": "Reconnect Tibber so Home Assistant can enable the new Tibber Data API features." + }, "invalid_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": { "message": "Timeout sending message with Tibber" } diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index f97e0e05e33..39495fbfa3a 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -39,6 +39,7 @@ APPLICATION_CREDENTIALS = [ "spotify", "tesla_fleet", "teslemetry", + "tibber", "twitch", "volvo", "watts", diff --git a/requirements_all.txt b/requirements_all.txt index 36f5f7f3126..50e1ff6f217 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1867,7 +1867,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.32.2 +pyTibber==0.33.1 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 95e2fd993ee..5a95a9da251 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1595,7 +1595,7 @@ pyHomee==1.3.8 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.32.2 +pyTibber==0.33.1 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/tests/components/tibber/conftest.py b/tests/components/tibber/conftest.py index 441a9d0b888..2f514cdeb13 100644 --- a/tests/components/tibber/conftest.py +++ b/tests/components/tibber/conftest.py @@ -1,24 +1,76 @@ """Test helpers for Tibber.""" from collections.abc import AsyncGenerator -from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +import time +from unittest.mock import AsyncMock, MagicMock, patch import pytest +import tibber +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.recorder import Recorder -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.core import HomeAssistant +from homeassistant.setup import async_setup_component 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 def config_entry(hass: HomeAssistant) -> MockConfigEntry: """Tibber config entry.""" config_entry = MockConfigEntry( domain=DOMAIN, - data={CONF_ACCESS_TOKEN: "token"}, + data={ + CONF_ACCESS_TOKEN: "token", + AUTH_IMPLEMENTATION: DOMAIN, + "token": { + "access_token": "test-token", + "refresh_token": "refresh-token", + "token_type": "Bearer", + "expires_at": time.time() + 3600, + }, + }, unique_id="tibber", ) config_entry.add_to_hass(hass) @@ -26,21 +78,69 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture -async def mock_tibber_setup( - recorder_mock: Recorder, config_entry: MockConfigEntry, hass: HomeAssistant -) -> AsyncGenerator[MagicMock]: - """Mock tibber entry setup.""" +def _tibber_patches() -> AsyncGenerator[tuple[MagicMock, MagicMock]]: + """Patch the Tibber libraries used by the integration.""" unique_user_id = "unique_user_id" title = "title" - tibber_mock = MagicMock() - tibber_mock.update_info = AsyncMock(return_value=True) - tibber_mock.user_id = PropertyMock(return_value=unique_user_id) - tibber_mock.name = PropertyMock(return_value=title) - tibber_mock.send_notification = AsyncMock() - tibber_mock.rt_disconnect = AsyncMock() + with ( + patch( + "tibber.Tibber", + autospec=True, + ) as mock_tibber, + patch( + "tibber.data_api.TibberDataAPI", + autospec=True, + ) as mock_data_api_client, + ): + tibber_mock = mock_tibber.return_value + tibber_mock.update_info = AsyncMock(return_value=True) + tibber_mock.user_id = unique_user_id + tibber_mock.name = title + tibber_mock.send_notification = AsyncMock() + tibber_mock.rt_disconnect = AsyncMock() + tibber_mock.get_homes = MagicMock(return_value=[]) - with patch("tibber.Tibber", return_value=tibber_mock): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - yield tibber_mock + data_api_client_mock = mock_data_api_client.return_value + data_api_client_mock.get_all_devices = AsyncMock(return_value={}) + data_api_client_mock.update_devices = AsyncMock(return_value={}) + + yield tibber_mock, data_api_client_mock + + +@pytest.fixture +def tibber_mock(_tibber_patches: tuple[MagicMock, MagicMock]) -> MagicMock: + """Return the patched Tibber connection mock.""" + return _tibber_patches[0] + + +@pytest.fixture +def data_api_client_mock(_tibber_patches: tuple[MagicMock, MagicMock]) -> MagicMock: + """Return the patched Tibber Data API client mock.""" + return _tibber_patches[1] + + +@pytest.fixture +async def mock_tibber_setup( + recorder_mock: Recorder, + config_entry: MockConfigEntry, + hass: HomeAssistant, + tibber_mock: MagicMock, + setup_credentials: None, +) -> MagicMock: + """Mock tibber entry setup.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return tibber_mock + + +@pytest.fixture +async def setup_credentials(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, + ) diff --git a/tests/components/tibber/test_config_flow.py b/tests/components/tibber/test_config_flow.py index 0c12c4a247b..bcd77b29eb2 100644 --- a/tests/components/tibber/test_config_flow.py +++ b/tests/components/tibber/test_config_flow.py @@ -1,7 +1,9 @@ """Tests for Tibber config flow.""" -from asyncio import TimeoutError -from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +import builtins +from http import HTTPStatus +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import parse_qs, urlparse from aiohttp import ClientError import pytest @@ -13,16 +15,22 @@ from tibber import ( from homeassistant import config_entries from homeassistant.components.recorder import Recorder +from homeassistant.components.tibber.application_credentials import TOKEN_URL from homeassistant.components.tibber.config_flow import ( + DATA_API_DEFAULT_SCOPES, ERR_CLIENT, ERR_TIMEOUT, ERR_TOKEN, ) -from homeassistant.components.tibber.const import DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.components.tibber.const import AUTH_IMPLEMENTATION, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import HomeAssistant 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) def tibber_setup_fixture(): @@ -31,6 +39,22 @@ def tibber_setup_fixture(): yield +def _mock_tibber( + tibber_mock: MagicMock, + *, + user_id: str = "unique_user_id", + title: str = "Mock Name", + update_side_effect: Exception | None = None, +) -> MagicMock: + """Configure the patched Tibber GraphQL client.""" + 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: """Test show configuration form.""" result = await hass.config_entries.flow.async_init( @@ -41,77 +65,239 @@ async def test_show_config_form(recorder_mock: Recorder, hass: HomeAssistant) -> 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( ("exception", "expected_error"), [ - (TimeoutError, ERR_TIMEOUT), - (ClientError, ERR_CLIENT), + (builtins.TimeoutError(), ERR_TIMEOUT), + (ClientError(), ERR_CLIENT), (InvalidLoginError(401), ERR_TOKEN), (RetryableHttpExceptionError(503), ERR_CLIENT), (FatalHttpExceptionError(404), ERR_CLIENT), ], ) -async def test_create_entry_exceptions( - recorder_mock: Recorder, hass: HomeAssistant, exception, expected_error +async def test_graphql_step_exceptions( + recorder_mock: Recorder, + hass: HomeAssistant, + tibber_mock: MagicMock, + exception: Exception, + expected_error: str, ) -> None: - """Test create entry from user input.""" - test_data = { - CONF_ACCESS_TOKEN: "valid", - } + """Validate GraphQL errors are surfaced.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - 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( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data - ) + _mock_tibber(tibber_mock, update_side_effect=exception) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "invalid"} + ) assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" assert result["errors"][CONF_ACCESS_TOKEN] == expected_error async def test_flow_entry_already_exists( - recorder_mock: Recorder, hass: HomeAssistant, config_entry + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry, + tibber_mock: MagicMock, ) -> None: """Test user input for config_entry that already exists.""" - test_data = { - CONF_ACCESS_TOKEN: "valid", - } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - with patch("tibber.Tibber.update_info", return_value=None): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data - ) + _mock_tibber(tibber_mock, user_id="tibber") + 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, + data_api_client_mock: MagicMock, +) -> 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_mock.get_userinfo = AsyncMock(side_effect=ClientError()) + 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, + tibber_mock: MagicMock, +) -> None: + """Abort when OAuth credentials are missing.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + _mock_tibber(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( + hass: HomeAssistant, + tibber_mock: MagicMock, +) -> None: + """Ensure the OAuth implementation requests Tibber scopes.""" + with patch("homeassistant.components.recorder.async_setup", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + _mock_tibber(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( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + tibber_mock: MagicMock, + data_api_client_mock: MagicMock, +) -> None: + """Test configuring Tibber via GraphQL + OAuth.""" + with patch("homeassistant.components.recorder.async_setup", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + _mock_tibber(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_mock.get_userinfo = AsyncMock( + return_value={"name": "Mock Name"} + ) + 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, + tibber_mock: MagicMock, +) -> 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} + ) + + _mock_tibber(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["reason"] == "already_configured" diff --git a/tests/components/tibber/test_diagnostics.py b/tests/components/tibber/test_diagnostics.py index 16c735596d0..021b9138c34 100644 --- a/tests/components/tibber/test_diagnostics.py +++ b/tests/components/tibber/test_diagnostics.py @@ -1,56 +1,178 @@ -"""Test the Netatmo diagnostics.""" +"""Test the Tibber diagnostics.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock + +import aiohttp +import pytest +import tibber from homeassistant.components.recorder import Recorder +from homeassistant.components.tibber.diagnostics import ( + async_get_config_entry_diagnostics, +) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.exceptions import ConfigEntryAuthFailed +from .conftest import create_tibber_device from .test_common import mock_get_homes +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -async def test_entry_diagnostics( +async def test_entry_diagnostics_empty( recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator, - config_entry, + config_entry: MockConfigEntry, + mock_tibber_setup: MagicMock, ) -> None: - """Test config entry diagnostics.""" - with patch( - "tibber.Tibber.update_info", - return_value=None, - ): - assert await async_setup_component(hass, "tibber", {}) + """Test config entry diagnostics with no homes.""" + tibber_mock = mock_tibber_setup + tibber_mock.get_homes.return_value = [] + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert isinstance(result, dict) + assert "homes" in result + assert "devices" in result + assert result["homes"] == [] + assert result["devices"] == [] + + +async def test_entry_diagnostics_with_homes( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + mock_tibber_setup: MagicMock, +) -> None: + """Test config entry diagnostics with homes.""" + tibber_mock = mock_tibber_setup + tibber_mock.get_homes.side_effect = mock_get_homes + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert isinstance(result, dict) + assert "homes" in result + assert "devices" in result + + homes = result["homes"] + assert isinstance(homes, list) + assert len(homes) == 1 + + home = homes[0] + assert "last_data_timestamp" in home + assert "has_active_subscription" in home + assert "has_real_time_consumption" in home + assert "last_cons_data_timestamp" in home + assert "country" in home + assert home["has_active_subscription"] is True + assert home["has_real_time_consumption"] is False + assert home["country"] == "NO" + + +async def test_data_api_diagnostics_no_data( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + data_api_client_mock: MagicMock, + setup_credentials: None, +) -> None: + """Test Data API diagnostics when coordinator has no data.""" + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patch( - "tibber.Tibber.get_homes", - return_value=[], - ): - result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + data_api_client_mock.get_all_devices.assert_awaited_once() + data_api_client_mock.update_devices.assert_awaited_once() - assert result == { - "homes": [], + result = await async_get_config_entry_diagnostics(hass, config_entry) + + assert isinstance(result, dict) + assert "homes" in result + assert "devices" in result + assert isinstance(result["homes"], list) + assert isinstance(result["devices"], list) + assert result["devices"] == [] + + +async def test_data_api_diagnostics_with_devices( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + data_api_client_mock: MagicMock, + setup_credentials: None, +) -> 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( - "tibber.Tibber.get_homes", - side_effect=mock_get_homes, - ): - result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + data_api_client_mock.get_all_devices = AsyncMock(return_value=devices) + data_api_client_mock.update_devices = AsyncMock(return_value=devices) - assert result == { - "homes": [ - { - "last_data_timestamp": "2016-01-01T12:48:57", - "has_active_subscription": True, - "has_real_time_consumption": False, - "last_cons_data_timestamp": "2016-01-01T12:44:57", - "country": "NO", - } - ], - } + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await async_get_config_entry_diagnostics(hass, config_entry) + + assert isinstance(result, dict) + assert "homes" in result + assert "devices" in result + + devices_list = result["devices"] + assert isinstance(devices_list, list) + assert len(devices_list) == 2 + + device_1 = next((d for d in devices_list if d["id"] == "device-1"), None) + assert device_1 is not None + assert device_1["name"] == "Device 1" + assert device_1["brand"] == "Tibber" + assert device_1["model"] == "Test Model" + + device_2 = next((d for d in devices_list if d["id"] == "device-2"), None) + assert device_2 is not None + assert device_2["name"] == "Device 2" + assert device_2["brand"] == "Tibber" + assert device_2["model"] == "Test Model" + + +@pytest.mark.parametrize( + "exception", + [ + ConfigEntryAuthFailed("Auth failed"), + TimeoutError(), + aiohttp.ClientError("Connection error"), + tibber.InvalidLoginError(401), + tibber.RetryableHttpExceptionError(503), + tibber.FatalHttpExceptionError(404), + ], +) +async def test_data_api_diagnostics_exceptions( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + tibber_mock: MagicMock, + setup_credentials: None, + exception: Exception, +) -> None: + """Test Data API diagnostics with various exception scenarios.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + tibber_mock.get_homes.side_effect = exception + + with pytest.raises(type(exception)): + await async_get_config_entry_diagnostics(hass, config_entry) diff --git a/tests/components/tibber/test_init.py b/tests/components/tibber/test_init.py index 9e5c132c99d..3007ef34e13 100644 --- a/tests/components/tibber/test_init.py +++ b/tests/components/tibber/test_init.py @@ -1,11 +1,17 @@ """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.tibber import DOMAIN +from homeassistant.components.tibber import DOMAIN, TibberRuntimeData, async_setup_entry from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed + +from tests.common import MockConfigEntry async def test_entry_unload( @@ -19,3 +25,69 @@ async def test_entry_unload( mock_tibber_setup.rt_disconnect.assert_called_once() await hass.async_block_till_done(wait_background_tasks=True) 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) diff --git a/tests/components/tibber/test_sensor.py b/tests/components/tibber/test_sensor.py new file mode 100644 index 00000000000..83b55931363 --- /dev/null +++ b/tests/components/tibber/test_sensor.py @@ -0,0 +1,45 @@ +"""Tests for the Tibber Data API sensors and coordinator.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant.components.recorder import Recorder +from homeassistant.components.tibber.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import create_tibber_device + +from tests.common import MockConfigEntry + + +async def test_data_api_sensors_are_created( + recorder_mock: Recorder, + hass: HomeAssistant, + config_entry: MockConfigEntry, + data_api_client_mock: AsyncMock, + setup_credentials: None, + entity_registry: er.EntityRegistry, +) -> None: + """Ensure Data API sensors are created and expose values from the coordinator.""" + data_api_client_mock.get_all_devices = AsyncMock( + return_value={"device-id": create_tibber_device(value=72.0)} + ) + data_api_client_mock.update_devices = AsyncMock( + return_value={"device-id": create_tibber_device(value=83.0)} + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + data_api_client_mock.get_all_devices.assert_awaited_once() + data_api_client_mock.update_devices.assert_awaited_once() + + unique_id = "external-id_storage.stateOfCharge" + entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == 83.0