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
parent
38723b277e
commit
22e475790f
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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]:
|
||||
"""
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue