diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 7d2af4af126eca..10088f62414352 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -88,7 +88,12 @@ async_discover_node_values, async_discover_single_value, ) -from .helpers import async_enable_statistics, get_device_id, get_unique_id +from .helpers import ( + async_enable_statistics, + get_device_id, + get_device_id_ext, + get_unique_id, +) from .migrate import async_migrate_discovered_value from .services import ZWaveServices @@ -116,17 +121,27 @@ def register_node_in_dev_reg( ) -> device_registry.DeviceEntry: """Register node in dev reg.""" device_id = get_device_id(client, node) - # If a device already exists but it doesn't match the new node, it means the node - # was replaced with a different device and the device needs to be removeed so the - # new device can be created. Otherwise if the device exists and the node is the same, - # the node was replaced with the same device model and we can reuse the device. - if (device := dev_reg.async_get_device({device_id})) and ( - device.model != node.device_config.label - or device.manufacturer != node.device_config.manufacturer + device_id_ext = get_device_id_ext(client, node) + device = dev_reg.async_get_device({device_id}) + + # Replace the device if it can be determined that this node is not the + # same product as it was previously. + if ( + device_id_ext + and device + and len(device.identifiers) == 2 + and device_id_ext not in device.identifiers ): remove_device_func(device) + device = None + + if device_id_ext: + ids = {device_id, device_id_ext} + else: + ids = {device_id} + params = { - ATTR_IDENTIFIERS: {device_id}, + ATTR_IDENTIFIERS: ids, ATTR_SW_VERSION: node.firmware_version, ATTR_NAME: node.name or node.device_config.description @@ -338,7 +353,14 @@ def async_on_node_removed(event: dict) -> None: device = dev_reg.async_get_device({dev_id}) # We assert because we know the device exists assert device - if not replaced: + if replaced: + discovered_value_ids.pop(device.id, None) + + async_dispatcher_send( + hass, + f"{DOMAIN}_{client.driver.controller.home_id}.{node.node_id}.node_status_remove_entity", + ) + else: remove_device(device) @callback diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index cf15f32932b01d..87e9f3adbbd097 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -20,6 +20,7 @@ LOGGER = logging.getLogger(__name__) EVENT_VALUE_UPDATED = "value updated" +EVENT_VALUE_REMOVED = "value removed" EVENT_DEAD = "dead" EVENT_ALIVE = "alive" @@ -99,6 +100,10 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( self.info.node.on(EVENT_VALUE_UPDATED, self._value_changed) ) + self.async_on_remove( + self.info.node.on(EVENT_VALUE_REMOVED, self._value_removed) + ) + for status_event in (EVENT_ALIVE, EVENT_DEAD): self.async_on_remove( self.info.node.on(status_event, self._node_status_alive_or_dead) @@ -171,7 +176,7 @@ def _node_status_alive_or_dead(self, event_data: dict) -> None: @callback def _value_changed(self, event_data: dict) -> None: - """Call when (one of) our watched values changes. + """Call when a value associated with our node changes. Should not be overridden by subclasses. """ @@ -193,6 +198,25 @@ def _value_changed(self, event_data: dict) -> None: self.on_value_update() self.async_write_ha_state() + @callback + def _value_removed(self, event_data: dict) -> None: + """Call when a value associated with our node is removed. + + Should not be overridden by subclasses. + """ + value_id = event_data["value"].value_id + + if value_id != self.info.primary_value.value_id: + return + + LOGGER.debug( + "[%s] Primary value %s is being removed", + self.entity_id, + value_id, + ) + + self.hass.async_create_task(self.async_remove()) + @callback def get_zwave_value( self, diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index aa6db532616680..363762ac72b11a 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -66,6 +66,19 @@ def get_device_id(client: ZwaveClient, node: ZwaveNode) -> tuple[str, str]: return (DOMAIN, f"{client.driver.controller.home_id}-{node.node_id}") +@callback +def get_device_id_ext(client: ZwaveClient, node: ZwaveNode) -> tuple[str, str] | None: + """Get extended device registry identifier for Z-Wave node.""" + if None in (node.manufacturer_id, node.product_type, node.product_id): + return None + + domain, dev_id = get_device_id(client, node) + return ( + domain, + f"{dev_id}-{node.manufacturer_id}:{node.product_type}:{node.product_id}", + ) + + @callback def get_home_and_node_id_from_device_id(device_id: tuple[str, ...]) -> list[str]: """ diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 1fb7b933972954..81ab803d9826d6 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -502,4 +502,11 @@ async def async_added_to_hass(self) -> None: self.async_poll_value, ) ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self.unique_id}_remove_entity", + self.async_remove, + ) + ) self.async_write_ha_state() diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 12eaccec612fb0..4f21f616ae131a 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -479,6 +479,18 @@ def fortrezz_ssa3_siren_state_fixture(): return json.loads(load_fixture("zwave_js/fortrezz_ssa3_siren_state.json")) +@pytest.fixture(name="zp3111_not_ready_state", scope="session") +def zp3111_not_ready_state_fixture(): + """Load the zp3111 4-in-1 sensor not-ready node state fixture data.""" + return json.loads(load_fixture("zwave_js/zp3111-5_not_ready_state.json")) + + +@pytest.fixture(name="zp3111_state", scope="session") +def zp3111_state_fixture(): + """Load the zp3111 4-in-1 sensor node state fixture data.""" + return json.loads(load_fixture("zwave_js/zp3111-5_state.json")) + + @pytest.fixture(name="client") def mock_client_fixture(controller_state, version_state, log_config_state): """Mock a client.""" @@ -919,3 +931,19 @@ def fortrezz_ssa3_siren_fixture(client, fortrezz_ssa3_siren_state): def firmware_file_fixture(): """Return mock firmware file stream.""" return io.BytesIO(bytes(10)) + + +@pytest.fixture(name="zp3111_not_ready") +def zp3111_not_ready_fixture(client, zp3111_not_ready_state): + """Mock a zp3111 4-in-1 sensor node in a not-ready state.""" + node = Node(client, copy.deepcopy(zp3111_not_ready_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="zp3111") +def zp3111_fixture(client, zp3111_state): + """Mock a zp3111 4-in-1 sensor node.""" + node = Node(client, copy.deepcopy(zp3111_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/zp3111-5_not_ready_state.json b/tests/components/zwave_js/fixtures/zp3111-5_not_ready_state.json new file mode 100644 index 00000000000000..f892eb5570eeec --- /dev/null +++ b/tests/components/zwave_js/fixtures/zp3111-5_not_ready_state.json @@ -0,0 +1,68 @@ +{ + "nodeId": 22, + "index": 0, + "status": 1, + "ready": false, + "isListening": false, + "isRouting": true, + "isSecure": "unknown", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 22, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + } + } + ], + "values": [], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [ + 40000, + 100000 + ], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [], + "interviewStage": "ProtocolInfo", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + } +} diff --git a/tests/components/zwave_js/fixtures/zp3111-5_state.json b/tests/components/zwave_js/fixtures/zp3111-5_state.json new file mode 100644 index 00000000000000..8de7dd2b713e4e --- /dev/null +++ b/tests/components/zwave_js/fixtures/zp3111-5_state.json @@ -0,0 +1,706 @@ +{ + "nodeId": 22, + "index": 0, + "installerIcon": 3079, + "userIcon": 3079, + "status": 2, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": false, + "manufacturerId": 265, + "productId": 8449, + "productType": 8225, + "firmwareVersion": "5.1", + "zwavePlusVersion": 1, + "deviceConfig": { + "filename": "/cache/db/devices/0x0109/zp3111-5.json", + "isEmbedded": true, + "manufacturer": "Vision Security", + "manufacturerId": 265, + "label": "ZP3111-5", + "description": "4-in-1 Sensor", + "devices": [ + { + "productType": 8225, + "productId": 8449 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "To add the ZP3111 to the Z-Wave network (inclusion), place the Z-Wave primary controller into inclusion mode. Press the Program Switch of ZP3111 for sending the NIF. After sending NIF, Z-Wave will send the auto inclusion, otherwise, ZP3111 will go to sleep after 20 seconds.", + "exclusion": "To remove the ZP3111 from the Z-Wave network (exclusion), place the Z-Wave primary controller into “exclusion” mode, and following its instruction to delete the ZP3111 to the controller. Press the Program Switch of ZP3111 once to be excluded.", + "reset": "Remove cover to trigged tamper switch, LED flash once & send out Alarm Report. Press Program Switch 10 times within 10 seconds, ZP3111 will send the “Device Reset Locally Notification” command and reset to the factory default. (Remark: This is to be used only in the case of primary controller being inoperable or otherwise unavailable.)", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/2479/ZP3111-5_R2_20170316.pdf" + } + }, + "label": "ZP3111-5", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 22, + "index": 0, + "installerIcon": 3079, + "userIcon": 3079, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + } + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "4.5" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": [ + "5.1", + "10.1" + ] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 265 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 8225 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 8449 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level" + }, + "value": true + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Home Security", + "propertyKey": "Cover status", + "propertyName": "Home Security", + "propertyKeyName": "Cover status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Cover status", + "ccSpecific": { + "notificationType": 7 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "Tampering, product cover removed" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Home Security", + "propertyKey": "Motion sensor status", + "propertyName": "Home Security", + "propertyKeyName": "Motion sensor status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Motion sensor status", + "ccSpecific": { + "notificationType": 7 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "8": "Motion detection" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 7, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + }, + "unit": "°C" + }, + "value": 21.98 + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Illuminance", + "propertyName": "Illuminance", + "ccVersion": 7, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Illuminance", + "ccSpecific": { + "sensorType": 3, + "scale": 0 + }, + "unit": "%" + }, + "value": 7.31 + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Humidity", + "propertyName": "Humidity", + "ccVersion": 7, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Humidity", + "ccSpecific": { + "sensorType": 5, + "scale": 0 + }, + "unit": "%" + }, + "value": 51.98 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Temperature Scale", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Scale", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Celsius", + "1": "Fahrenheit" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Temperature offset", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature offset", + "default": 1, + "min": 0, + "max": 50, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Humidity", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Configure Relative Humidity", + "label": "Humidity", + "default": 10, + "min": 1, + "max": 50, + "unit": "percent", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Light Sensor", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Sensor", + "default": 10, + "min": 1, + "max": 50, + "unit": "percent", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Trigger Interval", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Set the trigger interval for motion sensor re-activation.", + "label": "Trigger Interval", + "default": 180, + "min": 1, + "max": 255, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Motion Sensor Sensitivity", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Adjust sensitivity of the motion sensor.", + "label": "Motion Sensor Sensitivity", + "default": 4, + "min": 1, + "max": 7, + "states": { + "1": "highest", + "2": "higher", + "3": "high", + "4": "normal", + "5": "low", + "6": "lower", + "7": "lowest" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "LED indicator mode", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED indicator mode", + "default": 3, + "min": 1, + "max": 3, + "states": { + "1": "Off", + "2": "Pulsing Temperature, Flashing Motion", + "3": "Flashing Temperature and Motion" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 2, + "metadata": { + "type": "number", + "default": 3600, + "readable": false, + "writeable": true, + "label": "Wake Up interval", + "min": 600, + "max": 604800, + "steps": 600 + }, + "value": 3600 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller" + }, + "value": 1 + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [ + 40000, + 100000 + ], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 7, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 132, + "name": "Wake Up", + "version": 2, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 2, + "isSecure": false + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0109:0x2021:0x2101:5.1", + "statistics": { + "commandsTX": 39, + "commandsRX": 38, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + }, + "highestSecurityClass": -1 +} diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index d9a1695f60f305..7e39b7845337e3 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -12,7 +12,11 @@ from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY @@ -159,7 +163,7 @@ async def test_new_entity_on_value_added(hass, multisensor_6, client, integratio async def test_on_node_added_ready(hass, multisensor_6_state, client, integration): - """Test we handle a ready node added event.""" + """Test we handle a node added event with a ready node.""" dev_reg = dr.async_get(hass) node = Node(client, deepcopy(multisensor_6_state)) event = {"node": node} @@ -182,38 +186,34 @@ async def test_on_node_added_ready(hass, multisensor_6_state, client, integratio assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) -async def test_on_node_added_not_ready(hass, multisensor_6_state, client, integration): - """Test we handle a non ready node added event.""" +async def test_on_node_added_not_ready( + hass, zp3111_not_ready_state, client, integration +): + """Test we handle a node added event with a non-ready node.""" dev_reg = dr.async_get(hass) - node_data = deepcopy(multisensor_6_state) # Copy to allow modification in tests. - node = Node(client, node_data) - node.data["ready"] = False - event = {"node": node} - air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" + device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}" - state = hass.states.get(AIR_TEMPERATURE_SENSOR) + assert len(hass.states.async_all()) == 0 + assert not dev_reg.devices - assert not state # entity and device not yet added - assert not dev_reg.async_get_device( - identifiers={(DOMAIN, air_temperature_device_id)} + event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(zp3111_not_ready_state), + }, ) - - client.driver.controller.emit("node added", event) + client.driver.receive_event(event) await hass.async_block_till_done() - state = hass.states.get(AIR_TEMPERATURE_SENSOR) - - assert not state # entity not yet added but device added in registry - assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + # the only entity is the node status sensor + assert len(hass.states.async_all()) == 1 - node.data["ready"] = True - node.emit("ready", event) - await hass.async_block_till_done() - - state = hass.states.get(AIR_TEMPERATURE_SENSOR) - - assert state # entity added - assert state.state != STATE_UNAVAILABLE + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + # no extended device identifier yet + assert len(device.identifiers) == 1 async def test_existing_node_ready(hass, client, multisensor_6, integration): @@ -221,50 +221,163 @@ async def test_existing_node_ready(hass, client, multisensor_6, integration): dev_reg = dr.async_get(hass) node = multisensor_6 air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" + air_temperature_device_id_ext = ( + f"{air_temperature_device_id}-{node.manufacturer_id}:" + f"{node.product_type}:{node.product_id}" + ) state = hass.states.get(AIR_TEMPERATURE_SENSOR) assert state # entity and device added assert state.state != STATE_UNAVAILABLE - assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + device = dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + assert device + assert device == dev_reg.async_get_device( + identifiers={(DOMAIN, air_temperature_device_id_ext)} + ) -async def test_null_name(hass, client, null_name_check, integration): - """Test that node without a name gets a generic node name.""" - node = null_name_check - assert hass.states.get(f"switch.node_{node.node_id}") +async def test_existing_node_not_ready(hass, zp3111_not_ready, client, integration): + """Test we handle a non-ready node that exists during integration setup.""" + dev_reg = dr.async_get(hass) + node = zp3111_not_ready + device_id = f"{client.driver.controller.home_id}-{node.node_id}" + + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device.name == f"Node {node.node_id}" + assert not device.manufacturer + assert not device.model + assert not device.sw_version + + # the only entity is the node status sensor + assert len(hass.states.async_all()) == 1 + + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + # no extended device identifier yet + assert len(device.identifiers) == 1 -async def test_existing_node_not_ready(hass, client, multisensor_6): - """Test we handle a non ready node that exists during integration setup.""" + +async def test_existing_node_not_replaced_when_not_ready( + hass, zp3111, zp3111_not_ready_state, zp3111_state, client, integration +): + """Test when a node added event with a non-ready node is received. + + The existing node should not be replaced, and no customization should be lost. + """ dev_reg = dr.async_get(hass) - node = multisensor_6 - node.data = deepcopy(node.data) # Copy to allow modification in tests. - node.data["ready"] = False - event = {"node": node} - air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) - entry.add_to_hass(hass) + er_reg = er.async_get(hass) + kitchen_area = ar.async_get(hass).async_create("Kitchen") - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + device_id = f"{client.driver.controller.home_id}-{zp3111.node_id}" + device_id_ext = ( + f"{device_id}-{zp3111.manufacturer_id}:" + f"{zp3111.product_type}:{zp3111.product_id}" + ) - state = hass.states.get(AIR_TEMPERATURE_SENSOR) + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device.name == "4-in-1 Sensor" + assert not device.name_by_user + assert device.manufacturer == "Vision Security" + assert device.model == "ZP3111-5" + assert device.sw_version == "5.1" + assert not device.area_id + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + + motion_entity = "binary_sensor.4_in_1_sensor_home_security_motion_detection" + state = hass.states.get(motion_entity) + assert state + assert state.name == "4-in-1 Sensor: Home Security - Motion detection" + + dev_reg.async_update_device( + device.id, name_by_user="Custom Device Name", area_id=kitchen_area.id + ) - assert not state # entity not yet added - assert dev_reg.async_get_device( # device should be added - identifiers={(DOMAIN, air_temperature_device_id)} + custom_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert custom_device + assert custom_device.name == "4-in-1 Sensor" + assert custom_device.name_by_user == "Custom Device Name" + assert custom_device.manufacturer == "Vision Security" + assert custom_device.model == "ZP3111-5" + assert device.sw_version == "5.1" + assert custom_device.area_id == kitchen_area.id + assert custom_device == dev_reg.async_get_device( + identifiers={(DOMAIN, device_id_ext)} ) - node.data["ready"] = True - node.emit("ready", event) + custom_entity = "binary_sensor.custom_motion_sensor" + er_reg.async_update_entity( + motion_entity, new_entity_id=custom_entity, name="Custom Entity Name" + ) await hass.async_block_till_done() + state = hass.states.get(custom_entity) + assert state + assert state.name == "Custom Entity Name" + assert not hass.states.get(motion_entity) - state = hass.states.get(AIR_TEMPERATURE_SENSOR) + event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(zp3111_not_ready_state), + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() - assert state # entity and device added + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device.id == custom_device.id + assert device.identifiers == custom_device.identifiers + assert device.name == f"Node {zp3111.node_id}" + assert device.name_by_user == "Custom Device Name" + assert not device.manufacturer + assert not device.model + assert not device.sw_version + assert device.area_id == kitchen_area.id + + state = hass.states.get(custom_entity) + assert state + assert state.name == "Custom Entity Name" + + event = Event( + type="ready", + data={ + "source": "node", + "event": "ready", + "nodeId": zp3111_state["nodeId"], + "nodeState": deepcopy(zp3111_state), + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device.id == custom_device.id + assert device.identifiers == custom_device.identifiers + assert device.name == "4-in-1 Sensor" + assert device.name_by_user == "Custom Device Name" + assert device.manufacturer == "Vision Security" + assert device.model == "ZP3111-5" + assert device.area_id == kitchen_area.id + assert device.sw_version == "5.1" + + state = hass.states.get(custom_entity) + assert state assert state.state != STATE_UNAVAILABLE - assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + assert state.name == "Custom Entity Name" + + +async def test_null_name(hass, client, null_name_check, integration): + """Test that node without a name gets a generic node name.""" + node = null_name_check + assert hass.states.get(f"switch.node_{node.node_id}") async def test_start_addon( @@ -740,63 +853,460 @@ async def test_node_removed(hass, multisensor_6_state, client, integration): assert not dev_reg.async_get(old_device.id) -async def test_replace_same_node(hass, multisensor_6_state, client, integration): +async def test_replace_same_node( + hass, multisensor_6, multisensor_6_state, client, integration +): """Test when a node is replaced with itself that the device remains.""" dev_reg = dr.async_get(hass) - node = Node(client, deepcopy(multisensor_6_state)) - device_id = f"{client.driver.controller.home_id}-{node.node_id}" - event = {"node": node} + node_id = multisensor_6.node_id + multisensor_6_state = deepcopy(multisensor_6_state) - client.driver.controller.emit("node added", event) + device_id = f"{client.driver.controller.home_id}-{node_id}" + multisensor_6_device_id = ( + f"{device_id}-{multisensor_6.manufacturer_id}:" + f"{multisensor_6.product_type}:{multisensor_6.product_id}" + ) + + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device == dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id)} + ) + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW100" + dev_id = device.id + + assert hass.states.get(AIR_TEMPERATURE_SENSOR) + + # A replace node event has the extra field "replaced" set to True + # to distinguish it from an exclusion + event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "replaced": True, + "node": multisensor_6_state, + }, + ) + client.driver.receive_event(event) await hass.async_block_till_done() - old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) - assert old_device.id - event = {"node": node, "replaced": True} + # Device should still be there after the node was removed + device = dev_reg.async_get(dev_id) + assert device - client.driver.controller.emit("node removed", event) + # When the node is replaced, a non-ready node added event is emitted + event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": { + "nodeId": node_id, + "index": 0, + "status": 4, + "ready": False, + "isSecure": "unknown", + "interviewAttempts": 1, + "endpoints": [{"nodeId": node_id, "index": 0, "deviceClass": None}], + "values": [], + "deviceClass": None, + "commandClasses": [], + "interviewStage": "None", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + }, + }, + }, + ) + + # Device is still not removed + client.driver.receive_event(event) await hass.async_block_till_done() - # Assert device has remained - assert dev_reg.async_get(old_device.id) - event = {"node": node} + device = dev_reg.async_get(dev_id) + assert device - client.driver.controller.emit("node added", event) + event = Event( + type="ready", + data={ + "source": "node", + "event": "ready", + "nodeId": node_id, + "nodeState": multisensor_6_state, + }, + ) + client.driver.receive_event(event) await hass.async_block_till_done() - # Assert device has remained - assert dev_reg.async_get(old_device.id) + + # Device is the same + device = dev_reg.async_get(dev_id) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device == dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id)} + ) + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW100" + + assert hass.states.get(AIR_TEMPERATURE_SENSOR) async def test_replace_different_node( - hass, multisensor_6_state, hank_binary_switch_state, client, integration + hass, + multisensor_6, + multisensor_6_state, + hank_binary_switch_state, + client, + integration, ): """Test when a node is replaced with a different node.""" - hank_binary_switch_state = deepcopy(hank_binary_switch_state) - multisensor_6_state = deepcopy(multisensor_6_state) - hank_binary_switch_state["nodeId"] = multisensor_6_state["nodeId"] dev_reg = dr.async_get(hass) - old_node = Node(client, multisensor_6_state) - device_id = f"{client.driver.controller.home_id}-{old_node.node_id}" - new_node = Node(client, hank_binary_switch_state) - event = {"node": old_node} + node_id = multisensor_6.node_id + hank_binary_switch_state = deepcopy(hank_binary_switch_state) + hank_binary_switch_state["nodeId"] = node_id + + device_id = f"{client.driver.controller.home_id}-{node_id}" + multisensor_6_device_id = ( + f"{device_id}-{multisensor_6.manufacturer_id}:" + f"{multisensor_6.product_type}:{multisensor_6.product_id}" + ) + hank_device_id = ( + f"{device_id}-{hank_binary_switch_state['manufacturerId']}:" + f"{hank_binary_switch_state['productType']}:" + f"{hank_binary_switch_state['productId']}" + ) - client.driver.controller.emit("node added", event) - await hass.async_block_till_done() device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) assert device + assert device == dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id)} + ) + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW100" + dev_id = device.id - event = {"node": old_node, "replaced": True} + assert hass.states.get(AIR_TEMPERATURE_SENSOR) - client.driver.controller.emit("node removed", event) + # A replace node event has the extra field "replaced" set to True + # to distinguish it from an exclusion + event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "replaced": True, + "node": multisensor_6_state, + }, + ) + client.driver.receive_event(event) await hass.async_block_till_done() + # Device should still be there after the node was removed + device = dev_reg.async_get(dev_id) assert device - event = {"node": new_node} + # When the node is replaced, a non-ready node added event is emitted + event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": { + "nodeId": multisensor_6.node_id, + "index": 0, + "status": 4, + "ready": False, + "isSecure": "unknown", + "interviewAttempts": 1, + "endpoints": [ + {"nodeId": multisensor_6.node_id, "index": 0, "deviceClass": None} + ], + "values": [], + "deviceClass": None, + "commandClasses": [], + "interviewStage": "None", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + }, + }, + }, + ) - client.driver.controller.emit("node added", event) + # Device is still not removed + client.driver.receive_event(event) await hass.async_block_till_done() - device = dev_reg.async_get(device.id) - # assert device is new + + device = dev_reg.async_get(dev_id) assert device + + event = Event( + type="ready", + data={ + "source": "node", + "event": "ready", + "nodeId": node_id, + "nodeState": hank_binary_switch_state, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + # Old device and entities were removed, but the ID is re-used + device = dev_reg.async_get(dev_id) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id)}) + assert not dev_reg.async_get_device(identifiers={(DOMAIN, multisensor_6_device_id)}) assert device.manufacturer == "HANK Electronics Ltd." + assert device.model == "HKZW-SO01" + + assert not hass.states.get(AIR_TEMPERATURE_SENSOR) + assert hass.states.get("switch.smart_plug_with_two_usb_ports") + + +async def test_node_model_change(hass, zp3111, client, integration): + """Test when a node's model is changed due to an updated device config file. + + The device and entities should not be removed. + """ + dev_reg = dr.async_get(hass) + er_reg = er.async_get(hass) + + device_id = f"{client.driver.controller.home_id}-{zp3111.node_id}" + device_id_ext = ( + f"{device_id}-{zp3111.manufacturer_id}:" + f"{zp3111.product_type}:{zp3111.product_id}" + ) + + # Verify device and entities have default names/ids + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device.manufacturer == "Vision Security" + assert device.model == "ZP3111-5" + assert device.name == "4-in-1 Sensor" + assert not device.name_by_user + + dev_id = device.id + + motion_entity = "binary_sensor.4_in_1_sensor_home_security_motion_detection" + state = hass.states.get(motion_entity) + assert state + assert state.name == "4-in-1 Sensor: Home Security - Motion detection" + + # Customize device and entity names/ids + dev_reg.async_update_device(device.id, name_by_user="Custom Device Name") + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device.id == dev_id + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device.manufacturer == "Vision Security" + assert device.model == "ZP3111-5" + assert device.name == "4-in-1 Sensor" + assert device.name_by_user == "Custom Device Name" + + custom_entity = "binary_sensor.custom_motion_sensor" + er_reg.async_update_entity( + motion_entity, new_entity_id=custom_entity, name="Custom Entity Name" + ) + await hass.async_block_till_done() + assert not hass.states.get(motion_entity) + state = hass.states.get(custom_entity) + assert state + assert state.name == "Custom Entity Name" + + # Unload the integration + assert await hass.config_entries.async_unload(integration.entry_id) + await hass.async_block_till_done() + assert integration.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + # Simulate changes to the node labels + zp3111.device_config.data["description"] = "New Device Name" + zp3111.device_config.data["label"] = "New Device Model" + zp3111.device_config.data["manufacturer"] = "New Device Manufacturer" + + # Reload integration, it will re-add the nodes + integration.add_to_hass(hass) + await hass.config_entries.async_setup(integration.entry_id) + await hass.async_block_till_done() + + # Device name changes, but the customization is the same + device = dev_reg.async_get(dev_id) + assert device + assert device.id == dev_id + assert device.manufacturer == "New Device Manufacturer" + assert device.model == "New Device Model" + assert device.name == "New Device Name" + assert device.name_by_user == "Custom Device Name" + + assert not hass.states.get(motion_entity) + state = hass.states.get(custom_entity) + assert state + assert state.name == "Custom Entity Name" + + +async def test_disabled_node_status_entity_on_node_replaced( + hass, zp3111_state, zp3111, client, integration +): + """Test that when a node replacement event is received the node status sensor is removed.""" + node_status_entity = "sensor.4_in_1_sensor_node_status" + state = hass.states.get(node_status_entity) + assert state + assert state.state != STATE_UNAVAILABLE + + event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "replaced": True, + "node": zp3111_state, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(node_status_entity) + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_disabled_entity_on_value_removed(hass, zp3111, client, integration): + """Test that when entity primary values are removed the entity is removed.""" + er_reg = er.async_get(hass) + + # re-enable this default-disabled entity + sensor_cover_entity = "sensor.4_in_1_sensor_home_security_cover_status" + er_reg.async_update_entity(entity_id=sensor_cover_entity, disabled_by=None) + await hass.async_block_till_done() + + # must reload the integration when enabling an entity + await hass.config_entries.async_unload(integration.entry_id) + await hass.async_block_till_done() + assert integration.state is ConfigEntryState.NOT_LOADED + integration.add_to_hass(hass) + await hass.config_entries.async_setup(integration.entry_id) + await hass.async_block_till_done() + assert integration.state is ConfigEntryState.LOADED + + state = hass.states.get(sensor_cover_entity) + assert state + assert state.state != STATE_UNAVAILABLE + + # check for expected entities + binary_cover_entity = ( + "binary_sensor.4_in_1_sensor_home_security_tampering_product_cover_removed" + ) + state = hass.states.get(binary_cover_entity) + assert state + assert state.state != STATE_UNAVAILABLE + + battery_level_entity = "sensor.4_in_1_sensor_battery_level" + state = hass.states.get(battery_level_entity) + assert state + assert state.state != STATE_UNAVAILABLE + + unavailable_entities = { + state.entity_id + for state in hass.states.async_all() + if state.state == STATE_UNAVAILABLE + } + + # This value ID removal does not remove any entity + event = Event( + type="value removed", + data={ + "source": "node", + "event": "value removed", + "nodeId": zp3111.node_id, + "args": { + "commandClassName": "Wake Up", + "commandClass": 132, + "endpoint": 0, + "property": "wakeUpInterval", + "prevValue": 3600, + "propertyName": "wakeUpInterval", + }, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + assert all(state != STATE_UNAVAILABLE for state in hass.states.async_all()) + + # This value ID removal only affects the battery level entity + 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 + + # This value ID removal affects its multiple notification sensors + event = Event( + type="value removed", + data={ + "source": "node", + "event": "value removed", + "nodeId": zp3111.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Home Security", + "propertyKey": "Cover status", + "prevValue": 0, + "propertyName": "Home Security", + "propertyKeyName": "Cover status", + }, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(binary_cover_entity) + assert state + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get(sensor_cover_entity) + assert state + assert state.state == STATE_UNAVAILABLE + + # existing entities and the entities with removed values should be unavailable + new_unavailable_entities = { + state.entity_id + for state in hass.states.async_all() + if state.state == STATE_UNAVAILABLE + } + assert ( + unavailable_entities + | {battery_level_entity, binary_cover_entity, sensor_cover_entity} + == new_unavailable_entities + )