def PollPartialSyncTrigger(self, multiple_index): # TODO: ensure the appropriate users are connected # GET http://connect.garmin.com/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"]) # Then, check for users with new activities self._rate_limit() watch_activities_resp = session.get("http://connect.garmin.com/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())}}, {"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("http://connect.garmin.com/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("http://connect.garmin.com/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() ignore_resp = session.put("http://connect.garmin.com/proxy/userprofile-service/connection/decline/%s" % pending_connect["connectionRequestId"]) return to_sync_ids
def PollPartialSyncTrigger(self, multiple_index): # TODO: ensure the appropriate users are connected # GET http://connect.garmin.com/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("http://connect.garmin.com/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("http://connect.garmin.com/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("http://connect.garmin.com/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() ignore_resp = session.put("http://connect.garmin.com/proxy/userprofile-service/connection/decline/%s" % pending_connect["connectionRequestId"]) return to_sync_ids
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"]]
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()
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
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 GarminConnectService(ServiceBase): ID = "garminconnect" DisplayName = "Garmin Connect" DisplayAbbreviation = "GC" AuthenticationType = ServiceAuthenticationType.UsernamePassword RequiresExtendedAuthorizationDetails = True PartialSyncRequiresTrigger = True PartialSyncTriggerPollInterval = timedelta(minutes=10) 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, "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 cached = self._sessionCache.Get(record.ExternalID if record else email) if cached: return cached if record: # longing for C style overloads... password = CredentialStore.Decrypt( record.ExtendedAuthorization["Password"]) email = CredentialStore.Decrypt( record.ExtendedAuthorization["Email"]) 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)) self._sessionCache.Set(record.ExternalID if record else email, 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) 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" % res.status_code) 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"] name = activity.Name # Capture in logs notes = activity.Notes 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=urllib.parse.urlencode({ "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=urllib.parse.urlencode({ "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 _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 = requests.put( "http://connect.garmin.com/proxy/userprofile-service/connection/request/%s" % user_name, cookies=self._get_cookies(record=serviceRecord)) 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)) 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) cookies = self._get_cookies(email=active_watch_user["Username"], password=active_watch_user["Password"]) self._rate_limit() connections = requests.get( "http://connect.garmin.com/proxy/userprofile-service/socialProfile/connections", cookies=cookies).json() for connection in connections["userConnections"]: if connection["displayName"] == serviceRecord.ExternalID: self._rate_limit() dc_resp = requests.put( "http://connect.garmin.com/proxy/userprofile-service/connection/end/%s" % connection["connectionRequestId"], cookies=cookies) if dc_resp.status_code != 200: raise APIException( "Error disconnecting user watch accunt %s from %s: %s %s" % (active_watch_user, connection["displayName"], dc_resp.status_code, dc_resp.text)) serviceRecord.SetConfiguration({"WatchUserKey": None}) serviceRecord.SetPartialSyncTriggerSubscriptionState(False) def PollPartialSyncTrigger(self, multiple_index): # TODO: ensure the appropriate users are connected # GET http://connect.garmin.com/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] cookies = self._get_cookies(email=watch_user["Username"], password=watch_user["Password"]) self._rate_limit() pending_connections_resp = requests.get( "http://connect.garmin.com/proxy/userprofile-service/connection/pending", cookies=cookies) try: pending_connections = pending_connections_resp.json() except ValueError: raise Exception( "Could not parse pending connection requests: %s %s" % (pending_connections_resp.status_code, pending_connections_resp.text)) 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 = requests.put( "http://connect.garmin.com/proxy/userprofile-service/connection/accept/%s" % pending_connect["connectionRequestId"], cookies=cookies) if connect_resp.status_code != 200: raise APIException( "Error accepting request on watch account %s: %s %s" % (watch_user["Name"], connect_resp.status_code, connect_resp.text)) else: self._rate_limit() ignore_resp = requests.put( "http://connect.garmin.com/proxy/userprofile-service/connection/decline/%s" % pending_connect["connectionRequestId"], cookies=cookies) # Then, check for users with new activities self._rate_limit() watch_activities_resp = requests.get( "http://connect.garmin.com/proxy/activitylist-service/activities/comments/subscriptionFeed?start=1&limit=10", cookies=cookies) 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], reverse=True) # Highest IDs first active_users = dict(active_user_pairs) active_user_recs = [ ServiceRecord(x) for x in db.connections.find( { "Config.WatchUserKey": watch_user_key, "ExternalID": { "$in": list(active_users.keys()) } }, { "Config": 1, "ExternalID": 1, "Service": 1 }) ] 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._id) active_user_rec.SetConfiguration( {"WatchUserLastID": this_active_id}) return to_sync_ids def RevokeAuthorization(self, serviceRecord): # nothing to do here... pass def DeleteCachedData(self, serviceRecord): # nothing cached... pass