class NikePlusService(ServiceBase): ID = "nikeplus" DisplayName = "Nike+" DisplayAbbreviation = "N+" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True ReceivesStationaryActivities = False # No manual entry afaik _activityMappings = { "RUN": ActivityType.Running, "JOGGING": ActivityType.Running, "WALK": ActivityType.Walking, "CYCLE": ActivityType.Cycling, "STATIONARY_BIKING": ActivityType.Cycling, "MOUNTAIN_BIKING": ActivityType.MountainBiking, "CROSS_COUNTRY": ActivityType.CrossCountrySkiing, # Well, I think? "ELLIPTICAL": ActivityType.Elliptical, "HIKING": ActivityType.Hiking, "ROCK_CLIMBING": ActivityType.Climbing, "ICE_CLIMBING": ActivityType.Climbing, "SNOWBOARDING": ActivityType.Snowboarding, "SKIING": ActivityType.DownhillSkiing, "ICE_SKATING": ActivityType.Skating, "OTHER": ActivityType.Other } # Leave it to Nike+ to invent new timezones _timezones = { "ART": "America/Argentina/Buenos_Aires" # Close enough } _reverseActivityMappings = { "RUN": ActivityType.Running, # Their web frontend has a meltdown even trying to navigate to other activity types, who knows # So I won't exacerbate the problem... # "WALK": ActivityType.Walking, # "CYCLE": ActivityType.Cycling, # "MOUNTAIN_BIKING": ActivityType.MountainBiking, # "CROSS_COUNTRY": ActivityType.CrossCountrySkiing, # "ELLIPTICAL": ActivityType.Elliptical, # "HIKING": ActivityType.Hiking, # "ROCK_CLIMBING": ActivityType.Climbing, # "SNOWBOARDING": ActivityType.Snowboarding, # "SKIING": ActivityType.DownhillSkiing, # "ICE_SKATING": ActivityType.Skating, # "OTHER": ActivityType.Other } SupportedActivities = list(_reverseActivityMappings.values()) _sessionCache = SessionCache(lifetime=timedelta(minutes=45), freshen_on_get=False) _obligatoryHeaders = { "User-Agent": "NPConnect", "appId": NIKEPLUS_CLIENT_NAME } _obligatoryCookies = { "app": NIKEPLUS_CLIENT_NAME, "client_id": NIKEPLUS_CLIENT_ID, "client_secret": NIKEPLUS_CLIENT_SECRET } def _get_session(self, record=None, email=None, password=None, skip_cache=False): from tapiriik.auth.credential_storage import CredentialStore cached = self._sessionCache.Get(record.ExternalID if record else email) if cached and not skip_cache: return cached if record: password = CredentialStore.Decrypt( record.ExtendedAuthorization["Password"]) email = CredentialStore.Decrypt( record.ExtendedAuthorization["Email"]) # This is the most pleasent login flow I've dealt with in a long time session = requests.Session() session.headers.update(self._obligatoryHeaders) session.cookies.update(self._obligatoryCookies) res = session.post("https://api.nike.com/nsl/user/login", params={ "format": "json", "app": "app", "client_id": NIKEPLUS_CLIENT_ID, "client_secret": NIKEPLUS_CLIENT_SECRET }, data={ "email": email, "password": password }, headers={"Accept": "application/json"}) if res.status_code >= 500 and res.status_code < 600: raise APIException("Login exception %s - %s" % (res.status_code, res.text)) res_obj = res.json() if "access_token" not in res_obj: raise APIException("Invalid login %s - %s / %s" % (res.status_code, res.text, res.cookies), block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) # Was getting a super obscure error from the nether regions of requestse about duplicate cookies # So, store this in an easier-to-find location session.access_token = res_obj["access_token"] self._sessionCache.Set(record.ExternalID if record else email, session) return session def _with_auth(self, session, params={}): # For whatever reason the access_token needs to be a GET parameter :( params.update({ "access_token": session.access_token, "app": NIKEPLUS_CLIENT_NAME }) return params def WebInit(self): self.UserAuthorizationURL = WEB_ROOT + reverse( "auth_simple", kwargs={"service": self.ID}) def Authorize(self, email, password): from tapiriik.auth.credential_storage import CredentialStore session = self._get_session(email=email, password=password) user_data = session.get("https://api.nike.com/nsl/user/get", params=self._with_auth(session, {"format": "json"})) user_id = int( user_data.json()["serviceResponse"]["body"]["User"]["id"]) return (user_id, {}, { "Email": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password) }) def _durationToTimespan(self, duration): # Hours:Minutes:Seconds.Milliseconds duration = [float(x) for x in duration.split(":")] return timedelta(seconds=duration[2], minutes=duration[1], hours=duration[0]) def DownloadActivityList(self, serviceRecord, exhaustive=False): session = self._get_session(serviceRecord) list_params = self._with_auth(session, {"count": 20, "offset": 1}) activities = [] exclusions = [] while True: list_resp = session.get("https://api.nike.com/me/sport/activities", params=list_params) list_resp = list_resp.json() for act in list_resp["data"]: activity = UploadedActivity() activity.ServiceData = {"ID": act["activityId"]} if act["status"] != "COMPLETE": exclusions.append( APIExcludeActivity( "Not complete", activity_id=act["activityId"], permanent=False, user_exception=UserException( UserExceptionType.LiveTracking))) continue activity.StartTime = dateutil.parser.parse( act["startTime"]).replace(tzinfo=pytz.utc) activity.EndTime = activity.StartTime + self._durationToTimespan( act["metricSummary"]["duration"]) tz_name = act["activityTimeZone"] # They say these are all IANA standard names - they aren't if tz_name in self._timezones: tz_name = self._timezones[tz_name] activity.TZ = pytz.timezone(tz_name) if act["activityType"] in self._activityMappings: activity.Type = self._activityMappings[act["activityType"]] activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Kilometers, value=float(act["metricSummary"]["distance"])) activity.Stats.Strides = ActivityStatistic( ActivityStatisticUnit.Strides, value=int(act["metricSummary"]["steps"])) activity.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilocalories, value=float(act["metricSummary"]["calories"])) activity.CalculateUID() activities.append(activity) if len(list_resp["data"]) == 0 or not exhaustive: break list_params["offset"] += list_params["count"] return activities, exclusions def _nikeStream(self, stream, values_collection="values"): interval_secs = {"SEC": 1, "MIN": 60} if stream["intervalUnit"] not in interval_secs: # Who knows if they ever return it in a different unit? Their docs don't give a list raise Exception("Unknown stream interval unit %s" % stream["intervalUnit"]) interval = stream["intervalMetric"] * interval_secs[ stream["intervalUnit"]] for x in range(len(stream[values_collection])): yield (interval * x, stream[values_collection][x]) def DownloadActivity(self, serviceRecord, activity): session = self._get_session(serviceRecord) act_id = activity.ServiceData["ID"] activityDetails = session.get( "https://api.nike.com/me/sport/activities/%s" % act_id, params=self._with_auth(session)) activityDetails = activityDetails.json() streams = { metric["metricType"].lower(): self._nikeStream(metric) for metric in activityDetails["metrics"] } activity.GPS = activityDetails["isGpsActivity"] if activity.GPS: activityGps = session.get( "https://api.nike.com/me/sport/activities/%s/gps" % act_id, params=self._with_auth(session)) activityGps = activityGps.json() streams["gps"] = self._nikeStream(activityGps, "waypoints") activity.Stats.Elevation.update( ActivityStatistic(ActivityStatisticUnit.Meters, gain=float(activityGps["elevationGain"]), loss=float(activityGps["elevationLoss"]), max=float(activityGps["elevationMax"]), min=float(activityGps["elevationMin"]))) lap = Lap(startTime=activity.StartTime, endTime=activity.EndTime) lap.Stats = activity.Stats activity.Laps = [lap] # I thought I wrote StreamSampler to be generator-friendly - nope. streams = {k: list(v) for k, v in streams.items()} # The docs are unclear on which of these are actually stream metrics, oh well def stream_waypoint(offset, speed=None, distance=None, heartrate=None, calories=None, steps=None, watts=None, gps=None, **kwargs): wp = Waypoint() wp.Timestamp = activity.StartTime + timedelta(seconds=offset) wp.Speed = float(speed) if speed else None wp.Distance = float(distance) / 1000 if distance else None wp.HR = float(heartrate) if heartrate else None wp.Calories = float(calories) if calories else None wp.Power = float(watts) if watts else None if gps: wp.Location = Location(lat=float(gps["latitude"]), lon=float(gps["longitude"]), alt=float(gps["elevation"])) lap.Waypoints.append(wp) StreamSampler.SampleWithCallback(stream_waypoint, streams) activity.Stationary = len(lap.Waypoints) == 0 return activity def UploadActivity(self, serviceRecord, activity): metrics = { "data": [], "metricTypes": [], "intervalUnit": "SEC", "intervalValue": 10 if activity.Type == ActivityType.Running else 5 # What a joke. } act = [{ "deviceName": "tapiriik", "deviceType": "BIKE" if activity.Type == ActivityType.Cycling else "WATCH", # ??? nike+ is weird "startTime": calendar.timegm( activity.StartTime.astimezone(pytz.utc).timetuple()) * 1000, "timeZoneName": str(activity.TZ), "activityType": [ k for k, v in self._reverseActivityMappings.items() if v == activity.Type ][0], "metrics": metrics }] wps = activity.GetFlatWaypoints() wpidx = 0 full_metrics = [] max_metrics = set() while True: wp = wps[wpidx] my_metrics = {} if wp.Location and wp.Location.Latitude is not None and wp.Location.Longitude is not None: elev = wp.Location.Altitude if wp.Location.Altitude else 0 # They always require this field, it's meh my_metrics.update({ "latitude": wp.Location.Latitude, "longitude": wp.Location.Longitude, "elevation": elev }) if wp.Distance is not None: my_metrics["distance"] = wp.Distance / 1000 # m -> km if wp.HR is not None: my_metrics["heartrate"] = round(wp.HR) if wp.Speed is not None: my_metrics["speed"] = wp.Speed if wp.Calories is not None: my_metrics["calories"] = round(wp.Calories) if wp.Power is not None: my_metrics["watts"] = round(wp.Power) max_metrics |= my_metrics.keys() full_metrics.append(my_metrics) # Skip to next wp skip_delta = 0 while (wpidx + skip_delta < len(wps) - 1) and ( wps[wpidx + skip_delta].Timestamp - wps[wpidx].Timestamp ).total_seconds() < metrics["intervalValue"]: skip_delta += 1 if skip_delta == 0: break # We're done wpidx += skip_delta if wpidx == 0 and len(wps) > 0: raise Exception("Activity had waypoints, none were used") max_metrics = sorted(list(max_metrics)) metrics["metricTypes"] = max_metrics # Passing null metric values makes Nike+ sad # So we hold the last value until a new one is available frame_hold = {x: 0 for x in max_metrics} # Blegh, close enough for metric_frame in full_metrics: frame_hold.update(metric_frame) metrics["data"].append([frame_hold[x] for x in max_metrics]) headers = {"Content-Type": "application/json"} session = self._get_session(serviceRecord) upload_resp = session.post("https://api.nike.com/me/sport/activities", params=self._with_auth(session), data=json.dumps(act), headers=headers) if upload_resp.status_code != 201: error_codes = [x["code"] for x in upload_resp.json()["errors"]] if 320 in error_codes: # Invalid combination of metric types and blah blah blah raise APIException("Not enough data, have keys %s" % max_metrics, user_exception=UserException( UserExceptionType.InsufficientData)) raise APIException("Could not upload activity %s - %s" % (upload_resp.status_code, upload_resp.text)) return upload_resp.json()[0]["activityId"] def RevokeAuthorization(self, serviceRecord): # nothing to do here... pass def DeleteCachedData(self, serviceRecord): # nothing cached... pass def DeleteActivity(self, serviceRecord, uploadId): session = self._get_session(serviceRecord) del_res = session.delete( "https://api.nike.com/v1/me/sport/activities/%d" % uploadId) del_res.raise_for_status()
class GarminConnectService(ServiceBase): ID = "garminconnect" DisplayName = "Garmin Connect" DisplayAbbreviation = "GC" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True PartialSyncRequiresTrigger = len(GARMIN_CONNECT_USER_WATCH_ACCOUNTS) > 0 PartialSyncTriggerPollInterval = timedelta(minutes=20) PartialSyncTriggerPollMultiple = len( GARMIN_CONNECT_USER_WATCH_ACCOUNTS.keys()) ConfigurationDefaults = {"WatchUserKey": None, "WatchUserLastID": 0} _activityMappings = { "running": ActivityType.Running, "cycling": ActivityType.Cycling, "mountain_biking": ActivityType.MountainBiking, "walking": ActivityType.Walking, "hiking": ActivityType.Hiking, "resort_skiing_snowboarding": ActivityType.DownhillSkiing, "cross_country_skiing": ActivityType.CrossCountrySkiing, "skate_skiing": ActivityType.CrossCountrySkiing, # Well, it ain't downhill? "backcountry_skiing_snowboarding": ActivityType.CrossCountrySkiing, # ish "skating": ActivityType.Skating, "swimming": ActivityType.Swimming, "rowing": ActivityType.Rowing, "elliptical": ActivityType.Elliptical, "fitness_equipment": ActivityType.Gym, "rock_climbing": ActivityType.Climbing, "mountaineering": ActivityType.Climbing, "all": ActivityType.Other, # everything will eventually resolve to this "multi_sport": ActivityType.Other # Most useless type? You decide! } _reverseActivityMappings = { # Removes ambiguities when mapping back to their activity types "running": ActivityType.Running, "cycling": ActivityType.Cycling, "mountain_biking": ActivityType.MountainBiking, "walking": ActivityType.Walking, "hiking": ActivityType.Hiking, "resort_skiing_snowboarding": ActivityType.DownhillSkiing, "cross_country_skiing": ActivityType.CrossCountrySkiing, "skating": ActivityType.Skating, "swimming": ActivityType.Swimming, "rowing": ActivityType.Rowing, "elliptical": ActivityType.Elliptical, "fitness_equipment": ActivityType.Gym, "rock_climbing": ActivityType.Climbing, "other": ActivityType.Other # I guess? (vs. "all" that is) } SupportedActivities = list(_activityMappings.values()) SupportsHR = SupportsCadence = True SupportsActivityDeletion = True _sessionCache = SessionCache("garminconnect", lifetime=timedelta(minutes=120), freshen_on_get=True) _reauthAttempts = 1 # per request _unitMap = { "mph": ActivityStatisticUnit.MilesPerHour, "kph": ActivityStatisticUnit.KilometersPerHour, "hmph": ActivityStatisticUnit.HectometersPerHour, "hydph": ActivityStatisticUnit.HundredYardsPerHour, "celcius": ActivityStatisticUnit.DegreesCelcius, "fahrenheit": ActivityStatisticUnit.DegreesFahrenheit, "mile": ActivityStatisticUnit.Miles, "kilometer": ActivityStatisticUnit.Kilometers, "foot": ActivityStatisticUnit.Feet, "meter": ActivityStatisticUnit.Meters, "yard": ActivityStatisticUnit.Yards, "kilocalorie": ActivityStatisticUnit.Kilocalories, "bpm": ActivityStatisticUnit.BeatsPerMinute, "stepsPerMinute": ActivityStatisticUnit.DoubledStepsPerMinute, "rpm": ActivityStatisticUnit.RevolutionsPerMinute, "watt": ActivityStatisticUnit.Watts, "second": ActivityStatisticUnit.Seconds, "ms": ActivityStatisticUnit.Milliseconds } _obligatory_headers = {"Referer": "https://sync.tapiriik.com"} def __init__(self): cachedHierarchy = cachedb.gc_type_hierarchy.find_one() if not cachedHierarchy: rawHierarchy = requests.get( "https://connect.garmin.com/proxy/activity-service-1.2/json/activity_types", headers=self._obligatory_headers).text self._activityHierarchy = json.loads(rawHierarchy)["dictionary"] cachedb.gc_type_hierarchy.insert({"Hierarchy": rawHierarchy}) else: self._activityHierarchy = json.loads( cachedHierarchy["Hierarchy"])["dictionary"] rate_lock_path = tempfile.gettempdir( ) + "/gc_rate.%s.lock" % HTTP_SOURCE_ADDR # Ensure the rate lock file exists (...the easy way) open(rate_lock_path, "a").close() self._rate_lock = open(rate_lock_path, "r+") def _rate_limit(self): import fcntl, struct, time min_period = 1 # I appear to been banned from Garmin Connect while determining this. fcntl.flock(self._rate_lock, fcntl.LOCK_EX) try: self._rate_lock.seek(0) last_req_start = self._rate_lock.read() if not last_req_start: last_req_start = 0 else: last_req_start = float(last_req_start) wait_time = max(0, min_period - (time.time() - last_req_start)) time.sleep(wait_time) self._rate_lock.seek(0) self._rate_lock.write(str(time.time())) self._rate_lock.flush() finally: fcntl.flock(self._rate_lock, fcntl.LOCK_UN) def _request_with_reauth(self, serviceRecord, req_lambda): for i in range(self._reauthAttempts + 1): session = self._get_session(record=serviceRecord, skip_cache=i > 0) self._rate_limit() result = req_lambda(session) if result.status_code not in (403, 500): return result # Pass the failed response back any ways - another handler will catch it and provide a nicer error return result def _get_session(self, record=None, email=None, password=None, skip_cache=False): from tapiriik.auth.credential_storage import CredentialStore cached = self._sessionCache.Get(record.ExternalID if record else email) if cached and not skip_cache: logger.debug("Using cached credential") return cached if record: # longing for C style overloads... password = CredentialStore.Decrypt( record.ExtendedAuthorization["Password"]) email = CredentialStore.Decrypt( record.ExtendedAuthorization["Email"]) 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] 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 "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] # ...AND WE'RE NOT DONE YET! self._rate_limit() 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)) # There are 6 redirects that need to be followed to get the correct cookie # ... :( expected_redirect_count = 6 current_redirect_count = 1 while True: self._rate_limit() gcRedeemResp = session.get(gcRedeemResp.headers["location"], allow_redirects=False) if current_redirect_count >= expected_redirect_count and gcRedeemResp.status_code != 200: raise APIException( "GC redeem %d/%d error %s %s" % (current_redirect_count, expected_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 > expected_redirect_count: break self._sessionCache.Set(record.ExternalID if record else email, session) session.headers.update(self._obligatory_headers) return session def WebInit(self): self.UserAuthorizationURL = WEB_ROOT + reverse( "auth_simple", kwargs={"service": self.ID}) def Authorize(self, email, password): from tapiriik.auth.credential_storage import CredentialStore session = self._get_session(email=email, password=password, skip_cache=True) # TODO: http://connect.garmin.com/proxy/userprofile-service/socialProfile/ has the proper immutable user ID, not that anyone ever changes this one... self._rate_limit() username = session.get( "http://connect.garmin.com/user/username").json()["username"] if not len(username): raise APIException("Unable to retrieve username", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) return (username, {}, { "Email": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password) }) def UserUploadedActivityURL(self, uploadId): return "https://connect.garmin.com/modern/activity/%d" % uploadId def _resolveActivityType(self, act_type): # Mostly there are two levels of a hierarchy, so we don't really need this as the parent is included in the listing. # But maybe they'll change that some day? while act_type not in self._activityMappings: try: act_type = [ x["parent"]["key"] for x in self._activityHierarchy if x["key"] == act_type ][0] except IndexError: raise ValueError( "Activity type not found in activity hierarchy") return self._activityMappings[act_type] def DownloadActivityList(self, serviceRecord, exhaustive=False): #http://connect.garmin.com/proxy/activity-search-service-1.0/json/activities?&start=0&limit=50 page = 1 pageSz = 100 activities = [] exclusions = [] while True: logger.debug("Req with " + str({ "start": (page - 1) * pageSz, "limit": pageSz })) res = self._request_with_reauth( serviceRecord, lambda session: session.get( "https://connect.garmin.com/modern/proxy/activity-search-service-1.0/json/activities", params={ "start": (page - 1) * pageSz, "limit": pageSz })) try: res = res.json()["results"] except ValueError: res_txt = res.text # So it can capture in the log message raise APIException("Parse failure in GC list resp: %s - %s" % (res.status_code, res.text)) if "activities" not in res: break # No activities on this page - empty account. for act in res["activities"]: act = act["activity"] activity = UploadedActivity() # Don't really know why sumSampleCountTimestamp doesn't appear in swim activities - they're definitely timestamped... activity.Stationary = "sumSampleCountSpeed" not in act and "sumSampleCountTimestamp" not in act activity.GPS = "endLatitude" in act activity.Private = act["privacy"]["key"] == "private" try: activity.TZ = pytz.timezone(act["activityTimeZone"]["key"]) except pytz.exceptions.UnknownTimeZoneError: activity.TZ = pytz.FixedOffset( float(act["activityTimeZone"]["offset"]) * 60) logger.debug("Name " + act["activityName"]["value"] + ":") if len(act["activityName"]["value"].strip( )) and act["activityName"][ "value"] != "Untitled": # This doesn't work for internationalized accounts, oh well. activity.Name = act["activityName"]["value"] if len(act["activityDescription"]["value"].strip()): activity.Notes = act["activityDescription"]["value"] # beginTimestamp/endTimestamp is in UTC activity.StartTime = pytz.utc.localize( datetime.utcfromtimestamp( float(act["beginTimestamp"]["millis"]) / 1000)) if "sumElapsedDuration" in act: activity.EndTime = activity.StartTime + timedelta( 0, round(float(act["sumElapsedDuration"]["value"]))) elif "sumDuration" in act: activity.EndTime = activity.StartTime + timedelta( minutes=float(act["sumDuration"] ["minutesSeconds"].split(":")[0]), seconds=float(act["sumDuration"] ["minutesSeconds"].split(":")[1])) else: activity.EndTime = pytz.utc.localize( datetime.utcfromtimestamp( float(act["endTimestamp"]["millis"]) / 1000)) logger.debug("Activity s/t " + str(activity.StartTime) + " on page " + str(page)) activity.AdjustTZ() if "sumDistance" in act and float( act["sumDistance"]["value"]) != 0: activity.Stats.Distance = ActivityStatistic( self._unitMap[act["sumDistance"]["uom"]], value=float(act["sumDistance"]["value"])) if "device" in act and act["device"]["key"] != "unknown": devId = DeviceIdentifier.FindMatchingIdentifierOfType( DeviceIdentifierType.GC, {"Key": act["device"]["key"]}) ver_split = act["device"]["key"].split(".") ver_maj = None ver_min = None if len(ver_split) == 4: # 2.90.0.0 ver_maj = int(ver_split[0]) ver_min = int(ver_split[1]) activity.Device = Device(devId, verMaj=ver_maj, verMin=ver_min) activity.Type = self._resolveActivityType( act["activityType"]["key"]) activity.CalculateUID() activity.ServiceData = {"ActivityID": int(act["activityId"])} activities.append(activity) logger.debug("Finished page " + str(page) + " of " + str(res["search"]["totalPages"])) if not exhaustive or int(res["search"]["totalPages"]) == page: break else: page += 1 return activities, exclusions def _downloadActivitySummary(self, serviceRecord, activity): activityID = activity.ServiceData["ActivityID"] res = self._request_with_reauth( serviceRecord, lambda session: session. get("https://connect.garmin.com/modern/proxy/activity-service-1.3/json/activity/" + str(activityID))) try: raw_data = res.json() except ValueError: raise APIException("Failure downloading activity summary %s:%s" % (res.status_code, res.text)) stat_map = {} def mapStat(gcKey, statKey, type): stat_map[gcKey] = {"key": statKey, "attr": type} def applyStats(gc_dict, stats_obj): for gc_key, stat in stat_map.items(): if gc_key in gc_dict: value = float(gc_dict[gc_key]["value"]) units = self._unitMap[gc_dict[gc_key]["uom"]] if math.isinf(value): continue # GC returns the minimum speed as "-Infinity" instead of 0 some times :S getattr(stats_obj, stat["key"]).update( ActivityStatistic(units, **({ stat["attr"]: value }))) mapStat("SumMovingDuration", "MovingTime", "value") mapStat("SumDuration", "TimerTime", "value") mapStat("SumDistance", "Distance", "value") mapStat("MinSpeed", "Speed", "min") mapStat("MaxSpeed", "Speed", "max") mapStat("WeightedMeanSpeed", "Speed", "avg") mapStat("MinAirTemperature", "Temperature", "min") mapStat("MaxAirTemperature", "Temperature", "max") mapStat("WeightedMeanAirTemperature", "Temperature", "avg") mapStat("SumEnergy", "Energy", "value") mapStat("MaxHeartRate", "HR", "max") mapStat("WeightedMeanHeartRate", "HR", "avg") mapStat("MaxDoubleCadence", "RunCadence", "max") mapStat("WeightedMeanDoubleCadence", "RunCadence", "avg") mapStat("MaxBikeCadence", "Cadence", "max") mapStat("WeightedMeanBikeCadence", "Cadence", "avg") mapStat("MinPower", "Power", "min") mapStat("MaxPower", "Power", "max") mapStat("WeightedMeanPower", "Power", "avg") mapStat("MinElevation", "Elevation", "min") mapStat("MaxElevation", "Elevation", "max") mapStat("GainElevation", "Elevation", "gain") mapStat("LossElevation", "Elevation", "loss") applyStats(raw_data["activity"]["activitySummary"], activity.Stats) for lap_data in raw_data["activity"]["totalLaps"]["lapSummaryList"]: lap = Lap() if "BeginTimestamp" in lap_data: lap.StartTime = pytz.utc.localize( datetime.utcfromtimestamp( float(lap_data["BeginTimestamp"]["value"]) / 1000)) if "EndTimestamp" in lap_data: lap.EndTime = pytz.utc.localize( datetime.utcfromtimestamp( float(lap_data["EndTimestamp"]["value"]) / 1000)) elapsed_duration = None if "SumElapsedDuration" in lap_data: elapsed_duration = timedelta(seconds=round( float(lap_data["SumElapsedDuration"]["value"]))) elif "SumDuration" in lap_data: elapsed_duration = timedelta( seconds=round(float(lap_data["SumDuration"]["value"]))) if lap.StartTime and elapsed_duration: # Always recalculate end time based on duration, if we have the start time lap.EndTime = lap.StartTime + elapsed_duration if not lap.StartTime and lap.EndTime and elapsed_duration: # Sometimes calculate start time based on duration lap.StartTime = lap.EndTime - elapsed_duration if not lap.StartTime or not lap.EndTime: # Garmin Connect is weird. raise APIExcludeActivity( "Activity lap has no BeginTimestamp or EndTimestamp", user_exception=UserException(UserExceptionType.Corrupt)) applyStats(lap_data, lap.Stats) activity.Laps.append(lap) # In Garmin Land, max can be smaller than min for this field :S if activity.Stats.Power.Max is not None and activity.Stats.Power.Min is not None and activity.Stats.Power.Min > activity.Stats.Power.Max: activity.Stats.Power.Min = None def DownloadActivity(self, serviceRecord, activity): # First, download the summary stats and lap stats self._downloadActivitySummary(serviceRecord, activity) if len(activity.Laps) == 1: activity.Stats = activity.Laps[ 0].Stats # They must be identical to pass the verification if activity.Stationary: # Nothing else to download return activity # https://connect.garmin.com/proxy/activity-service-1.3/json/activityDetails/#### activityID = activity.ServiceData["ActivityID"] res = self._request_with_reauth( serviceRecord, lambda session: session. get("https://connect.garmin.com/modern/proxy/activity-service-1.3/json/activityDetails/" + str(activityID) + "?maxSize=999999999")) try: raw_data = res.json( )["com.garmin.activity.details.json.ActivityDetails"] except ValueError: raise APIException("Activity data parse error for %s: %s" % (res.status_code, res.text)) if "measurements" not in raw_data: activity.Stationary = True # We were wrong, oh well return activity attrs_map = {} def _map_attr(gc_key, wp_key, units, in_location=False, is_timestamp=False): attrs_map[gc_key] = { "key": wp_key, "to_units": units, "in_location": in_location, # Blegh "is_timestamp": is_timestamp # See above } _map_attr("directSpeed", "Speed", ActivityStatisticUnit.MetersPerSecond) _map_attr("sumDistance", "Distance", ActivityStatisticUnit.Meters) _map_attr("directHeartRate", "HR", ActivityStatisticUnit.BeatsPerMinute) _map_attr("directBikeCadence", "Cadence", ActivityStatisticUnit.RevolutionsPerMinute) _map_attr("directDoubleCadence", "RunCadence", ActivityStatisticUnit.StepsPerMinute) # 2*x mystery solved _map_attr("directAirTemperature", "Temp", ActivityStatisticUnit.DegreesCelcius) _map_attr("directPower", "Power", ActivityStatisticUnit.Watts) _map_attr("directElevation", "Altitude", ActivityStatisticUnit.Meters, in_location=True) _map_attr("directLatitude", "Latitude", None, in_location=True) _map_attr("directLongitude", "Longitude", None, in_location=True) _map_attr("directTimestamp", "Timestamp", None, is_timestamp=True) # Figure out which metrics we'll be seeing in this activity attrs_indexed = {} for measurement in raw_data["measurements"]: key = measurement["key"] if key in attrs_map: if attrs_map[key]["to_units"]: attrs_map[key]["from_units"] = self._unitMap[ measurement["unit"]] if attrs_map[key]["to_units"] == attrs_map[key][ "from_units"]: attrs_map[key]["to_units"] = attrs_map[key][ "from_units"] = None attrs_indexed[measurement["metricsIndex"]] = attrs_map[key] # Process the data frames frame_idx = 0 active_lap_idx = 0 for frame in raw_data["metrics"]: wp = Waypoint() for idx, attr in attrs_indexed.items(): value = frame["metrics"][idx] target_obj = wp if attr["in_location"]: if not wp.Location: wp.Location = Location() target_obj = wp.Location # Handle units if attr["is_timestamp"]: value = pytz.utc.localize( datetime.utcfromtimestamp(value / 1000)) elif attr["to_units"]: value = ActivityStatistic.convertValue( value, attr["from_units"], attr["to_units"]) # Write the value (can't use __dict__ because __slots__) setattr(target_obj, attr["key"], value) # Fix up lat/lng being zero (which appear to represent missing coords) if wp.Location and wp.Location.Latitude == 0 and wp.Location.Longitude == 0: wp.Location.Latitude = None wp.Location.Longitude = None # Please visit a physician before complaining about this if wp.HR == 0: wp.HR = None # Bump the active lap if required while (active_lap_idx < len(activity.Laps) - 1 and # Not the last lap activity.Laps[active_lap_idx + 1].StartTime <= wp.Timestamp ): active_lap_idx += 1 activity.Laps[active_lap_idx].Waypoints.append(wp) frame_idx += 1 return activity def UploadActivity(self, serviceRecord, activity): #/proxy/upload-service-1.1/json/upload/.fit fit_file = FITIO.Dump(activity) files = { "data": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit", fit_file) } res = self._request_with_reauth( serviceRecord, lambda session: session.post( "https://connect.garmin.com/proxy/upload-service-1.1/json/upload/.fit", files=files)) res = res.json()["detailedImportResult"] if len(res["successes"]) == 0: if len(res["failures"]) and len( res["failures"][0]["messages"]) and res["failures"][0][ "messages"][0]["content"] == "Duplicate activity": logger.debug("Duplicate") return # ...cool? raise APIException("Unable to upload activity %s" % res) if len(res["successes"]) > 1: raise APIException( "Uploaded succeeded, resulting in too many activities") actid = res["successes"][0]["internalId"] name = activity.Name # Capture in logs notes = activity.Notes # Update activity metadata not included in the FIT file. metadata_object = {} if activity.Name and activity.Name.strip(): metadata_object["activityName"] = activity.Name if activity.Notes and activity.Notes.strip(): metadata_object["description"] = activity.Notes if activity.Type not in [ ActivityType.Running, ActivityType.Cycling, ActivityType.Other ]: # Set the legit activity type - whatever it is, it's not supported by the FIT schema acttype = [ k for k, v in self._reverseActivityMappings.items() if v == activity.Type ] if len(acttype) == 0: raise APIWarning( "GarminConnect does not support activity type " + activity.Type) else: acttype = acttype[0] metadata_object["activityTypeDTO"] = {"typeKey": acttype} if activity.Private: metadata_object["accessControlRuleDTO"] = {"typeKey": "private"} if metadata_object: metadata_object["activityId"] = actid encoding_headers = { "Content-Type": "application/json; charset=UTF-8" } # GC really, really needs this part, otherwise it throws obscure errors like "Invalid signature for signature method HMAC-SHA1" res = self._request_with_reauth( serviceRecord, lambda session: session. put("https://connect.garmin.com/proxy/activity-service/activity/" + str(actid), data=json.dumps(metadata_object), headers=encoding_headers)) if res.status_code != 204: raise APIWarning("Unable to set activity metadata - %d %s" % (res.status_code, res.text)) return actid def _user_watch_user(self, serviceRecord): if not serviceRecord.GetConfiguration()["WatchUserKey"]: user_key = random.choice( list(GARMIN_CONNECT_USER_WATCH_ACCOUNTS.keys())) logger.info("Assigning %s a new watch user %s" % (serviceRecord.ExternalID, user_key)) serviceRecord.SetConfiguration({"WatchUserKey": user_key}) return GARMIN_CONNECT_USER_WATCH_ACCOUNTS[user_key] else: return GARMIN_CONNECT_USER_WATCH_ACCOUNTS[ serviceRecord.GetConfiguration()["WatchUserKey"]] def SubscribeToPartialSyncTrigger(self, serviceRecord): # PUT http://connect.garmin.com/proxy/userprofile-service/connection/request/cpfair # (the poll worker finishes the connection) user_name = self._user_watch_user(serviceRecord)["Name"] logger.info("Requesting connection to %s from %s" % (user_name, serviceRecord.ExternalID)) self._rate_limit() resp = self._get_session(record=serviceRecord, skip_cache=True).put( "https://connect.garmin.com/proxy/userprofile-service/connection/request/%s" % user_name) try: assert resp.status_code == 200 assert resp.json()["requestStatus"] == "Created" except: raise APIException( "Connection request failed with user watch account %s: %s %s" % (user_name, resp.status_code, resp.text)) else: serviceRecord.SetConfiguration( {"WatchConnectionID": resp.json()["id"]}) serviceRecord.SetPartialSyncTriggerSubscriptionState(True) def UnsubscribeFromPartialSyncTrigger(self, serviceRecord): # GET http://connect.garmin.com/proxy/userprofile-service/socialProfile/connections to get the ID # {"fullName":null,"userConnections":[{"userId":5754439,"displayName":"TapiirikAPITEST","fullName":null,"location":null,"profileImageUrlMedium":null,"profileImageUrlSmall":null,"connectionRequestId":1566024,"userConnectionStatus":2,"userRoles":["ROLE_CONNECTUSER","ROLE_FITNESS_USER"],"userPro":false}]} # PUT http://connect.garmin.com/proxy/userprofile-service/connection/end/1904201 # Unfortunately there's no way to delete a pending request - the poll worker will do this from the other end active_watch_user = self._user_watch_user(serviceRecord) session = self._get_session(email=active_watch_user["Username"], password=active_watch_user["Password"], skip_cache=True) if "WatchConnectionID" in serviceRecord.GetConfiguration(): self._rate_limit() dc_resp = session.put( "https://connect.garmin.com/modern/proxy/userprofile-service/connection/end/%s" % serviceRecord.GetConfiguration()["WatchConnectionID"]) if dc_resp.status_code != 200: raise APIException( "Error disconnecting user watch accunt %s from %s: %s %s" % (active_watch_user, serviceRecord.ExternalID, dc_resp.status_code, dc_resp.text)) serviceRecord.SetConfiguration({ "WatchUserKey": None, "WatchConnectionID": None }) serviceRecord.SetPartialSyncTriggerSubscriptionState(False) else: # I broke Garmin Connect by having too many connections per account, so I can no longer query the connection list # All the connection request emails are sitting unopened in an email inbox, though, so I'll be backfilling the IDs from those raise APIException("Did not store connection ID") def ShouldForcePartialSyncTrigger(self, serviceRecord): # The poll worker can't see private activities. return serviceRecord.GetConfiguration()["sync_private"] def PollPartialSyncTrigger(self, multiple_index): # TODO: ensure the appropriate users are connected # GET http://connect.garmin.com/modern/proxy/userprofile-service/connection/pending to get ID # [{"userId":6244126,"displayName":"tapiriik-sync-ulukhaktok","fullName":"tapiriik sync ulukhaktok","profileImageUrlSmall":null,"connectionRequestId":1904086,"requestViewed":true,"userRoles":["ROLE_CONNECTUSER"],"userPro":false}] # PUT http://connect.garmin.com/proxy/userprofile-service/connection/accept/1904086 # ...later... # GET http://connect.garmin.com/proxy/activitylist-service/activities/comments/subscriptionFeed?start=1&limit=10 # First, accept any pending connections watch_user_key = sorted(list( GARMIN_CONNECT_USER_WATCH_ACCOUNTS.keys()))[multiple_index] watch_user = GARMIN_CONNECT_USER_WATCH_ACCOUNTS[watch_user_key] session = self._get_session(email=watch_user["Username"], password=watch_user["Password"], skip_cache=True) # Then, check for users with new activities self._rate_limit() watch_activities_resp = session.get( "https://connect.garmin.com/modern/proxy/activitylist-service/activities/subscriptionFeed?limit=1000" ) try: watch_activities = watch_activities_resp.json() except ValueError: raise Exception("Could not parse new activities list: %s %s" % (watch_activities_resp.status_code, watch_activities_resp.text)) active_user_pairs = [(x["ownerDisplayName"], x["activityId"]) for x in watch_activities["activityList"]] active_user_pairs.sort( key=lambda x: x[1] ) # Highest IDs last (so they make it into the dict, supplanting lower IDs where appropriate) active_users = dict(active_user_pairs) active_user_recs = [ ServiceRecord(x) for x in db.connections.find( { "ExternalID": { "$in": list(active_users.keys()) }, "Service": "garminconnect" }, { "Config": 1, "ExternalID": 1, "Service": 1 }) ] if len(active_user_recs) != len(active_users.keys()): logger.warning("Mismatch %d records found for %d active users" % (len(active_user_recs), len(active_users.keys()))) to_sync_ids = [] for active_user_rec in active_user_recs: last_active_id = active_user_rec.GetConfiguration( )["WatchUserLastID"] this_active_id = active_users[active_user_rec.ExternalID] if this_active_id > last_active_id: to_sync_ids.append(active_user_rec.ExternalID) active_user_rec.SetConfiguration({ "WatchUserLastID": this_active_id, "WatchUserKey": watch_user_key }) self._rate_limit() pending_connections_resp = session.get( "https://connect.garmin.com/modern/proxy/userprofile-service/connection/pending" ) try: pending_connections = pending_connections_resp.json() except ValueError: logger.error("Could not parse pending connection requests: %s %s" % (pending_connections_resp.status_code, pending_connections_resp.text)) else: valid_pending_connections_external_ids = [ x["ExternalID"] for x in db.connections.find( { "Service": "garminconnect", "ExternalID": { "$in": [x["displayName"] for x in pending_connections] } }, {"ExternalID": 1}) ] logger.info( "Accepting %d, denying %d connection requests for %s" % (len(valid_pending_connections_external_ids), len(pending_connections) - len(valid_pending_connections_external_ids), watch_user_key)) for pending_connect in pending_connections: if pending_connect[ "displayName"] in valid_pending_connections_external_ids: self._rate_limit() connect_resp = session.put( "https://connect.garmin.com/modern/proxy/userprofile-service/connection/accept/%s" % pending_connect["connectionRequestId"]) if connect_resp.status_code != 200: logger.error( "Error accepting request on watch account %s: %s %s" % (watch_user["Name"], connect_resp.status_code, connect_resp.text)) else: self._rate_limit() session.put( "https://connect.garmin.com/modern/proxy/userprofile-service/connection/decline/%s" % pending_connect["connectionRequestId"]) return to_sync_ids def RevokeAuthorization(self, serviceRecord): # nothing to do here... pass def DeleteCachedData(self, serviceRecord): # nothing cached... pass def DeleteActivity(self, serviceRecord, uploadId): session = self._get_session(record=serviceRecord) self._rate_limit() del_res = session.delete( "https://connect.garmin.com/modern/proxy/activity-service/activity/%d" % uploadId) del_res.raise_for_status()
class RideWithGPSService(ServiceBase): ID = "rwgps" DisplayName = "Ride With GPS" DisplayAbbreviation = "RWG" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True # RWGPS does has a "recreation_types" list, but it is not actually used anywhere (yet) # (This is a subset of the things returned by that list for future reference...) _activityMappings = { "running": ActivityType.Running, "cycling": ActivityType.Cycling, "mountain biking": ActivityType.MountainBiking, "Hiking": ActivityType.Hiking, "all": ActivityType.Other # everything will eventually resolve to this } SupportedActivities = list(_activityMappings.values()) SupportsHR = SupportsCadence = True _sessionCache = SessionCache(lifetime=timedelta(minutes=30), freshen_on_get=True) def _add_auth_params(self, params=None, record=None): """ Adds apikey and authorization (email/password) to the passed-in params, returns modified params dict. """ from tapiriik.auth.credential_storage import CredentialStore if params is None: params = {} params['apikey'] = RWGPS_APIKEY if record: cached = self._sessionCache.Get(record.ExternalID) if cached: return cached password = CredentialStore.Decrypt( record.ExtendedAuthorization["Password"]) email = CredentialStore.Decrypt( record.ExtendedAuthorization["Email"]) params['email'] = email params['password'] = password return params def WebInit(self): self.UserAuthorizationURL = WEB_ROOT + reverse( "auth_simple", kwargs={"service": self.ID}) def Authorize(self, email, password): from tapiriik.auth.credential_storage import CredentialStore res = requests.get("https://ridewithgps.com/users/current.json", params={ 'email': email, 'password': password, 'apikey': RWGPS_APIKEY }) res.raise_for_status() res = res.json() if res["user"] is None: raise APIException("Invalid login", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) member_id = res["user"]["id"] if not member_id: raise APIException("Unable to retrieve id", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) return (member_id, {}, { "Email": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password) }) def _duration_to_seconds(self, s): """ Converts a duration in form HH:MM:SS to number of seconds for use in timedelta construction. """ hours, minutes, seconds = (["0", "0"] + s.split(":"))[-3:] hours = int(hours) minutes = int(minutes) seconds = float(seconds) total_seconds = int(hours + 60000 * minutes + 1000 * seconds) return total_seconds def DownloadActivityList(self, serviceRecord, exhaustive=False): # http://ridewithgps.com/users/1/trips.json?limit=200&order_by=created_at&order_dir=asc # offset also supported page = 1 pageSz = 50 activities = [] exclusions = [] while True: logger.debug("Req with " + str({ "start": (page - 1) * pageSz, "limit": pageSz })) # TODO: take advantage of their nice ETag support params = {"offset": (page - 1) * pageSz, "limit": pageSz} params = self._add_auth_params(params, record=serviceRecord) res = requests.get( "http://ridewithgps.com/users/{}/trips.json".format( serviceRecord.ExternalID), params=params) res = res.json() total_pages = math.ceil(int(res["results_count"]) / pageSz) for act in res["results"]: if "first_lat" not in act or "last_lat" not in act: exclusions.append( APIExcludeActivity("No points", activityId=act["activityId"], userException=UserException( UserExceptionType.Corrupt))) continue if "distance" not in act: exclusions.append( APIExcludeActivity("No distance", activityId=act["activityId"], userException=UserException( UserExceptionType.Corrupt))) continue activity = UploadedActivity() activity.TZ = pytz.timezone(act["time_zone"]) logger.debug("Name " + act["name"] + ":") if len(act["name"].strip()): activity.Name = act["name"] activity.StartTime = pytz.utc.localize( datetime.strptime(act["departed_at"], "%Y-%m-%dT%H:%M:%SZ")) activity.EndTime = activity.StartTime + timedelta( seconds=self._duration_to_seconds(act["duration"])) logger.debug("Activity s/t " + str(activity.StartTime) + " on page " + str(page)) activity.AdjustTZ() activity.Distance = float( act["distance"]) # This value is already in meters... # Activity type is not implemented yet in RWGPS results; we will assume cycling, though perhaps "OTHER" wouuld be correct activity.Type = ActivityType.Cycling activity.CalculateUID() activity.UploadedTo = [{ "Connection": serviceRecord, "ActivityID": act["id"] }] activities.append(activity) logger.debug("Finished page {} of {}".format(page, total_pages)) if not exhaustive or total_pages == page or total_pages == 0: break else: page += 1 return activities, exclusions def DownloadActivity(self, serviceRecord, activity): # https://ridewithgps.com/trips/??????.gpx activityID = [ x["ActivityID"] for x in activity.UploadedTo if x["Connection"] == serviceRecord ][0] res = requests.get( "https://ridewithgps.com/trips/{}.tcx".format(activityID), params=self._add_auth_params({'sub_format': 'history'}, record=serviceRecord)) try: TCXIO.Parse(res.content, activity) except ValueError as e: raise APIExcludeActivity("TCX parse error " + str(e), userException=UserException( UserExceptionType.Corrupt)) return activity def UploadActivity(self, serviceRecord, activity): # https://ridewithgps.com/trips.json tcx_file = TCXIO.Dump(activity) files = { "data_file": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".tcx", tcx_file) } params = {} params['trip[name]'] = activity.Name params[ 'trip[visibility]'] = 1 if activity.Private else 0 # Yes, this logic seems backwards but it's how it works res = requests.post("https://ridewithgps.com/trips.json", files=files, params=self._add_auth_params(params, record=serviceRecord)) if res.status_code % 100 == 4: raise APIException("Invalid login", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) res.raise_for_status() res = res.json() if res["success"] != 1: raise APIException("Unable to upload activity") def RevokeAuthorization(self, serviceRecord): # nothing to do here... pass def DeleteCachedData(self, serviceRecord): # nothing cached... pass
class SportTracksService(ServiceBase): ID = "sporttracks" DisplayName = "SportTracks" DisplayAbbreviation = "ST" AuthenticationType = ServiceAuthenticationType.OAuth OpenFitEndpoint = SPORTTRACKS_OPENFIT_ENDPOINT SupportsHR = True AuthenticationNoFrame = True """ Other Basketball Other Boxing Other Climbing Other Driving Other Flying Other Football Other Gardening Other Kitesurf Other Sailing Other Soccer Other Tennis Other Volleyball Other Windsurf Running Hashing Running Hills Running Intervals Running Orienteering Running Race Running Road Running Showshoe Running Speed Running Stair Running Track Running Trail Running Treadmill Cycling Hills Cycling Indoor Cycling Intervals Cycling Mountain Cycling Race Cycling Road Cycling Rollers Cycling Spinning Cycling Track Cycling Trainer Swimming Open Water Swimming Pool Swimming Race Walking Geocaching Walking Hiking Walking Nordic Walking Photography Walking Snowshoe Walking Treadmill Skiing Alpine Skiing Nordic Skiing Roller Skiing Snowboard Rowing Canoe Rowing Kayak Rowing Kitesurf Rowing Ocean Kayak Rowing Rafting Rowing Rowing Machine Rowing Sailing Rowing Standup Paddling Rowing Windsurf Skating Board Skating Ice Skating Inline Skating Race Skating Track Gym Aerobics Gym Elliptical Gym Plyometrics Gym Rowing Machine Gym Spinning Gym Stair Climber Gym Stationary Bike Gym Strength Gym Stretching Gym Treadmill Gym Yoga """ _activityMappings = { "running": ActivityType.Running, "cycling": ActivityType.Cycling, "mountain": ActivityType.MountainBiking, "walking": ActivityType.Walking, "hiking": ActivityType.Hiking, "snowboarding": ActivityType.Snowboarding, "skiing": ActivityType.DownhillSkiing, "nordic": ActivityType.CrossCountrySkiing, "skating": ActivityType.Skating, "swimming": ActivityType.Swimming, "rowing": ActivityType.Rowing, "elliptical": ActivityType.Elliptical, "gym": ActivityType.Gym, "other": ActivityType.Other } _reverseActivityMappings = { ActivityType.Running: "running", ActivityType.Cycling: "cycling", ActivityType.Walking: "walking", ActivityType.MountainBiking: "cycling: mountain", ActivityType.Hiking: "walking: hiking", ActivityType.CrossCountrySkiing: "skiing: nordic", # Equipment.Bindings.IsToeOnly ?? ActivityType.DownhillSkiing: "skiing", ActivityType.Snowboarding: "skiing: snowboarding", ActivityType.Skating: "skating", ActivityType.Swimming: "swimming", ActivityType.Rowing: "rowing", ActivityType.Elliptical: "gym: elliptical", ActivityType.Gym: "gym", ActivityType.Other: "other" } SupportedActivities = list(_reverseActivityMappings.keys()) _tokenCache = SessionCache("sporttracks", lifetime=timedelta(minutes=115), freshen_on_get=False) def WebInit(self): self.UserAuthorizationURL = "https://api.sporttracks.mobi/oauth2/authorize?response_type=code&client_id=%s&state=mobi_api" % SPORTTRACKS_CLIENT_ID def _getAuthHeaders(self, serviceRecord=None): token = self._tokenCache.Get(serviceRecord.ExternalID) if not token: if not serviceRecord.Authorization or "RefreshToken" not in serviceRecord.Authorization: # When I convert the existing users, people who didn't check the remember-credentials box will be stuck in limbo raise APIException("User not upgraded to OAuth", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) # Use refresh token to get access token # Hardcoded return URI to get around the lack of URL reversing without loading up all the Django stuff params = { "grant_type": "refresh_token", "refresh_token": serviceRecord.Authorization["RefreshToken"], "client_id": SPORTTRACKS_CLIENT_ID, "client_secret": SPORTTRACKS_CLIENT_SECRET, "redirect_uri": "https://tapiriik.com/auth/return/sporttracks" } response = requests.post( "https://api.sporttracks.mobi/oauth2/token", data=urllib.parse.urlencode(params), headers={"Content-Type": "application/x-www-form-urlencoded"}) if response.status_code != 200: if response.status_code >= 400 and response.status_code < 500: raise APIException( "Could not retrieve refreshed token %s %s" % (response.status_code, response.text), block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) raise APIException("Could not retrieve refreshed token %s %s" % (response.status_code, response.text)) token = response.json()["access_token"] self._tokenCache.Set(serviceRecord.ExternalID, token) return {"Authorization": "Bearer %s" % token} def RetrieveAuthorizationToken(self, req, level): from tapiriik.services import Service # might consider a real OAuth client code = req.GET.get("code") params = { "grant_type": "authorization_code", "code": code, "client_id": SPORTTRACKS_CLIENT_ID, "client_secret": SPORTTRACKS_CLIENT_SECRET, "redirect_uri": WEB_ROOT + reverse("oauth_return", kwargs={"service": "sporttracks"}) } response = requests.post( "https://api.sporttracks.mobi/oauth2/token", data=urllib.parse.urlencode(params), headers={"Content-Type": "application/x-www-form-urlencoded"}) if response.status_code != 200: print(response.text) raise APIException("Invalid code") access_token = response.json()["access_token"] refresh_token = response.json()["refresh_token"] uid_res = requests.post( "https://api.sporttracks.mobi/api/v2/system/connect", headers={"Authorization": "Bearer %s" % access_token}) uid = uid_res.json()["user"]["uid"] return (uid, {"RefreshToken": refresh_token}) def RevokeAuthorization(self, serviceRecord): pass # Can't revoke these tokens :( def DeleteCachedData(self, serviceRecord): cachedb.sporttracks_meta_cache.remove( {"ExternalID": serviceRecord.ExternalID}) def DownloadActivityList(self, serviceRecord, exhaustive=False): headers = self._getAuthHeaders(serviceRecord) activities = [] exclusions = [] pageUri = self.OpenFitEndpoint + "/fitnessActivities.json" activity_tz_cache_raw = cachedb.sporttracks_meta_cache.find_one( {"ExternalID": serviceRecord.ExternalID}) activity_tz_cache_raw = activity_tz_cache_raw if activity_tz_cache_raw else { "Activities": [] } activity_tz_cache = dict([(x["ActivityURI"], x["TZ"]) for x in activity_tz_cache_raw["Activities"] ]) while True: logger.debug("Req against " + pageUri) res = requests.get(pageUri, headers=headers) try: res = res.json() except ValueError: raise APIException( "Could not decode activity list response %s %s" % (res.status_code, res.text)) for act in res["items"]: activity = UploadedActivity() activity.ServiceData = {"ActivityURI": act["uri"]} if len(act["name"].strip()): activity.Name = act["name"] # Longstanding ST.mobi bug causes it to return negative partial-hour timezones as "-2:-30" instead of "-2:30" fixed_start_time = re.sub(r":-(\d\d)", r":\1", act["start_time"]) activity.StartTime = dateutil.parser.parse(fixed_start_time) if isinstance(activity.StartTime.tzinfo, tzutc): activity.TZ = pytz.utc # The dateutil tzutc doesn't have an _offset value. else: activity.TZ = pytz.FixedOffset( activity.StartTime.tzinfo.utcoffset( activity.StartTime).total_seconds() / 60 ) # Convert the dateutil lame timezones into pytz awesome timezones. activity.StartTime = activity.StartTime.replace( tzinfo=activity.TZ) activity.EndTime = activity.StartTime + timedelta( seconds=float(act["duration"])) activity.Stats.TimerTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=float(act["duration"] )) # OpenFit says this excludes paused times. # Sometimes activities get returned with a UTC timezone even when they are clearly not in UTC. if activity.TZ == pytz.utc: if act["uri"] in activity_tz_cache: activity.TZ = pytz.FixedOffset( activity_tz_cache[act["uri"]]) else: # So, we get the first location in the activity and calculate the TZ from that. try: firstLocation = self._downloadActivity( serviceRecord, activity, returnFirstLocation=True) except APIExcludeActivity: pass else: try: activity.CalculateTZ(firstLocation, recalculate=True) except: # We tried! pass else: activity.AdjustTZ() finally: activity_tz_cache[ act["uri"]] = activity.StartTime.utcoffset( ).total_seconds() / 60 logger.debug("Activity s/t " + str(activity.StartTime)) activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=float(act["total_distance"])) types = [x.strip().lower() for x in act["type"].split(":")] types.reverse( ) # The incoming format is like "walking: hiking" and we want the most specific first activity.Type = None for type_key in types: if type_key in self._activityMappings: activity.Type = self._activityMappings[type_key] break if not activity.Type: exclusions.append( APIExcludeActivity("Unknown activity type %s" % act["type"], activity_id=act["uri"], user_exception=UserException( UserExceptionType.Other))) continue activity.CalculateUID() activities.append(activity) if not exhaustive or "next" not in res or not len(res["next"]): break else: pageUri = res["next"] logger.debug("Writing back meta cache") cachedb.sporttracks_meta_cache.update( {"ExternalID": serviceRecord.ExternalID}, { "ExternalID": serviceRecord.ExternalID, "Activities": [{ "ActivityURI": k, "TZ": v } for k, v in activity_tz_cache.items()] }, upsert=True) return activities, exclusions def _downloadActivity(self, serviceRecord, activity, returnFirstLocation=False): activityURI = activity.ServiceData["ActivityURI"] headers = self._getAuthHeaders(serviceRecord) activityData = requests.get(activityURI, headers=headers) activityData = activityData.json() if "clock_duration" in activityData: activity.EndTime = activity.StartTime + timedelta( seconds=float(activityData["clock_duration"])) activity.Private = "sharing" in activityData and activityData[ "sharing"] != "public" activity.GPS = False # Gets set back if there is GPS data if "notes" in activityData: activity.Notes = activityData["notes"] activity.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilojoules, value=float(activityData["calories"])) activity.Stats.Elevation = ActivityStatistic( ActivityStatisticUnit.Meters, gain=float(activityData["elevation_gain"]) if "elevation_gain" in activityData else None, loss=float(activityData["elevation_loss"]) if "elevation_loss" in activityData else None) activity.Stats.HR = ActivityStatistic( ActivityStatisticUnit.BeatsPerMinute, avg=activityData["avg_heartrate"] if "avg_heartrate" in activityData else None, max=activityData["max_heartrate"] if "max_heartrate" in activityData else None) activity.Stats.Cadence = ActivityStatistic( ActivityStatisticUnit.RevolutionsPerMinute, avg=activityData["avg_cadence"] if "avg_cadence" in activityData else None, max=activityData["max_cadence"] if "max_cadence" in activityData else None) activity.Stats.Power = ActivityStatistic( ActivityStatisticUnit.Watts, avg=activityData["avg_power"] if "avg_power" in activityData else None, max=activityData["max_power"] if "max_power" in activityData else None) laps_info = [] laps_starts = [] if "laps" in activityData: laps_info = activityData["laps"] for lap in activityData["laps"]: laps_starts.append(dateutil.parser.parse(lap["start_time"])) lap = None for lapinfo in laps_info: lap = Lap() activity.Laps.append(lap) lap.StartTime = dateutil.parser.parse(lapinfo["start_time"]) lap.EndTime = lap.StartTime + timedelta( seconds=lapinfo["clock_duration"]) if "type" in lapinfo: lap.Intensity = LapIntensity.Active if lapinfo[ "type"] == "ACTIVE" else LapIntensity.Rest if "distance" in lapinfo: lap.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=float(lapinfo["distance"])) if "duration" in lapinfo: lap.Stats.TimerTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=lapinfo["duration"]) if "calories" in lapinfo: lap.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilojoules, value=lapinfo["calories"]) if "elevation_gain" in lapinfo: lap.Stats.Elevation.update( ActivityStatistic(ActivityStatisticUnit.Meters, gain=float(lapinfo["elevation_gain"]))) if "elevation_loss" in lapinfo: lap.Stats.Elevation.update( ActivityStatistic(ActivityStatisticUnit.Meters, loss=float(lapinfo["elevation_loss"]))) if "max_speed" in lapinfo: lap.Stats.Speed.update( ActivityStatistic(ActivityStatisticUnit.MetersPerSecond, max=float(lapinfo["max_speed"]))) if "max_speed" in lapinfo: lap.Stats.Speed.update( ActivityStatistic(ActivityStatisticUnit.MetersPerSecond, max=float(lapinfo["max_speed"]))) if "avg_speed" in lapinfo: lap.Stats.Speed.update( ActivityStatistic(ActivityStatisticUnit.MetersPerSecond, avg=float(lapinfo["avg_speed"]))) if "max_heartrate" in lapinfo: lap.Stats.HR.update( ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, max=float(lapinfo["max_heartrate"]))) if "avg_heartrate" in lapinfo: lap.Stats.HR.update( ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, avg=float(lapinfo["avg_heartrate"]))) if lap is None: # No explicit laps => make one that encompasses the entire activity lap = Lap() activity.Laps.append(lap) lap.Stats = activity.Stats lap.StartTime = activity.StartTime lap.EndTime = activity.EndTime elif len(activity.Laps) == 1: activity.Stats.update( activity.Laps[0].Stats ) # Lap stats have a bit more info generally. activity.Laps[0].Stats = activity.Stats timerStops = [] if "timer_stops" in activityData: for stop in activityData["timer_stops"]: timerStops.append([ dateutil.parser.parse(stop[0]), dateutil.parser.parse(stop[1]) ]) def isInTimerStop(timestamp): for stop in timerStops: if timestamp >= stop[0] and timestamp < stop[1]: return True if timestamp >= stop[1]: return False return False # Collate the individual streams into our waypoints. # Global sample rate is variable - will pick the next nearest stream datapoint. # Resampling happens on a lookbehind basis - new values will only appear their timestamp has been reached/passed wasInPause = False currentLapIdx = 0 lap = activity.Laps[currentLapIdx] streams = [] for stream in [ "location", "elevation", "heartrate", "power", "cadence", "distance" ]: if stream in activityData: streams.append(stream) stream_indices = dict([(stream, -1) for stream in streams ]) # -1 meaning the stream has yet to start stream_lengths = dict([(stream, len(activityData[stream]) / 2) for stream in streams]) # Data comes as "stream":[timestamp,value,timestamp,value,...] stream_values = {} for stream in streams: values = [] for x in range(0, int(len(activityData[stream]) / 2)): values.append((activityData[stream][x * 2], activityData[stream][x * 2 + 1])) stream_values[stream] = values currentOffset = 0 def streamVal(stream): nonlocal stream_values, stream_indices return stream_values[stream][stream_indices[stream]][1] def hasStreamData(stream): nonlocal stream_indices, streams return stream in streams and stream_indices[stream] >= 0 while True: advance_stream = None advance_offset = None for stream in streams: if stream_indices[stream] + 1 == stream_lengths[stream]: continue # We're at the end - can't advance if advance_offset is None or stream_values[stream][ stream_indices[stream] + 1][0] - currentOffset < advance_offset: advance_offset = stream_values[stream][ stream_indices[stream] + 1][0] - currentOffset advance_stream = stream if not advance_stream: break # We've hit the end of every stream, stop # Advance streams sharing the current timestamp for stream in streams: if stream == advance_stream: continue # For clarity, we increment this later if stream_indices[stream] + 1 == stream_lengths[stream]: continue # We're at the end - can't advance if stream_values[stream][ stream_indices[stream] + 1][0] == stream_values[advance_stream][ stream_indices[advance_stream] + 1][0]: stream_indices[stream] += 1 stream_indices[ advance_stream] += 1 # Advance the key stream for this waypoint currentOffset = stream_values[advance_stream][stream_indices[ advance_stream]][0] # Update the current time offset waypoint = Waypoint(activity.StartTime + timedelta(seconds=currentOffset)) if hasStreamData("location"): waypoint.Location = Location( streamVal("location")[0], streamVal("location")[1], None) activity.GPS = True if returnFirstLocation: return waypoint.Location if hasStreamData("elevation"): if not waypoint.Location: waypoint.Location = Location(None, None, None) waypoint.Location.Altitude = streamVal("elevation") if hasStreamData("heartrate"): waypoint.HR = streamVal("heartrate") if hasStreamData("power"): waypoint.Power = streamVal("power") if hasStreamData("cadence"): waypoint.Cadence = streamVal("cadence") if hasStreamData("distance"): waypoint.Distance = streamVal("distance") inPause = isInTimerStop(waypoint.Timestamp) waypoint.Type = WaypointType.Regular if not inPause else WaypointType.Pause if wasInPause and not inPause: waypoint.Type = WaypointType.Resume wasInPause = inPause # We only care if it's possible to start a new lap, i.e. there are more left if currentLapIdx + 1 < len(laps_starts): if laps_starts[currentLapIdx + 1] < waypoint.Timestamp: # A new lap has started currentLapIdx += 1 lap = activity.Laps[currentLapIdx] lap.Waypoints.append(waypoint) if returnFirstLocation: return None # I guess there were no waypoints? if activity.CountTotalWaypoints(): activity.GetFlatWaypoints()[0].Type = WaypointType.Start activity.GetFlatWaypoints()[-1].Type = WaypointType.End activity.Stationary = False else: activity.Stationary = True return activity def DownloadActivity(self, serviceRecord, activity): return self._downloadActivity(serviceRecord, activity) def UploadActivity(self, serviceRecord, activity, activitySource): activityData = {} # Props to the SportTracks API people for seamlessly supprting activities with or without TZ data. activityData["start_time"] = activity.StartTime.isoformat() if activity.Name: activityData["name"] = activity.Name if activity.Notes: activityData["notes"] = activity.Notes activityData[ "sharing"] = "public" if not activity.Private else "private" activityData["type"] = self._reverseActivityMappings[activity.Type] def _resolveDuration(obj): if obj.Stats.TimerTime.Value is not None: return obj.Stats.TimerTime.asUnits( ActivityStatisticUnit.Seconds).Value if obj.Stats.MovingTime.Value is not None: return obj.Stats.MovingTime.asUnits( ActivityStatisticUnit.Seconds).Value return (obj.EndTime - obj.StartTime).total_seconds() def _mapStat(dict, key, val, naturalValue=False): if val is not None: if naturalValue: val = round(val) dict[key] = val _mapStat(activityData, "clock_duration", (activity.EndTime - activity.StartTime).total_seconds()) _mapStat( activityData, "duration", _resolveDuration(activity) ) # This has to be set, otherwise all time shows up as "stopped" :( _mapStat( activityData, "total_distance", activity.Stats.Distance.asUnits( ActivityStatisticUnit.Meters).Value) _mapStat(activityData, "calories", activity.Stats.Energy.asUnits( ActivityStatisticUnit.Kilojoules).Value, naturalValue=True) _mapStat(activityData, "elevation_gain", activity.Stats.Elevation.Gain) _mapStat(activityData, "elevation_loss", activity.Stats.Elevation.Loss) _mapStat(activityData, "max_speed", activity.Stats.Speed.Max) _mapStat(activityData, "avg_heartrate", activity.Stats.HR.Average) _mapStat(activityData, "max_heartrate", activity.Stats.HR.Max) _mapStat(activityData, "avg_cadence", activity.Stats.Cadence.Average) _mapStat(activityData, "max_cadence", activity.Stats.Cadence.Max) _mapStat(activityData, "avg_power", activity.Stats.Power.Average) _mapStat(activityData, "max_power", activity.Stats.Power.Max) activityData["laps"] = [] lapNum = 0 for lap in activity.Laps: lapNum += 1 lapinfo = { "number": lapNum, "start_time": lap.StartTime.isoformat(), "type": "REST" if lap.Intensity == LapIntensity.Rest else "ACTIVE" } _mapStat(lapinfo, "clock_duration", (lap.EndTime - lap.StartTime).total_seconds()) # Required too. _mapStat(lapinfo, "duration", _resolveDuration( lap)) # This field is required for laps to be created. _mapStat( lapinfo, "distance", lap.Stats.Distance.asUnits( ActivityStatisticUnit.Meters).Value) # Probably required. _mapStat(lapinfo, "calories", lap.Stats.Energy.asUnits( ActivityStatisticUnit.Kilojoules).Value, naturalValue=True) _mapStat(lapinfo, "elevation_gain", lap.Stats.Elevation.Gain) _mapStat(lapinfo, "elevation_loss", lap.Stats.Elevation.Loss) _mapStat(lapinfo, "max_speed", lap.Stats.Speed.Max) _mapStat(lapinfo, "avg_heartrate", lap.Stats.HR.Average) _mapStat(lapinfo, "max_heartrate", lap.Stats.HR.Max) activityData["laps"].append(lapinfo) if not activity.Stationary: timer_stops = [] timer_stopped_at = None def stream_append(stream, wp, data): stream += [ round((wp.Timestamp - activity.StartTime).total_seconds()), data ] location_stream = [] distance_stream = [] elevation_stream = [] heartrate_stream = [] power_stream = [] cadence_stream = [] for lap in activity.Laps: for wp in lap.Waypoints: if wp.Location and wp.Location.Latitude and wp.Location.Longitude: stream_append( location_stream, wp, [wp.Location.Latitude, wp.Location.Longitude]) if wp.HR: stream_append(heartrate_stream, wp, round(wp.HR)) if wp.Distance: stream_append(distance_stream, wp, wp.Distance) if wp.Cadence or wp.RunCadence: stream_append( cadence_stream, wp, round(wp.Cadence) if wp.Cadence else round(wp.RunCadence)) if wp.Power: stream_append(power_stream, wp, wp.Power) if wp.Location and wp.Location.Altitude: stream_append(elevation_stream, wp, wp.Location.Altitude) if wp.Type == WaypointType.Pause and not timer_stopped_at: timer_stopped_at = wp.Timestamp if wp.Type != WaypointType.Pause and timer_stopped_at: timer_stops.append([timer_stopped_at, wp.Timestamp]) timer_stopped_at = None activityData["elevation"] = elevation_stream activityData["heartrate"] = heartrate_stream activityData["power"] = power_stream activityData["cadence"] = cadence_stream activityData["distance"] = distance_stream activityData["location"] = location_stream activityData["timer_stops"] = [[y.isoformat() for y in x] for x in timer_stops] headers = self._getAuthHeaders(serviceRecord) headers.update({"Content-Type": "application/json"}) upload_resp = requests.post(self.OpenFitEndpoint + "/fitnessActivities.json", data=json.dumps(activityData), headers=headers) if upload_resp.status_code != 200: if upload_resp.status_code == 401: raise APIException("ST.mobi trial expired", block=True, user_exception=UserException( UserExceptionType.AccountExpired, intervention_required=True)) raise APIException("Unable to upload activity %s" % upload_resp.text) return upload_resp.json()["uris"][0]
class SmashrunService(ServiceBase): ID = "smashrun" DisplayName = "Smashrun" DisplayAbbreviation = "SR" AuthenticationType = ServiceAuthenticationType.OAuth AuthenticationNoFrame = True # unfortunately, the smashrun dialog doesnt fit in the iframe... SupportedActivities = [ActivityType.Running] SupportsHR = SupportsCalories = SupportsCadence = SupportsTemp = True SupportsActivityDeletion = False _reverseActivityMappings = { ActivityType.Running: "running", } _activityMappings = { "running": ActivityType.Running, } _intensityMappings = { LapIntensity.Active: 'work', LapIntensity.Rest: 'recovery', LapIntensity.Warmup: 'warmup', LapIntensity.Cooldown: 'cooldown', } _tokenCache = SessionCache("smashrun", lifetime=timedelta(days=83)) def _getClient(self, serviceRec=None): cached_token = None if serviceRec: cached_token = self._tokenCache.Get(serviceRec.ExternalID) redirect_uri = None if serviceRec else WEB_ROOT + reverse( 'oauth_return', kwargs={'service': 'smashrun'}) client = SmashrunClient(client_id=SMASHRUN_CLIENT_ID, client_secret=SMASHRUN_CLIENT_SECRET, redirect_uri=redirect_uri, token=cached_token) if serviceRec and not cached_token: self._refreshToken(client, serviceRec) return client def _refreshToken(self, client, serviceRec): logger.info("refreshing auth token") token = client.refresh_token( refresh_token=serviceRec.Authorization['refresh_token']) self._cacheToken(serviceRec.ExternalID, token) def _cacheToken(self, uid, token): expiry = token[ 'expires_in'] - 24 * 60 # a 1 day buffer means we're less likely to expire mid-run self._tokenCache.Set(uid, token, lifetime=timedelta(seconds=expiry)) def WebInit(self): self.UserAuthorizationURL = reverse("oauth_redirect", kwargs={"service": "smashrun"}) def GenerateUserAuthorizationURL(self, session, level=None): client = self._getClient() url, state = client.get_auth_url() return url def RetrieveAuthorizationToken(self, req, level): code = req.GET.get("code") client = self._getClient() token = client.fetch_token(code=code) uid = client.get_userinfo()['id'] self._cacheToken(uid, token) return (uid, token) def RevokeAuthorization(self, serviceRecord): pass # TODO: smashrun doesn't seem to support this yet @handleExpiredToken def _getActivities(self, serviceRecord, exhaustive=False): client = self._getClient(serviceRec=serviceRecord) return list( client.get_activities(count=None if exhaustive else PAGE_COUNT, limit=None if exhaustive else PAGE_COUNT)) @handleExpiredToken def _getActivity(self, serviceRecord, activity): client = self._getClient(serviceRec=serviceRecord) return client.get_activity(activity.ServiceData['ActivityID']) @handleExpiredToken def _createActivity(self, serviceRecord, data): client = self._getClient(serviceRec=serviceRecord) return client.create_activity(data) def DownloadActivityList(self, serviceRecord, exhaustive=False): activities = [] exclusions = [] for act in self._getActivities(serviceRecord, exhaustive=exhaustive): activity = UploadedActivity() activity.StartTime = dateutil.parser.parse( act['startDateTimeLocal']) activity.EndTime = activity.StartTime + timedelta( seconds=act['duration']) _type = self._activityMappings.get(act['activityType']) if not _type: exclusions.append( APIExcludeActivity( "Unsupported activity type %s" % act['activityType'], activity_id=act["activityId"], user_exception=UserException(UserExceptionType.Other))) activity.ServiceData = {"ActivityID": act['activityId']} activity.Type = _type activity.Notes = act['notes'] activity.GPS = bool(act.get('startLatitude')) activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Kilometers, value=act['distance']) activity.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilocalories, value=act['calories']) if 'heartRateMin' in act: activity.Stats.HR = ActivityStatistic( ActivityStatisticUnit.BeatsPerMinute, min=act['heartRateMin'], max=act['heartRateMax'], avg=act['heartRateAverage']) activity.Stats.MovingTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=act['duration']) if 'temperature' in act: activity.Stats.Temperature = ActivityStatistic( ActivityStatisticUnit.DegreesCelcius, avg=act['temperature']) activity.CalculateUID() logger.debug("\tActivity s/t %s", activity.StartTime) activities.append(activity) return activities, exclusions # TODO: handle pauses def DownloadActivity(self, serviceRecord, activity): act = self._getActivity(serviceRecord, activity) recordingKeys = act.get('recordingKeys') if act['source'] == 'manual' or not recordingKeys: # it's a manually entered run, can't get much info activity.Stationary = True activity.Laps = [ Lap(startTime=activity.StartTime, endTime=activity.EndTime, stats=activity.Stats) ] return activity # FIXME: technically it could still be stationary if there are no long/lat values... activity.Stationary = False if not act['laps']: # no laps, just make one big lap activity.Laps = [ Lap(startTime=activity.StartTime, endTime=activity.EndTime, stats=activity.Stats) ] startTime = activity.StartTime for lapRecord in act['laps']: endTime = activity.StartTime + timedelta( seconds=lapRecord['endDuration']) lap = Lap(startTime=startTime, endTime=endTime) activity.Laps.append(lap) startTime = endTime + timedelta(seconds=1) for value in zip(*act['recordingValues']): record = dict(zip(recordingKeys, value)) ts = activity.StartTime + timedelta(seconds=record['clock']) if 'latitude' in record: alt = record.get('elevation') lat = record['latitude'] lon = record['longitude'] # Smashrun seems to replace missing measurements with -1 if lat == -1: lat = None if lon == -1: lon = None location = Location(lat=lat, lon=lon, alt=alt) hr = record.get('heartRate') runCadence = record.get('cadence') temp = record.get('temperature') distance = record.get('distance') * 1000 wp = Waypoint(timestamp=ts, location=location, hr=hr, runCadence=runCadence, temp=temp, distance=distance) # put the waypoint inside the lap it corresponds to for lap in activity.Laps: if lap.StartTime <= wp.Timestamp <= lap.EndTime: lap.Waypoints.append(wp) break return activity def _resolveDuration(self, obj): if obj.Stats.TimerTime.Value is not None: return obj.Stats.TimerTime.asUnits( ActivityStatisticUnit.Seconds).Value if obj.Stats.MovingTime.Value is not None: return obj.Stats.MovingTime.asUnits( ActivityStatisticUnit.Seconds).Value return (obj.EndTime - obj.StartTime).total_seconds() def UploadActivity(self, serviceRecord, activity): data = {} data['startDateTimeLocal'] = activity.StartTime.isoformat() data['distance'] = activity.Stats.Distance.asUnits( ActivityStatisticUnit.Kilometers).Value data['duration'] = self._resolveDuration(activity) data['activityType'] = self._reverseActivityMappings.get(activity.Type) def setIfNotNone(d, k, *vs, f=lambda x: x): for v in vs: if v is not None: d[k] = f(v) return setIfNotNone(data, 'notes', activity.Notes, activity.Name) setIfNotNone(data, 'cadenceAverage', activity.Stats.RunCadence.Average, f=int) setIfNotNone(data, 'cadenceMin', activity.Stats.RunCadence.Min, f=int) setIfNotNone(data, 'cadenceMax', activity.Stats.RunCadence.Max, f=int) setIfNotNone(data, 'heartRateAverage', activity.Stats.HR.Average, f=int) setIfNotNone(data, 'heartRateMin', activity.Stats.HR.Min, f=int) setIfNotNone(data, 'heartRateMax', activity.Stats.HR.Max, f=int) setIfNotNone(data, 'temperatureAverage', activity.Stats.Temperature.Average) if not activity.Laps[0].Waypoints: # no info, no need to go further self._createActivity(serviceRecord, data) return data['laps'] = [] recordings = defaultdict(list) def getattr_nested(obj, attr): attrs = attr.split('.') while attrs: r = getattr(obj, attrs.pop(0), None) obj = r return r def hasStat(activity, stat): for lap in activity.Laps: for wp in lap.Waypoints: if getattr_nested(wp, stat) is not None: return True return False hasDistance = hasStat(activity, 'Distance') hasTimestamp = hasStat(activity, 'Timestamp') hasLatitude = hasStat(activity, 'Location.Latitude') hasLongitude = hasStat(activity, 'Location.Longitude') hasAltitude = hasStat(activity, 'Location.Altitude') hasHeartRate = hasStat(activity, 'HR') hasCadence = hasStat(activity, 'RunCadence') hasTemp = hasStat(activity, 'Temp') for lap in activity.Laps: lapinfo = { 'lapType': self._intensityMappings.get(lap.Intensity, 'general'), 'endDuration': (lap.EndTime - activity.StartTime).total_seconds(), 'endDistance': lap.Waypoints[-1].Distance / 1000 } data['laps'].append(lapinfo) for wp in lap.Waypoints: if hasDistance: recordings['distance'].append(wp.Distance / 1000) if hasTimestamp: clock = (wp.Timestamp - activity.StartTime).total_seconds() recordings['clock'].append(int(clock)) if hasLatitude: recordings['latitude'].append(wp.Location.Latitude) if hasLongitude: recordings['longitude'].append(wp.Location.Longitude) if hasAltitude: recordings['elevation'].append(wp.Location.Altitude) if hasHeartRate: recordings['heartRate'].append(wp.HR) if hasCadence: recordings['cadence'].append(wp.RunCadence) if hasTemp: recordings['temperature'].append(wp.Temp) data['recordingKeys'] = sorted(recordings.keys()) data['recordingValues'] = [ recordings[k] for k in data['recordingKeys'] ] assert len(set(len(v) for v in data['recordingValues'])) == 1 self._createActivity(serviceRecord, data) def DeleteCachedData(self, serviceRecord): self._tokenCache.Delete(serviceRecord.ExternalID) def DeleteActivity(self, serviceRecord, uploadId): pass # TODO: smashrun doesn't support this yet
class GarminConnectService(ServiceBase): ID = "garminconnect" DisplayName = "Garmin Connect" DisplayAbbreviation = "GC" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True PartialSyncRequiresTrigger = len(GARMIN_CONNECT_USER_WATCH_ACCOUNTS) > 0 PartialSyncTriggerPollInterval = timedelta(minutes=20) PartialSyncTriggerPollMultiple = len( GARMIN_CONNECT_USER_WATCH_ACCOUNTS.keys()) # +1 from default due to my embarrassing inability to... # a) create a reasonable schema to allow for these updates. # b) write a query to reset the counters in the existing schema. DownloadRetryCount = 6 ConfigurationDefaults = {"WatchUserKey": None, "WatchUserLastID": 0} _activityMappings = { "running": ActivityType.Running, "indoor_running": ActivityType.Running, "cycling": ActivityType.Cycling, "mountain_biking": ActivityType.MountainBiking, "walking": ActivityType.Walking, "hiking": ActivityType.Hiking, "resort_skiing_snowboarding": ActivityType.DownhillSkiing, "cross_country_skiing": ActivityType.CrossCountrySkiing, "skate_skiing": ActivityType.CrossCountrySkiing, # Well, it ain't downhill? "backcountry_skiing_snowboarding": ActivityType.CrossCountrySkiing, # ish "skating": ActivityType.Skating, "swimming": ActivityType.Swimming, "rowing": ActivityType.Rowing, "elliptical": ActivityType.Elliptical, "fitness_equipment": ActivityType.Gym, "rock_climbing": ActivityType.Climbing, "mountaineering": ActivityType.Climbing, "strength_training": ActivityType.StrengthTraining, "stand_up_paddleboarding": ActivityType.StandUpPaddling, "all": ActivityType.Other, # everything will eventually resolve to this "multi_sport": ActivityType.Other # Most useless type? You decide! } _reverseActivityMappings = { # Removes ambiguities when mapping back to their activity types "running": ActivityType.Running, "cycling": ActivityType.Cycling, "mountain_biking": ActivityType.MountainBiking, "walking": ActivityType.Walking, "hiking": ActivityType.Hiking, "resort_skiing_snowboarding": ActivityType.DownhillSkiing, "cross_country_skiing": ActivityType.CrossCountrySkiing, "skating": ActivityType.Skating, "swimming": ActivityType.Swimming, "rowing": ActivityType.Rowing, "elliptical": ActivityType.Elliptical, "fitness_equipment": ActivityType.Gym, "rock_climbing": ActivityType.Climbing, "strength_training": ActivityType.StrengthTraining, "stand_up_paddleboarding": ActivityType.StandUpPaddling, "other": ActivityType.Other # I guess? (vs. "all" that is) } SupportedActivities = list(_activityMappings.values()) SupportsHR = SupportsCadence = True SupportsActivityDeletion = True _sessionCache = SessionCache("garminconnect", lifetime=timedelta(minutes=120), freshen_on_get=True) _reauthAttempts = 1 # per request _unitMap = { "mph": ActivityStatisticUnit.MilesPerHour, "kph": ActivityStatisticUnit.KilometersPerHour, "hmph": ActivityStatisticUnit.HectometersPerHour, "hydph": ActivityStatisticUnit.HundredYardsPerHour, "celcius": ActivityStatisticUnit.DegreesCelcius, "fahrenheit": ActivityStatisticUnit.DegreesFahrenheit, "mile": ActivityStatisticUnit.Miles, "kilometer": ActivityStatisticUnit.Kilometers, "foot": ActivityStatisticUnit.Feet, "meter": ActivityStatisticUnit.Meters, "yard": ActivityStatisticUnit.Yards, "kilocalorie": ActivityStatisticUnit.Kilocalories, "bpm": ActivityStatisticUnit.BeatsPerMinute, "stepsPerMinute": ActivityStatisticUnit.DoubledStepsPerMinute, "rpm": ActivityStatisticUnit.RevolutionsPerMinute, "watt": ActivityStatisticUnit.Watts, "second": ActivityStatisticUnit.Seconds, "ms": ActivityStatisticUnit.Milliseconds, "mps": ActivityStatisticUnit.MetersPerSecond } _obligatory_headers = {"Referer": "https://sync.tapiriik.com"} def __init__(self): cachedHierarchy = cachedb.gc_type_hierarchy.find_one() if not cachedHierarchy: rawHierarchy = requests.get( "https://connect.garmin.com/modern/proxy/activity-service/activity/activityTypes", headers=self._obligatory_headers).text self._activityHierarchy = json.loads(rawHierarchy) cachedb.gc_type_hierarchy.insert({"Hierarchy": rawHierarchy}) else: self._activityHierarchy = json.loads(cachedHierarchy["Hierarchy"]) # hashmaps for determining parent type key self._typeKeyParentMap = {} self._typeIdKeyMap = {} for x in self._activityHierarchy: self._typeKeyParentMap[x["typeKey"]] = x["parentTypeId"] self._typeIdKeyMap[x["typeId"]] = x["typeKey"] rate_lock_path = tempfile.gettempdir( ) + "/gc_rate.%s.lock" % HTTP_SOURCE_ADDR # Ensure the rate lock file exists (...the easy way) open(rate_lock_path, "a").close() self._rate_lock = open(rate_lock_path, "r+") def _rate_limit(self): import fcntl, struct, time min_period = 1 # I appear to been banned from Garmin Connect while determining this. fcntl.flock(self._rate_lock, fcntl.LOCK_EX) try: self._rate_lock.seek(0) last_req_start = self._rate_lock.read() if not last_req_start: last_req_start = 0 else: last_req_start = float(last_req_start) wait_time = max(0, min_period - (time.time() - last_req_start)) time.sleep(wait_time) self._rate_lock.seek(0) self._rate_lock.write(str(time.time())) self._rate_lock.flush() finally: fcntl.flock(self._rate_lock, fcntl.LOCK_UN) def _request_with_reauth(self, req_lambda, serviceRecord=None, email=None, password=None): for i in range(self._reauthAttempts + 1): session = self._get_session(record=serviceRecord, email=email, password=password, skip_cache=i > 0) self._rate_limit() result = req_lambda(session) if result.status_code not in (403, 500): return result # Pass the failed response back any ways - another handler will catch it and provide a nicer error return result def _get_session(self, record=None, email=None, password=None, skip_cache=False): from tapiriik.auth.credential_storage import CredentialStore cached = self._sessionCache.Get(record.ExternalID if record else email) if cached and not skip_cache: logger.debug("Using cached credential") return cached if record: # longing for C style overloads... password = CredentialStore.Decrypt( record.ExtendedAuthorization["Password"]) email = CredentialStore.Decrypt( record.ExtendedAuthorization["Email"]) 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" } # 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)) 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)) # ...AND WE'RE NOT DONE YET! self._rate_limit() gcRedeemResp = session.get("https://connect.garmin.com/modern", allow_redirects=False) 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: self._rate_limit() 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) session.headers.update(self._obligatory_headers) return session def WebInit(self): self.UserAuthorizationURL = WEB_ROOT + reverse( "auth_simple", kwargs={"service": self.ID}) def Authorize(self, email, password): from tapiriik.auth.credential_storage import CredentialStore session = self._get_session(email=email, password=password, skip_cache=True) self._rate_limit() 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"] except Exception as e: raise APIException("Unable to retrieve username: %s" % e, block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) return (username, {}, { "Email": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password) }) def UserUploadedActivityURL(self, uploadId): return "https://connect.garmin.com/modern/activity/%d" % uploadId def _resolveActivityType(self, act_type): # Mostly there are two levels of a hierarchy, so we don't really need this as the parent is included in the listing. # But maybe they'll change that some day? while act_type not in self._activityMappings: try: act_type = self._typeIdKeyMap[self._typeKeyParentMap[act_type]] except IndexError: raise ValueError( "Activity type not found in activity hierarchy") return self._activityMappings[act_type] def DownloadActivityList(self, serviceRecord, exhaustive=False): #https://connect.garmin.com/modern/proxy/activitylist-service/activities/search/activities?limit=20&start=0 page = 1 pageSz = 100 activities = [] exclusions = [] while True: logger.debug("Req with " + str({ "start": (page - 1) * pageSz, "limit": pageSz })) res = self._request_with_reauth( lambda session: session.get( "https://connect.garmin.com/modern/proxy/activitylist-service/activities/search/activities", params={ "start": (page - 1) * pageSz, "limit": pageSz }), serviceRecord) try: res = res.json() except ValueError: res_txt = res.text # So it can capture in the log message raise APIException("Parse failure in GC list resp: %s - %s" % (res.status_code, res_txt)) for act in res: activity = UploadedActivity() # stationary activities have movingDuration = None while non-gps static activities have 0.0 activity.Stationary = act["movingDuration"] is None activity.GPS = act["hasPolyline"] activity.Private = act["privacy"]["typeKey"] == "private" activity_name = act["activityName"] logger.debug("Name " + activity_name if activity_name is not None else "Untitled" + ":") if activity_name is not None and len( activity_name.strip() ) and activity_name != "Untitled": # This doesn't work for internationalized accounts, oh well. activity.Name = activity_name activity_description = act["description"] if activity_description is not None and len( activity_description.strip()): activity.Notes = activity_description activity.StartTime = pytz.utc.localize( datetime.strptime(act["startTimeGMT"], "%Y-%m-%d %H:%M:%S")) if act["elapsedDuration"] is not None: activity.EndTime = activity.StartTime + timedelta( 0, float(act["elapsedDuration"]) / 1000) elif act["duration"] is not None: activity.EndTime = activity.StartTime + timedelta( 0, float(act["duration"])) else: # somehow duration is not defined. Set 1 second then. activity.EndTime = activity.StartTime + timedelta(0, 1) logger.debug("Activity s/t " + str(activity.StartTime) + " on page " + str(page)) if "distance" in act and act["distance"] and float( act["distance"]) != 0: activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=float(act["distance"])) activity.Type = self._resolveActivityType( act["activityType"]["typeKey"]) activity.CalculateUID() activity.ServiceData = {"ActivityID": int(act["activityId"])} activities.append(activity) logger.debug("Finished page " + str(page)) if not exhaustive or len(res) == 0: break else: page += 1 return activities, exclusions def _downloadActivitySummary(self, serviceRecord, activity): activityID = activity.ServiceData["ActivityID"] summary_resp = self._request_with_reauth( lambda session: session. get("https://connect.garmin.com/modern/proxy/activity-service/activity/" + str(activityID)), serviceRecord) try: summary_data = summary_resp.json() except ValueError: raise APIException("Failure downloading activity summary %s:%s" % (summary_resp.status_code, summary_resp.text)) stat_map = {} def mapStat(gcKey, statKey, type, units): stat_map[gcKey] = {"key": statKey, "attr": type, "units": units} def applyStats(gc_dict, stats_obj): for gc_key, stat in stat_map.items(): if gc_key in gc_dict: value = float(gc_dict[gc_key]) if math.isinf(value): continue # GC returns the minimum speed as "-Infinity" instead of 0 some times :S getattr(stats_obj, stat["key"]).update( ActivityStatistic(stat["units"], **({ stat["attr"]: value }))) mapStat("movingDuration", "MovingTime", "value", ActivityStatisticUnit.Seconds) mapStat("duration", "TimerTime", "value", ActivityStatisticUnit.Seconds) mapStat("distance", "Distance", "value", ActivityStatisticUnit.Meters) mapStat("maxSpeed", "Speed", "max", ActivityStatisticUnit.MetersPerSecond) mapStat("averageSpeed", "Speed", "avg", ActivityStatisticUnit.MetersPerSecond) mapStat("calories", "Energy", "value", ActivityStatisticUnit.Kilocalories) mapStat("maxHR", "HR", "max", ActivityStatisticUnit.BeatsPerMinute) mapStat("averageHR", "HR", "avg", ActivityStatisticUnit.BeatsPerMinute) mapStat("minElevation", "Elevation", "min", ActivityStatisticUnit.Meters) mapStat("maxElevation", "Elevation", "max", ActivityStatisticUnit.Meters) mapStat("elevationGain", "Elevation", "gain", ActivityStatisticUnit.Meters) mapStat("elevationLoss", "Elevation", "loss", ActivityStatisticUnit.Meters) mapStat("averageBikeCadence", "Cadence", "avg", ActivityStatisticUnit.RevolutionsPerMinute) mapStat("averageCadence", "Cadence", "avg", ActivityStatisticUnit.StepsPerMinute) applyStats(summary_data["summaryDTO"], activity.Stats) laps_resp = self._request_with_reauth( lambda session: session. get("https://connect.garmin.com/modern/proxy/activity-service/activity/%s/splits" % str(activityID)), serviceRecord) try: laps_data = laps_resp.json() except ValueError: raise APIException( "Failure downloading activity laps summary %s:%s" % (laps_resp.status_code, laps_resp.text)) for lap_data in laps_data["lapDTOs"]: lap = Lap() if "startTimeGMT" in lap_data: lap.StartTime = pytz.utc.localize( datetime.strptime(lap_data["startTimeGMT"], "%Y-%m-%dT%H:%M:%S.0")) elapsed_duration = None if "elapsedDuration" in lap_data: elapsed_duration = timedelta( seconds=round(float(lap_data["elapsedDuration"]))) elif "duration" in lap_data: elapsed_duration = timedelta( seconds=round(float(lap_data["duration"]))) if lap.StartTime and elapsed_duration: # Always recalculate end time based on duration, if we have the start time lap.EndTime = lap.StartTime + elapsed_duration if not lap.StartTime and lap.EndTime and elapsed_duration: # Sometimes calculate start time based on duration lap.StartTime = lap.EndTime - elapsed_duration if not lap.StartTime or not lap.EndTime: # Garmin Connect is weird. raise APIExcludeActivity( "Activity lap has no BeginTimestamp or EndTimestamp", user_exception=UserException(UserExceptionType.Corrupt)) applyStats(lap_data, lap.Stats) activity.Laps.append(lap) # In Garmin Land, max can be smaller than min for this field :S if activity.Stats.Power.Max is not None and activity.Stats.Power.Min is not None and activity.Stats.Power.Min > activity.Stats.Power.Max: activity.Stats.Power.Min = None def DownloadActivity(self, serviceRecord, activity): # First, download the summary stats and lap stats self._downloadActivitySummary(serviceRecord, activity) if len(activity.Laps) == 1: activity.Stats = activity.Laps[ 0].Stats # They must be identical to pass the verification if activity.Stationary: # Nothing else to download return activity # https://connect.garmin.com/modern/proxy/download-service/export/tcx/activity/### activityID = activity.ServiceData["ActivityID"] res = self._request_with_reauth( lambda session: session. get("https://connect.garmin.com/modern/proxy/download-service/export/tcx/activity/{}" .format(activityID)), serviceRecord) try: tcx_data = res.text activity = TCXIO.Parse(tcx_data.encode('utf-8'), activity) except ValueError: raise APIException("Activity data parse error for %s: %s" % (res.status_code, res.text)) return activity def UploadActivity(self, serviceRecord, activity): #/proxy/upload-service-1.1/json/upload/.fit fit_file = FITIO.Dump(activity) files = { "data": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit", fit_file) } res = self._request_with_reauth( lambda session: session.post( "https://connect.garmin.com/modern/proxy/upload-service/upload/.fit", files=files, headers={"nk": "NT"}), serviceRecord) try: res = res.json()["detailedImportResult"] except ValueError: raise APIException("Bad response during GC upload: %s %s" % (res.status_code, res.text)) if len(res["successes"]) == 0: if len(res["failures"]) and len( res["failures"][0]["messages"]) and res["failures"][0][ "messages"][0]["content"] == "Duplicate activity": logger.debug("Duplicate") return # ...cool? raise APIException("Unable to upload activity %s" % res) if len(res["successes"]) > 1: raise APIException( "Uploaded succeeded, resulting in too many activities") actid = res["successes"][0]["internalId"] name = activity.Name # Capture in logs notes = activity.Notes # Update activity metadata not included in the FIT file. metadata_object = {} if activity.Name and activity.Name.strip(): metadata_object["activityName"] = activity.Name if activity.Notes and activity.Notes.strip(): metadata_object["description"] = activity.Notes if activity.Type not in [ ActivityType.Running, ActivityType.Cycling, ActivityType.Other ]: # Set the legit activity type - whatever it is, it's not supported by the FIT schema acttype = [ k for k, v in self._reverseActivityMappings.items() if v == activity.Type ] if len(acttype) == 0: raise APIWarning( "GarminConnect does not support activity type " + activity.Type) else: acttype = acttype[0] metadata_object["activityTypeDTO"] = {"typeKey": acttype} if activity.Private: metadata_object["accessControlRuleDTO"] = {"typeKey": "private"} if metadata_object: metadata_object["activityId"] = actid encoding_headers = { "Content-Type": "application/json; charset=UTF-8" } # GC really, really needs this part, otherwise it throws obscure errors like "Invalid signature for signature method HMAC-SHA1" res = self._request_with_reauth( lambda session: session. put("https://connect.garmin.com/proxy/activity-service/activity/" + str(actid), data=json.dumps(metadata_object), headers=encoding_headers), serviceRecord) if res.status_code != 204: raise APIWarning("Unable to set activity metadata - %d %s" % (res.status_code, res.text)) return actid def _user_watch_user(self, serviceRecord): if not serviceRecord.GetConfiguration()["WatchUserKey"]: user_key = random.choice( list(GARMIN_CONNECT_USER_WATCH_ACCOUNTS.keys())) logger.info("Assigning %s a new watch user %s" % (serviceRecord.ExternalID, user_key)) serviceRecord.SetConfiguration({"WatchUserKey": user_key}) return GARMIN_CONNECT_USER_WATCH_ACCOUNTS[user_key] else: return GARMIN_CONNECT_USER_WATCH_ACCOUNTS[ serviceRecord.GetConfiguration()["WatchUserKey"]] def SubscribeToPartialSyncTrigger(self, serviceRecord): # PUT http://connect.garmin.com/proxy/userprofile-service/connection/request/cpfair # (the poll worker finishes the connection) user_name = self._user_watch_user(serviceRecord)["Name"] logger.info("Requesting connection to %s from %s" % (user_name, serviceRecord.ExternalID)) self._rate_limit() resp = self._get_session(record=serviceRecord, skip_cache=True).put( "https://connect.garmin.com/proxy/userprofile-service/connection/request/%s" % user_name) try: assert resp.status_code == 200 assert resp.json()["requestStatus"] == "Created" except: raise APIException( "Connection request failed with user watch account %s: %s %s" % (user_name, resp.status_code, resp.text)) else: serviceRecord.SetConfiguration( {"WatchConnectionID": resp.json()["id"]}) serviceRecord.SetPartialSyncTriggerSubscriptionState(True) def UnsubscribeFromPartialSyncTrigger(self, serviceRecord): # GET http://connect.garmin.com/proxy/userprofile-service/socialProfile/connections to get the ID # {"fullName":null,"userConnections":[{"userId":5754439,"displayName":"TapiirikAPITEST","fullName":null,"location":null,"profileImageUrlMedium":null,"profileImageUrlSmall":null,"connectionRequestId":1566024,"userConnectionStatus":2,"userRoles":["ROLE_CONNECTUSER","ROLE_FITNESS_USER"],"userPro":false}]} # PUT http://connect.garmin.com/proxy/userprofile-service/connection/end/1904201 # Unfortunately there's no way to delete a pending request - the poll worker will do this from the other end active_watch_user = self._user_watch_user(serviceRecord) session = self._get_session(email=active_watch_user["Username"], password=active_watch_user["Password"], skip_cache=True) if "WatchConnectionID" in serviceRecord.GetConfiguration(): self._rate_limit() dc_resp = session.put( "https://connect.garmin.com/modern/proxy/userprofile-service/connection/end/%s" % serviceRecord.GetConfiguration()["WatchConnectionID"]) if dc_resp.status_code != 200: raise APIException( "Error disconnecting user watch accunt %s from %s: %s %s" % (active_watch_user, serviceRecord.ExternalID, dc_resp.status_code, dc_resp.text)) serviceRecord.SetConfiguration({ "WatchUserKey": None, "WatchConnectionID": None }) serviceRecord.SetPartialSyncTriggerSubscriptionState(False) else: # I broke Garmin Connect by having too many connections per account, so I can no longer query the connection list # All the connection request emails are sitting unopened in an email inbox, though, so I'll be backfilling the IDs from those raise APIException("Did not store connection ID") def ShouldForcePartialSyncTrigger(self, serviceRecord): # The poll worker can't see private activities. return serviceRecord.GetConfiguration()["sync_private"] def PollPartialSyncTrigger(self, multiple_index): # TODO: ensure the appropriate users are connected # GET http://connect.garmin.com/modern/proxy/userprofile-service/connection/pending to get ID # [{"userId":6244126,"displayName":"tapiriik-sync-ulukhaktok","fullName":"tapiriik sync ulukhaktok","profileImageUrlSmall":null,"connectionRequestId":1904086,"requestViewed":true,"userRoles":["ROLE_CONNECTUSER"],"userPro":false}] # PUT http://connect.garmin.com/proxy/userprofile-service/connection/accept/1904086 # ...later... # GET http://connect.garmin.com/proxy/activitylist-service/activities/comments/subscriptionFeed?start=1&limit=10 # First, accept any pending connections watch_user_key = sorted(list( GARMIN_CONNECT_USER_WATCH_ACCOUNTS.keys()))[multiple_index] watch_user = GARMIN_CONNECT_USER_WATCH_ACCOUNTS[watch_user_key] logger.debug("Initiating session for watch user %s", watch_user["Username"]) sess_args = { "email": watch_user["Username"], "password": watch_user["Password"] } # These seems to fail with a 500 (talkking about a timeout) the first time, so keep trying. SERVER_ERROR_RETRIES = 10 PAGE_SIZE = 100 TOTAL_SIZE = 1000 # Then, check for users with new activities watch_activities = [] for i in range(1, TOTAL_SIZE, PAGE_SIZE): for x in range(SERVER_ERROR_RETRIES): logger.debug("Fetching activity list from %d - attempt %d", i, x) watch_activities_resp = self._request_with_reauth( lambda session: session.get( "https://connect.garmin.com/modern/proxy/activitylist-service/activities/subscriptionFeed", params={ "limit": PAGE_SIZE, "start": i }), **sess_args) if watch_activities_resp.status_code != 500: break try: watch_activities += watch_activities_resp.json( )["activityList"] except ValueError: raise Exception("Could not parse new activities list: %s %s" % (watch_activities_resp.status_code, watch_activities_resp.text)) active_user_pairs = [(x["ownerDisplayName"], x["activityId"]) for x in watch_activities] active_user_pairs.sort( key=lambda x: x[1] ) # Highest IDs last (so they make it into the dict, supplanting lower IDs where appropriate) active_users = dict(active_user_pairs) active_user_recs = [ ServiceRecord(x) for x in db.connections.find( { "ExternalID": { "$in": list(active_users.keys()) }, "Service": "garminconnect" }, { "Config": 1, "ExternalID": 1, "Service": 1 }) ] if len(active_user_recs) != len(active_users.keys()): logger.warning("Mismatch %d records found for %d active users" % (len(active_user_recs), len(active_users.keys()))) to_sync_ids = [] for active_user_rec in active_user_recs: last_active_id = active_user_rec.GetConfiguration( )["WatchUserLastID"] this_active_id = active_users[active_user_rec.ExternalID] if this_active_id > last_active_id: to_sync_ids.append(active_user_rec.ExternalID) active_user_rec.SetConfiguration({ "WatchUserLastID": this_active_id, "WatchUserKey": watch_user_key }) for x in range(SERVER_ERROR_RETRIES): self._rate_limit() logger.debug("Fetching connection request list - attempt %d", x) pending_connections_resp = self._request_with_reauth( lambda session: session. get("https://connect.garmin.com/modern/proxy/userprofile-service/connection/pending" ), **sess_args) if pending_connections_resp.status_code != 500: break try: pending_connections = pending_connections_resp.json() except ValueError: logger.error("Could not parse pending connection requests: %s %s" % (pending_connections_resp.status_code, pending_connections_resp.text)) else: valid_pending_connections_external_ids = [ x["ExternalID"] for x in db.connections.find( { "Service": "garminconnect", "ExternalID": { "$in": [x["displayName"] for x in pending_connections] } }, {"ExternalID": 1}) ] logger.info( "Accepting %d, denying %d connection requests for %s" % (len(valid_pending_connections_external_ids), len(pending_connections) - len(valid_pending_connections_external_ids), watch_user_key)) for pending_connect in pending_connections: if pending_connect[ "displayName"] in valid_pending_connections_external_ids: self._rate_limit() connect_resp = self._request_with_reauth( lambda session: session. put("https://connect.garmin.com/modern/proxy/userprofile-service/connection/accept/%s" % pending_connect["connectionRequestId"]), **sess_args) if connect_resp.status_code != 200: logger.error( "Error accepting request on watch account %s: %s %s" % (watch_user["Name"], connect_resp.status_code, connect_resp.text)) else: self._rate_limit() self._request_with_reauth( lambda session: session. put("https://connect.garmin.com/modern/proxy/userprofile-service/connection/decline/%s" % pending_connect["connectionRequestId"]), **sess_args) return to_sync_ids def RevokeAuthorization(self, serviceRecord): # nothing to do here... pass def DeleteCachedData(self, serviceRecord): # nothing cached... pass def DeleteActivity(self, serviceRecord, uploadId): session = self._get_session(record=serviceRecord) self._rate_limit() del_res = session.delete( "https://connect.garmin.com/modern/proxy/activity-service/activity/%d" % uploadId) del_res.raise_for_status()
class MotivatoService(ServiceBase): ID = "motivato" DisplayName = "Motivato" DisplayAbbreviation = "MOT" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True _activityMappings = { ActivityType.Running: 1, ActivityType.Cycling: 2, ActivityType.MountainBiking: 2, ActivityType.Walking: 7, ActivityType.Hiking: 7, ActivityType.DownhillSkiing: 5, ActivityType.CrossCountrySkiing: 5, ActivityType.Snowboarding: 5, ActivityType.Skating: 5, ActivityType.Swimming: 3, ActivityType.Wheelchair: 5, ActivityType.Rowing: 5, ActivityType.Elliptical: 5, ActivityType.Gym: 4, ActivityType.Climbing: 5, ActivityType.Other: 5, } _reverseActivityMappings = { 1: ActivityType.Running, 2: ActivityType.Cycling, 3: ActivityType.Swimming, 4: ActivityType.Gym, 5: ActivityType.Other, 6: ActivityType.Other, 7: ActivityType.Walking } SupportedActivities = list(_reverseActivityMappings.values()) _sessionCache = SessionCache("motivato", lifetime=timedelta(minutes=30), freshen_on_get=True) _obligatory_headers = {"Referer": "https://www.siiink.com"} _urlRoot = "http://motivato.pl" def __init__(self): rate_lock_path = tempfile.gettempdir( ) + "/m_rate.%s.lock" % HTTP_SOURCE_ADDR # Ensure the rate lock file exists (...the easy way) open(rate_lock_path, "a").close() self._rate_lock = open(rate_lock_path, "r+") def WebInit(self): self.UserAuthorizationURL = WEB_ROOT + reverse( "auth_simple", kwargs={"service": self.ID}) def _getPaymentState(self, serviceRecord): # This method is also used by MotivatoExternalPaymentProvider to fetch user state session = self._get_session(record=serviceRecord) self._rate_limit() return session.get(self._urlRoot + "/api/tapiriikProfile").json()["isPremium"] def _applyPaymentState(self, serviceRecord): from tapiriik.auth import User state = self._getPaymentState(serviceRecord) ExternalPaymentProvider.FromID("motivato").ApplyPaymentState( User.GetByConnection(serviceRecord), state, serviceRecord.ExternalID, duration=None) def Authorize(self, email, password): from tapiriik.auth.credential_storage import CredentialStore session = self._get_session(email=email, password=password) self._rate_limit() id = session.get(self._urlRoot + "/api/tapiriikProfile").json()["id"] if not len(id): raise APIException("Unable to retrieve username", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) return (id, {}, { "Email": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password) }) def UploadActivity(self, serviceRecord, activity): logger.debug("Motivato UploadActivity") session = self._get_session(record=serviceRecord) dic = dict(training_at=activity.StartTime.strftime("%Y-%m-%d"), distance=activity.Stats.Distance.asUnits( ActivityStatisticUnit.Kilometers).Value, duration="", user_comment=activity.Notes, updated_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), created_at=activity.StartTime.strftime("%Y-%m-%d %H:%M:%S"), discipline_id=self._activityMappings[activity.Type], source_id=8, metas=dict( distance=activity.Stats.Distance.asUnits( ActivityStatisticUnit.Kilometers).Value, duration="", time_start=activity.StartTime.strftime("%H:%M:%S")), track={}) if activity.Stats.TimerTime.Value is not None: secs = activity.Stats.TimerTime.asUnits( ActivityStatisticUnit.Seconds).Value elif activity.Stats.MovingTime.Value is not None: secs = activity.Stats.MovingTime.asUnits( ActivityStatisticUnit.Seconds).Value else: secs = (activity.EndTime - activity.StartTime).total_seconds() dic["metas"]["duration"] = str(timedelta(seconds=secs)) dic["duration"] = str(timedelta(seconds=secs)) pace = str(timedelta(seconds=secs / activity.Stats.Distance.Value)) meta_hr_avg = activity.Stats.HR.Average meta_hr_max = activity.Stats.HR.Max if pace: dic["metas"]["pace"] = pace if meta_hr_avg: dic["metas"]["meta_hr_avg"] = meta_hr_avg if meta_hr_max: dic["metas"]["meta_hr_max"] = meta_hr_max if len(activity.Laps) > 0: dic["track"] = dict(name=activity.Name, mtime=secs, points=[]) for tk in activity.Laps: for wpt in tk.Waypoints: pt = dict( lat=wpt.Location.Latitude, lon=wpt.Location.Longitude, ele=wpt.Location.Altitude, bpm=wpt.HR, moment=wpt.Timestamp.strftime('%Y-%m-%d %H:%M:%S')) if wpt.Speed and wpt.Speed != None and wpt.Speed != 0: pt["pace"] = (1000.0 / wpt.Speed) dic["track"]["points"].append(pt) toSend = json.dumps(dic) try: res = session.post(self._urlRoot + "/api/workout", data=toSend) except APIWarning as e: raise APIException(str(e)) if res.status_code != 201: raise APIException("Activity didn't upload: %s, %s" % (res.status_code, res.text)) try: retJson = res.json() except ValueError: raise APIException("Activity upload parse error for %s, %s" % (res.status_code, res.text)) return retJson["id"] def _parseDate(self, date): return datetime.strptime(date, "%Y-%m-%d") def _parseDateTime(self, date): try: return datetime.strptime(date, "%Y-%m-%d %H:%M:%S") except ValueError: return datetime.strptime(date, "%Y-%m-%d %H:%M") def _durationToSeconds(self, dur): # in order to fight broken metas parts = dur.split(":") return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]) def DownloadActivityList(self, serviceRecord, exhaustive=False): logger.debug("Checking motivato premium state") self._applyPaymentState(serviceRecord) logger.debug("Motivato DownloadActivityList") session = self._get_session(record=serviceRecord) activities = [] exclusions = [] self._rate_limit() retried_auth = False #headers = {'X-App-With-Tracks': "true"} headers = {} res = session.post(self._urlRoot + "/api/workouts/sync", headers=headers) if res.status_code == 403 and not retried_auth: retried_auth = True session = self._get_session(serviceRecord, skip_cache=True) try: respList = res.json() except ValueError: res_txt = res.text # So it can capture in the log message raise APIException("Parse failure in Motivato list resp: %s" % res.status_code) for actInfo in respList: if "duration" in actInfo: duration = self._durationToSeconds(actInfo["duration"]) else: continue activity = UploadedActivity() if "time_start" in actInfo["metas"]: startTimeStr = actInfo["training_at"] + " " + actInfo["metas"][ "time_start"] else: startTimeStr = actInfo["training_at"] + " 00:00:00" activity.StartTime = self._parseDateTime(startTimeStr) activity.EndTime = self._parseDateTime(startTimeStr) + timedelta( seconds=duration) activity.Type = self._reverseActivityMappings[ actInfo["discipline_id"]] activity.Stats.TimerTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=duration) if "distance" in actInfo: activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Kilometers, value=float(actInfo["distance"])) #activity.Stats.Speed = ActivityStatistic(ActivityStatisticUnit.KilometersPerSecond, value=1.0/float(actInfo["metas"]["pace"])) activity.ServiceData = {"WorkoutID": int(actInfo["id"])} activity.CalculateUID() logger.debug("Generated UID %s" % activity.UID) activities.append(activity) return activities, exclusions def DownloadActivity(self, serviceRecord, activity): workoutID = activity.ServiceData["WorkoutID"] logger.debug("DownloadActivity for %s" % workoutID) session = self._get_session(record=serviceRecord) resp = session.get(self._urlRoot + "/api/workout/%d" % workoutID) try: res = resp.json() except ValueError: raise APIException( "Parse failure in Motivato activity (%d) download: %s" % (workoutID, res.text)) lap = Lap(stats=activity.Stats, startTime=activity.StartTime, endTime=activity.EndTime) activity.Laps = [lap] activity.GPS = False if "track" in res and "points" in res["track"]: for pt in res["track"]["points"]: wp = Waypoint() if "moment" not in pt: continue wp.Timestamp = self._parseDateTime(pt["moment"]) if ("lat" in pt and "lon" in pt) or "ele" in pt: wp.Location = Location() if "lat" in pt and "lon" in pt: wp.Location.Latitude = pt["lat"] wp.Location.Longitude = pt["lon"] activity.GPS = True if "ele" in pt: wp.Location.Altitude = float(pt["ele"]) if "bpm" in pt: wp.HR = pt["bpm"] lap.Waypoints.append(wp) activity.Stationary = len(lap.Waypoints) == 0 return activity def _get_session(self, record=None, email=None, password=None, skip_cache=False): from tapiriik.auth.credential_storage import CredentialStore cached = self._sessionCache.Get(record.ExternalID if record else email) if cached and not skip_cache: return cached if record: # longing for C style overloads... password = CredentialStore.Decrypt( record.ExtendedAuthorization["Password"]) email = CredentialStore.Decrypt( record.ExtendedAuthorization["Email"]) session = requests.Session() self._rate_limit() mPreResp = session.get(self._urlRoot + "/api/tapiriikProfile", allow_redirects=False) # New site gets this redirect, old one does not if mPreResp.status_code == 403: data = { "_username": email, "_password": password, "_remember_me": "true", } preResp = session.post(self._urlRoot + "/api/login", data=data) if preResp.status_code != 200: raise APIException("Login error %s %s" % (preResp.status_code, preResp.text)) try: preResp = preResp.json() except ValueError: raise APIException("Parse error %s %s" % (preResp.status_code, preResp.text), block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) if "success" not in preResp and "error" not in preResp: raise APIException("Login error", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) success = True error = "" if "success" in preResp: success = ["success"] if "error" in preResp: error = preResp["error"] if not success: logger.debug("Login error %s" % (error)) raise APIException("Invalid login", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) # Double check self._rate_limit() mRedeemResp1 = session.get(self._urlRoot + "/api/tapiriikProfile", allow_redirects=False) if mRedeemResp1.status_code != 200: raise APIException( "Motivato redeem error %s %s" % (mRedeemResp1.status_code, mRedeemResp1.text)) else: logger.debug("code %s" % mPreResp.status_code) raise APIException("Unknown Motivato prestart response %s %s" % (mPreResp.status_code, mPreResp.text)) self._sessionCache.Set(record.ExternalID if record else email, session) session.headers.update(self._obligatory_headers) return session def _rate_limit(self): import fcntl, time min_period = 1 print("Waiting for lock") fcntl.flock(self._rate_lock, fcntl.LOCK_EX) try: print("Have lock") self._rate_lock.seek(0) last_req_start = self._rate_lock.read() if not last_req_start: last_req_start = 0 else: last_req_start = float(last_req_start) wait_time = max(0, min_period - (time.time() - last_req_start)) time.sleep(wait_time) self._rate_lock.seek(0) self._rate_lock.write(str(time.time())) self._rate_lock.flush() print("Rate limited for %f" % wait_time) finally: fcntl.flock(self._rate_lock, fcntl.LOCK_UN) def DeleteCachedData(self, serviceRecord): # nothing cached... pass def RevokeAuthorization(self, serviceRecord): # nothing to do here... pass
class AerobiaService(ServiceBase): ID = "aerobia" DisplayName = "Aerobia" DisplayAbbreviation = "ARB" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True UserProfileURL = "http://www.aerobia.ru/users/{0}" UserActivityURL = "http://www.aerobia.ru/users/{0}/workouts/{1}" Configurable = True ConfigurationDefaults = {} # common -> aerobia (garmin tcx sport names) # todo may better to include this into tcxio logic instead _activityMappings = { ActivityType.Running: "Running", ActivityType.Cycling: "Biking", ActivityType.MountainBiking: "Mountain biking", ActivityType.Walking: "Walking", ActivityType.Hiking: "Hiking", ActivityType.DownhillSkiing: "Skiing downhill", ActivityType.CrossCountrySkiing: "Cross country skiing", ActivityType.Snowboarding: "Snowboard", ActivityType.Skating: "Skating", ActivityType.Swimming: "Swimming", #ActivityType.Wheelchair : "Wheelchair", ActivityType.Rowing: "Rowing", ActivityType.Elliptical: "Ellips", ActivityType.Gym: "Gym", ActivityType.Climbing: "Rock climbing", ActivityType.RollerSkiing: "Roller skiing", ActivityType.StrengthTraining: "Ofp", ActivityType.Other: "Sport" } # aerobia -> common _reverseActivityMappings = { 1: ActivityType.Cycling, 2: ActivityType.Running, 56: ActivityType.MountainBiking, 19: ActivityType.Walking, 43: ActivityType.Hiking, 9: ActivityType.DownhillSkiing, 3: ActivityType.CrossCountrySkiing, 46: ActivityType.Skating, 21: ActivityType.Swimming, 13: ActivityType.Rowing, 74: ActivityType.Elliptical, 54: ActivityType.Gym, 63: ActivityType.Climbing, 72: ActivityType.StrengthTraining, 6: ActivityType.Cycling, #cycling transport 22: ActivityType.Cycling, #indoor cycling 73: ActivityType.Gym, #stretching 76: ActivityType.Gym, #trx 83: ActivityType.CrossCountrySkiing, #classic skiing 65: ActivityType.Other, #triathlon 51: ActivityType.Other, #beach volleyball 53: ActivityType.Other, #basketball 55: ActivityType.Other, #roller sport 77: ActivityType.Running, #tredmill 66: ActivityType.Other, #roller skiing 7: ActivityType.Other, #rollers 58: ActivityType.Other, #nordic walking 10: ActivityType.Other, #snowboarding 16: ActivityType.Other, #walking sport 18: ActivityType.Other, #orienting 38: ActivityType.Other, #OTHER 61: ActivityType.Other, #WATER AEROBICS 79: ActivityType.Other, #ACROBATICS 23: ActivityType.Other, #AEROBICS 26: ActivityType.Other, #BOX 84: ActivityType.Other, #CYCLOCROSS 24: ActivityType.Other, #BADMINTON 52: ActivityType.Other, #VOLLEYBALL 50: ActivityType.Other, #MARTIAL ARTS 49: ActivityType.Other, #HANDBALL 48: ActivityType.Other, #GYMNASTICS 4: ActivityType.Other, #GOLF 36: ActivityType.Other, #SCUBA DIVING 85: ActivityType.Other, #DUATHLON 69: ActivityType.Other, #DELTAPLAN 47: ActivityType.Other, #YOGA 45: ActivityType.Other, #KITEBOARDING 80: ActivityType.Other, #KERLING 62: ActivityType.Other, #HORSE RIDING 71: ActivityType.Other, #СROSSFIT 64: ActivityType.Other, #CIRCLE WORKOUT 78: ActivityType.Other, #MOTORSPORT 44: ActivityType.Other, #ММА 70: ActivityType.Other, #PARAPLANE 35: ActivityType.Other, #PILATES 20: ActivityType.Other, #POLO 33: ActivityType.Other, #RUGBY 60: ActivityType.Other, #FISHING 67: ActivityType.Other, #SCOOTER 15: ActivityType.Other, #WINDSURFING 42: ActivityType.Other, #SQUASH 41: ActivityType.Other, #SKATEBOARD 75: ActivityType.Other, #STEPPER 29: ActivityType.Other, #DANCING 40: ActivityType.Other, #TENNIS 37: ActivityType.Other, #TABLE TENNIS 81: ActivityType.Other, #OUTDOOR FITNESS 31: ActivityType.Other, #FOOTBALL 59: ActivityType.Other, #FENCING 39: ActivityType.Other, #FIGURE SKATING 34: ActivityType.Other, #HOCKEY 82: ActivityType.Other, #CHESS 68: ActivityType.Other } SupportedActivities = list(_activityMappings.keys()) SupportsHR = SupportsCadence = True SupportsActivityDeletion = True _sessionCache = SessionCache("aerobia", lifetime=timedelta(minutes=120), freshen_on_get=True) _obligatory_headers = { # Without user-agent patch aerobia requests doesn't work "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko" } _urlRoot = "http://aerobia.ru/" _apiRoot = "http://aerobia.ru/api/" _loginUrlRoot = _apiRoot + "sign_in" _workoutsUrl = _apiRoot + "workouts" _workoutUrlJson = _apiRoot + "workouts/{id}.json" _workoutUrl = _urlRoot + "workouts/{id}" _uploadsUrl = _apiRoot + "uploads.json" def _get_session(self, record=None, username=None): cached = self._sessionCache.Get( record.ExternalID if record else username) if cached: return cached session = requests.Session() session.headers.update(self._obligatory_headers) return session def _get_auth_data(self, record=None, username=None, password=None): from tapiriik.auth.credential_storage import CredentialStore if record: # longing for C style overloads... password = CredentialStore.Decrypt( record.ExtendedAuthorization["Password"]) username = CredentialStore.Decrypt( record.ExtendedAuthorization["Email"]) session = self._get_session(record, username) request_parameters = { "user[email]": username, "user[password]": password } res = session.post(self._loginUrlRoot, data=request_parameters) if res.status_code != 200: raise APIException( "Login exception {} - {}".format(res.status_code, res.text), user_exception=UserException(UserExceptionType.Authorization)) res_xml = etree.fromstring(res.text.encode('utf-8')) info = res_xml.find("info") if info.get("status") != "ok": raise APIException(info.get("description"), user_exception=UserException( UserExceptionType.Authorization)) user_id = int(res_xml.find("user/id").get("value")) user_token = res_xml.find("user/authentication_token").get("value") return user_id, user_token def _call(self, serviceRecord, request_call, *args): retry_count = 3 resp = None ex = Exception() for i in range(0, retry_count): try: resp = request_call(args) break except APIException as ex: # try to refresh token first self._refresh_token(serviceRecord) except requests.exceptions.ConnectTimeout as ex: # Aerobia sometimes answer like # Failed to establish a new connection: [WinError 10060] may happen while listing. # wait a bit and retry time.sleep(.2) if resp is None: raise ex return resp def _refresh_token(self, record): logger.info("refreshing auth token") user_id, user_token = self._get_auth_data(record=record) auth_datails = {"OAuthToken": user_token} record.Authorization.update(auth_datails) db.connections.update({"_id": record._id}, {"$set": { "Authorization": auth_datails }}) def _with_auth(self, record, params={}): params.update( {"authentication_token": record.Authorization["OAuthToken"]}) return params def Authorize(self, username, password): from tapiriik.auth.credential_storage import CredentialStore user_id, user_token = self._get_auth_data(username=username, password=password) secret = { "Email": CredentialStore.Encrypt(username), "Password": CredentialStore.Encrypt(password) } authorizationData = {"OAuthToken": user_token} return (user_id, authorizationData, secret) def DownloadActivityList(self, serviceRecord, exhaustive=False): activities = [] exclusions = [] fetch_diary = lambda page=1: self._get_diary_xml(serviceRecord, page) total_pages = None page = 1 while True: diary_xml = self._call(serviceRecord, fetch_diary, page) for workout_info in diary_xml.findall("workouts/r"): activity = self._create_activity(workout_info) activities.append(activity) if total_pages is None: pagination = diary_xml.find("pagination") # New accounts have no data pages initially total_pages_str = pagination.get( "total_pages") if pagination else None total_pages = int(total_pages_str) if total_pages_str else 1 page += 1 if not exhaustive or page > total_pages: break return activities, exclusions def _get_diary_xml(self, serviceRecord, page=1): session = self._get_session(serviceRecord) diary_data = session.get(self._workoutsUrl, params=self._with_auth( serviceRecord, {"page": page})) diary_xml = etree.fromstring(diary_data.text.encode('utf-8')) info = diary_xml.find("info") if info.get("status") != "ok": raise APIException(info.get("description"), user_exception=UserException( UserExceptionType.DownloadError)) return diary_xml def _create_activity(self, data): activity = UploadedActivity() activity.Name = data.get("name") activity.StartTime = pytz.utc.localize( datetime.strptime(data.get("start_at"), "%Y-%m-%dT%H:%M:%SZ")) activity.EndTime = activity.StartTime + timedelta( 0, float(data.get("duration"))) sport_id = data.get("sport_id") activity.Type = self._reverseActivityMappings.get( int(sport_id), ActivityType.Other) if sport_id else ActivityType.Other distance = data.get("distance") activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Kilometers, value=float(distance) if distance else None) activity.Stats.MovingTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=float(data.get("total_time_in_seconds"))) avg_speed = data.get("average_speed") max_speed = data.get("max_speed") activity.Stats.Speed = ActivityStatistic( ActivityStatisticUnit.KilometersPerHour, avg=float(avg_speed) if avg_speed else None, max=float(max_speed) if max_speed else None) avg_hr = data.get("average_heart_rate") max_hr = data.get("maximum_heart_rate") activity.Stats.HR.update( ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, avg=float(avg_hr) if avg_hr else None, max=float(max_hr) if max_hr else None)) calories = data.get("calories") activity.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilocalories, value=int(calories) if calories else None) activity.ServiceData = {"ActivityID": data.get("id")} logger.debug("\tActivity s/t {}: {}".format(activity.StartTime, activity.Type)) activity.CalculateUID() return activity def DownloadActivity(self, serviceRecord, activity): session = self._get_session(serviceRecord) activity_id = activity.ServiceData["ActivityID"] tcx_data = session.get("{}export/workouts/{}/tcx".format( self._urlRoot, activity_id), data=self._with_auth(serviceRecord)) activity_ex = TCXIO.Parse(tcx_data.text.encode('utf-8'), activity) # Obtain more information about activity res = session.get(self._workoutUrlJson.format(id=activity_id), data=self._with_auth(serviceRecord)) activity_data = res.json() activity_ex.Name = activity_data["name"] # Notes comes as html. Hardly any other service will support this so needs to extract text data if "body" in activity_data["post"]: post_html = activity_data["post"]["body"] soup = BeautifulSoup(post_html) # Notes also contains styles, get rid of them for style in soup("style"): style.decompose() activity_ex.Notes = soup.getText() # Dirty hack to patch users inventory even if they use aerobia mobile app to record activities # Still need to sync with some service though. extra_data = {} self._put_default_inventory(activity, serviceRecord, extra_data) if extra_data: self._patch_activity(serviceRecord, extra_data, activity_id) return activity_ex def UploadActivity(self, serviceRecord, activity): session = self._get_session(serviceRecord) tcx_data = None # If some service provides ready-to-use tcx data why not to use it? if activity.SourceFile: tcx_data = activity.SourceFile.getContent(ActivityFileType.TCX) # Set aerobia-understandable sport name tcx_data = re.sub( r'(<Sport=\")\w+(\">)', r'\1{}\2'.format( self._activityMappings[activity.Type]), tcx_data) if tcx_data else None if not tcx_data: tcx_data = TCXIO.Dump(activity, self._activityMappings[activity.Type]) data = {"name": activity.Name, "description": activity.Notes} files = { "file": ("tap-sync-{}-{}.tcx".format(os.getpid(), activity.UID), tcx_data) } res = session.post(self._uploadsUrl, data=self._with_auth(serviceRecord, data), files=files) res_obj = res.json() uploaded_id = res_obj["workouts"][0]["id"] if "error" in res_obj: raise APIException(res_obj["error"], user_exception=UserException( UserExceptionType.UploadError)) extra_data = {} if activity.Name is not None: extra_data.update({"workout[name]": activity.Name}) self._put_default_inventory(activity, serviceRecord, extra_data) # Post extra data to newly uploaded activity if extra_data: self._patch_activity(serviceRecord, extra_data, uploaded_id) # return just uploaded activity id return uploaded_id def _put_default_inventory(self, activity, serviceRecord, data): rules = serviceRecord.Config[ "gearRules"] if "gearRules" in serviceRecord.Config else None if rules is None: return inventory = [] for rule in rules: if "sport" in rule and "gear" in rule: if activity.Type == rule["sport"]: inventory += rule["gear"] if len(inventory): data.update({"workout[inventory_ids][]": inventory}) def _patch_activity(self, serviceRecord, data, activity_id): session = self._get_session(serviceRecord) data.update({"_method": "put"}) update_activity = lambda x: session.post( self._workoutUrl.format(id=activity_id), data=self._with_auth(serviceRecord, data)) try: self._call(serviceRecord, update_activity) except Exception as e: # do nothing but logging - anything critical happened to interrupt process logger.debug("Unable to patch activity: " + e) def UserUploadedActivityURL(self, uploadId): raise NotImplementedError # TODO need to include user id #return self.UserActivityURL.format(userId, uploadId) def DeleteActivity(self, serviceRecord, uploadId): session = self._get_session(serviceRecord) delete_parameters = {"_method": "delete"} delete_call = lambda x: session.post( "{}workouts/{}".format(self._urlRoot, uploadId), data=self._with_auth(serviceRecord, delete_parameters)) self._call(serviceRecord, delete_call) def DeleteCachedData(self, serviceRecord): pass # No cached data... def RevokeAuthorization(self, serviceRecord): # nothing to do here... pass
class RideWithGPSService(ServiceBase): ID = "rwgps" DisplayName = "Ride With GPS" DisplayAbbreviation = "RWG" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True # RWGPS does has a "recreation_types" list, but it is not actually used anywhere (yet) # (This is a subset of the things returned by that list for future reference...) _activityMappings = { "running": ActivityType.Running, "cycling": ActivityType.Cycling, "mountain biking": ActivityType.MountainBiking, "Hiking": ActivityType.Hiking, "all": ActivityType.Other # everything will eventually resolve to this } SupportedActivities = [ActivityType.Cycling, ActivityType.MountainBiking] SupportsHR = SupportsCadence = True _sessionCache = SessionCache("rwgps", lifetime=timedelta(minutes=30), freshen_on_get=True) def _add_auth_params(self, params=None, record=None): """ Adds apikey and authorization (email/password) to the passed-in params, returns modified params dict. """ from tapiriik.auth.credential_storage import CredentialStore if params is None: params = {} params['apikey'] = RWGPS_APIKEY if record: cached = self._sessionCache.Get(record.ExternalID) if cached: return cached password = CredentialStore.Decrypt(record.ExtendedAuthorization["Password"]) email = CredentialStore.Decrypt(record.ExtendedAuthorization["Email"]) params['email'] = email params['password'] = password return params def WebInit(self): self.UserAuthorizationURL = WEB_ROOT + reverse("auth_simple", kwargs={"service": self.ID}) def Authorize(self, email, password): from tapiriik.auth.credential_storage import CredentialStore res = requests.get("https://ridewithgps.com/users/current.json", params={'email': email, 'password': password, 'apikey': RWGPS_APIKEY}) if res.status_code == 401: raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) res.raise_for_status() res = res.json() if res["user"] is None: raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) member_id = res["user"]["id"] if not member_id: raise APIException("Unable to retrieve id", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) return (member_id, {}, {"Email": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password)}) def _duration_to_seconds(self, s): """ Converts a duration in form HH:MM:SS to number of seconds for use in timedelta construction. """ hours, minutes, seconds = (["0", "0"] + s.split(":"))[-3:] hours = int(hours) minutes = int(minutes) seconds = float(seconds) total_seconds = int(hours + 60000 * minutes + 1000 * seconds) return total_seconds def DownloadActivityList(self, serviceRecord, exhaustive=False): def mapStatTriple(act, stats_obj, key, units): if "%s_max" % key in act and act["%s_max" % key]: stats_obj.update(ActivityStatistic(units, max=float(act["%s_max" % key]))) if "%s_min" % key in act and act["%s_min" % key]: stats_obj.update(ActivityStatistic(units, min=float(act["%s_min" % key]))) if "%s_avg" % key in act and act["%s_avg" % key]: stats_obj.update(ActivityStatistic(units, avg=float(act["%s_avg" % key]))) # http://ridewithgps.com/users/1/trips.json?limit=200&order_by=created_at&order_dir=asc # offset also supported activities = [] exclusions = [] # They don't actually support paging right now, for whatever reason params = self._add_auth_params({}, record=serviceRecord) res = requests.get("http://ridewithgps.com/users/{}/trips.json".format(serviceRecord.ExternalID), params=params) res = res.json() # Apparently some API users are seeing this new result format - I'm not if type(res) is dict: res = res.get("results", []) if res == []: return [], [] # No activities for act in res: if "distance" not in act: exclusions.append(APIExcludeActivity("No distance", activity_id=act["id"], user_exception=UserException(UserExceptionType.Corrupt))) continue if "duration" not in act or not act["duration"]: exclusions.append(APIExcludeActivity("No duration", activity_id=act["id"], user_exception=UserException(UserExceptionType.Corrupt))) continue activity = UploadedActivity() logger.debug("Name " + act["name"] + ":") if len(act["name"].strip()): activity.Name = act["name"] if len(act["description"].strip()): activity.Notes = act["description"] activity.GPS = act["is_gps"] activity.Stationary = not activity.GPS # I think # 0 = public, 1 = private, 2 = friends activity.Private = act["visibility"] == 1 activity.StartTime = dateutil.parser.parse(act["departed_at"]) try: activity.TZ = pytz.timezone(act["time_zone"]) except pytz.exceptions.UnknownTimeZoneError: # Sometimes the time_zone returned isn't quite what we'd like it # So, just pull the offset from the datetime if isinstance(activity.StartTime.tzinfo, tzutc): activity.TZ = pytz.utc # The dateutil tzutc doesn't have an _offset value. else: activity.TZ = pytz.FixedOffset(activity.StartTime.tzinfo.utcoffset(activity.StartTime).total_seconds() / 60) activity.StartTime = activity.StartTime.replace(tzinfo=activity.TZ) # Overwrite dateutil's sillyness activity.EndTime = activity.StartTime + timedelta(seconds=self._duration_to_seconds(act["duration"])) logger.debug("Activity s/t " + str(activity.StartTime)) activity.AdjustTZ() activity.Stats.Distance = ActivityStatistic(ActivityStatisticUnit.Meters, float(act["distance"])) mapStatTriple(act, activity.Stats.Power, "watts", ActivityStatisticUnit.Watts) mapStatTriple(act, activity.Stats.Speed, "speed", ActivityStatisticUnit.KilometersPerHour) mapStatTriple(act, activity.Stats.Cadence, "cad", ActivityStatisticUnit.RevolutionsPerMinute) mapStatTriple(act, activity.Stats.HR, "hr", ActivityStatisticUnit.BeatsPerMinute) if "elevation_gain" in act and act["elevation_gain"]: activity.Stats.Elevation.update(ActivityStatistic(ActivityStatisticUnit.Meters, gain=float(act["elevation_gain"]))) if "elevation_loss" in act and act["elevation_loss"]: activity.Stats.Elevation.update(ActivityStatistic(ActivityStatisticUnit.Meters, loss=float(act["elevation_loss"]))) # Activity type is not implemented yet in RWGPS results; we will assume cycling, though perhaps "OTHER" wouuld be correct activity.Type = ActivityType.Cycling activity.CalculateUID() activity.ServiceData = {"ActivityID": act["id"]} activities.append(activity) return activities, exclusions def DownloadActivity(self, serviceRecord, activity): if activity.Stationary: return activity # Nothing more to download - it doesn't serve these files for manually entered activites # https://ridewithgps.com/trips/??????.tcx activityID = activity.ServiceData["ActivityID"] res = requests.get("https://ridewithgps.com/trips/{}.tcx".format(activityID), params=self._add_auth_params({'sub_format': 'history'}, record=serviceRecord)) try: TCXIO.Parse(res.content, activity) except ValueError as e: raise APIExcludeActivity("TCX parse error " + str(e), user_exception=UserException(UserExceptionType.Corrupt)) return activity def UploadActivity(self, serviceRecord, activity): # https://ridewithgps.com/trips.json fit_file = FITIO.Dump(activity) files = {"data_file": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit", fit_file)} params = {} params['trip[name]'] = activity.Name params['trip[description]'] = activity.Notes if activity.Private: params['trip[visibility]'] = 1 # Yes, this logic seems backwards but it's how it works res = requests.post("https://ridewithgps.com/trips.json", files=files, params=self._add_auth_params(params, record=serviceRecord)) if res.status_code % 100 == 4: raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) res.raise_for_status() res = res.json() if res["success"] != 1: raise APIException("Unable to upload activity") def RevokeAuthorization(self, serviceRecord): # nothing to do here... pass def DeleteCachedData(self, serviceRecord): # nothing cached... pass
class BeginnerTriathleteService(ServiceBase): # TODO: BT has a kcal expenditure calculation, it just isn't being reported. Supply that for a future update.. # TODO: Implement laps on manual entries # TODO: BT supports activities other than the standard swim/bike/run, but a different interface is regrettably used ID = "beginnertriathlete" DisplayName = "BeginnerTriathlete" DisplayAbbreviation = "BT" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True ReceivesStationaryActivities = True SupportsHR = True SupportsActivityDeletion = True # Don't need to cache user settings for long, it is a quick lookup But if a user changes their timezone # or privacy settings, let's catch it *relatively* quick. Five minutes seems good. _sessionCache = SessionCache("beginnertriathlete", lifetime=timedelta(minutes=5), freshen_on_get=False) # Private fields _urlRoot = "https://beginnertriathlete.com/WebAPI/api/" _loginUrlRoot = _urlRoot + "login/" _sbrEventsUrlRoot = _urlRoot + "sbreventsummary/" _sbrEventDeleteUrlRoot = _urlRoot + "deletesbrevent/" _deviceUploadUrl = _urlRoot + "deviceupload/" _accountSettingsUrl = _urlRoot + "GeneralSettings/" _accountProfileUrl = _urlRoot + "profilesettings/" _accountInformationUrl = _urlRoot + "accountinformation/" _viewEntryUrl = "https://beginnertriathlete.com/discussion/training/view-event.asp?id=" _dateFormat = "{d.month}/{d.day}/{d.year}" _serverDefaultTimezone = "US/Central" _workoutTypeMappings = { "3": ActivityType.Swimming, "1": ActivityType.Cycling, "2": ActivityType.Running } _mimeTypeMappings = { "application/gpx+xml": _DeviceFileTypes.GPX, "application/vnd.garmin.tcx+xml": _DeviceFileTypes.TCX, "application/vnd.ant.fit": _DeviceFileTypes.FIT } _fileExtensionMappings = { ".gpx": _DeviceFileTypes.GPX, ".tcx": _DeviceFileTypes.TCX, ".fit": _DeviceFileTypes.FIT } SupportedActivities = [ ActivityType.Running, ActivityType.Cycling, ActivityType.MountainBiking, ActivityType.Walking, ActivityType.Swimming] def WebInit(self): self.UserAuthorizationURL = WEB_ROOT + reverse("auth_simple", kwargs={"service": self.ID}) # Exchange username & password for a UserToken and store it in ExtendedAuthorization if the user has elected to # remember login details. def Authorize(self, username, password): session = self._prepare_request() requestParameters = {"username": username, "password": password} user_resp = session.get(self._loginUrlRoot, params=requestParameters) if user_resp.status_code != 200: raise APIException("Login error") response = user_resp.json() if response["LoginResponseCode"] == 3: from tapiriik.auth.credential_storage import CredentialStore member_id = int(response["MemberId"]) token = response["UserToken"] return member_id, {}, {"UserToken": CredentialStore.Encrypt(token)} if response["LoginResponseCode"] == 0: raise APIException("Invalid API key") # Incorrect username or password if response["LoginResponseCode"] == -3 or response["LoginResponseCode"] == -2 or response["LoginResponseCode"] == -1: raise APIException( "Invalid login - Bad username or password", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) # Account is inactive or locked out - Rarely would happen if response["LoginResponseCode"] == 1 or response["LoginResponseCode"] == 2: raise APIException( "Invalid login - Account is inactive", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) # Something extra unusual has happened raise APIException( "Invalid login - Unknown error", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) # Get an activity summary over a date range def DownloadActivityList(self, serviceRecord, exhaustive=False): activities = [] if exhaustive: listEnd = datetime.now().date() + timedelta(days=1.5) firstEntry = self._getFirstTrainingEntryForMember(self._getUserToken(serviceRecord)) listStart = dateutil.parser.parse(firstEntry).date() else: listEnd = datetime.now() + timedelta(days=1.5) listStart = listEnd - timedelta(days=60) # Set headers necessary for a successful API request session = self._prepare_request(self._getUserToken(serviceRecord)) settings = self._getUserSettings(serviceRecord) # Iterate through the date range 60 days at a time. Dates are inclusive for all events on that date, # and do not contain timestamps. 5/1/20xx through 5/2/20xx would include all events 5/1 => 5/2 11:59:59PM while listStart < listEnd: pageDate = listStart + timedelta(days=59) if pageDate > listEnd: pageDate = listEnd print("Requesting %s to %s" % (listStart, pageDate)) # Request their actual logged data. Not their workout plan. Start and end date are inclusive and # the end date includes everything up until midnight, that day # Member ID can be sent as zero because we are retrieving our token's data, not someone else's. We # could store & supply the user's member id, but it would gain nothing requestParameters = { "startDate": self._dateFormat.format(d=listStart), "endDate": self._dateFormat.format(d=pageDate), "planned": "false", "memberid": "0"} workouts_resp = session.get(self._sbrEventsUrlRoot, params=requestParameters) if workouts_resp.status_code != 200: if workouts_resp.status_code == 401: # After login, the API does not differentiate between an unauthorized token and an invalid API key raise APIException( "Invalid login or API key", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) # Some other kind of error has occurred. It could be a server error. raise APIException("Workout listing error") workouts = workouts_resp.json() for workout in workouts: activity = self._populate_sbr_activity(workout, settings) activities.append(activity) listStart = listStart + timedelta(days=60) return activities, [] # Populate an activity with the information from BT's sbreventsummary endpoint. Contains basic data like # event type, duration, pace, HR, date and time. At the moment, manually entered laps are not reported. # Detailed activity data, laps, and GPS may be present in a .fit, .tcx, or .gpx file if the record came # from a device upload def _populate_sbr_activity(self, api_sbr_activity, usersettings): # Example JSON feed (unimportant fields have been removed) # [{ # "EventId": 63128401, # Internal ID # "EventType": 3, # Swim (3), bike (1), or run (2) # "EventDate": "4/22/2016", # "EventTime": "7:44 AM", # User's time, time zone not specified # "Planned": false, # Training plan or actual data # "TotalMinutes": 34.97, # "TotalKilometers": 1.55448, # "AverageHeartRate": 125, # "MinimumHeartRate": 100, # "MaximumHeartRate": 150, # "MemberId": 999999, # "MemberUsername": "******", # "HasDeviceUpload": true, # "DeviceUploadFile": "http://beginnertriathlete.com/discussion/storage/workouts/555555/abcd-123.fit", # "RouteName": "", # Might contain a description of the event # "Comments": "", # Same as above. Not overly often used. # }, ... ] activity = UploadedActivity() workout_id = api_sbr_activity["EventId"] eventType = api_sbr_activity["EventType"] eventDate = api_sbr_activity["EventDate"] eventTime = api_sbr_activity["EventTime"] totalMinutes = api_sbr_activity["TotalMinutes"] totalKms = api_sbr_activity["TotalKilometers"] averageHr = api_sbr_activity["AverageHeartRate"] minimumHr = api_sbr_activity["MinimumHeartRate"] maximumHr = api_sbr_activity["MaximumHeartRate"] deviceUploadFile = api_sbr_activity["DeviceUploadFile"] # Basic SBR data does not include GPS or sensor data. If this event originated from a device upload, # DownloadActivity will find it. activity.Stationary = True # Same as above- The data might be there, but it's not supplied in the basic activity feed. activity.GPS = False activity.Private = usersettings["Privacy"] activity.Type = self._workoutTypeMappings[str(eventType)] # Get the user's timezone from their profile. (Activity.TZ should be mentioned in the object hierarchy docs?) # Question: I believe if DownloadActivity finds device data, it will overwrite this. Which is OK with me. # The device data will most likely be more accurate. try: activity.TZ = pytz.timezone(usersettings["TimeZone"]) except pytz.exceptions.UnknownTimeZoneError: activity.TZ = pytz.timezone(self._serverDefaultTimezone) # activity.StartTime and EndTime aren't mentioned in the object hierarchy docs, but I see them # set in all the other providers. activity.StartTime = dateutil.parser.parse( eventDate + " " + eventTime, dayfirst=False).replace(tzinfo=activity.TZ) activity.EndTime = activity.StartTime + timedelta(minutes=totalMinutes) # We can calculate some metrics from the supplied data. Would love to see some non-source code documentation # on each statistic and what it expects as input. activity.Stats.Distance = ActivityStatistic(ActivityStatisticUnit.Kilometers, value=totalKms) activity.Stats.HR = ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, avg=float(averageHr), min=float(minimumHr), max=float(maximumHr)) activity.Stats.MovingTime = ActivityStatistic(ActivityStatisticUnit.Seconds, value=float(totalMinutes * 60)) activity.Stats.TimerTime = ActivityStatistic(ActivityStatisticUnit.Seconds, value=float(totalMinutes * 60)) # While BT does support laps, the current API doesn't report on them - a limitation that may need to be # corrected in a future update. For now, treat manual entries as a single lap. As more and more people upload # workouts using devices anyway, this probably matters much less than it once did. lap = Lap(stats=activity.Stats, startTime=activity.StartTime, endTime=activity.EndTime) activity.Laps = [lap] # Not 100% positive how this is utilized, but it is common for all providers. Detects duplicate downloads? activity.CalculateUID() # If a device file is attached, we'll get more details about this event in DownloadActivity activity.ServiceData = { "ID": int(workout_id), "DeviceUploadFile": deviceUploadFile } return activity def DownloadActivity(self, serviceRecord, activity): deviceUploadFile = activity.ServiceData.get("DeviceUploadFile") # No additional data about this event is available. if not deviceUploadFile: return activity logger.info("Downloading device file %s" % deviceUploadFile) session = self._prepare_request(self._getUserToken(serviceRecord)) res = session.get(deviceUploadFile) if res.status_code == 200: try: contentType = self._mimeTypeMappings[res.headers["content-type"]] if not contentType: remoteUrl = urlparse(deviceUploadFile).path extension = os.path.splitext(remoteUrl)[1] contentType = self._fileExtensionMappings[extension] if contentType: if contentType == _DeviceFileTypes.FIT: # Oh no! Not supported! So close .... # FITIO.Parse(res.content, activity) return activity if contentType == _DeviceFileTypes.TCX: TCXIO.Parse(res.content, activity) if contentType == _DeviceFileTypes.GPX: GPXIO.Parse(res.content, activity) except ValueError as e: raise APIExcludeActivity("Parse error " + deviceUploadFile + " " + str(e), user_exception=UserException(UserExceptionType.Corrupt), permanent=True) return activity def UploadActivity(self, serviceRecord, activity): # Upload the workout as a .FIT file session = self._prepare_request(self._getUserToken(serviceRecord)) uploaddata = FITIO.Dump(activity) files = {"deviceFile": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit", uploaddata)} response = session.post(self._deviceUploadUrl, files=files) if response.status_code != 200: raise APIException( "Error uploading workout", block=False) responseJson = response.json() if not responseJson['Success']: raise APIException( "Error uploading workout - " + response.Message, block=False) # The upload didn't return a PK for some reason. The queue might be stuck or some other internal error # but that doesn't necessarily warrant a reupload. eventId = responseJson["EventId"] if eventId == 0: return None return eventId def RevokeAuthorization(self, serviceRecord): # nothing to do here... pass def UserUploadedActivityURL(self, uploadId): return self._viewEntryUrl + str(uploadId) def DeleteActivity(self, serviceRecord, uploadId): session = self._prepare_request(self._getUserToken(serviceRecord)) requestParameters = {"id": uploadId} response = session.post(self._sbrEventDeleteUrlRoot, params=requestParameters) self._handleHttpErrorCodes(response) responseJson = response.json() if not responseJson: raise APIException( "Error deleting workout - " + uploadId, block=False) def DeleteCachedData(self, serviceRecord): # nothing to do here... pass # Sets the API key header necessary for all requests, and optionally the authentication token too. def _prepare_request(self, userToken=None): session = requests.Session() session.headers.update(self._set_request_api_headers()) # If the serviceRecord was included, try to include the UserToken, authenticating the request # The service record will contain ExtendedAuthorization data if the user chose to remember login details. if userToken: session.headers.update(self._set_request_authentication_header(userToken)) return session # The APIKey header is required for all requests. A key can be obtained by emailing [email protected]. def _set_request_api_headers(self): return {"APIKey": BT_APIKEY} # Upon successful authentication by Authorize, the ExtendedAuthorization dict will have a UserToken def _set_request_authentication_header(self, userToken): return {"UserToken": userToken} def _getFirstTrainingEntryForMember(self, userToken): session = self._prepare_request(userToken) response = session.get(self._accountInformationUrl) self._handleHttpErrorCodes(response) try: responseJson = response.json() return responseJson['FirstTrainingLog'] except ValueError as e: raise APIException("Parse error reading profile JSON " + str(e)) def _getUserSettings(self, serviceRecord, skip_cache=False): cached = self._sessionCache.Get(serviceRecord.ExternalID) if cached and not skip_cache: return cached if serviceRecord: timeZone = self._getTimeZone(self._getUserToken(serviceRecord)) privacy = self._getPrivacy(self._getUserToken(serviceRecord)) cached = { "TimeZone": timeZone, "Privacy": privacy } self._sessionCache.Set(serviceRecord.ExternalID, cached) return cached def _getUserToken(self, serviceRecord): userToken = None if serviceRecord: from tapiriik.auth.credential_storage import CredentialStore userToken = CredentialStore.Decrypt(serviceRecord.ExtendedAuthorization["UserToken"]) return userToken def _getTimeZone(self, token): session = self._prepare_request(token) response = session.get(self._accountSettingsUrl) self._handleHttpErrorCodes(response) try: # BT does not record whether the user observes DST and I am not even attempting to guess. responseJson = response.json() timezone = responseJson["UtcOffset"] if timezone == 0: timezoneStr = "Etc/GMT" elif timezone > 0: timezoneStr = "Etc/GMT+" + str(timezone) elif timezone < 0: timezoneStr = "Etc/GMT" + str(timezone) return timezoneStr except ValueError as e: raise APIException("Parse error reading profile JSON " + str(e)) def _getPrivacy(self, token): session = self._prepare_request(token) response = session.get(self._accountProfileUrl) self._handleHttpErrorCodes(response) try: # public - Everyone. Public # publicrestricted - Registered members. Public # friends - BT friends only. Private # private - Private responseJson = response.json() privacy = responseJson["TrainingPrivacy"] return not (privacy == "public" or privacy == "publicrestricted") except ValueError as e: raise APIException("Parse error reading privacy JSON " + str(e)) def _handleHttpErrorCodes(self, response): if response.status_code != 200: if response.status_code == 401: # After login, the API does not differentiate between an unauthorized token and an invalid API key raise APIException( "Invalid login or API key", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) # Server error? raise APIException( "HTTP error " + str(response.status_code), block=True)
class TrainingPeaksService(ServiceBase): ID = "trainingpeaks" DisplayName = "TrainingPeaks" DisplayAbbreviation = "TP" AuthenticationType = ServiceAuthenticationType.OAuth RequiresExtendedAuthorizationDetails = False ReceivesStationaryActivities = False SuppliesActivities = False AuthenticationNoFrame = True SupportsExhaustiveListing = False SupportsHR = SupportsCadence = SupportsTemp = SupportsPower = True # Not-so-coincidentally, similar to PWX. _workoutTypeMappings = { "bike": ActivityType.Cycling, "run": ActivityType.Running, "walk": ActivityType.Walking, "swim": ActivityType.Swimming, "mtb": ActivityType.MountainBiking, "xc-Ski": ActivityType.CrossCountrySkiing, "rowing": ActivityType.Rowing, "x-train": ActivityType.Other, "strength": ActivityType.Other, "other": ActivityType.Other, } SupportedActivities = ActivityType.List() # All. _redirect_url = "https://www.siiink.com/auth/return/trainingpeaks" _tokenCache = SessionCache("trainingpeaks", lifetime=timedelta(minutes=30), freshen_on_get=False) def WebInit(self): self.UserAuthorizationURL = TRAININGPEAKS_OAUTH_BASE_URL + "/oauth/authorize?" + urlencode( { "client_id": TRAININGPEAKS_CLIENT_ID, "response_type": "code", "redirect_uri": self._redirect_url, "scope": TRAININGPEAKS_CLIENT_SCOPE }) def RetrieveAuthorizationToken(self, req, level): code = req.GET.get("code") params = { "client_id": TRAININGPEAKS_CLIENT_ID, "client_secret": TRAININGPEAKS_CLIENT_SECRET, "grant_type": "authorization_code", "code": code, "redirect_uri": self._redirect_url } req_url = TRAININGPEAKS_OAUTH_BASE_URL + "/oauth/token" response = requests.post(req_url, data=params) if response.status_code != 200: raise APIException("Invalid code") auth_data = response.json() profile_data = requests.get(TRAININGPEAKS_API_BASE_URL + "/v1/athlete/profile", headers={ "Authorization": "Bearer %s" % auth_data["access_token"] }).json() if type(profile_data) is list and any("is not a valid athlete" in x for x in profile_data): raise APIException("TP user is coach account", block=True, user_exception=UserException( UserExceptionType.NonAthleteAccount, intervention_required=True)) return (profile_data["Id"], { "RefreshToken": auth_data["refresh_token"] }) def _apiHeaders(self, serviceRecord): # The old API was username/password, and the new API provides no means to automatically upgrade these credentials. if not serviceRecord.Authorization or "RefreshToken" not in serviceRecord.Authorization: raise APIException("TP user lacks OAuth credentials", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) token = self._tokenCache.Get(serviceRecord.ExternalID) if not token: # Use refresh token to get access token # Hardcoded return URI to get around the lack of URL reversing without loading up all the Django stuff params = { "client_id": TRAININGPEAKS_CLIENT_ID, "client_secret": TRAININGPEAKS_CLIENT_SECRET, "grant_type": "refresh_token", "refresh_token": serviceRecord.Authorization["RefreshToken"], # "redirect_uri": self._redirect_url } headers = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post(TRAININGPEAKS_OAUTH_BASE_URL + "/oauth/token", data=urlencode(params), headers=headers) if response.status_code != 200: if response.status_code >= 400 and response.status_code < 500: raise APIException( "Could not retrieve refreshed token %s %s" % (response.status_code, response.text), block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) raise APIException("Could not retrieve refreshed token %s %s" % (response.status_code, response.text)) token = response.json()["access_token"] self._tokenCache.Set(serviceRecord.ExternalID, token) return {"Authorization": "Bearer %s" % token} def RevokeAuthorization(self, serviceRecord): pass # No auth tokens to revoke... def DeleteCachedData(self, serviceRecord): pass # No cached data... def DownloadActivityList(self, svcRecord, exhaustive_start_time=None): activities = [] exclusions = [] headers = self._apiHeaders(svcRecord) limitDateFormat = "%Y-%m-%d" if exhaustive_start_time: totalListEnd = datetime.now() + timedelta( days=1.5) # Who knows which TZ it's in totalListStart = exhaustive_start_time - timedelta(days=1.5) else: totalListEnd = datetime.now() + timedelta( days=1.5) # Who knows which TZ it's in totalListStart = totalListEnd - timedelta( days=20) # Doesn't really matter listStep = timedelta(days=45) listEnd = totalListEnd listStart = max(totalListStart, totalListEnd - listStep) while True: logger.debug("Requesting %s to %s" % (listStart, listEnd)) resp = requests.get(TRAININGPEAKS_API_BASE_URL + "/v1/workouts/%s/%s" % (listStart.strftime(limitDateFormat), listEnd.strftime(limitDateFormat)), headers=headers) for act in resp.json(): if not act.get("completed", True): continue activity = UploadedActivity() activity.StartTime = dateutil.parser.parse( act["StartTime"]).replace(tzinfo=None) logger.debug("Activity s/t " + str(activity.StartTime)) activity.EndTime = activity.StartTime + timedelta( hours=act["TotalTime"]) activity.Name = act.get("Title", None) activity.Notes = act.get("Description", None) activity.Type = self._workoutTypeMappings.get( act.get("WorkoutType", "").lower(), ActivityType.Other) activity.Stats.Cadence = ActivityStatistic( ActivityStatisticUnit.RevolutionsPerMinute, avg=act.get("CadenceAverage", None), max=act.get("CadenceMaximum", None)) activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=act.get("Distance", None)) activity.Stats.Elevation = ActivityStatistic( ActivityStatisticUnit.Meters, avg=act.get("ElevationAverage", None), min=act.get("ElevationMinimum", None), max=act.get("ElevationMaximum", None), gain=act.get("ElevationGain", None), loss=act.get("ElevationLoss", None)) activity.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilojoules, value=act.get("Energy", None)) activity.Stats.HR = ActivityStatistic( ActivityStatisticUnit.BeatsPerMinute, avg=act.get("HeartRateAverage", None), min=act.get("HeartRateMinimum", None), max=act.get("HeartRateMaximum", None)) activity.Stats.Power = ActivityStatistic( ActivityStatisticUnit.Watts, avg=act.get("PowerAverage", None), max=act.get("PowerMaximum", None)) activity.Stats.Temperature = ActivityStatistic( ActivityStatisticUnit.DegreesCelcius, avg=act.get("TemperatureAverage", None), min=act.get("TemperatureMinimum", None), max=act.get("TemperatureMaximum", None)) activity.Stats.Speed = ActivityStatistic( ActivityStatisticUnit.MetersPerSecond, avg=act.get("VelocityAverage", None), max=act.get("VelocityMaximum", None)) activity.CalculateUID() activities.append(activity) if not exhaustive_start_time: break listStart -= listStep listEnd -= listStep if listEnd < totalListStart: break return activities, exclusions def UploadActivity(self, svcRecord, activity): pwxdata_gz = BytesIO() with gzip.GzipFile(fileobj=pwxdata_gz, mode="w") as gzf: gzf.write(PWXIO.Dump(activity).encode("utf-8")) headers = self._apiHeaders(svcRecord) headers.update({"Content-Type": "application/json"}) data = { "UploadClient": "tapiriik", "Filename": "tap-%s.pwx" % activity.UID, "SetWorkoutPublic": not activity.Private, # NB activity notes and name are in the PWX. "Data": base64.b64encode(pwxdata_gz.getvalue()).decode("ascii") } resp = requests.post(TRAININGPEAKS_API_BASE_URL + "/v1/file", data=json.dumps(data), headers=headers) if resp.status_code != 200: raise APIException("Unable to upload activity response " + resp.text + " status " + str(resp.status_code)) return resp.json()[0]["Id"]
class GarminConnectService(ServiceBase): ID = "garminconnect" DisplayName = "Garmin Connect" DisplayAbbreviation = "GC" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True _activityMappings = { "running": ActivityType.Running, "cycling": ActivityType.Cycling, "mountain_biking": ActivityType.MountainBiking, "walking": ActivityType.Walking, "hiking": ActivityType.Hiking, "resort_skiing_snowboarding": ActivityType.DownhillSkiing, "cross_country_skiing": ActivityType.CrossCountrySkiing, "skate_skiing": ActivityType.CrossCountrySkiing, # Well, it ain't downhill? "backcountry_skiing_snowboarding": ActivityType.CrossCountrySkiing, # ish "skating": ActivityType.Skating, "swimming": ActivityType.Swimming, "rowing": ActivityType.Rowing, "elliptical": ActivityType.Elliptical, "fitness_equipment": ActivityType.Gym, "all": ActivityType.Other # everything will eventually resolve to this } _reverseActivityMappings = { # Removes ambiguities when mapping back to their activity types "running": ActivityType.Running, "cycling": ActivityType.Cycling, "mountain_biking": ActivityType.MountainBiking, "walking": ActivityType.Walking, "hiking": ActivityType.Hiking, "resort_skiing_snowboarding": ActivityType.DownhillSkiing, "cross_country_skiing": ActivityType.CrossCountrySkiing, "skating": ActivityType.Skating, "swimming": ActivityType.Swimming, "rowing": ActivityType.Rowing, "elliptical": ActivityType.Elliptical, "fitness_equipment": ActivityType.Gym, "other": ActivityType.Other # I guess? (vs. "all" that is) } SupportedActivities = list(_activityMappings.values()) SupportsHR = SupportsCadence = True _sessionCache = SessionCache(lifetime=timedelta(minutes=30), freshen_on_get=True) _unitMap = { "mph": ActivityStatisticUnit.MilesPerHour, "kph": ActivityStatisticUnit.KilometersPerHour, "hmph": ActivityStatisticUnit.HectometersPerHour, "hydph": ActivityStatisticUnit.HundredYardsPerHour, "celcius": ActivityStatisticUnit.DegreesCelcius, "fahrenheit": ActivityStatisticUnit.DegreesFahrenheit, "mile": ActivityStatisticUnit.Miles, "kilometer": ActivityStatisticUnit.Kilometers, "foot": ActivityStatisticUnit.Feet, "meter": ActivityStatisticUnit.Meters, "yard": ActivityStatisticUnit.Yards, "kilocalorie": ActivityStatisticUnit.Kilocalories, "bpm": ActivityStatisticUnit.BeatsPerMinute, "stepsPerMinute": ActivityStatisticUnit.StepsPerMinute, "rpm": ActivityStatisticUnit.RevolutionsPerMinute, "watt": ActivityStatisticUnit.Watts } def __init__(self): cachedHierarchy = cachedb.gc_type_hierarchy.find_one() if not cachedHierarchy: rawHierarchy = requests.get( "http://connect.garmin.com/proxy/activity-service-1.2/json/activity_types" ).text self._activityHierarchy = json.loads(rawHierarchy)["dictionary"] cachedb.gc_type_hierarchy.insert({"Hierarchy": rawHierarchy}) else: self._activityHierarchy = json.loads( cachedHierarchy["Hierarchy"])["dictionary"] rate_lock_path = "/tmp/gc_rate.%s.lock" % HTTP_SOURCE_ADDR # Ensure the rate lock file exists (...the easy way) open(rate_lock_path, "a").close() self._rate_lock = open(rate_lock_path, "r+") def _rate_limit(self): import fcntl, struct, time min_period = 1 # I appear to been banned from Garmin Connect while determining this. print("Waiting for lock") fcntl.flock(self._rate_lock, fcntl.LOCK_EX) try: print("Have lock") self._rate_lock.seek(0) last_req_start = self._rate_lock.read() if not last_req_start: last_req_start = 0 else: last_req_start = float(last_req_start) wait_time = max(0, min_period - (time.time() - last_req_start)) time.sleep(wait_time) self._rate_lock.seek(0) self._rate_lock.write(str(time.time())) self._rate_lock.flush() print("Rate limited for %f" % wait_time) finally: fcntl.flock(self._rate_lock, fcntl.LOCK_UN) def _get_cookies(self, record=None, email=None, password=None): from tapiriik.auth.credential_storage import CredentialStore if record: cached = self._sessionCache.Get(record.ExternalID) if cached: return cached # longing for C style overloads... password = CredentialStore.Decrypt( record.ExtendedAuthorization["Password"]) email = CredentialStore.Decrypt( record.ExtendedAuthorization["Email"]) for x in range(10): self._rate_limit() gcPreResp = requests.get("http://connect.garmin.com/", allow_redirects=False) # New site gets this redirect, old one does not if gcPreResp.status_code == 200: self._rate_limit() 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: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) 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": "http://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! self._rate_limit() gcRedeemResp1 = requests.get( "http://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)) self._rate_limit() 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)) if record: self._sessionCache.Set(record.ExternalID, gcPreResp.cookies) return gcPreResp.cookies def WebInit(self): self.UserAuthorizationURL = WEB_ROOT + reverse( "auth_simple", kwargs={"service": self.ID}) def Authorize(self, email, password): from tapiriik.auth.credential_storage import CredentialStore cookies = self._get_cookies(email=email, password=password) self._rate_limit() username = requests.get("http://connect.garmin.com/user/username", cookies=cookies).json()["username"] if not len(username): raise APIException("Unable to retrieve username", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) return (username, {}, { "Email": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password) }) def _resolveActivityType(self, act_type): # Mostly there are two levels of a hierarchy, so we don't really need this as the parent is included in the listing. # But maybe they'll change that some day? while act_type not in self._activityMappings: try: act_type = [ x["parent"]["key"] for x in self._activityHierarchy if x["key"] == act_type ][0] except IndexError: raise ValueError( "Activity type not found in activity hierarchy") return self._activityMappings[act_type] def DownloadActivityList(self, serviceRecord, exhaustive=False): #http://connect.garmin.com/proxy/activity-search-service-1.0/json/activities?&start=0&limit=50 cookies = self._get_cookies(record=serviceRecord) page = 1 pageSz = 100 activities = [] exclusions = [] while True: logger.debug("Req with " + str({ "start": (page - 1) * pageSz, "limit": pageSz })) self._rate_limit() res = requests.get( "http://connect.garmin.com/proxy/activity-search-service-1.0/json/activities", params={ "start": (page - 1) * pageSz, "limit": pageSz }, cookies=cookies) res = res.json()["results"] if "activities" not in res: break # No activities on this page - empty account. for act in res["activities"]: act = act["activity"] if "sumDistance" not in act: exclusions.append( APIExcludeActivity("No distance", activityId=act["activityId"], userException=UserException( UserExceptionType.Corrupt))) continue activity = UploadedActivity() if "sumSampleCountSpeed" not in act and "sumSampleCountTimestamp" not in act: # Don't really know why sumSampleCountTimestamp doesn't appear in swim activities - they're definitely timestamped... activity.Stationary = True else: activity.Stationary = False try: activity.TZ = pytz.timezone(act["activityTimeZone"]["key"]) except pytz.exceptions.UnknownTimeZoneError: activity.TZ = pytz.FixedOffset( float(act["activityTimeZone"]["offset"]) * 60) logger.debug("Name " + act["activityName"]["value"] + ":") if len(act["activityName"]["value"].strip( )) and act["activityName"][ "value"] != "Untitled": # This doesn't work for internationalized accounts, oh well. activity.Name = act["activityName"]["value"] if len(act["activityDescription"]["value"].strip()): activity.Notes = act["activityDescription"]["value"] # beginTimestamp/endTimestamp is in UTC activity.StartTime = pytz.utc.localize( datetime.utcfromtimestamp( float(act["beginTimestamp"]["millis"]) / 1000)) if "sumElapsedDuration" in act: activity.EndTime = activity.StartTime + timedelta( 0, round(float(act["sumElapsedDuration"]["value"]))) elif "sumDuration" in act: activity.EndTime = activity.StartTime + timedelta( minutes=float(act["sumDuration"] ["minutesSeconds"].split(":")[0]), seconds=float(act["sumDuration"] ["minutesSeconds"].split(":")[1])) else: activity.EndTime = pytz.utc.localize( datetime.utcfromtimestamp( float(act["endTimestamp"]["millis"]) / 1000)) logger.debug("Activity s/t " + str(activity.StartTime) + " on page " + str(page)) activity.AdjustTZ() # TODO: fix the distance stats to account for the fact that this incorrectly reported km instead of meters for the longest time. activity.Stats.Distance = ActivityStatistic( self._unitMap[act["sumDistance"]["uom"]], value=float(act["sumDistance"]["value"])) def mapStat(gcKey, statKey, type, useSourceUnits=False): nonlocal activity, act if gcKey in act: value = float(act[gcKey]["value"]) if math.isinf(value): return # GC returns the minimum speed as "-Infinity" instead of 0 some times :S activity.Stats.__dict__[statKey].update( ActivityStatistic(self._unitMap[act[gcKey]["uom"]], **({ type: value }))) if useSourceUnits: activity.Stats.__dict__[ statKey] = activity.Stats.__dict__[ statKey].asUnits( self._unitMap[act[gcKey]["uom"]]) if "sumMovingDuration" in act: activity.Stats.MovingTime = ActivityStatistic( ActivityStatisticUnit.Time, value=timedelta( seconds=float(act["sumMovingDuration"]["value"]))) if "sumDuration" in act: activity.Stats.TimerTime = ActivityStatistic( ActivityStatisticUnit.Time, value=timedelta( minutes=float(act["sumDuration"] ["minutesSeconds"].split(":")[0]), seconds=float(act["sumDuration"] ["minutesSeconds"].split(":")[1]))) mapStat( "minSpeed", "Speed", "min", useSourceUnits=True ) # We need to suppress conversion here, so we can fix the pace-speed issue below mapStat("maxSpeed", "Speed", "max", useSourceUnits=True) mapStat("weightedMeanSpeed", "Speed", "avg", useSourceUnits=True) mapStat("minAirTemperature", "Temperature", "min") mapStat("maxAirTemperature", "Temperature", "max") mapStat("weightedMeanAirTemperature", "Temperature", "avg") mapStat("sumEnergy", "Energy", "value") mapStat("maxHeartRate", "HR", "max") mapStat("weightedMeanHeartRate", "HR", "avg") mapStat("maxRunCadence", "RunCadence", "max") mapStat("weightedMeanRunCadence", "RunCadence", "avg") mapStat("maxBikeCadence", "Cadence", "max") mapStat("weightedMeanBikeCadence", "Cadence", "avg") mapStat("minPower", "Power", "min") mapStat("maxPower", "Power", "max") mapStat("weightedMeanPower", "Power", "avg") mapStat("minElevation", "Elevation", "min") mapStat("maxElevation", "Elevation", "max") mapStat("gainElevation", "Elevation", "gain") mapStat("lossElevation", "Elevation", "loss") # In Garmin Land, max can be smaller than min for this field :S if activity.Stats.Power.Max is not None and activity.Stats.Power.Min is not None and activity.Stats.Power.Min > activity.Stats.Power.Max: activity.Stats.Power.Min = None # To get it to match what the user sees in GC. if activity.Stats.RunCadence.Max is not None: activity.Stats.RunCadence.Max *= 2 if activity.Stats.RunCadence.Average is not None: activity.Stats.RunCadence.Average *= 2 # GC incorrectly reports pace measurements as kph/mph when they are in fact in min/km or min/mi if "minSpeed" in act: if ":" in act["minSpeed"][ "withUnitAbbr"] and activity.Stats.Speed.Min: activity.Stats.Speed.Min = 60 / activity.Stats.Speed.Min if "maxSpeed" in act: if ":" in act["maxSpeed"][ "withUnitAbbr"] and activity.Stats.Speed.Max: activity.Stats.Speed.Max = 60 / activity.Stats.Speed.Max if "weightedMeanSpeed" in act: if ":" in act["weightedMeanSpeed"][ "withUnitAbbr"] and activity.Stats.Speed.Average: activity.Stats.Speed.Average = 60 / activity.Stats.Speed.Average # Similarly, they do weird stuff with HR at times - %-of-max and zones # ...and we can't just fix these, so we have to calculate it after the fact (blegh) recalcHR = False if "maxHeartRate" in act: if "%" in act["maxHeartRate"]["withUnitAbbr"] or "z" in act[ "maxHeartRate"]["withUnitAbbr"]: activity.Stats.HR.Max = None recalcHR = True if "weightedMeanHeartRate" in act: if "%" in act["weightedMeanHeartRate"][ "withUnitAbbr"] or "z" in act[ "weightedMeanHeartRate"]["withUnitAbbr"]: activity.Stats.HR.Average = None recalcHR = True activity.Type = self._resolveActivityType( act["activityType"]["key"]) activity.CalculateUID() activity.ServiceData = { "ActivityID": act["activityId"], "RecalcHR": recalcHR } activities.append(activity) logger.debug("Finished page " + str(page) + " of " + str(res["search"]["totalPages"])) if not exhaustive or int(res["search"]["totalPages"]) == page: break else: page += 1 return activities, exclusions def DownloadActivity(self, serviceRecord, activity): #http://connect.garmin.com/proxy/activity-service-1.1/tcx/activity/#####?full=true activityID = activity.ServiceData["ActivityID"] cookies = self._get_cookies(record=serviceRecord) self._rate_limit() res = requests.get( "http://connect.garmin.com/proxy/activity-service-1.1/tcx/activity/" + str(activityID) + "?full=true", cookies=cookies) try: TCXIO.Parse(res.content, activity) except ValueError as e: raise APIExcludeActivity("TCX parse error " + str(e), userException=UserException( UserExceptionType.Corrupt)) if activity.ServiceData["RecalcHR"]: logger.debug("Recalculating HR") avgHR, maxHR = ActivityStatisticCalculator.CalculateAverageMaxHR( activity) activity.Stats.HR.coalesceWith( ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, max=maxHR, avg=avgHR)) if len(activity.Laps) == 1: activity.Laps[0].Stats.update( activity.Stats ) # I trust Garmin Connect's stats more than whatever shows up in the TCX activity.Stats = activity.Laps[ 0].Stats # They must be identical to pass the verification if activity.Stats.Temperature.Min is not None or activity.Stats.Temperature.Max is not None or activity.Stats.Temperature.Average is not None: logger.debug("Retrieving additional temperature data") # TCX doesn't have temperature, for whatever reason... self._rate_limit() res = requests.get( "http://connect.garmin.com/proxy/activity-service-1.1/gpx/activity/" + str(activityID) + "?full=true", cookies=cookies) try: temp_act = GPXIO.Parse(res.content, suppress_validity_errors=True) except ValueError as e: pass else: logger.debug("Merging additional temperature data") full_waypoints = activity.GetFlatWaypoints() temp_waypoints = temp_act.GetFlatWaypoints() merge_idx = 0 for x in range(len(temp_waypoints)): while full_waypoints[merge_idx].Timestamp < temp_waypoints[ x].Timestamp and merge_idx < len( full_waypoints) - 1: merge_idx += 1 full_waypoints[merge_idx].Temp = temp_waypoints[x].Temp return activity def UploadActivity(self, serviceRecord, activity): #/proxy/upload-service-1.1/json/upload/.fit fit_file = FITIO.Dump(activity) files = { "data": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit", fit_file) } cookies = self._get_cookies(record=serviceRecord) self._rate_limit() res = requests.post( "http://connect.garmin.com/proxy/upload-service-1.1/json/upload/.tcx", files=files, cookies=cookies) res = res.json()["detailedImportResult"] if len(res["successes"]) == 0: raise APIException("Unable to upload activity") if len(res["successes"]) > 1: raise APIException( "Uploaded succeeded, resulting in too many activities") actid = res["successes"][0]["internalId"] encoding_headers = { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" } # GC really, really needs this part, otherwise it throws obscure errors like "Invalid signature for signature method HMAC-SHA1" warnings = [] try: if activity.Name and activity.Name.strip(): self._rate_limit() res = requests.post( "http://connect.garmin.com/proxy/activity-service-1.2/json/name/" + str(actid), data={"value": activity.Name.encode("UTF-8")}, cookies=cookies, headers=encoding_headers) try: res = res.json() except: raise APIWarning("Activity name request failed - %s" % res.text) if "display" not in res or res["display"][ "value"] != activity.Name: raise APIWarning("Unable to set activity name") except APIWarning as e: warnings.append(e) try: if activity.Notes and activity.Notes.strip(): self._rate_limit() res = requests.post( "https://connect.garmin.com/proxy/activity-service-1.2/json/description/" + str(actid), data={"value": activity.Notes.encode("UTF-8")}, cookies=cookies, headers=encoding_headers) try: res = res.json() except: raise APIWarning("Activity notes request failed - %s" % res.text) if "display" not in res or res["display"][ "value"] != activity.Notes: raise APIWarning("Unable to set activity notes") except APIWarning as e: warnings.append(e) try: if activity.Type not in [ ActivityType.Running, ActivityType.Cycling, ActivityType.Other ]: # Set the legit activity type - whatever it is, it's not supported by the TCX schema acttype = [ k for k, v in self._reverseActivityMappings.items() if v == activity.Type ] if len(acttype) == 0: raise APIWarning( "GarminConnect does not support activity type " + activity.Type) else: acttype = acttype[0] self._rate_limit() res = requests.post( "https://connect.garmin.com/proxy/activity-service-1.2/json/type/" + str(actid), data={"value": acttype}, cookies=cookies) res = res.json() if "activityType" not in res or res["activityType"][ "key"] != acttype: raise APIWarning("Unable to set activity type") except APIWarning as e: warnings.append(e) if len(warnings): raise APIWarning(str(warnings)) # Meh return actid def RevokeAuthorization(self, serviceRecord): # nothing to do here... pass def DeleteCachedData(self, serviceRecord): # nothing cached... pass
class PolarPersonalTrainerService(ServiceBase): ID = "polarpersonaltrainer" DisplayName = "Polar Personal Trainer" DisplayAbbreviation = "PPT" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True # Will be retired by the end of 2019. Only need to transfer data to another services. ReceivesActivities = False _sessionCache = SessionCache("polarpersonaltrainer", lifetime=timedelta(minutes=30), freshen_on_get=False) # PPT - common # due to we can actually put any sport name in PPT detect some wery common as well as types I personally have # cycling # means different bike presets, hope 4 will cover most cases _reverseActivityMappings = { "cycling": ActivityType.Cycling, "cycling 2": ActivityType.Cycling, "cycling 3": ActivityType.Cycling, "cycling 4": ActivityType.Cycling, "road biking": ActivityType.Cycling, "running": ActivityType.Running, "indoor running": ActivityType.Running, "mtb": ActivityType.MountainBiking, "mountain biking": ActivityType.MountainBiking, "walking": ActivityType.Walking, "skiing": ActivityType.CrossCountrySkiing, "swimming": ActivityType.Swimming, "ows": ActivityType.Swimming, "other sport": ActivityType.Other } def _get_session(self, record=None, username=None, password=None, skip_cache=False): from tapiriik.auth.credential_storage import CredentialStore cached = self._sessionCache.Get( record.ExternalID if record else username) if cached and not skip_cache: logger.debug("Using cached credential") return cached if record: # longing for C style overloads... password = CredentialStore.Decrypt( record.ExtendedAuthorization["Password"]) username = CredentialStore.Decrypt( record.ExtendedAuthorization["Email"]) session = requests.Session() data = {"username": username, "password": password} params = { "response_type": "code", "client_id": "ppt_client_id", "redirect_uri": "https://polarpersonaltrainer.com/oauth.ftl", "scope": "POLAR_SSO" } preResp = session.get("https://auth.polar.com/oauth/authorize", params=params) if preResp.status_code != 200: raise APIException("SSO prestart error {} {}".format( preResp.status_code, preResp.text)) # Extract csrf token bs = BeautifulSoup(preResp.text, "html.parser") csrftoken = bs.find("input", {"name": "_csrf"})["value"] data.update({"_csrf": csrftoken}) ssoResp = session.post("https://auth.polar.com/login", data=data) if ssoResp.status_code != 200 or "temporarily unavailable" in ssoResp.text: raise APIException("SSO error {} {}".format( ssoResp.status_code, ssoResp.text)) if "error" in ssoResp.url: raise APIException("Login exception {}".format(ssoResp.url), user_exception=UserException( UserExceptionType.Authorization)) # Finish auth process passing timezone session.get(ssoResp.url, params={"userTimezone": "-180"}) session.get("https://polarpersonaltrainer.com/user/index.ftl") self._sessionCache.Set(record.ExternalID if record else username, session) return session def Authorize(self, username, password): from tapiriik.auth.credential_storage import CredentialStore self._get_session(username=username, password=password, skip_cache=True) return (username, {}, { "Email": CredentialStore.Encrypt(username), "Password": CredentialStore.Encrypt(password) }) def DownloadActivityList(self, serviceRecord, exhaustive=False): #TODO find out polar session timeout session = self._get_session(serviceRecord) activities = [] exclusions = [] date_format = "{d.day}.{d.month}.{d.year}" end_date = datetime.now() + timedelta(days=1.5) start_date = date(1961, 4, 12) if exhaustive else end_date - timedelta(days=60) params = { "startDate": date_format.format(d=start_date), "endDate": date_format.format(d=end_date) } res = session.get( "https://polarpersonaltrainer.com/user/calendar/inc/listview.ftl", params=params) bs = BeautifulSoup(res.text, "html.parser") for activity_row in bs.select("tr[class^=listRow]"): data_cells = activity_row.findAll("td") info_cell = 0 date_cell = 4 time_cell = 3 result_type_cell = 5 sport_type_cell = 6 type_data = data_cells[info_cell].find( "input", {"name": "calendarItemTypes"}) # Skip fitness data whatever if type_data["value"] == "OptimizedExercise": activity = UploadedActivity() id = data_cells[info_cell].find( "input", {"name": "calendarItem"})["value"] name = data_cells[info_cell].find( "input", {"name": "calendarItemName"})["value"] activity.ExternalID = id activity.Name = name time_text = "{} {}".format(data_cells[date_cell].contents[0], data_cells[time_cell].contents[0]) activity.StartTime = pytz.utc.localize( datetime.strptime(time_text, "%d.%m.%Y %H:%M")) result_type_text = data_cells[result_type_cell].contents[0] if "Strength Training Result" in result_type_text: activity.Type = ActivityType.StrengthTraining # This type of activity always stationary activity.Stationary = True else: type_text = data_cells[sport_type_cell].contents[0] activity.Type = self._reverseActivityMappings.get( type_text.lower(), ActivityType.Other) logger.debug("\tActivity s/t {}: {}".format( activity.StartTime, activity.Type)) activity.CalculateUID() activities.append(activity) return activities, exclusions def DownloadActivity(self, serviceRecord, activity): session = self._get_session(serviceRecord) url = "https://www.polarpersonaltrainer.com/user/calendar/" gpxUrl = "index.gpx" xmlUrl = "index.jxml" gpx_data = { ".action": "gpx", "items.0.item": activity.ExternalID, "items.0.itemType": "OptimizedExercise" } xml_data = { ".action": "export", "items.0.item": activity.ExternalID, "items.0.itemType": "OptimizedExercise", ".filename": "training.xml" } xmlResp = session.post(url + xmlUrl, data=xml_data) xmlText = xmlResp.text gpxResp = session.post(url + gpxUrl, data=gpx_data) if gpxResp.status_code == 401: logger.debug( "Problem completing request. Unauthorized. Activity extId = {}" .format(activity.ExternalID)) raise APIException("Unknown authorization problem during request", user_exception=UserException( UserExceptionType.DownloadError)) gpxText = gpxResp.text activity.GPS = not ("The items you are exporting contain no GPS data" in gpxText) tcxData = convert(xmlText, activity.StartTime, gpxText if activity.GPS else None) activity = TCXIO.Parse(tcxData, activity) activity.SourceFile = SourceFile(tcxData.decode("utf-8"), ActivityFileType.TCX) return activity def RevokeAuthorization(self, serviceRecord): # nothing to do here... pass def DeleteCachedData(self, serviceRecord): # Nothing to delete pass def DeleteActivity(self, serviceRecord, uploadId): # Not supported pass def UploadActivity(self, serviceRecord, activity): # Not supported pass
class SportTracksService(ServiceBase): ID = "sporttracks" DisplayName = "SportTracks" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True OpenFitEndpoint = SPORTTRACKS_OPENFIT_ENDPOINT SupportsHR = True """ Other Basketball Other Boxing Other Climbing Other Driving Other Flying Other Football Other Gardening Other Kitesurf Other Sailing Other Soccer Other Tennis Other Volleyball Other Windsurf Running Hashing Running Hills Running Intervals Running Orienteering Running Race Running Road Running Showshoe Running Speed Running Stair Running Track Running Trail Running Treadmill Cycling Hills Cycling Indoor Cycling Intervals Cycling Mountain Cycling Race Cycling Road Cycling Rollers Cycling Spinning Cycling Track Cycling Trainer Swimming Open Water Swimming Pool Swimming Race Walking Geocaching Walking Hiking Walking Nordic Walking Photography Walking Snowshoe Walking Treadmill Skiing Alpine Skiing Nordic Skiing Roller Skiing Snowboard Rowing Canoe Rowing Kayak Rowing Kitesurf Rowing Ocean Kayak Rowing Rafting Rowing Rowing Machine Rowing Sailing Rowing Standup Paddling Rowing Windsurf Skating Board Skating Ice Skating Inline Skating Race Skating Track Gym Aerobics Gym Elliptical Gym Plyometrics Gym Rowing Machine Gym Spinning Gym Stair Climber Gym Stationary Bike Gym Strength Gym Stretching Gym Treadmill Gym Yoga """ _activityMappings = { "running": ActivityType.Running, "cycling": ActivityType.Cycling, "mountain": ActivityType.MountainBiking, "walking": ActivityType.Walking, "hiking": ActivityType.Hiking, "snowboarding": ActivityType.Snowboarding, "skiing": ActivityType.DownhillSkiing, "nordic": ActivityType.CrossCountrySkiing, "skating": ActivityType.Skating, "swimming": ActivityType.Swimming, "rowing": ActivityType.Rowing, "elliptical": ActivityType.Elliptical, "other": ActivityType.Other } _reverseActivityMappings = { ActivityType.Running: "running", ActivityType.Cycling: "cycling", ActivityType.Walking: "walking", ActivityType.MountainBiking: "cycling: mountain", ActivityType.Hiking: "walking: hiking", ActivityType.CrossCountrySkiing: "skiing: nordic", # Equipment.Bindings.IsToeOnly ?? ActivityType.DownhillSkiing: "skiing", ActivityType.Snowboarding: "skiing: snowboarding", ActivityType.Skating: "skating", ActivityType.Swimming: "swimming", ActivityType.Rowing: "rowing", ActivityType.Elliptical: "gym: elliptical", ActivityType.Other: "other" } SupportedActivities = list(_reverseActivityMappings.keys()) _sessionCache = SessionCache(lifetime=timedelta(minutes=30), freshen_on_get=True) def _get_cookies(self, record=None, email=None, password=None): return self._get_cookies_and_uid(record, email, password)[0] def _get_cookies_and_uid(self, record=None, email=None, password=None): from tapiriik.auth.credential_storage import CredentialStore if record: cached = self._sessionCache.Get(record.ExternalID) if cached: return cached password = CredentialStore.Decrypt(record.ExtendedAuthorization["Password"]) email = CredentialStore.Decrypt(record.ExtendedAuthorization["Email"]) params = {"username": email, "password": password} resp = requests.post(self.OpenFitEndpoint + "/user/login", data=json.dumps(params), allow_redirects=False, headers={"Accept": "application/json", "Content-Type": "application/json"}) if resp.status_code != 200: raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) retval = (resp.cookies, int(resp.json()["user"]["uid"])) if record: self._sessionCache.Set(record.ExternalID, retval) return retval def WebInit(self): self.UserAuthorizationURL = WEB_ROOT + reverse("auth_simple", kwargs={"service": self.ID}) def Authorize(self, email, password): from tapiriik.auth.credential_storage import CredentialStore cookies, uid = self._get_cookies_and_uid(email=email, password=password) return (uid, {}, {"Email": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password)}) def RevokeAuthorization(self, serviceRecord): pass # No auth tokens to revoke... def DeleteCachedData(self, serviceRecord): pass # No cached data... def DownloadActivityList(self, serviceRecord, exhaustive=False): cookies = self._get_cookies(record=serviceRecord) activities = [] exclusions = [] pageUri = self.OpenFitEndpoint + "/fitnessActivities.json" while True: logger.debug("Req against " + pageUri) res = requests.get(pageUri, cookies=cookies) res = res.json() for act in res["items"]: activity = UploadedActivity() activity.UploadedTo = [{"Connection": serviceRecord, "ActivityURI": act["uri"]}] if len(act["name"].strip()): activity.Name = act["name"] activity.StartTime = dateutil.parser.parse(act["start_time"]) if isinstance(activity.StartTime.tzinfo, tzutc): activity.TZ = pytz.utc # The dateutil tzutc doesn't have an _offset value. else: activity.TZ = pytz.FixedOffset(activity.StartTime.tzinfo._offset.total_seconds() / 60) # Convert the dateutil lame timezones into pytz awesome timezones. activity.StartTime = activity.StartTime.replace(tzinfo=activity.TZ) activity.EndTime = activity.StartTime + timedelta(seconds=float(act["duration"])) # Sometimes activities get returned with a UTC timezone even when they are clearly not in UTC. if activity.TZ == pytz.utc: # So, we get the first location in the activity and calculate the TZ from that. try: firstLocation = self._downloadActivity(serviceRecord, activity, returnFirstLocation=True) except APIExcludeActivity: pass else: activity.CalculateTZ(firstLocation) activity.AdjustTZ() logger.debug("Activity s/t " + str(activity.StartTime)) activity.Distance = float(act["total_distance"]) types = [x.strip().lower() for x in act["type"].split(":")] types.reverse() # The incoming format is like "walking: hiking" and we want the most specific first activity.Type = None for type_key in types: if type_key in self._activityMappings: activity.Type = self._activityMappings[type_key] break if not activity.Type: exclusions.append(APIExcludeActivity("Unknown activity type %s" % act["type"], activityId=act["uri"])) continue activity.CalculateUID() activities.append(activity) if not exhaustive or "next" not in res or not len(res["next"]): break else: pageUri = res["next"] return activities, exclusions def _downloadActivity(self, serviceRecord, activity, returnFirstLocation=False): activityURI = [x["ActivityURI"] for x in activity.UploadedTo if x["Connection"] == serviceRecord][0] cookies = self._get_cookies(record=serviceRecord) activityData = requests.get(activityURI, cookies=cookies) activityData = activityData.json() if "location" not in activityData: raise APIExcludeActivity("No points") timerStops = [] if "timer_stops" in activityData: for stop in activityData["timer_stops"]: timerStops.append([dateutil.parser.parse(stop[0]), dateutil.parser.parse(stop[1])]) def isInTimerStop(timestamp): for stop in timerStops: if timestamp >= stop[0] and timestamp < stop[1]: return True if timestamp >= stop[1]: return False return False laps = [] if "laps" in activityData: for lap in activityData["laps"]: laps.append(dateutil.parser.parse(lap["start_time"])) # Collate the individual streams into our waypoints. # Everything is resampled by nearest-neighbour to the rate of the location stream. parallel_indices = {} parallel_stream_lengths = {} for secondary_stream in ["elevation", "heartrate", "power", "cadence"]: if secondary_stream in activityData: parallel_indices[secondary_stream] = 0 parallel_stream_lengths[secondary_stream] = len(activityData[secondary_stream]) activity.Waypoints = [] wasInPause = False currentLapIdx = 0 for idx in range(0, len(activityData["location"]), 2): # Pick the nearest indices in the parallel streams for parallel_stream, parallel_index in parallel_indices.items(): if parallel_index + 2 == parallel_stream_lengths[parallel_stream]: continue # We're at the end of this stream # Is the next datapoint a better choice than the current? if abs(activityData["location"][idx] - activityData[parallel_stream][parallel_index + 2]) < abs(activityData["location"][idx] - activityData[parallel_stream][parallel_index]): parallel_indices[parallel_stream] += 2 waypoint = Waypoint(activity.StartTime + timedelta(0, activityData["location"][idx])) waypoint.Location = Location(activityData["location"][idx+1][0], activityData["location"][idx+1][1], None) if "elevation" in parallel_indices: waypoint.Location.Altitude = activityData["elevation"][parallel_indices["elevation"]+1] if returnFirstLocation: return waypoint.Location if "heartrate" in parallel_indices: waypoint.HR = activityData["heartrate"][parallel_indices["heartrate"]+1] if "power" in parallel_indices: waypoint.Power = activityData["power"][parallel_indices["power"]+1] if "cadence" in parallel_indices: waypoint.Cadence = activityData["cadence"][parallel_indices["cadence"]+1] inPause = isInTimerStop(waypoint.Timestamp) waypoint.Type = WaypointType.Regular if not inPause else WaypointType.Pause if wasInPause and not inPause: waypoint.Type = WaypointType.Resume wasInPause = inPause # We only care if it's possible to start a new lap, i.e. there are more left if currentLapIdx + 1 < len(laps): if laps[currentLapIdx + 1] < waypoint.Timestamp: # A new lap has started waypoint.Type = WaypointType.Lap currentLapIdx += 1 activity.Waypoints.append(waypoint) if returnFirstLocation: return None # I guess there were no waypoints? activity.Waypoints[0].Type = WaypointType.Start activity.Waypoints[-1].Type = WaypointType.End return activity def DownloadActivity(self, serviceRecord, activity): return self._downloadActivity(serviceRecord, activity) def UploadActivity(self, serviceRecord, activity): activity.EnsureTZ() activityData = {} # Props to the SportTracks API people for seamlessly supprting activities with or without TZ data. activityData["start_time"] = activity.StartTime.isoformat() if activity.Name: activityData["name"] = activity.Name activityData["type"] = self._reverseActivityMappings[activity.Type] lap_starts = [] timer_stops = [] timer_stopped_at = None def stream_append(stream, wp, data): stream += [int((wp.Timestamp - activity.StartTime).total_seconds()), data] location_stream = [] elevation_stream = [] heartrate_stream = [] power_stream = [] cadence_stream = [] for wp in activity.Waypoints: if wp.Location and wp.Location.Latitude and wp.Location.Longitude: stream_append(location_stream, wp, [wp.Location.Latitude, wp.Location.Longitude]) if wp.HR: stream_append(heartrate_stream, wp, int(wp.HR)) if wp.Cadence: stream_append(cadence_stream, wp, int(wp.Cadence)) if wp.Power: stream_append(power_stream, wp, wp.Power) if wp.Location and wp.Location.Altitude: stream_append(elevation_stream, wp, wp.Location.Altitude) if wp.Type == WaypointType.Lap: lap_starts.append(wp.Timestamp) if wp.Type == WaypointType.Pause and not timer_stopped_at: timer_stopped_at = wp.Timestamp if wp.Type != WaypointType.Pause and timer_stopped_at: timer_stops.append([timer_stopped_at, wp.Timestamp]) timer_stopped_at = None activityData["elevation"] = elevation_stream activityData["heartrate"] = heartrate_stream activityData["power"] = power_stream activityData["cadence"] = cadence_stream activityData["location"] = location_stream activityData["laps"] = [{"start_time": x.isoformat()} for x in lap_starts] activityData["timer_stops"] = [[y.isoformat() for y in x] for x in timer_stops] cookies = self._get_cookies(record=serviceRecord) upload_resp = requests.post(self.OpenFitEndpoint + "/fitnessActivities.json", data=json.dumps(activityData), cookies=cookies, headers={"Content-Type": "application/json"}) if upload_resp.status_code != 200: if upload_resp.status_code == 401: raise APIException("ST.mobi trial expired", block=True, user_exception=UserException(UserExceptionType.AccountExpired, intervention_required=True)) raise APIException("Unable to upload activity %s" % upload_resp.text)
class EndomondoService(ServiceBase): ID = "endomondo" DisplayName = "Endomondo" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True UserProfileURL = "http://www.endomondo.com/profile/{0}" UserActivityURL = "http://www.endomondo.com/workouts/{1}/{0}" _sessionCache = SessionCache(lifetime=timedelta(minutes=30), freshen_on_get=True) _activityMappings = { 0: ActivityType.Running, 2: ActivityType. Cycling, # the order of these matters since it picks the first match for uploads 1: ActivityType.Cycling, 3: ActivityType.MountainBiking, 4: ActivityType.Skating, 6: ActivityType.CrossCountrySkiing, 7: ActivityType.DownhillSkiing, 8: ActivityType.Snowboarding, 11: ActivityType.Rowing, 9: ActivityType.Rowing, # canoeing 18: ActivityType.Walking, 14: ActivityType.Walking, # fitness walking 16: ActivityType.Hiking, 17: ActivityType.Hiking, # orienteering 20: ActivityType.Swimming, 40: ActivityType.Swimming, # scuba diving 22: ActivityType.Other, 92: ActivityType.Wheelchair } _reverseActivityMappings = { # so that ambiguous events get mapped back to reasonable types 0: ActivityType.Running, 2: ActivityType.Cycling, 3: ActivityType.MountainBiking, 4: ActivityType.Skating, 6: ActivityType.CrossCountrySkiing, 7: ActivityType.DownhillSkiing, 8: ActivityType.Snowboarding, 11: ActivityType.Rowing, 18: ActivityType.Walking, 16: ActivityType.Hiking, 20: ActivityType.Swimming, 22: ActivityType.Other, 92: ActivityType.Wheelchair } SupportedActivities = list(_activityMappings.values()) SupportsHR = True SupportsCalories = False # not inside the activity? p.sure it calculates this after the fact anyways def WebInit(self): self.UserAuthorizationURL = WEB_ROOT + reverse( "auth_simple", kwargs={"service": "endomondo"}) def _parseKVP(self, data): out = {} for line in data.split("\n"): if line == "OK": continue match = re.match("(?P<key>[^=]+)=(?P<val>.+)$", line) if match is None: continue out[match.group("key")] = match.group("val") return out def _get_web_cookies(self, record=None, email=None, password=None): from tapiriik.auth.credential_storage import CredentialStore if record: cached = self._sessionCache.Get(record.ExternalID) if cached: return cached password = CredentialStore.Decrypt( record.ExtendedAuthorization["Password"]) email = CredentialStore.Decrypt( record.ExtendedAuthorization["Email"]) params = {"email": email, "password": password} resp = requests.post( "https://www.endomondo.com/access?wicket:interface=:1:pageContainer:lowerSection:lowerMain:lowerMainContent:signInPanel:signInFormPanel:signInForm::IFormSubmitListener::", data=params, allow_redirects=False) if resp.status_code >= 500 and resp.status_code < 600: raise APIException("Remote API failure") if resp.status_code != 302: # yep raise APIException("Invalid login", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) if record: self._sessionCache.Set(record.ExternalID, resp.cookies) return resp.cookies def Authorize(self, email, password): from tapiriik.auth.credential_storage import CredentialStore params = { "email": email, "password": password, "v": "2.4", "action": "pair", "deviceId": "TAP-SYNC-" + email.lower(), "country": "N/A" } # note to future self: deviceId can't change intra-account otherwise we'll get different tokens back resp = requests.get("https://api.mobile.endomondo.com/mobile/auth", params=params) if resp.text.strip() == "USER_UNKNOWN" or resp.text.strip( ) == "USER_EXISTS_PASSWORD_WRONG": raise APIException("Invalid login", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) data = self._parseKVP(resp.text) return (data["userId"], { "AuthToken": data["authToken"], "SecureToken": data["secureToken"] }, { "Email": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password) }) def RevokeAuthorization(self, serviceRecord): # you can't revoke the tokens endomondo distributes :\ pass def _downloadRawTrackRecord(self, serviceRecord, trackId): params = { "authToken": serviceRecord.Authorization["AuthToken"], "trackId": trackId } response = requests.get( "http://api.mobile.endomondo.com/mobile/readTrack", params=params) return response.text def _populateActivityFromTrackData(self, activity, recordText, minimumWaypoints=False): lap = Lap() activity.Laps = [lap] ### 1ST RECORD ### # userID; # timestamp - create date?; # type? W=1st # User name; # activity name; # activity type; # another timestamp - start time of event?; # duration.00; # distance (km); # kcal; #; # max alt; # min alt; # max HR; # avg HR; ### TRACK RECORDS ### # timestamp; # type (2=start, 3=end, 0=pause, 1=resume); # latitude; # longitude; #; #; # alt; # hr; wptsWithLocation = False wptsWithNonZeroAltitude = False rows = recordText.split("\n") for row in rows: if row == "OK" or len(row) == 0: continue split = row.split(";") if split[2] == "W": # init record lap.Stats.TimerTime = ActivityStatistic( ActivityStatisticUnit.Time, value=timedelta( seconds=float(split[7])) if split[7] != "" else None) lap.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Kilometers, value=float(split[8]) if split[8] != "" else None) lap.Stats.HR = ActivityStatistic( ActivityStatisticUnit.BeatsPerMinute, avg=float(split[14]) if split[14] != "" else None, max=float(split[13]) if split[13] != "" else None) lap.Stats.Elevation = ActivityStatistic( ActivityStatisticUnit.Meters, min=float(split[12]) if split[12] != "" else None, max=float(split[11]) if split[11] != "" else None) lap.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilocalories, value=float(split[12]) if split[12] != "" else None) activity.Stats.update(lap.Stats) lap.Stats = activity.Stats activity.Name = split[4] else: wp = Waypoint() if split[1] == "2": wp.Type = WaypointType.Start elif split[1] == "3": wp.Type = WaypointType.End elif split[1] == "0": wp.Type = WaypointType.Pause elif split[1] == "1": wp.Type = WaypointType.Resume else: wp.Type == WaypointType.Regular if split[0] == "": continue # no timestamp, for whatever reason wp.Timestamp = pytz.utc.localize( datetime.strptime(split[0], "%Y-%m-%d %H:%M:%S UTC") ) # it's like this as opposed to %z so I know when they change things (it'll break) if split[2] != "": wp.Location = Location(float(split[2]), float(split[3]), None) if wp.Location.Longitude > 180 or wp.Location.Latitude > 90 or wp.Location.Longitude < -180 or wp.Location.Latitude < -90: raise APIExcludeActivity("Out of range lat/lng") if wp.Location.Latitude is not None and wp.Location.Latitude is not None: wptsWithLocation = True if split[6] != "": wp.Location.Altitude = float( split[6]) # why this is missing: who knows? if wp.Location.Altitude != 0: wptsWithNonZeroAltitude = True if split[7] != "": wp.HR = float(split[7]) lap.Waypoints.append(wp) if wptsWithLocation and minimumWaypoints: break lap.Waypoints = sorted(activity.Waypoints, key=lambda v: v.Timestamp) if wptsWithLocation: if not wptsWithNonZeroAltitude: # do this here so, should the activity run near sea level, altitude data won't be spotty for x in lap.Waypoints: # clear waypoints of altitude data if all of them were logged at 0m (invalid) if x.Location is not None: x.Location.Altitude = None else: lap.Waypoints = [] # practically speaking def DownloadActivityList(self, serviceRecord, exhaustive=False): activities = [] exclusions = [] earliestDate = None earliestFirstPageDate = None paged = False while True: before = "" if earliestDate is None else earliestDate.astimezone( pytz.utc).strftime("%Y-%m-%d %H:%M:%S UTC") params = { "authToken": serviceRecord.Authorization["AuthToken"], "maxResults": 45, "before": before } logger.debug("Req with " + str(params)) response = requests.get( "http://api.mobile.endomondo.com/mobile/api/workout/list", params=params) if response.status_code != 200: if response.status_code == 401 or response.status_code == 403: raise APIException( "No authorization to retrieve activity list", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) raise APIException("Unable to retrieve activity list " + str(response)) data = response.json() if "error" in data and data["error"]["type"] == "AUTH_FAILED": raise APIException( "No authorization to retrieve activity list", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) track_ids = [] this_page_activities = [] for act in data["data"]: startTime = pytz.utc.localize( datetime.strptime(act["start_time"], "%Y-%m-%d %H:%M:%S UTC")) if earliestDate is None or startTime < earliestDate: # probably redundant, I would assume it works out the TZes... earliestDate = startTime logger.debug("activity pre") if "tracking" in act and act["tracking"]: logger.warning("\t tracking") exclusions.append( APIExcludeActivity("In progress", activityId=act["id"], permanent=False)) continue # come back once they've completed the activity track_ids.append(act["id"]) activity = UploadedActivity() activity.StartTime = startTime activity.EndTime = activity.StartTime + timedelta( 0, round(act["duration_sec"])) logger.debug("\tActivity s/t " + str(activity.StartTime)) activity.Stationary = not act["has_points"] if int(act["sport"]) in self._activityMappings: activity.Type = self._activityMappings[int(act["sport"])] activity.ServiceData = {"ActivityID": act["id"]} this_page_activities.append(activity) cached_track_tzs = cachedb.endomondo_activity_cache.find( {"TrackID": { "$in": track_ids }}) cached_track_tzs = dict([(x["TrackID"], x) for x in cached_track_tzs]) logger.debug("Have" + str(len(cached_track_tzs.keys())) + "/" + str(len(track_ids)) + " cached TZ records") for activity in this_page_activities: # attn service makers: why #(*%$ can't you all agree to use naive local time. So much simpler. cachedTrackData = None track_id = activity.ServiceData["ActivityID"] if track_id not in cached_track_tzs: logger.debug("\t Resolving TZ for %s" % activity.StartTime) cachedTrackData = self._downloadRawTrackRecord( serviceRecord, track_id) try: self._populateActivityFromTrackData( activity, cachedTrackData, minimumWaypoints=True) except APIExcludeActivity as e: e.ExternalActivityID = track_id logger.info("Encountered APIExcludeActivity %s" % str(e)) exclusions.append(e) continue if not activity.TZ and not activity.Stationary: logger.info("Couldn't determine TZ") exclusions.append( APIExcludeActivity("Couldn't determine TZ", activityId=track_id)) continue cachedTrackRecord = { "Owner": serviceRecord.ExternalID, "TrackID": track_id, "TZ": pickle.dumps(activity.TZ), "StartTime": activity.StartTime } cachedb.endomondo_activity_cache.insert(cachedTrackRecord) elif not activity.Stationary: activity.TZ = pickle.loads( cached_track_tzs[track_id]["TZ"]) activity.AdjustTZ() # Everything returned is in UTC activity.Laps = [] if int(act["sport"]) in self._activityMappings: activity.Type = self._activityMappings[int(act["sport"])] activity.ServiceData = { "ActivityID": act["id"], "ActivityData": cachedTrackData } activity.CalculateUID() activities.append(activity) if not paged: earliestFirstPageDate = earliestDate if not exhaustive or ("more" in data and data["more"] is False): break else: paged = True return activities, exclusions def DownloadActivity(self, serviceRecord, activity): trackData = activity.ServiceData["ActivityData"] if not trackData: # If this is a new activity, we will already have the track data, otherwise download it. trackData = self._downloadRawTrackRecord( serviceRecord, activity.ServiceData["ActivityID"]) self._populateActivityFromTrackData(activity, trackData) cookies = self._get_web_cookies(record=serviceRecord) summary_page = requests.get("http://www.endomondo.com/workouts/%d" % activity.ServiceData["ActivityID"], cookies=cookies) def _findStat(name): nonlocal summary_page result = re.findall( '<li class="' + name + '">.+?<span class="value">([^<]+)</span>', summary_page.text, re.DOTALL) return result[0] if len(result) else None def _mapStat(name, statKey, type): nonlocal activity _unitMap = { "mi": ActivityStatisticUnit.Miles, "km": ActivityStatisticUnit.Kilometers, "kcal": ActivityStatisticUnit.Kilocalories, "ft": ActivityStatisticUnit.Feet, "m": ActivityStatisticUnit.Meters, "rpm": ActivityStatisticUnit.RevolutionsPerMinute, "avg-hr": ActivityStatisticUnit.BeatsPerMinute, "max-hr": ActivityStatisticUnit.BeatsPerMinute, } statValue = _findStat(name) if statValue: statUnit = statValue.split( " ")[1] if " " in statValue else None unit = _unitMap[statUnit] if statUnit else _unitMap[name] statValue = statValue.split(" ")[0] valData = {type: float(statValue)} activity.Stats.__dict__[statKey].update( ActivityStatistic(unit, **valData)) _mapStat("max-hr", "HR", "max") _mapStat("avg-hr", "HR", "avg") _mapStat("calories", "Kilocalories", "value") _mapStat("elevation-asc", "Elevation", "gain") _mapStat("elevation-desc", "Elevation", "loss") _mapStat("cadence", "Cadence", "avg") # I would presume? _mapStat("distance", "Distance", "value") # I would presume? notes = re.findall('<div class="notes editable".+?<p>(.+?)</p>', summary_page.text) if len(notes): activity.Notes = notes[0] return activity def UploadActivity(self, serviceRecord, activity): cookies = self._get_web_cookies(record=serviceRecord) # Wicket sucks sucks sucks sucks sucks sucks. # Step 0 # http://www.endomondo.com/?wicket:bookmarkablePage=:com.endomondo.web.page.workout.CreateWorkoutPage2 # Get URL of file upload # <a href="#" id="id13a" onclick="var wcall=wicketAjaxGet('?wicket:interface=:8:pageContainer:lowerSection:lowerMain:lowerMainContent:importFileLink::IBehaviorListener:0:',function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$('id13a') != null;}.bind(this));return !wcall;">... <div class="fileImport"></div> upload_select = requests.get( "http://www.endomondo.com/?wicket:bookmarkablePage=:com.endomondo.web.page.workout.CreateWorkoutPage2", cookies=cookies) upload_lightbox_url = re.findall( '<a.+?onclick="var wcall=wicketAjaxGet\(\'(.+?)\'', upload_select.text)[3] logger.debug("Will request upload lightbox from %s" % upload_lightbox_url) # Step 1 # http://www.endomondo.com/upload-form-url # Get IFrame src upload_iframe = requests.get("http://www.endomondo.com/" + upload_lightbox_url, cookies=cookies) upload_iframe_src = re.findall('src="(.+?)"', upload_iframe.text)[0] logger.debug("Will request upload form from %s" % upload_iframe_src) # Step 2 # http://www.endomondo.com/iframe-url # Follow redirect to upload page # Get form ID # Get form target from <a class="next" name="uploadSumbit" id="id18d" value="Next" onclick="document.getElementById('fileUploadWaitIcon').style.display='block';var wcall=wicketSubmitFormById('id18c', '?wicket:interface=:13:importPanel:wizardStepPanel:uploadForm:uploadSumbit::IActivePageBehaviorListener:0:-1&wicket:ignoreIfNotActive=true', 'uploadSumbit' ,function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$$(this)&&Wicket.$$('id18c')}.bind(this));;; return false;">Next</a> upload_form_rd = requests.get("http://www.endomondo.com/" + upload_iframe_src, cookies=cookies, allow_redirects=False) assert ( upload_form_rd.status_code == 302 ) # Need to manually follow the redirect to keep the cookies available upload_form = requests.get(upload_form_rd.headers["location"], cookies=cookies) upload_form_id = re.findall('<form.+?id="([^"]+)"', upload_form.text)[0] upload_form_target = re.findall( "wicketSubmitFormById\('[^']+', '([^']+)'", upload_form.text)[0] logger.debug("Will POST upload form ID %s to %s" % (upload_form_id, upload_form_target)) # Step 3 # http://www.endomondo.com/upload-target # POST # formID_hf_0 # file as `uploadFile` # uploadSubmit=1 # Get ID from form # Get confirm target <a class="next" name="reviewSumbit" id="id191" value="Save" onclick="document.getElementById('fileSaveWaitIcon').style.display='block';var wcall=wicketSubmitFormById('id190', '?wicket:interface=:13:importPanel:wizardStepPanel:reviewForm:reviewSumbit::IActivePageBehaviorListener:0:-1&wicket:ignoreIfNotActive=true', 'reviewSumbit' ,function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$$(this)&&Wicket.$$('id190')}.bind(this));;; return false;">Save</a> fit_file = FITIO.Dump(activity) files = { "uploadFile": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit", fit_file) } data = {"uploadSumbit": 1, upload_form_id + "_hf_0": ""} upload_result = requests.post("http://www.endomondo.com/" + upload_form_target, data=data, files=files, cookies=cookies) confirm_form_id = re.findall('<form.+?id="([^"]+)"', upload_result.text)[0] confirm_form_target = re.findall( "wicketSubmitFormById\('[^']+', '([^']+)'", upload_result.text)[0] logger.debug("Will POST confirm form ID %s to %s" % (confirm_form_id, confirm_form_target)) # Step 4 # http://www.endomondo.com/confirm-target # POST # formID_hf_0 # workoutRow:0:mark=on # workoutRow:0:sport=X # reviewSumbit=1 sportId = [ k for k, v in self._reverseActivityMappings.items() if v == activity.Type ] if len(sportId) == 0: raise ValueError( "Endomondo service does not support activity type " + activity.Type) else: sportId = sportId[0] data = { confirm_form_id + "_hf_0": "", "workoutRow:0:mark": "on", "workoutRow:0:sport": sportId, "reviewSumbit": 1 } confirm_result = requests.post("http://www.endomondo.com" + confirm_form_target, data=data, cookies=cookies) assert (confirm_result.status_code == 200) # Step 5 # http://api.mobile.endomondo.com/mobile/api/workout/list # GET # authToken=xyz # maxResults=1 # before=utcTS+1 # Get activity ID before = (activity.StartTime + timedelta(seconds=90)).astimezone( pytz.utc).strftime("%Y-%m-%d %H:%M:%S UTC") params = { "authToken": serviceRecord.Authorization["AuthToken"], "maxResults": 1, "before": before } id_result = requests.get( "http://api.mobile.endomondo.com/mobile/api/workout/list", params=params) act_id = id_result.json()["data"][0]["id"] logger.debug("Retrieved activity ID %s" % act_id) # Step 6 # http://www.endomondo.com/workouts/xyz # Get edit URL <a class="enabled button edit" href="#" id="id171" onclick="var wcall=wicketAjaxGet('../?wicket:interface=:10:pageContainer:lowerSection:lowerMain:lowerMainContent:workout:details:actions:ownActions:editButton::IBehaviorListener:0:1',function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$('id171') != null;}.bind(this));return !wcall;">Edit</a> summary_page = requests.get("http://www.endomondo.com/workouts/%s" % act_id, cookies=cookies) edit_url = re.findall( '<a.+class="enabled button edit".+?onclick="var wcall=wicketAjaxGet\(\'../(.+?)\'', summary_page.text)[0] logger.debug("Will request edit form from %s" % edit_url) # Step 7 # http://www.endomondo.com/edit-url # Get form ID # Get form target from <a class="halfbutton" href="#" style="float:left;" name="saveButton" id="id1d5" value="Save" onclick="var wcall=wicketSubmitFormById('id1d4', '../?wicket:interface=:14:pageContainer:lightboxContainer:lightboxContent:panel:detailsContainer:workoutForm:saveButton::IActivePageBehaviorListener:0:1&wicket:ignoreIfNotActive=true', 'saveButton' ,function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$$(this)&&Wicket.$$('id1d4')}.bind(this));;; return false;">Save</a> edit_page = requests.get("http://www.endomondo.com/" + edit_url, cookies=cookies) edit_form_id = re.findall('<form.+?id="([^"]+)"', edit_page.text)[0] edit_form_target = re.findall( "wicketSubmitFormById\('[^']+', '([^']+)'", edit_page.text)[0] logger.debug("Will POST edit form ID %s to %s" % (edit_form_id, edit_form_target)) # Step 8 # http://www.endomondo.com/edit-finish-url # POST # id34e_hf_0 # sport: X # name: name123 # startTime:YYYY-MM-DD HH:MM # distance:1.00 km # duration:0h:10m:00s # metersAscent: # metersDescent: # averageHeartRate:30 # maximumHeartRate:100 # validityToggle:on ("include in statistics") # calorieRecomputeToggle:on # notes:asdasdasd # saveButton:1 duration = (activity.EndTime - activity.StartTime) duration_formatted = "%dh:%dm:%ds" % (duration.seconds / 3600, duration.seconds % 3600 / 60, duration.seconds % (60)) data = { edit_form_id + "_hf_0": "", "saveButton": "1", "validityToggle": "on", "calorieRecomputeToggle": "on", "startTime": activity.StartTime.strftime("%Y-%m-%d %H:%M"), "distance": "%s km" % activity.Stats.Distance.asUnits( ActivityStatisticUnit.Kilometers).Value, "sport": sportId, "duration": duration_formatted, "name": activity.Name, } if activity.Stats.Elevation.Gain is not None: data["metersAscent"] = int(round(activity.Stats.Elevation.Gain)) if activity.Stats.Elevation.Gain is not None: data["metersDescent"] = int(round(activity.Stats.Elevation.Loss)) if activity.Stats.HR.Average is not None: data["averageHeartRate"] = int(round(activity.Stats.HR.Average)) if activity.Stats.HR.Max is not None: data["maximumHeartRate"] = int(round(activity.Stats.HR.Max)) edit_result = requests.post("http://www.endomondo.com/" + edit_form_target, data=data, cookies=cookies) assert edit_result.status_code == 200 and "feedbackPanelERROR" not in edit_result.text def DeleteCachedData(self, serviceRecord): cachedb.endomondo_activity_cache.remove( {"Owner": serviceRecord.ExternalID})
class GarminConnectService(ServiceBase): ID = "garminconnect" DisplayName = "Garmin Connect" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True _activityMappings = { "running": ActivityType.Running, "cycling": ActivityType.Cycling, "mountain_biking": ActivityType.MountainBiking, "walking": ActivityType.Walking, "hiking": ActivityType.Hiking, "resort_skiing_snowboarding": ActivityType.DownhillSkiing, "cross_country_skiing": ActivityType.CrossCountrySkiing, "backcountry_skiing_snowboarding": ActivityType.CrossCountrySkiing, # ish "skating": ActivityType.Skating, "swimming": ActivityType.Swimming, "rowing": ActivityType.Rowing, "elliptical": ActivityType.Elliptical, "all": ActivityType.Other # everything will eventually resolve to this } _reverseActivityMappings = { # Removes ambiguities when mapping back to their activity types "running": ActivityType.Running, "cycling": ActivityType.Cycling, "mountain_biking": ActivityType.MountainBiking, "walking": ActivityType.Walking, "hiking": ActivityType.Hiking, "resort_skiing_snowboarding": ActivityType.DownhillSkiing, "cross_country_skiing": ActivityType.CrossCountrySkiing, "skating": ActivityType.Skating, "swimming": ActivityType.Swimming, "rowing": ActivityType.Rowing, "elliptical": ActivityType.Elliptical, "other": ActivityType.Other # I guess? (vs. "all" that is) } SupportedActivities = list(_activityMappings.values()) SupportsHR = SupportsCadence = True _sessionCache = SessionCache(lifetime=timedelta(minutes=30), freshen_on_get=True) def __init__(self): self._activityHierarchy = requests.get("http://connect.garmin.com/proxy/activity-service-1.2/json/activity_types").json()["dictionary"] def _get_cookies(self, record=None, email=None, password=None): from tapiriik.auth.credential_storage import CredentialStore if record: cached = self._sessionCache.Get(record.ExternalID) if cached: return cached # longing for C style overloads... password = CredentialStore.Decrypt(record.ExtendedAuthorization["Password"]) email = CredentialStore.Decrypt(record.ExtendedAuthorization["Email"]) params = {"login": "******", "login:loginUsernameField": email, "login:password": password, "login:signInButton": "Sign In", "javax.faces.ViewState": "j_id1"} preResp = requests.get("https://connect.garmin.com/signin") resp = requests.post("https://connect.garmin.com/signin", data=params, allow_redirects=False, cookies=preResp.cookies) if resp.status_code >= 500 and resp.status_code<600: raise APIException("Remote API failure") if resp.status_code != 302: # yep raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) if record: self._sessionCache.Set(record.ExternalID, preResp.cookies) return preResp.cookies def WebInit(self): self.UserAuthorizationURL = WEB_ROOT + reverse("auth_simple", kwargs={"service": self.ID}) def Authorize(self, email, password): from tapiriik.auth.credential_storage import CredentialStore cookies = self._get_cookies(email=email, password=password) username = requests.get("http://connect.garmin.com/user/username", cookies=cookies).json()["username"] if not len(username): raise APIException("Unable to retrieve username", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) return (username, {}, {"Email": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password)}) def _resolveActivityType(self, act_type): # Mostly there are two levels of a hierarchy, so we don't really need this as the parent is included in the listing. # But maybe they'll change that some day? while act_type not in self._activityMappings: try: act_type = [x["parent"]["key"] for x in self._activityHierarchy if x["key"] == act_type][0] except IndexError: raise ValueError("Activity type not found in activity hierarchy") return self._activityMappings[act_type] def DownloadActivityList(self, serviceRecord, exhaustive=False): #http://connect.garmin.com/proxy/activity-search-service-1.0/json/activities?&start=0&limit=50 cookies = self._get_cookies(record=serviceRecord) page = 1 pageSz = 50 activities = [] exclusions = [] while True: logger.debug("Req with " + str({"start": (page - 1) * pageSz, "limit": pageSz})) res = requests.get("http://connect.garmin.com/proxy/activity-search-service-1.0/json/activities", params={"start": (page - 1) * pageSz, "limit": pageSz}, cookies=cookies) res = res.json()["results"] if "activities" not in res: break # No activities on this page - empty account. for act in res["activities"]: act = act["activity"] if "beginLatitude" not in act or "endLatitude" not in act or (act["beginLatitude"] is act["endLatitude"] and act["beginLongitude"] is act["endLongitude"]): exclusions.append(APIExcludeActivity("No points", activityId=act["activityId"])) continue if "sumDistance" not in act: exclusions.append(APIExcludeActivity("No distance", activityId=act["activityId"])) continue activity = UploadedActivity() try: activity.TZ = pytz.timezone(act["activityTimeZone"]["key"]) except pytz.exceptions.UnknownTimeZoneError: activity.TZ = pytz.FixedOffset(float(act["activityTimeZone"]["offset"]) * 60) logger.debug("Name " + act["activityName"]["value"] + ":") if len(act["activityName"]["value"].strip()) and act["activityName"]["value"] != "Untitled": activity.Name = act["activityName"]["value"] # beginTimestamp/endTimestamp is in UTC activity.StartTime = pytz.utc.localize(datetime.utcfromtimestamp(float(act["beginTimestamp"]["millis"])/1000)) if "sumElapsedDuration" in act: activity.EndTime = activity.StartTime + timedelta(0, round(float(act["sumElapsedDuration"]["value"]))) elif "sumDuration" in act: activity.EndTime = activity.StartTime + timedelta(minutes=float(act["sumDuration"]["minutesSeconds"].split(":")[0]), seconds=float(act["sumDuration"]["minutesSeconds"].split(":")[1])) else: activity.EndTime = pytz.utc.localize(datetime.utcfromtimestamp(float(act["endTimestamp"]["millis"])/1000)) logger.debug("Activity s/t " + str(activity.StartTime) + " on page " + str(page)) activity.AdjustTZ() # TODO: fix the distance stats to account for the fact that this incorrectly reported km instead of meters for the longest time. activity.Distance = float(act["sumDistance"]["value"]) * (1.60934 if act["sumDistance"]["uom"] == "mile" else 1) * 1000 # In meters... activity.Type = self._resolveActivityType(act["activityType"]["key"]) activity.CalculateUID() activity.UploadedTo = [{"Connection": serviceRecord, "ActivityID": act["activityId"]}] activities.append(activity) logger.debug("Finished page " + str(page) + " of " + str(res["search"]["totalPages"])) if not exhaustive or int(res["search"]["totalPages"]) == page: break else: page += 1 return activities, exclusions def DownloadActivity(self, serviceRecord, activity): #http://connect.garmin.com/proxy/activity-service-1.1/tcx/activity/#####?full=true activityID = [x["ActivityID"] for x in activity.UploadedTo if x["Connection"] == serviceRecord][0] cookies = self._get_cookies(record=serviceRecord) res = requests.get("http://connect.garmin.com/proxy/activity-service-1.1/tcx/activity/" + str(activityID) + "?full=true", cookies=cookies) try: TCXIO.Parse(res.content, activity) except ValueError as e: raise APIExcludeActivity("TCX parse error " + str(e)) return activity def UploadActivity(self, serviceRecord, activity): #/proxy/upload-service-1.1/json/upload/.tcx activity.EnsureTZ() tcx_file = TCXIO.Dump(activity) files = {"data": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".tcx", tcx_file)} cookies = self._get_cookies(record=serviceRecord) res = requests.post("http://connect.garmin.com/proxy/upload-service-1.1/json/upload/.tcx", files=files, cookies=cookies) res = res.json()["detailedImportResult"] if len(res["successes"]) != 1: raise APIException("Unable to upload activity") actid = res["successes"][0]["internalId"] if activity.Type not in [ActivityType.Running, ActivityType.Cycling, ActivityType.Other]: # Set the legit activity type - whatever it is, it's not supported by the TCX schema acttype = [k for k, v in self._reverseActivityMappings.items() if v == activity.Type] if len(acttype) == 0: raise APIWarning("GarminConnect does not support activity type " + activity.Type) else: acttype = acttype[0] res = requests.post("http://connect.garmin.com/proxy/activity-service-1.2/json/type/" + str(actid), data={"value": acttype}, cookies=cookies) res = res.json() if "activityType" not in res or res["activityType"]["key"] != acttype: raise APIWarning("Unable to set activity type") def RevokeAuthorization(self, serviceRecord): # nothing to do here... pass def DeleteCachedData(self, serviceRecord): # nothing cached... pass