From 3e8e95f95e8f389cf8c27091eeb7cf7080e35fe7 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Fri, 13 Feb 2026 23:46:59 +0100 Subject: [PATCH] Handle Z-Wave values (re-)added at runtime (#162921) --- homeassistant/components/zwave_js/const.py | 2 + homeassistant/components/zwave_js/entity.py | 43 +++++- homeassistant/components/zwave_js/sensor.py | 4 +- tests/components/zwave_js/test_init.py | 154 +++++++++++++++++++- tests/components/zwave_js/test_sensor.py | 10 +- 5 files changed, 197 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 7ab1193d484..ce2710ec652 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -43,6 +43,8 @@ DOMAIN = "zwave_js" 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" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 263e5c20467..f79bd5473e0 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -22,13 +22,17 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import UNDEFINED -from .const import DOMAIN, EVENT_VALUE_UPDATED, LOGGER +from .const import ( + DOMAIN, + EVENT_VALUE_ADDED, + EVENT_VALUE_REMOVED, + EVENT_VALUE_UPDATED, + LOGGER, +) 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 -EVENT_VALUE_REMOVED = "value removed" - @dataclass(kw_only=True) class NewZwaveDiscoveryInfo(PlatformZwaveDiscoveryInfo): @@ -62,6 +66,7 @@ class ZWaveBaseEntity(Entity): self.config_entry = config_entry self.driver = driver self.info = info + self._primary_value_removed = False # entities requiring additional values, can add extra ids to this list self.watched_value_ids = {self.info.primary_value.value_id} @@ -135,6 +140,7 @@ class ZWaveBaseEntity(Entity): self.async_on_remove( self.info.node.on(EVENT_VALUE_UPDATED, self._value_changed) ) + self.async_on_remove(self.info.node.on(EVENT_VALUE_ADDED, self._value_added)) self.async_on_remove( self.info.node.on(EVENT_VALUE_REMOVED, self._value_removed) ) @@ -226,7 +232,11 @@ class ZWaveBaseEntity(Entity): @property def available(self) -> bool: """Return entity availability.""" - return self.driver.client.connected and bool(self.info.node.ready) + return ( + self.driver.client.connected + and bool(self.info.node.ready) + and not self._primary_value_removed + ) @callback def _value_changed(self, event_data: dict) -> None: @@ -269,7 +279,30 @@ class ZWaveBaseEntity(Entity): value_id, ) - self.hass.async_create_task(self.async_remove()) + self._primary_value_removed = True + self.async_write_ha_state() + + @callback + def _value_added(self, event_data: dict) -> None: + """Call when a value associated with our node is added. + + Should not be overridden by subclasses. + """ + value = event_data["value"] + + if value.value_id != self.info.primary_value.value_id: + return + + LOGGER.debug( + "[%s] Primary value %s was added", + self.entity_id, + value.value_id, + ) + + self.info.primary_value = value + self._primary_value_removed = False + self.on_value_update() + self.async_write_ha_state() @callback def get_zwave_value( diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index aa7ba9fde34..f5a73e1f6be 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -795,10 +795,10 @@ class ZWaveNumericSensor(ZwaveSensor): self._attr_native_unit_of_measurement = data.unit_of_measurement @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Return state of the sensor.""" if self.info.primary_value.value is None: - return 0 + return None return float(self.info.primary_value.value) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 2313f82a021..e98ec67dbf9 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -26,7 +26,7 @@ from homeassistant.components.persistent_notification import async_dismiss from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id, get_device_id_ext from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import ( area_registry as ar, @@ -1917,11 +1917,12 @@ async def test_disabled_node_status_entity_on_node_replaced( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_remove_entity_on_value_removed( hass: HomeAssistant, + entity_registry: er.EntityRegistry, zp3111: Node, client: MagicMock, integration: MockConfigEntry, ) -> None: - """Test that when entity primary values are removed the entity is removed.""" + """Test that when entity primary values are removed the entity becomes unavailable.""" idle_cover_status_button_entity = ( "button.4_in_1_sensor_idle_home_security_cover_status" ) @@ -2039,6 +2040,155 @@ async def test_remove_entity_on_value_removed( == new_unavailable_entities ) + # Entities should still be in the entity registry (not fully removed) + assert entity_registry.async_get(battery_level_entity) is not None + assert entity_registry.async_get(binary_cover_entity) is not None + assert entity_registry.async_get(idle_cover_status_button_entity) is not None + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) +async def test_value_removed_and_readded( + hass: HomeAssistant, + zp3111: Node, + client: MagicMock, + integration: MockConfigEntry, +) -> None: + """Test entity recovers when primary value is removed and re-added.""" + battery_level_entity = "sensor.4_in_1_sensor_battery_level" + + state = hass.states.get(battery_level_entity) + assert state + assert state.state == "0.0" + + # Remove the battery level value + event = Event( + type="value removed", + data={ + "source": "node", + "event": "value removed", + "nodeId": zp3111.node_id, + "args": { + "commandClassName": "Battery", + "commandClass": 128, + "endpoint": 0, + "property": "level", + "prevValue": 100, + "propertyName": "level", + }, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(battery_level_entity) + assert state + assert state.state == STATE_UNAVAILABLE + + # Re-add the battery level value with a new reading + event = Event( + type="value added", + data={ + "source": "node", + "event": "value added", + "nodeId": zp3111.node_id, + "args": { + "commandClassName": "Battery", + "commandClass": 128, + "endpoint": 0, + "property": "level", + "propertyName": "level", + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + }, + "value": 80, + }, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(battery_level_entity) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "80.0" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) +async def test_value_never_populated_then_added( + hass: HomeAssistant, + zp3111_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, +) -> None: + """Test entity updates when value metadata exists but value is None, then added.""" + # Modify the battery level value to have value=None (metadata exists but no data) + node_state = deepcopy(zp3111_state) + for value in node_state["values"]: + if value["commandClass"] == 128 and value["property"] == "level": + value["value"] = None + break + + event = Event( + "node added", + { + "source": "controller", + "event": "node added", + "node": node_state, + "result": {}, + }, + ) + client.driver.controller.receive_event(event) + await hass.async_block_till_done() + + # The entity should exist but have unknown state (value is None) + battery_level_entity = "sensor.4_in_1_sensor_battery_level" + state = hass.states.get(battery_level_entity) + assert state + assert state.state == STATE_UNKNOWN + + node = client.driver.controller.nodes[node_state["nodeId"]] + + # Now send "value added" event with actual value + event = Event( + type="value added", + data={ + "source": "node", + "event": "value added", + "nodeId": node.node_id, + "args": { + "commandClassName": "Battery", + "commandClass": 128, + "endpoint": 0, + "property": "level", + "propertyName": "level", + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + }, + "value": 75, + }, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(battery_level_entity) + assert state + assert state.state == "75.0" + async def test_identify_event( hass: HomeAssistant, diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index e287c9e988f..b9784f7ffa9 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -125,9 +125,7 @@ async def test_battery_sensors( entity_id = "sensor.keypad_v2_maximum_capacity" state = hass.states.get(entity_id) assert state - assert ( - state.state == "0" - ) # This should be None/unknown but will be fixed in a future PR. + assert state.state == STATE_UNKNOWN assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE assert ATTR_DEVICE_CLASS not in state.attributes assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT @@ -143,9 +141,7 @@ async def test_battery_sensors( entity_id = "sensor.keypad_v2_temperature" state = hass.states.get(entity_id) assert state - assert ( - state.state == "0" - ) # This should be None/unknown but will be fixed in a future PR. + assert state.state == STATE_UNKNOWN assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT @@ -225,7 +221,7 @@ async def test_numeric_sensor( await hass.async_block_till_done() state = hass.states.get("sensor.hsm200_illuminance") assert state - assert state.state == "0" + assert state.state == STATE_UNKNOWN async def test_invalid_multilevel_sensor_scale(