"""Support for the Fitbit API.""" from __future__ import annotations 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.sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, SensorEntity, ) from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_UNIT_SYSTEM from homeassistant.core import HomeAssistant 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.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import load_json, save_json from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( ATTR_ACCESS_TOKEN, ATTR_LAST_SAVED_AT, ATTR_REFRESH_TOKEN, ATTRIBUTION, BATTERY_LEVELS, CONF_CLOCK_FORMAT, CONF_MONITORED_RESOURCES, DEFAULT_CLOCK_FORMAT, DEFAULT_CONFIG, FITBIT_AUTH_CALLBACK_PATH, FITBIT_AUTH_START, FITBIT_CONFIG_FILE, FITBIT_DEFAULT_RESOURCES, FITBIT_MEASUREMENTS, FITBIT_RESOURCE_BATTERY, FITBIT_RESOURCES_KEYS, FITBIT_RESOURCES_LIST, FitbitSensorEntityDescription, ) _LOGGER: Final = logging.getLogger(__name__) _CONFIGURING: dict[str, str] = {} SCAN_INTERVAL: Final = datetime.timedelta(minutes=30) PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend( { vol.Optional( CONF_MONITORED_RESOURCES, default=FITBIT_DEFAULT_RESOURCES ): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_KEYS)]), vol.Optional(CONF_CLOCK_FORMAT, default=DEFAULT_CLOCK_FORMAT): vol.In( ["12H", "24H"] ), vol.Optional(CONF_UNIT_SYSTEM, default="default"): vol.In( ["en_GB", "en_US", "metric", "default"] ), } ) 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(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", ) 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 setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Fitbit sensor.""" config_path = hass.config.path(FITBIT_CONFIG_FILE) if os.path.isfile(config_path): config_file: ConfigType = cast(ConfigType, load_json(config_path)) if config_file == DEFAULT_CONFIG: request_app_setup( hass, config, add_entities, config_path, discovery_info=None ) return else: save_json(config_path, DEFAULT_CONFIG) request_app_setup(hass, config, add_entities, config_path, discovery_info=None) return if "fitbit" in _CONFIGURING: configurator.request_done(hass, _CONFIGURING.pop("fitbit")) access_token: str | None = config_file.get(ATTR_ACCESS_TOKEN) refresh_token: str | None = config_file.get(ATTR_REFRESH_TOKEN) expires_at: int | None = config_file.get(ATTR_LAST_SAVED_AT) if ( access_token is not None and refresh_token is not None and expires_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, ) if int(time.time()) - expires_at > 3600: authd_client.client.refresh_token() user_profile = authd_client.user_profile_get()["user"] if (unit_system := config[CONF_UNIT_SYSTEM]) == "default": authd_client.system = user_profile["locale"] if authd_client.system != "en_GB": if hass.config.units is METRIC_SYSTEM: authd_client.system = "metric" else: authd_client.system = "en_US" else: authd_client.system = unit_system registered_devs = authd_client.get_devices() clock_format = config[CONF_CLOCK_FORMAT] monitored_resources = config[CONF_MONITORED_RESOURCES] entities = [ FitbitSensor( authd_client, user_profile, config_path, description, hass.config.units is METRIC_SYSTEM, clock_format, ) for description in FITBIT_RESOURCES_LIST if description.key in monitored_resources ] if "devices/battery" in monitored_resources: entities.extend( [ FitbitSensor( authd_client, user_profile, config_path, FITBIT_RESOURCE_BATTERY, hass.config.units is METRIC_SYSTEM, clock_format, dev_extra, ) for dev_extra in registered_devs ] ) 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 ) 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"""