Re-discover Z-Wave list sensors when metadata states change (#166271)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
AlCalzone
2026-03-25 16:10:25 +01:00
committed by GitHub
co-authored by Martin Hjelmare
parent 5ef28932e5
commit c598aa6964
6 changed files with 199 additions and 34 deletions
+28 -31
View File
@@ -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:
@@ -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")
+58 -3
View File
@@ -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,
@@ -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."""
+5
View File
@@ -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(
+100
View File
@@ -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 = {