diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py index 04946f6386f..2a7b58d7d76 100644 --- a/homeassistant/components/fitbit/__init__.py +++ b/homeassistant/components/fitbit/__init__.py @@ -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 diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index bf287471292..9ebfbcf7188 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -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 diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py new file mode 100644 index 00000000000..95a7cf799bf --- /dev/null +++ b/homeassistant/components/fitbit/application_credentials.py @@ -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, + ), + ) diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py new file mode 100644 index 00000000000..d391660df97 --- /dev/null +++ b/homeassistant/components/fitbit/config_flow.py @@ -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) diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index 19734add07a..9c77ea79a4f 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -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", +] diff --git a/homeassistant/components/fitbit/manifest.json b/homeassistant/components/fitbit/manifest.json index 510fe8da900..7739c7237f0 100644 --- a/homeassistant/components/fitbit/manifest.json +++ b/homeassistant/components/fitbit/manifest.json @@ -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"], diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index e08f56e0e34..8fbd9a25474 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -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"""Fitbit Auth -

{response_message}

""" - - 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) diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json new file mode 100644 index 00000000000..240f34154ae --- /dev/null +++ b/homeassistant/components/fitbit/strings.json @@ -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." + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 8c9e3a57ddc..a4db1b4c0de 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -5,6 +5,7 @@ To update, run python3 -m script.hassfest APPLICATION_CREDENTIALS = [ "electric_kiwi", + "fitbit", "geocaching", "google", "google_assistant_sdk", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ef22ac4f653..b9e1fcf5259 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -143,6 +143,7 @@ FLOWS = { "fibaro", "filesize", "fireservicerota", + "fitbit", "fivem", "fjaraskupan", "flick_electric", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1d9c2208ad0..253669edf7d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1733,7 +1733,7 @@ "fitbit": { "name": "Fitbit", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "fivem": { diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 7499a060933..155e5499543 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -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.""" diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py new file mode 100644 index 00000000000..0418f7da0f4 --- /dev/null +++ b/tests/components/fitbit/test_config_flow.py @@ -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" diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py new file mode 100644 index 00000000000..65a7587f736 --- /dev/null +++ b/tests/components/fitbit/test_init.py @@ -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"] + ) diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 636afeacf16..9e2089b959c 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -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", + ]