From a33b5da81969ee4ee3a579648fb9c358fc93f030 Mon Sep 17 00:00:00 2001 From: Marc Forth Date: Tue, 13 Mar 2018 12:14:53 +0000 Subject: [PATCH 1/4] Create fitbit.py Hi Carlo, ## TLDR - Maybe made the fitbit component support multiple devices. (see disclaimer and instructions below!) ~~~~~~~~~~~~~~~~ ## Long version... I've been looking in to sleep monitoring and was looking at getting fitbits for my other-half and myself, but it's only any use spending the money if I can monitor us both with homeassistant. As such, I did a bit of research and saw the various threads that show the current component is only working with one device. So, I decided to have a look at amending it. Problem being, I can't test it because I don't have the devices to test it on, and I'm not buying the devices until I know it works. ## Disclaimer I am not a python programmer, I'm just learning the platform now, but I get the logic so I've fumbled through! It appears to be working as far as I can test it without the devices here. If this breaks anything I'm really really sorry! Make sure you follow the instructions below for some safeguarding! ## Instructions 1 - Make a backup copy of your current fitbit.conf somewhere away from your homeassistant instance. 2 - Delete your current local copy of fitbit.conf, and add this PR to your custom_components folder 3 - Change your configuration.yaml (or appropriate package/include) to: ``` sensor: - platform: fitbit filename: carlo.conf - platform: fitbit filename: wife.conf ``` plus whatever monitored conditions you want and your clock preference if relevant. 4 -Restart homeassistant 5 - You should get a configurator card for the two entities, copy the contents of your old fitbit.conf in to carlo.conf , sort out the details for your wife's in to hers. 6 - Let me know!!! ## If it all goes wrong Delete this custom component, reinstate your original fitbit.conf, revert your configuration.yaml (or appropriate include/package). ##Disclaimer 2 If it doesn't work, I might not be able to do much more as I am not a python programmer, but let me know because it might be something I can work out. If it does work I'll get another couple of people to test and then submit it as an update for the integrated component. --- config/custom_components/sensor/fitbit.py | 501 ++++++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 config/custom_components/sensor/fitbit.py diff --git a/config/custom_components/sensor/fitbit.py b/config/custom_components/sensor/fitbit.py new file mode 100644 index 00000000..22a4ae9f --- /dev/null +++ b/config/custom_components/sensor/fitbit.py @@ -0,0 +1,501 @@ +""" +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 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 + + +REQUIREMENTS = ['fitbit==0.3.0'] + +_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' +CONF_ATTRIBUTION = 'Data provided by Fitbit.com' +CONF_FILENAME = 'filename' + +DEPENDENCIES = ['http'] + +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']), + vol.Optional(CONF_FILENAME, default=FITBIT_CONFIG_FILE):cv.string +}) + + +def request_app_setup(hass, config, add_devices, config_path, + discovery_info=None): + """Assist user with configuring the Fitbit dev application.""" + configurator = hass.components.configurator + + # pylint: disable=unused-argument + def fitbit_configuration_callback(callback_data): + """Handle configuration updates.""" + config_path = hass.config.path(config.get(CONF_FILENAME)) + if os.path.isfile(config_path): + config_file = load_json(config_path) + if config_file == DEFAULT_CONFIG: + error_msg = ("You didn't correctly modify the {}}", + " please try again").format(config_path) + 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_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 {}.".format(config_path) + + _CONFIGURING['fitbit'] = configurator.request_config( + 'Fitbit ( {} )'.format(config.get(CONF_FILENAME)), 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 + + # pylint: disable=unused-argument + 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_devices, discovery_info=None): + """Set up the Fitbit sensor.""" + config_path = hass.config.path(config.get(CONF_FILENAME)) + if os.path.isfile(config_path): + config_file = load_json(config_path) + if config_file == DEFAULT_CONFIG: + request_app_setup( + hass, config, add_devices, config_path, discovery_info=None) + return False + else: + config_file = save_json(config_path, DEFAULT_CONFIG) + request_app_setup( + hass, config, add_devices, 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_devices(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_devices, 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_devices, oauth): + """Initialize the OAuth callback view.""" + self.config = config + self.add_devices = add_devices + 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 = """Fitbit Auth +

{}

""".format(response_message) + + if result: + config_contents = { + ATTR_ACCESS_TOKEN: result.get('access_token'), + ATTR_REFRESH_TOKEN: result.get('refresh_token'), + ATTR_CLIENT_ID: self.oauth.client_id, + ATTR_CLIENT_SECRET: self.oauth.client_secret, + ATTR_LAST_SAVED_AT: int(time.time()) + } + save_json(config.get(CONF_FILENAME), config_contents) + + hass.async_add_job(setup_platform, hass, self.config, self.add_devices) + + return html_response + + +class FitbitSensor(Entity): + """Implementation of a Fitbit sensor.""" + + def __init__(self, client, config_path, resource_type, + is_metric, clock_format, extra=None): + """Initialize the Fitbit sensor.""" + self.client = client + self.config_path = config_path + self.resource_type = resource_type + self.is_metric = is_metric + self.clock_format = clock_format + self.extra = extra + self._name = FITBIT_RESOURCES_LIST[self.resource_type][0] + if self.extra: + self._name = '{0} Battery'.format(self.extra.get('deviceVersion')) + unit_type = FITBIT_RESOURCES_LIST[self.resource_type][1] + if unit_type == "": + split_resource = self.resource_type.split('/') + try: + measurement_system = FITBIT_MEASUREMENTS[self.client.system] + except KeyError: + if self.is_metric: + measurement_system = FITBIT_MEASUREMENTS['metric'] + else: + measurement_system = FITBIT_MEASUREMENTS['en_US'] + unit_type = measurement_system[split_resource[-1]] + self._unit_of_measurement = unit_type + self._state = 0 + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + if self.resource_type == 'devices/battery' and self.extra: + battery_level = BATTERY_LEVELS[self.extra.get('battery')] + return icon_for_battery_level(battery_level=battery_level, + charging=None) + return 'mdi:{}'.format(FITBIT_RESOURCES_LIST[self.resource_type][2]) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {} + + attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + + if self.extra: + attrs['model'] = self.extra.get('deviceVersion') + attrs['type'] = self.extra.get('type').lower() + + return attrs + + def update(self): + """Get the latest data from the Fitbit API and update the states.""" + if self.resource_type == 'devices/battery' and self.extra: + self._state = self.extra.get('battery') + else: + container = self.resource_type.replace("/", "-") + response = self.client.time_series(self.resource_type, period='7d') + raw_state = response[container][-1].get('value') + if self.resource_type == 'activities/distance': + self._state = format(float(raw_state), '.2f') + elif self.resource_type == 'activities/tracker/distance': + self._state = format(float(raw_state), '.2f') + elif self.resource_type == 'body/bmi': + self._state = format(float(raw_state), '.1f') + elif self.resource_type == 'body/fat': + self._state = format(float(raw_state), '.1f') + elif self.resource_type == 'body/weight': + self._state = format(float(raw_state), '.1f') + elif self.resource_type == 'sleep/startTime': + if raw_state == '': + self._state = '-' + elif self.clock_format == '12H': + hours, minutes = raw_state.split(':') + hours, minutes = int(hours), int(minutes) + setting = 'AM' + if hours > 12: + setting = 'PM' + hours -= 12 + elif hours == 0: + hours = 12 + self._state = '{}:{:02d} {}'.format(hours, minutes, + setting) + else: + self._state = raw_state + else: + if self.is_metric: + self._state = raw_state + else: + try: + self._state = '{0:,}'.format(int(raw_state)) + except TypeError: + self._state = raw_state + + if self.resource_type == 'activities/heart': + self._state = response[container][-1]. \ + get('value').get('restingHeartRate') + + token = self.client.client.session.token + config_contents = { + ATTR_ACCESS_TOKEN: token.get('access_token'), + ATTR_REFRESH_TOKEN: token.get('refresh_token'), + ATTR_CLIENT_ID: self.client.client.client_id, + ATTR_CLIENT_SECRET: self.client.client.client_secret, + ATTR_LAST_SAVED_AT: int(time.time()) + } + save_json(self.config_path, config_contents) From 4750e12fe226935c038f8f9c59208932f7a3923f Mon Sep 17 00:00:00 2001 From: Marc Forth Date: Tue, 13 Mar 2018 12:28:23 +0000 Subject: [PATCH 2/4] Update fitbit.py --- config/custom_components/sensor/fitbit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/custom_components/sensor/fitbit.py b/config/custom_components/sensor/fitbit.py index 22a4ae9f..b2086da5 100644 --- a/config/custom_components/sensor/fitbit.py +++ b/config/custom_components/sensor/fitbit.py @@ -189,7 +189,7 @@ def request_app_setup(hass, config, add_devices, config_path, submit = "I have saved my Client ID and Client Secret into {}.".format(config_path) _CONFIGURING['fitbit'] = configurator.request_config( - 'Fitbit ( {} )'.format(config.get(CONF_FILENAME)), fitbit_configuration_callback, + 'Fitbit ( {} )').format(config.get(CONF_FILENAME), fitbit_configuration_callback, description=description, submit_caption=submit, description_image="/static/images/config_fitbit_app.png" ) From 41f5e492e71b3d5630c22db6e22fe7e368e441af Mon Sep 17 00:00:00 2001 From: Marc Forth Date: Tue, 13 Mar 2018 12:34:28 +0000 Subject: [PATCH 3/4] Update fitbit.py --- config/custom_components/sensor/fitbit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/custom_components/sensor/fitbit.py b/config/custom_components/sensor/fitbit.py index b2086da5..22a4ae9f 100644 --- a/config/custom_components/sensor/fitbit.py +++ b/config/custom_components/sensor/fitbit.py @@ -189,7 +189,7 @@ def request_app_setup(hass, config, add_devices, config_path, submit = "I have saved my Client ID and Client Secret into {}.".format(config_path) _CONFIGURING['fitbit'] = configurator.request_config( - 'Fitbit ( {} )').format(config.get(CONF_FILENAME), fitbit_configuration_callback, + 'Fitbit ( {} )'.format(config.get(CONF_FILENAME)), fitbit_configuration_callback, description=description, submit_caption=submit, description_image="/static/images/config_fitbit_app.png" ) From 8d672fbec826c511b8d45cc5322ccfe57067b557 Mon Sep 17 00:00:00 2001 From: Marc Forth Date: Tue, 13 Mar 2018 12:45:14 +0000 Subject: [PATCH 4/4] Update fitbit.py --- config/custom_components/sensor/fitbit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/custom_components/sensor/fitbit.py b/config/custom_components/sensor/fitbit.py index 22a4ae9f..5bba23a1 100644 --- a/config/custom_components/sensor/fitbit.py +++ b/config/custom_components/sensor/fitbit.py @@ -165,8 +165,8 @@ def request_app_setup(hass, config, add_devices, config_path, if os.path.isfile(config_path): config_file = load_json(config_path) if config_file == DEFAULT_CONFIG: - error_msg = ("You didn't correctly modify the {}}", - " please try again").format(config_path) + error_msg = ("You didn't correctly modify the file", + " please try again") configurator.notify_errors(_CONFIGURING['fitbit'], error_msg) else: