Update Fitbit integration to allow UI based configuration (#100897)
* Cleanup fitbit sensor API parsing * Remove API code that is not used yet * Configuration flow for fitbit * Code cleanup after manual review * Streamline code for review * Use scopes to determine which entities to enable * Use set for entity comparisons * Apply fitbit string pr feedback * Improve fitbit configuration flow error handling * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Fix typo in more places * Revert typing import * Revert custom domain back to default * Add additional config flow tests * Add breaks_in_ha_version to repair issues * Update homeassistant/components/fitbit/api.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Increase test coverage for token refresh success case * Add breaks_in_ha_version for sensor issue * Apply suggestions from code review Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Simplify translations, issue keys, and token refresh * Config flow test improvements * Simplify repair issue creation on fitbit import * Remove unused strings --------- Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
fe30c019b6
commit
bd2fee289d
15 changed files with 1188 additions and 302 deletions
|
@ -1 +1,47 @@
|
|||
"""The fitbit component."""
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from . import api
|
||||
from .const import DOMAIN
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up fitbit from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
fitbit_api = api.OAuthFitbitApi(
|
||||
hass, session, unit_system=entry.data.get("unit_system")
|
||||
)
|
||||
try:
|
||||
await fitbit_api.async_get_access_token()
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = fitbit_api
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
"""API for fitbit bound to Home Assistant OAuth."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from fitbit import Fitbit
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from .const import FitbitUnitSystem
|
||||
|
@ -13,32 +16,50 @@ from .model import FitbitDevice, FitbitProfile
|
|||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_REFRESH_TOKEN = "refresh_token"
|
||||
CONF_EXPIRES_AT = "expires_at"
|
||||
|
||||
class FitbitApi:
|
||||
"""Fitbit client library wrapper base class."""
|
||||
|
||||
class FitbitApi(ABC):
|
||||
"""Fitbit client library wrapper base class.
|
||||
|
||||
This can be subclassed with different implementations for providing an access
|
||||
token depending on the use case.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
client: Fitbit,
|
||||
unit_system: FitbitUnitSystem | None = None,
|
||||
) -> None:
|
||||
"""Initialize Fitbit auth."""
|
||||
self._hass = hass
|
||||
self._profile: FitbitProfile | None = None
|
||||
self._client = client
|
||||
self._unit_system = unit_system
|
||||
|
||||
@property
|
||||
def client(self) -> Fitbit:
|
||||
"""Property to expose the underlying client library."""
|
||||
return self._client
|
||||
@abstractmethod
|
||||
async def async_get_access_token(self) -> dict[str, Any]:
|
||||
"""Return a valid token dictionary for the Fitbit API."""
|
||||
|
||||
async def _async_get_client(self) -> Fitbit:
|
||||
"""Get synchronous client library, called before each client request."""
|
||||
# Always rely on Home Assistant's token update mechanism which refreshes
|
||||
# the data in the configuration entry.
|
||||
token = await self.async_get_access_token()
|
||||
return Fitbit(
|
||||
client_id=None,
|
||||
client_secret=None,
|
||||
access_token=token[CONF_ACCESS_TOKEN],
|
||||
refresh_token=token[CONF_REFRESH_TOKEN],
|
||||
expires_at=float(token[CONF_EXPIRES_AT]),
|
||||
)
|
||||
|
||||
async def async_get_user_profile(self) -> FitbitProfile:
|
||||
"""Return the user profile from the API."""
|
||||
if self._profile is None:
|
||||
client = await self._async_get_client()
|
||||
response: dict[str, Any] = await self._hass.async_add_executor_job(
|
||||
self._client.user_profile_get
|
||||
client.user_profile_get
|
||||
)
|
||||
_LOGGER.debug("user_profile_get=%s", response)
|
||||
profile = response["user"]
|
||||
|
@ -73,8 +94,9 @@ class FitbitApi:
|
|||
|
||||
async def async_get_devices(self) -> list[FitbitDevice]:
|
||||
"""Return available devices."""
|
||||
client = await self._async_get_client()
|
||||
devices: list[dict[str, str]] = await self._hass.async_add_executor_job(
|
||||
self._client.get_devices
|
||||
client.get_devices
|
||||
)
|
||||
_LOGGER.debug("get_devices=%s", devices)
|
||||
return [
|
||||
|
@ -90,17 +112,56 @@ class FitbitApi:
|
|||
|
||||
async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]:
|
||||
"""Return the most recent value from the time series for the specified resource type."""
|
||||
client = await self._async_get_client()
|
||||
|
||||
# Set request header based on the configured unit system
|
||||
self._client.system = await self.async_get_unit_system()
|
||||
client.system = await self.async_get_unit_system()
|
||||
|
||||
def _time_series() -> dict[str, Any]:
|
||||
return cast(
|
||||
dict[str, Any], self._client.time_series(resource_type, period="7d")
|
||||
)
|
||||
return cast(dict[str, Any], client.time_series(resource_type, period="7d"))
|
||||
|
||||
response: dict[str, Any] = await self._hass.async_add_executor_job(_time_series)
|
||||
_LOGGER.debug("time_series(%s)=%s", resource_type, response)
|
||||
key = resource_type.replace("/", "-")
|
||||
dated_results: list[dict[str, Any]] = response[key]
|
||||
return dated_results[-1]
|
||||
|
||||
|
||||
class OAuthFitbitApi(FitbitApi):
|
||||
"""Provide fitbit authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
oauth_session: config_entry_oauth2_flow.OAuth2Session,
|
||||
unit_system: FitbitUnitSystem | None = None,
|
||||
) -> None:
|
||||
"""Initialize OAuthFitbitApi."""
|
||||
super().__init__(hass, unit_system)
|
||||
self._oauth_session = oauth_session
|
||||
|
||||
async def async_get_access_token(self) -> dict[str, Any]:
|
||||
"""Return a valid access token for the Fitbit API."""
|
||||
if not self._oauth_session.valid_token:
|
||||
await self._oauth_session.async_ensure_token_valid()
|
||||
return self._oauth_session.token
|
||||
|
||||
|
||||
class ConfigFlowFitbitApi(FitbitApi):
|
||||
"""Profile fitbit authentication before a ConfigEntry exists.
|
||||
|
||||
This implementation directly provides the token without supporting refresh.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
token: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize ConfigFlowFitbitApi."""
|
||||
super().__init__(hass)
|
||||
self._token = token
|
||||
|
||||
async def async_get_access_token(self) -> dict[str, Any]:
|
||||
"""Return the token for the Fitbit API."""
|
||||
return self._token
|
||||
|
|
77
homeassistant/components/fitbit/application_credentials.py
Normal file
77
homeassistant/components/fitbit/application_credentials.py
Normal file
|
@ -0,0 +1,77 @@
|
|||
"""application_credentials platform the fitbit integration.
|
||||
|
||||
See https://dev.fitbit.com/build/reference/web-api/authorization/ for additional
|
||||
details on Fitbit authorization.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from homeassistant.components.application_credentials import (
|
||||
AuthImplementation,
|
||||
AuthorizationServer,
|
||||
ClientCredential,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FitbitOAuth2Implementation(AuthImplementation):
|
||||
"""Local OAuth2 implementation for Fitbit.
|
||||
|
||||
This implementation is needed to send the client id and secret as a Basic
|
||||
Authorization header.
|
||||
"""
|
||||
|
||||
async def async_resolve_external_data(self, external_data: dict[str, Any]) -> dict:
|
||||
"""Resolve the authorization code to tokens."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": external_data["code"],
|
||||
"redirect_uri": external_data["state"]["redirect_uri"],
|
||||
}
|
||||
resp = await session.post(self.token_url, data=data, headers=self._headers)
|
||||
resp.raise_for_status()
|
||||
return cast(dict, await resp.json())
|
||||
|
||||
async def _token_request(self, data: dict) -> dict:
|
||||
"""Make a token request."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
body = {
|
||||
**data,
|
||||
CONF_CLIENT_ID: self.client_id,
|
||||
CONF_CLIENT_SECRET: self.client_secret,
|
||||
}
|
||||
resp = await session.post(self.token_url, data=body, headers=self._headers)
|
||||
resp.raise_for_status()
|
||||
return cast(dict, await resp.json())
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict[str, str]:
|
||||
"""Build necessary authorization headers."""
|
||||
basic_auth = base64.b64encode(
|
||||
f"{self.client_id}:{self.client_secret}".encode()
|
||||
).decode()
|
||||
return {"Authorization": f"Basic {basic_auth}"}
|
||||
|
||||
|
||||
async def async_get_auth_implementation(
|
||||
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
|
||||
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation:
|
||||
"""Return a custom auth implementation."""
|
||||
return FitbitOAuth2Implementation(
|
||||
hass,
|
||||
auth_domain,
|
||||
credential,
|
||||
AuthorizationServer(
|
||||
authorize_url=OAUTH2_AUTHORIZE,
|
||||
token_url=OAUTH2_TOKEN,
|
||||
),
|
||||
)
|
54
homeassistant/components/fitbit/config_flow.py
Normal file
54
homeassistant/components/fitbit/config_flow.py
Normal file
|
@ -0,0 +1,54 @@
|
|||
"""Config flow for fitbit."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fitbit.exceptions import HTTPException
|
||||
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from . import api
|
||||
from .const import DOMAIN, OAUTH_SCOPES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OAuth2FlowHandler(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||
):
|
||||
"""Config flow to handle fitbit OAuth2 authentication."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict[str, str]:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
return {
|
||||
"scope": " ".join(OAUTH_SCOPES),
|
||||
"prompt": "consent",
|
||||
}
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
|
||||
"""Create an entry for the flow, or update existing entry."""
|
||||
|
||||
client = api.ConfigFlowFitbitApi(self.hass, data[CONF_TOKEN])
|
||||
try:
|
||||
profile = await client.async_get_user_profile()
|
||||
except HTTPException as err:
|
||||
_LOGGER.error("Failed to fetch user profile for Fitbit API: %s", err)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
await self.async_set_unique_id(profile.encoded_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=profile.full_name, data=data)
|
||||
|
||||
async def async_step_import(self, data: dict[str, Any]) -> FlowResult:
|
||||
"""Handle import from YAML."""
|
||||
return await self.async_oauth_create_entry(data)
|
|
@ -65,3 +65,16 @@ class FitbitUnitSystem(StrEnum):
|
|||
|
||||
EN_GB = "en_GB"
|
||||
"""Use United Kingdom units."""
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
|
|
|
@ -2,7 +2,8 @@
|
|||
"domain": "fitbit",
|
||||
"name": "Fitbit",
|
||||
"codeowners": ["@allenporter"],
|
||||
"dependencies": ["configurator", "http"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/fitbit",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["fitbit"],
|
||||
|
|
|
@ -7,17 +7,14 @@ from dataclasses import dataclass
|
|||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from aiohttp.web import Request
|
||||
from fitbit import Fitbit
|
||||
from fitbit.api import FitbitOauth2Client
|
||||
from oauthlib.oauth2.rfc6749.errors import MismatchingStateError, MissingTokenError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import configurator
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.application_credentials import (
|
||||
ClientCredential,
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.sensor import (
|
||||
PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA,
|
||||
SensorDeviceClass,
|
||||
|
@ -25,9 +22,11 @@ from homeassistant.components.sensor import (
|
|||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_CLIENT_ID,
|
||||
CONF_CLIENT_SECRET,
|
||||
CONF_TOKEN,
|
||||
CONF_UNIT_SYSTEM,
|
||||
PERCENTAGE,
|
||||
UnitOfLength,
|
||||
|
@ -35,11 +34,11 @@ from homeassistant.const import (
|
|||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
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.json import save_json
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util.json import load_json_object
|
||||
|
||||
|
@ -54,8 +53,7 @@ from .const import (
|
|||
CONF_MONITORED_RESOURCES,
|
||||
DEFAULT_CLOCK_FORMAT,
|
||||
DEFAULT_CONFIG,
|
||||
FITBIT_AUTH_CALLBACK_PATH,
|
||||
FITBIT_AUTH_START,
|
||||
DOMAIN,
|
||||
FITBIT_CONFIG_FILE,
|
||||
FITBIT_DEFAULT_RESOURCES,
|
||||
FitbitUnitSystem,
|
||||
|
@ -129,6 +127,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
|
||||
|
||||
|
||||
FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
||||
|
@ -137,18 +136,22 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
name="Activity Calories",
|
||||
native_unit_of_measurement="cal",
|
||||
icon="mdi:fire",
|
||||
scope="activity",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/calories",
|
||||
name="Calories",
|
||||
native_unit_of_measurement="cal",
|
||||
icon="mdi:fire",
|
||||
scope="activity",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/caloriesBMR",
|
||||
name="Calories BMR",
|
||||
native_unit_of_measurement="cal",
|
||||
icon="mdi:fire",
|
||||
scope="activity",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/distance",
|
||||
|
@ -157,6 +160,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
device_class=SensorDeviceClass.DISTANCE,
|
||||
value_fn=_distance_value_fn,
|
||||
unit_fn=_distance_unit,
|
||||
scope="activity",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/elevation",
|
||||
|
@ -164,12 +168,14 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
icon="mdi:walk",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
unit_fn=_elevation_unit,
|
||||
scope="activity",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/floors",
|
||||
name="Floors",
|
||||
native_unit_of_measurement="floors",
|
||||
icon="mdi:walk",
|
||||
scope="activity",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/heart",
|
||||
|
@ -177,6 +183,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",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/minutesFairlyActive",
|
||||
|
@ -184,6 +191,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
icon="mdi:walk",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
scope="activity",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/minutesLightlyActive",
|
||||
|
@ -191,6 +199,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
icon="mdi:walk",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
scope="activity",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/minutesSedentary",
|
||||
|
@ -198,6 +207,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
icon="mdi:seat-recline-normal",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
scope="activity",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/minutesVeryActive",
|
||||
|
@ -205,24 +215,30 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
icon="mdi:run",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
scope="activity",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/steps",
|
||||
name="Steps",
|
||||
native_unit_of_measurement="steps",
|
||||
icon="mdi:walk",
|
||||
scope="activity",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/tracker/activityCalories",
|
||||
name="Tracker Activity Calories",
|
||||
native_unit_of_measurement="cal",
|
||||
icon="mdi:fire",
|
||||
scope="activity",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/tracker/calories",
|
||||
name="Tracker Calories",
|
||||
native_unit_of_measurement="cal",
|
||||
icon="mdi:fire",
|
||||
scope="activity",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/tracker/distance",
|
||||
|
@ -231,6 +247,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
device_class=SensorDeviceClass.DISTANCE,
|
||||
value_fn=_distance_value_fn,
|
||||
unit_fn=_distance_unit,
|
||||
scope="activity",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/tracker/elevation",
|
||||
|
@ -238,12 +256,16 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
icon="mdi:walk",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
unit_fn=_elevation_unit,
|
||||
scope="activity",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/tracker/floors",
|
||||
name="Tracker Floors",
|
||||
native_unit_of_measurement="floors",
|
||||
icon="mdi:walk",
|
||||
scope="activity",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/tracker/minutesFairlyActive",
|
||||
|
@ -251,6 +273,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
icon="mdi:walk",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
scope="activity",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/tracker/minutesLightlyActive",
|
||||
|
@ -258,6 +282,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
icon="mdi:walk",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
scope="activity",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/tracker/minutesSedentary",
|
||||
|
@ -265,6 +291,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
icon="mdi:seat-recline-normal",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
scope="activity",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/tracker/minutesVeryActive",
|
||||
|
@ -272,12 +300,16 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
icon="mdi:run",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
scope="activity",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="activities/tracker/steps",
|
||||
name="Tracker Steps",
|
||||
native_unit_of_measurement="steps",
|
||||
icon="mdi:walk",
|
||||
scope="activity",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="body/bmi",
|
||||
|
@ -286,6 +318,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
icon="mdi:human",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=_body_value_fn,
|
||||
scope="weight",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="body/fat",
|
||||
|
@ -294,6 +328,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
icon="mdi:human",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=_body_value_fn,
|
||||
scope="weight",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="body/weight",
|
||||
|
@ -303,12 +339,14 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
device_class=SensorDeviceClass.WEIGHT,
|
||||
value_fn=_body_value_fn,
|
||||
unit_fn=_weight_unit,
|
||||
scope="weight",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="sleep/awakeningsCount",
|
||||
name="Awakenings Count",
|
||||
native_unit_of_measurement="times awaken",
|
||||
icon="mdi:sleep",
|
||||
scope="sleep",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="sleep/efficiency",
|
||||
|
@ -316,6 +354,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
native_unit_of_measurement=PERCENTAGE,
|
||||
icon="mdi:sleep",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
scope="sleep",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="sleep/minutesAfterWakeup",
|
||||
|
@ -323,6 +362,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
icon="mdi:sleep",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
scope="sleep",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="sleep/minutesAsleep",
|
||||
|
@ -330,6 +370,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
icon="mdi:sleep",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
scope="sleep",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="sleep/minutesAwake",
|
||||
|
@ -337,6 +378,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
icon="mdi:sleep",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
scope="sleep",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="sleep/minutesToFallAsleep",
|
||||
|
@ -344,6 +386,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
icon="mdi:sleep",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
scope="sleep",
|
||||
),
|
||||
FitbitSensorEntityDescription(
|
||||
key="sleep/timeInBed",
|
||||
|
@ -351,6 +394,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
|
|||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
icon="mdi:hotel",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
scope="sleep",
|
||||
),
|
||||
)
|
||||
|
||||
|
@ -359,18 +403,21 @@ SLEEP_START_TIME = FitbitSensorEntityDescription(
|
|||
key="sleep/startTime",
|
||||
name="Sleep Start Time",
|
||||
icon="mdi:clock",
|
||||
scope="sleep",
|
||||
)
|
||||
SLEEP_START_TIME_12HR = FitbitSensorEntityDescription(
|
||||
key="sleep/startTime",
|
||||
name="Sleep Start Time",
|
||||
icon="mdi:clock",
|
||||
value_fn=_clock_format_12h,
|
||||
scope="sleep",
|
||||
)
|
||||
|
||||
FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription(
|
||||
key="devices/battery",
|
||||
name="Battery",
|
||||
icon="mdi:battery",
|
||||
scope="settings",
|
||||
)
|
||||
|
||||
FITBIT_RESOURCES_KEYS: Final[list[str]] = [
|
||||
|
@ -397,88 +444,29 @@ PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend(
|
|||
}
|
||||
)
|
||||
|
||||
|
||||
def request_app_setup(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
config_path: str,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Assist user with configuring the Fitbit dev application."""
|
||||
|
||||
def fitbit_configuration_callback(fields: list[dict[str, str]]) -> None:
|
||||
"""Handle configuration updates."""
|
||||
config_path = hass.config.path(FITBIT_CONFIG_FILE)
|
||||
if os.path.isfile(config_path):
|
||||
config_file = load_json_object(config_path)
|
||||
if config_file == DEFAULT_CONFIG:
|
||||
error_msg = (
|
||||
f"You didn't correctly modify {FITBIT_CONFIG_FILE}, please try"
|
||||
" again."
|
||||
)
|
||||
|
||||
configurator.notify_errors(hass, _CONFIGURING["fitbit"], error_msg)
|
||||
else:
|
||||
setup_platform(hass, config, add_entities, discovery_info)
|
||||
else:
|
||||
setup_platform(hass, config, add_entities, discovery_info)
|
||||
|
||||
try:
|
||||
description = f"""Please create a Fitbit developer app at
|
||||
https://dev.fitbit.com/apps/new.
|
||||
For the OAuth 2.0 Application Type choose Personal.
|
||||
Set the Callback URL to {get_url(hass, require_ssl=True)}{FITBIT_AUTH_CALLBACK_PATH}.
|
||||
(Note: Your Home Assistant instance must be accessible via HTTPS.)
|
||||
They will provide you a Client ID and secret.
|
||||
These need to be saved into the file located at: {config_path}.
|
||||
Then come back here and hit the below button.
|
||||
"""
|
||||
except NoURLAvailableError:
|
||||
_LOGGER.error(
|
||||
"Could not find an SSL enabled URL for your Home Assistant instance. "
|
||||
"Fitbit requires that your Home Assistant instance is accessible via HTTPS"
|
||||
)
|
||||
return
|
||||
|
||||
submit = f"I have saved my Client ID and Client Secret into {FITBIT_CONFIG_FILE}."
|
||||
|
||||
_CONFIGURING["fitbit"] = configurator.request_config(
|
||||
hass,
|
||||
"Fitbit",
|
||||
fitbit_configuration_callback,
|
||||
description=description,
|
||||
submit_caption=submit,
|
||||
description_image="/static/images/config_fitbit_app.png",
|
||||
)
|
||||
# Only import configuration if it was previously created successfully with all
|
||||
# of the following fields.
|
||||
FITBIT_CONF_KEYS = [
|
||||
CONF_CLIENT_ID,
|
||||
CONF_CLIENT_SECRET,
|
||||
ATTR_ACCESS_TOKEN,
|
||||
ATTR_REFRESH_TOKEN,
|
||||
ATTR_LAST_SAVED_AT,
|
||||
]
|
||||
|
||||
|
||||
def request_oauth_completion(hass: HomeAssistant) -> None:
|
||||
"""Request user complete Fitbit OAuth2 flow."""
|
||||
if "fitbit" in _CONFIGURING:
|
||||
configurator.notify_errors(
|
||||
hass, _CONFIGURING["fitbit"], "Failed to register, please try again."
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
def fitbit_configuration_callback(fields: list[dict[str, str]]) -> None:
|
||||
"""Handle configuration updates."""
|
||||
|
||||
start_url = f"{get_url(hass, require_ssl=True)}{FITBIT_AUTH_START}"
|
||||
|
||||
description = f"Please authorize Fitbit by visiting {start_url}"
|
||||
|
||||
_CONFIGURING["fitbit"] = configurator.request_config(
|
||||
hass,
|
||||
"Fitbit",
|
||||
fitbit_configuration_callback,
|
||||
description=description,
|
||||
submit_caption="I have authorized Fitbit.",
|
||||
)
|
||||
def load_config_file(config_path: str) -> dict[str, Any] | None:
|
||||
"""Load existing valid fitbit.conf from disk for import."""
|
||||
if os.path.isfile(config_path):
|
||||
config_file = load_json_object(config_path)
|
||||
if config_file != DEFAULT_CONFIG and all(
|
||||
key in config_file for key in FITBIT_CONF_KEYS
|
||||
):
|
||||
return config_file
|
||||
return None
|
||||
|
||||
|
||||
def setup_platform(
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
|
@ -486,182 +474,119 @@ def setup_platform(
|
|||
) -> None:
|
||||
"""Set up the Fitbit sensor."""
|
||||
config_path = hass.config.path(FITBIT_CONFIG_FILE)
|
||||
if os.path.isfile(config_path):
|
||||
config_file = load_json_object(config_path)
|
||||
if config_file == DEFAULT_CONFIG:
|
||||
request_app_setup(
|
||||
hass, config, add_entities, config_path, discovery_info=None
|
||||
)
|
||||
return
|
||||
config_file = await hass.async_add_executor_job(load_config_file, config_path)
|
||||
_LOGGER.debug("loaded config file: %s", config_file)
|
||||
|
||||
if config_file is not None:
|
||||
_LOGGER.debug("Importing existing fitbit.conf application credentials")
|
||||
await async_import_client_credential(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ClientCredential(
|
||||
config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET]
|
||||
),
|
||||
)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
CONF_TOKEN: {
|
||||
ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN],
|
||||
ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN],
|
||||
"expires_at": config_file[ATTR_LAST_SAVED_AT],
|
||||
},
|
||||
CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT],
|
||||
CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM],
|
||||
CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES],
|
||||
},
|
||||
)
|
||||
translation_key = "deprecated_yaml_import"
|
||||
if (
|
||||
result.get("type") == FlowResultType.ABORT
|
||||
and result.get("reason") == "cannot_connect"
|
||||
):
|
||||
translation_key = "deprecated_yaml_import_issue_cannot_connect"
|
||||
else:
|
||||
save_json(config_path, DEFAULT_CONFIG)
|
||||
request_app_setup(hass, config, add_entities, config_path, discovery_info=None)
|
||||
return
|
||||
translation_key = "deprecated_yaml_no_import"
|
||||
|
||||
if "fitbit" in _CONFIGURING:
|
||||
configurator.request_done(hass, _CONFIGURING.pop("fitbit"))
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_yaml",
|
||||
breaks_in_ha_version="2024.5.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key=translation_key,
|
||||
)
|
||||
|
||||
if (
|
||||
(access_token := config_file.get(ATTR_ACCESS_TOKEN)) is not None
|
||||
and (refresh_token := config_file.get(ATTR_REFRESH_TOKEN)) is not None
|
||||
and (expires_at := config_file.get(ATTR_LAST_SAVED_AT)) is not None
|
||||
):
|
||||
authd_client = Fitbit(
|
||||
config_file.get(CONF_CLIENT_ID),
|
||||
config_file.get(CONF_CLIENT_SECRET),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_at=expires_at,
|
||||
refresh_cb=lambda x: None,
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Fitbit sensor platform."""
|
||||
|
||||
api: FitbitApi = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
# 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(" ")
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
resource_list = [
|
||||
*FITBIT_RESOURCES_LIST,
|
||||
SLEEP_START_TIME_12HR if clock_format == "12H" else SLEEP_START_TIME,
|
||||
]
|
||||
|
||||
entities = [
|
||||
FitbitSensor(
|
||||
api,
|
||||
user_profile.encoded_id,
|
||||
description,
|
||||
units=description.unit_fn(unit_system),
|
||||
enable_default_override=is_explicit_enable(description),
|
||||
)
|
||||
|
||||
if int(time.time()) - cast(int, expires_at) > 3600:
|
||||
authd_client.client.refresh_token()
|
||||
|
||||
api = FitbitApi(hass, authd_client, config[CONF_UNIT_SYSTEM])
|
||||
user_profile = asyncio.run_coroutine_threadsafe(
|
||||
api.async_get_user_profile(), hass.loop
|
||||
).result()
|
||||
unit_system = asyncio.run_coroutine_threadsafe(
|
||||
api.async_get_unit_system(), hass.loop
|
||||
).result()
|
||||
|
||||
clock_format = config[CONF_CLOCK_FORMAT]
|
||||
monitored_resources = config[CONF_MONITORED_RESOURCES]
|
||||
resource_list = [
|
||||
*FITBIT_RESOURCES_LIST,
|
||||
SLEEP_START_TIME_12HR if clock_format == "12H" else SLEEP_START_TIME,
|
||||
]
|
||||
entities = [
|
||||
FitbitSensor(
|
||||
api,
|
||||
user_profile.encoded_id,
|
||||
config_path,
|
||||
description,
|
||||
units=description.unit_fn(unit_system),
|
||||
)
|
||||
for description in resource_list
|
||||
if description.key in monitored_resources
|
||||
]
|
||||
if "devices/battery" in monitored_resources:
|
||||
devices = asyncio.run_coroutine_threadsafe(
|
||||
api.async_get_devices(),
|
||||
hass.loop,
|
||||
).result()
|
||||
entities.extend(
|
||||
[
|
||||
FitbitSensor(
|
||||
api,
|
||||
user_profile.encoded_id,
|
||||
config_path,
|
||||
FITBIT_RESOURCE_BATTERY,
|
||||
device,
|
||||
)
|
||||
for device in devices
|
||||
]
|
||||
)
|
||||
add_entities(entities, True)
|
||||
|
||||
else:
|
||||
oauth = FitbitOauth2Client(
|
||||
config_file.get(CONF_CLIENT_ID), config_file.get(CONF_CLIENT_SECRET)
|
||||
)
|
||||
|
||||
redirect_uri = f"{get_url(hass, require_ssl=True)}{FITBIT_AUTH_CALLBACK_PATH}"
|
||||
|
||||
fitbit_auth_start_url, _ = oauth.authorize_token_url(
|
||||
redirect_uri=redirect_uri,
|
||||
scope=[
|
||||
"activity",
|
||||
"heartrate",
|
||||
"nutrition",
|
||||
"profile",
|
||||
"settings",
|
||||
"sleep",
|
||||
"weight",
|
||||
],
|
||||
)
|
||||
|
||||
hass.http.register_redirect(FITBIT_AUTH_START, fitbit_auth_start_url)
|
||||
hass.http.register_view(FitbitAuthCallbackView(config, add_entities, oauth))
|
||||
|
||||
request_oauth_completion(hass)
|
||||
|
||||
|
||||
class FitbitAuthCallbackView(HomeAssistantView):
|
||||
"""Handle OAuth finish callback requests."""
|
||||
|
||||
requires_auth = False
|
||||
url = FITBIT_AUTH_CALLBACK_PATH
|
||||
name = "api:fitbit:callback"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
oauth: FitbitOauth2Client,
|
||||
) -> None:
|
||||
"""Initialize the OAuth callback view."""
|
||||
self.config = config
|
||||
self.add_entities = add_entities
|
||||
self.oauth = oauth
|
||||
|
||||
async def get(self, request: Request) -> str:
|
||||
"""Finish OAuth callback request."""
|
||||
hass: HomeAssistant = request.app["hass"]
|
||||
data = request.query
|
||||
|
||||
response_message = """Fitbit has been successfully authorized!
|
||||
You can close this window now!"""
|
||||
|
||||
result = None
|
||||
if data.get("code") is not None:
|
||||
redirect_uri = f"{get_url(hass, require_current_request=True)}{FITBIT_AUTH_CALLBACK_PATH}"
|
||||
|
||||
try:
|
||||
result = await hass.async_add_executor_job(
|
||||
self.oauth.fetch_access_token, data.get("code"), redirect_uri
|
||||
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),
|
||||
)
|
||||
except MissingTokenError as error:
|
||||
_LOGGER.error("Missing token: %s", error)
|
||||
response_message = f"""Something went wrong when
|
||||
attempting authenticating with Fitbit. The error
|
||||
encountered was {error}. Please try again!"""
|
||||
except MismatchingStateError as error:
|
||||
_LOGGER.error("Mismatched state, CSRF error: %s", error)
|
||||
response_message = f"""Something went wrong when
|
||||
attempting authenticating with Fitbit. The error
|
||||
encountered was {error}. Please try again!"""
|
||||
else:
|
||||
_LOGGER.error("Unknown error when authing")
|
||||
response_message = """Something went wrong when
|
||||
attempting authenticating with Fitbit.
|
||||
An unknown error occurred. Please try again!
|
||||
"""
|
||||
|
||||
if result is None:
|
||||
_LOGGER.error("Unknown error when authing")
|
||||
response_message = """Something went wrong when
|
||||
attempting authenticating with Fitbit.
|
||||
An unknown error occurred. Please try again!
|
||||
"""
|
||||
|
||||
html_response = f"""<html><head><title>Fitbit Auth</title></head>
|
||||
<body><h1>{response_message}</h1></body></html>"""
|
||||
|
||||
if result:
|
||||
config_contents = {
|
||||
ATTR_ACCESS_TOKEN: result.get("access_token"),
|
||||
ATTR_REFRESH_TOKEN: result.get("refresh_token"),
|
||||
CONF_CLIENT_ID: self.oauth.client_id,
|
||||
CONF_CLIENT_SECRET: self.oauth.client_secret,
|
||||
ATTR_LAST_SAVED_AT: int(time.time()),
|
||||
}
|
||||
save_json(hass.config.path(FITBIT_CONFIG_FILE), config_contents)
|
||||
|
||||
hass.async_add_job(setup_platform, hass, self.config, self.add_entities)
|
||||
|
||||
return html_response
|
||||
for device in devices
|
||||
]
|
||||
)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class FitbitSensor(SensorEntity):
|
||||
|
@ -674,15 +599,14 @@ class FitbitSensor(SensorEntity):
|
|||
self,
|
||||
api: FitbitApi,
|
||||
user_profile_id: str,
|
||||
config_path: str,
|
||||
description: FitbitSensorEntityDescription,
|
||||
device: FitbitDevice | None = None,
|
||||
units: str | None = None,
|
||||
enable_default_override: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the Fitbit sensor."""
|
||||
self.entity_description = description
|
||||
self.api = api
|
||||
self.config_path = config_path
|
||||
self.device = device
|
||||
|
||||
self._attr_unique_id = f"{user_profile_id}_{description.key}"
|
||||
|
@ -693,6 +617,9 @@ class FitbitSensor(SensorEntity):
|
|||
if units is not None:
|
||||
self._attr_native_unit_of_measurement = units
|
||||
|
||||
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."""
|
||||
|
@ -730,16 +657,3 @@ class FitbitSensor(SensorEntity):
|
|||
else:
|
||||
result = await self.api.async_get_latest_time_series(resource_type)
|
||||
self._attr_native_value = self.entity_description.value_fn(result)
|
||||
|
||||
self.hass.async_add_executor_job(self._update_token)
|
||||
|
||||
def _update_token(self) -> None:
|
||||
token = self.api.client.client.session.token
|
||||
config_contents = {
|
||||
ATTR_ACCESS_TOKEN: token.get("access_token"),
|
||||
ATTR_REFRESH_TOKEN: token.get("refresh_token"),
|
||||
CONF_CLIENT_ID: self.api.client.client.client_id,
|
||||
CONF_CLIENT_SECRET: self.api.client.client.client_secret,
|
||||
ATTR_LAST_SAVED_AT: int(time.time()),
|
||||
}
|
||||
save_json(self.config_path, config_contents)
|
||||
|
|
38
homeassistant/components/fitbit/strings.json
Normal file
38
homeassistant/components/fitbit/strings.json
Normal file
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Link Fitbit"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_no_import": {
|
||||
"title": "Fitbit YAML configuration is being removed",
|
||||
"description": "Configuring Fitbit using YAML is being removed.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf if it exists and restart Home Assistant and [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually."
|
||||
},
|
||||
"deprecated_yaml_import": {
|
||||
"title": "Fitbit YAML configuration is being removed",
|
||||
"description": "Configuring Fitbit using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically, including OAuth Application Credentials.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf and restart Home Assistant to fix this issue."
|
||||
},
|
||||
"deprecated_yaml_import_issue_cannot_connect": {
|
||||
"title": "The Fitbit YAML configuration import failed",
|
||||
"description": "Configuring Fitbit using YAML is being removed but there was a connection error importing your YAML configuration.\n\nRestart Home Assistant to try again or remove the Fitbit YAML configuration from your configuration.yaml file and remove the fitbit.conf and continue to [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually."
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ To update, run python3 -m script.hassfest
|
|||
|
||||
APPLICATION_CREDENTIALS = [
|
||||
"electric_kiwi",
|
||||
"fitbit",
|
||||
"geocaching",
|
||||
"google",
|
||||
"google_assistant_sdk",
|
||||
|
|
|
@ -143,6 +143,7 @@ FLOWS = {
|
|||
"fibaro",
|
||||
"filesize",
|
||||
"fireservicerota",
|
||||
"fitbit",
|
||||
"fivem",
|
||||
"fjaraskupan",
|
||||
"flick_electric",
|
||||
|
|
|
@ -1733,7 +1733,7 @@
|
|||
"fitbit": {
|
||||
"name": "Fitbit",
|
||||
"integration_type": "hub",
|
||||
"config_flow": false,
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"fivem": {
|
||||
|
|
|
@ -10,15 +10,28 @@ from unittest.mock import patch
|
|||
import pytest
|
||||
from requests_mock.mocker import Mocker
|
||||
|
||||
from homeassistant.components.fitbit.const import DOMAIN
|
||||
from homeassistant.components.application_credentials import (
|
||||
ClientCredential,
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.fitbit.const import (
|
||||
CONF_CLIENT_ID,
|
||||
CONF_CLIENT_SECRET,
|
||||
DOMAIN,
|
||||
OAUTH_SCOPES,
|
||||
)
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
CLIENT_ID = "1234"
|
||||
CLIENT_SECRET = "5678"
|
||||
PROFILE_USER_ID = "fitbit-api-user-id-1"
|
||||
FAKE_TOKEN = "some-token"
|
||||
FAKE_ACCESS_TOKEN = "some-access-token"
|
||||
FAKE_REFRESH_TOKEN = "some-refresh-token"
|
||||
FAKE_AUTH_IMPL = "conftest-imported-cred"
|
||||
|
||||
PROFILE_API_URL = "https://api.fitbit.com/1/user/-/profile.json"
|
||||
DEVICES_API_URL = "https://api.fitbit.com/1/user/-/devices.json"
|
||||
|
@ -26,6 +39,14 @@ TIMESERIES_API_URL_FORMAT = (
|
|||
"https://api.fitbit.com/1/user/-/{resource}/date/today/7d.json"
|
||||
)
|
||||
|
||||
# These constants differ from values in the config entry or fitbit.conf
|
||||
SERVER_ACCESS_TOKEN = {
|
||||
"refresh_token": "server-access-token",
|
||||
"access_token": "server-refresh-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="token_expiration_time")
|
||||
def mcok_token_expiration_time() -> float:
|
||||
|
@ -33,29 +54,73 @@ def mcok_token_expiration_time() -> float:
|
|||
return time.time() + 86400
|
||||
|
||||
|
||||
@pytest.fixture(name="scopes")
|
||||
def mock_scopes() -> list[str]:
|
||||
"""Fixture for expiration time of the config entry auth token."""
|
||||
return OAUTH_SCOPES
|
||||
|
||||
|
||||
@pytest.fixture(name="token_entry")
|
||||
def mock_token_entry(token_expiration_time: float, scopes: list[str]) -> dict[str, Any]:
|
||||
"""Fixture for OAuth 'token' data for a ConfigEntry."""
|
||||
return {
|
||||
"access_token": FAKE_ACCESS_TOKEN,
|
||||
"refresh_token": FAKE_REFRESH_TOKEN,
|
||||
"scope": " ".join(scopes),
|
||||
"token_type": "Bearer",
|
||||
"expires_at": token_expiration_time,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="config_entry")
|
||||
def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry:
|
||||
"""Fixture for a config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
"auth_implementation": FAKE_AUTH_IMPL,
|
||||
"token": token_entry,
|
||||
},
|
||||
unique_id=PROFILE_USER_ID,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def setup_credentials(hass: HomeAssistant) -> None:
|
||||
"""Fixture to setup credentials."""
|
||||
assert await async_setup_component(hass, "application_credentials", {})
|
||||
await async_import_client_credential(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ClientCredential(CLIENT_ID, CLIENT_SECRET),
|
||||
FAKE_AUTH_IMPL,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="fitbit_config_yaml")
|
||||
def mock_fitbit_config_yaml(token_expiration_time: float) -> dict[str, Any]:
|
||||
def mock_fitbit_config_yaml(token_expiration_time: float) -> dict[str, Any] | None:
|
||||
"""Fixture for the yaml fitbit.conf file contents."""
|
||||
return {
|
||||
"access_token": FAKE_TOKEN,
|
||||
CONF_CLIENT_ID: CLIENT_ID,
|
||||
CONF_CLIENT_SECRET: CLIENT_SECRET,
|
||||
"access_token": FAKE_ACCESS_TOKEN,
|
||||
"refresh_token": FAKE_REFRESH_TOKEN,
|
||||
"last_saved_at": token_expiration_time,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="fitbit_config_setup", autouse=True)
|
||||
@pytest.fixture(name="fitbit_config_setup")
|
||||
def mock_fitbit_config_setup(
|
||||
fitbit_config_yaml: dict[str, Any],
|
||||
fitbit_config_yaml: dict[str, Any] | None,
|
||||
) -> Generator[None, None, None]:
|
||||
"""Fixture to mock out fitbit.conf file data loading and persistence."""
|
||||
|
||||
has_config = fitbit_config_yaml is not None
|
||||
with patch(
|
||||
"homeassistant.components.fitbit.sensor.os.path.isfile", return_value=True
|
||||
"homeassistant.components.fitbit.sensor.os.path.isfile",
|
||||
return_value=has_config,
|
||||
), patch(
|
||||
"homeassistant.components.fitbit.sensor.load_json_object",
|
||||
return_value=fitbit_config_yaml,
|
||||
), patch(
|
||||
"homeassistant.components.fitbit.sensor.save_json",
|
||||
):
|
||||
yield
|
||||
|
||||
|
@ -112,6 +177,30 @@ async def mock_sensor_platform_setup(
|
|||
return run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Fixture to specify platforms to test."""
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture(name="integration_setup")
|
||||
async def mock_integration_setup(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
platforms: list[str],
|
||||
) -> Callable[[], Awaitable[bool]]:
|
||||
"""Fixture to set up the integration."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
async def run() -> bool:
|
||||
with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms):
|
||||
result = await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return result
|
||||
|
||||
return run
|
||||
|
||||
|
||||
@pytest.fixture(name="profile_id")
|
||||
def mock_profile_id() -> str:
|
||||
"""Fixture for the profile id returned from the API response."""
|
||||
|
|
315
tests/components/fitbit/test_config_flow.py
Normal file
315
tests/components/fitbit/test_config_flow.py
Normal file
|
@ -0,0 +1,315 @@
|
|||
"""Test the fitbit config flow."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import patch
|
||||
|
||||
from requests_mock.mocker import Mocker
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.fitbit.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir
|
||||
|
||||
from .conftest import (
|
||||
CLIENT_ID,
|
||||
FAKE_ACCESS_TOKEN,
|
||||
FAKE_AUTH_IMPL,
|
||||
FAKE_REFRESH_TOKEN,
|
||||
PROFILE_API_URL,
|
||||
PROFILE_USER_ID,
|
||||
SERVER_ACCESS_TOKEN,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
REDIRECT_URL = "https://example.com/auth/external/callback"
|
||||
|
||||
|
||||
async def test_full_flow(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
current_request_with_host: None,
|
||||
profile: None,
|
||||
setup_credentials: None,
|
||||
) -> None:
|
||||
"""Check full flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{
|
||||
"flow_id": result["flow_id"],
|
||||
"redirect_uri": REDIRECT_URL,
|
||||
},
|
||||
)
|
||||
assert result["type"] == FlowResultType.EXTERNAL_STEP
|
||||
assert result["url"] == (
|
||||
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
|
||||
f"&redirect_uri={REDIRECT_URL}"
|
||||
f"&state={state}"
|
||||
"&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent"
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
json=SERVER_ACCESS_TOKEN,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.fitbit.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
config_entry = entries[0]
|
||||
assert config_entry.title == "My name"
|
||||
assert config_entry.unique_id == PROFILE_USER_ID
|
||||
|
||||
data = dict(config_entry.data)
|
||||
assert "token" in data
|
||||
del data["token"]["expires_at"]
|
||||
assert dict(config_entry.data) == {
|
||||
"auth_implementation": FAKE_AUTH_IMPL,
|
||||
"token": SERVER_ACCESS_TOKEN,
|
||||
}
|
||||
|
||||
|
||||
async def test_api_failure(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
current_request_with_host: None,
|
||||
requests_mock: Mocker,
|
||||
setup_credentials: None,
|
||||
) -> None:
|
||||
"""Test a failure to fetch the profile during the setup flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{
|
||||
"flow_id": result["flow_id"],
|
||||
"redirect_uri": REDIRECT_URL,
|
||||
},
|
||||
)
|
||||
assert result["type"] == FlowResultType.EXTERNAL_STEP
|
||||
assert result["url"] == (
|
||||
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
|
||||
f"&redirect_uri={REDIRECT_URL}"
|
||||
f"&state={state}"
|
||||
"&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent"
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
json=SERVER_ACCESS_TOKEN,
|
||||
)
|
||||
|
||||
requests_mock.register_uri(
|
||||
"GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
assert result.get("type") == FlowResultType.ABORT
|
||||
assert result.get("reason") == "cannot_connect"
|
||||
|
||||
|
||||
async def test_config_entry_already_exists(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
current_request_with_host: None,
|
||||
requests_mock: Mocker,
|
||||
setup_credentials: None,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that an account may only be configured once."""
|
||||
|
||||
# Verify existing config entry
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{
|
||||
"flow_id": result["flow_id"],
|
||||
"redirect_uri": REDIRECT_URL,
|
||||
},
|
||||
)
|
||||
assert result["type"] == FlowResultType.EXTERNAL_STEP
|
||||
assert result["url"] == (
|
||||
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
|
||||
f"&redirect_uri={REDIRECT_URL}"
|
||||
f"&state={state}"
|
||||
"&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent"
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
json=SERVER_ACCESS_TOKEN,
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
assert result.get("type") == FlowResultType.ABORT
|
||||
assert result.get("reason") == "already_configured"
|
||||
|
||||
|
||||
async def test_import_fitbit_config(
|
||||
hass: HomeAssistant,
|
||||
fitbit_config_setup: None,
|
||||
sensor_platform_setup: Callable[[], Awaitable[bool]],
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test that platform configuration is imported successfully."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.fitbit.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
await sensor_platform_setup()
|
||||
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
|
||||
# Verify valid profile can be fetched from the API
|
||||
config_entry = entries[0]
|
||||
assert config_entry.title == "My name"
|
||||
assert config_entry.unique_id == PROFILE_USER_ID
|
||||
|
||||
data = dict(config_entry.data)
|
||||
assert "token" in data
|
||||
del data["token"]["expires_at"]
|
||||
# Verify imported values from fitbit.conf and configuration.yaml
|
||||
assert dict(config_entry.data) == {
|
||||
"auth_implementation": DOMAIN,
|
||||
"clock_format": "24H",
|
||||
"monitored_resources": ["activities/steps"],
|
||||
"token": {
|
||||
"access_token": FAKE_ACCESS_TOKEN,
|
||||
"refresh_token": FAKE_REFRESH_TOKEN,
|
||||
},
|
||||
"unit_system": "default",
|
||||
}
|
||||
|
||||
# Verify an issue is raised for deprecated configuration.yaml
|
||||
issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml"))
|
||||
assert issue
|
||||
assert issue.translation_key == "deprecated_yaml_import"
|
||||
|
||||
|
||||
async def test_import_fitbit_config_failure_cannot_connect(
|
||||
hass: HomeAssistant,
|
||||
fitbit_config_setup: None,
|
||||
sensor_platform_setup: Callable[[], Awaitable[bool]],
|
||||
issue_registry: ir.IssueRegistry,
|
||||
requests_mock: Mocker,
|
||||
) -> None:
|
||||
"""Test platform configuration fails to import successfully."""
|
||||
|
||||
requests_mock.register_uri(
|
||||
"GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.fitbit.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
await sensor_platform_setup()
|
||||
|
||||
assert len(mock_setup.mock_calls) == 0
|
||||
|
||||
# Verify an issue is raised that we were unable to import configuration
|
||||
issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml"))
|
||||
assert issue
|
||||
assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect"
|
||||
|
||||
|
||||
async def test_import_fitbit_config_already_exists(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
setup_credentials: None,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
fitbit_config_setup: None,
|
||||
sensor_platform_setup: Callable[[], Awaitable[bool]],
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test that platform configuration is not imported if it already exists."""
|
||||
|
||||
# Verify existing config entry
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.fitbit.async_setup_entry", return_value=True
|
||||
) as mock_config_entry_setup:
|
||||
await integration_setup()
|
||||
|
||||
assert len(mock_config_entry_setup.mock_calls) == 1
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.fitbit.async_setup_entry", return_value=True
|
||||
) as mock_import_setup:
|
||||
await sensor_platform_setup()
|
||||
|
||||
assert len(mock_import_setup.mock_calls) == 0
|
||||
|
||||
# Still one config entry
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
|
||||
# Verify an issue is raised for deprecated configuration.yaml
|
||||
issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml"))
|
||||
assert issue
|
||||
assert issue.translation_key == "deprecated_yaml_import"
|
||||
|
||||
|
||||
async def test_platform_setup_without_import(
|
||||
hass: HomeAssistant,
|
||||
sensor_platform_setup: Callable[[], Awaitable[bool]],
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test platform configuration.yaml but no existing fitbit.conf credentials."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.fitbit.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
await sensor_platform_setup()
|
||||
|
||||
# Verify no configuration entry is imported since the integration is not
|
||||
# fully setup properly
|
||||
assert len(mock_setup.mock_calls) == 0
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 0
|
||||
|
||||
# Verify an issue is raised for deprecated configuration.yaml
|
||||
assert len(issue_registry.issues) == 1
|
||||
issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml"))
|
||||
assert issue
|
||||
assert issue.translation_key == "deprecated_yaml_no_import"
|
96
tests/components/fitbit/test_init.py
Normal file
96
tests/components/fitbit/test_init.py
Normal file
|
@ -0,0 +1,96 @@
|
|||
"""Test fitbit component."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.fitbit.const import (
|
||||
CONF_CLIENT_ID,
|
||||
CONF_CLIENT_SECRET,
|
||||
OAUTH2_TOKEN,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .conftest import (
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
FAKE_ACCESS_TOKEN,
|
||||
FAKE_REFRESH_TOKEN,
|
||||
SERVER_ACCESS_TOKEN,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
|
||||
|
||||
async def test_setup(
|
||||
hass: HomeAssistant,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
config_entry: MockConfigEntry,
|
||||
setup_credentials: None,
|
||||
) -> None:
|
||||
"""Test setting up the integration."""
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
|
||||
assert await integration_setup()
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
assert await hass.config_entries.async_unload(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token_expiration_time", [12345])
|
||||
async def test_token_refresh_failure(
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
config_entry: MockConfigEntry,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
setup_credentials: None,
|
||||
) -> None:
|
||||
"""Test where token is expired and the refresh attempt fails and will be retried."""
|
||||
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
assert not await integration_setup()
|
||||
assert config_entry.state == ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token_expiration_time", [12345])
|
||||
async def test_token_refresh_success(
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
config_entry: MockConfigEntry,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
setup_credentials: None,
|
||||
) -> None:
|
||||
"""Test where token is expired and the refresh attempt succeeds."""
|
||||
|
||||
assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN
|
||||
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
json=SERVER_ACCESS_TOKEN,
|
||||
)
|
||||
|
||||
assert await integration_setup()
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
# Verify token request
|
||||
assert len(aioclient_mock.mock_calls) == 1
|
||||
assert aioclient_mock.mock_calls[0][2] == {
|
||||
CONF_CLIENT_ID: CLIENT_ID,
|
||||
CONF_CLIENT_SECRET: CLIENT_SECRET,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": FAKE_REFRESH_TOKEN,
|
||||
}
|
||||
|
||||
# Verify updated token
|
||||
assert (
|
||||
config_entry.data["token"]["access_token"]
|
||||
== SERVER_ACCESS_TOKEN["access_token"]
|
||||
)
|
|
@ -7,6 +7,8 @@ from typing import Any
|
|||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.fitbit.const import DOMAIN
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
|
@ -32,6 +34,12 @@ DEVICE_RESPONSE_ARIA_AIR = {
|
|||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[str]:
|
||||
"""Fixture to specify platforms to test."""
|
||||
return [Platform.SENSOR]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"monitored_resources",
|
||||
|
@ -176,6 +184,7 @@ DEVICE_RESPONSE_ARIA_AIR = {
|
|||
)
|
||||
async def test_sensors(
|
||||
hass: HomeAssistant,
|
||||
fitbit_config_setup: None,
|
||||
sensor_platform_setup: Callable[[], Awaitable[bool]],
|
||||
register_timeseries: Callable[[str, dict[str, Any]], None],
|
||||
entity_registry: er.EntityRegistry,
|
||||
|
@ -190,6 +199,8 @@ async def test_sensors(
|
|||
api_resource, timeseries_response(api_resource.replace("/", "-"), api_value)
|
||||
)
|
||||
await sensor_platform_setup()
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
|
@ -204,12 +215,15 @@ async def test_sensors(
|
|||
)
|
||||
async def test_device_battery_level(
|
||||
hass: HomeAssistant,
|
||||
fitbit_config_setup: None,
|
||||
sensor_platform_setup: Callable[[], Awaitable[bool]],
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test battery level sensor for devices."""
|
||||
|
||||
await sensor_platform_setup()
|
||||
assert await sensor_platform_setup()
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
|
||||
state = hass.states.get("sensor.charge_2_battery")
|
||||
assert state
|
||||
|
@ -269,6 +283,7 @@ async def test_device_battery_level(
|
|||
)
|
||||
async def test_profile_local(
|
||||
hass: HomeAssistant,
|
||||
fitbit_config_setup: None,
|
||||
sensor_platform_setup: Callable[[], Awaitable[bool]],
|
||||
register_timeseries: Callable[[str, dict[str, Any]], None],
|
||||
expected_unit: str,
|
||||
|
@ -277,6 +292,8 @@ async def test_profile_local(
|
|||
|
||||
register_timeseries("body/weight", timeseries_response("body-weight", "175"))
|
||||
await sensor_platform_setup()
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
|
||||
state = hass.states.get("sensor.weight")
|
||||
assert state
|
||||
|
@ -315,6 +332,7 @@ async def test_profile_local(
|
|||
)
|
||||
async def test_sleep_time_clock_format(
|
||||
hass: HomeAssistant,
|
||||
fitbit_config_setup: None,
|
||||
sensor_platform_setup: Callable[[], Awaitable[bool]],
|
||||
register_timeseries: Callable[[str, dict[str, Any]], None],
|
||||
api_response: str,
|
||||
|
@ -330,3 +348,165 @@ async def test_sleep_time_clock_format(
|
|||
state = hass.states.get("sensor.sleep_start_time")
|
||||
assert state
|
||||
assert state.state == expected_state
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("scopes"),
|
||||
[(["activity"])],
|
||||
)
|
||||
async def test_activity_scope_config_entry(
|
||||
hass: HomeAssistant,
|
||||
setup_credentials: None,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
register_timeseries: Callable[[str, dict[str, Any]], None],
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test activity sensors are enabled."""
|
||||
|
||||
for api_resource in (
|
||||
"activities/activityCalories",
|
||||
"activities/calories",
|
||||
"activities/distance",
|
||||
"activities/elevation",
|
||||
"activities/floors",
|
||||
"activities/minutesFairlyActive",
|
||||
"activities/minutesLightlyActive",
|
||||
"activities/minutesSedentary",
|
||||
"activities/minutesVeryActive",
|
||||
"activities/steps",
|
||||
):
|
||||
register_timeseries(
|
||||
api_resource, timeseries_response(api_resource.replace("/", "-"), "0")
|
||||
)
|
||||
assert await integration_setup()
|
||||
|
||||
states = hass.states.async_all()
|
||||
assert {s.entity_id for s in states} == {
|
||||
"sensor.activity_calories",
|
||||
"sensor.calories",
|
||||
"sensor.distance",
|
||||
"sensor.elevation",
|
||||
"sensor.floors",
|
||||
"sensor.minutes_fairly_active",
|
||||
"sensor.minutes_lightly_active",
|
||||
"sensor.minutes_sedentary",
|
||||
"sensor.minutes_very_active",
|
||||
"sensor.steps",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("scopes"),
|
||||
[(["heartrate"])],
|
||||
)
|
||||
async def test_heartrate_scope_config_entry(
|
||||
hass: HomeAssistant,
|
||||
setup_credentials: None,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
register_timeseries: Callable[[str, dict[str, Any]], None],
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test heartrate sensors are enabled."""
|
||||
|
||||
register_timeseries(
|
||||
"activities/heart",
|
||||
timeseries_response("activities-heart", {"restingHeartRate": "0"}),
|
||||
)
|
||||
assert await integration_setup()
|
||||
|
||||
states = hass.states.async_all()
|
||||
assert {s.entity_id for s in states} == {
|
||||
"sensor.resting_heart_rate",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("scopes"),
|
||||
[(["sleep"])],
|
||||
)
|
||||
async def test_sleep_scope_config_entry(
|
||||
hass: HomeAssistant,
|
||||
setup_credentials: None,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
register_timeseries: Callable[[str, dict[str, Any]], None],
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test sleep sensors are enabled."""
|
||||
|
||||
for api_resource in (
|
||||
"sleep/startTime",
|
||||
"sleep/timeInBed",
|
||||
"sleep/minutesToFallAsleep",
|
||||
"sleep/minutesAwake",
|
||||
"sleep/minutesAsleep",
|
||||
"sleep/minutesAfterWakeup",
|
||||
"sleep/efficiency",
|
||||
"sleep/awakeningsCount",
|
||||
):
|
||||
register_timeseries(
|
||||
api_resource,
|
||||
timeseries_response(api_resource.replace("/", "-"), "0"),
|
||||
)
|
||||
assert await integration_setup()
|
||||
|
||||
states = hass.states.async_all()
|
||||
assert {s.entity_id for s in states} == {
|
||||
"sensor.awakenings_count",
|
||||
"sensor.sleep_efficiency",
|
||||
"sensor.minutes_after_wakeup",
|
||||
"sensor.sleep_minutes_asleep",
|
||||
"sensor.sleep_minutes_awake",
|
||||
"sensor.sleep_minutes_to_fall_asleep",
|
||||
"sensor.sleep_time_in_bed",
|
||||
"sensor.sleep_start_time",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("scopes"),
|
||||
[(["weight"])],
|
||||
)
|
||||
async def test_weight_scope_config_entry(
|
||||
hass: HomeAssistant,
|
||||
setup_credentials: None,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
register_timeseries: Callable[[str, dict[str, Any]], None],
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test sleep sensors are enabled."""
|
||||
|
||||
register_timeseries("body/weight", timeseries_response("body-weight", "0"))
|
||||
assert await integration_setup()
|
||||
|
||||
states = hass.states.async_all()
|
||||
assert [s.entity_id for s in states] == [
|
||||
"sensor.weight",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("scopes", "devices_response"),
|
||||
[(["settings"], [DEVICE_RESPONSE_CHARGE_2])],
|
||||
)
|
||||
async def test_settings_scope_config_entry(
|
||||
hass: HomeAssistant,
|
||||
setup_credentials: None,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
register_timeseries: Callable[[str, dict[str, Any]], None],
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test heartrate sensors are enabled."""
|
||||
|
||||
for api_resource in ("activities/heart",):
|
||||
register_timeseries(
|
||||
api_resource,
|
||||
timeseries_response(
|
||||
api_resource.replace("/", "-"), {"restingHeartRate": "0"}
|
||||
),
|
||||
)
|
||||
assert await integration_setup()
|
||||
|
||||
states = hass.states.async_all()
|
||||
assert [s.entity_id for s in states] == [
|
||||
"sensor.charge_2_battery",
|
||||
]
|
||||
|
|
Loading…
Add table
Reference in a new issue