Add new Aqvify integration (#172936)

This commit is contained in:
Åke Strandberg
2026-06-08 15:45:03 +02:00
committed by GitHub
parent 828ec639dd
commit b8bdd2c47c
26 changed files with 1110 additions and 0 deletions
+1
View File
@@ -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.*
Generated
+2
View File
@@ -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
@@ -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)
@@ -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",
},
)
+3
View File
@@ -0,0 +1,3 @@
"""Constants for the Aqvify integration."""
DOMAIN = "aqvify"
@@ -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,
)
+35
View File
@@ -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
@@ -0,0 +1,12 @@
{
"entity": {
"sensor": {
"meter_value": {
"default": "mdi:waves-arrow-up"
},
"water_level": {
"default": "mdi:waves"
}
}
}
}
@@ -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"]
}
@@ -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
+79
View File
@@ -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]
)
@@ -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"
}
}
}
}
+1
View File
@@ -74,6 +74,7 @@ FLOWS = {
"aprilaire",
"apsystems",
"aquacell",
"aqvify",
"aranet",
"arcam_fmj",
"arve",
@@ -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",
Generated
+10
View File
@@ -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
+3
View File
@@ -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
+12
View File
@@ -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()
+111
View File
@@ -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)
@@ -0,0 +1,3 @@
{
"accountId": "test_account_id"
}
@@ -0,0 +1,6 @@
{
"dateTime": "2026-06-04T09:36:06+00:00",
"waterLevel": -0.136786005,
"meterValue": 0.823213995,
"status": null
}
@@ -0,0 +1,10 @@
[
{
"deviceKey": "DeviceKey_1",
"name": "Device 1"
},
{
"deviceKey": "DeviceKey_2",
"name": "Device 2"
}
]
@@ -0,0 +1,63 @@
# serializer version: 1
# name: test_device_registry_integration
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': 'https://app.aqvify.com',
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
'serial_number': 'DeviceKey_1',
'sw_version': None,
'via_device_id': None,
}),
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': 'https://app.aqvify.com',
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
'serial_number': 'DeviceKey_2',
'sw_version': None,
'via_device_id': None,
}),
])
# ---
@@ -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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Meter value',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.DISTANCE: 'distance'>,
'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': <UnitOfLength.METERS: 'm'>,
})
# ---
# name: test_sensor_snapshot[sensor.device_1_meter_value-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'distance',
'friendly_name': 'Device 1 Meter value',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfLength.METERS: 'm'>,
}),
'context': <ANY>,
'entity_id': 'sensor.device_1_meter_value',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.823213995',
})
# ---
# name: test_sensor_snapshot[sensor.device_1_water_level-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Water level',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.DISTANCE: 'distance'>,
'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': <UnitOfLength.METERS: 'm'>,
})
# ---
# name: test_sensor_snapshot[sensor.device_1_water_level-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'distance',
'friendly_name': 'Device 1 Water level',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfLength.METERS: 'm'>,
}),
'context': <ANY>,
'entity_id': 'sensor.device_1_water_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-0.136786005',
})
# ---
# name: test_sensor_snapshot[sensor.device_2_meter_value-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Meter value',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.DISTANCE: 'distance'>,
'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': <UnitOfLength.METERS: 'm'>,
})
# ---
# name: test_sensor_snapshot[sensor.device_2_meter_value-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'distance',
'friendly_name': 'Device 2 Meter value',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfLength.METERS: 'm'>,
}),
'context': <ANY>,
'entity_id': 'sensor.device_2_meter_value',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.823213995',
})
# ---
# name: test_sensor_snapshot[sensor.device_2_water_level-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Water level',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.DISTANCE: 'distance'>,
'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': <UnitOfLength.METERS: 'm'>,
})
# ---
# name: test_sensor_snapshot[sensor.device_2_water_level-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'distance',
'friendly_name': 'Device 2 Water level',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfLength.METERS: 'm'>,
}),
'context': <ANY>,
'entity_id': 'sensor.device_2_water_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-0.136786005',
})
# ---
+119
View File
@@ -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"
+75
View File
@@ -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
+31
View File
@@ -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
)