1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Handle Z-Wave values (re-)added at runtime (#162921)

This commit is contained in:
AlCalzone
2026-02-13 23:46:59 +01:00
committed by GitHub
parent 6d66df9346
commit 3e8e95f95e
5 changed files with 197 additions and 16 deletions

View File

@@ -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__)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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,

View File

@@ -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(