"""Support for the Fitbit API.""" import os import logging import datetime import time import voluptuous as vol from homeassistant.core import callback from homeassistant.components.http import HomeAssistantView from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.const import CONF_UNIT_SYSTEM from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level import homeassistant.helpers.config_validation as cv 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_CLIENT_ID = "client_id" ATTR_CLIENT_SECRET = "client_secret" 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 = {"client_id": "CLIENT_ID_HERE", "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", "minutes", "walk"], "activities/minutesLightlyActive": ["Minutes Lightly Active", "minutes", "walk"], "activities/minutesSedentary": [ "Minutes Sedentary", "minutes", "seat-recline-normal", ], "activities/minutesVeryActive": ["Minutes Very Active", "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", "minutes", "walk", ], "activities/tracker/minutesLightlyActive": [ "Tracker Minutes Lightly Active", "minutes", "walk", ], "activities/tracker/minutesSedentary": [ "Tracker Minutes Sedentary", "minutes", "seat-recline-normal", ], "activities/tracker/minutesVeryActive": [ "Tracker Minutes Very Active", "minutes", "run", ], "activities/tracker/steps": ["Tracker Steps", "steps", "walk"], "body/bmi": ["BMI", "BMI", "human"], "body/fat": ["Body Fat", "%", "human"], "body/weight": ["Weight", "", "human"], "devices/battery": ["Battery", None, None], "sleep/awakeningsCount": ["Awakenings Count", "times awaken", "sleep"], "sleep/efficiency": ["Sleep Efficiency", "%", "sleep"], "sleep/minutesAfterWakeup": ["Minutes After Wakeup", "minutes", "sleep"], "sleep/minutesAsleep": ["Sleep Minutes Asleep", "minutes", "sleep"], "sleep/minutesAwake": ["Sleep Minutes Awake", "minutes", "sleep"], "sleep/minutesToFallAsleep": ["Sleep Minutes to Fall Asleep", "minutes", "sleep"], "sleep/startTime": ["Sleep Start Time", None, "clock"], "sleep/timeInBed": ["Sleep Time in Bed", "minutes", "hotel"], } FITBIT_MEASUREMENTS = { "en_US": { "duration": "ms", "distance": "mi", "elevation": "ft", "height": "in", "weight": "lbs", "body": "in", "liquids": "fl. oz.", "blood glucose": "mg/dL", "battery": "", }, "en_GB": { "duration": "milliseconds", "distance": "kilometers", "elevation": "meters", "height": "centimeters", "weight": "stone", "body": "centimeters", "liquids": "milliliters", "blood glucose": "mmol/L", "battery": "", }, "metric": { "duration": "milliseconds", "distance": "kilometers", "elevation": "meters", "height": "centimeters", "weight": "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 = "{}{}".format(hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH) description = """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 {}. They will provide you a Client ID and secret. These need to be saved into the file located at: {}. Then come back here and hit the below button. """.format( start_url, config_path ) 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 = "{}{}".format(hass.config.api.base_url, FITBIT_AUTH_START) description = "Please authorize Fitbit by visiting {}".format(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")) import 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.Fitbit( config_file.get(ATTR_CLIENT_ID), config_file.get(ATTR_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 = fitbit.api.FitbitOauth2Client( config_file.get(ATTR_CLIENT_ID), config_file.get(ATTR_CLIENT_SECRET) ) redirect_uri = "{}{}".format( hass.config.api.base_url, 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.""" from oauthlib.oauth2.rfc6749.errors import MismatchingStateError from oauthlib.oauth2.rfc6749.errors import MissingTokenError 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 = "{}{}".format( hass.config.api.base_url, 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 = """Something went wrong when attempting authenticating with Fitbit. The error encountered was {}. Please try again!""".format( error ) except MismatchingStateError as error: _LOGGER.error("Mismatched state, CSRF error: %s", error) response_message = """Something went wrong when attempting authenticating with Fitbit. The error encountered was {}. Please try again!""".format( error ) 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 = """