diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 46761beae00..eca5a5aa853 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -148,6 +149,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b firmware_coordinator=firmware_coordinator, ) + cleanup_disconnected_cams(hass, config_entry.entry_id, host) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload( @@ -175,3 +178,56 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok + + +def cleanup_disconnected_cams( + hass: HomeAssistant, config_entry_id: str, host: ReolinkHost +) -> None: + """Clean-up disconnected camera channels or channels where a different model camera is connected.""" + if not host.api.is_nvr: + return + + device_reg = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(device_reg, config_entry_id) + for device in devices: + device_id = [ + dev_id[1].split("_ch") + for dev_id in device.identifiers + if dev_id[0] == DOMAIN + ][0] + + if len(device_id) < 2: + # Do not consider the NVR itself + continue + + ch = int(device_id[1]) + ch_model = host.api.camera_model(ch) + remove = False + if ch not in host.api.channels: + remove = True + _LOGGER.debug( + "Removing Reolink device %s, since no camera is connected to NVR channel %s anymore", + device.name, + ch, + ) + if ch_model not in [device.model, "Unknown"]: + remove = True + _LOGGER.debug( + "Removing Reolink device %s, since the camera model connected to channel %s changed from %s to %s", + device.name, + ch, + device.model, + ch_model, + ) + if not remove: + continue + + # clean entity and device registry + entity_reg = er.async_get(hass) + entities = er.async_entries_for_device( + entity_reg, device.id, include_disabled_entities=True + ) + for entity in entities: + entity_reg.async_remove(entity.entity_id) + + device_reg.async_remove_device(device.id) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 75d2dc0c661..464d4120c65 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -25,6 +25,8 @@ TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" TEST_NVR_NAME2 = "test2_reolink_name" TEST_USE_HTTPS = True +TEST_HOST_MODEL = "RLN8-410" +TEST_CAM_MODEL = "RLC-123" @pytest.fixture @@ -70,8 +72,8 @@ def reolink_connect_class( host_mock.hardware_version = "IPC_00000" host_mock.sw_version = "v1.0.0.0.0.0000" host_mock.manufacturer = "Reolink" - host_mock.model = "RLC-123" - host_mock.camera_model.return_value = "RLC-123" + host_mock.model = TEST_HOST_MODEL + host_mock.camera_model.return_value = TEST_CAM_MODEL host_mock.camera_name.return_value = TEST_NVR_NAME host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" host_mock.session_active = True diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 604a9364320..9f70673695c 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -41,7 +41,7 @@ 'event connection': 'Fast polling', 'firmware version': 'v1.0.0.0.0.0000', 'hardware version': 'IPC_00000', - 'model': 'RLC-123', + 'model': 'RLN8-410', 'stream channels': list([ 0, ]), diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index e2bd622bb43..6a9a8b957db 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -11,11 +11,15 @@ from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_MODEL, TEST_HOST_MODEL, TEST_NVR_NAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -102,6 +106,7 @@ async def test_entry_reloading( reolink_connect: MagicMock, ) -> None: """Test the entry is reloaded correctly when settings change.""" + reolink_connect.is_nvr = False assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -115,6 +120,58 @@ async def test_entry_reloading( assert config_entry.title == "New Name" +@pytest.mark.parametrize( + ("attr", "value", "expected_models"), + [ + ( + None, + None, + [TEST_HOST_MODEL, TEST_CAM_MODEL], + ), + ("channels", [], [TEST_HOST_MODEL]), + ( + "camera_model", + Mock(return_value="RLC-567"), + [TEST_HOST_MODEL, "RLC-567"], + ), + ], +) +async def test_cleanup_disconnected_cams( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + attr: str | None, + value: Any, + expected_models: list[str], +) -> None: + """Test device and entity registry are cleaned up when camera is disconnected from NVR.""" + reolink_connect.channels = [0] + # 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]) + + # reload integration 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) + + 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) + + async def test_no_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: