initial commit

This commit is contained in:
petergardfjall
2014-11-08 13:35:47 +01:00
parent b187971ef4
commit e030c015d2
11 changed files with 727 additions and 1 deletions
View File
+252
View File
@@ -0,0 +1,252 @@
#! /usr/bin/env python
"""A module for authenticating against and communicating with selected
parts of the Garmin Connect REST API.
"""
import json
import logging
import re
import requests
import sys
#
# Note: For more detailed information about the API services
# used by this module, log in to your Garmin Connect account
# through the web browser and visit the API documentation page
# for the REST service of interest. For example:
# https://connect.garmin.com/proxy/activity-service-1.3/index.html
# https://connect.garmin.com/proxy/activity-search-service-1.2/index.html
#
#
# Other useful references:
# https://github.com/cpfair/tapiriik/blob/master/tapiriik/services/GarminConnect/garminconnect.py
# https://forums.garmin.com/showthread.php?72150-connect-garmin-com-signin-question/page2
#
log = logging.getLogger(__name__)
# reduce logging noise from requests library
logging.getLogger("requests").setLevel(logging.ERROR)
def require_session(client_function):
"""Decorator that is used to annotate :class:`GarminClient`
methods that need an authenticated session before being called.
"""
def check_session(*args, **kwargs):
client_object = args[0]
if not client_object.session:
raise Exception("Attempt to use GarminClient without being connected. Call connect() before first use.'")
return client_function(*args, **kwargs)
return check_session
class GarminClient(object):
"""A client class used to authenticate with Garmin Connect and
extract data from the user account.
Since this class implements the context manager protocol, this object
can preferably be used together with the with-statement. This will
automatically take care of logging in to Garmin Connect before any
further interactions and logging out after the block completes or
a failure occurs.
Example of use: ::
with GarminClient("my.sample@sample.com", "secretpassword") as client:
ids = client.list_activity_ids()
for activity_id in ids:
gpx = client.get_activity_gpx(activity_id)
"""
def __init__(self, username, password):
"""Initialize a :class:`GarminClient` instance.
:param username: Garmin Connect user name or email address.
:type username: str
:param password: Garmin Connect account password.
:type password: str
"""
self.username = username
self.password = password
self.session = None
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.disconnect()
def connect(self):
self.session = requests.Session()
self._authenticate()
def disconnect(self):
if self.session:
self.session.close()
self.session = None
def _authenticate(self):
log.info("authenticating user ...")
params = {
"service": "http://connect.garmin.com/post-auth/login",
"clientId": "GarminConnect",
"consumeServiceTicket": "false"
}
flow_execution_key = self._get_flow_execution_key(params)
log.debug("flow execution key: '{}'".format(flow_execution_key))
validation_url = self._get_auth_ticket(flow_execution_key, params)
log.debug("auth ticket validation url: {}".format(validation_url))
self._validate_auth_ticket(validation_url)
# referer seems to be a header that is required by the REST API
self.session.headers.update({'Referer': "https://some.random.site"})
def _get_flow_execution_key(self, request_params):
log.debug("get flow execution key ...")
response = self.session.get(
"https://sso.garmin.com/sso/login", params=request_params)
# parse out flowExecutionKey
flow_execution_key = re.search(
r'name="lt"\s+value="([^"]+)"', response.text).groups(1)[0]
return flow_execution_key
def _get_auth_ticket(self, flow_execution_key, request_params):
data = {
"username": self.username, "password": self.password,
"_eventId": "submit", "embed": "true", "lt": flow_execution_key
}
log.debug("single sign-on ...")
sso_response = self.session.post(
"https://sso.garmin.com/sso/login",
params=request_params, data=data, allow_redirects=False)
# response must contain an SSO ticket
ticket_match = re.search("ticket=([^']+)'", sso_response.text)
if not ticket_match:
raise ValueError("failed to get authentication ticket: "
"did you enter valid credentials?")
ticket = ticket_match.group(1)
log.debug("SSO ticket: {}".format(ticket))
# response should contain a URL where auth ticket can be validated
validation_url = re.search(
r"response_url\s+=\s+'([^']+)'", sso_response.text)
validation_url = validation_url.group(1)
return validation_url
def _validate_auth_ticket(self, validation_url):
log.debug("validating authentication ticket ...")
response = self.session.get(validation_url, allow_redirects=True)
if not response.status_code == 200:
raise Exception(
u"failed to validate authentication ticket: {}:\n{}".format(
response.status_code, response.text))
@require_session
def list_activity_ids(self):
"""Return all activity ids stored by the logged in user.
:returns: The full list of activity identifiers.
:rtype: list of str
"""
ids = []
batch_size = 100
# fetch in batches since the API doesn't allow more than a certain
# number of activities to be retrieved on every invocation
for start_index in xrange(0, sys.maxint, batch_size):
next_batch = self._fetch_activity_ids(start_index, batch_size)
if not next_batch:
break
ids.extend(next_batch)
return ids
@require_session
def _fetch_activity_ids(self, start_index, max_limit=100):
"""Return a sequence of activity ids starting at a given index,
with index 0 being the user's most recently registered activity.
Should the index be out of bounds or the account empty, an empty
list is returned.
:param start_index: The index of the first activity to retrieve.
:type start_index: int
:param max_limit: The (maximum) number of activities to retrieve.
:type max_limit: int
:returns: A list of activity identifiers.
:rtype: list of str
"""
log.info("fetching activities {} through {} ...".format(start_index, start_index+max_limit-1))
response = self.session.get(
"https://connect.garmin.com/proxy/activity-search-service-1.2/json/activities", params={"start": start_index, "limit": max_limit})
if response.status_code != 200:
raise Exception(
u"failed to fetch activities {} to {} types: {}\n{}".format(
start_index, (start_index+max_limit-1),
response.status_code, response.text))
results = json.loads(response.text)["results"]
if not "activities" in results:
# index out of bounds or empty account
return []
entries = [int(entry["activity"]["activityId"]) for entry in results["activities"]]
log.debug("got {} activities.".format(len(entries)))
return entries
@require_session
def get_activity_summary(self, activity_id):
"""Return a summary about a given activity. The
summary contains several statistics, such as duration, GPS starting
point, GPS end point, elevation gain, max heart rate, max pace, max
speed, etc).
:param activity_id: Activity identifier.
:type activity_id: int
:returns: The activity summary as a JSON dict.
:rtype: dict
"""
response = self.session.get("https://connect.garmin.com/proxy/activity-service-1.3/json/activity/{}".format(activity_id))
if response.status_code != 200:
raise Exception(u"failed to fetch activity {}: {}\n{}".format(
activity_id, response.status_code, response.text))
return json.loads(response.text)
@require_session
def get_activity_details(self, activity_id):
"""Return a JSON representation of a given activity including
available measurements such as location (longitude, latitude),
heart rate, distance, pace, speed, elevation.
:param activity_id: Activity identifier.
:type activity_id: int
:returns: The activity details as a JSON dict.
:rtype: dict
"""
# mounted at xml or json depending on result encoding
response = self.session.get("https://connect.garmin.com/proxy/activity-service-1.3/json/activityDetails/{}".format(activity_id))
if response.status_code != 200:
raise Exception(u"failed to fetch activity details for {}: {}\n{}".format(
activity_id, response.status_code, response.text))
return json.loads(response.text)
@require_session
def get_activity_gpx(self, activity_id):
"""Return a GPX (GPS Exchange Format) representation of a
given activity.
:param activity_id: Activity identifier.
:type activity_id: int
:returns: The GPX representation of the activity as an XML string.
:rtype: str
"""
response = self.session.get("https://connect.garmin.com/proxy/activity-service-1.3/gpx/course/{}".format(activity_id))
# An alternate URL that seems to produce the same results
# and is the one used when exporting through the Garmin
# Connect web page.
#response = self.session.get("https://connect.garmin.com/proxy/activity-service-1.1/gpx/activity/{}?full=true".format(activity_id))
if response.status_code != 200:
raise Exception(u"failed to fetch GPX for activity {}: {}\n{}".format(
activity_id, response.status_code, response.text))
return response.text