diff --git a/.vscode/settings.json b/.vscode/settings.json index 541629d..823b7af 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,9 +2,9 @@ "python.testing.pytestArgs": [ "tests","--asyncio-mode=auto" ], + "python.experiments.optOutFrom": ["pythonTestAdapter"], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, - "python.experiments.enabled": false, "cSpell.words": [ "ASHRAE", "automations", @@ -24,4 +24,8 @@ "sysuptime" ], "cSpell.enabled": false, + "black-formatter.args": ["--line-length", "120"], + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + } } diff --git a/README.MD b/README.MD index ad40b78..99e94d3 100644 --- a/README.MD +++ b/README.MD @@ -178,11 +178,9 @@ Away Mode. The **Away** preset will put the S30 system into Manual Away Mode. Yo Cancel Manual Away Mode or Smart Away Mode. The **cancel away mode** preset will cancel the active away mode (manual or smart) and return the S30 system to whatever state it was in prior to putting it into away mode. This works the same as if you pressed the cancel away icon on the S30 panel. -**Emergency Heat** - Lennox systems that have a heat pump and an auxiliary furnace, have an additional HVAC_MODE to run just the auxiliary furnace. In the S30 App this is shown as Emergency Heat. Home Assistant **does not** allow this mode directly - instead Home Assistant provides support for turning aux_heat on and off - independent of the HVAC_MODE. The integration has the following behavior: +**Emergency Heat** - Lennox systems that have a heat pump and an auxiliary furnace, have an additional HVAC_MODE to run just the auxiliary furnace. In the S30 App this is shown as Emergency Heat. Home Assistant **does not** allow this mode directly - instead a **select.system_zone_hvac_mode** entity provides support for setting the hvac mode to emergency heat. The integration has the following behavior: -- If Emergency Heat Mode is set in the S30, the HA climate will show Heat and the Aux Heat switch will be on. -- If the HA Aux Switch is turned on, the S30 Heat Mode will be set to **Emergency Heat** -- If the HA Aux Switch is turned off, the S30 Heat Mode will be set to **Heat** +- If Emergency Heat Mode is set in the S30, the HA climate will show Heat and the select entity will show **Emergency Heat**. - If the Lennox Auxiliary Heat is running, the **aux** attribute in the HA Climate entity will be set to True and the HA Climate Entity will show **Heating** **Humidification and Dehumidification** @@ -474,11 +472,18 @@ Local Connections Only. Reports the WIFI signal strength, this sensor can be us ## Select Entities +### HVAC Mode + +If your Lennox System has Emergency Heat a select entity will be created to allow you to set the hvac_mode. + +select.\_hvac_mode + + ### Humidity Mode If your Lennox System has Humidification or Dehumidification capability, one of these select entities is created per zone to allow you to set the active humidity mode. The available options - depending on your equipment - are **Dehumidify**, **Humidify** and **Off** -select.\_humidity_mode +select.\_\_humidity_mode ### Dehumidification Modes diff --git a/custom_components/lennoxs30/climate.py b/custom_components/lennoxs30/climate.py index 6d4c244..77b8ec4 100644 --- a/custom_components/lennoxs30/climate.py +++ b/custom_components/lennoxs30/climate.py @@ -4,6 +4,7 @@ # pylint: disable=unused-argument # pylint: disable=line-too-long # pylint: disable=invalid-name +# pylint: disable=abstract-method from __future__ import annotations @@ -99,7 +100,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e class S30Climate(S30BaseEntityMixin, ClimateEntity): """Class for Lennox S30 thermostat.""" - def __init__(self, hass, manager: Manager, system: lennox_system, zone: lennox_zone): """Initialize the climate device.""" super().__init__(manager, system) @@ -603,9 +603,16 @@ def is_aux_heat(self) -> bool | None: res = self._zone.systemMode == LENNOX_HVAC_EMERGENCY_HEAT return res + def _create_aux_heat_issue(self, service: str): + _LOGGER.warning( + "climate.%s is deprecated and will be removed in version 2024.10 learn more https://github.com/PeteRager/lennoxs30/blob/master/docs/aux_heat.md", service + ) + + async def async_turn_aux_heat_on(self): """Turn auxiliary heater on.""" _LOGGER.info("climate:async_turn_aux_heat_on zone [%s]", self._myname) + self._create_aux_heat_issue("turn_aux_heat_on") if self.is_zone_disabled: raise HomeAssistantError(f"Unable to turn_aux_heat_on mode as zone [{self._myname}] is disabled") try: @@ -620,6 +627,7 @@ async def async_turn_aux_heat_on(self): async def async_turn_aux_heat_off(self): _LOGGER.info("climate:async_turn_aux_heat_off zone [%s]", self._myname) + self._create_aux_heat_issue("turn_aux_heat_off") # When Aux is turned off, we will revert the zone to Heat Mode. if self.is_zone_disabled: raise HomeAssistantError(f"Unable to turn_aux_heat_on mode as zone [{self._myname}] is disabled") diff --git a/custom_components/lennoxs30/const.py b/custom_components/lennoxs30/const.py index adb18ab..ee4372b 100644 --- a/custom_components/lennoxs30/const.py +++ b/custom_components/lennoxs30/const.py @@ -47,6 +47,8 @@ UNIQUE_ID_SUFFIX_BLE_COMMSTATUS: Final = "_BLE_COMMSTATUS" UNIQUE_ID_SUFFIX_VENTILATION_SELECT: Final = "_VENT_SELECT" UNIQUE_ID_SUFFIX_WIFI_RSSI: Final = "_WIFI_RSSI" +UNIQUE_ID_SUFFIX_EMERGENCY_HEAT: Final = "_EHEAT" +UNIQUE_ID_SUFFIX_ZONEMODE_SELECT: Final = "_ZONE_MODE_SELECT" VENTILATION_EQUIPMENT_ID = -900 diff --git a/custom_components/lennoxs30/manifest.json b/custom_components/lennoxs30/manifest.json index 15cd144..604a116 100644 --- a/custom_components/lennoxs30/manifest.json +++ b/custom_components/lennoxs30/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "issue_tracker" : "https://github.com/PeteRager/lennoxs30/issues", "quality_scale": "platinum", - "requirements": ["lennoxs30api==0.2.14"], - "version": "2024.3.0" + "requirements": ["lennoxs30api==0.2.15"], + "version": "2024.6.0" } \ No newline at end of file diff --git a/custom_components/lennoxs30/select.py b/custom_components/lennoxs30/select.py index b5c29b7..464f59c 100644 --- a/custom_components/lennoxs30/select.py +++ b/custom_components/lennoxs30/select.py @@ -4,10 +4,12 @@ # pylint: disable=unused-argument # pylint: disable=line-too-long # pylint: disable=invalid-name +# pylint: disable=abstract-method from typing import Any import logging +from homeassistant.components.climate.const import HVACMode from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry @@ -20,6 +22,8 @@ LENNOX_HUMIDITY_MODE_OFF, LENNOX_HUMIDITY_MODE_HUMIDIFY, LENNOX_HUMIDITY_MODE_DEHUMIDIFY, + LENNOX_HVAC_EMERGENCY_HEAT, + LENNOX_HVAC_HEAT_COOL, LENNOX_DEHUMIDIFICATION_MODE_HIGH, LENNOX_DEHUMIDIFICATION_MODE_MEDIUM, LENNOX_DEHUMIDIFICATION_MODE_AUTO, @@ -48,6 +52,7 @@ MANAGER, UNIQUE_ID_SUFFIX_EQ_PARAM_SELECT, UNIQUE_ID_SUFFIX_VENTILATION_SELECT, + UNIQUE_ID_SUFFIX_ZONEMODE_SELECT, ) from . import DOMAIN, Manager @@ -89,6 +94,13 @@ async def async_setup_entry( select = EquipmentParameterSelect(hass, manager, system, equipment, parameter) select_list.append(select) + for zone in system.zone_list: + if zone.is_zone_active(): + if zone.emergencyHeatingOption or system.has_emergency_heat(): + zone_emergency_heat = ZoneModeSelect(hass, manager, system, zone) + select_list.append(zone_emergency_heat) + + if len(select_list) != 0: async_add_entities(select_list, True) @@ -459,3 +471,105 @@ def extra_state_attributes(self): attrs: dict[str, Any] = {} attrs["installer_settings"] = self._system.ventilationControlMode return attrs + +class ZoneModeSelect(S30BaseEntityMixin, SelectEntity): + """Set the zone hvac mode""" + + def __init__( + self, + hass: HomeAssistant, + manager: Manager, + system: lennox_system, + zone: lennox_zone, + ): + super().__init__(manager, system) + self.hass: HomeAssistant = hass + self._zone = zone + self._myname = f"{self._system.name}_{self._zone.name}_hvac_mode" + _LOGGER.debug("Create ZoneModeSelect myname [%s]", self._myname) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + _LOGGER.debug("async_added_to_hass VentilationModeSelect myname [%s]", self._myname) + self._zone.registerOnUpdateCallback(self.zone_update_callback, ["systemMode"]) + self._system.registerOnUpdateCallback(self.system_update_callback, ["zoningMode"]) + await super().async_added_to_hass() + + def zone_update_callback(self): + """Callback for system updates""" + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "zone_update_callback ZoneModeSelect myname [%s]", + self._myname, + ) + self.schedule_update_ha_state() + + def system_update_callback(self): + """Callback for system updates""" + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "system_update_callback ZoneModeSelect myname [%s]", + self._myname, + ) + self.schedule_update_ha_state() + + + @property + def unique_id(self) -> str: + # HA fails with dashes in IDs + return self._zone.unique_id + UNIQUE_ID_SUFFIX_ZONEMODE_SELECT + + @property + def name(self): + return self._myname + + @property + def current_option(self) -> str: + r = self._zone.getSystemMode() + if r == LENNOX_HVAC_HEAT_COOL: + r = HVACMode.HEAT_COOL + return r + + @property + def options(self) -> list: + modes = [] + if self._zone.is_zone_disabled: + return modes + modes.append(HVACMode.OFF) + if self._zone.coolingOption: + modes.append(HVACMode.COOL) + if self._zone.heatingOption: + modes.append(HVACMode.HEAT) + if self._zone.coolingOption and self._zone.heatingOption: + modes.append(HVACMode.HEAT_COOL) + if self._zone.emergencyHeatingOption or self._system.has_emergency_heat(): + modes.append(LENNOX_HVAC_EMERGENCY_HEAT) + return modes + + async def async_select_option(self, option: str) -> None: + _LOGGER.info(LOG_INFO_SELECT_ASYNC_SELECT_OPTION, self.__class__.__name__, self._myname, option) + if self._zone.is_zone_disabled: + raise HomeAssistantError(f"Unable to set hvac_mode as zone [{self._myname}] is disabled") + try: + hvac_mode = option if option != HVACMode.HEAT_COOL else LENNOX_HVAC_HEAT_COOL + _LOGGER.info( + "select:async_set_option [%s] ha_mode [%s] lennox_mode [%s]", + self._myname, + option, + hvac_mode, + ) + await self._zone.setHVACMode(hvac_mode) + except S30Exception as ex: + raise HomeAssistantError(f"async_select_option [{self._myname}] [{ex.as_string()}]") from ex + except Exception as ex: + raise HomeAssistantError( + f"async_select_option unexpected exception, please log issue, [{self._myname}] exception [{ex}]" + ) from ex + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + result = { + "identifiers": {(DOMAIN, self._zone.unique_id)}, + } + return result diff --git a/custom_components/lennoxs30/translations/en.json b/custom_components/lennoxs30/translations/en.json index a2a6802..50674cc 100644 --- a/custom_components/lennoxs30/translations/en.json +++ b/custom_components/lennoxs30/translations/en.json @@ -86,5 +86,5 @@ "title": "Options" } } -} + } } diff --git a/docs/aux_heat.md b/docs/aux_heat.md new file mode 100644 index 0000000..d020134 --- /dev/null +++ b/docs/aux_heat.md @@ -0,0 +1,25 @@ +# Aux Heat + +## Overview + +Home Assistant has deprecated aux_heat and will remove it from the product in 2024.10.0 + +This integration will continue to support aux heat until 2024.10.0 + +If you are using aux heat you will need to migrate to use the new functionality before that date. + +## Changes + +A new select entity is created for each zone for systems that have aux heat. The name of the entity is **select.[system_name]_[zone_name]_hvac_mode()** + +This select entity will have all the valid hvac_modes for your system including **emergency heat**. You can select emergency heat directly from the drop down or use the service call: + +```yaml +service: select.select_option +target: + entity_id: select.ragehouse_zone_1_hvac_mode +data: + option: "emergency heat" +``` + +The climate entity hvac_mode will be heat when the lennox hvac_mode is heat or emergency heat. Changing the hvac_mode in the climate entity will cause the select to update. diff --git a/tests/conftest.py b/tests/conftest.py index d04bcaa..d807ed2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -75,11 +75,6 @@ pytest_plugins = "pytest_homeassistant_custom_component" - -@pytest.fixture(autouse=True) -def socket_enabled(): - pass - @pytest.fixture(autouse=True) def auto_enable_custom_integrations(enable_custom_integrations): yield @@ -131,8 +126,7 @@ def loadfile(name: str, sysId: str = None) -> json: @pytest.fixture def config_entry_local() -> config_entries.ConfigEntry: - config = config_entries.ConfigEntry(version=1, domain=DOMAIN, title="10.0.0.1", data={}, source="User") - config.unique_id = "12345" + config = config_entries.ConfigEntry(version=1, minor_version=0, domain=DOMAIN, title="10.0.0.1", data={}, source="User", unique_id="12345") config.data = {} config.data[CONF_CLOUD_CONNECTION] = False config.data[CONF_HOST] = "10.0.0.1" @@ -158,8 +152,7 @@ def config_entry_local() -> config_entries.ConfigEntry: @pytest.fixture def config_entry_cloud() -> config_entries.ConfigEntry: - config = config_entries.ConfigEntry(version=1, domain=DOMAIN, title="10.0.0.1", data={}, source="User") - config.unique_id = "12345" + config = config_entries.ConfigEntry(version=1, minor_version = 0, domain=DOMAIN, title="10.0.0.1", data={}, source="User", unique_id="12345") config.data = {} config.data[CONF_CLOUD_CONNECTION] = True config.data[CONF_EMAIL] = "pete.rage@rage.com" @@ -270,8 +263,7 @@ def manager_us_customary_units(hass: HomeAssistant, config_entry_local) -> Manag @pytest.fixture def manager_2_systems(hass) -> Manager: - config = config_entries.ConfigEntry(version=1, domain=DOMAIN, title="10.0.0.1", data={}, source="User") - config.unique_id = "12345" + config = config_entries.ConfigEntry(version=1, minor_version = 0, domain=DOMAIN, title="10.0.0.1", data={}, source="User", unique_id="12345") manager_to_return = Manager( hass=hass, @@ -326,9 +318,7 @@ def manager_2_systems(hass) -> Manager: @pytest.fixture def manager_mz(hass) -> Manager: - config = config_entries.ConfigEntry(version=1, domain=DOMAIN, title="10.0.0.1", data={}, source="User") - config.unique_id = "12345" - + config = config_entries.ConfigEntry(version=1, minor_version = 0, domain=DOMAIN, title="10.0.0.1", data={}, source="User", unique_id="12345") manager_to_return = Manager( hass=hass, config=config, @@ -371,9 +361,7 @@ def manager_mz(hass) -> Manager: @pytest.fixture def manager_system_04_furn_ac_zoning(hass) -> Manager: - config = config_entries.ConfigEntry(version=1, domain=DOMAIN, title="10.0.0.1", data={}, source="User") - config.unique_id = "12345" - + config = config_entries.ConfigEntry(version=1, minor_version = 0, domain=DOMAIN, title="10.0.0.1", data={}, source="User", unique_id="12345") manager_to_return = Manager( hass=hass, config=config, diff --git a/tests/test_async_setup_entry.py b/tests/test_async_setup_entry.py index aab2bb6..a271eaf 100644 --- a/tests/test_async_setup_entry.py +++ b/tests/test_async_setup_entry.py @@ -64,7 +64,7 @@ async def test_async_setup_entry_local(hass: HomeAssistant, caplog): } hass.data[LENNOX_DOMAIN] = {} - config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") + config_entry = config_entries.ConfigEntry(version=1, minor_version=0, domain=DOMAIN, title = "Test", data = data, source="my_source") with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: res = await async_setup_entry(hass, config_entry) @@ -182,7 +182,7 @@ async def test_async_setup_entry_cloud(hass, caplog): } hass.data[LENNOX_DOMAIN] = {} - config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") + config_entry = config_entries.ConfigEntry(version=1, minor_version=0, domain=DOMAIN, title = "Test", data = data, source="my_source") with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: res = await async_setup_entry(hass, config_entry) @@ -236,7 +236,7 @@ async def test_async_setup_entry_multiple(hass, caplog): } hass.data[LENNOX_DOMAIN] = {} - config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") + config_entry = config_entries.ConfigEntry(version=1, minor_version=0, domain=DOMAIN, title = "Test", data = data, source="my_source", unique_id="12345") with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: res = await async_setup_entry(hass, config_entry) @@ -286,7 +286,7 @@ async def test_async_setup_entry_multiple(hass, caplog): } hass.data[LENNOX_DOMAIN] = {} - config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test1", data, "my_source") + config_entry = config_entries.ConfigEntry(version=1, minor_version=0, domain=DOMAIN, title = "Test1", data = data, source="my_source", unique_id="123456") with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: res = await async_setup_entry(hass, config_entry) @@ -337,7 +337,7 @@ async def test_async_setup_entry_multiple(hass, caplog): } hass.data[LENNOX_DOMAIN] = {} - config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test3", data, "my_source") + config_entry = config_entries.ConfigEntry(version=1, minor_version=0, domain=DOMAIN, title = "Test2", data = data, source="my_source", unique_id="1234567") with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: res = await async_setup_entry(hass, config_entry) @@ -391,7 +391,7 @@ async def test_async_unload_entry_success(hass, caplog): } hass.data[LENNOX_DOMAIN] = {} - config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") + config_entry = config_entries.ConfigEntry(version=1, minor_version=0, domain=DOMAIN, title = "Test", data = data, source="my_source") with patch("custom_components.lennoxs30.Manager.s30_initialize") as _: res = await async_setup_entry(hass, config_entry) @@ -437,7 +437,7 @@ async def test_async_unload_entry_unload_fail(hass, caplog): } hass.data[LENNOX_DOMAIN] = {} - config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") + config_entry = config_entries.ConfigEntry(version=1, minor_version=0, domain=DOMAIN, title = "Test", data = data, source="my_source") with caplog.at_level(logging.ERROR): caplog.clear() diff --git a/tests/test_climate.py b/tests/test_climate.py index e48494d..4e228a1 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -12,26 +12,13 @@ from homeassistant.components.climate.const import ( PRESET_AWAY, PRESET_NONE, + HVACAction, + HVACMode, ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.components.climate.const import ( - CURRENT_HVAC_DRY, - CURRENT_HVAC_IDLE, - HVAC_MODE_COOL, - HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, - HVAC_MODE_OFF, - SUPPORT_AUX_HEAT, - SUPPORT_FAN_MODE, - SUPPORT_PRESET_MODE, - SUPPORT_TARGET_HUMIDITY, - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, -) -from homeassistant.const import ( - UnitOfTemperature -) - +from homeassistant.const import UnitOfTemperature +from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.helpers import issue_registry as ir from lennoxs30api.s30api_async import ( LENNOX_HVAC_COOL, @@ -719,61 +706,61 @@ async def test_climate_supported_features(hass, manager_mz: Manager): c = S30Climate(hass, manager, system, zone) feat = c.supported_features assert c.is_single_setpoint_active() is True - assert feat & SUPPORT_TARGET_TEMPERATURE != 0 - assert feat & SUPPORT_TARGET_TEMPERATURE_RANGE == 0 + assert feat & ClimateEntityFeature.TARGET_TEMPERATURE != 0 + assert feat & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE == 0 c._zone.system.single_setpoint_mode = False c._zone.systemMode = LENNOX_HVAC_HEAT_COOL feat = c.supported_features assert c.is_single_setpoint_active() is False - assert feat & SUPPORT_TARGET_TEMPERATURE == 0 - assert feat & SUPPORT_TARGET_TEMPERATURE_RANGE != 0 + assert feat & ClimateEntityFeature.TARGET_TEMPERATURE == 0 + assert feat & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE != 0 feat = c.supported_features assert c._zone.dehumidificationOption is True assert c._zone.humidificationOption is False assert c._zone.humidityMode == LENNOX_HUMIDITY_MODE_OFF - assert feat & SUPPORT_TARGET_HUMIDITY == 0 + assert feat & ClimateEntityFeature.TARGET_HUMIDITY == 0 c._zone.humidityMode = LENNOX_HUMIDITY_MODE_DEHUMIDIFY feat = c.supported_features - assert feat & SUPPORT_TARGET_HUMIDITY != 0 + assert feat & ClimateEntityFeature.TARGET_HUMIDITY != 0 c._zone.dehumidificationOption = False c._zone.humidificationOption = True c._zone.humidityMode = LENNOX_HUMIDITY_MODE_OFF feat = c.supported_features - assert feat & SUPPORT_TARGET_HUMIDITY == 0 + assert feat & ClimateEntityFeature.TARGET_HUMIDITY == 0 c._zone.humidityMode = LENNOX_HUMIDITY_MODE_HUMIDIFY feat = c.supported_features - assert feat & SUPPORT_TARGET_HUMIDITY != 0 + assert feat & ClimateEntityFeature.TARGET_HUMIDITY != 0 c._zone.humidificationOption = False feat = c.supported_features - assert feat & SUPPORT_TARGET_HUMIDITY == 0 + assert feat & ClimateEntityFeature.TARGET_HUMIDITY == 0 - assert feat & SUPPORT_AUX_HEAT == 0 - assert feat & SUPPORT_PRESET_MODE != 0 - assert feat & SUPPORT_FAN_MODE != 0 + assert feat & ClimateEntityFeature.AUX_HEAT == 0 + assert feat & ClimateEntityFeature.PRESET_MODE != 0 + assert feat & ClimateEntityFeature.FAN_MODE != 0 c._zone.emergencyHeatingOption = False with patch.object(system, "has_emergency_heat") as has_emergency_heat: has_emergency_heat.return_value = True feat = c.supported_features - assert feat & SUPPORT_AUX_HEAT != 0 + assert feat & ClimateEntityFeature.AUX_HEAT != 0 c._zone.emergencyHeatingOption = True with patch.object(system, "has_emergency_heat") as has_emergency_heat: has_emergency_heat.return_value = False feat = c.supported_features - assert feat & SUPPORT_AUX_HEAT != 0 + assert feat & ClimateEntityFeature.AUX_HEAT != 0 c._zone.emergencyHeatingOption = False with patch.object(system, "has_emergency_heat") as has_emergency_heat: has_emergency_heat.return_value = False feat = c.supported_features - assert feat & SUPPORT_AUX_HEAT == 0 + assert feat & ClimateEntityFeature.AUX_HEAT == 0 zone1: lennox_zone = system.zone_list[1] c1 = S30Climate(hass, manager, system, zone1) @@ -1018,17 +1005,17 @@ async def test_climate_hvac_mode(hass, manager_mz: Manager): zone1: lennox_zone = system.zone_list[1] c1 = S30Climate(hass, manager, system, zone1) - assert c.hvac_mode == HVAC_MODE_HEAT - assert c1.hvac_mode == HVAC_MODE_COOL + assert c.hvac_mode == HVACMode.HEAT + assert c1.hvac_mode == HVACMode.COOL zone.systemMode = LENNOX_HVAC_HEAT_COOL - assert c.hvac_mode == HVAC_MODE_HEAT_COOL + assert c.hvac_mode == HVACMode.HEAT_COOL zone.systemMode = LENNOX_HVAC_EMERGENCY_HEAT - assert c.hvac_mode == HVAC_MODE_HEAT + assert c.hvac_mode == HVACMode.HEAT system.zoningMode = LENNOX_ZONING_MODE_CENTRAL - assert c.hvac_mode == HVAC_MODE_HEAT + assert c.hvac_mode == HVACMode.HEAT assert c1.hvac_mode is None @@ -1055,21 +1042,21 @@ async def test_climate_hvac_modes(hass, manager_mz: Manager): modes = c.hvac_modes assert len(modes) == 4 - assert HVAC_MODE_OFF in modes - assert HVAC_MODE_HEAT in modes - assert HVAC_MODE_COOL in modes - assert HVAC_MODE_HEAT_COOL in modes + assert HVACMode.OFF in modes + assert HVACMode.HEAT in modes + assert HVACMode.COOL in modes + assert HVACMode.HEAT_COOL in modes zone.coolingOption = False modes = c.hvac_modes assert len(modes) == 2 - assert HVAC_MODE_OFF in modes - assert HVAC_MODE_HEAT in modes + assert HVACMode.OFF in modes + assert HVACMode.HEAT in modes zone.heatingOption = False modes = c.hvac_modes assert len(modes) == 1 - assert HVAC_MODE_OFF in modes + assert HVACMode.OFF in modes system.zoningMode = LENNOX_ZONING_MODE_CENTRAL modes = c.hvac_modes @@ -1087,32 +1074,32 @@ async def test_climate_set_hvac_mode(hass, manager_mz: Manager, caplog): with caplog.at_level(logging.ERROR): with patch.object(zone, "setHVACMode") as setHVACMode: caplog.clear() - await c.async_set_hvac_mode(HVAC_MODE_HEAT) + await c.async_set_hvac_mode(HVACMode.HEAT) assert setHVACMode.call_count == 1 assert setHVACMode.await_args[0][0] == LENNOX_HVAC_HEAT with caplog.at_level(logging.ERROR): with patch.object(zone, "setHVACMode") as setHVACMode: caplog.clear() - await c.async_set_hvac_mode(HVAC_MODE_COOL) + await c.async_set_hvac_mode(HVACMode.COOL) assert setHVACMode.call_count == 1 assert setHVACMode.await_args[0][0] == LENNOX_HVAC_COOL with caplog.at_level(logging.ERROR): with patch.object(zone, "setHVACMode") as setHVACMode: caplog.clear() - await c.async_set_hvac_mode(HVAC_MODE_HEAT_COOL) + await c.async_set_hvac_mode(HVACMode.HEAT_COOL) assert setHVACMode.call_count == 1 assert setHVACMode.await_args[0][0] == LENNOX_HVAC_HEAT_COOL with caplog.at_level(logging.ERROR): with patch.object(zone, "setHVACMode") as setHVACMode: caplog.clear() - await c.async_set_hvac_mode(HVAC_MODE_OFF) + await c.async_set_hvac_mode(HVACMode.OFF) assert setHVACMode.call_count == 1 assert setHVACMode.await_args[0][0] == LENNOX_HVAC_OFF - await conf_test_exception_handling(zone, "setHVACMode", c, c.async_set_hvac_mode, hvac_mode=HVAC_MODE_HEAT_COOL) + await conf_test_exception_handling(zone, "setHVACMode", c, c.async_set_hvac_mode, hvac_mode=HVACMode.HEAT_COOL) system.zoningMode = LENNOX_ZONING_MODE_CENTRAL with caplog.at_level(logging.ERROR): @@ -1120,7 +1107,7 @@ async def test_climate_set_hvac_mode(hass, manager_mz: Manager, caplog): caplog.clear() ex: HomeAssistantError = None try: - await c.async_set_hvac_mode(HVAC_MODE_OFF) + await c.async_set_hvac_mode(HVACMode.OFF) except HomeAssistantError as err: ex = err assert setHVACMode.call_count == 0 @@ -1144,7 +1131,7 @@ async def test_climate_hvac_action(hass, manager_mz: Manager): zone.systemMode = LENNOX_HVAC_COOL zone.tempOperation = LENNOX_TEMP_OPERATION_OFF zone.humOperation = LENNOX_HUMID_OPERATION_OFF - assert c.hvac_action == CURRENT_HVAC_IDLE + assert c.hvac_action == HVACAction.IDLE zone.systemMode = LENNOX_HVAC_COOL zone.tempOperation = LENNOX_TEMP_OPERATION_COOLING @@ -1154,12 +1141,12 @@ async def test_climate_hvac_action(hass, manager_mz: Manager): zone.systemMode = LENNOX_HVAC_COOL zone.tempOperation = LENNOX_TEMP_OPERATION_OFF zone.humOperation = LENNOX_HUMID_OPERATION_DEHUMID - assert c.hvac_action == CURRENT_HVAC_DRY + assert c.hvac_action == HVACAction.DRYING zone.systemMode = LENNOX_HVAC_COOL zone.tempOperation = LENNOX_TEMP_OPERATION_OFF zone.humOperation = LENNOX_HUMID_OPERATION_WAITING - assert c.hvac_action == CURRENT_HVAC_IDLE + assert c.hvac_action == HVACAction.IDLE zone.tempOperation = LENNOX_TEMP_OPERATION_OFF zone.humOperation = "unexpected_humdity_operation" @@ -1247,12 +1234,13 @@ async def test_climate_turn_aux_heat_on(hass, manager_mz: Manager, caplog): c = S30Climate(hass, manager, system, zone) zone.systemMode = LENNOX_HVAC_HEAT - with caplog.at_level(logging.ERROR): + with caplog.at_level(logging.WARNING): with patch.object(zone, "setHVACMode") as setHVACMode: caplog.clear() await c.async_turn_aux_heat_on() assert setHVACMode.call_count == 1 assert setHVACMode.await_args[0][0] == LENNOX_HVAC_EMERGENCY_HEAT + assert "turn_aux_heat_on is deprecated and will be removed in version 2024.10" in caplog.text await conf_test_exception_handling(zone, "setHVACMode", c, c.async_turn_aux_heat_on) @@ -1279,12 +1267,13 @@ async def test_climate_turn_aux_heat_off(hass, manager_mz: Manager, caplog): c = S30Climate(hass, manager, system, zone) zone.systemMode = LENNOX_HVAC_HEAT - with caplog.at_level(logging.ERROR): + with caplog.at_level(logging.WARNING): with patch.object(zone, "setHVACMode") as setHVACMode: caplog.clear() await c.async_turn_aux_heat_off() assert setHVACMode.call_count == 1 assert setHVACMode.await_args[0][0] == LENNOX_HVAC_HEAT + assert "turn_aux_heat_off is deprecated and will be removed in version 2024.10" in caplog.text await conf_test_exception_handling(zone, "setHVACMode", c, c.async_turn_aux_heat_off) @@ -1527,7 +1516,7 @@ async def test_climate_set_temperature(hass, manager_mz: Manager, caplog): except HomeAssistantError as err: ex = err assert ex is not None - assert "System Mode is [off]" in str(ex) + assert "System Mode is [off]" in str(ex) system.single_setpoint_mode = False zone.systemMode = "cool" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index da8281d..115adc4 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -426,7 +426,7 @@ async def test_upgrade_config_v1(hass): "message_debug_file": "", } - config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") + config_entry = config_entries.ConfigEntry(version=1, minor_version=0, domain=DOMAIN, title = "Test", data = data, source="my_source") with patch.object(hass.config_entries, "async_update_entry") as update_entry: await async_migrate_entry(hass, config_entry) assert update_entry.call_count == 1 @@ -469,7 +469,7 @@ async def test_upgrade_config_v1(hass): "message_debug_file": "", } - config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") + config_entry = config_entries.ConfigEntry(version=1, minor_version=0, domain=DOMAIN, title = "Test", data = data, source="my_source") with patch.object(hass.config_entries, "async_update_entry") as update_entry: await async_migrate_entry(hass, config_entry) assert update_entry.call_count == 1 @@ -516,7 +516,7 @@ async def test_upgrade_config_v2(hass): "timeout": 30, } - config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") + config_entry = config_entries.ConfigEntry(version=1, minor_version=0, domain=DOMAIN, title = "Test", data = data, source="my_source") with patch.object(hass.config_entries, "async_update_entry") as update_entry: await async_migrate_entry(hass, config_entry) assert update_entry.call_count == 1 @@ -562,7 +562,7 @@ async def test_upgrade_config_v2(hass): "timeout": 30, } - config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") + config_entry = config_entries.ConfigEntry(version=1, minor_version=0, domain=DOMAIN, title = "Test", data = data, source="my_source") with patch.object(hass.config_entries, "async_update_entry") as update_entry: await async_migrate_entry(hass, config_entry) assert update_entry.call_count == 1 @@ -611,7 +611,7 @@ async def test_upgrade_config_v3(hass, caplog): "timeout": 30, } - config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") + config_entry = config_entries.ConfigEntry(version=1, minor_version=0, domain=DOMAIN, title = "Test", data = data, source="my_source") with patch.object(hass.config_entries, "async_update_entry") as update_entry: await async_migrate_entry(hass, config_entry) assert update_entry.call_count == 1 @@ -657,7 +657,7 @@ async def test_upgrade_config_v3(hass, caplog): "timeout": 30, } - config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") + config_entry = config_entries.ConfigEntry(version=1, minor_version=0, domain=DOMAIN, title = "Test", data = data, source="my_source") with patch.object(hass.config_entries, "async_update_entry") as update_entry: await async_migrate_entry(hass, config_entry) assert update_entry.call_count == 1 diff --git a/tests/test_select_setup.py b/tests/test_select_setup.py index 414b6a1..8366dfc 100644 --- a/tests/test_select_setup.py +++ b/tests/test_select_setup.py @@ -14,12 +14,13 @@ HumidityModeSelect, EquipmentParameterSelect, VentilationModeSelect, + ZoneModeSelect, async_setup_entry, ) @pytest.mark.asyncio -async def test_async_number_setup_entry(hass, manager: Manager): +async def test_async_select_setup_entry(hass, manager: Manager): """Test the select setup""" system: lennox_system = manager.api.system_list[0] entry = manager.config_entry @@ -106,3 +107,18 @@ async def test_async_number_setup_entry(hass, manager: Manager): sensor_list = async_add_entities.call_args[0][0] assert len(sensor_list) == 1 assert isinstance(sensor_list[0], VentilationModeSelect) + + # ZoneModeSelect should be created + system.dehumidifierType = None + for zone in system.zone_list: + zone.dehumidificationOption = False + zone.humidificationOption = False + zone.emergencyHeatingOption = True + manager.create_equipment_parameters = False + system.ventilationUnitType = None + async_add_entities = Mock() + await async_setup_entry(hass, entry, async_add_entities) + assert async_add_entities.call_count == 1 + sensor_list = async_add_entities.call_args[0][0] + assert len(sensor_list) == 1 + assert isinstance(sensor_list[0], ZoneModeSelect) \ No newline at end of file diff --git a/tests/test_select_zone_mode.py b/tests/test_select_zone_mode.py new file mode 100644 index 0000000..5d0bbcd --- /dev/null +++ b/tests/test_select_zone_mode.py @@ -0,0 +1,193 @@ +# pylint: disable=too-many-lines +# pylint: disable=missing-module-docstring +# pylint: disable=missing-function-docstring +# pylint: disable=invalid-name +# pylint: disable=protected-access +# pylint: disable=line-too-long + +import logging +from unittest.mock import patch +import pytest + + +from homeassistant.components.climate.const import HVACMode +from homeassistant.exceptions import HomeAssistantError + +from lennoxs30api.s30api_async import ( + LENNOX_VENTILATION_MODE_OFF, + LENNOX_VENTILATION_MODE_ON, + LENNOX_VENTILATION_MODE_INSTALLER, + LENNOX_ZONING_MODE_CENTRAL, + LENNOX_HVAC_OFF, + LENNOX_HVAC_COOL, + LENNOX_HVAC_HEAT, + LENNOX_HVAC_HEAT_COOL, + LENNOX_HVAC_EMERGENCY_HEAT, + lennox_system, + lennox_zone, +) + + +from custom_components.lennoxs30 import Manager +from custom_components.lennoxs30.select import ZoneModeSelect +from custom_components.lennoxs30.const import LENNOX_DOMAIN, UNIQUE_ID_SUFFIX_ZONEMODE_SELECT + +from tests.conftest import ( + conf_test_exception_handling, + conftest_base_entity_availability, + conf_test_select_info_async_select_option, +) + + +@pytest.mark.asyncio +async def test_zone_mode_select_mode_unique_id(hass, manager: Manager): + system: lennox_system = manager.api.system_list[0] + zone: lennox_zone = system.zone_list[0] + c = ZoneModeSelect(hass, manager, system, zone) + assert c.unique_id == zone.unique_id + UNIQUE_ID_SUFFIX_ZONEMODE_SELECT + + +@pytest.mark.asyncio +async def test_zone_mode_select_mode_name(hass, manager: Manager): + system: lennox_system = manager.api.system_list[0] + zone: lennox_zone = system.zone_list[0] + c = ZoneModeSelect(hass, manager, system, zone) + assert c.name == system.name + "_" + zone.name + "_hvac_mode" + +@pytest.mark.parametrize("lennox_mode,ha_mode", + [(LENNOX_HVAC_HEAT, HVACMode.HEAT), + (LENNOX_HVAC_COOL, HVACMode.COOL), + (LENNOX_HVAC_HEAT_COOL, HVACMode.HEAT_COOL), + (LENNOX_HVAC_OFF, HVACMode.OFF), + (LENNOX_HVAC_EMERGENCY_HEAT,LENNOX_HVAC_EMERGENCY_HEAT), + ]) +@pytest.mark.asyncio +async def test_zone_mode_select_mode_current_option(hass, manager_mz: Manager, lennox_mode:str, ha_mode): + manager = manager_mz + system: lennox_system = manager.api.system_list[0] + zone: lennox_zone = system.zone_list[0] + c = ZoneModeSelect(hass, manager, system, zone) + + zone.systemMode = lennox_mode + assert c.current_option == ha_mode + + +@pytest.mark.asyncio +async def test_zone_mode_select_mode_subscription(hass, manager_mz: Manager): + manager = manager_mz + system: lennox_system = manager.api.system_list[0] + system.dehumidificationMode = None + zone: lennox_zone = system.zone_list[0] + c = ZoneModeSelect(hass, manager, system, zone) + await c.async_added_to_hass() + + for mode in (LENNOX_HVAC_COOL, LENNOX_HVAC_HEAT, LENNOX_HVAC_EMERGENCY_HEAT, LENNOX_HVAC_OFF): + with patch.object(c, "schedule_update_ha_state") as update_callback: + update_set = {"systemMode": mode} + zone.attr_updater(update_set, "systemMode") + zone.executeOnUpdateCallbacks() + assert update_callback.call_count == 1 + assert c.current_option == mode + assert c.available is True + + with patch.object(c, "schedule_update_ha_state") as update_callback: + update_set = {"systemMode": LENNOX_HVAC_HEAT_COOL} + zone.attr_updater(update_set, "systemMode") + zone.executeOnUpdateCallbacks() + assert update_callback.call_count == 1 + assert c.current_option == HVACMode.HEAT_COOL + assert c.available is True + + with patch.object(c, "schedule_update_ha_state") as update_callback: + update_set = {"zoningMode": LENNOX_ZONING_MODE_CENTRAL} + system.attr_updater(update_set, "zoningMode") + system.executeOnUpdateCallbacks() + assert update_callback.call_count == 1 + assert c.available is True + + conftest_base_entity_availability(manager, system, c) + + +@pytest.mark.asyncio +async def test_zone_mode_select_mode_options(hass, manager_mz: Manager): + manager = manager_mz + system: lennox_system = manager.api.system_list[0] + zone: lennox_zone = system.zone_list[0] + c = ZoneModeSelect(hass, manager, system, zone) + + zone.heatingOption = True + zone.coolingOption = False + zone.emergencyHeatingOption = False + opt = c.options + assert len(opt) == 2 + assert HVACMode.HEAT in opt + assert HVACMode.OFF in opt + + zone.heatingOption = False + zone.coolingOption = False + zone.emergencyHeatingOption = True + opt = c.options + assert len(opt) == 2 + assert LENNOX_HVAC_EMERGENCY_HEAT in opt + assert HVACMode.OFF in opt + + zone.heatingOption = False + zone.coolingOption = True + zone.emergencyHeatingOption = False + opt = c.options + assert len(opt) == 2 + assert HVACMode.COOL in opt + assert HVACMode.OFF in opt + + zone.heatingOption = True + zone.coolingOption = True + zone.emergencyHeatingOption = False + opt = c.options + assert len(opt) == 4 + assert HVACMode.HEAT in opt + assert HVACMode.HEAT_COOL in opt + assert HVACMode.COOL in opt + assert HVACMode.OFF in opt + + +@pytest.mark.asyncio +async def test_zone_mode_select_mode_async_select_options(hass, manager_mz: Manager, caplog): + manager = manager_mz + system: lennox_system = manager.api.system_list[0] + zone: lennox_zone = system.zone_list[1] + c = ZoneModeSelect(hass, manager, system, zone) + + with patch.object(zone, "setHVACMode") as set_hvac_mode: + await c.async_select_option(HVACMode.HEAT) + assert set_hvac_mode.call_count == 1 + set_hvac_mode.assert_called_once_with(LENNOX_HVAC_HEAT) + + with patch.object(zone, "setHVACMode") as set_hvac_mode: + await c.async_select_option(HVACMode.HEAT_COOL) + assert set_hvac_mode.call_count == 1 + set_hvac_mode.assert_called_once_with(LENNOX_HVAC_HEAT_COOL) + + await conf_test_exception_handling( + zone, "setHVACMode", c, c.async_select_option, option=LENNOX_HVAC_HEAT_COOL + ) + await conf_test_select_info_async_select_option(zone, "setHVACMode", c, caplog) + + with patch.object(zone, "setHVACMode") as set_hvac_mode: + system.zoningMode = LENNOX_ZONING_MODE_CENTRAL + with pytest.raises(HomeAssistantError) as hae: + await c.async_select_option(HVACMode.HEAT_COOL) + assert set_hvac_mode.call_count == 0 + assert "is disabled" in str(hae.value) + +@pytest.mark.asyncio +async def test_zone_mode_select_mode_device_info(hass, manager_mz: Manager): + manager = manager_mz + await manager.create_devices() + system: lennox_system = manager.api.system_list[0] + zone: lennox_zone = system.zone_list[0] + c = ZoneModeSelect(hass, manager, system, zone) + + identifiers = c.device_info["identifiers"] + for x in identifiers: + assert x[0] == LENNOX_DOMAIN + assert x[1] == zone.unique_id