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:
@@ -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__)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user