1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 08:26:41 +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
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

View File

@@ -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]):

View File

@@ -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

View File

@@ -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):

View File

@@ -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",
)

View File

@@ -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)})

View File

@@ -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"