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