From c598aa69641b864e4c4fe8fc5205595a212b8c8c Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Wed, 25 Mar 2026 16:10:25 +0100 Subject: [PATCH] Re-discover Z-Wave list sensors when metadata states change (#166271) Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/__init__.py | 59 +++++------ homeassistant/components/zwave_js/const.py | 1 + homeassistant/components/zwave_js/entity.py | 61 ++++++++++- homeassistant/components/zwave_js/sensor.py | 7 ++ tests/components/zwave_js/test_init.py | 5 + tests/components/zwave_js/test_sensor.py | 100 ++++++++++++++++++ 6 files changed, 199 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index aa3adf46de83..ca8c761b3b84 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -108,6 +108,8 @@ from .const import ( DOMAIN, ESPHOME_ADDON_VERSION, EVENT_DEVICE_ADDED_TO_REGISTRY, + EVENT_METADATA_UPDATED, + EVENT_VALUE_ADDED, EVENT_VALUE_UPDATED, LIB_LOGGER, LOGGER, @@ -599,6 +601,7 @@ class ControllerEvents: f"{DOMAIN}.node_reset_and_removed.{dev_id[1]}", ) + self.node_events.value_updates_disc_info.pop(node.node_id, None) self.remove_device(device) @callback @@ -756,6 +759,9 @@ class NodeEvents: self.dev_reg = controller_events.dev_reg self.ent_reg = er.async_get(hass) self.hass = hass + self.value_updates_disc_info: dict[ + int, dict[str, PlatformZwaveDiscoveryInfo] + ] = {} async def async_on_node_ready(self, node: ZwaveNode) -> None: """Handle node ready event.""" @@ -766,29 +772,23 @@ class NodeEvents: # Remove any old value ids if this is a reinterview. self.controller_events.discovered_value_ids.pop(device.id, None) + # Store the discovery info so it can be reused when re-discovering entities value_updates_disc_info: dict[str, PlatformZwaveDiscoveryInfo] = {} + self.value_updates_disc_info[node.node_id] = value_updates_disc_info # run discovery on all node values and create/update entities - await asyncio.gather( - *( - self.async_handle_discovery_info( - device, disc_info, value_updates_disc_info - ) - for disc_info in async_discover_node_values( - node, device, self.controller_events.discovered_value_ids - ) - ) - ) + for disc_info in async_discover_node_values( + node, device, self.controller_events.discovered_value_ids + ): + self.async_handle_discovery_info(device, disc_info, value_updates_disc_info) # add listeners to handle new values that get added later - for event in ("value added", EVENT_VALUE_UPDATED, "metadata updated"): + for event in (EVENT_VALUE_ADDED, EVENT_VALUE_UPDATED, EVENT_METADATA_UPDATED): self.config_entry.async_on_unload( node.on( event, - lambda event: self.hass.async_create_task( - self.async_on_value_added( - value_updates_disc_info, event["value"] - ) + lambda event: self.async_on_value_added( + value_updates_disc_info, event["value"] ), ) ) @@ -852,7 +852,8 @@ class NodeEvents: # an upstream bug, or the change has been reverted. async_delete_issue(self.hass, DOMAIN, issue_id) - async def async_handle_discovery_info( + @callback + def async_handle_discovery_info( self, device: dr.DeviceEntry, disc_info: PlatformZwaveDiscoveryInfo, @@ -897,15 +898,17 @@ class NodeEvents: ) ) - async def async_on_value_added( + @callback + def async_on_value_added( self, value_updates_disc_info: dict[str, PlatformZwaveDiscoveryInfo], value: Value, ) -> None: - """Fire value updated event.""" - # If node isn't ready or a device for this node doesn't already exist, we can - # let the node ready event handler perform discovery. If a value has already - # been processed, we don't need to do it again + """Run discovery when a value is added, updated or its metadata is changed.""" + # If node isn't ready or a device for this node doesn't already + # exist, we can let the node ready event handler perform discovery. + # If a value has already been processed, we don't need to do it + # again device_id = get_device_id( self.controller_events.driver_events.driver, value.node ) @@ -917,16 +920,10 @@ class NodeEvents: return LOGGER.debug("Processing node %s added value %s", value.node, value) - await asyncio.gather( - *( - self.async_handle_discovery_info( - device, disc_info, value_updates_disc_info - ) - for disc_info in async_discover_single_value( - value, device, self.controller_events.discovered_value_ids - ) - ) - ) + for disc_info in async_discover_single_value( + value, device, self.controller_events.discovered_value_ids + ): + self.async_handle_discovery_info(device, disc_info, value_updates_disc_info) @callback def async_on_value_notification(self, notification: ValueNotification) -> None: diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 2f00bd7b26ee..0c8cb7850811 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -45,6 +45,7 @@ EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry" EVENT_VALUE_ADDED = "value added" EVENT_VALUE_REMOVED = "value removed" EVENT_VALUE_UPDATED = "value updated" +EVENT_METADATA_UPDATED = "metadata updated" LOGGER = logging.getLogger(__package__) LIB_LOGGER = logging.getLogger("zwave_js_server") diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index f79bd5473e02..cb4db816c507 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -14,7 +14,6 @@ from zwave_js_server.model.value import ( get_value_id_str, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -24,6 +23,7 @@ from homeassistant.helpers.typing import UNDEFINED from .const import ( DOMAIN, + EVENT_METADATA_UPDATED, EVENT_VALUE_ADDED, EVENT_VALUE_REMOVED, EVENT_VALUE_UPDATED, @@ -31,7 +31,7 @@ from .const import ( ) from .discovery_data_template import BaseDiscoverySchemaDataTemplate from .helpers import get_device_id, get_unique_id, get_valueless_base_unique_id -from .models import PlatformZwaveDiscoveryInfo, ZwaveDiscoveryInfo +from .models import PlatformZwaveDiscoveryInfo, ZwaveDiscoveryInfo, ZwaveJSConfigEntry @dataclass(kw_only=True) @@ -55,10 +55,11 @@ class ZWaveBaseEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True + info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo, ) -> None: @@ -144,6 +145,9 @@ class ZWaveBaseEntity(Entity): self.async_on_remove( self.info.node.on(EVENT_VALUE_REMOVED, self._value_removed) ) + self.async_on_remove( + self.info.node.on(EVENT_METADATA_UPDATED, self._metadata_updated) + ) self.async_on_remove( async_dispatcher_connect( self.hass, @@ -304,6 +308,57 @@ class ZWaveBaseEntity(Entity): self.on_value_update() self.async_write_ha_state() + @callback + def should_rediscover_on_metadata_update(self) -> bool: + """Check if a metadata update requires entity rediscovery. + + To be overridden by subclasses that need to detect metadata changes. + Return True if the entity needs to be removed and re-discovered. + """ + return False + + @callback + def _metadata_updated(self, event_data: dict) -> None: + """Handle metadata update requiring entity rediscovery. + + Should not be overridden by subclasses. + """ + value = event_data["value"] + if value.value_id != self.info.primary_value.value_id: + return + + if not self.should_rediscover_on_metadata_update(): + return + + LOGGER.debug( + "[%s] Metadata options changed for %s, removing for rediscovery", + self.entity_id, + value.value_id, + ) + + self.hass.async_create_task(self._async_remove_and_rediscover(value)) + + async def _async_remove_and_rediscover(self, value: ZwaveValue) -> None: + """Remove entity and trigger re-discovery with updated metadata.""" + assert self.device_entry is not None + controller_events = ( + self.config_entry.runtime_data.driver_events.controller_events + ) + + # Remove entity first so the unique_id is freed up + await self.async_remove() + + # Now clear from discovered_value_ids and trigger re-discovery + # using the existing discovery info dict + controller_events.discovered_value_ids[self.device_entry.id].discard( + value.value_id + ) + node_events = controller_events.node_events + value_updates_disc_info = node_events.value_updates_disc_info[ + value.node.node_id + ] + node_events.async_on_value_added(value_updates_disc_info, value) + @callback def get_zwave_value( self, diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 1e7e3eb7829c..10c6553d97ab 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -985,6 +985,13 @@ class ZWaveListSensor(ZwaveSensor): self._attr_device_class = SensorDeviceClass.ENUM self._attr_options = list(info.primary_value.metadata.states.values()) + @callback + def should_rediscover_on_metadata_update(self) -> bool: + """Check if metadata states have changed.""" + return list(self.info.primary_value.metadata.states.values()) != ( + self._attr_options or [] + ) + @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the device specific state attributes.""" diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 333099c8ea07..95049d01a9ab 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1446,12 +1446,17 @@ async def test_node_removed( assert old_device assert old_device.id + node_events = integration.runtime_data.driver_events.controller_events.node_events + assert node.node_id in node_events.value_updates_disc_info + event = {"node": node, "reason": 0} client.driver.controller.emit("node removed", event) await hass.async_block_till_done() # Assert device has been removed assert not device_registry.async_get(old_device.id) + # Assert value_updates_disc_info has been cleaned up + assert node.node_id not in node_events.value_updates_disc_info async def test_replace_same_node( diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index fd76117930d6..e111d6aed911 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -926,6 +926,106 @@ async def test_opening_state_sensor( assert hass.states.get(entity_id) is None +async def test_opening_state_sensor_metadata_options_change( + hass: HomeAssistant, + hoppe_ehandle_connectsense: Node, + integration: MockConfigEntry, +) -> None: + """Test Opening state sensor is rediscovered when metadata options change.""" + entity_id = "sensor.ehandle_connectsense_opening_state" + node = hoppe_ehandle_connectsense + + # Verify initial state with 2 options + state = hass.states.get(entity_id) + assert state + assert state.state == "Closed" + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open"] + + # Simulate metadata update adding "Tilted" state + event = Event( + "metadata updated", + { + "source": "node", + "event": "metadata updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Access Control", + "propertyKey": "Opening state", + "propertyName": "Access Control", + "propertyKeyName": "Opening state", + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Opening state", + "ccSpecific": {"notificationType": 6}, + "min": 0, + "max": 255, + "states": { + "0": "Closed", + "1": "Open", + "2": "Tilted", + }, + "stateful": True, + "secret": False, + }, + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + # Entity should be rediscovered with 3 options + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open", "Tilted"] + + # Simulate metadata update removing "Tilted" state + event = Event( + "metadata updated", + { + "source": "node", + "event": "metadata updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Access Control", + "propertyKey": "Opening state", + "propertyName": "Access Control", + "propertyKeyName": "Opening state", + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Opening state", + "ccSpecific": {"notificationType": 6}, + "min": 0, + "max": 255, + "states": { + "0": "Closed", + "1": "Open", + }, + "stateful": True, + "secret": False, + }, + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + # Entity should be rediscovered with 2 options again + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open"] + + CONTROLLER_STATISTICS_ENTITY_PREFIX = "sensor.z_stick_gen5_usb_controller_" # controller statistics with initial state of 0 CONTROLLER_STATISTICS_SUFFIXES = {