diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index f38594c1594..71c5b2bf592 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -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.""" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index d5af1c072ee..c32e74c5b5f 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -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 diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 447b052b8c0..5fed86c4d81 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -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)