mirror of
https://github.com/home-assistant/core.git
synced 2025-04-25 01:38:02 +00:00
Handle entity creation on new added zwave_js value (#55987)
* Handle new entity creation when a new value is added * spacing * Update homeassistant/components/zwave_js/__init__.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * change variable name and use asyncio.gather * Centralized where discovered value IDs gets managed Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
ac1251c52b
commit
c785983cce
@ -68,7 +68,11 @@ from .const import (
|
||||
ZWAVE_JS_VALUE_NOTIFICATION_EVENT,
|
||||
ZWAVE_JS_VALUE_UPDATED_EVENT,
|
||||
)
|
||||
from .discovery import ZwaveDiscoveryInfo, async_discover_values
|
||||
from .discovery import (
|
||||
ZwaveDiscoveryInfo,
|
||||
async_discover_node_values,
|
||||
async_discover_single_value,
|
||||
)
|
||||
from .helpers import async_enable_statistics, get_device_id, get_unique_id
|
||||
from .migrate import async_migrate_discovered_value
|
||||
from .services import ZWaveServices
|
||||
@ -129,13 +133,60 @@ async def async_setup_entry( # noqa: C901
|
||||
entry_hass_data[DATA_PLATFORM_SETUP] = {}
|
||||
|
||||
registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict(dict)
|
||||
discovered_value_ids: dict[str, set[str]] = defaultdict(set)
|
||||
|
||||
async def async_handle_discovery_info(
|
||||
device: device_registry.DeviceEntry,
|
||||
disc_info: ZwaveDiscoveryInfo,
|
||||
value_updates_disc_info: dict[str, ZwaveDiscoveryInfo],
|
||||
) -> None:
|
||||
"""Handle discovery info and all dependent tasks."""
|
||||
# This migration logic was added in 2021.3 to handle a breaking change to
|
||||
# the value_id format. Some time in the future, this call (as well as the
|
||||
# helper functions) can be removed.
|
||||
async_migrate_discovered_value(
|
||||
hass,
|
||||
ent_reg,
|
||||
registered_unique_ids[device.id][disc_info.platform],
|
||||
device,
|
||||
client,
|
||||
disc_info,
|
||||
)
|
||||
|
||||
platform_setup_tasks = entry_hass_data[DATA_PLATFORM_SETUP]
|
||||
platform = disc_info.platform
|
||||
if platform not in platform_setup_tasks:
|
||||
platform_setup_tasks[platform] = hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(entry, platform)
|
||||
)
|
||||
await platform_setup_tasks[platform]
|
||||
|
||||
LOGGER.debug("Discovered entity: %s", disc_info)
|
||||
async_dispatcher_send(
|
||||
hass, f"{DOMAIN}_{entry.entry_id}_add_{platform}", disc_info
|
||||
)
|
||||
|
||||
# If we don't need to watch for updates return early
|
||||
if not disc_info.assumed_state:
|
||||
return
|
||||
value_updates_disc_info[disc_info.primary_value.value_id] = disc_info
|
||||
# If this is the first time we found a value we want to watch for updates,
|
||||
# return early
|
||||
if len(value_updates_disc_info) != 1:
|
||||
return
|
||||
# add listener for value updated events
|
||||
entry.async_on_unload(
|
||||
disc_info.node.on(
|
||||
"value updated",
|
||||
lambda event: async_on_value_updated_fire_event(
|
||||
value_updates_disc_info, event["value"]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
async def async_on_node_ready(node: ZwaveNode) -> None:
|
||||
"""Handle node ready event."""
|
||||
LOGGER.debug("Processing node %s", node)
|
||||
|
||||
platform_setup_tasks = entry_hass_data[DATA_PLATFORM_SETUP]
|
||||
|
||||
# register (or update) node in device registry
|
||||
device = register_node_in_dev_reg(hass, entry, dev_reg, client, node)
|
||||
# We only want to create the defaultdict once, even on reinterviews
|
||||
@ -145,44 +196,22 @@ async def async_setup_entry( # noqa: C901
|
||||
value_updates_disc_info: dict[str, ZwaveDiscoveryInfo] = {}
|
||||
|
||||
# run discovery on all node values and create/update entities
|
||||
for disc_info in async_discover_values(node, device):
|
||||
platform = disc_info.platform
|
||||
|
||||
# This migration logic was added in 2021.3 to handle a breaking change to
|
||||
# the value_id format. Some time in the future, this call (as well as the
|
||||
# helper functions) can be removed.
|
||||
async_migrate_discovered_value(
|
||||
hass,
|
||||
ent_reg,
|
||||
registered_unique_ids[device.id][platform],
|
||||
device,
|
||||
client,
|
||||
disc_info,
|
||||
)
|
||||
|
||||
if platform not in platform_setup_tasks:
|
||||
platform_setup_tasks[platform] = hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(entry, platform)
|
||||
await asyncio.gather(
|
||||
*(
|
||||
async_handle_discovery_info(device, disc_info, value_updates_disc_info)
|
||||
for disc_info in async_discover_node_values(
|
||||
node, device, discovered_value_ids
|
||||
)
|
||||
|
||||
await platform_setup_tasks[platform]
|
||||
|
||||
LOGGER.debug("Discovered entity: %s", disc_info)
|
||||
async_dispatcher_send(
|
||||
hass, f"{DOMAIN}_{entry.entry_id}_add_{platform}", disc_info
|
||||
)
|
||||
)
|
||||
|
||||
# Capture discovery info for values we want to watch for updates
|
||||
if disc_info.assumed_state:
|
||||
value_updates_disc_info[disc_info.primary_value.value_id] = disc_info
|
||||
|
||||
# add listener for value updated events if necessary
|
||||
if value_updates_disc_info:
|
||||
# add listeners to handle new values that get added later
|
||||
for event in ("value added", "value updated", "metadata updated"):
|
||||
entry.async_on_unload(
|
||||
node.on(
|
||||
"value updated",
|
||||
lambda event: async_on_value_updated(
|
||||
value_updates_disc_info, event["value"]
|
||||
event,
|
||||
lambda event: hass.async_create_task(
|
||||
async_on_value_added(value_updates_disc_info, event["value"])
|
||||
),
|
||||
)
|
||||
)
|
||||
@ -238,6 +267,31 @@ async def async_setup_entry( # noqa: C901
|
||||
# some visual feedback that something is (in the process of) being added
|
||||
register_node_in_dev_reg(hass, entry, dev_reg, client, node)
|
||||
|
||||
async def async_on_value_added(
|
||||
value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], 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
|
||||
device_id = get_device_id(client, value.node)
|
||||
if (
|
||||
not value.node.ready
|
||||
or not (device := dev_reg.async_get_device({device_id}))
|
||||
or value.value_id in discovered_value_ids[device.id]
|
||||
):
|
||||
return
|
||||
|
||||
LOGGER.debug("Processing node %s added value %s", value.node, value)
|
||||
await asyncio.gather(
|
||||
*(
|
||||
async_handle_discovery_info(device, disc_info, value_updates_disc_info)
|
||||
for disc_info in async_discover_single_value(
|
||||
value, device, discovered_value_ids
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_on_node_removed(node: ZwaveNode) -> None:
|
||||
"""Handle node removed event."""
|
||||
@ -247,6 +301,7 @@ async def async_setup_entry( # noqa: C901
|
||||
# note: removal of entity registry entry is handled by core
|
||||
dev_reg.async_remove_device(device.id) # type: ignore
|
||||
registered_unique_ids.pop(device.id, None) # type: ignore
|
||||
discovered_value_ids.pop(device.id, None) # type: ignore
|
||||
|
||||
@callback
|
||||
def async_on_value_notification(notification: ValueNotification) -> None:
|
||||
@ -313,7 +368,7 @@ async def async_setup_entry( # noqa: C901
|
||||
hass.bus.async_fire(ZWAVE_JS_NOTIFICATION_EVENT, event_data)
|
||||
|
||||
@callback
|
||||
def async_on_value_updated(
|
||||
def async_on_value_updated_fire_event(
|
||||
value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value
|
||||
) -> None:
|
||||
"""Fire value updated event."""
|
||||
|
@ -671,126 +671,138 @@ DISCOVERY_SCHEMAS = [
|
||||
|
||||
|
||||
@callback
|
||||
def async_discover_values(
|
||||
node: ZwaveNode, device: DeviceEntry
|
||||
def async_discover_node_values(
|
||||
node: ZwaveNode, device: DeviceEntry, discovered_value_ids: dict[str, set[str]]
|
||||
) -> Generator[ZwaveDiscoveryInfo, None, None]:
|
||||
"""Run discovery on ZWave node and return matching (primary) values."""
|
||||
for value in node.values.values():
|
||||
for schema in DISCOVERY_SCHEMAS:
|
||||
# check manufacturer_id
|
||||
if (
|
||||
schema.manufacturer_id is not None
|
||||
and value.node.manufacturer_id not in schema.manufacturer_id
|
||||
):
|
||||
continue
|
||||
# We don't need to rediscover an already processed value_id
|
||||
if value.value_id in discovered_value_ids[device.id]:
|
||||
continue
|
||||
yield from async_discover_single_value(value, device, discovered_value_ids)
|
||||
|
||||
# check product_id
|
||||
if (
|
||||
schema.product_id is not None
|
||||
and value.node.product_id not in schema.product_id
|
||||
):
|
||||
continue
|
||||
|
||||
# check product_type
|
||||
if (
|
||||
schema.product_type is not None
|
||||
and value.node.product_type not in schema.product_type
|
||||
):
|
||||
continue
|
||||
@callback
|
||||
def async_discover_single_value(
|
||||
value: ZwaveValue, device: DeviceEntry, discovered_value_ids: dict[str, set[str]]
|
||||
) -> Generator[ZwaveDiscoveryInfo, None, None]:
|
||||
"""Run discovery on a single ZWave value and return matching schema info."""
|
||||
discovered_value_ids[device.id].add(value.value_id)
|
||||
for schema in DISCOVERY_SCHEMAS:
|
||||
# check manufacturer_id
|
||||
if (
|
||||
schema.manufacturer_id is not None
|
||||
and value.node.manufacturer_id not in schema.manufacturer_id
|
||||
):
|
||||
continue
|
||||
|
||||
# check firmware_version_range
|
||||
if schema.firmware_version_range is not None and (
|
||||
(
|
||||
schema.firmware_version_range.min is not None
|
||||
and schema.firmware_version_range.min_ver
|
||||
> AwesomeVersion(value.node.firmware_version)
|
||||
# check product_id
|
||||
if (
|
||||
schema.product_id is not None
|
||||
and value.node.product_id not in schema.product_id
|
||||
):
|
||||
continue
|
||||
|
||||
# check product_type
|
||||
if (
|
||||
schema.product_type is not None
|
||||
and value.node.product_type not in schema.product_type
|
||||
):
|
||||
continue
|
||||
|
||||
# check firmware_version_range
|
||||
if schema.firmware_version_range is not None and (
|
||||
(
|
||||
schema.firmware_version_range.min is not None
|
||||
and schema.firmware_version_range.min_ver
|
||||
> AwesomeVersion(value.node.firmware_version)
|
||||
)
|
||||
or (
|
||||
schema.firmware_version_range.max is not None
|
||||
and schema.firmware_version_range.max_ver
|
||||
< AwesomeVersion(value.node.firmware_version)
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
# check firmware_version
|
||||
if (
|
||||
schema.firmware_version is not None
|
||||
and value.node.firmware_version not in schema.firmware_version
|
||||
):
|
||||
continue
|
||||
|
||||
# check device_class_basic
|
||||
if not check_device_class(
|
||||
value.node.device_class.basic, schema.device_class_basic
|
||||
):
|
||||
continue
|
||||
|
||||
# check device_class_generic
|
||||
if not check_device_class(
|
||||
value.node.device_class.generic, schema.device_class_generic
|
||||
):
|
||||
continue
|
||||
|
||||
# check device_class_specific
|
||||
if not check_device_class(
|
||||
value.node.device_class.specific, schema.device_class_specific
|
||||
):
|
||||
continue
|
||||
|
||||
# check primary value
|
||||
if not check_value(value, schema.primary_value):
|
||||
continue
|
||||
|
||||
# check additional required values
|
||||
if schema.required_values is not None and not all(
|
||||
any(check_value(val, val_scheme) for val in value.node.values.values())
|
||||
for val_scheme in schema.required_values
|
||||
):
|
||||
continue
|
||||
|
||||
# check for values that may not be present
|
||||
if schema.absent_values is not None and any(
|
||||
any(check_value(val, val_scheme) for val in value.node.values.values())
|
||||
for val_scheme in schema.absent_values
|
||||
):
|
||||
continue
|
||||
|
||||
# resolve helper data from template
|
||||
resolved_data = None
|
||||
additional_value_ids_to_watch = set()
|
||||
if schema.data_template:
|
||||
try:
|
||||
resolved_data = schema.data_template.resolve_data(value)
|
||||
except UnknownValueData as err:
|
||||
LOGGER.error(
|
||||
"Discovery for value %s on device '%s' (%s) will be skipped: %s",
|
||||
value,
|
||||
device.name_by_user or device.name,
|
||||
value.node,
|
||||
err,
|
||||
)
|
||||
or (
|
||||
schema.firmware_version_range.max is not None
|
||||
and schema.firmware_version_range.max_ver
|
||||
< AwesomeVersion(value.node.firmware_version)
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
# check firmware_version
|
||||
if (
|
||||
schema.firmware_version is not None
|
||||
and value.node.firmware_version not in schema.firmware_version
|
||||
):
|
||||
continue
|
||||
|
||||
# check device_class_basic
|
||||
if not check_device_class(
|
||||
value.node.device_class.basic, schema.device_class_basic
|
||||
):
|
||||
continue
|
||||
|
||||
# check device_class_generic
|
||||
if not check_device_class(
|
||||
value.node.device_class.generic, schema.device_class_generic
|
||||
):
|
||||
continue
|
||||
|
||||
# check device_class_specific
|
||||
if not check_device_class(
|
||||
value.node.device_class.specific, schema.device_class_specific
|
||||
):
|
||||
continue
|
||||
|
||||
# check primary value
|
||||
if not check_value(value, schema.primary_value):
|
||||
continue
|
||||
|
||||
# check additional required values
|
||||
if schema.required_values is not None and not all(
|
||||
any(check_value(val, val_scheme) for val in node.values.values())
|
||||
for val_scheme in schema.required_values
|
||||
):
|
||||
continue
|
||||
|
||||
# check for values that may not be present
|
||||
if schema.absent_values is not None and any(
|
||||
any(check_value(val, val_scheme) for val in node.values.values())
|
||||
for val_scheme in schema.absent_values
|
||||
):
|
||||
continue
|
||||
|
||||
# resolve helper data from template
|
||||
resolved_data = None
|
||||
additional_value_ids_to_watch = set()
|
||||
if schema.data_template:
|
||||
try:
|
||||
resolved_data = schema.data_template.resolve_data(value)
|
||||
except UnknownValueData as err:
|
||||
LOGGER.error(
|
||||
"Discovery for value %s on device '%s' (%s) will be skipped: %s",
|
||||
value,
|
||||
device.name_by_user or device.name,
|
||||
node,
|
||||
err,
|
||||
)
|
||||
continue
|
||||
additional_value_ids_to_watch = schema.data_template.value_ids_to_watch(
|
||||
resolved_data
|
||||
)
|
||||
|
||||
# all checks passed, this value belongs to an entity
|
||||
yield ZwaveDiscoveryInfo(
|
||||
node=value.node,
|
||||
primary_value=value,
|
||||
assumed_state=schema.assumed_state,
|
||||
platform=schema.platform,
|
||||
platform_hint=schema.hint,
|
||||
platform_data_template=schema.data_template,
|
||||
platform_data=resolved_data,
|
||||
additional_value_ids_to_watch=additional_value_ids_to_watch,
|
||||
entity_registry_enabled_default=schema.entity_registry_enabled_default,
|
||||
additional_value_ids_to_watch = schema.data_template.value_ids_to_watch(
|
||||
resolved_data
|
||||
)
|
||||
|
||||
if not schema.allow_multi:
|
||||
# break out of loop, this value may not be discovered by other schemas/platforms
|
||||
break
|
||||
# all checks passed, this value belongs to an entity
|
||||
yield ZwaveDiscoveryInfo(
|
||||
node=value.node,
|
||||
primary_value=value,
|
||||
assumed_state=schema.assumed_state,
|
||||
platform=schema.platform,
|
||||
platform_hint=schema.hint,
|
||||
platform_data_template=schema.data_template,
|
||||
platform_data=resolved_data,
|
||||
additional_value_ids_to_watch=additional_value_ids_to_watch,
|
||||
entity_registry_enabled_default=schema.entity_registry_enabled_default,
|
||||
)
|
||||
|
||||
if not schema.allow_multi:
|
||||
# return early since this value may not be discovered by other schemas/platforms
|
||||
return
|
||||
|
||||
|
||||
@callback
|
||||
|
@ -3,6 +3,7 @@ from copy import deepcopy
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
from zwave_js_server.event import Event
|
||||
from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion
|
||||
from zwave_js_server.model.node import Node
|
||||
|
||||
@ -124,6 +125,39 @@ async def test_listen_failure(hass, client, error):
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_new_entity_on_value_added(hass, multisensor_6, client, integration):
|
||||
"""Test we create a new entity if a value is added after the fact."""
|
||||
node: Node = multisensor_6
|
||||
|
||||
# Add a value on a random endpoint so we can be sure we should get a new entity
|
||||
event = Event(
|
||||
type="value added",
|
||||
data={
|
||||
"source": "node",
|
||||
"event": "value added",
|
||||
"nodeId": node.node_id,
|
||||
"args": {
|
||||
"commandClassName": "Multilevel Sensor",
|
||||
"commandClass": 49,
|
||||
"endpoint": 10,
|
||||
"property": "Ultraviolet",
|
||||
"propertyName": "Ultraviolet",
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": True,
|
||||
"writeable": False,
|
||||
"label": "Ultraviolet",
|
||||
"ccSpecific": {"sensorType": 27, "scale": 0},
|
||||
},
|
||||
"value": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
node.receive_event(event)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("sensor.multisensor_6_ultraviolet_10") is not None
|
||||
|
||||
|
||||
async def test_on_node_added_ready(hass, multisensor_6_state, client, integration):
|
||||
"""Test we handle a ready node added event."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
|
Loading…
x
Reference in New Issue
Block a user