From cb0b8a76502d58580a7a16bfd7597571c7e39b3e Mon Sep 17 00:00:00 2001 From: Daan Vervacke Date: Fri, 2 Oct 2026 23:18:06 +0200 Subject: [PATCH] Add ENGIE Belgium integration (#179094) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/engie_be/__init__.py | 96 ++ .../components/engie_be/config_flow.py | 147 +++ homeassistant/components/engie_be/const.py | 18 + .../components/engie_be/coordinator.py | 194 ++++ homeassistant/components/engie_be/icons.json | 156 +++ .../components/engie_be/manifest.json | 11 + .../components/engie_be/quality_scale.yaml | 78 ++ homeassistant/components/engie_be/sensor.py | 166 +++ .../components/engie_be/strings.json | 349 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + tests/components/engie_be/__init__.py | 1 + tests/components/engie_be/conftest.py | 202 ++++ .../engie_be/snapshots/test_sensor.ambr | 495 +++++++++ tests/components/engie_be/test_config_flow.py | 241 +++++ tests/components/engie_be/test_init.py | 346 ++++++ tests/components/engie_be/test_sensor.py | 998 ++++++++++++++++++ 21 files changed, 3521 insertions(+) create mode 100644 homeassistant/components/engie_be/__init__.py create mode 100644 homeassistant/components/engie_be/config_flow.py create mode 100644 homeassistant/components/engie_be/const.py create mode 100644 homeassistant/components/engie_be/coordinator.py create mode 100644 homeassistant/components/engie_be/icons.json create mode 100644 homeassistant/components/engie_be/manifest.json create mode 100644 homeassistant/components/engie_be/quality_scale.yaml create mode 100644 homeassistant/components/engie_be/sensor.py create mode 100644 homeassistant/components/engie_be/strings.json create mode 100644 tests/components/engie_be/__init__.py create mode 100644 tests/components/engie_be/conftest.py create mode 100644 tests/components/engie_be/snapshots/test_sensor.ambr create mode 100644 tests/components/engie_be/test_config_flow.py create mode 100644 tests/components/engie_be/test_init.py create mode 100644 tests/components/engie_be/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 871b9c121475..83e2b94f8867 100644 --- a/.strict-typing +++ b/.strict-typing @@ -204,6 +204,7 @@ homeassistant.components.energieleser.* homeassistant.components.energy.* homeassistant.components.energyid.* homeassistant.components.energyzero.* +homeassistant.components.engie_be.* homeassistant.components.enigma2.* homeassistant.components.enphase_envoy.* homeassistant.components.eq3btsmart.* diff --git a/CODEOWNERS b/CODEOWNERS index f8f98e498029..d85c20f061e1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -513,6 +513,8 @@ CLAUDE.md @home-assistant/core /tests/components/energyid/ @JrtPec @Molier /homeassistant/components/energyzero/ @klaasnicolaas /tests/components/energyzero/ @klaasnicolaas +/homeassistant/components/engie_be/ @DaanVervacke +/tests/components/engie_be/ @DaanVervacke /homeassistant/components/enigma2/ @autinerd /tests/components/enigma2/ @autinerd /homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac diff --git a/homeassistant/components/engie_be/__init__.py b/homeassistant/components/engie_be/__init__.py new file mode 100644 index 000000000000..54601c22d62d --- /dev/null +++ b/homeassistant/components/engie_be/__init__.py @@ -0,0 +1,96 @@ +"""The ENGIE Belgium integration.""" + +import asyncio +from dataclasses import dataclass + +from aioengiebelgium import EngieBeClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_REFRESH_TOKEN +from .coordinator import EngieBePricesCoordinator, EngieBeRelationsCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +@dataclass +class EngieBeHouseholdCoordinators: + """Per-household coordinators.""" + + prices: EngieBePricesCoordinator + + +@dataclass +class EngieBeRuntimeData: + """Runtime data for the ENGIE Belgium integration.""" + + relations: EngieBeRelationsCoordinator + households: dict[str, EngieBeHouseholdCoordinators] + + +type EngieBeConfigEntry = ConfigEntry[EngieBeRuntimeData] + + +async def async_setup_entry(hass: HomeAssistant, entry: EngieBeConfigEntry) -> bool: + """Set up ENGIE Belgium from a config entry.""" + + async def _persist_tokens(access_token: str, refresh_token: str) -> None: + """Persist rotated tokens to the config entry.""" + if ( + entry.data[CONF_ACCESS_TOKEN] == access_token + and entry.data[CONF_REFRESH_TOKEN] == refresh_token + ): + return + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_ACCESS_TOKEN: access_token, + CONF_REFRESH_TOKEN: refresh_token, + }, + ) + + client = EngieBeClient( + session=async_get_clientsession(hass), + access_token=entry.data[CONF_ACCESS_TOKEN], + refresh_token=entry.data[CONF_REFRESH_TOKEN], + on_token_refresh=_persist_tokens, + ) + + relations = EngieBeRelationsCoordinator(hass, entry, client) + await relations.async_config_entry_first_refresh() + + device_registry = dr.async_get(hass) + + @callback + def _async_create_household(ban: str) -> EngieBeHouseholdCoordinators: + """Build the coordinators for a business agreement and register its device.""" + household = EngieBeHouseholdCoordinators( + prices=EngieBePricesCoordinator( + hass, entry, client, ban, relations.data[ban] + ) + ) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, **household.prices.device_info + ) + return household + + households = {ban: _async_create_household(ban) for ban in relations.data} + await asyncio.gather( + *(household.prices.async_refresh() for household in households.values()) + ) + + entry.runtime_data = EngieBeRuntimeData(relations=relations, households=households) + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: EngieBeConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/engie_be/config_flow.py b/homeassistant/components/engie_be/config_flow.py new file mode 100644 index 000000000000..c40de72cbfc0 --- /dev/null +++ b/homeassistant/components/engie_be/config_flow.py @@ -0,0 +1,147 @@ +"""Config flow for the ENGIE Belgium integration.""" + +from typing import Any, override + +from aioengiebelgium import ( + AuthFlow, + EngieBeAuthenticationError, + EngieBeClient, + EngieBeCommunicationError, + EngieBeError, + EngieBeMfaError, + MfaMethod, +) +import probatio + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import CONF_MFA_METHOD, CONF_REFRESH_TOKEN, DOMAIN, USER_MANAGEMENT_URL + +_MFA_METHOD_SELECTOR = selector.SelectSelector( + selector.SelectSelectorConfig( + options=[method.value for method in MfaMethod], + translation_key="mfa_method", + ) +) +_CODE_SCHEMA = probatio.Schema({probatio.Required("code"): str}) + + +class EngieBeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ENGIE Belgium.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._email: str | None = None + self._password: str | None = None + self._mfa_method: MfaMethod = MfaMethod.SMS + self._auth_flow: AuthFlow | None = None + self._client: EngieBeClient | None = None + + async def _async_start_authentication( + self, user_input: dict[str, Any] + ) -> dict[str, str]: + """Start authentication with ENGIE Belgium and return any form errors.""" + self._email = user_input[CONF_EMAIL] + self._password = user_input[CONF_PASSWORD] + self._mfa_method = MfaMethod(user_input[CONF_MFA_METHOD]) + + session = async_create_clientsession(self.hass) + client = EngieBeClient(session=session) + self._client = client + try: + self._auth_flow = await client.async_start_authentication( + self._email, + self._password, + self._mfa_method, + auth_session=session, + ) + except EngieBeAuthenticationError: + return {"base": "invalid_auth"} + except EngieBeCommunicationError: + return {"base": "cannot_connect"} + except EngieBeError: + return {"base": "unknown"} + return {} + + async def _async_submit_mfa( + self, code: str + ) -> tuple[dict[str, str], tuple[str, str] | None]: + """Submit the MFA code and return (errors, tokens).""" + assert self._auth_flow is not None + try: + tokens = await self._auth_flow.async_submit_mfa(code) + except EngieBeMfaError: + return {"base": "invalid_mfa_code"}, None + except EngieBeAuthenticationError: + return {"base": "invalid_auth"}, None + except EngieBeCommunicationError: + return {"base": "cannot_connect"}, None + except EngieBeError: + return {"base": "unknown"}, None + return {}, tokens + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + errors = await self._async_start_authentication(user_input) + if not errors: + return await self.async_step_mfa() + + return self.async_show_form( + step_id="user", + data_schema=probatio.Schema( + { + probatio.Required(CONF_EMAIL): str, + probatio.Required(CONF_PASSWORD): str, + probatio.Required( + CONF_MFA_METHOD, default=MfaMethod.SMS.value + ): _MFA_METHOD_SELECTOR, + } + ), + description_placeholders={"user_management_url": USER_MANAGEMENT_URL}, + errors=errors, + ) + + async def async_step_mfa( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the MFA code entry step.""" + errors: dict[str, str] = {} + + if user_input is not None and self._email is not None: + errors, tokens = await self._async_submit_mfa(user_input["code"]) + if not errors and tokens is not None: + access_token, refresh_token = tokens + assert self._client is not None + subject = self._client.subject + if subject is None: + return self.async_show_form( + step_id="mfa", + data_schema=_CODE_SCHEMA, + errors={"base": "invalid_auth"}, + ) + await self.async_set_unique_id(subject) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self._email, + data={ + CONF_EMAIL: self._email, + CONF_MFA_METHOD: self._mfa_method.value, + CONF_ACCESS_TOKEN: access_token, + CONF_REFRESH_TOKEN: refresh_token, + }, + ) + + return self.async_show_form( + step_id="mfa", data_schema=_CODE_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/engie_be/const.py b/homeassistant/components/engie_be/const.py new file mode 100644 index 000000000000..ba16db480e4f --- /dev/null +++ b/homeassistant/components/engie_be/const.py @@ -0,0 +1,18 @@ +"""Constants for the ENGIE Belgium integration.""" + +from datetime import timedelta +import logging + +DOMAIN = "engie_be" +ATTRIBUTION = "Data provided by ENGIE Belgium" + +LOGGER = logging.getLogger(__package__) + +CONF_MFA_METHOD = "mfa_method" +CONF_REFRESH_TOKEN = "refresh_token" + +USER_MANAGEMENT_URL = ( + "https://www.engie.be/nl/energiedesk/usermanagement/manage-access/" +) + +PRICES_SCAN_INTERVAL = timedelta(hours=1) diff --git a/homeassistant/components/engie_be/coordinator.py b/homeassistant/components/engie_be/coordinator.py new file mode 100644 index 000000000000..884d7a7e3613 --- /dev/null +++ b/homeassistant/components/engie_be/coordinator.py @@ -0,0 +1,194 @@ +"""DataUpdateCoordinator for the ENGIE Belgium integration.""" + +import asyncio +from dataclasses import dataclass +from datetime import date +from typing import TYPE_CHECKING, override + +from aioengiebelgium import ( + BusinessAgreement, + EngieBeClient, + EngieBeError, + PricePeriod, + PriceSlot, + bare_ean, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import DOMAIN, LOGGER, PRICES_SCAN_INTERVAL + +if TYPE_CHECKING: + from . import EngieBeConfigEntry + +_DIRECTIONS = ("offtake", "injection") +_DIRECTION_PREFIXES = ("OFFTAKE_", "INJECTION_") +_BLENDED_SLOT_CODE = "EN" + + +def _mask(identifier: str) -> str: + """Mask an account/meter identifier down to its last four characters.""" + return f"…{identifier[-4:]}" + + +def normalize_slot_code(raw_code: str) -> str: + """Strip a redundant direction prefix from a raw time-of-use slot code.""" + for prefix in _DIRECTION_PREFIXES: + idx = raw_code.rfind(prefix) + if idx != -1: + return raw_code[idx + len(prefix) :] + return raw_code + + +def _current_period( + periods: tuple[PricePeriod, ...], today: date +) -> PricePeriod | None: + """Return the price period covering today, if any.""" + for period in periods: + if period.contains(today): + return period + return None + + +@dataclass +class EngieBePricesData: + """Pre-processed price lookup for one business agreement.""" + + slots: dict[tuple[str, str, str], PriceSlot] + eans: tuple[str, ...] + + +class EngieBeRelationsCoordinator(DataUpdateCoordinator[dict[str, BusinessAgreement]]): + """Coordinator that tracks the account's active business agreements.""" + + config_entry: EngieBeConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: EngieBeConfigEntry, + client: EngieBeClient, + ) -> None: + """Initialize the relations coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_relations", + ) + self.client = client + + @override + async def _async_update_data(self) -> dict[str, BusinessAgreement]: + """Fetch the account's active business agreements.""" + try: + relations = await self.client.async_get_customer_account_relations() + except EngieBeError as err: + raise UpdateFailed(str(err)) from err + + agreements = { + agreement.business_agreement_number: agreement + for account in relations.accounts + for agreement in account.customer_account.business_agreements + if agreement.active + } + if not agreements: + LOGGER.debug("No active business agreements found") + return agreements + + +class EngieBePricesCoordinator(DataUpdateCoordinator[EngieBePricesData]): + """Coordinator that fetches energy prices for one business agreement.""" + + config_entry: EngieBeConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: EngieBeConfigEntry, + client: EngieBeClient, + ban: str, + agreement: BusinessAgreement, + ) -> None: + """Initialize the prices coordinator for one business agreement.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_prices_{_mask(ban)}", + update_interval=PRICES_SCAN_INTERVAL, + ) + self.client = client + self.ban = ban + self.agreement = agreement + self.ean_energy_types: dict[str, str | None] = {} + device_name = ( + agreement.consumption_address.format() + if agreement.consumption_address is not None + else "" + ) or ban + self.device_info = DeviceInfo( + identifiers={(DOMAIN, ban)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="ENGIE Belgium", + name=device_name, + ) + + @override + async def _async_update_data(self) -> EngieBePricesData: + """Fetch this household's prices and pre-process them into a slot lookup.""" + try: + prices = await self.client.async_get_prices(self.ban) + except EngieBeError as err: + raise UpdateFailed(str(err)) from err + + new_eans = list( + dict.fromkeys( + ean_prices.ean + for ean_prices in prices.items + if bare_ean(ean_prices.ean) not in self.ean_energy_types + ) + ) + if new_eans: + service_points = await asyncio.gather( + *(self.client.async_get_service_point(ean) for ean in new_eans), + return_exceptions=True, + ) + for ean, service_point_result in zip(new_eans, service_points, strict=True): + if isinstance(service_point_result, EngieBeError): + LOGGER.debug( + "Fetching service point for %s failed: %s", + _mask(bare_ean(ean)), + service_point_result, + ) + continue + if isinstance(service_point_result, BaseException): + raise service_point_result + self.ean_energy_types.update(service_point_result.ean_energy_types) + self.ean_energy_types.setdefault(bare_ean(ean), None) + + brussels = dt_util.get_time_zone("Europe/Brussels") + assert brussels is not None + today = dt_util.now(brussels).date() + slots: dict[tuple[str, str, str], PriceSlot] = {} + for ean_prices in prices.items: + period = _current_period(ean_prices.periods, today) + if period is None: + continue + for direction in _DIRECTIONS: + direction_slots = ( + period.offtake if direction == "offtake" else period.injection + ) + for slot in direction_slots: + normalized = normalize_slot_code(slot.time_of_use_slot_code) + if normalized == _BLENDED_SLOT_CODE: + continue + slots[ean_prices.ean, direction, slot.time_of_use_slot_code] = slot + + return EngieBePricesData( + slots=slots, + eans=tuple(ean_prices.ean for ean_prices in prices.items), + ) diff --git a/homeassistant/components/engie_be/icons.json b/homeassistant/components/engie_be/icons.json new file mode 100644 index 000000000000..0ecf821a82b7 --- /dev/null +++ b/homeassistant/components/engie_be/icons.json @@ -0,0 +1,156 @@ +{ + "entity": { + "sensor": { + "electricity_price_injection": { + "default": "mdi:cash-plus" + }, + "electricity_price_injection_excl_vat": { + "default": "mdi:cash-plus" + }, + "electricity_price_injection_offpeak": { + "default": "mdi:cash-plus" + }, + "electricity_price_injection_offpeak_excl_vat": { + "default": "mdi:cash-plus" + }, + "electricity_price_injection_peak": { + "default": "mdi:cash-plus" + }, + "electricity_price_injection_peak_excl_vat": { + "default": "mdi:cash-plus" + }, + "electricity_price_injection_slot": { + "default": "mdi:cash-plus" + }, + "electricity_price_injection_slot_excl_vat": { + "default": "mdi:cash-plus" + }, + "electricity_price_injection_superoffpeak": { + "default": "mdi:cash-plus" + }, + "electricity_price_injection_superoffpeak_excl_vat": { + "default": "mdi:cash-plus" + }, + "electricity_price_offtake": { + "default": "mdi:cash-minus" + }, + "electricity_price_offtake_excl_vat": { + "default": "mdi:cash-minus" + }, + "electricity_price_offtake_offpeak": { + "default": "mdi:cash-minus" + }, + "electricity_price_offtake_offpeak_excl_vat": { + "default": "mdi:cash-minus" + }, + "electricity_price_offtake_peak": { + "default": "mdi:cash-minus" + }, + "electricity_price_offtake_peak_excl_vat": { + "default": "mdi:cash-minus" + }, + "electricity_price_offtake_slot": { + "default": "mdi:cash-minus" + }, + "electricity_price_offtake_slot_excl_vat": { + "default": "mdi:cash-minus" + }, + "electricity_price_offtake_superoffpeak": { + "default": "mdi:cash-minus" + }, + "electricity_price_offtake_superoffpeak_excl_vat": { + "default": "mdi:cash-minus" + }, + "energy_price_injection": { + "default": "mdi:cash-plus" + }, + "energy_price_injection_excl_vat": { + "default": "mdi:cash-plus" + }, + "energy_price_injection_offpeak": { + "default": "mdi:cash-plus" + }, + "energy_price_injection_offpeak_excl_vat": { + "default": "mdi:cash-plus" + }, + "energy_price_injection_peak": { + "default": "mdi:cash-plus" + }, + "energy_price_injection_peak_excl_vat": { + "default": "mdi:cash-plus" + }, + "energy_price_injection_slot": { + "default": "mdi:cash-plus" + }, + "energy_price_injection_slot_excl_vat": { + "default": "mdi:cash-plus" + }, + "energy_price_injection_superoffpeak": { + "default": "mdi:cash-plus" + }, + "energy_price_injection_superoffpeak_excl_vat": { + "default": "mdi:cash-plus" + }, + "energy_price_offtake": { + "default": "mdi:cash-minus" + }, + "energy_price_offtake_excl_vat": { + "default": "mdi:cash-minus" + }, + "energy_price_offtake_offpeak": { + "default": "mdi:cash-minus" + }, + "energy_price_offtake_offpeak_excl_vat": { + "default": "mdi:cash-minus" + }, + "energy_price_offtake_peak": { + "default": "mdi:cash-minus" + }, + "energy_price_offtake_peak_excl_vat": { + "default": "mdi:cash-minus" + }, + "energy_price_offtake_slot": { + "default": "mdi:cash-minus" + }, + "energy_price_offtake_slot_excl_vat": { + "default": "mdi:cash-minus" + }, + "energy_price_offtake_superoffpeak": { + "default": "mdi:cash-minus" + }, + "energy_price_offtake_superoffpeak_excl_vat": { + "default": "mdi:cash-minus" + }, + "gas_price_offtake": { + "default": "mdi:meter-gas" + }, + "gas_price_offtake_excl_vat": { + "default": "mdi:meter-gas" + }, + "gas_price_offtake_offpeak": { + "default": "mdi:meter-gas" + }, + "gas_price_offtake_offpeak_excl_vat": { + "default": "mdi:meter-gas" + }, + "gas_price_offtake_peak": { + "default": "mdi:meter-gas" + }, + "gas_price_offtake_peak_excl_vat": { + "default": "mdi:meter-gas" + }, + "gas_price_offtake_slot": { + "default": "mdi:meter-gas" + }, + "gas_price_offtake_slot_excl_vat": { + "default": "mdi:meter-gas" + }, + "gas_price_offtake_superoffpeak": { + "default": "mdi:meter-gas" + }, + "gas_price_offtake_superoffpeak_excl_vat": { + "default": "mdi:meter-gas" + } + } + } +} diff --git a/homeassistant/components/engie_be/manifest.json b/homeassistant/components/engie_be/manifest.json new file mode 100644 index 000000000000..25d7c623bc6e --- /dev/null +++ b/homeassistant/components/engie_be/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "engie_be", + "name": "ENGIE Belgium", + "codeowners": ["@DaanVervacke"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/engie_be", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["aioengiebelgium==0.4.3"] +} diff --git a/homeassistant/components/engie_be/quality_scale.yaml b/homeassistant/components/engie_be/quality_scale.yaml new file mode 100644 index 000000000000..88df97b554ec --- /dev/null +++ b/homeassistant/components/engie_be/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration provides no actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration provides no actions. + docs-conditions: + status: exempt + comment: Integration provides no conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration provides no triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration provides no actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration provides no options flow. + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Cloud account-bound integration; no discovery surface. + discovery: + status: exempt + comment: Cloud account-bound integration; no LAN/mDNS/USB surface to discover. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/engie_be/sensor.py b/homeassistant/components/engie_be/sensor.py new file mode 100644 index 000000000000..5d14b08278fc --- /dev/null +++ b/homeassistant/components/engie_be/sensor.py @@ -0,0 +1,166 @@ +"""Sensor platform for the ENGIE Belgium integration.""" + +from collections import Counter +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, override + +from aioengiebelgium import bare_ean + +from homeassistant.components.sensor import SensorEntity, SensorStateClass +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION +from .coordinator import ( + EngieBePricesCoordinator, + EngieBePricesData, + normalize_slot_code, +) + +if TYPE_CHECKING: + from . import EngieBeConfigEntry + +PARALLEL_UPDATES = 0 + +_UNIT = "EUR/kWh" +_SLOT_CODE_SUFFIXES = { + "TOTAL_HOURS": "", + "PEAK": "_peak", + "OFFPEAK": "_offpeak", + "SUPEROFFPEAK": "_superoffpeak", +} +_FALLBACK_SLOT_SUFFIX = "_slot" +_ENERGY_TYPE_KEYS = {"ELECTRICITY": "electricity", "GAS": "gas"} +_FALLBACK_TYPE_KEY = "energy" + + +def _energy_type_key(ean: str, ean_energy_types: Mapping[str, str | None]) -> str: + """Resolve the translation-key type dimension for an EAN.""" + energy_type = (ean_energy_types.get(bare_ean(ean)) or "").upper() + return _ENERGY_TYPE_KEYS.get(energy_type, _FALLBACK_TYPE_KEY) + + +def _duplicate_type_eans( + eans: Iterable[str], ean_energy_types: Mapping[str, str | None] +) -> set[str]: + """Return the EANs whose energy type recurs more than once in a household.""" + type_keys = {ean: _energy_type_key(ean, ean_energy_types) for ean in eans} + counts = Counter(type_keys.values()) + return {ean for ean, type_key in type_keys.items() if counts[type_key] > 1} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EngieBeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + runtime_data = entry.runtime_data + known_unique_ids: set[str] = set() + + @callback + def _async_add_new_entities() -> None: + """Add price sensors for any household/EAN/slot combination not yet known.""" + new_entities: list[EngieBePriceSensor] = [] + for ban, household in runtime_data.households.items(): + prices_data: EngieBePricesData | None = household.prices.data + if prices_data is None: + continue + duplicate_eans = _duplicate_type_eans( + prices_data.eans, household.prices.ean_energy_types + ) + for ean, direction, slot_code in prices_data.slots: + type_key = _energy_type_key(ean, household.prices.ean_energy_types) + ean_suffix = bare_ean(ean)[-4:] if ean in duplicate_eans else "" + for excl_vat in (False, True): + entity = EngieBePriceSensor( + household.prices, + business_agreement_number=ban, + ean=ean, + direction=direction, + slot_code=slot_code, + excl_vat=excl_vat, + type_key=type_key, + ean_suffix=ean_suffix, + ) + if entity.unique_id not in known_unique_ids: + new_entities.append(entity) + if new_entities: + known_unique_ids.update( + unique_id for entity in new_entities if (unique_id := entity.unique_id) + ) + async_add_entities(new_entities) + + for household in runtime_data.households.values(): + entry.async_on_unload( + household.prices.async_add_listener(_async_add_new_entities) + ) + _async_add_new_entities() + + +class EngieBePriceSensor(CoordinatorEntity[EngieBePricesCoordinator], SensorEntity): + """Representation of an ENGIE Belgium energy price sensor.""" + + _attr_has_entity_name = True + _attr_attribution = ATTRIBUTION + _attr_native_unit_of_measurement = _UNIT + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_suggested_display_precision = 6 + + def __init__( + self, + coordinator: EngieBePricesCoordinator, + *, + business_agreement_number: str, + ean: str, + direction: str, + slot_code: str, + excl_vat: bool, + type_key: str, + ean_suffix: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._attr_device_info = coordinator.device_info + self._ean = ean + self._direction = direction + self._slot_code = slot_code + self._excl_vat = excl_vat + + unique_id = f"{business_agreement_number}_{ean}_{direction}_{slot_code}" + self._attr_unique_id = f"{unique_id}_excl_vat" if excl_vat else unique_id + + normalized_slot_code = normalize_slot_code(slot_code) + suffix = _SLOT_CODE_SUFFIXES.get(normalized_slot_code, _FALLBACK_SLOT_SUFFIX) + translation_key = f"{type_key}_price_{direction}{suffix}" + if excl_vat: + translation_key = f"{translation_key}_excl_vat" + if ean_suffix: + translation_key = f"{translation_key}_with_ean" + self._attr_translation_key = translation_key + translation_placeholders: dict[str, str] = {} + if ean_suffix: + translation_placeholders["ean_suffix"] = ean_suffix + if suffix == _FALLBACK_SLOT_SUFFIX: + translation_placeholders["slot_code"] = normalized_slot_code.lower() + self._attr_translation_placeholders = translation_placeholders + if excl_vat: + self._attr_entity_registry_enabled_default = False + + @property + @override + def available(self) -> bool: + """Return True only when this entity's slot is present in the current data.""" + return ( + super().available + and (self._ean, self._direction, self._slot_code) + in self.coordinator.data.slots + ) + + @property + @override + def native_value(self) -> float | None: + """Return the current price.""" + slot = self.coordinator.data.slots[self._ean, self._direction, self._slot_code] + return slot.price_value_excl_vat if self._excl_vat else slot.price_value diff --git a/homeassistant/components/engie_be/strings.json b/homeassistant/components/engie_be/strings.json new file mode 100644 index 000000000000..6058fb5343a5 --- /dev/null +++ b/homeassistant/components/engie_be/strings.json @@ -0,0 +1,349 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_mfa_code": "Invalid verification code. Please try again.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "mfa": { + "data": { + "code": "Verification code" + }, + "data_description": { + "code": "The verification code ENGIE Belgium just sent you." + }, + "description": "Enter the 6-digit verification code that was sent to you." + }, + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "mfa_method": "Two-factor authentication method", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "Your ENGIE Belgium account email address.", + "mfa_method": "Choose how you want to receive your verification code.", + "password": "Your ENGIE Belgium account password." + }, + "description": "Enter your ENGIE Belgium credentials and two-factor authentication method. **A dedicated ENGIE account is required**: signing into the same account from engie.be or the ENGIE Smart App appears to revoke the integration's session and force you to re-authenticate. Create a separate user via the [ENGIE user management page]({user_management_url}). New accounts have 2FA enabled by default." + } + } + }, + "entity": { + "sensor": { + "electricity_price_injection": { + "name": "Electricity injection price" + }, + "electricity_price_injection_excl_vat": { + "name": "Electricity injection price excl. VAT" + }, + "electricity_price_injection_excl_vat_with_ean": { + "name": "Electricity injection price excl. VAT ({ean_suffix})" + }, + "electricity_price_injection_offpeak": { + "name": "Electricity off-peak injection price" + }, + "electricity_price_injection_offpeak_excl_vat": { + "name": "Electricity off-peak injection price excl. VAT" + }, + "electricity_price_injection_offpeak_excl_vat_with_ean": { + "name": "Electricity off-peak injection price excl. VAT ({ean_suffix})" + }, + "electricity_price_injection_offpeak_with_ean": { + "name": "Electricity off-peak injection price ({ean_suffix})" + }, + "electricity_price_injection_peak": { + "name": "Electricity peak injection price" + }, + "electricity_price_injection_peak_excl_vat": { + "name": "Electricity peak injection price excl. VAT" + }, + "electricity_price_injection_peak_excl_vat_with_ean": { + "name": "Electricity peak injection price excl. VAT ({ean_suffix})" + }, + "electricity_price_injection_peak_with_ean": { + "name": "Electricity peak injection price ({ean_suffix})" + }, + "electricity_price_injection_slot": { + "name": "Electricity injection price ({slot_code})" + }, + "electricity_price_injection_slot_excl_vat": { + "name": "Electricity injection price excl. VAT ({slot_code})" + }, + "electricity_price_injection_slot_excl_vat_with_ean": { + "name": "Electricity injection price excl. VAT ({slot_code}) ({ean_suffix})" + }, + "electricity_price_injection_slot_with_ean": { + "name": "Electricity injection price ({slot_code}) ({ean_suffix})" + }, + "electricity_price_injection_superoffpeak": { + "name": "Electricity super off-peak injection price" + }, + "electricity_price_injection_superoffpeak_excl_vat": { + "name": "Electricity super off-peak injection price excl. VAT" + }, + "electricity_price_injection_superoffpeak_excl_vat_with_ean": { + "name": "Electricity super off-peak injection price excl. VAT ({ean_suffix})" + }, + "electricity_price_injection_superoffpeak_with_ean": { + "name": "Electricity super off-peak injection price ({ean_suffix})" + }, + "electricity_price_injection_with_ean": { + "name": "Electricity injection price ({ean_suffix})" + }, + "electricity_price_offtake": { + "name": "Electricity offtake price" + }, + "electricity_price_offtake_excl_vat": { + "name": "Electricity offtake price excl. VAT" + }, + "electricity_price_offtake_excl_vat_with_ean": { + "name": "Electricity offtake price excl. VAT ({ean_suffix})" + }, + "electricity_price_offtake_offpeak": { + "name": "Electricity off-peak offtake price" + }, + "electricity_price_offtake_offpeak_excl_vat": { + "name": "Electricity off-peak offtake price excl. VAT" + }, + "electricity_price_offtake_offpeak_excl_vat_with_ean": { + "name": "Electricity off-peak offtake price excl. VAT ({ean_suffix})" + }, + "electricity_price_offtake_offpeak_with_ean": { + "name": "Electricity off-peak offtake price ({ean_suffix})" + }, + "electricity_price_offtake_peak": { + "name": "Electricity peak offtake price" + }, + "electricity_price_offtake_peak_excl_vat": { + "name": "Electricity peak offtake price excl. VAT" + }, + "electricity_price_offtake_peak_excl_vat_with_ean": { + "name": "Electricity peak offtake price excl. VAT ({ean_suffix})" + }, + "electricity_price_offtake_peak_with_ean": { + "name": "Electricity peak offtake price ({ean_suffix})" + }, + "electricity_price_offtake_slot": { + "name": "Electricity offtake price ({slot_code})" + }, + "electricity_price_offtake_slot_excl_vat": { + "name": "Electricity offtake price excl. VAT ({slot_code})" + }, + "electricity_price_offtake_slot_excl_vat_with_ean": { + "name": "Electricity offtake price excl. VAT ({slot_code}) ({ean_suffix})" + }, + "electricity_price_offtake_slot_with_ean": { + "name": "Electricity offtake price ({slot_code}) ({ean_suffix})" + }, + "electricity_price_offtake_superoffpeak": { + "name": "Electricity super off-peak offtake price" + }, + "electricity_price_offtake_superoffpeak_excl_vat": { + "name": "Electricity super off-peak offtake price excl. VAT" + }, + "electricity_price_offtake_superoffpeak_excl_vat_with_ean": { + "name": "Electricity super off-peak offtake price excl. VAT ({ean_suffix})" + }, + "electricity_price_offtake_superoffpeak_with_ean": { + "name": "Electricity super off-peak offtake price ({ean_suffix})" + }, + "electricity_price_offtake_with_ean": { + "name": "Electricity offtake price ({ean_suffix})" + }, + "energy_price_injection": { + "name": "Injection price" + }, + "energy_price_injection_excl_vat": { + "name": "Injection price excl. VAT" + }, + "energy_price_injection_excl_vat_with_ean": { + "name": "Injection price excl. VAT ({ean_suffix})" + }, + "energy_price_injection_offpeak": { + "name": "Off-peak injection price" + }, + "energy_price_injection_offpeak_excl_vat": { + "name": "Off-peak injection price excl. VAT" + }, + "energy_price_injection_offpeak_excl_vat_with_ean": { + "name": "Off-peak injection price excl. VAT ({ean_suffix})" + }, + "energy_price_injection_offpeak_with_ean": { + "name": "Off-peak injection price ({ean_suffix})" + }, + "energy_price_injection_peak": { + "name": "Peak injection price" + }, + "energy_price_injection_peak_excl_vat": { + "name": "Peak injection price excl. VAT" + }, + "energy_price_injection_peak_excl_vat_with_ean": { + "name": "Peak injection price excl. VAT ({ean_suffix})" + }, + "energy_price_injection_peak_with_ean": { + "name": "Peak injection price ({ean_suffix})" + }, + "energy_price_injection_slot": { + "name": "Injection price ({slot_code})" + }, + "energy_price_injection_slot_excl_vat": { + "name": "Injection price excl. VAT ({slot_code})" + }, + "energy_price_injection_slot_excl_vat_with_ean": { + "name": "Injection price excl. VAT ({slot_code}) ({ean_suffix})" + }, + "energy_price_injection_slot_with_ean": { + "name": "Injection price ({slot_code}) ({ean_suffix})" + }, + "energy_price_injection_superoffpeak": { + "name": "Super off-peak injection price" + }, + "energy_price_injection_superoffpeak_excl_vat": { + "name": "Super off-peak injection price excl. VAT" + }, + "energy_price_injection_superoffpeak_excl_vat_with_ean": { + "name": "Super off-peak injection price excl. VAT ({ean_suffix})" + }, + "energy_price_injection_superoffpeak_with_ean": { + "name": "Super off-peak injection price ({ean_suffix})" + }, + "energy_price_injection_with_ean": { + "name": "Injection price ({ean_suffix})" + }, + "energy_price_offtake": { + "name": "Offtake price" + }, + "energy_price_offtake_excl_vat": { + "name": "Offtake price excl. VAT" + }, + "energy_price_offtake_excl_vat_with_ean": { + "name": "Offtake price excl. VAT ({ean_suffix})" + }, + "energy_price_offtake_offpeak": { + "name": "Off-peak offtake price" + }, + "energy_price_offtake_offpeak_excl_vat": { + "name": "Off-peak offtake price excl. VAT" + }, + "energy_price_offtake_offpeak_excl_vat_with_ean": { + "name": "Off-peak offtake price excl. VAT ({ean_suffix})" + }, + "energy_price_offtake_offpeak_with_ean": { + "name": "Off-peak offtake price ({ean_suffix})" + }, + "energy_price_offtake_peak": { + "name": "Peak offtake price" + }, + "energy_price_offtake_peak_excl_vat": { + "name": "Peak offtake price excl. VAT" + }, + "energy_price_offtake_peak_excl_vat_with_ean": { + "name": "Peak offtake price excl. VAT ({ean_suffix})" + }, + "energy_price_offtake_peak_with_ean": { + "name": "Peak offtake price ({ean_suffix})" + }, + "energy_price_offtake_slot": { + "name": "Offtake price ({slot_code})" + }, + "energy_price_offtake_slot_excl_vat": { + "name": "Offtake price excl. VAT ({slot_code})" + }, + "energy_price_offtake_slot_excl_vat_with_ean": { + "name": "Offtake price excl. VAT ({slot_code}) ({ean_suffix})" + }, + "energy_price_offtake_slot_with_ean": { + "name": "Offtake price ({slot_code}) ({ean_suffix})" + }, + "energy_price_offtake_superoffpeak": { + "name": "Super off-peak offtake price" + }, + "energy_price_offtake_superoffpeak_excl_vat": { + "name": "Super off-peak offtake price excl. VAT" + }, + "energy_price_offtake_superoffpeak_excl_vat_with_ean": { + "name": "Super off-peak offtake price excl. VAT ({ean_suffix})" + }, + "energy_price_offtake_superoffpeak_with_ean": { + "name": "Super off-peak offtake price ({ean_suffix})" + }, + "energy_price_offtake_with_ean": { + "name": "Offtake price ({ean_suffix})" + }, + "gas_price_offtake": { + "name": "Gas offtake price" + }, + "gas_price_offtake_excl_vat": { + "name": "Gas offtake price excl. VAT" + }, + "gas_price_offtake_excl_vat_with_ean": { + "name": "Gas offtake price excl. VAT ({ean_suffix})" + }, + "gas_price_offtake_offpeak": { + "name": "Gas off-peak offtake price" + }, + "gas_price_offtake_offpeak_excl_vat": { + "name": "Gas off-peak offtake price excl. VAT" + }, + "gas_price_offtake_offpeak_excl_vat_with_ean": { + "name": "Gas off-peak offtake price excl. VAT ({ean_suffix})" + }, + "gas_price_offtake_offpeak_with_ean": { + "name": "Gas off-peak offtake price ({ean_suffix})" + }, + "gas_price_offtake_peak": { + "name": "Gas peak offtake price" + }, + "gas_price_offtake_peak_excl_vat": { + "name": "Gas peak offtake price excl. VAT" + }, + "gas_price_offtake_peak_excl_vat_with_ean": { + "name": "Gas peak offtake price excl. VAT ({ean_suffix})" + }, + "gas_price_offtake_peak_with_ean": { + "name": "Gas peak offtake price ({ean_suffix})" + }, + "gas_price_offtake_slot": { + "name": "Gas offtake price ({slot_code})" + }, + "gas_price_offtake_slot_excl_vat": { + "name": "Gas offtake price excl. VAT ({slot_code})" + }, + "gas_price_offtake_slot_excl_vat_with_ean": { + "name": "Gas offtake price excl. VAT ({slot_code}) ({ean_suffix})" + }, + "gas_price_offtake_slot_with_ean": { + "name": "Gas offtake price ({slot_code}) ({ean_suffix})" + }, + "gas_price_offtake_superoffpeak": { + "name": "Gas super off-peak offtake price" + }, + "gas_price_offtake_superoffpeak_excl_vat": { + "name": "Gas super off-peak offtake price excl. VAT" + }, + "gas_price_offtake_superoffpeak_excl_vat_with_ean": { + "name": "Gas super off-peak offtake price excl. VAT ({ean_suffix})" + }, + "gas_price_offtake_superoffpeak_with_ean": { + "name": "Gas super off-peak offtake price ({ean_suffix})" + }, + "gas_price_offtake_with_ean": { + "name": "Gas offtake price ({ean_suffix})" + } + } + }, + "selector": { + "mfa_method": { + "options": { + "email": "Email", + "sms": "SMS" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c070766c4c4e..483f10740d3a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -215,6 +215,7 @@ FLOWS = { "energieleser", "energyid", "energyzero", + "engie_be", "enigma2", "enocean", "enphase_envoy", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e541c28f21e9..c7645a96c7a5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1873,6 +1873,12 @@ "iot_class": "cloud_polling", "single_config_entry": true }, + "engie_be": { + "name": "ENGIE Belgium", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "enigma2": { "name": "Enigma2 (OpenWebif)", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index a1f98187cb8a..8046f3dad4db 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1798,6 +1798,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.engie_be.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.enigma2.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 67cc85f60287..844a35740e1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -259,6 +259,9 @@ aioelectricitymaps==1.1.1 # homeassistant.components.emonitor aioemonitor==1.0.5 +# homeassistant.components.engie_be +aioengiebelgium==0.4.3 + # homeassistant.components.esphome aioesphomeapi==46.6.0 diff --git a/tests/components/engie_be/__init__.py b/tests/components/engie_be/__init__.py new file mode 100644 index 000000000000..1c157b676891 --- /dev/null +++ b/tests/components/engie_be/__init__.py @@ -0,0 +1 @@ +"""Tests for the ENGIE Belgium integration.""" diff --git a/tests/components/engie_be/conftest.py b/tests/components/engie_be/conftest.py new file mode 100644 index 000000000000..c7b296942405 --- /dev/null +++ b/tests/components/engie_be/conftest.py @@ -0,0 +1,202 @@ +"""Common fixtures for the ENGIE Belgium tests.""" + +from collections.abc import Generator +from datetime import date +from unittest.mock import AsyncMock, MagicMock, patch + +from aioengiebelgium import ( + AccountRelation, + AuthFlow, + BusinessAgreement, + ConsumptionAddress, + CustomerAccount, + CustomerAccountRelations, + EanPrices, + PricePeriod, + PriceSlot, + PricesResponse, + ServicePoint, + bare_ean, +) +import pytest + +from homeassistant.components.engie_be.const import ( + CONF_MFA_METHOD, + CONF_REFRESH_TOKEN, + DOMAIN, +) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL + +from tests.common import MockConfigEntry + +EMAIL = "user@example.com" +PASSWORD = "hunter2" +SUBJECT = "auth0|69f5b418f3be21cc2ede9c98" +BAN = "000000000001" +BAN_2 = "000000000002" +OFFTAKE_ONLY_EAN = "541448820000000001_ID1" +OFFTAKE_INJECTION_EAN = "541448820000000002_ID1" +_SERVICE_POINT_ENERGY_TYPES = { + bare_ean(OFFTAKE_ONLY_EAN): "GAS", + bare_ean(OFFTAKE_INJECTION_EAN): "ELECTRICITY", +} + + +def _business_agreement(ban: str, *, with_address: bool = True) -> BusinessAgreement: + """Build a single active business agreement.""" + return BusinessAgreement( + business_agreement_number=ban, + active=True, + consumption_address=ConsumptionAddress( + street="Main street", + house_number="1", + postal_code="1000", + city="Brussels", + ) + if with_address + else None, + ) + + +def build_relations(*bans: str, with_address: bool = True) -> CustomerAccountRelations: + """Build a customer-account-relations response with the given active BANs.""" + bans = bans or (BAN,) + return CustomerAccountRelations( + accounts=( + AccountRelation( + id="account-1", + admin=True, + customer_account=CustomerAccount( + customer_account_number="can-1", + business_agreements=tuple( + _business_agreement(ban, with_address=with_address) + for ban in bans + ), + ), + ), + ) + ) + + +def build_prices( + *, + valid_from: date | None = date(2000, 1, 1), + valid_to: date | None = date(2099, 12, 31), +) -> PricesResponse: + """Build a prices response with an offtake-only EAN and a dual-direction EAN.""" + return PricesResponse( + items=( + EanPrices( + ean=OFFTAKE_ONLY_EAN, + periods=( + PricePeriod( + valid_from=valid_from, + valid_to=valid_to, + vat_tariff=6.0, + offtake=( + PriceSlot( + time_of_use_slot_code="TOTAL_HOURS", + price_value=0.123456, + price_value_excl_vat=0.116468, + ), + ), + ), + ), + ), + EanPrices( + ean=OFFTAKE_INJECTION_EAN, + periods=( + PricePeriod( + valid_from=valid_from, + valid_to=valid_to, + vat_tariff=6.0, + offtake=( + PriceSlot( + time_of_use_slot_code="S_TOU1_OFFTAKE_PEAK", + price_value=0.18, + price_value_excl_vat=0.169811, + ), + PriceSlot( + time_of_use_slot_code="EN", + price_value=0.12, + price_value_excl_vat=0.113208, + ), + PriceSlot( + time_of_use_slot_code="S_TOU1_OFFTAKE_WEEKEND", + price_value=0.15, + price_value_excl_vat=0.141509, + ), + ), + injection=( + PriceSlot( + time_of_use_slot_code="S_TOU1_INJECTION_PEAK", + price_value=0.05, + price_value_excl_vat=0.047170, + ), + ), + ), + ), + ), + ) + ) + + +def build_service_point(ean: str) -> ServicePoint: + """Build a service point response for a queried (possibly suffixed) EAN.""" + bare = bare_ean(ean) + return ServicePoint(ean_energy_types={bare: _SERVICE_POINT_ENERGY_TYPES[bare]}) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=EMAIL, + unique_id=SUBJECT, + data={ + CONF_EMAIL: EMAIL, + CONF_MFA_METHOD: "sms", + CONF_ACCESS_TOKEN: "access-token", + CONF_REFRESH_TOKEN: "refresh-token", + }, + ) + + +@pytest.fixture +def mock_engie_client(mock_auth_flow: MagicMock) -> Generator[MagicMock]: + """Mock the EngieBeClient class constructed by the integration and config flow.""" + with ( + patch( + "homeassistant.components.engie_be.EngieBeClient", autospec=True + ) as mock_client_class, + patch( + "homeassistant.components.engie_be.config_flow.EngieBeClient", + new=mock_client_class, + ), + ): + client = mock_client_class.return_value + client.async_get_customer_account_relations.return_value = build_relations() + client.async_get_prices.return_value = build_prices() + client.async_get_service_point.side_effect = build_service_point + client.async_start_authentication.return_value = mock_auth_flow + yield mock_client_class + + +@pytest.fixture +def mock_auth_flow() -> MagicMock: + """Return a mock in-progress authentication flow.""" + auth_flow = MagicMock(spec=AuthFlow) + auth_flow.async_submit_mfa = AsyncMock( + return_value=("new-access-token", "new-refresh-token") + ) + return auth_flow + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.engie_be.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/engie_be/snapshots/test_sensor.ambr b/tests/components/engie_be/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..5e3316599745 --- /dev/null +++ b/tests/components/engie_be/snapshots/test_sensor.ambr @@ -0,0 +1,495 @@ +# serializer version: 1 +# name: test_sensors[Main street 1, 1000 Brussels-device] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'engie_be', + '000000000001', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'ENGIE Belgium', + 'model': None, + 'model_id': None, + 'name': 'Main street 1, 1000 Brussels', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_electricity_offtake_price_excl_vat_weekend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.main_street_1_1000_brussels_electricity_offtake_price_excl_vat_weekend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity offtake price excl. VAT (weekend)', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 6, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electricity offtake price excl. VAT (weekend)', + 'platform': 'engie_be', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_price_offtake_slot_excl_vat', + 'unique_id': '000000000001_541448820000000002_ID1_offtake_S_TOU1_OFFTAKE_WEEKEND_excl_vat', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_electricity_offtake_price_excl_vat_weekend-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by ENGIE Belgium', + : 'Main street 1, 1000 Brussels Electricity offtake price excl. VAT (weekend)', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.main_street_1_1000_brussels_electricity_offtake_price_excl_vat_weekend', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.141509', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_electricity_offtake_price_weekend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.main_street_1_1000_brussels_electricity_offtake_price_weekend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity offtake price (weekend)', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 6, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electricity offtake price (weekend)', + 'platform': 'engie_be', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_price_offtake_slot', + 'unique_id': '000000000001_541448820000000002_ID1_offtake_S_TOU1_OFFTAKE_WEEKEND', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_electricity_offtake_price_weekend-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by ENGIE Belgium', + : 'Main street 1, 1000 Brussels Electricity offtake price (weekend)', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.main_street_1_1000_brussels_electricity_offtake_price_weekend', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.15', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_electricity_peak_injection_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.main_street_1_1000_brussels_electricity_peak_injection_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity peak injection price', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 6, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electricity peak injection price', + 'platform': 'engie_be', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_price_injection_peak', + 'unique_id': '000000000001_541448820000000002_ID1_injection_S_TOU1_INJECTION_PEAK', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_electricity_peak_injection_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by ENGIE Belgium', + : 'Main street 1, 1000 Brussels Electricity peak injection price', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.main_street_1_1000_brussels_electricity_peak_injection_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.05', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_electricity_peak_injection_price_excl_vat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.main_street_1_1000_brussels_electricity_peak_injection_price_excl_vat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity peak injection price excl. VAT', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 6, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electricity peak injection price excl. VAT', + 'platform': 'engie_be', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_price_injection_peak_excl_vat', + 'unique_id': '000000000001_541448820000000002_ID1_injection_S_TOU1_INJECTION_PEAK_excl_vat', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_electricity_peak_injection_price_excl_vat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by ENGIE Belgium', + : 'Main street 1, 1000 Brussels Electricity peak injection price excl. VAT', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.main_street_1_1000_brussels_electricity_peak_injection_price_excl_vat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.04717', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_electricity_peak_offtake_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.main_street_1_1000_brussels_electricity_peak_offtake_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity peak offtake price', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 6, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electricity peak offtake price', + 'platform': 'engie_be', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_price_offtake_peak', + 'unique_id': '000000000001_541448820000000002_ID1_offtake_S_TOU1_OFFTAKE_PEAK', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_electricity_peak_offtake_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by ENGIE Belgium', + : 'Main street 1, 1000 Brussels Electricity peak offtake price', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.main_street_1_1000_brussels_electricity_peak_offtake_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.18', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_electricity_peak_offtake_price_excl_vat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.main_street_1_1000_brussels_electricity_peak_offtake_price_excl_vat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity peak offtake price excl. VAT', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 6, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electricity peak offtake price excl. VAT', + 'platform': 'engie_be', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_price_offtake_peak_excl_vat', + 'unique_id': '000000000001_541448820000000002_ID1_offtake_S_TOU1_OFFTAKE_PEAK_excl_vat', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_electricity_peak_offtake_price_excl_vat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by ENGIE Belgium', + : 'Main street 1, 1000 Brussels Electricity peak offtake price excl. VAT', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.main_street_1_1000_brussels_electricity_peak_offtake_price_excl_vat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.169811', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_gas_offtake_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.main_street_1_1000_brussels_gas_offtake_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Gas offtake price', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 6, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gas offtake price', + 'platform': 'engie_be', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gas_price_offtake', + 'unique_id': '000000000001_541448820000000001_ID1_offtake_TOTAL_HOURS', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_gas_offtake_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by ENGIE Belgium', + : 'Main street 1, 1000 Brussels Gas offtake price', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.main_street_1_1000_brussels_gas_offtake_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.123456', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_gas_offtake_price_excl_vat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.main_street_1_1000_brussels_gas_offtake_price_excl_vat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Gas offtake price excl. VAT', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 6, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gas offtake price excl. VAT', + 'platform': 'engie_be', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gas_price_offtake_excl_vat', + 'unique_id': '000000000001_541448820000000001_ID1_offtake_TOTAL_HOURS_excl_vat', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensors[sensor.main_street_1_1000_brussels_gas_offtake_price_excl_vat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by ENGIE Belgium', + : 'Main street 1, 1000 Brussels Gas offtake price excl. VAT', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.main_street_1_1000_brussels_gas_offtake_price_excl_vat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.116468', + }) +# --- diff --git a/tests/components/engie_be/test_config_flow.py b/tests/components/engie_be/test_config_flow.py new file mode 100644 index 000000000000..b1d5d537e575 --- /dev/null +++ b/tests/components/engie_be/test_config_flow.py @@ -0,0 +1,241 @@ +"""Test the ENGIE Belgium config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from aioengiebelgium import ( + EngieBeAuthenticationError, + EngieBeCommunicationError, + EngieBeError, + EngieBeMfaError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.engie_be.const import CONF_MFA_METHOD, DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .conftest import EMAIL, PASSWORD, SUBJECT + +from tests.common import MockConfigEntry + +USER_INPUT = { + CONF_EMAIL: EMAIL, + CONF_PASSWORD: PASSWORD, + CONF_MFA_METHOD: "sms", +} + + +async def test_full_flow( + hass: HomeAssistant, + mock_engie_client: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user config flow.""" + mock_engie_client.return_value.subject = SUBJECT + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + sentinel_session = MagicMock() + with patch( + "homeassistant.components.engie_be.config_flow.async_create_clientsession", + return_value=sentinel_session, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mfa" + + call_kwargs = ( + mock_engie_client.return_value.async_start_authentication.call_args.kwargs + ) + assert call_kwargs["auth_session"] is sentinel_session + assert call_kwargs["auth_session"] is not async_get_clientsession(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"code": "123456"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == EMAIL + assert result["data"][CONF_EMAIL] == EMAIL + assert result["data"]["access_token"] == "new-access-token" + assert result["data"]["refresh_token"] == "new-refresh-token" + assert CONF_PASSWORD not in result["data"] + assert result["result"].unique_id == SUBJECT + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (EngieBeCommunicationError("boom"), "cannot_connect"), + (EngieBeAuthenticationError("boom"), "invalid_auth"), + (EngieBeError("boom"), "unknown"), + ], +) +async def test_user_step_errors( + hass: HomeAssistant, + mock_engie_client: MagicMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test recoverable errors on the user step.""" + mock_engie_client.return_value.subject = SUBJECT + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_engie_client.return_value.async_start_authentication.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_engie_client.return_value.async_start_authentication.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mfa" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"code": "123456"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_setup_entry.called + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (EngieBeMfaError("bad code"), "invalid_mfa_code"), + (EngieBeAuthenticationError("boom"), "invalid_auth"), + (EngieBeCommunicationError("boom"), "cannot_connect"), + (EngieBeError("boom"), "unknown"), + ], +) +async def test_mfa_submit_errors_recovery( + hass: HomeAssistant, + mock_engie_client: MagicMock, + mock_auth_flow: MagicMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test recoverable errors on the MFA submit step.""" + mock_engie_client.return_value.subject = SUBJECT + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["step_id"] == "mfa" + + mock_auth_flow.async_submit_mfa.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"code": "000000"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mfa" + assert result["errors"] == {"base": error} + + mock_auth_flow.async_submit_mfa.side_effect = None + mock_auth_flow.async_submit_mfa.return_value = ("access", "refresh") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"code": "123456"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_setup_entry.called + + +async def test_already_configured( + hass: HomeAssistant, + mock_engie_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting when the JWT subject is already configured.""" + mock_engie_client.return_value.subject = SUBJECT + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry(mock_config_entry, unique_id=SUBJECT) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mfa" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"code": "123456"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_two_accounts_get_distinct_entries( + hass: HomeAssistant, + mock_engie_client: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test that two distinct JWT subjects create two distinct entries.""" + other_subject = "auth0|7a3c9e21b48f5d0261af3d77" + for subject in (SUBJECT, other_subject): + mock_engie_client.return_value.subject = subject + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["step_id"] == "mfa" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"code": "123456"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + assert {entry.unique_id for entry in entries} == {SUBJECT, other_subject} + + +async def test_jwt_subject_missing_shows_form_error( + hass: HomeAssistant, + mock_engie_client: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test that a missing JWT subject shows a form error on the MFA step.""" + mock_engie_client.return_value.subject = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["step_id"] == "mfa" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"code": "123456"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mfa" + assert result["errors"] == {"base": "invalid_auth"} + + mock_engie_client.return_value.subject = SUBJECT + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"code": "123456"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_setup_entry.called diff --git a/tests/components/engie_be/test_init.py b/tests/components/engie_be/test_init.py new file mode 100644 index 000000000000..e55170bd1865 --- /dev/null +++ b/tests/components/engie_be/test_init.py @@ -0,0 +1,346 @@ +"""Test the ENGIE Belgium integration setup.""" + +from unittest.mock import AsyncMock, MagicMock + +from aioengiebelgium import ( + AccountRelation, + CustomerAccount, + CustomerAccountRelations, + EngieBeAuthenticationError, + EngieBeCommunicationError, + PricesResponse, +) +import pytest + +from homeassistant.components.engie_be.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .conftest import BAN, BAN_2, OFFTAKE_ONLY_EAN, build_prices, build_relations + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_engie_client") +async def test_setup_and_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful setup and unload of a config entry.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "side_effect", + [ + pytest.param(EngieBeCommunicationError("boom"), id="communication_error"), + pytest.param(EngieBeAuthenticationError("boom"), id="auth_error"), + ], +) +async def test_setup_relations_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + side_effect: Exception, +) -> None: + """Test setup handles a failure of the customer-account-relations fetch.""" + mock_engie_client.return_value.async_get_customer_account_relations.side_effect = ( + side_effect + ) + mock_config_entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert not hass.config_entries.flow.async_progress() + + +@pytest.mark.parametrize( + "side_effect", + [ + pytest.param(EngieBeCommunicationError("boom"), id="communication_error"), + pytest.param(EngieBeAuthenticationError("boom"), id="auth_error"), + ], +) +async def test_setup_prices_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + side_effect: Exception, +) -> None: + """Test setup succeeds with a device but no entities when the initial prices fetch fails.""" + mock_engie_client.return_value.async_get_prices.side_effect = side_effect + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, BAN), mock_config_entry.entry_id + ) + is not None + ) + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + +async def test_partial_first_refresh( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup comes up when only one of two BANs succeeds on the first refresh.""" + mock_engie_client.return_value.async_get_customer_account_relations.return_value = ( + build_relations(BAN, BAN_2) + ) + mock_engie_client.return_value.async_get_prices.side_effect = [ + build_prices(), + EngieBeCommunicationError("boom"), + ] + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert ( + entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{BAN}_{OFFTAKE_ONLY_EAN}_offtake_TOTAL_HOURS" + ) + is not None + ) + assert ( + entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{BAN_2}_{OFFTAKE_ONLY_EAN}_offtake_TOTAL_HOURS" + ) + is None + ) + assert "Error fetching" in caplog.text + assert BAN_2 not in caplog.text + assert BAN_2[-4:] in caplog.text + + +async def test_all_households_loaded_despite_prices_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup succeeds with devices but no entities when every household's first refresh fails.""" + mock_engie_client.return_value.async_get_customer_account_relations.return_value = ( + build_relations(BAN, BAN_2) + ) + mock_engie_client.return_value.async_get_prices.side_effect = ( + EngieBeCommunicationError("boom") + ) + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(device_entries) == 2 + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + +async def test_setup_without_active_agreements( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup succeeds without devices or entities when there are no active business agreements.""" + mock_engie_client.return_value.async_get_customer_account_relations.return_value = ( + CustomerAccountRelations( + accounts=( + AccountRelation( + id="account-1", + admin=True, + customer_account=CustomerAccount( + customer_account_number="can-1", + business_agreements=(), + ), + ), + ) + ) + ) + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert not dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + +async def test_device_created_for_household_without_prices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test a household device is registered even when its prices are empty.""" + mock_engie_client.return_value.async_get_customer_account_relations.return_value = ( + build_relations(BAN, BAN_2) + ) + mock_engie_client.return_value.async_get_prices.side_effect = [ + build_prices(), + PricesResponse(items=()), + ] + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(device_entries) == 2 + assert all( + device.entry_type is dr.DeviceEntryType.SERVICE for device in device_entries + ) + + ban_2_device = device_registry.async_get_device_by_identifier( + (DOMAIN, BAN_2), mock_config_entry.entry_id + ) + assert ban_2_device is not None + assert not er.async_entries_for_device(entity_registry, ban_2_device.id) + + ban_device = device_registry.async_get_device_by_identifier( + (DOMAIN, BAN), mock_config_entry.entry_id + ) + assert ban_device is not None + assert er.async_entries_for_device(entity_registry, ban_device.id) + + +async def test_token_refresh_skips_unchanged_tokens( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, +) -> None: + """Test the on_token_refresh callback is a no-op when tokens are unchanged.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + listener = AsyncMock() + mock_config_entry.add_update_listener(listener) + + on_token_refresh = mock_engie_client.call_args.kwargs["on_token_refresh"] + + await on_token_refresh("access-token", "refresh-token") + await hass.async_block_till_done() + + listener.assert_not_called() + assert mock_config_entry.data["access_token"] == "access-token" + assert mock_config_entry.data["refresh_token"] == "refresh-token" + + +async def test_token_refresh_persists_tokens( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, +) -> None: + """Test the on_token_refresh callback persists rotated tokens to entry.data.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + listener = AsyncMock() + mock_config_entry.add_update_listener(listener) + + on_token_refresh = mock_engie_client.call_args.kwargs["on_token_refresh"] + + await on_token_refresh("rotated-access", "rotated-refresh") + await hass.async_block_till_done() + + listener.assert_called_once() + assert mock_config_entry.data["access_token"] == "rotated-access" + assert mock_config_entry.data["refresh_token"] == "rotated-refresh" + assert mock_config_entry.data["email"] == "user@example.com" + assert mock_config_entry.data["mfa_method"] == "sms" + + +async def test_unexpected_service_point_error_does_not_prevent_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test an unexpected non-EngieBeError from a service-point lookup leaves the household without entities but still loads the entry.""" + mock_engie_client.return_value.async_get_service_point.side_effect = ValueError( + "boom" + ) + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert "Unexpected error fetching" in caplog.text + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, BAN), mock_config_entry.entry_id + ) + is not None + ) + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + +async def test_household_is_a_single_service_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a household's EANs share one SERVICE device instead of per-meter devices.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(device_entries) == 1 + + household_device = device_entries[0] + assert household_device.identifiers == {(DOMAIN, BAN)} + assert household_device.entry_type is dr.DeviceEntryType.SERVICE + assert household_device.via_device_id is None diff --git a/tests/components/engie_be/test_sensor.py b/tests/components/engie_be/test_sensor.py new file mode 100644 index 000000000000..2e58e0a62f9e --- /dev/null +++ b/tests/components/engie_be/test_sensor.py @@ -0,0 +1,998 @@ +"""Test the ENGIE Belgium sensor platform.""" + +from collections.abc import Callable, Mapping +import dataclasses +from datetime import date, timedelta +from unittest.mock import MagicMock + +from aioengiebelgium import ( + AccountRelation, + BusinessAgreement, + ConsumptionAddress, + CustomerAccount, + CustomerAccountRelations, + EanPrices, + EngieBeAuthenticationError, + EngieBeCommunicationError, + EngieBeError, + PricePeriod, + PriceSlot, + PricesResponse, + ServicePoint, + bare_ean, +) +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.engie_be.const import DOMAIN, PRICES_SCAN_INTERVAL +from homeassistant.components.engie_be.coordinator import EngieBePricesData +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .conftest import ( + BAN, + BAN_2, + OFFTAKE_INJECTION_EAN, + OFFTAKE_ONLY_EAN, + build_prices, + build_relations, +) + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +def _prices_by_ban( + mapping: Mapping[str, PricesResponse | EngieBeError], +) -> Callable[[str], PricesResponse]: + """Return an async_get_prices side_effect that resolves independently per BAN.""" + + def _side_effect(ban: str) -> PricesResponse: + result = mapping[ban] + if isinstance(result, EngieBeError): + raise result + return result + + return _side_effect + + +@pytest.mark.usefixtures("mock_engie_client", "entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the energy-price sensors for an offtake-only and a dual-direction EAN.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + for device_entry in sorted(device_entries, key=lambda entry: entry.id): + assert device_entry == snapshot(name=f"{device_entry.name}-device") + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("mock_engie_client") +async def test_slot_aware_naming( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test slot-aware translation keys drive friendly names while unique_ids keep the raw slot code.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + total_hours_entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{BAN}_{OFFTAKE_ONLY_EAN}_offtake_TOTAL_HOURS" + ) + assert total_hours_entity_id is not None + total_hours_state = hass.states.get(total_hours_entity_id) + assert total_hours_state is not None + assert "total_hours" not in total_hours_state.name.lower() + + peak_unique_id = f"{BAN}_{OFFTAKE_INJECTION_EAN}_offtake_S_TOU1_OFFTAKE_PEAK" + peak_entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, peak_unique_id + ) + assert peak_entity_id is not None + peak_entry = entity_registry.async_get(peak_entity_id) + assert peak_entry is not None + assert peak_entry.unique_id == peak_unique_id + peak_state = hass.states.get(peak_entity_id) + assert peak_state is not None + assert peak_state.name.endswith("Electricity peak offtake price") + + fallback_unique_id = f"{BAN}_{OFFTAKE_INJECTION_EAN}_offtake_S_TOU1_OFFTAKE_WEEKEND" + fallback_entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, fallback_unique_id + ) + assert fallback_entity_id is not None + fallback_state = hass.states.get(fallback_entity_id) + assert fallback_state is not None + assert fallback_state.name.endswith("Electricity offtake price (weekend)") + + assert ( + entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{BAN}_{OFFTAKE_INJECTION_EAN}_offtake_EN" + ) + is None + ) + + +@pytest.mark.usefixtures("mock_engie_client") +async def test_excl_vat_disabled_by_default( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test excl-VAT price sensors are disabled by default, incl-VAT ones are not.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + excl_vat_entries = [ + entry for entry in entity_entries if entry.unique_id.endswith("_excl_vat") + ] + incl_vat_entries = [ + entry for entry in entity_entries if not entry.unique_id.endswith("_excl_vat") + ] + + assert excl_vat_entries + assert incl_vat_entries + assert all( + entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + for entry in excl_vat_entries + ) + assert all(entry.disabled_by is None for entry in incl_vat_entries) + + +SECOND_ELECTRICITY_EAN = "541448820000000003_ID1" + + +def _single_period_prices(ean: str) -> EanPrices: + """Build a single-period, offtake-only EanPrices for the given EAN.""" + return EanPrices( + ean=ean, + periods=( + PricePeriod( + valid_from=date(2000, 1, 1), + valid_to=date(2099, 12, 31), + vat_tariff=6.0, + offtake=( + PriceSlot( + time_of_use_slot_code="TOTAL_HOURS", + price_value=0.2, + price_value_excl_vat=0.19, + ), + ), + ), + ), + ) + + +async def test_duplicate_type_suffix( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities get a distinct last-4 EAN suffix for two same-type household EANs.""" + eans = (OFFTAKE_INJECTION_EAN, SECOND_ELECTRICITY_EAN) + mock_engie_client.return_value.async_get_prices.return_value = PricesResponse( + items=tuple(_single_period_prices(ean) for ean in eans) + ) + mock_engie_client.return_value.async_get_service_point.side_effect = lambda ean: ( + ServicePoint(ean_energy_types={bare_ean(ean): "ELECTRICITY"}) + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + names: dict[str, str] = {} + for ean in eans: + entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{BAN}_{ean}_offtake_TOTAL_HOURS" + ) + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + names[ean] = state.name + + for ean in eans: + assert names[ean].endswith(f"({bare_ean(ean)[-4:]})") + assert names[OFFTAKE_INJECTION_EAN] != names[SECOND_ELECTRICITY_EAN] + + +@pytest.mark.parametrize( + "exception", + [ + pytest.param(EngieBeCommunicationError("boom"), id="communication_error"), + pytest.param(EngieBeAuthenticationError("boom"), id="auth_error"), + ], +) +async def test_type_fallback_when_service_point_lookup_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + exception: Exception, +) -> None: + """Test a failed service-point lookup falls back to the untyped entity name.""" + mock_engie_client.return_value.async_get_service_point.side_effect = [ + exception, + ServicePoint(ean_energy_types={bare_ean(OFFTAKE_INJECTION_EAN): "ELECTRICITY"}), + ] + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{BAN}_{OFFTAKE_ONLY_EAN}_offtake_TOTAL_HOURS" + ) + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert state.name.endswith("Offtake price") + + +def _empty_address_relations() -> CustomerAccountRelations: + """Build relations for a single BAN with an all-empty consumption address.""" + return CustomerAccountRelations( + accounts=( + AccountRelation( + id="account-1", + admin=True, + customer_account=CustomerAccount( + customer_account_number="can-1", + business_agreements=( + BusinessAgreement( + business_agreement_number=BAN, + active=True, + consumption_address=ConsumptionAddress( + street="", + house_number="", + postal_code="", + city="", + ), + ), + ), + ), + ), + ) + ) + + +@pytest.mark.parametrize( + "relations", + [ + pytest.param(build_relations(with_address=False), id="no-address"), + pytest.param(_empty_address_relations(), id="empty-address"), + ], +) +async def test_device_name_falls_back_to_ban( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + device_registry: dr.DeviceRegistry, + relations: CustomerAccountRelations, +) -> None: + """Test the device name falls back to the bare BAN without a usable consumption address.""" + mock_engie_client.return_value.async_get_customer_account_relations.return_value = ( + relations + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + household_device = device_registry.async_get_device_by_identifier( + (DOMAIN, BAN), mock_config_entry.entry_id + ) + assert household_device is not None + assert household_device.name == BAN + + +@pytest.mark.parametrize( + ("valid_from", "valid_to"), + [ + pytest.param(date(2026, 8, 1), date(2026, 9, 1), id="inside-window"), + pytest.param(date(2026, 8, 13), date(2026, 9, 1), id="first-day-inclusive"), + pytest.param( + date(2026, 8, 1), + date(2026, 9, 1), + id="date-objects", + ), + ], +) +async def test_period_window_creates_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + valid_from: date | None, + valid_to: date | None, +) -> None: + """Test sensors are created with a numeric state for a period covering today.""" + freezer.move_to("2026-08-13T12:00:00+02:00") + mock_engie_client.return_value.async_get_prices.return_value = build_prices( + valid_from=valid_from, valid_to=valid_to + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + enabled_entries = [ + entity_entry + for entity_entry in entity_entries + if entity_entry.disabled_by is None + ] + assert enabled_entries + for entity_entry in enabled_entries: + state = hass.states.get(entity_entry.entity_id) + assert state is not None + float(state.state) + + +async def test_period_window_uses_brussels_date( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test period selection uses the Brussels calendar date.""" + freezer.move_to("2026-08-12T22:30:00+00:00") + mock_engie_client.return_value.async_get_prices.return_value = build_prices( + valid_from=date(2026, 8, 13), valid_to=date(2026, 8, 14) + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("valid_from", "valid_to"), + [ + pytest.param(date(2026, 8, 1), date(2026, 8, 13), id="last-day-exclusive"), + pytest.param(date(2026, 1, 1), date(2026, 2, 1), id="expired-window"), + pytest.param(None, date(2026, 9, 1), id="malformed-dates"), + ], +) +async def test_period_window_skips_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + valid_from: date | None, + valid_to: date | None, +) -> None: + """Test no sensors are created when no period covers today (locks in exclusive valid_to).""" + freezer.move_to("2026-08-13T12:00:00+02:00") + mock_engie_client.return_value.async_get_prices.return_value = build_prices( + valid_from=valid_from, valid_to=valid_to + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert entity_entries == [] + + +async def test_period_window_refresh_makes_existing_sensors_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test existing sensors become unavailable after the period expires.""" + freezer.move_to("2026-08-12T12:00:00+02:00") + mock_engie_client.return_value.async_get_prices.return_value = build_prices( + valid_from=date(2026, 8, 1), valid_to=date(2026, 8, 13) + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + enabled_entity_entries = [ + entity_entry + for entity_entry in entity_entries + if entity_entry.disabled_by is None + ] + assert enabled_entity_entries + + freezer.tick(timedelta(days=1, seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + for entity_entry in enabled_entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) is not None + state = hass.states.get(entity_entry.entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +def _degraded_slots_empty() -> EngieBePricesData: + """Return prices data with no slots or EANs at all.""" + return EngieBePricesData(slots={}, eans=()) + + +def _degraded_period_expired() -> EngieBePricesData: + """Return prices data whose only period for the EAN has expired.""" + return EngieBePricesData(slots={}, eans=(OFFTAKE_ONLY_EAN,)) + + +def _degraded_slot_code_absent() -> EngieBePricesData: + """Return prices data whose current period has no matching offtake slot.""" + return EngieBePricesData( + slots={ + (OFFTAKE_ONLY_EAN, "offtake", "OTHER_CODE"): PriceSlot( + time_of_use_slot_code="OTHER_CODE", + price_value=0.5, + price_value_excl_vat=0.5, + ) + }, + eans=(OFFTAKE_ONLY_EAN,), + ) + + +def _degraded_ean_absent() -> EngieBePricesData: + """Return prices data whose slots belong to a different EAN entirely.""" + return EngieBePricesData( + slots={ + (OFFTAKE_INJECTION_EAN, "offtake", "TOTAL_HOURS"): PriceSlot( + time_of_use_slot_code="TOTAL_HOURS", + price_value=0.5, + price_value_excl_vat=0.5, + ) + }, + eans=(OFFTAKE_INJECTION_EAN,), + ) + + +@pytest.mark.usefixtures("mock_engie_client") +@pytest.mark.parametrize( + "degraded_data", + [ + pytest.param(_degraded_slots_empty, id="slots-empty"), + pytest.param(_degraded_period_expired, id="period-expired"), + pytest.param(_degraded_slot_code_absent, id="slot-code-absent"), + pytest.param(_degraded_ean_absent, id="ean-absent"), + ], +) +async def test_available_degrades_for_missing_slot( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + degraded_data: Callable[[], EngieBePricesData], +) -> None: + """Test a missing slot key makes the entity unavailable for every degraded-data branch.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{BAN}_{OFFTAKE_ONLY_EAN}_offtake_TOTAL_HOURS" + ) + assert entity_id is not None + + coordinator = mock_config_entry.runtime_data.households[BAN].prices + coordinator.async_set_updated_data(degraded_data()) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_recovering_ban_adds_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a BAN that fails on the first refresh gets its sensors once it recovers.""" + mock_engie_client.return_value.async_get_customer_account_relations.return_value = ( + build_relations(BAN, BAN_2) + ) + mock_engie_client.return_value.async_get_prices.side_effect = [ + build_prices(), + EngieBeCommunicationError("boom"), + ] + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + ban_2_unique_id = f"{BAN_2}_{OFFTAKE_ONLY_EAN}_offtake_TOTAL_HOURS" + assert ( + entity_registry.async_get_entity_id("sensor", DOMAIN, ban_2_unique_id) is None + ) + + mock_engie_client.return_value.async_get_prices.side_effect = [ + build_prices(), + build_prices(), + ] + freezer.tick(PRICES_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + ban_2_entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, ban_2_unique_id + ) + assert ban_2_entity_id is not None + entity_entry = entity_registry.async_get(ban_2_entity_id) + assert entity_entry is not None + ban_2_device = device_registry.async_get_device_by_identifier( + (DOMAIN, BAN_2), mock_config_entry.entry_id + ) + assert ban_2_device is not None + assert entity_entry.device_id == ban_2_device.id + + +async def test_period_gap_heals( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensors appear once today enters a price period that was a gap at setup.""" + freezer.move_to("2026-08-13T12:00:00+02:00") + mock_engie_client.return_value.async_get_prices.return_value = build_prices( + valid_from=date(2026, 8, 14), valid_to=date(2026, 9, 1) + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + == [] + ) + + freezer.tick(timedelta(days=1, seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + +def _add_offtake_slot( + prices: PricesResponse, ean: str, slot: PriceSlot +) -> PricesResponse: + """Return a copy of prices with an extra offtake slot added to one EAN's periods.""" + return PricesResponse( + items=tuple( + dataclasses.replace( + ean_prices, + periods=tuple( + dataclasses.replace(period, offtake=(*period.offtake, slot)) + for period in ean_prices.periods + ), + ) + if ean_prices.ean == ean + else ean_prices + for ean_prices in prices.items + ) + ) + + +async def test_new_slot_code_appears( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a new time-of-use slot code adds exactly its incl/excl VAT sensor pair.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + count_before = len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) + + extra_slot = PriceSlot( + time_of_use_slot_code="S_TOU1_OFFTAKE_NIGHT", + price_value=0.09, + price_value_excl_vat=0.084906, + ) + mock_engie_client.return_value.async_get_prices.return_value = _add_offtake_slot( + build_prices(), OFFTAKE_INJECTION_EAN, extra_slot + ) + freezer.tick(PRICES_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + count_after = len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) + assert count_after == count_before + 2 + + incl_vat_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{BAN}_{OFFTAKE_INJECTION_EAN}_offtake_S_TOU1_OFFTAKE_NIGHT" + ) + excl_vat_id = entity_registry.async_get_entity_id( + "sensor", + DOMAIN, + f"{BAN}_{OFFTAKE_INJECTION_EAN}_offtake_S_TOU1_OFFTAKE_NIGHT_excl_vat", + ) + assert incl_vat_id is not None + assert excl_vat_id is not None + + +async def test_no_duplicate_entities_on_repeated_refresh( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test refreshing with identical data does not add duplicate entities.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + count_before = len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) + + freezer.tick(PRICES_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + count_after = len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) + assert count_after == count_before + + +async def test_late_service_point_failure_falls_back( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a recovering BAN whose new EAN fails its service-point lookup falls back.""" + mock_engie_client.return_value.async_get_customer_account_relations.return_value = ( + build_relations(BAN, BAN_2) + ) + ban_2_prices = PricesResponse( + items=(_single_period_prices(SECOND_ELECTRICITY_EAN),) + ) + mock_engie_client.return_value.async_get_prices.side_effect = [ + build_prices(), + EngieBeCommunicationError("boom"), + ] + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_engie_client.return_value.async_get_prices.side_effect = _prices_by_ban( + {BAN: build_prices(), BAN_2: ban_2_prices} + ) + mock_engie_client.return_value.async_get_service_point.side_effect = ( + EngieBeCommunicationError("boom") + ) + freezer.tick(PRICES_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{BAN_2}_{SECOND_ELECTRICITY_EAN}_offtake_TOTAL_HOURS" + ) + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert state.name.endswith("Offtake price") + + +def _prices_entity_id(entity_registry: er.EntityRegistry, ban: str) -> str: + """Return the entity_id for a BAN's offtake-only price sensor.""" + unique_id = f"{ban}_{OFFTAKE_ONLY_EAN}_offtake_TOTAL_HOURS" + entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id) + assert entity_id is not None + return entity_id + + +def _single_ban_prices_response(ean: str, price_value: float) -> PricesResponse: + """Build a prices response with one offtake slot for the given EAN and price.""" + return PricesResponse( + items=( + EanPrices( + ean=ean, + periods=( + PricePeriod( + valid_from=date(2000, 1, 1), + valid_to=date(2099, 12, 31), + vat_tariff=6.0, + offtake=( + PriceSlot( + time_of_use_slot_code="TOTAL_HOURS", + price_value=price_value, + price_value_excl_vat=price_value, + ), + ), + ), + ), + ), + ) + ) + + +async def _setup_two_bans( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, +) -> None: + """Set up a config entry with two business agreements and initial prices.""" + mock_engie_client.return_value.async_get_customer_account_relations.return_value = ( + build_relations(BAN, BAN_2) + ) + mock_engie_client.return_value.async_get_prices.side_effect = _prices_by_ban( + { + BAN: _single_ban_prices_response(OFFTAKE_ONLY_EAN, 0.1), + BAN_2: _single_ban_prices_response(OFFTAKE_ONLY_EAN, 0.2), + } + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_single_ban_transient_failure_goes_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a transient failure for one BAN makes only that BAN's sensor unavailable.""" + await _setup_two_bans(hass, mock_config_entry, mock_engie_client) + + mock_engie_client.return_value.async_get_prices.side_effect = _prices_by_ban( + { + BAN: _single_ban_prices_response(OFFTAKE_ONLY_EAN, 0.3), + BAN_2: EngieBeCommunicationError("boom"), + } + ) + freezer.tick(PRICES_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state_ban = hass.states.get(_prices_entity_id(entity_registry, BAN)) + state_ban_2 = hass.states.get(_prices_entity_id(entity_registry, BAN_2)) + assert state_ban is not None + assert state_ban_2 is not None + assert state_ban.state == "0.3" + assert state_ban_2.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "exception", + [ + pytest.param(EngieBeCommunicationError("boom"), id="communication_error"), + pytest.param(EngieBeAuthenticationError("boom"), id="auth_error"), + ], +) +async def test_all_bans_fail( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test all BANs failing on refresh makes every sensor unavailable.""" + await _setup_two_bans(hass, mock_config_entry, mock_engie_client) + + mock_engie_client.return_value.async_get_prices.side_effect = exception + freezer.tick(PRICES_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state_ban = hass.states.get(_prices_entity_id(entity_registry, BAN)) + state_ban_2 = hass.states.get(_prices_entity_id(entity_registry, BAN_2)) + assert state_ban is not None + assert state_ban_2 is not None + assert state_ban.state == STATE_UNAVAILABLE + assert state_ban_2.state == STATE_UNAVAILABLE + assert not hass.config_entries.flow.async_progress() + + +async def test_unexpected_exception_is_not_swallowed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test an unexpected non-EngieBeError exception is logged instead of being swallowed.""" + await _setup_two_bans(hass, mock_config_entry, mock_engie_client) + + mock_engie_client.return_value.async_get_prices.side_effect = ValueError("boom") + freezer.tick(PRICES_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Unexpected error fetching" in caplog.text + + +async def test_failure_and_recovery_are_logged_once( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a failing BAN logs one error, stays quiet on repeat failures, then logs one recovery.""" + await _setup_two_bans(hass, mock_config_entry, mock_engie_client) + caplog.set_level("DEBUG", logger="homeassistant.components.engie_be") + + mock_engie_client.return_value.async_get_prices.side_effect = _prices_by_ban( + { + BAN: EngieBeCommunicationError("boom"), + BAN_2: _single_ban_prices_response(OFFTAKE_ONLY_EAN, 0.2), + } + ) + caplog.clear() + freezer.tick(PRICES_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert caplog.text.count("Error fetching") == 1 + assert "recovered" not in caplog.text + caplog.clear() + + mock_engie_client.return_value.async_get_prices.side_effect = _prices_by_ban( + { + BAN: EngieBeCommunicationError("boom"), + BAN_2: _single_ban_prices_response(OFFTAKE_ONLY_EAN, 0.3), + } + ) + freezer.tick(PRICES_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Error fetching" not in caplog.text + caplog.clear() + + mock_engie_client.return_value.async_get_prices.side_effect = _prices_by_ban( + { + BAN: _single_ban_prices_response(OFFTAKE_ONLY_EAN, 0.4), + BAN_2: _single_ban_prices_response(OFFTAKE_ONLY_EAN, 0.5), + } + ) + freezer.tick(PRICES_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert caplog.text.count("recovered") == 1 + + state_ban = hass.states.get(_prices_entity_id(entity_registry, BAN)) + state_ban_2 = hass.states.get(_prices_entity_id(entity_registry, BAN_2)) + assert state_ban is not None + assert state_ban_2 is not None + assert state_ban.state == "0.4" + assert state_ban_2.state == "0.5" + + +async def test_recovery_after_all_bans_fail( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensors recover with fresh values after all BANs previously failed.""" + await _setup_two_bans(hass, mock_config_entry, mock_engie_client) + + mock_engie_client.return_value.async_get_prices.side_effect = ( + EngieBeCommunicationError("boom") + ) + freezer.tick(PRICES_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_engie_client.return_value.async_get_prices.side_effect = _prices_by_ban( + { + BAN: _single_ban_prices_response(OFFTAKE_ONLY_EAN, 0.4), + BAN_2: _single_ban_prices_response(OFFTAKE_ONLY_EAN, 0.5), + } + ) + freezer.tick(PRICES_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state_ban = hass.states.get(_prices_entity_id(entity_registry, BAN)) + state_ban_2 = hass.states.get(_prices_entity_id(entity_registry, BAN_2)) + assert state_ban is not None + assert state_ban_2 is not None + assert state_ban.state == "0.4" + assert state_ban_2.state == "0.5" + + +async def test_service_point_transient_failure_is_retried( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a failed service-point fetch is retried on the next refresh cycle.""" + caplog.set_level("DEBUG", logger="homeassistant.components.engie_be") + mock_engie_client.return_value.async_get_prices.return_value = ( + _single_ban_prices_response(OFFTAKE_ONLY_EAN, 0.1) + ) + mock_engie_client.return_value.async_get_service_point.side_effect = [ + EngieBeCommunicationError("boom"), + ServicePoint(ean_energy_types={bare_ean(OFFTAKE_ONLY_EAN): "GAS"}), + ] + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert "Fetching service point for …0001 failed" in caplog.text + assert "…_ID1" not in caplog.text + + freezer.tick(PRICES_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_engie_client.return_value.async_get_service_point.call_count == 2 + + +async def test_service_point_success_without_ean_is_cached_once( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_engie_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a successful fetch omitting the EAN is cached once and not retried.""" + mock_engie_client.return_value.async_get_prices.return_value = ( + _single_ban_prices_response(OFFTAKE_ONLY_EAN, 0.1) + ) + mock_engie_client.return_value.async_get_service_point.side_effect = lambda ean: ( + ServicePoint(ean_energy_types={}) + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(PRICES_SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_engie_client.return_value.async_get_service_point.call_count == 1