From 198b875a6f91dfb99758e6227c6687eaaecc5334 Mon Sep 17 00:00:00 2001 From: Chris <firstof9@gmail.com> Date: Fri, 22 Jan 2021 09:51:39 -0700 Subject: [PATCH] Add cover platform to zwave js (#45193) --- homeassistant/components/zwave_js/const.py | 2 +- homeassistant/components/zwave_js/cover.py | 86 ++++ .../components/zwave_js/discovery.py | 15 + tests/components/zwave_js/conftest.py | 14 + tests/components/zwave_js/test_cover.py | 189 ++++++++ .../zwave_js/chain_actuator_zws12_state.json | 406 ++++++++++++++++++ 6 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/zwave_js/cover.py create mode 100644 tests/components/zwave_js/test_cover.py create mode 100644 tests/fixtures/zwave_js/chain_actuator_zws12_state.json diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 561b5a414c8..a8b0ddac9fd 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -3,7 +3,7 @@ DOMAIN = "zwave_js" NAME = "Z-Wave JS" -PLATFORMS = ["binary_sensor", "climate", "light", "lock", "sensor", "switch"] +PLATFORMS = ["binary_sensor", "climate", "cover", "light", "lock", "sensor", "switch"] DATA_CLIENT = "client" DATA_UNSUBSCRIBE = "unsubs" diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py new file mode 100644 index 00000000000..b7834e8e59c --- /dev/null +++ b/homeassistant/components/zwave_js/cover.py @@ -0,0 +1,86 @@ +"""Support for Z-Wave cover devices.""" +import logging +from typing import Any, Callable, List + +from zwave_js_server.client import Client as ZwaveClient + +from homeassistant.components.cover import ( + ATTR_POSITION, + DOMAIN as COVER_DOMAIN, + SUPPORT_CLOSE, + SUPPORT_OPEN, + CoverEntity, +) +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__) +SUPPORT_GARAGE = SUPPORT_OPEN | SUPPORT_CLOSE + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +) -> None: + """Set up Z-Wave Cover from Config Entry.""" + client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + + @callback + def async_add_cover(info: ZwaveDiscoveryInfo) -> None: + """Add Z-Wave cover.""" + entities: List[ZWaveBaseEntity] = [] + entities.append(ZWaveCover(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_{COVER_DOMAIN}", + async_add_cover, + ) + ) + + +def percent_to_zwave_position(value: int) -> int: + """Convert position in 0-100 scale to 0-99 scale. + + `value` -- (int) Position byte value from 0-100. + """ + if value > 0: + return max(1, round((value / 100) * 99)) + return 0 + + +class ZWaveCover(ZWaveBaseEntity, CoverEntity): + """Representation of a Z-Wave Cover device.""" + + @property + def is_closed(self) -> bool: + """Return true if cover is closed.""" + return bool(self.info.primary_value.value == 0) + + @property + def current_cover_position(self) -> int: + """Return the current position of cover where 0 means closed and 100 is fully open.""" + return round((self.info.primary_value.value / 99) * 100) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + target_value = self.get_zwave_value("targetValue") + await self.info.node.async_set_value( + target_value, percent_to_zwave_position(kwargs[ATTR_POSITION]) + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + target_value = self.get_zwave_value("targetValue") + await self.info.node.async_set_value(target_value, 99) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + target_value = self.get_zwave_value("targetValue") + await self.info.node.async_set_value(target_value, 0) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 590ae0f0d42..79bfcc9a811 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -154,6 +154,21 @@ DISCOVERY_SCHEMAS = [ command_class={CommandClass.SWITCH_BINARY}, property={"currentValue"}, ), + # cover + ZWaveDiscoverySchema( + platform="cover", + hint="cover", + device_class_generic={"Multilevel Switch"}, + device_class_specific={ + "Motor Control Class A", + "Motor Control Class B", + "Motor Control Class C", + "Multiposition Motor", + }, + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={"currentValue"}, + type={"number"}, + ), ] diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 72c53e22bfc..70c045816d5 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -82,6 +82,12 @@ def nortek_thermostat_state_fixture(): return json.loads(load_fixture("zwave_js/nortek_thermostat_state.json")) +@pytest.fixture(name="chain_actuator_zws12_state", scope="session") +def window_cover_state_fixture(): + """Load the window cover node state fixture data.""" + return json.loads(load_fixture("zwave_js/chain_actuator_zws12_state.json")) + + @pytest.fixture(name="client") def mock_client_fixture(controller_state, version_state): """Mock a client.""" @@ -190,3 +196,11 @@ async def integration_fixture(hass, client): await hass.async_block_till_done() return entry + + +@pytest.fixture(name="chain_actuator_zws12") +def window_cover_fixture(client, chain_actuator_zws12_state): + """Mock a window cover node.""" + node = Node(client, chain_actuator_zws12_state) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py new file mode 100644 index 00000000000..c327034b61c --- /dev/null +++ b/tests/components/zwave_js/test_cover.py @@ -0,0 +1,189 @@ +"""Test the Z-Wave JS cover platform.""" +from zwave_js_server.event import Event + +from homeassistant.components.cover import ATTR_CURRENT_POSITION + +WINDOW_COVER_ENTITY = "cover.zws_12_current_value" + + +async def test_cover(hass, client, chain_actuator_zws12, integration): + """Test the light entity.""" + node = chain_actuator_zws12 + state = hass.states.get(WINDOW_COVER_ENTITY) + + assert state + assert state.state == "closed" + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + + # Test setting position + await hass.services.async_call( + "cover", + "set_cover_position", + {"entity_id": WINDOW_COVER_ENTITY, "position": 50}, + 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"] == 6 + 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 position + await hass.services.async_call( + "cover", + "set_cover_position", + {"entity_id": WINDOW_COVER_ENTITY, "position": 0}, + 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"] == 6 + 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 opening + await hass.services.async_call( + "cover", + "open_cover", + {"entity_id": WINDOW_COVER_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"] == 6 + 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"] == 99 + + client.async_send_command.reset_mock() + + # Test position update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 6, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": 99, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(WINDOW_COVER_ENTITY) + assert state.state == "open" + + # Test closing + await hass.services.async_call( + "cover", + "close_cover", + {"entity_id": WINDOW_COVER_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"] == 6 + 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() + + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 6, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": 0, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(WINDOW_COVER_ENTITY) + assert state.state == "closed" diff --git a/tests/fixtures/zwave_js/chain_actuator_zws12_state.json b/tests/fixtures/zwave_js/chain_actuator_zws12_state.json new file mode 100644 index 00000000000..dbae35e04d0 --- /dev/null +++ b/tests/fixtures/zwave_js/chain_actuator_zws12_state.json @@ -0,0 +1,406 @@ +{ + "nodeId": 6, + "index": 0, + "installerIcon": 6656, + "userIcon": 6656, + "status": 4, + "ready": true, + "deviceClass": { + "basic": "Routing Slave", + "generic": "Multilevel Switch", + "specific": "Motor Control Class C", + "mandatorySupportedCCs": [ + "Basic", + "Multilevel Switch", + "Binary Switch", + "Manufacturer Specific", + "Version" + ], + "mandatoryControlCCs": [] + }, + "isListening": true, + "isFrequentListening": false, + "isRouting": true, + "maxBaudRate": 40000, + "isSecure": false, + "version": 4, + "isBeaming": true, + "manufacturerId": 133, + "productId": 273, + "productType": 2, + "firmwareVersion": "1.1", + "zwavePlusVersion": 1, + "nodeType": 0, + "roleType": 5, + "name": "ZWS 12\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000", + "location": "UNKNOWN\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000", + "deviceConfig": { + "manufacturerId": 133, + "manufacturer": "Fakro", + "label": "ZWS12n", + "description": "Chain actuator - window opener", + "devices": [ + { "productType": "0x0002", "productId": "0x0011" }, + { "productType": "0x0002", "productId": "0x0111" } + ], + "firmwareVersion": { "min": "0.0", "max": "255.255" }, + "paramInformation": { "_map": {} } + }, + "label": "ZWS12n", + "neighbors": [1, 2], + "interviewAttempts": 1, + "endpoints": [ + { "nodeId": 6, "index": 0, "installerIcon": 6656, "userIcon": 6656 } + ], + "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": "Open", + "propertyName": "Open", + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Open)", + "ccSpecific": { "switchType": 3 } + } + }, + { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "Close", + "propertyName": "Close", + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Close)", + "ccSpecific": { "switchType": 3 } + } + }, + { + "commandClassName": "Binary Switch", + "commandClass": 37, + "endpoint": 0, + "property": "currentValue", + "propertyName": "currentValue", + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value" + }, + "value": false + }, + { + "commandClassName": "Binary Switch", + "commandClass": 37, + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value" + } + }, + { + "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": 133 + }, + { + "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": 2 + }, + { + "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": 273 + }, + { + "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.33" + }, + { + "commandClassName": "Version", + "commandClass": 134, + "endpoint": 0, + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["1.1"] + }, + { + "commandClassName": "Version", + "commandClass": 134, + "endpoint": 0, + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + } + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 13, + "propertyName": "Last saved position", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 1, + "max": 2, + "default": 1, + "format": 0, + "allowManualEntry": true, + "label": "Last saved position", + "description": "Set servomotor in previous position", + "isFromConfig": true + }, + "value": 1 + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 15, + "propertyName": "Close after time", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 0, + "max": 120, + "default": 120, + "format": 0, + "allowManualEntry": true, + "label": "Close after time", + "description": "Close after time min", + "isFromConfig": true + }, + "value": 0 + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 7, + "propertyName": "Motor speed I", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 1, + "max": 4, + "default": 2, + "format": 0, + "allowManualEntry": true, + "label": "Motor speed I", + "description": "Motor speed I", + "isFromConfig": true + } + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 8, + "propertyName": "1 Motor speed II (rain sensor)", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 1, + "max": 4, + "default": 2, + "format": 0, + "allowManualEntry": true, + "label": "1 Motor speed II (rain sensor)", + "description": "1 Motor speed II (rain sensor)", + "isFromConfig": true + } + }, + { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 12, + "propertyName": "Callibrate", + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "valueSize": 1, + "min": 1, + "max": 2, + "default": 1, + "format": 0, + "allowManualEntry": true, + "label": "Callibrate", + "description": "This parameter on/off callibration function", + "isFromConfig": true + } + }, + { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Water Alarm", + "propertyKey": "Sensor status", + "propertyName": "Water Alarm", + "propertyKeyName": "Sensor status", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 255, + "label": "Sensor status", + "states": { "0": "idle", "2": "Water leak detected" }, + "ccSpecific": { "notificationType": 5 } + }, + "value": 0 + }, + { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Power Management", + "propertyKey": "Over-load status", + "propertyName": "Power Management", + "propertyKeyName": "Over-load status", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 255, + "label": "Over-load status", + "states": { "0": "idle", "8": "Over-load detected" }, + "ccSpecific": { "notificationType": 8 } + }, + "value": 0 + } + ] + } \ No newline at end of file