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,
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 @@ class WithingsData:
self.goals_coordinator,
self.activity_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),
activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client),
workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, client),
device_coordinator=WithingsDeviceDataUpdateCoordinator(hass, client),
)
for coordinator in withings_data.coordinators:

View File

@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
from aiowithings import (
Activity,
Device,
Goals,
MeasurementPosition,
MeasurementType,
@ -291,3 +292,17 @@ class WithingsWorkoutDataUpdateCoordinator(
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}

View File

@ -4,11 +4,16 @@ from __future__ import annotations
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 @@ class WithingsEntity[_T: WithingsDataUpdateCoordinator[Any]](CoordinatorEntity[_
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]

View File

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

View File

@ -9,6 +9,7 @@ from typing import Any
from aiowithings import (
Activity,
Device,
Goals,
MeasurementPosition,
MeasurementType,
@ -23,6 +24,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
PERCENTAGE,
Platform,
@ -33,8 +35,8 @@ from homeassistant.const import (
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 .const import (
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 @@ 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]:
"""Return a list of present goals."""
result = set()
@ -800,6 +821,48 @@ async def async_setup_entry(
_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 @@ class WithingsWorkoutSensor(
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)

View File

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

View File

@ -6,7 +6,7 @@ from typing import Any
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]

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")
def 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
# 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]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@ -14,6 +14,7 @@ from aiowithings import (
)
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.webhook import async_generate_url
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)

View File

@ -8,12 +8,14 @@ from freezegun.api import FrozenDateTimeFactory
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