1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-17 23:53:49 +01:00

Add dynamic devices to freshr (#165942)

This commit is contained in:
Leon Grave
2026-03-25 19:49:08 +01:00
committed by GitHub
parent 1ecbc44368
commit fabbfd93df
7 changed files with 223 additions and 35 deletions

View File

@@ -3,7 +3,7 @@
import asyncio import asyncio
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from .coordinator import ( from .coordinator import (
FreshrConfigEntry, FreshrConfigEntry,
@@ -21,10 +21,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bo
await devices_coordinator.async_config_entry_first_refresh() await devices_coordinator.async_config_entry_first_refresh()
readings: dict[str, FreshrReadingsCoordinator] = { readings: dict[str, FreshrReadingsCoordinator] = {
device.id: FreshrReadingsCoordinator( device_id: FreshrReadingsCoordinator(
hass, entry, device, devices_coordinator.client hass, entry, device, devices_coordinator.client
) )
for device in devices_coordinator.data for device_id, device in devices_coordinator.data.items()
} }
await asyncio.gather( await asyncio.gather(
*( *(
@@ -38,6 +38,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bo
readings=readings, 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) await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True return True

View File

@@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed 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.aiohttp_client import async_create_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -32,7 +33,7 @@ class FreshrData:
type FreshrConfigEntry = ConfigEntry[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.""" """Coordinator that refreshes the device list once an hour."""
config_entry: FreshrConfigEntry config_entry: FreshrConfigEntry
@@ -48,7 +49,7 @@ class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
) )
self.client = FreshrClient(session=async_create_clientsession(hass)) 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.""" """Fetch the list of devices from the Fresh-r API."""
username = self.config_entry.data[CONF_USERNAME] username = self.config_entry.data[CONF_USERNAME]
password = self.config_entry.data[CONF_PASSWORD] password = self.config_entry.data[CONF_PASSWORD]
@@ -68,8 +69,23 @@ class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="cannot_connect", translation_key="cannot_connect",
) from err ) 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]): class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]):

View File

@@ -45,7 +45,9 @@ rules:
discovery-update-info: discovery-update-info:
status: exempt status: exempt
comment: Integration connects to a cloud service; no local network discovery is possible. 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-data-update: done
docs-examples: done docs-examples: done
docs-known-limitations: done docs-known-limitations: done
@@ -53,7 +55,7 @@ rules:
docs-supported-functions: done docs-supported-functions: done
docs-troubleshooting: done docs-troubleshooting: done
docs-use-cases: done docs-use-cases: done
dynamic-devices: todo dynamic-devices: done
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done
@@ -64,7 +66,7 @@ rules:
repair-issues: repair-issues:
status: exempt status: exempt
comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow. comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow.
stale-devices: todo stale-devices: done
# Platinum # Platinum
async-dependency: done async-dependency: done

View File

@@ -20,7 +20,7 @@ from homeassistant.const import (
UnitOfTemperature, UnitOfTemperature,
UnitOfVolumeFlowRate, UnitOfVolumeFlowRate,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -112,26 +112,43 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Fresh-r sensors from a config entry.""" """Set up Fresh-r sensors from a config entry."""
entities: list[FreshrSensor] = [] coordinator = config_entry.runtime_data.devices
for device in config_entry.runtime_data.devices.data: known_devices: set[str] = set()
descriptions = SENSOR_TYPES.get(
device.device_type, SENSOR_TYPES[DeviceType.FRESH_R] @callback
) def _check_devices() -> None:
device_info = DeviceInfo( current = set(coordinator.data)
identifiers={(DOMAIN, device.id)}, removed_ids = known_devices - current
name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"), if removed_ids:
serial_number=device.id, known_devices.difference_update(removed_ids)
manufacturer="Fresh-r", new_ids = current - known_devices
) if not new_ids:
entities.extend( return
FreshrSensor( known_devices.update(new_ids)
config_entry.runtime_data.readings[device.id], entities: list[FreshrSensor] = []
description, for device_id in new_ids:
device_info, device = coordinator.data[device_id]
descriptions = SENSOR_TYPES.get(
device.device_type, SENSOR_TYPES[DeviceType.FRESH_R]
) )
for description in descriptions device_info = DeviceInfo(
) identifiers={(DOMAIN, device_id)},
async_add_entities(entities) 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): class FreshrSensor(CoordinatorEntity[FreshrReadingsCoordinator], SensorEntity):

View File

@@ -40,6 +40,7 @@ def mock_config_entry() -> MockConfigEntry:
domain=DOMAIN, domain=DOMAIN,
data={CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass"}, data={CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass"},
unique_id="test-user", unique_id="test-user",
entry_id="01JKRA6QKPBE00ZZ9BKWDB3CTB",
) )

View File

@@ -1,14 +1,22 @@
"""Test the Fresh-r initialization.""" """Test the Fresh-r initialization."""
from aiohttp import ClientError from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory
from pyfreshr.exceptions import ApiResponseError, LoginError from pyfreshr.exceptions import ApiResponseError, LoginError
import pytest 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.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant 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") @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) 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)})

View File

@@ -5,16 +5,19 @@ from unittest.mock import MagicMock
from aiohttp import ClientError from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
from pyfreshr.exceptions import ApiResponseError from pyfreshr.exceptions import ApiResponseError
from pyfreshr.models import DeviceReadings from pyfreshr.models import DeviceReadings, DeviceSummary
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.freshr.const import DOMAIN 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.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er 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 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") state = hass.states.get("sensor.fresh_r_inside_temperature")
assert state is not None assert state is not None
assert state.state == "unavailable" 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"