mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
450 lines
16 KiB
Python
450 lines
16 KiB
Python
"""Sensor platform for the Google Health integration."""
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Any, cast, override
|
|
|
|
from google_health_api.model import PairedDevice
|
|
|
|
from homeassistant.components.sensor import (
|
|
SensorDeviceClass,
|
|
SensorEntity,
|
|
SensorEntityDescription,
|
|
SensorStateClass,
|
|
)
|
|
from homeassistant.const import (
|
|
PERCENTAGE,
|
|
EntityCategory,
|
|
UnitOfEnergy,
|
|
UnitOfLength,
|
|
UnitOfMass,
|
|
UnitOfTime,
|
|
UnitOfVolume,
|
|
)
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.helpers import device_registry as dr
|
|
from homeassistant.helpers.device_registry import (
|
|
CONNECTION_NETWORK_MAC,
|
|
DeviceEntryType,
|
|
DeviceInfo,
|
|
async_get_device_id_by_identifier,
|
|
)
|
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
from homeassistant.helpers.typing import StateType
|
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|
from homeassistant.util import dt as dt_util
|
|
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem
|
|
|
|
from . import GoogleHealthConfigEntry
|
|
from .const import DOMAIN
|
|
from .coordinator import (
|
|
GoogleHealthActivityCoordinator,
|
|
GoogleHealthBodyCoordinator,
|
|
GoogleHealthDataUpdateCoordinator,
|
|
GoogleHealthDeviceCoordinator,
|
|
GoogleHealthNutritionCoordinator,
|
|
GoogleHealthSleepCoordinator,
|
|
)
|
|
|
|
PARALLEL_UPDATES = 0
|
|
|
|
|
|
@dataclass(frozen=True, kw_only=True)
|
|
class GoogleHealthSensorEntityDescription[
|
|
_CoordinatorT: GoogleHealthDataUpdateCoordinator[Any],
|
|
_ValueT: StateType,
|
|
](SensorEntityDescription):
|
|
"""Class describing Google Health sensor entities."""
|
|
|
|
value_fn: Callable[[Any], _ValueT]
|
|
suggested_unit_fn: Callable[[UnitSystem], str | None] | None = None
|
|
|
|
|
|
ACTIVITY_SENSORS: list[
|
|
GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, Any]
|
|
] = [
|
|
GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, int](
|
|
key="steps",
|
|
translation_key="steps",
|
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
value_fn=lambda data: data.steps.count_sum if data and data.steps else 0,
|
|
),
|
|
GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float](
|
|
key="distance",
|
|
native_unit_of_measurement=UnitOfLength.METERS,
|
|
device_class=SensorDeviceClass.DISTANCE,
|
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
value_fn=lambda data: (
|
|
data.distance.millimeters_sum / 1000.0 if data and data.distance else 0.0
|
|
),
|
|
suggested_unit_fn=lambda units: (
|
|
UnitOfLength.MILES
|
|
if units is US_CUSTOMARY_SYSTEM
|
|
else UnitOfLength.KILOMETERS
|
|
),
|
|
),
|
|
GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float](
|
|
key="active_calories",
|
|
translation_key="active_calories",
|
|
native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE,
|
|
device_class=SensorDeviceClass.ENERGY,
|
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
value_fn=lambda data: (
|
|
data.active_energy_burned.kcal_sum
|
|
if data and data.active_energy_burned
|
|
else 0.0
|
|
),
|
|
),
|
|
GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float](
|
|
key="total_calories",
|
|
translation_key="total_calories",
|
|
native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE,
|
|
device_class=SensorDeviceClass.ENERGY,
|
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
value_fn=lambda data: (
|
|
data.total_calories.kcal_sum if data and data.total_calories else 0.0
|
|
),
|
|
),
|
|
GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, int](
|
|
key="floors",
|
|
translation_key="floors",
|
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
value_fn=lambda data: data.floors.count_sum if data and data.floors else 0,
|
|
),
|
|
]
|
|
|
|
BODY_SENSORS: list[
|
|
GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, Any]
|
|
] = [
|
|
GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, float | None](
|
|
key="weight",
|
|
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
|
|
device_class=SensorDeviceClass.WEIGHT,
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
value_fn=lambda data: (
|
|
data.weight.weight_grams / 1000.0 if data and data.weight else None
|
|
),
|
|
suggested_unit_fn=lambda units: (
|
|
UnitOfMass.POUNDS if units is US_CUSTOMARY_SYSTEM else None
|
|
),
|
|
),
|
|
GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, int | None](
|
|
key="resting_heart_rate",
|
|
translation_key="resting_heart_rate",
|
|
native_unit_of_measurement="bpm",
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
value_fn=lambda data: (
|
|
data.resting_heart_rate.beats_per_minute
|
|
if data and data.resting_heart_rate
|
|
else None
|
|
),
|
|
),
|
|
GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, float | None](
|
|
key="body_fat",
|
|
translation_key="body_fat",
|
|
native_unit_of_measurement=PERCENTAGE,
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
value_fn=lambda data: (
|
|
data.body_fat.percentage if data and data.body_fat else None
|
|
),
|
|
),
|
|
]
|
|
|
|
SLEEP_SENSORS: list[
|
|
GoogleHealthSensorEntityDescription[GoogleHealthSleepCoordinator, Any]
|
|
] = [
|
|
GoogleHealthSensorEntityDescription[GoogleHealthSleepCoordinator, int | None](
|
|
key="sleep_asleep",
|
|
translation_key="sleep_asleep",
|
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
|
device_class=SensorDeviceClass.DURATION,
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
value_fn=lambda data: (
|
|
data.sleep.summary.minutes_asleep
|
|
if data and data.sleep and data.sleep.summary
|
|
else None
|
|
),
|
|
),
|
|
GoogleHealthSensorEntityDescription[GoogleHealthSleepCoordinator, int | None](
|
|
key="sleep_awake",
|
|
translation_key="sleep_awake",
|
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
|
device_class=SensorDeviceClass.DURATION,
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
value_fn=lambda data: (
|
|
data.sleep.summary.minutes_awake
|
|
if data and data.sleep and data.sleep.summary
|
|
else None
|
|
),
|
|
),
|
|
GoogleHealthSensorEntityDescription[GoogleHealthSleepCoordinator, int | None](
|
|
key="sleep_in_bed",
|
|
translation_key="sleep_in_bed",
|
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
|
device_class=SensorDeviceClass.DURATION,
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
value_fn=lambda data: (
|
|
data.sleep.summary.minutes_in_sleep_period
|
|
if data and data.sleep and data.sleep.summary
|
|
else None
|
|
),
|
|
),
|
|
GoogleHealthSensorEntityDescription[GoogleHealthSleepCoordinator, int | None](
|
|
key="sleep_to_fall_asleep",
|
|
translation_key="sleep_to_fall_asleep",
|
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
|
device_class=SensorDeviceClass.DURATION,
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
value_fn=lambda data: (
|
|
data.sleep.summary.minutes_to_fall_asleep
|
|
if data and data.sleep and data.sleep.summary
|
|
else None
|
|
),
|
|
),
|
|
GoogleHealthSensorEntityDescription[GoogleHealthSleepCoordinator, int | None](
|
|
key="sleep_after_wakeup",
|
|
translation_key="sleep_after_wakeup",
|
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
|
device_class=SensorDeviceClass.DURATION,
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
value_fn=lambda data: (
|
|
data.sleep.summary.minutes_after_wake_up
|
|
if data and data.sleep and data.sleep.summary
|
|
else None
|
|
),
|
|
),
|
|
]
|
|
|
|
|
|
NUTRITION_SENSORS: list[
|
|
GoogleHealthSensorEntityDescription[GoogleHealthNutritionCoordinator, Any]
|
|
] = [
|
|
GoogleHealthSensorEntityDescription[GoogleHealthNutritionCoordinator, float](
|
|
key="hydration",
|
|
translation_key="hydration",
|
|
native_unit_of_measurement=UnitOfVolume.LITERS,
|
|
device_class=SensorDeviceClass.VOLUME,
|
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
value_fn=lambda data: (
|
|
data.hydration.amount_consumed.milliliters_sum / 1000.0
|
|
if data and data.hydration and data.hydration.amount_consumed
|
|
else 0.0
|
|
),
|
|
suggested_unit_fn=lambda units: (
|
|
UnitOfVolume.FLUID_OUNCES if units is US_CUSTOMARY_SYSTEM else None
|
|
),
|
|
),
|
|
GoogleHealthSensorEntityDescription[GoogleHealthNutritionCoordinator, float](
|
|
key="calories_consumed",
|
|
translation_key="calories_consumed",
|
|
native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE,
|
|
device_class=SensorDeviceClass.ENERGY,
|
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
value_fn=lambda data: (
|
|
data.nutrition.energy.kcal_sum
|
|
if data and data.nutrition and data.nutrition.energy
|
|
else 0.0
|
|
),
|
|
),
|
|
]
|
|
|
|
|
|
@dataclass(frozen=True, kw_only=True)
|
|
class GoogleHealthDeviceSensorEntityDescription(SensorEntityDescription):
|
|
"""Class describing Google Health device sensor entities."""
|
|
|
|
value_fn: Callable[[PairedDevice], datetime | StateType]
|
|
|
|
|
|
DEVICE_SENSORS: list[GoogleHealthDeviceSensorEntityDescription] = [
|
|
GoogleHealthDeviceSensorEntityDescription(
|
|
key="battery_level",
|
|
device_class=SensorDeviceClass.BATTERY,
|
|
native_unit_of_measurement=PERCENTAGE,
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
value_fn=lambda device: device.battery_level,
|
|
),
|
|
GoogleHealthDeviceSensorEntityDescription(
|
|
key="last_sync_time",
|
|
translation_key="last_sync_time",
|
|
device_class=SensorDeviceClass.TIMESTAMP,
|
|
value_fn=lambda device: (
|
|
dt_util.parse_datetime(device.last_sync_time)
|
|
if device.last_sync_time
|
|
else None
|
|
),
|
|
),
|
|
]
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: GoogleHealthConfigEntry,
|
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
) -> None:
|
|
"""Set up the Google Health sensor platform."""
|
|
data = entry.runtime_data
|
|
|
|
entities: list[SensorEntity] = []
|
|
if (activity_coordinator := data.activity_coordinator) is not None:
|
|
entities.extend(
|
|
GoogleHealthSensor(activity_coordinator, entry.entry_id, description)
|
|
for description in ACTIVITY_SENSORS
|
|
)
|
|
if (body_coordinator := data.body_coordinator) is not None:
|
|
entities.extend(
|
|
GoogleHealthSensor(body_coordinator, entry.entry_id, description)
|
|
for description in BODY_SENSORS
|
|
)
|
|
if (sleep_coordinator := data.sleep_coordinator) is not None:
|
|
entities.extend(
|
|
GoogleHealthSensor(sleep_coordinator, entry.entry_id, description)
|
|
for description in SLEEP_SENSORS
|
|
)
|
|
if (nutrition_coordinator := data.nutrition_coordinator) is not None:
|
|
entities.extend(
|
|
GoogleHealthSensor(nutrition_coordinator, entry.entry_id, description)
|
|
for description in NUTRITION_SENSORS
|
|
)
|
|
if entities:
|
|
async_add_entities(entities)
|
|
|
|
if (device_coordinator := data.device_coordinator) is not None:
|
|
added_device_ids: set[str] = set()
|
|
|
|
@callback
|
|
def async_add_device_entities() -> None:
|
|
"""Add entities for new devices and remove stale devices."""
|
|
current_device_ids = set(device_coordinator.data)
|
|
# Drop any devices no longer present in the API. They will be removed below
|
|
added_device_ids.intersection_update(current_device_ids)
|
|
|
|
# Add any newly discovered devices
|
|
new_entities: list[SensorEntity] = []
|
|
for device in device_coordinator.data.values():
|
|
if device.device_id in added_device_ids:
|
|
continue
|
|
added_device_ids.add(device.device_id)
|
|
new_entities.extend(
|
|
GoogleHealthDeviceSensor(
|
|
device_coordinator,
|
|
entry.entry_id,
|
|
device,
|
|
description,
|
|
)
|
|
for description in DEVICE_SENSORS
|
|
)
|
|
if new_entities:
|
|
async_add_entities(new_entities)
|
|
|
|
# Remove any stale devices
|
|
device_registry = dr.async_get(hass)
|
|
active_identifiers = {(DOMAIN, entry.entry_id)} | {
|
|
(DOMAIN, device_id) for device_id in current_device_ids
|
|
}
|
|
for device_entry in dr.async_entries_for_config_entry(
|
|
device_registry, entry.entry_id
|
|
):
|
|
if not set(device_entry.identifiers) & active_identifiers:
|
|
device_registry.async_remove_device(device_entry.id)
|
|
|
|
async_add_device_entities()
|
|
entry.async_on_unload(
|
|
device_coordinator.async_add_listener(async_add_device_entities)
|
|
)
|
|
|
|
|
|
class GoogleHealthSensor[_CoordinatorT: GoogleHealthDataUpdateCoordinator[Any]](
|
|
CoordinatorEntity[_CoordinatorT], SensorEntity
|
|
):
|
|
"""Generic Google Health sensor entity."""
|
|
|
|
_attr_has_entity_name = True
|
|
entity_description: GoogleHealthSensorEntityDescription[_CoordinatorT, Any]
|
|
|
|
def __init__(
|
|
self,
|
|
coordinator: _CoordinatorT,
|
|
entry_id: str,
|
|
description: GoogleHealthSensorEntityDescription[_CoordinatorT, Any],
|
|
) -> None:
|
|
"""Initialize the sensor."""
|
|
super().__init__(coordinator)
|
|
self.entity_description = description
|
|
self._attr_unique_id = f"{entry_id}_{description.key}"
|
|
self._attr_device_info = DeviceInfo(
|
|
entry_type=DeviceEntryType.SERVICE,
|
|
identifiers={(DOMAIN, entry_id)},
|
|
manufacturer="Google",
|
|
)
|
|
|
|
@property
|
|
@override
|
|
def native_value(self) -> StateType:
|
|
"""Return the state of the sensor."""
|
|
return cast(StateType, self.entity_description.value_fn(self.coordinator.data))
|
|
|
|
@property
|
|
@override
|
|
def suggested_unit_of_measurement(self) -> str | None:
|
|
"""Return the suggested unit of measurement."""
|
|
if (suggested_unit_fn := self.entity_description.suggested_unit_fn) is not None:
|
|
return suggested_unit_fn(self.hass.config.units)
|
|
|
|
return super().suggested_unit_of_measurement
|
|
|
|
|
|
class GoogleHealthDeviceSensor(
|
|
CoordinatorEntity[GoogleHealthDeviceCoordinator], SensorEntity
|
|
):
|
|
"""Device-specific Google Health sensor entity."""
|
|
|
|
_attr_has_entity_name = True
|
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
|
entity_description: GoogleHealthDeviceSensorEntityDescription
|
|
|
|
def __init__(
|
|
self,
|
|
coordinator: GoogleHealthDeviceCoordinator,
|
|
entry_id: str,
|
|
device: PairedDevice,
|
|
description: GoogleHealthDeviceSensorEntityDescription,
|
|
) -> None:
|
|
"""Initialize the device sensor."""
|
|
super().__init__(coordinator)
|
|
self.entity_description = description
|
|
self.device_id = device.device_id
|
|
self._attr_unique_id = f"{device.device_id}_{description.key}"
|
|
|
|
# device_version is the product name (e.g. 'Fitbit Charge 6', 'Pixel Watch 3')
|
|
device_info = DeviceInfo(
|
|
identifiers={(DOMAIN, device.device_id)},
|
|
name=device.device_version
|
|
or (device.device_type.title() if device.device_type else "Device"),
|
|
model=device.device_type.title() if device.device_type else None,
|
|
sw_version=device.device_version,
|
|
via_device_id=async_get_device_id_by_identifier(
|
|
coordinator.hass,
|
|
(DOMAIN, entry_id),
|
|
config_entry_id=entry_id,
|
|
),
|
|
)
|
|
|
|
if device.mac_address:
|
|
device_info["connections"] = {(CONNECTION_NETWORK_MAC, device.mac_address)}
|
|
self._attr_device_info = device_info
|
|
|
|
@property
|
|
@override
|
|
def available(self) -> bool:
|
|
"""Return True if entity is available."""
|
|
return super().available and self.device_id in self.coordinator.data
|
|
|
|
@property
|
|
@override
|
|
def native_value(self) -> datetime | StateType:
|
|
"""Return the state of the sensor."""
|
|
device = self.coordinator.data[self.device_id]
|
|
return self.entity_description.value_fn(device)
|