From 41b59b6990b9caca5c61947f3d0dbbbf3ba4def1 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 20 Oct 2023 19:11:08 -0400 Subject: [PATCH] Add support for zwave_js event entities (#102285) Co-authored-by: Martin Hjelmare --- .../components/zwave_js/discovery.py | 14 + homeassistant/components/zwave_js/event.py | 98 ++++ tests/components/zwave_js/conftest.py | 14 + .../fixtures/central_scene_node_state.json | 431 ++++++++++++++++++ tests/components/zwave_js/test_event.py | 175 +++++++ tests/components/zwave_js/test_events.py | 2 +- 6 files changed, 733 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/zwave_js/event.py create mode 100644 tests/components/zwave_js/fixtures/central_scene_node_state.json create mode 100644 tests/components/zwave_js/test_event.py diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 46975631523..39d8c0e8855 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -162,6 +162,8 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): any_available_states: set[tuple[int, str]] | None = None # [optional] the value's value must match this value value: Any | None = None + # [optional] the value's metadata_stateful must match this value + stateful: bool | None = None @dataclass @@ -1045,6 +1047,15 @@ DISCOVERY_SCHEMAS = [ any_available_states={(0, "idle")}, ), ), + # event + # stateful = False + ZWaveDiscoverySchema( + platform=Platform.EVENT, + hint="stateless", + primary_value=ZWaveValueDiscoverySchema( + stateful=False, + ), + ), ] @@ -1294,6 +1305,9 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: # check value if schema.value is not None and value.value not in schema.value: return False + # check metadata_stateful + if schema.stateful is not None and value.metadata.stateful != schema.stateful: + return False return True diff --git a/homeassistant/components/zwave_js/event.py b/homeassistant/components/zwave_js/event.py new file mode 100644 index 00000000000..93860b6273e --- /dev/null +++ b/homeassistant/components/zwave_js/event.py @@ -0,0 +1,98 @@ +"""Support for Z-Wave controls using the event platform.""" +from __future__ import annotations + +from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.model.driver import Driver +from zwave_js_server.model.value import Value, ValueNotification + +from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTR_VALUE, DATA_CLIENT, DOMAIN +from .discovery import ZwaveDiscoveryInfo +from .entity import ZWaveBaseEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Z-Wave Event entity from Config Entry.""" + client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + + @callback + def async_add_event(info: ZwaveDiscoveryInfo) -> None: + """Add Z-Wave event entity.""" + driver = client.driver + assert driver is not None # Driver is ready before platforms are loaded. + entities: list[ZWaveBaseEntity] = [ZwaveEventEntity(config_entry, driver, info)] + async_add_entities(entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_{EVENT_DOMAIN}", + async_add_event, + ) + ) + + +def _cc_and_label(value: Value) -> str: + """Return a string with the command class and label.""" + label = value.metadata.label + if label: + label = label.lower() + return f"{value.command_class_name.capitalize()} {label}".strip() + + +class ZwaveEventEntity(ZWaveBaseEntity, EventEntity): + """Representation of a Z-Wave event entity.""" + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveEventEntity entity.""" + super().__init__(config_entry, driver, info) + value = self.value = info.primary_value + self.states: dict[int, str] = {} + + if states := value.metadata.states: + self._attr_event_types = sorted(states.values()) + self.states = {int(k): v for k, v in states.items()} + else: + self._attr_event_types = [_cc_and_label(value)] + # Entity class attributes + self._attr_name = self.generate_name(include_value_name=True) + + @callback + def _async_handle_event(self, value_notification: ValueNotification) -> None: + """Handle a value notification event.""" + # If the notification doesn't match the value we are tracking, we can return + value = self.value + if ( + value_notification.command_class != value.command_class + or value_notification.endpoint != value.endpoint + or value_notification.property_ != value.property_ + or value_notification.property_key != value.property_key + or (notification_value := value_notification.value) is None + ): + return + event_name = self.states.get(notification_value, _cc_and_label(value)) + self._trigger_event(event_name, {ATTR_VALUE: notification_value}) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + await super().async_added_to_hass() + self.async_on_remove( + self.info.node.on( + "value notification", + lambda event: self._async_handle_event(event["value_notification"]), + ) + ) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index b9feeab1f2f..db5495bce01 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -668,6 +668,12 @@ def climate_intermatic_pe653_state_fixture(): return json.loads(load_fixture("zwave_js/climate_intermatic_pe653_state.json")) +@pytest.fixture(name="central_scene_node_state", scope="session") +def central_scene_node_state_fixture(): + """Load node with Central Scene CC node state fixture data.""" + return json.loads(load_fixture("zwave_js/central_scene_node_state.json")) + + # model fixtures @@ -1304,3 +1310,11 @@ def climate_intermatic_pe653_fixture(client, climate_intermatic_pe653_state): node = Node(client, copy.deepcopy(climate_intermatic_pe653_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="central_scene_node") +def central_scene_node_fixture(client, central_scene_node_state): + """Mock a node with the Central Scene CC.""" + node = Node(client, copy.deepcopy(central_scene_node_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/central_scene_node_state.json b/tests/components/zwave_js/fixtures/central_scene_node_state.json new file mode 100644 index 00000000000..1fb01275ccf --- /dev/null +++ b/tests/components/zwave_js/fixtures/central_scene_node_state.json @@ -0,0 +1,431 @@ +{ + "nodeId": 51, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "firmwareVersion": "1.3.0", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 51, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": true + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed" + }, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x", + "5": "KeyPressed4x", + "6": "KeyPressed5x" + }, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.81" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.3"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.0.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.81.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "1.3.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 255 + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "statistics": { + "commandsTX": 42, + "commandsRX": 46, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 55.4, + "rssi": -72, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -72, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": 0, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_event.py b/tests/components/zwave_js/test_event.py new file mode 100644 index 00000000000..12187d3d227 --- /dev/null +++ b/tests/components/zwave_js/test_event.py @@ -0,0 +1,175 @@ +"""Test the Z-Wave JS event platform.""" +from datetime import timedelta + +from freezegun import freeze_time +from zwave_js_server.event import Event + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.components.zwave_js.const import ATTR_VALUE +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +BASIC_EVENT_VALUE_ENTITY = "event.honeywell_in_wall_smart_fan_control_event_value" +CENTRAL_SCENE_ENTITY = "event.node_51_scene_002" + + +async def test_basic( + hass: HomeAssistant, client, fan_honeywell_39358, integration +) -> None: + """Test the Basic CC event entity.""" + dt_util.now() + fut = dt_util.now() + timedelta(minutes=1) + node = fan_honeywell_39358 + state = hass.states.get(BASIC_EVENT_VALUE_ENTITY) + + assert state + assert state.state == STATE_UNKNOWN + + event = Event( + type="value notification", + data={ + "source": "node", + "event": "value notification", + "nodeId": node.node_id, + "args": { + "commandClassName": "Basic", + "commandClass": 32, + "endpoint": 0, + "property": "event", + "propertyName": "event", + "value": 255, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "min": 0, + "max": 255, + "label": "Event value", + }, + "ccVersion": 1, + }, + }, + ) + with freeze_time(fut): + node.receive_event(event) + + state = hass.states.get(BASIC_EVENT_VALUE_ENTITY) + + assert state + assert state.state == dt_util.as_utc(fut).isoformat(timespec="milliseconds") + attributes = state.attributes + assert attributes[ATTR_EVENT_TYPE] == "Basic event value" + assert attributes[ATTR_VALUE] == 255 + + +async def test_central_scene( + hass: HomeAssistant, client, central_scene_node, integration +) -> None: + """Test the Central Scene CC event entity.""" + dt_util.now() + fut = dt_util.now() + timedelta(minutes=1) + node = central_scene_node + state = hass.states.get(CENTRAL_SCENE_ENTITY) + + assert state + assert state.state == STATE_UNKNOWN + + event = Event( + type="value notification", + data={ + "source": "node", + "event": "value notification", + "nodeId": node.node_id, + "args": { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x", + "5": "KeyPressed4x", + "6": "KeyPressed5x", + }, + "stateful": False, + "secret": False, + }, + "value": 1, + }, + }, + ) + with freeze_time(fut): + node.receive_event(event) + + state = hass.states.get(CENTRAL_SCENE_ENTITY) + + assert state + assert state.state == dt_util.as_utc(fut).isoformat(timespec="milliseconds") + attributes = state.attributes + assert attributes[ATTR_EVENT_TYPE] == "KeyReleased" + assert attributes[ATTR_VALUE] == 1 + + # Try invalid value + event = Event( + type="value notification", + data={ + "source": "node", + "event": "value notification", + "nodeId": node.node_id, + "args": { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x", + "5": "KeyPressed4x", + "6": "KeyPressed5x", + }, + "stateful": False, + "secret": False, + }, + }, + }, + ) + with freeze_time(fut + timedelta(minutes=10)): + node.receive_event(event) + + # Nothing should have changed even though the time has changed + state = hass.states.get(CENTRAL_SCENE_ENTITY) + + assert state + assert state.state == dt_util.as_utc(fut).isoformat(timespec="milliseconds") + attributes = state.attributes + assert attributes[ATTR_EVENT_TYPE] == "KeyReleased" + assert attributes[ATTR_VALUE] == 1 diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index 80b179248d8..4fbaa97f118 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -1,4 +1,4 @@ -"""Test Z-Wave JS (value notification) events.""" +"""Test Z-Wave JS events.""" from unittest.mock import AsyncMock import pytest