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.
This commit is contained in:
parent
6d1876394e
commit
d78ee96e2a
8 changed files with 389 additions and 132 deletions
|
@ -8,8 +8,10 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
from homeassistant.helpers import config_entry_oauth2_flow
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
|
||||||
from . import api
|
from . import api
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN, FitbitScope
|
||||||
|
from .coordinator import FitbitData, FitbitDeviceCoordinator
|
||||||
from .exceptions import FitbitApiException, FitbitAuthException
|
from .exceptions import FitbitApiException, FitbitAuthException
|
||||||
|
from .model import config_from_entry_data
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||||
|
|
||||||
|
@ -34,7 +36,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
except FitbitApiException as err:
|
except FitbitApiException as err:
|
||||||
raise ConfigEntryNotReady from 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)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
|
@ -134,10 +134,10 @@ class FitbitApi(ABC):
|
||||||
return await self._hass.async_add_executor_job(func)
|
return await self._hass.async_add_executor_job(func)
|
||||||
except HTTPUnauthorized as err:
|
except HTTPUnauthorized as err:
|
||||||
_LOGGER.debug("Unauthorized error from fitbit API: %s", 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:
|
except HTTPException as err:
|
||||||
_LOGGER.debug("Error from fitbit API: %s", err)
|
_LOGGER.debug("Error from fitbit API: %s", err)
|
||||||
raise FitbitApiException from err
|
raise FitbitApiException("Error from fitbit API") from err
|
||||||
|
|
||||||
|
|
||||||
class OAuthFitbitApi(FitbitApi):
|
class OAuthFitbitApi(FitbitApi):
|
||||||
|
|
|
@ -67,14 +67,21 @@ class FitbitUnitSystem(StrEnum):
|
||||||
"""Use United Kingdom units."""
|
"""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_AUTHORIZE = "https://www.fitbit.com/oauth2/authorize"
|
||||||
OAUTH2_TOKEN = "https://api.fitbit.com/oauth2/token"
|
OAUTH2_TOKEN = "https://api.fitbit.com/oauth2/token"
|
||||||
OAUTH_SCOPES = [
|
OAUTH_SCOPES = [scope.value for scope in FitbitScope]
|
||||||
"activity",
|
|
||||||
"heartrate",
|
|
||||||
"nutrition",
|
|
||||||
"profile",
|
|
||||||
"settings",
|
|
||||||
"sleep",
|
|
||||||
"weight",
|
|
||||||
]
|
|
||||||
|
|
48
homeassistant/components/fitbit/coordinator.py
Normal file
48
homeassistant/components/fitbit/coordinator.py
Normal 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
|
|
@ -1,6 +1,10 @@
|
||||||
"""Data representation for fitbit API responses."""
|
"""Data representation for fitbit API responses."""
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .const import CONF_CLOCK_FORMAT, CONF_MONITORED_RESOURCES, FitbitScope
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -35,3 +39,40 @@ class FitbitDevice:
|
||||||
|
|
||||||
type: str
|
type: str
|
||||||
"""The type of the device such as TRACKER or SCALE."""
|
"""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)
|
||||||
|
|
|
@ -35,13 +35,14 @@ from homeassistant.const import (
|
||||||
UnitOfTime,
|
UnitOfTime,
|
||||||
UnitOfVolume,
|
UnitOfVolume,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.icon import icon_for_battery_level
|
from homeassistant.helpers.icon import icon_for_battery_level
|
||||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
from homeassistant.util.json import load_json_object
|
from homeassistant.util.json import load_json_object
|
||||||
|
|
||||||
from .api import FitbitApi
|
from .api import FitbitApi
|
||||||
|
@ -58,10 +59,12 @@ from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
FITBIT_CONFIG_FILE,
|
FITBIT_CONFIG_FILE,
|
||||||
FITBIT_DEFAULT_RESOURCES,
|
FITBIT_DEFAULT_RESOURCES,
|
||||||
|
FitbitScope,
|
||||||
FitbitUnitSystem,
|
FitbitUnitSystem,
|
||||||
)
|
)
|
||||||
from .exceptions import FitbitApiException
|
from .coordinator import FitbitData, FitbitDeviceCoordinator
|
||||||
from .model import FitbitDevice
|
from .exceptions import FitbitApiException, FitbitAuthException
|
||||||
|
from .model import FitbitDevice, config_from_entry_data
|
||||||
|
|
||||||
_LOGGER: Final = logging.getLogger(__name__)
|
_LOGGER: Final = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -137,7 +140,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription):
|
||||||
unit_type: str | None = None
|
unit_type: str | None = None
|
||||||
value_fn: Callable[[dict[str, Any]], Any] = _default_value_fn
|
value_fn: Callable[[dict[str, Any]], Any] = _default_value_fn
|
||||||
unit_fn: Callable[[FitbitUnitSystem], str | None] = lambda x: None
|
unit_fn: Callable[[FitbitUnitSystem], str | None] = lambda x: None
|
||||||
scope: str | None = None
|
scope: FitbitScope | None = None
|
||||||
|
|
||||||
|
|
||||||
FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
|
@ -146,7 +149,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
name="Activity Calories",
|
name="Activity Calories",
|
||||||
native_unit_of_measurement="cal",
|
native_unit_of_measurement="cal",
|
||||||
icon="mdi:fire",
|
icon="mdi:fire",
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -155,7 +158,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
name="Calories",
|
name="Calories",
|
||||||
native_unit_of_measurement="cal",
|
native_unit_of_measurement="cal",
|
||||||
icon="mdi:fire",
|
icon="mdi:fire",
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
),
|
),
|
||||||
FitbitSensorEntityDescription(
|
FitbitSensorEntityDescription(
|
||||||
|
@ -163,7 +166,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
name="Calories BMR",
|
name="Calories BMR",
|
||||||
native_unit_of_measurement="cal",
|
native_unit_of_measurement="cal",
|
||||||
icon="mdi:fire",
|
icon="mdi:fire",
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
@ -175,7 +178,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
device_class=SensorDeviceClass.DISTANCE,
|
device_class=SensorDeviceClass.DISTANCE,
|
||||||
value_fn=_distance_value_fn,
|
value_fn=_distance_value_fn,
|
||||||
unit_fn=_distance_unit,
|
unit_fn=_distance_unit,
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
),
|
),
|
||||||
FitbitSensorEntityDescription(
|
FitbitSensorEntityDescription(
|
||||||
|
@ -184,7 +187,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
icon="mdi:walk",
|
icon="mdi:walk",
|
||||||
device_class=SensorDeviceClass.DISTANCE,
|
device_class=SensorDeviceClass.DISTANCE,
|
||||||
unit_fn=_elevation_unit,
|
unit_fn=_elevation_unit,
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -193,7 +196,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
name="Floors",
|
name="Floors",
|
||||||
native_unit_of_measurement="floors",
|
native_unit_of_measurement="floors",
|
||||||
icon="mdi:walk",
|
icon="mdi:walk",
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -203,7 +206,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement="bpm",
|
native_unit_of_measurement="bpm",
|
||||||
icon="mdi:heart-pulse",
|
icon="mdi:heart-pulse",
|
||||||
value_fn=lambda result: int(result["value"]["restingHeartRate"]),
|
value_fn=lambda result: int(result["value"]["restingHeartRate"]),
|
||||||
scope="heartrate",
|
scope=FitbitScope.HEART_RATE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
),
|
),
|
||||||
FitbitSensorEntityDescription(
|
FitbitSensorEntityDescription(
|
||||||
|
@ -212,7 +215,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
icon="mdi:walk",
|
icon="mdi:walk",
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -222,7 +225,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
icon="mdi:walk",
|
icon="mdi:walk",
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -232,7 +235,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
icon="mdi:seat-recline-normal",
|
icon="mdi:seat-recline-normal",
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -242,7 +245,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
icon="mdi:run",
|
icon="mdi:run",
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -251,7 +254,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
name="Steps",
|
name="Steps",
|
||||||
native_unit_of_measurement="steps",
|
native_unit_of_measurement="steps",
|
||||||
icon="mdi:walk",
|
icon="mdi:walk",
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
),
|
),
|
||||||
FitbitSensorEntityDescription(
|
FitbitSensorEntityDescription(
|
||||||
|
@ -259,7 +262,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
name="Tracker Activity Calories",
|
name="Tracker Activity Calories",
|
||||||
native_unit_of_measurement="cal",
|
native_unit_of_measurement="cal",
|
||||||
icon="mdi:fire",
|
icon="mdi:fire",
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
@ -269,7 +272,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
name="Tracker Calories",
|
name="Tracker Calories",
|
||||||
native_unit_of_measurement="cal",
|
native_unit_of_measurement="cal",
|
||||||
icon="mdi:fire",
|
icon="mdi:fire",
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
@ -281,7 +284,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
device_class=SensorDeviceClass.DISTANCE,
|
device_class=SensorDeviceClass.DISTANCE,
|
||||||
value_fn=_distance_value_fn,
|
value_fn=_distance_value_fn,
|
||||||
unit_fn=_distance_unit,
|
unit_fn=_distance_unit,
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
@ -292,7 +295,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
icon="mdi:walk",
|
icon="mdi:walk",
|
||||||
device_class=SensorDeviceClass.DISTANCE,
|
device_class=SensorDeviceClass.DISTANCE,
|
||||||
unit_fn=_elevation_unit,
|
unit_fn=_elevation_unit,
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
@ -302,7 +305,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
name="Tracker Floors",
|
name="Tracker Floors",
|
||||||
native_unit_of_measurement="floors",
|
native_unit_of_measurement="floors",
|
||||||
icon="mdi:walk",
|
icon="mdi:walk",
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
@ -313,7 +316,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
icon="mdi:walk",
|
icon="mdi:walk",
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
@ -324,7 +327,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
icon="mdi:walk",
|
icon="mdi:walk",
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
@ -335,7 +338,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
icon="mdi:seat-recline-normal",
|
icon="mdi:seat-recline-normal",
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
@ -346,7 +349,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
icon="mdi:run",
|
icon="mdi:run",
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
@ -356,7 +359,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
name="Tracker Steps",
|
name="Tracker Steps",
|
||||||
native_unit_of_measurement="steps",
|
native_unit_of_measurement="steps",
|
||||||
icon="mdi:walk",
|
icon="mdi:walk",
|
||||||
scope="activity",
|
scope=FitbitScope.ACTIVITY,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
@ -368,7 +371,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
icon="mdi:human",
|
icon="mdi:human",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
value_fn=_body_value_fn,
|
value_fn=_body_value_fn,
|
||||||
scope="weight",
|
scope=FitbitScope.WEIGHT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -379,7 +382,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
icon="mdi:human",
|
icon="mdi:human",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
value_fn=_body_value_fn,
|
value_fn=_body_value_fn,
|
||||||
scope="weight",
|
scope=FitbitScope.WEIGHT,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -391,14 +394,14 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
device_class=SensorDeviceClass.WEIGHT,
|
device_class=SensorDeviceClass.WEIGHT,
|
||||||
value_fn=_body_value_fn,
|
value_fn=_body_value_fn,
|
||||||
unit_fn=_weight_unit,
|
unit_fn=_weight_unit,
|
||||||
scope="weight",
|
scope=FitbitScope.WEIGHT,
|
||||||
),
|
),
|
||||||
FitbitSensorEntityDescription(
|
FitbitSensorEntityDescription(
|
||||||
key="sleep/awakeningsCount",
|
key="sleep/awakeningsCount",
|
||||||
name="Awakenings Count",
|
name="Awakenings Count",
|
||||||
native_unit_of_measurement="times awaken",
|
native_unit_of_measurement="times awaken",
|
||||||
icon="mdi:sleep",
|
icon="mdi:sleep",
|
||||||
scope="sleep",
|
scope=FitbitScope.SLEEP,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -408,7 +411,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
icon="mdi:sleep",
|
icon="mdi:sleep",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
scope="sleep",
|
scope=FitbitScope.SLEEP,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
FitbitSensorEntityDescription(
|
FitbitSensorEntityDescription(
|
||||||
|
@ -417,7 +420,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
icon="mdi:sleep",
|
icon="mdi:sleep",
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
scope="sleep",
|
scope=FitbitScope.SLEEP,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -427,7 +430,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
icon="mdi:sleep",
|
icon="mdi:sleep",
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
scope="sleep",
|
scope=FitbitScope.SLEEP,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -437,7 +440,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
icon="mdi:sleep",
|
icon="mdi:sleep",
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
scope="sleep",
|
scope=FitbitScope.SLEEP,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -447,7 +450,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
icon="mdi:sleep",
|
icon="mdi:sleep",
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
scope="sleep",
|
scope=FitbitScope.SLEEP,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -457,7 +460,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
icon="mdi:hotel",
|
icon="mdi:hotel",
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
scope="sleep",
|
scope=FitbitScope.SLEEP,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
@ -467,7 +470,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
native_unit_of_measurement="cal",
|
native_unit_of_measurement="cal",
|
||||||
icon="mdi:food-apple",
|
icon="mdi:food-apple",
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
scope="nutrition",
|
scope=FitbitScope.NUTRITION,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
FitbitSensorEntityDescription(
|
FitbitSensorEntityDescription(
|
||||||
|
@ -476,7 +479,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||||
icon="mdi:cup-water",
|
icon="mdi:cup-water",
|
||||||
unit_fn=_water_unit,
|
unit_fn=_water_unit,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
scope="nutrition",
|
scope=FitbitScope.NUTRITION,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -486,7 +489,7 @@ SLEEP_START_TIME = FitbitSensorEntityDescription(
|
||||||
key="sleep/startTime",
|
key="sleep/startTime",
|
||||||
name="Sleep Start Time",
|
name="Sleep Start Time",
|
||||||
icon="mdi:clock",
|
icon="mdi:clock",
|
||||||
scope="sleep",
|
scope=FitbitScope.SLEEP,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
)
|
)
|
||||||
SLEEP_START_TIME_12HR = FitbitSensorEntityDescription(
|
SLEEP_START_TIME_12HR = FitbitSensorEntityDescription(
|
||||||
|
@ -494,7 +497,7 @@ SLEEP_START_TIME_12HR = FitbitSensorEntityDescription(
|
||||||
name="Sleep Start Time",
|
name="Sleep Start Time",
|
||||||
icon="mdi:clock",
|
icon="mdi:clock",
|
||||||
value_fn=_clock_format_12h,
|
value_fn=_clock_format_12h,
|
||||||
scope="sleep",
|
scope=FitbitScope.SLEEP,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -502,7 +505,7 @@ FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription(
|
||||||
key="devices/battery",
|
key="devices/battery",
|
||||||
name="Battery",
|
name="Battery",
|
||||||
icon="mdi:battery",
|
icon="mdi:battery",
|
||||||
scope="settings",
|
scope=FitbitScope.DEVICE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -614,41 +617,34 @@ async def async_setup_entry(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Fitbit sensor platform."""
|
"""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
|
# Note: This will only be one rpc since it will cache the user profile
|
||||||
(user_profile, unit_system) = await asyncio.gather(
|
(user_profile, unit_system) = await asyncio.gather(
|
||||||
api.async_get_user_profile(), api.async_get_unit_system()
|
api.async_get_user_profile(), api.async_get_unit_system()
|
||||||
)
|
)
|
||||||
|
|
||||||
clock_format = entry.data.get(CONF_CLOCK_FORMAT)
|
fitbit_config = config_from_entry_data(entry.data)
|
||||||
|
|
||||||
# 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(" ")
|
|
||||||
|
|
||||||
def is_explicit_enable(description: FitbitSensorEntityDescription) -> bool:
|
def is_explicit_enable(description: FitbitSensorEntityDescription) -> bool:
|
||||||
"""Determine if entity is enabled by default."""
|
"""Determine if entity is enabled by default."""
|
||||||
if monitored_resources is not None:
|
return fitbit_config.is_explicit_enable(description.key)
|
||||||
return description.key in monitored_resources
|
|
||||||
return False
|
|
||||||
|
|
||||||
def is_allowed_resource(description: FitbitSensorEntityDescription) -> bool:
|
def is_allowed_resource(description: FitbitSensorEntityDescription) -> bool:
|
||||||
"""Determine if an entity is allowed to be created."""
|
"""Determine if an entity is allowed to be created."""
|
||||||
if is_explicit_enable(description):
|
return fitbit_config.is_allowed_resource(description.scope, description.key)
|
||||||
return True
|
|
||||||
return description.scope in scopes
|
|
||||||
|
|
||||||
resource_list = [
|
resource_list = [
|
||||||
*FITBIT_RESOURCES_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 = [
|
entities = [
|
||||||
FitbitSensor(
|
FitbitSensor(
|
||||||
|
entry,
|
||||||
api,
|
api,
|
||||||
user_profile.encoded_id,
|
user_profile.encoded_id,
|
||||||
description,
|
description,
|
||||||
|
@ -658,22 +654,20 @@ async def async_setup_entry(
|
||||||
for description in resource_list
|
for description in resource_list
|
||||||
if is_allowed_resource(description)
|
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)
|
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):
|
class FitbitSensor(SensorEntity):
|
||||||
"""Implementation of a Fitbit sensor."""
|
"""Implementation of a Fitbit sensor."""
|
||||||
|
@ -683,22 +677,19 @@ class FitbitSensor(SensorEntity):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
api: FitbitApi,
|
api: FitbitApi,
|
||||||
user_profile_id: str,
|
user_profile_id: str,
|
||||||
description: FitbitSensorEntityDescription,
|
description: FitbitSensorEntityDescription,
|
||||||
device: FitbitDevice | None = None,
|
units: str | None,
|
||||||
units: str | None = None,
|
enable_default_override: bool,
|
||||||
enable_default_override: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the Fitbit sensor."""
|
"""Initialize the Fitbit sensor."""
|
||||||
|
self.config_entry = config_entry
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
self.api = api
|
self.api = api
|
||||||
self.device = device
|
|
||||||
|
|
||||||
self._attr_unique_id = f"{user_profile_id}_{description.key}"
|
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:
|
if units is not None:
|
||||||
self._attr_native_unit_of_measurement = units
|
self._attr_native_unit_of_measurement = units
|
||||||
|
@ -706,50 +697,71 @@ class FitbitSensor(SensorEntity):
|
||||||
if enable_default_override:
|
if enable_default_override:
|
||||||
self._attr_entity_registry_enabled_default = True
|
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
|
@property
|
||||||
def icon(self) -> str | None:
|
def icon(self) -> str | None:
|
||||||
"""Icon to use in the frontend, if any."""
|
"""Icon to use in the frontend, if any."""
|
||||||
if (
|
if battery_level := BATTERY_LEVELS.get(self.device.battery):
|
||||||
self.entity_description.key == "devices/battery"
|
|
||||||
and self.device is not None
|
|
||||||
and (battery_level := BATTERY_LEVELS.get(self.device.battery)) is not None
|
|
||||||
):
|
|
||||||
return icon_for_battery_level(battery_level=battery_level)
|
return icon_for_battery_level(battery_level=battery_level)
|
||||||
return self.entity_description.icon
|
return self.entity_description.icon
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, str | None]:
|
def extra_state_attributes(self) -> dict[str, str | None]:
|
||||||
"""Return the state attributes."""
|
"""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:
|
async def async_added_to_hass(self) -> None:
|
||||||
attrs["model"] = self.device.device_version
|
"""When entity is added to hass update state from existing coordinator data."""
|
||||||
device_type = self.device.type
|
await super().async_added_to_hass()
|
||||||
attrs["type"] = device_type.lower() if device_type is not None else None
|
self._handle_coordinator_update()
|
||||||
|
|
||||||
return attrs
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
async def async_update(self) -> None:
|
"""Handle updated data from the coordinator."""
|
||||||
"""Get the latest data from the Fitbit API and update the states."""
|
self.device = self.coordinator.data[self.device.id]
|
||||||
resource_type = self.entity_description.key
|
self._attr_native_value = self.device.battery
|
||||||
if resource_type == "devices/battery" and self.device is not None:
|
self.async_write_ha_state()
|
||||||
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)
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from requests_mock.mocker import Mocker
|
||||||
|
|
||||||
from homeassistant.components.fitbit.const import (
|
from homeassistant.components.fitbit.const import (
|
||||||
CONF_CLIENT_ID,
|
CONF_CLIENT_ID,
|
||||||
|
@ -16,6 +17,7 @@ from homeassistant.core import HomeAssistant
|
||||||
from .conftest import (
|
from .conftest import (
|
||||||
CLIENT_ID,
|
CLIENT_ID,
|
||||||
CLIENT_SECRET,
|
CLIENT_SECRET,
|
||||||
|
DEVICES_API_URL,
|
||||||
FAKE_ACCESS_TOKEN,
|
FAKE_ACCESS_TOKEN,
|
||||||
FAKE_REFRESH_TOKEN,
|
FAKE_REFRESH_TOKEN,
|
||||||
SERVER_ACCESS_TOKEN,
|
SERVER_ACCESS_TOKEN,
|
||||||
|
@ -125,3 +127,50 @@ async def test_token_requires_reauth(
|
||||||
flows = hass.config_entries.flow.async_progress()
|
flows = hass.config_entries.flow.async_progress()
|
||||||
assert len(flows) == 1
|
assert len(flows) == 1
|
||||||
assert flows[0]["step_id"] == "reauth_confirm"
|
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"
|
||||||
|
|
|
@ -27,6 +27,8 @@ from .conftest import (
|
||||||
timeseries_response,
|
timeseries_response,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
DEVICE_RESPONSE_CHARGE_2 = {
|
DEVICE_RESPONSE_CHARGE_2 = {
|
||||||
"battery": "Medium",
|
"battery": "Medium",
|
||||||
"batteryLevel": 60,
|
"batteryLevel": 60,
|
||||||
|
@ -577,6 +579,43 @@ async def test_sensor_update_failed(
|
||||||
assert state
|
assert state
|
||||||
assert state.state == "unavailable"
|
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(
|
@pytest.mark.parametrize(
|
||||||
("scopes", "mock_devices"),
|
("scopes", "mock_devices"),
|
||||||
|
@ -594,11 +633,6 @@ async def test_device_battery_level_update_failed(
|
||||||
"GET",
|
"GET",
|
||||||
DEVICES_API_URL,
|
DEVICES_API_URL,
|
||||||
[
|
[
|
||||||
{
|
|
||||||
"status_code": HTTPStatus.OK,
|
|
||||||
"json": [DEVICE_RESPONSE_CHARGE_2],
|
|
||||||
},
|
|
||||||
# A second spurious update request on startup
|
|
||||||
{
|
{
|
||||||
"status_code": HTTPStatus.OK,
|
"status_code": HTTPStatus.OK,
|
||||||
"json": [DEVICE_RESPONSE_CHARGE_2],
|
"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
|
# Request an update for the entity which will fail
|
||||||
await async_update_entity(hass, "sensor.charge_2_battery")
|
await async_update_entity(hass, "sensor.charge_2_battery")
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
state = hass.states.get("sensor.charge_2_battery")
|
state = hass.states.get("sensor.charge_2_battery")
|
||||||
assert state
|
assert state
|
||||||
assert state.state == "unavailable"
|
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"
|
||||||
|
|
Loading…
Add table
Reference in a new issue