diff --git a/homeassistant/components/freshr/__init__.py b/homeassistant/components/freshr/__init__.py index 52d62cff758..7d9938dcd4c 100644 --- a/homeassistant/components/freshr/__init__.py +++ b/homeassistant/components/freshr/__init__.py @@ -3,7 +3,7 @@ import asyncio from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from .coordinator import ( FreshrConfigEntry, @@ -21,10 +21,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bo await devices_coordinator.async_config_entry_first_refresh() readings: dict[str, FreshrReadingsCoordinator] = { - device.id: FreshrReadingsCoordinator( + device_id: FreshrReadingsCoordinator( hass, entry, device, devices_coordinator.client ) - for device in devices_coordinator.data + for device_id, device in devices_coordinator.data.items() } await asyncio.gather( *( @@ -38,6 +38,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bo readings=readings, ) + known_devices: set[str] = set(readings) + + @callback + def _handle_coordinator_update() -> None: + current = set(devices_coordinator.data) + removed_ids = known_devices - current + if removed_ids: + known_devices.difference_update(removed_ids) + for device_id in removed_ids: + entry.runtime_data.readings.pop(device_id, None) + new_ids = current - known_devices + if not new_ids: + return + known_devices.update(new_ids) + for device_id in new_ids: + device = devices_coordinator.data[device_id] + readings_coordinator = FreshrReadingsCoordinator( + hass, entry, device, devices_coordinator.client + ) + entry.runtime_data.readings[device_id] = readings_coordinator + hass.async_create_task( + readings_coordinator.async_refresh(), + name=f"freshr_readings_refresh_{device_id}", + ) + + entry.async_on_unload( + devices_coordinator.async_add_listener(_handle_coordinator_update) + ) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True diff --git a/homeassistant/components/freshr/coordinator.py b/homeassistant/components/freshr/coordinator.py index 3f68f218687..133e1f03f11 100644 --- a/homeassistant/components/freshr/coordinator.py +++ b/homeassistant/components/freshr/coordinator.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -32,7 +33,7 @@ class FreshrData: type FreshrConfigEntry = ConfigEntry[FreshrData] -class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]): +class FreshrDevicesCoordinator(DataUpdateCoordinator[dict[str, DeviceSummary]]): """Coordinator that refreshes the device list once an hour.""" config_entry: FreshrConfigEntry @@ -48,7 +49,7 @@ class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]): ) self.client = FreshrClient(session=async_create_clientsession(hass)) - async def _async_update_data(self) -> list[DeviceSummary]: + async def _async_update_data(self) -> dict[str, DeviceSummary]: """Fetch the list of devices from the Fresh-r API.""" username = self.config_entry.data[CONF_USERNAME] password = self.config_entry.data[CONF_PASSWORD] @@ -68,8 +69,23 @@ class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]): translation_domain=DOMAIN, translation_key="cannot_connect", ) from err - else: - return devices + + current = {device.id: device for device in devices} + + if self.data is not None: + stale_ids = set(self.data) - set(current) + if stale_ids: + device_registry = dr.async_get(self.hass) + for device_id in stale_ids: + if device := device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + return current class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]): diff --git a/homeassistant/components/freshr/quality_scale.yaml b/homeassistant/components/freshr/quality_scale.yaml index f8d2b1a97d7..c8c60a6330c 100644 --- a/homeassistant/components/freshr/quality_scale.yaml +++ b/homeassistant/components/freshr/quality_scale.yaml @@ -45,7 +45,9 @@ rules: discovery-update-info: status: exempt comment: Integration connects to a cloud service; no local network discovery is possible. - discovery: todo + discovery: + status: exempt + comment: No local network discovery of devices is possible (no zeroconf, mdns or other discovery mechanisms). docs-data-update: done docs-examples: done docs-known-limitations: done @@ -53,7 +55,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -64,7 +66,7 @@ rules: repair-issues: status: exempt comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow. - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/freshr/sensor.py b/homeassistant/components/freshr/sensor.py index 210c3fccf08..a943ecacabb 100644 --- a/homeassistant/components/freshr/sensor.py +++ b/homeassistant/components/freshr/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfVolumeFlowRate, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -112,26 +112,43 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Fresh-r sensors from a config entry.""" - entities: list[FreshrSensor] = [] - for device in config_entry.runtime_data.devices.data: - descriptions = SENSOR_TYPES.get( - device.device_type, SENSOR_TYPES[DeviceType.FRESH_R] - ) - device_info = DeviceInfo( - identifiers={(DOMAIN, device.id)}, - name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"), - serial_number=device.id, - manufacturer="Fresh-r", - ) - entities.extend( - FreshrSensor( - config_entry.runtime_data.readings[device.id], - description, - device_info, + coordinator = config_entry.runtime_data.devices + known_devices: set[str] = set() + + @callback + def _check_devices() -> None: + current = set(coordinator.data) + removed_ids = known_devices - current + if removed_ids: + known_devices.difference_update(removed_ids) + new_ids = current - known_devices + if not new_ids: + return + known_devices.update(new_ids) + entities: list[FreshrSensor] = [] + for device_id in new_ids: + device = coordinator.data[device_id] + descriptions = SENSOR_TYPES.get( + device.device_type, SENSOR_TYPES[DeviceType.FRESH_R] ) - for description in descriptions - ) - async_add_entities(entities) + device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"), + serial_number=device_id, + manufacturer="Fresh-r", + ) + entities.extend( + FreshrSensor( + config_entry.runtime_data.readings[device_id], + description, + device_info, + ) + for description in descriptions + ) + async_add_entities(entities) + + _check_devices() + config_entry.async_on_unload(coordinator.async_add_listener(_check_devices)) class FreshrSensor(CoordinatorEntity[FreshrReadingsCoordinator], SensorEntity): diff --git a/tests/components/freshr/conftest.py b/tests/components/freshr/conftest.py index cceca7966cd..85be3bedb81 100644 --- a/tests/components/freshr/conftest.py +++ b/tests/components/freshr/conftest.py @@ -40,6 +40,7 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, data={CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass"}, unique_id="test-user", + entry_id="01JKRA6QKPBE00ZZ9BKWDB3CTB", ) diff --git a/tests/components/freshr/test_init.py b/tests/components/freshr/test_init.py index 9acb546b270..e89bf80c64b 100644 --- a/tests/components/freshr/test_init.py +++ b/tests/components/freshr/test_init.py @@ -1,14 +1,22 @@ """Test the Fresh-r initialization.""" from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory from pyfreshr.exceptions import ApiResponseError, LoginError import pytest +from homeassistant.components.freshr.const import DOMAIN +from homeassistant.components.freshr.coordinator import ( + DEVICES_SCAN_INTERVAL, + READINGS_SCAN_INTERVAL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import MagicMock, MockConfigEntry +from .conftest import DEVICE_ID, MagicMock, MockConfigEntry + +from tests.common import async_fire_time_changed @pytest.mark.usefixtures("init_integration") @@ -64,3 +72,47 @@ async def test_setup_no_devices( er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) == [] ) + + +@pytest.mark.usefixtures("init_integration") +async def test_stale_device_removed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_freshr_client: MagicMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a device absent from a successful poll is removed from the registry.""" + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) + + mock_freshr_client.fetch_devices.return_value = [] + freezer.tick(DEVICES_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) is None + + call_count = mock_freshr_client.fetch_device_current.call_count + freezer.tick(READINGS_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_freshr_client.fetch_device_current.call_count == call_count + + +@pytest.mark.usefixtures("init_integration") +async def test_stale_device_not_removed_on_poll_error( + hass: HomeAssistant, + mock_freshr_client: MagicMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a device is not removed when the devices poll fails.""" + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) + + mock_freshr_client.fetch_devices.side_effect = ApiResponseError("cloud error") + freezer.tick(DEVICES_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) diff --git a/tests/components/freshr/test_sensor.py b/tests/components/freshr/test_sensor.py index 9ee1a23df16..6e33c90753a 100644 --- a/tests/components/freshr/test_sensor.py +++ b/tests/components/freshr/test_sensor.py @@ -5,16 +5,19 @@ from unittest.mock import MagicMock from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory from pyfreshr.exceptions import ApiResponseError -from pyfreshr.models import DeviceReadings +from pyfreshr.models import DeviceReadings, DeviceSummary import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.freshr.const import DOMAIN -from homeassistant.components.freshr.coordinator import READINGS_SCAN_INTERVAL +from homeassistant.components.freshr.coordinator import ( + DEVICES_SCAN_INTERVAL, + READINGS_SCAN_INTERVAL, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import DEVICE_ID +from .conftest import DEVICE_ID, MOCK_DEVICE_CURRENT from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -82,3 +85,71 @@ async def test_readings_connection_error_makes_unavailable( state = hass.states.get("sensor.fresh_r_inside_temperature") assert state is not None assert state.state == "unavailable" + + +DEVICE_ID_2 = "SN002" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_device_reappears_after_removal( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_freshr_client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that entities are re-created when a previously removed device reappears.""" + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) + + # Device disappears from the account + mock_freshr_client.fetch_devices.return_value = [] + freezer.tick(DEVICES_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) is None + + # Device reappears + mock_freshr_client.fetch_devices.return_value = [DeviceSummary(id=DEVICE_ID)] + mock_freshr_client.fetch_device_current.return_value = MOCK_DEVICE_CURRENT + freezer.tick(DEVICES_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) + t1_entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{DEVICE_ID}_t1" + ) + assert t1_entity_id + assert hass.states.get(t1_entity_id).state != "unavailable" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_dynamic_device_added( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_freshr_client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that sensors are created for a device that appears after initial setup.""" + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID_2)}) is None + + mock_freshr_client.fetch_devices.return_value = [ + DeviceSummary(id=DEVICE_ID), + DeviceSummary(id=DEVICE_ID_2), + ] + mock_freshr_client.fetch_device_current.return_value = MOCK_DEVICE_CURRENT + freezer.tick(DEVICES_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID_2)}) + t1_entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{DEVICE_ID_2}_t1" + ) + assert t1_entity_id + assert entity_registry.async_get_entity_id("sensor", DOMAIN, f"{DEVICE_ID_2}_co2") + assert hass.states.get(t1_entity_id).state != "unavailable"