From 7219a4fa98e20c4727e9946024cdcc28651859cc Mon Sep 17 00:00:00 2001 From: Jordi Date: Thu, 6 Jun 2024 22:33:58 +0200 Subject: [PATCH] Add Aquacell integration (#117117) * Initial commit * Support changed API Change sensor entity descriptions * Fix sensor not handling coordinator update * Implement re-authentication flow and handle token expiry * Bump aioaquacell * Bump aioaquacell * Cleanup and initial tests * Fixes for config flow tests * Cleanup * Fixes * Formatted * Use config entry runtime Use icon translations Removed reauth Removed last updated sensor Changed lid in place to binary sensor Cleanup * Remove reauth strings * Removed binary_sensor platform Fixed sensors not updating properly * Remove reauth tests Bump aioaquacell * Moved softener property to entity class Inlined validate_input method Renaming of entities Do a single async_add_entities call to add all entities Reduced code in try blocks * Made tests parameterized and use test fixture for api Cleaned up unused code Removed traces of reauth * Add check if refresh token is expired Add tests * Add missing unique_id to config entry mock Inlined _update_config_entry_refresh_token method Fix incorrect test method name and comment * Add snapshot test Changed WiFi level to WiFi strength * Bump aioaquacell to 0.1.7 * Move test_coordinator tests to test_init Add test for duplicate config entry --- CODEOWNERS | 2 + homeassistant/components/aquacell/__init__.py | 37 +++ .../components/aquacell/config_flow.py | 71 ++++ homeassistant/components/aquacell/const.py | 12 + .../components/aquacell/coordinator.py | 90 ++++++ homeassistant/components/aquacell/entity.py | 41 +++ homeassistant/components/aquacell/icons.json | 20 ++ .../components/aquacell/manifest.json | 12 + homeassistant/components/aquacell/sensor.py | 117 +++++++ .../components/aquacell/strings.json | 45 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/aquacell/__init__.py | 33 ++ tests/components/aquacell/conftest.py | 77 +++++ .../get_all_softeners_one_softener.json | 40 +++ .../aquacell/snapshots/test_sensor.ambr | 303 ++++++++++++++++++ tests/components/aquacell/test_config_flow.py | 111 +++++++ tests/components/aquacell/test_init.py | 102 ++++++ tests/components/aquacell/test_sensor.py | 25 ++ 21 files changed, 1151 insertions(+) create mode 100644 homeassistant/components/aquacell/__init__.py create mode 100644 homeassistant/components/aquacell/config_flow.py create mode 100644 homeassistant/components/aquacell/const.py create mode 100644 homeassistant/components/aquacell/coordinator.py create mode 100644 homeassistant/components/aquacell/entity.py create mode 100644 homeassistant/components/aquacell/icons.json create mode 100644 homeassistant/components/aquacell/manifest.json create mode 100644 homeassistant/components/aquacell/sensor.py create mode 100644 homeassistant/components/aquacell/strings.json create mode 100644 tests/components/aquacell/__init__.py create mode 100644 tests/components/aquacell/conftest.py create mode 100644 tests/components/aquacell/fixtures/get_all_softeners_one_softener.json create mode 100644 tests/components/aquacell/snapshots/test_sensor.ambr create mode 100644 tests/components/aquacell/test_config_flow.py create mode 100644 tests/components/aquacell/test_init.py create mode 100644 tests/components/aquacell/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index d9abbd9b851..3df0b4e54cf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -129,6 +129,8 @@ build.json @home-assistant/supervisor /tests/components/aprs/ @PhilRW /homeassistant/components/apsystems/ @mawoka-myblock @SonnenladenGmbH /tests/components/apsystems/ @mawoka-myblock @SonnenladenGmbH +/homeassistant/components/aquacell/ @Jordi1990 +/tests/components/aquacell/ @Jordi1990 /homeassistant/components/aranet/ @aschmitz @thecode @anrijs /tests/components/aranet/ @aschmitz @thecode @anrijs /homeassistant/components/arcam_fmj/ @elupus diff --git a/homeassistant/components/aquacell/__init__.py b/homeassistant/components/aquacell/__init__.py new file mode 100644 index 00000000000..fc67a3f2c53 --- /dev/null +++ b/homeassistant/components/aquacell/__init__.py @@ -0,0 +1,37 @@ +"""The Aquacell integration.""" + +from __future__ import annotations + +from aioaquacell import AquacellApi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import AquacellCoordinator + +PLATFORMS = [Platform.SENSOR] + +AquacellConfigEntry = ConfigEntry[AquacellCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: AquacellConfigEntry) -> bool: + """Set up Aquacell from a config entry.""" + session = async_get_clientsession(hass) + + aquacell_api = AquacellApi(session) + + coordinator = AquacellCoordinator(hass, aquacell_api) + + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/aquacell/config_flow.py b/homeassistant/components/aquacell/config_flow.py new file mode 100644 index 00000000000..a9c749e9e2d --- /dev/null +++ b/homeassistant/components/aquacell/config_flow.py @@ -0,0 +1,71 @@ +"""Config flow for Aquacell integration.""" + +from __future__ import annotations + +from datetime import datetime +import logging +from typing import Any + +from aioaquacell import ApiException, AquacellApi, AuthenticationFailed +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_REFRESH_TOKEN, CONF_REFRESH_TOKEN_CREATION_TIME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Aquacell.""" + + VERSION = 1 + + 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: + await self.async_set_unique_id( + user_input[CONF_EMAIL].lower(), raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + session = async_get_clientsession(self.hass) + api = AquacellApi(session) + try: + refresh_token = await api.authenticate( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + except ApiException: + errors["base"] = "cannot_connect" + except AuthenticationFailed: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_EMAIL], + data={ + **user_input, + CONF_REFRESH_TOKEN: refresh_token, + CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/aquacell/const.py b/homeassistant/components/aquacell/const.py new file mode 100644 index 00000000000..96568d2286b --- /dev/null +++ b/homeassistant/components/aquacell/const.py @@ -0,0 +1,12 @@ +"""Constants for the Aquacell integration.""" + +from datetime import timedelta + +DOMAIN = "aquacell" +DATA_AQUACELL = "DATA_AQUACELL" + +CONF_REFRESH_TOKEN = "refresh_token" +CONF_REFRESH_TOKEN_CREATION_TIME = "refresh_token_creation_time" + +REFRESH_TOKEN_EXPIRY_TIME = timedelta(days=30) +UPDATE_INTERVAL = timedelta(days=1) diff --git a/homeassistant/components/aquacell/coordinator.py b/homeassistant/components/aquacell/coordinator.py new file mode 100644 index 00000000000..dd5dfcd2d0d --- /dev/null +++ b/homeassistant/components/aquacell/coordinator.py @@ -0,0 +1,90 @@ +"""Coordinator to update data from Aquacell API.""" + +import asyncio +from datetime import datetime +import logging + +from aioaquacell import ( + AquacellApi, + AquacellApiException, + AuthenticationFailed, + Softener, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_CREATION_TIME, + REFRESH_TOKEN_EXPIRY_TIME, + UPDATE_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]): + """My aquacell coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, aquacell_api: AquacellApi) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Aquacell Coordinator", + update_interval=UPDATE_INTERVAL, + ) + + self.refresh_token = self.config_entry.data[CONF_REFRESH_TOKEN] + self.refresh_token_creation_time = self.config_entry.data[ + CONF_REFRESH_TOKEN_CREATION_TIME + ] + self.email = self.config_entry.data[CONF_EMAIL] + self.password = self.config_entry.data[CONF_PASSWORD] + self.aquacell_api = aquacell_api + + async def _async_update_data(self) -> dict[str, Softener]: + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + """ + + async with asyncio.timeout(10): + # Check if the refresh token is expired + expiry_time = ( + self.refresh_token_creation_time + + REFRESH_TOKEN_EXPIRY_TIME.total_seconds() + ) + try: + if datetime.now().timestamp() >= expiry_time: + await self._reauthenticate() + else: + await self.aquacell_api.authenticate_refresh(self.refresh_token) + _LOGGER.debug("Logged in using: %s", self.refresh_token) + + softeners = await self.aquacell_api.get_all_softeners() + except AuthenticationFailed as err: + raise ConfigEntryError from err + except AquacellApiException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + return {softener.dsn: softener for softener in softeners} + + async def _reauthenticate(self) -> None: + _LOGGER.debug("Attempting to renew refresh token") + refresh_token = await self.aquacell_api.authenticate(self.email, self.password) + self.refresh_token = refresh_token + data = { + **self.config_entry.data, + CONF_REFRESH_TOKEN: self.refresh_token, + CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), + } + + self.hass.config_entries.async_update_entry(self.config_entry, data=data) diff --git a/homeassistant/components/aquacell/entity.py b/homeassistant/components/aquacell/entity.py new file mode 100644 index 00000000000..6c746ded24c --- /dev/null +++ b/homeassistant/components/aquacell/entity.py @@ -0,0 +1,41 @@ +"""Aquacell entity.""" + +from aioaquacell import Softener + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AquacellCoordinator + + +class AquacellEntity(CoordinatorEntity[AquacellCoordinator]): + """Representation of an aquacell entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AquacellCoordinator, + softener_key: str, + entity_key: str, + ) -> None: + """Initialize the aquacell entity.""" + super().__init__(coordinator) + + self.softener_key = softener_key + + self._attr_unique_id = f"{softener_key}-{entity_key}" + self._attr_device_info = DeviceInfo( + name=self.softener.name, + hw_version=self.softener.fwVersion, + identifiers={(DOMAIN, str(softener_key))}, + manufacturer=self.softener.brand, + model=self.softener.ssn, + serial_number=softener_key, + ) + + @property + def softener(self) -> Softener: + """Handle updated data from the coordinator.""" + return self.coordinator.data[self.softener_key] diff --git a/homeassistant/components/aquacell/icons.json b/homeassistant/components/aquacell/icons.json new file mode 100644 index 00000000000..d7383f54d72 --- /dev/null +++ b/homeassistant/components/aquacell/icons.json @@ -0,0 +1,20 @@ +{ + "entity": { + "sensor": { + "salt_left_side_percentage": { + "default": "mdi:basket-fill" + }, + "salt_right_side_percentage": { + "default": "mdi:basket-fill" + }, + "wi_fi_strength": { + "default": "mdi:wifi", + "state": { + "low": "mdi:wifi-strength-1", + "medium": "mdi:wifi-strength-2", + "high": "mdi:wifi-strength-4" + } + } + } + } +} diff --git a/homeassistant/components/aquacell/manifest.json b/homeassistant/components/aquacell/manifest.json new file mode 100644 index 00000000000..1f43fa214d3 --- /dev/null +++ b/homeassistant/components/aquacell/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "aquacell", + "name": "Aquacell", + "codeowners": ["@Jordi1990"], + "config_flow": true, + "dependencies": ["http", "network"], + "documentation": "https://www.home-assistant.io/integrations/aquacell", + "integration_type": "device", + "iot_class": "cloud_polling", + "loggers": ["aioaquacell"], + "requirements": ["aioaquacell==0.1.7"] +} diff --git a/homeassistant/components/aquacell/sensor.py b/homeassistant/components/aquacell/sensor.py new file mode 100644 index 00000000000..702d75a0215 --- /dev/null +++ b/homeassistant/components/aquacell/sensor.py @@ -0,0 +1,117 @@ +"""Sensors exposing properties of the softener device.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from aioaquacell import Softener + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import AquacellConfigEntry +from .coordinator import AquacellCoordinator +from .entity import AquacellEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class SoftenerSensorEntityDescription(SensorEntityDescription): + """Describes Softener sensor entity.""" + + value_fn: Callable[[Softener], StateType] + + +SENSORS: tuple[SoftenerSensorEntityDescription, ...] = ( + SoftenerSensorEntityDescription( + key="salt_left_side_percentage", + translation_key="salt_left_side_percentage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda softener: softener.salt.leftPercent, + ), + SoftenerSensorEntityDescription( + key="salt_right_side_percentage", + translation_key="salt_right_side_percentage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda softener: softener.salt.rightPercent, + ), + SoftenerSensorEntityDescription( + key="salt_left_side_time_remaining", + translation_key="salt_left_side_time_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda softener: softener.salt.leftDays, + ), + SoftenerSensorEntityDescription( + key="salt_right_side_time_remaining", + translation_key="salt_right_side_time_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda softener: softener.salt.rightDays, + ), + SoftenerSensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda softener: softener.battery, + ), + SoftenerSensorEntityDescription( + key="wi_fi_strength", + translation_key="wi_fi_strength", + value_fn=lambda softener: softener.wifiLevel, + device_class=SensorDeviceClass.ENUM, + options=[ + "high", + "medium", + "low", + ], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AquacellConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensors.""" + softeners = config_entry.runtime_data.data + async_add_entities( + SoftenerSensor(config_entry.runtime_data, sensor, softener_key) + for sensor in SENSORS + for softener_key in softeners + ) + + +class SoftenerSensor(AquacellEntity, SensorEntity): + """Softener sensor.""" + + entity_description: SoftenerSensorEntityDescription + + def __init__( + self, + coordinator: AquacellCoordinator, + description: SoftenerSensorEntityDescription, + softener_key: str, + ) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator, softener_key, description.key) + + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.softener) diff --git a/homeassistant/components/aquacell/strings.json b/homeassistant/components/aquacell/strings.json new file mode 100644 index 00000000000..32b6bba943a --- /dev/null +++ b/homeassistant/components/aquacell/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "step": { + "user": { + "description": "Fill in your Aquacell mobile app credentials", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "salt_left_side_percentage": { + "name": "Salt left side percentage" + }, + "salt_right_side_percentage": { + "name": "Salt right side percentage" + }, + "salt_left_side_time_remaining": { + "name": "Salt left side time remaining" + }, + "salt_right_side_time_remaining": { + "name": "Salt right side time remaining" + }, + "wi_fi_strength": { + "name": "Wi-Fi strength", + "state": { + "low": "Low", + "medium": "Medium", + "high": "High" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d6060a360b5..745bad093d2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -55,6 +55,7 @@ FLOWS = { "apple_tv", "aprilaire", "apsystems", + "aquacell", "aranet", "arcam_fmj", "arve", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0665ba30351..9d7ffca6246 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -414,6 +414,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "aquacell": { + "name": "Aquacell", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "aqualogic": { "name": "AquaLogic", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 0b016e1ceca..86dc53bc5a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -191,6 +191,9 @@ aioambient==2024.01.0 # homeassistant.components.apcupsd aioapcaccess==0.4.2 +# homeassistant.components.aquacell +aioaquacell==0.1.7 + # homeassistant.components.aseko_pool_live aioaseko==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de5a135ee24..79ae24f8edc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,6 +170,9 @@ aioambient==2024.01.0 # homeassistant.components.apcupsd aioapcaccess==0.4.2 +# homeassistant.components.aquacell +aioaquacell==0.1.7 + # homeassistant.components.aseko_pool_live aioaseko==0.1.1 diff --git a/tests/components/aquacell/__init__.py b/tests/components/aquacell/__init__.py new file mode 100644 index 00000000000..c54bc539496 --- /dev/null +++ b/tests/components/aquacell/__init__.py @@ -0,0 +1,33 @@ +"""Tests for the Aquacell integration.""" + +from homeassistant.components.aquacell.const import ( + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_CREATION_TIME, +) +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_CONFIG_ENTRY = { + CONF_EMAIL: "test@test.com", + CONF_PASSWORD: "test-password", + CONF_REFRESH_TOKEN: "refresh-token", + CONF_REFRESH_TOKEN_CREATION_TIME: 0, +} + +TEST_USER_INPUT = { + CONF_EMAIL: "test@test.com", + CONF_PASSWORD: "test-password", +} + +DSN = "DSN" + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() diff --git a/tests/components/aquacell/conftest.py b/tests/components/aquacell/conftest.py new file mode 100644 index 00000000000..0d0949aee2a --- /dev/null +++ b/tests/components/aquacell/conftest.py @@ -0,0 +1,77 @@ +"""Common fixtures for the Aquacell tests.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, patch + +from aioaquacell import AquacellApi, Softener +import pytest + +from homeassistant.components.aquacell.const import ( + CONF_REFRESH_TOKEN_CREATION_TIME, + DOMAIN, +) +from homeassistant.const import CONF_EMAIL + +from tests.common import MockConfigEntry, load_json_array_fixture +from tests.components.aquacell import TEST_CONFIG_ENTRY + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aquacell.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_aquacell_api() -> Generator[AsyncMock, None, None]: + """Build a fixture for the Aquacell API that authenticates successfully and returns a single softener.""" + with ( + patch( + "homeassistant.components.aquacell.AquacellApi", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.aquacell.config_flow.AquacellApi", + new=mock_client, + ), + ): + mock_aquacell_api: AquacellApi = mock_client.return_value + mock_aquacell_api.authenticate.return_value = "refresh-token" + + softeners_dict = load_json_array_fixture( + "aquacell/get_all_softeners_one_softener.json" + ) + + softeners = [Softener(softener) for softener in softeners_dict] + mock_aquacell_api.get_all_softeners.return_value = softeners + + yield mock_aquacell_api + + +@pytest.fixture +def mock_config_entry_expired() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Aquacell", + unique_id=TEST_CONFIG_ENTRY[CONF_EMAIL], + data=TEST_CONFIG_ENTRY, + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Aquacell", + unique_id=TEST_CONFIG_ENTRY[CONF_EMAIL], + data={ + **TEST_CONFIG_ENTRY, + CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), + }, + ) diff --git a/tests/components/aquacell/fixtures/get_all_softeners_one_softener.json b/tests/components/aquacell/fixtures/get_all_softeners_one_softener.json new file mode 100644 index 00000000000..c8c61011c99 --- /dev/null +++ b/tests/components/aquacell/fixtures/get_all_softeners_one_softener.json @@ -0,0 +1,40 @@ +[ + { + "halfLevelNotificationEnabled": false, + "thresholds": {}, + "on_boarding_date": 1672751375085, + "dummy": "D", + "name": "AquaCell name", + "ssn": "SSN", + "dsn": "DSN", + "salt": { + "leftPercent": 100, + "rightPercent": 100, + "leftDays": 30, + "rightDays": 30, + "leftBlocks": 2, + "rightBlocks": 2, + "daysLeft": 30 + }, + "wifiLevel": "high", + "fwVersion": "HSWS 1.0 v1.0 Apr 16 2021 15:10:32", + "lastUpdate": 1715327070000, + "battery": 40, + "lidInPlace": true, + "buzzerNotificationEnabled": false, + "brand": "harvey", + "numberOfPeople": 1, + "location": { + "address": "address", + "postcode": "postal", + "country": "country" + }, + "dealer": { + "website": "", + "dealerId": "", + "shop": {}, + "name": "", + "support": {} + } + } +] diff --git a/tests/components/aquacell/snapshots/test_sensor.ambr b/tests/components/aquacell/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a237f59881a --- /dev/null +++ b/tests/components/aquacell/snapshots/test_sensor.ambr @@ -0,0 +1,303 @@ +# serializer version: 1 +# name: test_sensors[sensor.aquacell_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aquacell_name_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'aquacell', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DSN-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.aquacell_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'AquaCell name Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aquacell_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_left_side_percentage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aquacell_name_salt_left_side_percentage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Salt left side percentage', + 'platform': 'aquacell', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'salt_left_side_percentage', + 'unique_id': 'DSN-salt_left_side_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_left_side_percentage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AquaCell name Salt left side percentage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aquacell_name_salt_left_side_percentage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_left_side_time_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aquacell_name_salt_left_side_time_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Salt left side time remaining', + 'platform': 'aquacell', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'salt_left_side_time_remaining', + 'unique_id': 'DSN-salt_left_side_time_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_left_side_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'AquaCell name Salt left side time remaining', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aquacell_name_salt_left_side_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_right_side_percentage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aquacell_name_salt_right_side_percentage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Salt right side percentage', + 'platform': 'aquacell', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'salt_right_side_percentage', + 'unique_id': 'DSN-salt_right_side_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_right_side_percentage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AquaCell name Salt right side percentage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aquacell_name_salt_right_side_percentage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_right_side_time_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aquacell_name_salt_right_side_time_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Salt right side time remaining', + 'platform': 'aquacell', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'salt_right_side_time_remaining', + 'unique_id': 'DSN-salt_right_side_time_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_right_side_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'AquaCell name Salt right side time remaining', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aquacell_name_salt_right_side_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_sensors[sensor.aquacell_name_wi_fi_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'high', + 'medium', + 'low', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aquacell_name_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi strength', + 'platform': 'aquacell', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wi_fi_strength', + 'unique_id': 'DSN-wi_fi_strength', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aquacell_name_wi_fi_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'AquaCell name Wi-Fi strength', + 'options': list([ + 'high', + 'medium', + 'low', + ]), + }), + 'context': , + 'entity_id': 'sensor.aquacell_name_wi_fi_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- diff --git a/tests/components/aquacell/test_config_flow.py b/tests/components/aquacell/test_config_flow.py new file mode 100644 index 00000000000..7e348c47c78 --- /dev/null +++ b/tests/components/aquacell/test_config_flow.py @@ -0,0 +1,111 @@ +"""Test the Aquacell config flow.""" + +from unittest.mock import AsyncMock + +from aioaquacell import ApiException, AuthenticationFailed +import pytest + +from homeassistant.components.aquacell.const import CONF_REFRESH_TOKEN, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry +from tests.components.aquacell import TEST_CONFIG_ENTRY, TEST_USER_INPUT + + +async def test_config_flow_already_configured(hass: HomeAssistant) -> None: + """Test already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + **TEST_CONFIG_ENTRY, + }, + unique_id=TEST_CONFIG_ENTRY[CONF_EMAIL], + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_full_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_aquacell_api: AsyncMock +) -> None: + """Test the full config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_CONFIG_ENTRY[CONF_EMAIL] + assert result2["data"][CONF_EMAIL] == TEST_CONFIG_ENTRY[CONF_EMAIL] + assert result2["data"][CONF_PASSWORD] == TEST_CONFIG_ENTRY[CONF_PASSWORD] + assert result2["data"][CONF_REFRESH_TOKEN] == TEST_CONFIG_ENTRY[CONF_REFRESH_TOKEN] + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (ApiException, "cannot_connect"), + (AuthenticationFailed, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + exception: Exception, + error: str, + mock_setup_entry: AsyncMock, + mock_aquacell_api: AsyncMock, +) -> None: + """Test we handle form exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_aquacell_api.authenticate.side_effect = exception + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": error} + + mock_aquacell_api.authenticate.side_effect = None + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == TEST_CONFIG_ENTRY[CONF_EMAIL] + assert result3["data"][CONF_EMAIL] == TEST_CONFIG_ENTRY[CONF_EMAIL] + assert result3["data"][CONF_PASSWORD] == TEST_CONFIG_ENTRY[CONF_PASSWORD] + assert result3["data"][CONF_REFRESH_TOKEN] == TEST_CONFIG_ENTRY[CONF_REFRESH_TOKEN] + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/aquacell/test_init.py b/tests/components/aquacell/test_init.py new file mode 100644 index 00000000000..215b50719be --- /dev/null +++ b/tests/components/aquacell/test_init.py @@ -0,0 +1,102 @@ +"""Test the Aquacell init module.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import AsyncMock, patch + +from aioaquacell import AquacellApiException, AuthenticationFailed +import pytest + +from homeassistant.components.aquacell.const import ( + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_CREATION_TIME, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.aquacell import setup_integration + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_aquacell_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_coordinator_update_valid_refresh_token( + hass: HomeAssistant, + mock_aquacell_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert entry.state is ConfigEntryState.LOADED + + assert len(mock_aquacell_api.authenticate.mock_calls) == 0 + assert len(mock_aquacell_api.authenticate_refresh.mock_calls) == 1 + assert len(mock_aquacell_api.get_all_softeners.mock_calls) == 1 + + +async def test_coordinator_update_expired_refresh_token( + hass: HomeAssistant, + mock_aquacell_api: AsyncMock, + mock_config_entry_expired: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + mock_aquacell_api.authenticate.return_value = "new-refresh-token" + + now = datetime.now() + with patch( + "homeassistant.components.aquacell.coordinator.datetime" + ) as datetime_mock: + datetime_mock.now.return_value = now + await setup_integration(hass, mock_config_entry_expired) + + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert entry.state is ConfigEntryState.LOADED + + assert len(mock_aquacell_api.authenticate.mock_calls) == 1 + assert len(mock_aquacell_api.authenticate_refresh.mock_calls) == 0 + assert len(mock_aquacell_api.get_all_softeners.mock_calls) == 1 + + assert entry.data[CONF_REFRESH_TOKEN] == "new-refresh-token" + assert entry.data[CONF_REFRESH_TOKEN_CREATION_TIME] == now.timestamp() + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (AuthenticationFailed, ConfigEntryState.SETUP_ERROR), + (AquacellApiException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_load_exceptions( + hass: HomeAssistant, + mock_aquacell_api: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test load and unload entry.""" + mock_aquacell_api.authenticate_refresh.side_effect = exception + await setup_integration(hass, mock_config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert entry.state is expected_state diff --git a/tests/components/aquacell/test_sensor.py b/tests/components/aquacell/test_sensor.py new file mode 100644 index 00000000000..8c52c3caa1f --- /dev/null +++ b/tests/components/aquacell/test_sensor.py @@ -0,0 +1,25 @@ +"""Test the Aquacell init module.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.aquacell import setup_integration + + +async def test_sensors( + hass: HomeAssistant, + mock_aquacell_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the creation of Aquacell sensors.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)