Move Withings entity descriptions to platforms (#101820)

This commit is contained in:
Joost Lekkerkerker 2023-10-12 13:42:00 +02:00 committed by GitHub
parent 6450ae8d28
commit 3e4edc8edd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 40 additions and 164 deletions

View File

@ -1,42 +1,17 @@
"""Sensors flow for Withings.""" """Sensors flow for Withings."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from withings_api.common import NotifyAppli
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
BinarySensorEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, Measurement from .const import DOMAIN
from .coordinator import WithingsDataUpdateCoordinator from .coordinator import WithingsDataUpdateCoordinator
from .entity import WithingsEntity, WithingsEntityDescription from .entity import WithingsEntity
@dataclass
class WithingsBinarySensorEntityDescription(
BinarySensorEntityDescription, WithingsEntityDescription
):
"""Immutable class for describing withings binary sensor data."""
BINARY_SENSORS = [
# Webhook measurements.
WithingsBinarySensorEntityDescription(
key=Measurement.IN_BED.value,
measurement=Measurement.IN_BED,
measure_type=NotifyAppli.BED_IN,
translation_key="in_bed",
icon="mdi:bed",
device_class=BinarySensorDeviceClass.OCCUPANCY,
),
]
async def async_setup_entry( async def async_setup_entry(
@ -47,9 +22,7 @@ async def async_setup_entry(
"""Set up the sensor config entry.""" """Set up the sensor config entry."""
coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities = [ entities = [WithingsBinarySensor(coordinator)]
WithingsBinarySensor(coordinator, attribute) for attribute in BINARY_SENSORS
]
async_add_entities(entities) async_add_entities(entities)
@ -57,7 +30,13 @@ async def async_setup_entry(
class WithingsBinarySensor(WithingsEntity, BinarySensorEntity): class WithingsBinarySensor(WithingsEntity, BinarySensorEntity):
"""Implementation of a Withings sensor.""" """Implementation of a Withings sensor."""
entity_description: WithingsBinarySensorEntityDescription _attr_icon = "mdi:bed"
_attr_translation_key = "in_bed"
_attr_device_class = BinarySensorDeviceClass.OCCUPANCY
def __init__(self, coordinator: WithingsDataUpdateCoordinator) -> None:
"""Initialize binary sensor."""
super().__init__(coordinator, "in_bed")
@property @property
def is_on(self) -> bool | None: def is_on(self) -> bool | None:

View File

@ -1,46 +1,26 @@
"""Base entity for Withings.""" """Base entity for Withings."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from withings_api.common import GetSleepSummaryField, MeasureType, NotifyAppli
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, Measurement from .const import DOMAIN
from .coordinator import WithingsDataUpdateCoordinator from .coordinator import WithingsDataUpdateCoordinator
@dataclass
class WithingsEntityDescriptionMixin:
"""Mixin for describing withings data."""
measurement: Measurement
measure_type: NotifyAppli | GetSleepSummaryField | MeasureType
@dataclass
class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixin):
"""Immutable class for describing withings data."""
class WithingsEntity(CoordinatorEntity[WithingsDataUpdateCoordinator]): class WithingsEntity(CoordinatorEntity[WithingsDataUpdateCoordinator]):
"""Base class for withings entities.""" """Base class for withings entities."""
entity_description: WithingsEntityDescription
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__( def __init__(
self, self,
coordinator: WithingsDataUpdateCoordinator, coordinator: WithingsDataUpdateCoordinator,
description: WithingsEntityDescription, key: str,
) -> None: ) -> None:
"""Initialize the Withings entity.""" """Initialize the Withings entity."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = description self._attr_unique_id = f"withings_{coordinator.config_entry.unique_id}_{key}"
self._attr_unique_id = f"withings_{coordinator.config_entry.unique_id}_{description.measurement.value}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))}, identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))},
manufacturer="Withings", manufacturer="Withings",

View File

@ -33,14 +33,22 @@ from .const import (
Measurement, Measurement,
) )
from .coordinator import WithingsDataUpdateCoordinator from .coordinator import WithingsDataUpdateCoordinator
from .entity import WithingsEntity, WithingsEntityDescription from .entity import WithingsEntity
@dataclass
class WithingsEntityDescriptionMixin:
"""Mixin for describing withings data."""
measurement: Measurement
measure_type: GetSleepSummaryField | MeasureType
@dataclass @dataclass
class WithingsSensorEntityDescription( class WithingsSensorEntityDescription(
SensorEntityDescription, WithingsEntityDescription SensorEntityDescription, WithingsEntityDescriptionMixin
): ):
"""Immutable class for describing withings binary sensor data.""" """Immutable class for describing withings data."""
SENSORS = [ SENSORS = [
@ -371,6 +379,15 @@ class WithingsSensor(WithingsEntity, SensorEntity):
entity_description: WithingsSensorEntityDescription entity_description: WithingsSensorEntityDescription
def __init__(
self,
coordinator: WithingsDataUpdateCoordinator,
entity_description: WithingsSensorEntityDescription,
) -> None:
"""Initialize sensor."""
super().__init__(coordinator, entity_description.key)
self.entity_description = entity_description
@property @property
def native_value(self) -> None | str | int | float: def native_value(self) -> None | str | int | float:
"""Return the state of the entity.""" """Return the state of the entity."""

View File

@ -1,137 +1,37 @@
"""Tests for the Withings component.""" """Tests for the Withings component."""
from datetime import timedelta from datetime import timedelta
from typing import Any
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from withings_api.common import NotifyAppli
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.withings.const import DOMAIN, Measurement from homeassistant.components.withings.const import DOMAIN
from homeassistant.components.withings.entity import WithingsEntityDescription
from homeassistant.components.withings.sensor import SENSORS from homeassistant.components.withings.sensor import SENSORS
from homeassistant.const import STATE_UNAVAILABLE from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, State from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_registry import EntityRegistry
from . import call_webhook, prepare_webhook_setup, setup_integration from . import setup_integration
from .conftest import USER_ID, WEBHOOK_ID from .conftest import USER_ID
from tests.common import MockConfigEntry, async_fire_time_changed from tests.common import MockConfigEntry, async_fire_time_changed
from tests.typing import ClientSessionGenerator
WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = {
attr.measurement: attr for attr in SENSORS
}
EXPECTED_DATA = (
(Measurement.WEIGHT_KG, 70.0),
(Measurement.FAT_MASS_KG, 5.0),
(Measurement.FAT_FREE_MASS_KG, 60.0),
(Measurement.MUSCLE_MASS_KG, 50.0),
(Measurement.BONE_MASS_KG, 10.0),
(Measurement.HEIGHT_M, 2.0),
(Measurement.FAT_RATIO_PCT, 0.07),
(Measurement.DIASTOLIC_MMHG, 70.0),
(Measurement.SYSTOLIC_MMGH, 100.0),
(Measurement.HEART_PULSE_BPM, 60.0),
(Measurement.SPO2_PCT, 0.95),
(Measurement.HYDRATION, 0.95),
(Measurement.PWV, 100.0),
(Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, 160.0),
(Measurement.SLEEP_DEEP_DURATION_SECONDS, 322),
(Measurement.SLEEP_HEART_RATE_AVERAGE, 164.0),
(Measurement.SLEEP_HEART_RATE_MAX, 165.0),
(Measurement.SLEEP_HEART_RATE_MIN, 166.0),
(Measurement.SLEEP_LIGHT_DURATION_SECONDS, 334),
(Measurement.SLEEP_REM_DURATION_SECONDS, 336),
(Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, 169.0),
(Measurement.SLEEP_RESPIRATORY_RATE_MAX, 170.0),
(Measurement.SLEEP_RESPIRATORY_RATE_MIN, 171.0),
(Measurement.SLEEP_SCORE, 222),
(Measurement.SLEEP_SNORING, 173.0),
(Measurement.SLEEP_SNORING_EPISODE_COUNT, 348),
(Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, 162.0),
(Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, 163.0),
(Measurement.SLEEP_WAKEUP_COUNT, 350),
(Measurement.SLEEP_WAKEUP_DURATION_SECONDS, 176.0),
)
async def async_get_entity_id( async def async_get_entity_id(
hass: HomeAssistant, hass: HomeAssistant,
description: WithingsEntityDescription, key: str,
user_id: int, user_id: int,
platform: str, platform: str,
) -> str | None: ) -> str | None:
"""Get an entity id for a user's attribute.""" """Get an entity id for a user's attribute."""
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)
unique_id = f"withings_{user_id}_{description.measurement.value}" unique_id = f"withings_{user_id}_{key}"
return entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) return entity_registry.async_get_entity_id(platform, DOMAIN, unique_id)
def async_assert_state_equals(
entity_id: str,
state_obj: State,
expected: Any,
description: WithingsEntityDescription,
) -> None:
"""Assert at given state matches what is expected."""
assert state_obj, f"Expected entity {entity_id} to exist but it did not"
assert state_obj.state == str(expected), (
f"Expected {expected} but was {state_obj.state} "
f"for measure {description.measurement}, {entity_id}"
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_default_enabled_entities(
hass: HomeAssistant,
withings: AsyncMock,
webhook_config_entry: MockConfigEntry,
hass_client_no_auth: ClientSessionGenerator,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test entities enabled by default."""
await setup_integration(hass, webhook_config_entry)
await prepare_webhook_setup(hass, freezer)
entity_registry: EntityRegistry = er.async_get(hass)
client = await hass_client_no_auth()
# Assert entities should exist.
for attribute in SENSORS:
entity_id = await async_get_entity_id(hass, attribute, USER_ID, SENSOR_DOMAIN)
assert entity_id
assert entity_registry.async_is_registered(entity_id)
resp = await call_webhook(
hass,
WEBHOOK_ID,
{"userid": USER_ID, "appli": NotifyAppli.SLEEP},
client,
)
assert resp.message_code == 0
resp = await call_webhook(
hass,
WEBHOOK_ID,
{"userid": USER_ID, "appli": NotifyAppli.WEIGHT},
client,
)
assert resp.message_code == 0
for measurement, expected in EXPECTED_DATA:
attribute = WITHINGS_MEASUREMENTS_MAP[measurement]
entity_id = await async_get_entity_id(hass, attribute, USER_ID, SENSOR_DOMAIN)
state_obj = hass.states.get(entity_id)
async_assert_state_equals(entity_id, state_obj, expected, attribute)
@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities( async def test_all_entities(
hass: HomeAssistant, hass: HomeAssistant,
@ -143,7 +43,7 @@ async def test_all_entities(
await setup_integration(hass, polling_config_entry) await setup_integration(hass, polling_config_entry)
for sensor in SENSORS: for sensor in SENSORS:
entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN) entity_id = await async_get_entity_id(hass, sensor.key, USER_ID, SENSOR_DOMAIN)
assert hass.states.get(entity_id) == snapshot assert hass.states.get(entity_id) == snapshot