def UploadActivity(self, serviceRecord, activity): # https://ridewithgps.com/trips.json fit_file = FITIO.Dump(activity) files = { "data_file": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit", fit_file) } params = {} params['trip[name]'] = activity.Name params['trip[description]'] = activity.Notes if activity.Private: params[ 'trip[visibility]'] = 1 # Yes, this logic seems backwards but it's how it works res = requests.post("https://ridewithgps.com/trips.json", files=files, params=self._add_auth_params(params, record=serviceRecord)) if res.status_code % 100 == 4: raise APIException("Invalid login", block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) res.raise_for_status() res = res.json() if res["success"] != 1: raise APIException("Unable to upload activity")
def UploadActivity(self, serviceRecord, activity): # Upload the workout as a .FIT file session = self._prepare_request(self._getUserToken(serviceRecord)) uploaddata = FITIO.Dump(activity) files = {"deviceFile": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit", uploaddata)} response = session.post(self._deviceUploadUrl, files=files) if response.status_code != 200: raise APIException( "Error uploading workout", block=False) responseJson = response.json() if not responseJson['Success']: raise APIException( "Error uploading workout - " + response.Message, block=False) # The upload didn't return a PK for some reason. The queue might be stuck or some other internal error # but that doesn't necessarily warrant a reupload. eventId = responseJson["EventId"] if eventId == 0: return None return eventId
def 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"]): if res["failures"][0]["messages"][0]["content"] == "Duplicate activity": logger.debug("Duplicate") return # ...cool? if res["failures"][0]["messages"][0]["content"] == "The user is from EU location, but upload consent is not yet granted or revoked": raise APIException("EU user with no upload consent", block=True, user_exception=UserException(UserExceptionType.GCUploadConsent, intervention_required=True)) 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 UploadActivity(self, serviceRecord, activity): """ POST a Multipart-Encoded File URL: http://app.velohero.com/upload/file Parameters: user = username pass = password view = json file = multipart-encodes file (fit, tcx, pwx, gpx, srm, hrm...) Maximum file size per file is 16 MB. """ fit_file = FITIO.Dump(activity) files = { "file": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit", fit_file) } params = self._add_auth_params({"view": "json"}, record=serviceRecord) res = requests.post(self._urlRoot + "/upload/file", files=files, 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 upload activity") res.raise_for_status() res = res.json() if "error" in res: raise APIException(res["error"])
def UploadActivity(self, serviceRecord, activity, activitySource): # Upload the workout as a .FIT file uploaddata = FITIO.Dump(activity) headers = self._apiHeaders(serviceRecord.Authorization) headers['Content-Type'] = 'application/octet-stream' resp = requests.post(TRAINASONE_SERVER_URL + "/api/sync/activity/fit", data=uploaddata, headers=headers) if resp.status_code != 200: raise APIException( "Error uploading activity - " + str(resp.status_code), block=False) responseJson = resp.json() if not responseJson["id"]: raise APIException( "Error uploading activity - " + resp.Message, block=False) activityId = responseJson["id"] return activityId
def UploadActivity(self, serviceRecord, activity): logger.info("Activity tz " + str(activity.TZ) + " dt tz " + str(activity.StartTime.tzinfo) + " starttime " + str(activity.StartTime)) if self.LastUpload is not None: while (datetime.now() - self.LastUpload).total_seconds() < 5: time.sleep(1) logger.debug("Inter-upload cooldown") source_svc = None if hasattr(activity, "ServiceDataCollection"): source_svc = str(list(activity.ServiceDataCollection.keys())[0]) upload_id = None if activity.CountTotalWaypoints(): req = { "data_type": "fit", "activity_name": activity.Name, "description": activity.Notes, # Paul Mach said so. "activity_type": self._activityTypeMappings[activity.Type], "private": 1 if activity.Private else 0 } if "fit" in activity.PrerenderedFormats: logger.debug("Using prerendered FIT") fitData = activity.PrerenderedFormats["fit"] else: # TODO: put the fit back into PrerenderedFormats once there's more RAM to go around and there's a possibility of it actually being used. fitData = FITIO.Dump(activity) files = { "file": ("tap-sync-" + activity.UID + "-" + str(os.getpid()) + ("-" + source_svc if source_svc else "") + ".fit", fitData) } self._globalRateLimit() response = requests.post("https://www.strava.com/api/v3/uploads", data=req, files=files, headers=self._apiHeaders(serviceRecord)) if response.status_code != 201: if response.status_code == 401: raise APIException("No authorization to upload activity " + activity.UID + " response " + response.text + " status " + str(response.status_code), block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) if "duplicate of activity" in response.text: logger.debug("Duplicate") self.LastUpload = datetime.now() return # Fine by me. The majority of these cases were caused by a dumb optimization that meant existing activities on services were never flagged as such if tapiriik didn't have to synchronize them elsewhere. raise APIException("Unable to upload activity " + activity.UID + " response " + response.text + " status " + str(response.status_code)) upload_id = response.json()["id"] upload_poll_wait = 8 # The mode of processing times while not response.json()["activity_id"]: time.sleep(upload_poll_wait) self._globalRateLimit() response = requests.get( "https://www.strava.com/api/v3/uploads/%s" % upload_id, headers=self._apiHeaders(serviceRecord)) logger.debug("Waiting for upload - status %s id %s" % (response.json()["status"], response.json()["activity_id"])) if response.json()["error"]: error = response.json()["error"] if "duplicate of activity" in error: self.LastUpload = datetime.now() logger.debug("Duplicate") return # I guess we're done here? raise APIException( "Strava failed while processing activity - last status %s" % response.text) upload_id = response.json()["activity_id"] else: localUploadTS = activity.StartTime.strftime("%Y-%m-%d %H:%M:%S") req = { "name": activity.Name if activity.Name else activity.StartTime.strftime("%d/%m/%Y"), # This is required "description": activity.Notes, "type": self._activityTypeMappings[activity.Type], "private": 1 if activity.Private else 0, "start_date_local": localUploadTS, "distance": activity.Stats.Distance.asUnits( ActivityStatisticUnit.Meters).Value, "elapsed_time": round((activity.EndTime - activity.StartTime).total_seconds()) } headers = self._apiHeaders(serviceRecord) self._globalRateLimit() response = requests.post( "https://www.strava.com/api/v3/activities", data=req, headers=headers) # FFR this method returns the same dict as the activity listing, as REST services are wont to do. if response.status_code != 201: if response.status_code == 401: raise APIException("No authorization to upload activity " + activity.UID + " response " + response.text + " status " + str(response.status_code), block=True, user_exception=UserException( UserExceptionType.Authorization, intervention_required=True)) raise APIException("Unable to upload stationary activity " + activity.UID + " response " + response.text + " status " + str(response.status_code)) upload_id = response.json()["id"] self.LastUpload = datetime.now() return upload_id
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) } session = self._get_session(record=serviceRecord) self._rate_limit() res = session.post( "http://connect.garmin.com/proxy/upload-service-1.1/json/upload/.fit", files=files) res = res.json()["detailedImportResult"] if len(res["successes"]) == 0: 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 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 = session.post( "http://connect.garmin.com/proxy/activity-service-1.2/json/name/" + str(actid), data=urlencode({ "value": activity.Name }).encode("UTF-8"), 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 = session.post( "http://connect.garmin.com/proxy/activity-service-1.2/json/description/" + str(actid), data=urlencode({ "value": activity.Notes }).encode("UTF-8"), 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 = session.post( "http://connect.garmin.com/proxy/activity-service-1.2/json/type/" + str(actid), data={"value": acttype}) 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) try: if activity.Private: self._rate_limit() res = session.post( "http://connect.garmin.com/proxy/activity-service-1.2/json/privacy/" + str(actid), data={"value": "private"}) res = res.json() if "definition" not in res or res["definition"][ "key"] != "private": raise APIWarning("Unable to set activity privacy") except APIWarning as e: warnings.append(e) if len(warnings): raise APIWarning(str(warnings)) # Meh return actid
def UploadActivity(self, serviceRecord, activity): """ POST a Multipart-Encoded File URL: https://app.velohero.com/upload/file Parameters: user = username pass = password view = json file = multipart-encodes file (fit, tcx, pwx, gpx, srm, hrm...) Maximum file size per file is 16 MB. """ has_location = has_distance = has_speed = False for lap in activity.Laps: for wp in lap.Waypoints: if wp.Location and wp.Location.Latitude and wp.Location.Longitude: has_location = True if wp.Distance: has_distance = True if wp.Speed: has_speed = True if has_location and has_distance and has_speed: format = "fit" data = FITIO.Dump(activity) elif has_location and has_distance: format = "tcx" data = TCXIO.Dump(activity) elif has_location: format = "gpx" data = GPXIO.Dump(activity) else: format = "fit" data = FITIO.Dump(activity) # Upload files = {"file": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + "." + format, data)} params = self._add_auth_params({"view":"json"}, record=serviceRecord) res = requests.post(self._urlRoot + "/upload/file", headers=self._obligatory_headers, files=files, 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 upload activity") res.raise_for_status() try: res = res.json() except ValueError: raise APIException("Could not decode activity list") if "error" in res: raise APIException(res["error"]) # Set date, start time, comment and sport if "id" in res: workoutId = res["id"] params = self._add_auth_params({ "workout_date" : activity.StartTime.strftime("%Y-%m-%d"), "workout_start_time" : activity.StartTime.strftime("%H:%M:%S"), "workout_comment" : activity.Notes, "sport_id" : self._activityMappings[activity.Type], "workout_hide": "yes" if activity.Private else "no" }, record=serviceRecord) res = requests.get(self._urlRoot + "/workouts/change/{}".format(workoutId), headers=self._obligatory_headers, params=params) if res.status_code != 200: if res.status_code == 403: raise APIException("No authorization to change activity with workout ID: {}".format(workoutId), block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) raise APIException("Unable to change activity with workout ID: {}".format(workoutId)) return workoutId
def UploadActivity(self, serviceRecord, activity): cookies = self._get_web_cookies(record=serviceRecord) # Wicket sucks sucks sucks sucks sucks sucks. # Step 0 # http://www.endomondo.com/?wicket:bookmarkablePage=:com.endomondo.web.page.workout.CreateWorkoutPage2 # Get URL of file upload # <a href="#" id="id13a" onclick="var wcall=wicketAjaxGet('?wicket:interface=:8:pageContainer:lowerSection:lowerMain:lowerMainContent:importFileLink::IBehaviorListener:0:',function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$('id13a') != null;}.bind(this));return !wcall;">... <div class="fileImport"></div> upload_select = requests.get( "http://www.endomondo.com/?wicket:bookmarkablePage=:com.endomondo.web.page.workout.CreateWorkoutPage2", cookies=cookies) upload_lightbox_url = re.findall( '<a.+?onclick="var wcall=wicketAjaxGet\(\'(.+?)\'', upload_select.text)[3] logger.debug("Will request upload lightbox from %s" % upload_lightbox_url) # Step 1 # http://www.endomondo.com/upload-form-url # Get IFrame src upload_iframe = requests.get("http://www.endomondo.com/" + upload_lightbox_url, cookies=cookies) upload_iframe_src = re.findall('src="(.+?)"', upload_iframe.text)[0] logger.debug("Will request upload form from %s" % upload_iframe_src) # Step 2 # http://www.endomondo.com/iframe-url # Follow redirect to upload page # Get form ID # Get form target from <a class="next" name="uploadSumbit" id="id18d" value="Next" onclick="document.getElementById('fileUploadWaitIcon').style.display='block';var wcall=wicketSubmitFormById('id18c', '?wicket:interface=:13:importPanel:wizardStepPanel:uploadForm:uploadSumbit::IActivePageBehaviorListener:0:-1&wicket:ignoreIfNotActive=true', 'uploadSumbit' ,function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$$(this)&&Wicket.$$('id18c')}.bind(this));;; return false;">Next</a> upload_form_rd = requests.get("http://www.endomondo.com/" + upload_iframe_src, cookies=cookies, allow_redirects=False) assert ( upload_form_rd.status_code == 302 ) # Need to manually follow the redirect to keep the cookies available upload_form = requests.get(upload_form_rd.headers["location"], cookies=cookies) upload_form_id = re.findall('<form.+?id="([^"]+)"', upload_form.text)[0] upload_form_target = re.findall( "wicketSubmitFormById\('[^']+', '([^']+)'", upload_form.text)[0] logger.debug("Will POST upload form ID %s to %s" % (upload_form_id, upload_form_target)) # Step 3 # http://www.endomondo.com/upload-target # POST # formID_hf_0 # file as `uploadFile` # uploadSubmit=1 # Get ID from form # Get confirm target <a class="next" name="reviewSumbit" id="id191" value="Save" onclick="document.getElementById('fileSaveWaitIcon').style.display='block';var wcall=wicketSubmitFormById('id190', '?wicket:interface=:13:importPanel:wizardStepPanel:reviewForm:reviewSumbit::IActivePageBehaviorListener:0:-1&wicket:ignoreIfNotActive=true', 'reviewSumbit' ,function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$$(this)&&Wicket.$$('id190')}.bind(this));;; return false;">Save</a> fit_file = FITIO.Dump(activity) files = { "uploadFile": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit", fit_file) } data = {"uploadSumbit": 1, upload_form_id + "_hf_0": ""} upload_result = requests.post("http://www.endomondo.com/" + upload_form_target, data=data, files=files, cookies=cookies) confirm_form_id = re.findall('<form.+?id="([^"]+)"', upload_result.text)[0] confirm_form_target = re.findall( "wicketSubmitFormById\('[^']+', '([^']+)'", upload_result.text)[0] logger.debug("Will POST confirm form ID %s to %s" % (confirm_form_id, confirm_form_target)) # Step 4 # http://www.endomondo.com/confirm-target # POST # formID_hf_0 # workoutRow:0:mark=on # workoutRow:0:sport=X # reviewSumbit=1 sportId = [ k for k, v in self._reverseActivityMappings.items() if v == activity.Type ] if len(sportId) == 0: raise ValueError( "Endomondo service does not support activity type " + activity.Type) else: sportId = sportId[0] data = { confirm_form_id + "_hf_0": "", "workoutRow:0:mark": "on", "workoutRow:0:sport": sportId, "reviewSumbit": 1 } confirm_result = requests.post("http://www.endomondo.com" + confirm_form_target, data=data, cookies=cookies) assert (confirm_result.status_code == 200) # Step 5 # http://api.mobile.endomondo.com/mobile/api/workout/list # GET # authToken=xyz # maxResults=1 # before=utcTS+1 # Get activity ID before = (activity.StartTime + timedelta(seconds=90)).astimezone( pytz.utc).strftime("%Y-%m-%d %H:%M:%S UTC") params = { "authToken": serviceRecord.Authorization["AuthToken"], "maxResults": 1, "before": before } id_result = requests.get( "http://api.mobile.endomondo.com/mobile/api/workout/list", params=params) act_id = id_result.json()["data"][0]["id"] logger.debug("Retrieved activity ID %s" % act_id) # Step 6 # http://www.endomondo.com/workouts/xyz # Get edit URL <a class="enabled button edit" href="#" id="id171" onclick="var wcall=wicketAjaxGet('../?wicket:interface=:10:pageContainer:lowerSection:lowerMain:lowerMainContent:workout:details:actions:ownActions:editButton::IBehaviorListener:0:1',function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$('id171') != null;}.bind(this));return !wcall;">Edit</a> summary_page = requests.get("http://www.endomondo.com/workouts/%s" % act_id, cookies=cookies) edit_url = re.findall( '<a.+class="enabled button edit".+?onclick="var wcall=wicketAjaxGet\(\'../(.+?)\'', summary_page.text)[0] logger.debug("Will request edit form from %s" % edit_url) # Step 7 # http://www.endomondo.com/edit-url # Get form ID # Get form target from <a class="halfbutton" href="#" style="float:left;" name="saveButton" id="id1d5" value="Save" onclick="var wcall=wicketSubmitFormById('id1d4', '../?wicket:interface=:14:pageContainer:lightboxContainer:lightboxContent:panel:detailsContainer:workoutForm:saveButton::IActivePageBehaviorListener:0:1&wicket:ignoreIfNotActive=true', 'saveButton' ,function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$$(this)&&Wicket.$$('id1d4')}.bind(this));;; return false;">Save</a> edit_page = requests.get("http://www.endomondo.com/" + edit_url, cookies=cookies) edit_form_id = re.findall('<form.+?id="([^"]+)"', edit_page.text)[0] edit_form_target = re.findall( "wicketSubmitFormById\('[^']+', '([^']+)'", edit_page.text)[0] logger.debug("Will POST edit form ID %s to %s" % (edit_form_id, edit_form_target)) # Step 8 # http://www.endomondo.com/edit-finish-url # POST # id34e_hf_0 # sport: X # name: name123 # startTime:YYYY-MM-DD HH:MM # distance:1.00 km # duration:0h:10m:00s # metersAscent: # metersDescent: # averageHeartRate:30 # maximumHeartRate:100 # validityToggle:on ("include in statistics") # calorieRecomputeToggle:on # notes:asdasdasd # saveButton:1 duration = (activity.EndTime - activity.StartTime) duration_formatted = "%dh:%dm:%ds" % (duration.seconds / 3600, duration.seconds % 3600 / 60, duration.seconds % (60)) data = { edit_form_id + "_hf_0": "", "saveButton": "1", "validityToggle": "on", "calorieRecomputeToggle": "on", "startTime": activity.StartTime.strftime("%Y-%m-%d %H:%M"), "distance": "%s km" % activity.Stats.Distance.asUnits( ActivityStatisticUnit.Kilometers).Value, "sport": sportId, "duration": duration_formatted, "name": activity.Name, } if activity.Stats.Elevation.Gain is not None: data["metersAscent"] = int(round(activity.Stats.Elevation.Gain)) if activity.Stats.Elevation.Gain is not None: data["metersDescent"] = int(round(activity.Stats.Elevation.Loss)) if activity.Stats.HR.Average is not None: data["averageHeartRate"] = int(round(activity.Stats.HR.Average)) if activity.Stats.HR.Max is not None: data["maximumHeartRate"] = int(round(activity.Stats.HR.Max)) edit_result = requests.post("http://www.endomondo.com/" + edit_form_target, data=data, cookies=cookies) assert edit_result.status_code == 200 and "feedbackPanelERROR" not in edit_result.text