"""Test the Z-Wave JS init module.""" import asyncio from collections.abc import Generator from copy import deepcopy import logging from typing import Any from unittest.mock import AsyncMock, MagicMock, call, patch from aiohasupervisor import SupervisorError from aiohasupervisor.models import AddonsOptions import pytest from zwave_js_server.client import Client from zwave_js_server.const import SecurityClass from zwave_js_server.event import Event from zwave_js_server.exceptions import ( BaseZwaveJSServerError, InvalidServerVersion, NotConnected, ) from zwave_js_server.model.controller import ProvisioningEntry from zwave_js_server.model.node import Node, NodeDataType from zwave_js_server.model.version import VersionInfo from homeassistant.components.hassio import HassioAPIError from homeassistant.components.persistent_notification import async_dismiss from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id, get_device_id_ext from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import ( area_registry as ar, device_registry as dr, entity_registry as er, issue_registry as ir, ) from homeassistant.setup import async_setup_component from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY from tests.common import ( MockConfigEntry, async_call_logger_set_level, async_fire_time_changed, async_get_persistent_notifications, ) from tests.typing import WebSocketGenerator CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller" @pytest.fixture(name="connect_timeout") def connect_timeout_fixture() -> Generator[int]: """Mock the connect timeout.""" with patch("homeassistant.components.zwave_js.CONNECT_TIMEOUT", new=0) as timeout: yield timeout async def test_entry_setup_unload( hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry, ) -> None: """Test the integration set up and unload.""" entry = integration assert client.connect.call_count == 1 assert entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(entry.entry_id) assert client.disconnect.call_count == 1 assert entry.state is ConfigEntryState.NOT_LOADED @pytest.mark.usefixtures("integration") async def test_home_assistant_stop( hass: HomeAssistant, client: MagicMock, ) -> None: """Test we clean up on home assistant stop.""" await hass.async_stop() assert client.disconnect.call_count == 1 @pytest.mark.usefixtures("client", "connect_timeout") async def test_initialized_timeout(hass: HomeAssistant) -> None: """Test we handle a timeout during client initialization.""" 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() assert entry.state is ConfigEntryState.SETUP_RETRY @pytest.mark.usefixtures("client") async def test_enabled_statistics(hass: HomeAssistant) -> None: """Test that we enabled statistics if the entry is opted in.""" entry = MockConfigEntry( domain="zwave_js", data={"url": "ws://test.org", "data_collection_opted_in": True}, ) entry.add_to_hass(hass) with patch( "zwave_js_server.model.driver.Driver.async_enable_statistics" ) as mock_cmd: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert mock_cmd.called @pytest.mark.usefixtures("client") async def test_disabled_statistics(hass: HomeAssistant) -> None: """Test that we disabled statistics if the entry is opted out.""" entry = MockConfigEntry( domain="zwave_js", data={"url": "ws://test.org", "data_collection_opted_in": False}, ) entry.add_to_hass(hass) with patch( "zwave_js_server.model.driver.Driver.async_disable_statistics" ) as mock_cmd: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert mock_cmd.called @pytest.mark.usefixtures("client") async def test_noop_statistics(hass: HomeAssistant) -> None: """Test that we don't make statistics calls if user hasn't set preference.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) with ( patch( "zwave_js_server.model.driver.Driver.async_enable_statistics" ) as mock_cmd1, patch( "zwave_js_server.model.driver.Driver.async_disable_statistics" ) as mock_cmd2, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert not mock_cmd1.called assert not mock_cmd2.called async def test_driver_ready_timeout_during_setup( hass: HomeAssistant, client: MagicMock, listen_block: asyncio.Event, ) -> None: """Test we handle driver ready timeout during setup.""" async def listen(driver_ready: asyncio.Event) -> None: """Mock listen.""" await listen_block.wait() client.listen.side_effect = listen entry = MockConfigEntry( domain="zwave_js", data={"url": "ws://test.org", "data_collection_opted_in": True}, ) entry.add_to_hass(hass) assert client.disconnect.call_count == 0 with patch("homeassistant.components.zwave_js.DRIVER_READY_TIMEOUT", new=0): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY assert client.disconnect.call_count == 1 @pytest.mark.parametrize("core_state", [CoreState.running, CoreState.stopping]) @pytest.mark.parametrize( ("listen_future_result_method", "listen_future_result"), [ ("set_exception", BaseZwaveJSServerError("Boom")), ("set_exception", Exception("Boom")), ("set_result", None), ], ) async def test_listen_done_during_setup_before_forward_entry( hass: HomeAssistant, client: MagicMock, listen_block: asyncio.Event, listen_result: asyncio.Future[None], core_state: CoreState, listen_future_result_method: str, listen_future_result: Exception | None, ) -> None: """Test listen task finishing during setup before forward entry.""" assert hass.state is CoreState.running async def listen(driver_ready: asyncio.Event) -> None: await listen_block.wait() await listen_result async_fire_time_changed(hass, fire_all=True) client.listen.side_effect = listen hass.set_state(core_state) listen_block.set() getattr(listen_result, listen_future_result_method)(listen_future_result) entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) assert client.disconnect.call_count == 0 await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY assert client.disconnect.call_count == 1 async def test_not_connected_during_setup_after_forward_entry( hass: HomeAssistant, client: MagicMock, listen_block: asyncio.Event, listen_result: asyncio.Future[None], ) -> None: """Test we handle not connected client during setup after forward entry.""" async def send_command_side_effect(*args: Any, **kwargs: Any) -> None: """Mock send command.""" listen_block.set() listen_result.set_result(None) # Yield to allow the listen task to run await asyncio.sleep(0) raise NotConnected("Boom") async def listen(driver_ready: asyncio.Event) -> None: """Mock listen.""" driver_ready.set() client.async_send_command.side_effect = send_command_side_effect await listen_block.wait() await listen_result client.listen.side_effect = listen entry = MockConfigEntry( domain="zwave_js", data={"url": "ws://test.org", "data_collection_opted_in": True}, ) entry.add_to_hass(hass) assert client.disconnect.call_count == 0 await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY assert client.disconnect.call_count == 1 @pytest.mark.parametrize("core_state", [CoreState.running, CoreState.stopping]) @pytest.mark.parametrize( ("listen_future_result_method", "listen_future_result"), [ ("set_exception", BaseZwaveJSServerError("Boom")), ("set_exception", Exception("Boom")), ("set_result", None), ], ) async def test_listen_done_during_setup_after_forward_entry( hass: HomeAssistant, client: MagicMock, listen_block: asyncio.Event, listen_result: asyncio.Future[None], core_state: CoreState, listen_future_result_method: str, listen_future_result: Exception | None, ) -> None: """Test listen task finishing during setup after forward entry.""" assert hass.state is CoreState.running original_send_command_side_effect = client.async_send_command.side_effect async def send_command_side_effect(*args: Any, **kwargs: Any) -> None: """Mock send command.""" listen_block.set() getattr(listen_result, listen_future_result_method)(listen_future_result) client.async_send_command.side_effect = original_send_command_side_effect # Yield to allow the listen task to run await asyncio.sleep(0) async def listen(driver_ready: asyncio.Event) -> None: """Mock listen.""" driver_ready.set() client.async_send_command.side_effect = send_command_side_effect await listen_block.wait() await listen_result client.listen.side_effect = listen hass.set_state(core_state) entry = MockConfigEntry( domain="zwave_js", data={"url": "ws://test.org", "data_collection_opted_in": True}, ) entry.add_to_hass(hass) assert client.disconnect.call_count == 0 await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY assert client.disconnect.call_count == 1 @pytest.mark.parametrize( ("core_state", "final_config_entry_state", "disconnect_call_count"), [ ( CoreState.running, ConfigEntryState.SETUP_RETRY, 2, ), # the reload will cause a disconnect call too ( CoreState.stopping, ConfigEntryState.LOADED, 0, ), # the home assistant stop event will handle the disconnect ], ) @pytest.mark.parametrize( ("listen_future_result_method", "listen_future_result"), [ ("set_exception", BaseZwaveJSServerError("Boom")), ("set_exception", Exception("Boom")), ("set_result", None), ], ) async def test_listen_done_after_setup( hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry, listen_block: asyncio.Event, listen_result: asyncio.Future[None], core_state: CoreState, listen_future_result_method: str, listen_future_result: Exception | None, final_config_entry_state: ConfigEntryState, disconnect_call_count: int, ) -> None: """Test listen task finishing after setup.""" config_entry = integration assert config_entry.state is ConfigEntryState.LOADED assert hass.state is CoreState.running assert client.disconnect.call_count == 0 hass.set_state(core_state) listen_block.set() getattr(listen_result, listen_future_result_method)(listen_future_result) await hass.async_block_till_done() assert config_entry.state is final_config_entry_state assert client.disconnect.call_count == disconnect_call_count @pytest.mark.usefixtures("client") async def test_new_entity_on_value_added( hass: HomeAssistant, multisensor_6: Node, integration: MockConfigEntry, ) -> None: """Test we create a new entity if a value is added after the fact.""" node: Node = multisensor_6 # Add a value on a random endpoint so we can be sure we should get a new entity event = Event( type="value added", data={ "source": "node", "event": "value added", "nodeId": node.node_id, "args": { "commandClassName": "Multilevel Sensor", "commandClass": 49, "endpoint": 10, "property": "Ultraviolet", "propertyName": "Ultraviolet", "metadata": { "type": "number", "readable": True, "writeable": False, "label": "Ultraviolet", "ccSpecific": {"sensorType": 27, "scale": 0}, }, "value": 0, }, }, ) node.receive_event(event) await hass.async_block_till_done() assert hass.states.get("sensor.multisensor_6_ultraviolet_10") is not None @pytest.mark.usefixtures("integration") async def test_on_node_added_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, multisensor_6_state: NodeDataType, client: MagicMock, ) -> None: """Test we handle a node added event with a ready node.""" node = Node(client, deepcopy(multisensor_6_state)) event = {"node": node} air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" state = hass.states.get(AIR_TEMPERATURE_SENSOR) assert not state # entity and device not yet added assert not device_registry.async_get_device( identifiers={(DOMAIN, air_temperature_device_id)} ) client.driver.controller.emit("node added", 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 device_registry.async_get_device( identifiers={(DOMAIN, air_temperature_device_id)} ) async def test_on_node_added_preprovisioned( hass: HomeAssistant, device_registry: dr.DeviceRegistry, multisensor_6_state, client, integration, ) -> None: """Test node added event with a preprovisioned device.""" dsk = "test" node = Node(client, deepcopy(multisensor_6_state)) device = device_registry.async_get_or_create( config_entry_id=integration.entry_id, identifiers={(DOMAIN, f"provision_{dsk}")}, ) provisioning_entry = ProvisioningEntry.from_dict( { "dsk": dsk, "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], "device_id": device.id, } ) with patch( f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entry", side_effect=lambda id: provisioning_entry if id == node.node_id else None, ): event = {"node": node} client.driver.controller.emit("node added", event) await hass.async_block_till_done() device = device_registry.async_get(device.id) assert device assert device.identifiers == { get_device_id(client.driver, node), get_device_id_ext(client.driver, node), } assert device.sw_version == node.firmware_version # There should only be the controller and the preprovisioned device assert len(device_registry.devices) == 2 @pytest.mark.usefixtures("integration") async def test_on_node_added_not_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, zp3111_not_ready_state: NodeDataType, client: MagicMock, ) -> None: """Test we handle a node added event with a non-ready node.""" device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}" assert len(hass.states.async_all()) == 1 assert len(device_registry.devices) == 1 node_state = deepcopy(zp3111_not_ready_state) node_state["isSecure"] = False event = Event( type="node added", data={ "source": "controller", "event": "node added", "node": node_state, "result": {}, }, ) client.driver.receive_event(event) await hass.async_block_till_done() device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device # no extended device identifier yet assert len(device.identifiers) == 1 entities = er.async_entries_for_device(entity_registry, device.id) # the only entities are the node status sensor, last_seen sensor, and ping button assert len(entities) == 3 async def test_existing_node_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client: MagicMock, multisensor_6: Node, integration: MockConfigEntry, ) -> None: """Test we handle a ready node that exists during integration setup.""" 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 device = device_registry.async_get_device( identifiers={(DOMAIN, air_temperature_device_id)} ) assert device assert device == device_registry.async_get_device( identifiers={(DOMAIN, air_temperature_device_id_ext)} ) async def test_existing_node_reinterview( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client: Client, multisensor_6_state: NodeDataType, multisensor_6: Node, integration: MockConfigEntry, ) -> None: """Test we handle a node re-interview firing a node ready event.""" node = multisensor_6 assert client.driver is not None 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 device = device_registry.async_get_device( identifiers={(DOMAIN, air_temperature_device_id)} ) assert device assert device == device_registry.async_get_device( identifiers={(DOMAIN, air_temperature_device_id_ext)} ) assert device.sw_version == "1.12" node_state = deepcopy(multisensor_6_state) node_state["firmwareVersion"] = "1.13" event = Event( type="ready", data={ "source": "node", "event": "ready", "nodeId": node.node_id, "nodeState": node_state, }, ) client.driver.receive_event(event) await hass.async_block_till_done() state = hass.states.get(AIR_TEMPERATURE_SENSOR) assert state assert state.state != STATE_UNAVAILABLE device = device_registry.async_get_device( identifiers={(DOMAIN, air_temperature_device_id)} ) assert device assert device == device_registry.async_get_device( identifiers={(DOMAIN, air_temperature_device_id_ext)} ) assert device.sw_version == "1.13" async def test_existing_node_not_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, client: MagicMock, zp3111_not_ready: Node, integration: MockConfigEntry, ) -> None: """Test we handle a non-ready node that exists during integration setup.""" node = zp3111_not_ready device_id = f"{client.driver.controller.home_id}-{node.node_id}" device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device assert device.name == f"Node {node.node_id}" assert not device.manufacturer assert not device.model assert not device.sw_version device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device # no extended device identifier yet assert len(device.identifiers) == 1 entities = er.async_entries_for_device(entity_registry, device.id) # the only entities are the node status sensor, last_seen sensor, and ping button assert len(entities) == 3 async def test_existing_node_not_replaced_when_not_ready( hass: HomeAssistant, area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, client: MagicMock, zp3111: Node, zp3111_not_ready_state: NodeDataType, zp3111_state: NodeDataType, integration: MockConfigEntry, ) -> None: """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. """ kitchen_area = area_registry.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 = device_registry.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 == device_registry.async_get_device( identifiers={(DOMAIN, device_id_ext)} ) motion_entity = "binary_sensor.4_in_1_sensor_motion_detection" state = hass.states.get(motion_entity) assert state assert state.name == "4-in-1 Sensor Motion detection" device_registry.async_update_device( device.id, name_by_user="Custom Device Name", area_id=kitchen_area.id ) custom_device = device_registry.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 == device_registry.async_get_device( identifiers={(DOMAIN, device_id_ext)} ) custom_entity = "binary_sensor.custom_motion_sensor" entity_registry.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) node_state = deepcopy(zp3111_not_ready_state) node_state["isSecure"] = False event = Event( type="node added", data={ "source": "controller", "event": "node added", "node": node_state, "result": {}, }, ) client.driver.receive_event(event) await hass.async_block_till_done() device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device assert device == device_registry.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 = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device assert device == device_registry.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" @pytest.mark.usefixtures("client") async def test_null_name( hass: HomeAssistant, null_name_check: Node, integration: MockConfigEntry, ) -> None: """Test that node without a name gets a generic node name.""" node = null_name_check assert hass.states.get(f"switch.node_{node.node_id}") @pytest.mark.usefixtures("addon_installed", "addon_info") async def test_start_addon( hass: HomeAssistant, install_addon: AsyncMock, set_addon_options: AsyncMock, start_addon: AsyncMock, ) -> None: """Test start the Z-Wave JS add-on during entry setup.""" device = "/test" s0_legacy_key = "s0_legacy" s2_access_control_key = "s2_access_control" s2_authenticated_key = "s2_authenticated" s2_unauthenticated_key = "s2_unauthenticated" lr_s2_access_control_key = "lr_s2_access_control" lr_s2_authenticated_key = "lr_s2_authenticated" addon_options = { "device": device, "s0_legacy_key": s0_legacy_key, "s2_access_control_key": s2_access_control_key, "s2_authenticated_key": s2_authenticated_key, "s2_unauthenticated_key": s2_unauthenticated_key, "lr_s2_access_control_key": lr_s2_access_control_key, "lr_s2_authenticated_key": lr_s2_authenticated_key, } entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", data={ "use_addon": True, "usb_path": device, "s0_legacy_key": s0_legacy_key, "s2_access_control_key": s2_access_control_key, "s2_authenticated_key": s2_authenticated_key, "s2_unauthenticated_key": s2_unauthenticated_key, "lr_s2_access_control_key": lr_s2_access_control_key, "lr_s2_authenticated_key": lr_s2_authenticated_key, }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY assert install_addon.call_count == 0 assert set_addon_options.call_count == 1 assert set_addon_options.call_args == call( "core_zwave_js", AddonsOptions(config=addon_options) ) assert start_addon.call_count == 1 assert start_addon.call_args == call("core_zwave_js") @pytest.mark.usefixtures("addon_not_installed", "addon_info") async def test_install_addon( hass: HomeAssistant, install_addon: AsyncMock, set_addon_options: AsyncMock, start_addon: AsyncMock, ) -> None: """Test install and start the Z-Wave JS add-on during entry setup.""" device = "/test" s0_legacy_key = "s0_legacy" s2_access_control_key = "s2_access_control" s2_authenticated_key = "s2_authenticated" s2_unauthenticated_key = "s2_unauthenticated" addon_options = { "device": device, "s0_legacy_key": s0_legacy_key, "s2_access_control_key": s2_access_control_key, "s2_authenticated_key": s2_authenticated_key, "s2_unauthenticated_key": s2_unauthenticated_key, } entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", data={ "use_addon": True, "usb_path": device, "s0_legacy_key": s0_legacy_key, "s2_access_control_key": s2_access_control_key, "s2_authenticated_key": s2_authenticated_key, "s2_unauthenticated_key": s2_unauthenticated_key, }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY assert install_addon.call_count == 1 assert install_addon.call_args == call("core_zwave_js") assert set_addon_options.call_count == 1 assert set_addon_options.call_args == call( "core_zwave_js", AddonsOptions(config=addon_options) ) assert start_addon.call_count == 1 assert start_addon.call_args == call("core_zwave_js") @pytest.mark.usefixtures("addon_installed", "addon_info", "set_addon_options") @pytest.mark.parametrize("addon_info_side_effect", [SupervisorError("Boom")]) async def test_addon_info_failure( hass: HomeAssistant, install_addon: AsyncMock, start_addon: AsyncMock, ) -> None: """Test failure to get add-on info for Z-Wave JS add-on during entry setup.""" device = "/test" network_key = "abc123" entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", data={"use_addon": True, "usb_path": device, "network_key": network_key}, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY assert install_addon.call_count == 0 assert start_addon.call_count == 0 @pytest.mark.usefixtures("addon_running", "addon_info", "client") @pytest.mark.parametrize( ( "old_device", "new_device", "old_s0_legacy_key", "new_s0_legacy_key", "old_s2_access_control_key", "new_s2_access_control_key", "old_s2_authenticated_key", "new_s2_authenticated_key", "old_s2_unauthenticated_key", "new_s2_unauthenticated_key", "old_lr_s2_access_control_key", "new_lr_s2_access_control_key", "old_lr_s2_authenticated_key", "new_lr_s2_authenticated_key", ), [ ( "/old_test", "/new_test", "old123", "new123", "old456", "new456", "old789", "new789", "old987", "new987", "old654", "new654", "old321", "new321", ) ], ) async def test_addon_options_changed( hass: HomeAssistant, install_addon: AsyncMock, addon_options: dict[str, Any], start_addon: AsyncMock, old_device: str, new_device: str, old_s0_legacy_key: str, new_s0_legacy_key: str, old_s2_access_control_key: str, new_s2_access_control_key: str, old_s2_authenticated_key: str, new_s2_authenticated_key: str, old_s2_unauthenticated_key: str, new_s2_unauthenticated_key: str, old_lr_s2_access_control_key: str, new_lr_s2_access_control_key: str, old_lr_s2_authenticated_key: str, new_lr_s2_authenticated_key: str, ) -> None: """Test update config entry data on entry setup if add-on options changed.""" addon_options["device"] = new_device addon_options["s0_legacy_key"] = new_s0_legacy_key addon_options["s2_access_control_key"] = new_s2_access_control_key addon_options["s2_authenticated_key"] = new_s2_authenticated_key addon_options["s2_unauthenticated_key"] = new_s2_unauthenticated_key addon_options["lr_s2_access_control_key"] = new_lr_s2_access_control_key addon_options["lr_s2_authenticated_key"] = new_lr_s2_authenticated_key entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", data={ "url": "ws://host1:3001", "use_addon": True, "usb_path": old_device, "s0_legacy_key": old_s0_legacy_key, "s2_access_control_key": old_s2_access_control_key, "s2_authenticated_key": old_s2_authenticated_key, "s2_unauthenticated_key": old_s2_unauthenticated_key, "lr_s2_access_control_key": old_lr_s2_access_control_key, "lr_s2_authenticated_key": old_lr_s2_authenticated_key, }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED assert entry.data["usb_path"] == new_device assert entry.data["s0_legacy_key"] == new_s0_legacy_key assert entry.data["s2_access_control_key"] == new_s2_access_control_key assert entry.data["s2_authenticated_key"] == new_s2_authenticated_key assert entry.data["s2_unauthenticated_key"] == new_s2_unauthenticated_key assert entry.data["lr_s2_access_control_key"] == new_lr_s2_access_control_key assert entry.data["lr_s2_authenticated_key"] == new_lr_s2_authenticated_key assert install_addon.call_count == 0 assert start_addon.call_count == 0 @pytest.mark.usefixtures("addon_running") @pytest.mark.parametrize( ( "addon_version", "update_available", "update_calls", "backup_calls", "update_addon_side_effect", "create_backup_side_effect", ), [ ("1.0.0", True, 1, 1, None, None), ("1.0.0", False, 0, 0, None, None), ("1.0.0", True, 1, 1, SupervisorError("Boom"), None), ("1.0.0", True, 0, 1, None, HassioAPIError("Boom")), ], ) async def test_update_addon( hass: HomeAssistant, client: MagicMock, addon_info: AsyncMock, create_backup: AsyncMock, update_addon: AsyncMock, addon_options: dict[str, Any], addon_version: str, update_available: bool, update_calls: int, backup_calls: int, update_addon_side_effect: Exception | None, create_backup_side_effect: Exception | None, ) -> None: """Test update the Z-Wave JS add-on during entry setup.""" device = "/test" network_key = "abc123" addon_options["device"] = device addon_options["network_key"] = network_key addon_info.return_value.version = addon_version addon_info.return_value.update_available = update_available create_backup.side_effect = create_backup_side_effect update_addon.side_effect = update_addon_side_effect client.connect.side_effect = InvalidServerVersion( VersionInfo("a", "b", 1, 1, 1), 1, "Invalid version" ) entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", data={ "url": "ws://host1:3001", "use_addon": True, "usb_path": device, "network_key": network_key, }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY assert create_backup.call_count == backup_calls assert update_addon.call_count == update_calls async def test_issue_registry( hass: HomeAssistant, client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test issue registry.""" device = "/test" network_key = "abc123" client.connect.side_effect = InvalidServerVersion( VersionInfo("a", "b", 1, 1, 1), 1, "Invalid version" ) entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", data={ "url": "ws://host1:3001", "use_addon": False, "usb_path": device, "network_key": network_key, }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY assert issue_registry.async_get_issue(DOMAIN, "invalid_server_version") async def connect(): await asyncio.sleep(0) client.connected = True client.connect = AsyncMock(side_effect=connect) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED assert not issue_registry.async_get_issue(DOMAIN, "invalid_server_version") @pytest.mark.usefixtures("addon_running", "client") @pytest.mark.parametrize( ("stop_addon_side_effect", "entry_state"), [ (None, ConfigEntryState.NOT_LOADED), (SupervisorError("Boom"), ConfigEntryState.FAILED_UNLOAD), ], ) async def test_stop_addon( hass: HomeAssistant, addon_options: dict[str, Any], stop_addon: AsyncMock, stop_addon_side_effect: Exception | None, entry_state: ConfigEntryState, ) -> None: """Test stop the Z-Wave JS add-on on entry unload if entry is disabled.""" stop_addon.side_effect = stop_addon_side_effect device = "/test" network_key = "abc123" addon_options["device"] = device addon_options["network_key"] = network_key entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", data={ "url": "ws://host1:3001", "use_addon": True, "usb_path": device, "network_key": network_key, }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED await hass.config_entries.async_set_disabled_by( entry.entry_id, ConfigEntryDisabler.USER ) await hass.async_block_till_done() assert entry.state == entry_state assert stop_addon.call_count == 1 assert stop_addon.call_args == call("core_zwave_js") @pytest.mark.usefixtures("addon_installed") async def test_remove_entry( hass: HomeAssistant, stop_addon: AsyncMock, create_backup: AsyncMock, uninstall_addon: AsyncMock, caplog: pytest.LogCaptureFixture, ) -> None: """Test remove the config entry.""" # test successful remove without created add-on entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", data={"integration_created_addon": False}, ) entry.add_to_hass(hass) assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 1 await hass.config_entries.async_remove(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 # test successful remove with created add-on entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", data={"integration_created_addon": True}, ) entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 assert stop_addon.call_args == call("core_zwave_js") assert create_backup.call_count == 1 assert create_backup.call_args == call( hass, {"name": "addon_core_zwave_js_1.0.0", "addons": ["core_zwave_js"]}, partial=True, ) assert uninstall_addon.call_count == 1 assert uninstall_addon.call_args == call("core_zwave_js") assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 stop_addon.reset_mock() create_backup.reset_mock() uninstall_addon.reset_mock() # test add-on stop failure entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 stop_addon.side_effect = SupervisorError() await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 assert stop_addon.call_args == call("core_zwave_js") assert create_backup.call_count == 0 assert uninstall_addon.call_count == 0 assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 assert "Failed to stop the Z-Wave JS add-on" in caplog.text stop_addon.side_effect = None stop_addon.reset_mock() create_backup.reset_mock() uninstall_addon.reset_mock() # test create backup failure entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 create_backup.side_effect = HassioAPIError() await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 assert stop_addon.call_args == call("core_zwave_js") assert create_backup.call_count == 1 assert create_backup.call_args == call( hass, {"name": "addon_core_zwave_js_1.0.0", "addons": ["core_zwave_js"]}, partial=True, ) assert uninstall_addon.call_count == 0 assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 assert "Failed to create a backup of the Z-Wave JS add-on" in caplog.text create_backup.side_effect = None stop_addon.reset_mock() create_backup.reset_mock() uninstall_addon.reset_mock() # test add-on uninstall failure entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 uninstall_addon.side_effect = SupervisorError() await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 assert stop_addon.call_args == call("core_zwave_js") assert create_backup.call_count == 1 assert create_backup.call_args == call( hass, {"name": "addon_core_zwave_js_1.0.0", "addons": ["core_zwave_js"]}, partial=True, ) assert uninstall_addon.call_count == 1 assert uninstall_addon.call_args == call("core_zwave_js") assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 assert "Failed to uninstall the Z-Wave JS add-on" in caplog.text @pytest.mark.usefixtures("climate_radio_thermostat_ct100_plus", "lock_schlage_be469") async def test_removed_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client: MagicMock, integration: MockConfigEntry, ) -> None: """Test that the device registry gets updated when a device gets removed.""" driver = client.driver assert driver # Verify how many nodes are available assert len(driver.controller.nodes) == 3 # Make sure there are the same number of devices device_entries = dr.async_entries_for_config_entry( device_registry, integration.entry_id ) assert len(device_entries) == 3 # Remove a node and reload the entry old_node = driver.controller.nodes.pop(13) await hass.config_entries.async_reload(integration.entry_id) await hass.async_block_till_done() # Assert that the node was removed from the device registry device_entries = dr.async_entries_for_config_entry( device_registry, integration.entry_id ) assert len(device_entries) == 2 assert ( device_registry.async_get_device(identifiers={get_device_id(driver, old_node)}) is None ) @pytest.mark.usefixtures("client", "eaton_rf9640_dimmer") async def test_suggested_area( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test that suggested area works.""" 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() entity_entry = entity_registry.async_get(EATON_RF9640_ENTITY) assert entity_entry assert entity_entry.device_id is not None device = device_registry.async_get(entity_entry.device_id) assert device assert device.area_id is not None async def test_node_removed( hass: HomeAssistant, device_registry: dr.DeviceRegistry, multisensor_6_state, client: MagicMock, integration: MockConfigEntry, ) -> None: """Test that device gets removed when node gets removed.""" node = Node(client, deepcopy(multisensor_6_state)) device_id = f"{client.driver.controller.home_id}-{node.node_id}" event = { "source": "controller", "event": "node added", "node": multisensor_6_state, "result": {}, } client.driver.controller.receive_event(Event("node added", event)) await hass.async_block_till_done() old_device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert old_device assert old_device.id event = {"node": node, "reason": 0} client.driver.controller.emit("node removed", event) await hass.async_block_till_done() # Assert device has been removed assert not device_registry.async_get(old_device.id) async def test_replace_same_node( hass: HomeAssistant, device_registry: dr.DeviceRegistry, multisensor_6: Node, multisensor_6_state: NodeDataType, client: MagicMock, integration: MockConfigEntry, ) -> None: """Test when a node is replaced with itself that the device remains.""" node_id = multisensor_6.node_id multisensor_6_state = deepcopy(multisensor_6_state) 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 = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device assert device == device_registry.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 "reason" # to distinguish it from an exclusion event = Event( type="node removed", data={ "source": "controller", "event": "node removed", "reason": 3, "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 = device_registry.async_get(dev_id) assert device # 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": False, "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, }, "isControllerNode": False, }, "result": {}, }, ) # Device is still not removed client.driver.receive_event(event) await hass.async_block_till_done() device = device_registry.async_get(dev_id) assert device 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() # Device is the same device = device_registry.async_get(dev_id) assert device assert device == device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device == device_registry.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: HomeAssistant, device_registry: dr.DeviceRegistry, multisensor_6: Node, multisensor_6_state: NodeDataType, hank_binary_switch_state: NodeDataType, client: MagicMock, integration: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: """Test when a node is replaced with a different node.""" node_id = multisensor_6.node_id state = deepcopy(hank_binary_switch_state) state["nodeId"] = node_id device_id = f"{client.driver.controller.home_id}-{node_id}" multisensor_6_device_id_ext = ( f"{device_id}-{multisensor_6.manufacturer_id}:" f"{multisensor_6.product_type}:{multisensor_6.product_id}" ) hank_device_id_ext = ( f"{device_id}-{state['manufacturerId']}:" f"{state['productType']}:" f"{state['productId']}" ) device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device assert device == device_registry.async_get_device( identifiers={(DOMAIN, multisensor_6_device_id_ext)} ) assert device.manufacturer == "AEON Labs" assert device.model == "ZW100" dev_id = device.id assert hass.states.get(AIR_TEMPERATURE_SENSOR) # Remove existing node event = Event( type="node removed", data={ "source": "controller", "event": "node removed", "reason": 3, "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 = device_registry.async_get_device( identifiers={(DOMAIN, multisensor_6_device_id_ext)} ) assert device assert len(device.identifiers) == 2 # 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": False, "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, }, "isControllerNode": False, }, "result": {}, }, ) # Device is still not removed client.driver.receive_event(event) await hass.async_block_till_done() device = device_registry.async_get(dev_id) assert device event = Event( type="ready", data={ "source": "node", "event": "ready", "nodeId": node_id, "nodeState": state, }, ) client.driver.receive_event(event) await hass.async_block_till_done() # node ID based device identifier should be moved from the old multisensor device # to the new hank device and both the old and new devices should exist. new_device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert new_device hank_device = device_registry.async_get_device( identifiers={(DOMAIN, hank_device_id_ext)} ) assert hank_device assert hank_device == new_device assert hank_device.identifiers == { (DOMAIN, device_id), (DOMAIN, hank_device_id_ext), } multisensor_6_device = device_registry.async_get_device( identifiers={(DOMAIN, multisensor_6_device_id_ext)} ) assert multisensor_6_device assert multisensor_6_device != new_device assert multisensor_6_device.identifiers == {(DOMAIN, multisensor_6_device_id_ext)} assert new_device.manufacturer == "HANK Electronics Ltd." assert new_device.model == "HKZW-SO01" # We keep the old entities in case there are customizations that a user wants to # keep. They can always delete the device and that will remove the entities as well. assert hass.states.get(AIR_TEMPERATURE_SENSOR) assert hass.states.get("switch.smart_plug_with_two_usb_ports") # Try to add back the first node to see if the device IDs are correct # Remove existing node event = Event( type="node removed", data={ "source": "controller", "event": "node removed", "reason": 3, "node": state, }, ) client.driver.receive_event(event) await hass.async_block_till_done() # Device should still be there after the node was removed device = device_registry.async_get_device( identifiers={(DOMAIN, hank_device_id_ext)} ) assert device assert len(device.identifiers) == 2 # 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": False, "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, }, "isControllerNode": False, }, "result": {}, }, ) client.driver.receive_event(event) await hass.async_block_till_done() # Mark node as ready 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 await async_setup_component(hass, "config", {}) # node ID based device identifier should be moved from the new hank device # to the old multisensor device and both the old and new devices should exist. old_device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert old_device hank_device = device_registry.async_get_device( identifiers={(DOMAIN, hank_device_id_ext)} ) assert hank_device assert hank_device != old_device assert hank_device.identifiers == {(DOMAIN, hank_device_id_ext)} multisensor_6_device = device_registry.async_get_device( identifiers={(DOMAIN, multisensor_6_device_id_ext)} ) assert multisensor_6_device assert multisensor_6_device == old_device assert multisensor_6_device.identifiers == { (DOMAIN, device_id), (DOMAIN, multisensor_6_device_id_ext), } ws_client = await hass_ws_client(hass) # Simulate the driver not being ready to ensure that the device removal handler # does not crash driver = client.driver client.driver = None response = await ws_client.remove_device(hank_device.id, integration.entry_id) assert not response["success"] client.driver = driver # Attempting to remove the hank device should pass, but removing the multisensor should not response = await ws_client.remove_device(hank_device.id, integration.entry_id) assert response["success"] response = await ws_client.remove_device( multisensor_6_device.id, integration.entry_id ) assert not response["success"] async def test_node_model_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, zp3111: Node, client: MagicMock, integration: MockConfigEntry, ) -> None: """Test when a node's model is changed due to an updated device config file. The device and entities should not be removed. """ 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 = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device assert device == device_registry.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_motion_detection" state = hass.states.get(motion_entity) assert state assert state.name == "4-in-1 Sensor Motion detection" # Customize device and entity names/ids device_registry.async_update_device(device.id, name_by_user="Custom Device Name") device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device assert device.id == dev_id assert device == device_registry.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" entity_registry.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 = device_registry.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" @pytest.mark.usefixtures("zp3111", "integration") async def test_disabled_node_status_entity_on_node_replaced( hass: HomeAssistant, zp3111_state: NodeDataType, client: MagicMock, ) -> None: """Test when node replacement event is received, 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", "reason": 3, "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: HomeAssistant, zp3111: Node, client: MagicMock, integration: MockConfigEntry, ) -> None: """Test that when entity primary values are removed the entity is removed.""" idle_cover_status_button_entity = ( "button.4_in_1_sensor_idle_home_security_cover_status" ) # 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(idle_cover_status_button_entity) assert state assert state.state != STATE_UNAVAILABLE # check for expected entities binary_cover_entity = "binary_sensor.4_in_1_sensor_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(idle_cover_status_button_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, idle_cover_status_button_entity, } == new_unavailable_entities ) async def test_identify_event( hass: HomeAssistant, client: MagicMock, multisensor_6: Node, integration: MockConfigEntry, ) -> None: """Test controller identify event.""" # One config entry scenario event = Event( type="identify", data={ "source": "controller", "event": "identify", "nodeId": multisensor_6.node_id, }, ) dev_id = get_device_id(client.driver, multisensor_6) msg_id = f"{DOMAIN}.identify_controller.{dev_id[1]}" client.driver.controller.receive_event(event) notifications = async_get_persistent_notifications(hass) assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert notifications[msg_id]["message"].startswith("`Multisensor 6`") assert "with the home ID" not in notifications[msg_id]["message"] async_dismiss(hass, msg_id) # Add mock config entry to simulate having multiple entries new_entry = MockConfigEntry(domain=DOMAIN) new_entry.add_to_hass(hass) # Test case where config entry title and home ID don't match client.driver.controller.receive_event(event) notifications = async_get_persistent_notifications(hass) assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert ( "network `Mock Title`, with the home ID `3245146787`" in notifications[msg_id]["message"] ) async_dismiss(hass, msg_id) # Test case where config entry title and home ID do match hass.config_entries.async_update_entry(integration, title="3245146787") client.driver.controller.receive_event(event) notifications = async_get_persistent_notifications(hass) assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] async def test_server_logging( hass: HomeAssistant, client: MagicMock, caplog: pytest.LogCaptureFixture ) -> None: """Test automatic server logging functionality.""" def _reset_mocks(): client.async_send_command.reset_mock() client.enable_server_logging.reset_mock() client.disable_server_logging.reset_mock() # Set server logging to disabled client.server_logging_enabled = False 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() # Setup logger and set log level to debug to trigger event listener assert await async_setup_component(hass, "logger", {"logger": {}}) assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.DEBUG client.async_send_command.reset_mock() async with async_call_logger_set_level( "zwave_js_server", "DEBUG", hass=hass, caplog=caplog ): assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.DEBUG # Validate that the server logging was enabled assert len(client.async_send_command.call_args_list) == 1 assert client.async_send_command.call_args[0][0] == { "command": "driver.update_log_config", "config": {"level": "debug"}, } assert client.enable_server_logging.called assert not client.disable_server_logging.called _reset_mocks() # Emulate server by setting log level to debug event = Event( type="log config updated", data={ "source": "driver", "event": "log config updated", "config": { "enabled": False, "level": "debug", "logToFile": True, "filename": "test", "forceConsole": True, }, }, ) client.driver.receive_event(event) # "Enable" server logging and unload the entry client.server_logging_enabled = True await hass.config_entries.async_unload(entry.entry_id) # Validate that the server logging was disabled assert len(client.async_send_command.call_args_list) == 1 assert client.async_send_command.call_args[0][0] == { "command": "driver.update_log_config", "config": {"level": "info"}, } assert not client.enable_server_logging.called assert client.disable_server_logging.called _reset_mocks() # Validate that the server logging doesn't get enabled because HA thinks it already # is enabled await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 2 assert client.async_send_command.call_args_list[0][0][0] == { "command": "controller.get_provisioning_entries", } assert client.async_send_command.call_args_list[1][0][0] == { "command": "controller.get_provisioning_entry", "dskOrNodeId": 1, } assert not client.enable_server_logging.called assert not client.disable_server_logging.called _reset_mocks() # "Disable" server logging and unload the entry client.server_logging_enabled = False await hass.config_entries.async_unload(entry.entry_id) # Validate that the server logging was not disabled because HA thinks it is already # is disabled assert len(client.async_send_command.call_args_list) == 0 assert not client.enable_server_logging.called assert not client.disable_server_logging.called async def test_factory_reset_node( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client: MagicMock, multisensor_6: Node, multisensor_6_state: NodeDataType, integration: MockConfigEntry, ) -> None: """Test when a node is removed because it was reset.""" # One config entry scenario remove_event = Event( type="node removed", data={ "source": "controller", "event": "node removed", "reason": 5, "node": deepcopy(multisensor_6_state), }, ) dev_id = get_device_id(client.driver, multisensor_6) msg_id = f"{DOMAIN}.node_reset_and_removed.{dev_id[1]}" client.driver.controller.receive_event(remove_event) notifications = async_get_persistent_notifications(hass) assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert notifications[msg_id]["message"].startswith("`Multisensor 6`") assert "with the home ID" not in notifications[msg_id]["message"] async_dismiss(hass, msg_id) await hass.async_block_till_done() assert not device_registry.async_get_device(identifiers={dev_id}) # Add mock config entry to simulate having multiple entries new_entry = MockConfigEntry(domain=DOMAIN) new_entry.add_to_hass(hass) # Re-add the node then remove it again add_event = Event( type="node added", data={ "source": "controller", "event": "node added", "node": deepcopy(multisensor_6_state), "result": {}, }, ) client.driver.controller.receive_event(add_event) await hass.async_block_till_done() remove_event.data["node"] = deepcopy(multisensor_6_state) client.driver.controller.receive_event(remove_event) # Test case where config entry title and home ID don't match notifications = async_get_persistent_notifications(hass) assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert ( "network `Mock Title`, with the home ID `3245146787`" in notifications[msg_id]["message"] ) async_dismiss(hass, msg_id) # Test case where config entry title and home ID do match hass.config_entries.async_update_entry(integration, title="3245146787") add_event = Event( type="node added", data={ "source": "controller", "event": "node added", "node": deepcopy(multisensor_6_state), "result": {}, }, ) client.driver.controller.receive_event(add_event) await hass.async_block_till_done() remove_event.data["node"] = deepcopy(multisensor_6_state) client.driver.controller.receive_event(remove_event) notifications = async_get_persistent_notifications(hass) assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert "network with the home ID `3245146787`" in notifications[msg_id]["message"]