diff --git a/.coveragerc b/.coveragerc index 2a91d1f17b1..028aacead28 100644 --- a/.coveragerc +++ b/.coveragerc @@ -148,6 +148,7 @@ omit = homeassistant/components/sensor/dht.py homeassistant/components/sensor/efergy.py homeassistant/components/sensor/eliqonline.py + homeassistant/components/sensor/fitbit.py homeassistant/components/sensor/forecast.py homeassistant/components/sensor/glances.py homeassistant/components/sensor/google_travel_time.py diff --git a/homeassistant/components/frontend/www_static/images/config_fitbit_app.png b/homeassistant/components/frontend/www_static/images/config_fitbit_app.png new file mode 100644 index 00000000000..271a0c6dd47 Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/config_fitbit_app.png differ diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py new file mode 100644 index 00000000000..f928b9a78e9 --- /dev/null +++ b/homeassistant/components/sensor/fitbit.py @@ -0,0 +1,372 @@ +""" +Support for the Fitbit API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.fitbit/ +""" +import os +import json +import logging +import datetime +import time + +from homeassistant.const import HTTP_OK +from homeassistant.util import Throttle +from homeassistant.helpers.entity import Entity +from homeassistant.loader import get_component + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ["fitbit==0.2.2"] +DEPENDENCIES = ["http"] + +ICON = "mdi:walk" + +_CONFIGURING = {} + +# Return cached results if last scan was less then this time ago. +MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=30) + +FITBIT_AUTH_START = "/auth/fitbit" +FITBIT_AUTH_CALLBACK_PATH = "/auth/fitbit/callback" + +DEFAULT_CONFIG = { + "client_id": "CLIENT_ID_HERE", + "client_secret": "CLIENT_SECRET_HERE" +} + +FITBIT_CONFIG_FILE = "fitbit.conf" + +FITBIT_RESOURCES_LIST = { + "activities/activityCalories": "cal", + "activities/calories": "cal", + "activities/caloriesBMR": "cal", + "activities/distance": "", + "activities/elevation": "", + "activities/floors": "floors", + "activities/heart": "bpm", + "activities/minutesFairlyActive": "minutes", + "activities/minutesLightlyActive": "minutes", + "activities/minutesSedentary": "minutes", + "activities/minutesVeryActive": "minutes", + "activities/steps": "steps", + "activities/tracker/activityCalories": "cal", + "activities/tracker/calories": "cal", + "activities/tracker/distance": "", + "activities/tracker/elevation": "", + "activities/tracker/floors": "floors", + "activities/tracker/minutesFairlyActive": "minutes", + "activities/tracker/minutesLightlyActive": "minutes", + "activities/tracker/minutesSedentary": "minutes", + "activities/tracker/minutesVeryActive": "minutes", + "activities/tracker/steps": "steps", + "body/bmi": "BMI", + "body/fat": "%", + "sleep/awakeningsCount": "times awaken", + "sleep/efficiency": "%", + "sleep/minutesAfterWakeup": "minutes", + "sleep/minutesAsleep": "minutes", + "sleep/minutesAwake": "minutes", + "sleep/minutesToFallAsleep": "minutes", + "sleep/startTime": "start time", + "sleep/timeInBed": "time in bed", + "body/weight": "" +} + +FITBIT_DEFAULT_RESOURCE_LIST = ["activities/steps"] + +FITBIT_MEASUREMENTS = { + "en_US": { + "duration": "ms", + "distance": "mi", + "elevation": "ft", + "height": "in", + "weight": "lbs", + "body": "in", + "liquids": "fl. oz.", + "blood glucose": "mg/dL", + }, + "en_UK": { + "duration": "milliseconds", + "distance": "kilometers", + "elevation": "meters", + "height": "centimeters", + "weight": "stone", + "body": "centimeters", + "liquids": "millileters", + "blood glucose": "mmol/l" + }, + "metric": { + "duration": "milliseconds", + "distance": "kilometers", + "elevation": "meters", + "height": "centimeters", + "weight": "kilograms", + "body": "centimeters", + "liquids": "millileters", + "blood glucose": "mmol/l" + } +} + + +def config_from_file(filename, config=None): + """Small configuration file management function.""" + if config: + # We"re writing configuration + try: + with open(filename, "w") as fdesc: + fdesc.write(json.dumps(config)) + except IOError as error: + _LOGGER.error("Saving config file failed: %s", error) + return False + return config + else: + # We"re reading config + if os.path.isfile(filename): + try: + with open(filename, "r") as fdesc: + return json.loads(fdesc.read()) + except IOError as error: + _LOGGER.error("Reading config file failed: %s", error) + # This won"t work yet + return False + else: + return {} + + +def request_app_setup(hass, config, add_devices, config_path, + discovery_info=None): + """Assist user with configuring the Fitbit dev application.""" + configurator = get_component("configurator") + + # pylint: disable=unused-argument + def fitbit_configuration_callback(callback_data): + """The actions to do when our configuration callback is called.""" + config_path = hass.config.path(FITBIT_CONFIG_FILE) + if os.path.isfile(config_path): + config_file = config_from_file(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_devices, discovery_info) + else: + setup_platform(hass, config, add_devices, discovery_info) + + start_url = "{}{}".format(hass.config.api.base_url, FITBIT_AUTH_START) + + 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( + hass, "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 = get_component("configurator") + if "fitbit" in _CONFIGURING: + configurator.notify_errors( + _CONFIGURING["fitbit"], "Failed to register, please try again.") + + return + + # pylint: disable=unused-argument + def fitbit_configuration_callback(callback_data): + """The actions to do when our configuration callback is called.""" + + 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( + hass, "Fitbit", fitbit_configuration_callback, + description=description, + submit_caption="I have authorized Fitbit." + ) + +# pylint: disable=too-many-locals + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Fitbit sensor.""" + config_path = hass.config.path(FITBIT_CONFIG_FILE) + if os.path.isfile(config_path): + config_file = config_from_file(config_path) + if config_file == DEFAULT_CONFIG: + request_app_setup(hass, config, add_devices, config_path, + discovery_info=None) + return False + else: + config_file = config_from_file(config_path, DEFAULT_CONFIG) + request_app_setup(hass, config, add_devices, config_path, + discovery_info=None) + return False + + if "fitbit" in _CONFIGURING: + get_component("configurator").request_done(_CONFIGURING.pop("fitbit")) + + import fitbit + + access_token = config_file.get("access_token") + refresh_token = config_file.get("refresh_token") + if None not in (access_token, refresh_token): + authd_client = fitbit.Fitbit(config.get("client_id"), + config.get("client_secret"), + access_token=access_token, + refresh_token=refresh_token) + + if int(time.time()) - config_file.get("last_saved_at", 0) > 3600: + authd_client.client.refresh_token() + + authd_client.system = authd_client.user_profile_get()["user"]["locale"] + + dev = [] + for resource in config.get("monitored_resources", + FITBIT_DEFAULT_RESOURCE_LIST): + dev.append(FitbitSensor(authd_client, config_path, resource)) + add_devices(dev) + + else: + oauth = fitbit.api.FitbitOauth2Client(config.get("client_id"), + config.get("client_secret")) + + redirect_uri = "{}{}".format(hass.config.api.base_url, + FITBIT_AUTH_CALLBACK_PATH) + + def _start_fitbit_auth(handler, path_match, data): + """Start Fitbit OAuth2 flow.""" + url, _ = oauth.authorize_token_url(redirect_uri=redirect_uri, + scope=["activity", "heartrate", + "nutrition", "profile", + "settings", "sleep", + "weight"]) + handler.send_response(301) + handler.send_header("Location", url) + handler.end_headers() + + def _finish_fitbit_auth(handler, path_match, data): + """Finish Fitbit OAuth2 flow.""" + response_message = """Fitbit has been successfully authorized! + You can close this window now!""" + from oauthlib.oauth2.rfc6749.errors import MismatchingStateError + from oauthlib.oauth2.rfc6749.errors import MissingTokenError + if data.get("code") is not None: + try: + 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! + """ + + html_response = """