Skip to content
This repository has been archived by the owner on Nov 14, 2020. It is now read-only.

Commit

Permalink
Fix Garmin sync after switching Garmin Connect to 'modern'
Browse files Browse the repository at this point in the history
Fixing authorisation issues.
Changed file upload procedure.
  • Loading branch information
jaroslawhartman committed Apr 21, 2017
1 parent b802248 commit e585831
Show file tree
Hide file tree
Showing 2 changed files with 119 additions and 123 deletions.
235 changes: 115 additions & 120 deletions garmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ class LoginFailed(Exception):

class GarminConnect(object):
LOGIN_URL = 'https://connect.garmin.com/signin'
UPLOAD_URL = 'https://connect.garmin.com/proxy/upload-service-1.1/json/upload/.fit'
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(urllib2.HTTPRedirectHandler):
Expand All @@ -35,133 +35,128 @@ def http_error_302(self, req, fp, code, msg, headers):
##############################################
# From https://github.com/cpfair/tapiriik

def _get_cookies(self, record=None, email=None, password=None):

gcPreResp = requests.get("https://connect.garmin.com/", allow_redirects=False)
# New site gets this redirect, old one does not
if gcPreResp.status_code == 200:
gcPreResp = requests.get("https://connect.garmin.com/signin", allow_redirects=False)
req_count = int(re.search("j_id(\d+)", gcPreResp.text).groups(1)[0])
params = {"login": "login", "login:loginUsernameField": email, "login:password": password, "login:signInButton": "Sign In"}
auth_retries = 3 # Did I mention Garmin Connect is silly?
for retries in range(auth_retries):
params["javax.faces.ViewState"] = "j_id%d" % req_count
req_count += 1
self._rate_limit()
resp = requests.post("https://connect.garmin.com/signin", data=params, allow_redirects=False, cookies=gcPreResp.cookies)
if resp.status_code >= 500 and resp.status_code < 600:
raise APIException("Remote API failure")
if resp.status_code != 302: # yep
if "errorMessage" in resp.text:
if retries < auth_retries - 1:
time.sleep(1)
continue
else:
raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))
else:
raise APIException("Mystery login error %s" % resp.text)
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/post-auth/login",
"redirectAfterAccountLoginUrl": "http://connect.garmin.com/post-auth/login",
"redirectAfterAccountCreationUrl": "http://connect.garmin.com/post-auth/login",
# "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"
}
# 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/login", params=params)
if preResp.status_code != 200:
raise APIException("SSO prestart error %s %s" % (preResp.status_code, preResp.text))

data["lt"] = re.search("name=\"lt\"\s+value=\"([^\"]+)\"", preResp.text).groups(1)[0]

# print "Received lt: " + data["lt"]

ssoResp = session.post("https://sso.garmin.com/sso/login", params=params, data=data, allow_redirects=False)
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))

ticket_match = re.search("ticket=([^']+)'", ssoResp.text)

if not ticket_match:
raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))
ticket = ticket_match.groups(1)[0]

# print "Ticket: " + ticket
# self.print_cookies(cookies=session.cookies)

# ...AND WE'RE NOT DONE YET!

gcRedeemResp = session.get("https://connect.garmin.com/post-auth/login", params={"ticket": ticket}, allow_redirects=False)
if gcRedeemResp.status_code != 302:
raise APIException("GC redeem-start error %s %s" % (gcRedeemResp.status_code, gcRedeemResp.text))

# self.print_cookies(cookies=session.cookies)

# There are 6 redirects that need to be followed to get the correct cookie
# ... :(
max_redirect_count = 7
current_redirect_count = 1
while True:
gcRedeemResp = session.get(gcRedeemResp.headers["location"], allow_redirects=False)

# self.print_cookies(cookies=session.cookies)

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
elif gcPreResp.status_code == 302:
# 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/post-auth/login",
# "redirectAfterAccountLoginUrl": "http://connect.garmin.com/post-auth/login",
# "redirectAfterAccountCreationUrl": "http://connect.garmin.com/post-auth/login",
# "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"
}
# I may never understand what motivates people to mangle a perfectly good protocol like HTTP in the ways they do...
preResp = requests.get("https://sso.garmin.com/sso/login", params=params)
if preResp.status_code != 200:
raise APIException("SSO prestart error %s %s" % (preResp.status_code, preResp.text))
data["lt"] = re.search("name=\"lt\"\s+value=\"([^\"]+)\"", preResp.text).groups(1)[0]

ssoResp = requests.post("https://sso.garmin.com/sso/login", params=params, data=data, allow_redirects=False, cookies=preResp.cookies)
if ssoResp.status_code != 200:
raise APIException("SSO error %s %s" % (ssoResp.status_code, ssoResp.text))

ticket_match = re.search("ticket=([^']+)'", ssoResp.text)
if not ticket_match:
raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))
ticket = ticket_match.groups(1)[0]

# ...AND WE'RE NOT DONE YET!

gcRedeemResp1 = requests.get("https://connect.garmin.com/post-auth/login", params={"ticket": ticket}, allow_redirects=False, cookies=gcPreResp.cookies)
if gcRedeemResp1.status_code != 302:
raise APIException("GC redeem 1 error %s %s" % (gcRedeemResp1.status_code, gcRedeemResp1.text))

gcRedeemResp2 = requests.get(gcRedeemResp1.headers["location"], cookies=gcPreResp.cookies, allow_redirects=False)
if gcRedeemResp2.status_code != 302:
raise APIException("GC redeem 2 error %s %s" % (gcRedeemResp2.status_code, gcRedeemResp2.text))

else:
raise APIException("Unknown GC prestart response %s %s" % (gcPreResp.status_code, gcPreResp.text))

self._sessionCache.Set(record.ExternalID if record else email, gcPreResp.cookies)

return gcPreResp.cookies

self._sessionCache.Set(record.ExternalID if record else email, session.cookies)

return session

def print_cookies(self, cookies):
print "Cookies"

for key, value in cookies.items():
print "Key: " + key + ", " + value

def login(self, username, password):

cookies = self._get_cookies(email=username, password=password)
GCusername = requests.get("https://connect.garmin.com/user/username", cookies=cookies).json()["username"]
session = self._get_session(email=username, password=password)
res = session.get("https://connect.garmin.com/user/username")
GCusername = res.json()["username"]

sys.stderr.write('Garmin Connect User Name: ' + GCusername + '\n')

if not len(GCusername):
raise APIException("Unable to retrieve username", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))
return (cookies)
return (session)

def upload_file(self, f, cookie):
self.opener = self.create_opener(cookie)


# accept file object or string
if isinstance(f, file):
f.seek(0)
fbody = f.read()
else:
fbody = f

boundary = '----withingsgarmin'
req = urllib2.Request(self.UPLOAD_URL)
req.add_header('Content-Type', 'multipart/form-data; boundary=%s' % boundary)

# file
lines = []
lines.append('--%s' % boundary)
lines.append('Content-Disposition: form-data; name="data"; filename="weight.fit"')
lines.append('Content-Type: application/octet-stream')
lines.append('')
lines.append(fbody)

lines.append('--%s--' % boundary)
lines.append('')
r = self.opener.open(req, '\r\n'.join(lines))
return r.code == 200
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:
raise APIException("Bad response during GC upload: %s %s" % (res.status_code, res.text))

return (res.status_code == 200 or res.status_code == 201)

7 changes: 4 additions & 3 deletions sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
GARMIN_USERNAME = ''
GARMIN_PASSWORD = ''


class DateOption(Option):
def check_date(option, opt, value):
valid_formats = ['%Y-%m-%d', '%Y%m%d', '%Y/%m/%d']
Expand Down Expand Up @@ -112,11 +111,13 @@ def verbose_print(s):
sys.stdout.write(fit.getvalue())
return

verbose_print("Fit file: " + fit.getvalue())

# garmin connect
garmin = GarminConnect()
cookie = garmin.login(garmin_username, garmin_password)
session = garmin.login(garmin_username, garmin_password)
verbose_print('attempting to upload fit file...\n')
r = garmin.upload_file(fit.getvalue(), cookie)
r = garmin.upload_file(fit.getvalue(), session)
if r:
verbose_print('weight.fit has been successfully uploaded!\n')

Expand Down

0 comments on commit e585831

Please sign in to comment.