diff --git a/.vscode/settings.json b/.vscode/settings.json index 32a3b7e..a0df205 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,21 +1,28 @@ { - "python.testing.pytestArgs": ["tests"], + "python.testing.pytestArgs": ["tests", "--asyncio-mode=auto"], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "cSpell.words": [ + "ASHRAE", + "automations", + "desp", + "dewpoint", "HACS", "heatpump", + "homeassistant", + "husp", "HVAC", "Lennox", "lennoxicomfort", "lennoxs", + "rssi", "Setpoint", "setpoints", "sysuptime" ], "python.formatting.provider": "black", "python.formatting.blackArgs": ["--line-length", "120"], - "cSpell.enabled": false, + "cSpell.enabled": true, "python.linting.flake8Enabled": false, "python.linting.enabled": true, "python.linting.flake8Args": ["--config=setup.cfg", "--doctests"], diff --git a/README.MD b/README.MD index dd19b43..f508e4b 100644 --- a/README.MD +++ b/README.MD @@ -20,6 +20,7 @@ Cloud Connections depending on the device model. We believe these configurations # Important Information - Note: Where we mention S30 in this documentation that also applies to S40, E30 and M30 +- If you are having problems with your S40 - please make sure you have software version 04.25.0070 or greater installed. You also may need to shutdown HA and reboot the thermostats in order to establish a connection. - It is recommended to disable automatic software updates on the Thermostat. This will prevent outages if new versions are not compatible with this integration. - If you have more than one S30 in your local network, please use a different app_id for each instance (e.g. homeassistant_1, homeassistant_2) - If you are running more than 1 Home Assistant communicating to the S30 (for example a test and prod system) - you _MUST_ use a different _app_id_ for each instance - [Detailed Configuration](#Detailed-Configuration-Parameters) @@ -164,7 +165,7 @@ If you want to change the integration configuration, do into the integrations pa The integration detects the unit system configured in Home Assistant and reports the data in the correct units. Celsius is in 0.5 degree increments. Fahrenheit is in 1.0 degrees increments. The Lennox API delivers data in both units, so there is no conversion in the Integration and what you see in the Lennox UI should be what you see in HA. -# HASS Scripts and automation samples +# Homeassistant Scripts and automation samples Samples of useful automations and scripts can be found in the [samples/](./samples/) directory. If you have some you'd like included, open a pull request. @@ -232,23 +233,23 @@ The integration provides all of the standard climate attributes, including In addition the following extra attributes are provided, to allow for a more detailed information on the current zone operation. -| Attribute Name | Description | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| allergenDefender | Indicates if this is enabled on the zone | -| aux | Auxiliary Heating is On when True, off when False. Typically this means the hest pump is disabled and the furnace is running instead. | -| balancePoint | This is related to heatpump lockout, details / values are not well known yet - if you know let us know! | -| coolCoast | This will only appear in non-zoning systems and it indicates the system is set to a single setpoint mode, the system was in heating mode and has turned off to coast to the desired temperature | -| damper | Position of damper - range 0-100. Observationally values are either 0 or 100; where 100 = damper open. | -| defrost | Defrost is active | -| demand | CFM of air demand for the zone. Thanks @blyons16 for getting this information from his installers | -| fan | Indicates if the fan is currently running. Note: this is true only when the fan is running and there is no active HVAC action (cooling, heating, etc.) | -| heatCoast | This will only appear in non-zoning systems and it indicates the system is set to a single setpoint mode, the system was in heating mode and has turned off to coast to the desired temperature | -| humidityOperation | Current active humidity operation - **Waiting**,**Humidifying**, **Drying** or **Off** | -| ssr | Not known what this attribute indicates. If you know let us know! | -| tempOperation | Current active temperature operation heating or cooling | -| ventilation | Indicates if external ventilation is currently active on this zone | -| zoneEnabled | Indicates if the zone is currently enabled. When in a multi-zone system, disabling iHarmony Zoning, turns the system into a single zone system controlled by Zone 1. The other climate entities are disabled except for reporting Ambient Temperature and Humidity | -| zoningMode | The system zoning mode - **central** or **zoned** | +| Attribute Name | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| allergenDefender | Indicates if this is enabled on the zone | +| aux | Auxiliary Heating is On when True, off when False. Typically this means the hest pump is disabled and the furnace is running instead. | +| balancePoint | This is related to heatpump lockout, details / values are not well known yet - if you know let us know! | +| coolCoast | This will only appear in non-zoning systems and it indicates the system is set to a single setpoint mode, the system was in heating mode and has turned off to coast to the desired temperature | +| damper | Position of damper - range 0-100. Observationally values are either 0 or 100; where 100 = damper open. | +| defrost | Defrost is active | +| demand | % of maximum CFM of air demand for the zone. Value is 0-100 | +| fan | Indicates if the fan is currently running. Note: this is true only when the fan is running and there is no active HVAC action (cooling, heating, etc.) | +| heatCoast | This will only appear in non-zoning systems and it indicates the system is set to a single setpoint mode, the system was in heating mode and has turned off to coast to the desired temperature | +| humidityOperation | Current active humidity operation - **Waiting**,**Humidifying**, **Drying** or **Off** | +| ssr | When enabled, smooth set back begins recovery up to two hours before the programmed time so that the programmed temperature is reached at the corresponding programmed event time. Assume 12°F (6.72°C) per hour for first-stage gas/electric heating and 6°F (3.36°C) per hour for first-stage compressor based heating or cooling.With Smooth Set Back disabled, the system will start a recovery at the programmed time. Options are enabled or disabled. Default is enabled. See manual for more details | +| tempOperation | Current active temperature operation heating or cooling | +| ventilation | Indicates if external ventilation is currently active on this zone | +| zoneEnabled | Indicates if the zone is currently enabled. When in a multi-zone system, disabling iHarmony Zoning, turns the system into a single zone system controlled by Zone 1. The other climate entities are disabled except for reporting Ambient Temperature and Humidity | +| zoningMode | The system zoning mode - **central** or **zoned** | ## lennoxs30.state or lennoxs30.conn\_\ or lennoxs30.\_ @@ -327,23 +328,23 @@ The 21P02 is a line powered air quality sensor. #### Sensors -| Entity Type | Name | Units | Notes | -| ----------- | -------------------- | ------- | ------------------------------------------------ | -| sensor | Co2 | PPM | CO2 level | -| sensor | Co2 component score | Text | Fair, Good ? | -| sensor | Co2 lta | PPM | long term average | -| sensor | Co2 sta | PPM | short term average | -| sensor | Mitigation Action | Text | Current action being taken to addess air quality | -| sensor | Mitigation State | Text | ? | -| sensor | Overall Index | Text | Overall air quality - Fair, Good, ? | -| sensor | Pm25 | ug/m3 ? | Particulate Matter level | -| sensor | Pm25 component score | Text | Fair, Good ? | -| sensor | Pm25 lta | ug/m3 ? | long term average | -| sensor | Pm25 sta | ug/m3 ? | short term average | -| sensor | VOC | ug/m3 ? | Volatile Organic Compounds | -| sensor | VOC component score | Text | Fair, Good ? | -| sensor | VOC lta | ug/m3 ? | long term average | -| sensor | VOC sta | ug/m3 ? | short term average | +| Entity Type | Name | Units | Notes | +| ----------- | -------------------- | ------- | ------------------------------------------------- | +| sensor | Co2 | PPM | CO2 level | +| sensor | Co2 component score | Text | Fair, Good ? | +| sensor | Co2 lta | PPM | long term average | +| sensor | Co2 sta | PPM | short term average | +| sensor | Mitigation Action | Text | Current action being taken to address air quality | +| sensor | Mitigation State | Text | ? | +| sensor | Overall Index | Text | Overall air quality - Fair, Good, ? | +| sensor | Pm25 | ug/m3 ? | Particulate Matter level | +| sensor | Pm25 component score | Text | Fair, Good ? | +| sensor | Pm25 lta | ug/m3 ? | long term average | +| sensor | Pm25 sta | ug/m3 ? | short term average | +| sensor | VOC | ug/m3 ? | Volatile Organic Compounds | +| sensor | VOC component score | Text | Fair, Good ? | +| sensor | VOC lta | ug/m3 ? | long term average | +| sensor | VOC sta | ug/m3 ? | short term average | #### Diagnostic Sensors @@ -526,22 +527,59 @@ switch.\_manual_away_mode ### ventilation -If your system has an external outdoor air damper, a 1 or 2 stage ERV, or a 1 or 2 stage HRV, a switch will be created. +If your system has an external outdoor air damper, a 1 or 2 stage ERV, or a 1 or 2 stage HRV ventilation controls will be created. -switch.\_ventilation +There are two different aspects of ventilation + +1. The Ventilation Mode which is **On**, **Off** or **Installer**. When set to **Installer** the system will run in a Timed mode or ASHRAE mode depending on how the installer configured the system. When set to **On** the ventilation will run indefinitely. When set to **Off** the ventilation will not run. +2. **Ventilate Now** - this is a one time ventilation for a duration. For example, on the panel, select Ventilate Now and enter 10 minutes - and ventilation will run for 10 minutes. + +If the system is receiving weather data; ventilation will not occur if the outside dewpoint is above the configured threshold. The outside dewpoint, for S40, is available in **sensor.\*\_wt_env_dewpoint**. The threshold is available in an equipment parameter called **Ventilation High Outdoor Dew Point Limit** + +#### select.\_ventilation_mode + +Allows setting ventilation mode to On, Off or Installer. + +Attribute **installer_settings** contains **installer** or **ASHRAE** + +#### number.\_ventilate_now + +Note: in older installs this entity may be called **number.\_timed_ventilation**, it was renamed in 2023_10_0 to make it similar to the panel text. + +Setting this to a non-zero number will place the system into a **ventilate now** mode for the specified number of minutes. This is equivalent to selecting a timed ventilation from the S30 panel. As the amount of remaining time decreases, the value of the number will update. + +For example, setting this to 10 - will start ventilation for 10 minutes. After 1 minute has elapsed; this value will be 9; after another minute it will be 8. Setting this to Zero cancels any in-progress timed ventilation. Another way to cancel a timed ventilation is to turn the ventilation switch entity off. + +#### switch.\_ventilation The switch will reflect the current state of the ventilation damper and will allow turning the mode on or off. The switch has the following attributes: -| Attribute | Type | Description | -| ------------------------ | ---- | ------------------------------------------------------------------------ | -| ventilationRemainingTime | int | number of minutes remaining in ventilation action | -| ventilatingUntilTime | int | integer timestamp of the end time of the ventilation action | -| diagVentilationRuntime | int | total number of minutes the system has ventilated for over it's lifetime | -| alwaysOn | bool | indicates if the switch is on because of an **always on** command | -| timed | bool | indicates if the switch is on because a timed ventilation is active | +| Attribute | Type | Description | +| ------------------------ | ---- | --------------------------------------------------------------------------------- | +| ventilationRemainingTime | int | number of minutes remaining in ventilation action | +| ventilatingUntilTime | int | integer timestamp of the end time of the ventilation action | +| diagVentilationRuntime | int | total number of minutes the system has ventilated for over it's lifetime | +| alwaysOn | bool | indicates if the switch is on because of an **always on** command | +| timed | bool | indicates if the switch is on because a ventilate now timed ventilation is active | + +Turning this switch on is equivalent to selecting "Always On" from the S30 Panel. If a ventilate now is active, this switch will be on and turning it off will cancel the timed ventilation. + +### weather - S40 only -Turning this switch on is equivalent to selecting "Always On" from the S30 Panel. If a timed ventilation is active, this switch will be on and turning it off will cancel the timed ventilation. +The S40 makes its weather data available. The S30 does not appear to. While the data includes an hourly and daily forecast - that information is available in HA through a variety of weather providers. However there are specific environmental data that the S40 uses to control ventilation and allergen defender mode - this information is available in a set of sensors. Note: if your S40 is not connected to the internet these sensors will be unavailable. + +| Sensor | Type | Values | +| -------------------------------- | ------ | ------------------------------------------------------------------------------------ | +| sensor.\*\_wt_env_air_quality | string | "hazardous","very unhealthy", "unhealthy", "unhealthy sensitive", "moderate", "good" | +| sensor.\*\_wt_env_tree | string | "low","moderate","high","very high", "extreme" | +| sensor.\*\_wt_env_grass | string | "low","moderate","high","very high", "extreme" | +| sensor.\*\_wt_env_mold | string | "low","moderate","high","very high", "extreme" | +| sensor.\*\_wt_env_uv_index | string | "low","moderate","high","very high", "extreme" | +| sensor.\*\_wt_env_dewpoint | number | C or F | +| sensor.\*\_wt_env_wind_speed | number | Km/H, MPH | +| sensor.\*\_wt_env_cloud_coverage | number | 0-100 | +| sensor.\*\_wt_env_humidity | number | 0-100 | ### zoning mode @@ -620,7 +658,7 @@ binary_sensor.\_relay_server ### cloud_connected (cloud connections only) -This sensor indicates if the themostat is "Online" in the Lennox Cloud. The state should be connected. A **disconnected** state indicates that the integration is able to communicate to the Lennox Cloud but the thermostat is not able to. Typically this indicates a Wifi config issue with the thermostat. +This sensor indicates if the thermostat is "Online" in the Lennox Cloud. The state should be connected. A **disconnected** state indicates that the integration is able to communicate to the Lennox Cloud but the thermostat is not able to. Typically this indicates a Wifi config issue with the thermostat. During testing, turning off the wifi router the S30 was connected caused this sensor to become **disconnected** after 5 minutes. The sensor is configured to be updated every 10 minutes. @@ -648,12 +686,6 @@ number.\_circulate_time Lennox systems with dehumidifiers may have the ability to provide overcooling to lower the humidity when the dehumidification mode is **Max**. This number allow you to set this over-cooling. Typically is is 0-4 degrees F or 0-2 degrees Celsius. -### timed_ventilation - -Lennox Systems with ventilation configured for timed mode (not ASHRAE) will have this number entity created. The entity is in units of minutes. Setting this to a non-zero number will place the system into ventilation mode for the specified number of minutes. This is equivalent to selecting a timed ventilation from the S30 panel. As the amount of remaining time decreases, the value of the number will update. - -For example, setting this to 10 - will start ventilation for 10 minutes. After 1 minute has elapsed; this value will be 9; after another minute it will be 8. Setting this to Zero cancels any in-progress timed ventilaion. Another way to cancel a timed ventilation is to turn the ventilation switch entity off. - ## Button Entities ### Reset Smart Hub diff --git a/custom_components/lennoxs30/const.py b/custom_components/lennoxs30/const.py index 84e442f..506876b 100644 --- a/custom_components/lennoxs30/const.py +++ b/custom_components/lennoxs30/const.py @@ -45,6 +45,7 @@ UNIQUE_ID_SUFFIX_RESET_SMART_HUB: Final = "_RESET_SMART_HUB" UNIQUE_ID_SUFFIX_BLE: Final = "_BLE" UNIQUE_ID_SUFFIX_BLE_COMMSTATUS: Final = "_BLE_COMMSTATUS" +UNIQUE_ID_SUFFIX_VENTILATION_SELECT: Final = "_VENT_SELECT" VENTILATION_EQUIPMENT_ID = -900 diff --git a/custom_components/lennoxs30/diagnostics.py b/custom_components/lennoxs30/diagnostics.py new file mode 100644 index 0000000..158c84b --- /dev/null +++ b/custom_components/lennoxs30/diagnostics.py @@ -0,0 +1,52 @@ +"""Diagnostics support for Nest.""" +# pylint: disable=line-too-long +from __future__ import annotations +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from . import MANAGER, Manager +from .const import LENNOX_DOMAIN + + +async def async_get_config_entry_diagnostics(hass: HomeAssistant, config_entry: ConfigEntry) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] + data: dict[str, Any] = {} + + data["config"] = {} + for key, val in config_entry.data.items(): + data["config"][key] = val + if CONF_PASSWORD in data["config"]: + data["config"][CONF_PASSWORD] = "**redacted**" + if CONF_EMAIL in data["config"]: + data["config"][CONF_EMAIL] = "**redacted**" + data["system"] = {} + for system in manager.api.system_list: + system_data: dict[str, any] = { + "relayServer": system.relayServerConnected, + "internet": system.internetStatus, + "diagLevel": system.diagLevel, + "cloud_status": system.cloud_status, + "productType": system.productType, + "sibling_identifier": system.sibling_identifier, + "sibling_ip": system.sibling_ipAddress, + "softwareVersion": system.softwareVersion, + "sysUpTime": system.sysUpTime, + } + + system_data["equipment"] = {} + for eq_id, equipment in system.equipment.items(): + element = { + "name": equipment.equipment_name, + "eqType": equipment.equipType, + "eqTypeName": equipment.equipment_type_name, + "model": equipment.unit_model_number, + } + system_data["equipment"][eq_id] = element + data["system"][system.sysId] = system_data + + data["comm_metrics"] = manager.getMetricsList() + return data diff --git a/custom_components/lennoxs30/manifest.json b/custom_components/lennoxs30/manifest.json index 57cf037..4b195c0 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.8"], - "version": "2023.6.1" + "requirements": ["lennoxs30api==0.2.10"], + "version": "2023.10.0" } \ No newline at end of file diff --git a/custom_components/lennoxs30/number.py b/custom_components/lennoxs30/number.py index 40d83c1..f0dfe8a 100644 --- a/custom_components/lennoxs30/number.py +++ b/custom_components/lennoxs30/number.py @@ -29,7 +29,6 @@ lennox_system, LENNOX_CIRCULATE_TIME_MAX, LENNOX_CIRCULATE_TIME_MIN, - LENNOX_VENTILATION_CONTROL_MODE_TIMED, ) from lennoxs30api.lennox_equipment import ( @@ -98,7 +97,7 @@ async def async_setup_entry( number = CirculateTime(hass, manager, system) number_list.append(number) - if system.supports_ventilation() and system.ventilationControlMode == LENNOX_VENTILATION_CONTROL_MODE_TIMED: + if system.supports_ventilation(): number = TimedVentilationNumber(hass, manager, system) number_list.append(number) @@ -368,7 +367,7 @@ class TimedVentilationNumber(S30BaseEntityMixin, NumberEntity): def __init__(self, hass: HomeAssistant, manager: Manager, system: lennox_system): super().__init__(manager, system) self._hass = hass - self._myname = self._system.name + "_timed_ventilation" + self._myname = self._system.name + "_ventilate_now" _LOGGER.debug("Create TimedVentilationNumber myname [%s]", self._myname) async def async_added_to_hass(self) -> None: diff --git a/custom_components/lennoxs30/select.py b/custom_components/lennoxs30/select.py index c3a8cb4..b5c29b7 100644 --- a/custom_components/lennoxs30/select.py +++ b/custom_components/lennoxs30/select.py @@ -23,6 +23,10 @@ LENNOX_DEHUMIDIFICATION_MODE_HIGH, LENNOX_DEHUMIDIFICATION_MODE_MEDIUM, LENNOX_DEHUMIDIFICATION_MODE_AUTO, + LENNOX_VENTILATION_MODES, + LENNOX_VENTILATION_MODE_INSTALLER, + LENNOX_VENTILATION_MODE_ON, + LENNOX_VENTILATION_MODE_OFF, lennox_system, lennox_zone, ) @@ -39,7 +43,12 @@ ) from .base_entity import S30BaseEntityMixin -from .const import LOG_INFO_SELECT_ASYNC_SELECT_OPTION, MANAGER, UNIQUE_ID_SUFFIX_EQ_PARAM_SELECT +from .const import ( + LOG_INFO_SELECT_ASYNC_SELECT_OPTION, + MANAGER, + UNIQUE_ID_SUFFIX_EQ_PARAM_SELECT, + UNIQUE_ID_SUFFIX_VENTILATION_SELECT, +) from . import DOMAIN, Manager @@ -61,6 +70,11 @@ async def async_setup_entry( _LOGGER.debug("Create DehumidificationModeSelect system [%s]", system.sysId) sel = DehumidificationModeSelect(hass, manager, system) select_list.append(sel) + + if system.supports_ventilation(): + _LOGGER.info("Create S30 ventilation select system [%s]", system.sysId) + select_list.append(VentilationModeSelect(hass, manager, system)) + for zone in system.zone_list: if zone.is_zone_active(): if zone.dehumidificationOption or zone.humidificationOption: @@ -362,3 +376,86 @@ def entity_category(self): @property def extra_state_attributes(self) -> dict[str, Any]: return helper_get_parameter_extra_attributes(self.equipment, self.parameter) + + +class VentilationModeSelect(S30BaseEntityMixin, SelectEntity): + """Set the ventilation mode""" + + def __init__( + self, + hass: HomeAssistant, + manager: Manager, + system: lennox_system, + ): + super().__init__(manager, system) + self.hass: HomeAssistant = hass + self._myname = self._system.name + "_ventilation_mode" + _LOGGER.debug("Create VentilationModeSelect 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._system.registerOnUpdateCallback( + self.system_update_callback, + [ + "ventilationMode", + "ventilationControlMode", + ], + ) + await super().async_added_to_hass() + + def system_update_callback(self): + """Callback for system updates""" + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "system_update_callback VentilationModeSelect myname [%s] system ventilation mode [%s]", + self._myname, + self._system.ventilationMode, + ) + self.schedule_update_ha_state() + + @property + def unique_id(self) -> str: + # HA fails with dashes in IDs + return self._system.unique_id + UNIQUE_ID_SUFFIX_VENTILATION_SELECT + + @property + def name(self): + return self._myname + + @property + def current_option(self) -> str: + return self._system.ventilationMode + + @property + def options(self) -> list: + return LENNOX_VENTILATION_MODES + + async def async_select_option(self, option: str) -> None: + _LOGGER.info(LOG_INFO_SELECT_ASYNC_SELECT_OPTION, self.__class__.__name__, self._myname, option) + try: + if option == LENNOX_VENTILATION_MODE_ON: + await self._system.ventilation_on() + elif option == LENNOX_VENTILATION_MODE_OFF: + await self._system.ventilation_off() + elif option == LENNOX_VENTILATION_MODE_INSTALLER: + await self._system.ventilation_installer() + else: + raise HomeAssistantError(f"select_option [{self._myname}] invalid mode [{option}]") + except Exception as ex: + raise HomeAssistantError(f"select_option unexpected exception, [{self._myname}] exception [{ex}]") from ex + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + result = { + "identifiers": {(DOMAIN, self._system.unique_id)}, + } + return result + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + attrs: dict[str, Any] = {} + attrs["installer_settings"] = self._system.ventilationControlMode + return attrs diff --git a/custom_components/lennoxs30/sensor.py b/custom_components/lennoxs30/sensor.py index f5794e7..a9c2e2a 100644 --- a/custom_components/lennoxs30/sensor.py +++ b/custom_components/lennoxs30/sensor.py @@ -14,6 +14,7 @@ UnitOfFrequency, UnitOfElectricCurrent, UnitOfElectricPotential, + REVOLUTIONS_PER_MINUTE, ) from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry @@ -47,6 +48,7 @@ from .ble_device_21p02 import lennox_21p02_sensors, lennox_iaq_sensors from .sensor_ble import S40BleSensor from .sensor_iaq import S40IAQSensor +from .sensor_wt_env import lennox_wt_env_sensors, WTEnvSensor, lennox_wt_env_sensors_metric, lennox_wt_env_sensors_us _LOGGER = logging.getLogger(__name__) @@ -105,6 +107,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e diagsensor = S30DiagSensor(hass, manager, system, equip, diagnostic) sensor_list.append(diagsensor) + if system.is_s40: + for env in lennox_wt_env_sensors: + wt_sensor = WTEnvSensor(hass, manager, system, env) + sensor_list.append(wt_sensor) + if manager.is_metric: + for env in lennox_wt_env_sensors_metric: + wt_sensor = WTEnvSensor(hass, manager, system, env) + sensor_list.append(wt_sensor) + else: + for env in lennox_wt_env_sensors_us: + wt_sensor = WTEnvSensor(hass, manager, system, env) + sensor_list.append(wt_sensor) + if manager.create_sensors: for zone in system.zone_list: if zone.is_zone_active(): @@ -182,7 +197,13 @@ def __init__( self._equipment: lennox_equipment = equipment self._diagnostic: lennox_equipment_diagnostic = diagnostic - if diagnostic.unit.strip() == "": + self.uom = diagnostic.unit.strip() + if self.uom == "": + # Lennox does not provide a unit for RPM + if self._diagnostic.name.endswith("RPM"): + self.uom = REVOLUTIONS_PER_MINUTE + + if self.uom == "": self._state_class = None else: self._state_class = SensorStateClass.MEASUREMENT @@ -255,7 +276,7 @@ def name(self): @property def native_unit_of_measurement(self): - return lennox_uom_to_ha_uom(self._diagnostic.unit) + return lennox_uom_to_ha_uom(self.uom) @property def device_class(self): diff --git a/custom_components/lennoxs30/sensor_wt_env.py b/custom_components/lennoxs30/sensor_wt_env.py new file mode 100644 index 0000000..f1b823c --- /dev/null +++ b/custom_components/lennoxs30/sensor_wt_env.py @@ -0,0 +1,225 @@ +"""Support for Lennoxs30 outdoor temperature sensor""" +# pylint: disable=global-statement +# pylint: disable=broad-except +# pylint: disable=unused-argument +# pylint: disable=line-too-long +# pylint: disable=invalid-name +import logging + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorStateClass, SensorDeviceClass +from homeassistant.const import PERCENTAGE, UnitOfTemperature, UnitOfSpeed + +from lennoxs30api import lennox_system + +from . import Manager +from .base_entity import S30BaseEntityMixin +from .const import LENNOX_DOMAIN +from .helpers import helper_create_system_unique_id + +_LOGGER = logging.getLogger(__name__) + + +lennox_wt_env_sensors = [ + { + "name": "wt env airquality", + "input": "wt_env_airQuality", + "availability_attribute": "wt_is_valid", + "unavailable_value": "error", + }, + { + "name": "wt env tree", + "input": "wt_env_tree", + "availability_attribute": "wt_is_valid", + "unavailable_value": "error", + }, + { + "name": "wt env weed", + "input": "wt_env_weed", + "availability_attribute": "wt_is_valid", + "unavailable_value": "error", + }, + { + "name": "wt env grass", + "input": "wt_env_grass", + "availability_attribute": "wt_is_valid", + "unavailable_value": "error", + }, + { + "name": "wt env mold", + "input": "wt_env_mold", + "availability_attribute": "wt_is_valid", + "unavailable_value": "error", + }, + { + "name": "wt env uv index", + "input": "wt_env_uvIndex", + "availability_attribute": "wt_is_valid", + "unavailable_value": "error", + }, + { + "name": "wt env humidity", + "input": "wt_env_humidity", + "availability_attribute": "wt_is_valid", + "unavailable_value": "error", + "precision": 0, + "state_class": SensorStateClass.MEASUREMENT, + "device_class": SensorDeviceClass.HUMIDITY, + "uom": PERCENTAGE, + }, + { + "name": "wt env cloud coverage", + "input": "wt_env_cloudCoverage", + "availability_attribute": "wt_is_valid", + "unavailable_value": "error", + "precision": 0, + "state_class": SensorStateClass.MEASUREMENT, + "uom": PERCENTAGE, + }, +] + +lennox_wt_env_sensors_us = [ + { + "name": "wt env wind speed", + "input": "wt_env_windSpeed", + "availability_attribute": "wt_is_valid", + "unavailable_value": "error", + "precision": 1, + "state_class": SensorStateClass.MEASUREMENT, + "device_class": SensorDeviceClass.SPEED, + "uom": UnitOfSpeed.MILES_PER_HOUR, + }, + { + "name": "wt env dewpoint", + "input": "wt_env_dewpoint", + "availability_attribute": "wt_is_valid", + "unavailable_value": "error", + "precision": 1, + "state_class": SensorStateClass.MEASUREMENT, + "device_class": SensorDeviceClass.TEMPERATURE, + "uom": UnitOfTemperature.FAHRENHEIT, + }, +] + +lennox_wt_env_sensors_metric = [ + { + "name": "wt env wind speed", + "input": "wt_env_windSpeedK", + "availability_attribute": "wt_is_valid", + "unavailable_value": "error", + "precision": 1, + "state_class": SensorStateClass.MEASUREMENT, + "device_class": SensorDeviceClass.SPEED, + "uom": UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + { + "name": "wt env dewpoint", + "input": "wt_env_dewpointC", + "availability_attribute": "wt_is_valid", + "unavailable_value": "error", + "precision": 1, + "state_class": SensorStateClass.MEASUREMENT, + "device_class": SensorDeviceClass.TEMPERATURE, + "uom": UnitOfTemperature.CELSIUS, + }, +] + + +class WTEnvSensor(S30BaseEntityMixin, SensorEntity): + """Class for Lennox S40 WTEnvSensor Sensors.""" + + def __init__( + self, + hass: HomeAssistant, + manager: Manager, + system: lennox_system, + sensor_dict: dict, + ): + super().__init__(manager, system) + self._hass: HomeAssistant = hass + self._sensor_dict: dict = sensor_dict + self._myname: str = self._system.name + " " + sensor_dict["name"] + self._system_attr: str = sensor_dict["input"] + self._unavailable_value: str = sensor_dict.get("unavailable_value") + self._availability_attribute: str = sensor_dict.get("availability_attribute") + self._precision: int = sensor_dict.get("precision") + self._state_class = sensor_dict.get("state_class") + self._device_class = sensor_dict.get("device_class") + self._uom = sensor_dict.get("uom") + self._entity_category = sensor_dict.get("entity_category") + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + _LOGGER.debug("async_added_to_hass WTEnvSensor myname [%s]", self._myname) + attribs = [] + attribs.append(self._system_attr) + if self._availability_attribute is not None: + attribs.append(self._availability_attribute) + self._system.registerOnUpdateCallback(self.sensor_value_update, attribs) + await super().async_added_to_hass() + + def sensor_value_update(self): + """Callback to execute on data change""" + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("sensor_value_update WTEnvSensor myname [%s]", self._myname) + self.schedule_update_ha_state() + + @property + def unique_id(self) -> str: + return helper_create_system_unique_id( + self._system, + self._system_attr, + ) + + @property + def name(self): + return self._myname + + @property + def native_value(self): + value = getattr(self._system, self._system_attr) + if self._state_class is None: + return value + try: + return round(float(value), self._precision) + except ValueError as e: + _LOGGER.warning( + "native_value myname [%s] sensor value [%s] exception: [%s]", + self._myname, + value, + e, + ) + return None + + @property + def state_class(self): + return self._state_class + + @property + def device_class(self): + return self._device_class + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return { + "identifiers": {(LENNOX_DOMAIN, self._system.unique_id)}, + } + + @property + def native_unit_of_measurement(self): + return self._uom + + @property + def available(self) -> bool: + if getattr(self._system, self._system_attr) == self._unavailable_value: + return False + if getattr(self._system, self._availability_attribute) is False: + return False + return super().available + + @property + def entity_category(self): + return self._entity_category diff --git a/custom_components/lennoxs30/switch.py b/custom_components/lennoxs30/switch.py index 92e3e05..89d8d99 100644 --- a/custom_components/lennoxs30/switch.py +++ b/custom_components/lennoxs30/switch.py @@ -493,6 +493,7 @@ def __init__( self._hass = hass self._myname = self._system.name + "_parameter_safety" self._rearm_duration_sec = rearm_duration_sec + self._rearm_task = None manager.parameter_safety_turn_on(self._system.sysId) @property @@ -527,12 +528,15 @@ def device_info(self) -> DeviceInfo: async def async_turn_on(self, **kwargs): _LOGGER.info(LOG_INFO_SWITCH_ASYNC_TURN_ON, self.__class__.__name__, self._myname) self._manager.parameter_safety_turn_on(self._system.sysId) + if self._rearm_task is not None: + self._rearm_task.cancel() + self._rearm_task = None self.schedule_update_ha_state() async def async_turn_off(self, **kwargs): _LOGGER.info(LOG_INFO_SWITCH_ASYNC_TURN_OFF, self.__class__.__name__, self._myname) self._manager.parameter_safety_turn_off(self._system.sysId) - asyncio.create_task(self.async_rearm_task()) + self._rearm_task = asyncio.create_task(self.async_rearm_task()) self.schedule_update_ha_state() async def async_rearm_task(self): diff --git a/tests/conftest.py b/tests/conftest.py index 6cada1a..6dd4dad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,7 @@ ) from lennoxs30api.s30exception import S30Exception +from homeassistant.helpers import device_registry as dr from homeassistant.exceptions import HomeAssistantError from homeassistant.components.number import NumberEntity from homeassistant.components.select import SelectEntity @@ -80,6 +81,13 @@ def auto_enable_custom_integrations(enable_custom_integrations): yield +@pytest.fixture(autouse=True) +def disable_device_registry(hass): + device_registry = dr.async_get(hass) + with patch.object(device_registry, "async_get_or_create"): + yield + + @pytest.fixture def calls(hass): """Track calls to a mock service.""" diff --git a/tests/messages/weather.json b/tests/messages/weather.json new file mode 100644 index 0000000..ff96bb4 --- /dev/null +++ b/tests/messages/weather.json @@ -0,0 +1,378 @@ +{ + "MessageId": 0, + "SenderID": "**redacted**", + "TargetID": "homeassistant", + "MessageType": "PropertyChange", + "Data": { + "weather": { + "publisher": { + "publisherName": "remote" + }, + "status": { + "isValid": true, + "city": "**redacted**", + "state": "IL", + "current": [ + { + "temperature": 69.0, + "temperatureC": 21.0, + "temperatureLow": 61.0, + "temperatureLowC": 16.0, + "temperatureHigh": 74.0, + "temperatureHighC": 23.0, + "iconId": 7, + "time": 1695331380, + "id": 0, + "iconDescription": "Cloudy", + "cloudCoverage": 98, + "atmosPressure": 30.15, + "rainProbability": 96, + "sunrise": 1695296340, + "sunset": 1695340320 + } + ], + "hourly": [ + { + "temperature": 70.0, + "temperatureC": 21.0, + "temperatureLow": 0.0, + "temperatureLowC": 0.0, + "temperatureHigh": 0.0, + "temperatureHighC": 0.0, + "iconId": 12, + "time": 1695333600, + "id": 0, + "iconDescription": "Rain showers", + "cloudCoverage": 97, + "atmosPressure": 0.0, + "rainProbability": 66, + "sunrise": null, + "sunset": null + }, + { + "temperature": 69.0, + "temperatureC": 21.0, + "temperatureLow": 0.0, + "temperatureLowC": 0.0, + "temperatureHigh": 0.0, + "temperatureHighC": 0.0, + "iconId": 4, + "time": 1695337200, + "id": 1, + "iconDescription": "Partly cloudy", + "cloudCoverage": 70, + "atmosPressure": 0.0, + "rainProbability": 49, + "sunrise": null, + "sunset": null + }, + { + "temperature": 68.0, + "temperatureC": 20.0, + "temperatureLow": 0.0, + "temperatureLowC": 0.0, + "temperatureHigh": 0.0, + "temperatureHighC": 0.0, + "iconId": 36, + "time": 1695340800, + "id": 2, + "iconDescription": "Partly cloudy", + "cloudCoverage": 70, + "atmosPressure": 0.0, + "rainProbability": 47, + "sunrise": null, + "sunset": null + }, + { + "temperature": 66.0, + "temperatureC": 19.0, + "temperatureLow": 0.0, + "temperatureLowC": 0.0, + "temperatureHigh": 0.0, + "temperatureHighC": 0.0, + "iconId": 38, + "time": 1695344400, + "id": 3, + "iconDescription": "Mostly cloudy", + "cloudCoverage": 89, + "atmosPressure": 0.0, + "rainProbability": 44, + "sunrise": null, + "sunset": null + }, + { + "temperature": 65.0, + "temperatureC": 18.0, + "temperatureLow": 0.0, + "temperatureLowC": 0.0, + "temperatureHigh": 0.0, + "temperatureHighC": 0.0, + "iconId": 7, + "time": 1695348000, + "id": 4, + "iconDescription": "Cloudy", + "cloudCoverage": 90, + "atmosPressure": 0.0, + "rainProbability": 48, + "sunrise": null, + "sunset": null + }, + { + "temperature": 64.0, + "temperatureC": 18.0, + "temperatureLow": 0.0, + "temperatureLowC": 0.0, + "temperatureHigh": 0.0, + "temperatureHighC": 0.0, + "iconId": 12, + "time": 1695351600, + "id": 5, + "iconDescription": "Rain showers", + "cloudCoverage": 93, + "atmosPressure": 0.0, + "rainProbability": 52, + "sunrise": null, + "sunset": null + }, + { + "temperature": 64.0, + "temperatureC": 18.0, + "temperatureLow": 0.0, + "temperatureLowC": 0.0, + "temperatureHigh": 0.0, + "temperatureHighC": 0.0, + "iconId": 7, + "time": 1695355200, + "id": 6, + "iconDescription": "Cloudy", + "cloudCoverage": 96, + "atmosPressure": 0.0, + "rainProbability": 43, + "sunrise": null, + "sunset": null + }, + { + "temperature": 64.0, + "temperatureC": 18.0, + "temperatureLow": 0.0, + "temperatureLowC": 0.0, + "temperatureHigh": 0.0, + "temperatureHighC": 0.0, + "iconId": 12, + "time": 1695358800, + "id": 7, + "iconDescription": "Rain showers", + "cloudCoverage": 98, + "atmosPressure": 0.0, + "rainProbability": 51, + "sunrise": null, + "sunset": null + }, + { + "temperature": 64.0, + "temperatureC": 18.0, + "temperatureLow": 0.0, + "temperatureLowC": 0.0, + "temperatureHigh": 0.0, + "temperatureHighC": 0.0, + "iconId": 7, + "time": 1695362400, + "id": 8, + "iconDescription": "Cloudy", + "cloudCoverage": 98, + "atmosPressure": 0.0, + "rainProbability": 43, + "sunrise": null, + "sunset": null + }, + { + "temperature": 63.0, + "temperatureC": 17.0, + "temperatureLow": 0.0, + "temperatureLowC": 0.0, + "temperatureHigh": 0.0, + "temperatureHighC": 0.0, + "iconId": 12, + "time": 1695366000, + "id": 9, + "iconDescription": "Rain showers", + "cloudCoverage": 96, + "atmosPressure": 0.0, + "rainProbability": 51, + "sunrise": null, + "sunset": null + }, + { + "temperature": 63.0, + "temperatureC": 17.0, + "temperatureLow": 0.0, + "temperatureLowC": 0.0, + "temperatureHigh": 0.0, + "temperatureHighC": 0.0, + "iconId": 7, + "time": 1695369600, + "id": 10, + "iconDescription": "Cloudy", + "cloudCoverage": 92, + "atmosPressure": 0.0, + "rainProbability": 47, + "sunrise": null, + "sunset": null + }, + { + "temperature": 63.0, + "temperatureC": 17.0, + "temperatureLow": 0.0, + "temperatureLowC": 0.0, + "temperatureHigh": 0.0, + "temperatureHighC": 0.0, + "iconId": 7, + "time": 1695373200, + "id": 11, + "iconDescription": "Cloudy", + "cloudCoverage": 90, + "atmosPressure": 0.0, + "rainProbability": 44, + "sunrise": null, + "sunset": null + } + ], + "daily": [ + { + "temperature": 0.0, + "temperatureC": 0.0, + "temperatureLow": 61.0, + "temperatureLowC": 16.0, + "temperatureHigh": 74.0, + "temperatureHighC": 23.0, + "iconId": 12, + "time": 1695297600, + "id": 0, + "iconDescription": "Rain showers", + "cloudCoverage": 91, + "atmosPressure": 0.0, + "rainProbability": 96, + "sunrise": null, + "sunset": null + }, + { + "temperature": 0.0, + "temperatureC": 0.0, + "temperatureLow": 59.0, + "temperatureLowC": 15.0, + "temperatureHigh": 78.0, + "temperatureHighC": 26.0, + "iconId": 4, + "time": 1695384000, + "id": 1, + "iconDescription": "Partly cloudy", + "cloudCoverage": 68, + "atmosPressure": 0.0, + "rainProbability": 40, + "sunrise": null, + "sunset": null + }, + { + "temperature": 0.0, + "temperatureC": 0.0, + "temperatureLow": 57.0, + "temperatureLowC": 14.0, + "temperatureHigh": 79.0, + "temperatureHighC": 26.0, + "iconId": 4, + "time": 1695470400, + "id": 2, + "iconDescription": "Partly cloudy", + "cloudCoverage": 46, + "atmosPressure": 0.0, + "rainProbability": 3, + "sunrise": null, + "sunset": null + }, + { + "temperature": 0.0, + "temperatureC": 0.0, + "temperatureLow": 56.0, + "temperatureLowC": 13.0, + "temperatureHigh": 73.0, + "temperatureHighC": 23.0, + "iconId": 4, + "time": 1695556800, + "id": 3, + "iconDescription": "Partly cloudy", + "cloudCoverage": 50, + "atmosPressure": 0.0, + "rainProbability": 8, + "sunrise": null, + "sunset": null + }, + { + "temperature": 0.0, + "temperatureC": 0.0, + "temperatureLow": 58.0, + "temperatureLowC": 14.0, + "temperatureHigh": 70.0, + "temperatureHighC": 21.0, + "iconId": 4, + "time": 1695643200, + "id": 4, + "iconDescription": "Partly cloudy", + "cloudCoverage": 79, + "atmosPressure": 0.0, + "rainProbability": 19, + "sunrise": null, + "sunset": null + }, + { + "temperature": 0.0, + "temperatureC": 0.0, + "temperatureLow": 55.0, + "temperatureLowC": 13.0, + "temperatureHigh": 70.0, + "temperatureHighC": 21.0, + "iconId": 3, + "time": 1695729600, + "id": 5, + "iconDescription": "Partly cloudy", + "cloudCoverage": 31, + "atmosPressure": 0.0, + "rainProbability": 18, + "sunrise": null, + "sunset": null + }, + { + "temperature": 0.0, + "temperatureC": 0.0, + "temperatureLow": 52.0, + "temperatureLowC": 11.0, + "temperatureHigh": 70.0, + "temperatureHighC": 21.0, + "iconId": 1, + "time": 1695816000, + "id": 6, + "iconDescription": "Clear", + "cloudCoverage": 0, + "atmosPressure": 0.0, + "rainProbability": 11, + "sunrise": null, + "sunset": null + } + ], + "env": { + "airQuality": "good", + "tree": "extreme", + "weed": "very high", + "grass": "high", + "mold": "moderate", + "uvIndex": "low", + "humidity": 84, + "windSpeed": 1.0, + "windSpeedK": 2.0, + "cloudCoverage": 97, + "Dewpoint": 64.0, + "DewpointC": 18.0 + } + } + } + } +} diff --git a/tests/test_async_setup_entry.py b/tests/test_async_setup_entry.py index 81f6ffd..3ec1f2c 100644 --- a/tests/test_async_setup_entry.py +++ b/tests/test_async_setup_entry.py @@ -22,7 +22,7 @@ EVENT_HOMEASSISTANT_STOP, ) from homeassistant import config_entries - +from homeassistant.core import HomeAssistant from lennoxs30api.s30exception import S30Exception, EC_LOGIN, EC_COMMS_ERROR, EC_CONFIG_TIMEOUT @@ -44,7 +44,7 @@ @pytest.mark.asyncio -async def test_async_setup_entry_local(hass, caplog): +async def test_async_setup_entry_local(hass: HomeAssistant, caplog): data = { "cloud_connection": False, "host": "192.168.1.93", @@ -69,103 +69,96 @@ async def test_async_setup_entry_local(hass, caplog): config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") - with patch.object(hass.bus, "async_listen_once") as listen_once: - with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: - res = await async_setup_entry(hass, config_entry) - assert res is True - manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] - assert listen_once.call_count == 1 - assert listen_once.call_args[0][0] == EVENT_HOMEASSISTANT_STOP - assert listen_once.call_args[0][1] == manager.async_shutdown - assert s30_initialize.call_count == 1 - - assert manager.config_entry == config_entry - assert manager._hass == hass - assert manager._config == config_entry - assert manager._poll_interval == 1 - assert manager._fast_poll_interval == 0.75 - assert manager._fast_poll_count == 5 - assert manager._protocol == "https" - assert manager._ip_address == "192.168.1.93" - assert manager._pii_message_log is False - assert manager._message_debug_logging is True - assert manager._message_logging_file == "messages.log" - assert manager.allergen_defender_switch is False - assert manager.create_sensors is True - assert manager.create_alert_sensors is True - assert manager.create_inverter_power is False - assert manager.create_diagnostic_sensors is False - assert manager.create_equipment_parameters is False - assert manager._conf_init_wait_time == 30 - assert manager.is_metric is True - assert manager.connection_state == DOMAIN_STATE - - with patch.object(hass.bus, "async_listen_once") as listen_once: - with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: - with patch("custom_components.lennoxs30.Manager.updateState") as update_state: - s30_initialize.side_effect = S30Exception("login error", EC_LOGIN, 0) - ex: HomeAssistantError = None - try: - res = await async_setup_entry(hass, config_entry) - except HomeAssistantError as he: - ex = he - assert ex is not None - assert "unable to login" in str(ex) - assert "please check credential" in str(ex) - assert update_state.call_count == 1 - assert update_state.call_args[0][0] == DS_LOGIN_FAILED + with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: + res = await async_setup_entry(hass, config_entry) + assert res is True + manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] + assert s30_initialize.call_count == 1 + + assert manager.config_entry == config_entry + assert manager._hass == hass + assert manager._config == config_entry + assert manager._poll_interval == 1 + assert manager._fast_poll_interval == 0.75 + assert manager._fast_poll_count == 5 + assert manager._protocol == "https" + assert manager._ip_address == "192.168.1.93" + assert manager._pii_message_log is False + assert manager._message_debug_logging is True + assert manager._message_logging_file == "messages.log" + assert manager.allergen_defender_switch is False + assert manager.create_sensors is True + assert manager.create_alert_sensors is True + assert manager.create_inverter_power is False + assert manager.create_diagnostic_sensors is False + assert manager.create_equipment_parameters is False + assert manager._conf_init_wait_time == 30 + assert manager.is_metric is True + assert manager.connection_state == DOMAIN_STATE + + with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: + with patch("custom_components.lennoxs30.Manager.updateState") as update_state: + s30_initialize.side_effect = S30Exception("login error", EC_LOGIN, 0) + ex: HomeAssistantError = None + try: + res = await async_setup_entry(hass, config_entry) + except HomeAssistantError as he: + ex = he + assert ex is not None + assert "unable to login" in str(ex) + assert "please check credential" in str(ex) + assert update_state.call_count == 1 + assert update_state.call_args[0][0] == DS_LOGIN_FAILED with caplog.at_level(logging.INFO): caplog.clear() - with patch.object(hass.bus, "async_listen_once") as listen_once: - with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: - with patch("custom_components.lennoxs30.Manager.updateState") as update_state: - with patch("asyncio.create_task") as create_task: - s30_initialize.side_effect = S30Exception("Timeout waiting for config", EC_CONFIG_TIMEOUT, 0) - ex: HomeAssistantError = None - res = await async_setup_entry(hass, config_entry) - manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] + with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: + with patch("custom_components.lennoxs30.Manager.updateState") as update_state: + with patch("asyncio.create_task") as create_task: + s30_initialize.side_effect = S30Exception("Timeout waiting for config", EC_CONFIG_TIMEOUT, 0) + ex: HomeAssistantError = None + res = await async_setup_entry(hass, config_entry) + manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] - assert res is True + assert res is True - assert create_task.call_count == 1 - assert create_task.call_args[0][0].__name__ == "initialize_retry_task" + assert create_task.call_count == 1 + assert create_task.call_args[0][0].__name__ == "initialize_retry_task" - assert len(caplog.messages) >= 2 + assert len(caplog.messages) >= 2 - record = caplog.records[len(caplog.messages) - 2] - assert record.levelname == "WARNING" - assert "Timeout waiting for config" in record.message + record = caplog.records[len(caplog.messages) - 2] + assert record.levelname == "WARNING" + assert "Timeout waiting for config" in record.message - record = caplog.records[len(caplog.messages) - 1] - assert record.levelname == "INFO" - assert "connection will be retried" in record.message + record = caplog.records[len(caplog.messages) - 1] + assert record.levelname == "INFO" + assert "connection will be retried" in record.message with caplog.at_level(logging.INFO): caplog.clear() - with patch.object(hass.bus, "async_listen_once") as listen_once: - with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: - with patch("custom_components.lennoxs30.Manager.updateState") as update_state: - with patch("asyncio.create_task") as create_task: - s30_initialize.side_effect = S30Exception("Transport Error", EC_COMMS_ERROR, 0) - ex: HomeAssistantError = None - res = await async_setup_entry(hass, config_entry) - manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] + with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: + with patch("custom_components.lennoxs30.Manager.updateState") as update_state: + with patch("asyncio.create_task") as create_task: + s30_initialize.side_effect = S30Exception("Transport Error", EC_COMMS_ERROR, 0) + ex: HomeAssistantError = None + res = await async_setup_entry(hass, config_entry) + manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] - assert res is True + assert res is True - assert create_task.call_count == 1 - assert create_task.call_args[0][0].__name__ == "initialize_retry_task" + assert create_task.call_count == 1 + assert create_task.call_args[0][0].__name__ == "initialize_retry_task" - assert len(caplog.messages) >= 2 + assert len(caplog.messages) >= 2 - record = caplog.records[len(caplog.messages) - 2] - assert record.levelname == "ERROR" - assert "Transport Error" in record.message + record = caplog.records[len(caplog.messages) - 2] + assert record.levelname == "ERROR" + assert "Transport Error" in record.message - record = caplog.records[len(caplog.messages) - 1] - assert record.levelname == "INFO" - assert "connection will be retried" in record.message + record = caplog.records[len(caplog.messages) - 1] + assert record.levelname == "INFO" + assert "connection will be retried" in record.message @pytest.mark.asyncio @@ -194,36 +187,32 @@ async def test_async_setup_entry_cloud(hass, caplog): config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") - with patch.object(hass.bus, "async_listen_once") as listen_once: - with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: - res = await async_setup_entry(hass, config_entry) - assert res is True - manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] - assert listen_once.call_count == 1 - assert listen_once.call_args[0][0] == EVENT_HOMEASSISTANT_STOP - assert listen_once.call_args[0][1] == manager.async_shutdown - assert s30_initialize.call_count == 1 - - assert manager.config_entry == config_entry - assert manager._hass == hass - assert manager._config == config_entry - assert manager._poll_interval == 1 - assert manager._fast_poll_interval == 0.75 - assert manager._fast_poll_count == 5 - assert manager.api._username == "pete.rage@rage.com" - assert manager.api._password == "rage" - assert manager._pii_message_log is False - assert manager._message_debug_logging is True - assert manager._message_logging_file is None - assert manager.allergen_defender_switch is False - assert manager.create_sensors is True - assert manager.create_alert_sensors is True - assert manager.create_inverter_power is False - assert manager.create_diagnostic_sensors is False - assert manager.create_equipment_parameters is False - assert manager._conf_init_wait_time == 30 - assert manager.is_metric is True - assert manager.connection_state == DOMAIN_STATE + with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: + res = await async_setup_entry(hass, config_entry) + assert res is True + manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] + assert s30_initialize.call_count == 1 + + assert manager.config_entry == config_entry + assert manager._hass == hass + assert manager._config == config_entry + assert manager._poll_interval == 1 + assert manager._fast_poll_interval == 0.75 + assert manager._fast_poll_count == 5 + assert manager.api._username == "pete.rage@rage.com" + assert manager.api._password == "rage" + assert manager._pii_message_log is False + assert manager._message_debug_logging is True + assert manager._message_logging_file is None + assert manager.allergen_defender_switch is False + assert manager.create_sensors is True + assert manager.create_alert_sensors is True + assert manager.create_inverter_power is False + assert manager.create_diagnostic_sensors is False + assert manager.create_equipment_parameters is False + assert manager._conf_init_wait_time == 30 + assert manager.is_metric is True + assert manager.connection_state == DOMAIN_STATE @pytest.mark.asyncio @@ -252,36 +241,32 @@ async def test_async_setup_entry_multiple(hass, caplog): config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") - with patch.object(hass.bus, "async_listen_once") as listen_once: - with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: - res = await async_setup_entry(hass, config_entry) - assert res is True - manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] - assert listen_once.call_count == 1 - assert listen_once.call_args[0][0] == EVENT_HOMEASSISTANT_STOP - assert listen_once.call_args[0][1] == manager.async_shutdown - assert s30_initialize.call_count == 1 - - assert manager.config_entry == config_entry - assert manager._hass == hass - assert manager._config == config_entry - assert manager._poll_interval == 1 - assert manager._fast_poll_interval == 0.75 - assert manager._fast_poll_count == 5 - assert manager._protocol == "https" - assert manager._ip_address == "192.168.1.93" - assert manager._pii_message_log is False - assert manager._message_debug_logging is True - assert manager._message_logging_file == "messages.log" - assert manager.allergen_defender_switch is False - assert manager.create_sensors is True - assert manager.create_alert_sensors is True - assert manager.create_inverter_power is False - assert manager.create_diagnostic_sensors is False - assert manager.create_equipment_parameters is False - assert manager._conf_init_wait_time == 30 - assert manager.is_metric is True - assert manager.connection_state == DOMAIN_STATE + with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: + res = await async_setup_entry(hass, config_entry) + assert res is True + manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] + assert s30_initialize.call_count == 1 + + assert manager.config_entry == config_entry + assert manager._hass == hass + assert manager._config == config_entry + assert manager._poll_interval == 1 + assert manager._fast_poll_interval == 0.75 + assert manager._fast_poll_count == 5 + assert manager._protocol == "https" + assert manager._ip_address == "192.168.1.93" + assert manager._pii_message_log is False + assert manager._message_debug_logging is True + assert manager._message_logging_file == "messages.log" + assert manager.allergen_defender_switch is False + assert manager.create_sensors is True + assert manager.create_alert_sensors is True + assert manager.create_inverter_power is False + assert manager.create_diagnostic_sensors is False + assert manager.create_equipment_parameters is False + assert manager._conf_init_wait_time == 30 + assert manager.is_metric is True + assert manager.connection_state == DOMAIN_STATE data = { "cloud_connection": False, @@ -306,36 +291,32 @@ async def test_async_setup_entry_multiple(hass, caplog): config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test1", data, "my_source") - with patch.object(hass.bus, "async_listen_once") as listen_once: - with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: - res = await async_setup_entry(hass, config_entry) - assert res is True - manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] - assert listen_once.call_count == 1 - assert listen_once.call_args[0][0] == EVENT_HOMEASSISTANT_STOP - assert listen_once.call_args[0][1] == manager.async_shutdown - assert s30_initialize.call_count == 1 - - assert manager.config_entry == config_entry - assert manager._hass == hass - assert manager._config == config_entry - assert manager._poll_interval == 1 - assert manager._fast_poll_interval == 0.75 - assert manager._fast_poll_count == 5 - assert manager._protocol == "https" - assert manager._ip_address == "192.168.1.94" - assert manager._pii_message_log is False - assert manager._message_debug_logging is True - assert manager._message_logging_file == "messages.log" - assert manager.allergen_defender_switch is False - assert manager.create_sensors is True - assert manager.create_alert_sensors is True - assert manager.create_inverter_power is False - assert manager.create_diagnostic_sensors is False - assert manager.create_equipment_parameters is False - assert manager._conf_init_wait_time == 30 - assert manager.is_metric is True - assert manager.connection_state == "lennoxs30.conn_192_168_1_94" + with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: + res = await async_setup_entry(hass, config_entry) + assert res is True + manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] + assert s30_initialize.call_count == 1 + + assert manager.config_entry == config_entry + assert manager._hass == hass + assert manager._config == config_entry + assert manager._poll_interval == 1 + assert manager._fast_poll_interval == 0.75 + assert manager._fast_poll_count == 5 + assert manager._protocol == "https" + assert manager._ip_address == "192.168.1.94" + assert manager._pii_message_log is False + assert manager._message_debug_logging is True + assert manager._message_logging_file == "messages.log" + assert manager.allergen_defender_switch is False + assert manager.create_sensors is True + assert manager.create_alert_sensors is True + assert manager.create_inverter_power is False + assert manager.create_diagnostic_sensors is False + assert manager.create_equipment_parameters is False + assert manager._conf_init_wait_time == 30 + assert manager.is_metric is True + assert manager.connection_state == "lennoxs30.conn_192_168_1_94" data = { "cloud_connection": True, @@ -361,36 +342,32 @@ async def test_async_setup_entry_multiple(hass, caplog): config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test3", data, "my_source") - with patch.object(hass.bus, "async_listen_once") as listen_once: - with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: - res = await async_setup_entry(hass, config_entry) - assert res is True - manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] - assert listen_once.call_count == 1 - assert listen_once.call_args[0][0] == EVENT_HOMEASSISTANT_STOP - assert listen_once.call_args[0][1] == manager.async_shutdown - assert s30_initialize.call_count == 1 - - assert manager.config_entry == config_entry - assert manager._hass == hass - assert manager._config == config_entry - assert manager._poll_interval == 1 - assert manager._fast_poll_interval == 0.75 - assert manager._fast_poll_count == 5 - assert manager.api._username == "pete.rage@rage.com" - assert manager.api._password == "rage" - assert manager._pii_message_log is False - assert manager._message_debug_logging is True - assert manager._message_logging_file is None - assert manager.allergen_defender_switch is False - assert manager.create_sensors is True - assert manager.create_alert_sensors is True - assert manager.create_inverter_power is False - assert manager.create_diagnostic_sensors is False - assert manager.create_equipment_parameters is False - assert manager._conf_init_wait_time == 30 - assert manager.is_metric is True - assert manager.connection_state == "lennoxs30.conn_pete_rage" + with patch("custom_components.lennoxs30.Manager.s30_initialize") as s30_initialize: + res = await async_setup_entry(hass, config_entry) + assert res is True + manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] + assert s30_initialize.call_count == 1 + + assert manager.config_entry == config_entry + assert manager._hass == hass + assert manager._config == config_entry + assert manager._poll_interval == 1 + assert manager._fast_poll_interval == 0.75 + assert manager._fast_poll_count == 5 + assert manager.api._username == "pete.rage@rage.com" + assert manager.api._password == "rage" + assert manager._pii_message_log is False + assert manager._message_debug_logging is True + assert manager._message_logging_file is None + assert manager.allergen_defender_switch is False + assert manager.create_sensors is True + assert manager.create_alert_sensors is True + assert manager.create_inverter_power is False + assert manager.create_diagnostic_sensors is False + assert manager.create_equipment_parameters is False + assert manager._conf_init_wait_time == 30 + assert manager.is_metric is True + assert manager.connection_state == "lennoxs30.conn_pete_rage" @pytest.mark.asyncio @@ -419,25 +396,24 @@ async def test_async_unload_entry_success(hass, caplog): config_entry = config_entries.ConfigEntry(1, DOMAIN, "Test", data, "my_source") - with patch.object(hass.bus, "async_listen_once") as _: - with patch("custom_components.lennoxs30.Manager.s30_initialize") as _: - res = await async_setup_entry(hass, config_entry) - assert res is True - manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] - with patch.object(hass.config_entries, "async_unload_platforms") as mock_unload_platforms: - with patch.object(manager, "async_shutdown") as mockasync_shutdown: - mock_unload_platforms.return_value = True - res = await async_unload_entry(hass, config_entry) - assert mock_unload_platforms.call_count == 1 - assert mock_unload_platforms.call_args[0][0] == config_entry - assert mock_unload_platforms.call_args[0][1] == PLATFORMS + with patch("custom_components.lennoxs30.Manager.s30_initialize") as _: + res = await async_setup_entry(hass, config_entry) + assert res is True + manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] + with patch.object(hass.config_entries, "async_unload_platforms") as mock_unload_platforms: + with patch.object(manager, "async_shutdown") as mockasync_shutdown: + mock_unload_platforms.return_value = True + res = await async_unload_entry(hass, config_entry) + assert mock_unload_platforms.call_count == 1 + assert mock_unload_platforms.call_args[0][0] == config_entry + assert mock_unload_platforms.call_args[0][1] == PLATFORMS - assert mockasync_shutdown.call_count == 1 - assert mockasync_shutdown.call_args[0][0] is None + assert mockasync_shutdown.call_count == 1 + assert mockasync_shutdown.call_args[0][0] is None - assert hass.data[LENNOX_DOMAIN].get(config_entry.unique_id) is None + assert hass.data[LENNOX_DOMAIN].get(config_entry.unique_id) is None - assert res is True + assert res is True @pytest.mark.asyncio @@ -468,73 +444,71 @@ async def test_async_unload_entry_unload_fail(hass, caplog): with caplog.at_level(logging.ERROR): caplog.clear() - with patch.object(hass.bus, "async_listen_once") as _: - with patch("custom_components.lennoxs30.Manager.s30_initialize") as _: - res = await async_setup_entry(hass, config_entry) - assert res is True - manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] - with patch.object(hass.config_entries, "async_unload_platforms") as mock_unload_platforms: - mock_unload_platforms.return_value = False - with patch.object(manager, "async_shutdown") as mockasync_shutdown: - res = await async_unload_entry(hass, config_entry) - assert mock_unload_platforms.call_count == 1 - assert mock_unload_platforms.call_args[0][0] == config_entry - assert mock_unload_platforms.call_args[0][1] == PLATFORMS - - assert mockasync_shutdown.call_count == 0 - assert hass.data[LENNOX_DOMAIN].get(config_entry.unique_id) is not None - - assert res is False - - assert len(caplog.records) == 1 - msg = caplog.messages[0] - assert "call to hass.config_entries.async_unload_platforms returned False" in msg - - with patch.object(hass.config_entries, "async_unload_platforms") as mock_unload_platforms: - mock_unload_platforms.return_value = True - with patch.object(manager, "async_shutdown") as mockasync_shutdown: - mockasync_shutdown.side_effect = S30Exception("This is the error", EC_COMMS_ERROR, 0) - caplog.clear() - res = await async_unload_entry(hass, config_entry) - assert mock_unload_platforms.call_count == 1 - assert mock_unload_platforms.call_args[0][0] == config_entry - assert mock_unload_platforms.call_args[0][1] == PLATFORMS - - assert mockasync_shutdown.call_count == 1 - assert hass.data[LENNOX_DOMAIN].get(config_entry.unique_id) is None - - assert res is True - - assert len(caplog.records) == 1 - msg = caplog.messages[0] - assert "async_unload_entry" in msg - assert "This is the error" in msg - assert str(config_entry.unique_id) in msg + with patch("custom_components.lennoxs30.Manager.s30_initialize") as _: + res = await async_setup_entry(hass, config_entry) + assert res is True + manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] + with patch.object(hass.config_entries, "async_unload_platforms") as mock_unload_platforms: + mock_unload_platforms.return_value = False + with patch.object(manager, "async_shutdown") as mockasync_shutdown: + res = await async_unload_entry(hass, config_entry) + assert mock_unload_platforms.call_count == 1 + assert mock_unload_platforms.call_args[0][0] == config_entry + assert mock_unload_platforms.call_args[0][1] == PLATFORMS + + assert mockasync_shutdown.call_count == 0 + assert hass.data[LENNOX_DOMAIN].get(config_entry.unique_id) is not None + + assert res is False + + assert len(caplog.records) == 1 + msg = caplog.messages[0] + assert "call to hass.config_entries.async_unload_platforms returned False" in msg + + with patch.object(hass.config_entries, "async_unload_platforms") as mock_unload_platforms: + mock_unload_platforms.return_value = True + with patch.object(manager, "async_shutdown") as mockasync_shutdown: + mockasync_shutdown.side_effect = S30Exception("This is the error", EC_COMMS_ERROR, 0) + caplog.clear() + res = await async_unload_entry(hass, config_entry) + assert mock_unload_platforms.call_count == 1 + assert mock_unload_platforms.call_args[0][0] == config_entry + assert mock_unload_platforms.call_args[0][1] == PLATFORMS + + assert mockasync_shutdown.call_count == 1 + assert hass.data[LENNOX_DOMAIN].get(config_entry.unique_id) is None + + assert res is True + + assert len(caplog.records) == 1 + msg = caplog.messages[0] + assert "async_unload_entry" in msg + assert "This is the error" in msg + assert str(config_entry.unique_id) in msg with caplog.at_level(logging.ERROR): caplog.clear() - with patch.object(hass.bus, "async_listen_once") as _: - with patch("custom_components.lennoxs30.Manager.s30_initialize") as _: - res = await async_setup_entry(hass, config_entry) - assert res is True - manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] - with patch.object(hass.config_entries, "async_unload_platforms") as mock_unload_platforms: - mock_unload_platforms.return_value = True - with patch.object(manager, "async_shutdown") as mockasync_shutdown: - mockasync_shutdown.side_effect = ValueError("bad value") - caplog.clear() - res = await async_unload_entry(hass, config_entry) - assert mock_unload_platforms.call_count == 1 - assert mock_unload_platforms.call_args[0][0] == config_entry - assert mock_unload_platforms.call_args[0][1] == PLATFORMS - - assert mockasync_shutdown.call_count == 1 - assert hass.data[LENNOX_DOMAIN].get(config_entry.unique_id) is None - - assert res is True - - assert len(caplog.records) == 1 - msg = caplog.messages[0] - assert "async_unload_entry" in msg - assert "unexpected exception" in msg - assert str(config_entry.unique_id) in msg + with patch("custom_components.lennoxs30.Manager.s30_initialize") as _: + res = await async_setup_entry(hass, config_entry) + assert res is True + manager: Manager = hass.data[LENNOX_DOMAIN][config_entry.unique_id][MANAGER] + with patch.object(hass.config_entries, "async_unload_platforms") as mock_unload_platforms: + mock_unload_platforms.return_value = True + with patch.object(manager, "async_shutdown") as mockasync_shutdown: + mockasync_shutdown.side_effect = ValueError("bad value") + caplog.clear() + res = await async_unload_entry(hass, config_entry) + assert mock_unload_platforms.call_count == 1 + assert mock_unload_platforms.call_args[0][0] == config_entry + assert mock_unload_platforms.call_args[0][1] == PLATFORMS + + assert mockasync_shutdown.call_count == 1 + assert hass.data[LENNOX_DOMAIN].get(config_entry.unique_id) is None + + assert res is True + + assert len(caplog.records) == 1 + msg = caplog.messages[0] + assert "async_unload_entry" in msg + assert "unexpected exception" in msg + assert str(config_entry.unique_id) in msg diff --git a/tests/test_diag_sensor.py b/tests/test_diag_sensor.py index 4d5c0c5..7281be5 100644 --- a/tests/test_diag_sensor.py +++ b/tests/test_diag_sensor.py @@ -17,6 +17,7 @@ VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, ELECTRIC_POTENTIAL_VOLT, TIME_MINUTES, + REVOLUTIONS_PER_MINUTE, ) from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorDeviceClass from homeassistant.helpers.entity import EntityCategory @@ -157,6 +158,14 @@ async def test_diag_sensor_unit_of_measure_device_class(hass, manager: Manager): assert s.unit_of_measurement == PERCENTAGE assert s.device_class is None + equipment = system.equipment[2] + diagnostic = equipment.diagnostics[4] + assert diagnostic.name.endswith("RPM") + assert diagnostic.unit.strip() == "" + s = S30DiagSensor(hass, manager, system, equipment, diagnostic) + assert s.native_unit_of_measurement == REVOLUTIONS_PER_MINUTE + assert s.device_class is None + equipment = system.equipment[1] diagnostic = equipment.diagnostics[9] s = S30DiagSensor(hass, manager, system, equipment, diagnostic) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..b8a5332 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,51 @@ +"""Test the diagnostics""" +# pylint: disable=line-too-long +import pytest + +from custom_components.lennoxs30 import Manager +from custom_components.lennoxs30.const import MANAGER +from custom_components.lennoxs30.diagnostics import async_get_config_entry_diagnostics +from homeassistant import config_entries + + +@pytest.mark.asyncio +async def test_diagnostics_local( + hass, manager_system_04_furn_ac_zoning_ble: Manager, config_entry_local: config_entries.ConfigEntry +): + """Test the alert sensor""" + manager = manager_system_04_furn_ac_zoning_ble + entry = manager.config_entry = config_entry_local + hass.data["lennoxs30"] = {} + hass.data["lennoxs30"][entry.unique_id] = {MANAGER: manager} + diags = await async_get_config_entry_diagnostics(hass, entry) + + assert "config" in diags + assert diags["config"]["host"] == "10.0.0.1" + assert "system" in diags + assert "0000000-0000-0000-0000-000000000001" in diags["system"] + system = diags["system"]["0000000-0000-0000-0000-000000000001"] + assert len(system["equipment"]) == 4 + assert "comm_metrics" in diags + assert diags["comm_metrics"]["message_count"] == 6 + + +@pytest.mark.asyncio +async def test_diagnostics_cloud( + hass, manager_system_04_furn_ac_zoning_ble: Manager, config_entry_cloud: config_entries.ConfigEntry +): + """Test the alert sensor""" + manager = manager_system_04_furn_ac_zoning_ble + entry = manager.config_entry = config_entry_cloud + hass.data["lennoxs30"] = {} + hass.data["lennoxs30"][entry.unique_id] = {MANAGER: manager} + diags = await async_get_config_entry_diagnostics(hass, entry) + + assert "config" in diags + assert diags["config"]["password"] == "**redacted**" + assert diags["config"]["email"] == "**redacted**" + assert "system" in diags + assert "0000000-0000-0000-0000-000000000001" in diags["system"] + system = diags["system"]["0000000-0000-0000-0000-000000000001"] + assert len(system["equipment"]) == 4 + assert "comm_metrics" in diags + assert diags["comm_metrics"]["message_count"] == 6 diff --git a/tests/test_manager.py b/tests/test_manager.py index 22336f8..d81ca9b 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -1009,6 +1009,10 @@ async def test_manager_unique_id_update(hass, manager_us_customary_units: Manage entry_id = manager.config_entry.entry_id + return + + # pylint: disable=unreachable + # device registry not working without a real config enty dev_reg = dr.async_get(hass) id1 = dev_reg.async_get_or_create(config_entry_id=entry_id, name="S30", identifiers={("lennoxs30", "123")}).id id2 = dev_reg.async_get_or_create( diff --git a/tests/test_number_timed_ventilation.py b/tests/test_number_timed_ventilation.py index 6e425ff..3c740bf 100644 --- a/tests/test_number_timed_ventilation.py +++ b/tests/test_number_timed_ventilation.py @@ -40,7 +40,7 @@ async def test_timed_ventilation_time_unique_id(hass, manager: Manager): async def test_timed_ventilation_time_name(hass, manager: Manager): system: lennox_system = manager.api.system_list[0] c = TimedVentilationNumber(hass, manager, system) - assert c.name == system.name + "_timed_ventilation" + assert c.name == system.name + "_ventilate_now" @pytest.mark.asyncio diff --git a/tests/test_select_setup.py b/tests/test_select_setup.py index ea91d25..414b6a1 100644 --- a/tests/test_select_setup.py +++ b/tests/test_select_setup.py @@ -1,27 +1,26 @@ -from pickle import FALSE -from lennoxs30api.s30api_async import ( - LENNOX_VENTILATION_DAMPER, - lennox_system, -) -from custom_components.lennoxs30 import ( - Manager, -) +"""Test setup of select entities""" +# pylint: disable=line-too-long +# pylint: disable=protected-access + +from unittest.mock import Mock import pytest -from custom_components.lennoxs30.const import MANAGER +from lennoxs30api.s30api_async import LENNOX_VENTILATION_2_SPEED_HRV, lennox_system +from custom_components.lennoxs30 import Manager + +from custom_components.lennoxs30.const import MANAGER from custom_components.lennoxs30.select import ( DehumidificationModeSelect, HumidityModeSelect, EquipmentParameterSelect, + VentilationModeSelect, async_setup_entry, ) -from unittest.mock import Mock - - @pytest.mark.asyncio -async def test_async_number_setup_entry(hass, manager: Manager, caplog): +async def test_async_number_setup_entry(hass, manager: Manager): + """Test the select setup""" system: lennox_system = manager.api.system_list[0] entry = manager.config_entry hass.data["lennoxs30"] = {} @@ -93,3 +92,17 @@ async def test_async_number_setup_entry(hass, manager: Manager, caplog): assert len(sensor_list) == 15 for i in range(0, 15): assert isinstance(sensor_list[i], EquipmentParameterSelect) + + # VenitlatioNModeSelect should be created + system.dehumidifierType = None + for zone in system.zone_list: + zone.dehumidificationOption = False + zone.humidificationOption = False + manager.create_equipment_parameters = False + system.ventilationUnitType = LENNOX_VENTILATION_2_SPEED_HRV + 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], VentilationModeSelect) diff --git a/tests/test_select_ventilation_mode.py b/tests/test_select_ventilation_mode.py new file mode 100644 index 0000000..4ef507d --- /dev/null +++ b/tests/test_select_ventilation_mode.py @@ -0,0 +1,173 @@ +# 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.exceptions import HomeAssistantError + +from lennoxs30api.s30api_async import ( + LENNOX_VENTILATION_MODE_OFF, + LENNOX_VENTILATION_MODE_ON, + LENNOX_VENTILATION_MODE_INSTALLER, + LENNOX_VENTILATION_CONTROL_MODE_ASHRAE, + lennox_system, +) + + +from custom_components.lennoxs30 import Manager +from custom_components.lennoxs30.select import VentilationModeSelect +from custom_components.lennoxs30.const import LENNOX_DOMAIN + +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_select_ventilation_mode_unique_id(hass, manager: Manager): + system: lennox_system = manager.api.system_list[0] + c = VentilationModeSelect(hass, manager, system) + assert c.unique_id == system.unique_id + "_VENT_SELECT" + + +@pytest.mark.asyncio +async def test_select_ventilation_mode_name(hass, manager: Manager): + system: lennox_system = manager.api.system_list[0] + c = VentilationModeSelect(hass, manager, system) + assert c.name == system.name + "_ventilation_mode" + + +@pytest.mark.asyncio +async def test_select_ventilation_mode_current_option(hass, manager_mz: Manager): + manager = manager_mz + system: lennox_system = manager.api.system_list[0] + c = VentilationModeSelect(hass, manager, system) + + system.ventilationMode = LENNOX_VENTILATION_MODE_INSTALLER + assert c.current_option == LENNOX_VENTILATION_MODE_INSTALLER + assert c.available is True + arr = c.extra_state_attributes + assert len(arr) == 1 + assert arr["installer_settings"] == system.ventilationControlMode + + system.ventilationMode = LENNOX_VENTILATION_MODE_ON + assert c.current_option == LENNOX_VENTILATION_MODE_ON + assert c.available is True + + system.ventilationMode = LENNOX_VENTILATION_MODE_OFF + assert c.current_option == LENNOX_VENTILATION_MODE_OFF + assert c.available is True + + +@pytest.mark.asyncio +async def test_select_ventilation_mode_subscription(hass, manager_mz: Manager): + manager = manager_mz + system: lennox_system = manager.api.system_list[0] + system.dehumidificationMode = None + c = VentilationModeSelect(hass, manager, system) + await c.async_added_to_hass() + + with patch.object(c, "schedule_update_ha_state") as update_callback: + update_set = {"ventilationMode": LENNOX_VENTILATION_MODE_OFF} + system.attr_updater(update_set, "ventilationMode") + system.executeOnUpdateCallbacks() + assert update_callback.call_count == 1 + assert c.current_option == LENNOX_VENTILATION_MODE_OFF + assert c.available is True + + with patch.object(c, "schedule_update_ha_state") as update_callback: + update_set = {"ventilationMode": LENNOX_VENTILATION_MODE_ON} + system.attr_updater(update_set, "ventilationMode") + system.executeOnUpdateCallbacks() + assert update_callback.call_count == 1 + assert c.current_option == LENNOX_VENTILATION_MODE_ON + assert c.available is True + + with patch.object(c, "schedule_update_ha_state") as update_callback: + update_set = {"ventilationMode": LENNOX_VENTILATION_MODE_INSTALLER} + system.attr_updater(update_set, "ventilationMode") + system.executeOnUpdateCallbacks() + assert update_callback.call_count == 1 + assert c.current_option == LENNOX_VENTILATION_MODE_INSTALLER + assert c.available is True + + with patch.object(c, "schedule_update_ha_state") as update_callback: + update_set = {"ventilationControlMode": LENNOX_VENTILATION_CONTROL_MODE_ASHRAE} + system.attr_updater(update_set, "ventilationControlMode") + system.executeOnUpdateCallbacks() + assert update_callback.call_count == 1 + assert c.extra_state_attributes["installer_settings"] == LENNOX_VENTILATION_CONTROL_MODE_ASHRAE + assert c.available is True + + conftest_base_entity_availability(manager, system, c) + + +@pytest.mark.asyncio +async def test_select_ventilation_mode_options(hass, manager_mz: Manager): + manager = manager_mz + system: lennox_system = manager.api.system_list[0] + c = VentilationModeSelect(hass, manager, system) + + opt = c.options + assert len(opt) == 3 + assert LENNOX_VENTILATION_MODE_INSTALLER in opt + assert LENNOX_VENTILATION_MODE_ON in opt + assert LENNOX_VENTILATION_MODE_OFF in opt + + +@pytest.mark.asyncio +async def test_select_ventilation_mode_async_select_options(hass, manager_mz: Manager, caplog): + manager = manager_mz + system: lennox_system = manager.api.system_list[0] + c = VentilationModeSelect(hass, manager, system) + + with patch.object(system, "ventilation_on") as set_dehumidificationMode: + await c.async_select_option(LENNOX_VENTILATION_MODE_ON) + assert set_dehumidificationMode.call_count == 1 + + with patch.object(system, "ventilation_off") as set_dehumidificationMode: + await c.async_select_option(LENNOX_VENTILATION_MODE_OFF) + assert set_dehumidificationMode.call_count == 1 + + with patch.object(system, "ventilation_installer") as set_dehumidificationMode: + await c.async_select_option(LENNOX_VENTILATION_MODE_INSTALLER) + assert set_dehumidificationMode.call_count == 1 + + with caplog.at_level(logging.ERROR): + caplog.clear() + with patch.object(system, "set_dehumidificationMode") as set_dehumidificationMode: + ex: HomeAssistantError = None + try: + await c.async_select_option("bad_value") + except HomeAssistantError as err: + ex = err + assert ex is not None + assert set_dehumidificationMode.call_count == 0 + msg = str(ex) + assert "bad_value" in msg + + await conf_test_exception_handling( + system, "ventilation_on", c, c.async_select_option, option=LENNOX_VENTILATION_MODE_ON + ) + await conf_test_select_info_async_select_option(system, "ventilation_on", c, caplog) + + +@pytest.mark.asyncio +async def test_select_ventilation_mode_device_info(hass, manager_mz: Manager): + manager = manager_mz + await manager.create_devices() + system: lennox_system = manager.api.system_list[0] + c = VentilationModeSelect(hass, manager, system) + + identifiers = c.device_info["identifiers"] + for x in identifiers: + assert x[0] == LENNOX_DOMAIN + assert x[1] == system.unique_id diff --git a/tests/test_sensor_setup.py b/tests/test_sensor_setup.py index 0f5156e..5b19af7 100644 --- a/tests/test_sensor_setup.py +++ b/tests/test_sensor_setup.py @@ -2,18 +2,18 @@ # pylint: disable=consider-using-enumerate import logging from unittest.mock import Mock +import pytest + +from homeassistant.const import UnitOfTemperature from lennoxs30api.s30api_async import ( LENNOX_STATUS_NOT_EXIST, LENNOX_STATUS_GOOD, lennox_system, + LENNOX_PRODUCT_TYPE_S40, ) -import pytest - -from custom_components.lennoxs30 import ( - Manager, -) +from custom_components.lennoxs30 import Manager from custom_components.lennoxs30.const import MANAGER from custom_components.lennoxs30.sensor import ( S30ActiveAlertsList, @@ -27,7 +27,7 @@ ) from custom_components.lennoxs30.sensor_ble import S40BleSensor from custom_components.lennoxs30.sensor_iaq import S40IAQSensor - +from custom_components.lennoxs30.sensor_wt_env import WTEnvSensor from tests.conftest import loadfile @@ -286,3 +286,45 @@ async def test_async_setup_entry(hass, manager: Manager, caplog): assert system.ble_devices[513].deviceName in caplog.messages[0] assert "SOME_NEW_DEVICE" in caplog.messages[0] + + # Weather Sensors + message = loadfile("weather.json", system.sysId) + system.processMessage(message) + system.outdoorTemperatureStatus = LENNOX_STATUS_NOT_EXIST + manager.create_inverter_power = False + manager.create_sensors = False + manager.create_diagnostic_sensors = False + manager.create_alert_sensors = False + async_add_entities = Mock() + + # Weather only supported on S40 + assert not system.is_s40 + await async_setup_entry(hass, entry, async_add_entities) + assert async_add_entities.called == 0 + + system.productType = LENNOX_PRODUCT_TYPE_S40 + assert system.is_s40 + await async_setup_entry(hass, entry, async_add_entities) + assert async_add_entities.called == 1 + + sensor_list = async_add_entities.call_args[0][0] + assert len(sensor_list) == 10 + for index in range(0, 10): + sensor = sensor_list[index] + assert isinstance(sensor, WTEnvSensor) + assert sensor.native_value is not None + assert sensor.available + + sensor: WTEnvSensor = sensor_list[9] + assert manager.is_metric + assert sensor.native_unit_of_measurement == UnitOfTemperature.CELSIUS + + manager.is_metric = False + await async_setup_entry(hass, entry, async_add_entities) + sensor_list = async_add_entities.call_args[0][0] + assert len(sensor_list) == 10 + for index in range(0, 10): + assert isinstance(sensor_list[index], WTEnvSensor) + + sensor: WTEnvSensor = sensor_list[9] + assert sensor.native_unit_of_measurement == UnitOfTemperature.FAHRENHEIT diff --git a/tests/test_sensor_wt_env.py b/tests/test_sensor_wt_env.py new file mode 100644 index 0000000..21e2288 --- /dev/null +++ b/tests/test_sensor_wt_env.py @@ -0,0 +1,136 @@ +"""Test BLE Sensors""" +# pylint: disable=line-too-long +import logging +from unittest.mock import patch +import pytest + +from homeassistant.const import PERCENTAGE +from homeassistant.components.sensor import SensorStateClass, SensorDeviceClass +from lennoxs30api.s30api_async import lennox_system, LENNOX_PRODUCT_TYPE_S40 +from custom_components.lennoxs30 import ( + Manager, +) +from custom_components.lennoxs30.const import LENNOX_DOMAIN + +from custom_components.lennoxs30.sensor import WTEnvSensor, lennox_wt_env_sensors +from tests.conftest import conftest_base_entity_availability, loadfile + + +@pytest.mark.asyncio +async def test_wt_env_sensor_text(hass, manager_system_04_furn_ac_zoning_ble: Manager): + """Test the alert sensor""" + manager = manager_system_04_furn_ac_zoning_ble + system: lennox_system = manager.api.system_list[0] + system.productType = LENNOX_PRODUCT_TYPE_S40 + data = loadfile("weather.json", system.sysId) + manager.api.processMessage(data) + + sensor_dict = lennox_wt_env_sensors[0] + sensor = WTEnvSensor(hass, manager, system, sensor_dict) + + assert sensor.unique_id == (system.unique_id + "wt_env_airQuality").replace("-", "") + assert sensor.name == system.name + " " + sensor_dict["name"] + assert sensor.available is True + assert sensor.should_poll is False + assert sensor.update() is True + assert sensor.state_class is None + assert sensor.device_class is None + assert sensor.extra_state_attributes is None + assert sensor.native_value == "good" + assert sensor.entity_category is None + assert sensor.native_unit_of_measurement is None + + identifiers = sensor.device_info["identifiers"] + for ids in identifiers: + assert ids[0] == LENNOX_DOMAIN + assert ids[1] == system.unique_id + + system.wt_env_airQuality = "error" + assert sensor.available is False + + system.wt_env_airQuality = "moderate" + assert sensor.available is True + assert sensor.native_value == "moderate" + + system.wt_is_valid = False + assert sensor.available is False + + +@pytest.mark.asyncio +async def test_wt_env_sensor_humidity(hass, manager_system_04_furn_ac_zoning_ble: Manager): + """Test the wt_env sensosr""" + manager = manager_system_04_furn_ac_zoning_ble + system: lennox_system = manager.api.system_list[0] + system.productType = LENNOX_PRODUCT_TYPE_S40 + data = loadfile("weather.json", system.sysId) + manager.api.processMessage(data) + + sensor_dict = lennox_wt_env_sensors[6] + sensor = WTEnvSensor(hass, manager, system, sensor_dict) + + assert sensor.unique_id == (system.unique_id + "wt_env_humidity").replace("-", "") + assert sensor.name == system.name + " " + sensor_dict["name"] + assert sensor.available is True + assert sensor.should_poll is False + assert sensor.update() is True + assert sensor.state_class == SensorStateClass.MEASUREMENT + assert sensor.device_class == SensorDeviceClass.HUMIDITY + assert sensor.extra_state_attributes is None + assert sensor.native_value == 84 + assert sensor.native_unit_of_measurement == PERCENTAGE + + identifiers = sensor.device_info["identifiers"] + for ids in identifiers: + assert ids[0] == LENNOX_DOMAIN + assert ids[1] == system.unique_id + + system.wt_env_humidity = "error" + assert sensor.native_value is None + + system.wt_env_humidity = 86 + assert sensor.native_value == 86 + + system.wt_is_valid = False + assert sensor.available is False + + +@pytest.mark.asyncio +async def test_wt_env_subscription(hass, manager_system_04_furn_ac_zoning_ble: Manager, caplog): + """Test the alert sensor subscription""" + manager = manager_system_04_furn_ac_zoning_ble + system: lennox_system = manager.api.system_list[0] + system.productType = LENNOX_PRODUCT_TYPE_S40 + data = loadfile("weather.json", system.sysId) + manager.api.processMessage(data) + + sensor_dict = lennox_wt_env_sensors[6] + sensor = WTEnvSensor(hass, manager, system, sensor_dict) + await sensor.async_added_to_hass() + + with caplog.at_level(logging.DEBUG): + with patch.object(sensor, "schedule_update_ha_state") as update_callback: + caplog.clear() + update = {"value": "65.4"} + system.attr_updater(update, "value", "wt_env_humidity") + system.executeOnUpdateCallbacks() + assert update_callback.call_count == 1 + assert sensor.native_value == 65.0 + assert len(caplog.messages) == 2 + assert sensor.name in caplog.messages[1] + assert "sensor_value_update" in caplog.messages[1] + + with patch.object(sensor, "schedule_update_ha_state") as update_callback: + caplog.clear() + update = {"value": False} + system.attr_updater(update, "value", "wt_is_valid") + system.executeOnUpdateCallbacks() + assert update_callback.call_count == 1 + assert sensor.available is False + assert len(caplog.messages) == 2 + assert sensor.name in caplog.messages[1] + assert "sensor_value_update" in caplog.messages[1] + + update = {"value": True} + system.attr_updater(update, "value", "wt_is_valid") + system.executeOnUpdateCallbacks() + conftest_base_entity_availability(manager, system, sensor)