From dfe16eb136901d70cfb0d7738144ffe7a144c5d0 Mon Sep 17 00:00:00 2001 From: Sam Sinnamon Date: Wed, 21 Jul 2021 12:23:40 +0000 Subject: [PATCH 01/10] airtouch 4 climate control integration --- .coveragerc | 3 + CODEOWNERS | 1 + .../components/airtouch4/__init__.py | 86 +++++ homeassistant/components/airtouch4/climate.py | 350 ++++++++++++++++++ .../components/airtouch4/config_flow.py | 60 +++ homeassistant/components/airtouch4/const.py | 3 + .../components/airtouch4/manifest.json | 13 + .../components/airtouch4/strings.json | 16 + .../components/airtouch4/translations/en.json | 17 + homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/airtouch4/__init__.py | 1 + .../components/airtouch4/test_config_flow.py | 102 +++++ 14 files changed, 659 insertions(+) create mode 100644 homeassistant/components/airtouch4/__init__.py create mode 100644 homeassistant/components/airtouch4/climate.py create mode 100644 homeassistant/components/airtouch4/config_flow.py create mode 100644 homeassistant/components/airtouch4/const.py create mode 100644 homeassistant/components/airtouch4/manifest.json create mode 100644 homeassistant/components/airtouch4/strings.json create mode 100644 homeassistant/components/airtouch4/translations/en.json create mode 100644 tests/components/airtouch4/__init__.py create mode 100644 tests/components/airtouch4/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 4b5c082065038a..6b0d030049c7ee 100644 --- a/.coveragerc +++ b/.coveragerc @@ -36,6 +36,9 @@ omit = homeassistant/components/agent_dvr/helpers.py homeassistant/components/airnow/__init__.py homeassistant/components/airnow/sensor.py + homeassistant/components/airtouch4/__init__.py + homeassistant/components/airtouch4/climate.py + homeassistant/components/airtouch4/const.py homeassistant/components/airvisual/__init__.py homeassistant/components/airvisual/sensor.py homeassistant/components/aladdin_connect/* diff --git a/CODEOWNERS b/CODEOWNERS index c6696c485fedf9..1dedb1d421b586 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -29,6 +29,7 @@ homeassistant/components/aemet/* @noltari homeassistant/components/agent_dvr/* @ispysoftware homeassistant/components/airly/* @bieniu homeassistant/components/airnow/* @asymworks +homeassistant/components/airtouch4/* @LonePurpleWolf homeassistant/components/airvisual/* @bachya homeassistant/components/alarmdecoder/* @ajschmidt8 homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy diff --git a/homeassistant/components/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py new file mode 100644 index 00000000000000..7a333532ebe997 --- /dev/null +++ b/homeassistant/components/airtouch4/__init__.py @@ -0,0 +1,86 @@ +"""The AirTouch4 integration.""" +import logging + +from airtouch4pyapi import AirTouch + +from homeassistant.components.climate import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["climate"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up AirTouch4 from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + host = entry.data[CONF_HOST] + airtouch = AirTouch(host) + await airtouch.UpdateInfo() + info = airtouch.GetAcs() + try: + if not info: + raise ConfigEntryNotReady + except OSError as error: + raise ConfigEntryNotReady() from error + coordinator = AirtouchDataUpdateCoordinator(hass, airtouch) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = { + "coordinator": coordinator, + } + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + 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: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Airtouch data.""" + + def __init__(self, hass, airtouch): + """Initialize global Airtouch data updater.""" + self.airtouch = airtouch + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Fetch data from Airtouch.""" + try: + await self.airtouch.UpdateInfo() + return { + "acs": [ + {"ac_number": ac.AcNumber, "is_on": ac.IsOn} + for ac in self.airtouch.GetAcs() + ], + "groups": [ + { + "group_number": group.GroupNumber, + "group_name": group.GroupName, + "is_on": group.IsOn, + } + for group in self.airtouch.GetGroups() + ], + } + except OSError as error: + raise UpdateFailed from error diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py new file mode 100644 index 00000000000000..543ddd0f61a461 --- /dev/null +++ b/homeassistant/components/airtouch4/climate.py @@ -0,0 +1,350 @@ +"""AirTouch 4 component to control of AirTouch 4 Climate Devices.""" + +import logging + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_FAN_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import callback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE +AT_TO_HA_STATE = { + "Heat": HVAC_MODE_HEAT, + "Cool": HVAC_MODE_COOL, + "AutoHeat": HVAC_MODE_AUTO, + "AutoCool": HVAC_MODE_AUTO, + "Auto": HVAC_MODE_AUTO, + "Dry": HVAC_MODE_DRY, + "Fan": HVAC_MODE_FAN_ONLY, + "Off": HVAC_MODE_OFF, +} + +HA_STATE_TO_AT = {value: key for key, value in AT_TO_HA_STATE.items()} + +AT_TO_HA_FAN_SPEED = { + "Quiet": FAN_DIFFUSE, + "Low": FAN_LOW, + "Medium": FAN_MEDIUM, + "High": FAN_HIGH, + "Powerful": FAN_FOCUS, + "Auto": FAN_AUTO, + "Turbo": "turbo", +} + +HA_FAN_SPEED_TO_AT = {value: key for key, value in AT_TO_HA_FAN_SPEED.items()} + +_LOGGER = logging.getLogger(__name__) + + +def _build_entity(coordinator, group_number, info): + group = AirtouchGroup(coordinator, group_number, info) + _LOGGER.debug("Found device %s", group) + return group + + +def _build_entity_ac(coordinator, ac_number, info): + ac = AirtouchAC(coordinator, ac_number, info) + _LOGGER.debug("Found ac %s", ac) + return ac + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Airtouch 4.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + info = coordinator.data + entities = [ + _build_entity(coordinator, group["group_number"], info) + for group in info["groups"] + ] + [_build_entity_ac(coordinator, ac["ac_number"], info) for ac in info["acs"]] + + async_add_entities(entities) + + +class AirtouchAC(CoordinatorEntity, ClimateEntity): + """Representation of an AirTouch 4 ac.""" + + def __init__(self, coordinator, ac_number, info): + """Initialize the climate device.""" + super().__init__(coordinator) + self._ac_number = ac_number + self._airtouch = coordinator.airtouch + self._info = info + self._unit = self._airtouch.GetAcs()[self._ac_number] + + @callback + def _handle_coordinator_update(self): + self._unit = self._airtouch.GetAcs()[self._ac_number] + return super()._handle_coordinator_update() + + @property + def device_info(self): + """Return device info for this device.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Airtouch", + "model": "Airtouch 4", + } + + @property + def unique_id(self): + """Return unique ID for this device.""" + return f"ac_{self._ac_number}" + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FAN_MODE + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._unit.Temperature + + @property + def name(self): + """Return the name of the climate device.""" + return f"AC {self._ac_number}" + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def fan_mode(self): + """Return fan mode of the AC this group belongs to.""" + return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._ac_number].AcFanSpeed] + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsForAc(self._ac_number) + return [AT_TO_HA_FAN_SPEED[speed] for speed in airtouch_fan_speeds] + + @property + def hvac_mode(self): + """Return hvac target hvac state.""" + is_off = self._unit.PowerState == "Off" + if is_off: + return HVAC_MODE_OFF + + return AT_TO_HA_STATE[self._airtouch.acs[self._ac_number].AcMode] + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + airtouch_modes = self._airtouch.GetSupportedCoolingModesForAc(self._ac_number) + modes = [AT_TO_HA_STATE[mode] for mode in airtouch_modes] + modes.extend([HVAC_MODE_OFF]) + return modes + + async def async_set_hvac_mode(self, hvac_mode): + """Set new operation mode.""" + if hvac_mode not in HA_STATE_TO_AT: + raise ValueError(f"Unsupported HVAC mode: {hvac_mode}") + + if hvac_mode == HVAC_MODE_OFF: + return await self.async_turn_off() + await self._airtouch.SetCoolingModeForAc( + self._ac_number, HA_STATE_TO_AT[hvac_mode] + ) + # in case it isn't already, unless the HVAC mode was off, then the ac should be on + await self.async_turn_on() + self._unit = self._airtouch.GetAcs()[self._ac_number] + _LOGGER.debug("Setting operation mode of %s to %s", self._ac_number, hvac_mode) + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode): + """Set new fan mode.""" + if fan_mode not in self.fan_modes: + raise ValueError(f"Unsupported fan mode: {fan_mode}") + + _LOGGER.debug("Setting fan mode of %s to %s", self._ac_number, fan_mode) + await self._airtouch.SetFanSpeedForAc( + self._ac_number, HA_FAN_SPEED_TO_AT[fan_mode] + ) + self._unit = self._airtouch.GetAcs()[self._ac_number] + self.async_write_ha_state() + + async def async_turn_on(self): + """Turn on.""" + _LOGGER.debug("Turning %s on", self.unique_id) + # in case ac is not on. Airtouch turns itself off if no groups are turned on (even if groups turned back on) + await self._airtouch.TurnAcOn(self._ac_number) + + async def async_turn_off(self): + """Turn off.""" + _LOGGER.debug("Turning %s off", self.unique_id) + await self._airtouch.TurnAcOff(self._ac_number) + self.async_write_ha_state() + + +class AirtouchGroup(CoordinatorEntity, ClimateEntity): + """Representation of an AirTouch 4 group.""" + + def __init__(self, coordinator, group_number, info): + """Initialize the climate device.""" + super().__init__(coordinator) + self._group_number = group_number + self._airtouch = coordinator.airtouch + self._info = info + self._unit = self._airtouch.GetGroupByGroupNumber(self._group_number) + + @callback + def _handle_coordinator_update(self): + self._unit = self._airtouch.GetGroupByGroupNumber(self._group_number) + return super()._handle_coordinator_update() + + @property + def device_info(self): + """Return device info for this device.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Airtouch", + "model": "Airtouch 4", + } + + @property + def unique_id(self): + """Return unique ID for this device.""" + return self._group_number + + @property + def min_temp(self): + """Return Minimum Temperature for AC of this group.""" + return self._airtouch.acs[self._unit.BelongsToAc].MinSetpoint + + @property + def max_temp(self): + """Return Max Temperature for AC of this group.""" + return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + + @property + def name(self): + """Return the name of the climate device.""" + return self._unit.GroupName + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._unit.Temperature + + @property + def target_temperature(self): + """Return the temperature we are trying to reach.""" + return self._unit.TargetSetpoint + + @property + def hvac_mode(self): + """Return hvac target hvac state.""" + # there are other power states that aren't 'on' but still count as on (eg. 'Turbo') + is_off = self._unit.PowerState == "Off" + if is_off: + return HVAC_MODE_OFF + + return HVAC_MODE_FAN_ONLY + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + return [HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY] + + async def async_set_hvac_mode(self, hvac_mode): + """Set new operation mode.""" + if hvac_mode not in HA_STATE_TO_AT: + raise ValueError(f"Unsupported HVAC mode: {hvac_mode}") + + if hvac_mode == HVAC_MODE_OFF: + return await self.async_turn_off() + if self.hvac_mode == HVAC_MODE_OFF: + await self.async_turn_on() + self._unit = self._airtouch.GetGroups()[self._group_number] + _LOGGER.debug( + "Setting operation mode of %s to %s", self._group_number, hvac_mode + ) + self.async_write_ha_state() + + @property + def fan_mode(self): + """Return fan mode of the AC this group belongs to.""" + return AT_TO_HA_FAN_SPEED[self._airtouch.acs[self._unit.BelongsToAc].AcFanSpeed] + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + airtouch_fan_speeds = self._airtouch.GetSupportedFanSpeedsByGroup( + self._group_number + ) + return [AT_TO_HA_FAN_SPEED[speed] for speed in airtouch_fan_speeds] + + async def async_set_temperature(self, **kwargs): + """Set new target temperatures.""" + temp = kwargs.get(ATTR_TEMPERATURE) + + _LOGGER.debug("Setting temp of %s to %s", self._group_number, str(temp)) + self._unit = await self._airtouch.SetGroupToTemperature( + self._group_number, int(temp) + ) + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode): + """Set new fan mode.""" + if fan_mode not in self.fan_modes: + raise ValueError(f"Unsupported fan mode: {fan_mode}") + + _LOGGER.debug("Setting fan mode of %s to %s", self._group_number, fan_mode) + self._unit = await self._airtouch.SetFanSpeedByGroup( + self._group_number, HA_FAN_SPEED_TO_AT[fan_mode] + ) + self.async_write_ha_state() + + async def async_turn_on(self): + """Turn on.""" + _LOGGER.debug("Turning %s on", self.unique_id) + await self._airtouch.TurnGroupOn(self._group_number) + + # in case ac is not on. Airtouch turns itself off if no groups are turned on (even if groups turned back on) + await self._airtouch.TurnAcOn( + self._airtouch.GetGroupByGroupNumber(self._group_number).BelongsToAc + ) + # this might cause the ac object to be wrong, so force the shared data store to update + await self.coordinator.async_request_refresh() + self.async_write_ha_state() + + async def async_turn_off(self): + """Turn off.""" + _LOGGER.debug("Turning %s off", self.unique_id) + await self._airtouch.TurnGroupOff(self._group_number) + # this will cause the ac object to be wrong (ac turns off automatically if no groups are running), so force the shared data store to update + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/airtouch4/config_flow.py b/homeassistant/components/airtouch4/config_flow.py new file mode 100644 index 00000000000000..a05ce9970fe752 --- /dev/null +++ b/homeassistant/components/airtouch4/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for AirTouch4.""" +from airtouch4pyapi import AirTouch +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_HOST + +from .const import DOMAIN + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +def _createAirtouchObject(host): + return AirTouch(host) + + +async def _validate_connection(hass: core.HomeAssistant, host): + airtouch = AirTouch(host) + await airtouch.UpdateInfo() + + if hasattr(airtouch, "error"): + if isinstance(airtouch.error, Exception): + raise airtouch.error + return airtouch.error + return bool(airtouch.GetGroups()) + + +class AirtouchConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle an Airtouch config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + + errors = {} + + host = user_input[CONF_HOST] + + try: + result = await _validate_connection(self.hass, host) + if not result: + errors["base"] = "no_units" + except Exception: + errors["base"] = "cannot_connect" + + if errors: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + return self.async_create_entry( + title=user_input[CONF_HOST], + data={ + CONF_HOST: user_input[CONF_HOST], + }, + ) diff --git a/homeassistant/components/airtouch4/const.py b/homeassistant/components/airtouch4/const.py new file mode 100644 index 00000000000000..e110a6cee81150 --- /dev/null +++ b/homeassistant/components/airtouch4/const.py @@ -0,0 +1,3 @@ +"""Constants for the AirTouch4 integration.""" + +DOMAIN = "airtouch4" diff --git a/homeassistant/components/airtouch4/manifest.json b/homeassistant/components/airtouch4/manifest.json new file mode 100644 index 00000000000000..fbefe6f5334355 --- /dev/null +++ b/homeassistant/components/airtouch4/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "airtouch4", + "name": "AirTouch 4", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airtouch4", + "requirements": [ + "airtouch4pyapi==1.0.2" + ], + "codeowners": [ + "@LonePurpleWolf" + ], + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/airtouch4/strings.json b/homeassistant/components/airtouch4/strings.json new file mode 100644 index 00000000000000..e69fd912ab968f --- /dev/null +++ b/homeassistant/components/airtouch4/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_units": "Could not find any AirTouch 4 Groups." + }, + "step": { + "user": { + "title": "Setup your AirTouch 4 connection details.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + } + } +} diff --git a/homeassistant/components/airtouch4/translations/en.json b/homeassistant/components/airtouch4/translations/en.json new file mode 100644 index 00000000000000..2bde2ea760a329 --- /dev/null +++ b/homeassistant/components/airtouch4/translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + + "error": { + "cannot_connect": "Failed to connect", + "no_units": "Could not find any AirTouch 4 Groups." + }, + "step": { + "user": { + "title": "Setup your AirTouch 4.", + "data": { + "host": "Host" + } + } + } + } + } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b4a6fcc3775e99..6be4f70b38e06b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -16,6 +16,7 @@ "agent_dvr", "airly", "airnow", + "airtouch4", "airvisual", "alarmdecoder", "almond", diff --git a/requirements_all.txt b/requirements_all.txt index dfbfd346929f0f..badf6983bf652c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -257,6 +257,9 @@ aioymaps==1.1.0 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airtouch4 +airtouch4pyapi==1.0.2 + # homeassistant.components.aladdin_connect aladdin_connect==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8ab3042137d679..54cbe119389192 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -178,6 +178,9 @@ aioymaps==1.1.0 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airtouch4 +airtouch4pyapi==1.0.2 + # homeassistant.components.ambee ambee==0.3.0 diff --git a/tests/components/airtouch4/__init__.py b/tests/components/airtouch4/__init__.py new file mode 100644 index 00000000000000..cc267ee41d1eb2 --- /dev/null +++ b/tests/components/airtouch4/__init__.py @@ -0,0 +1 @@ +"""Tests for the AirTouch4 integration.""" diff --git a/tests/components/airtouch4/test_config_flow.py b/tests/components/airtouch4/test_config_flow.py new file mode 100644 index 00000000000000..f2418bbe20a855 --- /dev/null +++ b/tests/components/airtouch4/test_config_flow.py @@ -0,0 +1,102 @@ +"""Test the AirTouch 4 config flow.""" +from airtouch4pyapi.airtouch import AirTouchAc, AirTouchGroup + +from homeassistant import config_entries +from homeassistant.components.airtouch4.const import DOMAIN + +from tests.async_mock import patch + + +async def test_form(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + mockAc = AirTouchAc() + mockGroups = AirTouchGroup() + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch.GetAcs", + return_value=[mockAc], + ), patch( + "homeassistant.components.airtouch4.config_flow.AirTouch.UpdateInfo", + return_value=None, + ), patch( + "homeassistant.components.airtouch4.config_flow.AirTouch.GetGroups", + return_value=[mockGroups], + ), patch( + "homeassistant.components.airtouch4.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.airtouch4.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "0.0.0.1" + assert result2["data"] == { + "host": "0.0.0.1", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_timeout(hass): + """Test we handle a connection timeout.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch.GetAcs", + side_effect=TimeoutError(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_connection_refused(hass): + """Test we handle a connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_no_units(hass): + """Test we handle no units found.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mockAc = AirTouchAc() + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch.GetAcs", + return_value=[mockAc], + ), patch( + "homeassistant.components.airtouch4.config_flow.AirTouch.UpdateInfo", + return_value=None, + ), patch( + "homeassistant.components.airtouch4.config_flow.AirTouch.GetGroups", + return_value=[], + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "no_units"} From fb4375ec39753aa9a74d19783a586a8fdc22e027 Mon Sep 17 00:00:00 2001 From: Sam Sinnamon Date: Wed, 21 Jul 2021 15:02:53 +0000 Subject: [PATCH 02/10] enhance tests for airtouch. Fix linting issues --- homeassistant/components/airtouch4/climate.py | 6 +++--- homeassistant/components/airtouch4/config_flow.py | 8 ++------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index 543ddd0f61a461..fae8e6e5686d5b 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -61,9 +61,9 @@ def _build_entity(coordinator, group_number, info): def _build_entity_ac(coordinator, ac_number, info): - ac = AirtouchAC(coordinator, ac_number, info) - _LOGGER.debug("Found ac %s", ac) - return ac + air_conditioner = AirtouchAC(coordinator, ac_number, info) + _LOGGER.debug("Found ac %s", air_conditioner) + return air_conditioner async def async_setup_entry(hass, config_entry, async_add_entities): diff --git a/homeassistant/components/airtouch4/config_flow.py b/homeassistant/components/airtouch4/config_flow.py index a05ce9970fe752..6ceb18aaa326d4 100644 --- a/homeassistant/components/airtouch4/config_flow.py +++ b/homeassistant/components/airtouch4/config_flow.py @@ -10,10 +10,6 @@ DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) -def _createAirtouchObject(host): - return AirTouch(host) - - async def _validate_connection(hass: core.HomeAssistant, host): airtouch = AirTouch(host) await airtouch.UpdateInfo() @@ -21,7 +17,7 @@ async def _validate_connection(hass: core.HomeAssistant, host): if hasattr(airtouch, "error"): if isinstance(airtouch.error, Exception): raise airtouch.error - return airtouch.error + raise ConnectionError() return bool(airtouch.GetGroups()) @@ -44,7 +40,7 @@ async def async_step_user(self, user_input=None): result = await _validate_connection(self.hass, host) if not result: errors["base"] = "no_units" - except Exception: + except (OSError, ConnectionError): errors["base"] = "cannot_connect" if errors: From f2f21099d74c54fdc7ea30a5e733bd82a97662db Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 21 Jul 2021 19:30:05 +0200 Subject: [PATCH 03/10] Fix tests --- tests/components/airtouch4/test_config_flow.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/components/airtouch4/test_config_flow.py b/tests/components/airtouch4/test_config_flow.py index f2418bbe20a855..7b6f70b8bbbdbd 100644 --- a/tests/components/airtouch4/test_config_flow.py +++ b/tests/components/airtouch4/test_config_flow.py @@ -1,11 +1,11 @@ """Test the AirTouch 4 config flow.""" +from unittest.mock import patch + from airtouch4pyapi.airtouch import AirTouchAc, AirTouchGroup from homeassistant import config_entries from homeassistant.components.airtouch4.const import DOMAIN -from tests.async_mock import patch - async def test_form(hass): """Test we get the form.""" @@ -26,8 +26,6 @@ async def test_form(hass): "homeassistant.components.airtouch4.config_flow.AirTouch.GetGroups", return_value=[mockGroups], ), patch( - "homeassistant.components.airtouch4.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.airtouch4.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -41,7 +39,6 @@ async def test_form(hass): assert result2["data"] == { "host": "0.0.0.1", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 From 7e2a81fbe589424c3d2d38021a8abcb52816f93c Mon Sep 17 00:00:00 2001 From: Sam Sinnamon Date: Wed, 21 Jul 2021 23:32:56 +0000 Subject: [PATCH 04/10] rework tests --- .../components/airtouch4/test_config_flow.py | 70 +++++++++++++------ 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/tests/components/airtouch4/test_config_flow.py b/tests/components/airtouch4/test_config_flow.py index 7b6f70b8bbbdbd..2aaa5a54a4b9d1 100644 --- a/tests/components/airtouch4/test_config_flow.py +++ b/tests/components/airtouch4/test_config_flow.py @@ -1,7 +1,7 @@ """Test the AirTouch 4 config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from airtouch4pyapi.airtouch import AirTouchAc, AirTouchGroup +from airtouch4pyapi.airtouch import AirTouch, AirTouchAc, AirTouchGroup from homeassistant import config_entries from homeassistant.components.airtouch4.const import DOMAIN @@ -14,17 +14,18 @@ async def test_form(hass): ) assert result["type"] == "form" assert result["errors"] is None - mockAc = AirTouchAc() - mockGroups = AirTouchGroup() + mock_ac = AirTouchAc() + mock_groups = AirTouchGroup() + with patch( "homeassistant.components.airtouch4.config_flow.AirTouch.GetAcs", - return_value=[mockAc], + return_value=[mock_ac], ), patch( "homeassistant.components.airtouch4.config_flow.AirTouch.UpdateInfo", return_value=None, ), patch( "homeassistant.components.airtouch4.config_flow.AirTouch.GetGroups", - return_value=[mockGroups], + return_value=[mock_groups], ), patch( "homeassistant.components.airtouch4.async_setup_entry", return_value=True, @@ -47,17 +48,37 @@ async def test_form_timeout(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.error = TimeoutError() with patch( - "homeassistant.components.airtouch4.config_flow.AirTouch.GetAcs", - side_effect=TimeoutError(), + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "0.0.0.1"} ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} + +async def test_form_library_error_message(hass): + """Test we handle an unknown error message from the library.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.error = "example error message" + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} async def test_form_connection_refused(hass): @@ -65,13 +86,18 @@ async def test_form_connection_refused(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {"host": "0.0.0.1"} - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.error = ConnectionRefusedError() + with patch( + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "0.0.0.1"} + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} async def test_form_no_units(hass): @@ -80,10 +106,10 @@ async def test_form_no_units(hass): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mockAc = AirTouchAc() + mock_ac = AirTouchAc() with patch( "homeassistant.components.airtouch4.config_flow.AirTouch.GetAcs", - return_value=[mockAc], + return_value=[mock_ac], ), patch( "homeassistant.components.airtouch4.config_flow.AirTouch.UpdateInfo", return_value=None, @@ -95,5 +121,5 @@ async def test_form_no_units(hass): result["flow_id"], {"host": "0.0.0.1"} ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "no_units"} + assert result2["type"] == "form" + assert result2["errors"] == {"base": "no_units"} From 3fc82d4944b3d4ffafb1c26518ec9e4c2c6549b3 Mon Sep 17 00:00:00 2001 From: Sam Sinnamon Date: Tue, 17 Aug 2021 07:15:08 +0000 Subject: [PATCH 05/10] fix latest qa issues --- .../components/airtouch4/__init__.py | 36 ++++----- homeassistant/components/airtouch4/climate.py | 73 ++++++++----------- .../components/airtouch4/config_flow.py | 32 ++++---- .../components/airtouch4/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/airtouch4/test_config_flow.py | 42 +++++------ 7 files changed, 85 insertions(+), 104 deletions(-) diff --git a/homeassistant/components/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py index 7a333532ebe997..708999fc107121 100644 --- a/homeassistant/components/airtouch4/__init__.py +++ b/homeassistant/components/airtouch4/__init__.py @@ -2,6 +2,7 @@ import logging from airtouch4pyapi import AirTouch +from airtouch4pyapi.airtouch import AirTouchStatus from homeassistant.components.climate import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntry @@ -66,21 +67,20 @@ def __init__(self, hass, airtouch): async def _async_update_data(self): """Fetch data from Airtouch.""" - try: - await self.airtouch.UpdateInfo() - return { - "acs": [ - {"ac_number": ac.AcNumber, "is_on": ac.IsOn} - for ac in self.airtouch.GetAcs() - ], - "groups": [ - { - "group_number": group.GroupNumber, - "group_name": group.GroupName, - "is_on": group.IsOn, - } - for group in self.airtouch.GetGroups() - ], - } - except OSError as error: - raise UpdateFailed from error + await self.airtouch.UpdateInfo() + if self.airtouch.Status != AirTouchStatus.OK: + raise UpdateFailed("Airtouch connection issue") + return { + "acs": [ + {"ac_number": ac.AcNumber, "is_on": ac.IsOn} + for ac in self.airtouch.GetAcs() + ], + "groups": [ + { + "group_number": group.GroupNumber, + "group_name": group.GroupName, + "is_on": group.IsOn, + } + for group in self.airtouch.GetGroups() + ], + } diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index fae8e6e5686d5b..5f26280c96b67f 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -29,15 +29,21 @@ AT_TO_HA_STATE = { "Heat": HVAC_MODE_HEAT, "Cool": HVAC_MODE_COOL, - "AutoHeat": HVAC_MODE_AUTO, + "AutoHeat": HVAC_MODE_AUTO, # airtouch reports either autoheat or autocool "AutoCool": HVAC_MODE_AUTO, "Auto": HVAC_MODE_AUTO, "Dry": HVAC_MODE_DRY, "Fan": HVAC_MODE_FAN_ONLY, - "Off": HVAC_MODE_OFF, } -HA_STATE_TO_AT = {value: key for key, value in AT_TO_HA_STATE.items()} +HA_STATE_TO_AT = { + HVAC_MODE_HEAT: "Heat", + HVAC_MODE_COOL: "Cool", + HVAC_MODE_AUTO: "Auto", + HVAC_MODE_DRY: "Dry", + HVAC_MODE_FAN_ONLY: "Fan", + HVAC_MODE_OFF: "Off", +} AT_TO_HA_FAN_SPEED = { "Quiet": FAN_DIFFUSE, @@ -49,31 +55,23 @@ "Turbo": "turbo", } +AT_GROUP_MODES = [HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY] + HA_FAN_SPEED_TO_AT = {value: key for key, value in AT_TO_HA_FAN_SPEED.items()} _LOGGER = logging.getLogger(__name__) -def _build_entity(coordinator, group_number, info): - group = AirtouchGroup(coordinator, group_number, info) - _LOGGER.debug("Found device %s", group) - return group - - -def _build_entity_ac(coordinator, ac_number, info): - air_conditioner = AirtouchAC(coordinator, ac_number, info) - _LOGGER.debug("Found ac %s", air_conditioner) - return air_conditioner - - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Airtouch 4.""" coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] info = coordinator.data entities = [ - _build_entity(coordinator, group["group_number"], info) + AirtouchGroup(coordinator, group["group_number"], info) for group in info["groups"] - ] + [_build_entity_ac(coordinator, ac["ac_number"], info) for ac in info["acs"]] + ] + [AirtouchAC(coordinator, ac["ac_number"], info) for ac in info["acs"]] + + _LOGGER.debug(" Found entities %s", entities) async_add_entities(entities) @@ -81,6 +79,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AirtouchAC(CoordinatorEntity, ClimateEntity): """Representation of an AirTouch 4 ac.""" + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + _attr_temperature_unit = TEMP_CELSIUS + def __init__(self, coordinator, ac_number, info): """Initialize the climate device.""" super().__init__(coordinator) @@ -109,11 +110,6 @@ def unique_id(self): """Return unique ID for this device.""" return f"ac_{self._ac_number}" - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FAN_MODE - @property def current_temperature(self): """Return the current temperature.""" @@ -124,11 +120,6 @@ def name(self): """Return the name of the climate device.""" return f"AC {self._ac_number}" - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - @property def fan_mode(self): """Return fan mode of the AC this group belongs to.""" @@ -188,7 +179,8 @@ async def async_set_fan_mode(self, fan_mode): async def async_turn_on(self): """Turn on.""" _LOGGER.debug("Turning %s on", self.unique_id) - # in case ac is not on. Airtouch turns itself off if no groups are turned on (even if groups turned back on) + # in case ac is not on. Airtouch turns itself off if no groups are turned on + # (even if groups turned back on) await self._airtouch.TurnAcOn(self._ac_number) async def async_turn_off(self): @@ -201,6 +193,9 @@ async def async_turn_off(self): class AirtouchGroup(CoordinatorEntity, ClimateEntity): """Representation of an AirTouch 4 group.""" + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE + _attr_temperature_unit = TEMP_CELSIUS + def __init__(self, coordinator, group_number, info): """Initialize the climate device.""" super().__init__(coordinator) @@ -239,21 +234,11 @@ def max_temp(self): """Return Max Temperature for AC of this group.""" return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_TARGET_TEMPERATURE - @property def name(self): """Return the name of the climate device.""" return self._unit.GroupName - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - @property def current_temperature(self): """Return the current temperature.""" @@ -277,7 +262,7 @@ def hvac_mode(self): @property def hvac_modes(self): """Return the list of available operation modes.""" - return [HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY] + return AT_GROUP_MODES async def async_set_hvac_mode(self, hvac_mode): """Set new operation mode.""" @@ -333,11 +318,13 @@ async def async_turn_on(self): _LOGGER.debug("Turning %s on", self.unique_id) await self._airtouch.TurnGroupOn(self._group_number) - # in case ac is not on. Airtouch turns itself off if no groups are turned on (even if groups turned back on) + # in case ac is not on. Airtouch turns itself off if no groups are turned on + # (even if groups turned back on) await self._airtouch.TurnAcOn( self._airtouch.GetGroupByGroupNumber(self._group_number).BelongsToAc ) - # this might cause the ac object to be wrong, so force the shared data store to update + # this might cause the ac object to be wrong, so force the shared data + # store to update await self.coordinator.async_request_refresh() self.async_write_ha_state() @@ -345,6 +332,8 @@ async def async_turn_off(self): """Turn off.""" _LOGGER.debug("Turning %s off", self.unique_id) await self._airtouch.TurnGroupOff(self._group_number) - # this will cause the ac object to be wrong (ac turns off automatically if no groups are running), so force the shared data store to update + # this will cause the ac object to be wrong + # (ac turns off automatically if no groups are running) + # so force the shared data store to update await self.coordinator.async_request_refresh() self.async_write_ha_state() diff --git a/homeassistant/components/airtouch4/config_flow.py b/homeassistant/components/airtouch4/config_flow.py index 6ceb18aaa326d4..bb4c5cbcd6af72 100644 --- a/homeassistant/components/airtouch4/config_flow.py +++ b/homeassistant/components/airtouch4/config_flow.py @@ -1,8 +1,8 @@ """Config flow for AirTouch4.""" -from airtouch4pyapi import AirTouch +from airtouch4pyapi import AirTouch, AirTouchStatus import voluptuous as vol -from homeassistant import config_entries, core +from homeassistant import config_entries from homeassistant.const import CONF_HOST from .const import DOMAIN @@ -10,22 +10,10 @@ DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) -async def _validate_connection(hass: core.HomeAssistant, host): - airtouch = AirTouch(host) - await airtouch.UpdateInfo() - - if hasattr(airtouch, "error"): - if isinstance(airtouch.error, Exception): - raise airtouch.error - raise ConnectionError() - return bool(airtouch.GetGroups()) - - class AirtouchConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle an Airtouch config flow.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" @@ -35,13 +23,19 @@ async def async_step_user(self, user_input=None): errors = {} host = user_input[CONF_HOST] + self._async_abort_entries_match({CONF_HOST: host}) + + airtouch = AirTouch(host) + await airtouch.UpdateInfo() + airtouch_status = airtouch.Status + airtouch_has_groups = bool( + airtouch.Status == AirTouchStatus.OK and airtouch.GetGroups() + ) - try: - result = await _validate_connection(self.hass, host) - if not result: - errors["base"] = "no_units" - except (OSError, ConnectionError): + if airtouch_status != AirTouchStatus.OK: errors["base"] = "cannot_connect" + elif airtouch_has_groups is False: + errors["base"] = "no_units" if errors: return self.async_show_form( diff --git a/homeassistant/components/airtouch4/manifest.json b/homeassistant/components/airtouch4/manifest.json index fbefe6f5334355..8297081ae9dff9 100644 --- a/homeassistant/components/airtouch4/manifest.json +++ b/homeassistant/components/airtouch4/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airtouch4", "requirements": [ - "airtouch4pyapi==1.0.2" + "airtouch4pyapi==1.0.5" ], "codeowners": [ "@LonePurpleWolf" diff --git a/requirements_all.txt b/requirements_all.txt index badf6983bf652c..66d8eb50289e5c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -258,7 +258,7 @@ aioymaps==1.1.0 airly==1.1.0 # homeassistant.components.airtouch4 -airtouch4pyapi==1.0.2 +airtouch4pyapi==1.0.5 # homeassistant.components.aladdin_connect aladdin_connect==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 54cbe119389192..16591436f25c8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -179,7 +179,7 @@ aioymaps==1.1.0 airly==1.1.0 # homeassistant.components.airtouch4 -airtouch4pyapi==1.0.2 +airtouch4pyapi==1.0.5 # homeassistant.components.ambee ambee==0.3.0 diff --git a/tests/components/airtouch4/test_config_flow.py b/tests/components/airtouch4/test_config_flow.py index 2aaa5a54a4b9d1..a98b24ef88db14 100644 --- a/tests/components/airtouch4/test_config_flow.py +++ b/tests/components/airtouch4/test_config_flow.py @@ -1,7 +1,7 @@ """Test the AirTouch 4 config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch -from airtouch4pyapi.airtouch import AirTouch, AirTouchAc, AirTouchGroup +from airtouch4pyapi.airtouch import AirTouch, AirTouchAc, AirTouchGroup, AirTouchStatus from homeassistant import config_entries from homeassistant.components.airtouch4.const import DOMAIN @@ -16,16 +16,15 @@ async def test_form(hass): assert result["errors"] is None mock_ac = AirTouchAc() mock_groups = AirTouchGroup() + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.Status = AirTouchStatus.OK + mock_airtouch.GetAcs = Mock(return_value=[mock_ac]) + mock_airtouch.GetGroups = Mock(return_value=[mock_groups]) with patch( - "homeassistant.components.airtouch4.config_flow.AirTouch.GetAcs", - return_value=[mock_ac], - ), patch( - "homeassistant.components.airtouch4.config_flow.AirTouch.UpdateInfo", - return_value=None, - ), patch( - "homeassistant.components.airtouch4.config_flow.AirTouch.GetGroups", - return_value=[mock_groups], + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, ), patch( "homeassistant.components.airtouch4.async_setup_entry", return_value=True, @@ -50,7 +49,7 @@ async def test_form_timeout(hass): ) mock_airtouch = AirTouch("") mock_airtouch.UpdateInfo = AsyncMock() - mock_airtouch.error = TimeoutError() + mock_airtouch.status = AirTouchStatus.CONNECTION_INTERRUPTED with patch( "homeassistant.components.airtouch4.config_flow.AirTouch", return_value=mock_airtouch, @@ -69,7 +68,7 @@ async def test_form_library_error_message(hass): ) mock_airtouch = AirTouch("") mock_airtouch.UpdateInfo = AsyncMock() - mock_airtouch.error = "example error message" + mock_airtouch.status = AirTouchStatus.ERROR with patch( "homeassistant.components.airtouch4.config_flow.AirTouch", return_value=mock_airtouch, @@ -88,7 +87,7 @@ async def test_form_connection_refused(hass): ) mock_airtouch = AirTouch("") mock_airtouch.UpdateInfo = AsyncMock() - mock_airtouch.error = ConnectionRefusedError() + mock_airtouch.status = AirTouchStatus.NOT_CONNECTED with patch( "homeassistant.components.airtouch4.config_flow.AirTouch", return_value=mock_airtouch, @@ -105,17 +104,16 @@ async def test_form_no_units(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_ac = AirTouchAc() + mock_airtouch = AirTouch("") + mock_airtouch.UpdateInfo = AsyncMock() + mock_airtouch.Status = AirTouchStatus.OK + mock_airtouch.GetAcs = Mock(return_value=[mock_ac]) + mock_airtouch.GetGroups = Mock(return_value=[]) + with patch( - "homeassistant.components.airtouch4.config_flow.AirTouch.GetAcs", - return_value=[mock_ac], - ), patch( - "homeassistant.components.airtouch4.config_flow.AirTouch.UpdateInfo", - return_value=None, - ), patch( - "homeassistant.components.airtouch4.config_flow.AirTouch.GetGroups", - return_value=[], + "homeassistant.components.airtouch4.config_flow.AirTouch", + return_value=mock_airtouch, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "0.0.0.1"} From b5c75f2f1153ece75aa0905867fed6b896ffdee2 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 17 Aug 2021 10:09:42 +0200 Subject: [PATCH 06/10] Clean up --- homeassistant/components/airtouch4/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index 5f26280c96b67f..5712afcdfd1183 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -145,7 +145,7 @@ def hvac_modes(self): """Return the list of available operation modes.""" airtouch_modes = self._airtouch.GetSupportedCoolingModesForAc(self._ac_number) modes = [AT_TO_HA_STATE[mode] for mode in airtouch_modes] - modes.extend([HVAC_MODE_OFF]) + modes.append(HVAC_MODE_OFF) return modes async def async_set_hvac_mode(self, hvac_mode): From c28f075093bc5477db570c42662def068da645d1 Mon Sep 17 00:00:00 2001 From: Sam Sinnamon Date: Tue, 17 Aug 2021 08:40:37 +0000 Subject: [PATCH 07/10] add already_configured message --- homeassistant/components/airtouch4/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/airtouch4/strings.json b/homeassistant/components/airtouch4/strings.json index e69fd912ab968f..a23044d39f7f8a 100644 --- a/homeassistant/components/airtouch4/strings.json +++ b/homeassistant/components/airtouch4/strings.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Device is already configured" + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_units": "Could not find any AirTouch 4 Groups." From acb0dd953c00d92c18ffb03c9b75460d73ddca12 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 17 Aug 2021 11:52:42 +0200 Subject: [PATCH 08/10] Use common string --- homeassistant/components/airtouch4/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/airtouch4/strings.json b/homeassistant/components/airtouch4/strings.json index a23044d39f7f8a..5259b20fb7356b 100644 --- a/homeassistant/components/airtouch4/strings.json +++ b/homeassistant/components/airtouch4/strings.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", From c8f881decbccf4506fe04d8d22c0502abff30b03 Mon Sep 17 00:00:00 2001 From: Sam Sinnamon Date: Tue, 17 Aug 2021 13:39:29 +0000 Subject: [PATCH 09/10] further qa fixes --- homeassistant/components/airtouch4/__init__.py | 7 ++----- homeassistant/components/airtouch4/climate.py | 6 +----- homeassistant/components/airtouch4/config_flow.py | 2 +- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py index 708999fc107121..6b6a9b7e83e821 100644 --- a/homeassistant/components/airtouch4/__init__.py +++ b/homeassistant/components/airtouch4/__init__.py @@ -25,11 +25,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: airtouch = AirTouch(host) await airtouch.UpdateInfo() info = airtouch.GetAcs() - try: - if not info: - raise ConfigEntryNotReady - except OSError as error: - raise ConfigEntryNotReady() from error + if not info: + raise ConfigEntryNotReady coordinator = AirtouchDataUpdateCoordinator(hass, airtouch) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = { diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index 5712afcdfd1183..5c670a12ad22bf 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -195,6 +195,7 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity): _attr_supported_features = SUPPORT_TARGET_TEMPERATURE _attr_temperature_unit = TEMP_CELSIUS + _attr_hvac_modes = AT_GROUP_MODES def __init__(self, coordinator, group_number, info): """Initialize the climate device.""" @@ -259,11 +260,6 @@ def hvac_mode(self): return HVAC_MODE_FAN_ONLY - @property - def hvac_modes(self): - """Return the list of available operation modes.""" - return AT_GROUP_MODES - async def async_set_hvac_mode(self, hvac_mode): """Set new operation mode.""" if hvac_mode not in HA_STATE_TO_AT: diff --git a/homeassistant/components/airtouch4/config_flow.py b/homeassistant/components/airtouch4/config_flow.py index bb4c5cbcd6af72..e395c71349b47b 100644 --- a/homeassistant/components/airtouch4/config_flow.py +++ b/homeassistant/components/airtouch4/config_flow.py @@ -34,7 +34,7 @@ async def async_step_user(self, user_input=None): if airtouch_status != AirTouchStatus.OK: errors["base"] = "cannot_connect" - elif airtouch_has_groups is False: + elif not airtouch_has_groups: errors["base"] = "no_units" if errors: From a7b9fc713a76acb72613d6dd80e9a7482c3ca306 Mon Sep 17 00:00:00 2001 From: Sam Sinnamon Date: Tue, 17 Aug 2021 13:46:02 +0000 Subject: [PATCH 10/10] simplify airtouch4 domain storage --- homeassistant/components/airtouch4/__init__.py | 4 +--- homeassistant/components/airtouch4/climate.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py index 6b6a9b7e83e821..0ec63161ea3400 100644 --- a/homeassistant/components/airtouch4/__init__.py +++ b/homeassistant/components/airtouch4/__init__.py @@ -29,9 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady coordinator = AirtouchDataUpdateCoordinator(hass, airtouch) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = { - "coordinator": coordinator, - } + hass.data[DOMAIN][entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index 5c670a12ad22bf..7202feb0527969 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -64,7 +64,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Airtouch 4.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + coordinator = hass.data[DOMAIN][config_entry.entry_id] info = coordinator.data entities = [ AirtouchGroup(coordinator, group["group_number"], info)