def test_stat_coalesce_multi(self): stat1 = ActivityStatistic(ActivityStatisticUnit.Meters, value=1) stat2 = ActivityStatistic(ActivityStatisticUnit.Meters, value=2) stat3 = ActivityStatistic(ActivityStatisticUnit.Meters, value=3) stat4 = ActivityStatistic(ActivityStatisticUnit.Meters, value=4) stat5 = ActivityStatistic(ActivityStatisticUnit.Meters, value=5) stat1.coalesceWith(stat2) stat1.coalesceWith(stat3) stat1.coalesceWith(stat4) stat1.coalesceWith(stat5) self.assertEqual(stat1.Value, 3)
def test_unitconv_temp(self): stat = ActivityStatistic(ActivityStatisticUnit.DegreesCelcius, value=0) self.assertEqual(stat.asUnits(ActivityStatisticUnit.DegreesFahrenheit).Value, 32) stat = ActivityStatistic(ActivityStatisticUnit.DegreesCelcius, value=-40) self.assertEqual(stat.asUnits(ActivityStatisticUnit.DegreesFahrenheit).Value, -40) stat = ActivityStatistic(ActivityStatisticUnit.DegreesFahrenheit, value=-40) self.assertEqual(stat.asUnits(ActivityStatisticUnit.DegreesCelcius).Value, -40) stat = ActivityStatistic(ActivityStatisticUnit.DegreesFahrenheit, value=32) self.assertEqual(stat.asUnits(ActivityStatisticUnit.DegreesCelcius).Value, 0)
def test_unitconv_distance_cross(self): stat = ActivityStatistic(ActivityStatisticUnit.Kilometers, value=1) self.assertAlmostEqual(stat.asUnits(ActivityStatisticUnit.Miles).Value, 0.6214, places=4) stat = ActivityStatistic(ActivityStatisticUnit.Miles, value=1) self.assertAlmostEqual(stat.asUnits(ActivityStatisticUnit.Kilometers).Value, 1.609, places=3) stat = ActivityStatistic(ActivityStatisticUnit.Miles, value=1) self.assertAlmostEqual(stat.asUnits(ActivityStatisticUnit.Meters).Value, 1609, places=0)
def test_stat_coalesce_missing(self): stat1 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None) stat2 = ActivityStatistic(ActivityStatisticUnit.Meters, value=2) stat1.coalesceWith(stat2) self.assertEqual(stat1.Value, 2) stat1 = ActivityStatistic(ActivityStatisticUnit.Meters, value=1) stat2 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None) stat1.coalesceWith(stat2) self.assertEqual(stat1.Value, 1)
def test_unitconv_velocity_cross(self): stat = ActivityStatistic(ActivityStatisticUnit.KilometersPerHour, value=100) self.assertAlmostEqual(stat.asUnits(ActivityStatisticUnit.MilesPerHour).Value, 62, places=0) stat = ActivityStatistic(ActivityStatisticUnit.MilesPerHour, value=60) self.assertAlmostEqual(stat.asUnits(ActivityStatisticUnit.KilometersPerHour).Value, 96.5, places=0)
def test_unitconv_distance_nonmetric(self): stat = ActivityStatistic(ActivityStatisticUnit.Miles, value=1) self.assertEqual(stat.asUnits(ActivityStatisticUnit.Feet).Value, 5280) stat = ActivityStatistic(ActivityStatisticUnit.Feet, value=5280/2) self.assertEqual(stat.asUnits(ActivityStatisticUnit.Miles).Value, 0.5)
def DownloadActivityList(self, serviceRecord, exhaustive=False): activities = [] session = self._get_session(record=serviceRecord) session.headers.update({"Accept": "application/json"}) workouts_resp = session.get( "https://api.trainerroad.com/api/careerworkouts") if workouts_resp.status_code != 200: if workouts_resp.status_code == 401: raise APIException("Invalid login", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) raise APIException("Workout listing error") cached_record = cachedb.trainerroad_meta.find_one( {"ExternalID": serviceRecord.ExternalID}) if not cached_record: cached_workout_meta = {} else: cached_workout_meta = cached_record["Workouts"] workouts = workouts_resp.json() for workout in workouts: # Un/f their API doesn't provide the start/end times in the list response # So we need to pull the extra data, if it's not already cached workout_id = str(workout["Id"]) # Mongo doesn't do non-string keys if workout_id not in cached_workout_meta: meta_resp = session.get( "https://api.trainerroad.com/api/careerworkouts?guid=%s" % workout["Guid"]) # We don't need everything full_meta = meta_resp.json() meta = { key: full_meta[key] for key in [ "WorkoutDate", "WorkoutName", "WorkoutNotes", "TotalMinutes", "TotalKM", "AvgWatts", "Kj" ] } cached_workout_meta[workout_id] = meta else: meta = cached_workout_meta[workout_id] activity = UploadedActivity() activity.ServiceData = {"ID": int(workout_id)} activity.Name = meta["WorkoutName"] activity.Notes = meta["WorkoutNotes"] activity.Type = ActivityType.Cycling # Everything's in UTC activity.StartTime = dateutil.parser.parse( meta["WorkoutDate"]).replace(tzinfo=pytz.utc) activity.EndTime = activity.StartTime + timedelta( minutes=meta["TotalMinutes"]) activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Kilometers, value=meta["TotalKM"]) activity.Stats.Power = ActivityStatistic( ActivityStatisticUnit.Watts, avg=meta["AvgWatts"]) activity.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilojoules, value=meta["Kj"]) activity.Stationary = False activity.GPS = False activity.CalculateUID() activities.append(activity) cachedb.trainerroad_meta.update( {"ExternalID": serviceRecord.ExternalID}, { "ExternalID": serviceRecord.ExternalID, "Workouts": cached_workout_meta }, upsert=True) return activities, []
def test_stat_sum(self): stat1 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None, min=None) stat2 = ActivityStatistic(ActivityStatisticUnit.Meters, value=2, max=2) stat3 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None, gain=3) stat4 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None, gain=4) stat5 = ActivityStatistic(ActivityStatisticUnit.Meters, value=5, max=3) stat5.sumWith(stat2) stat3.sumWith(stat5) stat4.sumWith(stat3) stat1.sumWith(stat4) self.assertEqual(stat1.Value, 7) self.assertEqual(stat1.Max, 3) self.assertEqual(stat1.Gain, 7)
def test_stat_coalesce_multi_missingmixed(self): stat1 = ActivityStatistic(ActivityStatisticUnit.Meters, value=1) stat2 = ActivityStatistic(ActivityStatisticUnit.Meters, value=2) stat3 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None) stat4 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None) stat5 = ActivityStatistic(ActivityStatisticUnit.Meters, value=5) stat5.coalesceWith(stat2) stat3.coalesceWith(stat5) stat4.coalesceWith(stat3) stat1.coalesceWith(stat4) self.assertAlmostEqual(stat1.Value, 8/3)
def DownloadActivityList(self, svcRecord, exhaustive=False): activities = [] exclusions = [] now = datetime.now() prev = now - timedelta(6 * 365 / 12) period = [] aperiod = "%s%02d-%s%02d" % (prev.year, prev.month, now.year, now.month) period.append(aperiod) if exhaustive: for _ in range(20): now = prev prev = now - timedelta(6 * 365 / 12) aperiod = "%s%02d-%s%02d" % (prev.year, prev.month, now.year, now.month) period.append(aperiod) for dateInterval in period: headers = self._getAuthHeaders(svcRecord) resp = requests.get(DECATHLONCOACH_API_BASE_URL + "/users/" + str(svcRecord.ExternalID) + "/activities.xml?date=" + dateInterval, headers=headers) if resp.status_code == 400: logger.info(resp.content) raise APIException( "No authorization to retrieve activity list", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) if resp.status_code == 401: logger.info(resp.content) raise APIException( "No authorization to retrieve activity list", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) if resp.status_code == 403: logger.info(resp.content) raise APIException( "No authorization to retrieve activity list", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) root = xml.fromstring(resp.content) logger.info("\t\t nb activity : " + str(len(root.findall('.//ID')))) for ride in root.iter('ACTIVITY'): activity = UploadedActivity() activity.TZ = pytz.timezone("UTC") startdate = ride.find('.//STARTDATE').text + ride.find( './/TIMEZONE').text datebase = parse(startdate) activity.StartTime = datebase #pytz.utc.localize(datebase) activity.ServiceData = { "ActivityID": ride.find('ID').text, "Manual": ride.find('MANUAL').text } logger.info("\t\t DecathlonCoach Activity ID : " + ride.find('ID').text) if ride.find('SPORTID' ).text not in self._reverseActivityTypeMappings: exclusions.append( APIExcludeActivity("Unsupported activity type %s" % ride.find('SPORTID').text, activity_id=ride.find('ID').text, user_exception=UserException( UserExceptionType.Other))) logger.info( "\t\tDecathlonCoach Unknown activity, sport id " + ride.find('SPORTID').text + " is not mapped") continue activity.Type = self._reverseActivityTypeMappings[ride.find( 'SPORTID').text] for val in ride.iter('VALUE'): if val.get('id') == self._unitMap["duration"]: activity.EndTime = activity.StartTime + timedelta( 0, int(val.text)) if val.get('id') == self._unitMap["distance"]: activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=int(val.text)) if val.get('id') == self._unitMap["kcal"]: activity.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilocalories, value=int(val.text)) if val.get('id') == self._unitMap["speedaverage"]: meterperhour = int(val.text) meterpersecond = meterperhour / 3600 activity.Stats.Speed = ActivityStatistic( ActivityStatisticUnit.MetersPerSecond, avg=meterpersecond, max=None) if ride.find('LIBELLE' ).text == "" or ride.find('LIBELLE').text is None: txtdate = startdate.split(' ') activity.Name = "Sport DecathlonCoach " + txtdate[0] else: activity.Name = ride.find('LIBELLE').text activity.Private = False activity.Stationary = ride.find('MANUAL').text activity.GPS = ride.find('ABOUT').find('TRACK').text activity.AdjustTZ() activity.CalculateUID() activities.append(activity) return activities, exclusions
def test_stat_update(self): stat1 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None, min=None) stat2 = ActivityStatistic(ActivityStatisticUnit.Meters, value=2, max=2) stat3 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None, gain=3) stat4 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None, gain=4) stat5 = ActivityStatistic(ActivityStatisticUnit.Meters, value=5, max=3) stat5.update(stat2) stat3.update(stat5) stat4.update(stat3) stat1.update(stat4) self.assertEqual(stat1.Value, 2) self.assertEqual(stat1.Max, 2) self.assertEqual(stat1.Gain, 3)
def DownloadActivityList(self, svcRecord, exhaustive=False): activities = [] exclusions = [] url = self.SingletrackerDomain + "getRidesByUserId" extID = svcRecord.ExternalID payload = {"userId": extID} headers = { 'content-type': "application/json", 'cache-control': "no-cache", } response = requests.post(url, data=json.dumps(payload), headers=headers) try: reqdata = response.json() except ValueError: raise APIException( "Failed parsing Singletracker list response %s - %s" % (resp.status_code, resp.text)) for ride in reqdata: activity = UploadedActivity() activity.StartTime = datetime.strptime( datetime.utcfromtimestamp( ride["startTime"]).strftime('%Y-%m-%d %H:%M:%S'), "%Y-%m-%d %H:%M:%S") if "stopTime" in ride: activity.EndTime = datetime.strptime( datetime.utcfromtimestamp( ride["stopTime"]).strftime('%Y-%m-%d %H:%M:%S'), "%Y-%m-%d %H:%M:%S") activity.ServiceData = { "ActivityID": ride["rideId"], "Manual": "False" } activity.Name = ride["trackName"] logger.debug("\tActivity s/t %s: %s" % (activity.StartTime, activity.Name)) activity.Type = ActivityType.MountainBiking if "totalDistance" in ride: activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=ride["totalDistance"]) if "avgSpeed" in ride: activity.Stats.Speed = ActivityStatistic( ActivityStatisticUnit.MetersPerSecond, avg=ride["avgSpeed"]) activity.Notes = None activity.GPS = True activity.Private = False activity.Stationary = False # True = no sensor data activity.CalculateUID() activities.append(activity) return activities, exclusions
def test_stat_coalesce_multi_mixed2(self): stat1 = ActivityStatistic(ActivityStatisticUnit.Meters, value=1) stat2 = ActivityStatistic(ActivityStatisticUnit.Meters, value=2) stat3 = ActivityStatistic(ActivityStatisticUnit.Meters, value=3) stat4 = ActivityStatistic(ActivityStatisticUnit.Meters, value=4) stat5 = ActivityStatistic(ActivityStatisticUnit.Meters, value=5) stat5.coalesceWith(stat2) stat3.coalesceWith(stat5) stat4.coalesceWith(stat3) stat1.coalesceWith(stat4) self.assertEqual(stat1.Value, 3)
def DownloadActivityList(self, serviceRecord, exhaustive=False): oauthSession = self._oauthSession(serviceRecord) activities = [] exclusions = [] page_url = "https://api.endomondo.com/api/1/workouts" while True: resp = oauthSession.get(page_url) try: respList = resp.json()["data"] except ValueError: self._rateLimitBailout(resp) raise APIException("Error decoding activity list resp %s %s" % (resp.status_code, resp.text)) for actInfo in respList: activity = UploadedActivity() activity.StartTime = self._parseDate(actInfo["start_time"]) logger.debug("Activity s/t %s" % activity.StartTime) if "is_tracking" in actInfo and actInfo["is_tracking"]: exclusions.append( APIExcludeActivity( "Not complete", activity_id=actInfo["id"], permanent=False, user_exception=UserException( UserExceptionType.LiveTracking))) continue if "end_time" in actInfo: activity.EndTime = self._parseDate(actInfo["end_time"]) if actInfo["sport"] in self._activityMappings: activity.Type = self._activityMappings[actInfo["sport"]] # "duration" is timer time if "duration_total" in actInfo: activity.Stats.TimerTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=float(actInfo["duration_total"])) if "distance_total" in actInfo: activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Kilometers, value=float(actInfo["distance_total"])) if "calories_total" in actInfo: activity.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilocalories, value=float(actInfo["calories_total"])) activity.Stats.Elevation = ActivityStatistic( ActivityStatisticUnit.Meters) if "altitude_max" in actInfo: activity.Stats.Elevation.Max = float( actInfo["altitude_max"]) if "altitude_min" in actInfo: activity.Stats.Elevation.Min = float( actInfo["altitude_min"]) if "total_ascent" in actInfo: activity.Stats.Elevation.Gain = float( actInfo["total_ascent"]) if "total_descent" in actInfo: activity.Stats.Elevation.Loss = float( actInfo["total_descent"]) activity.Stats.Speed = ActivityStatistic( ActivityStatisticUnit.KilometersPerHour) if "speed_max" in actInfo: activity.Stats.Speed.Max = float(actInfo["speed_max"]) if "heart_rate_avg" in actInfo: activity.Stats.HR = ActivityStatistic( ActivityStatisticUnit.BeatsPerMinute, avg=float(actInfo["heart_rate_avg"])) if "heart_rate_max" in actInfo: activity.Stats.HR.update( ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, max=float( actInfo["heart_rate_max"]))) if "cadence_avg" in actInfo: activity.Stats.Cadence = ActivityStatistic( ActivityStatisticUnit.RevolutionsPerMinute, avg=int(actInfo["cadence_avg"])) if "cadence_max" in actInfo: activity.Stats.Cadence.update( ActivityStatistic( ActivityStatisticUnit.RevolutionsPerMinute, max=int(actInfo["cadence_max"]))) if "title" in actInfo: activity.Name = actInfo["title"] activity.ServiceData = {"WorkoutID": int(actInfo["id"])} activity.CalculateUID() activities.append(activity) paging = resp.json()["paging"] if "next" not in paging or not paging["next"] or not exhaustive: break else: page_url = paging["next"] return activities, exclusions
def DownloadActivityList(self, svcRecord, exhaustive=False): ns = { "tpw": "http://www.trainingpeaks.com/TPWebServices/", "xsi": "http://www.w3.org/2001/XMLSchema-instance" } activities = [] exclusions = [] reqData = self._authData(svcRecord) limitDateFormat = "%d %B %Y" if exhaustive: listEnd = datetime.now() + timedelta( days=1.5) # Who knows which TZ it's in listStart = datetime(day=1, month=1, year=1980) # The beginning of time else: listEnd = datetime.now() + timedelta( days=1.5) # Who knows which TZ it's in listStart = listEnd - timedelta(days=20) # Doesn't really matter lastActivityDay = None discoveredWorkoutIds = [] while True: reqData.update({ "startDate": listStart.strftime(limitDateFormat), "endDate": listEnd.strftime(limitDateFormat) }) print("Requesting %s to %s" % (listStart, listEnd)) resp = requests.post( "https://www.trainingpeaks.com/tpwebservices/service.asmx/GetWorkoutsForAthlete", data=reqData) xresp = etree.XML(resp.content) for xworkout in xresp: activity = UploadedActivity() workoutId = xworkout.find("tpw:WorkoutId", namespaces=ns).text workoutDayEl = xworkout.find("tpw:WorkoutDay", namespaces=ns) startTimeEl = xworkout.find("tpw:StartTime", namespaces=ns) workoutDay = dateutil.parser.parse(workoutDayEl.text) startTime = dateutil.parser.parse( startTimeEl.text ) if startTimeEl is not None and startTimeEl.text else None if lastActivityDay is None or workoutDay.replace( tzinfo=None) > lastActivityDay: lastActivityDay = workoutDay.replace(tzinfo=None) if startTime is None: continue # Planned but not executed yet. activity.StartTime = startTime endTimeEl = xworkout.find("tpw:TimeTotalInSeconds", namespaces=ns) if not endTimeEl.text: exclusions.append( APIExcludeActivity("Activity has no duration", activityId=workoutId, userException=UserException( UserExceptionType.Corrupt))) continue activity.EndTime = activity.StartTime + timedelta( seconds=float(endTimeEl.text)) distEl = xworkout.find("tpw:DistanceInMeters", namespaces=ns) if distEl.text: activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=float(distEl.text)) # PWX is damn near comprehensive, no need to fill in any of the other statisitcs here, really if workoutId in discoveredWorkoutIds: continue # There's the possibility of query overlap, if there are multiple activities on a single day that fall across the query return limit discoveredWorkoutIds.append(workoutId) workoutTypeEl = xworkout.find("tpw:WorkoutTypeDescription", namespaces=ns) if workoutTypeEl.text: if workoutTypeEl.text == "Day Off": continue # TrainingPeaks has some weird activity types... if workoutTypeEl.text not in self._workoutTypeMappings: exclusions.append( APIExcludeActivity("Activity type %s unknown" % workoutTypeEl.text, activityId=workoutId, userException=UserException( UserExceptionType.Corrupt))) continue activity.Type = self._workoutTypeMappings[ workoutTypeEl.text] activity.ServiceData = {"WorkoutID": workoutId} activity.CalculateUID() activities.append(activity) if not exhaustive: break # Since TP only lets us query by date range, to get full activity history we need to query successively smaller ranges if len(xresp): if listStart == lastActivityDay: break # This wouldn't work if you had more than #MaxQueryReturn activities on that day - but that number is probably 50+ listStart = lastActivityDay else: break # We're done return activities, exclusions
def DownloadActivityList(self, serviceRecord, exhaustive=False): #http://connect.garmin.com/proxy/activity-search-service-1.0/json/activities?&start=0&limit=50 session = self._get_session(record=serviceRecord) page = 1 pageSz = 100 activities = [] exclusions = [] while True: logger.debug("Req with " + str({ "start": (page - 1) * pageSz, "limit": pageSz })) self._rate_limit() retried_auth = False while True: res = session.get( "http://connect.garmin.com/proxy/activity-search-service-1.0/json/activities", params={ "start": (page - 1) * pageSz, "limit": pageSz }) # It's 10 PM and I have no clue why it's throwing these errors, maybe we just need to log in again? if res.status_code == 403 and not retried_auth: retried_auth = True session = self._get_session(serviceRecord, skip_cache=True) else: break 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() # 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() # 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"])) 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 DownloadActivityList(self, svcRecord, exhaustive=False): activities = [] exclusions = [] before = earliestDate = None # define low parameter limit = 20 offset = 0 sort = "desc" # get user Fitbit ID userID = svcRecord.ExternalID # get service Tapiriik ID service_id = svcRecord._id # get user "start sync from date" info # then prepare afterDate var (this var determine the date since we download activities) user = db.users.find_one({'ConnectedServices': {'$elemMatch': {'ID': service_id, 'Service': 'fitbit'}}}) afterDateObj = datetime.now() - timedelta(days=1) if user['Config']['sync_skip_before'] is not None: afterDateObj = user['Config']['sync_skip_before'] else: if exhaustive: afterDateObj = datetime.now() - timedelta(days=3650) # throw back to 10 years afterDate = afterDateObj.strftime("%Y-%m-%d") logging.info("\t Download Fitbit activities since : " + afterDate) # prepare parameters to set in fitbit request uri uri_parameters = { 'limit': limit, 'offset': offset, 'sort': sort, 'afterDate': afterDate, 'token': svcRecord.Authorization.get('AccessToken') } # set base fitbit request uri activities_uri_origin = 'https://api.fitbit.com/1/user/' + userID + '/activities/list.json' # first execute offset = 0, # offset will be set to -1 if fitbit response don't give next pagination info # offset will be incremented by 1 if fitbit response give next pagination info index_total = 0 while offset > -1: # prepare uri parameters uri_parameters['offset'] = offset # build fitbit uri with new parameters activities_uri = activities_uri_origin + "?" + urlencode(uri_parameters) # execute fitbit request using "request with auth" function (it refreshes token if needed) logging.info("\t\t downloading offset : " + str(offset)) resp = self._requestWithAuth(lambda session: session.get( activities_uri, headers={ 'Authorization': 'Bearer ' + svcRecord.Authorization.get('AccessToken') }), svcRecord) # check if request has error if resp.status_code != 204 and resp.status_code != 200: raise APIException("Unable to find Fitbit activities") # get request data data = {} try: data = resp.json() except ValueError: raise APIException("Failed parsing fitbit list response %s - %s" % (resp.status_code, resp.text)) # if request return activities infos if data['activities']: ftbt_activities = data['activities'] logging.info("\t\t nb activity : " + str(len(ftbt_activities))) # for every activities in this request pagination # (Fitbit give 20 activities MAXIMUM, use limit parameter) for ftbt_activity in ftbt_activities: index_total = index_total +1 activity = UploadedActivity() #parse date start to get timezone and date parsedDate = ftbt_activity["startTime"][0:19] + ftbt_activity["startTime"][23:] activity.StartTime = datetime.strptime(parsedDate, "%Y-%m-%dT%H:%M:%S%z") activity.TZ = pytz.utc logger.debug("\tActivity s/t %s: %s" % (activity.StartTime, ftbt_activity["activityName"])) activity.EndTime = activity.StartTime + timedelta(0, (ftbt_activity["duration"]/1000)) activity.ServiceData = {"ActivityID": ftbt_activity["logId"], "Manual": ftbt_activity["logType"]} # check if activity type ID exists if ftbt_activity["activityTypeId"] not in self._reverseActivityTypeMappings: exclusions.append(APIExcludeActivity("Unsupported activity type %s" % ftbt_activity["activityTypeId"], activity_id=ftbt_activity["logId"], user_exception=UserException(UserExceptionType.Other))) logger.info("\t\tUnknown activity") continue activity.Type = self._reverseActivityTypeMappings[ftbt_activity["activityTypeId"]] activity.Stats.Distance = ActivityStatistic(ActivityStatisticUnit.Kilometers, value=ftbt_activity["distance"]) if "speed" in ftbt_activity: activity.Stats.Speed = ActivityStatistic( ActivityStatisticUnit.KilometersPerHour, avg=ftbt_activity["speed"], max=ftbt_activity["speed"] ) activity.Stats.Energy = ActivityStatistic(ActivityStatisticUnit.Kilocalories, value=ftbt_activity["calories"]) # Todo: find fitbit data name #activity.Stats.MovingTime = ActivityStatistic(ActivityStatisticUnit.Seconds, value=ride[ # "moving_time"] if "moving_time" in ride and ride[ # "moving_time"] > 0 else None) # They don't let you manually enter this, and I think it returns 0 for those activities. # Todo: find fitbit data name #if "average_watts" in ride: # activity.Stats.Power = ActivityStatistic(ActivityStatisticUnit.Watts, # avg=ride["average_watts"]) if "averageHeartRate" in ftbt_activity: activity.Stats.HR.update( ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, avg=ftbt_activity["averageHeartRate"])) # Todo: find fitbit data name #if "max_heartrate" in ride: # activity.Stats.HR.update( # ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, max=ride["max_heartrate"])) # Todo: find fitbit data name #if "average_cadence" in ride: # activity.Stats.Cadence.update(ActivityStatistic(ActivityStatisticUnit.RevolutionsPerMinute, # avg=ride["average_cadence"])) # Todo: find fitbit data name #if "average_temp" in ride: # activity.Stats.Temperature.update( # ActivityStatistic(ActivityStatisticUnit.DegreesCelcius, avg=ride["average_temp"])) if "calories" in ftbt_activity: activity.Stats.Energy = ActivityStatistic(ActivityStatisticUnit.Kilocalories, value=ftbt_activity["calories"]) activity.Name = ftbt_activity["activityName"] activity.Private = False if ftbt_activity['logType'] is 'manual': activity.Stationary = True else: activity.Stationary = False # Todo: find fitbit data #activity.GPS = ("start_latlng" in ride) and (ride["start_latlng"] is not None) activity.AdjustTZ() activity.CalculateUID() activities.append(activity) logging.info("\t\t Fitbit Activity ID : " + str(ftbt_activity["logId"])) if not exhaustive: break # get next info for while condition and prepare offset for next request if 'next' not in data['pagination'] or not data['pagination']['next']: next = None offset = -1 else: next = data['pagination']['next'] offset = offset + 1 logging.info("\t\t total Fitbit activities downloaded : " + str(index_total)) return activities, exclusions
def DownloadActivityList(self, serviceRecord, exhaustive=False): def mapStatTriple(act, stats_obj, key, units): if "%s_max" % key in act and act["%s_max" % key]: stats_obj.update( ActivityStatistic(units, max=float(act["%s_max" % key]))) if "%s_min" % key in act and act["%s_min" % key]: stats_obj.update( ActivityStatistic(units, min=float(act["%s_min" % key]))) if "%s_avg" % key in act and act["%s_avg" % key]: stats_obj.update( ActivityStatistic(units, avg=float(act["%s_avg" % key]))) # http://ridewithgps.com/users/1/trips.json?limit=200&order_by=created_at&order_dir=asc # offset also supported activities = [] exclusions = [] # They don't actually support paging right now, for whatever reason params = self._add_auth_params({}, record=serviceRecord) res = requests.get( "https://ridewithgps.com/users/{}/trips.json".format( serviceRecord.ExternalID), params=params) res = res.json() # Apparently some API users are seeing this new result format - I'm not if type(res) is dict: res = res.get("results", []) if res == []: return [], [] # No activities for act in res: if "distance" not in act: exclusions.append( APIExcludeActivity("No distance", activity_id=act["id"], user_exception=UserException( UserExceptionType.Corrupt))) continue if "duration" not in act or not act["duration"]: exclusions.append( APIExcludeActivity("No duration", activity_id=act["id"], user_exception=UserException( UserExceptionType.Corrupt))) continue activity = UploadedActivity() logger.debug("Name " + act["name"] + ":") if len(act["name"].strip()): activity.Name = act["name"] if len(act["description"].strip()): activity.Notes = act["description"] activity.GPS = act["is_gps"] activity.Stationary = not activity.GPS # I think # 0 = public, 1 = private, 2 = friends activity.Private = act["visibility"] == 1 activity.StartTime = dateutil.parser.parse(act["departed_at"]) try: activity.TZ = pytz.timezone(act["time_zone"]) except pytz.exceptions.UnknownTimeZoneError: # Sometimes the time_zone returned isn't quite what we'd like it # So, just pull the offset from the datetime if isinstance(activity.StartTime.tzinfo, tzutc): activity.TZ = pytz.utc # The dateutil tzutc doesn't have an _offset value. else: activity.TZ = pytz.FixedOffset( activity.StartTime.tzinfo.utcoffset( activity.StartTime).total_seconds() / 60) activity.StartTime = activity.StartTime.replace( tzinfo=activity.TZ) # Overwrite dateutil's sillyness activity.EndTime = activity.StartTime + timedelta( seconds=self._duration_to_seconds(act["duration"])) logger.debug("Activity s/t " + str(activity.StartTime)) activity.AdjustTZ() activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, float(act["distance"])) mapStatTriple(act, activity.Stats.Power, "watts", ActivityStatisticUnit.Watts) mapStatTriple(act, activity.Stats.Speed, "speed", ActivityStatisticUnit.KilometersPerHour) mapStatTriple(act, activity.Stats.Cadence, "cad", ActivityStatisticUnit.RevolutionsPerMinute) mapStatTriple(act, activity.Stats.HR, "hr", ActivityStatisticUnit.BeatsPerMinute) if "elevation_gain" in act and act["elevation_gain"]: activity.Stats.Elevation.update( ActivityStatistic(ActivityStatisticUnit.Meters, gain=float(act["elevation_gain"]))) if "elevation_loss" in act and act["elevation_loss"]: activity.Stats.Elevation.update( ActivityStatistic(ActivityStatisticUnit.Meters, loss=float(act["elevation_loss"]))) # Activity type is not implemented yet in RWGPS results; we will assume cycling, though perhaps "OTHER" wouuld be correct activity.Type = ActivityType.Cycling activity.CalculateUID() activity.ServiceData = {"ActivityID": act["id"]} activities.append(activity) return activities, exclusions
def DownloadActivityList(self, svcRecord, exhaustive_start_time=None): activities = [] exclusions = [] headers = self._apiHeaders(svcRecord) limitDateFormat = "%Y-%m-%d" if exhaustive_start_time: totalListEnd = datetime.now() + timedelta( days=1.5) # Who knows which TZ it's in totalListStart = exhaustive_start_time - timedelta(days=1.5) else: totalListEnd = datetime.now() + timedelta( days=1.5) # Who knows which TZ it's in totalListStart = totalListEnd - timedelta( days=20) # Doesn't really matter listStep = timedelta(days=45) listEnd = totalListEnd listStart = max(totalListStart, totalListEnd - listStep) while True: logger.debug("Requesting %s to %s" % (listStart, listEnd)) resp = requests.get(TRAININGPEAKS_API_BASE_URL + "/v1/workouts/%s/%s" % (listStart.strftime(limitDateFormat), listEnd.strftime(limitDateFormat)), headers=headers) for act in resp.json(): if not act.get("completed", True): continue activity = UploadedActivity() activity.StartTime = dateutil.parser.parse( act["StartTime"]).replace(tzinfo=None) logger.debug("Activity s/t " + str(activity.StartTime)) activity.EndTime = activity.StartTime + timedelta( hours=act["TotalTime"]) activity.Name = act.get("Title", None) activity.Notes = act.get("Description", None) activity.Type = self._workoutTypeMappings.get( act.get("WorkoutType", "").lower(), ActivityType.Other) activity.Stats.Cadence = ActivityStatistic( ActivityStatisticUnit.RevolutionsPerMinute, avg=act.get("CadenceAverage", None), max=act.get("CadenceMaximum", None)) activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=act.get("Distance", None)) activity.Stats.Elevation = ActivityStatistic( ActivityStatisticUnit.Meters, avg=act.get("ElevationAverage", None), min=act.get("ElevationMinimum", None), max=act.get("ElevationMaximum", None), gain=act.get("ElevationGain", None), loss=act.get("ElevationLoss", None)) activity.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilojoules, value=act.get("Energy", None)) activity.Stats.HR = ActivityStatistic( ActivityStatisticUnit.BeatsPerMinute, avg=act.get("HeartRateAverage", None), min=act.get("HeartRateMinimum", None), max=act.get("HeartRateMaximum", None)) activity.Stats.Power = ActivityStatistic( ActivityStatisticUnit.Watts, avg=act.get("PowerAverage", None), max=act.get("PowerMaximum", None)) activity.Stats.Temperature = ActivityStatistic( ActivityStatisticUnit.DegreesCelcius, avg=act.get("TemperatureAverage", None), min=act.get("TemperatureMinimum", None), max=act.get("TemperatureMaximum", None)) activity.Stats.Speed = ActivityStatistic( ActivityStatisticUnit.MetersPerSecond, avg=act.get("VelocityAverage", None), max=act.get("VelocityMaximum", None)) activity.CalculateUID() activities.append(activity) if not exhaustive_start_time: break listStart -= listStep listEnd -= listStep if listEnd < totalListStart: break return activities, exclusions
def test_stat_coalesce_multi_missingmixed_multivalued(self): stat1 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None, min=None) stat2 = ActivityStatistic(ActivityStatisticUnit.Meters, value=2, max=2) stat3 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None, gain=3) stat4 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None, loss=4) stat5 = ActivityStatistic(ActivityStatisticUnit.Meters, value=5, min=3) stat5.coalesceWith(stat2) stat3.coalesceWith(stat5) stat4.coalesceWith(stat3) stat1.coalesceWith(stat4) self.assertAlmostEqual(stat1.Value, 7 / 2) self.assertEqual(stat1.Min, 3) self.assertEqual(stat1.Max, 2) self.assertEqual(stat1.Gain, 3) self.assertEqual(stat1.Loss, 4)
def DownloadActivityList(self, serviceRecord, exhaustive=False): """ GET List of Activities as JSON File URL: http://app.velohero.com/export/workouts/json Parameters: user = username pass = password date_from = YYYY-MM-DD date_to = YYYY-MM-DD """ activities = [] exclusions = [] discoveredWorkoutIds = [] params = self._add_auth_params({}, record=serviceRecord) limitDateFormat = "%Y-%m-%d" if exhaustive: listEnd = datetime.now() + timedelta( days=1.5) # Who knows which TZ it's in listStart = datetime(day=1, month=1, year=1980) # The beginning of time else: listEnd = datetime.now() + timedelta( days=1.5) # Who knows which TZ it's in listStart = listEnd - timedelta(days=20) # Doesn't really matter params.update({ "date_from": listStart.strftime(limitDateFormat), "date_to": listEnd.strftime(limitDateFormat) }) logger.debug("Requesting %s to %s" % (listStart, listEnd)) res = requests.get(self._urlRoot + "/export/workouts/json", params=params) if res.status_code != 200: if res.status_code == 403: raise APIException("Invalid login", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) raise APIException("Unable to retrieve activity list") res.raise_for_status() try: res = res.json() except ValueError: raise APIException("Could not decode activity list") if "workouts" not in res: raise APIException("No activities") for workout in res["workouts"]: workoutId = int(workout["id"]) if workoutId in discoveredWorkoutIds: continue # There's the possibility of query overlap discoveredWorkoutIds.append(workoutId) if workout["file"] is not "1": logger.debug("Skip workout with ID: " + str(workoutId) + " (no file)") continue # Skip activity without samples (no PWX export) activity = UploadedActivity() logger.debug("Workout ID: " + str(workoutId)) # Duration (dur_time) duration = self._durationToSeconds(workout["dur_time"]) activity.Stats.TimerTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=duration) # Start time (date_ymd, start_time) startTimeStr = workout["date_ymd"] + " " + workout["start_time"] activity.StartTime = self._parseDateTime(startTimeStr) # End time (date_ymd, start_time) + dur_time activity.EndTime = self._parseDateTime(startTimeStr) + timedelta( seconds=duration) # Sport (sport_id) if workout["sport_id"] in self._reverseActivityMappings: activity.Type = self._reverseActivityMappings[ workout["sport_id"]] else: activity.Type = ActivityType.Other # Distance (dist_km) activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Kilometers, value=float(workout["dist_km"])) # Workout is hidden activity.Private = workout["hide"] == "1" activity.ServiceData = {"workoutId": workoutId} activity.CalculateUID() activities.append(activity) return activities, exclusions
def _populate_sbr_activity(self, api_sbr_activity, usersettings): # Example JSON feed (unimportant fields have been removed) # [{ # "EventId": 63128401, # Internal ID # "EventType": 3, # Swim (3), bike (1), or run (2) # "EventDate": "4/22/2016", # "EventTime": "7:44 AM", # User's time, time zone not specified # "Planned": false, # Training plan or actual data # "TotalMinutes": 34.97, # "TotalKilometers": 1.55448, # "AverageHeartRate": 125, # "MinimumHeartRate": 100, # "MaximumHeartRate": 150, # "MemberId": 999999, # "MemberUsername": "******", # "HasDeviceUpload": true, # "DeviceUploadFile": "http://beginnertriathlete.com/discussion/storage/workouts/555555/abcd-123.fit", # "RouteName": "", # Might contain a description of the event # "Comments": "", # User supplied notes # }, ... ] activity = UploadedActivity() workout_id = api_sbr_activity["EventId"] eventType = api_sbr_activity["EventType"] eventDate = api_sbr_activity["EventDate"] eventTime = api_sbr_activity["EventTime"] totalMinutes = api_sbr_activity["TotalMinutes"] totalKms = api_sbr_activity["TotalKilometers"] averageHr = api_sbr_activity["AverageHeartRate"] minimumHr = api_sbr_activity["MinimumHeartRate"] maximumHr = api_sbr_activity["MaximumHeartRate"] deviceUploadFile = api_sbr_activity["DeviceUploadFile"] comments = api_sbr_activity["Comments"] # Basic SBR data does not include GPS or sensor data. If this event originated from a device upload, # DownloadActivity will find it. activity.Stationary = True # Same as above- The data might be there, but it's not supplied in the basic activity feed. activity.GPS = False activity.Notes = comments activity.Private = usersettings["Privacy"] activity.Type = self._workoutTypeMappings[str(eventType)] # Get the user's timezone from their profile. (Activity.TZ should be mentioned in the object hierarchy docs?) # Question: I believe if DownloadActivity finds device data, it will overwrite this. Which is OK with me. # The device data will most likely be more accurate. try: activity.TZ = pytz.timezone(usersettings["TimeZone"]) except pytz.exceptions.UnknownTimeZoneError: activity.TZ = pytz.timezone(self._serverDefaultTimezone) # activity.StartTime and EndTime aren't mentioned in the object hierarchy docs, but I see them # set in all the other providers. activity.StartTime = dateutil.parser.parse( eventDate + " " + eventTime, dayfirst=False).replace(tzinfo=activity.TZ) activity.EndTime = activity.StartTime + timedelta(minutes=totalMinutes) # We can calculate some metrics from the supplied data. Would love to see some non-source code documentation # on each statistic and what it expects as input. activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Kilometers, value=totalKms) activity.Stats.HR = ActivityStatistic( ActivityStatisticUnit.BeatsPerMinute, avg=float(averageHr), min=float(minimumHr), max=float(maximumHr)) activity.Stats.MovingTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=float(totalMinutes * 60)) activity.Stats.TimerTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=float(totalMinutes * 60)) # While BT does support laps, the current API doesn't report on them - a limitation that may need to be # corrected in a future update. For now, treat manual entries as a single lap. As more and more people upload # workouts using devices anyway, this probably matters much less than it once did. lap = Lap(stats=activity.Stats, startTime=activity.StartTime, endTime=activity.EndTime) activity.Laps = [lap] # Not 100% positive how this is utilized, but it is common for all providers. Detects duplicate downloads? activity.CalculateUID() # If a device file is attached, we'll get more details about this event in DownloadActivity activity.ServiceData = { "ID": int(workout_id), "DeviceUploadFile": deviceUploadFile } return activity
def test_stat_coalesce(self): stat1 = ActivityStatistic(ActivityStatisticUnit.Meters, value=1) stat2 = ActivityStatistic(ActivityStatisticUnit.Meters, value=2) stat1.coalesceWith(stat2) self.assertEqual(stat1.Value, 1.5)
def DownloadActivityList(self, serviceRecord, exhaustive=False): headers = self._getAuthHeaders(serviceRecord) activities = [] exclusions = [] pageUri = self.OpenFitEndpoint + "/fitnessActivities.json" activity_tz_cache_raw = cachedb.sporttracks_meta_cache.find_one( {"ExternalID": serviceRecord.ExternalID}) activity_tz_cache_raw = activity_tz_cache_raw if activity_tz_cache_raw else { "Activities": [] } activity_tz_cache = dict([(x["ActivityURI"], x["TZ"]) for x in activity_tz_cache_raw["Activities"] ]) while True: logger.debug("Req against " + pageUri) res = requests.get(pageUri, headers=headers) try: res = res.json() except ValueError: raise APIException( "Could not decode activity list response %s %s" % (res.status_code, res.text)) for act in res["items"]: activity = UploadedActivity() activity.ServiceData = {"ActivityURI": act["uri"]} if len(act["name"].strip()): activity.Name = act["name"] # Longstanding ST.mobi bug causes it to return negative partial-hour timezones as "-2:-30" instead of "-2:30" fixed_start_time = re.sub(r":-(\d\d)", r":\1", act["start_time"]) activity.StartTime = dateutil.parser.parse(fixed_start_time) if isinstance(activity.StartTime.tzinfo, tzutc): activity.TZ = pytz.utc # The dateutil tzutc doesn't have an _offset value. else: activity.TZ = pytz.FixedOffset( activity.StartTime.tzinfo.utcoffset( activity.StartTime).total_seconds() / 60 ) # Convert the dateutil lame timezones into pytz awesome timezones. activity.StartTime = activity.StartTime.replace( tzinfo=activity.TZ) activity.EndTime = activity.StartTime + timedelta( seconds=float(act["duration"])) activity.Stats.TimerTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=float(act["duration"] )) # OpenFit says this excludes paused times. # Sometimes activities get returned with a UTC timezone even when they are clearly not in UTC. if activity.TZ == pytz.utc: if act["uri"] in activity_tz_cache: activity.TZ = pytz.FixedOffset( activity_tz_cache[act["uri"]]) else: # So, we get the first location in the activity and calculate the TZ from that. try: firstLocation = self._downloadActivity( serviceRecord, activity, returnFirstLocation=True) except APIExcludeActivity: pass else: try: activity.CalculateTZ(firstLocation, recalculate=True) except: # We tried! pass else: activity.AdjustTZ() finally: activity_tz_cache[ act["uri"]] = activity.StartTime.utcoffset( ).total_seconds() / 60 logger.debug("Activity s/t " + str(activity.StartTime)) activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=float(act["total_distance"])) types = [x.strip().lower() for x in act["type"].split(":")] types.reverse( ) # The incoming format is like "walking: hiking" and we want the most specific first activity.Type = None for type_key in types: if type_key in self._activityMappings: activity.Type = self._activityMappings[type_key] break if not activity.Type: exclusions.append( APIExcludeActivity("Unknown activity type %s" % act["type"], activity_id=act["uri"], user_exception=UserException( UserExceptionType.Other))) continue activity.CalculateUID() activities.append(activity) if not exhaustive or "next" not in res or not len(res["next"]): break else: pageUri = res["next"] logger.debug("Writing back meta cache") cachedb.sporttracks_meta_cache.update( {"ExternalID": serviceRecord.ExternalID}, { "ExternalID": serviceRecord.ExternalID, "Activities": [{ "ActivityURI": k, "TZ": v } for k, v in activity_tz_cache.items()] }, upsert=True) return activities, exclusions
def test_stat_coalesce_multi_missingmixed(self): stat1 = ActivityStatistic(ActivityStatisticUnit.Meters, value=1) stat2 = ActivityStatistic(ActivityStatisticUnit.Meters, value=2) stat3 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None) stat4 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None) stat5 = ActivityStatistic(ActivityStatisticUnit.Meters, value=5) stat5.coalesceWith(stat2) stat3.coalesceWith(stat5) stat4.coalesceWith(stat3) stat1.coalesceWith(stat4) self.assertAlmostEqual(stat1.Value, 8 / 3)
def _downloadActivity(self, serviceRecord, activity, returnFirstLocation=False): activityURI = activity.ServiceData["ActivityURI"] headers = self._getAuthHeaders(serviceRecord) activityData = requests.get(activityURI, headers=headers) activityData = activityData.json() if "clock_duration" in activityData: activity.EndTime = activity.StartTime + timedelta( seconds=float(activityData["clock_duration"])) activity.Private = "sharing" in activityData and activityData[ "sharing"] != "public" activity.GPS = False # Gets set back if there is GPS data if "notes" in activityData: activity.Notes = activityData["notes"] activity.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilojoules, value=float(activityData["calories"])) activity.Stats.Elevation = ActivityStatistic( ActivityStatisticUnit.Meters, gain=float(activityData["elevation_gain"]) if "elevation_gain" in activityData else None, loss=float(activityData["elevation_loss"]) if "elevation_loss" in activityData else None) activity.Stats.HR = ActivityStatistic( ActivityStatisticUnit.BeatsPerMinute, avg=activityData["avg_heartrate"] if "avg_heartrate" in activityData else None, max=activityData["max_heartrate"] if "max_heartrate" in activityData else None) activity.Stats.Cadence = ActivityStatistic( ActivityStatisticUnit.RevolutionsPerMinute, avg=activityData["avg_cadence"] if "avg_cadence" in activityData else None, max=activityData["max_cadence"] if "max_cadence" in activityData else None) activity.Stats.Power = ActivityStatistic( ActivityStatisticUnit.Watts, avg=activityData["avg_power"] if "avg_power" in activityData else None, max=activityData["max_power"] if "max_power" in activityData else None) laps_info = [] laps_starts = [] if "laps" in activityData: laps_info = activityData["laps"] for lap in activityData["laps"]: laps_starts.append(dateutil.parser.parse(lap["start_time"])) lap = None for lapinfo in laps_info: lap = Lap() activity.Laps.append(lap) lap.StartTime = dateutil.parser.parse(lapinfo["start_time"]) lap.EndTime = lap.StartTime + timedelta( seconds=lapinfo["clock_duration"]) if "type" in lapinfo: lap.Intensity = LapIntensity.Active if lapinfo[ "type"] == "ACTIVE" else LapIntensity.Rest if "distance" in lapinfo: lap.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=float(lapinfo["distance"])) if "duration" in lapinfo: lap.Stats.TimerTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=lapinfo["duration"]) if "calories" in lapinfo: lap.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilojoules, value=lapinfo["calories"]) if "elevation_gain" in lapinfo: lap.Stats.Elevation.update( ActivityStatistic(ActivityStatisticUnit.Meters, gain=float(lapinfo["elevation_gain"]))) if "elevation_loss" in lapinfo: lap.Stats.Elevation.update( ActivityStatistic(ActivityStatisticUnit.Meters, loss=float(lapinfo["elevation_loss"]))) if "max_speed" in lapinfo: lap.Stats.Speed.update( ActivityStatistic(ActivityStatisticUnit.MetersPerSecond, max=float(lapinfo["max_speed"]))) if "max_speed" in lapinfo: lap.Stats.Speed.update( ActivityStatistic(ActivityStatisticUnit.MetersPerSecond, max=float(lapinfo["max_speed"]))) if "avg_speed" in lapinfo: lap.Stats.Speed.update( ActivityStatistic(ActivityStatisticUnit.MetersPerSecond, avg=float(lapinfo["avg_speed"]))) if "max_heartrate" in lapinfo: lap.Stats.HR.update( ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, max=float(lapinfo["max_heartrate"]))) if "avg_heartrate" in lapinfo: lap.Stats.HR.update( ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, avg=float(lapinfo["avg_heartrate"]))) if lap is None: # No explicit laps => make one that encompasses the entire activity lap = Lap() activity.Laps.append(lap) lap.Stats = activity.Stats lap.StartTime = activity.StartTime lap.EndTime = activity.EndTime elif len(activity.Laps) == 1: activity.Stats.update( activity.Laps[0].Stats ) # Lap stats have a bit more info generally. activity.Laps[0].Stats = activity.Stats timerStops = [] if "timer_stops" in activityData: for stop in activityData["timer_stops"]: timerStops.append([ dateutil.parser.parse(stop[0]), dateutil.parser.parse(stop[1]) ]) def isInTimerStop(timestamp): for stop in timerStops: if timestamp >= stop[0] and timestamp < stop[1]: return True if timestamp >= stop[1]: return False return False # Collate the individual streams into our waypoints. # Global sample rate is variable - will pick the next nearest stream datapoint. # Resampling happens on a lookbehind basis - new values will only appear their timestamp has been reached/passed wasInPause = False currentLapIdx = 0 lap = activity.Laps[currentLapIdx] streams = [] for stream in [ "location", "elevation", "heartrate", "power", "cadence", "distance" ]: if stream in activityData: streams.append(stream) stream_indices = dict([(stream, -1) for stream in streams ]) # -1 meaning the stream has yet to start stream_lengths = dict([(stream, len(activityData[stream]) / 2) for stream in streams]) # Data comes as "stream":[timestamp,value,timestamp,value,...] stream_values = {} for stream in streams: values = [] for x in range(0, int(len(activityData[stream]) / 2)): values.append((activityData[stream][x * 2], activityData[stream][x * 2 + 1])) stream_values[stream] = values currentOffset = 0 def streamVal(stream): nonlocal stream_values, stream_indices return stream_values[stream][stream_indices[stream]][1] def hasStreamData(stream): nonlocal stream_indices, streams return stream in streams and stream_indices[stream] >= 0 while True: advance_stream = None advance_offset = None for stream in streams: if stream_indices[stream] + 1 == stream_lengths[stream]: continue # We're at the end - can't advance if advance_offset is None or stream_values[stream][ stream_indices[stream] + 1][0] - currentOffset < advance_offset: advance_offset = stream_values[stream][ stream_indices[stream] + 1][0] - currentOffset advance_stream = stream if not advance_stream: break # We've hit the end of every stream, stop # Advance streams sharing the current timestamp for stream in streams: if stream == advance_stream: continue # For clarity, we increment this later if stream_indices[stream] + 1 == stream_lengths[stream]: continue # We're at the end - can't advance if stream_values[stream][ stream_indices[stream] + 1][0] == stream_values[advance_stream][ stream_indices[advance_stream] + 1][0]: stream_indices[stream] += 1 stream_indices[ advance_stream] += 1 # Advance the key stream for this waypoint currentOffset = stream_values[advance_stream][stream_indices[ advance_stream]][0] # Update the current time offset waypoint = Waypoint(activity.StartTime + timedelta(seconds=currentOffset)) if hasStreamData("location"): waypoint.Location = Location( streamVal("location")[0], streamVal("location")[1], None) activity.GPS = True if returnFirstLocation: return waypoint.Location if hasStreamData("elevation"): if not waypoint.Location: waypoint.Location = Location(None, None, None) waypoint.Location.Altitude = streamVal("elevation") if hasStreamData("heartrate"): waypoint.HR = streamVal("heartrate") if hasStreamData("power"): waypoint.Power = streamVal("power") if hasStreamData("cadence"): waypoint.Cadence = streamVal("cadence") if hasStreamData("distance"): waypoint.Distance = streamVal("distance") inPause = isInTimerStop(waypoint.Timestamp) waypoint.Type = WaypointType.Regular if not inPause else WaypointType.Pause if wasInPause and not inPause: waypoint.Type = WaypointType.Resume wasInPause = inPause # We only care if it's possible to start a new lap, i.e. there are more left if currentLapIdx + 1 < len(laps_starts): if laps_starts[currentLapIdx + 1] < waypoint.Timestamp: # A new lap has started currentLapIdx += 1 lap = activity.Laps[currentLapIdx] lap.Waypoints.append(waypoint) if returnFirstLocation: return None # I guess there were no waypoints? if activity.CountTotalWaypoints(): activity.GetFlatWaypoints()[0].Type = WaypointType.Start activity.GetFlatWaypoints()[-1].Type = WaypointType.End activity.Stationary = False else: activity.Stationary = True return activity
def 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 test_unitconv_distance_nonmetric(self): stat = ActivityStatistic(ActivityStatisticUnit.Miles, value=1) self.assertEqual(stat.asUnits(ActivityStatisticUnit.Feet).Value, 5280) stat = ActivityStatistic(ActivityStatisticUnit.Feet, value=5280 / 2) self.assertEqual(stat.asUnits(ActivityStatisticUnit.Miles).Value, 0.5)
def DownloadActivityList(self, serviceRecord, exhaustive=False): logger.debug("Checking motivato premium state") self._applyPaymentState(serviceRecord) logger.debug("Motivato DownloadActivityList") session = self._get_session(record=serviceRecord) activities = [] exclusions = [] self._rate_limit() retried_auth = False #headers = {'X-App-With-Tracks': "true"} headers = {} res = session.post(self._urlRoot + "/api/workouts/sync", headers=headers) if res.status_code == 403 and not retried_auth: retried_auth = True session = self._get_session(serviceRecord, skip_cache=True) try: respList = res.json() except ValueError: res_txt = res.text # So it can capture in the log message raise APIException("Parse failure in Motivato list resp: %s" % res.status_code) for actInfo in respList: if "duration" in actInfo: duration = self._durationToSeconds(actInfo["duration"]) else: continue activity = UploadedActivity() if "time_start" in actInfo["metas"]: startTimeStr = actInfo["training_at"] + " " + actInfo["metas"][ "time_start"] else: startTimeStr = actInfo["training_at"] + " 00:00:00" activity.StartTime = self._parseDateTime(startTimeStr) activity.EndTime = self._parseDateTime(startTimeStr) + timedelta( seconds=duration) activity.Type = self._reverseActivityMappings[ actInfo["discipline_id"]] activity.Stats.TimerTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=duration) if "distance" in actInfo: activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Kilometers, value=float(actInfo["distance"])) #activity.Stats.Speed = ActivityStatistic(ActivityStatisticUnit.KilometersPerSecond, value=1.0/float(actInfo["metas"]["pace"])) activity.ServiceData = {"WorkoutID": int(actInfo["id"])} activity.CalculateUID() logger.debug("Generated UID %s" % activity.UID) activities.append(activity) return activities, exclusions
def create_random_activity(svc=None, actType=ActivityType.Other, tz=False, record=None): ''' creates completely random activity with valid waypoints and data ''' act = TestTools.create_blank_activity(svc, actType, record=record) if tz is True: tz = pytz.timezone(pytz.all_timezones[random.randint( 0, len(pytz.all_timezones) - 1)]) act.TZ = tz elif tz is not False: act.TZ = tz if len(act.Waypoints) > 0: raise ValueError("Waypoint list already populated") # this is entirely random in case the testing account already has events in it (API doesn't support delete, etc) act.StartTime = datetime(random.randint(2000, 2020), random.randint(1, 12), random.randint(1, 28), random.randint(0, 23), random.randint(0, 59), random.randint(0, 59)) if tz is not False: if hasattr(tz, "localize"): act.StartTime = tz.localize(act.StartTime) else: act.StartTime = act.StartTime.replace(tzinfo=tz) act.EndTime = act.StartTime + timedelta( 0, random.randint(60 * 5, 60 * 60) ) # don't really need to upload 1000s of pts to test this... act.Stats.Distance = ActivityStatistic(ActivityStatisticUnit.Meters, value=random.random() * 10000) act.Name = str(random.random()) paused = False waypointTime = act.StartTime backToBackPauses = False while waypointTime < act.EndTime: wp = Waypoint() if waypointTime == act.StartTime: wp.Type = WaypointType.Start wp.Timestamp = waypointTime wp.Location = Location( random.random() * 180 - 90, random.random() * 180 - 90, random.random() * 1000) # this is gonna be one intense activity if not (wp.HR == wp.Cadence == wp.Calories == wp.Power == wp.Temp == None): raise ValueError("Waypoint did not initialize cleanly") if svc.SupportsHR: wp.HR = float(random.randint(90, 180)) if svc.SupportsPower: wp.Power = float(random.randint(0, 1000)) if svc.SupportsCalories: wp.Calories = float(random.randint(0, 500)) if svc.SupportsCadence: wp.Cadence = float(random.randint(0, 100)) if svc.SupportsTemp: wp.Temp = float(random.randint(0, 100)) if (random.randint(40, 50) == 42 or backToBackPauses) and not paused: # pause quite often wp.Type = WaypointType.Pause paused = True elif paused: paused = False wp.Type = WaypointType.Resume backToBackPauses = not backToBackPauses waypointTime += timedelta(0, int(random.random() + 9.5)) # 10ish seconds if waypointTime > act.EndTime: wp.Timestamp = act.EndTime wp.Type = WaypointType.End act.Waypoints.append(wp) if len(act.Waypoints) == 0: raise ValueError("No waypoints populated") return act
def test_stat_coalesce_multi_missingmixed_multivalued(self): stat1 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None, min=None) stat2 = ActivityStatistic(ActivityStatisticUnit.Meters, value=2, max=2) stat3 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None, gain=3) stat4 = ActivityStatistic(ActivityStatisticUnit.Meters, value=None, loss=4) stat5 = ActivityStatistic(ActivityStatisticUnit.Meters, value=5, min=3) stat5.coalesceWith(stat2) stat3.coalesceWith(stat5) stat4.coalesceWith(stat3) stat1.coalesceWith(stat4) self.assertAlmostEqual(stat1.Value, 7/2) self.assertEqual(stat1.Min, 3) self.assertEqual(stat1.Max, 2) self.assertEqual(stat1.Gain, 3) self.assertEqual(stat1.Loss, 4)
def DownloadActivityList(self, serviceRecord, exhaustive=False): logger.debug("DownloadActivityList") allItems = [] headers = self._apiHeaders(serviceRecord) nextRequest = '/v7.1/workout/?user=' + str(serviceRecord.ExternalID) while True: response = requests.get("https://api.mapmyfitness.com" + nextRequest, headers=headers) if response.status_code != 200: if response.status_code == 401 or response.status_code == 403: raise APIException( "No authorization to retrieve activity list", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) raise APIException( "Unable to retrieve activity list " + str(response), serviceRecord) data = response.json() allItems += data["_embedded"]["workouts"] nextLink = data["_links"].get("next") if not exhaustive or not nextLink: break nextRequest = nextLink[0]["href"] activities = [] exclusions = [] for act in allItems: # TODO catch exception and add to exclusions activity = UploadedActivity() activityID = act["_links"]["self"][0]["id"] activity.StartTime = datetime.strptime(act["start_datetime"], "%Y-%m-%dT%H:%M:%S%z") activity.Notes = act["notes"] if "notes" in act else None # aggregate aggregates = act["aggregates"] elapsed_time_total = aggregates[ "elapsed_time_total"] if "elapsed_time_total" in aggregates else "0" activity.EndTime = activity.StartTime + timedelta( 0, round(float(elapsed_time_total))) activity.Stats.TimerTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=float(elapsed_time_total)) activity.Stats.MovingTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=float(elapsed_time_total)) if "active_time_total" in aggregates: activity.Stats.MovingTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=float(aggregates["active_time_total"])) if "distance_total" in aggregates: activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=float(aggregates["distance_total"])) if "speed_min" in aggregates: activity.Stats.Speed.Min = float(aggregates["speed_min"]) if "speed_max" in aggregates: activity.Stats.Speed.Max = float(aggregates["speed_max"]) if "speed_avg" in aggregates: activity.Stats.Speed.Average = float(aggregates["speed_avg"]) if "heartrate_min" in aggregates: activity.Stats.HR.update( ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, min=float(aggregates["heartrate_min"]))) if "heartrate_max" in aggregates: activity.Stats.HR.update( ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, max=float(aggregates["heartrate_max"]))) if "heartrate_avg" in aggregates: activity.Stats.HR = ActivityStatistic( ActivityStatisticUnit.BeatsPerMinute, avg=float(aggregates["heartrate_avg"])) if "cadence_min" in aggregates: activity.Stats.Cadence.update( ActivityStatistic( ActivityStatisticUnit.RevolutionsPerMinute, min=int(aggregates["cadence_min"]))) if "cadence_max" in aggregates: activity.Stats.Cadence.update( ActivityStatistic( ActivityStatisticUnit.RevolutionsPerMinute, max=int(aggregates["cadence_max"]))) if "cadence_avg" in aggregates: activity.Stats.Cadence = ActivityStatistic( ActivityStatisticUnit.RevolutionsPerMinute, avg=int(aggregates["cadence_avg"])) if "power_min" in aggregates: activity.Stats.Power.update( ActivityStatistic(ActivityStatisticUnit.Watts, min=int(aggregates["power_min"]))) if "power_max" in aggregates: activity.Stats.Power.update( ActivityStatistic(ActivityStatisticUnit.Watts, max=int(aggregates["power_max"]))) if "power_avg" in aggregates: activity.Stats.Power = ActivityStatistic( ActivityStatisticUnit.Watts, avg=int(aggregates["power_avg"])) activityTypeLink = act["_links"].get("activity_type") activityTypeID = activityTypeLink[0][ "id"] if activityTypeLink is not None else None privacyLink = act["_links"].get("privacy") privacyID = privacyLink[0][ "id"] if privacyLink is not None else None activity.Private = privacyID == "0" activity.Type = self._resolveActivityType(activityTypeID, headers) activity.ServiceData = { "ActivityID": activityID, "activityTypeID": activityTypeID, "privacyID": privacyID } activity.CalculateUID() activities.append(activity) return activities, exclusions
def test_unitconv_distance_metric(self): stat = ActivityStatistic(ActivityStatisticUnit.Kilometers, value=1) self.assertEqual(stat.asUnits(ActivityStatisticUnit.Meters).Value, 1000) stat = ActivityStatistic(ActivityStatisticUnit.Meters, value=250) self.assertEqual(stat.asUnits(ActivityStatisticUnit.Kilometers).Value, 0.25)
def DownloadActivityList(self, svcRecord, exhaustive=False): activities = [] exclusions = [] before = earliestDate = None while True: if before is not None and before < 0: break # Caused by activities that "happened" before the epoch. We generally don't care about those activities... logger.debug("Req with before=" + str(before) + "/" + str(earliestDate)) resp = self._requestWithAuth( lambda session: session.get( "https://www.strava.com/api/v3/athletes/" + str( svcRecord.ExternalID) + "/activities", params={"before": before}), svcRecord) if resp.status_code == 401: raise APIException( "No authorization to retrieve activity list", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) earliestDate = None try: reqdata = resp.json() except ValueError: raise APIException( "Failed parsing strava list response %s - %s" % (resp.status_code, resp.text)) if not len(reqdata): break # No more activities to see for ride in reqdata: activity = UploadedActivity() activity.TZ = pytz.timezone( re.sub("^\([^\)]+\)\s*", "", ride["timezone"]) ) # Comes back as "(GMT -13:37) The Stuff/We Want"" activity.StartTime = pytz.utc.localize( datetime.strptime(ride["start_date"], "%Y-%m-%dT%H:%M:%SZ")) logger.debug("\tActivity s/t %s: %s" % (activity.StartTime, ride["name"])) if not earliestDate or activity.StartTime < earliestDate: earliestDate = activity.StartTime before = calendar.timegm( activity.StartTime.astimezone(pytz.utc).timetuple()) activity.EndTime = activity.StartTime + timedelta( 0, ride["elapsed_time"]) activity.ServiceData = { "ActivityID": ride["id"], "Manual": ride["manual"] } if ride["type"] not in self._reverseActivityTypeMappings: exclusions.append( APIExcludeActivity("Unsupported activity type %s" % ride["type"], activity_id=ride["id"], user_exception=UserException( UserExceptionType.Other))) logger.debug("\t\tUnknown activity") continue activity.Type = self._reverseActivityTypeMappings[ride["type"]] activity.Stats.Distance = ActivityStatistic( ActivityStatisticUnit.Meters, value=ride["distance"]) if "max_speed" in ride or "average_speed" in ride: activity.Stats.Speed = ActivityStatistic( ActivityStatisticUnit.MetersPerSecond, avg=ride["average_speed"] if "average_speed" in ride else None, max=ride["max_speed"] if "max_speed" in ride else None) activity.Stats.MovingTime = ActivityStatistic( ActivityStatisticUnit.Seconds, value=ride["moving_time"] if "moving_time" in ride and ride["moving_time"] > 0 else None ) # They don't let you manually enter this, and I think it returns 0 for those activities. # Strava doesn't handle "timer time" to the best of my knowledge - although they say they do look at the FIT total_timer_time field, so...? if "average_watts" in ride: activity.Stats.Power = ActivityStatistic( ActivityStatisticUnit.Watts, avg=ride["average_watts"]) if "average_heartrate" in ride: activity.Stats.HR.update( ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, avg=ride["average_heartrate"])) if "max_heartrate" in ride: activity.Stats.HR.update( ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute, max=ride["max_heartrate"])) if "average_cadence" in ride: activity.Stats.Cadence.update( ActivityStatistic( ActivityStatisticUnit.RevolutionsPerMinute, avg=ride["average_cadence"])) if "average_temp" in ride: activity.Stats.Temperature.update( ActivityStatistic(ActivityStatisticUnit.DegreesCelcius, avg=ride["average_temp"])) if "calories" in ride: activity.Stats.Energy = ActivityStatistic( ActivityStatisticUnit.Kilocalories, value=ride["calories"]) activity.Name = ride["name"] activity.Private = ride["private"] activity.Stationary = ride["manual"] activity.GPS = ("start_latlng" in ride) and (ride["start_latlng"] is not None) activity.AdjustTZ() activity.CalculateUID() activities.append(activity) if not exhaustive or not earliestDate: break return activities, exclusions
def test_unitconv_velocity_metric(self): stat = ActivityStatistic(ActivityStatisticUnit.MetersPerSecond, value=100) self.assertEqual(stat.asUnits(ActivityStatisticUnit.KilometersPerHour).Value, 360) stat = ActivityStatistic(ActivityStatisticUnit.KilometersPerHour, value=50) self.assertAlmostEqual(stat.asUnits(ActivityStatisticUnit.MetersPerSecond).Value, 13.89, places=2)
def DownloadActivity(self, serviceRecord, activity): session = self._get_session(serviceRecord) act_id = activity.ServiceData["ID"] activityDetails = session.get( "https://api.nike.com/me/sport/activities/%s" % act_id, params=self._with_auth(session)) activityDetails = activityDetails.json() streams = { metric["metricType"].lower(): self._nikeStream(metric) for metric in activityDetails["metrics"] } activity.GPS = activityDetails["isGpsActivity"] if activity.GPS: activityGps = session.get( "https://api.nike.com/me/sport/activities/%s/gps" % act_id, params=self._with_auth(session)) activityGps = activityGps.json() streams["gps"] = self._nikeStream(activityGps, "waypoints") activity.Stats.Elevation.update( ActivityStatistic(ActivityStatisticUnit.Meters, gain=float(activityGps["elevationGain"]), loss=float(activityGps["elevationLoss"]), max=float(activityGps["elevationMax"]), min=float(activityGps["elevationMin"]))) lap = Lap(startTime=activity.StartTime, endTime=activity.EndTime) lap.Stats = activity.Stats activity.Laps = [lap] # I thought I wrote StreamSampler to be generator-friendly - nope. streams = {k: list(v) for k, v in streams.items()} # The docs are unclear on which of these are actually stream metrics, oh well def stream_waypoint(offset, speed=None, distance=None, heartrate=None, calories=None, steps=None, watts=None, gps=None, **kwargs): wp = Waypoint() wp.Timestamp = activity.StartTime + timedelta(seconds=offset) wp.Speed = float(speed) if speed else None wp.Distance = float(distance) / 1000 if distance else None wp.HR = float(heartrate) if heartrate else None wp.Calories = float(calories) if calories else None wp.Power = float(watts) if watts else None if gps: wp.Location = Location(lat=float(gps["latitude"]), lon=float(gps["longitude"]), alt=float(gps["elevation"])) lap.Waypoints.append(wp) StreamSampler.SampleWithCallback(stream_waypoint, streams) activity.Stationary = len(lap.Waypoints) == 0 return activity
def test_unitconv_noop(self): stat = ActivityStatistic(ActivityStatisticUnit.KilometersPerHour, value=100) self.assertEqual(stat.asUnits(ActivityStatisticUnit.KilometersPerHour).Value, 100)
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 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) else: activity.EndTime = activity.StartTime + timedelta( 0, float(act["duration"])) 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 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/activity-service/activity/###/details activityID = activity.ServiceData["ActivityID"] res = self._request_with_reauth( lambda session: session. get("https://connect.garmin.com/modern/proxy/activity-service/activity/{}/details?maxSize=999999999" .format(activityID)), serviceRecord) try: raw_data = res.json() except ValueError: raise APIException("Activity data parse error for %s: %s" % (res.status_code, res.text)) if "metricDescriptors" 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["metricDescriptors"]: key = measurement["key"] if key in attrs_map: if attrs_map[key]["to_units"]: attrs_map[key]["from_units"] = self._unitMap[ measurement["unit"]["key"]] 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["activityDetailMetrics"]: 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 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"] session = self._get_session(record=serviceRecord) self._rate_limit() res = session.get("http://connect.garmin.com/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 = {} attr_count = len(raw_data["measurements"]) 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 # 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 test_unitconv_temp(self): stat = ActivityStatistic(ActivityStatisticUnit.DegreesCelcius, value=0) self.assertEqual( stat.asUnits(ActivityStatisticUnit.DegreesFahrenheit).Value, 32) stat = ActivityStatistic(ActivityStatisticUnit.DegreesCelcius, value=-40) self.assertEqual( stat.asUnits(ActivityStatisticUnit.DegreesFahrenheit).Value, -40) stat = ActivityStatistic(ActivityStatisticUnit.DegreesFahrenheit, value=-40) self.assertEqual( stat.asUnits(ActivityStatisticUnit.DegreesCelcius).Value, -40) stat = ActivityStatistic(ActivityStatisticUnit.DegreesFahrenheit, value=32) self.assertEqual( stat.asUnits(ActivityStatisticUnit.DegreesCelcius).Value, 0)