Compare commits

...

30 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
0f460730c5 adjust available state
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-27 20:33:01 +01:00
Daniel Hjelseth Høyer
2b898d2eab config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-27 18:54:32 +01:00
Daniel Hjelseth Høyer
45ddbb6ab8 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-27 16:48:46 +01:00
Daniel Hjelseth Høyer
7d8a89258f config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-27 16:36:51 +01:00
Daniel Hjelseth Høyer
420e01ef26 fix strings
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-26 07:13:34 +01:00
Daniel Hjelseth Høyer
b1b3eb80b1 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-25 20:25:58 +01:00
Daniel Hjelseth Høyer
1d717a1957 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-25 19:02:38 +01:00
Daniel Hjelseth Høyer
a8e3f79941 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-25 18:57:12 +01:00
Daniel Hjelseth Høyer
946afd7d91 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-25 07:17:34 +01:00
Daniel Hjelseth Høyer
f6277d0ec2 Merge branch 'dev' into tibber_data 2025-11-24 12:21:03 +01:00
Daniel Hjelseth Høyer
d7aa939f83 Merge branch 'tibber_data' of github.com:home-assistant/core into tibber_data 2025-11-19 06:53:01 +01:00
Daniel Hjelseth Høyer
77b349d00f test
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-19 06:52:06 +01:00
Daniel Hjelseth Høyer
1c036128fa Merge branch 'dev' into tibber_data 2025-11-18 08:40:09 +01:00
Daniel Hjelseth Høyer
16d898cc8e test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-18 07:12:48 +01:00
Daniel Hjelseth Høyer
a7225c7cd4 Merge branch 'dev' into tibber_data 2025-11-18 06:51:29 +01:00
Daniel Hjelseth Høyer
433a429c5a test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-18 06:37:33 +01:00
Daniel Hjelseth Høyer
c4770ed423 test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-17 20:57:03 +01:00
Daniel Hjelseth Høyer
df329fd273 test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-17 20:36:43 +01:00
Daniel Hjelseth Høyer
6eb40574bc tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 19:39:19 +01:00
Daniel Hjelseth Høyer
4fd1ef5483 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 17:49:18 +01:00
Daniel Hjelseth Høyer
7ec5d5305d Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 16:38:01 +01:00
Daniel Hjelseth Høyer
7f31d2538e Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 16:08:45 +01:00
Daniel Hjelseth Høyer
e1943307cf Merge branch 'dev' into tibber_data 2025-11-16 16:08:21 +01:00
Daniel Hjelseth Høyer
a06529d187 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 15:59:18 +01:00
Daniel Hjelseth Høyer
21554af6a1 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 12:14:03 +01:00
Daniel Hjelseth Høyer
b4aae93c45 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-14 19:18:22 +01:00
Daniel Hjelseth Høyer
1f9c244c5c Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-14 06:01:05 +01:00
Daniel Hjelseth Høyer
9fa1b1b8df Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-13 22:11:18 +01:00
Daniel Hjelseth Høyer
f3ac3ecf05 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-13 21:07:27 +01:00
Daniel Hjelseth Høyer
9477b2206b Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-13 20:07:57 +01:00
19 changed files with 1156 additions and 152 deletions

View File

@@ -1,20 +1,30 @@
"""Support for Tibber."""
from __future__ import annotations
from dataclasses import dataclass
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, DATA_HASS_CONFIG, DOMAIN
from .services import async_setup_services
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
@@ -24,6 +34,34 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
_LOGGER = logging.getLogger(__name__)
@dataclass(slots=True)
class TibberRuntimeData:
"""Runtime data for Tibber API entries."""
tibber_connection: tibber.Tibber
session: OAuth2Session | None = 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."""
if self.session is None:
raise ConfigEntryAuthFailed("OAuth session not available")
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."""
@@ -37,13 +75,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
if AUTH_IMPLEMENTATION not in entry.data:
entry.async_start_reauth(hass)
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="data_api_reauth_required",
)
tibber_connection = tibber.Tibber(
access_token=entry.data[CONF_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 +96,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
await tibber_connection.update_info()
except (
TimeoutError,
aiohttp.ClientError,
@@ -65,8 +108,32 @@ 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
hass.data[DOMAIN] = TibberRuntimeData(
tibber_connection=tibber_connection,
session=session,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -76,6 +143,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
config_entry, PLATFORMS
)
if unload_ok:
tibber_connection = hass.data[DOMAIN]
await tibber_connection.rt_disconnect()
if runtime := hass.data.pop(DOMAIN, None):
await runtime.tibber_connection.rt_disconnect()
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,17 +2,21 @@
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 AUTH_IMPLEMENTATION, DATA_API_DEFAULT_SCOPES, DOMAIN
DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
ERR_TIMEOUT = "timeout"
@@ -20,29 +24,58 @@ 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_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={},
)
self._access_token = user_input[CONF_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:
await tibber_connection.update_info()
except TimeoutError:
@@ -57,25 +90,79 @@ class TibberConfigFlow(ConfigFlow, domain=DOMAIN):
errors[CONF_ACCESS_TOKEN] = ERR_CLIENT
if errors:
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_ACCESS_TOKEN: self._access_token or ""}
)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
step_id=SOURCE_USER,
data_schema=data_schema,
description_placeholders={"url": TOKEN_URL},
errors=errors,
)
unique_id = tibber_connection.user_id
await self.async_set_unique_id(unique_id)
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()
self._async_abort_entries_match({AUTH_IMPLEMENTATION: DOMAIN})
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_ACCESS_TOKEN)
self._title = reauth_entry.title
if reauth_entry.unique_id is not None:
await self.async_set_unique_id(reauth_entry.unique_id)
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_ACCESS_TOKEN)
self._title = reauth_entry.title
if user_input is None:
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
description_placeholders={"url": TOKEN_URL},
errors={},
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_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_create_entry(title=self._title, data=data)

View File

@@ -1,5 +1,19 @@
"""Constants for Tibber integration."""
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",
]

View File

@@ -4,9 +4,14 @@ 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
if TYPE_CHECKING:
from . import TibberRuntimeData
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
@@ -22,6 +27,7 @@ from homeassistant.components.recorder.statistics import (
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
@@ -187,3 +193,45 @@ 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."""
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
runtime_data: TibberRuntimeData,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN} Data API",
update_interval=timedelta(minutes=1),
config_entry=entry,
)
self._runtime_data = runtime_data
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()
self.data = await client.get_all_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()
return devices

View File

@@ -2,23 +2,30 @@
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any, cast
import aiohttp
import tibber
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from .const import DOMAIN
if TYPE_CHECKING:
from . import TibberRuntimeData
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
tibber_connection: tibber.Tibber = hass.data[DOMAIN]
return {
runtime = cast("TibberRuntimeData | None", hass.data.get(DOMAIN))
if runtime is None:
return {"homes": []}
result: dict[str, Any] = {
"homes": [
{
"last_data_timestamp": home.last_data_timestamp,
@@ -27,6 +34,38 @@ 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)
]
}
if runtime.session:
devices: dict[str, Any] = {}
error: str | None = None
try:
client = await runtime.async_get_client(hass)
devices = await client.get_all_devices()
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",
"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"]
}

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from tibber import Tibber
from homeassistant.components.notify import (
@@ -14,7 +16,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
from .const import DOMAIN
if TYPE_CHECKING:
from . import TibberRuntimeData
async def async_setup_entry(
@@ -39,7 +44,10 @@ class TibberNotificationEntity(NotifyEntity):
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]
runtime = cast("TibberRuntimeData | None", self.hass.data.get(DOMAIN))
if runtime is None:
raise HomeAssistantError("Tibber integration is not initialized")
tibber_connection: Tibber = runtime.tibber_connection
try:
await tibber_connection.send_notification(
title or ATTR_TITLE_DEFAULT, message

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
import datetime
from datetime import timedelta
@@ -10,7 +11,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 +29,7 @@ from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfLength,
UnitOfPower,
)
from homeassistant.core import Event, HomeAssistant, callback
@@ -42,7 +45,7 @@ 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 .coordinator import TibberDataAPICoordinator, TibberDataCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -260,6 +263,58 @@ 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="connector.status",
translation_key="connector_status",
device_class=SensorDeviceClass.ENUM,
options=["connected", "disconnected", "unknown"],
),
SensorEntityDescription(
key="charging.status",
translation_key="charging_status",
device_class=SensorDeviceClass.ENUM,
options=["charging", "idle", "unknown"],
),
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,
@@ -267,7 +322,23 @@ async def async_setup_entry(
) -> None:
"""Set up the Tibber sensor."""
tibber_connection = hass.data[DOMAIN]
await asyncio.gather(
_async_setup_data_api_sensors(hass, entry, async_add_entities),
_async_setup_graphql_sensors(hass, entry, async_add_entities),
)
async def _async_setup_graphql_sensors(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber sensor."""
runtime = hass.data.get(DOMAIN)
if runtime is None:
raise PlatformNotReady("Tibber runtime is not ready")
tibber_connection = runtime.tibber_connection
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
@@ -280,7 +351,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
@@ -328,14 +403,91 @@ async def async_setup_entry(
async_add_entities(entities, True)
async def _async_setup_data_api_sensors(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors backed by the Tibber Data API."""
runtime = hass.data.get(DOMAIN)
if runtime is None:
raise PlatformNotReady("Tibber runtime is not ready")
coordinator = TibberDataAPICoordinator(hass, entry, runtime)
await coordinator.async_config_entry_first_refresh()
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.error("Sensor %s not found", 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."""
device = self.coordinator.data.get(self._device_id)
if device is None:
return None
for sensor in device.sensors:
if sensor.id == self.entity_description.key:
return sensor.value
return None
@property
def available(self) -> bool:
"""Return whether the sensor is available."""
return super().available and self.native_value is not None
class TibberSensor(SensorEntity):
"""Representation of a generic Tibber sensor."""
_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 +518,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 +595,7 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]):
def __init__(
self,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
coordinator: TibberDataCoordinator,
entity_description: SensorEntityDescription,
) -> None:
@@ -470,7 +622,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 +684,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 +770,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

View File

@@ -33,7 +33,8 @@ SERVICE_SCHEMA: Final = vol.Schema(
async def __get_prices(call: ServiceCall) -> ServiceResponse:
tibber_connection = call.hass.data[DOMAIN]
runtime = call.hass.data[DOMAIN]
tibber_connection = runtime.tibber_connection
start = __get_date(call.data.get(ATTR_START), "start")
end = __get_date(call.data.get(ATTR_END), "end")

View File

@@ -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,28 @@
"average_power": {
"name": "Average power"
},
"charging_current_max": {
"name": "Maximum charging current"
},
"charging_current_offline_fallback": {
"name": "Offline fallback charging current"
},
"charging_status": {
"name": "Charging status",
"state": {
"charging": "Charging",
"idle": "Idle",
"unknown": "Unknown"
}
},
"connector_status": {
"name": "Connector status",
"state": {
"connected": "Connected",
"disconnected": "Disconnected",
"unknown": "Unknown"
}
},
"current_l1": {
"name": "Current L1"
},
@@ -88,9 +118,18 @@
"power_production": {
"name": "Power production"
},
"range_remaining": {
"name": "Remaining range"
},
"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": {
"name": "Voltage phase1"
},
@@ -103,9 +142,15 @@
}
},
"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}"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"send_message_timeout": {
"message": "Timeout sending message with Tibber"
}

View File

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

2
requirements_all.txt generated
View File

@@ -1843,7 +1843,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

View File

@@ -1559,7 +1559,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

View File

@@ -4,21 +4,66 @@ from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock, PropertyMock, 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,
},
unique_id="tibber",
)
config_entry.add_to_hass(hass)
@@ -40,7 +85,35 @@ async def mock_tibber_setup(
tibber_mock.send_notification = AsyncMock()
tibber_mock.rt_disconnect = AsyncMock()
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()
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,
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
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,
)

View File

@@ -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(
*,
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:
"""Test show configuration form."""
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"
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,
exception: Exception,
expected_error: str,
) -> 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(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):
"""Validate GraphQL errors are surfaced."""
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["step_id"] == "user"
assert result["errors"][CONF_ACCESS_TOKEN] == expected_error
@@ -115,3 +112,123 @@ async def test_flow_entry_already_exists(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
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["reason"] == "already_configured"

View File

@@ -0,0 +1,131 @@
"""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.const import DOMAIN
from homeassistant.components.tibber.coordinator import TibberDataAPICoordinator
from homeassistant.components.tibber.sensor import (
TibberDataAPISensor,
_async_setup_data_api_sensors,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import UpdateFailed
from .conftest import create_tibber_device
from tests.common import MockConfigEntry
@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
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."""
runtime = MagicMock()
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)}
)
runtime.async_get_client = AsyncMock(return_value=client)
hass.data[DOMAIN] = runtime
added_entities: list[TibberDataAPISensor] = []
def async_add_entities(entities: list[TibberDataAPISensor]) -> None:
added_entities.extend(entities)
data_api_entry.mock_state(hass, ConfigEntryState.SETUP_IN_PROGRESS)
await _async_setup_data_api_sensors(hass, data_api_entry, async_add_entities)
assert runtime.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 = {}
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."""
runtime = MagicMock()
runtime.async_get_client = AsyncMock(side_effect=aiohttp.ClientError("boom"))
hass.data[DOMAIN] = runtime
coordinator = TibberDataAPICoordinator(hass, data_api_entry, runtime)
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."""
runtime = MagicMock()
runtime.async_get_client = AsyncMock(side_effect=ConfigEntryAuthFailed("invalid"))
hass.data[DOMAIN] = runtime
coordinator = TibberDataAPICoordinator(hass, data_api_entry, runtime)
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."""
runtime = MagicMock()
runtime.async_get_client = AsyncMock(side_effect=exception)
hass.data[DOMAIN] = runtime
coordinator = TibberDataAPICoordinator(hass, data_api_entry, runtime)
with pytest.raises(UpdateFailed):
await coordinator._async_update_data()

View File

@@ -1,13 +1,24 @@
"""Test the Netatmo diagnostics."""
"""Test the Tibber diagnostics."""
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
import pytest
import tibber
from homeassistant.components.recorder import Recorder
from homeassistant.components.tibber import TibberRuntimeData
from homeassistant.components.tibber.const import DOMAIN
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
@@ -16,31 +27,23 @@ async def test_entry_diagnostics(
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", {})
tibber_mock = mock_tibber_setup
runtime = hass.data[DOMAIN]
runtime.session = None
tibber_mock.get_homes.return_value = []
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)
assert result == {
"homes": [],
}
with patch(
"tibber.Tibber.get_homes",
side_effect=mock_get_homes,
):
tibber_mock.get_homes.side_effect = mock_get_homes
result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
assert result == {
@@ -54,3 +57,136 @@ async def test_entry_diagnostics(
}
],
}
async def test_data_api_diagnostics_no_runtime(
recorder_mock: Recorder,
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
) -> None:
"""Test Data API diagnostics when runtime is not available."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={},
unique_id="data-api",
)
config_entry.add_to_hass(hass)
tibber_mock = MagicMock()
tibber_mock.get_homes = MagicMock(return_value=[])
runtime = TibberRuntimeData(session=None, tibber_connection=tibber_mock)
hass.data[DOMAIN] = runtime
result = await async_get_config_entry_diagnostics(hass, config_entry)
assert result == {
"homes": [],
}
async def test_data_api_diagnostics_success(
recorder_mock: Recorder,
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
) -> None:
"""Test Data API diagnostics with successful device retrieval."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={},
unique_id="data-api",
)
config_entry.add_to_hass(hass)
session = MagicMock()
session.async_ensure_token_valid = AsyncMock()
session.token = "test-token"
client = MagicMock()
client.get_all_devices = AsyncMock(
return_value={
"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",
),
}
)
runtime = TibberRuntimeData(session=session, tibber_connection=MagicMock())
with patch.object(
TibberRuntimeData,
"async_get_client",
new_callable=AsyncMock,
return_value=client,
):
hass.data[DOMAIN] = runtime
result = await async_get_config_entry_diagnostics(hass, config_entry)
assert result["error"] is None
assert len(result["devices"]) == 2
device_ids = {device["id"] for device in result["devices"]}
assert device_ids == {"device-1", "device-2"}
for device in result["devices"]:
assert device["id"] in ("device-1", "device-2")
assert device["brand"] == "Tibber"
assert device["model"] == "Test Model"
if device["id"] == "device-1":
assert device["name"] == "Device 1"
else:
assert device["name"] == "Device 2"
@pytest.mark.parametrize(
("exception", "expected_error"),
[
(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,
hass_client: ClientSessionGenerator,
exception: Exception,
expected_error: str,
) -> None:
"""Test Data API diagnostics with various exception scenarios."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={},
unique_id="data-api",
)
config_entry.add_to_hass(hass)
session = MagicMock()
session.async_ensure_token_valid = AsyncMock()
session.token = "test-token"
client = MagicMock()
client.get_all_devices = AsyncMock(side_effect=exception)
runtime = TibberRuntimeData(session=session, tibber_connection=MagicMock())
with patch.object(
TibberRuntimeData,
"async_get_client",
new_callable=AsyncMock,
return_value=client,
):
hass.data[DOMAIN] = runtime
result = await async_get_config_entry_diagnostics(hass, config_entry)
assert result["error"] == expected_error
assert result["devices"] == []

View File

@@ -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,67 @@ 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 == 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() # type: ignore[assignment]
session.token = {CONF_ACCESS_TOKEN: "access-token"}
runtime = TibberRuntimeData(session=session, tibber_connection=MagicMock())
with patch(
"homeassistant.components.tibber.__init__.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() # type: ignore[assignment]
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)