From eff024d69113de9755b2c0dd85a62fe52d84d25b Mon Sep 17 00:00:00 2001 From: Matthew FitzGerald-Chamberlain Date: Thu, 22 Jun 2023 00:13:13 -0500 Subject: [PATCH 1/5] Add Aprilaire integration --- CODEOWNERS | 2 + .../components/aprilaire/__init__.py | 71 ++ homeassistant/components/aprilaire/climate.py | 363 ++++++++ .../components/aprilaire/config_flow.py | 78 ++ homeassistant/components/aprilaire/const.py | 6 + .../components/aprilaire/coordinator.py | 176 ++++ homeassistant/components/aprilaire/entity.py | 85 ++ .../components/aprilaire/manifest.json | 12 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/aprilaire/__init__.py | 1 + tests/components/aprilaire/conftest.py | 15 + tests/components/aprilaire/test_climate.py | 832 ++++++++++++++++++ .../components/aprilaire/test_config_flow.py | 210 +++++ .../components/aprilaire/test_coordinator.py | 231 +++++ tests/components/aprilaire/test_entity.py | 181 ++++ tests/components/aprilaire/test_init.py | 251 ++++++ 19 files changed, 2527 insertions(+) create mode 100644 homeassistant/components/aprilaire/__init__.py create mode 100644 homeassistant/components/aprilaire/climate.py create mode 100644 homeassistant/components/aprilaire/config_flow.py create mode 100644 homeassistant/components/aprilaire/const.py create mode 100644 homeassistant/components/aprilaire/coordinator.py create mode 100644 homeassistant/components/aprilaire/entity.py create mode 100644 homeassistant/components/aprilaire/manifest.json create mode 100644 tests/components/aprilaire/__init__.py create mode 100644 tests/components/aprilaire/conftest.py create mode 100644 tests/components/aprilaire/test_climate.py create mode 100644 tests/components/aprilaire/test_config_flow.py create mode 100644 tests/components/aprilaire/test_coordinator.py create mode 100644 tests/components/aprilaire/test_entity.py create mode 100644 tests/components/aprilaire/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 7e09c3c8147157..c1c8bb849ed1b5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -100,6 +100,8 @@ build.json @home-assistant/supervisor /tests/components/application_credentials/ @home-assistant/core /homeassistant/components/apprise/ @caronc /tests/components/apprise/ @caronc +/homeassistant/components/aprilaire/ @chamberlain2007 +/tests/components/aprilaire/ @chamberlain2007 /homeassistant/components/aprs/ @PhilRW /tests/components/aprs/ @PhilRW /homeassistant/components/aranet/ @aschmitz diff --git a/homeassistant/components/aprilaire/__init__.py b/homeassistant/components/aprilaire/__init__.py new file mode 100644 index 00000000000000..1fd8880614b9ef --- /dev/null +++ b/homeassistant/components/aprilaire/__init__.py @@ -0,0 +1,71 @@ +"""The Aprilaire integration.""" + +from __future__ import annotations + +import logging +from typing import cast + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant + +from .const import DOMAIN, LOG_NAME +from .coordinator import AprilaireCoordinator + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, **kwargs) -> bool: + """Set up a config entry for Aprilaire.""" + + logger = cast(logging.Logger, kwargs.get("logger")) + + if not logger: # pragma: no cover + logger = logging.getLogger(LOG_NAME) # pragma: no cover + + config = entry.data + + host = config.get("host") + if host is None or len(host) == 0: + logger.error("Invalid host %s", host) + return False + + port = config.get("port") + if port is None or port <= 0: + logger.error("Invalid port %s", port) + return False + + coordinator = AprilaireCoordinator(hass, host, port, logger) + await coordinator.start_listen() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + async def ready_callback(ready: bool): + if ready: + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def _async_close(_: Event) -> None: + coordinator.stop_listen() # pragma: no cover + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close) + ) + else: + logger.error("Failed to wait for ready") + + coordinator.stop_listen() + + await coordinator.wait_for_ready(ready_callback) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + coordinator: AprilaireCoordinator = hass.data[DOMAIN].pop(entry.entry_id) + coordinator.stop_listen() + + return unload_ok diff --git a/homeassistant/components/aprilaire/climate.py b/homeassistant/components/aprilaire/climate.py new file mode 100644 index 00000000000000..3197ea1257fc54 --- /dev/null +++ b/homeassistant/components/aprilaire/climate.py @@ -0,0 +1,363 @@ +"""The Aprilaire climate component.""" + +from __future__ import annotations + +from collections.abc import Mapping +from enum import IntFlag +from typing import Any + +from pyaprilaire.const import Attribute + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_ON, + PRESET_AWAY, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PRECISION_HALVES, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AprilaireCoordinator +from .entity import BaseAprilaireEntity + +FAN_CIRCULATE = "Circulate" + +PRESET_TEMPORARY_HOLD = "Temporary" +PRESET_PERMANENT_HOLD = "Permanent" +PRESET_VACATION = "Vacation" + +HVAC_MODE_MAP = { + 1: HVACMode.OFF, + 2: HVACMode.HEAT, + 3: HVACMode.COOL, + 4: HVACMode.HEAT, + 5: HVACMode.AUTO, +} + +HVAC_MODES_MAP = { + 1: [HVACMode.OFF, HVACMode.HEAT], + 2: [HVACMode.OFF, HVACMode.COOL], + 3: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL], + 4: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL], + 5: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO], + 6: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO], +} + +PRESET_MODE_MAP = { + 1: PRESET_TEMPORARY_HOLD, + 2: PRESET_PERMANENT_HOLD, + 3: PRESET_AWAY, + 4: PRESET_VACATION, +} + +FAN_MODE_MAP = { + 1: FAN_ON, + 2: FAN_AUTO, + 3: FAN_CIRCULATE, +} + + +class ExtendedClimateEntityFeature(IntFlag): + """Supported features of the Aprilaire climate entity.""" + + TARGET_DEHUMIDITY = 2 << 10 + FRESH_AIR = 2 << 11 + AIR_CLEANING = 2 << 12 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add climates for passed config_entry in HA.""" + + coordinator: AprilaireCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities([AprilaireClimate(coordinator)]) + + +class AprilaireClimate(BaseAprilaireEntity, ClimateEntity): + """Climate entity for Aprilaire.""" + + _attr_fan_modes = [FAN_AUTO, FAN_ON, FAN_CIRCULATE] + _attr_min_humidity = 10 + _attr_max_humidity = 50 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + @property + def precision(self) -> float: + """Get the precision based on the unit.""" + return ( + PRECISION_HALVES + if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS + else PRECISION_WHOLE + ) + + @property + def name(self) -> str | None: + """Get name of entity.""" + return "Thermostat" + + @property + def supported_features(self) -> ClimateEntityFeature: + """Get supported features.""" + features = 0 + + if Attribute.MODE not in self._coordinator.data: + features = features | ClimateEntityFeature.TARGET_TEMPERATURE + else: + if self._coordinator.data.get(Attribute.MODE) == 5: + features = features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + else: + features = features | ClimateEntityFeature.TARGET_TEMPERATURE + + if self._coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) == 2: + features = features | ClimateEntityFeature.TARGET_HUMIDITY + + if self._coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) == 1: + features = features | ExtendedClimateEntityFeature.TARGET_DEHUMIDITY + + if self._coordinator.data.get(Attribute.AIR_CLEANING_AVAILABLE) == 1: + features = features | ExtendedClimateEntityFeature.AIR_CLEANING + + if self._coordinator.data.get(Attribute.VENTILATION_AVAILABLE) == 1: + features = features | ExtendedClimateEntityFeature.FRESH_AIR + + features = features | ClimateEntityFeature.PRESET_MODE + + features = features | ClimateEntityFeature.FAN_MODE + + return features + + @property + def current_humidity(self) -> int | None: + """Get current humidity.""" + return self._coordinator.data.get( + Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE + ) + + @property + def target_humidity(self) -> int | None: + """Get current target humidity.""" + return self._coordinator.data.get(Attribute.HUMIDIFICATION_SETPOINT) + + @property + def hvac_mode(self) -> HVACMode | None: + """Get HVAC mode.""" + + if mode := self._coordinator.data.get(Attribute.MODE): + if hvac_mode := HVAC_MODE_MAP.get(mode): + return hvac_mode + + return None + + @property + def hvac_modes(self) -> list[HVACMode]: + """Get supported HVAC modes.""" + + if modes := self._coordinator.data.get(Attribute.THERMOSTAT_MODES): + if thermostat_modes := HVAC_MODES_MAP.get(modes): + return thermostat_modes + + return [] + + @property + def hvac_action(self) -> HVACAction | None: + """Get the current HVAC action.""" + + heating_equipment_status = self._coordinator.data.get( + Attribute.HEATING_EQUIPMENT_STATUS, 0 + ) + + if heating_equipment_status > 0: + return HVACAction.HEATING + + cooling_equipment_status = self._coordinator.data.get( + Attribute.COOLING_EQUIPMENT_STATUS, 0 + ) + + if cooling_equipment_status > 0: + return HVACAction.COOLING + + return HVACAction.IDLE + + @property + def current_temperature(self) -> float | None: + """Get current temperature.""" + return self._coordinator.data.get( + Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_VALUE + ) + + @property + def target_temperature(self) -> float | None: + """Get the target temperature.""" + + hvac_mode = self.hvac_mode + + if hvac_mode == HVACMode.COOL: + return self.target_temperature_high + if hvac_mode == HVACMode.HEAT: + return self.target_temperature_low + + return None + + @property + def target_temperature_step(self) -> float | None: + """Get the step for the target temperature based on the unit.""" + return ( + PRECISION_HALVES + if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS + else PRECISION_WHOLE + ) + + @property + def target_temperature_high(self) -> float | None: + """Get cool setpoint.""" + return self._coordinator.data.get(Attribute.COOL_SETPOINT) + + @property + def target_temperature_low(self) -> float | None: + """Get heat setpoint.""" + return self._coordinator.data.get(Attribute.HEAT_SETPOINT) + + @property + def preset_mode(self) -> str | None: + """Get the current preset mode.""" + if hold := self._coordinator.data.get(Attribute.HOLD): + if preset_mode := PRESET_MODE_MAP.get(hold): + return preset_mode + + return PRESET_NONE + + @property + def preset_modes(self) -> list[str] | None: + """Get the supported preset modes.""" + presets = [PRESET_NONE, PRESET_VACATION] + + if self._coordinator.data.get(Attribute.AWAY_AVAILABLE) == 1: + presets.append(PRESET_AWAY) + + hold = self._coordinator.data.get(Attribute.HOLD, 0) + + if hold == 1: + presets.append(PRESET_TEMPORARY_HOLD) + elif hold == 2: + presets.append(PRESET_PERMANENT_HOLD) + + return presets + + @property + def fan_mode(self) -> str | None: + """Get fan mode.""" + + if mode := self._coordinator.data.get(Attribute.FAN_MODE): + if fan_mode := FAN_MODE_MAP.get(mode): + return fan_mode + + return None + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return device specific state attributes.""" + return { + "fan_status": "on" + if self._coordinator.data.get(Attribute.FAN_STATUS, 0) == 1 + else "off", + "humidification_setpoint": self._coordinator.data.get( + Attribute.HUMIDIFICATION_SETPOINT + ), + "dehumidification_setpoint": self._coordinator.data.get( + Attribute.DEHUMIDIFICATION_SETPOINT + ), + "air_cleaning_mode": {1: "constant", 2: "automatic"}.get( + self._coordinator.data.get(Attribute.AIR_CLEANING_MODE, 0), "off" + ), + "air_cleaning_event": {3: "3hour", 4: "24hour"}.get( + self._coordinator.data.get(Attribute.AIR_CLEANING_EVENT, 0), "off" + ), + "fresh_air_mode": {1: "automatic"}.get( + self._coordinator.data.get(Attribute.FRESH_AIR_MODE, 0), "off" + ), + "fresh_air_event": {2: "3hour", 3: "24hour"}.get( + self._coordinator.data.get(Attribute.FRESH_AIR_EVENT, 0), "off" + ), + } + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + + cool_setpoint = 0 + heat_setpoint = 0 + + if temperature := kwargs.get("temperature"): + if self._coordinator.data.get(Attribute.MODE) == 3: + cool_setpoint = temperature + else: + heat_setpoint = temperature + else: + if target_temp_low := kwargs.get("target_temp_low"): + heat_setpoint = target_temp_low + if target_temp_high := kwargs.get("target_temp_high"): + cool_setpoint = target_temp_high + + if cool_setpoint == 0 and heat_setpoint == 0: + return + + await self._coordinator.client.update_setpoint(cool_setpoint, heat_setpoint) + + await self._coordinator.client.read_control() + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidification setpoint.""" + + await self._coordinator.client.set_humidification_setpoint(humidity) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the fan mode.""" + + try: + fan_mode_value_index = list(FAN_MODE_MAP.values()).index(fan_mode) + except ValueError as exc: + raise ValueError(f"Unsupported fan mode {fan_mode}") from exc + + fan_mode_value = list(FAN_MODE_MAP.keys())[fan_mode_value_index] + + await self._coordinator.client.update_fan_mode(fan_mode_value) + + await self._coordinator.client.read_control() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + + try: + mode_value_index = list(HVAC_MODE_MAP.values()).index(hvac_mode) + except ValueError as exc: + raise ValueError(f"Unsupported HVAC mode {hvac_mode}") from exc + + mode_value = list(HVAC_MODE_MAP.keys())[mode_value_index] + + await self._coordinator.client.update_mode(mode_value) + + await self._coordinator.client.read_control() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode.""" + + if preset_mode == PRESET_AWAY: + await self._coordinator.client.set_hold(3) + elif preset_mode == PRESET_VACATION: + await self._coordinator.client.set_hold(4) + elif preset_mode == PRESET_NONE: + await self._coordinator.client.set_hold(0) + else: + raise ValueError(f"Unsupported preset mode {preset_mode}") + + await self._coordinator.client.read_scheduling() diff --git a/homeassistant/components/aprilaire/config_flow.py b/homeassistant/components/aprilaire/config_flow.py new file mode 100644 index 00000000000000..7485265ec68a92 --- /dev/null +++ b/homeassistant/components/aprilaire/config_flow.py @@ -0,0 +1,78 @@ +"""Config flow for the Aprilaire integration.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import pyaprilaire.client +from pyaprilaire.const import Attribute, FunctionalDomain +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import AbortFlow, FlowResult + +from .const import DOMAIN, LOG_NAME + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=7000): int, + } +) + +_LOGGER = logging.getLogger(LOG_NAME) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Aprilaire.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + await self.async_set_unique_id( + f'aprilaire_{user_input[CONF_HOST].replace(".", "")}{user_input[CONF_PORT]}' + ) + self._abort_if_unique_id_configured() + except AbortFlow as err: + errors["base"] = err.reason + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = str(err) + else: + client = pyaprilaire.client.AprilaireClient( + user_input[CONF_HOST], user_input[CONF_PORT], lambda data: None, _LOGGER + ) + + await client.start_listen() + + data = await client.wait_for_response( + FunctionalDomain.IDENTIFICATION, 2, 30 + ) + + client.stop_listen() + + if data and Attribute.MAC_ADDRESS in data: + # Sleeping to not overload the socket + await asyncio.sleep(5) + + return self.async_create_entry(title="Aprilaire", data=user_input) + + errors["base"] = "connection_failed" + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/aprilaire/const.py b/homeassistant/components/aprilaire/const.py new file mode 100644 index 00000000000000..5ae6d334cd91d2 --- /dev/null +++ b/homeassistant/components/aprilaire/const.py @@ -0,0 +1,6 @@ +"""Constants for the Aprilaire integration.""" + +from __future__ import annotations + +DOMAIN = "aprilaire" +LOG_NAME = "aprilaire" diff --git a/homeassistant/components/aprilaire/coordinator.py b/homeassistant/components/aprilaire/coordinator.py new file mode 100644 index 00000000000000..b308f154c28aef --- /dev/null +++ b/homeassistant/components/aprilaire/coordinator.py @@ -0,0 +1,176 @@ +"""The Aprilaire coordinator.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from logging import Logger +from typing import Any + +import pyaprilaire.client +from pyaprilaire.const import MODELS, Attribute, FunctionalDomain + +from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +RECONNECT_INTERVAL = 60 * 60 +RETRY_CONNECTION_INTERVAL = 10 + + +class AprilaireCoordinator(DataUpdateCoordinator): + """Coordinator for interacting with the thermostat.""" + + def __init__( + self, hass: HomeAssistant, host: str, port: int, logger: Logger + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + logger, + name=DOMAIN, + ) + + self.data: dict[str, Any] = {} + + self.client = pyaprilaire.client.AprilaireClient( + host, + port, + self.async_set_updated_data, + self.logger, + RECONNECT_INTERVAL, + RETRY_CONNECTION_INTERVAL, + ) + + def async_set_updated_data(self, data: Any) -> None: + """Manually update data, notify listeners and reset refresh interval.""" + + old_device_info = self.create_device_info(self.data) + + if self.data is not None: + data = self.data | data + + super().async_set_updated_data(data) + + new_device_info = self.create_device_info(data) + + if ( + old_device_info is not None + and new_device_info is not None + and old_device_info != new_device_info + ): + device_registry = dr.async_get(self.hass) + + device = device_registry.async_get_device(old_device_info["identifiers"]) + + if device is not None: + new_device_info.pop("identifiers") + new_device_info.pop("connections") + + device_registry.async_update_device( + device_id=device.id, **new_device_info # type: ignore[misc] + ) + + async def start_listen(self): + """Start listening for data.""" + await self.client.start_listen() + + def stop_listen(self): + """Stop listening for data.""" + self.client.stop_listen() + + async def wait_for_ready(self, ready_callback: Callable[[bool], Awaitable[None]]): + """Wait for the client to be ready.""" + + if not self.data or Attribute.MAC_ADDRESS not in self.data: + data = await self.client.wait_for_response( + FunctionalDomain.IDENTIFICATION, 2, 30 + ) + + if not data or Attribute.MAC_ADDRESS not in data: + self.logger.error("Missing MAC address, cannot create unique ID") + await ready_callback(False) + + return + + if not self.data or Attribute.NAME not in self.data: + await self.client.wait_for_response(FunctionalDomain.IDENTIFICATION, 4, 30) + + if not self.data or Attribute.THERMOSTAT_MODES not in self.data: + await self.client.wait_for_response(FunctionalDomain.CONTROL, 7, 30) + + if ( + not self.data + or Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_STATUS not in self.data + ): + await self.client.wait_for_response(FunctionalDomain.SENSORS, 2, 30) + + await ready_callback(True) + + @property + def device_name(self) -> str: + """Get the name of the thermostat.""" + + return self.create_device_name(self.data) + + def create_device_name(self, data: dict[str, Any]) -> str: + """Create the name of the thermostat.""" + + name = data.get(Attribute.NAME) + + if name is None or len(name) == 0: + return "Aprilaire" + + return name + + def get_hw_version(self, data: dict[str, Any]) -> str: + """Get the hardware version.""" + + if hardware_revision := data.get(Attribute.HARDWARE_REVISION): + return ( + f"Rev. {chr(hardware_revision)}" + if hardware_revision > ord("A") + else str(hardware_revision) + ) + + return "Unknown" + + @property + def device_info(self) -> DeviceInfo | None: + """Get the device info for the thermostat.""" + return self.create_device_info(self.data) + + def create_device_info(self, data: dict[str, Any]) -> DeviceInfo | None: + """Create the device info for the thermostat.""" + + if Attribute.MAC_ADDRESS not in data: + return None + + device_info = DeviceInfo( + identifiers={(DOMAIN, data[Attribute.MAC_ADDRESS])}, + name=self.create_device_name(data), + manufacturer="Aprilaire", + ) + + model_number = data.get(Attribute.MODEL_NUMBER) + if model_number is not None: + device_info["model"] = ( + MODELS[model_number] + if model_number in MODELS + else f"Unknown ({model_number})" + ) + + device_info["hw_version"] = self.get_hw_version(data) + + firmware_major_revision = data.get(Attribute.FIRMWARE_MAJOR_REVISION) + firmware_minor_revision = data.get(Attribute.FIRMWARE_MINOR_REVISION) + if firmware_major_revision is not None: + device_info["sw_version"] = ( + str(firmware_major_revision) + if firmware_minor_revision is None + else f"{firmware_major_revision}.{firmware_minor_revision:02}" + ) + + return device_info diff --git a/homeassistant/components/aprilaire/entity.py b/homeassistant/components/aprilaire/entity.py new file mode 100644 index 00000000000000..a43905e6564d32 --- /dev/null +++ b/homeassistant/components/aprilaire/entity.py @@ -0,0 +1,85 @@ +"""Base functionality for Aprilaire entities.""" + +from __future__ import annotations + +from pyaprilaire.const import Attribute + +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify + +from .coordinator import AprilaireCoordinator + + +class BaseAprilaireEntity(CoordinatorEntity, Entity): + """Base for Aprilaire entities.""" + + _attr_available = False + _attr_has_entity_name = True + + def __init__(self, coordinator: AprilaireCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._coordinator = coordinator + + self._update_available() + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + self._coordinator.logger.debug("Current data: %s", self._coordinator.data) + + self._update_available() + + self.async_write_ha_state() + + def _update_available(self): + """Update the entity availability.""" + + connected: bool = self._coordinator.data.get( + Attribute.CONNECTED, None + ) or self._coordinator.data.get(Attribute.RECONNECTING, None) + + stopped: bool = self._coordinator.data.get(Attribute.STOPPED, None) + + if stopped: + self._attr_available = False + elif not connected: + self._attr_available = False + else: + self._attr_available = ( + self._coordinator.data.get(Attribute.MAC_ADDRESS, None) is not None + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._attr_available + + @property + def should_poll(self) -> bool: + """Do not need to poll.""" + return False + + @property + def unique_id(self) -> str | None: + """Return a unique ID.""" + return slugify( + self._coordinator.data[Attribute.MAC_ADDRESS].replace(":", "_") + + "_" + + self.name + ) + + @property + def device_info(self) -> DeviceInfo | None: + """Return device specific attributes.""" + + return self._coordinator.device_info + + @property + def extra_state_attributes(self): + """Return device specific state attributes.""" + return { + "device_name": self._coordinator.device_name, + "device_location": self._coordinator.data.get(Attribute.LOCATION), + } diff --git a/homeassistant/components/aprilaire/manifest.json b/homeassistant/components/aprilaire/manifest.json new file mode 100644 index 00000000000000..c1d0b6e5996754 --- /dev/null +++ b/homeassistant/components/aprilaire/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "aprilaire", + "name": "Aprilaire", + "codeowners": ["@chamberlain2007"], + "config_flow": true, + "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/aprilaire", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["pyaprilaire"], + "requirements": ["pyaprilaire==0.7.0"] +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d8ffa25b765da0..e236a19d138837 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -45,6 +45,7 @@ "anthemav", "apcupsd", "apple_tv", + "aprilaire", "aranet", "arcam_fmj", "aseko_pool_live", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 314e8ffa092f05..62622b9e935e72 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -344,6 +344,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "aprilaire": { + "name": "Aprilaire", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "aprs": { "name": "APRS", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index c87482c2fef635..272face84fa300 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1560,6 +1560,9 @@ pyairnow==1.2.1 # homeassistant.components.airvisual_pro pyairvisual==2022.12.1 +# homeassistant.components.aprilaire +pyaprilaire==0.7.0 + # homeassistant.components.atag pyatag==0.3.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ee2ae9f67c5244..964430541ffc76 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1163,6 +1163,9 @@ pyairnow==1.2.1 # homeassistant.components.airvisual_pro pyairvisual==2022.12.1 +# homeassistant.components.aprilaire +pyaprilaire==0.7.0 + # homeassistant.components.atag pyatag==0.3.5.3 diff --git a/tests/components/aprilaire/__init__.py b/tests/components/aprilaire/__init__.py new file mode 100644 index 00000000000000..0ebf7fc304c7a6 --- /dev/null +++ b/tests/components/aprilaire/__init__.py @@ -0,0 +1 @@ +"""Tests for Aprilaire.""" diff --git a/tests/components/aprilaire/conftest.py b/tests/components/aprilaire/conftest.py new file mode 100644 index 00000000000000..35ea5bee3d031b --- /dev/null +++ b/tests/components/aprilaire/conftest.py @@ -0,0 +1,15 @@ +"""Fixtures for Aprilaire integration.""" + +import asyncio + +import pytest + + +@pytest.fixture(autouse=True) +def verify_cleanup(event_loop: asyncio.AbstractEventLoop): + """Verify that the test has cleaned up resources correctly.""" + tasks_before = asyncio.all_tasks(event_loop) + yield + tasks = asyncio.all_tasks(event_loop) - tasks_before + if tasks: + event_loop.run_until_complete(asyncio.wait(tasks)) diff --git a/tests/components/aprilaire/test_climate.py b/tests/components/aprilaire/test_climate.py new file mode 100644 index 00000000000000..41892e68d2603f --- /dev/null +++ b/tests/components/aprilaire/test_climate.py @@ -0,0 +1,832 @@ +"""Tests for the Aprilaire climate entity.""" + +import logging +from unittest.mock import AsyncMock, Mock, PropertyMock, patch + +from pyaprilaire.client import AprilaireClient +import pytest + +from homeassistant.components.aprilaire.climate import ( + FAN_CIRCULATE, + PRESET_PERMANENT_HOLD, + PRESET_TEMPORARY_HOLD, + PRESET_VACATION, + AprilaireClimate, + ExtendedClimateEntityFeature, + async_setup_entry, +) +from homeassistant.components.aprilaire.const import DOMAIN +from homeassistant.components.aprilaire.coordinator import AprilaireCoordinator +from homeassistant.components.climate import ( + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + FAN_AUTO, + FAN_ON, + PRESET_AWAY, + PRESET_NONE, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntries, ConfigEntry +from homeassistant.core import Config, EventBus, HomeAssistant +from homeassistant.util import uuid as uuid_util +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM + + +@pytest.fixture +def logger() -> logging.Logger: + """Return a logger.""" + logger = logging.getLogger() + logger.propagate = False + + return logger + + +@pytest.fixture +def client() -> AprilaireClient: + """Return a mock client.""" + return AsyncMock(AprilaireClient) + + +@pytest.fixture +def coordinator( + client: AprilaireClient, logger: logging.Logger +) -> AprilaireCoordinator: + """Return a mock coordinator.""" + coordinator_mock = AsyncMock(AprilaireCoordinator) + coordinator_mock.data = {} + coordinator_mock.client = client + coordinator_mock.logger = logger + + return coordinator_mock + + +@pytest.fixture +def entry_id() -> str: + """Return a random ID.""" + return uuid_util.random_uuid_hex() + + +@pytest.fixture +def hass(coordinator: AprilaireCoordinator, entry_id: str) -> HomeAssistant: + """Return a mock HomeAssistant instance.""" + hass_mock = AsyncMock(HomeAssistant) + hass_mock.data = {DOMAIN: {entry_id: coordinator}} + hass_mock.config_entries = AsyncMock(ConfigEntries) + hass_mock.bus = AsyncMock(EventBus) + hass_mock.config = Mock(Config) + + return hass_mock + + +@pytest.fixture +def config_entry(entry_id: str) -> ConfigEntry: + """Return a mock config entry.""" + config_entry_mock = AsyncMock(ConfigEntry) + config_entry_mock.data = {"host": "test123", "port": 123} + config_entry_mock.entry_id = entry_id + + return config_entry_mock + + +@pytest.fixture +async def climate(config_entry: ConfigEntry, hass: HomeAssistant) -> AprilaireClimate: + """Return a climate instance.""" + async_add_entities_mock = Mock() + async_get_current_platform_mock = Mock() + + with patch( + "homeassistant.helpers.entity_platform.async_get_current_platform", + new=async_get_current_platform_mock, + ): + await async_setup_entry(hass, config_entry, async_add_entities_mock) + + sensors_list = async_add_entities_mock.call_args_list[0][0] + + climate = sensors_list[0][0] + climate._attr_available = True + climate.hass = hass + + return climate + + +def test_climate_entity_name(climate: AprilaireClimate) -> None: + """Test the entity name.""" + assert climate.entity_name == "Thermostat" + + +def test_climate_min_temp(climate: AprilaireClimate) -> None: + """Test the minimum temperature.""" + assert climate.min_temp == DEFAULT_MIN_TEMP + + +def test_climate_max_temp(climate: AprilaireClimate) -> None: + """Test the maximum temperature.""" + assert climate.max_temp == DEFAULT_MAX_TEMP + + +def test_climate_fan_modes(climate: AprilaireClimate) -> None: + """Test the supported fan modes.""" + assert climate.fan_modes == [FAN_AUTO, FAN_ON, FAN_CIRCULATE] + + +def test_climate_fan_mode( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the current fan mode.""" + assert climate.fan_mode is None + + coordinator.data = { + "fan_mode": 0, + } + + assert climate.fan_mode is None + + coordinator.data = { + "fan_mode": 1, + } + + assert climate.fan_mode == FAN_ON + + coordinator.data = { + "fan_mode": 2, + } + + assert climate.fan_mode == FAN_AUTO + + coordinator.data = { + "fan_mode": 3, + } + + assert climate.fan_mode == FAN_CIRCULATE + + +def test_supported_features_no_mode(climate: AprilaireClimate) -> None: + """Test the supported featured with no mode set.""" + assert ( + climate.supported_features + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + ) + + +def test_supported_features_mode_5( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the supported featured with mode 5.""" + coordinator.data = { + "mode": 5, + } + + assert ( + climate.supported_features + == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + ) + + +def test_supported_features_mode_4( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the supported featured with mode 4.""" + coordinator.data = { + "mode": 4, + } + + assert ( + climate.supported_features + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + ) + + +def test_supported_features_humidification_available( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the supported featured with humidification available.""" + coordinator.data = { + "humidification_available": 2, + } + + assert ( + climate.supported_features + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_HUMIDITY + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + ) + + +def test_supported_features_dehumidification_available( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the supported featured with dehumidification available.""" + coordinator.data = { + "dehumidification_available": 1, + } + + assert ( + climate.supported_features + == ClimateEntityFeature.TARGET_TEMPERATURE + | ExtendedClimateEntityFeature.TARGET_DEHUMIDITY + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + ) + + +def test_supported_features_air_cleaning_available( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the supported featured with air cleaning available.""" + coordinator.data = { + "air_cleaning_available": 1, + } + + assert ( + climate.supported_features + == ClimateEntityFeature.TARGET_TEMPERATURE + | ExtendedClimateEntityFeature.AIR_CLEANING + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + ) + + +def test_supported_features_ventilation_available( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the supported featured with ventilation available.""" + coordinator.data = { + "ventilation_available": 1, + } + + assert ( + climate.supported_features + == ClimateEntityFeature.TARGET_TEMPERATURE + | ExtendedClimateEntityFeature.FRESH_AIR + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + ) + + +def test_current_temperature( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the current temperature.""" + assert climate.current_temperature is None + + coordinator.data = { + "indoor_temperature_controlling_sensor_value": 20, + } + + assert climate.current_temperature == 20 + + +def test_current_humidity( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the current humidity.""" + assert climate.current_humidity is None + + coordinator.data = { + "indoor_humidity_controlling_sensor_value": 20, + } + + assert climate.current_humidity == 20 + + +def test_target_temperature_low( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the heat setpoint.""" + assert climate.target_temperature_low is None + + coordinator.data = { + "heat_setpoint": 20, + } + + assert climate.target_temperature_low == 20 + + +def test_target_temperature_high( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the cool setpoint.""" + assert climate.target_temperature_high is None + + coordinator.data = { + "cool_setpoint": 20, + } + + assert climate.target_temperature_high == 20 + + +def test_target_temperature(climate: AprilaireClimate) -> None: + """Test the target temperature.""" + target_temperature_low_mock = PropertyMock(return_value=20) + target_temperature_high_mock = PropertyMock(return_value=25) + hvac_mode_mock = PropertyMock(return_value=HVACMode.OFF) + + with ( + patch( + "custom_components.aprilaire.climate.AprilaireClimate.target_temperature_low", + new=target_temperature_low_mock, + ), + patch( + "custom_components.aprilaire.climate.AprilaireClimate.target_temperature_high", + new=target_temperature_high_mock, + ), + patch( + "custom_components.aprilaire.climate.AprilaireClimate.hvac_mode", + new=hvac_mode_mock, + ), + ): + assert climate.target_temperature is None + + hvac_mode_mock.return_value = HVACMode.COOL + + assert climate.target_temperature == 25 + + hvac_mode_mock.return_value = HVACMode.HEAT + + assert climate.target_temperature == 20 + + +def test_target_temperature_step(climate: AprilaireClimate) -> None: + """Test the target temperature step.""" + climate.hass.config.units = METRIC_SYSTEM + assert climate.target_temperature_step == 0.5 + + climate.hass.config.units = US_CUSTOMARY_SYSTEM + assert climate.target_temperature_step == 1 + + +def test_precision(climate: AprilaireClimate) -> None: + """Test the precision.""" + climate.hass.config.units = METRIC_SYSTEM + assert climate.precision == 0.5 + + climate.hass.config.units = US_CUSTOMARY_SYSTEM + assert climate.precision == 1 + + +def test_hvac_mode( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the current HVAC mode.""" + assert climate.hvac_mode is None + + coordinator.data = { + "mode": 0, + } + + assert climate.hvac_mode is None + + coordinator.data = { + "mode": 1, + } + + assert climate.hvac_mode == HVACMode.OFF + + coordinator.data = { + "mode": 2, + } + + assert climate.hvac_mode == HVACMode.HEAT + + coordinator.data = { + "mode": 3, + } + + assert climate.hvac_mode == HVACMode.COOL + + coordinator.data = { + "mode": 4, + } + + assert climate.hvac_mode == HVACMode.HEAT + + coordinator.data = { + "mode": 5, + } + + assert climate.hvac_mode == HVACMode.AUTO + + +def test_hvac_modes( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the available HVAC modes.""" + assert climate.hvac_modes == [] + + coordinator.data = { + "thermostat_modes": 0, + } + + assert climate.hvac_modes == [] + + coordinator.data = { + "thermostat_modes": 1, + } + + assert climate.hvac_modes, [HVACMode.OFF == HVACMode.HEAT] + + coordinator.data = { + "thermostat_modes": 2, + } + + assert climate.hvac_modes, [HVACMode.OFF == HVACMode.COOL] + + coordinator.data = { + "thermostat_modes": 3, + } + + assert climate.hvac_modes, [HVACMode.OFF, HVACMode.HEAT == HVACMode.COOL] + + coordinator.data = { + "thermostat_modes": 4, + } + + assert climate.hvac_modes, [HVACMode.OFF, HVACMode.HEAT == HVACMode.COOL] + + coordinator.data = { + "thermostat_modes": 5, + } + + assert climate.hvac_modes == [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.AUTO, + ] + + coordinator.data = { + "thermostat_modes": 6, + } + + assert climate.hvac_modes == [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.AUTO, + ] + + +def test_hvac_action( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the current HVAC action.""" + assert climate.hvac_action == HVACAction.IDLE + + coordinator.data = { + "heating_equipment_status": 0, + "cooling_equipment_status": 0, + } + + assert climate.hvac_action == HVACAction.IDLE + + coordinator.data = { + "heating_equipment_status": 1, + "cooling_equipment_status": 0, + } + + assert climate.hvac_action == HVACAction.HEATING + + coordinator.data = { + "heating_equipment_status": 1, + "cooling_equipment_status": 1, + } + + assert climate.hvac_action == HVACAction.HEATING + + coordinator.data = { + "heating_equipment_status": 0, + "cooling_equipment_status": 1, + } + + assert climate.hvac_action == HVACAction.COOLING + + +def test_preset_modes( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the available preset modes.""" + assert climate.preset_modes, [PRESET_NONE == PRESET_VACATION] + + coordinator.data = { + "away_available": 1, + } + + assert climate.preset_modes, [PRESET_NONE, PRESET_VACATION == PRESET_AWAY] + + coordinator.data = { + "hold": 1, + } + + assert climate.preset_modes == [PRESET_NONE, PRESET_VACATION, PRESET_TEMPORARY_HOLD] + + coordinator.data = { + "hold": 2, + } + + assert climate.preset_modes == [PRESET_NONE, PRESET_VACATION, PRESET_PERMANENT_HOLD] + + coordinator.data = { + "hold": 1, + "away_available": 1, + } + + assert climate.preset_modes == [ + PRESET_NONE, + PRESET_VACATION, + PRESET_AWAY, + PRESET_TEMPORARY_HOLD, + ] + + coordinator.data = { + "hold": 2, + "away_available": 1, + } + + assert climate.preset_modes == [ + PRESET_NONE, + PRESET_VACATION, + PRESET_AWAY, + PRESET_PERMANENT_HOLD, + ] + + +def test_preset_mode( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the current preset mode.""" + assert climate.preset_mode == PRESET_NONE + + coordinator.data = { + "hold": 0, + } + + assert climate.preset_mode == PRESET_NONE + + coordinator.data = { + "hold": 1, + } + + assert climate.preset_mode == PRESET_TEMPORARY_HOLD + + coordinator.data = { + "hold": 2, + } + + assert climate.preset_mode == PRESET_PERMANENT_HOLD + + coordinator.data = { + "hold": 3, + } + + assert climate.preset_mode == PRESET_AWAY + + coordinator.data = { + "hold": 4, + } + + assert climate.preset_mode == PRESET_VACATION + + +def test_climate_target_humidity( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the target humidity.""" + assert climate.target_humidity is None + + coordinator.data = { + "humidification_setpoint": 10, + } + + assert climate.target_humidity == 10 + + +def test_climate_min_humidity(climate: AprilaireClimate) -> None: + """Test the minimum humidity.""" + assert climate.min_humidity == 10 + + +def test_climate_max_humidity(climate: AprilaireClimate) -> None: + """Test the maximum humidity.""" + assert climate.max_humidity == 50 + + +def test_climate_extra_state_attributes( + climate: AprilaireClimate, coordinator: AprilaireCoordinator +) -> None: + """Test the extra state attributes.""" + coordinator.data = { + "fan_status": 0, + } + + assert climate.extra_state_attributes.get("fan_status") == "off" + + coordinator.data = { + "fan_status": 1, + } + + assert climate.extra_state_attributes.get("fan_status") == "on" + + +async def test_set_hvac_mode( + client: AprilaireClient, + climate: AprilaireClimate, +) -> None: + """Test setting the HVAC mode.""" + + await climate.async_set_hvac_mode(HVACMode.OFF) + + client.update_mode.assert_called_once_with(1) + client.read_control.assert_called_once() + client.reset_mock() + + await climate.async_set_hvac_mode(HVACMode.HEAT) + + client.update_mode.assert_called_once_with(2) + client.read_control.assert_called_once() + client.reset_mock() + + await climate.async_set_hvac_mode(HVACMode.COOL) + + client.update_mode.assert_called_once_with(3) + client.read_control.assert_called_once() + client.reset_mock() + + await climate.async_set_hvac_mode(HVACMode.AUTO) + + client.update_mode.assert_called_once_with(5) + client.read_control.assert_called_once() + client.reset_mock() + + with pytest.raises(ValueError): + await climate.async_set_hvac_mode(HVACMode.HEAT_COOL) + + client.update_mode.assert_not_called() + client.read_control.assert_not_called() + client.reset_mock() + + with pytest.raises(ValueError): + await climate.async_set_hvac_mode(HVACMode.DRY) + + client.update_mode.assert_not_called() + client.read_control.assert_not_called() + client.reset_mock() + + with pytest.raises(ValueError): + await climate.async_set_hvac_mode(HVACMode.FAN_ONLY) + + client.update_mode.assert_not_called() + client.read_control.assert_not_called() + client.reset_mock() + + +async def test_set_temperature( + client: AprilaireClient, + climate: AprilaireClimate, + coordinator: AprilaireCoordinator, +) -> None: + """Test setting the temperature.""" + + coordinator.data = { + "mode": 1, + } + + await climate.async_set_temperature(temperature=20) + + client.update_setpoint.assert_called_once_with(0, 20) + client.read_control.assert_called_once() + client.reset_mock() + + coordinator.data = { + "mode": 3, + } + + await climate.async_set_temperature(temperature=20) + + client.update_setpoint.assert_called_once_with(20, 0) + client.read_control.assert_called_once() + client.reset_mock() + + await climate.async_set_temperature(target_temp_low=20) + + client.update_setpoint.assert_called_once_with(0, 20) + client.read_control.assert_called_once() + client.reset_mock() + + await climate.async_set_temperature(target_temp_high=20) + + client.update_setpoint.assert_called_once_with(20, 0) + client.read_control.assert_called_once() + client.reset_mock() + + await climate.async_set_temperature(target_temp_low=20, target_temp_high=30) + + client.update_setpoint.assert_called_once_with(30, 20) + client.read_control.assert_called_once() + client.reset_mock() + + await climate.async_set_temperature() + + client.update_setpoint.assert_not_called() + client.read_control.assert_not_called() + client.reset_mock() + + +async def test_set_fan_mode( + client: AprilaireClient, + climate: AprilaireClimate, +) -> None: + """Test setting the fan mode.""" + + await climate.async_set_fan_mode(FAN_ON) + + client.update_fan_mode.assert_called_once_with(1) + client.read_control.assert_called_once() + client.reset_mock() + + await climate.async_set_fan_mode(FAN_AUTO) + + client.update_fan_mode.assert_called_once_with(2) + client.read_control.assert_called_once() + client.reset_mock() + + await climate.async_set_fan_mode(FAN_CIRCULATE) + + client.update_fan_mode.assert_called_once_with(3) + client.read_control.assert_called_once() + client.reset_mock() + + with pytest.raises(ValueError): + await climate.async_set_fan_mode("") + + client.update_fan_mode.assert_not_called() + client.read_control.assert_not_called() + client.reset_mock() + + +async def test_set_preset_mode( + client: AprilaireClient, + climate: AprilaireClimate, +) -> None: + """Test setting the preset mode.""" + + await climate.async_set_preset_mode(PRESET_AWAY) + + client.set_hold.assert_called_once_with(3) + client.read_scheduling.assert_called_once() + client.reset_mock() + + await climate.async_set_preset_mode(PRESET_VACATION) + + client.set_hold.assert_called_once_with(4) + client.read_scheduling.assert_called_once() + client.reset_mock() + + await climate.async_set_preset_mode(PRESET_NONE) + + client.set_hold.assert_called_once_with(0) + client.read_scheduling.assert_called_once() + client.reset_mock() + + with pytest.raises(ValueError): + await climate.async_set_preset_mode(PRESET_TEMPORARY_HOLD) + + client.set_hold.assert_not_called() + client.read_scheduling.assert_not_called() + client.reset_mock() + + with pytest.raises(ValueError): + await climate.async_set_preset_mode(PRESET_PERMANENT_HOLD) + + client.set_hold.assert_not_called() + client.read_scheduling.assert_not_called() + client.reset_mock() + + with pytest.raises(ValueError): + await climate.async_set_preset_mode("") + + client.set_hold.assert_not_called() + client.read_scheduling.assert_not_called() + client.reset_mock() + + +async def test_set_humidity( + client: AprilaireClient, + climate: AprilaireClimate, + coordinator: AprilaireCoordinator, +) -> None: + """Test setting the humidity.""" + + coordinator.data["humidification_available"] = 2 + + await climate.async_set_humidity(30) + + client.set_humidification_setpoint.assert_called_with(30) diff --git a/tests/components/aprilaire/test_config_flow.py b/tests/components/aprilaire/test_config_flow.py new file mode 100644 index 00000000000000..5348a1b907f81e --- /dev/null +++ b/tests/components/aprilaire/test_config_flow.py @@ -0,0 +1,210 @@ +"""Tests for the Aprilaire config flow.""" + +import logging +from unittest.mock import AsyncMock, Mock, patch + +from pyaprilaire.client import AprilaireClient +from pyaprilaire.const import FunctionalDomain +import pytest + +from homeassistant.components.aprilaire.config_flow import ( + STEP_USER_DATA_SCHEMA, + ConfigFlow, +) +from homeassistant.config_entries import ConfigEntries, ConfigEntry +from homeassistant.core import EventBus, HomeAssistant +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.util import uuid as uuid_util + + +@pytest.fixture +def logger() -> logging.Logger: + """Return a logger.""" + logger = logging.getLogger() + logger.propagate = False + + return logger + + +@pytest.fixture +def client() -> AprilaireClient: + """Return a mock client.""" + return AsyncMock(AprilaireClient) + + +@pytest.fixture +def entry_id() -> str: + """Return a random ID.""" + return uuid_util.random_uuid_hex() + + +@pytest.fixture +def hass() -> HomeAssistant: + """Return a mock HomeAssistant instance.""" + hass_mock = AsyncMock(HomeAssistant) + hass_mock.data = {} + hass_mock.config_entries = AsyncMock(ConfigEntries) + hass_mock.bus = AsyncMock(EventBus) + + return hass_mock + + +@pytest.fixture +def config_entry(entry_id: str) -> ConfigEntry: + """Return a mock config entry.""" + + config_entry_mock = AsyncMock(ConfigEntry) + config_entry_mock.data = {"host": "test123", "port": 123} + config_entry_mock.entry_id = entry_id + + return config_entry_mock + + +async def test_user_input_step() -> None: + """Test the user input step.""" + + show_form_mock = Mock() + + config_flow = ConfigFlow() + config_flow.async_show_form = show_form_mock + + await config_flow.async_step_user(None) + + show_form_mock.assert_called_once_with( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + +async def test_unique_id_abort() -> None: + """Test that the flow is aborted if a non-unique ID is provided.""" + + show_form_mock = Mock() + set_unique_id_mock = AsyncMock() + abort_if_unique_id_configured_mock = Mock( + side_effect=AbortFlow("already_configured") + ) + + config_flow = ConfigFlow() + config_flow.async_show_form = show_form_mock + config_flow.async_set_unique_id = set_unique_id_mock + config_flow._abort_if_unique_id_configured = abort_if_unique_id_configured_mock + + await config_flow.async_step_user( + { + "host": "localhost", + "port": 7000, + } + ) + + show_form_mock.assert_called_once_with( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "already_configured"}, + ) + + +async def test_unique_id_exception( + caplog: pytest.LogCaptureFixture, logger: logging.Logger +) -> None: + """Assert that the flow is aborted if the unique ID throws an exception.""" + + show_form_mock = Mock() + set_unique_id_mock = AsyncMock() + abort_if_unique_id_configured_mock = Mock(side_effect=Exception("test")) + + config_flow = ConfigFlow() + config_flow.async_show_form = show_form_mock + config_flow.async_set_unique_id = set_unique_id_mock + config_flow._abort_if_unique_id_configured = abort_if_unique_id_configured_mock + + with caplog.at_level(logging.INFO, logger=logger.name): + await config_flow.async_step_user( + { + "host": "localhost", + "port": 7000, + } + ) + + assert caplog.text != "" + + show_form_mock.assert_called_once_with( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "test"}, + ) + + +async def test_config_flow_invalid_data(client: AprilaireClient) -> None: + """Test that the flow is aborted with invalid data.""" + + show_form_mock = Mock() + set_unique_id_mock = AsyncMock() + abort_if_unique_id_configured_mock = Mock() + + config_flow = ConfigFlow() + config_flow.async_show_form = show_form_mock + config_flow.async_set_unique_id = set_unique_id_mock + config_flow._abort_if_unique_id_configured = abort_if_unique_id_configured_mock + + with patch("pyaprilaire.client.AprilaireClient", return_value=client): + await config_flow.async_step_user( + { + "host": "localhost", + "port": 7000, + } + ) + + client.start_listen.assert_called_once() + client.wait_for_response.assert_called_once_with( + FunctionalDomain.IDENTIFICATION, 2, 30 + ) + client.stop_listen.assert_called_once() + + show_form_mock.assert_called_once_with( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "connection_failed"}, + ) + + +async def test_config_flow_data(client: AprilaireClient) -> None: + """Test the config flow with valid data.""" + + show_form_mock = Mock() + set_unique_id_mock = AsyncMock() + abort_if_unique_id_configured_mock = Mock() + create_entry_mock = Mock() + sleep_mock = AsyncMock() + + config_flow = ConfigFlow() + config_flow.async_show_form = show_form_mock + config_flow.async_set_unique_id = set_unique_id_mock + config_flow._abort_if_unique_id_configured = abort_if_unique_id_configured_mock + config_flow.async_create_entry = create_entry_mock + + client.wait_for_response = AsyncMock(return_value={"mac_address": "test"}) + + with patch("pyaprilaire.client.AprilaireClient", return_value=client), patch( + "asyncio.sleep", new=sleep_mock + ): + await config_flow.async_step_user( + { + "host": "localhost", + "port": 7000, + } + ) + + client.start_listen.assert_called_once() + client.wait_for_response.assert_called_once_with( + FunctionalDomain.IDENTIFICATION, 2, 30 + ) + client.stop_listen.assert_called_once() + sleep_mock.assert_awaited_once() + + create_entry_mock.assert_called_once_with( + title="Aprilaire", + data={ + "host": "localhost", + "port": 7000, + }, + ) diff --git a/tests/components/aprilaire/test_coordinator.py b/tests/components/aprilaire/test_coordinator.py new file mode 100644 index 00000000000000..735adc50194b64 --- /dev/null +++ b/tests/components/aprilaire/test_coordinator.py @@ -0,0 +1,231 @@ +"""Tests for the Aprilaire coordinator.""" + +import logging +from unittest.mock import AsyncMock, Mock, patch + +from pyaprilaire.client import AprilaireClient +from pyaprilaire.const import FunctionalDomain +import pytest + +from homeassistant.components.aprilaire.const import DOMAIN +from homeassistant.components.aprilaire.coordinator import AprilaireCoordinator +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry + + +@pytest.fixture +def logger() -> logging.Logger: + """Return a logger.""" + logger = logging.getLogger() + logger.propagate = False + + return logger + + +@pytest.fixture +def device_registry() -> DeviceRegistry: + """Return a mock device registry.""" + return Mock(DeviceRegistry) + + +@pytest.fixture +def hass(device_registry: DeviceRegistry) -> HomeAssistant: + """Return a mock HomeAssistant instance.""" + hass_mock = Mock(HomeAssistant) + hass_mock.data = {"device_registry": device_registry} + + return hass_mock + + +@pytest.fixture +def client() -> AprilaireClient: + """Return a mock client.""" + return AsyncMock(AprilaireClient) + + +@pytest.fixture +def coordinator( + client: AprilaireClient, hass: HomeAssistant, logger: logging.Logger +) -> AprilaireCoordinator: + """Return a mock coordinator.""" + with patch( + "pyaprilaire.client.AprilaireClient", + return_value=client, + ): + return AprilaireCoordinator(hass, "", 0, logger) + + +async def test_start_listen(coordinator: AprilaireCoordinator) -> None: + """Test that the coordinator starts the client listening.""" + + await coordinator.start_listen() + + assert coordinator.client.start_listen.call_count == 1 + + +def test_stop_listen(coordinator: AprilaireCoordinator) -> None: + """Test that the coordinator stops the client listening.""" + + coordinator.stop_listen() + + assert coordinator.client.stop_listen.call_count == 1 + + +def test_set_updated_data(coordinator: AprilaireCoordinator) -> None: + """Test updating the coordinator data.""" + + test_data = {"testKey": "testValue"} + + coordinator.async_set_updated_data(test_data) + + assert coordinator.data == test_data + + +def test_device_name_default(coordinator: AprilaireCoordinator) -> None: + """Test the default device name.""" + assert coordinator.device_name == "Aprilaire" + + +def test_device_name(coordinator: AprilaireCoordinator) -> None: + """Test the device name when provided to the coordinator.""" + + test_device_name = "Test Device Name" + + coordinator.async_set_updated_data({"name": test_device_name}) + + assert coordinator.device_name == test_device_name + + +def test_device_info(coordinator: AprilaireCoordinator) -> None: + """Test the device info.""" + + test_mac_address = "1:2:3:4:5:6" + test_device_name = "Test Device Name" + test_model_number = 0 + test_hardware_revision = ord("B") + test_firmware_major_revision = 1 + test_firmware_minor_revision = 5 + + coordinator.async_set_updated_data( + { + "mac_address": test_mac_address, + "name": test_device_name, + "model_number": test_model_number, + "hardware_revision": test_hardware_revision, + "firmware_major_revision": test_firmware_major_revision, + "firmware_minor_revision": test_firmware_minor_revision, + } + ) + + device_info = coordinator.device_info + + assert device_info["identifiers"] == {(DOMAIN, test_mac_address)} + assert device_info["name"] == test_device_name + assert device_info["model"] == "8476W" + assert device_info["hw_version"] == "Rev. B" + assert ( + device_info["sw_version"] + == f"{test_firmware_major_revision}.{test_firmware_minor_revision:02}" + ) + + +def test_hw_version_A(coordinator: AprilaireCoordinator) -> None: + """Test the hardware version for revision A.""" + assert coordinator.get_hw_version({"hardware_revision": 1}) == "1" + + +def test_hw_version_B(coordinator: AprilaireCoordinator) -> None: + """Test the hardware version for revision B.""" + assert coordinator.get_hw_version({"hardware_revision": ord("B")}) == "Rev. B" + + +def test_updated_device( + coordinator: AprilaireCoordinator, device_registry: DeviceRegistry +) -> None: + """Test updating the device info.""" + + test_mac_address = "1:2:3:4:5:6" + test_device_name = "Test Device Name" + test_model_number = 0 + test_hardware_revision = ord("B") + test_firmware_major_revision = 1 + test_firmware_minor_revision = 5 + + test_new_mac_address = "1:2:3:4:5:7" + test_new_device_name = "Test Device Name 2" + test_new_model_number = 1 + test_new_hardware_revision = ord("C") + test_new_firmware_major_revision = 2 + test_new_firmware_minor_revision = 6 + + coordinator.async_set_updated_data( + { + "mac_address": test_mac_address, + "name": test_device_name, + "model_number": test_model_number, + "hardware_revision": test_hardware_revision, + "firmware_major_revision": test_firmware_major_revision, + "firmware_minor_revision": test_firmware_minor_revision, + } + ) + + coordinator.async_set_updated_data( + { + "mac_address": test_new_mac_address, + "name": test_new_device_name, + "model_number": test_new_model_number, + "hardware_revision": test_new_hardware_revision, + "firmware_major_revision": test_new_firmware_major_revision, + "firmware_minor_revision": test_new_firmware_minor_revision, + } + ) + + assert device_registry.async_update_device.call_count == 1 + + new_device_info = device_registry.async_update_device.call_args[1] + + assert new_device_info == new_device_info | { + "name": test_new_device_name, + "manufacturer": "Aprilaire", + "model": "8810", + "hw_version": "Rev. C", + "sw_version": "2.06", + } + + +async def test_wait_for_ready_mac_fail( + caplog: pytest.LogCaptureFixture, + coordinator: AprilaireCoordinator, + logger: logging.Logger, +) -> None: + """Test the handling of a missing MAC address.""" + + ready_callback_mock = AsyncMock() + + with caplog.at_level(logging.INFO, logger=logger.name): + await coordinator.wait_for_ready(ready_callback_mock) + + assert caplog.record_tuples == [ + ("root", logging.ERROR, "Missing MAC address, cannot create unique ID"), + ] + + assert ready_callback_mock.call_count == 1 + assert ready_callback_mock.call_args[0][0] is False + + +async def test_wait_for_ready(coordinator: AprilaireCoordinator) -> None: + """Test waiting for the client to be ready.""" + + ready_callback_mock = AsyncMock() + + wait_for_response_mock = AsyncMock() + wait_for_response_mock.return_value = {"mac_address": "1:2:3:4:5:6"} + + coordinator.client.wait_for_response = wait_for_response_mock + + await coordinator.wait_for_ready(ready_callback_mock) + + wait_for_response_mock.assert_any_call(FunctionalDomain.IDENTIFICATION, 2, 30) + wait_for_response_mock.assert_any_call(FunctionalDomain.IDENTIFICATION, 4, 30) + wait_for_response_mock.assert_any_call(FunctionalDomain.CONTROL, 7, 30) + wait_for_response_mock.assert_any_call(FunctionalDomain.SENSORS, 2, 30) diff --git a/tests/components/aprilaire/test_entity.py b/tests/components/aprilaire/test_entity.py new file mode 100644 index 00000000000000..0eebf4290edb10 --- /dev/null +++ b/tests/components/aprilaire/test_entity.py @@ -0,0 +1,181 @@ +"""Tests for the Aprilaire base entity.""" + +import logging +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.aprilaire.coordinator import AprilaireCoordinator +from homeassistant.components.aprilaire.entity import BaseAprilaireEntity +from homeassistant.helpers.entity import DeviceInfo + + +@pytest.fixture +def logger() -> logging.Logger: + """Return a logger.""" + logger = logging.getLogger() + logger.propagate = False + + return logger + + +@pytest.fixture +def coordinator(logger: logging.Logger) -> AprilaireCoordinator: + """Return a mock coordinator.""" + + coordinator_mock = AsyncMock(AprilaireCoordinator) + coordinator_mock.data = {} + coordinator_mock.logger = logger + + return coordinator_mock + + +async def test_available_on_init(coordinator: AprilaireCoordinator) -> None: + """Test that the entity becomes available on init.""" + + update_available_mock = Mock() + with patch( + "custom_components.aprilaire.entity.BaseAprilaireEntity._update_available", + new=update_available_mock, + ): + BaseAprilaireEntity(coordinator) + update_available_mock.assert_called_once() + + +async def test_handle_coordinator_update(coordinator: AprilaireCoordinator) -> None: + """Test that the coordinator updates the entity.""" + + update_available_mock = Mock() + async_write_ha_state_mock = Mock() + + with patch( + "custom_components.aprilaire.entity.BaseAprilaireEntity._update_available", + new=update_available_mock, + ), patch( + "homeassistant.helpers.entity.Entity.async_write_ha_state", + new=async_write_ha_state_mock, + ): + entity = BaseAprilaireEntity(coordinator) + entity._handle_coordinator_update() + + assert update_available_mock.call_count == 2 + + async_write_ha_state_mock.assert_called_once() + + +async def test_update_available_stopped(coordinator: AprilaireCoordinator) -> None: + """Test that the stopped state causes the entity to not be available.""" + + entity = BaseAprilaireEntity(coordinator) + + coordinator.data["stopped"] = True + entity._update_available() + + assert entity._attr_available is False + assert entity.available is False + + +async def test_update_available_no_mac(coordinator: AprilaireCoordinator) -> None: + """Test that no MAC address causes the entity to not be available.""" + + entity = BaseAprilaireEntity(coordinator) + + coordinator.data["connected"] = True + coordinator.data["stopped"] = False + coordinator.data["mac_address"] = None + entity._update_available() + + assert entity._attr_available is False + assert entity.available is False + + +async def test_update_available_connected_not_stopped( + coordinator: AprilaireCoordinator, +) -> None: + """Test that the connected state causes the entity to be available.""" + + entity = BaseAprilaireEntity(coordinator) + + coordinator.data["connected"] = True + coordinator.data["stopped"] = False + coordinator.data["mac_address"] = "1:2:3:4:5:6" + entity._update_available() + + assert entity._attr_available is True + assert entity.available is True + + +async def test_update_available_reconnecting_not_stopped( + coordinator: AprilaireCoordinator, +) -> None: + """Test that the entity remains available when reconnecting.""" + + entity = BaseAprilaireEntity(coordinator) + + coordinator.data["connected"] = False + coordinator.data["reconnecting"] = True + coordinator.data["stopped"] = False + coordinator.data["mac_address"] = "1:2:3:4:5:6" + entity._update_available() + + assert entity._attr_available is True + assert entity.available is True + + +def test_should_poll(coordinator: AprilaireCoordinator) -> None: + """Test that the entity does not poll.""" + + entity = BaseAprilaireEntity(coordinator) + + assert entity.should_poll is False + + +def test_unique_id(coordinator: AprilaireCoordinator) -> None: + """Test the generation of the entity's unique ID.""" + + entity = BaseAprilaireEntity(coordinator) + + coordinator.data["mac_address"] = "1:2:3:4:5:6" + + with patch( + "custom_components.aprilaire.entity.BaseAprilaireEntity.entity_name", + new="Test Entity", + ): + assert entity.unique_id == "1_2_3_4_5_6_test_entity" + + +def test_name(coordinator: AprilaireCoordinator) -> None: + """Test the entity name.""" + + entity = BaseAprilaireEntity(coordinator) + coordinator.device_name = "Aprilaire" + + with patch( + "custom_components.aprilaire.entity.BaseAprilaireEntity.entity_name", + new="Test Entity", + ): + assert entity.name == "Aprilaire Test Entity" + + +def test_extra_state_attributes(coordinator: AprilaireCoordinator) -> None: + """Test the entity's extra state attributes.""" + + entity = BaseAprilaireEntity(coordinator) + coordinator.device_name = "Aprilaire" + coordinator.data["location"] = "Test Location" + + assert entity.extra_state_attributes == { + "device_name": "Aprilaire", + "device_location": "Test Location", + } + + +def test_device_info(coordinator: AprilaireCoordinator) -> None: + """Test the device info.""" + + coordinator.device_info = DeviceInfo() + + entity = BaseAprilaireEntity(coordinator) + device_info = entity.device_info + + assert device_info == coordinator.device_info diff --git a/tests/components/aprilaire/test_init.py b/tests/components/aprilaire/test_init.py new file mode 100644 index 00000000000000..6d07c92a3c30af --- /dev/null +++ b/tests/components/aprilaire/test_init.py @@ -0,0 +1,251 @@ +"""Tests for the Aprilaire integration setup.""" + +from collections.abc import Awaitable, Callable +import logging +from unittest.mock import AsyncMock, Mock, patch + +from pyaprilaire.client import AprilaireClient +import pytest + +from homeassistant.components.aprilaire import async_setup_entry, async_unload_entry +from homeassistant.components.aprilaire.const import DOMAIN +from homeassistant.components.aprilaire.coordinator import AprilaireCoordinator +from homeassistant.config_entries import ConfigEntries, ConfigEntry +from homeassistant.core import EventBus, HomeAssistant +from homeassistant.util import uuid as uuid_util + + +@pytest.fixture +def logger() -> logging.Logger: + """Return a logger.""" + logger = logging.getLogger() + logger.propagate = False + + return logger + + +@pytest.fixture +def entry_id() -> str: + """Return a random ID.""" + return uuid_util.random_uuid_hex() + + +@pytest.fixture +def hass() -> HomeAssistant: + """Return a mock HomeAssistant instance.""" + + hass_mock = AsyncMock(HomeAssistant) + hass_mock.data = {} + hass_mock.config_entries = AsyncMock(ConfigEntries) + hass_mock.bus = AsyncMock(EventBus) + + return hass_mock + + +@pytest.fixture +def config_entry(entry_id: str) -> ConfigEntry: + """Return a mock config entry.""" + + config_entry_mock = AsyncMock(ConfigEntry) + config_entry_mock.data = {"host": "test123", "port": 123} + config_entry_mock.entry_id = entry_id + + return config_entry_mock + + +@pytest.fixture +def client() -> AprilaireClient: + """Return a mock client.""" + return AsyncMock(AprilaireClient) + + +async def test_async_setup_entry( + caplog: pytest.LogCaptureFixture, + client: AprilaireClient, + config_entry: ConfigEntry, + entry_id: str, + hass: HomeAssistant, + logger: logging.Logger, +) -> None: + """Test handling of setup with missing MAC address.""" + + with patch( + "pyaprilaire.client.AprilaireClient", + return_value=client, + ), caplog.at_level(logging.INFO, logger=logger.name): + setup_result = await async_setup_entry(hass, config_entry, logger=logger) + + assert setup_result is True + + client.start_listen.assert_called_once() + + assert isinstance(hass.data[DOMAIN][entry_id], AprilaireCoordinator) + + assert caplog.record_tuples == [ + ("root", logging.ERROR, "Missing MAC address, cannot create unique ID"), + ("root", logging.ERROR, "Failed to wait for ready"), + ] + + +async def test_async_setup_entry_ready( + client: AprilaireClient, + config_entry: ConfigEntry, + hass: HomeAssistant, + logger: logging.Logger, +) -> None: + """Test setup entry with valid data.""" + + async def wait_for_ready(self, ready_callback: Callable[[bool], Awaitable[None]]): + await ready_callback(True) + + with patch( + "pyaprilaire.client.AprilaireClient", + return_value=client, + ), patch( + "custom_components.aprilaire.coordinator.AprilaireCoordinator.wait_for_ready", + new=wait_for_ready, + ): + setup_result = await async_setup_entry(hass, config_entry, logger=logger) + + assert setup_result is True + + +async def test_async_setup_entry_not_ready( + caplog: pytest.LogCaptureFixture, + client: AprilaireClient, + config_entry: ConfigEntry, + hass: HomeAssistant, + logger: logging.Logger, +) -> None: + """Test handling of setup when client is not ready.""" + + async def wait_for_ready(self, ready_callback: Callable[[bool], Awaitable[None]]): + await ready_callback(False) + + with patch( + "pyaprilaire.client.AprilaireClient", + return_value=client, + ), patch( + "custom_components.aprilaire.coordinator.AprilaireCoordinator.wait_for_ready", + new=wait_for_ready, + ), caplog.at_level(logging.INFO, logger=logger.name): + setup_result = await async_setup_entry(hass, config_entry, logger=logger) + + assert setup_result is True + + client.stop_listen.assert_called_once() + + assert caplog.record_tuples == [("root", logging.ERROR, "Failed to wait for ready")] + + +async def test_invalid_host( + caplog: pytest.LogCaptureFixture, + client: AprilaireClient, + hass: HomeAssistant, + logger: logging.Logger, +) -> None: + """Test setup with invalid host.""" + + config_entry_mock = AsyncMock() + config_entry_mock.data = {} + + with patch( + "pyaprilaire.client.AprilaireClient", + return_value=client, + ), caplog.at_level(logging.INFO, logger=logger.name): + setup_result = await async_setup_entry(hass, config_entry_mock, logger=logger) + + assert setup_result is False + + client.start_listen.assert_not_called() + + assert caplog.record_tuples == [("root", logging.ERROR, "Invalid host None")] + + +async def test_invalid_port( + caplog: pytest.LogCaptureFixture, + client: AprilaireClient, + hass: HomeAssistant, + logger: logging.Logger, +) -> None: + """Test setup with invalid port.""" + + config_entry_mock = AsyncMock() + config_entry_mock.data = {"host": "test123"} + + with patch( + "pyaprilaire.client.AprilaireClient", + return_value=client, + ), caplog.at_level(logging.INFO, logger=logger.name): + setup_result = await async_setup_entry(hass, config_entry_mock, logger=logger) + + assert setup_result is False + + client.start_listen.assert_not_called() + + assert caplog.record_tuples == [("root", logging.ERROR, "Invalid port None")] + + +async def test_unload_entry_ok( + client: AprilaireClient, + config_entry: ConfigEntry, + hass: HomeAssistant, + logger: logging.Logger, +) -> None: + """Test unloading the config entry.""" + + async def wait_for_ready(self, ready_callback: Callable[[bool], Awaitable[None]]): + await ready_callback(True) + + stop_listen_mock = Mock() + + hass.config_entries.async_unload_platforms = AsyncMock(return_value=True) + + with patch( + "pyaprilaire.client.AprilaireClient", + return_value=client, + ), patch( + "custom_components.aprilaire.coordinator.AprilaireCoordinator.wait_for_ready", + new=wait_for_ready, + ), patch( + "custom_components.aprilaire.coordinator.AprilaireCoordinator.stop_listen", + new=stop_listen_mock, + ): + await async_setup_entry(hass, config_entry, logger=logger) + + unload_result = await async_unload_entry(hass, config_entry) + + hass.config_entries.async_unload_platforms.assert_called_once() + + assert unload_result is True + + stop_listen_mock.assert_called_once() + + +async def test_unload_entry_not_ok( + client: AprilaireClient, + config_entry: ConfigEntry, + hass: HomeAssistant, + logger: logging.Logger, +) -> None: + """Test handling of unload failure.""" + + async def wait_for_ready(self, ready_callback: Callable[[bool], Awaitable[None]]): + await ready_callback(True) + + with patch( + "pyaprilaire.client.AprilaireClient", + return_value=client, + ), patch( + "custom_components.aprilaire.coordinator.AprilaireCoordinator.wait_for_ready", + new=wait_for_ready, + ): + await async_setup_entry(hass, config_entry, logger=logger) + + hass.config_entries.async_unload_platforms = AsyncMock(return_value=False) + + unload_result = await async_unload_entry(hass, config_entry) + + hass.config_entries.async_unload_platforms.assert_called_once() + + assert unload_result is False From 8c6d037d7ede259b45e01005ffd74e6f3e2b8d44 Mon Sep 17 00:00:00 2001 From: Matthew FitzGerald-Chamberlain Date: Mon, 3 Jul 2023 20:28:52 +0000 Subject: [PATCH 2/5] Fix test errors --- .../components/aprilaire/coordinator.py | 4 ++-- tests/components/aprilaire/test_climate.py | 10 +++++----- .../components/aprilaire/test_config_flow.py | 2 +- tests/components/aprilaire/test_entity.py | 19 +++---------------- tests/components/aprilaire/test_init.py | 10 +++++----- 5 files changed, 16 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/aprilaire/coordinator.py b/homeassistant/components/aprilaire/coordinator.py index b308f154c28aef..1184466ab8cd04 100644 --- a/homeassistant/components/aprilaire/coordinator.py +++ b/homeassistant/components/aprilaire/coordinator.py @@ -66,8 +66,8 @@ def async_set_updated_data(self, data: Any) -> None: device = device_registry.async_get_device(old_device_info["identifiers"]) if device is not None: - new_device_info.pop("identifiers") - new_device_info.pop("connections") + new_device_info.pop("identifiers", None) + new_device_info.pop("connections", None) device_registry.async_update_device( device_id=device.id, **new_device_info # type: ignore[misc] diff --git a/tests/components/aprilaire/test_climate.py b/tests/components/aprilaire/test_climate.py index 41892e68d2603f..7f9e4b9114a7ee 100644 --- a/tests/components/aprilaire/test_climate.py +++ b/tests/components/aprilaire/test_climate.py @@ -111,9 +111,9 @@ async def climate(config_entry: ConfigEntry, hass: HomeAssistant) -> AprilaireCl return climate -def test_climate_entity_name(climate: AprilaireClimate) -> None: +def test_climate_name(climate: AprilaireClimate) -> None: """Test the entity name.""" - assert climate.entity_name == "Thermostat" + assert climate.name == "Thermostat" def test_climate_min_temp(climate: AprilaireClimate) -> None: @@ -332,15 +332,15 @@ def test_target_temperature(climate: AprilaireClimate) -> None: with ( patch( - "custom_components.aprilaire.climate.AprilaireClimate.target_temperature_low", + "homeassistant.components.aprilaire.climate.AprilaireClimate.target_temperature_low", new=target_temperature_low_mock, ), patch( - "custom_components.aprilaire.climate.AprilaireClimate.target_temperature_high", + "homeassistant.components.aprilaire.climate.AprilaireClimate.target_temperature_high", new=target_temperature_high_mock, ), patch( - "custom_components.aprilaire.climate.AprilaireClimate.hvac_mode", + "homeassistant.components.aprilaire.climate.AprilaireClimate.hvac_mode", new=hvac_mode_mock, ), ): diff --git a/tests/components/aprilaire/test_config_flow.py b/tests/components/aprilaire/test_config_flow.py index 5348a1b907f81e..55e1d3e16f6a16 100644 --- a/tests/components/aprilaire/test_config_flow.py +++ b/tests/components/aprilaire/test_config_flow.py @@ -99,7 +99,7 @@ async def test_unique_id_abort() -> None: show_form_mock.assert_called_once_with( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, - errors={"base": "already_configured"}, + errors={"base": "Flow aborted: already_configured"}, ) diff --git a/tests/components/aprilaire/test_entity.py b/tests/components/aprilaire/test_entity.py index 0eebf4290edb10..80cf8b72c2c4bb 100644 --- a/tests/components/aprilaire/test_entity.py +++ b/tests/components/aprilaire/test_entity.py @@ -35,7 +35,7 @@ async def test_available_on_init(coordinator: AprilaireCoordinator) -> None: update_available_mock = Mock() with patch( - "custom_components.aprilaire.entity.BaseAprilaireEntity._update_available", + "homeassistant.components.aprilaire.entity.BaseAprilaireEntity._update_available", new=update_available_mock, ): BaseAprilaireEntity(coordinator) @@ -49,7 +49,7 @@ async def test_handle_coordinator_update(coordinator: AprilaireCoordinator) -> N async_write_ha_state_mock = Mock() with patch( - "custom_components.aprilaire.entity.BaseAprilaireEntity._update_available", + "homeassistant.components.aprilaire.entity.BaseAprilaireEntity._update_available", new=update_available_mock, ), patch( "homeassistant.helpers.entity.Entity.async_write_ha_state", @@ -138,25 +138,12 @@ def test_unique_id(coordinator: AprilaireCoordinator) -> None: coordinator.data["mac_address"] = "1:2:3:4:5:6" with patch( - "custom_components.aprilaire.entity.BaseAprilaireEntity.entity_name", + "homeassistant.components.aprilaire.entity.BaseAprilaireEntity.name", new="Test Entity", ): assert entity.unique_id == "1_2_3_4_5_6_test_entity" -def test_name(coordinator: AprilaireCoordinator) -> None: - """Test the entity name.""" - - entity = BaseAprilaireEntity(coordinator) - coordinator.device_name = "Aprilaire" - - with patch( - "custom_components.aprilaire.entity.BaseAprilaireEntity.entity_name", - new="Test Entity", - ): - assert entity.name == "Aprilaire Test Entity" - - def test_extra_state_attributes(coordinator: AprilaireCoordinator) -> None: """Test the entity's extra state attributes.""" diff --git a/tests/components/aprilaire/test_init.py b/tests/components/aprilaire/test_init.py index 6d07c92a3c30af..3b59889474e88c 100644 --- a/tests/components/aprilaire/test_init.py +++ b/tests/components/aprilaire/test_init.py @@ -102,7 +102,7 @@ async def wait_for_ready(self, ready_callback: Callable[[bool], Awaitable[None]] "pyaprilaire.client.AprilaireClient", return_value=client, ), patch( - "custom_components.aprilaire.coordinator.AprilaireCoordinator.wait_for_ready", + "homeassistant.components.aprilaire.coordinator.AprilaireCoordinator.wait_for_ready", new=wait_for_ready, ): setup_result = await async_setup_entry(hass, config_entry, logger=logger) @@ -126,7 +126,7 @@ async def wait_for_ready(self, ready_callback: Callable[[bool], Awaitable[None]] "pyaprilaire.client.AprilaireClient", return_value=client, ), patch( - "custom_components.aprilaire.coordinator.AprilaireCoordinator.wait_for_ready", + "homeassistant.components.aprilaire.coordinator.AprilaireCoordinator.wait_for_ready", new=wait_for_ready, ), caplog.at_level(logging.INFO, logger=logger.name): setup_result = await async_setup_entry(hass, config_entry, logger=logger) @@ -205,10 +205,10 @@ async def wait_for_ready(self, ready_callback: Callable[[bool], Awaitable[None]] "pyaprilaire.client.AprilaireClient", return_value=client, ), patch( - "custom_components.aprilaire.coordinator.AprilaireCoordinator.wait_for_ready", + "homeassistant.components.aprilaire.coordinator.AprilaireCoordinator.wait_for_ready", new=wait_for_ready, ), patch( - "custom_components.aprilaire.coordinator.AprilaireCoordinator.stop_listen", + "homeassistant.components.aprilaire.coordinator.AprilaireCoordinator.stop_listen", new=stop_listen_mock, ): await async_setup_entry(hass, config_entry, logger=logger) @@ -237,7 +237,7 @@ async def wait_for_ready(self, ready_callback: Callable[[bool], Awaitable[None]] "pyaprilaire.client.AprilaireClient", return_value=client, ), patch( - "custom_components.aprilaire.coordinator.AprilaireCoordinator.wait_for_ready", + "homeassistant.components.aprilaire.coordinator.AprilaireCoordinator.wait_for_ready", new=wait_for_ready, ): await async_setup_entry(hass, config_entry, logger=logger) From a98e7014140b8c6d94df5047199abb18bcfd02e2 Mon Sep 17 00:00:00 2001 From: Matthew FitzGerald-Chamberlain Date: Mon, 3 Jul 2023 15:41:25 -0500 Subject: [PATCH 3/5] Update constants --- .../components/aprilaire/__init__.py | 6 ++--- homeassistant/components/aprilaire/climate.py | 23 ++++++++----------- homeassistant/components/aprilaire/const.py | 6 +++++ 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/aprilaire/__init__.py b/homeassistant/components/aprilaire/__init__.py index 1fd8880614b9ef..d1e2e65856beae 100644 --- a/homeassistant/components/aprilaire/__init__.py +++ b/homeassistant/components/aprilaire/__init__.py @@ -6,7 +6,7 @@ from typing import cast from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from .const import DOMAIN, LOG_NAME @@ -25,12 +25,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, **kwargs) - config = entry.data - host = config.get("host") + host = config.get(CONF_HOST) if host is None or len(host) == 0: logger.error("Invalid host %s", host) return False - port = config.get("port") + port = config.get(CONF_PORT) if port is None or port <= 0: logger.error("Invalid port %s", port) return False diff --git a/homeassistant/components/aprilaire/climate.py b/homeassistant/components/aprilaire/climate.py index 3197ea1257fc54..fc38e84936c602 100644 --- a/homeassistant/components/aprilaire/climate.py +++ b/homeassistant/components/aprilaire/climate.py @@ -23,16 +23,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import ( + DOMAIN, + FAN_CIRCULATE, + PRESET_PERMANENT_HOLD, + PRESET_TEMPORARY_HOLD, + PRESET_VACATION, +) from .coordinator import AprilaireCoordinator from .entity import BaseAprilaireEntity -FAN_CIRCULATE = "Circulate" - -PRESET_TEMPORARY_HOLD = "Temporary" -PRESET_PERMANENT_HOLD = "Permanent" -PRESET_VACATION = "Vacation" - HVAC_MODE_MAP = { 1: HVACMode.OFF, 2: HVACMode.HEAT, @@ -111,13 +111,10 @@ def supported_features(self) -> ClimateEntityFeature: """Get supported features.""" features = 0 - if Attribute.MODE not in self._coordinator.data: - features = features | ClimateEntityFeature.TARGET_TEMPERATURE + if self._coordinator.data.get(Attribute.MODE) == 5: + features = features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE else: - if self._coordinator.data.get(Attribute.MODE) == 5: - features = features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - else: - features = features | ClimateEntityFeature.TARGET_TEMPERATURE + features = features | ClimateEntityFeature.TARGET_TEMPERATURE if self._coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) == 2: features = features | ClimateEntityFeature.TARGET_HUMIDITY diff --git a/homeassistant/components/aprilaire/const.py b/homeassistant/components/aprilaire/const.py index 5ae6d334cd91d2..d60224c7f1fe4d 100644 --- a/homeassistant/components/aprilaire/const.py +++ b/homeassistant/components/aprilaire/const.py @@ -4,3 +4,9 @@ DOMAIN = "aprilaire" LOG_NAME = "aprilaire" + +FAN_CIRCULATE = "Circulate" + +PRESET_TEMPORARY_HOLD = "Temporary" +PRESET_PERMANENT_HOLD = "Permanent" +PRESET_VACATION = "Vacation" From a64114623f0f31d91a66b42346a90c0eaf49a759 Mon Sep 17 00:00:00 2001 From: Matthew FitzGerald-Chamberlain Date: Mon, 3 Jul 2023 15:43:26 -0500 Subject: [PATCH 4/5] Code review cleanup --- homeassistant/components/aprilaire/config_flow.py | 4 +--- homeassistant/components/aprilaire/entity.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/aprilaire/config_flow.py b/homeassistant/components/aprilaire/config_flow.py index 7485265ec68a92..b4bfc111542c36 100644 --- a/homeassistant/components/aprilaire/config_flow.py +++ b/homeassistant/components/aprilaire/config_flow.py @@ -12,7 +12,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN, LOG_NAME @@ -47,8 +47,6 @@ async def async_step_user( f'aprilaire_{user_input[CONF_HOST].replace(".", "")}{user_input[CONF_PORT]}' ) self._abort_if_unique_id_configured() - except AbortFlow as err: - errors["base"] = err.reason except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = str(err) diff --git a/homeassistant/components/aprilaire/entity.py b/homeassistant/components/aprilaire/entity.py index a43905e6564d32..722f31a152f3cf 100644 --- a/homeassistant/components/aprilaire/entity.py +++ b/homeassistant/components/aprilaire/entity.py @@ -42,9 +42,7 @@ def _update_available(self): stopped: bool = self._coordinator.data.get(Attribute.STOPPED, None) - if stopped: - self._attr_available = False - elif not connected: + if stopped or not connected: self._attr_available = False else: self._attr_available = ( From 8fe667511a94f5277542449e3d846680aa1817de Mon Sep 17 00:00:00 2001 From: Matthew FitzGerald-Chamberlain Date: Mon, 3 Jul 2023 18:19:43 -0500 Subject: [PATCH 5/5] Add Aprilaire humidifier/dehumidifier --- .../components/aprilaire/__init__.py | 2 +- homeassistant/components/aprilaire/climate.py | 7 - homeassistant/components/aprilaire/const.py | 3 + .../components/aprilaire/humidifier.py | 142 +++++++++ tests/components/aprilaire/test_climate.py | 17 -- tests/components/aprilaire/test_humidifier.py | 273 ++++++++++++++++++ 6 files changed, 419 insertions(+), 25 deletions(-) create mode 100644 homeassistant/components/aprilaire/humidifier.py create mode 100644 tests/components/aprilaire/test_humidifier.py diff --git a/homeassistant/components/aprilaire/__init__.py b/homeassistant/components/aprilaire/__init__.py index d1e2e65856beae..1c9694c629be19 100644 --- a/homeassistant/components/aprilaire/__init__.py +++ b/homeassistant/components/aprilaire/__init__.py @@ -12,7 +12,7 @@ from .const import DOMAIN, LOG_NAME from .coordinator import AprilaireCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.HUMIDIFIER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, **kwargs) -> bool: diff --git a/homeassistant/components/aprilaire/climate.py b/homeassistant/components/aprilaire/climate.py index fc38e84936c602..5a5c5928b737bc 100644 --- a/homeassistant/components/aprilaire/climate.py +++ b/homeassistant/components/aprilaire/climate.py @@ -67,7 +67,6 @@ class ExtendedClimateEntityFeature(IntFlag): """Supported features of the Aprilaire climate entity.""" - TARGET_DEHUMIDITY = 2 << 10 FRESH_AIR = 2 << 11 AIR_CLEANING = 2 << 12 @@ -119,9 +118,6 @@ def supported_features(self) -> ClimateEntityFeature: if self._coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) == 2: features = features | ClimateEntityFeature.TARGET_HUMIDITY - if self._coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) == 1: - features = features | ExtendedClimateEntityFeature.TARGET_DEHUMIDITY - if self._coordinator.data.get(Attribute.AIR_CLEANING_AVAILABLE) == 1: features = features | ExtendedClimateEntityFeature.AIR_CLEANING @@ -271,9 +267,6 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: "humidification_setpoint": self._coordinator.data.get( Attribute.HUMIDIFICATION_SETPOINT ), - "dehumidification_setpoint": self._coordinator.data.get( - Attribute.DEHUMIDIFICATION_SETPOINT - ), "air_cleaning_mode": {1: "constant", 2: "automatic"}.get( self._coordinator.data.get(Attribute.AIR_CLEANING_MODE, 0), "off" ), diff --git a/homeassistant/components/aprilaire/const.py b/homeassistant/components/aprilaire/const.py index d60224c7f1fe4d..b3937e5835fa31 100644 --- a/homeassistant/components/aprilaire/const.py +++ b/homeassistant/components/aprilaire/const.py @@ -10,3 +10,6 @@ PRESET_TEMPORARY_HOLD = "Temporary" PRESET_PERMANENT_HOLD = "Permanent" PRESET_VACATION = "Vacation" + +MIN_HUMIDITY = 10 +MAX_HUMIDITY = 50 diff --git a/homeassistant/components/aprilaire/humidifier.py b/homeassistant/components/aprilaire/humidifier.py new file mode 100644 index 00000000000000..aa2f34798fa7c2 --- /dev/null +++ b/homeassistant/components/aprilaire/humidifier.py @@ -0,0 +1,142 @@ +"""The Aprilaire humidifier component.""" + +from __future__ import annotations + +from typing import Any + +from pyaprilaire.const import Attribute + +from homeassistant.components.humidifier import ( + HumidifierAction, + HumidifierDeviceClass, + HumidifierEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, MAX_HUMIDITY, MIN_HUMIDITY +from .coordinator import AprilaireCoordinator +from .entity import BaseAprilaireEntity + +DEHUMIDIFICATION_STATUS_MAP = { + 0: HumidifierAction.IDLE, + 1: HumidifierAction.IDLE, + 2: HumidifierAction.DRYING, + 3: HumidifierAction.DRYING, + 4: HumidifierAction.OFF, +} + +HUMIDIFICATION_STATUS_MAP = { + 0: HumidifierAction.IDLE, + 1: HumidifierAction.IDLE, + 2: HumidifierAction.HUMIDIFYING, + 3: HumidifierAction.OFF, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add humidifiers for passed config_entry in HA.""" + + coordinator: AprilaireCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) == 2: + async_add_entities([AprilaireHumidifier(coordinator)]) + + if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) == 1: + async_add_entities([AprilaireDehumidifier(coordinator)]) + + +class BaseAprilaireHumidifier(BaseAprilaireEntity, HumidifierEntity): + """Base entity for Aprilaire humidifier/dehumidifier.""" + + _attr_is_on = True + _attr_max_humidity = MAX_HUMIDITY + _attr_min_humidity = MIN_HUMIDITY + + @property + def current_humidity(self) -> int | None: + """Get current humidity.""" + return self._coordinator.data.get( + Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE + ) + + def turn_on(self, **kwargs: Any) -> None: + """Ignore requests to turn on the humidifier as this is not controllable.""" + + def turn_off(self, **kwargs: Any) -> None: + """Ignore requests to turn off the humidifier as this is not controllable.""" + + +class AprilaireHumidifier(BaseAprilaireHumidifier): + """An Aprilaire humidifier.""" + + _attr_device_class = HumidifierDeviceClass.HUMIDIFIER + _attr_name = "Humidifier" + + @property + def action(self) -> HumidifierAction | None: + """Return the current action.""" + + humidification_status: int | None = self._coordinator.data.get( + Attribute.HUMIDIFICATION_STATUS + ) + + if humidification_status is None: + return None + + if humidification_status_value := HUMIDIFICATION_STATUS_MAP.get( + humidification_status + ): + return humidification_status_value + + return None + + @property + def target_humidity(self) -> int | None: + """Get current target humidity.""" + return self._coordinator.data.get(Attribute.HUMIDIFICATION_SETPOINT) + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidification setpoint.""" + + await self._coordinator.client.set_humidification_setpoint(humidity) + + +class AprilaireDehumidifier(BaseAprilaireHumidifier): + """An Aprilaire dehumidifier.""" + + _attr_device_class = HumidifierDeviceClass.DEHUMIDIFIER + _attr_name = "Dehumidifier" + + @property + def action(self) -> HumidifierAction | None: + """Return the current action.""" + + dehumidification_status: int | None = self._coordinator.data.get( + Attribute.DEHUMIDIFICATION_STATUS + ) + + if dehumidification_status is None: + return None + + if dehumidification_status_value := DEHUMIDIFICATION_STATUS_MAP.get( + dehumidification_status + ): + return dehumidification_status_value + + return None + + @property + def target_humidity(self) -> int | None: + """Get current target humidity.""" + return self._coordinator.data.get(Attribute.DEHUMIDIFICATION_SETPOINT) + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidification setpoint.""" + + await self._coordinator.client.set_dehumidification_setpoint(humidity) diff --git a/tests/components/aprilaire/test_climate.py b/tests/components/aprilaire/test_climate.py index 7f9e4b9114a7ee..d1398127c3e7d6 100644 --- a/tests/components/aprilaire/test_climate.py +++ b/tests/components/aprilaire/test_climate.py @@ -221,23 +221,6 @@ def test_supported_features_humidification_available( ) -def test_supported_features_dehumidification_available( - climate: AprilaireClimate, coordinator: AprilaireCoordinator -) -> None: - """Test the supported featured with dehumidification available.""" - coordinator.data = { - "dehumidification_available": 1, - } - - assert ( - climate.supported_features - == ClimateEntityFeature.TARGET_TEMPERATURE - | ExtendedClimateEntityFeature.TARGET_DEHUMIDITY - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.FAN_MODE - ) - - def test_supported_features_air_cleaning_available( climate: AprilaireClimate, coordinator: AprilaireCoordinator ) -> None: diff --git a/tests/components/aprilaire/test_humidifier.py b/tests/components/aprilaire/test_humidifier.py new file mode 100644 index 00000000000000..e38f0216d6f55a --- /dev/null +++ b/tests/components/aprilaire/test_humidifier.py @@ -0,0 +1,273 @@ +"""Tests for the Aprilaire humidifier/dehumidifier entity.""" + +import logging +from unittest.mock import AsyncMock, Mock, patch + +from pyaprilaire.client import AprilaireClient +from pyaprilaire.const import Attribute +import pytest + +from homeassistant.components.aprilaire.const import DOMAIN, MAX_HUMIDITY, MIN_HUMIDITY +from homeassistant.components.aprilaire.coordinator import AprilaireCoordinator +from homeassistant.components.aprilaire.humidifier import ( + AprilaireDehumidifier, + AprilaireHumidifier, + async_setup_entry, +) +from homeassistant.components.humidifier.const import HumidifierAction +from homeassistant.config_entries import ConfigEntries, ConfigEntry +from homeassistant.core import Config, EventBus, HomeAssistant +from homeassistant.util import uuid as uuid_util + + +@pytest.fixture +def logger() -> logging.Logger: + """Return a logger.""" + logger = logging.getLogger() + logger.propagate = False + + return logger + + +@pytest.fixture +def client() -> AprilaireClient: + """Return a mock client.""" + return AsyncMock(AprilaireClient) + + +@pytest.fixture +def coordinator( + client: AprilaireClient, logger: logging.Logger +) -> AprilaireCoordinator: + """Return a mock coordinator.""" + coordinator_mock = AsyncMock(AprilaireCoordinator) + coordinator_mock.data = {} + coordinator_mock.client = client + coordinator_mock.logger = logger + + return coordinator_mock + + +@pytest.fixture +def entry_id() -> str: + """Return a random ID.""" + return uuid_util.random_uuid_hex() + + +@pytest.fixture +def hass(coordinator: AprilaireCoordinator, entry_id: str) -> HomeAssistant: + """Return a mock HomeAssistant instance.""" + hass_mock = AsyncMock(HomeAssistant) + hass_mock.data = {DOMAIN: {entry_id: coordinator}} + hass_mock.config_entries = AsyncMock(ConfigEntries) + hass_mock.bus = AsyncMock(EventBus) + hass_mock.config = Mock(Config) + + return hass_mock + + +@pytest.fixture +def config_entry(entry_id: str) -> ConfigEntry: + """Return a mock config entry.""" + config_entry_mock = AsyncMock(ConfigEntry) + config_entry_mock.data = {"host": "test123", "port": 123} + config_entry_mock.entry_id = entry_id + + return config_entry_mock + + +@pytest.fixture +async def humidifier( + config_entry: ConfigEntry, coordinator: AprilaireCoordinator, hass: HomeAssistant +) -> AprilaireHumidifier: + """Return a climate instance.""" + + coordinator.data[Attribute.HUMIDIFICATION_AVAILABLE] = 2 + + async_add_entities_mock = Mock() + async_get_current_platform_mock = Mock() + + with patch( + "homeassistant.helpers.entity_platform.async_get_current_platform", + new=async_get_current_platform_mock, + ): + await async_setup_entry(hass, config_entry, async_add_entities_mock) + + humidifiers_list = async_add_entities_mock.call_args_list[0][0] + + humidifier = next( + x for x in humidifiers_list[0] if isinstance(x, AprilaireHumidifier) + ) + humidifier._attr_available = True + humidifier.hass = hass + + return humidifier + + +@pytest.fixture +async def dehumidifier( + config_entry: ConfigEntry, coordinator: AprilaireCoordinator, hass: HomeAssistant +) -> AprilaireDehumidifier: + """Return a climate instance.""" + + coordinator.data[Attribute.DEHUMIDIFICATION_AVAILABLE] = 1 + + async_add_entities_mock = Mock() + async_get_current_platform_mock = Mock() + + with patch( + "homeassistant.helpers.entity_platform.async_get_current_platform", + new=async_get_current_platform_mock, + ): + await async_setup_entry(hass, config_entry, async_add_entities_mock) + + humidifiers_list = async_add_entities_mock.call_args_list[0][0] + + dehumidifier = next( + x for x in humidifiers_list[0] if isinstance(x, AprilaireDehumidifier) + ) + dehumidifier._attr_available = True + dehumidifier.hass = hass + + return dehumidifier + + +def test_humidifier_current_humidity( + coordinator: AprilaireCoordinator, humidifier: AprilaireHumidifier +): + """Test the humidifier's current humidity.""" + + coordinator.data[Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE] = 20 + + assert humidifier.current_humidity == 20 + + +def test_humidifier_on(humidifier: AprilaireHumidifier): + """Test that the humidifier is always on.""" + assert humidifier.is_on is True + + +def test_humidifier_max_humidity(humidifier: AprilaireHumidifier): + """Test the humidifier's maximum humidity.""" + assert humidifier.max_humidity == MAX_HUMIDITY + + +def test_humidifier_min_humidity(humidifier: AprilaireHumidifier): + """Test the humidifier's minimum humidity.""" + assert humidifier.min_humidity == MIN_HUMIDITY + + +def test_humidifier_action( + coordinator: AprilaireCoordinator, humidifier: AprilaireHumidifier +): + """Test the humidifier's action.""" + + assert humidifier.action is None + + coordinator.data[Attribute.HUMIDIFICATION_STATUS] = 0 + assert humidifier.action == HumidifierAction.IDLE + + coordinator.data[Attribute.HUMIDIFICATION_STATUS] = 1 + assert humidifier.action == HumidifierAction.IDLE + + coordinator.data[Attribute.HUMIDIFICATION_STATUS] = 2 + assert humidifier.action == HumidifierAction.HUMIDIFYING + + coordinator.data[Attribute.HUMIDIFICATION_STATUS] = 3 + assert humidifier.action == HumidifierAction.OFF + + coordinator.data[Attribute.HUMIDIFICATION_STATUS] = 4 + assert humidifier.action is None + + +def test_humidifier_target_humidity( + coordinator: AprilaireCoordinator, humidifier: AprilaireHumidifier +): + """Test the humidifier's target humidity.""" + + coordinator.data[Attribute.HUMIDIFICATION_SETPOINT] = 20 + + assert humidifier.target_humidity == 20 + + +async def test_humidifier_set_humidity( + client: AprilaireClient, + humidifier: AprilaireHumidifier, +) -> None: + """Test setting the humidifier's humidity.""" + + await humidifier.async_set_humidity(30) + + client.set_humidification_setpoint.assert_called_with(30) + + +def test_dehumidifier_current_humidity( + coordinator: AprilaireCoordinator, dehumidifier: AprilaireDehumidifier +): + """Test the dehumidifier's current humidity.""" + + coordinator.data[Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE] = 20 + + assert dehumidifier.current_humidity == 20 + + +def test_dehumidifier_on(dehumidifier: AprilaireDehumidifier): + """Test that the dehumidifier is always on.""" + assert dehumidifier.is_on is True + + +def test_dehumidifier_max_humidity(dehumidifier: AprilaireDehumidifier): + """Test the dehumidifier's maximum humidity.""" + assert dehumidifier.max_humidity == MAX_HUMIDITY + + +def test_dehumidifier_min_humidity(dehumidifier: AprilaireDehumidifier): + """Test the dehumidifier's minimum humidity.""" + assert dehumidifier.min_humidity == MIN_HUMIDITY + + +def test_dehumidifier_action( + coordinator: AprilaireCoordinator, dehumidifier: AprilaireDehumidifier +): + """Test the dehumidifier's action.""" + + assert dehumidifier.action is None + + coordinator.data[Attribute.DEHUMIDIFICATION_STATUS] = 0 + assert dehumidifier.action == HumidifierAction.IDLE + + coordinator.data[Attribute.DEHUMIDIFICATION_STATUS] = 1 + assert dehumidifier.action == HumidifierAction.IDLE + + coordinator.data[Attribute.DEHUMIDIFICATION_STATUS] = 2 + assert dehumidifier.action == HumidifierAction.DRYING + + coordinator.data[Attribute.DEHUMIDIFICATION_STATUS] = 3 + assert dehumidifier.action == HumidifierAction.DRYING + + coordinator.data[Attribute.DEHUMIDIFICATION_STATUS] = 4 + assert dehumidifier.action == HumidifierAction.OFF + + coordinator.data[Attribute.DEHUMIDIFICATION_STATUS] = 5 + assert dehumidifier.action is None + + +def test_dehumidifier_target_humidity( + coordinator: AprilaireCoordinator, dehumidifier: AprilaireDehumidifier +): + """Test the dehumidifier's target humidity.""" + + coordinator.data[Attribute.DEHUMIDIFICATION_SETPOINT] = 20 + + assert dehumidifier.target_humidity == 20 + + +async def test_dehumidifier_set_humidity( + client: AprilaireClient, + dehumidifier: AprilaireDehumidifier, +) -> None: + """Test setting the dehumidifier's humidity.""" + + await dehumidifier.async_set_humidity(30) + + client.set_dehumidification_setpoint.assert_called_with(30)