From 2cb7f824268274466a4f4096bf77419a7a57887a Mon Sep 17 00:00:00 2001 From: Steffen Vogel Date: Tue, 18 Aug 2020 16:32:20 +0200 Subject: [PATCH] major rewrite and update for Python 3 --- .gitignore | 6 +- Docker/Dockerfile | 29 -- Docker/build.sh | 7 - Dockerfile | 14 + MANIFEST.in | 1 + README.md | 112 +++---- README.origin | 70 ----- other/Sync icon.png => contrib/icon.png | Bin contrib/k8s-job.yaml | 72 +++++ contrib/withings.html | 37 +++ garmin.py | 179 ----------- measurements.py | 45 --- sessioncache.py | 38 --- setup.py | 36 +++ sync.py | 148 ---------- test.py | 225 -------------- trainerroad/__init__.py | 1 - withings2.py | 271 ----------------- withings_sync/__init__.py | 0 .../config}/withings_app.json | 0 fit.py => withings_sync/fit.py | 2 - withings_sync/garmin.py | 196 +++++++++++++ withings_sync/sync.py | 168 +++++++++++ .../api.py => withings_sync/trainerroad.py | 9 +- withings_sync/withings2.py | 277 ++++++++++++++++++ 25 files changed, 840 insertions(+), 1103 deletions(-) delete mode 100644 Docker/Dockerfile delete mode 100755 Docker/build.sh create mode 100644 Dockerfile create mode 100644 MANIFEST.in delete mode 100644 README.origin rename other/Sync icon.png => contrib/icon.png (100%) create mode 100644 contrib/k8s-job.yaml create mode 100644 contrib/withings.html delete mode 100644 garmin.py delete mode 100644 measurements.py delete mode 100644 sessioncache.py create mode 100644 setup.py delete mode 100755 sync.py delete mode 100755 test.py delete mode 100644 trainerroad/__init__.py delete mode 100755 withings2.py create mode 100644 withings_sync/__init__.py rename {config => withings_sync/config}/withings_app.json (100%) rename fit.py => withings_sync/fit.py (99%) create mode 100644 withings_sync/garmin.py create mode 100755 withings_sync/sync.py rename trainerroad/api.py => withings_sync/trainerroad.py (99%) create mode 100755 withings_sync/withings2.py diff --git a/.gitignore b/.gitignore index b4b7b13..0d74320 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,2 @@ -runme.sh *.pyc -runme2.sh -weight.fit -.DS_Store -config/withings_user.json \ No newline at end of file +*.egg-info/ \ No newline at end of file diff --git a/Docker/Dockerfile b/Docker/Dockerfile deleted file mode 100644 index 6f0a8ea..0000000 --- a/Docker/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -FROM alpine as git - -RUN apk update && apk add git openssh-client - -ARG SSH_PRIVATE_KEY - -RUN mkdir ~/.ssh/ && \ - echo "$SSH_PRIVATE_KEY" >> ~/.ssh/id_rsa && \ - chmod 600 ~/.ssh/id_rsa && \ - touch ~/.ssh/known_hosts && \ - ssh-keyscan github.com >> ~/.ssh/known_hosts - -RUN git clone git@github.com:jaroslawhartman/withings-garmin-v2.git - -FROM python:3.6-alpine - -RUN mkdir -p /withings-garmin-v2 -COPY --from=git /withings-garmin-v2 /withings-garmin-v2 - -RUN apk add --update --no-cache libxml2-dev libxslt-dev gcc musl-dev - -RUN pip install --upgrade pip && \ - pip install requests && \ - pip install lxml - -WORKDIR /withings-garmin-v2 - -ENTRYPOINT ["./sync.py"] -CMD ["-h"] diff --git a/Docker/build.sh b/Docker/build.sh deleted file mode 100755 index 3a45d4a..0000000 --- a/Docker/build.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -SSH_PRIVATE_KEY="$(cat ~/.ssh/id_rsa)" - -docker build --build-arg SSH_PRIVATE_KEY="$SSH_PRIVATE_KEY" -t withings-garmin . - -docker tag withings-garmin:latest jaroslawhartman/withings-garmin:latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bca82de --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.8-alpine + +RUN apk add --update --no-cache libxml2-dev libxslt-dev gcc musl-dev + +# Profit from Docker build cache buy building python lxml here.. +RUN pip3 install lxml requests + +RUN mkdir -p /src +COPY . /src + +RUN cd /src && \ + python3 ./setup.py install + +ENTRYPOINT ["withings-sync"] diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..695153d --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include withings_sync/config/*.json \ No newline at end of file diff --git a/README.md b/README.md index b379735..481fe0d 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,49 @@ -# withings-garmin-v2 (and TrainerRoad) +# withings-sync -A tool for synchronisation of Withings (ex. Nokia Health Body) to Garmin Connect and Trainer Road. +A tool for synchronisation of Withings (ex. Nokia Health Body) to: + +- Garmin Connect +- Trainer Road -**NOTE: For Docker usage hits see at end of this document** https://hub.docker.com/r/jaroslawhartman/withings-garmin +**NOTE: For Docker usage hits see at end of this document:** https://hub.docker.com/r/stv0g/withings-sync **NOTE: Included support for Withings OAuth2! See 'Obtaining Withings authorization'** ## References * Based on withings-garmin by Masayuki Hamasaki, improved to support SSO authorization in Garmin Connect 2. +* Based on withings-garmin-v2 by Jarek Hartman, improved Python 3 compatability and code-style * SSO authorization derived from https://github.com/cpfair/tapiriik * TrainerRoad API from https://github.com/stuwilkins/python-trainerroad -## Pre-requisites - -* Python 3 -* 'Requests: HTTP for Humans' (http://docs.python-requests.org/en/latest/) -* lxml - -Using pip: - -``` -yum install libxml2-dev libxslt-dev gcc -pip install --upgrade pip && pip install requests && pip install lxml -``` - -``` -$ sudo easy_install requests - -``` - -* simplejson +## Installation -``` -$ sudo easy_install simplejson +```bash +$ pip install withings-sync ``` ## Usage ``` -Usage: sync.py [options] +usage: withings-sync [-h] [--garmin-username GARMIN_USERNAME] [--garmin-password GARMIN_PASSWORD] [--trainerroad-username TRAINERROAD_USERNAME] [--trainerroad-password TRAINERROAD_PASSWORD] + [--fromdate DATE] [--todate DATE] [--no-upload] [--verbose] + +A tool for synchronisation of Withings (ex. Nokia Health Body) to Garmin Connect and Trainer Road. -Options: +optional arguments: -h, --help show this help message and exit - --garmin-username=, --gu= + --garmin-username GARMIN_USERNAME, --gu GARMIN_USERNAME username to login Garmin Connect. - --garmin-password=, --gp= + --garmin-password GARMIN_PASSWORD, --gp GARMIN_PASSWORD password to login Garmin Connect. - --trainerroad-username=, --tu= + --trainerroad-username TRAINERROAD_USERNAME, --tu TRAINERROAD_USERNAME username to login TrainerRoad. - --trainerroad-password=, --tp= + --trainerroad-password TRAINERROAD_PASSWORD, --tp TRAINERROAD_PASSWORD username to login TrainerRoad. - -f , --fromdate= - -t , --todate= - --no-upload Won't upload to Garmin Connect and output binary- - strings to stdout. - -v, --verbose Run verbosely - + --fromdate DATE, -f DATE + --todate DATE, -t DATE + --no-upload Won't upload to Garmin Connect and output binary-strings to stdout. + --verbose, -v Run verbosely ``` ### Obtaining Withings Authorization Code @@ -64,13 +51,9 @@ Options: When running for a very first time, you need to obtain Withings authorization: -``` -$ ./sync.py -f 2019-01-25 -v +```bash +$ withings-sync -f 2019-01-25 -v Can't read config file config/withings_user.json -*************************************** -* W A R N I N G * -*************************************** - User interaction needed to get Authentification Code from Withings! Open the following URL in your web browser and copy back the token. You will have *30 seconds* before the token expires. HURRY UP! @@ -91,42 +74,18 @@ This is one-time activity and it will not be needed to repeat. ### Docker ``` -$ docker pull jaroslawhartman/withings-garmin +$ docker pull stv0g/withings-sync ``` First start to ensure the script can start successfully: -``` -jhartman@docker:~/withings-garmin-v2/Docker$ docker run -it --rm --name withings jaroslawhartman/withings-garmin -Usage: sync.py [options] -Options: - -h, --help show this help message and exit - --garmin-username=, --gu= - username to login Garmin Connect. - --garmin-password=, --gp= - password to login Garmin Connect. - --trainerroad-username=, --tu= - username to login TrainerRoad. - --trainerroad-password=, --tp= - username to login TrainerRoad. - -f , --fromdate= - -t , --todate= - --no-upload Won't upload to Garmin Connect and output binary- - strings to stdout. - -v, --verbose Run verbosely -``` - -Obtaining Withings authoorisation: +Obtaining Withings authorisation: ``` -$ docker run -it --name withings jaroslawhartman/withings-garmin --garmin-username= --garmin-password= +$ docker run -v $HOME:/root --interactive --tty --name withings jaroslawhartman/withings-garmin --garmin-username= --garmin-password= Can't read config file config/withings_user.json -*************************************** -* W A R N I N G * -*************************************** - User interaction needed to get Authentification Code from Withings! Open the following URL in your web browser and copy back the token. You will have *30 seconds* before the token expires. HURRY UP! @@ -156,17 +115,12 @@ Garmin Connect User Name: JaHa.WAW.PL Fit file uploaded to Garmin Connect ``` +### Run a periodic Kubernetes job -### You can hardcode your usernames and passwords in the script (`sync.py`): - -...but why would you? Better to provide them as commandline parameters. +Edit the credentials in `contrib/k8s-job.yaml` and run: -``` -GARMIN_USERNAME = '' -GARMIN_PASSWORD = '' - -TRAINERROAD_USERNAME = '' -TRAINERROAD_PASSWORD = '' +```bash +$ kubectl apply -f contrib/k8s-job.yaml ``` ### For advanced users - registering own Withings application @@ -194,3 +148,5 @@ Configure them in `config/withings_app.json`, for example: "consumer_secret": "a75d65******1df1514c16719ef7bd69fa7*****2e2b0ed48f1765" } ``` + +For the callback URL you will need to setup a webserver hosting `contrib/withings.html`. diff --git a/README.origin b/README.origin deleted file mode 100644 index db23e06..0000000 --- a/README.origin +++ /dev/null @@ -1,70 +0,0 @@ -===================== -withings-garmin-v2 -===================== - -Based on withings-garmin by Masayuki Hamasaki, improved to support SSO authorization in Garmin Connect 2. - -SSO authorization derived from https://github.com/cpfair/tapiriik - -Additionally, requires 'Requests: HTTP for Humans' (http://docs.python-requests.org/en/latest/) - -$ sudo easy_install requests - ------------------------- - -このツールは,Withingsで記録した体重データをGarmin Connectと同期するためのツールです. - -以下のようにsync.pyを起動してください. -$ python sync.py --wu user@example.com --wp password --ws USR --gu garmin_user --gp garmin_pass - --f (--fromdate) と -t (--todate) オプションを利用することにより,同期する期間を設定することができます. -例えば,以下のようにした場合 2011年1月20日 から 2011年2月2日までのデータだけを同期します. - -$ python sync.py -f 2011-01-20 -t 2011-02-02 - -日付を指定しなかった場合 -f,-t ともにスクリプト実行時の日付がセットされます.(当日のデータだけ同期) - -自動で同期する場合はcron等をご利用ください. - - -requires --------- -Python 2.5+ -$ sudo easy_install simplejson (for Python2.5 only) - -usage ------ -Usage: $python sync.py [options] - -Options: - -h, --help show this help message and exit - --withings-username=, --wu= - username to login Withings Web Service. - --withings-password=, --wp= - password to login Withings Web Service. - --withings-shortname=, --ws= - your shortname used in Withings. - --garmin-username=, --gu= - username to login Garmin Connect. - --garmin-password=, --gp= - password to login Garmin Connect. - -f , --fromdate= - -t , --todate= - --no-upload Won't upload to Garmin Connect and output binary- - string to stdout. - -v, --verbose Run verbosely - - -tips ----- -- 以下のようにすると, Garmin Connectへのアップロードを行わず, .fitファイルを作ることだけができます. -$ python sync.py --no-upload > weight.fit - -- sync.py の定数を設定すると対応するオプションを省略できます. -WITHINGS_USERNMAE = '' -WITHINGS_PASSWORD = '' -WITHINGS_SHORTNAME = '' - -GARMIN_USERNAME = '' -GARMIN_PASSWORD = '' - diff --git a/other/Sync icon.png b/contrib/icon.png similarity index 100% rename from other/Sync icon.png rename to contrib/icon.png diff --git a/contrib/k8s-job.yaml b/contrib/k8s-job.yaml new file mode 100644 index 0000000..a29e61f --- /dev/null +++ b/contrib/k8s-job.yaml @@ -0,0 +1,72 @@ +--- +apiVersion: "v1 +kind: Namespace +metadata: + name: withings +--- +# Secret for Garmin and TrainerRoad credentials +# PLEASE CHANGE! +apiVersion: v1 +kind: Secret +metadata: + name: credentials + namespace: withings +type: Opaque +data: + # Base64 encoded!!! + GARMIN_PASSWORD: "" + GARMIN_USERNAME: "" + TRAINERROAD_PASSWORD: "" + TRAINERROAD_USERNAME: "" +--- +# PVC for storing Withings OAuth tokens +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + finalizers: + - kubernetes.io/pvc-protection + name: withings-oauth-cache + namespace: withings +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Mi + volumeMode: Filesystem +--- +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: withings-garmin-sync + namespace: withings +spec: + concurrencyPolicy: Allow + failedJobsHistoryLimit: 1 + jobTemplate: + spec: + template: + spec: + containers: + - args: + - -v + env: + - name: HOME + value: /root + envFrom: + - secretRef: + name: credentials + image: stv0g/withings-sync + imagePullPolicy: Always + name: withings-garmin-sync + volumeMounts: + - mountPath: /root/ + name: oauth-cache + restartPolicy: Never + volumes: + - name: oauth-cache + persistentVolumeClaim: + claimName: withings-oauth-cache + schedule: '*/15 * * * *' + successfulJobsHistoryLimit: 3 + diff --git a/contrib/withings.html b/contrib/withings.html new file mode 100644 index 0000000..c9cd5f3 --- /dev/null +++ b/contrib/withings.html @@ -0,0 +1,37 @@ + + + + + Withings Authorization Token + + +

Your Withings Authorization Token

+ + +

You must copy it back to the script during next 30 seconds, otherwise it will get expired.

+ + + + diff --git a/garmin.py b/garmin.py deleted file mode 100644 index da8181d..0000000 --- a/garmin.py +++ /dev/null @@ -1,179 +0,0 @@ -# -*- coding: utf-8 -*- - -from sessioncache import SessionCache -from datetime import datetime, timedelta -import urllib.request, urllib.error, urllib.parse -import urllib.request, urllib.parse, urllib.error -import datetime -import requests -import re -import sys -import json - -class LoginSucceeded(Exception): - pass - - -class LoginFailed(Exception): - pass - - -class GarminConnect(object): - LOGIN_URL = 'https://connect.garmin.com/signin' - UPLOAD_URL = 'https://connect.garmin.com/modern/proxy/upload-service/upload/.fit' - - _sessionCache = SessionCache(lifetime=timedelta(minutes=30), freshen_on_get=True) - - def create_opener(self, cookie): - this = self - class _HTTPRedirectHandler(urllib.request.HTTPRedirectHandler): - def http_error_302(self, req, fp, code, msg, headers): - if req.get_full_url() == this.LOGIN_URL: - raise LoginSucceeded - return urllib.request.HTTPRedirectHandler.http_error_302(self, req, fp, code, msg, headers) - return urllib.request.build_opener(_HTTPRedirectHandler, urllib.request.HTTPCookieProcessor(cookie)) - - ############################################## - # From https://github.com/cpfair/tapiriik - - def _get_session(self, record=None, email=None, password=None): - session = requests.Session() - - # JSIG CAS, cool I guess. - # Not quite OAuth though, so I'll continue to collect raw credentials. - # Commented stuff left in case this ever breaks because of missing parameters... - data = { - "username": email, - "password": password, - "_eventId": "submit", - "embed": "true", - # "displayNameRequired": "false" - } - params = { - "service": "https://connect.garmin.com/modern", - # "redirectAfterAccountLoginUrl": "http://connect.garmin.com/modern", - # "redirectAfterAccountCreationUrl": "http://connect.garmin.com/modern", - # "webhost": "olaxpw-connect00.garmin.com", - "clientId": "GarminConnect", - "gauthHost": "https://sso.garmin.com/sso", - # "rememberMeShown": "true", - # "rememberMeChecked": "false", - "consumeServiceTicket": "false", - # "id": "gauth-widget", - # "embedWidget": "false", - # "cssUrl": "https://static.garmincdn.com/com.garmin.connect/ui/src-css/gauth-custom.css", - # "source": "http://connect.garmin.com/en-US/signin", - # "createAccountShown": "true", - # "openCreateAccount": "false", - # "usernameShown": "true", - # "displayNameShown": "false", - # "initialFocus": "true", - # "locale": "en" - } - - headers = { - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36', - "Referer": "https://jhartman.pl", - 'origin': 'https://sso.garmin.com' - } - - # I may never understand what motivates people to mangle a perfectly good protocol like HTTP in the ways they do... - preResp = session.get("https://sso.garmin.com/sso/signin", params=params, headers=headers) - if preResp.status_code != 200: - raise APIException("SSO prestart error %s %s" % (preResp.status_code, preResp.text)) - - ssoResp = session.post("https://sso.garmin.com/sso/login", params=params, data=data, allow_redirects=False, headers=headers) - if ssoResp.status_code != 200 or "temporarily unavailable" in ssoResp.text: - raise APIException("SSO error %s %s" % (ssoResp.status_code, ssoResp.text)) - - if ">sendEvent('FAIL')" in ssoResp.text: - raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) - if ">sendEvent('ACCOUNT_LOCKED')" in ssoResp.text: - raise APIException("Account Locked", block=True, user_exception=UserException(UserExceptionType.Locked, intervention_required=True)) - - if "renewPassword" in ssoResp.text: - raise APIException("Reset password", block=True, user_exception=UserException(UserExceptionType.RenewPassword, intervention_required=True)) - - # self.print_cookies(cookies=session.cookies) - - # ...AND WE'RE NOT DONE YET! - - gcRedeemResp = session.get("https://connect.garmin.com/modern", allow_redirects=False, headers=headers) - if gcRedeemResp.status_code != 302: - raise APIException("GC redeem-start error %s %s" % (gcRedeemResp.status_code, gcRedeemResp.text)) - - url_prefix = "https://connect.garmin.com" - - # There are 6 redirects that need to be followed to get the correct cookie - # ... :( - max_redirect_count = 7 - current_redirect_count = 1 - while True: - url = gcRedeemResp.headers["location"] - - # Fix up relative redirects. - if url.startswith("/"): - url = url_prefix + url - url_prefix = "/".join(url.split("/")[:3]) - gcRedeemResp = session.get(url, allow_redirects=False) - - if current_redirect_count >= max_redirect_count and gcRedeemResp.status_code != 200: - raise APIException("GC redeem %d/%d error %s %s" % (current_redirect_count, max_redirect_count, gcRedeemResp.status_code, gcRedeemResp.text)) - if gcRedeemResp.status_code == 200 or gcRedeemResp.status_code == 404: - break - current_redirect_count += 1 - if current_redirect_count > max_redirect_count: - break - - self._sessionCache.Set(record.ExternalID if record else email, session.cookies) - - # self.print_cookies(session.cookies) - - session.headers.update(headers) - - return session - - def print_cookies(self, cookies): - print("Cookies") - - for key, value in list(cookies.items()): - print("Key: " + key + ", " + value) - - def login(self, username, password): - - session = self._get_session(email=username, password=password) - - try: - dashboard = session.get("http://connect.garmin.com/modern") - - userdata_json_str = re.search(r"VIEWER_SOCIAL_PROFILE\s*=\s*JSON\.parse\((.+)\);$", dashboard.text, re.MULTILINE).group(1) - userdata = json.loads(json.loads(userdata_json_str)) - username = userdata["displayName"] - - print(username) - - sys.stderr.write('Garmin Connect User Name: ' + username + '\n') - - except Exception as e: - print(e) - sys.stderr.write('Unable to retrieve Garmin username! Most likely: incorrect Garmin login or password!\n') - - return (session) - - - def upload_file(self, f, session): - files = {"data": ("withings.fit", f)} - - res = session.post(self.UPLOAD_URL, - files=files, - headers={"nk": "NT"}) - - try: - resp = res.json()["detailedImportResult"] - except (ValueError, KeyError): - if(res.status_code == 204): # HTTP result 204 - "no content" - sys.stderr.write('No data to upload, try to use --fromdate and --todate\n') - else: - print("Bad response during GC upload: " + str(res.status_code)) - - return (res.status_code == 200 or res.status_code == 201 or res.status_code == 204) diff --git a/measurements.py b/measurements.py deleted file mode 100644 index 0b2ed09..0000000 --- a/measurements.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- - -# Calculate Garmin Connect physical parameters -# displayed on http://connect.garmin.com/health -# - - -class Measurements(object): - - # M or F - gender = 'M' - - def getWeight(self): - return 100 - - def getPercentFat(self): - return 100 - - # http://www.medcalc.com/tbw.html - def getPercentHydration(self): - return - - def getVisceralFatMass(self): - return 100 - - def getBoneMass(self): - return 100 - - def getMuscleMass(self): - return 100 - - def getActiveMet(self): - return 100 - - def getActiveMet(self): - return 100 - - def getPhysiqueRating(self): - return 100 - - def getMetabolicAge(self): - return 100 - - def getVisceralFatRating(self): - return 100 \ No newline at end of file diff --git a/sessioncache.py b/sessioncache.py deleted file mode 100644 index 85a36ae..0000000 --- a/sessioncache.py +++ /dev/null @@ -1,38 +0,0 @@ -# From https://github.com/cpfair/tapiriik - -from datetime import datetime - -class SessionCache: - def __init__(self, lifetime, freshen_on_get=False): - self._lifetime = lifetime - self._autorefresh = freshen_on_get - self._cache = {} - - def Get(self, pk, freshen=False): - if pk not in self._cache: - return - record = self._cache[pk] - if record.Expired(): - del self._cache[pk] - return None - if self._autorefresh or freshen: - record.Refresh() - return record.Get() - - def Set(self, pk, value): - self._cache[pk] = SessionCacheRecord(value, self._lifetime) - -class SessionCacheRecord: - def __init__(self, data, lifetime): - self._value = data - self._lifetime = lifetime - self.Refresh() - - def Expired(self): - return self._timestamp < datetime.utcnow() - self._lifetime - - def Refresh(self): - self._timestamp = datetime.utcnow() - - def Get(self): - return self._value diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..bb0e707 --- /dev/null +++ b/setup.py @@ -0,0 +1,36 @@ +import os +from setuptools import setup + +# Utility function to read the README file. +# Used for the long_description. It's nice, because now 1) we have a top level +# README file and 2) it's easier to type in the README file than to put a raw +# string in below ... +def read(fname): + return open(os.path.join(os.path.dirname(__file__), fname)).read() + +setup( + name='withings-sync', + version='3.0.0', + author='Masayuki Hamasaki', + description='A tool for synchronisation of Withings (ex. Nokia Health Body) to Garmin Connect and Trainer Road.', + license='MIT', + keywords='garmin withings sync api scale smarthome', + url='http://packages.python.org/an_example_pypi_project', + packages=['withings_sync'], + long_description=read('README.md'), + classifiers=[ + 'Topic :: Utilities', + 'License :: OSI Approved :: MIT License', + ], + install_requires=[ + 'lxml', + 'requests' + ], + entry_points={ + 'console_scripts': [ + 'withings-sync=withings_sync.sync:main' + ], + }, + zip_safe=False, + include_package_data=True +) diff --git a/sync.py b/sync.py deleted file mode 100755 index dba7d5d..0000000 --- a/sync.py +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -from withings2 import WithingsAccount -from garmin import GarminConnect -from fit import FitEncoder_Weight -import trainerroad - -from optparse import OptionParser -from optparse import Option -from optparse import OptionValueError -from datetime import date -from datetime import datetime - -import json -import time -import sys - -GARMIN_USERNAME = '' -GARMIN_PASSWORD = '' - -TRAINERROAD_USERNAME = '' -TRAINERROAD_PASSWORD = '' - -class DateOption(Option): - def check_date(option, opt, value): - valid_formats = ['%Y-%m-%d', '%Y%m%d', '%Y/%m/%d'] - for f in valid_formats: - try: - dt = datetime.strptime(value, f) - return dt.date() - except ValueError: - pass - raise OptionValueError('option %s: invalid date or format: %s. use following format: %s' - % (opt, value, ','.join(valid_formats))) - TYPES = Option.TYPES + ('date',) - TYPE_CHECKER = Option.TYPE_CHECKER.copy() - TYPE_CHECKER['date'] = check_date - - -def main(): - usage = 'usage: sync.py [options]' - p = OptionParser(usage=usage, option_class=DateOption) - p.add_option('--garmin-username', '--gu', - default=GARMIN_USERNAME, type='string', metavar='', help='username to login Garmin Connect.') - p.add_option('--garmin-password', '--gp', - default=GARMIN_PASSWORD, type='string', metavar='', help='password to login Garmin Connect.') - p.add_option('--trainerroad-username', '--tu', - default=TRAINERROAD_USERNAME, type='string', metavar='', help='username to login TrainerRoad.') - p.add_option('--trainerroad-password', '--tp', - default=TRAINERROAD_PASSWORD, type='string', metavar='', help='username to login TrainerRoad.') - p.add_option('-f', '--fromdate', type='date', default=date.today(), metavar='') - p.add_option('-t', '--todate', type='date', default=date.today(), metavar='') - p.add_option('--no-upload', action='store_true', help="Won't upload to Garmin Connect and output binary-strings to stdout.") - p.add_option('-v', '--verbose', action='store_true', help='Run verbosely') - opts, args = p.parse_args() - - sync(**opts.__dict__) - - -def sync(garmin_username, garmin_password, trainerroad_username, trainerroad_password, fromdate, todate, - no_upload, verbose): - - def verbose_print(s): - if verbose: - if no_upload: - sys.stderr.write(s) - else: - sys.stdout.write(s) - - # Withings API - withings = WithingsAccount() - - startdate = int(time.mktime(fromdate.timetuple())) - enddate = int(time.mktime(todate.timetuple())) + 86399 - - groups = withings.getMeasurements(startdate=startdate, enddate=enddate) - - # create fit file - verbose_print('generating fit file...\n') - fit = FitEncoder_Weight() - fit.write_file_info() - fit.write_file_creator() - - last_dt = None - last_weight = 0 - - for group in groups: - # get extra physical measurements - - dt = group.get_datetime() - weight = group.get_weight() - fat_ratio = group.get_fat_ratio() - muscle_mass = group.get_muscle_mass() - hydration = group.get_hydration() - bone_mass = group.get_bone_mass() - - fit.write_device_info(timestamp=dt) - fit.write_weight_scale(timestamp=dt, - weight=weight, - percent_fat=fat_ratio, - percent_hydration=(hydration*100.0/weight) if (hydration and weight) else None, - bone_mass=bone_mass, - muscle_mass=muscle_mass - ) - verbose_print('appending weight scale record... %s %skg %s%%\n' % (dt, weight, fat_ratio)) - last_dt = dt - last_weight = weight - fit.finish() - - - # garmin connect - - if trainerroad_username and last_weight > 0: - print('Trainerroad username set -- attempting to sync') - print(" Last weight {}".format(last_weight)) - print(" Measured {}".format(last_dt)) - - tr = trainerroad.TrainerRoad(trainerroad_username, trainerroad_password) - tr.connect() - print ("Current TrainerRoad weight: {} kg ".format(tr.weight)) - print ("Updating TrainerRoad weight to {} kg".format(last_weight)) - tr.weight = round(last_weight, 1) - tr.disconnect() - print ("TrainerRoad update done!\n") - - - else: - print('No Trainerroad username or a new measurement - skipping sync') - - - if no_upload: - sys.stdout.buffer.write(fit.getvalue()) - return - - if garmin_username: - garmin = GarminConnect() - session = garmin.login(garmin_username, garmin_password) - verbose_print('attempting to upload fit file...\n') - r = garmin.upload_file(fit.getvalue(), session) - if r: - print("Fit file uploaded to Garmin Connect") - else: - print('No Garmin username - skipping sync\n') - - -if __name__ == '__main__': - main() diff --git a/test.py b/test.py deleted file mode 100755 index 82ca5df..0000000 --- a/test.py +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env python - -import sys -import requests -import json -import argparse - -from datetime import date -from datetime import datetime -import time - - -class Withings(): - AUTHORIZE_URL = 'https://account.withings.com/oauth2_user/authorize2' - TOKEN_URL = 'https://account.withings.com/oauth2/token' - GETMEAS_URL = 'https://wbsapi.withings.net/measure?action=getmeas' - APP_CONFIG = 'config/withings_app.json' - USER_CONFIG = 'config/withings_user.json' - - # 1 Weight (kg) - # 4 Height (meter) - # 5 Fat Free Mass (kg) - # 6 Fat Ratio (%) - # 8 Fat Mass Weight (kg) - # 9 Diastolic Blood Pressure (mmHg) - # 10 Systolic Blood Pressure (mmHg) - # 11 Heart Pulse (bpm) - only for BPM devices - # 12 Temperature - # 54 SP02 (%) - # 71 Body Temperature - # 73 Skin Temperature - # 76 Muscle Mass - # 77 Hydration - # 88 Bone Mass - # 91 Pulse Wave Velocity - - MEASTYPE_WEIGHT = 1 - -class WithingsConfig(Withings): - config = {} - config_file = "" - - def __init__(self, config_file): - self.config_file = config_file - self.read() - - def read(self): - try: - with open(self.config_file) as f: - self.config = json.load(f) - except (ValueError, FileNotFoundError): - print("Can't read config file " + self.config_file) - self.config = {} - - def write(self): - with open(self.config_file, "w") as f: - json.dump(self.config, f, indent=4, sort_keys=True) - -class WitingsOAuth2(Withings): - app_config = user_config = None - - def __init__(self): - app_cfg = WithingsConfig(Withings.APP_CONFIG) - self.app_config = app_cfg.config - - user_cfg = WithingsConfig(Withings.USER_CONFIG) - self.user_config = user_cfg.config - - if not self.user_config.get('access_token'): - if not self.user_config.get('authentification_code'): - self.user_config['authentification_code'] = self.getAuthenticationCode() - - self.getAccessToken() - - self.refreshAccessToken() - - app_cfg.write() - user_cfg.write() - - def getAuthenticationCode(self): - params = { - "response_type" : "code", - "client_id" : self.app_config['client_id'], - "state" : "OK", - "scope" : "user.metrics", - "redirect_uri" : self.app_config['callback_url'], - } - - print("***************************************") - print("* W A R N I N G *") - print("***************************************") - print() - print("User interaction needed to get Authentification Code!") - print() - print("Open the following URL in your web browser and copy back the token. You will have *30 seconds* before the token expires. HURRY UP!") - print("(This is one-time activity)") - print() - - url = Withings.AUTHORIZE_URL + '?' - - for key, value in params.items(): - url = url + key + '=' + value + "&" - - print(url) - print() - - authentification_code = input("Token : ") - - return authentification_code - - def getAccessToken(self): - print("Get Access Token") - - params = { - "grant_type" : "authorization_code", - "client_id" : self.app_config['client_id'], - "client_secret" : self.app_config['consumer_secret'], - "code" : self.user_config['authentification_code'], - "redirect_uri" : self.app_config['callback_url'], - } - - req = requests.post(Withings.TOKEN_URL, params ) - - accessToken = req.json() - - print(accessToken) - - if(accessToken.get('errors')): - print("Received error(s):") - for message in accessToken.get('errors'): - error = message.get('message') - print(" " + error) - if "invalid code" in error: - print("Removing invalid authentification_code") - self.user_config['authentification_code'] = '' - - print() - print("If it's regarding an invalid code, try to start the script again to obtain a new link.") - - self.user_config['access_token'] = accessToken.get('access_token') - self.user_config['refresh_token'] = accessToken.get('refresh_token') - self.user_config['userid'] = accessToken.get('userid') - - def refreshAccessToken(self): - print("Refresh Access Token") - - params = { - "grant_type" : "refresh_token", - "client_id" : self.app_config['client_id'], - "client_secret" : self.app_config['consumer_secret'], - "refresh_token" : self.user_config['refresh_token'], - } - - req = requests.post(Withings.TOKEN_URL, params ) - - accessToken = req.json() - - print(accessToken) - - if(accessToken.get('errors')): - print("Received error(s):") - for message in accessToken.get('errors'): - error = message.get('message') - print(" " + error) - if "invalid code" in error: - print("Removing invalid authentification_code") - self.user_config['authentification_code'] = '' - - print() - print("If it's regarding an invalid code, try to start the script again to obtain a new link.") - - self.user_config['access_token'] = accessToken.get('access_token') - self.user_config['refresh_token'] = accessToken.get('refresh_token') - self.user_config['userid'] = accessToken.get('userid') - -class WithingsAccount(Withings): - def __init__(self): - self.withings = WitingsOAuth2() - - def getMeasurements(self, startdate, enddate): - print("Get Measurements") - - params = { - "access_token" : self.withings.user_config['access_token'], - "meastype" : Withings.MEASTYPE_WEIGHT, - "category" : 1, - "startdate" : startdate, - "enddate" : enddate, - } - - req = requests.post(Withings.GETMEAS_URL, params ) - - measurements = req.json() - - print(measurements) - - -def main(): - usage = 'usage: sync.py [options]' - p = argparse.ArgumentParser(usage=usage) - p.add_argument('-f', '--fromdate', type=date, default=date.today(), metavar='') - # p.add_argument('-f', '--fromdate', type='date', default=date.today(), metavar='') - p.add_argument('-t', '--todate', type=date, default=date.today(), metavar='') - args = p.parse_args() - - print(args) - - fromdate = args.fromdate - todate = args.todate - - print(fromdate) - print(todate) - - startdate = int(time.mktime(fromdate.timetuple())) - enddate = int(time.mktime(todate.timetuple())) + 86399 - - print(startdate) - print(enddate) - - withingsAccount = WithingsAccount() - - withingsAccount.getMeasurements(startdate, enddate) - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/trainerroad/__init__.py b/trainerroad/__init__.py deleted file mode 100644 index 438629d..0000000 --- a/trainerroad/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .api import TrainerRoad diff --git a/withings2.py b/withings2.py deleted file mode 100755 index b783dd7..0000000 --- a/withings2.py +++ /dev/null @@ -1,271 +0,0 @@ -#!/usr/bin/env python - -import sys -import requests -import json -import argparse - -from datetime import date -from datetime import datetime -import time - -class WithingsException(Exception): - pass - -class Withings(): - AUTHORIZE_URL = 'https://account.withings.com/oauth2_user/authorize2' - TOKEN_URL = 'https://account.withings.com/oauth2/token' - GETMEAS_URL = 'https://wbsapi.withings.net/measure?action=getmeas' - APP_CONFIG = 'config/withings_app.json' - USER_CONFIG = 'config/withings_user.json' - -class WithingsConfig(Withings): - config = {} - config_file = "" - - def __init__(self, config_file): - self.config_file = config_file - self.read() - - def read(self): - try: - with open(self.config_file) as f: - self.config = json.load(f) - except (ValueError, FileNotFoundError): - print("Can't read config file " + self.config_file) - self.config = {} - - def write(self): - with open(self.config_file, "w") as f: - json.dump(self.config, f, indent=4, sort_keys=True) - -class WithingsOAuth2(Withings): - app_config = user_config = None - - def __init__(self): - app_cfg = WithingsConfig(Withings.APP_CONFIG) - self.app_config = app_cfg.config - - user_cfg = WithingsConfig(Withings.USER_CONFIG) - self.user_config = user_cfg.config - - if not self.user_config.get('access_token'): - if not self.user_config.get('authentification_code'): - self.user_config['authentification_code'] = self.getAuthenticationCode() - - self.getAccessToken() - - self.refreshAccessToken() - - app_cfg.write() - user_cfg.write() - - def getAuthenticationCode(self): - params = { - "response_type" : "code", - "client_id" : self.app_config['client_id'], - "state" : "OK", - "scope" : "user.metrics", - "redirect_uri" : self.app_config['callback_url'], - } - - print("***************************************") - print("* W A R N I N G *") - print("***************************************") - print() - print("User interaction needed to get Authentification Code from Withings!") - print() - print("Open the following URL in your web browser and copy back the token. You will have *30 seconds* before the token expires. HURRY UP!") - print("(This is one-time activity)") - print() - - url = Withings.AUTHORIZE_URL + '?' - - for key, value in params.items(): - url = url + key + '=' + value + "&" - - print(url) - print() - - authentification_code = input("Token : ") - - return authentification_code - - def getAccessToken(self): - print("Withings: Get Access Token") - - params = { - "grant_type" : "authorization_code", - "client_id" : self.app_config['client_id'], - "client_secret" : self.app_config['consumer_secret'], - "code" : self.user_config['authentification_code'], - "redirect_uri" : self.app_config['callback_url'], - } - - req = requests.post(Withings.TOKEN_URL, params ) - - accessToken = req.json() - - if(accessToken.get('errors')): - print("Received error(s):") - for message in accessToken.get('errors'): - error = message.get('message') - print(" " + error) - if "invalid code" in error: - print("Removing invalid authentification_code") - self.user_config['authentification_code'] = '' - - print() - print("If it's regarding an invalid code, try to start the script again to obtain a new link.") - - self.user_config['access_token'] = accessToken.get('access_token') - self.user_config['refresh_token'] = accessToken.get('refresh_token') - self.user_config['userid'] = accessToken.get('userid') - - def refreshAccessToken(self): - print("Withings: Refresh Access Token") - - params = { - "grant_type" : "refresh_token", - "client_id" : self.app_config['client_id'], - "client_secret" : self.app_config['consumer_secret'], - "refresh_token" : self.user_config['refresh_token'], - } - - req = requests.post(Withings.TOKEN_URL, params ) - - accessToken = req.json() - - if(accessToken.get('errors')): - print("Received error(s):") - for message in accessToken.get('errors'): - error = message.get('message') - print(" " + error) - if "invalid code" in error: - print("Removing invalid authentification_code") - self.user_config['authentification_code'] = '' - - print() - print("If it's regarding an invalid code, try to start the script again to obtain a new link.") - - self.user_config['access_token'] = accessToken.get('access_token') - self.user_config['refresh_token'] = accessToken.get('refresh_token') - self.user_config['userid'] = accessToken.get('userid') - -class WithingsAccount(Withings): - def __init__(self): - self.withings = WithingsOAuth2() - - def getMeasurements(self, startdate, enddate): - print("Withings: Get Measurements") - - params = { - "access_token" : self.withings.user_config['access_token'], - # "meastype" : Withings.MEASTYPE_WEIGHT, - "category" : 1, - "startdate" : startdate, - "enddate" : enddate, - } - - req = requests.post(Withings.GETMEAS_URL, params ) - - measurements = req.json() - - if measurements.get('status') == 0: - print(" Measurements received") - - return [WithingsMeasureGroup(g) for g in measurements.get('body').get('measuregrps')] - -class WithingsMeasureGroup(object): - def __init__(self, measuregrp): - self._raw_data = measuregrp - self.id = measuregrp.get('grpid') - self.attrib = measuregrp.get('attrib') - self.date = measuregrp.get('date') - self.category = measuregrp.get('category') - self.measures = [WithingsMeasure(m) for m in measuregrp['measures']] - - def __iter__(self): - for measure in self.measures: - yield measure - - def __len__(self): - return len(self.measures) - - def get_datetime(self): - return datetime.fromtimestamp(self.date) - - def get_weight(self): - """convinient function to get weight""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_WEIGHT: - return measure.get_value() - return None - - def get_fat_ratio(self): - """convinient function to get fat ratio""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_FAT_RATIO: - return measure.get_value() - return None - - def get_muscle_mass(self): - """convinient function to get muscle mass""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_MUSCLE_MASS: - return measure.get_value() - return None - - def get_hydration(self): - """convinient function to get hydration""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_HYDRATION: - return measure.get_value() - return None - - def get_bone_mass(self): - """convinient function to get bone mass""" - for measure in self.measures: - if measure.type == WithingsMeasure.TYPE_BONE_MASS: - return measure.get_value() - return None - -class WithingsMeasure(object): - TYPE_WEIGHT = 1 - TYPE_HEIGHT = 4 - TYPE_FAT_FREE_MASS = 5 - TYPE_FAT_RATIO = 6 - TYPE_FAT_MASS_WEIGHT = 8 - TYPE_MUSCLE_MASS = 76 - TYPE_HYDRATION = 77 - TYPE_BONE_MASS = 88 - - def __init__(self, measure): - self._raw_data = measure - self.value = measure.get('value') - self.type = measure.get('type') - self.unit = measure.get('unit') - - def __str__(self): - type_s = 'unknown' - unit_s = '' - if (self.type == self.TYPE_WEIGHT): - type_s = 'Weight' - unit_s = 'kg' - elif (self.type == self.TYPE_HEIGHT): - type_s = 'Height' - unit_s = 'meter' - elif (self.type == self.TYPE_FAT_FREE_MASS): - type_s = 'Fat Free Mass' - unit_s = 'kg' - elif (self.type == self.TYPE_FAT_RATIO): - type_s = 'Fat Ratio' - unit_s = '%' - elif (self.type == self.TYPE_FAT_MASS_WEIGHT): - type_s = 'Fat Mass Weight' - unit_s = 'kg' - return '%s: %s %s' % (type_s, self.get_value(), unit_s) - - def get_value(self): - return self.value * pow(10, self.unit) - diff --git a/withings_sync/__init__.py b/withings_sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/withings_app.json b/withings_sync/config/withings_app.json similarity index 100% rename from config/withings_app.json rename to withings_sync/config/withings_app.json diff --git a/fit.py b/withings_sync/fit.py similarity index 99% rename from fit.py rename to withings_sync/fit.py index cad31a7..5d24328 100644 --- a/fit.py +++ b/withings_sync/fit.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from io import BytesIO from struct import pack from struct import unpack diff --git a/withings_sync/garmin.py b/withings_sync/garmin.py new file mode 100644 index 0000000..ec5cb0a --- /dev/null +++ b/withings_sync/garmin.py @@ -0,0 +1,196 @@ +from datetime import timedelta +import urllib.request +import urllib.error +import urllib.parse +import requests +import re +import sys +import json +import logging + +log = logging.getLogger('garmin') + + +class LoginSucceeded(Exception): + pass + + +class LoginFailed(Exception): + pass + + +class APIException(Exception): + pass + + +class GarminConnect(object): + LOGIN_URL = 'https://connect.garmin.com/signin' + UPLOAD_URL = 'https://connect.garmin.com/modern/proxy/upload-service/upload/.fit' + + def create_opener(self, cookie): + this = self + + class _HTTPRedirectHandler(urllib.request.HTTPRedirectHandler): + def http_error_302(self, req, fp, code, msg, headers): + if req.get_full_url() == this.LOGIN_URL: + raise LoginSucceeded + + return urllib.request.HTTPRedirectHandler.http_error_302(self, req, fp, code, msg, headers) + + return urllib.request.build_opener(_HTTPRedirectHandler, urllib.request.HTTPCookieProcessor(cookie)) + + # From https://github.com/cpfair/tapiriik + + def _get_session(self, record=None, email=None, password=None): + session = requests.Session() + + # JSIG CAS, cool I guess. + # Not quite OAuth though, so I'll continue to collect raw credentials. + # Commented stuff left in case this ever breaks because of missing parameters... + data = { + 'username': email, + 'password': password, + '_eventId': 'submit', + 'embed': 'true', + # 'displayNameRequired': 'false' + } + params = { + 'service': 'https://connect.garmin.com/modern', + # 'redirectAfterAccountLoginUrl': 'http://connect.garmin.com/modern', + # 'redirectAfterAccountCreationUrl': 'http://connect.garmin.com/modern', + # 'webhost': 'olaxpw-connect00.garmin.com', + 'clientId': 'GarminConnect', + 'gauthHost': 'https://sso.garmin.com/sso', + # 'rememberMeShown': 'true', + # 'rememberMeChecked': 'false', + 'consumeServiceTicket': 'false', + # 'id': 'gauth-widget', + # 'embedWidget': 'false', + # 'cssUrl': 'https://static.garmincdn.com/com.garmin.connect/ui/src-css/gauth-custom.css', + # 'source': 'http://connect.garmin.com/en-US/signin', + # 'createAccountShown': 'true', + # 'openCreateAccount': 'false', + # 'usernameShown': 'true', + # 'displayNameShown': 'false', + # 'initialFocus': 'true', + # 'locale': 'en' + } + + headers = { + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36', + 'Referer': 'https://jhartman.pl', + 'origin': 'https://sso.garmin.com' + } + + # I may never understand what motivates people to mangle a perfectly good protocol like HTTP in the ways they do... + preResp = session.get('https://sso.garmin.com/sso/signin', params=params, headers=headers) + if preResp.status_code != 200: + raise APIException('SSO prestart error %s %s' % (preResp.status_code, preResp.text)) + + ssoResp = session.post('https://sso.garmin.com/sso/login', params=params, data=data, allow_redirects=False, headers=headers) + + if ssoResp.status_code != 200 or 'temporarily unavailable' in ssoResp.text: + raise APIException('SSO error %s %s' % (ssoResp.status_code, ssoResp.text)) + + if '>sendEvent(\'FAIL\')' in ssoResp.text: + raise APIException('Invalid login') + + if '>sendEvent(\'ACCOUNT_LOCKED\')' in ssoResp.text: + raise APIException('Account Locked') + + if 'renewPassword' in ssoResp.text: + raise APIException('Reset password') + + # self.print_cookies(cookies=session.cookies) + + # ...AND WE'RE NOT DONE YET! + + gcRedeemResp = session.get('https://connect.garmin.com/modern', + allow_redirects=False, + headers=headers) + if gcRedeemResp.status_code != 302: + raise APIException(f'GC redeem-start error {gcRedeemResp.status_code} {gcRedeemResp.text}') + + url_prefix = 'https://connect.garmin.com' + + # There are 6 redirects that need to be followed to get the correct cookie + # ... :( + max_redirect_count = 7 + current_redirect_count = 1 + while True: + url = gcRedeemResp.headers['location'] + + # Fix up relative redirects. + if url.startswith('/'): + url = url_prefix + url + url_prefix = '/'.join(url.split('/')[:3]) + gcRedeemResp = session.get(url, allow_redirects=False) + + if (current_redirect_count >= max_redirect_count and + gcRedeemResp.status_code != 200): + raise APIException(f'GC redeem {current_redirect_count}/' + '{max_redirect_count} error ' + '{gcRedeemResp.status_code} ' + '{gcRedeemResp.text}') + + if gcRedeemResp.status_code in [200, 404]: + break + + current_redirect_count += 1 + if current_redirect_count > max_redirect_count: + break + + # self.print_cookies(session.cookies) + + session.headers.update(headers) + + return session + + def print_cookies(self, cookies): + log.debug('Cookies: ') + for key, value in list(cookies.items()): + log.debug(' %s = %s', key, value) + + def login(self, username, password): + + session = self._get_session(email=username, password=password) + + try: + dashboard = session.get('http://connect.garmin.com/modern') + + userdata_json_str = re.search(r'VIEWER_SOCIAL_PROFILE\s*=\s*JSON\.parse\((.+)\);$', dashboard.text, re.MULTILINE).group(1) + userdata = json.loads(json.loads(userdata_json_str)) + username = userdata['displayName'] + + log.info('Garmin Connect User Name: %s', username) + + except Exception as e: + log.error(e) + log.error('Unable to retrieve Garmin username! Most likely: ' + 'incorrect Garmin login or password!') + + return session + + def upload_file(self, f, session): + files = { + 'data': ( + 'withings.fit', f + ) + } + + res = session.post(self.UPLOAD_URL, + files=files, + headers={'nk': 'NT'}) + + try: + resp = res.json() + + if 'detailedImportResult' not in resp: + raise KeyError + except (ValueError, KeyError): + if res.status_code == 204: # HTTP result 204 - 'no content' + log.error('No data to upload, try to use --fromdate and --todate') + else: + log.error('Bad response during GC upload: %s', res.status_code) + + return res.status_code in [200, 201, 204] diff --git a/withings_sync/sync.py b/withings_sync/sync.py new file mode 100755 index 0000000..256d55c --- /dev/null +++ b/withings_sync/sync.py @@ -0,0 +1,168 @@ +import argparse +import time +import sys +import os +import logging + +from datetime import date, datetime + +from withings_sync.withings2 import WithingsAccount +from withings_sync.garmin import GarminConnect +from withings_sync.trainerroad import TrainerRoad +from withings_sync.fit import FitEncoder_Weight + + +def get_args(): + parser = argparse.ArgumentParser( + description=('A tool for synchronisation of Withings ' + '(ex. Nokia Health Body) to Garmin Connect' + ' and Trainer Road.') + ) + + def date_parser(s): + datetime.datetime.strptime(s, '%Y-%m-%d') + + parser.add_argument('--garmin-username', '--gu', + default=os.environ.get('GARMIN_USERNAME'), + type=str, + metavar='GARMIN_USERNAME', + help='username to login Garmin Connect.') + parser.add_argument('--garmin-password', '--gp', + default=os.environ.get('GARMIN_PASSWORD'), + type=str, + metavar='GARMIN_PASSWORD', + help='password to login Garmin Connect.') + + parser.add_argument('--trainerroad-username', '--tu', + default=os.environ.get('TRAINERROAD_USERNAME'), + type=str, + metavar='TRAINERROAD_USERNAME', + help='username to login TrainerRoad.') + parser.add_argument('--trainerroad-password', '--tp', + default=os.environ.get('TRAINERROAD_PASSWORD'), + type=str, + metavar='TRAINERROAD_PASSWORD', + help='username to login TrainerRoad.') + + parser.add_argument('--fromdate', '-f', + type=date_parser, + default=date.today(), + metavar='DATE') + parser.add_argument('--todate', '-t', + type=date_parser, + default=date.today(), + metavar='DATE') + + parser.add_argument('--no-upload', + action='store_true', + help=('Won\'t upload to Garmin Connect and ' + 'output binary-strings to stdout.')) + parser.add_argument('--verbose', '-v', + action='store_true', + help='Run verbosely') + + return parser.parse_args() + + +def sync(garmin_username, garmin_password, + trainerroad_username, trainerroad_password, + fromdate, todate, + no_upload, verbose): + + + logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + + # Withings API + withings = WithingsAccount() + + startdate = int(time.mktime(fromdate.timetuple())) + enddate = int(time.mktime(todate.timetuple())) + 86399 + + groups = withings.getMeasurements(startdate=startdate, enddate=enddate) + + # Create FIT file + logging.debug('Generating fit file...') + fit = FitEncoder_Weight() + fit.write_file_info() + fit.write_file_creator() + + last_dt = None + last_weight = None + + for group in groups: + # Get extra physical measurements + dt = group.get_datetime() + weight = group.get_weight() + fat_ratio = group.get_fat_ratio() + muscle_mass = group.get_muscle_mass() + hydration = group.get_hydration() + bone_mass = group.get_bone_mass() + + fit.write_device_info(timestamp=dt) + fit.write_weight_scale(timestamp=dt, + weight=weight, + percent_fat=fat_ratio, + percent_hydration=(hydration*100.0/weight) + if (hydration and weight) else None, + bone_mass=bone_mass, + muscle_mass=muscle_mass) + + logging.debug('Record: %s weight=%s kg, ' + 'fat_ratio=%s %%, ' + 'muscle_mass=%s Kg, ' + 'hydration=%s %%, ' + 'bone_mass=%s Kg', + dt, weight, fat_ratio, + muscle_mass, hydration, bone_mass) + + if last_dt is None or dt > last_dt: + last_dt = dt + last_weight = weight + + fit.finish() + + if last_weight is None: + logging.error('Invalid weight') + return -1 + + if no_upload: + sys.stdout.buffer.write(fit.getvalue()) + return 0 + + # Upload to Trainer Road + if trainerroad_username: + logging.info('Trainerroad username set -- attempting to sync') + logging.info(' Last weight {}'.format(last_weight)) + logging.info(' Measured {}'.format(last_dt)) + + tr = TrainerRoad(trainerroad_username, trainerroad_password) + tr.connect() + + logging.info(f'Current TrainerRoad weight: {tr.weight} kg ') + logging.info(f'Updating TrainerRoad weight to {last_weight} kg') + + tr.weight = round(last_weight, 1) + tr.disconnect() + + logging.info('TrainerRoad update done!') + else: + logging.info('No Trainerroad username or a new measurement ' + '- skipping sync') + + # Upload to Garmin Connect + if garmin_username: + garmin = GarminConnect() + session = garmin.login(garmin_username, garmin_password) + logging.debug('attempting to upload fit file...') + r = garmin.upload_file(fit.getvalue(), session) + if r: + logging.info('Fit file uploaded to Garmin Connect') + else: + logging.info('No Garmin username - skipping sync') + + +def main(): + args = get_args() + + sync(**vars(args)) diff --git a/trainerroad/api.py b/withings_sync/trainerroad.py similarity index 99% rename from trainerroad/api.py rename to withings_sync/trainerroad.py index 4faff6c..a97ab87 100644 --- a/trainerroad/api.py +++ b/withings_sync/trainerroad.py @@ -1,12 +1,13 @@ import requests import json -import lxml.html -from lxml import etree -from io import StringIO, BytesIO import logging +from lxml import etree +from io import StringIO + logger = logging.getLogger(__name__) + class TrainerRoad: _ftp = 'Ftp' _weight = 'Weight' @@ -29,7 +30,6 @@ def __init__(self, username, password): self._password = password self._session = None - def connect(self): self._session = requests.Session() self._session.auth = (self._username, self._password) @@ -200,7 +200,6 @@ def get_workouts(self): return data - def get_workout(self, guid): res = self._session.get(self._workout_url + '?guid={}'.format(str(guid))) diff --git a/withings_sync/withings2.py b/withings_sync/withings2.py new file mode 100755 index 0000000..56424ae --- /dev/null +++ b/withings_sync/withings2.py @@ -0,0 +1,277 @@ +import logging +import requests +import json +import pkg_resources +import os + +from datetime import datetime + +log = logging.getLogger('withings') + + +class WithingsException(Exception): + pass + + +class Withings(): + AUTHORIZE_URL = 'https://account.withings.com/oauth2_user/authorize2' + TOKEN_URL = 'https://account.withings.com/oauth2/token' + GETMEAS_URL = 'https://wbsapi.withings.net/measure?action=getmeas' + APP_CONFIG = pkg_resources.resource_filename(__name__, 'config/withings_app.json') + USER_CONFIG = os.environ.get('HOME', '.') + '/.withings_user.json' + + +class WithingsConfig(Withings): + config = {} + config_file = '' + + def __init__(self, config_file): + self.config_file = config_file + self.read() + + def read(self): + try: + with open(self.config_file) as f: + self.config = json.load(f) + except (ValueError, FileNotFoundError): + log.error('Can\'t read config file ' + self.config_file) + self.config = {} + + def write(self): + with open(self.config_file, 'w') as f: + json.dump(self.config, f, indent=4, sort_keys=True) + + +class WithingsOAuth2(Withings): + app_config = user_config = None + + def __init__(self): + app_cfg = WithingsConfig(Withings.APP_CONFIG) + self.app_config = app_cfg.config + + user_cfg = WithingsConfig(Withings.USER_CONFIG) + self.user_config = user_cfg.config + + if not self.user_config.get('access_token'): + if not self.user_config.get('authentification_code'): + self.user_config['authentification_code'] = self.getAuthenticationCode() + + self.getAccessToken() + + self.refreshAccessToken() + + user_cfg.write() + + def getAuthenticationCode(self): + params = { + 'response_type': 'code', + 'client_id': self.app_config['client_id'], + 'state': 'OK', + 'scope': 'user.metrics', + 'redirect_uri': self.app_config['callback_url'], + } + + log.warn('User interaction needed to get Authentification ' + 'Code from Withings!') + log.warn('') + log.warn('Open the following URL in your web browser and copy back ' + 'the token. You will have *30 seconds* before the ' + 'token expires. HURRY UP!') + log.warn('(This is one-time activity)') + log.warn('') + + url = Withings.AUTHORIZE_URL + '?' + + for key, value in params.items(): + url = url + key + '=' + value + '&' + + log.info(url) + log.info('') + + authentification_code = input('Token : ') + + return authentification_code + + def getAccessToken(self): + log.info('Get Access Token') + + params = { + 'grant_type': 'authorization_code', + 'client_id': self.app_config['client_id'], + 'client_secret': self.app_config['consumer_secret'], + 'code': self.user_config['authentification_code'], + 'redirect_uri': self.app_config['callback_url'], + } + + req = requests.post(Withings.TOKEN_URL, params) + + accessToken = req.json() + + if(accessToken.get('errors')): + log.error('Received error(s):') + for message in accessToken.get('errors'): + error = message.get('message') + log.error(' ' + error) + if 'invalid code' in error: + log.error('Removing invalid authentification_code') + self.user_config['authentification_code'] = '' + + log.error() + log.error('If it\'s regarding an invalid code, ' + 'try to start the script again to obtain a new link.') + + self.user_config['access_token'] = accessToken.get('access_token') + self.user_config['refresh_token'] = accessToken.get('refresh_token') + self.user_config['userid'] = accessToken.get('userid') + + def refreshAccessToken(self): + log.info('Refresh Access Token') + + params = { + 'grant_type': 'refresh_token', + 'client_id': self.app_config['client_id'], + 'client_secret': self.app_config['consumer_secret'], + 'refresh_token': self.user_config['refresh_token'], + } + + req = requests.post(Withings.TOKEN_URL, params) + + accessToken = req.json() + + if(accessToken.get('errors')): + log.error('Received error(s):') + for message in accessToken.get('errors'): + error = message.get('message') + log.error(' ' + error) + if 'invalid code' in error: + log.error('Removing invalid authentification_code') + self.user_config['authentification_code'] = '' + + log.error() + log.error('If it\'s regarding an invalid code, try to start the' + ' script again to obtain a new link.') + + self.user_config['access_token'] = accessToken.get('access_token') + self.user_config['refresh_token'] = accessToken.get('refresh_token') + self.user_config['userid'] = accessToken.get('userid') + + +class WithingsAccount(Withings): + def __init__(self): + self.withings = WithingsOAuth2() + + def getMeasurements(self, startdate, enddate): + log.info('Get Measurements') + + params = { + 'access_token': self.withings.user_config['access_token'], + # 'meastype': Withings.MEASTYPE_WEIGHT, + 'category': 1, + 'startdate': startdate, + 'enddate': enddate, + } + + req = requests.post(Withings.GETMEAS_URL, params) + + measurements = req.json() + + if measurements.get('status') == 0: + log.debug('Measurements received') + + return [WithingsMeasureGroup(g) for + g in measurements.get('body').get('measuregrps')] + + +class WithingsMeasureGroup(object): + def __init__(self, measuregrp): + self._raw_data = measuregrp + self.id = measuregrp.get('grpid') + self.attrib = measuregrp.get('attrib') + self.date = measuregrp.get('date') + self.category = measuregrp.get('category') + self.measures = [WithingsMeasure(m) for m in measuregrp['measures']] + + def __iter__(self): + for measure in self.measures: + yield measure + + def __len__(self): + return len(self.measures) + + def get_datetime(self): + return datetime.fromtimestamp(self.date) + + def get_weight(self): + '''convinient function to get weight''' + for measure in self.measures: + if measure.type == WithingsMeasure.TYPE_WEIGHT: + return measure.get_value() + return None + + def get_fat_ratio(self): + '''convinient function to get fat ratio''' + for measure in self.measures: + if measure.type == WithingsMeasure.TYPE_FAT_RATIO: + return measure.get_value() + return None + + def get_muscle_mass(self): + '''convinient function to get muscle mass''' + for measure in self.measures: + if measure.type == WithingsMeasure.TYPE_MUSCLE_MASS: + return measure.get_value() + return None + + def get_hydration(self): + '''convinient function to get hydration''' + for measure in self.measures: + if measure.type == WithingsMeasure.TYPE_HYDRATION: + return measure.get_value() + return None + + def get_bone_mass(self): + '''convinient function to get bone mass''' + for measure in self.measures: + if measure.type == WithingsMeasure.TYPE_BONE_MASS: + return measure.get_value() + return None + + +class WithingsMeasure(object): + TYPE_WEIGHT = 1 + TYPE_HEIGHT = 4 + TYPE_FAT_FREE_MASS = 5 + TYPE_FAT_RATIO = 6 + TYPE_FAT_MASS_WEIGHT = 8 + TYPE_MUSCLE_MASS = 76 + TYPE_HYDRATION = 77 + TYPE_BONE_MASS = 88 + + def __init__(self, measure): + self._raw_data = measure + self.value = measure.get('value') + self.type = measure.get('type') + self.unit = measure.get('unit') + + def __str__(self): + type_s = 'unknown' + unit_s = '' + if (self.type == self.TYPE_WEIGHT): + type_s = 'Weight' + unit_s = 'kg' + elif (self.type == self.TYPE_HEIGHT): + type_s = 'Height' + unit_s = 'meter' + elif (self.type == self.TYPE_FAT_FREE_MASS): + type_s = 'Fat Free Mass' + unit_s = 'kg' + elif (self.type == self.TYPE_FAT_RATIO): + type_s = 'Fat Ratio' + unit_s = '%' + elif (self.type == self.TYPE_FAT_MASS_WEIGHT): + type_s = 'Fat Mass Weight' + unit_s = 'kg' + return '%s: %s %s' % (type_s, self.get_value(), unit_s) + + def get_value(self): + return self.value * pow(10, self.unit)