Skip to content

Commit

Permalink
Add devices to Withings (#126853)
Browse files Browse the repository at this point in the history
  • Loading branch information
joostlek authored Sep 30, 2024
1 parent 05288da commit 1080580
Show file tree
Hide file tree
Showing 12 changed files with 417 additions and 5 deletions.
4 changes: 4 additions & 0 deletions homeassistant/components/withings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
WithingsActivityDataUpdateCoordinator,
WithingsBedPresenceDataUpdateCoordinator,
WithingsDataUpdateCoordinator,
WithingsDeviceDataUpdateCoordinator,
WithingsGoalsDataUpdateCoordinator,
WithingsMeasurementDataUpdateCoordinator,
WithingsSleepDataUpdateCoordinator,
Expand All @@ -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:
Expand All @@ -84,6 +86,7 @@ def __post_init__(self) -> None:
self.goals_coordinator,
self.activity_coordinator,
self.workout_coordinator,
self.device_coordinator,
}


Expand Down Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions homeassistant/components/withings/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from aiowithings import (
Activity,
Device,
Goals,
MeasurementPosition,
MeasurementType,
Expand Down Expand Up @@ -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}
39 changes: 38 additions & 1 deletion homeassistant/components/withings/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]):
Expand All @@ -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]
8 changes: 8 additions & 0 deletions homeassistant/components/withings/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
Expand Down
88 changes: 86 additions & 2 deletions homeassistant/components/withings/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from aiowithings import (
Activity,
Device,
Goals,
MeasurementPosition,
MeasurementType,
Expand All @@ -23,6 +24,7 @@
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
PERCENTAGE,
Platform,
Expand All @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
8 changes: 8 additions & 0 deletions homeassistant/components/withings/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
Expand Down
10 changes: 9 additions & 1 deletion tests/components/withings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
23 changes: 23 additions & 0 deletions tests/components/withings/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
65 changes: 65 additions & 0 deletions tests/components/withings/snapshots/test_init.ambr
Original file line number Diff line number Diff line change
@@ -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>,
})
# ---
Loading

0 comments on commit 1080580

Please sign in to comment.