diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index 742ab9907a5a..4e61e98fdb9a 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -115,19 +115,23 @@ async def async_attach_trigger( @callback def async_on_value_updated( - value: Value, device: dr.DeviceEntry, event: dict + value_id: str, device: dr.DeviceEntry, event: dict ) -> None: """Handle value update.""" event_value: Value = event["value"] - if event_value != value: + if event_value.value_id != value_id: return # Get previous value and its state value if it exists prev_value_raw = event["args"]["prevValue"] - prev_value = value.metadata.states.get(str(prev_value_raw), prev_value_raw) + prev_value = event_value.metadata.states.get( + str(prev_value_raw), prev_value_raw + ) # Get current value and its state value if it exists curr_value_raw = event["args"]["newValue"] - curr_value = value.metadata.states.get(str(curr_value_raw), curr_value_raw) + curr_value = event_value.metadata.states.get( + str(curr_value_raw), curr_value_raw + ) # Check from and to values against previous and current values respectively for value_to_eval, raw_value_to_eval, match in ( (prev_value, prev_value_raw, from_value), @@ -140,17 +144,17 @@ async def async_attach_trigger( return device_name = device.name_by_user or device.name - description = f"Z-Wave value {value.value_id} updated on {device_name}" + description = f"Z-Wave value {event_value.value_id} updated on {device_name}" payload = { ATTR_DEVICE_ID: device.id, - ATTR_NODE_ID: value.node.node_id, - ATTR_COMMAND_CLASS: value.command_class, - ATTR_COMMAND_CLASS_NAME: value.command_class_name, - ATTR_PROPERTY: value.property_, - ATTR_PROPERTY_NAME: value.property_name, + ATTR_NODE_ID: event_value.node.node_id, + ATTR_COMMAND_CLASS: event_value.command_class, + ATTR_COMMAND_CLASS_NAME: event_value.command_class_name, + ATTR_PROPERTY: event_value.property_, + ATTR_PROPERTY_NAME: event_value.property_name, ATTR_ENDPOINT: endpoint, - ATTR_PROPERTY_KEY: value.property_key, - ATTR_PROPERTY_KEY_NAME: value.property_key_name, + ATTR_PROPERTY_KEY: event_value.property_key, + ATTR_PROPERTY_KEY_NAME: event_value.property_key_name, ATTR_PREVIOUS_VALUE: prev_value, ATTR_PREVIOUS_VALUE_RAW: prev_value_raw, ATTR_CURRENT_VALUE: curr_value, @@ -185,12 +189,10 @@ async def async_attach_trigger( value_id = get_value_id_str( node, command_class, property_, endpoint, property_key ) - value = node.values[value_id] - # We need to store the current value and device for the callback unsubs.append( node.on( EVENT_VALUE_UPDATED, - functools.partial(async_on_value_updated, value, device), + functools.partial(async_on_value_updated, value_id, device), ) ) diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 06b699e88394..a4a2a3f9cd92 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -1,5 +1,6 @@ """The tests for Z-Wave JS automation triggers.""" +import copy from unittest.mock import patch import pytest @@ -1322,6 +1323,105 @@ async def test_server_reconnect_value_updated( assert old_listener not in new_node._listeners.get(event_name, []) +async def test_server_reconnect_value_updated_missing_value( + hass: HomeAssistant, + client, + lock_schlage_be469, + lock_schlage_be469_state, + integration, +) -> None: + """Test value_updated trigger re-registers when value is absent at reconnect.""" + trigger_type = f"{DOMAIN}.value_updated" + + no_value_filter = async_capture_events(hass, "no_value_filter") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": trigger_type, + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, + }, + "action": { + "event": "no_value_filter", + }, + }, + ] + }, + ) + + # Remove the node so we can re-add it without the target value, simulating the + # reconnect race where node.values is not yet fully populated when + # _create_zwave_listeners runs. + node_removed_event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "reason": 0, + "node": lock_schlage_be469_state, + }, + ) + client.driver.controller.receive_event(node_removed_event) + assert 20 not in client.driver.controller.nodes + await hass.async_block_till_done() + + partial_state = copy.deepcopy(lock_schlage_be469_state) + partial_state["values"] = [ + v for v in partial_state["values"] if v.get("propertyName") != "latchStatus" + ] + node_added_event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": partial_state, + "result": {}, + }, + ) + client.driver.controller.receive_event(node_added_event) + await hass.async_block_till_done() + + # Reload the integration, which fires the connected_to_server signal that causes + # _create_zwave_listeners to re-register listeners on the new node. With the old + # code the missing latchStatus value would raise a KeyError and leave the trigger + # without a listener. + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + # Fire a value updated event on the new node — the trigger must fire even though + # the value was absent when _create_zwave_listeners ran. + new_node = client.driver.controller.nodes[20] + value_updated_event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": new_node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": CommandClass.DOOR_LOCK.value, + "endpoint": 0, + "property": "latchStatus", + "newValue": "boo", + "prevValue": "hiss", + "propertyName": "latchStatus", + }, + }, + ) + new_node.receive_event(value_updated_event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + + async def test_zwave_js_old_syntax( hass: HomeAssistant, client, lock_schlage_be469, integration ) -> None: