From fa61ad072d6f30d3b1e5ad9e8c280edec4aee1a6 Mon Sep 17 00:00:00 2001 From: danielsmyers Date: Mon, 29 Jul 2024 02:25:04 -0700 Subject: [PATCH] Add Bryant Evolution Integration (#119788) * Add an integration for Bryant Evolution HVAC systems. * Update newly created tests so that they pass. * Improve compliance with home assistant guidelines. * Added tests * remove xxx * Minor test cleanups * Add a test for reading HVAC actions. * Update homeassistant/components/bryant_evolution/__init__.py Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> * Update homeassistant/components/bryant_evolution/climate.py Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> * Update homeassistant/components/bryant_evolution/climate.py Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> * Update homeassistant/components/bryant_evolution/climate.py Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> * Update homeassistant/components/bryant_evolution/climate.py Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> * Update homeassistant/components/bryant_evolution/climate.py Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> * Update homeassistant/components/bryant_evolution/config_flow.py Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> * Address reviewer comments. * Address additional reviewer comments. * Use translation for exception error messages. * Simplify config flow. * Continue addressing comments * Use mocking rather than DI to provide a for-test client in tests. * Fix a failure in test_config_flow.py * Track host->filename in strings.json. * Use config entry ID for climate entity unique id * Guard against fan mode returning None in async_update. * Move unavailable-client check from climate.py to init.py. * Improve test coverage * Bump evolutionhttp version * Address comments * update comment * only have one _can_reach_device fn * Auto-detect which systems and zones are attached. * Add support for reconfiguration * Fix a few review comments * Introduce multiple devices * Track evolutionhttp library change that returns additional per-zone information during enumeration * Move construction of devices to init * Avoid triplicate writing * rework tests to use mocks * Correct attribute name to unbreak test * Pull magic tuple of system-zone into a constant * Address some test comments * Create test_init.py * simplify test_reconfigure * Replace disable_auto_entity_update with mocks. * Update tests/components/bryant_evolution/test_climate.py Co-authored-by: Joost Lekkerkerker * Update tests/components/bryant_evolution/test_climate.py Co-authored-by: Joost Lekkerkerker * Update tests/components/bryant_evolution/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/bryant_evolution/config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/bryant_evolution/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/bryant_evolution/test_config_flow.py Co-authored-by: Joost Lekkerkerker * fix test errors * do not access runtime_data in tests * use snapshot_platform and type fixtures --------- Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/bryant_evolution/__init__.py | 84 ++++++ .../components/bryant_evolution/climate.py | 252 +++++++++++++++++ .../bryant_evolution/config_flow.py | 87 ++++++ .../components/bryant_evolution/const.py | 4 + .../components/bryant_evolution/manifest.json | 10 + .../components/bryant_evolution/names.py | 18 ++ .../components/bryant_evolution/strings.json | 48 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/bryant_evolution/__init__.py | 1 + tests/components/bryant_evolution/conftest.py | 70 +++++ .../snapshots/test_climate.ambr | 83 ++++++ .../bryant_evolution/test_climate.py | 259 ++++++++++++++++++ .../bryant_evolution/test_config_flow.py | 170 ++++++++++++ .../components/bryant_evolution/test_init.py | 112 ++++++++ 20 files changed, 1224 insertions(+) create mode 100644 homeassistant/components/bryant_evolution/__init__.py create mode 100644 homeassistant/components/bryant_evolution/climate.py create mode 100644 homeassistant/components/bryant_evolution/config_flow.py create mode 100644 homeassistant/components/bryant_evolution/const.py create mode 100644 homeassistant/components/bryant_evolution/manifest.json create mode 100644 homeassistant/components/bryant_evolution/names.py create mode 100644 homeassistant/components/bryant_evolution/strings.json create mode 100644 tests/components/bryant_evolution/__init__.py create mode 100644 tests/components/bryant_evolution/conftest.py create mode 100644 tests/components/bryant_evolution/snapshots/test_climate.ambr create mode 100644 tests/components/bryant_evolution/test_climate.py create mode 100644 tests/components/bryant_evolution/test_config_flow.py create mode 100644 tests/components/bryant_evolution/test_init.py diff --git a/.strict-typing b/.strict-typing index 84cdbe02424..a4f6d198d97 100644 --- a/.strict-typing +++ b/.strict-typing @@ -120,6 +120,7 @@ homeassistant.components.bond.* homeassistant.components.braviatv.* homeassistant.components.brother.* homeassistant.components.browser.* +homeassistant.components.bryant_evolution.* homeassistant.components.bthome.* homeassistant.components.button.* homeassistant.components.calendar.* diff --git a/CODEOWNERS b/CODEOWNERS index 7db252a9117..fc18be91239 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -221,6 +221,8 @@ build.json @home-assistant/supervisor /tests/components/brottsplatskartan/ @gjohansson-ST /homeassistant/components/brunt/ @eavanvalkenburg /tests/components/brunt/ @eavanvalkenburg +/homeassistant/components/bryant_evolution/ @danielsmyers +/tests/components/bryant_evolution/ @danielsmyers /homeassistant/components/bsblan/ @liudger /tests/components/bsblan/ @liudger /homeassistant/components/bt_smarthub/ @typhoon2099 diff --git a/homeassistant/components/bryant_evolution/__init__.py b/homeassistant/components/bryant_evolution/__init__.py new file mode 100644 index 00000000000..6ff58ad5df5 --- /dev/null +++ b/homeassistant/components/bryant_evolution/__init__.py @@ -0,0 +1,84 @@ +"""The Bryant Evolution integration.""" + +from __future__ import annotations + +import logging + +from evolutionhttp import BryantEvolutionLocalClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_FILENAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from . import names +from .const import CONF_SYSTEM_ZONE, DOMAIN + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + +type BryantEvolutionLocalClients = dict[tuple[int, int], BryantEvolutionLocalClient] +type BryantEvolutionConfigEntry = ConfigEntry[BryantEvolutionLocalClients] +_LOGGER = logging.getLogger(__name__) + + +async def _can_reach_device(client: BryantEvolutionLocalClient) -> bool: + """Return whether we can reach the device at the given filename.""" + # Verify that we can read current temperature to check that the + # (filename, system, zone) is valid. + return await client.read_current_temperature() is not None + + +async def async_setup_entry( + hass: HomeAssistant, entry: BryantEvolutionConfigEntry +) -> bool: + """Set up Bryant Evolution from a config entry.""" + + # Add a device for the SAM itself. + sam_uid = names.sam_device_uid(entry) + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, sam_uid)}, + manufacturer="Bryant", + name="System Access Module", + ) + + # Add a device for each system. + for sys_id in (1, 2): + if not any(sz[0] == sys_id for sz in entry.data[CONF_SYSTEM_ZONE]): + _LOGGER.debug( + "Skipping system %s because it is not configured for this integration: %s", + sys_id, + entry.data[CONF_SYSTEM_ZONE], + ) + continue + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, names.system_device_uid(sam_uid, sys_id))}, + via_device=(DOMAIN, names.sam_device_uid(entry)), + manufacturer="Bryant", + name=f"System {sys_id}", + ) + + # Create a client for every zone. + entry.runtime_data = {} + for sz in entry.data[CONF_SYSTEM_ZONE]: + try: + client = await BryantEvolutionLocalClient.get_client( + sz[0], sz[1], entry.data[CONF_FILENAME] + ) + if not await _can_reach_device(client): + raise ConfigEntryNotReady + entry.runtime_data[tuple(sz)] = client + except FileNotFoundError as f: + raise ConfigEntryNotReady from f + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: BryantEvolutionConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/bryant_evolution/climate.py b/homeassistant/components/bryant_evolution/climate.py new file mode 100644 index 00000000000..dd31097a1ee --- /dev/null +++ b/homeassistant/components/bryant_evolution/climate.py @@ -0,0 +1,252 @@ +"""Support for Bryant Evolution HVAC systems.""" + +from datetime import timedelta +import logging +from typing import Any + +from evolutionhttp import BryantEvolutionLocalClient + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BryantEvolutionConfigEntry, names +from .const import CONF_SYSTEM_ZONE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +SCAN_INTERVAL = timedelta(seconds=60) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BryantEvolutionConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + + # Add a climate entity for each system/zone. + sam_uid = names.sam_device_uid(config_entry) + entities: list[Entity] = [] + for sz in config_entry.data[CONF_SYSTEM_ZONE]: + system_id = sz[0] + zone_id = sz[1] + client = config_entry.runtime_data.get(tuple(sz)) + climate = BryantEvolutionClimate( + client, + system_id, + zone_id, + sam_uid, + ) + entities.append(climate) + async_add_entities(entities, update_before_add=True) + + +class BryantEvolutionClimate(ClimateEntity): + """ClimateEntity for Bryant Evolution HVAC systems. + + Design note: this class updates using polling. However, polling + is very slow (~1500 ms / parameter). To improve the user + experience on updates, we also locally update this instance and + call async_write_ha_state as well. + """ + + _attr_has_entity_name = True + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + _attr_hvac_modes = [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ] + _attr_fan_modes = ["auto", "low", "med", "high"] + _enable_turn_on_off_backwards_compatibility = False + + def __init__( + self, + client: BryantEvolutionLocalClient, + system_id: int, + zone_id: int, + sam_uid: str, + ) -> None: + """Initialize an entity from parts.""" + self._client = client + self._attr_name = None + self._attr_unique_id = names.zone_entity_uid(sam_uid, system_id, zone_id) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer="Bryant", + via_device=(DOMAIN, names.system_device_uid(sam_uid, system_id)), + name=f"System {system_id} Zone {zone_id}", + ) + + async def async_update(self) -> None: + """Update the entity state.""" + self._attr_current_temperature = await self._client.read_current_temperature() + if (fan_mode := await self._client.read_fan_mode()) is not None: + self._attr_fan_mode = fan_mode.lower() + else: + self._attr_fan_mode = None + self._attr_target_temperature = None + self._attr_target_temperature_high = None + self._attr_target_temperature_low = None + self._attr_hvac_mode = await self._read_hvac_mode() + + # Set target_temperature or target_temperature_{high, low} based on mode. + match self._attr_hvac_mode: + case HVACMode.HEAT: + self._attr_target_temperature = ( + await self._client.read_heating_setpoint() + ) + case HVACMode.COOL: + self._attr_target_temperature = ( + await self._client.read_cooling_setpoint() + ) + case HVACMode.HEAT_COOL: + self._attr_target_temperature_high = ( + await self._client.read_cooling_setpoint() + ) + self._attr_target_temperature_low = ( + await self._client.read_heating_setpoint() + ) + case HVACMode.OFF: + pass + case _: + _LOGGER.error("Unknown HVAC mode %s", self._attr_hvac_mode) + + # Note: depends on current temperature and target temperature low read + # above. + self._attr_hvac_action = await self._read_hvac_action() + + async def _read_hvac_mode(self) -> HVACMode: + mode_and_active = await self._client.read_hvac_mode() + if not mode_and_active: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="failed_to_read_hvac_mode" + ) + mode = mode_and_active[0] + mode_enum = { + "HEAT": HVACMode.HEAT, + "COOL": HVACMode.COOL, + "AUTO": HVACMode.HEAT_COOL, + "OFF": HVACMode.OFF, + }.get(mode.upper()) + if mode_enum is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_parse_hvac_mode", + translation_placeholders={"mode": mode}, + ) + return mode_enum + + async def _read_hvac_action(self) -> HVACAction: + """Return the current running hvac operation.""" + mode_and_active = await self._client.read_hvac_mode() + if not mode_and_active: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="failed_to_read_hvac_action" + ) + mode, is_active = mode_and_active + if not is_active: + return HVACAction.OFF + match mode.upper(): + case "HEAT": + return HVACAction.HEATING + case "COOL": + return HVACAction.COOLING + case "OFF": + return HVACAction.OFF + case "AUTO": + # In AUTO, we need to figure out what the actual action is + # based on the setpoints. + if ( + self.current_temperature is not None + and self.target_temperature_low is not None + ): + if self.current_temperature > self.target_temperature_low: + # If the system is on and the current temperature is + # higher than the point at which heating would activate, + # then we must be cooling. + return HVACAction.COOLING + return HVACAction.HEATING + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_parse_hvac_mode", + translation_placeholders={ + "mode_and_active": mode_and_active, + "current_temperature": str(self.current_temperature), + "target_temperature_low": str(self.target_temperature_low), + }, + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.HEAT_COOL: + hvac_mode = HVACMode.AUTO + if not await self._client.set_hvac_mode(hvac_mode): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="failed_to_set_hvac_mode" + ) + self._attr_hvac_mode = hvac_mode + self._async_write_ha_state() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if kwargs.get("target_temp_high"): + temp = int(kwargs["target_temp_high"]) + if not await self._client.set_cooling_setpoint(temp): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="failed_to_set_clsp" + ) + self._attr_target_temperature_high = temp + + if kwargs.get("target_temp_low"): + temp = int(kwargs["target_temp_low"]) + if not await self._client.set_heating_setpoint(temp): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="failed_to_set_htsp" + ) + self._attr_target_temperature_low = temp + + if kwargs.get("temperature"): + temp = int(kwargs["temperature"]) + fn = ( + self._client.set_heating_setpoint + if self.hvac_mode == HVACMode.HEAT + else self._client.set_cooling_setpoint + ) + if not await fn(temp): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="failed_to_set_temp" + ) + self._attr_target_temperature = temp + + # If we get here, we must have changed something unless HA allowed an + # invalid service call (without any recognized kwarg). + self._async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + if not await self._client.set_fan_mode(fan_mode): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="failed_to_set_fan_mode" + ) + self._attr_fan_mode = fan_mode.lower() + self.async_write_ha_state() diff --git a/homeassistant/components/bryant_evolution/config_flow.py b/homeassistant/components/bryant_evolution/config_flow.py new file mode 100644 index 00000000000..a6b07daf96b --- /dev/null +++ b/homeassistant/components/bryant_evolution/config_flow.py @@ -0,0 +1,87 @@ +"""Config flow for Bryant Evolution integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from evolutionhttp import BryantEvolutionLocalClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_FILENAME +from homeassistant.helpers.typing import UNDEFINED + +from .const import CONF_SYSTEM_ZONE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_FILENAME, default="/dev/ttyUSB0"): str, + } +) + + +async def _enumerate_sz(tty: str) -> list[tuple[int, int]]: + """Return (system, zone) tuples for each system+zone accessible through tty.""" + return [ + (system_id, zone.zone_id) + for system_id in (1, 2) + for zone in await BryantEvolutionLocalClient.enumerate_zones(system_id, tty) + ] + + +class BryantConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Bryant Evolution.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + system_zone = await _enumerate_sz(user_input[CONF_FILENAME]) + except FileNotFoundError: + _LOGGER.error("Could not open %s: not found", user_input[CONF_FILENAME]) + errors["base"] = "cannot_connect" + else: + if len(system_zone) != 0: + return self.async_create_entry( + title=f"SAM at {user_input[CONF_FILENAME]}", + data={ + CONF_FILENAME: user_input[CONF_FILENAME], + CONF_SYSTEM_ZONE: system_zone, + }, + ) + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle integration reconfiguration.""" + errors: dict[str, str] = {} + if user_input is not None: + system_zone = await _enumerate_sz(user_input[CONF_FILENAME]) + if len(system_zone) != 0: + our_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + assert our_entry is not None, "Could not find own entry" + return self.async_update_reload_and_abort( + entry=our_entry, + data={ + CONF_FILENAME: user_input[CONF_FILENAME], + CONF_SYSTEM_ZONE: system_zone, + }, + unique_id=UNDEFINED, + reason="reconfigured", + ) + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="reconfigure", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/bryant_evolution/const.py b/homeassistant/components/bryant_evolution/const.py new file mode 100644 index 00000000000..82637b34eb9 --- /dev/null +++ b/homeassistant/components/bryant_evolution/const.py @@ -0,0 +1,4 @@ +"""Constants for the Bryant Evolution integration.""" + +DOMAIN = "bryant_evolution" +CONF_SYSTEM_ZONE = "system_zone" diff --git a/homeassistant/components/bryant_evolution/manifest.json b/homeassistant/components/bryant_evolution/manifest.json new file mode 100644 index 00000000000..27fd8860e76 --- /dev/null +++ b/homeassistant/components/bryant_evolution/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "bryant_evolution", + "name": "Bryant Evolution", + "codeowners": ["@danielsmyers"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/bryant_evolution", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["evolutionhttp==0.0.18"] +} diff --git a/homeassistant/components/bryant_evolution/names.py b/homeassistant/components/bryant_evolution/names.py new file mode 100644 index 00000000000..dbe0eb65b60 --- /dev/null +++ b/homeassistant/components/bryant_evolution/names.py @@ -0,0 +1,18 @@ +"""Functions to generate names for devices and entities.""" + +from homeassistant.config_entries import ConfigEntry + + +def sam_device_uid(entry: ConfigEntry) -> str: + """Return the UID for the SAM device.""" + return entry.entry_id + + +def system_device_uid(sam_uid: str, system_id: int) -> str: + """Return the UID for a given system (e.g., 1) under a SAM.""" + return f"{sam_uid}-S{system_id}" + + +def zone_entity_uid(sam_uid: str, system_id: int, zone_id: int) -> str: + """Return the UID for a given system and zone (e.g., 1 and 2) under a SAM.""" + return f"{sam_uid}-S{system_id}-Z{zone_id}" diff --git a/homeassistant/components/bryant_evolution/strings.json b/homeassistant/components/bryant_evolution/strings.json new file mode 100644 index 00000000000..1ce9d58bb10 --- /dev/null +++ b/homeassistant/components/bryant_evolution/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "step": { + "user": { + "data": { + "filename": "Serial port filename" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "exceptions": { + "failed_to_read_hvac_mode": { + "message": "Failed to read current HVAC mode" + }, + "failed_to_parse_hvac_mode": { + "message": "Cannot parse response to HVACMode: {mode}" + }, + "failed_to_read_hvac_action": { + "message": "Failed to read current HVAC action" + }, + "failed_to_parse_hvac_action": { + "message": "Could not determine HVAC action: {mode_and_active}, {self.current_temperature}, {self.target_temperature_low}" + }, + "failed_to_set_hvac_mode": { + "message": "Failed to set HVAC mode" + }, + "failed_to_set_clsp": { + "message": "Failed to set cooling setpoint" + }, + "failed_to_set_htsp": { + "message": "Failed to set heating setpoint" + }, + "failed_to_set_temp": { + "message": "Failed to set temperature" + }, + "failed_to_set_fan_mode": { + "message": "Failed to set fan mode" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1cea31202bc..e7d5278dd89 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -93,6 +93,7 @@ FLOWS = { "brother", "brottsplatskartan", "brunt", + "bryant_evolution", "bsblan", "bthome", "buienradar", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 74efe96dd2d..8bfef6a9887 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -810,6 +810,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "bryant_evolution": { + "name": "Bryant Evolution", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "bsblan": { "name": "BSB-Lan", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 9a35b74e6d5..dd7904d798b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -955,6 +955,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bryant_evolution.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.bthome.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index b4df02e398f..3b599b00ce8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -845,6 +845,9 @@ eufylife-ble-client==0.1.8 # homeassistant.components.evohome evohome-async==0.4.20 +# homeassistant.components.bryant_evolution +evolutionhttp==0.0.18 + # homeassistant.components.faa_delays faadelays==2023.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a949a12623f..27d112fb4f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -711,6 +711,9 @@ eternalegypt==0.0.16 # homeassistant.components.eufylife_ble eufylife-ble-client==0.1.8 +# homeassistant.components.bryant_evolution +evolutionhttp==0.0.18 + # homeassistant.components.faa_delays faadelays==2023.9.1 diff --git a/tests/components/bryant_evolution/__init__.py b/tests/components/bryant_evolution/__init__.py new file mode 100644 index 00000000000..22fa2950253 --- /dev/null +++ b/tests/components/bryant_evolution/__init__.py @@ -0,0 +1 @@ +"""Tests for the Bryant Evolution integration.""" diff --git a/tests/components/bryant_evolution/conftest.py b/tests/components/bryant_evolution/conftest.py new file mode 100644 index 00000000000..cc9dfbec1e1 --- /dev/null +++ b/tests/components/bryant_evolution/conftest.py @@ -0,0 +1,70 @@ +"""Common fixtures for the Bryant Evolution tests.""" + +from collections.abc import Generator, Mapping +from unittest.mock import AsyncMock, patch + +from evolutionhttp import BryantEvolutionLocalClient +import pytest + +from homeassistant.components.bryant_evolution.const import CONF_SYSTEM_ZONE, DOMAIN +from homeassistant.const import CONF_FILENAME +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.bryant_evolution.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +DEFAULT_SYSTEM_ZONES = ((1, 1), (1, 2), (2, 3)) +""" +A tuple of (system, zone) pairs representing the default system and zone configurations +for the Bryant Evolution integration. +""" + + +@pytest.fixture(autouse=True) +def mock_evolution_client_factory() -> Generator[AsyncMock, None, None]: + """Mock an Evolution client.""" + with patch( + "evolutionhttp.BryantEvolutionLocalClient.get_client", + austospec=True, + ) as mock_get_client: + clients: Mapping[tuple[int, int], AsyncMock] = {} + for system, zone in DEFAULT_SYSTEM_ZONES: + clients[(system, zone)] = AsyncMock(spec=BryantEvolutionLocalClient) + client = clients[system, zone] + client.read_zone_name.return_value = f"System {system} Zone {zone}" + client.read_current_temperature.return_value = 75 + client.read_hvac_mode.return_value = ("COOL", False) + client.read_fan_mode.return_value = "AUTO" + client.read_cooling_setpoint.return_value = 72 + mock_get_client.side_effect = lambda system, zone, tty: clients[ + (system, zone) + ] + yield mock_get_client + + +@pytest.fixture +async def mock_evolution_entry( + hass: HomeAssistant, + mock_evolution_client_factory: AsyncMock, +) -> MockConfigEntry: + """Configure and return a Bryant evolution integration.""" + hass.config.units = US_CUSTOMARY_SYSTEM + entry = MockConfigEntry( + entry_id="01J3XJZSTEF6G5V0QJX6HBC94T", # For determinism in snapshot tests + domain=DOMAIN, + data={CONF_FILENAME: "/dev/ttyUSB0", CONF_SYSTEM_ZONE: [(1, 1)]}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry diff --git a/tests/components/bryant_evolution/snapshots/test_climate.ambr b/tests/components/bryant_evolution/snapshots/test_climate.ambr new file mode 100644 index 00000000000..4f6c1f2bbc4 --- /dev/null +++ b/tests/components/bryant_evolution/snapshots/test_climate.ambr @@ -0,0 +1,83 @@ +# serializer version: 1 +# name: test_setup_integration_success[climate.system_1_zone_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'med', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 95, + 'min_temp': 45, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.system_1_zone_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'bryant_evolution', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01J3XJZSTEF6G5V0QJX6HBC94T-S1-Z1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_integration_success[climate.system_1_zone_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 75, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'low', + 'med', + 'high', + ]), + 'friendly_name': 'System 1 Zone 1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 95, + 'min_temp': 45, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 72, + }), + 'context': , + 'entity_id': 'climate.system_1_zone_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/bryant_evolution/test_climate.py b/tests/components/bryant_evolution/test_climate.py new file mode 100644 index 00000000000..42944c32bc2 --- /dev/null +++ b/tests/components/bryant_evolution/test_climate.py @@ -0,0 +1,259 @@ +"""Test the BryantEvolutionClient type.""" + +from collections.abc import Generator +from datetime import timedelta +import logging +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.bryant_evolution.climate import SCAN_INTERVAL +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +_LOGGER = logging.getLogger(__name__) + + +async def trigger_polling(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: + """Trigger a polling event.""" + freezer.tick(SCAN_INTERVAL + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + +async def test_setup_integration_success( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_evolution_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test that an instance can be constructed.""" + await snapshot_platform( + hass, entity_registry, snapshot, mock_evolution_entry.entry_id + ) + + +async def test_set_temperature_mode_cool( + hass: HomeAssistant, + mock_evolution_entry: MockConfigEntry, + mock_evolution_client_factory: Generator[AsyncMock, None, None], + freezer: FrozenDateTimeFactory, +) -> None: + """Test setting the temperature in cool mode.""" + # Start with known initial conditions + client = await mock_evolution_client_factory(1, 1, "/dev/unused") + client.read_hvac_mode.return_value = ("COOL", False) + client.read_cooling_setpoint.return_value = 75 + await trigger_polling(hass, freezer) + state = hass.states.get("climate.system_1_zone_1") + assert state.attributes["temperature"] == 75, state.attributes + + # Make the call, modifting the mock client to throw an exception on + # read to ensure that the update is visible iff we call + # async_update_ha_state. + data = {ATTR_TEMPERATURE: 70} + data[ATTR_ENTITY_ID] = "climate.system_1_zone_1" + client.read_cooling_setpoint.side_effect = Exception("fake failure") + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True + ) + + # Verify effect. + client.set_cooling_setpoint.assert_called_once_with(70) + state = hass.states.get("climate.system_1_zone_1") + assert state.attributes["temperature"] == 70 + + +async def test_set_temperature_mode_heat( + hass: HomeAssistant, + mock_evolution_entry: MockConfigEntry, + mock_evolution_client_factory: Generator[AsyncMock, None, None], + freezer: FrozenDateTimeFactory, +) -> None: + """Test setting the temperature in heat mode.""" + + # Start with known initial conditions + client = await mock_evolution_client_factory(1, 1, "/dev/unused") + client.read_hvac_mode.return_value = ("HEAT", False) + client.read_heating_setpoint.return_value = 60 + await trigger_polling(hass, freezer) + + # Make the call, modifting the mock client to throw an exception on + # read to ensure that the update is visible iff we call + # async_update_ha_state. + data = {"temperature": 65} + data[ATTR_ENTITY_ID] = "climate.system_1_zone_1" + client.read_heating_setpoint.side_effect = Exception("fake failure") + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True + ) + # Verify effect. + state = hass.states.get("climate.system_1_zone_1") + assert state.attributes["temperature"] == 65, state.attributes + + +async def test_set_temperature_mode_heat_cool( + hass: HomeAssistant, + mock_evolution_entry: MockConfigEntry, + mock_evolution_client_factory: Generator[AsyncMock, None, None], + freezer: FrozenDateTimeFactory, +) -> None: + """Test setting the temperature in heat_cool mode.""" + + # Enter heat_cool with known setpoints + mock_client = await mock_evolution_client_factory(1, 1, "/dev/unused") + mock_client.read_hvac_mode.return_value = ("AUTO", False) + mock_client.read_cooling_setpoint.return_value = 90 + mock_client.read_heating_setpoint.return_value = 40 + await trigger_polling(hass, freezer) + state = hass.states.get("climate.system_1_zone_1") + assert state.state == "heat_cool" + assert state.attributes["target_temp_low"] == 40 + assert state.attributes["target_temp_high"] == 90 + + # Make the call, modifting the mock client to throw an exception on + # read to ensure that the update is visible iff we call + # async_update_ha_state. + mock_client.read_heating_setpoint.side_effect = Exception("fake failure") + mock_client.read_cooling_setpoint.side_effect = Exception("fake failure") + data = {"target_temp_low": 70, "target_temp_high": 80} + data[ATTR_ENTITY_ID] = "climate.system_1_zone_1" + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True + ) + state = hass.states.get("climate.system_1_zone_1") + assert state.attributes["target_temp_low"] == 70, state.attributes + assert state.attributes["target_temp_high"] == 80, state.attributes + mock_client.set_cooling_setpoint.assert_called_once_with(80) + mock_client.set_heating_setpoint.assert_called_once_with(70) + + +async def test_set_fan_mode( + hass: HomeAssistant, + mock_evolution_entry: MockConfigEntry, + mock_evolution_client_factory: Generator[AsyncMock, None, None], +) -> None: + """Test that setting fan mode works.""" + mock_client = await mock_evolution_client_factory(1, 1, "/dev/unused") + fan_modes = ["auto", "low", "med", "high"] + for mode in fan_modes: + # Make the call, modifting the mock client to throw an exception on + # read to ensure that the update is visible iff we call + # async_update_ha_state. + mock_client.read_fan_mode.side_effect = Exception("fake failure") + data = {ATTR_FAN_MODE: mode} + data[ATTR_ENTITY_ID] = "climate.system_1_zone_1" + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, data, blocking=True + ) + assert ( + hass.states.get("climate.system_1_zone_1").attributes[ATTR_FAN_MODE] == mode + ) + mock_client.set_fan_mode.assert_called_with(mode) + + +@pytest.mark.parametrize( + ("hvac_mode", "evolution_mode"), + [("heat_cool", "auto"), ("heat", "heat"), ("cool", "cool"), ("off", "off")], +) +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_evolution_entry: MockConfigEntry, + mock_evolution_client_factory: Generator[AsyncMock, None, None], + hvac_mode, + evolution_mode, +) -> None: + """Test that setting HVAC mode works.""" + mock_client = await mock_evolution_client_factory(1, 1, "/dev/unused") + + # Make the call, modifting the mock client to throw an exception on + # read to ensure that the update is visible iff we call + # async_update_ha_state. + data = {ATTR_HVAC_MODE: hvac_mode} + data[ATTR_ENTITY_ID] = "climate.system_1_zone_1" + mock_client.read_hvac_mode.side_effect = Exception("fake failure") + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, data, blocking=True + ) + await hass.async_block_till_done() + assert hass.states.get("climate.system_1_zone_1").state == evolution_mode + mock_client.set_hvac_mode.assert_called_with(evolution_mode) + + +@pytest.mark.parametrize( + ("curr_temp", "expected_action"), + [(62, HVACAction.HEATING), (70, HVACAction.OFF), (80, HVACAction.COOLING)], +) +async def test_read_hvac_action_heat_cool( + hass: HomeAssistant, + mock_evolution_entry: MockConfigEntry, + mock_evolution_client_factory: Generator[AsyncMock, None, None], + freezer: FrozenDateTimeFactory, + curr_temp: int, + expected_action: HVACAction, +) -> None: + """Test that we can read the current HVAC action in heat_cool mode.""" + htsp = 68 + clsp = 72 + + mock_client = await mock_evolution_client_factory(1, 1, "/dev/unused") + mock_client.read_heating_setpoint.return_value = htsp + mock_client.read_cooling_setpoint.return_value = clsp + is_active = curr_temp < htsp or curr_temp > clsp + mock_client.read_hvac_mode.return_value = ("auto", is_active) + mock_client.read_current_temperature.return_value = curr_temp + await trigger_polling(hass, freezer) + state = hass.states.get("climate.system_1_zone_1") + assert state.attributes[ATTR_HVAC_ACTION] == expected_action + + +@pytest.mark.parametrize( + ("mode", "active", "expected_action"), + [ + ("heat", True, "heating"), + ("heat", False, "off"), + ("cool", True, "cooling"), + ("cool", False, "off"), + ("off", False, "off"), + ], +) +async def test_read_hvac_action( + hass: HomeAssistant, + mock_evolution_entry: MockConfigEntry, + mock_evolution_client_factory: Generator[AsyncMock, None, None], + freezer: FrozenDateTimeFactory, + mode: str, + active: bool, + expected_action: str, +) -> None: + """Test that we can read the current HVAC action.""" + # Initial state should be no action. + assert ( + hass.states.get("climate.system_1_zone_1").attributes[ATTR_HVAC_ACTION] + == HVACAction.OFF + ) + # Perturb the system and verify we see an action. + mock_client = await mock_evolution_client_factory(1, 1, "/dev/unused") + mock_client.read_heating_setpoint.return_value = 75 # Needed if mode == heat + mock_client.read_hvac_mode.return_value = (mode, active) + await trigger_polling(hass, freezer) + assert ( + hass.states.get("climate.system_1_zone_1").attributes[ATTR_HVAC_ACTION] + == expected_action + ) diff --git a/tests/components/bryant_evolution/test_config_flow.py b/tests/components/bryant_evolution/test_config_flow.py new file mode 100644 index 00000000000..39d203201eb --- /dev/null +++ b/tests/components/bryant_evolution/test_config_flow.py @@ -0,0 +1,170 @@ +"""Test the Bryant Evolution config flow.""" + +from unittest.mock import DEFAULT, AsyncMock, patch + +from evolutionhttp import BryantEvolutionLocalClient, ZoneInfo + +from homeassistant import config_entries +from homeassistant.components.bryant_evolution.const import CONF_SYSTEM_ZONE, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_FILENAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch.object( + BryantEvolutionLocalClient, + "enumerate_zones", + return_value=DEFAULT, + ) as mock_call, + ): + mock_call.side_effect = lambda system_id, filename: { + 1: [ZoneInfo(1, 1, "S1Z1"), ZoneInfo(1, 2, "S1Z2")], + 2: [ZoneInfo(2, 3, "S2Z2"), ZoneInfo(2, 4, "S2Z3")], + }.get(system_id, []) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FILENAME: "test_form_success", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY, result + assert result["title"] == "SAM at test_form_success" + assert result["data"] == { + CONF_FILENAME: "test_form_success", + CONF_SYSTEM_ZONE: [(1, 1), (1, 2), (2, 3), (2, 4)], + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, + mock_evolution_client_factory: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with ( + patch.object( + BryantEvolutionLocalClient, + "enumerate_zones", + return_value=DEFAULT, + ) as mock_call, + ): + mock_call.return_value = [] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FILENAME: "test_form_cannot_connect", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with ( + patch.object( + BryantEvolutionLocalClient, + "enumerate_zones", + return_value=DEFAULT, + ) as mock_call, + ): + mock_call.side_effect = lambda system_id, filename: { + 1: [ZoneInfo(1, 1, "S1Z1"), ZoneInfo(1, 2, "S1Z2")], + 2: [ZoneInfo(2, 3, "S2Z3"), ZoneInfo(2, 4, "S2Z4")], + }.get(system_id, []) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FILENAME: "some-serial", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "SAM at some-serial" + assert result["data"] == { + CONF_FILENAME: "some-serial", + CONF_SYSTEM_ZONE: [(1, 1), (1, 2), (2, 3), (2, 4)], + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect_bad_file( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_evolution_client_factory: AsyncMock, +) -> None: + """Test we handle cannot connect error from a missing file.""" + mock_evolution_client_factory.side_effect = FileNotFoundError("test error") + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + # This file does not exist. + CONF_FILENAME: "test_form_cannot_connect_bad_file", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_reconfigure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_evolution_entry: MockConfigEntry, +) -> None: + """Test that reconfigure discovers additional systems and zones.""" + + # Reconfigure with additional systems and zones. + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_evolution_entry.entry_id, + }, + ) + with ( + patch.object( + BryantEvolutionLocalClient, + "enumerate_zones", + return_value=DEFAULT, + ) as mock_call, + ): + mock_call.side_effect = lambda system_id, filename: { + 1: [ZoneInfo(1, 1, "S1Z1")], + 2: [ZoneInfo(2, 3, "S2Z3"), ZoneInfo(2, 4, "S2Z4"), ZoneInfo(2, 5, "S2Z5")], + }.get(system_id, []) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FILENAME: "test_reconfigure", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT, result + assert result["reason"] == "reconfigured" + config_entry = hass.config_entries.async_entries()[0] + assert config_entry.data[CONF_SYSTEM_ZONE] == [ + (1, 1), + (2, 3), + (2, 4), + (2, 5), + ] diff --git a/tests/components/bryant_evolution/test_init.py b/tests/components/bryant_evolution/test_init.py new file mode 100644 index 00000000000..72734f7e117 --- /dev/null +++ b/tests/components/bryant_evolution/test_init.py @@ -0,0 +1,112 @@ +"""Test setup for the bryant_evolution integration.""" + +import logging +from unittest.mock import AsyncMock + +from evolutionhttp import BryantEvolutionLocalClient +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.bryant_evolution.const import CONF_SYSTEM_ZONE, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_FILENAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from .conftest import DEFAULT_SYSTEM_ZONES +from .test_climate import trigger_polling + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +async def test_setup_integration_prevented_by_unavailable_client( + hass: HomeAssistant, mock_evolution_client_factory: AsyncMock +) -> None: + """Test that setup throws ConfigEntryNotReady when the client is unavailable.""" + mock_evolution_client_factory.side_effect = FileNotFoundError("test error") + mock_evolution_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_FILENAME: "test_setup_integration_prevented_by_unavailable_client", + CONF_SYSTEM_ZONE: [(1, 1)], + }, + ) + mock_evolution_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_evolution_entry.entry_id) + await hass.async_block_till_done() + assert mock_evolution_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_integration_client_returns_none( + hass: HomeAssistant, mock_evolution_client_factory: AsyncMock +) -> None: + """Test that an unavailable client causes ConfigEntryNotReady.""" + mock_client = AsyncMock(spec=BryantEvolutionLocalClient) + mock_evolution_client_factory.side_effect = None + mock_evolution_client_factory.return_value = mock_client + mock_client.read_fan_mode.return_value = None + mock_client.read_current_temperature.return_value = None + mock_client.read_hvac_mode.return_value = None + mock_client.read_cooling_setpoint.return_value = None + mock_client.read_zone_name.return_value = None + mock_evolution_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_FILENAME: "/dev/ttyUSB0", CONF_SYSTEM_ZONE: [(1, 1)]}, + ) + mock_evolution_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_evolution_entry.entry_id) + await hass.async_block_till_done() + assert mock_evolution_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_multiple_systems_zones( + hass: HomeAssistant, + mock_evolution_client_factory: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a device with multiple systems and zones works.""" + hass.config.units = US_CUSTOMARY_SYSTEM + mock_evolution_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_FILENAME: "/dev/ttyUSB0", CONF_SYSTEM_ZONE: DEFAULT_SYSTEM_ZONES}, + ) + mock_evolution_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_evolution_entry.entry_id) + await hass.async_block_till_done() + + # Set the temperature of each zone to its zone number so that we can + # ensure we've created the right client for each zone. + for sz, client in mock_evolution_entry.runtime_data.items(): + client.read_current_temperature.return_value = sz[1] + await trigger_polling(hass, freezer) + + # Check that each system and zone has the expected temperature value to + # verify that the initial setup flow worked as expected. + for sz in DEFAULT_SYSTEM_ZONES: + system = sz[0] + zone = sz[1] + state = hass.states.get(f"climate.system_{system}_zone_{zone}") + assert state, hass.states.async_all() + assert state.attributes["current_temperature"] == zone + + # Check that the created devices are wired to each other as expected. + device_registry = dr.async_get(hass) + + def find_device(name): + return next(filter(lambda x: x.name == name, device_registry.devices.values())) + + sam = find_device("System Access Module") + s1 = find_device("System 1") + s2 = find_device("System 2") + s1z1 = find_device("System 1 Zone 1") + s1z2 = find_device("System 1 Zone 2") + s2z3 = find_device("System 2 Zone 3") + + assert sam.via_device_id is None + assert s1.via_device_id == sam.id + assert s2.via_device_id == sam.id + assert s1z1.via_device_id == s1.id + assert s1z2.via_device_id == s1.id + assert s2z3.via_device_id == s2.id