diff --git a/CODEOWNERS b/CODEOWNERS index e8eaa71fb05..f1645f95759 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -121,6 +121,8 @@ build.json @home-assistant/supervisor /tests/components/androidtv/ @JeffLIrion @ollo69 /homeassistant/components/androidtv_remote/ @tronikos @Drafteed /tests/components/androidtv_remote/ @tronikos @Drafteed +/homeassistant/components/anglian_water/ @pantherale0 +/tests/components/anglian_water/ @pantherale0 /homeassistant/components/anova/ @Lash-L /tests/components/anova/ @Lash-L /homeassistant/components/anthemav/ @hyralex diff --git a/homeassistant/components/anglian_water/__init__.py b/homeassistant/components/anglian_water/__init__.py new file mode 100644 index 00000000000..e695905071a --- /dev/null +++ b/homeassistant/components/anglian_water/__init__.py @@ -0,0 +1,70 @@ +"""The Anglian Water integration.""" + +from __future__ import annotations + +from pyanglianwater import AnglianWater +from pyanglianwater.auth import MSOB2CAuth +from pyanglianwater.exceptions import ( + ExpiredAccessTokenError, + SelfAssertedError, + SmartMeterUnavailableError, +) + +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_ACCOUNT_NUMBER, DOMAIN +from .coordinator import AnglianWaterConfigEntry, AnglianWaterUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: AnglianWaterConfigEntry +) -> bool: + """Set up Anglian Water from a config entry.""" + auth = MSOB2CAuth( + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + refresh_token=entry.data[CONF_ACCESS_TOKEN], + account_number=entry.data[CONF_ACCOUNT_NUMBER], + ) + try: + await auth.send_refresh_request() + except (ExpiredAccessTokenError, SelfAssertedError) as err: + raise ConfigEntryAuthFailed from err + + _aw = AnglianWater(authenticator=auth) + + try: + await _aw.validate_smart_meter() + except SmartMeterUnavailableError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, translation_key="smart_meter_unavailable" + ) from err + + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_ACCESS_TOKEN: auth.refresh_token} + ) + entry.runtime_data = coordinator = AnglianWaterUpdateCoordinator( + hass=hass, api=_aw, config_entry=entry + ) + + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: AnglianWaterConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/anglian_water/config_flow.py b/homeassistant/components/anglian_water/config_flow.py new file mode 100644 index 00000000000..246291c5b31 --- /dev/null +++ b/homeassistant/components/anglian_water/config_flow.py @@ -0,0 +1,103 @@ +"""Config flow for the Anglian Water integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiohttp import CookieJar +from pyanglianwater import AnglianWater +from pyanglianwater.auth import BaseAuth, MSOB2CAuth +from pyanglianwater.exceptions import ( + InvalidAccountIdError, + SelfAssertedError, + SmartMeterUnavailableError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import CONF_ACCOUNT_NUMBER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): selector.TextSelector(), + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + } +) + + +async def validate_credentials(auth: MSOB2CAuth) -> str | MSOB2CAuth: + """Validate the provided credentials.""" + try: + await auth.send_login_request() + except SelfAssertedError: + return "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + return "unknown" + _aw = AnglianWater(authenticator=auth) + try: + await _aw.validate_smart_meter() + except (InvalidAccountIdError, SmartMeterUnavailableError): + return "smart_meter_unavailable" + return auth + + +class AnglianWaterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Anglian Water.""" + + 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: + validation_response = await validate_credentials( + MSOB2CAuth( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + session=async_create_clientsession( + self.hass, + cookie_jar=CookieJar(quote_cookie=False), + ), + account_number=user_input.get(CONF_ACCOUNT_NUMBER), + ) + ) + if isinstance(validation_response, BaseAuth): + account_number = ( + user_input.get(CONF_ACCOUNT_NUMBER) + or validation_response.account_number + ) + await self.async_set_unique_id(account_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=account_number, + data={ + **user_input, + CONF_ACCESS_TOKEN: validation_response.refresh_token, + CONF_ACCOUNT_NUMBER: account_number, + }, + ) + if validation_response == "smart_meter_unavailable": + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA.extend( + { + vol.Required(CONF_ACCOUNT_NUMBER): selector.TextSelector(), + } + ), + errors={"base": validation_response}, + ) + errors["base"] = validation_response + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/anglian_water/const.py b/homeassistant/components/anglian_water/const.py new file mode 100644 index 00000000000..d00ab192c44 --- /dev/null +++ b/homeassistant/components/anglian_water/const.py @@ -0,0 +1,4 @@ +"""Constants for the Anglian Water integration.""" + +DOMAIN = "anglian_water" +CONF_ACCOUNT_NUMBER = "account_number" diff --git a/homeassistant/components/anglian_water/coordinator.py b/homeassistant/components/anglian_water/coordinator.py new file mode 100644 index 00000000000..461139d988e --- /dev/null +++ b/homeassistant/components/anglian_water/coordinator.py @@ -0,0 +1,49 @@ +"""Anglian Water data coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pyanglianwater import AnglianWater +from pyanglianwater.exceptions import ExpiredAccessTokenError, UnknownEndpointError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +type AnglianWaterConfigEntry = ConfigEntry[AnglianWaterUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) +UPDATE_INTERVAL = timedelta(minutes=60) + + +class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]): + """Anglian Water data update coordinator.""" + + config_entry: AnglianWaterConfigEntry + + def __init__( + self, + hass: HomeAssistant, + api: AnglianWater, + config_entry: AnglianWaterConfigEntry, + ) -> None: + """Initialize update coordinator.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + config_entry=config_entry, + ) + self.api = api + + async def _async_update_data(self) -> None: + """Update data from Anglian Water's API.""" + try: + return await self.api.update() + except (ExpiredAccessTokenError, UnknownEndpointError) as err: + raise UpdateFailed from err diff --git a/homeassistant/components/anglian_water/entity.py b/homeassistant/components/anglian_water/entity.py new file mode 100644 index 00000000000..763a63737d9 --- /dev/null +++ b/homeassistant/components/anglian_water/entity.py @@ -0,0 +1,44 @@ +"""Anglian Water entity.""" + +from __future__ import annotations + +import logging + +from pyanglianwater.meter import SmartMeter + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AnglianWaterUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class AnglianWaterEntity(CoordinatorEntity[AnglianWaterUpdateCoordinator]): + """Defines a Anglian Water entity.""" + + def __init__( + self, + coordinator: AnglianWaterUpdateCoordinator, + smart_meter: SmartMeter, + ) -> None: + """Initialize Anglian Water entity.""" + super().__init__(coordinator) + self.smart_meter = smart_meter + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, smart_meter.serial_number)}, + name="Smart Water Meter", + manufacturer="Anglian Water", + serial_number=smart_meter.serial_number, + ) + + async def async_added_to_hass(self) -> None: + """When entity is loaded.""" + self.coordinator.api.updated_data_callbacks.append(self.async_write_ha_state) + await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + """When will be removed from HASS.""" + self.coordinator.api.updated_data_callbacks.remove(self.async_write_ha_state) + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/anglian_water/manifest.json b/homeassistant/components/anglian_water/manifest.json new file mode 100644 index 00000000000..3a402ed5394 --- /dev/null +++ b/homeassistant/components/anglian_water/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "anglian_water", + "name": "Anglian Water", + "codeowners": ["@pantherale0"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/anglian_water", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["pyanglianwater==2.1.0"] +} diff --git a/homeassistant/components/anglian_water/quality_scale.yaml b/homeassistant/components/anglian_water/quality_scale.yaml new file mode 100644 index 00000000000..89718827e9c --- /dev/null +++ b/homeassistant/components/anglian_water/quality_scale.yaml @@ -0,0 +1,83 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + 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: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + Unable to discover meters. + discovery: + status: exempt + comment: | + Unable to discover meters. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + No entities are disabled by default. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: | + Entities do not require different icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + Read-only integration and no repairs are possible. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/anglian_water/sensor.py b/homeassistant/components/anglian_water/sensor.py new file mode 100644 index 00000000000..3cb31926845 --- /dev/null +++ b/homeassistant/components/anglian_water/sensor.py @@ -0,0 +1,118 @@ +"""Anglian Water sensor platform.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from pyanglianwater.meter import SmartMeter + +from homeassistant.components.sensor import ( + EntityCategory, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfVolume +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import AnglianWaterConfigEntry, AnglianWaterUpdateCoordinator +from .entity import AnglianWaterEntity + +PARALLEL_UPDATES = 0 + + +class AnglianWaterSensor(StrEnum): + """Store keys for Anglian Water sensors.""" + + YESTERDAY_CONSUMPTION = "yesterday_consumption" + YESTERDAY_WATER_COST = "yesterday_water_cost" + YESTERDAY_SEWERAGE_COST = "yesterday_sewerage_cost" + LATEST_READING = "latest_reading" + + +@dataclass(frozen=True, kw_only=True) +class AnglianWaterSensorEntityDescription(SensorEntityDescription): + """Describes AnglianWater sensor entity.""" + + value_fn: Callable[[SmartMeter], float] + + +ENTITY_DESCRIPTIONS: tuple[AnglianWaterSensorEntityDescription, ...] = ( + AnglianWaterSensorEntityDescription( + key=AnglianWaterSensor.YESTERDAY_CONSUMPTION, + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.WATER, + value_fn=lambda entity: entity.get_yesterday_consumption, + state_class=SensorStateClass.TOTAL, + translation_key=AnglianWaterSensor.YESTERDAY_CONSUMPTION, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AnglianWaterSensorEntityDescription( + key=AnglianWaterSensor.LATEST_READING, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.WATER, + value_fn=lambda entity: entity.latest_read, + state_class=SensorStateClass.TOTAL_INCREASING, + translation_key=AnglianWaterSensor.LATEST_READING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AnglianWaterSensorEntityDescription( + key=AnglianWaterSensor.YESTERDAY_WATER_COST, + native_unit_of_measurement="GBP", + device_class=SensorDeviceClass.MONETARY, + value_fn=lambda entity: entity.yesterday_water_cost, + translation_key=AnglianWaterSensor.YESTERDAY_WATER_COST, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AnglianWaterSensorEntityDescription( + key=AnglianWaterSensor.YESTERDAY_SEWERAGE_COST, + native_unit_of_measurement="GBP", + device_class=SensorDeviceClass.MONETARY, + value_fn=lambda entity: entity.yesterday_sewerage_cost, + translation_key=AnglianWaterSensor.YESTERDAY_SEWERAGE_COST, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AnglianWaterConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + async_add_devices( + AnglianWaterSensorEntity( + coordinator=entry.runtime_data, + description=entity_description, + smart_meter=smart_meter, + ) + for entity_description in ENTITY_DESCRIPTIONS + for smart_meter in entry.runtime_data.api.meters.values() + ) + + +class AnglianWaterSensorEntity(AnglianWaterEntity, SensorEntity): + """Defines a Anglian Water sensor.""" + + entity_description: AnglianWaterSensorEntityDescription + + def __init__( + self, + coordinator: AnglianWaterUpdateCoordinator, + smart_meter: SmartMeter, + description: AnglianWaterSensorEntityDescription, + ) -> None: + """Initialize Anglian Water sensor.""" + super().__init__(coordinator, smart_meter) + self.entity_description = description + self._attr_unique_id = f"{smart_meter.serial_number}_{description.key}" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.smart_meter) diff --git a/homeassistant/components/anglian_water/strings.json b/homeassistant/components/anglian_water/strings.json new file mode 100644 index 00000000000..81ccd8c87ea --- /dev/null +++ b/homeassistant/components/anglian_water/strings.json @@ -0,0 +1,55 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "smart_meter_unavailable": "This account does not have any smart meters associated with it. If this is unexpected, enter your Billing Account Number found at the top of your latest bill.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "account_number": "Billing Account Number", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "account_number": "Your account number found on your latest bill.", + "password": "Your password", + "username": "Username or email used to login to the Anglian Water website." + }, + "description": "Enter your Anglian Water account credentials to connect to Home Assistant." + } + } + }, + "entity": { + "sensor": { + "latest_reading": { + "name": "Latest reading" + }, + "yesterday_consumption": { + "name": "Yesterday's usage" + }, + "yesterday_sewerage_cost": { + "name": "Yesterday's sewerage cost" + }, + "yesterday_water_cost": { + "name": "Yesterday's water cost" + } + } + }, + "exceptions": { + "auth_expired": { + "message": "Authentication token expired" + }, + "service_unavailable": { + "message": "Anglian Water services are currently unavailable for maintenance." + }, + "smart_meter_unavailable": { + "message": "This account no longer has a smart meter associated with it." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 471231aed33..7ccfcb4b1cd 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -60,6 +60,7 @@ FLOWS = { "android_ip_webcam", "androidtv", "androidtv_remote", + "anglian_water", "anova", "anthemav", "anthropic", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 941cecd01d4..d59c3f40751 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -339,6 +339,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "anglian_water": { + "name": "Anglian Water", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "anova": { "name": "Anova", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 1528b9cdbb0..95d6bbdc8b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1870,6 +1870,9 @@ pyairobotrest==0.1.0 # homeassistant.components.airvisual_pro pyairvisual==2023.08.1 +# homeassistant.components.anglian_water +pyanglianwater==2.1.0 + # homeassistant.components.aprilaire pyaprilaire==0.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e41d581ef1..0e9d913f410 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1586,6 +1586,9 @@ pyairobotrest==0.1.0 # homeassistant.components.airvisual_pro pyairvisual==2023.08.1 +# homeassistant.components.anglian_water +pyanglianwater==2.1.0 + # homeassistant.components.aprilaire pyaprilaire==0.9.1 diff --git a/tests/components/anglian_water/__init__.py b/tests/components/anglian_water/__init__.py new file mode 100644 index 00000000000..c5c375869eb --- /dev/null +++ b/tests/components/anglian_water/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Anglian Water integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the integration for testing platforms.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/anglian_water/conftest.py b/tests/components/anglian_water/conftest.py new file mode 100644 index 00000000000..0cf7f2d57bc --- /dev/null +++ b/tests/components/anglian_water/conftest.py @@ -0,0 +1,95 @@ +"""Common fixtures for the Anglian Water tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from pyanglianwater.meter import SmartMeter +import pytest + +from homeassistant.components.anglian_water.const import CONF_ACCOUNT_NUMBER, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME + +from .const import ACCESS_TOKEN, ACCOUNT_NUMBER, PASSWORD, USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_ACCOUNT_NUMBER: ACCOUNT_NUMBER, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + }, + unique_id=ACCOUNT_NUMBER, + ) + + +@pytest.fixture +def mock_smart_meter() -> SmartMeter: + """Return a mocked Smart Meter.""" + mock = AsyncMock(spec=SmartMeter) + mock.serial_number = "TESTSN" + mock.get_yesterday_consumption = 50 + mock.latest_read = 50 + mock.yesterday_water_cost = 0.5 + mock.yesterday_sewerage_cost = 0.5 + return mock + + +@pytest.fixture +def mock_anglian_water_authenticator() -> Generator[MagicMock]: + """Mock a Anglian Water authenticator.""" + with ( + patch( + "homeassistant.components.anglian_water.MSOB2CAuth", autospec=True + ) as mock_auth_class, + patch( + "homeassistant.components.anglian_water.config_flow.MSOB2CAuth", + new=mock_auth_class, + ), + ): + mock_instance = mock_auth_class.return_value + mock_instance.account_number = ACCOUNT_NUMBER + mock_instance.access_token = ACCESS_TOKEN + mock_instance.refresh_token = ACCESS_TOKEN + mock_instance.send_login_request.return_value = None + mock_instance.send_refresh_request.return_value = None + yield mock_instance + + +@pytest.fixture +def mock_anglian_water_client( + mock_smart_meter: SmartMeter, mock_anglian_water_authenticator: MagicMock +) -> Generator[AsyncMock]: + """Mock a Anglian Water client.""" + # Create a mock instance with our meters and config first. + with ( + patch( + "homeassistant.components.anglian_water.AnglianWater", autospec=True + ) as mock_client_class, + patch( + "homeassistant.components.anglian_water.config_flow.AnglianWater", + new=mock_client_class, + ), + ): + mock_client = mock_client_class.return_value + mock_client.meters = {mock_smart_meter.serial_number: mock_smart_meter} + mock_client.account_config = {"meter_type": "SmartMeter"} + mock_client.updated_data_callbacks = [] + mock_client.validate_smart_meter.return_value = None + yield mock_client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.anglian_water.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/anglian_water/const.py b/tests/components/anglian_water/const.py new file mode 100644 index 00000000000..399e7354753 --- /dev/null +++ b/tests/components/anglian_water/const.py @@ -0,0 +1,6 @@ +"""Constants for the Anglian Water test suite.""" + +ACCOUNT_NUMBER = "12345678" +ACCESS_TOKEN = "valid_token" +USERNAME = "hello@example.com" +PASSWORD = "SecurePassword123" diff --git a/tests/components/anglian_water/snapshots/test_sensor.ambr b/tests/components/anglian_water/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..0e7965f87d6 --- /dev/null +++ b/tests/components/anglian_water/snapshots/test_sensor.ambr @@ -0,0 +1,209 @@ +# serializer version: 1 +# name: test_sensor[sensor.anglian_water_testsn_latest_reading-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.anglian_water_testsn_latest_reading', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'anglian_water', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'TESTSN_latest_reading', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.anglian_water_testsn_latest_reading-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.anglian_water_testsn_latest_reading', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensor[sensor.anglian_water_testsn_yesterday_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.anglian_water_testsn_yesterday_consumption', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'anglian_water', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'TESTSN_yesterday_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.anglian_water_testsn_yesterday_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.anglian_water_testsn_yesterday_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensor[sensor.anglian_water_testsn_yesterday_sewerage_cost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.anglian_water_testsn_yesterday_sewerage_cost', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'anglian_water', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'TESTSN_yesterday_sewerage_cost', + 'unit_of_measurement': 'GBP', + }) +# --- +# name: test_sensor[sensor.anglian_water_testsn_yesterday_sewerage_cost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'unit_of_measurement': 'GBP', + }), + 'context': , + 'entity_id': 'sensor.anglian_water_testsn_yesterday_sewerage_cost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_sensor[sensor.anglian_water_testsn_yesterday_water_cost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.anglian_water_testsn_yesterday_water_cost', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'anglian_water', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'TESTSN_yesterday_water_cost', + 'unit_of_measurement': 'GBP', + }) +# --- +# name: test_sensor[sensor.anglian_water_testsn_yesterday_water_cost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'unit_of_measurement': 'GBP', + }), + 'context': , + 'entity_id': 'sensor.anglian_water_testsn_yesterday_water_cost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- diff --git a/tests/components/anglian_water/test_config_flow.py b/tests/components/anglian_water/test_config_flow.py new file mode 100644 index 00000000000..86b205987f3 --- /dev/null +++ b/tests/components/anglian_water/test_config_flow.py @@ -0,0 +1,193 @@ +"""Test the Anglian Water config flow.""" + +from unittest.mock import AsyncMock + +from pyanglianwater.exceptions import ( + InvalidAccountIdError, + SelfAssertedError, + SmartMeterUnavailableError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.anglian_water.const import CONF_ACCOUNT_NUMBER, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import ACCESS_TOKEN, ACCOUNT_NUMBER, PASSWORD, USERNAME + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_anglian_water_authenticator: AsyncMock, + mock_anglian_water_client: AsyncMock, +) -> None: + """Test a full and successful config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result is not None + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ACCOUNT_NUMBER + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN + assert result["data"][CONF_ACCOUNT_NUMBER] == ACCOUNT_NUMBER + assert result["result"].unique_id == ACCOUNT_NUMBER + + +async def test_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_anglian_water_authenticator: AsyncMock, + mock_anglian_water_client: AsyncMock, +) -> None: + """Test that the flow aborts when the entry is already added.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result is not None + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception_type", "expected_error"), + [(SelfAssertedError, "invalid_auth"), (ValueError, "unknown")], +) +async def test_auth_recover_exception( + hass: HomeAssistant, + mock_anglian_water_authenticator: AsyncMock, + mock_anglian_water_client: AsyncMock, + exception_type, + expected_error, +) -> None: + """Test that the flow can recover from an auth exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result is not None + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_anglian_water_authenticator.send_login_request.side_effect = exception_type + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + # Now test we can recover + + mock_anglian_water_authenticator.send_login_request.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ACCOUNT_NUMBER + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN + assert result["data"][CONF_ACCOUNT_NUMBER] == ACCOUNT_NUMBER + assert result["result"].unique_id == ACCOUNT_NUMBER + + +@pytest.mark.parametrize( + ("exception_type", "expected_error"), + [ + (SmartMeterUnavailableError, "smart_meter_unavailable"), + (InvalidAccountIdError, "smart_meter_unavailable"), + ], +) +async def test_account_recover_exception( + hass: HomeAssistant, + mock_anglian_water_authenticator: AsyncMock, + mock_anglian_water_client: AsyncMock, + exception_type, + expected_error, +) -> None: + """Test that the flow can recover from an account related exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result is not None + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_anglian_water_client.validate_smart_meter.side_effect = exception_type + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + # Now test we can recover + + mock_anglian_water_client.validate_smart_meter.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_ACCOUNT_NUMBER: ACCOUNT_NUMBER, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ACCOUNT_NUMBER + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN + assert result["data"][CONF_ACCOUNT_NUMBER] == ACCOUNT_NUMBER + assert result["result"].unique_id == ACCOUNT_NUMBER diff --git a/tests/components/anglian_water/test_sensor.py b/tests/components/anglian_water/test_sensor.py new file mode 100644 index 00000000000..d9c0b3446da --- /dev/null +++ b/tests/components/anglian_water/test_sensor.py @@ -0,0 +1,30 @@ +"""Test Anglian Water sensors.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensor( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_anglian_water_client: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor platform.""" + with patch( + "homeassistant.components.anglian_water._PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)