Add devices to Withings (#126853)

This commit is contained in:
Joost Lekkerkerker 2024-09-30 21:06:51 +02:00 committed by GitHub
parent 05288dad51
commit 10805805fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 417 additions and 5 deletions

View File

@ -48,6 +48,7 @@ from .coordinator import (
WithingsActivityDataUpdateCoordinator, WithingsActivityDataUpdateCoordinator,
WithingsBedPresenceDataUpdateCoordinator, WithingsBedPresenceDataUpdateCoordinator,
WithingsDataUpdateCoordinator, WithingsDataUpdateCoordinator,
WithingsDeviceDataUpdateCoordinator,
WithingsGoalsDataUpdateCoordinator, WithingsGoalsDataUpdateCoordinator,
WithingsMeasurementDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator,
WithingsSleepDataUpdateCoordinator, WithingsSleepDataUpdateCoordinator,
@ -73,6 +74,7 @@ class WithingsData:
goals_coordinator: WithingsGoalsDataUpdateCoordinator goals_coordinator: WithingsGoalsDataUpdateCoordinator
activity_coordinator: WithingsActivityDataUpdateCoordinator activity_coordinator: WithingsActivityDataUpdateCoordinator
workout_coordinator: WithingsWorkoutDataUpdateCoordinator workout_coordinator: WithingsWorkoutDataUpdateCoordinator
device_coordinator: WithingsDeviceDataUpdateCoordinator
coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set) coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set)
def __post_init__(self) -> None: def __post_init__(self) -> None:
@ -84,6 +86,7 @@ class WithingsData:
self.goals_coordinator, self.goals_coordinator,
self.activity_coordinator, self.activity_coordinator,
self.workout_coordinator, self.workout_coordinator,
self.device_coordinator,
} }
@ -122,6 +125,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WithingsConfigEntry) ->
goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client), goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client),
activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client), activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client),
workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, client), workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, client),
device_coordinator=WithingsDeviceDataUpdateCoordinator(hass, client),
) )
for coordinator in withings_data.coordinators: for coordinator in withings_data.coordinators:

View File

@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
from aiowithings import ( from aiowithings import (
Activity, Activity,
Device,
Goals, Goals,
MeasurementPosition, MeasurementPosition,
MeasurementType, MeasurementType,
@ -291,3 +292,17 @@ class WithingsWorkoutDataUpdateCoordinator(
self._previous_data = latest_workout self._previous_data = latest_workout
self._last_valid_update = latest_workout.end_date self._last_valid_update = latest_workout.end_date
return self._previous_data 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}

View File

@ -4,11 +4,16 @@ from __future__ import annotations
from typing import Any from typing import Any
from aiowithings import Device
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import WithingsDataUpdateCoordinator from .coordinator import (
WithingsDataUpdateCoordinator,
WithingsDeviceDataUpdateCoordinator,
)
class WithingsEntity[_T: WithingsDataUpdateCoordinator[Any]](CoordinatorEntity[_T]): class WithingsEntity[_T: WithingsDataUpdateCoordinator[Any]](CoordinatorEntity[_T]):
@ -28,3 +33,35 @@ class WithingsEntity[_T: WithingsDataUpdateCoordinator[Any]](CoordinatorEntity[_
identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))}, identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))},
manufacturer="Withings", 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]

View File

@ -136,6 +136,14 @@
}, },
"workout_duration": { "workout_duration": {
"default": "mdi:timer" "default": "mdi:timer"
},
"battery": {
"default": "mdi:battery-off",
"state": {
"low": "mdi:battery-20",
"medium": "mdi:battery-50",
"high": "mdi:battery"
}
} }
} }
} }

View File

@ -9,6 +9,7 @@ from typing import Any
from aiowithings import ( from aiowithings import (
Activity, Activity,
Device,
Goals, Goals,
MeasurementPosition, MeasurementPosition,
MeasurementType, MeasurementType,
@ -23,6 +24,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription, SensorEntityDescription,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ( from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
Platform, Platform,
@ -33,8 +35,8 @@ from homeassistant.const import (
UnitOfTime, UnitOfTime,
) )
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.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@ -51,12 +53,13 @@ from .const import (
from .coordinator import ( from .coordinator import (
WithingsActivityDataUpdateCoordinator, WithingsActivityDataUpdateCoordinator,
WithingsDataUpdateCoordinator, WithingsDataUpdateCoordinator,
WithingsDeviceDataUpdateCoordinator,
WithingsGoalsDataUpdateCoordinator, WithingsGoalsDataUpdateCoordinator,
WithingsMeasurementDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator,
WithingsSleepDataUpdateCoordinator, WithingsSleepDataUpdateCoordinator,
WithingsWorkoutDataUpdateCoordinator, WithingsWorkoutDataUpdateCoordinator,
) )
from .entity import WithingsEntity from .entity import WithingsDeviceEntity, WithingsEntity
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@ -650,6 +653,24 @@ WORKOUT_SENSORS = [
] ]
@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]: def get_current_goals(goals: Goals) -> set[str]:
"""Return a list of present goals.""" """Return a list of present goals."""
result = set() result = set()
@ -800,6 +821,48 @@ async def async_setup_entry(
_async_add_workout_entities _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: if not entities:
LOGGER.warning( LOGGER.warning(
"No data found for Withings entry %s, sensors will be added when new data is available" "No data found for Withings entry %s, sensors will be added when new data is available"
@ -923,3 +986,24 @@ class WithingsWorkoutSensor(
if not self.coordinator.data: if not self.coordinator.data:
return None return None
return self.entity_description.value_fn(self.coordinator.data) 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)

View File

@ -307,6 +307,14 @@
}, },
"workout_duration": { "workout_duration": {
"name": "Last workout duration" "name": "Last workout duration"
},
"battery": {
"name": "[%key:component::sensor::entity_component::battery::name%]",
"state": {
"low": "Low",
"medium": "Medium",
"high": "High"
}
} }
} }
} }

View File

@ -6,7 +6,7 @@ from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
from aiohttp.test_utils import TestClient 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 freezegun.api import FrozenDateTimeFactory
from homeassistant.components.webhook import async_generate_url from homeassistant.components.webhook import async_generate_url
@ -109,3 +109,11 @@ def load_sleep_fixture(
"""Return sleep summaries from fixture.""" """Return sleep summaries from fixture."""
sleep_json = load_json_array_fixture("withings/sleep_summaries.json") sleep_json = load_json_array_fixture("withings/sleep_summaries.json")
return [SleepSummary.from_api(sleep_summary) for sleep_summary in sleep_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]

View File

@ -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") @pytest.fixture(name="withings")
def mock_withings(): def mock_withings():
"""Mock withings.""" """Mock withings."""

View File

@ -0,0 +1,65 @@
# serializer version: 1
# name: test_devices[12345]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
})
# ---
# name: test_devices[f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': <ANY>,
})
# ---

View File

@ -1,4 +1,62 @@
# serializer version: 1 # 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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.body_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'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': <ANY>,
'entity_id': 'sensor.body_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'high',
})
# ---
# name: test_all_entities[sensor.henk_active_calories_burnt_today-entry] # name: test_all_entities[sensor.henk_active_calories_burnt_today-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({

View File

@ -14,6 +14,7 @@ from aiowithings import (
) )
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy import SnapshotAssertion
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import cloud from homeassistant.components import cloud
@ -22,6 +23,7 @@ from homeassistant.components.webhook import async_generate_url
from homeassistant.components.withings.const import DOMAIN from homeassistant.components.withings.const import DOMAIN
from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import call_webhook, prepare_webhook_setup, setup_integration from . import call_webhook, prepare_webhook_setup, setup_integration
@ -569,3 +571,21 @@ async def test_webhook_post(
resp.close() resp.close()
assert data["code"] == expected_code 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)

View File

@ -8,12 +8,14 @@ from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from homeassistant.components.withings import DOMAIN
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform
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 . import ( from . import (
load_activity_fixture, load_activity_fixture,
load_device_fixture,
load_goals_fixture, load_goals_fixture,
load_measurements_fixture, load_measurements_fixture,
load_sleep_fixture, load_sleep_fixture,
@ -351,3 +353,83 @@ async def test_warning_if_no_entities_created(
await setup_integration(hass, polling_config_entry, False) await setup_integration(hass, polling_config_entry, False)
assert "No data found for Withings entry" in caplog.text 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