From 21aa07e3e58268d6ca1425cd05abcfd847b9872c Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 15 Mar 2022 22:17:51 -0400 Subject: [PATCH] Add Z-Wave thermostat fan entity (#65865) * Add Z-Wave thermostat fan entity * Fix failing test, increase number of entities to 27 * Add tests to improve coverage * Take back unrelated changes to climate.py * Clean up guard clauses, use info.primary_value, and make entity disabled by default * Fix tests * Add more tests for code coverage * Remove unused const * Remove speed parameter from overridden method since it was removed from entity * Address PR comments --- .../components/zwave_js/discovery.py | 12 + homeassistant/components/zwave_js/fan.py | 121 +++++- tests/components/zwave_js/conftest.py | 16 + tests/components/zwave_js/test_fan.py | 369 +++++++++++++++++- tests/components/zwave_js/test_init.py | 2 +- 5 files changed, 517 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 69a3d05539b..e580833da9d 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -34,6 +34,7 @@ from zwave_js_server.const.command_class.sound_switch import ( ) from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_CURRENT_TEMP_PROPERTY, + THERMOSTAT_FAN_MODE_PROPERTY, THERMOSTAT_MODE_PROPERTY, THERMOSTAT_SETPOINT_PROPERTY, ) @@ -510,6 +511,17 @@ DISCOVERY_SCHEMAS = [ type={"any"}, ), ), + # thermostat fan + ZWaveDiscoverySchema( + platform="fan", + hint="thermostat_fan", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.THERMOSTAT_FAN_MODE}, + property={THERMOSTAT_FAN_MODE_PROPERTY}, + type={"number"}, + ), + entity_registry_enabled_default=False, + ), # humidifier # hygrostats supporting mode (and optional setpoint) ZWaveDiscoverySchema( diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 0291e6d3c64..585a72fc6de 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -5,15 +5,22 @@ import math from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import TARGET_VALUE_PROPERTY +from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass +from zwave_js_server.const.command_class.thermostat import ( + THERMOSTAT_FAN_OFF_PROPERTY, + THERMOSTAT_FAN_STATE_PROPERTY, +) +from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, + SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, FanEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( @@ -26,11 +33,14 @@ from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo from .discovery_data_template import FanSpeedDataTemplate from .entity import ZWaveBaseEntity +from .helpers import get_value_of_zwave_value SUPPORTED_FEATURES = SUPPORT_SET_SPEED DEFAULT_SPEED_RANGE = (1, 99) # off is not included +ATTR_FAN_STATE = "fan_state" + async def async_setup_entry( hass: HomeAssistant, @@ -46,6 +56,8 @@ async def async_setup_entry( entities: list[ZWaveBaseEntity] = [] if info.platform_hint == "configured_fan_speed": entities.append(ConfiguredSpeedRangeZwaveFan(config_entry, client, info)) + elif info.platform_hint == "thermostat_fan": + entities.append(ZwaveThermostatFan(config_entry, client, info)) else: entities.append(ZwaveFan(config_entry, client, info)) @@ -224,3 +236,110 @@ class ConfiguredSpeedRangeZwaveFan(ZwaveFan): # the UI handles steps e.g., for a 3-speed fan, you get steps at 33, # 67, and 100. return round(percentage) + + +class ZwaveThermostatFan(ZWaveBaseEntity, FanEntity): + """Representation of a Z-Wave thermostat fan.""" + + _fan_mode: ZwaveValue + _fan_off: ZwaveValue | None = None + _fan_state: ZwaveValue | None = None + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the thermostat fan.""" + super().__init__(config_entry, client, info) + + self._fan_mode = self.info.primary_value + + self._fan_off = self.get_zwave_value( + THERMOSTAT_FAN_OFF_PROPERTY, + CommandClass.THERMOSTAT_FAN_MODE, + add_to_watched_value_ids=True, + ) + self._fan_state = self.get_zwave_value( + THERMOSTAT_FAN_STATE_PROPERTY, + CommandClass.THERMOSTAT_FAN_STATE, + add_to_watched_value_ids=True, + ) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the device on.""" + if not self._fan_off: + raise HomeAssistantError("Unhandled action turn_on") + await self.info.node.async_set_value(self._fan_off, False) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + if not self._fan_off: + raise HomeAssistantError("Unhandled action turn_off") + await self.info.node.async_set_value(self._fan_off, True) + + @property + def is_on(self) -> bool | None: + """Return true if device is on.""" + if (value := get_value_of_zwave_value(self._fan_off)) is None: + return None + return not cast(bool, value) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., auto, smart, interval, favorite.""" + value = get_value_of_zwave_value(self._fan_mode) + if value is None or str(value) not in self._fan_mode.metadata.states: + return None + return cast(str, self._fan_mode.metadata.states[str(value)]) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + + try: + new_state = next( + int(state) + for state, label in self._fan_mode.metadata.states.items() + if label == preset_mode + ) + except StopIteration: + raise ValueError(f"Received an invalid fan mode: {preset_mode}") from None + + await self.info.node.async_set_value(self._fan_mode, new_state) + + @property + def preset_modes(self) -> list[str] | None: + """Return a list of available preset modes.""" + if not self._fan_mode.metadata.states: + return None + return list(self._fan_mode.metadata.states.values()) + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_PRESET_MODE + + @property + def fan_state(self) -> str | None: + """Return the current state, Idle, Running, etc.""" + value = get_value_of_zwave_value(self._fan_state) + if ( + value is None + or self._fan_state is None + or str(value) not in self._fan_state.metadata.states + ): + return None + return cast(str, self._fan_state.metadata.states[str(value)]) + + @property + def extra_state_attributes(self) -> dict[str, str] | None: + """Return the optional state attributes.""" + attrs = {} + + if state := self.fan_state: + attrs[ATTR_FAN_STATE] = state + + return attrs diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 9696c922fb3..a36e3870253 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -676,6 +676,22 @@ def climate_adc_t3000_missing_mode_fixture(client, climate_adc_t3000_state): return node +@pytest.fixture(name="climate_adc_t3000_missing_fan_mode_states") +def climate_adc_t3000_missing_fan_mode_states_fixture(client, climate_adc_t3000_state): + """Mock a climate ADC-T3000 node with missing 'states' metadata on Thermostat Fan Mode.""" + data = copy.deepcopy(climate_adc_t3000_state) + data["name"] = f"{data['name']} missing fan mode states" + for value in data["values"]: + if ( + value["commandClassName"] == "Thermostat Fan Mode" + and value["property"] == "mode" + ): + del value["metadata"]["states"] + node = Node(client, data) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="climate_danfoss_lc_13") def climate_danfoss_lc_13_fixture(client, climate_danfoss_lc_13_state): """Mock a climate radio danfoss LC-13 node.""" diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 80c057223ac..2535377e9d3 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -3,9 +3,30 @@ import math import pytest from voluptuous.error import MultipleInvalid +from zwave_js_server.const import CommandClass from zwave_js_server.event import Event -from homeassistant.components.fan import ATTR_PERCENTAGE, ATTR_PERCENTAGE_STEP +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + SUPPORT_PRESET_MODE, +) +from homeassistant.components.zwave_js.fan import ATTR_FAN_STATE +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry async def test_generic_fan(hass, client, fan_generic, integration): @@ -304,3 +325,349 @@ async def test_fixed_speeds_fan(hass, client, ge_12730, integration): state = hass.states.get(entity_id) assert math.isclose(state.attributes[ATTR_PERCENTAGE_STEP], 33.3333, rel_tol=1e-3) + + +async def test_thermostat_fan(hass, client, climate_adc_t3000, integration): + """Test the fan entity for a z-wave fan.""" + node = climate_adc_t3000 + entity_id = "fan.adc_t3000" + + registry = entity_registry.async_get(hass) + state = hass.states.get(entity_id) + assert state is None + + entry = registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION + + # Test enabling entity + updated_entry = registry.async_update_entity(entity_id, disabled_by=None) + assert updated_entry != entry + assert updated_entry.disabled is False + + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + assert state.attributes.get(ATTR_FAN_STATE) == "Idle / off" + assert state.attributes.get(ATTR_PRESET_MODE) == "Auto low" + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == SUPPORT_PRESET_MODE + + # Test setting preset mode + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "Low"}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 68 + assert args["valueId"] == { + "ccVersion": 3, + "commandClassName": "Thermostat Fan Mode", + "commandClass": CommandClass.THERMOSTAT_FAN_MODE.value, + "endpoint": 0, + "property": "mode", + "propertyName": "mode", + "metadata": { + "label": "Thermostat fan mode", + "max": 255, + "min": 0, + "type": "number", + "readable": True, + "writeable": True, + "states": {"0": "Auto low", "1": "Low", "6": "Circulation"}, + }, + "value": 0, + } + assert args["value"] == 1 + + client.async_send_command.reset_mock() + + # Test setting unknown preset mode + with pytest.raises(ValueError): + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "Turbo"}, + blocking=True, + ) + + client.async_send_command.reset_mock() + + # Test turning off + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 68 + assert args["valueId"] == { + "ccVersion": 3, + "commandClassName": "Thermostat Fan Mode", + "commandClass": CommandClass.THERMOSTAT_FAN_MODE.value, + "endpoint": 0, + "property": "off", + "propertyName": "off", + "metadata": { + "label": "Thermostat fan turned off", + "type": "boolean", + "readable": True, + "writeable": True, + }, + "value": False, + } + assert args["value"] + + client.async_send_command.reset_mock() + + # Test turning on + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 68 + assert args["valueId"] == { + "ccVersion": 3, + "commandClassName": "Thermostat Fan Mode", + "commandClass": CommandClass.THERMOSTAT_FAN_MODE.value, + "endpoint": 0, + "property": "off", + "propertyName": "off", + "metadata": { + "label": "Thermostat fan turned off", + "type": "boolean", + "readable": True, + "writeable": True, + }, + "value": False, + } + assert not args["value"] + + client.async_send_command.reset_mock() + + # Test fan state update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 68, + "args": { + "commandClassName": "Thermostat Fan State", + "commandClass": CommandClass.THERMOSTAT_FAN_STATE.value, + "endpoint": 0, + "property": "state", + "newValue": 4, + "prevValue": 0, + "propertyName": "state", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_FAN_STATE) == "Circulation mode" + + client.async_send_command.reset_mock() + + # Test unknown fan state update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 68, + "args": { + "commandClassName": "Thermostat Fan State", + "commandClass": CommandClass.THERMOSTAT_FAN_STATE.value, + "endpoint": 0, + "property": "state", + "newValue": 99, + "prevValue": 0, + "propertyName": "state", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(entity_id) + assert not state.attributes.get(ATTR_FAN_STATE) + + client.async_send_command.reset_mock() + + # Test fan mode update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 68, + "args": { + "commandClassName": "Thermostat Fan Mode", + "commandClass": CommandClass.THERMOSTAT_FAN_MODE.value, + "endpoint": 0, + "property": "mode", + "newValue": 1, + "prevValue": 0, + "propertyName": "mode", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_PRESET_MODE) == "Low" + + client.async_send_command.reset_mock() + + # Test fan mode update from value updated event for an unknown mode + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 68, + "args": { + "commandClassName": "Thermostat Fan Mode", + "commandClass": CommandClass.THERMOSTAT_FAN_MODE.value, + "endpoint": 0, + "property": "mode", + "newValue": 79, + "prevValue": 0, + "propertyName": "mode", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(entity_id) + assert not state.attributes.get(ATTR_PRESET_MODE) + + client.async_send_command.reset_mock() + + # Test fan mode turned off update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 68, + "args": { + "commandClassName": "Thermostat Fan Mode", + "commandClass": CommandClass.THERMOSTAT_FAN_MODE.value, + "endpoint": 0, + "property": "off", + "newValue": True, + "prevValue": False, + "propertyName": "off", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +async def test_thermostat_fan_without_off( + hass, client, climate_radio_thermostat_ct100_plus, integration +): + """Test the fan entity for a z-wave fan without "off" property.""" + entity_id = "fan.z_wave_thermostat" + + registry = entity_registry.async_get(hass) + state = hass.states.get(entity_id) + assert state is None + + entry = registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION + + # Test enabling entity + updated_entry = registry.async_update_entity(entity_id, disabled_by=None) + assert updated_entry != entry + assert updated_entry.disabled is False + + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + # Test turning off + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 0 + assert state.state == STATE_UNKNOWN + + client.async_send_command.reset_mock() + + # Test turning on + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 0 + assert state.state == STATE_UNKNOWN + + client.async_send_command.reset_mock() + + +async def test_thermostat_fan_without_preset_modes( + hass, client, climate_adc_t3000_missing_fan_mode_states, integration +): + """Test the fan entity for a z-wave fan without "states" metadata.""" + entity_id = "fan.adc_t3000_missing_fan_mode_states" + + registry = entity_registry.async_get(hass) + state = hass.states.get(entity_id) + assert state is None + + entry = registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION + + # Test enabling entity + updated_entry = registry.async_update_entity(entity_id, disabled_by=None) + assert updated_entry != entry + assert updated_entry.disabled is False + + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + + assert not state.attributes.get(ATTR_PRESET_MODE) + assert not state.attributes.get(ATTR_PRESET_MODES) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 1b3a2d1204f..7b3fd773839 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -811,7 +811,7 @@ async def test_removed_device( # Check how many entities there are ent_reg = er.async_get(hass) entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 28 + assert len(entity_entries) == 29 # Remove a node and reload the entry old_node = client.driver.controller.nodes.pop(13)