"""Support for the Fitbit API.""" import datetime import logging import os import time from fitbit import Fitbit from fitbit.api import FitbitOauth2Client from oauthlib.oauth2.rfc6749.errors import MismatchingStateError, MissingTokenError import voluptuous as vol from homeassistant.components.http import HomeAssistantView from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_UNIT_SYSTEM, LENGTH_FEET, MASS_KILOGRAMS, MASS_MILLIGRAMS, TIME_MILLISECONDS, TIME_MINUTES, UNIT_PERCENTAGE, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.network import get_url from homeassistant.util.json import load_json, save_json _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) ATTR_ACCESS_TOKEN = "access_token" ATTR_REFRESH_TOKEN = "refresh_token" ATTR_LAST_SAVED_AT = "last_saved_at" CONF_MONITORED_RESOURCES = "monitored_resources" CONF_CLOCK_FORMAT = "clock_format" ATTRIBUTION = "Data provided by Fitbit.com" FITBIT_AUTH_CALLBACK_PATH = "/api/fitbit/callback" FITBIT_AUTH_START = "/api/fitbit" FITBIT_CONFIG_FILE = "fitbit.conf" FITBIT_DEFAULT_RESOURCES = ["activities/steps"] SCAN_INTERVAL = datetime.timedelta(minutes=30) DEFAULT_CONFIG = { CONF_CLIENT_ID: "CLIENT_ID_HERE", CONF_CLIENT_SECRET: "CLIENT_SECRET_HERE", } FITBIT_RESOURCES_LIST = { "activities/activityCalories": ["Activity Calories", "cal", "fire"], "activities/calories": ["Calories", "cal", "fire"], "activities/caloriesBMR": ["Calories BMR", "cal", "fire"], "activities/distance": ["Distance", "", "map-marker"], "activities/elevation": ["Elevation", "", "walk"], "activities/floors": ["Floors", "floors", "walk"], "activities/heart": ["Resting Heart Rate", "bpm", "heart-pulse"], "activities/minutesFairlyActive": ["Minutes Fairly Active", TIME_MINUTES, "walk"], "activities/minutesLightlyActive": ["Minutes Lightly Active", TIME_MINUTES, "walk"], "activities/minutesSedentary": [ "Minutes Sedentary", TIME_MINUTES, "seat-recline-normal", ], "activities/minutesVeryActive": ["Minutes Very Active", TIME_MINUTES, "run"], "activities/steps": ["Steps", "steps", "walk"], "activities/tracker/activityCalories": ["Tracker Activity Calories", "cal", "fire"], "activities/tracker/calories": ["Tracker Calories", "cal", "fire"], "activities/tracker/distance": ["Tracker Distance", "", "map-marker"], "activities/tracker/elevation": ["Tracker Elevation", "", "walk"], "activities/tracker/floors": ["Tracker Floors", "floors", "walk"], "activities/tracker/minutesFairlyActive": [ "Tracker Minutes Fairly Active", TIME_MINUTES, "walk", ], "activities/tracker/minutesLightlyActive": [ "Tracker Minutes Lightly Active", TIME_MINUTES, "walk", ], "activities/tracker/minutesSedentary": [ "Tracker Minutes Sedentary", TIME_MINUTES, "seat-recline-normal", ], "activities/tracker/minutesVeryActive": [ "Tracker Minutes Very Active", TIME_MINUTES, "run", ], "activities/tracker/steps": ["Tracker Steps", "steps", "walk"], "body/bmi": ["BMI", "BMI", "human"], "body/fat": ["Body Fat", UNIT_PERCENTAGE, "human"], "body/weight": ["Weight", "", "human"], "devices/battery": ["Battery", None, None], "sleep/awakeningsCount": ["Awakenings Count", "times awaken", "sleep"], "sleep/efficiency": ["Sleep Efficiency", UNIT_PERCENTAGE, "sleep"], "sleep/minutesAfterWakeup": ["Minutes After Wakeup", TIME_MINUTES, "sleep"], "sleep/minutesAsleep": ["Sleep Minutes Asleep", TIME_MINUTES, "sleep"], "sleep/minutesAwake": ["Sleep Minutes Awake", TIME_MINUTES, "sleep"], "sleep/minutesToFallAsleep": [ "Sleep Minutes to Fall Asleep", TIME_MINUTES, "sleep", ], "sleep/startTime": ["Sleep Start Time", None, "clock"], "sleep/timeInBed": ["Sleep Time in Bed", TIME_MINUTES, "hotel"], } FITBIT_MEASUREMENTS = { "en_US": { "duration": TIME_MILLISECONDS, "distance": "mi", "elevation": LENGTH_FEET, "height": "in", "weight": "lbs", "body": "in", "liquids": "fl. oz.", "blood glucose": f"{MASS_MILLIGRAMS}/dL", "battery": "", }, "en_GB": { "duration": TIME_MILLISECONDS, "distance": "kilometers", "elevation": "meters", "height": "centimeters", "weight": "stone", "body": "centimeters", "liquids": "milliliters", "blood glucose": "mmol/L", "battery": "", }, "metric": { "duration": TIME_MILLISECONDS, "distance": "kilometers", "elevation": "meters", "height": "centimeters", "weight": MASS_KILOGRAMS, "body": "centimeters", "liquids": "milliliters", "blood glucose": "mmol/L", "battery": "", }, } BATTERY_LEVELS = {"High": 100, "Medium": 50, "Low": 20, "Empty": 0} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional( CONF_MONITORED_RESOURCES, default=FITBIT_DEFAULT_RESOURCES ): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_LIST)]), vol.Optional(CONF_CLOCK_FORMAT, default="24H"): vol.In(["12H", "24H"]), vol.Optional(CONF_UNIT_SYSTEM, default="default"): vol.In( ["en_GB", "en_US", "metric", "default"] ), } ) def request_app_setup(hass, config, add_entities, config_path, discovery_info=None): """Assist user with configuring the Fitbit dev application.""" configurator = hass.components.configurator def fitbit_configuration_callback(callback_data): """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 = ( "You didn't correctly modify fitbit.conf", " please try again", ) configurator.notify_errors(_CONFIGURING["fitbit"], error_msg) else: setup_platform(hass, config, add_entities, discovery_info) else: setup_platform(hass, config, add_entities, discovery_info) start_url = f"{get_url(hass)}{FITBIT_AUTH_CALLBACK_PATH}" 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 {start_url}. 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. """ submit = "I have saved my Client ID and Client Secret into fitbit.conf." _CONFIGURING["fitbit"] = configurator.request_config( "Fitbit", fitbit_configuration_callback, description=description, submit_caption=submit, description_image="/static/images/config_fitbit_app.png", ) def request_oauth_completion(hass): """Request user complete Fitbit OAuth2 flow.""" configurator = hass.components.configurator if "fitbit" in _CONFIGURING: configurator.notify_errors( _CONFIGURING["fitbit"], "Failed to register, please try again." ) return def fitbit_configuration_callback(callback_data): """Handle configuration updates.""" start_url = f"{get_url(hass)}{FITBIT_AUTH_START}" description = f"Please authorize Fitbit by visiting {start_url}" _CONFIGURING["fitbit"] = configurator.request_config( "Fitbit", fitbit_configuration_callback, description=description, submit_caption="I have authorized Fitbit.", ) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Fitbit sensor.""" 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: request_app_setup( hass, config, add_entities, config_path, discovery_info=None ) return False else: save_json(config_path, DEFAULT_CONFIG) request_app_setup(hass, config, add_entities, config_path, discovery_info=None) return False if "fitbit" in _CONFIGURING: hass.components.configurator.request_done(_CONFIGURING.pop("fitbit")) access_token = config_file.get(ATTR_ACCESS_TOKEN) refresh_token = config_file.get(ATTR_REFRESH_TOKEN) expires_at = config_file.get(ATTR_LAST_SAVED_AT) if None not in (access_token, refresh_token): 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() unit_system = config.get(CONF_UNIT_SYSTEM) if unit_system == "default": authd_client.system = authd_client.user_profile_get()["user"]["locale"] if authd_client.system != "en_GB": if hass.config.units.is_metric: authd_client.system = "metric" else: authd_client.system = "en_US" else: authd_client.system = unit_system dev = [] registered_devs = authd_client.get_devices() clock_format = config.get(CONF_CLOCK_FORMAT) for resource in config.get(CONF_MONITORED_RESOURCES): # monitor battery for all linked FitBit devices if resource == "devices/battery": for dev_extra in registered_devs: dev.append( FitbitSensor( authd_client, config_path, resource, hass.config.units.is_metric, clock_format, dev_extra, ) ) else: dev.append( FitbitSensor( authd_client, config_path, resource, hass.config.units.is_metric, clock_format, ) ) add_entities(dev, True) else: oauth = FitbitOauth2Client( config_file.get(CONF_CLIENT_ID), config_file.get(CONF_CLIENT_SECRET) ) redirect_uri = f"{get_url(hass)}{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, add_entities, oauth): """Initialize the OAuth callback view.""" self.config = config self.add_entities = add_entities self.oauth = oauth @callback def get(self, request): """Finish OAuth callback request.""" hass = 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)}{FITBIT_AUTH_CALLBACK_PATH}" try: result = 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"""