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:
Allen Porter 2023-09-30 16:56:39 -07:00 committed by GitHub
parent fe30c019b6
commit bd2fee289d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1188 additions and 302 deletions

View file

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

View file

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

View 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,
),
)

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

View file

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

View file

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

View file

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

View 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."
}
}
}

View file

@ -5,6 +5,7 @@ To update, run python3 -m script.hassfest
APPLICATION_CREDENTIALS = [
"electric_kiwi",
"fitbit",
"geocaching",
"google",
"google_assistant_sdk",

View file

@ -143,6 +143,7 @@ FLOWS = {
"fibaro",
"filesize",
"fireservicerota",
"fitbit",
"fivem",
"fjaraskupan",
"flick_electric",

View file

@ -1733,7 +1733,7 @@
"fitbit": {
"name": "Fitbit",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "cloud_polling"
},
"fivem": {

View file

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

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

View 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"]
)

View file

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