diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index ecd8f5f7f25b..d841e22b061c 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -35,6 +35,7 @@ from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2 from .const import DOMAIN from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity +from .utils import error_detail _LOGGER = logging.getLogger(__name__) @@ -86,7 +87,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as aio_exception: await aqualink.close() raise ConfigEntryNotReady( - f"Error while attempting login: {aio_exception}" + f"Error while attempting login: {error_detail(aio_exception)}" ) from aio_exception try: @@ -96,10 +97,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> raise ConfigEntryAuthFailed( "Invalid credentials for iAquaLink" ) from auth_exception - except AqualinkServiceException as svc_exception: + except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as svc_exception: await aqualink.close() raise ConfigEntryNotReady( - f"Error while attempting to retrieve systems list: {svc_exception}" + "Error while attempting to retrieve systems list: " + f"{error_detail(svc_exception)}" ) from svc_exception systems_list = list(systems.values()) @@ -132,10 +134,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> raise ConfigEntryAuthFailed( "Invalid credentials for iAquaLink" ) from auth_exception - except AqualinkServiceException as svc_exception: + except ( + AqualinkServiceException, + TimeoutError, + httpx.HTTPError, + ) as svc_exception: await aqualink.close() raise ConfigEntryNotReady( - f"Error while attempting to retrieve devices list: {svc_exception}" + "Error while attempting to retrieve devices list: " + f"{error_detail(svc_exception)}" ) from svc_exception device_registry = dr.async_get(hass) diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 0ad575843101..a5b034653a57 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -74,9 +74,13 @@ class HassAqualinkThermostat(AqualinkEntity[AqualinkThermostat], ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Turn the underlying heater switch on or off.""" if hvac_mode == HVACMode.HEAT: - await await_or_reraise(self.dev.turn_on()) + await await_or_reraise( + self.hass, self.coordinator.config_entry, self.dev.turn_on() + ) elif hvac_mode == HVACMode.OFF: - await await_or_reraise(self.dev.turn_off()) + await await_or_reraise( + self.hass, self.coordinator.config_entry, self.dev.turn_off() + ) else: _LOGGER.warning("Unknown operation mode: %s", hvac_mode) @@ -98,7 +102,11 @@ class HassAqualinkThermostat(AqualinkEntity[AqualinkThermostat], ClimateEntity): @refresh_system async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - await await_or_reraise(self.dev.set_temperature(int(kwargs[ATTR_TEMPERATURE]))) + await await_or_reraise( + self.hass, + self.coordinator.config_entry, + self.dev.set_temperature(int(kwargs[ATTR_TEMPERATURE])), + ) @property def current_temperature(self) -> float | None: diff --git a/homeassistant/components/iaqualink/config_flow.py b/homeassistant/components/iaqualink/config_flow.py index 03a48f4932f1..8800b55491ed 100644 --- a/homeassistant/components/iaqualink/config_flow.py +++ b/homeassistant/components/iaqualink/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure zone component.""" +"""Config flow for iAquaLink.""" from collections.abc import Mapping from typing import Any @@ -31,7 +31,7 @@ CREDENTIALS_DATA_SCHEMA = vol.Schema( class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN): - """Aqualink config flow.""" + """iAquaLink config flow.""" VERSION = 1 @@ -50,7 +50,7 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN): pass except AqualinkServiceUnauthorizedException: return {"base": "invalid_auth"} - except AqualinkServiceException, httpx.HTTPError: + except AqualinkServiceException, TimeoutError, httpx.HTTPError: return {"base": "cannot_connect"} return {} diff --git a/homeassistant/components/iaqualink/coordinator.py b/homeassistant/components/iaqualink/coordinator.py index ade0dec0b2f4..b43eb147dd4b 100644 --- a/homeassistant/components/iaqualink/coordinator.py +++ b/homeassistant/components/iaqualink/coordinator.py @@ -16,6 +16,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, UPDATE_INTERVAL_BY_SYSTEM_TYPE, UPDATE_INTERVAL_DEFAULT +from .utils import error_detail _LOGGER = logging.getLogger(__name__) @@ -51,9 +52,10 @@ class AqualinkDataUpdateCoordinator(DataUpdateCoordinator[None]): self.system.serial, ) return - except (AqualinkServiceException, httpx.HTTPError) as err: + except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as err: raise UpdateFailed( - f"Unable to update iAquaLink system {self.system.serial}: {err}" + "Unable to update iAquaLink system " + f"{self.system.serial}: {error_detail(err)}" ) from err if self.system.online is not True: raise UpdateFailed(f"iAquaLink system {self.system.serial} is offline") diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index 7fc49ba74b2d..f01f0a41ccfe 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -67,18 +67,28 @@ class HassAqualinkLight(AqualinkEntity[AqualinkLight], LightEntity): """ # For now I'm assuming lights support either effects or brightness. if effect_name := kwargs.get(ATTR_EFFECT): - await await_or_reraise(self.dev.set_effect_by_name(effect_name)) + await await_or_reraise( + self.hass, + self.coordinator.config_entry, + self.dev.set_effect_by_name(effect_name), + ) elif brightness := kwargs.get(ATTR_BRIGHTNESS): # Aqualink supports percentages in 25% increments. pct = round(brightness * 4.0 / 255) * 25 - await await_or_reraise(self.dev.set_brightness(pct)) + await await_or_reraise( + self.hass, self.coordinator.config_entry, self.dev.set_brightness(pct) + ) else: - await await_or_reraise(self.dev.turn_on()) + await await_or_reraise( + self.hass, self.coordinator.config_entry, self.dev.turn_on() + ) @refresh_system async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - await await_or_reraise(self.dev.turn_off()) + await await_or_reraise( + self.hass, self.coordinator.config_entry, self.dev.turn_off() + ) @property def brightness(self) -> int: @@ -86,7 +96,7 @@ class HassAqualinkLight(AqualinkEntity[AqualinkLight], LightEntity): The scale needs converting between 0-100 and 0-255. """ - return self.dev.brightness * 255 / 100 + return round(self.dev.brightness * 255 / 100) @property def effect(self) -> str: diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index d977a4c87f64..ec442defd126 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["iaqualink"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["iaqualink==0.7.0", "h2==4.3.0"], "single_config_entry": true } diff --git a/homeassistant/components/iaqualink/quality_scale.yaml b/homeassistant/components/iaqualink/quality_scale.yaml index cefaaca753cd..26af8ae41ae5 100644 --- a/homeassistant/components/iaqualink/quality_scale.yaml +++ b/homeassistant/components/iaqualink/quality_scale.yaml @@ -24,7 +24,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt @@ -35,7 +35,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/homeassistant/components/iaqualink/switch.py b/homeassistant/components/iaqualink/switch.py index 556e5f223c53..c82b15b6bbaa 100644 --- a/homeassistant/components/iaqualink/switch.py +++ b/homeassistant/components/iaqualink/switch.py @@ -56,9 +56,13 @@ class HassAqualinkSwitch(AqualinkEntity[AqualinkSwitch], SwitchEntity): @refresh_system async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" - await await_or_reraise(self.dev.turn_on()) + await await_or_reraise( + self.hass, self.coordinator.config_entry, self.dev.turn_on() + ) @refresh_system async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" - await await_or_reraise(self.dev.turn_off()) + await await_or_reraise( + self.hass, self.coordinator.config_entry, self.dev.turn_off() + ) diff --git a/homeassistant/components/iaqualink/utils.py b/homeassistant/components/iaqualink/utils.py index 2bce5fba6c05..c34867205d44 100644 --- a/homeassistant/components/iaqualink/utils.py +++ b/homeassistant/components/iaqualink/utils.py @@ -3,14 +3,42 @@ from collections.abc import Awaitable import httpx -from iaqualink.exception import AqualinkServiceException +from iaqualink.exception import ( + AqualinkServiceException, + AqualinkServiceUnauthorizedException, +) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -async def await_or_reraise(awaitable: Awaitable) -> None: +def error_detail(err: Exception) -> str: + """Return a non-empty error detail for iaqualink exceptions.""" + if detail := str(err): + return detail + return type(err).__name__ + + +async def await_or_reraise( + hass: HomeAssistant, + config_entry: ConfigEntry | None, + awaitable: Awaitable, +) -> None: """Execute API call while catching service exceptions.""" try: await awaitable + except AqualinkServiceUnauthorizedException as auth_exception: + if config_entry is not None: + config_entry.async_start_reauth(hass) + raise ConfigEntryAuthFailed( + "Invalid credentials for iAquaLink" + ) from auth_exception + except TimeoutError as timeout_exception: + raise HomeAssistantError( + f"Aqualink error: {error_detail(timeout_exception)}" + ) from timeout_exception except (AqualinkServiceException, httpx.HTTPError) as svc_exception: - raise HomeAssistantError(f"Aqualink error: {svc_exception}") from svc_exception + raise HomeAssistantError( + f"Aqualink error: {error_detail(svc_exception)}" + ) from svc_exception diff --git a/tests/components/iaqualink/conftest.py b/tests/components/iaqualink/conftest.py index a8e44f7a0191..8bd1bfabe43e 100644 --- a/tests/components/iaqualink/conftest.py +++ b/tests/components/iaqualink/conftest.py @@ -1,15 +1,26 @@ """Configuration for iAquaLink tests.""" -import random +from dataclasses import dataclass from unittest.mock import AsyncMock, PropertyMock, patch from iaqualink.client import AqualinkClient from iaqualink.device import AqualinkDevice from iaqualink.system import AqualinkSystem +from iaqualink.systems.iaqua.device import ( + IaquaAuxSwitch, + IaquaBinarySensor, + IaquaLightSwitch, + IaquaSensor, + IaquaThermostat, +) +from iaqualink.systems.iaqua.system import IaquaSystem import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.iaqualink import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -18,14 +29,18 @@ MOCK_PASSWORD = "password" MOCK_DATA = {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD} -def async_returns(x): - """Return value-returning async mock.""" - return AsyncMock(return_value=x) +@dataclass +class SystemDevices: + """All devices created by setup_integration.""" - -def async_raises(x): - """Return exception-raising async mock.""" - return AsyncMock(side_effect=x) + system: IaquaSystem + switch: IaquaAuxSwitch + light: IaquaLightSwitch + binary_sensor: IaquaBinarySensor + sensor: IaquaSensor + thermostat: IaquaThermostat + heater: IaquaAuxSwitch + pool_temp: IaquaSensor @pytest.fixture(name="client") @@ -42,13 +57,129 @@ def get_aqualink_system(aqualink, cls=None, data=None): if data is None: data = {} - num = random.randint(0, 99999) data["name"] = "Pool" - data["serial_number"] = f"SN{num:05}" + data["serial_number"] = "SN00001" return cls(aqualink=aqualink, data=data) +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, +) -> SystemDevices: + """Set up the iAquaLink integration with all platform entities.""" + system = get_aqualink_system( + client, + cls=IaquaSystem, + data={"home_screen": [{}, {}, {}, {"temp_scale": "F"}]}, + ) + system.online = True + + async def update() -> None: + system.temp_unit = "F" + + system.update = AsyncMock(side_effect=update) + + switch = get_aqualink_device( + system, name="aux_1", cls=IaquaAuxSwitch, data={"state": "1", "aux": "1"} + ) + light = get_aqualink_device( + system, + name="aux_2", + cls=IaquaLightSwitch, + data={"state": "1", "aux": "2", "label": "Pool Light"}, + ) + binary_sensor = get_aqualink_device( + system, name="freeze_protection", cls=IaquaBinarySensor, data={"state": "1"} + ) + sensor = get_aqualink_device( + system, name="ph", cls=IaquaSensor, data={"state": "7.2"} + ) + thermostat = get_aqualink_device( + system, name="pool_set_point", cls=IaquaThermostat, data={"state": "84"} + ) + heater = get_aqualink_device( + system, name="pool_heater", cls=IaquaAuxSwitch, data={"state": "1", "aux": "3"} + ) + pool_temp = get_aqualink_device( + system, name="pool_temp", cls=IaquaSensor, data={"state": "80"} + ) + + platform_devices = { + d.name: d for d in (switch, light, binary_sensor, sensor, thermostat) + } + system.devices = { + **platform_devices, + heater.name: heater, + pool_temp.name: pool_temp, + } + system.get_devices = AsyncMock(return_value=platform_devices) + system.set_aux = AsyncMock() + system.set_temps = AsyncMock() + system.set_light = AsyncMock() + + await setup_entry(hass, config_entry, system) + + return SystemDevices( + system=system, + switch=switch, + light=light, + binary_sensor=binary_sensor, + sensor=sensor, + thermostat=thermostat, + heater=heater, + pool_temp=pool_temp, + ) + + +async def setup_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + system: AqualinkSystem, +) -> None: + """Set up a config entry with a pre-built system.""" + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), + patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value={system.serial: system}, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +async def assert_platform_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + domain: str, +) -> None: + """Assert all entities for a given platform domain are set up correctly.""" + await setup_integration(hass, config_entry, client) + + entity_entries = [ + e + for e in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + if e.domain == domain + ] + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) + + def get_aqualink_device(system, name, cls=None, data=None): """Create aqualink device.""" if cls is None: diff --git a/tests/components/iaqualink/snapshots/test_binary_sensor.ambr b/tests/components/iaqualink/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..e4906c806b9d --- /dev/null +++ b/tests/components/iaqualink/snapshots/test_binary_sensor.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_setup[binary_sensor.freeze_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.freeze_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'iaqualink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SN00001_freeze_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.freeze_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'cold', + 'friendly_name': 'Freeze Protection', + }), + 'context': , + 'entity_id': 'binary_sensor.freeze_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/iaqualink/snapshots/test_climate.ambr b/tests/components/iaqualink/snapshots/test_climate.ambr new file mode 100644 index 000000000000..b60cb052f532 --- /dev/null +++ b/tests/components/iaqualink/snapshots/test_climate.ambr @@ -0,0 +1,68 @@ +# serializer version: 1 +# name: test_setup[climate.pool_set_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 40.0, + 'min_temp': 1.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.pool_set_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'iaqualink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'SN00001_pool_set_point', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[climate.pool_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26.7, + 'friendly_name': 'Pool Set Point', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 40.0, + 'min_temp': 1.1, + 'supported_features': , + 'temperature': 28.9, + }), + 'context': , + 'entity_id': 'climate.pool_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/iaqualink/snapshots/test_entity.ambr b/tests/components/iaqualink/snapshots/test_entity.ambr new file mode 100644 index 000000000000..ea4910a65917 --- /dev/null +++ b/tests/components/iaqualink/snapshots/test_entity.ambr @@ -0,0 +1,278 @@ +# serializer version: 1 +# name: test_setup[binary_sensor.freeze_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.freeze_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'iaqualink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SN00001_freeze_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.freeze_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'cold', + 'friendly_name': 'Freeze Protection', + }), + 'context': , + 'entity_id': 'binary_sensor.freeze_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup[climate.pool_set_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 40.0, + 'min_temp': 1.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.pool_set_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'iaqualink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'SN00001_pool_set_point', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[climate.pool_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26.7, + 'friendly_name': 'Pool Set Point', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 40.0, + 'min_temp': 1.1, + 'supported_features': , + 'temperature': 28.9, + }), + 'context': , + 'entity_id': 'climate.pool_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup[light.pool_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.pool_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'iaqualink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SN00001_aux_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.pool_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Pool Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.pool_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup[sensor.ph-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ph', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'iaqualink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SN00001_ph', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.ph-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ph', + }), + 'context': , + 'entity_id': 'sensor.ph', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.2', + }) +# --- +# name: test_setup[switch.aux_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.aux_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'iaqualink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SN00001_aux_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[switch.aux_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aux 1', + }), + 'context': , + 'entity_id': 'switch.aux_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/iaqualink/snapshots/test_light.ambr b/tests/components/iaqualink/snapshots/test_light.ambr new file mode 100644 index 000000000000..4e30c1ad5b85 --- /dev/null +++ b/tests/components/iaqualink/snapshots/test_light.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_setup[light.pool_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.pool_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'iaqualink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SN00001_aux_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.pool_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Pool Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.pool_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/iaqualink/snapshots/test_sensor.ambr b/tests/components/iaqualink/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..f17610ddb525 --- /dev/null +++ b/tests/components/iaqualink/snapshots/test_sensor.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_setup[sensor.ph-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ph', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'iaqualink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SN00001_ph', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.ph-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ph', + }), + 'context': , + 'entity_id': 'sensor.ph', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.2', + }) +# --- diff --git a/tests/components/iaqualink/snapshots/test_switch.ambr b/tests/components/iaqualink/snapshots/test_switch.ambr new file mode 100644 index 000000000000..c66911e955ab --- /dev/null +++ b/tests/components/iaqualink/snapshots/test_switch.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_setup[switch.aux_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.aux_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'iaqualink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SN00001_aux_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[switch.aux_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aux 1', + }), + 'context': , + 'entity_id': 'switch.aux_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/iaqualink/test_binary_sensor.py b/tests/components/iaqualink/test_binary_sensor.py new file mode 100644 index 000000000000..98174ecac6ac --- /dev/null +++ b/tests/components/iaqualink/test_binary_sensor.py @@ -0,0 +1,117 @@ +"""Binary sensor platform tests for iAquaLink.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from iaqualink.client import AqualinkClient +from iaqualink.systems.iaqua.device import IaquaBinarySensor +from iaqualink.systems.iaqua.system import IaquaSystem +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + assert_platform_setup, + get_aqualink_device, + get_aqualink_system, + setup_entry, +) + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all binary sensor entities are created correctly.""" + await assert_platform_setup( + hass, config_entry, client, entity_registry, snapshot, BINARY_SENSOR_DOMAIN + ) + + +async def _setup_binary_sensor( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + *, + name: str, + state: str, +) -> str: + """Set up the integration with a single binary sensor entity.""" + system = get_aqualink_system(client, cls=IaquaSystem) + system.online = True + system.update = AsyncMock() + sensor = get_aqualink_device( + system, name=name, cls=IaquaBinarySensor, data={"state": state} + ) + system.get_devices = AsyncMock(return_value={sensor.name: sensor}) + await setup_entry(hass, config_entry, system) + + entity_ids = hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN) + assert len(entity_ids) == 1 + return entity_ids[0] + + +@pytest.mark.parametrize( + ("name", "expected_class"), + [ + pytest.param("freeze_protection", BinarySensorDeviceClass.COLD, id="freeze"), + pytest.param("auxiliary", None, id="default"), + ], +) +async def test_binary_sensor_device_class( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + entity_registry: er.EntityRegistry, + name: str, + expected_class: BinarySensorDeviceClass | None, +) -> None: + """Test binary sensor device class mapping.""" + entity_id = await _setup_binary_sensor( + hass, + config_entry, + client, + name=name, + state="1", + ) + + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.original_device_class == expected_class + assert entry.has_entity_name is True + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + +async def test_binary_sensor_off_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, +) -> None: + """Test binary sensor off state is exposed through Home Assistant.""" + entity_id = await _setup_binary_sensor( + hass, + config_entry, + client, + name="freeze_protection", + state="0", + ) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF diff --git a/tests/components/iaqualink/test_climate.py b/tests/components/iaqualink/test_climate.py new file mode 100644 index 000000000000..0fce812e17ad --- /dev/null +++ b/tests/components/iaqualink/test_climate.py @@ -0,0 +1,355 @@ +"""Climate platform tests for iAquaLink.""" + +from __future__ import annotations + +import logging +from typing import Any +from unittest.mock import AsyncMock + +import httpx +from iaqualink.client import AqualinkClient +from iaqualink.exception import ( + AqualinkServiceException, + AqualinkServiceUnauthorizedException, +) +from iaqualink.systems.iaqua.device import ( + AqualinkState, + IaquaAuxSwitch, + IaquaSensor, + IaquaThermostat, +) +from iaqualink.systems.iaqua.system import IaquaSystem +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from .conftest import ( + assert_platform_setup, + get_aqualink_device, + get_aqualink_system, + setup_entry, +) + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all climate entities are created correctly.""" + await assert_platform_setup( + hass, config_entry, client, entity_registry, snapshot, CLIMATE_DOMAIN + ) + + +async def _setup_thermostat( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + *, + temp_unit: str = "F", + heater_state: str = AqualinkState.ON.value, + target_temperature: str = "84", + current_temperature: str = "80", +) -> tuple[IaquaSystem, object, object, object, str, object]: + """Set up the integration with a single thermostat entity.""" + if temp_unit == "F": + hass.config.units = US_CUSTOMARY_SYSTEM + + system = get_aqualink_system( + client, + cls=IaquaSystem, + data={"home_screen": [{}, {}, {}, {"temp_scale": temp_unit}]}, + ) + system.online = True + + async def update() -> None: + system.temp_unit = temp_unit + + system.update = AsyncMock(side_effect=update) + heater = get_aqualink_device( + system, + name="pool_heater", + cls=IaquaAuxSwitch, + data={"state": heater_state, "aux": "1"}, + ) + sensor = get_aqualink_device( + system, + name="pool_temp", + cls=IaquaSensor, + data={"state": current_temperature}, + ) + thermostat = get_aqualink_device( + system, + name="pool_set_point", + cls=IaquaThermostat, + data={"state": target_temperature}, + ) + system.devices = { + heater.name: heater, + sensor.name: sensor, + thermostat.name: thermostat, + } + system.get_devices = AsyncMock(return_value={thermostat.name: thermostat}) + system.set_aux = AsyncMock() + system.set_temps = AsyncMock() + + await setup_entry(hass, config_entry, system) + + entity_ids = hass.states.async_entity_ids(CLIMATE_DOMAIN) + assert len(entity_ids) == 1 + entity_id = entity_ids[0] + entity_state = hass.states.get(entity_id) + assert entity_state is not None + return system, heater, sensor, thermostat, entity_id, entity_state + + +@pytest.mark.parametrize( + ("heater_state", "expected_action", "expected_mode"), + [ + pytest.param( + AqualinkState.ON.value, HVACAction.HEATING, HVACMode.HEAT, id="heating" + ), + pytest.param( + AqualinkState.ENABLED.value, HVACAction.IDLE, HVACMode.OFF, id="idle" + ), + pytest.param(AqualinkState.OFF.value, HVACAction.OFF, HVACMode.OFF, id="off"), + ], +) +async def test_thermostat_properties( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + heater_state: str, + expected_action: HVACAction, + expected_mode: HVACMode, +) -> None: + """Test thermostat properties and HVAC action mapping.""" + _, _, _, _, _, entity_state = await _setup_thermostat( + hass, + config_entry, + client, + heater_state=heater_state, + ) + + assert entity_state.state == expected_mode + assert entity_state.attributes["hvac_action"] == expected_action + assert entity_state.attributes["temperature"] == 84.0 + assert entity_state.attributes["current_temperature"] == 80.0 + + +async def test_thermostat_current_temperature_none( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, +) -> None: + """Test thermostat current temperature handles empty values.""" + _, _, _, _, _, entity_state = await _setup_thermostat( + hass, + config_entry, + client, + temp_unit="C", + heater_state=AqualinkState.OFF.value, + target_temperature="24", + current_temperature="", + ) + + assert entity_state.state == HVACMode.OFF + assert entity_state.attributes["current_temperature"] is None + + +@pytest.mark.parametrize( + ("hvac_mode", "initial_state", "expected_state"), + [ + pytest.param(HVACMode.HEAT, AqualinkState.OFF.value, HVACMode.HEAT, id="heat"), + pytest.param(HVACMode.OFF, AqualinkState.ON.value, HVACMode.OFF, id="off"), + ], +) +async def test_thermostat_set_hvac_mode( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + hvac_mode: HVACMode, + initial_state: str, + expected_state: HVACMode, +) -> None: + """Test thermostat HVAC mode service updates Home Assistant state.""" + system, heater, _, _, entity_id, _ = await _setup_thermostat( + hass, + config_entry, + client, + heater_state=initial_state, + ) + + async def set_aux(_: str) -> None: + heater.data["state"] = ( + AqualinkState.ON.value + if hvac_mode == HVACMode.HEAT + else AqualinkState.OFF.value + ) + + system.set_aux = AsyncMock(side_effect=set_aux) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == expected_state + + +async def test_thermostat_set_temperature( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, +) -> None: + """Test thermostat target temperature service updates Home Assistant state.""" + system, _, _, thermostat, entity_id, _ = await _setup_thermostat( + hass, + config_entry, + client, + ) + + async def set_temps(data: dict[str, str]) -> None: + thermostat.data["state"] = data["temp1"] + + system.set_temps = AsyncMock(side_effect=set_temps) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 84.9}, + blocking=True, + ) + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.attributes["temperature"] == 84.0 + + +@pytest.mark.parametrize( + ("service", "service_data", "system_attr"), + [ + pytest.param( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + "set_aux", + id="set-hvac-mode", + ), + pytest.param( + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 84}, + "set_temps", + id="set-temperature", + ), + ], +) +@pytest.mark.parametrize( + ("raised_exception", "expected_exception", "match"), + [ + pytest.param( + AqualinkServiceException, + HomeAssistantError, + "Aqualink error: AqualinkServiceException", + id="service", + ), + pytest.param( + TimeoutError(), + HomeAssistantError, + "Aqualink error: TimeoutError", + id="timeout", + ), + pytest.param( + httpx.HTTPError("boom"), + HomeAssistantError, + "Aqualink error: boom", + id="http", + ), + pytest.param( + AqualinkServiceUnauthorizedException, + ConfigEntryAuthFailed, + "Invalid credentials for iAquaLink", + id="unauthorized", + ), + ], +) +async def test_climate_action_errors_leave_state_unchanged( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + service: str, + service_data: dict[str, Any], + system_attr: str, + raised_exception: Exception | type[Exception], + expected_exception: type[Exception], + match: str, +) -> None: + """Test climate action errors are surfaced through the service call.""" + system, _, _, _, entity_id, entity_state = await _setup_thermostat( + hass, + config_entry, + client, + heater_state=AqualinkState.OFF.value, + ) + initial_state = entity_state.state + setattr(system, system_attr, AsyncMock(side_effect=raised_exception)) + + with pytest.raises(expected_exception, match=match): + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == initial_state + + +async def test_thermostat_set_unknown_hvac_mode_logs_warning( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test unsupported HVAC modes log a warning and keep state unchanged.""" + system, _, _, _, entity_id, _ = await _setup_thermostat( + hass, + config_entry, + client, + heater_state=AqualinkState.OFF.value, + ) + climate_component = hass.data[CLIMATE_DOMAIN] + entity = climate_component.get_entity(entity_id) + assert entity is not None + + with caplog.at_level(logging.WARNING): + await entity.async_set_hvac_mode(HVACMode.COOL) + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == HVACMode.OFF + assert "Unknown operation mode" in caplog.text + system.set_aux.assert_not_called() diff --git a/tests/components/iaqualink/test_config_flow.py b/tests/components/iaqualink/test_config_flow.py index bf1c1208c96d..1cf57fe0d819 100644 --- a/tests/components/iaqualink/test_config_flow.py +++ b/tests/components/iaqualink/test_config_flow.py @@ -1,11 +1,14 @@ """Tests for iAquaLink config flow.""" +from typing import Any from unittest.mock import patch +import httpx from iaqualink.exception import ( AqualinkServiceException, AqualinkServiceUnauthorizedException, ) +import pytest from homeassistant.components.iaqualink import DOMAIN, config_flow from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER @@ -23,12 +26,15 @@ DHCP_DISCOVERY = DhcpServiceInfo( ) -async def test_already_configured( +async def _async_mock_login(self: Any) -> None: + """Mock a successful login.""" + + +async def test_single_instance_allowed( hass: HomeAssistant, config_entry: MockConfigEntry, - config_data: dict[str, str], ) -> None: - """Test config flow when iaqualink component is already setup.""" + """Test config flow aborts when an entry already exists.""" config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -70,7 +76,7 @@ async def test_dhcp_discovery_starts_user_flow( with ( patch( "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", - return_value=None, + _async_mock_login, ), patch( "homeassistant.components.iaqualink.async_setup_entry", @@ -107,16 +113,26 @@ async def test_with_invalid_credentials( assert result["errors"] == {"base": "invalid_auth"} -async def test_service_exception( - hass: HomeAssistant, config_data: dict[str, str] +@pytest.mark.parametrize( + "raised_exception", + [ + pytest.param(AqualinkServiceException, id="service"), + pytest.param(TimeoutError, id="timeout"), + pytest.param(httpx.HTTPError("boom"), id="http"), + ], +) +async def test_cannot_connect_exception( + hass: HomeAssistant, + config_data: dict[str, str], + raised_exception: Exception | type[Exception], ) -> None: - """Test config flow encountering service exception.""" + """Test config flow encountering a connection-related exception.""" flow = config_flow.AqualinkFlowHandler() flow.hass = hass with patch( "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", - side_effect=AqualinkServiceException, + side_effect=raised_exception, ): result = await flow.async_step_user(config_data) @@ -131,10 +147,11 @@ async def test_with_existing_config( """Test config flow with existing configuration.""" flow = config_flow.AqualinkFlowHandler() flow.hass = hass + flow.context = {} with patch( "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", - return_value=None, + _async_mock_login, ): result = await flow.async_step_user(config_data) @@ -169,8 +186,6 @@ async def test_reauth_success(hass: HomeAssistant, config_data: dict[str, str]) ) entry.add_to_hass(hass) - new_username = "updated@example.com" - result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM @@ -180,7 +195,7 @@ async def test_reauth_success(hass: HomeAssistant, config_data: dict[str, str]) with ( patch( "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", - return_value=None, + _async_mock_login, ), patch( "homeassistant.config_entries.ConfigEntries.async_reload", @@ -189,16 +204,18 @@ async def test_reauth_success(hass: HomeAssistant, config_data: dict[str, str]) ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: new_username, CONF_PASSWORD: "new_password"}, + { + CONF_USERNAME: config_data[CONF_USERNAME], + CONF_PASSWORD: "new_password", + }, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" - assert entry.title == new_username + assert entry.title == config_data[CONF_USERNAME] assert dict(entry.data) == { **config_data, - CONF_USERNAME: new_username, CONF_PASSWORD: "new_password", } @@ -214,8 +231,6 @@ async def test_reconfigure_success( ) entry.add_to_hass(hass) - new_username = "updated@example.com" - result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM @@ -225,7 +240,7 @@ async def test_reconfigure_success( with ( patch( "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", - return_value=None, + _async_mock_login, ), patch( "homeassistant.config_entries.ConfigEntries.async_reload", @@ -234,16 +249,15 @@ async def test_reconfigure_success( ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: new_username, CONF_PASSWORD: "new_password"}, + {CONF_USERNAME: config_data[CONF_USERNAME], CONF_PASSWORD: "new_password"}, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - assert entry.title == new_username + assert entry.title == config_data[CONF_USERNAME] assert dict(entry.data) == { **config_data, - CONF_USERNAME: new_username, CONF_PASSWORD: "new_password", } @@ -252,7 +266,10 @@ async def test_reauth_invalid_auth( hass: HomeAssistant, config_data: dict[str, str] ) -> None: """Test reauthentication with invalid credentials.""" - entry = MockConfigEntry(domain=DOMAIN, data=config_data) + entry = MockConfigEntry( + domain=DOMAIN, + data=config_data, + ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -271,18 +288,31 @@ async def test_reauth_invalid_auth( assert result["errors"] == {"base": "invalid_auth"} +@pytest.mark.parametrize( + "raised_exception", + [ + pytest.param(AqualinkServiceException, id="service"), + pytest.param(TimeoutError, id="timeout"), + pytest.param(httpx.HTTPError("boom"), id="http"), + ], +) async def test_reauth_cannot_connect( - hass: HomeAssistant, config_data: dict[str, str] + hass: HomeAssistant, + config_data: dict[str, str], + raised_exception: Exception | type[Exception], ) -> None: """Test reauthentication when the service cannot be reached.""" - entry = MockConfigEntry(domain=DOMAIN, data=config_data) + entry = MockConfigEntry( + domain=DOMAIN, + data=config_data, + ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) with patch( "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", - side_effect=AqualinkServiceException, + side_effect=raised_exception, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/iaqualink/test_entity.py b/tests/components/iaqualink/test_entity.py new file mode 100644 index 000000000000..349b03ea70eb --- /dev/null +++ b/tests/components/iaqualink/test_entity.py @@ -0,0 +1,33 @@ +"""Entity base tests for iAquaLink.""" + +from __future__ import annotations + +from iaqualink.client import AqualinkClient +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all entities are created by setup_integration.""" + await setup_integration(hass, config_entry, client) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + state = hass.states.get(entity_entry.entity_id) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") diff --git a/tests/components/iaqualink/test_init.py b/tests/components/iaqualink/test_init.py index fe4058fa5e55..fe441c552ef6 100644 --- a/tests/components/iaqualink/test_init.py +++ b/tests/components/iaqualink/test_init.py @@ -3,19 +3,14 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory +import httpx from iaqualink.client import AqualinkClient from iaqualink.exception import ( AqualinkServiceException, AqualinkServiceThrottledException, AqualinkServiceUnauthorizedException, ) -from iaqualink.systems.iaqua.device import ( - IaquaAuxSwitch, - IaquaBinarySensor, - IaquaLightSwitch, - IaquaSensor, - IaquaThermostat, -) +from iaqualink.systems.iaqua.device import IaquaLightSwitch from iaqualink.systems.iaqua.system import IaquaSystem import pytest @@ -39,7 +34,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from .conftest import get_aqualink_device, get_aqualink_system +from .conftest import get_aqualink_device, get_aqualink_system, setup_integration from tests.common import MockConfigEntry, async_fire_time_changed @@ -55,37 +50,23 @@ async def _advance_coordinator_time( await hass.async_block_till_done(wait_background_tasks=True) +@pytest.mark.parametrize( + "raised_exception", + [ + pytest.param(AqualinkServiceException, id="service"), + pytest.param(TimeoutError, id="timeout"), + pytest.param(httpx.HTTPError("boom"), id="http"), + ], +) async def test_system_refresh_failure_marks_entities_unavailable( hass: HomeAssistant, config_entry: MockConfigEntry, client: AqualinkClient, freezer: FrozenDateTimeFactory, + raised_exception: Exception | type[Exception], ) -> None: """Test a system refresh failure marks attached entities unavailable.""" - config_entry.add_to_hass(hass) - - system = get_aqualink_system(client, cls=IaquaSystem) - system.online = True - system.update = AsyncMock() - systems = {system.serial: system} - light = get_aqualink_device( - system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"} - ) - devices = {light.name: light} - system.get_devices = AsyncMock(return_value=devices) - - with ( - patch( - "homeassistant.components.iaqualink.AqualinkClient.login", - return_value=None, - ), - patch( - "homeassistant.components.iaqualink.AqualinkClient.get_systems", - return_value=systems, - ), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + devices = await setup_integration(hass, config_entry, client) entity_ids = hass.states.async_entity_ids(LIGHT_DOMAIN) assert len(entity_ids) == 1 @@ -96,10 +77,10 @@ async def test_system_refresh_failure_marks_entities_unavailable( assert state.state == STATE_ON async def fail_update() -> None: - system.online = None - raise AqualinkServiceException + devices.system.online = None + raise raised_exception - system.update = AsyncMock(side_effect=fail_update) + devices.system.update = AsyncMock(side_effect=fail_update) await _advance_coordinator_time(hass, freezer) @@ -165,39 +146,16 @@ async def test_light_service_calls_update_entity_state( client: AqualinkClient, ) -> None: """Test light service calls update entity state from device properties.""" - config_entry.add_to_hass(hass) - - system = get_aqualink_system(client, cls=IaquaSystem) - system.online = True - system.update = AsyncMock() - systems = {system.serial: system} - light = get_aqualink_device( - system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"} - ) - devices = {light.name: light} - system.get_devices = AsyncMock(return_value=devices) + devices = await setup_integration(hass, config_entry, client) async def turn_off() -> None: - light.data["state"] = "0" + devices.light.data["state"] = "0" async def turn_on() -> None: - light.data["state"] = "1" + devices.light.data["state"] = "1" - light.turn_off = AsyncMock(side_effect=turn_off) - light.turn_on = AsyncMock(side_effect=turn_on) - - with ( - patch( - "homeassistant.components.iaqualink.AqualinkClient.login", - return_value=None, - ), - patch( - "homeassistant.components.iaqualink.AqualinkClient.get_systems", - return_value=systems, - ), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + devices.light.turn_off = AsyncMock(side_effect=turn_off) + devices.light.turn_on = AsyncMock(side_effect=turn_on) entity_ids = hass.states.async_entity_ids(LIGHT_DOMAIN) assert len(entity_ids) == 1 @@ -228,15 +186,25 @@ async def test_light_service_calls_update_entity_state( assert state.state == STATE_ON -async def test_setup_login_exception( - hass: HomeAssistant, config_entry: MockConfigEntry +@pytest.mark.parametrize( + "raised_exception", + [ + pytest.param(AqualinkServiceException, id="service"), + pytest.param(TimeoutError, id="timeout"), + pytest.param(httpx.HTTPError("boom"), id="http"), + ], +) +async def test_setup_login_retry_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + raised_exception: Exception | type[Exception], ) -> None: - """Test setup encountering a login exception.""" + """Test setup retries on connection-related login exceptions.""" config_entry.add_to_hass(hass) with patch( "homeassistant.components.iaqualink.AqualinkClient.login", - side_effect=AqualinkServiceException, + side_effect=raised_exception, ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -264,24 +232,18 @@ async def test_setup_login_unauthorized( assert flows[0]["context"]["source"] == SOURCE_REAUTH -async def test_setup_login_timeout( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> None: - """Test setup encountering a timeout while logging in.""" - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.iaqualink.AqualinkClient.login", - side_effect=TimeoutError, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.SETUP_RETRY - - +@pytest.mark.parametrize( + "raised_exception", + [ + pytest.param(AqualinkServiceException, id="service"), + pytest.param(TimeoutError, id="timeout"), + pytest.param(httpx.HTTPError("boom"), id="http"), + ], +) async def test_setup_systems_exception( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, + config_entry: MockConfigEntry, + raised_exception: Exception | type[Exception], ) -> None: """Test setup encountering an exception while retrieving systems.""" config_entry.add_to_hass(hass) @@ -293,7 +255,7 @@ async def test_setup_systems_exception( ), patch( "homeassistant.components.iaqualink.AqualinkClient.get_systems", - side_effect=AqualinkServiceException, + side_effect=raised_exception, ), ): await hass.config_entries.async_setup(config_entry.entry_id) @@ -385,15 +347,25 @@ async def test_setup_no_systems_recognized( assert config_entry.state is ConfigEntryState.SETUP_ERROR +@pytest.mark.parametrize( + "raised_exception", + [ + pytest.param(AqualinkServiceException, id="service"), + pytest.param(TimeoutError, id="timeout"), + pytest.param(httpx.HTTPError("boom"), id="http"), + ], +) async def test_setup_devices_exception( hass: HomeAssistant, config_entry: MockConfigEntry, client: AqualinkClient, + raised_exception: Exception | type[Exception], ) -> None: """Test setup encountering an exception while retrieving devices.""" config_entry.add_to_hass(hass) system = get_aqualink_system(client, cls=IaquaSystem) + system.online = True system.update = AsyncMock() systems = {system.serial: system} @@ -411,13 +383,51 @@ async def test_setup_devices_exception( "get_devices", ) as mock_get_devices, ): - mock_get_devices.side_effect = AqualinkServiceException + mock_get_devices.side_effect = raised_exception await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_setup_devices_unauthorized( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, +) -> None: + """Test setup encountering an unauthorized exception while retrieving devices.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client, cls=IaquaSystem) + system.online = True + system.update = AsyncMock() + systems = {system.serial: system} + + with ( + patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), + patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ), + patch.object( + system, + "get_devices", + side_effect=AqualinkServiceUnauthorizedException, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + + async def test_setup_all_good_no_recognized_devices( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -473,56 +483,7 @@ async def test_setup_all_good_all_device_types( entity_registry: er.EntityRegistry, ) -> None: """Test setup ending in one device of each type recognized.""" - config_entry.add_to_hass(hass) - - system = get_aqualink_system(client, cls=IaquaSystem) - system.online = True - system.update = AsyncMock() - systems = {system.serial: system} - - devices = [ - get_aqualink_device( - system, name="aux_1", cls=IaquaAuxSwitch, data={"state": "0"} - ), - get_aqualink_device( - system, name="freeze_protection", cls=IaquaBinarySensor, data={"state": "0"} - ), - get_aqualink_device( - system, name="aux_2", cls=IaquaLightSwitch, data={"state": "0"} - ), - get_aqualink_device(system, name="ph", cls=IaquaSensor, data={"state": "7.2"}), - get_aqualink_device( - system, name="pool_set_point", cls=IaquaThermostat, data={"state": "0"} - ), - ] - devices = {d.name: d for d in devices} - - pool_heater = get_aqualink_device( - system, name="pool_heater", cls=IaquaAuxSwitch, data={"state": "0"} - ) - pool_temp = get_aqualink_device( - system, name="pool_temp", cls=IaquaSensor, data={"state": "72"} - ) - system.devices = { - **{d.name: d for d in devices.values()}, - pool_heater.name: pool_heater, - pool_temp.name: pool_temp, - } - - system.get_devices = AsyncMock(return_value=devices) - - with ( - patch( - "homeassistant.components.iaqualink.AqualinkClient.login", - return_value=None, - ), - patch( - "homeassistant.components.iaqualink.AqualinkClient.get_systems", - return_value=systems, - ), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, config_entry, client) assert config_entry.state is ConfigEntryState.LOADED @@ -557,32 +518,8 @@ async def test_multiple_updates( freezer: FrozenDateTimeFactory, ) -> None: """Test all possible results of online status transition after update.""" - config_entry.add_to_hass(hass) - - system = get_aqualink_system(client, cls=IaquaSystem) - system.online = True - system.update = AsyncMock() - systems = {system.serial: system} - - light = get_aqualink_device( - system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"} - ) - devices = {light.name: light} - - system.get_devices = AsyncMock(return_value=devices) - - with ( - patch( - "homeassistant.components.iaqualink.AqualinkClient.login", - return_value=None, - ), - patch( - "homeassistant.components.iaqualink.AqualinkClient.get_systems", - return_value=systems, - ), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + devices = await setup_integration(hass, config_entry, client) + system = devices.system assert config_entry.state is ConfigEntryState.LOADED @@ -683,31 +620,7 @@ async def test_entity_assumed_and_available( freezer: FrozenDateTimeFactory, ) -> None: """Test assumed_state and_available properties for all values of online.""" - config_entry.add_to_hass(hass) - - system = get_aqualink_system(client, cls=IaquaSystem) - system.online = True - systems = {system.serial: system} - - light = get_aqualink_device( - system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"} - ) - devices = {light.name: light} - system.get_devices = AsyncMock(return_value=devices) - system.update = AsyncMock() - - with ( - patch( - "homeassistant.components.iaqualink.AqualinkClient.login", - return_value=None, - ), - patch( - "homeassistant.components.iaqualink.AqualinkClient.get_systems", - return_value=systems, - ), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + devices = await setup_integration(hass, config_entry, client) entity_ids = hass.states.async_entity_ids(LIGHT_DOMAIN) assert len(entity_ids) == 1 @@ -715,19 +628,19 @@ async def test_entity_assumed_and_available( name = entity_ids[0] # None means maybe. - light.system.online = None + devices.light.system.online = None await _advance_coordinator_time(hass, freezer) state = hass.states.get(name) assert state.state == STATE_UNAVAILABLE assert state.attributes.get(ATTR_ASSUMED_STATE) is True - light.system.online = False + devices.light.system.online = False await _advance_coordinator_time(hass, freezer) state = hass.states.get(name) assert state.state == STATE_UNAVAILABLE assert state.attributes.get(ATTR_ASSUMED_STATE) is True - light.system.online = True + devices.light.system.online = True await _advance_coordinator_time(hass, freezer) state = hass.states.get(name) assert state.state == STATE_ON @@ -741,34 +654,11 @@ async def test_system_refresh_unauthorized_triggers_reauth( freezer: FrozenDateTimeFactory, ) -> None: """Test an unauthorized refresh starts reauthentication.""" - config_entry.add_to_hass(hass) - - system = get_aqualink_system(client, cls=IaquaSystem) - system.online = True - system.update = AsyncMock() - systems = {system.serial: system} - - light = get_aqualink_device( - system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"} - ) - system.get_devices = AsyncMock(return_value={light.name: light}) - - with ( - patch( - "homeassistant.components.iaqualink.AqualinkClient.login", - return_value=None, - ), - patch( - "homeassistant.components.iaqualink.AqualinkClient.get_systems", - return_value=systems, - ), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + devices = await setup_integration(hass, config_entry, client) assert config_entry.state is ConfigEntryState.LOADED - system.update = AsyncMock(side_effect=AqualinkServiceUnauthorizedException) + devices.system.update = AsyncMock(side_effect=AqualinkServiceUnauthorizedException) await _advance_coordinator_time(hass, freezer) diff --git a/tests/components/iaqualink/test_light.py b/tests/components/iaqualink/test_light.py new file mode 100644 index 000000000000..540f334dc4a5 --- /dev/null +++ b/tests/components/iaqualink/test_light.py @@ -0,0 +1,281 @@ +"""Light platform tests for iAquaLink.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import httpx +from iaqualink.client import AqualinkClient +from iaqualink.exception import ( + AqualinkServiceException, + AqualinkServiceUnauthorizedException, +) +from iaqualink.systems.iaqua.device import ( + IaquaColorLightJC, + IaquaDimmableLight, + IaquaLightSwitch, +) +from iaqualink.systems.iaqua.system import IaquaSystem +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_EFFECT, + DOMAIN as LIGHT_DOMAIN, + ColorMode, + LightEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + assert_platform_setup, + get_aqualink_device, + get_aqualink_system, + setup_entry, +) + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all light entities are created correctly.""" + await assert_platform_setup( + hass, config_entry, client, entity_registry, snapshot, LIGHT_DOMAIN + ) + + +async def _setup_light( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + cls: type, + data: dict[str, str], +) -> tuple[IaquaSystem, object, str, object]: + """Set up the integration with a single light entity.""" + system = get_aqualink_system(client, cls=IaquaSystem) + system.online = True + system.update = AsyncMock() + light = get_aqualink_device(system, name="aux_1", cls=cls, data=data) + system.get_devices = AsyncMock(return_value={light.name: light}) + system.set_aux = AsyncMock() + system.set_light = AsyncMock() + + await setup_entry(hass, config_entry, system) + + entity_ids = hass.states.async_entity_ids(LIGHT_DOMAIN) + assert len(entity_ids) == 1 + entity_id = entity_ids[0] + entity_state = hass.states.get(entity_id) + assert entity_state is not None + return system, light, entity_id, entity_state + + +async def test_effect_light_setup_exposes_effect_features( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, +) -> None: + """Test color lights expose effect-related state through Home Assistant.""" + _, _, _, entity_state = await _setup_light( + hass, + config_entry, + client, + IaquaColorLightJC, + {"state": "1", "aux": "1", "subtype": "1", "label": "pool light"}, + ) + + assert entity_state.state == STATE_ON + assert entity_state.attributes["color_mode"] == ColorMode.ONOFF.value + assert entity_state.attributes["supported_features"] == LightEntityFeature.EFFECT + assert "Alpine White" in entity_state.attributes["effect_list"] + + +async def test_dimmable_light_setup_exposes_brightness( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, +) -> None: + """Test dimmable lights expose brightness through Home Assistant.""" + _, _, _, entity_state = await _setup_light( + hass, + config_entry, + client, + IaquaDimmableLight, + {"state": "1", "aux": "1", "subtype": "50", "label": "pool light"}, + ) + + assert entity_state.state == STATE_ON + assert entity_state.attributes["color_mode"] == ColorMode.BRIGHTNESS.value + assert entity_state.attributes["brightness"] == 128 + + +async def test_light_turn_on_with_effect_updates_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, +) -> None: + """Test turning on a color light with an effect uses the effect branch.""" + system, light, entity_id, _ = await _setup_light( + hass, + config_entry, + client, + IaquaColorLightJC, + {"state": "0", "aux": "1", "subtype": "1", "label": "pool light"}, + ) + light_component = hass.data[LIGHT_DOMAIN] + entity = light_component.get_entity(entity_id) + assert entity is not None + + async def set_light(data: dict[str, str]) -> None: + light.data["state"] = data["light"] + + system.set_light = AsyncMock(side_effect=set_light) + + await entity.async_turn_on(**{ATTR_EFFECT: "Alpine White"}) + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + + +async def test_light_turn_on_with_brightness_updates_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, +) -> None: + """Test turning on a dimmable light updates Home Assistant state.""" + system, light, entity_id, _ = await _setup_light( + hass, + config_entry, + client, + IaquaDimmableLight, + {"state": "0", "aux": "1", "subtype": "0", "label": "pool light"}, + ) + + async def set_light(data: dict[str, str]) -> None: + light.data["state"] = "1" if data["light"] != "0" else "0" + light.data["subtype"] = data["light"] + + system.set_light = AsyncMock(side_effect=set_light) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + assert entity_state.attributes["brightness"] == 128 + + +async def test_light_turn_on_without_attributes_updates_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, +) -> None: + """Test turning on a basic light uses the default turn_on path.""" + system, light, entity_id, _ = await _setup_light( + hass, + config_entry, + client, + IaquaLightSwitch, + {"state": "0", "aux": "1", "label": "pool light"}, + ) + + async def set_aux(_: str) -> None: + light.data["state"] = "1" + + system.set_aux = AsyncMock(side_effect=set_aux) + light_component = hass.data[LIGHT_DOMAIN] + entity = light_component.get_entity(entity_id) + assert entity is not None + + await entity.async_turn_on() + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + + +@pytest.mark.parametrize( + ("raised_exception", "expected_exception", "match"), + [ + pytest.param( + AqualinkServiceException, + HomeAssistantError, + "Aqualink error: AqualinkServiceException", + id="service", + ), + pytest.param( + TimeoutError(), + HomeAssistantError, + "Aqualink error: TimeoutError", + id="timeout", + ), + pytest.param( + httpx.HTTPError("boom"), + HomeAssistantError, + "Aqualink error: boom", + id="http", + ), + pytest.param( + AqualinkServiceUnauthorizedException, + ConfigEntryAuthFailed, + "Invalid credentials for iAquaLink", + id="unauthorized", + ), + pytest.param( + Exception("Test exception"), + Exception, + "Test exception", + id="unexpected", + ), + ], +) +async def test_light_turn_off_errors_leave_state_unchanged( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + raised_exception: Exception | type[Exception], + expected_exception: type[Exception], + match: str, +) -> None: + """Test turn-off errors are surfaced through the light service call.""" + system, _, entity_id, _ = await _setup_light( + hass, + config_entry, + client, + IaquaLightSwitch, + {"state": "1", "aux": "1", "label": "pool light"}, + ) + system.set_aux = AsyncMock(side_effect=raised_exception) + + with pytest.raises(expected_exception, match=match): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON diff --git a/tests/components/iaqualink/test_sensor.py b/tests/components/iaqualink/test_sensor.py new file mode 100644 index 000000000000..650226168d4a --- /dev/null +++ b/tests/components/iaqualink/test_sensor.py @@ -0,0 +1,148 @@ +"""Sensor platform tests for iAquaLink.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from iaqualink.client import AqualinkClient +from iaqualink.systems.iaqua.device import IaquaSensor +from iaqualink.systems.iaqua.system import IaquaSystem +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.const import STATE_UNKNOWN, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from .conftest import ( + assert_platform_setup, + get_aqualink_device, + get_aqualink_system, + setup_entry, +) + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all sensor entities are created correctly.""" + await assert_platform_setup( + hass, config_entry, client, entity_registry, snapshot, SENSOR_DOMAIN + ) + + +async def _setup_sensor( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + *, + name: str, + state: str, + temp_unit: str = "F", +) -> tuple[str, object]: + """Set up the integration with a single sensor entity.""" + system = get_aqualink_system( + client, + cls=IaquaSystem, + data={"home_screen": [{}, {}, {}, {"temp_scale": temp_unit}]}, + ) + system.online = True + + async def update() -> None: + system.temp_unit = temp_unit + + system.update = AsyncMock(side_effect=update) + sensor = get_aqualink_device( + system, name=name, cls=IaquaSensor, data={"state": state} + ) + system.get_devices = AsyncMock(return_value={sensor.name: sensor}) + + await setup_entry(hass, config_entry, system) + + entity_ids = hass.states.async_entity_ids(SENSOR_DOMAIN) + assert len(entity_ids) == 1 + entity_id = entity_ids[0] + entity_state = hass.states.get(entity_id) + assert entity_state is not None + return entity_id, entity_state + + +@pytest.mark.parametrize( + ("name", "temp_unit", "expected_class", "expected_unit"), + [ + pytest.param( + "pool_temp", + "F", + SensorDeviceClass.TEMPERATURE, + UnitOfTemperature.FAHRENHEIT, + id="fahrenheit-temperature", + ), + pytest.param( + "spa_temp", + "C", + SensorDeviceClass.TEMPERATURE, + UnitOfTemperature.CELSIUS, + id="celsius-temperature", + ), + pytest.param("ph", "F", None, None, id="non-temperature"), + ], +) +async def test_sensor_initialization( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + name: str, + temp_unit: str, + expected_class: SensorDeviceClass | None, + expected_unit: UnitOfTemperature | None, +) -> None: + """Test sensor initialization for temperature and generic sensors.""" + if expected_unit is UnitOfTemperature.FAHRENHEIT: + hass.config.units = US_CUSTOMARY_SYSTEM + + _, entity_state = await _setup_sensor( + hass, + config_entry, + client, + name=name, + temp_unit=temp_unit, + state="72", + ) + + assert entity_state.attributes.get("device_class") == expected_class + assert entity_state.attributes.get("unit_of_measurement") == expected_unit + + +@pytest.mark.parametrize( + ("state", "expected_state"), + [ + pytest.param("", STATE_UNKNOWN, id="empty"), + pytest.param("72", "72", id="int"), + pytest.param("7.2", "7.2", id="float"), + ], +) +async def test_sensor_native_value( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + state: str, + expected_state: str, +) -> None: + """Test sensor state parsing.""" + _, entity_state = await _setup_sensor( + hass, + config_entry, + client, + name="ph", + state=state, + ) + + assert entity_state.state == expected_state diff --git a/tests/components/iaqualink/test_switch.py b/tests/components/iaqualink/test_switch.py new file mode 100644 index 000000000000..e58ff41af516 --- /dev/null +++ b/tests/components/iaqualink/test_switch.py @@ -0,0 +1,214 @@ +"""Switch platform tests for iAquaLink.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import httpx +from iaqualink.client import AqualinkClient +from iaqualink.exception import ( + AqualinkServiceException, + AqualinkServiceUnauthorizedException, +) +from iaqualink.systems.iaqua.device import IaquaAuxSwitch +from iaqualink.systems.iaqua.system import IaquaSystem +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + assert_platform_setup, + get_aqualink_device, + get_aqualink_system, + setup_entry, +) + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all switch entities are created correctly.""" + await assert_platform_setup( + hass, config_entry, client, entity_registry, snapshot, SWITCH_DOMAIN + ) + + +async def _setup_switch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + *, + label: str, + state: str, +) -> tuple[IaquaSystem, object, str, object]: + """Set up the integration with a single switch entity.""" + system = get_aqualink_system(client, cls=IaquaSystem) + system.online = True + system.update = AsyncMock() + switch = get_aqualink_device( + system, + name=label.lower().replace(" ", "_"), + cls=IaquaAuxSwitch, + data={"state": state, "aux": "1", "label": label}, + ) + system.get_devices = AsyncMock(return_value={switch.name: switch}) + system.set_aux = AsyncMock() + + await setup_entry(hass, config_entry, system) + + entity_ids = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entity_ids) == 1 + entity_id = entity_ids[0] + entity_state = hass.states.get(entity_id) + assert entity_state is not None + return system, switch, entity_id, entity_state + + +@pytest.mark.parametrize( + ("label", "expected_icon"), + [ + pytest.param("Cleaner", "mdi:robot-vacuum", id="cleaner"), + pytest.param("Waterfall", "mdi:fountain", id="waterfall"), + pytest.param("Spa Dscnt", "mdi:fountain", id="descent"), + pytest.param("Filter Pump", "mdi:fan", id="pump"), + pytest.param("Spa Blower", "mdi:fan", id="blower"), + pytest.param("Pool Heater", "mdi:radiator", id="heater"), + pytest.param("Auxiliary", None, id="default"), + ], +) +async def test_switch_icons( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + label: str, + expected_icon: str | None, +) -> None: + """Test switch icons are derived from the device label.""" + _, _, _, entity_state = await _setup_switch( + hass, + config_entry, + client, + label=label, + state="1", + ) + + assert entity_state.attributes.get("icon") == expected_icon + assert entity_state.state == STATE_ON + + +@pytest.mark.parametrize( + ("service", "initial_state", "expected_state"), + [ + pytest.param(SERVICE_TURN_ON, "0", STATE_ON, id="turn-on"), + pytest.param(SERVICE_TURN_OFF, "1", STATE_OFF, id="turn-off"), + ], +) +async def test_switch_actions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + service: str, + initial_state: str, + expected_state: str, +) -> None: + """Test switch services update Home Assistant state.""" + system, switch, entity_id, _ = await _setup_switch( + hass, + config_entry, + client, + label="Auxiliary", + state=initial_state, + ) + + async def set_aux(_: str) -> None: + switch.data["state"] = "1" if service == SERVICE_TURN_ON else "0" + + system.set_aux = AsyncMock(side_effect=set_aux) + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == expected_state + + +@pytest.mark.parametrize( + ("raised_exception", "expected_exception", "match"), + [ + pytest.param( + AqualinkServiceException, + HomeAssistantError, + "Aqualink error: AqualinkServiceException", + id="service", + ), + pytest.param( + TimeoutError(), + HomeAssistantError, + "Aqualink error: TimeoutError", + id="timeout", + ), + pytest.param( + httpx.HTTPError("boom"), + HomeAssistantError, + "Aqualink error: boom", + id="http", + ), + pytest.param( + AqualinkServiceUnauthorizedException, + ConfigEntryAuthFailed, + "Invalid credentials for iAquaLink", + id="unauthorized", + ), + ], +) +async def test_switch_turn_off_errors_leave_state_unchanged( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + raised_exception: Exception | type[Exception], + expected_exception: type[Exception], + match: str, +) -> None: + """Test turn-off errors are surfaced through the switch service call.""" + system, _, entity_id, _ = await _setup_switch( + hass, + config_entry, + client, + label="Auxiliary", + state="1", + ) + system.set_aux = AsyncMock(side_effect=raised_exception) + + with pytest.raises(expected_exception, match=match): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON diff --git a/tests/components/iaqualink/test_utils.py b/tests/components/iaqualink/test_utils.py deleted file mode 100644 index fed3497caba8..000000000000 --- a/tests/components/iaqualink/test_utils.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Tests for iAquaLink integration utility functions.""" - -from iaqualink.exception import AqualinkServiceException -import pytest - -from homeassistant.components.iaqualink.utils import await_or_reraise -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError - -from .conftest import async_raises, async_returns - - -async def test_await_or_reraise(hass: HomeAssistant) -> None: - """Test await_or_reraise for all values of awaitable.""" - async_noop = async_returns(None) - await await_or_reraise(async_noop()) - - with pytest.raises(Exception) as exc_info: - await await_or_reraise(async_raises(Exception("Test exception"))()) - assert str(exc_info.value) == "Test exception" - - async_ex = async_raises(AqualinkServiceException) - with pytest.raises(HomeAssistantError): - await await_or_reraise(async_ex())