From 10805805fe931705a32073da3cf3d7d00a88bf4b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 30 Sep 2024 21:06:51 +0200 Subject: [PATCH] Add devices to Withings (#126853) --- homeassistant/components/withings/__init__.py | 4 + .../components/withings/coordinator.py | 15 ++++ homeassistant/components/withings/entity.py | 39 +++++++- homeassistant/components/withings/icons.json | 8 ++ homeassistant/components/withings/sensor.py | 88 ++++++++++++++++++- .../components/withings/strings.json | 8 ++ tests/components/withings/__init__.py | 10 ++- tests/components/withings/conftest.py | 23 +++++ .../withings/snapshots/test_init.ambr | 65 ++++++++++++++ .../withings/snapshots/test_sensor.ambr | 58 ++++++++++++ tests/components/withings/test_init.py | 20 +++++ tests/components/withings/test_sensor.py | 84 +++++++++++++++++- 12 files changed, 417 insertions(+), 5 deletions(-) create mode 100644 tests/components/withings/snapshots/test_init.ambr diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 908548084aea0c..1c196bd4b92b01 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -48,6 +48,7 @@ WithingsActivityDataUpdateCoordinator, WithingsBedPresenceDataUpdateCoordinator, WithingsDataUpdateCoordinator, + WithingsDeviceDataUpdateCoordinator, WithingsGoalsDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator, WithingsSleepDataUpdateCoordinator, @@ -73,6 +74,7 @@ class WithingsData: goals_coordinator: WithingsGoalsDataUpdateCoordinator activity_coordinator: WithingsActivityDataUpdateCoordinator workout_coordinator: WithingsWorkoutDataUpdateCoordinator + device_coordinator: WithingsDeviceDataUpdateCoordinator coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set) def __post_init__(self) -> None: @@ -84,6 +86,7 @@ def __post_init__(self) -> None: self.goals_coordinator, self.activity_coordinator, self.workout_coordinator, + self.device_coordinator, } @@ -122,6 +125,7 @@ async def _refresh_token() -> str: goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client), activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client), workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, client), + device_coordinator=WithingsDeviceDataUpdateCoordinator(hass, client), ) for coordinator in withings_data.coordinators: diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 361a20acafdf21..79419ae23ffc8f 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -8,6 +8,7 @@ from aiowithings import ( Activity, + Device, Goals, MeasurementPosition, MeasurementType, @@ -291,3 +292,17 @@ async def _internal_update_data(self) -> Workout | None: self._previous_data = latest_workout self._last_valid_update = latest_workout.end_date return self._previous_data + + +class WithingsDeviceDataUpdateCoordinator( + WithingsDataUpdateCoordinator[dict[str, Device]] +): + """Withings device coordinator.""" + + coordinator_name: str = "device" + _default_update_interval = timedelta(hours=1) + + async def _internal_update_data(self) -> dict[str, Device]: + """Update coordinator data.""" + devices = await self._client.get_devices() + return {device.device_id: device for device in devices} diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index a5cb62b72a209e..5c548fdb260d8f 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -4,11 +4,16 @@ from typing import Any +from aiowithings import Device + from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import WithingsDataUpdateCoordinator +from .coordinator import ( + WithingsDataUpdateCoordinator, + WithingsDeviceDataUpdateCoordinator, +) class WithingsEntity[_T: WithingsDataUpdateCoordinator[Any]](CoordinatorEntity[_T]): @@ -28,3 +33,35 @@ def __init__( identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))}, manufacturer="Withings", ) + + +class WithingsDeviceEntity(WithingsEntity[WithingsDeviceDataUpdateCoordinator]): + """Base class for withings device entities.""" + + def __init__( + self, + coordinator: WithingsDeviceDataUpdateCoordinator, + device_id: str, + key: str, + ) -> None: + """Initialize the Withings entity.""" + super().__init__(coordinator, key) + self._attr_unique_id = f"{device_id}_{key}" + self.device_id = device_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + manufacturer="Withings", + name=self.device.raw_model, + model=self.device.raw_model, + via_device=(DOMAIN, str(coordinator.config_entry.unique_id)), + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.device_id in self.coordinator.data + + @property + def device(self) -> Device: + """Return the Withings device.""" + return self.coordinator.data[self.device_id] diff --git a/homeassistant/components/withings/icons.json b/homeassistant/components/withings/icons.json index f6fb5e74136ff1..79ff7489bf89ff 100644 --- a/homeassistant/components/withings/icons.json +++ b/homeassistant/components/withings/icons.json @@ -136,6 +136,14 @@ }, "workout_duration": { "default": "mdi:timer" + }, + "battery": { + "default": "mdi:battery-off", + "state": { + "low": "mdi:battery-20", + "medium": "mdi:battery-50", + "high": "mdi:battery" + } } } } diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 20fd72845aee17..cc9a6e88d7c6dc 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -9,6 +9,7 @@ from aiowithings import ( Activity, + Device, Goals, MeasurementPosition, MeasurementType, @@ -23,6 +24,7 @@ SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( PERCENTAGE, Platform, @@ -33,8 +35,8 @@ UnitOfTime, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -51,12 +53,13 @@ from .coordinator import ( WithingsActivityDataUpdateCoordinator, WithingsDataUpdateCoordinator, + WithingsDeviceDataUpdateCoordinator, WithingsGoalsDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator, WithingsSleepDataUpdateCoordinator, WithingsWorkoutDataUpdateCoordinator, ) -from .entity import WithingsEntity +from .entity import WithingsDeviceEntity, WithingsEntity @dataclass(frozen=True, kw_only=True) @@ -650,6 +653,24 @@ class WithingsWorkoutSensorEntityDescription(SensorEntityDescription): ] +@dataclass(frozen=True, kw_only=True) +class WithingsDeviceSensorEntityDescription(SensorEntityDescription): + """Immutable class for describing withings data.""" + + value_fn: Callable[[Device], StateType] + + +DEVICE_SENSORS = [ + WithingsDeviceSensorEntityDescription( + key="battery", + translation_key="battery", + options=["low", "medium", "high"], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda device: device.battery, + ) +] + + def get_current_goals(goals: Goals) -> set[str]: """Return a list of present goals.""" result = set() @@ -800,6 +821,48 @@ def _async_add_workout_entities() -> None: _async_add_workout_entities ) + device_coordinator = withings_data.device_coordinator + + current_devices: set[str] = set() + + def _async_device_listener() -> None: + """Add device entities.""" + received_devices = set(device_coordinator.data) + new_devices = received_devices - current_devices + old_devices = current_devices - received_devices + if new_devices: + device_registry = dr.async_get(hass) + for device_id in new_devices: + if device := device_registry.async_get_device({(DOMAIN, device_id)}): + if any( + ( + config_entry := hass.config_entries.async_get_entry( + config_entry_id + ) + ) + and config_entry.state == ConfigEntryState.LOADED + for config_entry_id in device.config_entries + ): + continue + async_add_entities( + WithingsDeviceSensor(device_coordinator, description, device_id) + for description in DEVICE_SENSORS + ) + current_devices.add(device_id) + + if old_devices: + device_registry = dr.async_get(hass) + for device_id in old_devices: + if device := device_registry.async_get_device({(DOMAIN, device_id)}): + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + current_devices.remove(device_id) + + device_coordinator.async_add_listener(_async_device_listener) + + _async_device_listener() + if not entities: LOGGER.warning( "No data found for Withings entry %s, sensors will be added when new data is available" @@ -923,3 +986,24 @@ def native_value(self) -> StateType: if not self.coordinator.data: return None return self.entity_description.value_fn(self.coordinator.data) + + +class WithingsDeviceSensor(WithingsDeviceEntity, SensorEntity): + """Implementation of a Withings workout sensor.""" + + entity_description: WithingsDeviceSensorEntityDescription + + def __init__( + self, + coordinator: WithingsDeviceDataUpdateCoordinator, + entity_description: WithingsDeviceSensorEntityDescription, + device_id: str, + ) -> None: + """Initialize sensor.""" + super().__init__(coordinator, device_id, entity_description.key) + self.entity_description = entity_description + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + return self.entity_description.value_fn(self.device) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index fb86b16c3be15a..16c47932c4a64d 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -307,6 +307,14 @@ }, "workout_duration": { "name": "Last workout duration" + }, + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]", + "state": { + "low": "Low", + "medium": "Medium", + "high": "High" + } } } } diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 4b97fc488342ac..8469a5a462aa74 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -6,7 +6,7 @@ from urllib.parse import urlparse from aiohttp.test_utils import TestClient -from aiowithings import Activity, Goals, MeasurementGroup, SleepSummary, Workout +from aiowithings import Activity, Device, Goals, MeasurementGroup, SleepSummary, Workout from freezegun.api import FrozenDateTimeFactory from homeassistant.components.webhook import async_generate_url @@ -109,3 +109,11 @@ def load_sleep_fixture( """Return sleep summaries from fixture.""" sleep_json = load_json_array_fixture("withings/sleep_summaries.json") return [SleepSummary.from_api(sleep_summary) for sleep_summary in sleep_json] + + +def load_device_fixture( + fixture: str = "withings/devices.json", +) -> list[Device]: + """Return sleep summaries from fixture.""" + devices_json = load_json_array_fixture(fixture) + return [Device.from_api(device) for device in devices_json] diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index dfb0658b64a918..5b73240908a6c4 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -133,6 +133,29 @@ def polling_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: ) +@pytest.fixture +def second_polling_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Create Withings entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title="Not Henk", + unique_id="54321", + data={ + "auth_implementation": DOMAIN, + "token": { + "status": 0, + "userid": "54321", + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": ",".join(scopes), + }, + "profile": TITLE, + "webhook_id": WEBHOOK_ID, + }, + ) + + @pytest.fixture(name="withings") def mock_withings(): """Mock withings.""" diff --git a/tests/components/withings/snapshots/test_init.ambr b/tests/components/withings/snapshots/test_init.ambr new file mode 100644 index 00000000000000..be221cad313762 --- /dev/null +++ b/tests/components/withings/snapshots/test_init.ambr @@ -0,0 +1,65 @@ +# serializer version: 1 +# name: test_devices[12345] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'withings', + '12345', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Withings', + 'model': None, + 'model_id': None, + 'name': 'henk', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'withings', + 'f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Withings', + 'model': 'Body+', + 'model_id': None, + 'name': 'Body+', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 70a86c79038529..cfecfb1e28ec3b 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -1,4 +1,62 @@ # serializer version: 1 +# name: test_all_entities[sensor.body_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.body_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.body_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Body+ Battery', + 'options': list([ + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'sensor.body_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- # name: test_all_entities[sensor.henk_active_calories_burnt_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 0375d1869d93d9..e07e1f90cb46cf 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -14,6 +14,7 @@ ) from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from homeassistant import config_entries from homeassistant.components import cloud @@ -22,6 +23,7 @@ from homeassistant.components.withings.const import DOMAIN from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.util import dt as dt_util from . import call_webhook, prepare_webhook_setup, setup_integration @@ -569,3 +571,21 @@ async def test_webhook_post( resp.close() assert data["code"] == expected_code + + +async def test_devices( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + device_registry: dr.DeviceRegistry, +) -> None: + """Test devices.""" + await setup_integration(hass, webhook_config_entry) + + await hass.async_block_till_done() + + for device_id in ("12345", "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d"): + device = device_registry.async_get_device({(DOMAIN, device_id)}) + assert device is not None + assert device == snapshot(name=device_id) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 8966006e47fc18..20927c197a401f 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -8,12 +8,14 @@ import pytest from syrupy import SnapshotAssertion +from homeassistant.components.withings import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform 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 . import ( load_activity_fixture, + load_device_fixture, load_goals_fixture, load_measurements_fixture, load_sleep_fixture, @@ -351,3 +353,83 @@ async def test_warning_if_no_entities_created( await setup_integration(hass, polling_config_entry, False) assert "No data found for Withings entry" in caplog.text + + +async def test_device_sensors_created_when_device_data_received( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device sensors will be added if we receive device data.""" + withings.get_devices.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.body_battery") is None + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.body_battery") is None + + withings.get_devices.return_value = load_device_fixture() + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.body_battery") + assert device_registry.async_get_device( + {(DOMAIN, "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d")} + ) + + withings.get_devices.return_value = [] + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.body_battery") is None + assert not device_registry.async_get_device( + {(DOMAIN, "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d")} + ) + + +async def test_device_two_config_entries( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + second_polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device sensors will be added for one config entry only at a time.""" + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.body_battery") is not None + + second_polling_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(second_polling_config_entry.entry_id) + + assert hass.states.get("sensor.not_henk_temperature") is not None + + assert "Platform withings does not generate unique IDs" not in caplog.text + + await hass.config_entries.async_unload(polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.body_battery").state == STATE_UNAVAILABLE + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.body_battery").state != STATE_UNAVAILABLE + + await hass.config_entries.async_setup(polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert "Platform withings does not generate unique IDs" not in caplog.text