Avoid removing zwave_js devices for non-ready nodes (#59964)

* Only replace a node if the mfgr id / prod id / prod type differ

* Prefer original device name for unready node

* move register_node_in_dev_reg into async_setup_entry

* simplify get_device_id_ext

* Don't need hex ids

* Revert "move register_node_in_dev_reg into async_setup_entry"

This reverts commit f900e5fb0c67cc81657a1452b51c313bccb6f9e1.

* Revert Callable change

* Revert device backup name

* Add test fixtures

* Update existing not ready test with new fixture data

* Check device properties after node added event

* Add entity check

* Check for extended device id

* better device info checks

* Use receive_event to properly setup components

* Cleanup tests

* improve test_replace_different_node

* improve test_replace_same_node

* add test test_node_model_change

* Clean up long comments and strings

* Format

* Reload integration to detect node device config changes

* update assertions

* Disable entities on "value removed" event

* Disable node status sensor on node replacement

* Add test for disabling entities on remove value event

* Add test for disabling node status sensor on node replacement

* disable entity -> remove entity

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/62847/head
kpine 2021-12-27 03:31:31 -08:00 committed by GitHub
parent 38723b277e
commit 22e475790f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 1480 additions and 102 deletions

View File

@ -88,7 +88,12 @@ from .discovery import (
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 @@ async def async_setup_entry( # noqa: C901
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

View File

@ -20,6 +20,7 @@ from .migrate import async_add_migration_entity_value
LOGGER = logging.getLogger(__name__)
EVENT_VALUE_UPDATED = "value updated"
EVENT_VALUE_REMOVED = "value removed"
EVENT_DEAD = "dead"
EVENT_ALIVE = "alive"
@ -99,6 +100,10 @@ class ZWaveBaseEntity(Entity):
self.async_on_remove(
self.info.node.on(EVENT_VALUE_UPDATED, self._value_changed)
)
self.async_on_remove(
self.info.node.on(EVENT_VALUE_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 @@ class ZWaveBaseEntity(Entity):
@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 @@ class ZWaveBaseEntity(Entity):
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,

View File

@ -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]:
"""

View File

@ -502,4 +502,11 @@ class ZWaveNodeStatusSensor(SensorEntity):
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()

View File

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

View File

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

View File

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

View File

@ -12,7 +12,11 @@ from homeassistant.components.zwave_js.const import DOMAIN
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)
# the only entity is the node status sensor
assert len(hass.states.async_all()) == 1
assert not state # entity not yet added but device added in registry
assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)})
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,12 +221,157 @@ 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_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_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)
er_reg = er.async_get(hass)
kitchen_area = ar.async_get(hass).async_create("Kitchen")
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}"
)
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
)
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)}
)
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)
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()
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 state.name == "Custom Entity Name"
async def test_null_name(hass, client, null_name_check, integration):
@ -235,38 +380,6 @@ async def test_null_name(hass, client, null_name_check, integration):
assert hass.states.get(f"switch.node_{node.node_id}")
async def test_existing_node_not_ready(hass, client, multisensor_6):
"""Test we handle a non ready node that exists during integration setup."""
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)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get(AIR_TEMPERATURE_SENSOR)
assert not state # entity not yet added
assert dev_reg.async_get_device( # device should be added
identifiers={(DOMAIN, air_temperature_device_id)}
)
node.data["ready"] = True
node.emit("ready", event)
await hass.async_block_till_done()
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)})
async def test_start_addon(
hass, addon_installed, install_addon, addon_options, set_addon_options, 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
)