diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index a8b0ddac9fd..248553648e8 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -3,7 +3,16 @@ DOMAIN = "zwave_js" NAME = "Z-Wave JS" -PLATFORMS = ["binary_sensor", "climate", "cover", "light", "lock", "sensor", "switch"] +PLATFORMS = [ + "binary_sensor", + "climate", + "cover", + "fan", + "light", + "lock", + "sensor", + "switch", +] DATA_CLIENT = "client" DATA_UNSUBSCRIBE = "unsubs" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 79bfcc9a811..7bef6eb603f 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -169,6 +169,16 @@ DISCOVERY_SCHEMAS = [ property={"currentValue"}, type={"number"}, ), + # fan + ZWaveDiscoverySchema( + platform="fan", + hint="fan", + device_class_generic={"Multilevel Switch"}, + device_class_specific={"Fan Switch"}, + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={"currentValue"}, + type={"number"}, + ), ] diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py new file mode 100644 index 00000000000..7113272d2ea --- /dev/null +++ b/homeassistant/components/zwave_js/fan.py @@ -0,0 +1,112 @@ +"""Support for Z-Wave fans.""" +import logging +import math +from typing import Any, Callable, List, Optional + +from zwave_js_server.client import Client as ZwaveClient + +from homeassistant.components.fan import ( + DOMAIN as FAN_DOMAIN, + SPEED_HIGH, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_OFF, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN +from .discovery import ZwaveDiscoveryInfo +from .entity import ZWaveBaseEntity + +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_FEATURES = SUPPORT_SET_SPEED + +# Value will first be divided to an integer +VALUE_TO_SPEED = {0: SPEED_OFF, 1: SPEED_LOW, 2: SPEED_MEDIUM, 3: SPEED_HIGH} +SPEED_TO_VALUE = {SPEED_OFF: 0, SPEED_LOW: 1, SPEED_MEDIUM: 50, SPEED_HIGH: 99} +SPEED_LIST = [*SPEED_TO_VALUE] + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +) -> None: + """Set up Z-Wave Fan from Config Entry.""" + client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + + @callback + def async_add_fan(info: ZwaveDiscoveryInfo) -> None: + """Add Z-Wave fan.""" + entities: List[ZWaveBaseEntity] = [] + entities.append(ZwaveFan(config_entry, client, info)) + async_add_entities(entities) + + hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_{FAN_DOMAIN}", + async_add_fan, + ) + ) + + +class ZwaveFan(ZWaveBaseEntity, FanEntity): + """Representation of a Z-Wave fan.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the fan.""" + super().__init__(config_entry, client, info) + self._previous_speed: Optional[str] = None + + async def async_set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + if speed not in SPEED_TO_VALUE: + raise ValueError(f"Invalid speed received: {speed}") + self._previous_speed = speed + target_value = self.get_zwave_value("targetValue") + await self.info.node.async_set_value(target_value, SPEED_TO_VALUE[speed]) + + async def async_turn_on(self, speed: Optional[str] = None, **kwargs: Any) -> None: + """Turn the device on.""" + if speed is None: + # Value 255 tells device to return to previous value + target_value = self.get_zwave_value("targetValue") + await self.info.node.async_set_value(target_value, 255) + else: + await self.async_set_speed(speed) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + target_value = self.get_zwave_value("targetValue") + await self.info.node.async_set_value(target_value, 0) + + @property + def is_on(self) -> bool: + """Return true if device is on (speed above 0).""" + return bool(self.info.primary_value.value > 0) + + @property + def speed(self) -> Optional[str]: + """Return the current speed. + + The Z-Wave speed value is a byte 0-255. 255 means previous value. + The normal range of the speed is 0-99. 0 means off. + """ + value = math.ceil(self.info.primary_value.value * 3 / 100) + return VALUE_TO_SPEED.get(value, self._previous_speed) + + @property + def speed_list(self) -> List[str]: + """Get the list of available speeds.""" + return SPEED_LIST + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORTED_FEATURES diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 70c045816d5..74bf103a49e 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -88,6 +88,12 @@ def window_cover_state_fixture(): return json.loads(load_fixture("zwave_js/chain_actuator_zws12_state.json")) +@pytest.fixture(name="in_wall_smart_fan_control_state", scope="session") +def in_wall_smart_fan_control_state_fixture(): + """Load the fan node state fixture data.""" + return json.loads(load_fixture("zwave_js/in_wall_smart_fan_control_state.json")) + + @pytest.fixture(name="client") def mock_client_fixture(controller_state, version_state): """Mock a client.""" @@ -204,3 +210,11 @@ def window_cover_fixture(client, chain_actuator_zws12_state): node = Node(client, chain_actuator_zws12_state) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="in_wall_smart_fan_control") +def in_wall_smart_fan_control_fixture(client, in_wall_smart_fan_control_state): + """Mock a fan node.""" + node = Node(client, in_wall_smart_fan_control_state) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py new file mode 100644 index 00000000000..5b726179ac9 --- /dev/null +++ b/tests/components/zwave_js/test_fan.py @@ -0,0 +1,172 @@ +"""Test the Z-Wave JS fan platform.""" +import pytest +from zwave_js_server.event import Event + +from homeassistant.components.fan import ATTR_SPEED, SPEED_MEDIUM + +FAN_ENTITY = "fan.in_wall_smart_fan_control_current_value" + + +async def test_fan(hass, client, in_wall_smart_fan_control, integration): + """Test the fan entity.""" + node = in_wall_smart_fan_control + state = hass.states.get(FAN_ENTITY) + + assert state + assert state.state == "off" + + # Test turn on setting speed + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": FAN_ENTITY, "speed": SPEED_MEDIUM}, + 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"] == 17 + assert args["valueId"] == { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "metadata": { + "label": "Target value", + "max": 99, + "min": 0, + "type": "number", + "readable": True, + "writeable": True, + "label": "Target value", + }, + } + assert args["value"] == 50 + + client.async_send_command.reset_mock() + + # Test setting unknown speed + with pytest.raises(ValueError): + await hass.services.async_call( + "fan", + "set_speed", + {"entity_id": FAN_ENTITY, "speed": 99}, + blocking=True, + ) + + client.async_send_command.reset_mock() + + # Test turn on no speed + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": FAN_ENTITY}, + 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"] == 17 + assert args["valueId"] == { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "metadata": { + "label": "Target value", + "max": 99, + "min": 0, + "type": "number", + "readable": True, + "writeable": True, + "label": "Target value", + }, + } + assert args["value"] == 255 + + client.async_send_command.reset_mock() + + # Test turning off + await hass.services.async_call( + "fan", + "turn_off", + {"entity_id": FAN_ENTITY}, + 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"] == 17 + assert args["valueId"] == { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "metadata": { + "label": "Target value", + "max": 99, + "min": 0, + "type": "number", + "readable": True, + "writeable": True, + "label": "Target value", + }, + } + assert args["value"] == 0 + + client.async_send_command.reset_mock() + + # Test speed update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 17, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": 99, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(FAN_ENTITY) + assert state.state == "on" + assert state.attributes[ATTR_SPEED] == "high" + + client.async_send_command.reset_mock() + + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 17, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": 0, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(FAN_ENTITY) + assert state.state == "off" + assert state.attributes[ATTR_SPEED] == "off" diff --git a/tests/fixtures/zwave_js/in_wall_smart_fan_control_state.json b/tests/fixtures/zwave_js/in_wall_smart_fan_control_state.json new file mode 100644 index 00000000000..fe5550a5424 --- /dev/null +++ b/tests/fixtures/zwave_js/in_wall_smart_fan_control_state.json @@ -0,0 +1,354 @@ +{ + "nodeId": 17, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024, + "status": 4, + "ready": true, + "deviceClass": { + "basic": "Routing Slave", + "generic": "Multilevel Switch", + "specific": "Fan Switch", + "mandatorySupportedCCs": [ + "Basic", + "Multilevel Switch" + ], + "mandatoryControlCCs": [] + }, + "isListening": true, + "isFrequentListening": false, + "isRouting": true, + "maxBaudRate": 40000, + "isSecure": false, + "version": 4, + "isBeaming": true, + "manufacturerId": 99, + "productId": 12593, + "productType": 18756, + "firmwareVersion": "5.22", + "zwavePlusVersion": 1, + "nodeType": 0, + "roleType": 5, + "deviceConfig": { + "manufacturerId": 99, + "manufacturer": "GE/Jasco", + "label": "ZW4002", + "description": "In-Wall Smart Fan Control", + "devices": [ + { + "productType": "0x4944", + "productId": "0x3131" + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "associations": {}, + "paramInformation": { + "_map": {} + } + }, + "label": "ZW4002", + "neighbors": [ + 1, + 2, + 6, + 8, + 9, + 10, + 11, + 14, + 15, + 16, + 18, + 19, + 20, + 21, + 23, + 26, + 27, + 30, + 31, + 33, + 36, + 37, + 38, + 39, + 41, + 42 + ], + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 17, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024 + } + ], + "values": [ + { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 99, + "label": "Target value" + } + }, + { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "duration", + "propertyName": "duration", + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Transition duration" + } + }, + { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "propertyName": "currentValue", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 99, + "label": "Current value" + }, + "value": 0 + }, + { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "Up", + "propertyName": "Up", + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "Down", + "propertyName": "Down", + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "commandClassName": "Scene Activation", + "commandClass": 43, + "endpoint": 0, + "property": "sceneId", + "propertyName": "sceneId", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 1, + "max": 255, + "label": "Scene ID" + } + }, + { + "commandClassName": "Scene Activation", + "commandClass": 43, + "endpoint": 0, + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Dimming duration" + } + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 3, + "propertyName": "Night Light", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 255, + "default": 0, + "format": 1, + "allowManualEntry": false, + "states": { + "0": "LED on when switch is OFF", + "1": "LED on when switch is ON", + "2": "LED always off" + }, + "label": "Night Light", + "description": "Defines the behavior of the blue LED", + "isFromConfig": true + }, + "value": 0 + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 4, + "propertyName": "Invert Switch", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 255, + "default": 0, + "format": 1, + "allowManualEntry": false, + "states": { + "0": "No", + "1": "Yes" + }, + "label": "Invert Switch", + "description": "Invert the ON/OFF Switch State", + "isFromConfig": true + }, + "value": 0 + }, + { + "commandClassName": "Manufacturer Specific", + "commandClass": 114, + "endpoint": 0, + "property": "manufacturerId", + "propertyName": "manufacturerId", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Manufacturer ID" + }, + "value": 99 + }, + { + "commandClassName": "Manufacturer Specific", + "commandClass": 114, + "endpoint": 0, + "property": "productType", + "propertyName": "productType", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product type" + }, + "value": 18756 + }, + { + "commandClassName": "Manufacturer Specific", + "commandClass": 114, + "endpoint": 0, + "property": "productId", + "propertyName": "productId", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product ID" + }, + "value": 12593 + }, + { + "commandClassName": "Version", + "commandClass": 134, + "endpoint": 0, + "property": "libraryType", + "propertyName": "libraryType", + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Library type" + }, + "value": 3 + }, + { + "commandClassName": "Version", + "commandClass": 134, + "endpoint": 0, + "property": "protocolVersion", + "propertyName": "protocolVersion", + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "4.54" + }, + { + "commandClassName": "Version", + "commandClass": 134, + "endpoint": 0, + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": [ + "5.22" + ] + }, + { + "commandClassName": "Version", + "commandClass": 134, + "endpoint": 0, + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + } + } + ] + } \ No newline at end of file