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:
Raman Gupta 2021-09-10 17:49:31 -04:00 committed by GitHub
parent ac1251c52b
commit c785983cce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 249 additions and 148 deletions

View File

@ -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."""

View File

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

View File

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