diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index cc293d970b2..a319024633c 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -188,6 +188,46 @@ async def async_remove_config_entry_device( host: ReolinkHost = hass.data[DOMAIN][config_entry.entry_id].host (device_uid, ch, is_chime) = get_device_uid_and_ch(device, host) + if is_chime: + await host.api.get_state(cmd="GetDingDongList") + chime = host.api.chime(ch) + if ( + chime is None + or chime.connect_state is None + or chime.connect_state < 0 + or chime.channel not in host.api.channels + ): + _LOGGER.debug( + "Removing Reolink chime %s with id %s, " + "since it is not coupled to %s anymore", + device.name, + ch, + host.api.nvr_name, + ) + return True + + # remove the chime from the host + await chime.remove() + await host.api.get_state(cmd="GetDingDongList") + if chime.connect_state < 0: + _LOGGER.debug( + "Removed Reolink chime %s with id %s from %s", + device.name, + ch, + host.api.nvr_name, + ) + return True + + _LOGGER.warning( + "Cannot remove Reolink chime %s with id %s, because it is still connected " + "to %s, please first remove the chime " + "in the reolink app", + device.name, + ch, + host.api.nvr_name, + ) + return False + if not host.api.is_nvr or ch is None: _LOGGER.warning( "Cannot remove Reolink device %s, because it is not a camera connected " diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 053792ad667..c47822e125c 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -190,3 +190,8 @@ class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity): serial_number=str(chime.dev_id), configuration_url=self._conf_url, ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._chime.online and super().available diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index c74cac76192..981dcc30e60 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -4,6 +4,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest +from reolink_aio.api import Chime from homeassistant.components.reolink import const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL @@ -107,6 +108,14 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} host_mock.checked_api_versions = {"GetEvents": 1} host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} + + # enums + host_mock.whiteled_mode.return_value = 1 + host_mock.whiteled_mode_list.return_value = ["off", "auto"] + host_mock.doorbell_led.return_value = "Off" + host_mock.doorbell_led_list.return_value = ["stayoff", "auto"] + host_mock.auto_track_method.return_value = 3 + host_mock.daynight_state.return_value = "Black&White" yield host_mock_class @@ -145,3 +154,26 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: ) config_entry.add_to_hass(hass) return config_entry + + +@pytest.fixture +def test_chime(reolink_connect: MagicMock) -> None: + """Mock a reolink chime.""" + TEST_CHIME = Chime( + host=reolink_connect, + dev_id=12345678, + channel=0, + ) + TEST_CHIME.name = "Test chime" + TEST_CHIME.volume = 3 + TEST_CHIME.connect_state = 2 + TEST_CHIME.led_state = True + TEST_CHIME.event_info = { + "md": {"switch": 0, "musicId": 0}, + "people": {"switch": 0, "musicId": 1}, + "visitor": {"switch": 1, "musicId": 2}, + } + + reolink_connect.chime_list = [TEST_CHIME] + reolink_connect.chime.return_value = TEST_CHIME + return TEST_CHIME diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 4f745530b6b..5334e171e5e 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -6,6 +6,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from reolink_aio.api import Chime from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError from homeassistant.components.reolink import ( @@ -40,6 +41,8 @@ from tests.typing import WebSocketGenerator pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms") +CHIME_MODEL = "Reolink Chime" + async def test_wait(*args, **key_args): """Ensure a mocked function takes a bit of time to be able to timeout in test.""" @@ -224,13 +227,9 @@ async def test_removing_disconnected_cams( device_models = [device.model for device in device_entries] assert sorted(device_models) == sorted([TEST_HOST_MODEL, TEST_CAM_MODEL]) - # reload integration after 'disconnecting' a camera. + # Try to remove the device after 'disconnecting' a camera. if attr is not None: setattr(reolink_connect, attr, value) - with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): - assert await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - expected_success = TEST_CAM_MODEL not in expected_models for device in device_entries: if device.model == TEST_CAM_MODEL: @@ -244,6 +243,79 @@ async def test_removing_disconnected_cams( assert sorted(device_models) == sorted(expected_models) +@pytest.mark.parametrize( + ("attr", "value", "expected_models"), + [ + ( + None, + None, + [TEST_HOST_MODEL, TEST_CAM_MODEL, CHIME_MODEL], + ), + ( + "connect_state", + -1, + [TEST_HOST_MODEL, TEST_CAM_MODEL], + ), + ( + "remove", + -1, + [TEST_HOST_MODEL, TEST_CAM_MODEL], + ), + ], +) +async def test_removing_chime( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + test_chime: Chime, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + attr: str | None, + value: Any, + expected_models: list[str], +) -> None: + """Test removing a chime.""" + reolink_connect.channels = [0] + assert await async_setup_component(hass, "config", {}) + client = await hass_ws_client(hass) + # setup CH 0 and NVR switch entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + device_models = [device.model for device in device_entries] + assert sorted(device_models) == sorted( + [TEST_HOST_MODEL, TEST_CAM_MODEL, CHIME_MODEL] + ) + + if attr == "remove": + + async def test_remove_chime(*args, **key_args): + """Remove chime.""" + test_chime.connect_state = -1 + + test_chime.remove = test_remove_chime + elif attr is not None: + setattr(test_chime, attr, value) + + # Try to remove the device after 'disconnecting' a chime. + expected_success = CHIME_MODEL not in expected_models + for device in device_entries: + if device.model == CHIME_MODEL: + response = await client.remove_device(device.id, config_entry.entry_id) + assert response["success"] == expected_success + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + device_models = [device.model for device in device_entries] + assert sorted(device_models) == sorted(expected_models) + + @pytest.mark.parametrize( ( "original_id", diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 53c1e494b3d..5536797d7d3 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -33,8 +33,6 @@ async def test_floodlight_mode_select( entity_registry: er.EntityRegistry, ) -> None: """Test select entity with floodlight_mode.""" - reolink_connect.whiteled_mode.return_value = 1 - reolink_connect.whiteled_mode_list.return_value = ["off", "auto"] with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): assert await hass.config_entries.async_setup(config_entry.entry_id) is True await hass.async_block_till_done() @@ -72,6 +70,14 @@ async def test_floodlight_mode_select( blocking=True, ) + reolink_connect.whiteled_mode.return_value = -99 # invalid value + async_fire_time_changed( + hass, utcnow() + DEVICE_UPDATE_INTERVAL + timedelta(seconds=30) + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + async def test_play_quick_reply_message( hass: HomeAssistant, @@ -103,25 +109,10 @@ async def test_chime_select( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, + test_chime: Chime, entity_registry: er.EntityRegistry, ) -> None: """Test chime select entity.""" - TEST_CHIME = Chime( - host=reolink_connect, - dev_id=12345678, - channel=0, - ) - TEST_CHIME.name = "Test chime" - TEST_CHIME.volume = 3 - TEST_CHIME.led_state = True - TEST_CHIME.event_info = { - "md": {"switch": 0, "musicId": 0}, - "people": {"switch": 0, "musicId": 1}, - "visitor": {"switch": 1, "musicId": 2}, - } - - reolink_connect.chime_list = [TEST_CHIME] - with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): assert await hass.config_entries.async_setup(config_entry.entry_id) is True await hass.async_block_till_done() @@ -131,16 +122,16 @@ async def test_chime_select( entity_id = f"{Platform.SELECT}.test_chime_visitor_ringtone" assert hass.states.is_state(entity_id, "pianokey") - TEST_CHIME.set_tone = AsyncMock() + test_chime.set_tone = AsyncMock() await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, {ATTR_ENTITY_ID: entity_id, "option": "off"}, blocking=True, ) - TEST_CHIME.set_tone.assert_called_once() + test_chime.set_tone.assert_called_once() - TEST_CHIME.set_tone = AsyncMock(side_effect=ReolinkError("Test error")) + test_chime.set_tone = AsyncMock(side_effect=ReolinkError("Test error")) with pytest.raises(HomeAssistantError): await hass.services.async_call( SELECT_DOMAIN, @@ -149,7 +140,7 @@ async def test_chime_select( blocking=True, ) - TEST_CHIME.set_tone = AsyncMock(side_effect=InvalidParameterError("Test error")) + test_chime.set_tone = AsyncMock(side_effect=InvalidParameterError("Test error")) with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, @@ -158,7 +149,7 @@ async def test_chime_select( blocking=True, ) - TEST_CHIME.event_info = {} + test_chime.event_info = {} async_fire_time_changed( hass, utcnow() + DEVICE_UPDATE_INTERVAL + timedelta(seconds=30) )