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.
pull/101664/head
Allen Porter 2023-10-08 23:12:59 -07:00 committed by GitHub
parent 6d1876394e
commit d78ee96e2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 389 additions and 132 deletions

View File

@ -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)

View File

@ -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):

View File

@ -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]

View File

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

View File

@ -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)

View File

@ -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()

View File

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

View File

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