From d78ee96e2a862da167bc99f35186ec78eff93cf6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 8 Oct 2023 23:12:59 -0700 Subject: [PATCH] Update fitbit device fetch to use a data update coordinator (#101619) * Add a fitbit device update coordinator * Remove unnecessary debug output * Update comments * Update fitbit coordinator exception handling and test coverage * Handle reauth failures in other sensors * Fix scope changes after rebase. --- homeassistant/components/fitbit/__init__.py | 14 +- homeassistant/components/fitbit/api.py | 4 +- homeassistant/components/fitbit/const.py | 25 +- .../components/fitbit/coordinator.py | 48 ++++ homeassistant/components/fitbit/model.py | 41 +++ homeassistant/components/fitbit/sensor.py | 240 +++++++++--------- tests/components/fitbit/test_init.py | 49 ++++ tests/components/fitbit/test_sensor.py | 100 +++++++- 8 files changed, 389 insertions(+), 132 deletions(-) create mode 100644 homeassistant/components/fitbit/coordinator.py diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py index acf3014fb33..40ea9fb1152 100644 --- a/homeassistant/components/fitbit/__init__.py +++ b/homeassistant/components/fitbit/__init__.py @@ -8,8 +8,10 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow from . import api -from .const import DOMAIN +from .const import DOMAIN, FitbitScope +from .coordinator import FitbitData, FitbitDeviceCoordinator from .exceptions import FitbitApiException, FitbitAuthException +from .model import config_from_entry_data PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -34,7 +36,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except FitbitApiException as err: raise ConfigEntryNotReady from err - hass.data[DOMAIN][entry.entry_id] = fitbit_api + fitbit_config = config_from_entry_data(entry.data) + coordinator: FitbitDeviceCoordinator | None = None + if fitbit_config.is_allowed_resource(FitbitScope.DEVICE, "devices/battery"): + coordinator = FitbitDeviceCoordinator(hass, fitbit_api) + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = FitbitData( + api=fitbit_api, device_coordinator=coordinator + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index dab64724e1c..ceb619c4385 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -134,10 +134,10 @@ class FitbitApi(ABC): return await self._hass.async_add_executor_job(func) except HTTPUnauthorized as err: _LOGGER.debug("Unauthorized error from fitbit API: %s", err) - raise FitbitAuthException from err + raise FitbitAuthException("Authentication error from fitbit API") from err except HTTPException as err: _LOGGER.debug("Error from fitbit API: %s", err) - raise FitbitApiException from err + raise FitbitApiException("Error from fitbit API") from err class OAuthFitbitApi(FitbitApi): diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index 9c77ea79a4f..45b81b3919e 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -67,14 +67,21 @@ class FitbitUnitSystem(StrEnum): """Use United Kingdom units.""" +CONF_SCOPE: Final = "scope" + + +class FitbitScope(StrEnum): + """OAuth scopes for fitbit.""" + + ACTIVITY = "activity" + HEART_RATE = "heartrate" + NUTRITION = "nutrition" + PROFILE = "profile" + DEVICE = "settings" + SLEEP = "sleep" + WEIGHT = "weight" + + OAUTH2_AUTHORIZE = "https://www.fitbit.com/oauth2/authorize" OAUTH2_TOKEN = "https://api.fitbit.com/oauth2/token" -OAUTH_SCOPES = [ - "activity", - "heartrate", - "nutrition", - "profile", - "settings", - "sleep", - "weight", -] +OAUTH_SCOPES = [scope.value for scope in FitbitScope] diff --git a/homeassistant/components/fitbit/coordinator.py b/homeassistant/components/fitbit/coordinator.py new file mode 100644 index 00000000000..5c156955f90 --- /dev/null +++ b/homeassistant/components/fitbit/coordinator.py @@ -0,0 +1,48 @@ +"""Coordinator for fetching data from fitbit API.""" + +import asyncio +from dataclasses import dataclass +import datetime +import logging +from typing import Final + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import FitbitApi +from .exceptions import FitbitApiException, FitbitAuthException +from .model import FitbitDevice + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL: Final = datetime.timedelta(minutes=30) +TIMEOUT = 10 + + +class FitbitDeviceCoordinator(DataUpdateCoordinator): + """Coordinator for fetching fitbit devices from the API.""" + + def __init__(self, hass: HomeAssistant, api: FitbitApi) -> None: + """Initialize FitbitDeviceCoordinator.""" + super().__init__(hass, _LOGGER, name="Fitbit", update_interval=UPDATE_INTERVAL) + self._api = api + + async def _async_update_data(self) -> dict[str, FitbitDevice]: + """Fetch data from API endpoint.""" + async with asyncio.timeout(TIMEOUT): + try: + devices = await self._api.async_get_devices() + except FitbitAuthException as err: + raise ConfigEntryAuthFailed(err) from err + except FitbitApiException as err: + raise UpdateFailed(err) from err + return {device.id: device for device in devices} + + +@dataclass +class FitbitData: + """Config Entry global data.""" + + api: FitbitApi + device_coordinator: FitbitDeviceCoordinator | None diff --git a/homeassistant/components/fitbit/model.py b/homeassistant/components/fitbit/model.py index 3d321d8dd01..38b1d0bb786 100644 --- a/homeassistant/components/fitbit/model.py +++ b/homeassistant/components/fitbit/model.py @@ -1,6 +1,10 @@ """Data representation for fitbit API responses.""" +from collections.abc import Mapping from dataclasses import dataclass +from typing import Any + +from .const import CONF_CLOCK_FORMAT, CONF_MONITORED_RESOURCES, FitbitScope @dataclass @@ -35,3 +39,40 @@ class FitbitDevice: type: str """The type of the device such as TRACKER or SCALE.""" + + +@dataclass +class FitbitConfig: + """Information from the fitbit ConfigEntry data.""" + + clock_format: str | None + monitored_resources: set[str] | None + scopes: set[FitbitScope] + + def is_explicit_enable(self, key: str) -> bool: + """Determine if entity is enabled by default.""" + if self.monitored_resources is not None: + return key in self.monitored_resources + return False + + def is_allowed_resource(self, scope: FitbitScope | None, key: str) -> bool: + """Determine if an entity is allowed to be created.""" + if self.is_explicit_enable(key): + return True + return scope in self.scopes + + +def config_from_entry_data(data: Mapping[str, Any]) -> FitbitConfig: + """Parse the integration config entry into a FitbitConfig.""" + + clock_format = data.get(CONF_CLOCK_FORMAT) + + # Originally entities were configured explicitly from yaml config. Newer + # configurations will infer which entities to enable based on the allowed + # scopes the user selected during OAuth. When creating entities based on + # scopes, some entities are disabled by default. + monitored_resources = data.get(CONF_MONITORED_RESOURCES) + fitbit_scopes: set[FitbitScope] = set({}) + if scopes := data["token"].get("scope"): + fitbit_scopes = set({FitbitScope(scope) for scope in scopes.split(" ")}) + return FitbitConfig(clock_format, monitored_resources, fitbit_scopes) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 51b1b64a391..17bd21544e0 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -35,13 +35,14 @@ from homeassistant.const import ( UnitOfTime, UnitOfVolume, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.json import load_json_object from .api import FitbitApi @@ -58,10 +59,12 @@ from .const import ( DOMAIN, FITBIT_CONFIG_FILE, FITBIT_DEFAULT_RESOURCES, + FitbitScope, FitbitUnitSystem, ) -from .exceptions import FitbitApiException -from .model import FitbitDevice +from .coordinator import FitbitData, FitbitDeviceCoordinator +from .exceptions import FitbitApiException, FitbitAuthException +from .model import FitbitDevice, config_from_entry_data _LOGGER: Final = logging.getLogger(__name__) @@ -137,7 +140,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): unit_type: str | None = None value_fn: Callable[[dict[str, Any]], Any] = _default_value_fn unit_fn: Callable[[FitbitUnitSystem], str | None] = lambda x: None - scope: str | None = None + scope: FitbitScope | None = None FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( @@ -146,7 +149,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Activity Calories", native_unit_of_measurement="cal", icon="mdi:fire", - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -155,7 +158,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Calories", native_unit_of_measurement="cal", icon="mdi:fire", - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.TOTAL_INCREASING, ), FitbitSensorEntityDescription( @@ -163,7 +166,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Calories BMR", native_unit_of_measurement="cal", icon="mdi:fire", - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -175,7 +178,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, unit_fn=_distance_unit, - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.TOTAL_INCREASING, ), FitbitSensorEntityDescription( @@ -184,7 +187,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, unit_fn=_elevation_unit, - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -193,7 +196,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Floors", native_unit_of_measurement="floors", icon="mdi:walk", - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -203,7 +206,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement="bpm", icon="mdi:heart-pulse", value_fn=lambda result: int(result["value"]["restingHeartRate"]), - scope="heartrate", + scope=FitbitScope.HEART_RATE, state_class=SensorStateClass.MEASUREMENT, ), FitbitSensorEntityDescription( @@ -212,7 +215,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -222,7 +225,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -232,7 +235,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:seat-recline-normal", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -242,7 +245,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:run", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -251,7 +254,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Steps", native_unit_of_measurement="steps", icon="mdi:walk", - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.TOTAL_INCREASING, ), FitbitSensorEntityDescription( @@ -259,7 +262,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Tracker Activity Calories", native_unit_of_measurement="cal", icon="mdi:fire", - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -269,7 +272,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Tracker Calories", native_unit_of_measurement="cal", icon="mdi:fire", - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -281,7 +284,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, unit_fn=_distance_unit, - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -292,7 +295,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, unit_fn=_elevation_unit, - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -302,7 +305,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Tracker Floors", native_unit_of_measurement="floors", icon="mdi:walk", - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -313,7 +316,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -324,7 +327,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -335,7 +338,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:seat-recline-normal", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -346,7 +349,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:run", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -356,7 +359,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Tracker Steps", native_unit_of_measurement="steps", icon="mdi:walk", - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -368,7 +371,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, value_fn=_body_value_fn, - scope="weight", + scope=FitbitScope.WEIGHT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -379,7 +382,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, value_fn=_body_value_fn, - scope="weight", + scope=FitbitScope.WEIGHT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -391,14 +394,14 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.WEIGHT, value_fn=_body_value_fn, unit_fn=_weight_unit, - scope="weight", + scope=FitbitScope.WEIGHT, ), FitbitSensorEntityDescription( key="sleep/awakeningsCount", name="Awakenings Count", native_unit_of_measurement="times awaken", icon="mdi:sleep", - scope="sleep", + scope=FitbitScope.SLEEP, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -408,7 +411,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=PERCENTAGE, icon="mdi:sleep", state_class=SensorStateClass.MEASUREMENT, - scope="sleep", + scope=FitbitScope.SLEEP, entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( @@ -417,7 +420,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, - scope="sleep", + scope=FitbitScope.SLEEP, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -427,7 +430,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, - scope="sleep", + scope=FitbitScope.SLEEP, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -437,7 +440,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, - scope="sleep", + scope=FitbitScope.SLEEP, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -447,7 +450,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, - scope="sleep", + scope=FitbitScope.SLEEP, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -457,7 +460,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:hotel", device_class=SensorDeviceClass.DURATION, - scope="sleep", + scope=FitbitScope.SLEEP, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -467,7 +470,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement="cal", icon="mdi:food-apple", state_class=SensorStateClass.TOTAL_INCREASING, - scope="nutrition", + scope=FitbitScope.NUTRITION, entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( @@ -476,7 +479,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:cup-water", unit_fn=_water_unit, state_class=SensorStateClass.TOTAL_INCREASING, - scope="nutrition", + scope=FitbitScope.NUTRITION, entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -486,7 +489,7 @@ SLEEP_START_TIME = FitbitSensorEntityDescription( key="sleep/startTime", name="Sleep Start Time", icon="mdi:clock", - scope="sleep", + scope=FitbitScope.SLEEP, entity_category=EntityCategory.DIAGNOSTIC, ) SLEEP_START_TIME_12HR = FitbitSensorEntityDescription( @@ -494,7 +497,7 @@ SLEEP_START_TIME_12HR = FitbitSensorEntityDescription( name="Sleep Start Time", icon="mdi:clock", value_fn=_clock_format_12h, - scope="sleep", + scope=FitbitScope.SLEEP, entity_category=EntityCategory.DIAGNOSTIC, ) @@ -502,7 +505,7 @@ FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( key="devices/battery", name="Battery", icon="mdi:battery", - scope="settings", + scope=FitbitScope.DEVICE, entity_category=EntityCategory.DIAGNOSTIC, ) @@ -614,41 +617,34 @@ async def async_setup_entry( ) -> None: """Set up the Fitbit sensor platform.""" - api: FitbitApi = hass.data[DOMAIN][entry.entry_id] + data: FitbitData = hass.data[DOMAIN][entry.entry_id] + api = data.api # Note: This will only be one rpc since it will cache the user profile (user_profile, unit_system) = await asyncio.gather( api.async_get_user_profile(), api.async_get_unit_system() ) - clock_format = entry.data.get(CONF_CLOCK_FORMAT) - - # Originally entities were configured explicitly from yaml config. Newer - # configurations will infer which entities to enable based on the allowed - # scopes the user selected during OAuth. When creating entities based on - # scopes, some entities are disabled by default. - monitored_resources = entry.data.get(CONF_MONITORED_RESOURCES) - scopes = entry.data["token"].get("scope", "").split(" ") + fitbit_config = config_from_entry_data(entry.data) def is_explicit_enable(description: FitbitSensorEntityDescription) -> bool: """Determine if entity is enabled by default.""" - if monitored_resources is not None: - return description.key in monitored_resources - return False + return fitbit_config.is_explicit_enable(description.key) def is_allowed_resource(description: FitbitSensorEntityDescription) -> bool: """Determine if an entity is allowed to be created.""" - if is_explicit_enable(description): - return True - return description.scope in scopes + return fitbit_config.is_allowed_resource(description.scope, description.key) resource_list = [ *FITBIT_RESOURCES_LIST, - SLEEP_START_TIME_12HR if clock_format == "12H" else SLEEP_START_TIME, + SLEEP_START_TIME_12HR + if fitbit_config.clock_format == "12H" + else SLEEP_START_TIME, ] entities = [ FitbitSensor( + entry, api, user_profile.encoded_id, description, @@ -658,22 +654,20 @@ async def async_setup_entry( for description in resource_list if is_allowed_resource(description) ] - if is_allowed_resource(FITBIT_RESOURCE_BATTERY): - devices = await api.async_get_devices() - entities.extend( - [ - FitbitSensor( - api, - user_profile.encoded_id, - FITBIT_RESOURCE_BATTERY, - device=device, - enable_default_override=is_explicit_enable(FITBIT_RESOURCE_BATTERY), - ) - for device in devices - ] - ) async_add_entities(entities, True) + if data.device_coordinator and is_allowed_resource(FITBIT_RESOURCE_BATTERY): + async_add_entities( + FitbitBatterySensor( + data.device_coordinator, + user_profile.encoded_id, + FITBIT_RESOURCE_BATTERY, + device=device, + enable_default_override=is_explicit_enable(FITBIT_RESOURCE_BATTERY), + ) + for device in data.device_coordinator.data.values() + ) + class FitbitSensor(SensorEntity): """Implementation of a Fitbit sensor.""" @@ -683,22 +677,19 @@ class FitbitSensor(SensorEntity): def __init__( self, + config_entry: ConfigEntry, api: FitbitApi, user_profile_id: str, description: FitbitSensorEntityDescription, - device: FitbitDevice | None = None, - units: str | None = None, - enable_default_override: bool = False, + units: str | None, + enable_default_override: bool, ) -> None: """Initialize the Fitbit sensor.""" + self.config_entry = config_entry self.entity_description = description self.api = api - self.device = device self._attr_unique_id = f"{user_profile_id}_{description.key}" - if device is not None: - self._attr_name = f"{device.device_version} Battery" - self._attr_unique_id = f"{self._attr_unique_id}_{device.id}" if units is not None: self._attr_native_unit_of_measurement = units @@ -706,50 +697,71 @@ class FitbitSensor(SensorEntity): if enable_default_override: self._attr_entity_registry_enabled_default = True + async def async_update(self) -> None: + """Get the latest data from the Fitbit API and update the states.""" + try: + result = await self.api.async_get_latest_time_series( + self.entity_description.key + ) + except FitbitAuthException: + self._attr_available = False + self.config_entry.async_start_reauth(self.hass) + except FitbitApiException: + self._attr_available = False + else: + self._attr_available = True + self._attr_native_value = self.entity_description.value_fn(result) + + +class FitbitBatterySensor(CoordinatorEntity, SensorEntity): + """Implementation of a Fitbit sensor.""" + + entity_description: FitbitSensorEntityDescription + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: FitbitDeviceCoordinator, + user_profile_id: str, + description: FitbitSensorEntityDescription, + device: FitbitDevice, + enable_default_override: bool, + ) -> None: + """Initialize the Fitbit sensor.""" + super().__init__(coordinator) + self.entity_description = description + self.device = device + self._attr_unique_id = f"{user_profile_id}_{description.key}" + if device is not None: + self._attr_name = f"{device.device_version} Battery" + self._attr_unique_id = f"{self._attr_unique_id}_{device.id}" + + if enable_default_override: + self._attr_entity_registry_enabled_default = True + @property def icon(self) -> str | None: """Icon to use in the frontend, if any.""" - if ( - self.entity_description.key == "devices/battery" - and self.device is not None - and (battery_level := BATTERY_LEVELS.get(self.device.battery)) is not None - ): + if battery_level := BATTERY_LEVELS.get(self.device.battery): return icon_for_battery_level(battery_level=battery_level) return self.entity_description.icon @property def extra_state_attributes(self) -> dict[str, str | None]: """Return the state attributes.""" - attrs: dict[str, str | None] = {} + return { + "model": self.device.device_version, + "type": self.device.type.lower() if self.device.type is not None else None, + } - if self.device is not None: - attrs["model"] = self.device.device_version - device_type = self.device.type - attrs["type"] = device_type.lower() if device_type is not None else None + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() - return attrs - - async def async_update(self) -> None: - """Get the latest data from the Fitbit API and update the states.""" - resource_type = self.entity_description.key - if resource_type == "devices/battery" and self.device is not None: - device_id = self.device.id - try: - registered_devs: list[FitbitDevice] = await self.api.async_get_devices() - except FitbitApiException: - self._attr_available = False - else: - self._attr_available = True - self.device = next( - device for device in registered_devs if device.id == device_id - ) - self._attr_native_value = self.device.battery - return - - try: - result = await self.api.async_get_latest_time_series(resource_type) - except FitbitApiException: - self._attr_available = False - else: - self._attr_available = True - self._attr_native_value = self.entity_description.value_fn(result) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.device = self.coordinator.data[self.device.id] + self._attr_native_value = self.device.battery + self.async_write_ha_state() diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py index 32dc9b0cc98..b6bf75c1c69 100644 --- a/tests/components/fitbit/test_init.py +++ b/tests/components/fitbit/test_init.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus import pytest +from requests_mock.mocker import Mocker from homeassistant.components.fitbit.const import ( CONF_CLIENT_ID, @@ -16,6 +17,7 @@ from homeassistant.core import HomeAssistant from .conftest import ( CLIENT_ID, CLIENT_SECRET, + DEVICES_API_URL, FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN, SERVER_ACCESS_TOKEN, @@ -125,3 +127,50 @@ async def test_token_requires_reauth( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_device_update_coordinator_failure( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, + requests_mock: Mocker, +) -> None: + """Test case where the device update coordinator fails on the first request.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + + requests_mock.register_uri( + "GET", + DEVICES_API_URL, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + assert not await integration_setup() + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_device_update_coordinator_reauth( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, + requests_mock: Mocker, +) -> None: + """Test case where the device update coordinator fails on the first request.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + + requests_mock.register_uri( + "GET", + DEVICES_API_URL, + status_code=HTTPStatus.UNAUTHORIZED, + json={ + "errors": [{"errorType": "invalid_grant"}], + }, + ) + + assert not await integration_setup() + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 7c980ac84a7..926b599dfb5 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -27,6 +27,8 @@ from .conftest import ( timeseries_response, ) +from tests.common import MockConfigEntry + DEVICE_RESPONSE_CHARGE_2 = { "battery": "Medium", "batteryLevel": 60, @@ -577,6 +579,43 @@ async def test_sensor_update_failed( assert state assert state.state == "unavailable" + # Verify the config entry is in a normal state (no reauth required) + flows = hass.config_entries.flow.async_progress() + assert not flows + + +@pytest.mark.parametrize( + ("scopes"), + [(["heartrate"])], +) +async def test_sensor_update_failed_requires_reauth( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + requests_mock: Mocker, +) -> None: + """Test a sensor update request requires reauth.""" + + requests_mock.register_uri( + "GET", + TIMESERIES_API_URL_FORMAT.format(resource="activities/heart"), + status_code=HTTPStatus.UNAUTHORIZED, + json={ + "errors": [{"errorType": "invalid_grant"}], + }, + ) + + assert await integration_setup() + + state = hass.states.get("sensor.resting_heart_rate") + assert state + assert state.state == "unavailable" + + # Verify that reauth is required + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + @pytest.mark.parametrize( ("scopes", "mock_devices"), @@ -594,11 +633,6 @@ async def test_device_battery_level_update_failed( "GET", DEVICES_API_URL, [ - { - "status_code": HTTPStatus.OK, - "json": [DEVICE_RESPONSE_CHARGE_2], - }, - # A second spurious update request on startup { "status_code": HTTPStatus.OK, "json": [DEVICE_RESPONSE_CHARGE_2], @@ -626,7 +660,63 @@ async def test_device_battery_level_update_failed( # Request an update for the entity which will fail await async_update_entity(hass, "sensor.charge_2_battery") + await hass.async_block_till_done() state = hass.states.get("sensor.charge_2_battery") assert state assert state.state == "unavailable" + + # Verify the config entry is in a normal state (no reauth required) + flows = hass.config_entries.flow.async_progress() + assert not flows + + +@pytest.mark.parametrize( + ("scopes", "mock_devices"), + [(["settings"], None)], +) +async def test_device_battery_level_reauth_required( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + requests_mock: Mocker, +) -> None: + """Test API failure requires reauth.""" + + requests_mock.register_uri( + "GET", + DEVICES_API_URL, + [ + { + "status_code": HTTPStatus.OK, + "json": [DEVICE_RESPONSE_CHARGE_2], + }, + # Fail when requesting an update + { + "status_code": HTTPStatus.UNAUTHORIZED, + "json": { + "errors": [{"errorType": "invalid_grant"}], + }, + }, + ], + ) + + assert await integration_setup() + + state = hass.states.get("sensor.charge_2_battery") + assert state + assert state.state == "Medium" + + # Request an update for the entity which will fail + await async_update_entity(hass, "sensor.charge_2_battery") + await hass.async_block_till_done() + + state = hass.states.get("sensor.charge_2_battery") + assert state + assert state.state == "unavailable" + + # Verify that reauth is required + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm"