diff --git a/.strict-typing b/.strict-typing index ba072005a3415..4e85d4f0c151c 100644 --- a/.strict-typing +++ b/.strict-typing @@ -96,6 +96,7 @@ homeassistant.components.aprs.* homeassistant.components.apsystems.* homeassistant.components.aqualogic.* homeassistant.components.aquostv.* +homeassistant.components.aqvify.* homeassistant.components.aranet.* homeassistant.components.arcam_fmj.* homeassistant.components.arris_tg2492lg.* diff --git a/CODEOWNERS b/CODEOWNERS index 0802144fb666e..53b50e00f9dc4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -162,6 +162,8 @@ CLAUDE.md @home-assistant/core /tests/components/apsystems/ @mawoka-myblock @SonnenladenGmbH /homeassistant/components/aquacell/ @Jordi1990 /tests/components/aquacell/ @Jordi1990 +/homeassistant/components/aqvify/ @astrandb +/tests/components/aqvify/ @astrandb /homeassistant/components/aranet/ @aschmitz @thecode @anrijs /tests/components/aranet/ @aschmitz @thecode @anrijs /homeassistant/components/arcam_fmj/ @elupus diff --git a/homeassistant/components/aqvify/__init__.py b/homeassistant/components/aqvify/__init__.py new file mode 100644 index 0000000000000..4dfbc30eef0d6 --- /dev/null +++ b/homeassistant/components/aqvify/__init__.py @@ -0,0 +1,28 @@ +"""The Aqvify integration.""" + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import AqvifyConfigEntry, AqvifyCoordinator + +_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: AqvifyConfigEntry) -> bool: + """Set up Aqvify from a config entry.""" + + coordinator = AqvifyCoordinator(hass, entry) + 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: AqvifyConfigEntry) -> bool: + """Unload Aqvify config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/aqvify/config_flow.py b/homeassistant/components/aqvify/config_flow.py new file mode 100644 index 0000000000000..3ba764f0328c5 --- /dev/null +++ b/homeassistant/components/aqvify/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for the Aqvify integration.""" + +import logging +from typing import Any + +from aiohttp import ClientResponseError +from pyaqvify import AqvifyAPI, AqvifyAuthException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } +) + + +class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Aqvify.""" + + 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: + hub = AqvifyAPI( + user_input[CONF_API_KEY], + websession=async_get_clientsession(self.hass), + ) + try: + account_data = await hub.async_get_account_id() + except AqvifyAuthException: + errors["base"] = "invalid_auth" + except ClientResponseError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(account_data.account_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Aqvify", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={ + "aqvify_url": "https://app.aqvify.com/User", + }, + ) diff --git a/homeassistant/components/aqvify/const.py b/homeassistant/components/aqvify/const.py new file mode 100644 index 0000000000000..45003e65ecbf2 --- /dev/null +++ b/homeassistant/components/aqvify/const.py @@ -0,0 +1,3 @@ +"""Constants for the Aqvify integration.""" + +DOMAIN = "aqvify" diff --git a/homeassistant/components/aqvify/coordinator.py b/homeassistant/components/aqvify/coordinator.py new file mode 100644 index 0000000000000..858475aad1806 --- /dev/null +++ b/homeassistant/components/aqvify/coordinator.py @@ -0,0 +1,92 @@ +"""Coordinator for Aqvify integration.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from aiohttp import ClientResponseError +from pyaqvify import AqvifyAPI, AqvifyAuthException, AqvifyDeviceData, AqvifyDevices + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(seconds=60) + +type AqvifyConfigEntry = ConfigEntry[AqvifyCoordinator] + + +@dataclass +class AqvifyCoordinatorData: + """Data class for storing coordinator data.""" + + devices: AqvifyDevices + device_data: dict[str, AqvifyDeviceData] + + +class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]): + """Data update coordinator for Aqvify devices.""" + + config_entry: AqvifyConfigEntry + + def __init__(self, hass: HomeAssistant, entry: AqvifyConfigEntry) -> None: + """Initialize the Aqvify data update coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + config_entry=entry, + ) + + self.api_client = AqvifyAPI( + entry.data[CONF_API_KEY], websession=async_get_clientsession(hass) + ) + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + try: + await self.api_client.async_get_account_id() + except AqvifyAuthException as err: + raise ConfigEntryAuthFailed(f"Invalid Aqvify API key: {err}") from err + except (ClientResponseError, TimeoutError) as err: + raise ConfigEntryNotReady( + f"Failed to connect to Aqvify API: {err}" + ) from err + + async def _async_update_data(self) -> AqvifyCoordinatorData: + """Fetch device state.""" + try: + devices = await self.api_client.async_get_devices() + except ClientResponseError as err: + raise UpdateFailed(f"Error communicating with Aqvify API: {err}") from err + except TimeoutError as err: + raise UpdateFailed(f"Timeout communicating with Aqvify API: {err}") from err + + device_data = {} + for device in devices.devices.values(): + try: + device_key = str(device.device_key) + device_data[ + device_key + ] = await self.api_client.async_get_device_latest_data(device_key) + except ClientResponseError as err: + raise UpdateFailed( + f"Error communicating with Aqvify API: {err}" + ) from err + except TimeoutError as err: + raise UpdateFailed( + f"Timeout communicating with Aqvify API: {err}" + ) from err + + return AqvifyCoordinatorData( + devices=devices, + device_data=device_data, + ) diff --git a/homeassistant/components/aqvify/entity.py b/homeassistant/components/aqvify/entity.py new file mode 100644 index 0000000000000..2f893f7de7f05 --- /dev/null +++ b/homeassistant/components/aqvify/entity.py @@ -0,0 +1,35 @@ +"""Defines a base Aqvify entity.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AqvifyCoordinator + + +class AqvifyBaseEntity(CoordinatorEntity[AqvifyCoordinator]): + """Defines a base Aqvify entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AqvifyCoordinator, + description: EntityDescription, + device_key: str, + ) -> None: + """Initialize the Aqvify entity.""" + super().__init__(coordinator) + + account_id = self.coordinator.config_entry.unique_id + self.device_key = device_key + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{account_id}_{device_key}")}, + name=coordinator.data.devices.devices[device_key].name, + manufacturer="Aqvify", + configuration_url="https://app.aqvify.com", + serial_number=device_key, + ) + self._attr_unique_id = f"{account_id}_{device_key}_{description.key}" + self.entity_description = description diff --git a/homeassistant/components/aqvify/icons.json b/homeassistant/components/aqvify/icons.json new file mode 100644 index 0000000000000..96ff49e001150 --- /dev/null +++ b/homeassistant/components/aqvify/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "meter_value": { + "default": "mdi:waves-arrow-up" + }, + "water_level": { + "default": "mdi:waves" + } + } + } +} diff --git a/homeassistant/components/aqvify/manifest.json b/homeassistant/components/aqvify/manifest.json new file mode 100644 index 0000000000000..fadc9f9e1e82d --- /dev/null +++ b/homeassistant/components/aqvify/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "aqvify", + "name": "Aqvify", + "codeowners": ["@astrandb"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aqvify", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["pyaqvify"], + "quality_scale": "bronze", + "requirements": ["pyaqvify==0.0.8"] +} diff --git a/homeassistant/components/aqvify/quality_scale.yaml b/homeassistant/components/aqvify/quality_scale.yaml new file mode 100644 index 0000000000000..1c294e6b795c4 --- /dev/null +++ b/homeassistant/components/aqvify/quality_scale.yaml @@ -0,0 +1,69 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No actions in this integration. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration do not explicitly subscribe to events. + 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: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + 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: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/aqvify/sensor.py b/homeassistant/components/aqvify/sensor.py new file mode 100644 index 0000000000000..e20ebb5f989f0 --- /dev/null +++ b/homeassistant/components/aqvify/sensor.py @@ -0,0 +1,79 @@ +"""Sensor platform for Aqvify integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from pyaqvify import AqvifyDeviceData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.const import UnitOfLength +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import AqvifyConfigEntry +from .entity import AqvifyBaseEntity + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AqvifySensorEntityDescription(SensorEntityDescription): + """Description of an Aqvify sensor entity.""" + + value_fn: Callable[[AqvifyDeviceData], float | int | None] + + +ENTITIES: tuple[AqvifySensorEntityDescription, ...] = ( + AqvifySensorEntityDescription( + key="meter_value", + translation_key="meter_value", + native_unit_of_measurement=UnitOfLength.METERS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=2, + value_fn=lambda value: value.meter_value, + ), + AqvifySensorEntityDescription( + key="water_level", + translation_key="water_level", + native_unit_of_measurement=UnitOfLength.METERS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=2, + value_fn=lambda value: value.water_level, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AqvifyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Aqvify sensor entities from a config entry.""" + async_add_entities( + AqvifySensor(entry.runtime_data, description, device_key) + for description in ENTITIES + for device_key in entry.runtime_data.data.devices.devices + ) + + +class AqvifySensor(AqvifyBaseEntity, SensorEntity): + """Representation of an Aqvify sensor entity.""" + + entity_description: AqvifySensorEntityDescription + + @property + def native_value(self) -> StateType | datetime | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn( + self.coordinator.data.device_data[self.device_key] + ) diff --git a/homeassistant/components/aqvify/strings.json b/homeassistant/components/aqvify/strings.json new file mode 100644 index 0000000000000..d04db9ef205d5 --- /dev/null +++ b/homeassistant/components/aqvify/strings.json @@ -0,0 +1,33 @@ +{ + "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%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_key": "API key" + }, + "data_description": { + "api_key": "Your Aqvify API key" + }, + "description": "Navigate to your [Aqvify account]({aqvify_url}), copy your API key, and paste it below." + } + } + }, + "entity": { + "sensor": { + "meter_value": { + "name": "Meter value" + }, + "water_level": { + "name": "Water level" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b5e0330d3ae17..6b931be024509 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -74,6 +74,7 @@ "aprilaire", "apsystems", "aquacell", + "aqvify", "aranet", "arcam_fmj", "arve", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f4fa6aee06c65..72c7c9d7bfecf 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -520,6 +520,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "aqvify": { + "name": "Aqvify", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "aranet": { "name": "Aranet", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 6871bd7c88241..50b94e0470890 100644 --- a/mypy.ini +++ b/mypy.ini @@ -716,6 +716,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.aqvify.*] +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.aranet.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index ceed25876ed7a..52aa7a5d6e790 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2017,6 +2017,9 @@ pyanglianwater==3.1.2 # homeassistant.components.aprilaire pyaprilaire==0.9.1 +# homeassistant.components.aqvify +pyaqvify==0.0.8 + # homeassistant.components.atag pyatag==0.3.5.3 diff --git a/tests/components/aqvify/__init__.py b/tests/components/aqvify/__init__.py new file mode 100644 index 0000000000000..7cb8a469788f9 --- /dev/null +++ b/tests/components/aqvify/__init__.py @@ -0,0 +1,12 @@ +"""Tests for Aqvify integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Helper for setting up the component.""" + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/aqvify/conftest.py b/tests/components/aqvify/conftest.py new file mode 100644 index 0000000000000..121abf5490b07 --- /dev/null +++ b/tests/components/aqvify/conftest.py @@ -0,0 +1,111 @@ +"""Common fixtures for the Aqvify tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from pyaqvify import AqvifyAccount, AqvifyDeviceData, AqvifyDevices +import pytest + +from homeassistant.components.aqvify.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import ( + MockConfigEntry, + async_load_json_array_fixture, + async_load_json_object_fixture, +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aqvify.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + minor_version=1, + domain=DOMAIN, + title="Aqvify test", + data={"api_key": "fake_api_key"}, + entry_id="aqvify_test", + unique_id="test_account_id", + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def mock_aqvify_client( + device_fixture: list[dict[str, Any]], + device_data_fixture: dict[str, Any], + account_fixture: dict[str, Any], +) -> Generator[MagicMock]: + """Mock an Aqvify client.""" + + with ( + patch( + "homeassistant.components.aqvify.coordinator.AqvifyAPI", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.aqvify.config_flow.AqvifyAPI", + new=mock_client, + ), + ): + client = mock_client.return_value + + client.async_get_account_id.return_value = AqvifyAccount(account_fixture) + client.async_get_devices.return_value = AqvifyDevices(device_fixture) + client.async_get_device_latest_data.return_value = AqvifyDeviceData( + device_data_fixture + ) + yield client + + +@pytest.fixture(scope="package") +def load_device_file() -> str: + """Fixture for loading device file.""" + return "default_devices.json" + + +@pytest.fixture(scope="package") +def load_device_data_file() -> str: + """Fixture for loading device data file.""" + return "default_device_data.json" + + +@pytest.fixture(scope="package") +def load_account_file() -> str: + """Fixture for loading account file.""" + return "default_account.json" + + +@pytest.fixture +async def device_fixture( + hass: HomeAssistant, load_device_file: str +) -> list[dict[str, Any]]: + """Fixture for device.""" + return await async_load_json_array_fixture(hass, load_device_file, DOMAIN) + + +@pytest.fixture +async def device_data_fixture( + hass: HomeAssistant, load_device_data_file: str +) -> dict[str, Any]: + """Fixture for device data.""" + return await async_load_json_object_fixture(hass, load_device_data_file, DOMAIN) + + +@pytest.fixture +async def account_fixture( + hass: HomeAssistant, load_account_file: str +) -> dict[str, Any]: + """Fixture for account data.""" + return await async_load_json_object_fixture(hass, load_account_file, DOMAIN) diff --git a/tests/components/aqvify/fixtures/default_account.json b/tests/components/aqvify/fixtures/default_account.json new file mode 100644 index 0000000000000..98531af9528c0 --- /dev/null +++ b/tests/components/aqvify/fixtures/default_account.json @@ -0,0 +1,3 @@ +{ + "accountId": "test_account_id" +} diff --git a/tests/components/aqvify/fixtures/default_device_data.json b/tests/components/aqvify/fixtures/default_device_data.json new file mode 100644 index 0000000000000..bb632cba80598 --- /dev/null +++ b/tests/components/aqvify/fixtures/default_device_data.json @@ -0,0 +1,6 @@ +{ + "dateTime": "2026-06-04T09:36:06+00:00", + "waterLevel": -0.136786005, + "meterValue": 0.823213995, + "status": null +} diff --git a/tests/components/aqvify/fixtures/default_devices.json b/tests/components/aqvify/fixtures/default_devices.json new file mode 100644 index 0000000000000..ca4aab9a4d4e2 --- /dev/null +++ b/tests/components/aqvify/fixtures/default_devices.json @@ -0,0 +1,10 @@ +[ + { + "deviceKey": "DeviceKey_1", + "name": "Device 1" + }, + { + "deviceKey": "DeviceKey_2", + "name": "Device 2" + } +] diff --git a/tests/components/aqvify/snapshots/test_init.ambr b/tests/components/aqvify/snapshots/test_init.ambr new file mode 100644 index 0000000000000..fe3d2c0558203 --- /dev/null +++ b/tests/components/aqvify/snapshots/test_init.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_device_registry_integration + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://app.aqvify.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'aqvify', + 'test_account_id_DeviceKey_1', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Aqvify', + 'model': None, + 'model_id': None, + 'name': 'Device 1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'DeviceKey_1', + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://app.aqvify.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'aqvify', + 'test_account_id_DeviceKey_2', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Aqvify', + 'model': None, + 'model_id': None, + 'name': 'Device 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'DeviceKey_2', + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- diff --git a/tests/components/aqvify/snapshots/test_sensor.ambr b/tests/components/aqvify/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..54dcadfdbf2c1 --- /dev/null +++ b/tests/components/aqvify/snapshots/test_sensor.ambr @@ -0,0 +1,233 @@ +# serializer version: 1 +# name: test_sensor_snapshot[sensor.device_1_meter_value-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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': None, + 'entity_id': 'sensor.device_1_meter_value', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Meter value', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter value', + 'platform': 'aqvify', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'meter_value', + 'unique_id': 'test_account_id_DeviceKey_1_meter_value', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.device_1_meter_value-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Device 1 Meter value', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_1_meter_value', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.823213995', + }) +# --- +# name: test_sensor_snapshot[sensor.device_1_water_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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': None, + 'entity_id': 'sensor.device_1_water_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Water level', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water level', + 'platform': 'aqvify', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_level', + 'unique_id': 'test_account_id_DeviceKey_1_water_level', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.device_1_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Device 1 Water level', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_1_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.136786005', + }) +# --- +# name: test_sensor_snapshot[sensor.device_2_meter_value-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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': None, + 'entity_id': 'sensor.device_2_meter_value', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Meter value', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter value', + 'platform': 'aqvify', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'meter_value', + 'unique_id': 'test_account_id_DeviceKey_2_meter_value', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.device_2_meter_value-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Device 2 Meter value', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_2_meter_value', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.823213995', + }) +# --- +# name: test_sensor_snapshot[sensor.device_2_water_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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': None, + 'entity_id': 'sensor.device_2_water_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Water level', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water level', + 'platform': 'aqvify', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_level', + 'unique_id': 'test_account_id_DeviceKey_2_water_level', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.device_2_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Device 2 Water level', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_2_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.136786005', + }) +# --- diff --git a/tests/components/aqvify/test_config_flow.py b/tests/components/aqvify/test_config_flow.py new file mode 100644 index 0000000000000..6c752c128be23 --- /dev/null +++ b/tests/components/aqvify/test_config_flow.py @@ -0,0 +1,119 @@ +"""Test the Aqvify config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from aiohttp import ClientResponseError +from pyaqvify import AqvifyAuthException +import pytest + +from homeassistant.components.aqvify.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_full_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_aqvify_client: MagicMock +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "test-api-key", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Aqvify" + assert result["data"] == { + CONF_API_KEY: "test-api-key", + } + assert result["result"].unique_id == "test_account_id" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_base"), + [ + (AqvifyAuthException, "invalid_auth"), + ( + ClientResponseError(request_info=None, history=None, status=500), + "cannot_connect", + ), + (TypeError, "unknown"), + ], +) +async def test_form_invalid( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_aqvify_client: MagicMock, + side_effect: Exception, + error_base: str, +) -> None: + """Test we handle errors during form submission.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_aqvify_client.async_get_account_id.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "test-api-key", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_base} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + mock_aqvify_client.async_get_account_id.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "test-api-key", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Aqvify" + assert result["data"] == { + CONF_API_KEY: "test-api-key", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_same_account_setup( + hass: HomeAssistant, mock_config_entry: AsyncMock, mock_aqvify_client: MagicMock +) -> None: + """Test setup same account twice.""" + + # Create an existing config entry for the same user account + mock_config_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["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "test-api-key2", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/aqvify/test_init.py b/tests/components/aqvify/test_init.py new file mode 100644 index 0000000000000..99bba4e0134d6 --- /dev/null +++ b/tests/components/aqvify/test_init.py @@ -0,0 +1,75 @@ +"""Test the Aqvify init.""" + +from unittest.mock import MagicMock + +from pyaqvify import AqvifyAuthException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_aqvify_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + entry = mock_config_entry + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("error", "expected_state"), + [ + (None, ConfigEntryState.LOADED), + (AqvifyAuthException, ConfigEntryState.SETUP_ERROR), + (TimeoutError, ConfigEntryState.SETUP_RETRY), + ], + ids=["no_error", "auth_error", "timeout_error"], +) +async def test_setup_entry_with_error( + hass: HomeAssistant, + mock_aqvify_client: MagicMock, + mock_config_entry: MockConfigEntry, + error: Exception | None, + expected_state: ConfigEntryState, +) -> None: + """Test setup entry with error.""" + mock_aqvify_client.async_get_account_id.side_effect = error + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state + + +async def test_device_registry_integration( + hass: HomeAssistant, + mock_aqvify_client: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device registry integration creates correct devices.""" + await setup_integration(hass, mock_config_entry) + + # Get all devices created for this config entry + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + # Snapshot the devices to ensure they have the correct structure + assert device_entries == snapshot diff --git a/tests/components/aqvify/test_sensor.py b/tests/components/aqvify/test_sensor.py new file mode 100644 index 0000000000000..c046ad6e11c04 --- /dev/null +++ b/tests/components/aqvify/test_sensor.py @@ -0,0 +1,31 @@ +"""Test Aqvify sensor platform.""" + +from unittest.mock import MagicMock, patch + +import pytest +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 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_snapshot( + hass: HomeAssistant, + mock_aqvify_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor setup for cloud connection.""" + with patch("homeassistant.components.aqvify.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + )