Ejemplo n.º 1
0
class NikePlusService(ServiceBase):
    ID = "nikeplus"
    DisplayName = "Nike+"
    DisplayAbbreviation = "N+"
    AuthenticationType = ServiceAuthenticationType.UsernamePassword
    RequiresExtendedAuthorizationDetails = True
    ReceivesStationaryActivities = False  # No manual entry afaik

    _activityMappings = {
        "RUN": ActivityType.Running,
        "JOGGING": ActivityType.Running,
        "WALK": ActivityType.Walking,
        "CYCLE": ActivityType.Cycling,
        "STATIONARY_BIKING": ActivityType.Cycling,
        "MOUNTAIN_BIKING": ActivityType.MountainBiking,
        "CROSS_COUNTRY": ActivityType.CrossCountrySkiing,  # Well, I think?
        "ELLIPTICAL": ActivityType.Elliptical,
        "HIKING": ActivityType.Hiking,
        "ROCK_CLIMBING": ActivityType.Climbing,
        "ICE_CLIMBING": ActivityType.Climbing,
        "SNOWBOARDING": ActivityType.Snowboarding,
        "SKIING": ActivityType.DownhillSkiing,
        "ICE_SKATING": ActivityType.Skating,
        "OTHER": ActivityType.Other
    }

    # Leave it to Nike+ to invent new timezones
    _timezones = {
        "ART": "America/Argentina/Buenos_Aires"  # Close enough
    }

    _reverseActivityMappings = {
        "RUN": ActivityType.Running,
        # Their web frontend has a meltdown even trying to navigate to other activity types, who knows
        # So I won't exacerbate the problem...
        # "WALK": ActivityType.Walking,
        # "CYCLE": ActivityType.Cycling,
        # "MOUNTAIN_BIKING": ActivityType.MountainBiking,
        # "CROSS_COUNTRY": ActivityType.CrossCountrySkiing,
        # "ELLIPTICAL": ActivityType.Elliptical,
        # "HIKING": ActivityType.Hiking,
        # "ROCK_CLIMBING": ActivityType.Climbing,
        # "SNOWBOARDING": ActivityType.Snowboarding,
        # "SKIING": ActivityType.DownhillSkiing,
        # "ICE_SKATING": ActivityType.Skating,
        # "OTHER": ActivityType.Other
    }

    SupportedActivities = list(_reverseActivityMappings.values())

    _sessionCache = SessionCache(lifetime=timedelta(minutes=45),
                                 freshen_on_get=False)

    _obligatoryHeaders = {
        "User-Agent": "NPConnect",
        "appId": NIKEPLUS_CLIENT_NAME
    }

    _obligatoryCookies = {
        "app": NIKEPLUS_CLIENT_NAME,
        "client_id": NIKEPLUS_CLIENT_ID,
        "client_secret": NIKEPLUS_CLIENT_SECRET
    }

    def _get_session(self,
                     record=None,
                     email=None,
                     password=None,
                     skip_cache=False):
        from tapiriik.auth.credential_storage import CredentialStore
        cached = self._sessionCache.Get(record.ExternalID if record else email)
        if cached and not skip_cache:
            return cached
        if record:
            password = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Password"])
            email = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Email"])

        # This is the most pleasent login flow I've dealt with in a long time
        session = requests.Session()
        session.headers.update(self._obligatoryHeaders)
        session.cookies.update(self._obligatoryCookies)

        res = session.post("https://api.nike.com/nsl/user/login",
                           params={
                               "format": "json",
                               "app": "app",
                               "client_id": NIKEPLUS_CLIENT_ID,
                               "client_secret": NIKEPLUS_CLIENT_SECRET
                           },
                           data={
                               "email": email,
                               "password": password
                           },
                           headers={"Accept": "application/json"})

        if res.status_code >= 500 and res.status_code < 600:
            raise APIException("Login exception %s - %s" %
                               (res.status_code, res.text))

        res_obj = res.json()

        if "access_token" not in res_obj:
            raise APIException("Invalid login %s - %s / %s" %
                               (res.status_code, res.text, res.cookies),
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.Authorization,
                                   intervention_required=True))

        # Was getting a super obscure error from the nether regions of requestse about duplicate cookies
        # So, store this in an easier-to-find location
        session.access_token = res_obj["access_token"]

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

        return session

    def _with_auth(self, session, params={}):
        # For whatever reason the access_token needs to be a GET parameter :(
        params.update({
            "access_token": session.access_token,
            "app": NIKEPLUS_CLIENT_NAME
        })
        return params

    def WebInit(self):
        self.UserAuthorizationURL = WEB_ROOT + reverse(
            "auth_simple", kwargs={"service": self.ID})

    def Authorize(self, email, password):
        from tapiriik.auth.credential_storage import CredentialStore
        session = self._get_session(email=email, password=password)

        user_data = session.get("https://api.nike.com/nsl/user/get",
                                params=self._with_auth(session,
                                                       {"format": "json"}))
        user_id = int(
            user_data.json()["serviceResponse"]["body"]["User"]["id"])

        return (user_id, {}, {
            "Email": CredentialStore.Encrypt(email),
            "Password": CredentialStore.Encrypt(password)
        })

    def _durationToTimespan(self, duration):
        # Hours:Minutes:Seconds.Milliseconds
        duration = [float(x) for x in duration.split(":")]
        return timedelta(seconds=duration[2],
                         minutes=duration[1],
                         hours=duration[0])

    def DownloadActivityList(self, serviceRecord, exhaustive=False):
        session = self._get_session(serviceRecord)
        list_params = self._with_auth(session, {"count": 20, "offset": 1})

        activities = []
        exclusions = []

        while True:
            list_resp = session.get("https://api.nike.com/me/sport/activities",
                                    params=list_params)
            list_resp = list_resp.json()

            for act in list_resp["data"]:
                activity = UploadedActivity()
                activity.ServiceData = {"ID": act["activityId"]}

                if act["status"] != "COMPLETE":
                    exclusions.append(
                        APIExcludeActivity(
                            "Not complete",
                            activity_id=act["activityId"],
                            permanent=False,
                            user_exception=UserException(
                                UserExceptionType.LiveTracking)))
                    continue

                activity.StartTime = dateutil.parser.parse(
                    act["startTime"]).replace(tzinfo=pytz.utc)
                activity.EndTime = activity.StartTime + self._durationToTimespan(
                    act["metricSummary"]["duration"])

                tz_name = act["activityTimeZone"]

                # They say these are all IANA standard names - they aren't
                if tz_name in self._timezones:
                    tz_name = self._timezones[tz_name]

                activity.TZ = pytz.timezone(tz_name)

                if act["activityType"] in self._activityMappings:
                    activity.Type = self._activityMappings[act["activityType"]]

                activity.Stats.Distance = ActivityStatistic(
                    ActivityStatisticUnit.Kilometers,
                    value=float(act["metricSummary"]["distance"]))
                activity.Stats.Strides = ActivityStatistic(
                    ActivityStatisticUnit.Strides,
                    value=int(act["metricSummary"]["steps"]))
                activity.Stats.Energy = ActivityStatistic(
                    ActivityStatisticUnit.Kilocalories,
                    value=float(act["metricSummary"]["calories"]))
                activity.CalculateUID()
                activities.append(activity)

            if len(list_resp["data"]) == 0 or not exhaustive:
                break
            list_params["offset"] += list_params["count"]

        return activities, exclusions

    def _nikeStream(self, stream, values_collection="values"):
        interval_secs = {"SEC": 1, "MIN": 60}
        if stream["intervalUnit"] not in interval_secs:
            # Who knows if they ever return it in a different unit? Their docs don't give a list
            raise Exception("Unknown stream interval unit %s" %
                            stream["intervalUnit"])

        interval = stream["intervalMetric"] * interval_secs[
            stream["intervalUnit"]]
        for x in range(len(stream[values_collection])):
            yield (interval * x, stream[values_collection][x])

    def DownloadActivity(self, serviceRecord, activity):
        session = self._get_session(serviceRecord)
        act_id = activity.ServiceData["ID"]
        activityDetails = session.get(
            "https://api.nike.com/me/sport/activities/%s" % act_id,
            params=self._with_auth(session))
        activityDetails = activityDetails.json()

        streams = {
            metric["metricType"].lower(): self._nikeStream(metric)
            for metric in activityDetails["metrics"]
        }

        activity.GPS = activityDetails["isGpsActivity"]

        if activity.GPS:
            activityGps = session.get(
                "https://api.nike.com/me/sport/activities/%s/gps" % act_id,
                params=self._with_auth(session))
            activityGps = activityGps.json()
            streams["gps"] = self._nikeStream(activityGps, "waypoints")
            activity.Stats.Elevation.update(
                ActivityStatistic(ActivityStatisticUnit.Meters,
                                  gain=float(activityGps["elevationGain"]),
                                  loss=float(activityGps["elevationLoss"]),
                                  max=float(activityGps["elevationMax"]),
                                  min=float(activityGps["elevationMin"])))

        lap = Lap(startTime=activity.StartTime, endTime=activity.EndTime)
        lap.Stats = activity.Stats
        activity.Laps = [lap]
        # I thought I wrote StreamSampler to be generator-friendly - nope.
        streams = {k: list(v) for k, v in streams.items()}

        # The docs are unclear on which of these are actually stream metrics, oh well
        def stream_waypoint(offset,
                            speed=None,
                            distance=None,
                            heartrate=None,
                            calories=None,
                            steps=None,
                            watts=None,
                            gps=None,
                            **kwargs):
            wp = Waypoint()
            wp.Timestamp = activity.StartTime + timedelta(seconds=offset)
            wp.Speed = float(speed) if speed else None
            wp.Distance = float(distance) / 1000 if distance else None
            wp.HR = float(heartrate) if heartrate else None
            wp.Calories = float(calories) if calories else None
            wp.Power = float(watts) if watts else None

            if gps:
                wp.Location = Location(lat=float(gps["latitude"]),
                                       lon=float(gps["longitude"]),
                                       alt=float(gps["elevation"]))
            lap.Waypoints.append(wp)

        StreamSampler.SampleWithCallback(stream_waypoint, streams)

        activity.Stationary = len(lap.Waypoints) == 0

        return activity

    def UploadActivity(self, serviceRecord, activity):
        metrics = {
            "data": [],
            "metricTypes": [],
            "intervalUnit": "SEC",
            "intervalValue":
            10 if activity.Type == ActivityType.Running else 5  # What a joke.
        }

        act = [{
            "deviceName":
            "tapiriik",
            "deviceType":
            "BIKE" if activity.Type == ActivityType.Cycling else
            "WATCH",  # ??? nike+ is weird
            "startTime":
            calendar.timegm(
                activity.StartTime.astimezone(pytz.utc).timetuple()) * 1000,
            "timeZoneName":
            str(activity.TZ),
            "activityType": [
                k for k, v in self._reverseActivityMappings.items()
                if v == activity.Type
            ][0],
            "metrics":
            metrics
        }]

        wps = activity.GetFlatWaypoints()
        wpidx = 0
        full_metrics = []
        max_metrics = set()
        while True:
            wp = wps[wpidx]
            my_metrics = {}

            if wp.Location and wp.Location.Latitude is not None and wp.Location.Longitude is not None:
                elev = wp.Location.Altitude if wp.Location.Altitude else 0  # They always require this field, it's meh
                my_metrics.update({
                    "latitude": wp.Location.Latitude,
                    "longitude": wp.Location.Longitude,
                    "elevation": elev
                })

            if wp.Distance is not None:
                my_metrics["distance"] = wp.Distance / 1000  # m -> km

            if wp.HR is not None:
                my_metrics["heartrate"] = round(wp.HR)

            if wp.Speed is not None:
                my_metrics["speed"] = wp.Speed

            if wp.Calories is not None:
                my_metrics["calories"] = round(wp.Calories)

            if wp.Power is not None:
                my_metrics["watts"] = round(wp.Power)

            max_metrics |= my_metrics.keys()
            full_metrics.append(my_metrics)

            # Skip to next wp
            skip_delta = 0
            while (wpidx + skip_delta < len(wps) - 1) and (
                    wps[wpidx + skip_delta].Timestamp - wps[wpidx].Timestamp
            ).total_seconds() < metrics["intervalValue"]:
                skip_delta += 1
            if skip_delta == 0:
                break  # We're done
            wpidx += skip_delta

        if wpidx == 0 and len(wps) > 0:
            raise Exception("Activity had waypoints, none were used")

        max_metrics = sorted(list(max_metrics))
        metrics["metricTypes"] = max_metrics

        # Passing null metric values makes Nike+ sad
        # So we hold the last value until a new one is available
        frame_hold = {x: 0 for x in max_metrics}  # Blegh, close enough
        for metric_frame in full_metrics:
            frame_hold.update(metric_frame)
            metrics["data"].append([frame_hold[x] for x in max_metrics])

        headers = {"Content-Type": "application/json"}

        session = self._get_session(serviceRecord)
        upload_resp = session.post("https://api.nike.com/me/sport/activities",
                                   params=self._with_auth(session),
                                   data=json.dumps(act),
                                   headers=headers)

        if upload_resp.status_code != 201:
            error_codes = [x["code"] for x in upload_resp.json()["errors"]]
            if 320 in error_codes:  # Invalid combination of metric types and blah blah blah
                raise APIException("Not enough data, have keys %s" %
                                   max_metrics,
                                   user_exception=UserException(
                                       UserExceptionType.InsufficientData))
            raise APIException("Could not upload activity %s - %s" %
                               (upload_resp.status_code, upload_resp.text))

        return upload_resp.json()[0]["activityId"]

    def RevokeAuthorization(self, serviceRecord):
        # nothing to do here...
        pass

    def DeleteCachedData(self, serviceRecord):
        # nothing cached...
        pass

    def DeleteActivity(self, serviceRecord, uploadId):
        session = self._get_session(serviceRecord)
        del_res = session.delete(
            "https://api.nike.com/v1/me/sport/activities/%d" % uploadId)
        del_res.raise_for_status()
Ejemplo n.º 2
0
class GarminConnectService(ServiceBase):
    ID = "garminconnect"
    DisplayName = "Garmin Connect"
    DisplayAbbreviation = "GC"
    AuthenticationType = ServiceAuthenticationType.UsernamePassword
    RequiresExtendedAuthorizationDetails = True
    PartialSyncRequiresTrigger = len(GARMIN_CONNECT_USER_WATCH_ACCOUNTS) > 0
    PartialSyncTriggerPollInterval = timedelta(minutes=20)
    PartialSyncTriggerPollMultiple = len(
        GARMIN_CONNECT_USER_WATCH_ACCOUNTS.keys())

    ConfigurationDefaults = {"WatchUserKey": None, "WatchUserLastID": 0}

    _activityMappings = {
        "running": ActivityType.Running,
        "cycling": ActivityType.Cycling,
        "mountain_biking": ActivityType.MountainBiking,
        "walking": ActivityType.Walking,
        "hiking": ActivityType.Hiking,
        "resort_skiing_snowboarding": ActivityType.DownhillSkiing,
        "cross_country_skiing": ActivityType.CrossCountrySkiing,
        "skate_skiing":
        ActivityType.CrossCountrySkiing,  # Well, it ain't downhill?
        "backcountry_skiing_snowboarding":
        ActivityType.CrossCountrySkiing,  # ish
        "skating": ActivityType.Skating,
        "swimming": ActivityType.Swimming,
        "rowing": ActivityType.Rowing,
        "elliptical": ActivityType.Elliptical,
        "fitness_equipment": ActivityType.Gym,
        "rock_climbing": ActivityType.Climbing,
        "mountaineering": ActivityType.Climbing,
        "all":
        ActivityType.Other,  # everything will eventually resolve to this
        "multi_sport": ActivityType.Other  # Most useless type? You decide!
    }

    _reverseActivityMappings = {  # Removes ambiguities when mapping back to their activity types
        "running": ActivityType.Running,
        "cycling": ActivityType.Cycling,
        "mountain_biking": ActivityType.MountainBiking,
        "walking": ActivityType.Walking,
        "hiking": ActivityType.Hiking,
        "resort_skiing_snowboarding": ActivityType.DownhillSkiing,
        "cross_country_skiing": ActivityType.CrossCountrySkiing,
        "skating": ActivityType.Skating,
        "swimming": ActivityType.Swimming,
        "rowing": ActivityType.Rowing,
        "elliptical": ActivityType.Elliptical,
        "fitness_equipment": ActivityType.Gym,
        "rock_climbing": ActivityType.Climbing,
        "other": ActivityType.Other  # I guess? (vs. "all" that is)
    }

    SupportedActivities = list(_activityMappings.values())

    SupportsHR = SupportsCadence = True

    SupportsActivityDeletion = True

    _sessionCache = SessionCache("garminconnect",
                                 lifetime=timedelta(minutes=120),
                                 freshen_on_get=True)
    _reauthAttempts = 1  # per request

    _unitMap = {
        "mph": ActivityStatisticUnit.MilesPerHour,
        "kph": ActivityStatisticUnit.KilometersPerHour,
        "hmph": ActivityStatisticUnit.HectometersPerHour,
        "hydph": ActivityStatisticUnit.HundredYardsPerHour,
        "celcius": ActivityStatisticUnit.DegreesCelcius,
        "fahrenheit": ActivityStatisticUnit.DegreesFahrenheit,
        "mile": ActivityStatisticUnit.Miles,
        "kilometer": ActivityStatisticUnit.Kilometers,
        "foot": ActivityStatisticUnit.Feet,
        "meter": ActivityStatisticUnit.Meters,
        "yard": ActivityStatisticUnit.Yards,
        "kilocalorie": ActivityStatisticUnit.Kilocalories,
        "bpm": ActivityStatisticUnit.BeatsPerMinute,
        "stepsPerMinute": ActivityStatisticUnit.DoubledStepsPerMinute,
        "rpm": ActivityStatisticUnit.RevolutionsPerMinute,
        "watt": ActivityStatisticUnit.Watts,
        "second": ActivityStatisticUnit.Seconds,
        "ms": ActivityStatisticUnit.Milliseconds
    }

    _obligatory_headers = {"Referer": "https://sync.tapiriik.com"}

    def __init__(self):
        cachedHierarchy = cachedb.gc_type_hierarchy.find_one()
        if not cachedHierarchy:
            rawHierarchy = requests.get(
                "https://connect.garmin.com/proxy/activity-service-1.2/json/activity_types",
                headers=self._obligatory_headers).text
            self._activityHierarchy = json.loads(rawHierarchy)["dictionary"]
            cachedb.gc_type_hierarchy.insert({"Hierarchy": rawHierarchy})
        else:
            self._activityHierarchy = json.loads(
                cachedHierarchy["Hierarchy"])["dictionary"]
        rate_lock_path = tempfile.gettempdir(
        ) + "/gc_rate.%s.lock" % HTTP_SOURCE_ADDR
        # Ensure the rate lock file exists (...the easy way)
        open(rate_lock_path, "a").close()
        self._rate_lock = open(rate_lock_path, "r+")

    def _rate_limit(self):
        import fcntl, struct, time
        min_period = 1  # I appear to been banned from Garmin Connect while determining this.
        fcntl.flock(self._rate_lock, fcntl.LOCK_EX)
        try:
            self._rate_lock.seek(0)
            last_req_start = self._rate_lock.read()
            if not last_req_start:
                last_req_start = 0
            else:
                last_req_start = float(last_req_start)

            wait_time = max(0, min_period - (time.time() - last_req_start))
            time.sleep(wait_time)

            self._rate_lock.seek(0)
            self._rate_lock.write(str(time.time()))
            self._rate_lock.flush()
        finally:
            fcntl.flock(self._rate_lock, fcntl.LOCK_UN)

    def _request_with_reauth(self, serviceRecord, req_lambda):
        for i in range(self._reauthAttempts + 1):
            session = self._get_session(record=serviceRecord, skip_cache=i > 0)
            self._rate_limit()
            result = req_lambda(session)
            if result.status_code not in (403, 500):
                return result
        # Pass the failed response back any ways - another handler will catch it and provide a nicer error
        return result

    def _get_session(self,
                     record=None,
                     email=None,
                     password=None,
                     skip_cache=False):
        from tapiriik.auth.credential_storage import CredentialStore
        cached = self._sessionCache.Get(record.ExternalID if record else email)
        if cached and not skip_cache:
            logger.debug("Using cached credential")
            return cached
        if record:
            #  longing for C style overloads...
            password = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Password"])
            email = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Email"])

        session = requests.Session()

        # JSIG CAS, cool I guess.
        # Not quite OAuth though, so I'll continue to collect raw credentials.
        # Commented stuff left in case this ever breaks because of missing parameters...
        data = {
            "username": email,
            "password": password,
            "_eventId": "submit",
            "embed": "true",
            # "displayNameRequired": "false"
        }
        params = {
            "service": "https://connect.garmin.com/post-auth/login",
            # "redirectAfterAccountLoginUrl": "http://connect.garmin.com/post-auth/login",
            # "redirectAfterAccountCreationUrl": "http://connect.garmin.com/post-auth/login",
            # "webhost": "olaxpw-connect00.garmin.com",
            "clientId": "GarminConnect",
            # "gauthHost": "https://sso.garmin.com/sso",
            # "rememberMeShown": "true",
            # "rememberMeChecked": "false",
            "consumeServiceTicket": "false",
            # "id": "gauth-widget",
            # "embedWidget": "false",
            # "cssUrl": "https://static.garmincdn.com/com.garmin.connect/ui/src-css/gauth-custom.css",
            # "source": "http://connect.garmin.com/en-US/signin",
            # "createAccountShown": "true",
            # "openCreateAccount": "false",
            # "usernameShown": "true",
            # "displayNameShown": "false",
            # "initialFocus": "true",
            # "locale": "en"
        }
        # I may never understand what motivates people to mangle a perfectly good protocol like HTTP in the ways they do...
        preResp = session.get("https://sso.garmin.com/sso/login",
                              params=params)
        if preResp.status_code != 200:
            raise APIException("SSO prestart error %s %s" %
                               (preResp.status_code, preResp.text))
        data["lt"] = re.search("name=\"lt\"\s+value=\"([^\"]+)\"",
                               preResp.text).groups(1)[0]

        ssoResp = session.post("https://sso.garmin.com/sso/login",
                               params=params,
                               data=data,
                               allow_redirects=False)
        if ssoResp.status_code != 200 or "temporarily unavailable" in ssoResp.text:
            raise APIException("SSO error %s %s" %
                               (ssoResp.status_code, ssoResp.text))

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

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

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

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

            if current_redirect_count >= expected_redirect_count and gcRedeemResp.status_code != 200:
                raise APIException(
                    "GC redeem %d/%d error %s %s" %
                    (current_redirect_count, expected_redirect_count,
                     gcRedeemResp.status_code, gcRedeemResp.text))
            if gcRedeemResp.status_code == 200 or gcRedeemResp.status_code == 404:
                break
            current_redirect_count += 1
            if current_redirect_count > expected_redirect_count:
                break

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

        session.headers.update(self._obligatory_headers)

        return session

    def WebInit(self):
        self.UserAuthorizationURL = WEB_ROOT + reverse(
            "auth_simple", kwargs={"service": self.ID})

    def Authorize(self, email, password):
        from tapiriik.auth.credential_storage import CredentialStore
        session = self._get_session(email=email,
                                    password=password,
                                    skip_cache=True)
        # TODO: http://connect.garmin.com/proxy/userprofile-service/socialProfile/ has the proper immutable user ID, not that anyone ever changes this one...
        self._rate_limit()
        username = session.get(
            "http://connect.garmin.com/user/username").json()["username"]
        if not len(username):
            raise APIException("Unable to retrieve username",
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.Authorization,
                                   intervention_required=True))
        return (username, {}, {
            "Email": CredentialStore.Encrypt(email),
            "Password": CredentialStore.Encrypt(password)
        })

    def UserUploadedActivityURL(self, uploadId):
        return "https://connect.garmin.com/modern/activity/%d" % uploadId

    def _resolveActivityType(self, act_type):
        # Mostly there are two levels of a hierarchy, so we don't really need this as the parent is included in the listing.
        # But maybe they'll change that some day?
        while act_type not in self._activityMappings:
            try:
                act_type = [
                    x["parent"]["key"] for x in self._activityHierarchy
                    if x["key"] == act_type
                ][0]
            except IndexError:
                raise ValueError(
                    "Activity type not found in activity hierarchy")
        return self._activityMappings[act_type]

    def DownloadActivityList(self, serviceRecord, exhaustive=False):
        #http://connect.garmin.com/proxy/activity-search-service-1.0/json/activities?&start=0&limit=50
        page = 1
        pageSz = 100
        activities = []
        exclusions = []
        while True:
            logger.debug("Req with " + str({
                "start": (page - 1) * pageSz,
                "limit": pageSz
            }))

            res = self._request_with_reauth(
                serviceRecord, lambda session: session.get(
                    "https://connect.garmin.com/modern/proxy/activity-search-service-1.0/json/activities",
                    params={
                        "start": (page - 1) * pageSz,
                        "limit": pageSz
                    }))

            try:
                res = res.json()["results"]
            except ValueError:
                res_txt = res.text  # So it can capture in the log message
                raise APIException("Parse failure in GC list resp: %s - %s" %
                                   (res.status_code, res.text))
            if "activities" not in res:
                break  # No activities on this page - empty account.
            for act in res["activities"]:
                act = act["activity"]
                activity = UploadedActivity()

                # Don't really know why sumSampleCountTimestamp doesn't appear in swim activities - they're definitely timestamped...
                activity.Stationary = "sumSampleCountSpeed" not in act and "sumSampleCountTimestamp" not in act
                activity.GPS = "endLatitude" in act

                activity.Private = act["privacy"]["key"] == "private"

                try:
                    activity.TZ = pytz.timezone(act["activityTimeZone"]["key"])
                except pytz.exceptions.UnknownTimeZoneError:
                    activity.TZ = pytz.FixedOffset(
                        float(act["activityTimeZone"]["offset"]) * 60)

                logger.debug("Name " + act["activityName"]["value"] + ":")
                if len(act["activityName"]["value"].strip(
                )) and act["activityName"][
                        "value"] != "Untitled":  # This doesn't work for internationalized accounts, oh well.
                    activity.Name = act["activityName"]["value"]

                if len(act["activityDescription"]["value"].strip()):
                    activity.Notes = act["activityDescription"]["value"]

                # beginTimestamp/endTimestamp is in UTC
                activity.StartTime = pytz.utc.localize(
                    datetime.utcfromtimestamp(
                        float(act["beginTimestamp"]["millis"]) / 1000))
                if "sumElapsedDuration" in act:
                    activity.EndTime = activity.StartTime + timedelta(
                        0, round(float(act["sumElapsedDuration"]["value"])))
                elif "sumDuration" in act:
                    activity.EndTime = activity.StartTime + timedelta(
                        minutes=float(act["sumDuration"]
                                      ["minutesSeconds"].split(":")[0]),
                        seconds=float(act["sumDuration"]
                                      ["minutesSeconds"].split(":")[1]))
                else:
                    activity.EndTime = pytz.utc.localize(
                        datetime.utcfromtimestamp(
                            float(act["endTimestamp"]["millis"]) / 1000))
                logger.debug("Activity s/t " + str(activity.StartTime) +
                             " on page " + str(page))
                activity.AdjustTZ()

                if "sumDistance" in act and float(
                        act["sumDistance"]["value"]) != 0:
                    activity.Stats.Distance = ActivityStatistic(
                        self._unitMap[act["sumDistance"]["uom"]],
                        value=float(act["sumDistance"]["value"]))

                if "device" in act and act["device"]["key"] != "unknown":
                    devId = DeviceIdentifier.FindMatchingIdentifierOfType(
                        DeviceIdentifierType.GC, {"Key": act["device"]["key"]})
                    ver_split = act["device"]["key"].split(".")
                    ver_maj = None
                    ver_min = None
                    if len(ver_split) == 4:
                        # 2.90.0.0
                        ver_maj = int(ver_split[0])
                        ver_min = int(ver_split[1])
                    activity.Device = Device(devId,
                                             verMaj=ver_maj,
                                             verMin=ver_min)

                activity.Type = self._resolveActivityType(
                    act["activityType"]["key"])

                activity.CalculateUID()

                activity.ServiceData = {"ActivityID": int(act["activityId"])}

                activities.append(activity)
            logger.debug("Finished page " + str(page) + " of " +
                         str(res["search"]["totalPages"]))
            if not exhaustive or int(res["search"]["totalPages"]) == page:
                break
            else:
                page += 1
        return activities, exclusions

    def _downloadActivitySummary(self, serviceRecord, activity):
        activityID = activity.ServiceData["ActivityID"]

        res = self._request_with_reauth(
            serviceRecord, lambda session: session.
            get("https://connect.garmin.com/modern/proxy/activity-service-1.3/json/activity/"
                + str(activityID)))

        try:
            raw_data = res.json()
        except ValueError:
            raise APIException("Failure downloading activity summary %s:%s" %
                               (res.status_code, res.text))
        stat_map = {}

        def mapStat(gcKey, statKey, type):
            stat_map[gcKey] = {"key": statKey, "attr": type}

        def applyStats(gc_dict, stats_obj):
            for gc_key, stat in stat_map.items():
                if gc_key in gc_dict:
                    value = float(gc_dict[gc_key]["value"])
                    units = self._unitMap[gc_dict[gc_key]["uom"]]
                    if math.isinf(value):
                        continue  # GC returns the minimum speed as "-Infinity" instead of 0 some times :S
                    getattr(stats_obj, stat["key"]).update(
                        ActivityStatistic(units, **({
                            stat["attr"]: value
                        })))

        mapStat("SumMovingDuration", "MovingTime", "value")
        mapStat("SumDuration", "TimerTime", "value")
        mapStat("SumDistance", "Distance", "value")
        mapStat("MinSpeed", "Speed", "min")
        mapStat("MaxSpeed", "Speed", "max")
        mapStat("WeightedMeanSpeed", "Speed", "avg")
        mapStat("MinAirTemperature", "Temperature", "min")
        mapStat("MaxAirTemperature", "Temperature", "max")
        mapStat("WeightedMeanAirTemperature", "Temperature", "avg")
        mapStat("SumEnergy", "Energy", "value")
        mapStat("MaxHeartRate", "HR", "max")
        mapStat("WeightedMeanHeartRate", "HR", "avg")
        mapStat("MaxDoubleCadence", "RunCadence", "max")
        mapStat("WeightedMeanDoubleCadence", "RunCadence", "avg")
        mapStat("MaxBikeCadence", "Cadence", "max")
        mapStat("WeightedMeanBikeCadence", "Cadence", "avg")
        mapStat("MinPower", "Power", "min")
        mapStat("MaxPower", "Power", "max")
        mapStat("WeightedMeanPower", "Power", "avg")
        mapStat("MinElevation", "Elevation", "min")
        mapStat("MaxElevation", "Elevation", "max")
        mapStat("GainElevation", "Elevation", "gain")
        mapStat("LossElevation", "Elevation", "loss")

        applyStats(raw_data["activity"]["activitySummary"], activity.Stats)

        for lap_data in raw_data["activity"]["totalLaps"]["lapSummaryList"]:
            lap = Lap()
            if "BeginTimestamp" in lap_data:
                lap.StartTime = pytz.utc.localize(
                    datetime.utcfromtimestamp(
                        float(lap_data["BeginTimestamp"]["value"]) / 1000))
            if "EndTimestamp" in lap_data:
                lap.EndTime = pytz.utc.localize(
                    datetime.utcfromtimestamp(
                        float(lap_data["EndTimestamp"]["value"]) / 1000))

            elapsed_duration = None
            if "SumElapsedDuration" in lap_data:
                elapsed_duration = timedelta(seconds=round(
                    float(lap_data["SumElapsedDuration"]["value"])))
            elif "SumDuration" in lap_data:
                elapsed_duration = timedelta(
                    seconds=round(float(lap_data["SumDuration"]["value"])))

            if lap.StartTime and elapsed_duration:
                # Always recalculate end time based on duration, if we have the start time
                lap.EndTime = lap.StartTime + elapsed_duration
            if not lap.StartTime and lap.EndTime and elapsed_duration:
                # Sometimes calculate start time based on duration
                lap.StartTime = lap.EndTime - elapsed_duration

            if not lap.StartTime or not lap.EndTime:
                # Garmin Connect is weird.
                raise APIExcludeActivity(
                    "Activity lap has no BeginTimestamp or EndTimestamp",
                    user_exception=UserException(UserExceptionType.Corrupt))

            applyStats(lap_data, lap.Stats)
            activity.Laps.append(lap)

        # In Garmin Land, max can be smaller than min for this field :S
        if activity.Stats.Power.Max is not None and activity.Stats.Power.Min is not None and activity.Stats.Power.Min > activity.Stats.Power.Max:
            activity.Stats.Power.Min = None

    def DownloadActivity(self, serviceRecord, activity):
        # First, download the summary stats and lap stats
        self._downloadActivitySummary(serviceRecord, activity)

        if len(activity.Laps) == 1:
            activity.Stats = activity.Laps[
                0].Stats  # They must be identical to pass the verification

        if activity.Stationary:
            # Nothing else to download
            return activity

        # https://connect.garmin.com/proxy/activity-service-1.3/json/activityDetails/####
        activityID = activity.ServiceData["ActivityID"]
        res = self._request_with_reauth(
            serviceRecord, lambda session: session.
            get("https://connect.garmin.com/modern/proxy/activity-service-1.3/json/activityDetails/"
                + str(activityID) + "?maxSize=999999999"))
        try:
            raw_data = res.json(
            )["com.garmin.activity.details.json.ActivityDetails"]
        except ValueError:
            raise APIException("Activity data parse error for %s: %s" %
                               (res.status_code, res.text))

        if "measurements" not in raw_data:
            activity.Stationary = True  # We were wrong, oh well
            return activity

        attrs_map = {}

        def _map_attr(gc_key,
                      wp_key,
                      units,
                      in_location=False,
                      is_timestamp=False):
            attrs_map[gc_key] = {
                "key": wp_key,
                "to_units": units,
                "in_location": in_location,  # Blegh
                "is_timestamp": is_timestamp  # See above
            }

        _map_attr("directSpeed", "Speed",
                  ActivityStatisticUnit.MetersPerSecond)
        _map_attr("sumDistance", "Distance", ActivityStatisticUnit.Meters)
        _map_attr("directHeartRate", "HR",
                  ActivityStatisticUnit.BeatsPerMinute)
        _map_attr("directBikeCadence", "Cadence",
                  ActivityStatisticUnit.RevolutionsPerMinute)
        _map_attr("directDoubleCadence", "RunCadence",
                  ActivityStatisticUnit.StepsPerMinute)  # 2*x mystery solved
        _map_attr("directAirTemperature", "Temp",
                  ActivityStatisticUnit.DegreesCelcius)
        _map_attr("directPower", "Power", ActivityStatisticUnit.Watts)
        _map_attr("directElevation",
                  "Altitude",
                  ActivityStatisticUnit.Meters,
                  in_location=True)
        _map_attr("directLatitude", "Latitude", None, in_location=True)
        _map_attr("directLongitude", "Longitude", None, in_location=True)
        _map_attr("directTimestamp", "Timestamp", None, is_timestamp=True)

        # Figure out which metrics we'll be seeing in this activity
        attrs_indexed = {}
        for measurement in raw_data["measurements"]:
            key = measurement["key"]
            if key in attrs_map:
                if attrs_map[key]["to_units"]:
                    attrs_map[key]["from_units"] = self._unitMap[
                        measurement["unit"]]
                    if attrs_map[key]["to_units"] == attrs_map[key][
                            "from_units"]:
                        attrs_map[key]["to_units"] = attrs_map[key][
                            "from_units"] = None
                attrs_indexed[measurement["metricsIndex"]] = attrs_map[key]

        # Process the data frames
        frame_idx = 0
        active_lap_idx = 0
        for frame in raw_data["metrics"]:
            wp = Waypoint()
            for idx, attr in attrs_indexed.items():
                value = frame["metrics"][idx]
                target_obj = wp
                if attr["in_location"]:
                    if not wp.Location:
                        wp.Location = Location()
                    target_obj = wp.Location

                # Handle units
                if attr["is_timestamp"]:
                    value = pytz.utc.localize(
                        datetime.utcfromtimestamp(value / 1000))
                elif attr["to_units"]:
                    value = ActivityStatistic.convertValue(
                        value, attr["from_units"], attr["to_units"])

                # Write the value (can't use __dict__ because __slots__)
                setattr(target_obj, attr["key"], value)

            # Fix up lat/lng being zero (which appear to represent missing coords)
            if wp.Location and wp.Location.Latitude == 0 and wp.Location.Longitude == 0:
                wp.Location.Latitude = None
                wp.Location.Longitude = None
            # Please visit a physician before complaining about this
            if wp.HR == 0:
                wp.HR = None
            # Bump the active lap if required
            while (active_lap_idx < len(activity.Laps) - 1
                   and  # Not the last lap
                   activity.Laps[active_lap_idx + 1].StartTime <= wp.Timestamp
                   ):
                active_lap_idx += 1
            activity.Laps[active_lap_idx].Waypoints.append(wp)
            frame_idx += 1

        return activity

    def UploadActivity(self, serviceRecord, activity):
        #/proxy/upload-service-1.1/json/upload/.fit
        fit_file = FITIO.Dump(activity)
        files = {
            "data":
            ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit",
             fit_file)
        }

        res = self._request_with_reauth(
            serviceRecord, lambda session: session.post(
                "https://connect.garmin.com/proxy/upload-service-1.1/json/upload/.fit",
                files=files))
        res = res.json()["detailedImportResult"]

        if len(res["successes"]) == 0:
            if len(res["failures"]) and len(
                    res["failures"][0]["messages"]) and res["failures"][0][
                        "messages"][0]["content"] == "Duplicate activity":
                logger.debug("Duplicate")
                return  # ...cool?
            raise APIException("Unable to upload activity %s" % res)
        if len(res["successes"]) > 1:
            raise APIException(
                "Uploaded succeeded, resulting in too many activities")
        actid = res["successes"][0]["internalId"]

        name = activity.Name  # Capture in logs
        notes = activity.Notes

        # Update activity metadata not included in the FIT file.
        metadata_object = {}
        if activity.Name and activity.Name.strip():
            metadata_object["activityName"] = activity.Name
        if activity.Notes and activity.Notes.strip():
            metadata_object["description"] = activity.Notes
        if activity.Type not in [
                ActivityType.Running, ActivityType.Cycling, ActivityType.Other
        ]:
            # Set the legit activity type - whatever it is, it's not supported by the FIT schema
            acttype = [
                k for k, v in self._reverseActivityMappings.items()
                if v == activity.Type
            ]
            if len(acttype) == 0:
                raise APIWarning(
                    "GarminConnect does not support activity type " +
                    activity.Type)
            else:
                acttype = acttype[0]
            metadata_object["activityTypeDTO"] = {"typeKey": acttype}
        if activity.Private:
            metadata_object["accessControlRuleDTO"] = {"typeKey": "private"}

        if metadata_object:
            metadata_object["activityId"] = actid
            encoding_headers = {
                "Content-Type": "application/json; charset=UTF-8"
            }  # GC really, really needs this part, otherwise it throws obscure errors like "Invalid signature for signature method HMAC-SHA1"
            res = self._request_with_reauth(
                serviceRecord, lambda session: session.
                put("https://connect.garmin.com/proxy/activity-service/activity/"
                    + str(actid),
                    data=json.dumps(metadata_object),
                    headers=encoding_headers))
            if res.status_code != 204:
                raise APIWarning("Unable to set activity metadata - %d %s" %
                                 (res.status_code, res.text))

        return actid

    def _user_watch_user(self, serviceRecord):
        if not serviceRecord.GetConfiguration()["WatchUserKey"]:
            user_key = random.choice(
                list(GARMIN_CONNECT_USER_WATCH_ACCOUNTS.keys()))
            logger.info("Assigning %s a new watch user %s" %
                        (serviceRecord.ExternalID, user_key))
            serviceRecord.SetConfiguration({"WatchUserKey": user_key})
            return GARMIN_CONNECT_USER_WATCH_ACCOUNTS[user_key]
        else:
            return GARMIN_CONNECT_USER_WATCH_ACCOUNTS[
                serviceRecord.GetConfiguration()["WatchUserKey"]]

    def SubscribeToPartialSyncTrigger(self, serviceRecord):
        # PUT http://connect.garmin.com/proxy/userprofile-service/connection/request/cpfair
        # (the poll worker finishes the connection)
        user_name = self._user_watch_user(serviceRecord)["Name"]
        logger.info("Requesting connection to %s from %s" %
                    (user_name, serviceRecord.ExternalID))
        self._rate_limit()
        resp = self._get_session(record=serviceRecord, skip_cache=True).put(
            "https://connect.garmin.com/proxy/userprofile-service/connection/request/%s"
            % user_name)
        try:
            assert resp.status_code == 200
            assert resp.json()["requestStatus"] == "Created"
        except:
            raise APIException(
                "Connection request failed with user watch account %s: %s %s" %
                (user_name, resp.status_code, resp.text))
        else:
            serviceRecord.SetConfiguration(
                {"WatchConnectionID": resp.json()["id"]})

        serviceRecord.SetPartialSyncTriggerSubscriptionState(True)

    def UnsubscribeFromPartialSyncTrigger(self, serviceRecord):
        # GET http://connect.garmin.com/proxy/userprofile-service/socialProfile/connections to get the ID
        #  {"fullName":null,"userConnections":[{"userId":5754439,"displayName":"TapiirikAPITEST","fullName":null,"location":null,"profileImageUrlMedium":null,"profileImageUrlSmall":null,"connectionRequestId":1566024,"userConnectionStatus":2,"userRoles":["ROLE_CONNECTUSER","ROLE_FITNESS_USER"],"userPro":false}]}
        # PUT http://connect.garmin.com/proxy/userprofile-service/connection/end/1904201
        # Unfortunately there's no way to delete a pending request - the poll worker will do this from the other end
        active_watch_user = self._user_watch_user(serviceRecord)
        session = self._get_session(email=active_watch_user["Username"],
                                    password=active_watch_user["Password"],
                                    skip_cache=True)
        if "WatchConnectionID" in serviceRecord.GetConfiguration():
            self._rate_limit()
            dc_resp = session.put(
                "https://connect.garmin.com/modern/proxy/userprofile-service/connection/end/%s"
                % serviceRecord.GetConfiguration()["WatchConnectionID"])
            if dc_resp.status_code != 200:
                raise APIException(
                    "Error disconnecting user watch accunt %s from %s: %s %s" %
                    (active_watch_user, serviceRecord.ExternalID,
                     dc_resp.status_code, dc_resp.text))

            serviceRecord.SetConfiguration({
                "WatchUserKey": None,
                "WatchConnectionID": None
            })
            serviceRecord.SetPartialSyncTriggerSubscriptionState(False)
        else:
            # I broke Garmin Connect by having too many connections per account, so I can no longer query the connection list
            # All the connection request emails are sitting unopened in an email inbox, though, so I'll be backfilling the IDs from those
            raise APIException("Did not store connection ID")

    def ShouldForcePartialSyncTrigger(self, serviceRecord):
        # The poll worker can't see private activities.
        return serviceRecord.GetConfiguration()["sync_private"]

    def PollPartialSyncTrigger(self, multiple_index):
        # TODO: ensure the appropriate users are connected
        # GET http://connect.garmin.com/modern/proxy/userprofile-service/connection/pending to get ID
        #  [{"userId":6244126,"displayName":"tapiriik-sync-ulukhaktok","fullName":"tapiriik sync ulukhaktok","profileImageUrlSmall":null,"connectionRequestId":1904086,"requestViewed":true,"userRoles":["ROLE_CONNECTUSER"],"userPro":false}]
        # PUT http://connect.garmin.com/proxy/userprofile-service/connection/accept/1904086
        # ...later...
        # GET http://connect.garmin.com/proxy/activitylist-service/activities/comments/subscriptionFeed?start=1&limit=10

        # First, accept any pending connections
        watch_user_key = sorted(list(
            GARMIN_CONNECT_USER_WATCH_ACCOUNTS.keys()))[multiple_index]
        watch_user = GARMIN_CONNECT_USER_WATCH_ACCOUNTS[watch_user_key]
        session = self._get_session(email=watch_user["Username"],
                                    password=watch_user["Password"],
                                    skip_cache=True)

        # Then, check for users with new activities
        self._rate_limit()
        watch_activities_resp = session.get(
            "https://connect.garmin.com/modern/proxy/activitylist-service/activities/subscriptionFeed?limit=1000"
        )
        try:
            watch_activities = watch_activities_resp.json()
        except ValueError:
            raise Exception("Could not parse new activities list: %s %s" %
                            (watch_activities_resp.status_code,
                             watch_activities_resp.text))

        active_user_pairs = [(x["ownerDisplayName"], x["activityId"])
                             for x in watch_activities["activityList"]]
        active_user_pairs.sort(
            key=lambda x: x[1]
        )  # Highest IDs last (so they make it into the dict, supplanting lower IDs where appropriate)
        active_users = dict(active_user_pairs)

        active_user_recs = [
            ServiceRecord(x) for x in db.connections.find(
                {
                    "ExternalID": {
                        "$in": list(active_users.keys())
                    },
                    "Service": "garminconnect"
                }, {
                    "Config": 1,
                    "ExternalID": 1,
                    "Service": 1
                })
        ]

        if len(active_user_recs) != len(active_users.keys()):
            logger.warning("Mismatch %d records found for %d active users" %
                           (len(active_user_recs), len(active_users.keys())))

        to_sync_ids = []
        for active_user_rec in active_user_recs:
            last_active_id = active_user_rec.GetConfiguration(
            )["WatchUserLastID"]
            this_active_id = active_users[active_user_rec.ExternalID]
            if this_active_id > last_active_id:
                to_sync_ids.append(active_user_rec.ExternalID)
                active_user_rec.SetConfiguration({
                    "WatchUserLastID": this_active_id,
                    "WatchUserKey": watch_user_key
                })

        self._rate_limit()
        pending_connections_resp = session.get(
            "https://connect.garmin.com/modern/proxy/userprofile-service/connection/pending"
        )
        try:
            pending_connections = pending_connections_resp.json()
        except ValueError:
            logger.error("Could not parse pending connection requests: %s %s" %
                         (pending_connections_resp.status_code,
                          pending_connections_resp.text))
        else:
            valid_pending_connections_external_ids = [
                x["ExternalID"] for x in db.connections.find(
                    {
                        "Service": "garminconnect",
                        "ExternalID": {
                            "$in":
                            [x["displayName"] for x in pending_connections]
                        }
                    }, {"ExternalID": 1})
            ]
            logger.info(
                "Accepting %d, denying %d connection requests for %s" %
                (len(valid_pending_connections_external_ids),
                 len(pending_connections) -
                 len(valid_pending_connections_external_ids), watch_user_key))
            for pending_connect in pending_connections:
                if pending_connect[
                        "displayName"] in valid_pending_connections_external_ids:
                    self._rate_limit()
                    connect_resp = session.put(
                        "https://connect.garmin.com/modern/proxy/userprofile-service/connection/accept/%s"
                        % pending_connect["connectionRequestId"])
                    if connect_resp.status_code != 200:
                        logger.error(
                            "Error accepting request on watch account %s: %s %s"
                            % (watch_user["Name"], connect_resp.status_code,
                               connect_resp.text))
                else:
                    self._rate_limit()
                    session.put(
                        "https://connect.garmin.com/modern/proxy/userprofile-service/connection/decline/%s"
                        % pending_connect["connectionRequestId"])

        return to_sync_ids

    def RevokeAuthorization(self, serviceRecord):
        # nothing to do here...
        pass

    def DeleteCachedData(self, serviceRecord):
        # nothing cached...
        pass

    def DeleteActivity(self, serviceRecord, uploadId):
        session = self._get_session(record=serviceRecord)
        self._rate_limit()
        del_res = session.delete(
            "https://connect.garmin.com/modern/proxy/activity-service/activity/%d"
            % uploadId)
        del_res.raise_for_status()
Ejemplo n.º 3
0
class RideWithGPSService(ServiceBase):
    ID = "rwgps"
    DisplayName = "Ride With GPS"
    DisplayAbbreviation = "RWG"
    AuthenticationType = ServiceAuthenticationType.UsernamePassword
    RequiresExtendedAuthorizationDetails = True

    # RWGPS does has a "recreation_types" list, but it is not actually used anywhere (yet)
    # (This is a subset of the things returned by that list for future reference...)
    _activityMappings = {
        "running": ActivityType.Running,
        "cycling": ActivityType.Cycling,
        "mountain biking": ActivityType.MountainBiking,
        "Hiking": ActivityType.Hiking,
        "all": ActivityType.Other  # everything will eventually resolve to this
    }

    SupportedActivities = list(_activityMappings.values())

    SupportsHR = SupportsCadence = True

    _sessionCache = SessionCache(lifetime=timedelta(minutes=30),
                                 freshen_on_get=True)

    def _add_auth_params(self, params=None, record=None):
        """
        Adds apikey and authorization (email/password) to the passed-in params,
        returns modified params dict.
        """
        from tapiriik.auth.credential_storage import CredentialStore
        if params is None:
            params = {}
        params['apikey'] = RWGPS_APIKEY
        if record:
            cached = self._sessionCache.Get(record.ExternalID)
            if cached:
                return cached
            password = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Password"])
            email = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Email"])
            params['email'] = email
            params['password'] = password
        return params

    def WebInit(self):
        self.UserAuthorizationURL = WEB_ROOT + reverse(
            "auth_simple", kwargs={"service": self.ID})

    def Authorize(self, email, password):
        from tapiriik.auth.credential_storage import CredentialStore
        res = requests.get("https://ridewithgps.com/users/current.json",
                           params={
                               'email': email,
                               'password': password,
                               'apikey': RWGPS_APIKEY
                           })
        res.raise_for_status()
        res = res.json()
        if res["user"] is None:
            raise APIException("Invalid login",
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.Authorization,
                                   intervention_required=True))
        member_id = res["user"]["id"]
        if not member_id:
            raise APIException("Unable to retrieve id",
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.Authorization,
                                   intervention_required=True))
        return (member_id, {}, {
            "Email": CredentialStore.Encrypt(email),
            "Password": CredentialStore.Encrypt(password)
        })

    def _duration_to_seconds(self, s):
        """
        Converts a duration in form HH:MM:SS to number of seconds for use in timedelta construction.
        """
        hours, minutes, seconds = (["0", "0"] + s.split(":"))[-3:]
        hours = int(hours)
        minutes = int(minutes)
        seconds = float(seconds)
        total_seconds = int(hours + 60000 * minutes + 1000 * seconds)
        return total_seconds

    def DownloadActivityList(self, serviceRecord, exhaustive=False):
        # http://ridewithgps.com/users/1/trips.json?limit=200&order_by=created_at&order_dir=asc
        # offset also supported
        page = 1
        pageSz = 50
        activities = []
        exclusions = []
        while True:
            logger.debug("Req with " + str({
                "start": (page - 1) * pageSz,
                "limit": pageSz
            }))
            # TODO: take advantage of their nice ETag support
            params = {"offset": (page - 1) * pageSz, "limit": pageSz}
            params = self._add_auth_params(params, record=serviceRecord)

            res = requests.get(
                "http://ridewithgps.com/users/{}/trips.json".format(
                    serviceRecord.ExternalID),
                params=params)
            res = res.json()
            total_pages = math.ceil(int(res["results_count"]) / pageSz)
            for act in res["results"]:
                if "first_lat" not in act or "last_lat" not in act:
                    exclusions.append(
                        APIExcludeActivity("No points",
                                           activityId=act["activityId"],
                                           userException=UserException(
                                               UserExceptionType.Corrupt)))
                    continue
                if "distance" not in act:
                    exclusions.append(
                        APIExcludeActivity("No distance",
                                           activityId=act["activityId"],
                                           userException=UserException(
                                               UserExceptionType.Corrupt)))
                    continue
                activity = UploadedActivity()

                activity.TZ = pytz.timezone(act["time_zone"])

                logger.debug("Name " + act["name"] + ":")
                if len(act["name"].strip()):
                    activity.Name = act["name"]

                activity.StartTime = pytz.utc.localize(
                    datetime.strptime(act["departed_at"],
                                      "%Y-%m-%dT%H:%M:%SZ"))
                activity.EndTime = activity.StartTime + timedelta(
                    seconds=self._duration_to_seconds(act["duration"]))
                logger.debug("Activity s/t " + str(activity.StartTime) +
                             " on page " + str(page))
                activity.AdjustTZ()

                activity.Distance = float(
                    act["distance"])  # This value is already in meters...
                # Activity type is not implemented yet in RWGPS results; we will assume cycling, though perhaps "OTHER" wouuld be correct
                activity.Type = ActivityType.Cycling

                activity.CalculateUID()
                activity.UploadedTo = [{
                    "Connection": serviceRecord,
                    "ActivityID": act["id"]
                }]
                activities.append(activity)
            logger.debug("Finished page {} of {}".format(page, total_pages))
            if not exhaustive or total_pages == page or total_pages == 0:
                break
            else:
                page += 1
        return activities, exclusions

    def DownloadActivity(self, serviceRecord, activity):
        # https://ridewithgps.com/trips/??????.gpx
        activityID = [
            x["ActivityID"] for x in activity.UploadedTo
            if x["Connection"] == serviceRecord
        ][0]
        res = requests.get(
            "https://ridewithgps.com/trips/{}.tcx".format(activityID),
            params=self._add_auth_params({'sub_format': 'history'},
                                         record=serviceRecord))
        try:
            TCXIO.Parse(res.content, activity)
        except ValueError as e:
            raise APIExcludeActivity("TCX parse error " + str(e),
                                     userException=UserException(
                                         UserExceptionType.Corrupt))

        return activity

    def UploadActivity(self, serviceRecord, activity):
        # https://ridewithgps.com/trips.json

        tcx_file = TCXIO.Dump(activity)
        files = {
            "data_file":
            ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".tcx",
             tcx_file)
        }
        params = {}
        params['trip[name]'] = activity.Name
        params[
            'trip[visibility]'] = 1 if activity.Private else 0  # Yes, this logic seems backwards but it's how it works

        res = requests.post("https://ridewithgps.com/trips.json",
                            files=files,
                            params=self._add_auth_params(params,
                                                         record=serviceRecord))
        if res.status_code % 100 == 4:
            raise APIException("Invalid login",
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.Authorization,
                                   intervention_required=True))
        res.raise_for_status()
        res = res.json()
        if res["success"] != 1:
            raise APIException("Unable to upload activity")

    def RevokeAuthorization(self, serviceRecord):
        # nothing to do here...
        pass

    def DeleteCachedData(self, serviceRecord):
        # nothing cached...
        pass
Ejemplo n.º 4
0
class SportTracksService(ServiceBase):
    ID = "sporttracks"
    DisplayName = "SportTracks"
    DisplayAbbreviation = "ST"
    AuthenticationType = ServiceAuthenticationType.OAuth
    OpenFitEndpoint = SPORTTRACKS_OPENFIT_ENDPOINT
    SupportsHR = True
    AuthenticationNoFrame = True
    """ Other   Basketball
        Other   Boxing
        Other   Climbing
        Other   Driving
        Other   Flying
        Other   Football
        Other   Gardening
        Other   Kitesurf
        Other   Sailing
        Other   Soccer
        Other   Tennis
        Other   Volleyball
        Other   Windsurf
        Running Hashing
        Running Hills
        Running Intervals
        Running Orienteering
        Running Race
        Running Road
        Running Showshoe
        Running Speed
        Running Stair
        Running Track
        Running Trail
        Running Treadmill
        Cycling Hills
        Cycling Indoor
        Cycling Intervals
        Cycling Mountain
        Cycling Race
        Cycling Road
        Cycling Rollers
        Cycling Spinning
        Cycling Track
        Cycling Trainer
        Swimming    Open Water
        Swimming    Pool
        Swimming    Race
        Walking Geocaching
        Walking Hiking
        Walking Nordic
        Walking Photography
        Walking Snowshoe
        Walking Treadmill
        Skiing  Alpine
        Skiing  Nordic
        Skiing  Roller
        Skiing  Snowboard
        Rowing  Canoe
        Rowing  Kayak
        Rowing  Kitesurf
        Rowing  Ocean Kayak
        Rowing  Rafting
        Rowing  Rowing Machine
        Rowing  Sailing
        Rowing  Standup Paddling
        Rowing  Windsurf
        Skating Board
        Skating Ice
        Skating Inline
        Skating Race
        Skating Track
        Gym Aerobics
        Gym Elliptical
        Gym Plyometrics
        Gym Rowing Machine
        Gym Spinning
        Gym Stair Climber
        Gym Stationary Bike
        Gym Strength
        Gym Stretching
        Gym Treadmill
        Gym Yoga
    """

    _activityMappings = {
        "running": ActivityType.Running,
        "cycling": ActivityType.Cycling,
        "mountain": ActivityType.MountainBiking,
        "walking": ActivityType.Walking,
        "hiking": ActivityType.Hiking,
        "snowboarding": ActivityType.Snowboarding,
        "skiing": ActivityType.DownhillSkiing,
        "nordic": ActivityType.CrossCountrySkiing,
        "skating": ActivityType.Skating,
        "swimming": ActivityType.Swimming,
        "rowing": ActivityType.Rowing,
        "elliptical": ActivityType.Elliptical,
        "gym": ActivityType.Gym,
        "other": ActivityType.Other
    }

    _reverseActivityMappings = {
        ActivityType.Running: "running",
        ActivityType.Cycling: "cycling",
        ActivityType.Walking: "walking",
        ActivityType.MountainBiking: "cycling: mountain",
        ActivityType.Hiking: "walking: hiking",
        ActivityType.CrossCountrySkiing:
        "skiing: nordic",  #  Equipment.Bindings.IsToeOnly ??
        ActivityType.DownhillSkiing: "skiing",
        ActivityType.Snowboarding: "skiing: snowboarding",
        ActivityType.Skating: "skating",
        ActivityType.Swimming: "swimming",
        ActivityType.Rowing: "rowing",
        ActivityType.Elliptical: "gym: elliptical",
        ActivityType.Gym: "gym",
        ActivityType.Other: "other"
    }

    SupportedActivities = list(_reverseActivityMappings.keys())

    _tokenCache = SessionCache("sporttracks",
                               lifetime=timedelta(minutes=115),
                               freshen_on_get=False)

    def WebInit(self):
        self.UserAuthorizationURL = "https://api.sporttracks.mobi/oauth2/authorize?response_type=code&client_id=%s&state=mobi_api" % SPORTTRACKS_CLIENT_ID

    def _getAuthHeaders(self, serviceRecord=None):
        token = self._tokenCache.Get(serviceRecord.ExternalID)
        if not token:
            if not serviceRecord.Authorization or "RefreshToken" not in serviceRecord.Authorization:
                # When I convert the existing users, people who didn't check the remember-credentials box will be stuck in limbo
                raise APIException("User not upgraded to OAuth",
                                   block=True,
                                   user_exception=UserException(
                                       UserExceptionType.Authorization,
                                       intervention_required=True))

            # Use refresh token to get access token
            # Hardcoded return URI to get around the lack of URL reversing without loading up all the Django stuff
            params = {
                "grant_type": "refresh_token",
                "refresh_token": serviceRecord.Authorization["RefreshToken"],
                "client_id": SPORTTRACKS_CLIENT_ID,
                "client_secret": SPORTTRACKS_CLIENT_SECRET,
                "redirect_uri": "https://tapiriik.com/auth/return/sporttracks"
            }
            response = requests.post(
                "https://api.sporttracks.mobi/oauth2/token",
                data=urllib.parse.urlencode(params),
                headers={"Content-Type": "application/x-www-form-urlencoded"})
            if response.status_code != 200:
                if response.status_code >= 400 and response.status_code < 500:
                    raise APIException(
                        "Could not retrieve refreshed token %s %s" %
                        (response.status_code, response.text),
                        block=True,
                        user_exception=UserException(
                            UserExceptionType.Authorization,
                            intervention_required=True))
                raise APIException("Could not retrieve refreshed token %s %s" %
                                   (response.status_code, response.text))
            token = response.json()["access_token"]
            self._tokenCache.Set(serviceRecord.ExternalID, token)

        return {"Authorization": "Bearer %s" % token}

    def RetrieveAuthorizationToken(self, req, level):
        from tapiriik.services import Service
        #  might consider a real OAuth client
        code = req.GET.get("code")
        params = {
            "grant_type":
            "authorization_code",
            "code":
            code,
            "client_id":
            SPORTTRACKS_CLIENT_ID,
            "client_secret":
            SPORTTRACKS_CLIENT_SECRET,
            "redirect_uri":
            WEB_ROOT +
            reverse("oauth_return", kwargs={"service": "sporttracks"})
        }

        response = requests.post(
            "https://api.sporttracks.mobi/oauth2/token",
            data=urllib.parse.urlencode(params),
            headers={"Content-Type": "application/x-www-form-urlencoded"})
        if response.status_code != 200:
            print(response.text)
            raise APIException("Invalid code")
        access_token = response.json()["access_token"]
        refresh_token = response.json()["refresh_token"]

        uid_res = requests.post(
            "https://api.sporttracks.mobi/api/v2/system/connect",
            headers={"Authorization": "Bearer %s" % access_token})
        uid = uid_res.json()["user"]["uid"]

        return (uid, {"RefreshToken": refresh_token})

    def RevokeAuthorization(self, serviceRecord):
        pass  # Can't revoke these tokens :(

    def DeleteCachedData(self, serviceRecord):
        cachedb.sporttracks_meta_cache.remove(
            {"ExternalID": serviceRecord.ExternalID})

    def DownloadActivityList(self, serviceRecord, exhaustive=False):
        headers = self._getAuthHeaders(serviceRecord)
        activities = []
        exclusions = []
        pageUri = self.OpenFitEndpoint + "/fitnessActivities.json"

        activity_tz_cache_raw = cachedb.sporttracks_meta_cache.find_one(
            {"ExternalID": serviceRecord.ExternalID})
        activity_tz_cache_raw = activity_tz_cache_raw if activity_tz_cache_raw else {
            "Activities": []
        }
        activity_tz_cache = dict([(x["ActivityURI"], x["TZ"])
                                  for x in activity_tz_cache_raw["Activities"]
                                  ])

        while True:
            logger.debug("Req against " + pageUri)
            res = requests.get(pageUri, headers=headers)
            try:
                res = res.json()
            except ValueError:
                raise APIException(
                    "Could not decode activity list response %s %s" %
                    (res.status_code, res.text))
            for act in res["items"]:
                activity = UploadedActivity()
                activity.ServiceData = {"ActivityURI": act["uri"]}

                if len(act["name"].strip()):
                    activity.Name = act["name"]
                    # Longstanding ST.mobi bug causes it to return negative partial-hour timezones as "-2:-30" instead of "-2:30"
                fixed_start_time = re.sub(r":-(\d\d)", r":\1",
                                          act["start_time"])
                activity.StartTime = dateutil.parser.parse(fixed_start_time)
                if isinstance(activity.StartTime.tzinfo, tzutc):
                    activity.TZ = pytz.utc  # The dateutil tzutc doesn't have an _offset value.
                else:
                    activity.TZ = pytz.FixedOffset(
                        activity.StartTime.tzinfo.utcoffset(
                            activity.StartTime).total_seconds() / 60
                    )  # Convert the dateutil lame timezones into pytz awesome timezones.

                activity.StartTime = activity.StartTime.replace(
                    tzinfo=activity.TZ)
                activity.EndTime = activity.StartTime + timedelta(
                    seconds=float(act["duration"]))
                activity.Stats.TimerTime = ActivityStatistic(
                    ActivityStatisticUnit.Seconds,
                    value=float(act["duration"]
                                ))  # OpenFit says this excludes paused times.

                # Sometimes activities get returned with a UTC timezone even when they are clearly not in UTC.
                if activity.TZ == pytz.utc:
                    if act["uri"] in activity_tz_cache:
                        activity.TZ = pytz.FixedOffset(
                            activity_tz_cache[act["uri"]])
                    else:
                        # So, we get the first location in the activity and calculate the TZ from that.
                        try:
                            firstLocation = self._downloadActivity(
                                serviceRecord,
                                activity,
                                returnFirstLocation=True)
                        except APIExcludeActivity:
                            pass
                        else:
                            try:
                                activity.CalculateTZ(firstLocation,
                                                     recalculate=True)
                            except:
                                # We tried!
                                pass
                            else:
                                activity.AdjustTZ()
                            finally:
                                activity_tz_cache[
                                    act["uri"]] = activity.StartTime.utcoffset(
                                    ).total_seconds() / 60

                logger.debug("Activity s/t " + str(activity.StartTime))
                activity.Stats.Distance = ActivityStatistic(
                    ActivityStatisticUnit.Meters,
                    value=float(act["total_distance"]))

                types = [x.strip().lower() for x in act["type"].split(":")]
                types.reverse(
                )  # The incoming format is like "walking: hiking" and we want the most specific first
                activity.Type = None
                for type_key in types:
                    if type_key in self._activityMappings:
                        activity.Type = self._activityMappings[type_key]
                        break
                if not activity.Type:
                    exclusions.append(
                        APIExcludeActivity("Unknown activity type %s" %
                                           act["type"],
                                           activity_id=act["uri"],
                                           user_exception=UserException(
                                               UserExceptionType.Other)))
                    continue

                activity.CalculateUID()
                activities.append(activity)
            if not exhaustive or "next" not in res or not len(res["next"]):
                break
            else:
                pageUri = res["next"]
        logger.debug("Writing back meta cache")
        cachedb.sporttracks_meta_cache.update(
            {"ExternalID": serviceRecord.ExternalID}, {
                "ExternalID":
                serviceRecord.ExternalID,
                "Activities": [{
                    "ActivityURI": k,
                    "TZ": v
                } for k, v in activity_tz_cache.items()]
            },
            upsert=True)
        return activities, exclusions

    def _downloadActivity(self,
                          serviceRecord,
                          activity,
                          returnFirstLocation=False):
        activityURI = activity.ServiceData["ActivityURI"]
        headers = self._getAuthHeaders(serviceRecord)
        activityData = requests.get(activityURI, headers=headers)
        activityData = activityData.json()

        if "clock_duration" in activityData:
            activity.EndTime = activity.StartTime + timedelta(
                seconds=float(activityData["clock_duration"]))

        activity.Private = "sharing" in activityData and activityData[
            "sharing"] != "public"

        activity.GPS = False  # Gets set back if there is GPS data

        if "notes" in activityData:
            activity.Notes = activityData["notes"]

        activity.Stats.Energy = ActivityStatistic(
            ActivityStatisticUnit.Kilojoules,
            value=float(activityData["calories"]))

        activity.Stats.Elevation = ActivityStatistic(
            ActivityStatisticUnit.Meters,
            gain=float(activityData["elevation_gain"])
            if "elevation_gain" in activityData else None,
            loss=float(activityData["elevation_loss"])
            if "elevation_loss" in activityData else None)

        activity.Stats.HR = ActivityStatistic(
            ActivityStatisticUnit.BeatsPerMinute,
            avg=activityData["avg_heartrate"]
            if "avg_heartrate" in activityData else None,
            max=activityData["max_heartrate"]
            if "max_heartrate" in activityData else None)
        activity.Stats.Cadence = ActivityStatistic(
            ActivityStatisticUnit.RevolutionsPerMinute,
            avg=activityData["avg_cadence"]
            if "avg_cadence" in activityData else None,
            max=activityData["max_cadence"]
            if "max_cadence" in activityData else None)
        activity.Stats.Power = ActivityStatistic(
            ActivityStatisticUnit.Watts,
            avg=activityData["avg_power"]
            if "avg_power" in activityData else None,
            max=activityData["max_power"]
            if "max_power" in activityData else None)

        laps_info = []
        laps_starts = []
        if "laps" in activityData:
            laps_info = activityData["laps"]
            for lap in activityData["laps"]:
                laps_starts.append(dateutil.parser.parse(lap["start_time"]))
        lap = None
        for lapinfo in laps_info:
            lap = Lap()
            activity.Laps.append(lap)
            lap.StartTime = dateutil.parser.parse(lapinfo["start_time"])
            lap.EndTime = lap.StartTime + timedelta(
                seconds=lapinfo["clock_duration"])
            if "type" in lapinfo:
                lap.Intensity = LapIntensity.Active if lapinfo[
                    "type"] == "ACTIVE" else LapIntensity.Rest
            if "distance" in lapinfo:
                lap.Stats.Distance = ActivityStatistic(
                    ActivityStatisticUnit.Meters,
                    value=float(lapinfo["distance"]))
            if "duration" in lapinfo:
                lap.Stats.TimerTime = ActivityStatistic(
                    ActivityStatisticUnit.Seconds, value=lapinfo["duration"])
            if "calories" in lapinfo:
                lap.Stats.Energy = ActivityStatistic(
                    ActivityStatisticUnit.Kilojoules,
                    value=lapinfo["calories"])
            if "elevation_gain" in lapinfo:
                lap.Stats.Elevation.update(
                    ActivityStatistic(ActivityStatisticUnit.Meters,
                                      gain=float(lapinfo["elevation_gain"])))
            if "elevation_loss" in lapinfo:
                lap.Stats.Elevation.update(
                    ActivityStatistic(ActivityStatisticUnit.Meters,
                                      loss=float(lapinfo["elevation_loss"])))
            if "max_speed" in lapinfo:
                lap.Stats.Speed.update(
                    ActivityStatistic(ActivityStatisticUnit.MetersPerSecond,
                                      max=float(lapinfo["max_speed"])))
            if "max_speed" in lapinfo:
                lap.Stats.Speed.update(
                    ActivityStatistic(ActivityStatisticUnit.MetersPerSecond,
                                      max=float(lapinfo["max_speed"])))
            if "avg_speed" in lapinfo:
                lap.Stats.Speed.update(
                    ActivityStatistic(ActivityStatisticUnit.MetersPerSecond,
                                      avg=float(lapinfo["avg_speed"])))
            if "max_heartrate" in lapinfo:
                lap.Stats.HR.update(
                    ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute,
                                      max=float(lapinfo["max_heartrate"])))
            if "avg_heartrate" in lapinfo:
                lap.Stats.HR.update(
                    ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute,
                                      avg=float(lapinfo["avg_heartrate"])))
        if lap is None:  # No explicit laps => make one that encompasses the entire activity
            lap = Lap()
            activity.Laps.append(lap)
            lap.Stats = activity.Stats
            lap.StartTime = activity.StartTime
            lap.EndTime = activity.EndTime
        elif len(activity.Laps) == 1:
            activity.Stats.update(
                activity.Laps[0].Stats
            )  # Lap stats have a bit more info generally.
            activity.Laps[0].Stats = activity.Stats

        timerStops = []
        if "timer_stops" in activityData:
            for stop in activityData["timer_stops"]:
                timerStops.append([
                    dateutil.parser.parse(stop[0]),
                    dateutil.parser.parse(stop[1])
                ])

        def isInTimerStop(timestamp):
            for stop in timerStops:
                if timestamp >= stop[0] and timestamp < stop[1]:
                    return True
                if timestamp >= stop[1]:
                    return False
            return False

        # Collate the individual streams into our waypoints.
        # Global sample rate is variable - will pick the next nearest stream datapoint.
        # Resampling happens on a lookbehind basis - new values will only appear their timestamp has been reached/passed

        wasInPause = False
        currentLapIdx = 0
        lap = activity.Laps[currentLapIdx]

        streams = []
        for stream in [
                "location", "elevation", "heartrate", "power", "cadence",
                "distance"
        ]:
            if stream in activityData:
                streams.append(stream)
        stream_indices = dict([(stream, -1) for stream in streams
                               ])  # -1 meaning the stream has yet to start
        stream_lengths = dict([(stream, len(activityData[stream]) / 2)
                               for stream in streams])
        # Data comes as "stream":[timestamp,value,timestamp,value,...]
        stream_values = {}
        for stream in streams:
            values = []
            for x in range(0, int(len(activityData[stream]) / 2)):
                values.append((activityData[stream][x * 2],
                               activityData[stream][x * 2 + 1]))
            stream_values[stream] = values

        currentOffset = 0

        def streamVal(stream):
            nonlocal stream_values, stream_indices
            return stream_values[stream][stream_indices[stream]][1]

        def hasStreamData(stream):
            nonlocal stream_indices, streams
            return stream in streams and stream_indices[stream] >= 0

        while True:
            advance_stream = None
            advance_offset = None
            for stream in streams:
                if stream_indices[stream] + 1 == stream_lengths[stream]:
                    continue  # We're at the end - can't advance
                if advance_offset is None or stream_values[stream][
                        stream_indices[stream] +
                        1][0] - currentOffset < advance_offset:
                    advance_offset = stream_values[stream][
                        stream_indices[stream] + 1][0] - currentOffset
                    advance_stream = stream
            if not advance_stream:
                break  # We've hit the end of every stream, stop
            # Advance streams sharing the current timestamp
            for stream in streams:
                if stream == advance_stream:
                    continue  # For clarity, we increment this later
                if stream_indices[stream] + 1 == stream_lengths[stream]:
                    continue  # We're at the end - can't advance
                if stream_values[stream][
                        stream_indices[stream] +
                        1][0] == stream_values[advance_stream][
                            stream_indices[advance_stream] + 1][0]:
                    stream_indices[stream] += 1
            stream_indices[
                advance_stream] += 1  # Advance the key stream for this waypoint
            currentOffset = stream_values[advance_stream][stream_indices[
                advance_stream]][0]  # Update the current time offset

            waypoint = Waypoint(activity.StartTime +
                                timedelta(seconds=currentOffset))

            if hasStreamData("location"):
                waypoint.Location = Location(
                    streamVal("location")[0],
                    streamVal("location")[1], None)
                activity.GPS = True
                if returnFirstLocation:
                    return waypoint.Location

            if hasStreamData("elevation"):
                if not waypoint.Location:
                    waypoint.Location = Location(None, None, None)
                waypoint.Location.Altitude = streamVal("elevation")

            if hasStreamData("heartrate"):
                waypoint.HR = streamVal("heartrate")

            if hasStreamData("power"):
                waypoint.Power = streamVal("power")

            if hasStreamData("cadence"):
                waypoint.Cadence = streamVal("cadence")

            if hasStreamData("distance"):
                waypoint.Distance = streamVal("distance")

            inPause = isInTimerStop(waypoint.Timestamp)
            waypoint.Type = WaypointType.Regular if not inPause else WaypointType.Pause
            if wasInPause and not inPause:
                waypoint.Type = WaypointType.Resume
            wasInPause = inPause

            # We only care if it's possible to start a new lap, i.e. there are more left
            if currentLapIdx + 1 < len(laps_starts):
                if laps_starts[currentLapIdx + 1] < waypoint.Timestamp:
                    # A new lap has started
                    currentLapIdx += 1
                    lap = activity.Laps[currentLapIdx]

            lap.Waypoints.append(waypoint)

        if returnFirstLocation:
            return None  # I guess there were no waypoints?
        if activity.CountTotalWaypoints():
            activity.GetFlatWaypoints()[0].Type = WaypointType.Start
            activity.GetFlatWaypoints()[-1].Type = WaypointType.End
            activity.Stationary = False
        else:
            activity.Stationary = True

        return activity

    def DownloadActivity(self, serviceRecord, activity):
        return self._downloadActivity(serviceRecord, activity)

    def UploadActivity(self, serviceRecord, activity, activitySource):
        activityData = {}
        # Props to the SportTracks API people for seamlessly supprting activities with or without TZ data.
        activityData["start_time"] = activity.StartTime.isoformat()
        if activity.Name:
            activityData["name"] = activity.Name
        if activity.Notes:
            activityData["notes"] = activity.Notes
        activityData[
            "sharing"] = "public" if not activity.Private else "private"
        activityData["type"] = self._reverseActivityMappings[activity.Type]

        def _resolveDuration(obj):
            if obj.Stats.TimerTime.Value is not None:
                return obj.Stats.TimerTime.asUnits(
                    ActivityStatisticUnit.Seconds).Value
            if obj.Stats.MovingTime.Value is not None:
                return obj.Stats.MovingTime.asUnits(
                    ActivityStatisticUnit.Seconds).Value
            return (obj.EndTime - obj.StartTime).total_seconds()

        def _mapStat(dict, key, val, naturalValue=False):
            if val is not None:
                if naturalValue:
                    val = round(val)
                dict[key] = val

        _mapStat(activityData, "clock_duration",
                 (activity.EndTime - activity.StartTime).total_seconds())
        _mapStat(
            activityData, "duration", _resolveDuration(activity)
        )  # This has to be set, otherwise all time shows up as "stopped" :(
        _mapStat(
            activityData, "total_distance",
            activity.Stats.Distance.asUnits(
                ActivityStatisticUnit.Meters).Value)
        _mapStat(activityData,
                 "calories",
                 activity.Stats.Energy.asUnits(
                     ActivityStatisticUnit.Kilojoules).Value,
                 naturalValue=True)
        _mapStat(activityData, "elevation_gain", activity.Stats.Elevation.Gain)
        _mapStat(activityData, "elevation_loss", activity.Stats.Elevation.Loss)
        _mapStat(activityData, "max_speed", activity.Stats.Speed.Max)
        _mapStat(activityData, "avg_heartrate", activity.Stats.HR.Average)
        _mapStat(activityData, "max_heartrate", activity.Stats.HR.Max)
        _mapStat(activityData, "avg_cadence", activity.Stats.Cadence.Average)
        _mapStat(activityData, "max_cadence", activity.Stats.Cadence.Max)
        _mapStat(activityData, "avg_power", activity.Stats.Power.Average)
        _mapStat(activityData, "max_power", activity.Stats.Power.Max)

        activityData["laps"] = []
        lapNum = 0
        for lap in activity.Laps:
            lapNum += 1
            lapinfo = {
                "number": lapNum,
                "start_time": lap.StartTime.isoformat(),
                "type":
                "REST" if lap.Intensity == LapIntensity.Rest else "ACTIVE"
            }
            _mapStat(lapinfo, "clock_duration",
                     (lap.EndTime -
                      lap.StartTime).total_seconds())  # Required too.
            _mapStat(lapinfo, "duration", _resolveDuration(
                lap))  # This field is required for laps to be created.
            _mapStat(
                lapinfo, "distance",
                lap.Stats.Distance.asUnits(
                    ActivityStatisticUnit.Meters).Value)  # Probably required.
            _mapStat(lapinfo,
                     "calories",
                     lap.Stats.Energy.asUnits(
                         ActivityStatisticUnit.Kilojoules).Value,
                     naturalValue=True)
            _mapStat(lapinfo, "elevation_gain", lap.Stats.Elevation.Gain)
            _mapStat(lapinfo, "elevation_loss", lap.Stats.Elevation.Loss)
            _mapStat(lapinfo, "max_speed", lap.Stats.Speed.Max)
            _mapStat(lapinfo, "avg_heartrate", lap.Stats.HR.Average)
            _mapStat(lapinfo, "max_heartrate", lap.Stats.HR.Max)

            activityData["laps"].append(lapinfo)
        if not activity.Stationary:
            timer_stops = []
            timer_stopped_at = None

            def stream_append(stream, wp, data):
                stream += [
                    round((wp.Timestamp - activity.StartTime).total_seconds()),
                    data
                ]

            location_stream = []
            distance_stream = []
            elevation_stream = []
            heartrate_stream = []
            power_stream = []
            cadence_stream = []
            for lap in activity.Laps:
                for wp in lap.Waypoints:
                    if wp.Location and wp.Location.Latitude and wp.Location.Longitude:
                        stream_append(
                            location_stream, wp,
                            [wp.Location.Latitude, wp.Location.Longitude])
                    if wp.HR:
                        stream_append(heartrate_stream, wp, round(wp.HR))
                    if wp.Distance:
                        stream_append(distance_stream, wp, wp.Distance)
                    if wp.Cadence or wp.RunCadence:
                        stream_append(
                            cadence_stream, wp,
                            round(wp.Cadence)
                            if wp.Cadence else round(wp.RunCadence))
                    if wp.Power:
                        stream_append(power_stream, wp, wp.Power)
                    if wp.Location and wp.Location.Altitude:
                        stream_append(elevation_stream, wp,
                                      wp.Location.Altitude)
                    if wp.Type == WaypointType.Pause and not timer_stopped_at:
                        timer_stopped_at = wp.Timestamp
                    if wp.Type != WaypointType.Pause and timer_stopped_at:
                        timer_stops.append([timer_stopped_at, wp.Timestamp])
                        timer_stopped_at = None

            activityData["elevation"] = elevation_stream
            activityData["heartrate"] = heartrate_stream
            activityData["power"] = power_stream
            activityData["cadence"] = cadence_stream
            activityData["distance"] = distance_stream
            activityData["location"] = location_stream
            activityData["timer_stops"] = [[y.isoformat() for y in x]
                                           for x in timer_stops]

        headers = self._getAuthHeaders(serviceRecord)
        headers.update({"Content-Type": "application/json"})
        upload_resp = requests.post(self.OpenFitEndpoint +
                                    "/fitnessActivities.json",
                                    data=json.dumps(activityData),
                                    headers=headers)
        if upload_resp.status_code != 200:
            if upload_resp.status_code == 401:
                raise APIException("ST.mobi trial expired",
                                   block=True,
                                   user_exception=UserException(
                                       UserExceptionType.AccountExpired,
                                       intervention_required=True))
            raise APIException("Unable to upload activity %s" %
                               upload_resp.text)
        return upload_resp.json()["uris"][0]
Ejemplo n.º 5
0
class SmashrunService(ServiceBase):
    ID = "smashrun"
    DisplayName = "Smashrun"
    DisplayAbbreviation = "SR"
    AuthenticationType = ServiceAuthenticationType.OAuth
    AuthenticationNoFrame = True  # unfortunately, the smashrun dialog doesnt fit in the iframe...
    SupportedActivities = [ActivityType.Running]
    SupportsHR = SupportsCalories = SupportsCadence = SupportsTemp = True
    SupportsActivityDeletion = False

    _reverseActivityMappings = {
        ActivityType.Running: "running",
    }
    _activityMappings = {
        "running": ActivityType.Running,
    }

    _intensityMappings = {
        LapIntensity.Active: 'work',
        LapIntensity.Rest: 'recovery',
        LapIntensity.Warmup: 'warmup',
        LapIntensity.Cooldown: 'cooldown',
    }

    _tokenCache = SessionCache("smashrun", lifetime=timedelta(days=83))

    def _getClient(self, serviceRec=None):
        cached_token = None
        if serviceRec:
            cached_token = self._tokenCache.Get(serviceRec.ExternalID)
        redirect_uri = None if serviceRec else WEB_ROOT + reverse(
            'oauth_return', kwargs={'service': 'smashrun'})
        client = SmashrunClient(client_id=SMASHRUN_CLIENT_ID,
                                client_secret=SMASHRUN_CLIENT_SECRET,
                                redirect_uri=redirect_uri,
                                token=cached_token)
        if serviceRec and not cached_token:
            self._refreshToken(client, serviceRec)
        return client

    def _refreshToken(self, client, serviceRec):
        logger.info("refreshing auth token")
        token = client.refresh_token(
            refresh_token=serviceRec.Authorization['refresh_token'])
        self._cacheToken(serviceRec.ExternalID, token)

    def _cacheToken(self, uid, token):
        expiry = token[
            'expires_in'] - 24 * 60  # a 1 day buffer means we're less likely to expire mid-run
        self._tokenCache.Set(uid, token, lifetime=timedelta(seconds=expiry))

    def WebInit(self):
        self.UserAuthorizationURL = reverse("oauth_redirect",
                                            kwargs={"service": "smashrun"})

    def GenerateUserAuthorizationURL(self, session, level=None):
        client = self._getClient()
        url, state = client.get_auth_url()
        return url

    def RetrieveAuthorizationToken(self, req, level):
        code = req.GET.get("code")
        client = self._getClient()
        token = client.fetch_token(code=code)
        uid = client.get_userinfo()['id']
        self._cacheToken(uid, token)
        return (uid, token)

    def RevokeAuthorization(self, serviceRecord):
        pass  # TODO: smashrun doesn't seem to support this yet

    @handleExpiredToken
    def _getActivities(self, serviceRecord, exhaustive=False):
        client = self._getClient(serviceRec=serviceRecord)
        return list(
            client.get_activities(count=None if exhaustive else PAGE_COUNT,
                                  limit=None if exhaustive else PAGE_COUNT))

    @handleExpiredToken
    def _getActivity(self, serviceRecord, activity):
        client = self._getClient(serviceRec=serviceRecord)
        return client.get_activity(activity.ServiceData['ActivityID'])

    @handleExpiredToken
    def _createActivity(self, serviceRecord, data):
        client = self._getClient(serviceRec=serviceRecord)
        return client.create_activity(data)

    def DownloadActivityList(self, serviceRecord, exhaustive=False):
        activities = []
        exclusions = []

        for act in self._getActivities(serviceRecord, exhaustive=exhaustive):
            activity = UploadedActivity()
            activity.StartTime = dateutil.parser.parse(
                act['startDateTimeLocal'])
            activity.EndTime = activity.StartTime + timedelta(
                seconds=act['duration'])
            _type = self._activityMappings.get(act['activityType'])
            if not _type:
                exclusions.append(
                    APIExcludeActivity(
                        "Unsupported activity type %s" % act['activityType'],
                        activity_id=act["activityId"],
                        user_exception=UserException(UserExceptionType.Other)))
            activity.ServiceData = {"ActivityID": act['activityId']}
            activity.Type = _type
            activity.Notes = act['notes']
            activity.GPS = bool(act.get('startLatitude'))
            activity.Stats.Distance = ActivityStatistic(
                ActivityStatisticUnit.Kilometers, value=act['distance'])
            activity.Stats.Energy = ActivityStatistic(
                ActivityStatisticUnit.Kilocalories, value=act['calories'])
            if 'heartRateMin' in act:
                activity.Stats.HR = ActivityStatistic(
                    ActivityStatisticUnit.BeatsPerMinute,
                    min=act['heartRateMin'],
                    max=act['heartRateMax'],
                    avg=act['heartRateAverage'])
            activity.Stats.MovingTime = ActivityStatistic(
                ActivityStatisticUnit.Seconds, value=act['duration'])

            if 'temperature' in act:
                activity.Stats.Temperature = ActivityStatistic(
                    ActivityStatisticUnit.DegreesCelcius,
                    avg=act['temperature'])
            activity.CalculateUID()
            logger.debug("\tActivity s/t %s", activity.StartTime)
            activities.append(activity)

        return activities, exclusions

    # TODO: handle pauses
    def DownloadActivity(self, serviceRecord, activity):
        act = self._getActivity(serviceRecord, activity)
        recordingKeys = act.get('recordingKeys')
        if act['source'] == 'manual' or not recordingKeys:
            # it's a manually entered run, can't get much info
            activity.Stationary = True
            activity.Laps = [
                Lap(startTime=activity.StartTime,
                    endTime=activity.EndTime,
                    stats=activity.Stats)
            ]
            return activity

        # FIXME: technically it could still be stationary if there are no long/lat values...
        activity.Stationary = False

        if not act['laps']:
            # no laps, just make one big lap
            activity.Laps = [
                Lap(startTime=activity.StartTime,
                    endTime=activity.EndTime,
                    stats=activity.Stats)
            ]

        startTime = activity.StartTime
        for lapRecord in act['laps']:
            endTime = activity.StartTime + timedelta(
                seconds=lapRecord['endDuration'])
            lap = Lap(startTime=startTime, endTime=endTime)
            activity.Laps.append(lap)
            startTime = endTime + timedelta(seconds=1)

        for value in zip(*act['recordingValues']):
            record = dict(zip(recordingKeys, value))
            ts = activity.StartTime + timedelta(seconds=record['clock'])
            if 'latitude' in record:
                alt = record.get('elevation')
                lat = record['latitude']
                lon = record['longitude']
                # Smashrun seems to replace missing measurements with -1
                if lat == -1:
                    lat = None
                if lon == -1:
                    lon = None
                location = Location(lat=lat, lon=lon, alt=alt)
            hr = record.get('heartRate')
            runCadence = record.get('cadence')
            temp = record.get('temperature')
            distance = record.get('distance') * 1000
            wp = Waypoint(timestamp=ts,
                          location=location,
                          hr=hr,
                          runCadence=runCadence,
                          temp=temp,
                          distance=distance)
            # put the waypoint inside the lap it corresponds to
            for lap in activity.Laps:
                if lap.StartTime <= wp.Timestamp <= lap.EndTime:
                    lap.Waypoints.append(wp)
                    break

        return activity

    def _resolveDuration(self, obj):
        if obj.Stats.TimerTime.Value is not None:
            return obj.Stats.TimerTime.asUnits(
                ActivityStatisticUnit.Seconds).Value
        if obj.Stats.MovingTime.Value is not None:
            return obj.Stats.MovingTime.asUnits(
                ActivityStatisticUnit.Seconds).Value
        return (obj.EndTime - obj.StartTime).total_seconds()

    def UploadActivity(self, serviceRecord, activity):
        data = {}
        data['startDateTimeLocal'] = activity.StartTime.isoformat()
        data['distance'] = activity.Stats.Distance.asUnits(
            ActivityStatisticUnit.Kilometers).Value
        data['duration'] = self._resolveDuration(activity)
        data['activityType'] = self._reverseActivityMappings.get(activity.Type)

        def setIfNotNone(d, k, *vs, f=lambda x: x):
            for v in vs:
                if v is not None:
                    d[k] = f(v)
                    return

        setIfNotNone(data, 'notes', activity.Notes, activity.Name)
        setIfNotNone(data,
                     'cadenceAverage',
                     activity.Stats.RunCadence.Average,
                     f=int)
        setIfNotNone(data, 'cadenceMin', activity.Stats.RunCadence.Min, f=int)
        setIfNotNone(data, 'cadenceMax', activity.Stats.RunCadence.Max, f=int)
        setIfNotNone(data,
                     'heartRateAverage',
                     activity.Stats.HR.Average,
                     f=int)
        setIfNotNone(data, 'heartRateMin', activity.Stats.HR.Min, f=int)
        setIfNotNone(data, 'heartRateMax', activity.Stats.HR.Max, f=int)
        setIfNotNone(data, 'temperatureAverage',
                     activity.Stats.Temperature.Average)

        if not activity.Laps[0].Waypoints:
            # no info, no need to go further
            self._createActivity(serviceRecord, data)
            return

        data['laps'] = []
        recordings = defaultdict(list)

        def getattr_nested(obj, attr):
            attrs = attr.split('.')
            while attrs:
                r = getattr(obj, attrs.pop(0), None)
                obj = r
            return r

        def hasStat(activity, stat):
            for lap in activity.Laps:
                for wp in lap.Waypoints:
                    if getattr_nested(wp, stat) is not None:
                        return True
            return False

        hasDistance = hasStat(activity, 'Distance')
        hasTimestamp = hasStat(activity, 'Timestamp')
        hasLatitude = hasStat(activity, 'Location.Latitude')
        hasLongitude = hasStat(activity, 'Location.Longitude')
        hasAltitude = hasStat(activity, 'Location.Altitude')
        hasHeartRate = hasStat(activity, 'HR')
        hasCadence = hasStat(activity, 'RunCadence')
        hasTemp = hasStat(activity, 'Temp')

        for lap in activity.Laps:
            lapinfo = {
                'lapType': self._intensityMappings.get(lap.Intensity,
                                                       'general'),
                'endDuration':
                (lap.EndTime - activity.StartTime).total_seconds(),
                'endDistance': lap.Waypoints[-1].Distance / 1000
            }
            data['laps'].append(lapinfo)
            for wp in lap.Waypoints:
                if hasDistance:
                    recordings['distance'].append(wp.Distance / 1000)
                if hasTimestamp:
                    clock = (wp.Timestamp - activity.StartTime).total_seconds()
                    recordings['clock'].append(int(clock))
                if hasLatitude:
                    recordings['latitude'].append(wp.Location.Latitude)
                if hasLongitude:
                    recordings['longitude'].append(wp.Location.Longitude)
                if hasAltitude:
                    recordings['elevation'].append(wp.Location.Altitude)
                if hasHeartRate:
                    recordings['heartRate'].append(wp.HR)
                if hasCadence:
                    recordings['cadence'].append(wp.RunCadence)
                if hasTemp:
                    recordings['temperature'].append(wp.Temp)

        data['recordingKeys'] = sorted(recordings.keys())
        data['recordingValues'] = [
            recordings[k] for k in data['recordingKeys']
        ]
        assert len(set(len(v) for v in data['recordingValues'])) == 1
        self._createActivity(serviceRecord, data)

    def DeleteCachedData(self, serviceRecord):
        self._tokenCache.Delete(serviceRecord.ExternalID)

    def DeleteActivity(self, serviceRecord, uploadId):
        pass  # TODO: smashrun doesn't support this yet
Ejemplo n.º 6
0
class GarminConnectService(ServiceBase):
    ID = "garminconnect"
    DisplayName = "Garmin Connect"
    DisplayAbbreviation = "GC"
    AuthenticationType = ServiceAuthenticationType.UsernamePassword
    RequiresExtendedAuthorizationDetails = True
    PartialSyncRequiresTrigger = len(GARMIN_CONNECT_USER_WATCH_ACCOUNTS) > 0
    PartialSyncTriggerPollInterval = timedelta(minutes=20)
    PartialSyncTriggerPollMultiple = len(
        GARMIN_CONNECT_USER_WATCH_ACCOUNTS.keys())
    # +1 from default due to my embarrassing inability to...
    # a) create a reasonable schema to allow for these updates.
    # b) write a query to reset the counters in the existing schema.
    DownloadRetryCount = 6

    ConfigurationDefaults = {"WatchUserKey": None, "WatchUserLastID": 0}

    _activityMappings = {
        "running": ActivityType.Running,
        "indoor_running": ActivityType.Running,
        "cycling": ActivityType.Cycling,
        "mountain_biking": ActivityType.MountainBiking,
        "walking": ActivityType.Walking,
        "hiking": ActivityType.Hiking,
        "resort_skiing_snowboarding": ActivityType.DownhillSkiing,
        "cross_country_skiing": ActivityType.CrossCountrySkiing,
        "skate_skiing":
        ActivityType.CrossCountrySkiing,  # Well, it ain't downhill?
        "backcountry_skiing_snowboarding":
        ActivityType.CrossCountrySkiing,  # ish
        "skating": ActivityType.Skating,
        "swimming": ActivityType.Swimming,
        "rowing": ActivityType.Rowing,
        "elliptical": ActivityType.Elliptical,
        "fitness_equipment": ActivityType.Gym,
        "rock_climbing": ActivityType.Climbing,
        "mountaineering": ActivityType.Climbing,
        "strength_training": ActivityType.StrengthTraining,
        "stand_up_paddleboarding": ActivityType.StandUpPaddling,
        "all":
        ActivityType.Other,  # everything will eventually resolve to this
        "multi_sport": ActivityType.Other  # Most useless type? You decide!
    }

    _reverseActivityMappings = {  # Removes ambiguities when mapping back to their activity types
        "running": ActivityType.Running,
        "cycling": ActivityType.Cycling,
        "mountain_biking": ActivityType.MountainBiking,
        "walking": ActivityType.Walking,
        "hiking": ActivityType.Hiking,
        "resort_skiing_snowboarding": ActivityType.DownhillSkiing,
        "cross_country_skiing": ActivityType.CrossCountrySkiing,
        "skating": ActivityType.Skating,
        "swimming": ActivityType.Swimming,
        "rowing": ActivityType.Rowing,
        "elliptical": ActivityType.Elliptical,
        "fitness_equipment": ActivityType.Gym,
        "rock_climbing": ActivityType.Climbing,
        "strength_training": ActivityType.StrengthTraining,
        "stand_up_paddleboarding": ActivityType.StandUpPaddling,
        "other": ActivityType.Other  # I guess? (vs. "all" that is)
    }

    SupportedActivities = list(_activityMappings.values())

    SupportsHR = SupportsCadence = True

    SupportsActivityDeletion = True

    _sessionCache = SessionCache("garminconnect",
                                 lifetime=timedelta(minutes=120),
                                 freshen_on_get=True)
    _reauthAttempts = 1  # per request

    _unitMap = {
        "mph": ActivityStatisticUnit.MilesPerHour,
        "kph": ActivityStatisticUnit.KilometersPerHour,
        "hmph": ActivityStatisticUnit.HectometersPerHour,
        "hydph": ActivityStatisticUnit.HundredYardsPerHour,
        "celcius": ActivityStatisticUnit.DegreesCelcius,
        "fahrenheit": ActivityStatisticUnit.DegreesFahrenheit,
        "mile": ActivityStatisticUnit.Miles,
        "kilometer": ActivityStatisticUnit.Kilometers,
        "foot": ActivityStatisticUnit.Feet,
        "meter": ActivityStatisticUnit.Meters,
        "yard": ActivityStatisticUnit.Yards,
        "kilocalorie": ActivityStatisticUnit.Kilocalories,
        "bpm": ActivityStatisticUnit.BeatsPerMinute,
        "stepsPerMinute": ActivityStatisticUnit.DoubledStepsPerMinute,
        "rpm": ActivityStatisticUnit.RevolutionsPerMinute,
        "watt": ActivityStatisticUnit.Watts,
        "second": ActivityStatisticUnit.Seconds,
        "ms": ActivityStatisticUnit.Milliseconds,
        "mps": ActivityStatisticUnit.MetersPerSecond
    }

    _obligatory_headers = {"Referer": "https://sync.tapiriik.com"}

    def __init__(self):
        cachedHierarchy = cachedb.gc_type_hierarchy.find_one()
        if not cachedHierarchy:
            rawHierarchy = requests.get(
                "https://connect.garmin.com/modern/proxy/activity-service/activity/activityTypes",
                headers=self._obligatory_headers).text
            self._activityHierarchy = json.loads(rawHierarchy)
            cachedb.gc_type_hierarchy.insert({"Hierarchy": rawHierarchy})
        else:
            self._activityHierarchy = json.loads(cachedHierarchy["Hierarchy"])

        # hashmaps for determining parent type key
        self._typeKeyParentMap = {}
        self._typeIdKeyMap = {}
        for x in self._activityHierarchy:
            self._typeKeyParentMap[x["typeKey"]] = x["parentTypeId"]
            self._typeIdKeyMap[x["typeId"]] = x["typeKey"]

        rate_lock_path = tempfile.gettempdir(
        ) + "/gc_rate.%s.lock" % HTTP_SOURCE_ADDR
        # Ensure the rate lock file exists (...the easy way)
        open(rate_lock_path, "a").close()
        self._rate_lock = open(rate_lock_path, "r+")

    def _rate_limit(self):
        import fcntl, struct, time
        min_period = 1  # I appear to been banned from Garmin Connect while determining this.
        fcntl.flock(self._rate_lock, fcntl.LOCK_EX)
        try:
            self._rate_lock.seek(0)
            last_req_start = self._rate_lock.read()
            if not last_req_start:
                last_req_start = 0
            else:
                last_req_start = float(last_req_start)

            wait_time = max(0, min_period - (time.time() - last_req_start))
            time.sleep(wait_time)

            self._rate_lock.seek(0)
            self._rate_lock.write(str(time.time()))
            self._rate_lock.flush()
        finally:
            fcntl.flock(self._rate_lock, fcntl.LOCK_UN)

    def _request_with_reauth(self,
                             req_lambda,
                             serviceRecord=None,
                             email=None,
                             password=None):
        for i in range(self._reauthAttempts + 1):
            session = self._get_session(record=serviceRecord,
                                        email=email,
                                        password=password,
                                        skip_cache=i > 0)
            self._rate_limit()
            result = req_lambda(session)
            if result.status_code not in (403, 500):
                return result
        # Pass the failed response back any ways - another handler will catch it and provide a nicer error
        return result

    def _get_session(self,
                     record=None,
                     email=None,
                     password=None,
                     skip_cache=False):
        from tapiriik.auth.credential_storage import CredentialStore
        cached = self._sessionCache.Get(record.ExternalID if record else email)
        if cached and not skip_cache:
            logger.debug("Using cached credential")
            return cached
        if record:
            #  longing for C style overloads...
            password = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Password"])
            email = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Email"])

        session = requests.Session()

        # JSIG CAS, cool I guess.
        # Not quite OAuth though, so I'll continue to collect raw credentials.
        # Commented stuff left in case this ever breaks because of missing parameters...
        data = {
            "username": email,
            "password": password,
            "_eventId": "submit",
            "embed": "true",
            # "displayNameRequired": "false"
        }
        params = {
            "service": "https://connect.garmin.com/modern",
            # "redirectAfterAccountLoginUrl": "http://connect.garmin.com/modern",
            # "redirectAfterAccountCreationUrl": "http://connect.garmin.com/modern",
            # "webhost": "olaxpw-connect00.garmin.com",
            "clientId": "GarminConnect",
            "gauthHost": "https://sso.garmin.com/sso",
            # "rememberMeShown": "true",
            # "rememberMeChecked": "false",
            "consumeServiceTicket": "false",
            # "id": "gauth-widget",
            # "embedWidget": "false",
            # "cssUrl": "https://static.garmincdn.com/com.garmin.connect/ui/src-css/gauth-custom.css",
            # "source": "http://connect.garmin.com/en-US/signin",
            # "createAccountShown": "true",
            # "openCreateAccount": "false",
            # "usernameShown": "true",
            # "displayNameShown": "false",
            # "initialFocus": "true",
            # "locale": "en"
        }
        # I may never understand what motivates people to mangle a perfectly good protocol like HTTP in the ways they do...
        preResp = session.get("https://sso.garmin.com/sso/login",
                              params=params)
        if preResp.status_code != 200:
            raise APIException("SSO prestart error %s %s" %
                               (preResp.status_code, preResp.text))

        ssoResp = session.post("https://sso.garmin.com/sso/login",
                               params=params,
                               data=data,
                               allow_redirects=False)
        if ssoResp.status_code != 200 or "temporarily unavailable" in ssoResp.text:
            raise APIException("SSO error %s %s" %
                               (ssoResp.status_code, ssoResp.text))

        if ">sendEvent('FAIL')" in ssoResp.text:
            raise APIException("Invalid login",
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.Authorization,
                                   intervention_required=True))
        if ">sendEvent('ACCOUNT_LOCKED')" in ssoResp.text:
            raise APIException("Account Locked",
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.Locked,
                                   intervention_required=True))

        if "renewPassword" in ssoResp.text:
            raise APIException("Reset password",
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.RenewPassword,
                                   intervention_required=True))

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

        self._rate_limit()
        gcRedeemResp = session.get("https://connect.garmin.com/modern",
                                   allow_redirects=False)
        if gcRedeemResp.status_code != 302:
            raise APIException("GC redeem-start error %s %s" %
                               (gcRedeemResp.status_code, gcRedeemResp.text))
        url_prefix = "https://connect.garmin.com"
        # There are 6 redirects that need to be followed to get the correct cookie
        # ... :(
        max_redirect_count = 7
        current_redirect_count = 1
        while True:
            self._rate_limit()
            url = gcRedeemResp.headers["location"]
            # Fix up relative redirects.
            if url.startswith("/"):
                url = url_prefix + url
            url_prefix = "/".join(url.split("/")[:3])
            gcRedeemResp = session.get(url, allow_redirects=False)

            if current_redirect_count >= max_redirect_count and gcRedeemResp.status_code != 200:
                raise APIException(
                    "GC redeem %d/%d error %s %s" %
                    (current_redirect_count, max_redirect_count,
                     gcRedeemResp.status_code, gcRedeemResp.text))
            if gcRedeemResp.status_code == 200 or gcRedeemResp.status_code == 404:
                break
            current_redirect_count += 1
            if current_redirect_count > max_redirect_count:
                break

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

        session.headers.update(self._obligatory_headers)

        return session

    def WebInit(self):
        self.UserAuthorizationURL = WEB_ROOT + reverse(
            "auth_simple", kwargs={"service": self.ID})

    def Authorize(self, email, password):
        from tapiriik.auth.credential_storage import CredentialStore
        session = self._get_session(email=email,
                                    password=password,
                                    skip_cache=True)
        self._rate_limit()
        try:
            dashboard = session.get("http://connect.garmin.com/modern")
            userdata_json_str = re.search(
                r"VIEWER_SOCIAL_PROFILE\s*=\s*JSON\.parse\((.+)\);$",
                dashboard.text, re.MULTILINE).group(1)
            userdata = json.loads(json.loads(userdata_json_str))
            username = userdata["displayName"]
        except Exception as e:
            raise APIException("Unable to retrieve username: %s" % e,
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.Authorization,
                                   intervention_required=True))
        return (username, {}, {
            "Email": CredentialStore.Encrypt(email),
            "Password": CredentialStore.Encrypt(password)
        })

    def UserUploadedActivityURL(self, uploadId):
        return "https://connect.garmin.com/modern/activity/%d" % uploadId

    def _resolveActivityType(self, act_type):
        # Mostly there are two levels of a hierarchy, so we don't really need this as the parent is included in the listing.
        # But maybe they'll change that some day?
        while act_type not in self._activityMappings:
            try:
                act_type = self._typeIdKeyMap[self._typeKeyParentMap[act_type]]
            except IndexError:
                raise ValueError(
                    "Activity type not found in activity hierarchy")
        return self._activityMappings[act_type]

    def DownloadActivityList(self, serviceRecord, exhaustive=False):
        #https://connect.garmin.com/modern/proxy/activitylist-service/activities/search/activities?limit=20&start=0
        page = 1
        pageSz = 100
        activities = []
        exclusions = []
        while True:
            logger.debug("Req with " + str({
                "start": (page - 1) * pageSz,
                "limit": pageSz
            }))

            res = self._request_with_reauth(
                lambda session: session.get(
                    "https://connect.garmin.com/modern/proxy/activitylist-service/activities/search/activities",
                    params={
                        "start": (page - 1) * pageSz,
                        "limit": pageSz
                    }), serviceRecord)

            try:
                res = res.json()
            except ValueError:
                res_txt = res.text  # So it can capture in the log message
                raise APIException("Parse failure in GC list resp: %s - %s" %
                                   (res.status_code, res_txt))
            for act in res:
                activity = UploadedActivity()
                # stationary activities have movingDuration = None while non-gps static activities have 0.0
                activity.Stationary = act["movingDuration"] is None
                activity.GPS = act["hasPolyline"]

                activity.Private = act["privacy"]["typeKey"] == "private"

                activity_name = act["activityName"]
                logger.debug("Name " + activity_name if activity_name
                             is not None else "Untitled" + ":")
                if activity_name is not None and len(
                        activity_name.strip()
                ) and activity_name != "Untitled":  # This doesn't work for internationalized accounts, oh well.
                    activity.Name = activity_name

                activity_description = act["description"]
                if activity_description is not None and len(
                        activity_description.strip()):
                    activity.Notes = activity_description

                activity.StartTime = pytz.utc.localize(
                    datetime.strptime(act["startTimeGMT"],
                                      "%Y-%m-%d %H:%M:%S"))
                if act["elapsedDuration"] is not None:
                    activity.EndTime = activity.StartTime + timedelta(
                        0,
                        float(act["elapsedDuration"]) / 1000)
                elif act["duration"] is not None:
                    activity.EndTime = activity.StartTime + timedelta(
                        0, float(act["duration"]))
                else:
                    # somehow duration is not defined. Set 1 second then.
                    activity.EndTime = activity.StartTime + timedelta(0, 1)

                logger.debug("Activity s/t " + str(activity.StartTime) +
                             " on page " + str(page))

                if "distance" in act and act["distance"] and float(
                        act["distance"]) != 0:
                    activity.Stats.Distance = ActivityStatistic(
                        ActivityStatisticUnit.Meters,
                        value=float(act["distance"]))

                activity.Type = self._resolveActivityType(
                    act["activityType"]["typeKey"])

                activity.CalculateUID()

                activity.ServiceData = {"ActivityID": int(act["activityId"])}

                activities.append(activity)
            logger.debug("Finished page " + str(page))
            if not exhaustive or len(res) == 0:
                break
            else:
                page += 1
        return activities, exclusions

    def _downloadActivitySummary(self, serviceRecord, activity):
        activityID = activity.ServiceData["ActivityID"]

        summary_resp = self._request_with_reauth(
            lambda session: session.
            get("https://connect.garmin.com/modern/proxy/activity-service/activity/"
                + str(activityID)), serviceRecord)

        try:
            summary_data = summary_resp.json()
        except ValueError:
            raise APIException("Failure downloading activity summary %s:%s" %
                               (summary_resp.status_code, summary_resp.text))
        stat_map = {}

        def mapStat(gcKey, statKey, type, units):
            stat_map[gcKey] = {"key": statKey, "attr": type, "units": units}

        def applyStats(gc_dict, stats_obj):
            for gc_key, stat in stat_map.items():
                if gc_key in gc_dict:
                    value = float(gc_dict[gc_key])
                    if math.isinf(value):
                        continue  # GC returns the minimum speed as "-Infinity" instead of 0 some times :S
                    getattr(stats_obj, stat["key"]).update(
                        ActivityStatistic(stat["units"],
                                          **({
                                              stat["attr"]: value
                                          })))

        mapStat("movingDuration", "MovingTime", "value",
                ActivityStatisticUnit.Seconds)
        mapStat("duration", "TimerTime", "value",
                ActivityStatisticUnit.Seconds)
        mapStat("distance", "Distance", "value", ActivityStatisticUnit.Meters)
        mapStat("maxSpeed", "Speed", "max",
                ActivityStatisticUnit.MetersPerSecond)
        mapStat("averageSpeed", "Speed", "avg",
                ActivityStatisticUnit.MetersPerSecond)
        mapStat("calories", "Energy", "value",
                ActivityStatisticUnit.Kilocalories)
        mapStat("maxHR", "HR", "max", ActivityStatisticUnit.BeatsPerMinute)
        mapStat("averageHR", "HR", "avg", ActivityStatisticUnit.BeatsPerMinute)
        mapStat("minElevation", "Elevation", "min",
                ActivityStatisticUnit.Meters)
        mapStat("maxElevation", "Elevation", "max",
                ActivityStatisticUnit.Meters)
        mapStat("elevationGain", "Elevation", "gain",
                ActivityStatisticUnit.Meters)
        mapStat("elevationLoss", "Elevation", "loss",
                ActivityStatisticUnit.Meters)
        mapStat("averageBikeCadence", "Cadence", "avg",
                ActivityStatisticUnit.RevolutionsPerMinute)
        mapStat("averageCadence", "Cadence", "avg",
                ActivityStatisticUnit.StepsPerMinute)

        applyStats(summary_data["summaryDTO"], activity.Stats)

        laps_resp = self._request_with_reauth(
            lambda session: session.
            get("https://connect.garmin.com/modern/proxy/activity-service/activity/%s/splits"
                % str(activityID)), serviceRecord)
        try:
            laps_data = laps_resp.json()
        except ValueError:
            raise APIException(
                "Failure downloading activity laps summary %s:%s" %
                (laps_resp.status_code, laps_resp.text))

        for lap_data in laps_data["lapDTOs"]:
            lap = Lap()
            if "startTimeGMT" in lap_data:
                lap.StartTime = pytz.utc.localize(
                    datetime.strptime(lap_data["startTimeGMT"],
                                      "%Y-%m-%dT%H:%M:%S.0"))

            elapsed_duration = None
            if "elapsedDuration" in lap_data:
                elapsed_duration = timedelta(
                    seconds=round(float(lap_data["elapsedDuration"])))
            elif "duration" in lap_data:
                elapsed_duration = timedelta(
                    seconds=round(float(lap_data["duration"])))

            if lap.StartTime and elapsed_duration:
                # Always recalculate end time based on duration, if we have the start time
                lap.EndTime = lap.StartTime + elapsed_duration
            if not lap.StartTime and lap.EndTime and elapsed_duration:
                # Sometimes calculate start time based on duration
                lap.StartTime = lap.EndTime - elapsed_duration

            if not lap.StartTime or not lap.EndTime:
                # Garmin Connect is weird.
                raise APIExcludeActivity(
                    "Activity lap has no BeginTimestamp or EndTimestamp",
                    user_exception=UserException(UserExceptionType.Corrupt))

            applyStats(lap_data, lap.Stats)
            activity.Laps.append(lap)

        # In Garmin Land, max can be smaller than min for this field :S
        if activity.Stats.Power.Max is not None and activity.Stats.Power.Min is not None and activity.Stats.Power.Min > activity.Stats.Power.Max:
            activity.Stats.Power.Min = None

    def DownloadActivity(self, serviceRecord, activity):
        # First, download the summary stats and lap stats
        self._downloadActivitySummary(serviceRecord, activity)

        if len(activity.Laps) == 1:
            activity.Stats = activity.Laps[
                0].Stats  # They must be identical to pass the verification

        if activity.Stationary:
            # Nothing else to download
            return activity

        # https://connect.garmin.com/modern/proxy/download-service/export/tcx/activity/###
        activityID = activity.ServiceData["ActivityID"]
        res = self._request_with_reauth(
            lambda session: session.
            get("https://connect.garmin.com/modern/proxy/download-service/export/tcx/activity/{}"
                .format(activityID)), serviceRecord)
        try:
            tcx_data = res.text
            activity = TCXIO.Parse(tcx_data.encode('utf-8'), activity)
        except ValueError:
            raise APIException("Activity data parse error for %s: %s" %
                               (res.status_code, res.text))

        return activity

    def UploadActivity(self, serviceRecord, activity):
        #/proxy/upload-service-1.1/json/upload/.fit
        fit_file = FITIO.Dump(activity)
        files = {
            "data":
            ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit",
             fit_file)
        }

        res = self._request_with_reauth(
            lambda session: session.post(
                "https://connect.garmin.com/modern/proxy/upload-service/upload/.fit",
                files=files,
                headers={"nk": "NT"}), serviceRecord)
        try:
            res = res.json()["detailedImportResult"]
        except ValueError:
            raise APIException("Bad response during GC upload: %s %s" %
                               (res.status_code, res.text))

        if len(res["successes"]) == 0:
            if len(res["failures"]) and len(
                    res["failures"][0]["messages"]) and res["failures"][0][
                        "messages"][0]["content"] == "Duplicate activity":
                logger.debug("Duplicate")
                return  # ...cool?
            raise APIException("Unable to upload activity %s" % res)
        if len(res["successes"]) > 1:
            raise APIException(
                "Uploaded succeeded, resulting in too many activities")
        actid = res["successes"][0]["internalId"]

        name = activity.Name  # Capture in logs
        notes = activity.Notes

        # Update activity metadata not included in the FIT file.
        metadata_object = {}
        if activity.Name and activity.Name.strip():
            metadata_object["activityName"] = activity.Name
        if activity.Notes and activity.Notes.strip():
            metadata_object["description"] = activity.Notes
        if activity.Type not in [
                ActivityType.Running, ActivityType.Cycling, ActivityType.Other
        ]:
            # Set the legit activity type - whatever it is, it's not supported by the FIT schema
            acttype = [
                k for k, v in self._reverseActivityMappings.items()
                if v == activity.Type
            ]
            if len(acttype) == 0:
                raise APIWarning(
                    "GarminConnect does not support activity type " +
                    activity.Type)
            else:
                acttype = acttype[0]
            metadata_object["activityTypeDTO"] = {"typeKey": acttype}
        if activity.Private:
            metadata_object["accessControlRuleDTO"] = {"typeKey": "private"}

        if metadata_object:
            metadata_object["activityId"] = actid
            encoding_headers = {
                "Content-Type": "application/json; charset=UTF-8"
            }  # GC really, really needs this part, otherwise it throws obscure errors like "Invalid signature for signature method HMAC-SHA1"
            res = self._request_with_reauth(
                lambda session: session.
                put("https://connect.garmin.com/proxy/activity-service/activity/"
                    + str(actid),
                    data=json.dumps(metadata_object),
                    headers=encoding_headers), serviceRecord)
            if res.status_code != 204:
                raise APIWarning("Unable to set activity metadata - %d %s" %
                                 (res.status_code, res.text))

        return actid

    def _user_watch_user(self, serviceRecord):
        if not serviceRecord.GetConfiguration()["WatchUserKey"]:
            user_key = random.choice(
                list(GARMIN_CONNECT_USER_WATCH_ACCOUNTS.keys()))
            logger.info("Assigning %s a new watch user %s" %
                        (serviceRecord.ExternalID, user_key))
            serviceRecord.SetConfiguration({"WatchUserKey": user_key})
            return GARMIN_CONNECT_USER_WATCH_ACCOUNTS[user_key]
        else:
            return GARMIN_CONNECT_USER_WATCH_ACCOUNTS[
                serviceRecord.GetConfiguration()["WatchUserKey"]]

    def SubscribeToPartialSyncTrigger(self, serviceRecord):
        # PUT http://connect.garmin.com/proxy/userprofile-service/connection/request/cpfair
        # (the poll worker finishes the connection)
        user_name = self._user_watch_user(serviceRecord)["Name"]
        logger.info("Requesting connection to %s from %s" %
                    (user_name, serviceRecord.ExternalID))
        self._rate_limit()
        resp = self._get_session(record=serviceRecord, skip_cache=True).put(
            "https://connect.garmin.com/proxy/userprofile-service/connection/request/%s"
            % user_name)
        try:
            assert resp.status_code == 200
            assert resp.json()["requestStatus"] == "Created"
        except:
            raise APIException(
                "Connection request failed with user watch account %s: %s %s" %
                (user_name, resp.status_code, resp.text))
        else:
            serviceRecord.SetConfiguration(
                {"WatchConnectionID": resp.json()["id"]})

        serviceRecord.SetPartialSyncTriggerSubscriptionState(True)

    def UnsubscribeFromPartialSyncTrigger(self, serviceRecord):
        # GET http://connect.garmin.com/proxy/userprofile-service/socialProfile/connections to get the ID
        #  {"fullName":null,"userConnections":[{"userId":5754439,"displayName":"TapiirikAPITEST","fullName":null,"location":null,"profileImageUrlMedium":null,"profileImageUrlSmall":null,"connectionRequestId":1566024,"userConnectionStatus":2,"userRoles":["ROLE_CONNECTUSER","ROLE_FITNESS_USER"],"userPro":false}]}
        # PUT http://connect.garmin.com/proxy/userprofile-service/connection/end/1904201
        # Unfortunately there's no way to delete a pending request - the poll worker will do this from the other end
        active_watch_user = self._user_watch_user(serviceRecord)
        session = self._get_session(email=active_watch_user["Username"],
                                    password=active_watch_user["Password"],
                                    skip_cache=True)
        if "WatchConnectionID" in serviceRecord.GetConfiguration():
            self._rate_limit()
            dc_resp = session.put(
                "https://connect.garmin.com/modern/proxy/userprofile-service/connection/end/%s"
                % serviceRecord.GetConfiguration()["WatchConnectionID"])
            if dc_resp.status_code != 200:
                raise APIException(
                    "Error disconnecting user watch accunt %s from %s: %s %s" %
                    (active_watch_user, serviceRecord.ExternalID,
                     dc_resp.status_code, dc_resp.text))

            serviceRecord.SetConfiguration({
                "WatchUserKey": None,
                "WatchConnectionID": None
            })
            serviceRecord.SetPartialSyncTriggerSubscriptionState(False)
        else:
            # I broke Garmin Connect by having too many connections per account, so I can no longer query the connection list
            # All the connection request emails are sitting unopened in an email inbox, though, so I'll be backfilling the IDs from those
            raise APIException("Did not store connection ID")

    def ShouldForcePartialSyncTrigger(self, serviceRecord):
        # The poll worker can't see private activities.
        return serviceRecord.GetConfiguration()["sync_private"]

    def PollPartialSyncTrigger(self, multiple_index):
        # TODO: ensure the appropriate users are connected
        # GET http://connect.garmin.com/modern/proxy/userprofile-service/connection/pending to get ID
        #  [{"userId":6244126,"displayName":"tapiriik-sync-ulukhaktok","fullName":"tapiriik sync ulukhaktok","profileImageUrlSmall":null,"connectionRequestId":1904086,"requestViewed":true,"userRoles":["ROLE_CONNECTUSER"],"userPro":false}]
        # PUT http://connect.garmin.com/proxy/userprofile-service/connection/accept/1904086
        # ...later...
        # GET http://connect.garmin.com/proxy/activitylist-service/activities/comments/subscriptionFeed?start=1&limit=10

        # First, accept any pending connections
        watch_user_key = sorted(list(
            GARMIN_CONNECT_USER_WATCH_ACCOUNTS.keys()))[multiple_index]
        watch_user = GARMIN_CONNECT_USER_WATCH_ACCOUNTS[watch_user_key]
        logger.debug("Initiating session for watch user %s",
                     watch_user["Username"])
        sess_args = {
            "email": watch_user["Username"],
            "password": watch_user["Password"]
        }

        # These seems to fail with a 500 (talkking about a timeout) the first time, so keep trying.
        SERVER_ERROR_RETRIES = 10
        PAGE_SIZE = 100
        TOTAL_SIZE = 1000
        # Then, check for users with new activities
        watch_activities = []
        for i in range(1, TOTAL_SIZE, PAGE_SIZE):
            for x in range(SERVER_ERROR_RETRIES):
                logger.debug("Fetching activity list from %d - attempt %d", i,
                             x)
                watch_activities_resp = self._request_with_reauth(
                    lambda session: session.get(
                        "https://connect.garmin.com/modern/proxy/activitylist-service/activities/subscriptionFeed",
                        params={
                            "limit": PAGE_SIZE,
                            "start": i
                        }), **sess_args)
                if watch_activities_resp.status_code != 500:
                    break
            try:
                watch_activities += watch_activities_resp.json(
                )["activityList"]
            except ValueError:
                raise Exception("Could not parse new activities list: %s %s" %
                                (watch_activities_resp.status_code,
                                 watch_activities_resp.text))

        active_user_pairs = [(x["ownerDisplayName"], x["activityId"])
                             for x in watch_activities]
        active_user_pairs.sort(
            key=lambda x: x[1]
        )  # Highest IDs last (so they make it into the dict, supplanting lower IDs where appropriate)
        active_users = dict(active_user_pairs)

        active_user_recs = [
            ServiceRecord(x) for x in db.connections.find(
                {
                    "ExternalID": {
                        "$in": list(active_users.keys())
                    },
                    "Service": "garminconnect"
                }, {
                    "Config": 1,
                    "ExternalID": 1,
                    "Service": 1
                })
        ]

        if len(active_user_recs) != len(active_users.keys()):
            logger.warning("Mismatch %d records found for %d active users" %
                           (len(active_user_recs), len(active_users.keys())))

        to_sync_ids = []
        for active_user_rec in active_user_recs:
            last_active_id = active_user_rec.GetConfiguration(
            )["WatchUserLastID"]
            this_active_id = active_users[active_user_rec.ExternalID]
            if this_active_id > last_active_id:
                to_sync_ids.append(active_user_rec.ExternalID)
                active_user_rec.SetConfiguration({
                    "WatchUserLastID": this_active_id,
                    "WatchUserKey": watch_user_key
                })

        for x in range(SERVER_ERROR_RETRIES):
            self._rate_limit()
            logger.debug("Fetching connection request list - attempt %d", x)
            pending_connections_resp = self._request_with_reauth(
                lambda session: session.
                get("https://connect.garmin.com/modern/proxy/userprofile-service/connection/pending"
                    ), **sess_args)
            if pending_connections_resp.status_code != 500:
                break
        try:
            pending_connections = pending_connections_resp.json()
        except ValueError:
            logger.error("Could not parse pending connection requests: %s %s" %
                         (pending_connections_resp.status_code,
                          pending_connections_resp.text))
        else:
            valid_pending_connections_external_ids = [
                x["ExternalID"] for x in db.connections.find(
                    {
                        "Service": "garminconnect",
                        "ExternalID": {
                            "$in":
                            [x["displayName"] for x in pending_connections]
                        }
                    }, {"ExternalID": 1})
            ]
            logger.info(
                "Accepting %d, denying %d connection requests for %s" %
                (len(valid_pending_connections_external_ids),
                 len(pending_connections) -
                 len(valid_pending_connections_external_ids), watch_user_key))
            for pending_connect in pending_connections:
                if pending_connect[
                        "displayName"] in valid_pending_connections_external_ids:
                    self._rate_limit()
                    connect_resp = self._request_with_reauth(
                        lambda session: session.
                        put("https://connect.garmin.com/modern/proxy/userprofile-service/connection/accept/%s"
                            % pending_connect["connectionRequestId"]),
                        **sess_args)
                    if connect_resp.status_code != 200:
                        logger.error(
                            "Error accepting request on watch account %s: %s %s"
                            % (watch_user["Name"], connect_resp.status_code,
                               connect_resp.text))
                else:
                    self._rate_limit()
                    self._request_with_reauth(
                        lambda session: session.
                        put("https://connect.garmin.com/modern/proxy/userprofile-service/connection/decline/%s"
                            % pending_connect["connectionRequestId"]),
                        **sess_args)

        return to_sync_ids

    def RevokeAuthorization(self, serviceRecord):
        # nothing to do here...
        pass

    def DeleteCachedData(self, serviceRecord):
        # nothing cached...
        pass

    def DeleteActivity(self, serviceRecord, uploadId):
        session = self._get_session(record=serviceRecord)
        self._rate_limit()
        del_res = session.delete(
            "https://connect.garmin.com/modern/proxy/activity-service/activity/%d"
            % uploadId)
        del_res.raise_for_status()
Ejemplo n.º 7
0
class MotivatoService(ServiceBase):
    ID = "motivato"
    DisplayName = "Motivato"
    DisplayAbbreviation = "MOT"
    AuthenticationType = ServiceAuthenticationType.UsernamePassword
    RequiresExtendedAuthorizationDetails = True

    _activityMappings = {
        ActivityType.Running: 1,
        ActivityType.Cycling: 2,
        ActivityType.MountainBiking: 2,
        ActivityType.Walking: 7,
        ActivityType.Hiking: 7,
        ActivityType.DownhillSkiing: 5,
        ActivityType.CrossCountrySkiing: 5,
        ActivityType.Snowboarding: 5,
        ActivityType.Skating: 5,
        ActivityType.Swimming: 3,
        ActivityType.Wheelchair: 5,
        ActivityType.Rowing: 5,
        ActivityType.Elliptical: 5,
        ActivityType.Gym: 4,
        ActivityType.Climbing: 5,
        ActivityType.Other: 5,
    }

    _reverseActivityMappings = {
        1: ActivityType.Running,
        2: ActivityType.Cycling,
        3: ActivityType.Swimming,
        4: ActivityType.Gym,
        5: ActivityType.Other,
        6: ActivityType.Other,
        7: ActivityType.Walking
    }

    SupportedActivities = list(_reverseActivityMappings.values())

    _sessionCache = SessionCache("motivato",
                                 lifetime=timedelta(minutes=30),
                                 freshen_on_get=True)
    _obligatory_headers = {"Referer": "https://www.siiink.com"}

    _urlRoot = "http://motivato.pl"

    def __init__(self):
        rate_lock_path = tempfile.gettempdir(
        ) + "/m_rate.%s.lock" % HTTP_SOURCE_ADDR
        # Ensure the rate lock file exists (...the easy way)
        open(rate_lock_path, "a").close()
        self._rate_lock = open(rate_lock_path, "r+")

    def WebInit(self):
        self.UserAuthorizationURL = WEB_ROOT + reverse(
            "auth_simple", kwargs={"service": self.ID})

    def _getPaymentState(self, serviceRecord):
        # This method is also used by MotivatoExternalPaymentProvider to fetch user state
        session = self._get_session(record=serviceRecord)
        self._rate_limit()
        return session.get(self._urlRoot +
                           "/api/tapiriikProfile").json()["isPremium"]

    def _applyPaymentState(self, serviceRecord):
        from tapiriik.auth import User
        state = self._getPaymentState(serviceRecord)
        ExternalPaymentProvider.FromID("motivato").ApplyPaymentState(
            User.GetByConnection(serviceRecord),
            state,
            serviceRecord.ExternalID,
            duration=None)

    def Authorize(self, email, password):
        from tapiriik.auth.credential_storage import CredentialStore
        session = self._get_session(email=email, password=password)
        self._rate_limit()
        id = session.get(self._urlRoot + "/api/tapiriikProfile").json()["id"]
        if not len(id):
            raise APIException("Unable to retrieve username",
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.Authorization,
                                   intervention_required=True))
        return (id, {}, {
            "Email": CredentialStore.Encrypt(email),
            "Password": CredentialStore.Encrypt(password)
        })

    def UploadActivity(self, serviceRecord, activity):
        logger.debug("Motivato UploadActivity")
        session = self._get_session(record=serviceRecord)

        dic = dict(training_at=activity.StartTime.strftime("%Y-%m-%d"),
                   distance=activity.Stats.Distance.asUnits(
                       ActivityStatisticUnit.Kilometers).Value,
                   duration="",
                   user_comment=activity.Notes,
                   updated_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                   created_at=activity.StartTime.strftime("%Y-%m-%d %H:%M:%S"),
                   discipline_id=self._activityMappings[activity.Type],
                   source_id=8,
                   metas=dict(
                       distance=activity.Stats.Distance.asUnits(
                           ActivityStatisticUnit.Kilometers).Value,
                       duration="",
                       time_start=activity.StartTime.strftime("%H:%M:%S")),
                   track={})

        if activity.Stats.TimerTime.Value is not None:
            secs = activity.Stats.TimerTime.asUnits(
                ActivityStatisticUnit.Seconds).Value
        elif activity.Stats.MovingTime.Value is not None:
            secs = activity.Stats.MovingTime.asUnits(
                ActivityStatisticUnit.Seconds).Value
        else:
            secs = (activity.EndTime - activity.StartTime).total_seconds()

        dic["metas"]["duration"] = str(timedelta(seconds=secs))
        dic["duration"] = str(timedelta(seconds=secs))

        pace = str(timedelta(seconds=secs / activity.Stats.Distance.Value))
        meta_hr_avg = activity.Stats.HR.Average
        meta_hr_max = activity.Stats.HR.Max

        if pace:
            dic["metas"]["pace"] = pace

        if meta_hr_avg:
            dic["metas"]["meta_hr_avg"] = meta_hr_avg

        if meta_hr_max:
            dic["metas"]["meta_hr_max"] = meta_hr_max

        if len(activity.Laps) > 0:
            dic["track"] = dict(name=activity.Name, mtime=secs, points=[])

            for tk in activity.Laps:
                for wpt in tk.Waypoints:
                    pt = dict(
                        lat=wpt.Location.Latitude,
                        lon=wpt.Location.Longitude,
                        ele=wpt.Location.Altitude,
                        bpm=wpt.HR,
                        moment=wpt.Timestamp.strftime('%Y-%m-%d %H:%M:%S'))

                    if wpt.Speed and wpt.Speed != None and wpt.Speed != 0:
                        pt["pace"] = (1000.0 / wpt.Speed)

                    dic["track"]["points"].append(pt)

        toSend = json.dumps(dic)

        try:
            res = session.post(self._urlRoot + "/api/workout", data=toSend)
        except APIWarning as e:
            raise APIException(str(e))

        if res.status_code != 201:
            raise APIException("Activity didn't upload: %s, %s" %
                               (res.status_code, res.text))

        try:
            retJson = res.json()
        except ValueError:
            raise APIException("Activity upload parse error for %s, %s" %
                               (res.status_code, res.text))

        return retJson["id"]

    def _parseDate(self, date):
        return datetime.strptime(date, "%Y-%m-%d")

    def _parseDateTime(self, date):
        try:
            return datetime.strptime(date, "%Y-%m-%d %H:%M:%S")
        except ValueError:
            return datetime.strptime(date, "%Y-%m-%d %H:%M")

    def _durationToSeconds(self, dur):
        # in order to fight broken metas
        parts = dur.split(":")
        return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])

    def DownloadActivityList(self, serviceRecord, exhaustive=False):
        logger.debug("Checking motivato premium state")
        self._applyPaymentState(serviceRecord)

        logger.debug("Motivato DownloadActivityList")
        session = self._get_session(record=serviceRecord)
        activities = []
        exclusions = []

        self._rate_limit()

        retried_auth = False
        #headers = {'X-App-With-Tracks': "true"}
        headers = {}
        res = session.post(self._urlRoot + "/api/workouts/sync",
                           headers=headers)

        if res.status_code == 403 and not retried_auth:
            retried_auth = True
            session = self._get_session(serviceRecord, skip_cache=True)

        try:
            respList = res.json()
        except ValueError:
            res_txt = res.text  # So it can capture in the log message
            raise APIException("Parse failure in Motivato list resp: %s" %
                               res.status_code)

        for actInfo in respList:
            if "duration" in actInfo:
                duration = self._durationToSeconds(actInfo["duration"])
            else:
                continue

            activity = UploadedActivity()
            if "time_start" in actInfo["metas"]:
                startTimeStr = actInfo["training_at"] + " " + actInfo["metas"][
                    "time_start"]
            else:
                startTimeStr = actInfo["training_at"] + " 00:00:00"

            activity.StartTime = self._parseDateTime(startTimeStr)
            activity.EndTime = self._parseDateTime(startTimeStr) + timedelta(
                seconds=duration)
            activity.Type = self._reverseActivityMappings[
                actInfo["discipline_id"]]
            activity.Stats.TimerTime = ActivityStatistic(
                ActivityStatisticUnit.Seconds, value=duration)
            if "distance" in actInfo:
                activity.Stats.Distance = ActivityStatistic(
                    ActivityStatisticUnit.Kilometers,
                    value=float(actInfo["distance"]))
            #activity.Stats.Speed = ActivityStatistic(ActivityStatisticUnit.KilometersPerSecond, value=1.0/float(actInfo["metas"]["pace"]))

            activity.ServiceData = {"WorkoutID": int(actInfo["id"])}

            activity.CalculateUID()
            logger.debug("Generated UID %s" % activity.UID)
            activities.append(activity)

        return activities, exclusions

    def DownloadActivity(self, serviceRecord, activity):
        workoutID = activity.ServiceData["WorkoutID"]
        logger.debug("DownloadActivity for %s" % workoutID)

        session = self._get_session(record=serviceRecord)

        resp = session.get(self._urlRoot + "/api/workout/%d" % workoutID)

        try:
            res = resp.json()
        except ValueError:
            raise APIException(
                "Parse failure in Motivato activity (%d) download: %s" %
                (workoutID, res.text))

        lap = Lap(stats=activity.Stats,
                  startTime=activity.StartTime,
                  endTime=activity.EndTime)
        activity.Laps = [lap]
        activity.GPS = False
        if "track" in res and "points" in res["track"]:
            for pt in res["track"]["points"]:
                wp = Waypoint()
                if "moment" not in pt:
                    continue
                wp.Timestamp = self._parseDateTime(pt["moment"])

                if ("lat" in pt and "lon" in pt) or "ele" in pt:
                    wp.Location = Location()
                    if "lat" in pt and "lon" in pt:
                        wp.Location.Latitude = pt["lat"]
                        wp.Location.Longitude = pt["lon"]
                        activity.GPS = True
                    if "ele" in pt:
                        wp.Location.Altitude = float(pt["ele"])

                if "bpm" in pt:
                    wp.HR = pt["bpm"]

                lap.Waypoints.append(wp)

        activity.Stationary = len(lap.Waypoints) == 0

        return activity

    def _get_session(self,
                     record=None,
                     email=None,
                     password=None,
                     skip_cache=False):
        from tapiriik.auth.credential_storage import CredentialStore
        cached = self._sessionCache.Get(record.ExternalID if record else email)
        if cached and not skip_cache:
            return cached
        if record:
            #  longing for C style overloads...
            password = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Password"])
            email = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Email"])

        session = requests.Session()
        self._rate_limit()
        mPreResp = session.get(self._urlRoot + "/api/tapiriikProfile",
                               allow_redirects=False)
        # New site gets this redirect, old one does not
        if mPreResp.status_code == 403:
            data = {
                "_username": email,
                "_password": password,
                "_remember_me": "true",
            }
            preResp = session.post(self._urlRoot + "/api/login", data=data)

            if preResp.status_code != 200:
                raise APIException("Login error %s %s" %
                                   (preResp.status_code, preResp.text))

            try:
                preResp = preResp.json()
            except ValueError:
                raise APIException("Parse error %s %s" %
                                   (preResp.status_code, preResp.text),
                                   block=True,
                                   user_exception=UserException(
                                       UserExceptionType.Authorization,
                                       intervention_required=True))

            if "success" not in preResp and "error" not in preResp:
                raise APIException("Login error",
                                   block=True,
                                   user_exception=UserException(
                                       UserExceptionType.Authorization,
                                       intervention_required=True))

            success = True
            error = ""

            if "success" in preResp:
                success = ["success"]

            if "error" in preResp:
                error = preResp["error"]

            if not success:
                logger.debug("Login error %s" % (error))
                raise APIException("Invalid login",
                                   block=True,
                                   user_exception=UserException(
                                       UserExceptionType.Authorization,
                                       intervention_required=True))

            # Double check

            self._rate_limit()
            mRedeemResp1 = session.get(self._urlRoot + "/api/tapiriikProfile",
                                       allow_redirects=False)
            if mRedeemResp1.status_code != 200:
                raise APIException(
                    "Motivato redeem error %s %s" %
                    (mRedeemResp1.status_code, mRedeemResp1.text))

        else:
            logger.debug("code %s" % mPreResp.status_code)
            raise APIException("Unknown Motivato prestart response %s %s" %
                               (mPreResp.status_code, mPreResp.text))

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

        session.headers.update(self._obligatory_headers)

        return session

    def _rate_limit(self):
        import fcntl, time
        min_period = 1
        print("Waiting for lock")
        fcntl.flock(self._rate_lock, fcntl.LOCK_EX)
        try:
            print("Have lock")
            self._rate_lock.seek(0)
            last_req_start = self._rate_lock.read()
            if not last_req_start:
                last_req_start = 0
            else:
                last_req_start = float(last_req_start)

            wait_time = max(0, min_period - (time.time() - last_req_start))
            time.sleep(wait_time)

            self._rate_lock.seek(0)
            self._rate_lock.write(str(time.time()))
            self._rate_lock.flush()

            print("Rate limited for %f" % wait_time)
        finally:
            fcntl.flock(self._rate_lock, fcntl.LOCK_UN)

    def DeleteCachedData(self, serviceRecord):
        # nothing cached...
        pass

    def RevokeAuthorization(self, serviceRecord):
        # nothing to do here...
        pass
Ejemplo n.º 8
0
class AerobiaService(ServiceBase):
    ID = "aerobia"
    DisplayName = "Aerobia"
    DisplayAbbreviation = "ARB"
    AuthenticationType = ServiceAuthenticationType.UsernamePassword
    RequiresExtendedAuthorizationDetails = True
    UserProfileURL = "http://www.aerobia.ru/users/{0}"
    UserActivityURL = "http://www.aerobia.ru/users/{0}/workouts/{1}"

    Configurable = True
    ConfigurationDefaults = {}

    # common -> aerobia (garmin tcx sport names)
    # todo may better to include this into tcxio logic instead
    _activityMappings = {
        ActivityType.Running: "Running",
        ActivityType.Cycling: "Biking",
        ActivityType.MountainBiking: "Mountain biking",
        ActivityType.Walking: "Walking",
        ActivityType.Hiking: "Hiking",
        ActivityType.DownhillSkiing: "Skiing downhill",
        ActivityType.CrossCountrySkiing: "Cross country skiing",
        ActivityType.Snowboarding: "Snowboard",
        ActivityType.Skating: "Skating",
        ActivityType.Swimming: "Swimming",
        #ActivityType.Wheelchair : "Wheelchair",
        ActivityType.Rowing: "Rowing",
        ActivityType.Elliptical: "Ellips",
        ActivityType.Gym: "Gym",
        ActivityType.Climbing: "Rock climbing",
        ActivityType.RollerSkiing: "Roller skiing",
        ActivityType.StrengthTraining: "Ofp",
        ActivityType.Other: "Sport"
    }

    # aerobia -> common
    _reverseActivityMappings = {
        1: ActivityType.Cycling,
        2: ActivityType.Running,
        56: ActivityType.MountainBiking,
        19: ActivityType.Walking,
        43: ActivityType.Hiking,
        9: ActivityType.DownhillSkiing,
        3: ActivityType.CrossCountrySkiing,
        46: ActivityType.Skating,
        21: ActivityType.Swimming,
        13: ActivityType.Rowing,
        74: ActivityType.Elliptical,
        54: ActivityType.Gym,
        63: ActivityType.Climbing,
        72: ActivityType.StrengthTraining,
        6: ActivityType.Cycling,  #cycling transport
        22: ActivityType.Cycling,  #indoor cycling
        73: ActivityType.Gym,  #stretching
        76: ActivityType.Gym,  #trx
        83: ActivityType.CrossCountrySkiing,  #classic skiing
        65: ActivityType.Other,  #triathlon
        51: ActivityType.Other,  #beach volleyball
        53: ActivityType.Other,  #basketball
        55: ActivityType.Other,  #roller sport
        77: ActivityType.Running,  #tredmill
        66: ActivityType.Other,  #roller skiing
        7: ActivityType.Other,  #rollers
        58: ActivityType.Other,  #nordic walking
        10: ActivityType.Other,  #snowboarding
        16: ActivityType.Other,  #walking sport
        18: ActivityType.Other,  #orienting
        38: ActivityType.Other,  #OTHER
        61: ActivityType.Other,  #WATER AEROBICS
        79: ActivityType.Other,  #ACROBATICS
        23: ActivityType.Other,  #AEROBICS
        26: ActivityType.Other,  #BOX
        84: ActivityType.Other,  #CYCLOCROSS
        24: ActivityType.Other,  #BADMINTON
        52: ActivityType.Other,  #VOLLEYBALL
        50: ActivityType.Other,  #MARTIAL ARTS
        49: ActivityType.Other,  #HANDBALL
        48: ActivityType.Other,  #GYMNASTICS
        4: ActivityType.Other,  #GOLF
        36: ActivityType.Other,  #SCUBA DIVING
        85: ActivityType.Other,  #DUATHLON
        69: ActivityType.Other,  #DELTAPLAN
        47: ActivityType.Other,  #YOGA
        45: ActivityType.Other,  #KITEBOARDING
        80: ActivityType.Other,  #KERLING
        62: ActivityType.Other,  #HORSE RIDING
        71: ActivityType.Other,  #СROSSFIT
        64: ActivityType.Other,  #CIRCLE WORKOUT
        78: ActivityType.Other,  #MOTORSPORT
        44: ActivityType.Other,  #ММА
        70: ActivityType.Other,  #PARAPLANE
        35: ActivityType.Other,  #PILATES
        20: ActivityType.Other,  #POLO
        33: ActivityType.Other,  #RUGBY
        60: ActivityType.Other,  #FISHING
        67: ActivityType.Other,  #SCOOTER
        15: ActivityType.Other,  #WINDSURFING
        42: ActivityType.Other,  #SQUASH
        41: ActivityType.Other,  #SKATEBOARD
        75: ActivityType.Other,  #STEPPER
        29: ActivityType.Other,  #DANCING
        40: ActivityType.Other,  #TENNIS
        37: ActivityType.Other,  #TABLE TENNIS
        81: ActivityType.Other,  #OUTDOOR FITNESS
        31: ActivityType.Other,  #FOOTBALL
        59: ActivityType.Other,  #FENCING
        39: ActivityType.Other,  #FIGURE SKATING
        34: ActivityType.Other,  #HOCKEY
        82: ActivityType.Other,  #CHESS
        68: ActivityType.Other
    }

    SupportedActivities = list(_activityMappings.keys())

    SupportsHR = SupportsCadence = True

    SupportsActivityDeletion = True

    _sessionCache = SessionCache("aerobia",
                                 lifetime=timedelta(minutes=120),
                                 freshen_on_get=True)
    _obligatory_headers = {
        # Without user-agent patch aerobia requests doesn't work
        "User-Agent":
        "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko"
    }

    _urlRoot = "http://aerobia.ru/"
    _apiRoot = "http://aerobia.ru/api/"
    _loginUrlRoot = _apiRoot + "sign_in"
    _workoutsUrl = _apiRoot + "workouts"
    _workoutUrlJson = _apiRoot + "workouts/{id}.json"
    _workoutUrl = _urlRoot + "workouts/{id}"
    _uploadsUrl = _apiRoot + "uploads.json"

    def _get_session(self, record=None, username=None):
        cached = self._sessionCache.Get(
            record.ExternalID if record else username)
        if cached:
            return cached

        session = requests.Session()
        session.headers.update(self._obligatory_headers)

        return session

    def _get_auth_data(self, record=None, username=None, password=None):
        from tapiriik.auth.credential_storage import CredentialStore

        if record:
            #  longing for C style overloads...
            password = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Password"])
            username = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Email"])

        session = self._get_session(record, username)
        request_parameters = {
            "user[email]": username,
            "user[password]": password
        }
        res = session.post(self._loginUrlRoot, data=request_parameters)

        if res.status_code != 200:
            raise APIException(
                "Login exception {} - {}".format(res.status_code, res.text),
                user_exception=UserException(UserExceptionType.Authorization))

        res_xml = etree.fromstring(res.text.encode('utf-8'))

        info = res_xml.find("info")
        if info.get("status") != "ok":
            raise APIException(info.get("description"),
                               user_exception=UserException(
                                   UserExceptionType.Authorization))

        user_id = int(res_xml.find("user/id").get("value"))
        user_token = res_xml.find("user/authentication_token").get("value")

        return user_id, user_token

    def _call(self, serviceRecord, request_call, *args):
        retry_count = 3
        resp = None
        ex = Exception()
        for i in range(0, retry_count):
            try:
                resp = request_call(args)
                break
            except APIException as ex:
                # try to refresh token first
                self._refresh_token(serviceRecord)
            except requests.exceptions.ConnectTimeout as ex:
                # Aerobia sometimes answer like
                # Failed to establish a new connection: [WinError 10060] may happen while listing.
                # wait a bit and retry
                time.sleep(.2)
        if resp is None:
            raise ex
        return resp

    def _refresh_token(self, record):
        logger.info("refreshing auth token")
        user_id, user_token = self._get_auth_data(record=record)
        auth_datails = {"OAuthToken": user_token}
        record.Authorization.update(auth_datails)
        db.connections.update({"_id": record._id},
                              {"$set": {
                                  "Authorization": auth_datails
                              }})

    def _with_auth(self, record, params={}):
        params.update(
            {"authentication_token": record.Authorization["OAuthToken"]})
        return params

    def Authorize(self, username, password):
        from tapiriik.auth.credential_storage import CredentialStore
        user_id, user_token = self._get_auth_data(username=username,
                                                  password=password)

        secret = {
            "Email": CredentialStore.Encrypt(username),
            "Password": CredentialStore.Encrypt(password)
        }
        authorizationData = {"OAuthToken": user_token}

        return (user_id, authorizationData, secret)

    def DownloadActivityList(self, serviceRecord, exhaustive=False):
        activities = []
        exclusions = []

        fetch_diary = lambda page=1: self._get_diary_xml(serviceRecord, page)

        total_pages = None
        page = 1
        while True:
            diary_xml = self._call(serviceRecord, fetch_diary, page)

            for workout_info in diary_xml.findall("workouts/r"):
                activity = self._create_activity(workout_info)
                activities.append(activity)

            if total_pages is None:
                pagination = diary_xml.find("pagination")
                # New accounts have no data pages initially
                total_pages_str = pagination.get(
                    "total_pages") if pagination else None
                total_pages = int(total_pages_str) if total_pages_str else 1
            page += 1

            if not exhaustive or page > total_pages:
                break

        return activities, exclusions

    def _get_diary_xml(self, serviceRecord, page=1):
        session = self._get_session(serviceRecord)
        diary_data = session.get(self._workoutsUrl,
                                 params=self._with_auth(
                                     serviceRecord, {"page": page}))
        diary_xml = etree.fromstring(diary_data.text.encode('utf-8'))

        info = diary_xml.find("info")
        if info.get("status") != "ok":
            raise APIException(info.get("description"),
                               user_exception=UserException(
                                   UserExceptionType.DownloadError))

        return diary_xml

    def _create_activity(self, data):
        activity = UploadedActivity()
        activity.Name = data.get("name")
        activity.StartTime = pytz.utc.localize(
            datetime.strptime(data.get("start_at"), "%Y-%m-%dT%H:%M:%SZ"))
        activity.EndTime = activity.StartTime + timedelta(
            0, float(data.get("duration")))
        sport_id = data.get("sport_id")
        activity.Type = self._reverseActivityMappings.get(
            int(sport_id),
            ActivityType.Other) if sport_id else ActivityType.Other

        distance = data.get("distance")
        activity.Stats.Distance = ActivityStatistic(
            ActivityStatisticUnit.Kilometers,
            value=float(distance) if distance else None)
        activity.Stats.MovingTime = ActivityStatistic(
            ActivityStatisticUnit.Seconds,
            value=float(data.get("total_time_in_seconds")))
        avg_speed = data.get("average_speed")
        max_speed = data.get("max_speed")
        activity.Stats.Speed = ActivityStatistic(
            ActivityStatisticUnit.KilometersPerHour,
            avg=float(avg_speed) if avg_speed else None,
            max=float(max_speed) if max_speed else None)
        avg_hr = data.get("average_heart_rate")
        max_hr = data.get("maximum_heart_rate")
        activity.Stats.HR.update(
            ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute,
                              avg=float(avg_hr) if avg_hr else None,
                              max=float(max_hr) if max_hr else None))
        calories = data.get("calories")
        activity.Stats.Energy = ActivityStatistic(
            ActivityStatisticUnit.Kilocalories,
            value=int(calories) if calories else None)

        activity.ServiceData = {"ActivityID": data.get("id")}

        logger.debug("\tActivity s/t {}: {}".format(activity.StartTime,
                                                    activity.Type))
        activity.CalculateUID()
        return activity

    def DownloadActivity(self, serviceRecord, activity):
        session = self._get_session(serviceRecord)
        activity_id = activity.ServiceData["ActivityID"]

        tcx_data = session.get("{}export/workouts/{}/tcx".format(
            self._urlRoot, activity_id),
                               data=self._with_auth(serviceRecord))
        activity_ex = TCXIO.Parse(tcx_data.text.encode('utf-8'), activity)
        # Obtain more information about activity
        res = session.get(self._workoutUrlJson.format(id=activity_id),
                          data=self._with_auth(serviceRecord))
        activity_data = res.json()
        activity_ex.Name = activity_data["name"]
        # Notes comes as html. Hardly any other service will support this so needs to extract text data
        if "body" in activity_data["post"]:
            post_html = activity_data["post"]["body"]
            soup = BeautifulSoup(post_html)
            # Notes also contains styles, get rid of them
            for style in soup("style"):
                style.decompose()
            activity_ex.Notes = soup.getText()

        # Dirty hack to patch users inventory even if they use aerobia mobile app to record activities
        # Still need to sync with some service though.
        extra_data = {}
        self._put_default_inventory(activity, serviceRecord, extra_data)
        if extra_data:
            self._patch_activity(serviceRecord, extra_data, activity_id)

        return activity_ex

    def UploadActivity(self, serviceRecord, activity):
        session = self._get_session(serviceRecord)
        tcx_data = None
        # If some service provides ready-to-use tcx data why not to use it?
        if activity.SourceFile:
            tcx_data = activity.SourceFile.getContent(ActivityFileType.TCX)
            # Set aerobia-understandable sport name
            tcx_data = re.sub(
                r'(<Sport=\")\w+(\">)', r'\1{}\2'.format(
                    self._activityMappings[activity.Type]),
                tcx_data) if tcx_data else None
        if not tcx_data:
            tcx_data = TCXIO.Dump(activity,
                                  self._activityMappings[activity.Type])

        data = {"name": activity.Name, "description": activity.Notes}
        files = {
            "file": ("tap-sync-{}-{}.tcx".format(os.getpid(),
                                                 activity.UID), tcx_data)
        }
        res = session.post(self._uploadsUrl,
                           data=self._with_auth(serviceRecord, data),
                           files=files)
        res_obj = res.json()
        uploaded_id = res_obj["workouts"][0]["id"]

        if "error" in res_obj:
            raise APIException(res_obj["error"],
                               user_exception=UserException(
                                   UserExceptionType.UploadError))

        extra_data = {}
        if activity.Name is not None:
            extra_data.update({"workout[name]": activity.Name})

        self._put_default_inventory(activity, serviceRecord, extra_data)

        # Post extra data to newly uploaded activity
        if extra_data:
            self._patch_activity(serviceRecord, extra_data, uploaded_id)

        # return just uploaded activity id
        return uploaded_id

    def _put_default_inventory(self, activity, serviceRecord, data):
        rules = serviceRecord.Config[
            "gearRules"] if "gearRules" in serviceRecord.Config else None
        if rules is None:
            return
        inventory = []
        for rule in rules:
            if "sport" in rule and "gear" in rule:
                if activity.Type == rule["sport"]:
                    inventory += rule["gear"]
        if len(inventory):
            data.update({"workout[inventory_ids][]": inventory})

    def _patch_activity(self, serviceRecord, data, activity_id):
        session = self._get_session(serviceRecord)

        data.update({"_method": "put"})
        update_activity = lambda x: session.post(
            self._workoutUrl.format(id=activity_id),
            data=self._with_auth(serviceRecord, data))
        try:
            self._call(serviceRecord, update_activity)
        except Exception as e:
            # do nothing but logging - anything critical happened to interrupt process
            logger.debug("Unable to patch activity: " + e)

    def UserUploadedActivityURL(self, uploadId):
        raise NotImplementedError
        # TODO need to include user id
        #return self.UserActivityURL.format(userId, uploadId)

    def DeleteActivity(self, serviceRecord, uploadId):
        session = self._get_session(serviceRecord)
        delete_parameters = {"_method": "delete"}
        delete_call = lambda x: session.post(
            "{}workouts/{}".format(self._urlRoot, uploadId),
            data=self._with_auth(serviceRecord, delete_parameters))
        self._call(serviceRecord, delete_call)

    def DeleteCachedData(self, serviceRecord):
        pass  # No cached data...

    def RevokeAuthorization(self, serviceRecord):
        # nothing to do here...
        pass
Ejemplo n.º 9
0
class RideWithGPSService(ServiceBase):
    ID = "rwgps"
    DisplayName = "Ride With GPS"
    DisplayAbbreviation = "RWG"
    AuthenticationType = ServiceAuthenticationType.UsernamePassword
    RequiresExtendedAuthorizationDetails = True

    # RWGPS does has a "recreation_types" list, but it is not actually used anywhere (yet)
    # (This is a subset of the things returned by that list for future reference...)
    _activityMappings = {
                                "running": ActivityType.Running,
                                "cycling": ActivityType.Cycling,
                                "mountain biking": ActivityType.MountainBiking,
                                "Hiking": ActivityType.Hiking,
                                "all": ActivityType.Other  # everything will eventually resolve to this
    }

    SupportedActivities = [ActivityType.Cycling, ActivityType.MountainBiking]

    SupportsHR = SupportsCadence = True

    _sessionCache = SessionCache("rwgps", lifetime=timedelta(minutes=30), freshen_on_get=True)

    def _add_auth_params(self, params=None, record=None):
        """
        Adds apikey and authorization (email/password) to the passed-in params,
        returns modified params dict.
        """
        from tapiriik.auth.credential_storage import CredentialStore
        if params is None:
            params = {}
        params['apikey'] = RWGPS_APIKEY
        if record:
            cached = self._sessionCache.Get(record.ExternalID)
            if cached:
                return cached
            password = CredentialStore.Decrypt(record.ExtendedAuthorization["Password"])
            email = CredentialStore.Decrypt(record.ExtendedAuthorization["Email"])
            params['email'] = email
            params['password'] = password
        return params

    def WebInit(self):
        self.UserAuthorizationURL = WEB_ROOT + reverse("auth_simple", kwargs={"service": self.ID})

    def Authorize(self, email, password):
        from tapiriik.auth.credential_storage import CredentialStore
        res = requests.get("https://ridewithgps.com/users/current.json",
                           params={'email': email, 'password': password, 'apikey': RWGPS_APIKEY})
        if res.status_code == 401:
            raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))
        res.raise_for_status()
        res = res.json()
        if res["user"] is None:
            raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))
        member_id = res["user"]["id"]
        if not member_id:
            raise APIException("Unable to retrieve id", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))
        return (member_id, {}, {"Email": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password)})

    def _duration_to_seconds(self, s):
        """
        Converts a duration in form HH:MM:SS to number of seconds for use in timedelta construction.
        """
        hours, minutes, seconds = (["0", "0"] + s.split(":"))[-3:]
        hours = int(hours)
        minutes = int(minutes)
        seconds = float(seconds)
        total_seconds = int(hours + 60000 * minutes + 1000 * seconds)
        return total_seconds

    def DownloadActivityList(self, serviceRecord, exhaustive=False):

        def mapStatTriple(act, stats_obj, key, units):
            if "%s_max" % key in act and act["%s_max" % key]:
                stats_obj.update(ActivityStatistic(units, max=float(act["%s_max" % key])))
            if "%s_min" % key in act and act["%s_min" % key]:
                stats_obj.update(ActivityStatistic(units, min=float(act["%s_min" % key])))
            if "%s_avg" % key in act and act["%s_avg" % key]:
                stats_obj.update(ActivityStatistic(units, avg=float(act["%s_avg" % key])))


        # http://ridewithgps.com/users/1/trips.json?limit=200&order_by=created_at&order_dir=asc
        # offset also supported
        activities = []
        exclusions = []
        # They don't actually support paging right now, for whatever reason
        params = self._add_auth_params({}, record=serviceRecord)

        res = requests.get("http://ridewithgps.com/users/{}/trips.json".format(serviceRecord.ExternalID), params=params)
        res = res.json()

        # Apparently some API users are seeing this new result format - I'm not
        if type(res) is dict:
            res = res.get("results", [])

        if res == []:
            return [], [] # No activities
        for act in res:
            if "distance" not in act:
                exclusions.append(APIExcludeActivity("No distance", activity_id=act["id"], user_exception=UserException(UserExceptionType.Corrupt)))
                continue
            if "duration" not in act or not act["duration"]:
                exclusions.append(APIExcludeActivity("No duration", activity_id=act["id"], user_exception=UserException(UserExceptionType.Corrupt)))
                continue
            activity = UploadedActivity()

            logger.debug("Name " + act["name"] + ":")
            if len(act["name"].strip()):
                activity.Name = act["name"]

            if len(act["description"].strip()):
                activity.Notes = act["description"]

            activity.GPS = act["is_gps"]
            activity.Stationary = not activity.GPS # I think

            # 0 = public, 1 = private, 2 = friends
            activity.Private = act["visibility"] == 1

            activity.StartTime = dateutil.parser.parse(act["departed_at"])

            try:
                activity.TZ = pytz.timezone(act["time_zone"])
            except pytz.exceptions.UnknownTimeZoneError:
                # Sometimes the time_zone returned isn't quite what we'd like it
                # So, just pull the offset from the datetime
                if isinstance(activity.StartTime.tzinfo, tzutc):
                    activity.TZ = pytz.utc # The dateutil tzutc doesn't have an _offset value.
                else:
                    activity.TZ = pytz.FixedOffset(activity.StartTime.tzinfo.utcoffset(activity.StartTime).total_seconds() / 60)

            activity.StartTime = activity.StartTime.replace(tzinfo=activity.TZ) # Overwrite dateutil's sillyness

            activity.EndTime = activity.StartTime + timedelta(seconds=self._duration_to_seconds(act["duration"]))
            logger.debug("Activity s/t " + str(activity.StartTime))
            activity.AdjustTZ()

            activity.Stats.Distance = ActivityStatistic(ActivityStatisticUnit.Meters, float(act["distance"]))

            mapStatTriple(act, activity.Stats.Power, "watts", ActivityStatisticUnit.Watts)
            mapStatTriple(act, activity.Stats.Speed, "speed", ActivityStatisticUnit.KilometersPerHour)
            mapStatTriple(act, activity.Stats.Cadence, "cad", ActivityStatisticUnit.RevolutionsPerMinute)
            mapStatTriple(act, activity.Stats.HR, "hr", ActivityStatisticUnit.BeatsPerMinute)

            if "elevation_gain" in act and act["elevation_gain"]:
                activity.Stats.Elevation.update(ActivityStatistic(ActivityStatisticUnit.Meters, gain=float(act["elevation_gain"])))

            if "elevation_loss" in act and act["elevation_loss"]:
                activity.Stats.Elevation.update(ActivityStatistic(ActivityStatisticUnit.Meters, loss=float(act["elevation_loss"])))

            # Activity type is not implemented yet in RWGPS results; we will assume cycling, though perhaps "OTHER" wouuld be correct
            activity.Type = ActivityType.Cycling

            activity.CalculateUID()
            activity.ServiceData = {"ActivityID": act["id"]}
            activities.append(activity)
        return activities, exclusions

    def DownloadActivity(self, serviceRecord, activity):
        if activity.Stationary:
            return activity # Nothing more to download - it doesn't serve these files for manually entered activites
        # https://ridewithgps.com/trips/??????.tcx
        activityID = activity.ServiceData["ActivityID"]
        res = requests.get("https://ridewithgps.com/trips/{}.tcx".format(activityID),
                           params=self._add_auth_params({'sub_format': 'history'}, record=serviceRecord))
        try:
            TCXIO.Parse(res.content, activity)
        except ValueError as e:
            raise APIExcludeActivity("TCX parse error " + str(e), user_exception=UserException(UserExceptionType.Corrupt))

        return activity

    def UploadActivity(self, serviceRecord, activity):
        # https://ridewithgps.com/trips.json

        fit_file = FITIO.Dump(activity)
        files = {"data_file": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit", fit_file)}
        params = {}
        params['trip[name]'] = activity.Name
        params['trip[description]'] = activity.Notes
        if activity.Private:
            params['trip[visibility]'] = 1 # Yes, this logic seems backwards but it's how it works

        res = requests.post("https://ridewithgps.com/trips.json", files=files,
                            params=self._add_auth_params(params, record=serviceRecord))
        if res.status_code % 100 == 4:
            raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))
        res.raise_for_status()
        res = res.json()
        if res["success"] != 1:
            raise APIException("Unable to upload activity")


    def RevokeAuthorization(self, serviceRecord):
        # nothing to do here...
        pass

    def DeleteCachedData(self, serviceRecord):
        # nothing cached...
        pass
Ejemplo n.º 10
0
class BeginnerTriathleteService(ServiceBase):

    # TODO: BT has a kcal expenditure calculation, it just isn't being reported. Supply that for a future update..
    # TODO: Implement laps on manual entries
    # TODO: BT supports activities other than the standard swim/bike/run, but a different interface is regrettably used

    ID = "beginnertriathlete"
    DisplayName = "BeginnerTriathlete"
    DisplayAbbreviation = "BT"
    AuthenticationType = ServiceAuthenticationType.UsernamePassword
    RequiresExtendedAuthorizationDetails = True
    ReceivesStationaryActivities = True
    SupportsHR = True
    SupportsActivityDeletion = True

    # Don't need to cache user settings for long, it is a quick lookup But if a user changes their timezone
    # or privacy settings, let's catch it *relatively* quick. Five minutes seems good.
    _sessionCache = SessionCache("beginnertriathlete", lifetime=timedelta(minutes=5), freshen_on_get=False)

    # Private fields
    _urlRoot = "https://beginnertriathlete.com/WebAPI/api/"
    _loginUrlRoot = _urlRoot + "login/"
    _sbrEventsUrlRoot = _urlRoot + "sbreventsummary/"
    _sbrEventDeleteUrlRoot = _urlRoot + "deletesbrevent/"
    _deviceUploadUrl = _urlRoot + "deviceupload/"
    _accountSettingsUrl = _urlRoot + "GeneralSettings/"
    _accountProfileUrl = _urlRoot + "profilesettings/"
    _accountInformationUrl = _urlRoot + "accountinformation/"
    _viewEntryUrl = "https://beginnertriathlete.com/discussion/training/view-event.asp?id="
    _dateFormat = "{d.month}/{d.day}/{d.year}"
    _serverDefaultTimezone = "US/Central"
    _workoutTypeMappings = {
        "3": ActivityType.Swimming,
        "1": ActivityType.Cycling,
        "2": ActivityType.Running
    }
    _mimeTypeMappings = {
        "application/gpx+xml": _DeviceFileTypes.GPX,
        "application/vnd.garmin.tcx+xml": _DeviceFileTypes.TCX,
        "application/vnd.ant.fit": _DeviceFileTypes.FIT
    }
    _fileExtensionMappings = {
        ".gpx": _DeviceFileTypes.GPX,
        ".tcx": _DeviceFileTypes.TCX,
        ".fit": _DeviceFileTypes.FIT
    }
    SupportedActivities = [
        ActivityType.Running,
        ActivityType.Cycling,
        ActivityType.MountainBiking,
        ActivityType.Walking,
        ActivityType.Swimming]

    def WebInit(self):
        self.UserAuthorizationURL = WEB_ROOT + reverse("auth_simple", kwargs={"service": self.ID})

    # Exchange username & password for a UserToken and store it in ExtendedAuthorization if the user has elected to
    # remember login details.
    def Authorize(self, username, password):
        session = self._prepare_request()
        requestParameters = {"username": username,  "password": password}
        user_resp = session.get(self._loginUrlRoot, params=requestParameters)

        if user_resp.status_code != 200:
            raise APIException("Login error")

        response = user_resp.json()

        if response["LoginResponseCode"] == 3:
            from tapiriik.auth.credential_storage import CredentialStore
            member_id = int(response["MemberId"])
            token = response["UserToken"]
            return member_id, {}, {"UserToken": CredentialStore.Encrypt(token)}

        if response["LoginResponseCode"] == 0:
            raise APIException("Invalid API key")

        # Incorrect username or password
        if response["LoginResponseCode"] == -3 or response["LoginResponseCode"] == -2 or response["LoginResponseCode"] == -1:
            raise APIException(
                "Invalid login - Bad username or password",
                block=True,
                user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))

        # Account is inactive or locked out - Rarely would happen
        if response["LoginResponseCode"] == 1 or response["LoginResponseCode"] == 2:
            raise APIException(
                "Invalid login - Account is inactive",
                block=True,
                user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))

        # Something extra unusual has happened
        raise APIException(
            "Invalid login - Unknown error",
            block=True,
            user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))

    # Get an activity summary over a date range
    def DownloadActivityList(self, serviceRecord, exhaustive=False):
        activities = []
        if exhaustive:
            listEnd = datetime.now().date() + timedelta(days=1.5)
            firstEntry = self._getFirstTrainingEntryForMember(self._getUserToken(serviceRecord))
            listStart = dateutil.parser.parse(firstEntry).date()
        else:
            listEnd = datetime.now() + timedelta(days=1.5)
            listStart = listEnd - timedelta(days=60)

        # Set headers necessary for a successful API request
        session = self._prepare_request(self._getUserToken(serviceRecord))
        settings = self._getUserSettings(serviceRecord)

        # Iterate through the date range 60 days at a time. Dates are inclusive for all events on that date,
        # and do not contain timestamps. 5/1/20xx through 5/2/20xx would include all events 5/1 => 5/2 11:59:59PM
        while listStart < listEnd:
            pageDate = listStart + timedelta(days=59)
            if pageDate > listEnd:
                pageDate = listEnd

            print("Requesting %s to %s" % (listStart, pageDate))

            # Request their actual logged data. Not their workout plan. Start and end date are inclusive and
            # the end date includes everything up until midnight, that day
            # Member ID can be sent as zero because we are retrieving our token's data, not someone else's. We
            # could store & supply the user's member id, but it would gain nothing
            requestParameters = {
                "startDate": self._dateFormat.format(d=listStart),
                "endDate": self._dateFormat.format(d=pageDate),
                "planned": "false",
                "memberid": "0"}
            workouts_resp = session.get(self._sbrEventsUrlRoot, params=requestParameters)

            if workouts_resp.status_code != 200:
                if workouts_resp.status_code == 401:
                    # After login, the API does not differentiate between an unauthorized token and an invalid API key
                    raise APIException(
                        "Invalid login or API key",
                        block=True,
                        user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))

                # Some other kind of error has occurred. It could be a server error.
                raise APIException("Workout listing error")

            workouts = workouts_resp.json()
            for workout in workouts:
                activity = self._populate_sbr_activity(workout, settings)
                activities.append(activity)

            listStart = listStart + timedelta(days=60)

        return activities, []

    # Populate an activity with the information from BT's sbreventsummary endpoint. Contains basic data like
    # event type, duration, pace, HR, date and time. At the moment, manually entered laps are not reported.
    # Detailed activity data, laps, and GPS may be present in a .fit, .tcx, or .gpx file if the record came
    # from a device upload

    def _populate_sbr_activity(self, api_sbr_activity, usersettings):
        # Example JSON feed (unimportant fields have been removed)
        # [{
        #    "EventId": 63128401,                   #  Internal ID
        #    "EventType": 3,                        #  Swim (3), bike (1), or run (2)
        #    "EventDate": "4/22/2016",
        #    "EventTime": "7:44 AM",                #  User's time, time zone not specified
        #    "Planned": false,                      #  Training plan or actual data
        #    "TotalMinutes": 34.97,
        #    "TotalKilometers": 1.55448,
        #    "AverageHeartRate": 125,
        #    "MinimumHeartRate": 100,
        #    "MaximumHeartRate": 150,
        #    "MemberId": 999999,
        #    "MemberUsername": "******",
        #    "HasDeviceUpload": true,
        #    "DeviceUploadFile": "http://beginnertriathlete.com/discussion/storage/workouts/555555/abcd-123.fit",
        #    "RouteName": "",                       #  Might contain a description of the event
        #    "Comments": "",                        #  Same as above. Not overly often used.
        # }, ... ]

        activity = UploadedActivity()
        workout_id = api_sbr_activity["EventId"]
        eventType = api_sbr_activity["EventType"]
        eventDate = api_sbr_activity["EventDate"]
        eventTime = api_sbr_activity["EventTime"]
        totalMinutes = api_sbr_activity["TotalMinutes"]
        totalKms = api_sbr_activity["TotalKilometers"]
        averageHr = api_sbr_activity["AverageHeartRate"]
        minimumHr = api_sbr_activity["MinimumHeartRate"]
        maximumHr = api_sbr_activity["MaximumHeartRate"]
        deviceUploadFile = api_sbr_activity["DeviceUploadFile"]

        # Basic SBR data does not include GPS or sensor data. If this event originated from a device upload,
        # DownloadActivity will find it.
        activity.Stationary = True

        # Same as above- The data might be there, but it's not supplied in the basic activity feed.
        activity.GPS = False

        activity.Private = usersettings["Privacy"]
        activity.Type = self._workoutTypeMappings[str(eventType)]

        # Get the user's timezone from their profile. (Activity.TZ should be mentioned in the object hierarchy docs?)
        # Question: I believe if DownloadActivity finds device data, it will overwrite this. Which is OK with me.
        # The device data will most likely be more accurate.
        try:
            activity.TZ = pytz.timezone(usersettings["TimeZone"])
        except pytz.exceptions.UnknownTimeZoneError:
            activity.TZ = pytz.timezone(self._serverDefaultTimezone)

        # activity.StartTime and EndTime aren't mentioned in the object hierarchy docs, but I see them
        # set in all the other providers.
        activity.StartTime = dateutil.parser.parse(
            eventDate + " " + eventTime,
            dayfirst=False).replace(tzinfo=activity.TZ)
        activity.EndTime = activity.StartTime + timedelta(minutes=totalMinutes)

        # We can calculate some metrics from the supplied data. Would love to see some non-source code documentation
        # on each statistic and what it expects as input.
        activity.Stats.Distance = ActivityStatistic(ActivityStatisticUnit.Kilometers,
                                                    value=totalKms)
        activity.Stats.HR = ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute,
                                              avg=float(averageHr),
                                              min=float(minimumHr),
                                              max=float(maximumHr))
        activity.Stats.MovingTime = ActivityStatistic(ActivityStatisticUnit.Seconds,
                                                      value=float(totalMinutes * 60))
        activity.Stats.TimerTime = ActivityStatistic(ActivityStatisticUnit.Seconds,
                                                     value=float(totalMinutes * 60))
        # While BT does support laps, the current API doesn't report on them - a limitation that may need to be
        # corrected in a future update. For now, treat manual entries as a single lap. As more and more people upload
        # workouts using devices anyway, this probably matters much less than it once did.
        lap = Lap(stats=activity.Stats, startTime=activity.StartTime, endTime=activity.EndTime)
        activity.Laps = [lap]

        # Not 100% positive how this is utilized, but it is common for all providers. Detects duplicate downloads?
        activity.CalculateUID()

        # If a device file is attached, we'll get more details about this event in DownloadActivity
        activity.ServiceData = {
            "ID": int(workout_id),
            "DeviceUploadFile": deviceUploadFile
        }

        return activity

    def DownloadActivity(self, serviceRecord, activity):
        deviceUploadFile = activity.ServiceData.get("DeviceUploadFile")

        # No additional data about this event is available.
        if not deviceUploadFile:
            return activity

        logger.info("Downloading device file %s" % deviceUploadFile)
        session = self._prepare_request(self._getUserToken(serviceRecord))
        res = session.get(deviceUploadFile)

        if res.status_code == 200:
            try:
                contentType = self._mimeTypeMappings[res.headers["content-type"]]
                if not contentType:
                    remoteUrl = urlparse(deviceUploadFile).path
                    extension = os.path.splitext(remoteUrl)[1]
                    contentType = self._fileExtensionMappings[extension]

                if contentType:
                    if contentType == _DeviceFileTypes.FIT:
                        # Oh no! Not supported! So close ....
                        # FITIO.Parse(res.content, activity)
                        return activity
                    if contentType == _DeviceFileTypes.TCX:
                        TCXIO.Parse(res.content, activity)
                    if contentType == _DeviceFileTypes.GPX:
                        GPXIO.Parse(res.content, activity)
            except ValueError as e:
                raise APIExcludeActivity("Parse error " + deviceUploadFile + " " + str(e),
                                         user_exception=UserException(UserExceptionType.Corrupt),
                                         permanent=True)

        return activity

    def UploadActivity(self, serviceRecord, activity):
        # Upload the workout as a .FIT file
        session = self._prepare_request(self._getUserToken(serviceRecord))
        uploaddata = FITIO.Dump(activity)
        files = {"deviceFile": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit", uploaddata)}
        response = session.post(self._deviceUploadUrl, files=files)

        if response.status_code != 200:
            raise APIException(
                "Error uploading workout",
                block=False)

        responseJson = response.json()

        if not responseJson['Success']:
            raise APIException(
                "Error uploading workout - " + response.Message,
                block=False)

        # The upload didn't return a PK for some reason. The queue might be stuck or some other internal error
        # but that doesn't necessarily warrant a reupload.
        eventId = responseJson["EventId"]
        if eventId == 0:
            return None
        return eventId

    def RevokeAuthorization(self, serviceRecord):
        # nothing to do here...
        pass

    def UserUploadedActivityURL(self, uploadId):
        return self._viewEntryUrl + str(uploadId)

    def DeleteActivity(self, serviceRecord, uploadId):
        session = self._prepare_request(self._getUserToken(serviceRecord))
        requestParameters = {"id": uploadId}
        response = session.post(self._sbrEventDeleteUrlRoot, params=requestParameters)

        self._handleHttpErrorCodes(response)

        responseJson = response.json()

        if not responseJson:
            raise APIException(
                "Error deleting workout - " + uploadId,
                block=False)

    def DeleteCachedData(self, serviceRecord):
        # nothing to do here...
        pass

    # Sets the API key header necessary for all requests, and optionally the authentication token too.
    def _prepare_request(self, userToken=None):
        session = requests.Session()
        session.headers.update(self._set_request_api_headers())

        # If the serviceRecord was included, try to include the UserToken, authenticating the request
        # The service record will contain ExtendedAuthorization data if the user chose to remember login details.
        if userToken:
            session.headers.update(self._set_request_authentication_header(userToken))
        return session

    # The APIKey header is required for all requests. A key can be obtained by emailing [email protected].
    def _set_request_api_headers(self):
        return {"APIKey": BT_APIKEY}

    # Upon successful authentication by Authorize, the ExtendedAuthorization dict will have a UserToken
    def _set_request_authentication_header(self, userToken):
        return {"UserToken": userToken}

    def _getFirstTrainingEntryForMember(self, userToken):
        session = self._prepare_request(userToken)
        response = session.get(self._accountInformationUrl)
        self._handleHttpErrorCodes(response)

        try:
            responseJson = response.json()
            return responseJson['FirstTrainingLog']
        except ValueError as e:
            raise APIException("Parse error reading profile JSON " + str(e))

    def _getUserSettings(self, serviceRecord, skip_cache=False):
        cached = self._sessionCache.Get(serviceRecord.ExternalID)
        if cached and not skip_cache:
            return cached
        if serviceRecord:
            timeZone = self._getTimeZone(self._getUserToken(serviceRecord))
            privacy = self._getPrivacy(self._getUserToken(serviceRecord))
            cached = {
                "TimeZone": timeZone,
                "Privacy": privacy
            }
            self._sessionCache.Set(serviceRecord.ExternalID, cached)
        return cached

    def _getUserToken(self, serviceRecord):
        userToken = None
        if serviceRecord:
            from tapiriik.auth.credential_storage import CredentialStore
            userToken = CredentialStore.Decrypt(serviceRecord.ExtendedAuthorization["UserToken"])
        return userToken

    def _getTimeZone(self, token):
        session = self._prepare_request(token)
        response = session.get(self._accountSettingsUrl)
        self._handleHttpErrorCodes(response)

        try:
            # BT does not record whether the user observes DST and I am not even attempting to guess.
            responseJson = response.json()
            timezone = responseJson["UtcOffset"]
            if timezone == 0:
                timezoneStr = "Etc/GMT"
            elif timezone > 0:
                timezoneStr = "Etc/GMT+" + str(timezone)
            elif timezone < 0:
                timezoneStr = "Etc/GMT" + str(timezone)
            return timezoneStr
        except ValueError as e:
            raise APIException("Parse error reading profile JSON " + str(e))

    def _getPrivacy(self, token):
        session = self._prepare_request(token)
        response = session.get(self._accountProfileUrl)
        self._handleHttpErrorCodes(response)

        try:
            # public           - Everyone. Public
            # publicrestricted - Registered members. Public
            # friends          - BT friends only. Private
            # private          - Private
            responseJson = response.json()
            privacy = responseJson["TrainingPrivacy"]
            return not (privacy == "public" or privacy == "publicrestricted")
        except ValueError as e:
            raise APIException("Parse error reading privacy JSON " + str(e))

    def _handleHttpErrorCodes(self, response):
        if response.status_code != 200:
            if response.status_code == 401:
                # After login, the API does not differentiate between an unauthorized token and an invalid API key
                raise APIException(
                    "Invalid login or API key",
                    block=True,
                    user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))
            # Server error?
            raise APIException(
                "HTTP error " + str(response.status_code),
                block=True)
Ejemplo n.º 11
0
class TrainingPeaksService(ServiceBase):
    ID = "trainingpeaks"
    DisplayName = "TrainingPeaks"
    DisplayAbbreviation = "TP"
    AuthenticationType = ServiceAuthenticationType.OAuth
    RequiresExtendedAuthorizationDetails = False
    ReceivesStationaryActivities = False
    SuppliesActivities = False
    AuthenticationNoFrame = True
    SupportsExhaustiveListing = False

    SupportsHR = SupportsCadence = SupportsTemp = SupportsPower = True

    # Not-so-coincidentally, similar to PWX.
    _workoutTypeMappings = {
        "bike": ActivityType.Cycling,
        "run": ActivityType.Running,
        "walk": ActivityType.Walking,
        "swim": ActivityType.Swimming,
        "mtb": ActivityType.MountainBiking,
        "xc-Ski": ActivityType.CrossCountrySkiing,
        "rowing": ActivityType.Rowing,
        "x-train": ActivityType.Other,
        "strength": ActivityType.Other,
        "other": ActivityType.Other,
    }
    SupportedActivities = ActivityType.List()  # All.

    _redirect_url = "https://www.siiink.com/auth/return/trainingpeaks"
    _tokenCache = SessionCache("trainingpeaks",
                               lifetime=timedelta(minutes=30),
                               freshen_on_get=False)

    def WebInit(self):
        self.UserAuthorizationURL = TRAININGPEAKS_OAUTH_BASE_URL + "/oauth/authorize?" + urlencode(
            {
                "client_id": TRAININGPEAKS_CLIENT_ID,
                "response_type": "code",
                "redirect_uri": self._redirect_url,
                "scope": TRAININGPEAKS_CLIENT_SCOPE
            })

    def RetrieveAuthorizationToken(self, req, level):
        code = req.GET.get("code")
        params = {
            "client_id": TRAININGPEAKS_CLIENT_ID,
            "client_secret": TRAININGPEAKS_CLIENT_SECRET,
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": self._redirect_url
        }

        req_url = TRAININGPEAKS_OAUTH_BASE_URL + "/oauth/token"
        response = requests.post(req_url, data=params)
        if response.status_code != 200:
            raise APIException("Invalid code")
        auth_data = response.json()

        profile_data = requests.get(TRAININGPEAKS_API_BASE_URL +
                                    "/v1/athlete/profile",
                                    headers={
                                        "Authorization":
                                        "Bearer %s" % auth_data["access_token"]
                                    }).json()
        if type(profile_data) is list and any("is not a valid athlete" in x
                                              for x in profile_data):
            raise APIException("TP user is coach account",
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.NonAthleteAccount,
                                   intervention_required=True))
        return (profile_data["Id"], {
            "RefreshToken": auth_data["refresh_token"]
        })

    def _apiHeaders(self, serviceRecord):
        # The old API was username/password, and the new API provides no means to automatically upgrade these credentials.
        if not serviceRecord.Authorization or "RefreshToken" not in serviceRecord.Authorization:
            raise APIException("TP user lacks OAuth credentials",
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.Authorization,
                                   intervention_required=True))

        token = self._tokenCache.Get(serviceRecord.ExternalID)
        if not token:
            # Use refresh token to get access token
            # Hardcoded return URI to get around the lack of URL reversing without loading up all the Django stuff
            params = {
                "client_id": TRAININGPEAKS_CLIENT_ID,
                "client_secret": TRAININGPEAKS_CLIENT_SECRET,
                "grant_type": "refresh_token",
                "refresh_token": serviceRecord.Authorization["RefreshToken"],
                # "redirect_uri": self._redirect_url
            }
            headers = {"Content-Type": "application/x-www-form-urlencoded"}
            response = requests.post(TRAININGPEAKS_OAUTH_BASE_URL +
                                     "/oauth/token",
                                     data=urlencode(params),
                                     headers=headers)
            if response.status_code != 200:
                if response.status_code >= 400 and response.status_code < 500:
                    raise APIException(
                        "Could not retrieve refreshed token %s %s" %
                        (response.status_code, response.text),
                        block=True,
                        user_exception=UserException(
                            UserExceptionType.Authorization,
                            intervention_required=True))
                raise APIException("Could not retrieve refreshed token %s %s" %
                                   (response.status_code, response.text))
            token = response.json()["access_token"]
            self._tokenCache.Set(serviceRecord.ExternalID, token)

        return {"Authorization": "Bearer %s" % token}

    def RevokeAuthorization(self, serviceRecord):
        pass  # No auth tokens to revoke...

    def DeleteCachedData(self, serviceRecord):
        pass  # No cached data...

    def DownloadActivityList(self, svcRecord, exhaustive_start_time=None):
        activities = []
        exclusions = []

        headers = self._apiHeaders(svcRecord)

        limitDateFormat = "%Y-%m-%d"

        if exhaustive_start_time:
            totalListEnd = datetime.now() + timedelta(
                days=1.5)  # Who knows which TZ it's in
            totalListStart = exhaustive_start_time - timedelta(days=1.5)
        else:
            totalListEnd = datetime.now() + timedelta(
                days=1.5)  # Who knows which TZ it's in
            totalListStart = totalListEnd - timedelta(
                days=20)  # Doesn't really matter

        listStep = timedelta(days=45)
        listEnd = totalListEnd
        listStart = max(totalListStart, totalListEnd - listStep)

        while True:
            logger.debug("Requesting %s to %s" % (listStart, listEnd))
            resp = requests.get(TRAININGPEAKS_API_BASE_URL +
                                "/v1/workouts/%s/%s" %
                                (listStart.strftime(limitDateFormat),
                                 listEnd.strftime(limitDateFormat)),
                                headers=headers)

            for act in resp.json():
                if not act.get("completed", True):
                    continue
                activity = UploadedActivity()
                activity.StartTime = dateutil.parser.parse(
                    act["StartTime"]).replace(tzinfo=None)
                logger.debug("Activity s/t " + str(activity.StartTime))
                activity.EndTime = activity.StartTime + timedelta(
                    hours=act["TotalTime"])
                activity.Name = act.get("Title", None)
                activity.Notes = act.get("Description", None)
                activity.Type = self._workoutTypeMappings.get(
                    act.get("WorkoutType", "").lower(), ActivityType.Other)

                activity.Stats.Cadence = ActivityStatistic(
                    ActivityStatisticUnit.RevolutionsPerMinute,
                    avg=act.get("CadenceAverage", None),
                    max=act.get("CadenceMaximum", None))
                activity.Stats.Distance = ActivityStatistic(
                    ActivityStatisticUnit.Meters,
                    value=act.get("Distance", None))
                activity.Stats.Elevation = ActivityStatistic(
                    ActivityStatisticUnit.Meters,
                    avg=act.get("ElevationAverage", None),
                    min=act.get("ElevationMinimum", None),
                    max=act.get("ElevationMaximum", None),
                    gain=act.get("ElevationGain", None),
                    loss=act.get("ElevationLoss", None))
                activity.Stats.Energy = ActivityStatistic(
                    ActivityStatisticUnit.Kilojoules,
                    value=act.get("Energy", None))
                activity.Stats.HR = ActivityStatistic(
                    ActivityStatisticUnit.BeatsPerMinute,
                    avg=act.get("HeartRateAverage", None),
                    min=act.get("HeartRateMinimum", None),
                    max=act.get("HeartRateMaximum", None))
                activity.Stats.Power = ActivityStatistic(
                    ActivityStatisticUnit.Watts,
                    avg=act.get("PowerAverage", None),
                    max=act.get("PowerMaximum", None))
                activity.Stats.Temperature = ActivityStatistic(
                    ActivityStatisticUnit.DegreesCelcius,
                    avg=act.get("TemperatureAverage", None),
                    min=act.get("TemperatureMinimum", None),
                    max=act.get("TemperatureMaximum", None))
                activity.Stats.Speed = ActivityStatistic(
                    ActivityStatisticUnit.MetersPerSecond,
                    avg=act.get("VelocityAverage", None),
                    max=act.get("VelocityMaximum", None))
                activity.CalculateUID()
                activities.append(activity)

            if not exhaustive_start_time:
                break

            listStart -= listStep
            listEnd -= listStep
            if listEnd < totalListStart:
                break

        return activities, exclusions

    def UploadActivity(self, svcRecord, activity):
        pwxdata_gz = BytesIO()
        with gzip.GzipFile(fileobj=pwxdata_gz, mode="w") as gzf:
            gzf.write(PWXIO.Dump(activity).encode("utf-8"))

        headers = self._apiHeaders(svcRecord)
        headers.update({"Content-Type": "application/json"})
        data = {
            "UploadClient": "tapiriik",
            "Filename": "tap-%s.pwx" % activity.UID,
            "SetWorkoutPublic": not activity.Private,
            # NB activity notes and name are in the PWX.
            "Data": base64.b64encode(pwxdata_gz.getvalue()).decode("ascii")
        }

        resp = requests.post(TRAININGPEAKS_API_BASE_URL + "/v1/file",
                             data=json.dumps(data),
                             headers=headers)
        if resp.status_code != 200:
            raise APIException("Unable to upload activity response " +
                               resp.text + " status " + str(resp.status_code))
        return resp.json()[0]["Id"]
Ejemplo n.º 12
0
class GarminConnectService(ServiceBase):
    ID = "garminconnect"
    DisplayName = "Garmin Connect"
    DisplayAbbreviation = "GC"
    AuthenticationType = ServiceAuthenticationType.UsernamePassword
    RequiresExtendedAuthorizationDetails = True

    _activityMappings = {
        "running": ActivityType.Running,
        "cycling": ActivityType.Cycling,
        "mountain_biking": ActivityType.MountainBiking,
        "walking": ActivityType.Walking,
        "hiking": ActivityType.Hiking,
        "resort_skiing_snowboarding": ActivityType.DownhillSkiing,
        "cross_country_skiing": ActivityType.CrossCountrySkiing,
        "skate_skiing":
        ActivityType.CrossCountrySkiing,  # Well, it ain't downhill?
        "backcountry_skiing_snowboarding":
        ActivityType.CrossCountrySkiing,  # ish
        "skating": ActivityType.Skating,
        "swimming": ActivityType.Swimming,
        "rowing": ActivityType.Rowing,
        "elliptical": ActivityType.Elliptical,
        "fitness_equipment": ActivityType.Gym,
        "all": ActivityType.Other  # everything will eventually resolve to this
    }

    _reverseActivityMappings = {  # Removes ambiguities when mapping back to their activity types
        "running": ActivityType.Running,
        "cycling": ActivityType.Cycling,
        "mountain_biking": ActivityType.MountainBiking,
        "walking": ActivityType.Walking,
        "hiking": ActivityType.Hiking,
        "resort_skiing_snowboarding": ActivityType.DownhillSkiing,
        "cross_country_skiing": ActivityType.CrossCountrySkiing,
        "skating": ActivityType.Skating,
        "swimming": ActivityType.Swimming,
        "rowing": ActivityType.Rowing,
        "elliptical": ActivityType.Elliptical,
        "fitness_equipment": ActivityType.Gym,
        "other": ActivityType.Other  # I guess? (vs. "all" that is)
    }

    SupportedActivities = list(_activityMappings.values())

    SupportsHR = SupportsCadence = True

    _sessionCache = SessionCache(lifetime=timedelta(minutes=30),
                                 freshen_on_get=True)

    _unitMap = {
        "mph": ActivityStatisticUnit.MilesPerHour,
        "kph": ActivityStatisticUnit.KilometersPerHour,
        "hmph": ActivityStatisticUnit.HectometersPerHour,
        "hydph": ActivityStatisticUnit.HundredYardsPerHour,
        "celcius": ActivityStatisticUnit.DegreesCelcius,
        "fahrenheit": ActivityStatisticUnit.DegreesFahrenheit,
        "mile": ActivityStatisticUnit.Miles,
        "kilometer": ActivityStatisticUnit.Kilometers,
        "foot": ActivityStatisticUnit.Feet,
        "meter": ActivityStatisticUnit.Meters,
        "yard": ActivityStatisticUnit.Yards,
        "kilocalorie": ActivityStatisticUnit.Kilocalories,
        "bpm": ActivityStatisticUnit.BeatsPerMinute,
        "stepsPerMinute": ActivityStatisticUnit.StepsPerMinute,
        "rpm": ActivityStatisticUnit.RevolutionsPerMinute,
        "watt": ActivityStatisticUnit.Watts
    }

    def __init__(self):
        cachedHierarchy = cachedb.gc_type_hierarchy.find_one()
        if not cachedHierarchy:
            rawHierarchy = requests.get(
                "http://connect.garmin.com/proxy/activity-service-1.2/json/activity_types"
            ).text
            self._activityHierarchy = json.loads(rawHierarchy)["dictionary"]
            cachedb.gc_type_hierarchy.insert({"Hierarchy": rawHierarchy})
        else:
            self._activityHierarchy = json.loads(
                cachedHierarchy["Hierarchy"])["dictionary"]
        rate_lock_path = "/tmp/gc_rate.%s.lock" % HTTP_SOURCE_ADDR
        # Ensure the rate lock file exists (...the easy way)
        open(rate_lock_path, "a").close()
        self._rate_lock = open(rate_lock_path, "r+")

    def _rate_limit(self):
        import fcntl, struct, time
        min_period = 1  # I appear to been banned from Garmin Connect while determining this.
        print("Waiting for lock")
        fcntl.flock(self._rate_lock, fcntl.LOCK_EX)
        try:
            print("Have lock")
            self._rate_lock.seek(0)
            last_req_start = self._rate_lock.read()
            if not last_req_start:
                last_req_start = 0
            else:
                last_req_start = float(last_req_start)

            wait_time = max(0, min_period - (time.time() - last_req_start))
            time.sleep(wait_time)

            self._rate_lock.seek(0)
            self._rate_lock.write(str(time.time()))
            self._rate_lock.flush()

            print("Rate limited for %f" % wait_time)
        finally:
            fcntl.flock(self._rate_lock, fcntl.LOCK_UN)

    def _get_cookies(self, record=None, email=None, password=None):
        from tapiriik.auth.credential_storage import CredentialStore
        if record:
            cached = self._sessionCache.Get(record.ExternalID)
            if cached:
                return cached
            #  longing for C style overloads...
            password = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Password"])
            email = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Email"])

        for x in range(10):
            self._rate_limit()
        gcPreResp = requests.get("http://connect.garmin.com/",
                                 allow_redirects=False)
        # New site gets this redirect, old one does not
        if gcPreResp.status_code == 200:
            self._rate_limit()
            gcPreResp = requests.get("https://connect.garmin.com/signin",
                                     allow_redirects=False)
            req_count = int(
                re.search("j_id(\d+)", gcPreResp.text).groups(1)[0])
            params = {
                "login": "******",
                "login:loginUsernameField": email,
                "login:password": password,
                "login:signInButton": "Sign In"
            }
            auth_retries = 3  # Did I mention Garmin Connect is silly?
            for retries in range(auth_retries):
                params["javax.faces.ViewState"] = "j_id%d" % req_count
                req_count += 1
                self._rate_limit()
                resp = requests.post("https://connect.garmin.com/signin",
                                     data=params,
                                     allow_redirects=False,
                                     cookies=gcPreResp.cookies)
                if resp.status_code >= 500 and resp.status_code < 600:
                    raise APIException("Remote API failure")
                if resp.status_code != 302:  # yep
                    if "errorMessage" in resp.text:
                        if retries < auth_retries - 1:
                            time.sleep(1)
                            continue
                        else:
                            raise APIException(
                                "Invalid login",
                                block=True,
                                user_exception=UserException(
                                    UserExceptionType.Authorization,
                                    intervention_required=True))
                    else:
                        raise APIException("Mystery login error %s" %
                                           resp.text)
                break
        elif gcPreResp.status_code == 302:
            # JSIG CAS, cool I guess.
            # Not quite OAuth though, so I'll continue to collect raw credentials.
            # Commented stuff left in case this ever breaks because of missing parameters...
            data = {
                "username": email,
                "password": password,
                "_eventId": "submit",
                "embed": "true",
                # "displayNameRequired": "false"
            }
            params = {
                "service": "http://connect.garmin.com/post-auth/login",
                # "redirectAfterAccountLoginUrl": "http://connect.garmin.com/post-auth/login",
                # "redirectAfterAccountCreationUrl": "http://connect.garmin.com/post-auth/login",
                # "webhost": "olaxpw-connect00.garmin.com",
                "clientId": "GarminConnect",
                # "gauthHost": "https://sso.garmin.com/sso",
                # "rememberMeShown": "true",
                # "rememberMeChecked": "false",
                "consumeServiceTicket": "false",
                # "id": "gauth-widget",
                # "embedWidget": "false",
                # "cssUrl": "https://static.garmincdn.com/com.garmin.connect/ui/src-css/gauth-custom.css",
                # "source": "http://connect.garmin.com/en-US/signin",
                # "createAccountShown": "true",
                # "openCreateAccount": "false",
                # "usernameShown": "true",
                # "displayNameShown": "false",
                # "initialFocus": "true",
                # "locale": "en"
            }
            # I may never understand what motivates people to mangle a perfectly good protocol like HTTP in the ways they do...
            preResp = requests.get("https://sso.garmin.com/sso/login",
                                   params=params)
            if preResp.status_code != 200:
                raise APIException("SSO prestart error %s %s" %
                                   (preResp.status_code, preResp.text))
            data["lt"] = re.search("name=\"lt\"\s+value=\"([^\"]+)\"",
                                   preResp.text).groups(1)[0]

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

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

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

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

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

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

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

        return gcPreResp.cookies

    def WebInit(self):
        self.UserAuthorizationURL = WEB_ROOT + reverse(
            "auth_simple", kwargs={"service": self.ID})

    def Authorize(self, email, password):
        from tapiriik.auth.credential_storage import CredentialStore
        cookies = self._get_cookies(email=email, password=password)
        self._rate_limit()
        username = requests.get("http://connect.garmin.com/user/username",
                                cookies=cookies).json()["username"]
        if not len(username):
            raise APIException("Unable to retrieve username",
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.Authorization,
                                   intervention_required=True))
        return (username, {}, {
            "Email": CredentialStore.Encrypt(email),
            "Password": CredentialStore.Encrypt(password)
        })

    def _resolveActivityType(self, act_type):
        # Mostly there are two levels of a hierarchy, so we don't really need this as the parent is included in the listing.
        # But maybe they'll change that some day?
        while act_type not in self._activityMappings:
            try:
                act_type = [
                    x["parent"]["key"] for x in self._activityHierarchy
                    if x["key"] == act_type
                ][0]
            except IndexError:
                raise ValueError(
                    "Activity type not found in activity hierarchy")
        return self._activityMappings[act_type]

    def DownloadActivityList(self, serviceRecord, exhaustive=False):
        #http://connect.garmin.com/proxy/activity-search-service-1.0/json/activities?&start=0&limit=50
        cookies = self._get_cookies(record=serviceRecord)
        page = 1
        pageSz = 100
        activities = []
        exclusions = []
        while True:
            logger.debug("Req with " + str({
                "start": (page - 1) * pageSz,
                "limit": pageSz
            }))
            self._rate_limit()
            res = requests.get(
                "http://connect.garmin.com/proxy/activity-search-service-1.0/json/activities",
                params={
                    "start": (page - 1) * pageSz,
                    "limit": pageSz
                },
                cookies=cookies)
            res = res.json()["results"]
            if "activities" not in res:
                break  # No activities on this page - empty account.
            for act in res["activities"]:
                act = act["activity"]
                if "sumDistance" not in act:
                    exclusions.append(
                        APIExcludeActivity("No distance",
                                           activityId=act["activityId"],
                                           userException=UserException(
                                               UserExceptionType.Corrupt)))
                    continue
                activity = UploadedActivity()

                if "sumSampleCountSpeed" not in act and "sumSampleCountTimestamp" not in act:  # Don't really know why sumSampleCountTimestamp doesn't appear in swim activities - they're definitely timestamped...
                    activity.Stationary = True
                else:
                    activity.Stationary = False

                try:
                    activity.TZ = pytz.timezone(act["activityTimeZone"]["key"])
                except pytz.exceptions.UnknownTimeZoneError:
                    activity.TZ = pytz.FixedOffset(
                        float(act["activityTimeZone"]["offset"]) * 60)

                logger.debug("Name " + act["activityName"]["value"] + ":")
                if len(act["activityName"]["value"].strip(
                )) and act["activityName"][
                        "value"] != "Untitled":  # This doesn't work for internationalized accounts, oh well.
                    activity.Name = act["activityName"]["value"]

                if len(act["activityDescription"]["value"].strip()):
                    activity.Notes = act["activityDescription"]["value"]
                # beginTimestamp/endTimestamp is in UTC
                activity.StartTime = pytz.utc.localize(
                    datetime.utcfromtimestamp(
                        float(act["beginTimestamp"]["millis"]) / 1000))
                if "sumElapsedDuration" in act:
                    activity.EndTime = activity.StartTime + timedelta(
                        0, round(float(act["sumElapsedDuration"]["value"])))
                elif "sumDuration" in act:
                    activity.EndTime = activity.StartTime + timedelta(
                        minutes=float(act["sumDuration"]
                                      ["minutesSeconds"].split(":")[0]),
                        seconds=float(act["sumDuration"]
                                      ["minutesSeconds"].split(":")[1]))
                else:
                    activity.EndTime = pytz.utc.localize(
                        datetime.utcfromtimestamp(
                            float(act["endTimestamp"]["millis"]) / 1000))
                logger.debug("Activity s/t " + str(activity.StartTime) +
                             " on page " + str(page))
                activity.AdjustTZ()
                # TODO: fix the distance stats to account for the fact that this incorrectly reported km instead of meters for the longest time.
                activity.Stats.Distance = ActivityStatistic(
                    self._unitMap[act["sumDistance"]["uom"]],
                    value=float(act["sumDistance"]["value"]))

                def mapStat(gcKey, statKey, type, useSourceUnits=False):
                    nonlocal activity, act
                    if gcKey in act:
                        value = float(act[gcKey]["value"])
                        if math.isinf(value):
                            return  # GC returns the minimum speed as "-Infinity" instead of 0 some times :S
                        activity.Stats.__dict__[statKey].update(
                            ActivityStatistic(self._unitMap[act[gcKey]["uom"]],
                                              **({
                                                  type: value
                                              })))
                        if useSourceUnits:
                            activity.Stats.__dict__[
                                statKey] = activity.Stats.__dict__[
                                    statKey].asUnits(
                                        self._unitMap[act[gcKey]["uom"]])

                if "sumMovingDuration" in act:
                    activity.Stats.MovingTime = ActivityStatistic(
                        ActivityStatisticUnit.Time,
                        value=timedelta(
                            seconds=float(act["sumMovingDuration"]["value"])))

                if "sumDuration" in act:
                    activity.Stats.TimerTime = ActivityStatistic(
                        ActivityStatisticUnit.Time,
                        value=timedelta(
                            minutes=float(act["sumDuration"]
                                          ["minutesSeconds"].split(":")[0]),
                            seconds=float(act["sumDuration"]
                                          ["minutesSeconds"].split(":")[1])))

                mapStat(
                    "minSpeed", "Speed", "min", useSourceUnits=True
                )  # We need to suppress conversion here, so we can fix the pace-speed issue below
                mapStat("maxSpeed", "Speed", "max", useSourceUnits=True)
                mapStat("weightedMeanSpeed",
                        "Speed",
                        "avg",
                        useSourceUnits=True)
                mapStat("minAirTemperature", "Temperature", "min")
                mapStat("maxAirTemperature", "Temperature", "max")
                mapStat("weightedMeanAirTemperature", "Temperature", "avg")
                mapStat("sumEnergy", "Energy", "value")
                mapStat("maxHeartRate", "HR", "max")
                mapStat("weightedMeanHeartRate", "HR", "avg")
                mapStat("maxRunCadence", "RunCadence", "max")
                mapStat("weightedMeanRunCadence", "RunCadence", "avg")
                mapStat("maxBikeCadence", "Cadence", "max")
                mapStat("weightedMeanBikeCadence", "Cadence", "avg")
                mapStat("minPower", "Power", "min")
                mapStat("maxPower", "Power", "max")
                mapStat("weightedMeanPower", "Power", "avg")
                mapStat("minElevation", "Elevation", "min")
                mapStat("maxElevation", "Elevation", "max")
                mapStat("gainElevation", "Elevation", "gain")
                mapStat("lossElevation", "Elevation", "loss")

                # In Garmin Land, max can be smaller than min for this field :S
                if activity.Stats.Power.Max is not None and activity.Stats.Power.Min is not None and activity.Stats.Power.Min > activity.Stats.Power.Max:
                    activity.Stats.Power.Min = None

                # To get it to match what the user sees in GC.
                if activity.Stats.RunCadence.Max is not None:
                    activity.Stats.RunCadence.Max *= 2
                if activity.Stats.RunCadence.Average is not None:
                    activity.Stats.RunCadence.Average *= 2

                # GC incorrectly reports pace measurements as kph/mph when they are in fact in min/km or min/mi
                if "minSpeed" in act:
                    if ":" in act["minSpeed"][
                            "withUnitAbbr"] and activity.Stats.Speed.Min:
                        activity.Stats.Speed.Min = 60 / activity.Stats.Speed.Min
                if "maxSpeed" in act:
                    if ":" in act["maxSpeed"][
                            "withUnitAbbr"] and activity.Stats.Speed.Max:
                        activity.Stats.Speed.Max = 60 / activity.Stats.Speed.Max
                if "weightedMeanSpeed" in act:
                    if ":" in act["weightedMeanSpeed"][
                            "withUnitAbbr"] and activity.Stats.Speed.Average:
                        activity.Stats.Speed.Average = 60 / activity.Stats.Speed.Average

                # Similarly, they do weird stuff with HR at times - %-of-max and zones
                # ...and we can't just fix these, so we have to calculate it after the fact (blegh)
                recalcHR = False
                if "maxHeartRate" in act:
                    if "%" in act["maxHeartRate"]["withUnitAbbr"] or "z" in act[
                            "maxHeartRate"]["withUnitAbbr"]:
                        activity.Stats.HR.Max = None
                        recalcHR = True
                if "weightedMeanHeartRate" in act:
                    if "%" in act["weightedMeanHeartRate"][
                            "withUnitAbbr"] or "z" in act[
                                "weightedMeanHeartRate"]["withUnitAbbr"]:
                        activity.Stats.HR.Average = None
                        recalcHR = True

                activity.Type = self._resolveActivityType(
                    act["activityType"]["key"])

                activity.CalculateUID()
                activity.ServiceData = {
                    "ActivityID": act["activityId"],
                    "RecalcHR": recalcHR
                }

                activities.append(activity)
            logger.debug("Finished page " + str(page) + " of " +
                         str(res["search"]["totalPages"]))
            if not exhaustive or int(res["search"]["totalPages"]) == page:
                break
            else:
                page += 1
        return activities, exclusions

    def DownloadActivity(self, serviceRecord, activity):
        #http://connect.garmin.com/proxy/activity-service-1.1/tcx/activity/#####?full=true
        activityID = activity.ServiceData["ActivityID"]
        cookies = self._get_cookies(record=serviceRecord)
        self._rate_limit()
        res = requests.get(
            "http://connect.garmin.com/proxy/activity-service-1.1/tcx/activity/"
            + str(activityID) + "?full=true",
            cookies=cookies)
        try:
            TCXIO.Parse(res.content, activity)
        except ValueError as e:
            raise APIExcludeActivity("TCX parse error " + str(e),
                                     userException=UserException(
                                         UserExceptionType.Corrupt))

        if activity.ServiceData["RecalcHR"]:
            logger.debug("Recalculating HR")
            avgHR, maxHR = ActivityStatisticCalculator.CalculateAverageMaxHR(
                activity)
            activity.Stats.HR.coalesceWith(
                ActivityStatistic(ActivityStatisticUnit.BeatsPerMinute,
                                  max=maxHR,
                                  avg=avgHR))

        if len(activity.Laps) == 1:
            activity.Laps[0].Stats.update(
                activity.Stats
            )  # I trust Garmin Connect's stats more than whatever shows up in the TCX
            activity.Stats = activity.Laps[
                0].Stats  # They must be identical to pass the verification

        if activity.Stats.Temperature.Min is not None or activity.Stats.Temperature.Max is not None or activity.Stats.Temperature.Average is not None:
            logger.debug("Retrieving additional temperature data")
            # TCX doesn't have temperature, for whatever reason...
            self._rate_limit()
            res = requests.get(
                "http://connect.garmin.com/proxy/activity-service-1.1/gpx/activity/"
                + str(activityID) + "?full=true",
                cookies=cookies)
            try:
                temp_act = GPXIO.Parse(res.content,
                                       suppress_validity_errors=True)
            except ValueError as e:
                pass
            else:
                logger.debug("Merging additional temperature data")
                full_waypoints = activity.GetFlatWaypoints()
                temp_waypoints = temp_act.GetFlatWaypoints()

                merge_idx = 0

                for x in range(len(temp_waypoints)):
                    while full_waypoints[merge_idx].Timestamp < temp_waypoints[
                            x].Timestamp and merge_idx < len(
                                full_waypoints) - 1:
                        merge_idx += 1
                    full_waypoints[merge_idx].Temp = temp_waypoints[x].Temp

        return activity

    def UploadActivity(self, serviceRecord, activity):
        #/proxy/upload-service-1.1/json/upload/.fit
        fit_file = FITIO.Dump(activity)
        files = {
            "data":
            ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".fit",
             fit_file)
        }
        cookies = self._get_cookies(record=serviceRecord)
        self._rate_limit()
        res = requests.post(
            "http://connect.garmin.com/proxy/upload-service-1.1/json/upload/.tcx",
            files=files,
            cookies=cookies)
        res = res.json()["detailedImportResult"]

        if len(res["successes"]) == 0:
            raise APIException("Unable to upload activity")
        if len(res["successes"]) > 1:
            raise APIException(
                "Uploaded succeeded, resulting in too many activities")
        actid = res["successes"][0]["internalId"]

        encoding_headers = {
            "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
        }  # GC really, really needs this part, otherwise it throws obscure errors like "Invalid signature for signature method HMAC-SHA1"
        warnings = []
        try:
            if activity.Name and activity.Name.strip():
                self._rate_limit()
                res = requests.post(
                    "http://connect.garmin.com/proxy/activity-service-1.2/json/name/"
                    + str(actid),
                    data={"value": activity.Name.encode("UTF-8")},
                    cookies=cookies,
                    headers=encoding_headers)
                try:
                    res = res.json()
                except:
                    raise APIWarning("Activity name request failed - %s" %
                                     res.text)
                if "display" not in res or res["display"][
                        "value"] != activity.Name:
                    raise APIWarning("Unable to set activity name")
        except APIWarning as e:
            warnings.append(e)

        try:
            if activity.Notes and activity.Notes.strip():
                self._rate_limit()
                res = requests.post(
                    "https://connect.garmin.com/proxy/activity-service-1.2/json/description/"
                    + str(actid),
                    data={"value": activity.Notes.encode("UTF-8")},
                    cookies=cookies,
                    headers=encoding_headers)
                try:
                    res = res.json()
                except:
                    raise APIWarning("Activity notes request failed - %s" %
                                     res.text)
                if "display" not in res or res["display"][
                        "value"] != activity.Notes:
                    raise APIWarning("Unable to set activity notes")
        except APIWarning as e:
            warnings.append(e)

        try:
            if activity.Type not in [
                    ActivityType.Running, ActivityType.Cycling,
                    ActivityType.Other
            ]:
                # Set the legit activity type - whatever it is, it's not supported by the TCX schema
                acttype = [
                    k for k, v in self._reverseActivityMappings.items()
                    if v == activity.Type
                ]
                if len(acttype) == 0:
                    raise APIWarning(
                        "GarminConnect does not support activity type " +
                        activity.Type)
                else:
                    acttype = acttype[0]
                self._rate_limit()
                res = requests.post(
                    "https://connect.garmin.com/proxy/activity-service-1.2/json/type/"
                    + str(actid),
                    data={"value": acttype},
                    cookies=cookies)
                res = res.json()
                if "activityType" not in res or res["activityType"][
                        "key"] != acttype:
                    raise APIWarning("Unable to set activity type")
        except APIWarning as e:
            warnings.append(e)

        if len(warnings):
            raise APIWarning(str(warnings))  # Meh
        return actid

    def RevokeAuthorization(self, serviceRecord):
        # nothing to do here...
        pass

    def DeleteCachedData(self, serviceRecord):
        # nothing cached...
        pass
Ejemplo n.º 13
0
class PolarPersonalTrainerService(ServiceBase):
    ID = "polarpersonaltrainer"
    DisplayName = "Polar Personal Trainer"
    DisplayAbbreviation = "PPT"
    AuthenticationType = ServiceAuthenticationType.UsernamePassword
    RequiresExtendedAuthorizationDetails = True

    # Will be retired by the end of 2019. Only need to transfer data to another services.
    ReceivesActivities = False

    _sessionCache = SessionCache("polarpersonaltrainer",
                                 lifetime=timedelta(minutes=30),
                                 freshen_on_get=False)

    # PPT - common
    # due to we can actually put any sport name in PPT detect some wery common as well as types I personally have
    # cycling # means different bike presets, hope 4 will cover most cases
    _reverseActivityMappings = {
        "cycling": ActivityType.Cycling,
        "cycling 2": ActivityType.Cycling,
        "cycling 3": ActivityType.Cycling,
        "cycling 4": ActivityType.Cycling,
        "road biking": ActivityType.Cycling,
        "running": ActivityType.Running,
        "indoor running": ActivityType.Running,
        "mtb": ActivityType.MountainBiking,
        "mountain biking": ActivityType.MountainBiking,
        "walking": ActivityType.Walking,
        "skiing": ActivityType.CrossCountrySkiing,
        "swimming": ActivityType.Swimming,
        "ows": ActivityType.Swimming,
        "other sport": ActivityType.Other
    }

    def _get_session(self,
                     record=None,
                     username=None,
                     password=None,
                     skip_cache=False):
        from tapiriik.auth.credential_storage import CredentialStore
        cached = self._sessionCache.Get(
            record.ExternalID if record else username)
        if cached and not skip_cache:
            logger.debug("Using cached credential")
            return cached
        if record:
            #  longing for C style overloads...
            password = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Password"])
            username = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Email"])

        session = requests.Session()

        data = {"username": username, "password": password}
        params = {
            "response_type": "code",
            "client_id": "ppt_client_id",
            "redirect_uri": "https://polarpersonaltrainer.com/oauth.ftl",
            "scope": "POLAR_SSO"
        }

        preResp = session.get("https://auth.polar.com/oauth/authorize",
                              params=params)
        if preResp.status_code != 200:
            raise APIException("SSO prestart error {} {}".format(
                preResp.status_code, preResp.text))

        # Extract csrf token
        bs = BeautifulSoup(preResp.text, "html.parser")
        csrftoken = bs.find("input", {"name": "_csrf"})["value"]
        data.update({"_csrf": csrftoken})
        ssoResp = session.post("https://auth.polar.com/login", data=data)
        if ssoResp.status_code != 200 or "temporarily unavailable" in ssoResp.text:
            raise APIException("SSO error {} {}".format(
                ssoResp.status_code, ssoResp.text))

        if "error" in ssoResp.url:
            raise APIException("Login exception {}".format(ssoResp.url),
                               user_exception=UserException(
                                   UserExceptionType.Authorization))

        # Finish auth process passing timezone
        session.get(ssoResp.url, params={"userTimezone": "-180"})

        session.get("https://polarpersonaltrainer.com/user/index.ftl")

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

        return session

    def Authorize(self, username, password):
        from tapiriik.auth.credential_storage import CredentialStore
        self._get_session(username=username,
                          password=password,
                          skip_cache=True)

        return (username, {}, {
            "Email": CredentialStore.Encrypt(username),
            "Password": CredentialStore.Encrypt(password)
        })

    def DownloadActivityList(self, serviceRecord, exhaustive=False):
        #TODO find out polar session timeout
        session = self._get_session(serviceRecord)

        activities = []
        exclusions = []

        date_format = "{d.day}.{d.month}.{d.year}"
        end_date = datetime.now() + timedelta(days=1.5)
        start_date = date(1961, 4,
                          12) if exhaustive else end_date - timedelta(days=60)
        params = {
            "startDate": date_format.format(d=start_date),
            "endDate": date_format.format(d=end_date)
        }
        res = session.get(
            "https://polarpersonaltrainer.com/user/calendar/inc/listview.ftl",
            params=params)

        bs = BeautifulSoup(res.text, "html.parser")
        for activity_row in bs.select("tr[class^=listRow]"):

            data_cells = activity_row.findAll("td")
            info_cell = 0
            date_cell = 4
            time_cell = 3
            result_type_cell = 5
            sport_type_cell = 6
            type_data = data_cells[info_cell].find(
                "input", {"name": "calendarItemTypes"})
            # Skip fitness data whatever
            if type_data["value"] == "OptimizedExercise":
                activity = UploadedActivity()

                id = data_cells[info_cell].find(
                    "input", {"name": "calendarItem"})["value"]
                name = data_cells[info_cell].find(
                    "input", {"name": "calendarItemName"})["value"]
                activity.ExternalID = id
                activity.Name = name

                time_text = "{} {}".format(data_cells[date_cell].contents[0],
                                           data_cells[time_cell].contents[0])
                activity.StartTime = pytz.utc.localize(
                    datetime.strptime(time_text, "%d.%m.%Y %H:%M"))

                result_type_text = data_cells[result_type_cell].contents[0]
                if "Strength Training Result" in result_type_text:
                    activity.Type = ActivityType.StrengthTraining
                    # This type of activity always stationary
                    activity.Stationary = True
                else:
                    type_text = data_cells[sport_type_cell].contents[0]
                    activity.Type = self._reverseActivityMappings.get(
                        type_text.lower(), ActivityType.Other)

                logger.debug("\tActivity s/t {}: {}".format(
                    activity.StartTime, activity.Type))
                activity.CalculateUID()
                activities.append(activity)

        return activities, exclusions

    def DownloadActivity(self, serviceRecord, activity):
        session = self._get_session(serviceRecord)

        url = "https://www.polarpersonaltrainer.com/user/calendar/"
        gpxUrl = "index.gpx"
        xmlUrl = "index.jxml"

        gpx_data = {
            ".action": "gpx",
            "items.0.item": activity.ExternalID,
            "items.0.itemType": "OptimizedExercise"
        }

        xml_data = {
            ".action": "export",
            "items.0.item": activity.ExternalID,
            "items.0.itemType": "OptimizedExercise",
            ".filename": "training.xml"
        }

        xmlResp = session.post(url + xmlUrl, data=xml_data)
        xmlText = xmlResp.text
        gpxResp = session.post(url + gpxUrl, data=gpx_data)
        if gpxResp.status_code == 401:
            logger.debug(
                "Problem completing request. Unauthorized. Activity extId = {}"
                .format(activity.ExternalID))
            raise APIException("Unknown authorization problem during request",
                               user_exception=UserException(
                                   UserExceptionType.DownloadError))

        gpxText = gpxResp.text
        activity.GPS = not ("The items you are exporting contain no GPS data"
                            in gpxText)

        tcxData = convert(xmlText, activity.StartTime,
                          gpxText if activity.GPS else None)
        activity = TCXIO.Parse(tcxData, activity)
        activity.SourceFile = SourceFile(tcxData.decode("utf-8"),
                                         ActivityFileType.TCX)

        return activity

    def RevokeAuthorization(self, serviceRecord):
        # nothing to do here...
        pass

    def DeleteCachedData(self, serviceRecord):
        # Nothing to delete
        pass

    def DeleteActivity(self, serviceRecord, uploadId):
        # Not supported
        pass

    def UploadActivity(self, serviceRecord, activity):
        # Not supported
        pass
Ejemplo n.º 14
0
class SportTracksService(ServiceBase):
    ID = "sporttracks"
    DisplayName = "SportTracks"
    AuthenticationType = ServiceAuthenticationType.UsernamePassword
    RequiresExtendedAuthorizationDetails = True
    OpenFitEndpoint = SPORTTRACKS_OPENFIT_ENDPOINT
    SupportsHR = True

    """ Other   Basketball
        Other   Boxing
        Other   Climbing
        Other   Driving
        Other   Flying
        Other   Football
        Other   Gardening
        Other   Kitesurf
        Other   Sailing
        Other   Soccer
        Other   Tennis
        Other   Volleyball
        Other   Windsurf
        Running Hashing
        Running Hills
        Running Intervals
        Running Orienteering
        Running Race
        Running Road
        Running Showshoe
        Running Speed
        Running Stair
        Running Track
        Running Trail
        Running Treadmill
        Cycling Hills
        Cycling Indoor
        Cycling Intervals
        Cycling Mountain
        Cycling Race
        Cycling Road
        Cycling Rollers
        Cycling Spinning
        Cycling Track
        Cycling Trainer
        Swimming    Open Water
        Swimming    Pool
        Swimming    Race
        Walking Geocaching
        Walking Hiking
        Walking Nordic
        Walking Photography
        Walking Snowshoe
        Walking Treadmill
        Skiing  Alpine
        Skiing  Nordic
        Skiing  Roller
        Skiing  Snowboard
        Rowing  Canoe
        Rowing  Kayak
        Rowing  Kitesurf
        Rowing  Ocean Kayak
        Rowing  Rafting
        Rowing  Rowing Machine
        Rowing  Sailing
        Rowing  Standup Paddling
        Rowing  Windsurf
        Skating Board
        Skating Ice
        Skating Inline
        Skating Race
        Skating Track
        Gym Aerobics
        Gym Elliptical
        Gym Plyometrics
        Gym Rowing Machine
        Gym Spinning
        Gym Stair Climber
        Gym Stationary Bike
        Gym Strength
        Gym Stretching
        Gym Treadmill
        Gym Yoga
    """

    _activityMappings = {
        "running": ActivityType.Running,
        "cycling": ActivityType.Cycling,
        "mountain": ActivityType.MountainBiking,
        "walking": ActivityType.Walking,
        "hiking": ActivityType.Hiking,
        "snowboarding": ActivityType.Snowboarding,
        "skiing": ActivityType.DownhillSkiing,
        "nordic": ActivityType.CrossCountrySkiing,
        "skating": ActivityType.Skating,
        "swimming": ActivityType.Swimming,
        "rowing": ActivityType.Rowing,
        "elliptical": ActivityType.Elliptical,
        "other": ActivityType.Other
    }

    _reverseActivityMappings = {
        ActivityType.Running: "running",
        ActivityType.Cycling: "cycling",
        ActivityType.Walking: "walking",
        ActivityType.MountainBiking: "cycling: mountain",
        ActivityType.Hiking: "walking: hiking",
        ActivityType.CrossCountrySkiing: "skiing: nordic",  #  Equipment.Bindings.IsToeOnly ??
        ActivityType.DownhillSkiing: "skiing",
        ActivityType.Snowboarding: "skiing: snowboarding",
        ActivityType.Skating: "skating",
        ActivityType.Swimming: "swimming",
        ActivityType.Rowing: "rowing",
        ActivityType.Elliptical: "gym: elliptical",
        ActivityType.Other: "other"
    }

    SupportedActivities = list(_reverseActivityMappings.keys())

    _sessionCache = SessionCache(lifetime=timedelta(minutes=30), freshen_on_get=True)

    def _get_cookies(self, record=None, email=None, password=None):
        return self._get_cookies_and_uid(record, email, password)[0]

    def _get_cookies_and_uid(self, record=None, email=None, password=None):
        from tapiriik.auth.credential_storage import CredentialStore
        if record:
            cached = self._sessionCache.Get(record.ExternalID)
            if cached:
                return cached
            password = CredentialStore.Decrypt(record.ExtendedAuthorization["Password"])
            email = CredentialStore.Decrypt(record.ExtendedAuthorization["Email"])
        params = {"username": email, "password": password}
        resp = requests.post(self.OpenFitEndpoint + "/user/login", data=json.dumps(params), allow_redirects=False, headers={"Accept": "application/json", "Content-Type": "application/json"})
        if resp.status_code != 200:
            raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))

        retval = (resp.cookies, int(resp.json()["user"]["uid"]))
        if record:
            self._sessionCache.Set(record.ExternalID, retval)
        return retval

    def WebInit(self):
        self.UserAuthorizationURL = WEB_ROOT + reverse("auth_simple", kwargs={"service": self.ID})

    def Authorize(self, email, password):
        from tapiriik.auth.credential_storage import CredentialStore
        cookies, uid = self._get_cookies_and_uid(email=email, password=password)
        return (uid, {}, {"Email": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password)})

    def RevokeAuthorization(self, serviceRecord):
        pass  # No auth tokens to revoke...

    def DeleteCachedData(self, serviceRecord):
        pass  # No cached data...

    def DownloadActivityList(self, serviceRecord, exhaustive=False):
        cookies = self._get_cookies(record=serviceRecord)
        activities = []
        exclusions = []
        pageUri = self.OpenFitEndpoint + "/fitnessActivities.json"
        while True:
            logger.debug("Req against " + pageUri)
            res = requests.get(pageUri, cookies=cookies)
            res = res.json()
            for act in res["items"]:
                activity = UploadedActivity()
                activity.UploadedTo = [{"Connection": serviceRecord, "ActivityURI": act["uri"]}]

                if len(act["name"].strip()):
                    activity.Name = act["name"]
                activity.StartTime = dateutil.parser.parse(act["start_time"])
                if isinstance(activity.StartTime.tzinfo, tzutc):
                    activity.TZ = pytz.utc # The dateutil tzutc doesn't have an _offset value.
                else:
                    activity.TZ = pytz.FixedOffset(activity.StartTime.tzinfo._offset.total_seconds() / 60)  # Convert the dateutil lame timezones into pytz awesome timezones.

                activity.StartTime = activity.StartTime.replace(tzinfo=activity.TZ)
                activity.EndTime = activity.StartTime + timedelta(seconds=float(act["duration"]))

                # Sometimes activities get returned with a UTC timezone even when they are clearly not in UTC.
                if activity.TZ == pytz.utc:
                    # So, we get the first location in the activity and calculate the TZ from that.
                    try:
                        firstLocation = self._downloadActivity(serviceRecord, activity, returnFirstLocation=True)
                    except APIExcludeActivity:
                        pass
                    else:
                        activity.CalculateTZ(firstLocation)
                        activity.AdjustTZ()

                logger.debug("Activity s/t " + str(activity.StartTime))
                activity.Distance = float(act["total_distance"])

                types = [x.strip().lower() for x in act["type"].split(":")]
                types.reverse()  # The incoming format is like "walking: hiking" and we want the most specific first
                activity.Type = None
                for type_key in types:
                    if type_key in self._activityMappings:
                        activity.Type = self._activityMappings[type_key]
                        break
                if not activity.Type:
                    exclusions.append(APIExcludeActivity("Unknown activity type %s" % act["type"], activityId=act["uri"]))
                    continue

                activity.CalculateUID()
                activities.append(activity)
            if not exhaustive or "next" not in res or not len(res["next"]):
                break
            else:
                pageUri = res["next"]
        return activities, exclusions

    def _downloadActivity(self, serviceRecord, activity, returnFirstLocation=False):
        activityURI = [x["ActivityURI"] for x in activity.UploadedTo if x["Connection"] == serviceRecord][0]
        cookies = self._get_cookies(record=serviceRecord)
        activityData = requests.get(activityURI, cookies=cookies)
        activityData = activityData.json()
        if "location" not in activityData:
            raise APIExcludeActivity("No points")

        timerStops = []
        if "timer_stops" in activityData:
            for stop in activityData["timer_stops"]:
                timerStops.append([dateutil.parser.parse(stop[0]), dateutil.parser.parse(stop[1])])

        def isInTimerStop(timestamp):
            for stop in timerStops:
                if timestamp >= stop[0] and timestamp < stop[1]:
                    return True
                if timestamp >= stop[1]:
                    return False
            return False

        laps = []
        if "laps" in activityData:
            for lap in activityData["laps"]:
                laps.append(dateutil.parser.parse(lap["start_time"]))
        # Collate the individual streams into our waypoints.
        # Everything is resampled by nearest-neighbour to the rate of the location stream.
        parallel_indices = {}
        parallel_stream_lengths = {}
        for secondary_stream in ["elevation", "heartrate", "power", "cadence"]:
            if secondary_stream in activityData:
                parallel_indices[secondary_stream] = 0
                parallel_stream_lengths[secondary_stream] = len(activityData[secondary_stream])

        activity.Waypoints = []
        wasInPause = False
        currentLapIdx = 0
        for idx in range(0, len(activityData["location"]), 2):
            # Pick the nearest indices in the parallel streams
            for parallel_stream, parallel_index in parallel_indices.items():
                if parallel_index + 2 == parallel_stream_lengths[parallel_stream]:
                    continue  # We're at the end of this stream
                # Is the next datapoint a better choice than the current?
                if abs(activityData["location"][idx] - activityData[parallel_stream][parallel_index + 2]) < abs(activityData["location"][idx] - activityData[parallel_stream][parallel_index]):
                    parallel_indices[parallel_stream] += 2

            waypoint = Waypoint(activity.StartTime + timedelta(0, activityData["location"][idx]))
            waypoint.Location = Location(activityData["location"][idx+1][0], activityData["location"][idx+1][1], None)
            if "elevation" in parallel_indices:
                waypoint.Location.Altitude = activityData["elevation"][parallel_indices["elevation"]+1]

            if returnFirstLocation:
                return waypoint.Location

            if "heartrate" in parallel_indices:
                waypoint.HR = activityData["heartrate"][parallel_indices["heartrate"]+1]

            if "power" in parallel_indices:
                waypoint.Power = activityData["power"][parallel_indices["power"]+1]

            if "cadence" in parallel_indices:
                waypoint.Cadence = activityData["cadence"][parallel_indices["cadence"]+1]


            inPause = isInTimerStop(waypoint.Timestamp)
            waypoint.Type = WaypointType.Regular if not inPause else WaypointType.Pause
            if wasInPause and not inPause:
                waypoint.Type = WaypointType.Resume
            wasInPause = inPause

            # We only care if it's possible to start a new lap, i.e. there are more left
            if currentLapIdx + 1 < len(laps):
                if laps[currentLapIdx + 1] < waypoint.Timestamp:
                    # A new lap has started
                    waypoint.Type = WaypointType.Lap
                    currentLapIdx += 1

            activity.Waypoints.append(waypoint)

        if returnFirstLocation:
            return None  # I guess there were no waypoints?

        activity.Waypoints[0].Type = WaypointType.Start
        activity.Waypoints[-1].Type = WaypointType.End
        return activity

    def DownloadActivity(self, serviceRecord, activity):
        return self._downloadActivity(serviceRecord, activity)

    def UploadActivity(self, serviceRecord, activity):
        activity.EnsureTZ()
        activityData = {}
        # Props to the SportTracks API people for seamlessly supprting activities with or without TZ data.
        activityData["start_time"] = activity.StartTime.isoformat()
        if activity.Name:
            activityData["name"] = activity.Name

        activityData["type"] = self._reverseActivityMappings[activity.Type]

        lap_starts = []
        timer_stops = []
        timer_stopped_at = None

        def stream_append(stream, wp, data):
            stream += [int((wp.Timestamp - activity.StartTime).total_seconds()), data]

        location_stream = []
        elevation_stream = []
        heartrate_stream = []
        power_stream = []
        cadence_stream = []
        for wp in activity.Waypoints:
            if wp.Location and wp.Location.Latitude and wp.Location.Longitude:
                stream_append(location_stream, wp, [wp.Location.Latitude, wp.Location.Longitude])
            if wp.HR:
                stream_append(heartrate_stream, wp, int(wp.HR))
            if wp.Cadence:
                stream_append(cadence_stream, wp, int(wp.Cadence))
            if wp.Power:
                stream_append(power_stream, wp, wp.Power)
            if wp.Location and wp.Location.Altitude:
                stream_append(elevation_stream, wp, wp.Location.Altitude)
            if wp.Type == WaypointType.Lap:
                lap_starts.append(wp.Timestamp)
            if wp.Type == WaypointType.Pause and not timer_stopped_at:
                timer_stopped_at = wp.Timestamp
            if wp.Type != WaypointType.Pause and timer_stopped_at:
                timer_stops.append([timer_stopped_at, wp.Timestamp])
                timer_stopped_at = None

        activityData["elevation"] = elevation_stream
        activityData["heartrate"] = heartrate_stream
        activityData["power"] = power_stream
        activityData["cadence"] = cadence_stream
        activityData["location"] = location_stream
        activityData["laps"] = [{"start_time": x.isoformat()} for x in lap_starts]
        activityData["timer_stops"] = [[y.isoformat() for y in x] for x in timer_stops]

        cookies = self._get_cookies(record=serviceRecord)
        upload_resp = requests.post(self.OpenFitEndpoint + "/fitnessActivities.json", data=json.dumps(activityData), cookies=cookies, headers={"Content-Type": "application/json"})
        if upload_resp.status_code != 200:
            if upload_resp.status_code == 401:
                raise APIException("ST.mobi trial expired", block=True, user_exception=UserException(UserExceptionType.AccountExpired, intervention_required=True))
            raise APIException("Unable to upload activity %s" % upload_resp.text)
Ejemplo n.º 15
0
class EndomondoService(ServiceBase):
    ID = "endomondo"
    DisplayName = "Endomondo"
    AuthenticationType = ServiceAuthenticationType.UsernamePassword
    RequiresExtendedAuthorizationDetails = True
    UserProfileURL = "http://www.endomondo.com/profile/{0}"
    UserActivityURL = "http://www.endomondo.com/workouts/{1}/{0}"

    _sessionCache = SessionCache(lifetime=timedelta(minutes=30),
                                 freshen_on_get=True)

    _activityMappings = {
        0: ActivityType.Running,
        2: ActivityType.
        Cycling,  # the order of these matters since it picks the first match for uploads
        1: ActivityType.Cycling,
        3: ActivityType.MountainBiking,
        4: ActivityType.Skating,
        6: ActivityType.CrossCountrySkiing,
        7: ActivityType.DownhillSkiing,
        8: ActivityType.Snowboarding,
        11: ActivityType.Rowing,
        9: ActivityType.Rowing,  # canoeing
        18: ActivityType.Walking,
        14: ActivityType.Walking,  # fitness walking
        16: ActivityType.Hiking,
        17: ActivityType.Hiking,  # orienteering
        20: ActivityType.Swimming,
        40: ActivityType.Swimming,  # scuba diving
        22: ActivityType.Other,
        92: ActivityType.Wheelchair
    }

    _reverseActivityMappings = {  # so that ambiguous events get mapped back to reasonable types
        0: ActivityType.Running,
        2: ActivityType.Cycling,
        3: ActivityType.MountainBiking,
        4: ActivityType.Skating,
        6: ActivityType.CrossCountrySkiing,
        7: ActivityType.DownhillSkiing,
        8: ActivityType.Snowboarding,
        11: ActivityType.Rowing,
        18: ActivityType.Walking,
        16: ActivityType.Hiking,
        20: ActivityType.Swimming,
        22: ActivityType.Other,
        92: ActivityType.Wheelchair
    }

    SupportedActivities = list(_activityMappings.values())
    SupportsHR = True
    SupportsCalories = False  # not inside the activity? p.sure it calculates this after the fact anyways

    def WebInit(self):
        self.UserAuthorizationURL = WEB_ROOT + reverse(
            "auth_simple", kwargs={"service": "endomondo"})

    def _parseKVP(self, data):
        out = {}
        for line in data.split("\n"):
            if line == "OK":
                continue
            match = re.match("(?P<key>[^=]+)=(?P<val>.+)$", line)
            if match is None:
                continue
            out[match.group("key")] = match.group("val")
        return out

    def _get_web_cookies(self, record=None, email=None, password=None):
        from tapiriik.auth.credential_storage import CredentialStore
        if record:
            cached = self._sessionCache.Get(record.ExternalID)
            if cached:
                return cached
            password = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Password"])
            email = CredentialStore.Decrypt(
                record.ExtendedAuthorization["Email"])
        params = {"email": email, "password": password}
        resp = requests.post(
            "https://www.endomondo.com/access?wicket:interface=:1:pageContainer:lowerSection:lowerMain:lowerMainContent:signInPanel:signInFormPanel:signInForm::IFormSubmitListener::",
            data=params,
            allow_redirects=False)
        if resp.status_code >= 500 and resp.status_code < 600:
            raise APIException("Remote API failure")
        if resp.status_code != 302:  # yep
            raise APIException("Invalid login",
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.Authorization,
                                   intervention_required=True))
        if record:
            self._sessionCache.Set(record.ExternalID, resp.cookies)
        return resp.cookies

    def Authorize(self, email, password):
        from tapiriik.auth.credential_storage import CredentialStore
        params = {
            "email": email,
            "password": password,
            "v": "2.4",
            "action": "pair",
            "deviceId": "TAP-SYNC-" + email.lower(),
            "country": "N/A"
        }  # note to future self: deviceId can't change intra-account otherwise we'll get different tokens back

        resp = requests.get("https://api.mobile.endomondo.com/mobile/auth",
                            params=params)
        if resp.text.strip() == "USER_UNKNOWN" or resp.text.strip(
        ) == "USER_EXISTS_PASSWORD_WRONG":
            raise APIException("Invalid login",
                               block=True,
                               user_exception=UserException(
                                   UserExceptionType.Authorization,
                                   intervention_required=True))
        data = self._parseKVP(resp.text)

        return (data["userId"], {
            "AuthToken": data["authToken"],
            "SecureToken": data["secureToken"]
        }, {
            "Email": CredentialStore.Encrypt(email),
            "Password": CredentialStore.Encrypt(password)
        })

    def RevokeAuthorization(self, serviceRecord):
        #  you can't revoke the tokens endomondo distributes :\
        pass

    def _downloadRawTrackRecord(self, serviceRecord, trackId):
        params = {
            "authToken": serviceRecord.Authorization["AuthToken"],
            "trackId": trackId
        }
        response = requests.get(
            "http://api.mobile.endomondo.com/mobile/readTrack", params=params)
        return response.text

    def _populateActivityFromTrackData(self,
                                       activity,
                                       recordText,
                                       minimumWaypoints=False):
        lap = Lap()
        activity.Laps = [lap]
        ###       1ST RECORD      ###
        # userID;
        # timestamp - create date?;
        # type? W=1st
        # User name;
        # activity name;
        # activity type;
        # another timestamp - start time of event?;
        # duration.00;
        # distance (km);
        # kcal;
        #;
        # max alt;
        # min alt;
        # max HR;
        # avg HR;

        ###     TRACK RECORDS     ###
        # timestamp;
        # type (2=start, 3=end, 0=pause, 1=resume);
        # latitude;
        # longitude;
        #;
        #;
        # alt;
        # hr;
        wptsWithLocation = False
        wptsWithNonZeroAltitude = False
        rows = recordText.split("\n")
        for row in rows:
            if row == "OK" or len(row) == 0:
                continue
            split = row.split(";")
            if split[2] == "W":
                # init record
                lap.Stats.TimerTime = ActivityStatistic(
                    ActivityStatisticUnit.Time,
                    value=timedelta(
                        seconds=float(split[7])) if split[7] != "" else None)
                lap.Stats.Distance = ActivityStatistic(
                    ActivityStatisticUnit.Kilometers,
                    value=float(split[8]) if split[8] != "" else None)
                lap.Stats.HR = ActivityStatistic(
                    ActivityStatisticUnit.BeatsPerMinute,
                    avg=float(split[14]) if split[14] != "" else None,
                    max=float(split[13]) if split[13] != "" else None)
                lap.Stats.Elevation = ActivityStatistic(
                    ActivityStatisticUnit.Meters,
                    min=float(split[12]) if split[12] != "" else None,
                    max=float(split[11]) if split[11] != "" else None)
                lap.Stats.Energy = ActivityStatistic(
                    ActivityStatisticUnit.Kilocalories,
                    value=float(split[12]) if split[12] != "" else None)
                activity.Stats.update(lap.Stats)
                lap.Stats = activity.Stats
                activity.Name = split[4]
            else:
                wp = Waypoint()
                if split[1] == "2":
                    wp.Type = WaypointType.Start
                elif split[1] == "3":
                    wp.Type = WaypointType.End
                elif split[1] == "0":
                    wp.Type = WaypointType.Pause
                elif split[1] == "1":
                    wp.Type = WaypointType.Resume
                else:
                    wp.Type == WaypointType.Regular

                if split[0] == "":
                    continue  # no timestamp, for whatever reason
                wp.Timestamp = pytz.utc.localize(
                    datetime.strptime(split[0], "%Y-%m-%d %H:%M:%S UTC")
                )  # it's like this as opposed to %z so I know when they change things (it'll break)
                if split[2] != "":
                    wp.Location = Location(float(split[2]), float(split[3]),
                                           None)
                    if wp.Location.Longitude > 180 or wp.Location.Latitude > 90 or wp.Location.Longitude < -180 or wp.Location.Latitude < -90:
                        raise APIExcludeActivity("Out of range lat/lng")
                    if wp.Location.Latitude is not None and wp.Location.Latitude is not None:
                        wptsWithLocation = True
                    if split[6] != "":
                        wp.Location.Altitude = float(
                            split[6])  # why this is missing: who knows?
                        if wp.Location.Altitude != 0:
                            wptsWithNonZeroAltitude = True

                if split[7] != "":
                    wp.HR = float(split[7])
                lap.Waypoints.append(wp)
                if wptsWithLocation and minimumWaypoints:
                    break
        lap.Waypoints = sorted(activity.Waypoints, key=lambda v: v.Timestamp)
        if wptsWithLocation:
            if not wptsWithNonZeroAltitude:  # do this here so, should the activity run near sea level, altitude data won't be spotty
                for x in lap.Waypoints:  # clear waypoints of altitude data if all of them were logged at 0m (invalid)
                    if x.Location is not None:
                        x.Location.Altitude = None
        else:
            lap.Waypoints = []  # practically speaking

    def DownloadActivityList(self, serviceRecord, exhaustive=False):

        activities = []
        exclusions = []
        earliestDate = None
        earliestFirstPageDate = None
        paged = False

        while True:
            before = "" if earliestDate is None else earliestDate.astimezone(
                pytz.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
            params = {
                "authToken": serviceRecord.Authorization["AuthToken"],
                "maxResults": 45,
                "before": before
            }
            logger.debug("Req with " + str(params))
            response = requests.get(
                "http://api.mobile.endomondo.com/mobile/api/workout/list",
                params=params)

            if response.status_code != 200:
                if response.status_code == 401 or response.status_code == 403:
                    raise APIException(
                        "No authorization to retrieve activity list",
                        block=True,
                        user_exception=UserException(
                            UserExceptionType.Authorization,
                            intervention_required=True))
                raise APIException("Unable to retrieve activity list " +
                                   str(response))
            data = response.json()

            if "error" in data and data["error"]["type"] == "AUTH_FAILED":
                raise APIException(
                    "No authorization to retrieve activity list",
                    block=True,
                    user_exception=UserException(
                        UserExceptionType.Authorization,
                        intervention_required=True))

            track_ids = []
            this_page_activities = []
            for act in data["data"]:
                startTime = pytz.utc.localize(
                    datetime.strptime(act["start_time"],
                                      "%Y-%m-%d %H:%M:%S UTC"))
                if earliestDate is None or startTime < earliestDate:  # probably redundant, I would assume it works out the TZes...
                    earliestDate = startTime
                logger.debug("activity pre")
                if "tracking" in act and act["tracking"]:
                    logger.warning("\t tracking")
                    exclusions.append(
                        APIExcludeActivity("In progress",
                                           activityId=act["id"],
                                           permanent=False))
                    continue  # come back once they've completed the activity
                track_ids.append(act["id"])
                activity = UploadedActivity()
                activity.StartTime = startTime
                activity.EndTime = activity.StartTime + timedelta(
                    0, round(act["duration_sec"]))
                logger.debug("\tActivity s/t " + str(activity.StartTime))

                activity.Stationary = not act["has_points"]

                if int(act["sport"]) in self._activityMappings:
                    activity.Type = self._activityMappings[int(act["sport"])]
                activity.ServiceData = {"ActivityID": act["id"]}

                this_page_activities.append(activity)
            cached_track_tzs = cachedb.endomondo_activity_cache.find(
                {"TrackID": {
                    "$in": track_ids
                }})
            cached_track_tzs = dict([(x["TrackID"], x)
                                     for x in cached_track_tzs])
            logger.debug("Have" + str(len(cached_track_tzs.keys())) + "/" +
                         str(len(track_ids)) + " cached TZ records")

            for activity in this_page_activities:
                # attn service makers: why #(*%$ can't you all agree to use naive local time. So much simpler.
                cachedTrackData = None
                track_id = activity.ServiceData["ActivityID"]

                if track_id not in cached_track_tzs:
                    logger.debug("\t Resolving TZ for %s" % activity.StartTime)
                    cachedTrackData = self._downloadRawTrackRecord(
                        serviceRecord, track_id)
                    try:
                        self._populateActivityFromTrackData(
                            activity, cachedTrackData, minimumWaypoints=True)
                    except APIExcludeActivity as e:
                        e.ExternalActivityID = track_id
                        logger.info("Encountered APIExcludeActivity %s" %
                                    str(e))
                        exclusions.append(e)
                        continue

                    if not activity.TZ and not activity.Stationary:
                        logger.info("Couldn't determine TZ")
                        exclusions.append(
                            APIExcludeActivity("Couldn't determine TZ",
                                               activityId=track_id))
                        continue
                    cachedTrackRecord = {
                        "Owner": serviceRecord.ExternalID,
                        "TrackID": track_id,
                        "TZ": pickle.dumps(activity.TZ),
                        "StartTime": activity.StartTime
                    }
                    cachedb.endomondo_activity_cache.insert(cachedTrackRecord)
                elif not activity.Stationary:
                    activity.TZ = pickle.loads(
                        cached_track_tzs[track_id]["TZ"])
                    activity.AdjustTZ()  # Everything returned is in UTC

                activity.Laps = []
                if int(act["sport"]) in self._activityMappings:
                    activity.Type = self._activityMappings[int(act["sport"])]

                activity.ServiceData = {
                    "ActivityID": act["id"],
                    "ActivityData": cachedTrackData
                }
                activity.CalculateUID()
                activities.append(activity)

            if not paged:
                earliestFirstPageDate = earliestDate
            if not exhaustive or ("more" in data and data["more"] is False):
                break
            else:
                paged = True
        return activities, exclusions

    def DownloadActivity(self, serviceRecord, activity):
        trackData = activity.ServiceData["ActivityData"]

        if not trackData:
            # If this is a new activity, we will already have the track data, otherwise download it.
            trackData = self._downloadRawTrackRecord(
                serviceRecord, activity.ServiceData["ActivityID"])

        self._populateActivityFromTrackData(activity, trackData)

        cookies = self._get_web_cookies(record=serviceRecord)
        summary_page = requests.get("http://www.endomondo.com/workouts/%d" %
                                    activity.ServiceData["ActivityID"],
                                    cookies=cookies)

        def _findStat(name):
            nonlocal summary_page
            result = re.findall(
                '<li class="' + name +
                '">.+?<span class="value">([^<]+)</span>', summary_page.text,
                re.DOTALL)
            return result[0] if len(result) else None

        def _mapStat(name, statKey, type):
            nonlocal activity
            _unitMap = {
                "mi": ActivityStatisticUnit.Miles,
                "km": ActivityStatisticUnit.Kilometers,
                "kcal": ActivityStatisticUnit.Kilocalories,
                "ft": ActivityStatisticUnit.Feet,
                "m": ActivityStatisticUnit.Meters,
                "rpm": ActivityStatisticUnit.RevolutionsPerMinute,
                "avg-hr": ActivityStatisticUnit.BeatsPerMinute,
                "max-hr": ActivityStatisticUnit.BeatsPerMinute,
            }
            statValue = _findStat(name)
            if statValue:
                statUnit = statValue.split(
                    " ")[1] if " " in statValue else None
                unit = _unitMap[statUnit] if statUnit else _unitMap[name]
                statValue = statValue.split(" ")[0]
                valData = {type: float(statValue)}
                activity.Stats.__dict__[statKey].update(
                    ActivityStatistic(unit, **valData))

        _mapStat("max-hr", "HR", "max")
        _mapStat("avg-hr", "HR", "avg")
        _mapStat("calories", "Kilocalories", "value")
        _mapStat("elevation-asc", "Elevation", "gain")
        _mapStat("elevation-desc", "Elevation", "loss")
        _mapStat("cadence", "Cadence", "avg")  # I would presume?
        _mapStat("distance", "Distance", "value")  # I would presume?

        notes = re.findall('<div class="notes editable".+?<p>(.+?)</p>',
                           summary_page.text)
        if len(notes):
            activity.Notes = notes[0]

        return activity

    def UploadActivity(self, serviceRecord, activity):

        cookies = self._get_web_cookies(record=serviceRecord)
        # Wicket sucks sucks sucks sucks sucks sucks.
        # Step 0
        #   http://www.endomondo.com/?wicket:bookmarkablePage=:com.endomondo.web.page.workout.CreateWorkoutPage2
        #   Get URL of file upload
        #       <a href="#" id="id13a" onclick="var wcall=wicketAjaxGet('?wicket:interface=:8:pageContainer:lowerSection:lowerMain:lowerMainContent:importFileLink::IBehaviorListener:0:',function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$('id13a') != null;}.bind(this));return !wcall;">...                    <div class="fileImport"></div>
        upload_select = requests.get(
            "http://www.endomondo.com/?wicket:bookmarkablePage=:com.endomondo.web.page.workout.CreateWorkoutPage2",
            cookies=cookies)
        upload_lightbox_url = re.findall(
            '<a.+?onclick="var wcall=wicketAjaxGet\(\'(.+?)\'',
            upload_select.text)[3]
        logger.debug("Will request upload lightbox from %s" %
                     upload_lightbox_url)
        # Step 1
        #   http://www.endomondo.com/upload-form-url
        #   Get IFrame src
        upload_iframe = requests.get("http://www.endomondo.com/" +
                                     upload_lightbox_url,
                                     cookies=cookies)
        upload_iframe_src = re.findall('src="(.+?)"', upload_iframe.text)[0]
        logger.debug("Will request upload form from %s" % upload_iframe_src)
        # Step 2
        #   http://www.endomondo.com/iframe-url
        #   Follow redirect to upload page
        #   Get form ID
        #   Get form target from <a class="next" name="uploadSumbit" id="id18d" value="Next" onclick="document.getElementById('fileUploadWaitIcon').style.display='block';var wcall=wicketSubmitFormById('id18c', '?wicket:interface=:13:importPanel:wizardStepPanel:uploadForm:uploadSumbit::IActivePageBehaviorListener:0:-1&amp;wicket:ignoreIfNotActive=true', 'uploadSumbit' ,function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$$(this)&amp;&amp;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&amp;wicket:ignoreIfNotActive=true', 'reviewSumbit' ,function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$$(this)&amp;&amp;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&amp;wicket:ignoreIfNotActive=true', 'saveButton' ,function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$$(this)&amp;&amp;Wicket.$$('id1d4')}.bind(this));;; return false;">Save</a>
        edit_page = requests.get("http://www.endomondo.com/" + edit_url,
                                 cookies=cookies)
        edit_form_id = re.findall('<form.+?id="([^"]+)"', edit_page.text)[0]
        edit_form_target = re.findall(
            "wicketSubmitFormById\('[^']+', '([^']+)'", edit_page.text)[0]
        logger.debug("Will POST edit form ID %s to %s" %
                     (edit_form_id, edit_form_target))
        # Step 8
        #   http://www.endomondo.com/edit-finish-url
        #   POST
        #       id34e_hf_0
        #       sport: X
        #       name: name123
        #       startTime:YYYY-MM-DD HH:MM
        #       distance:1.00 km
        #       duration:0h:10m:00s
        #       metersAscent:
        #       metersDescent:
        #       averageHeartRate:30
        #       maximumHeartRate:100
        #       validityToggle:on ("include in statistics")
        #       calorieRecomputeToggle:on
        #       notes:asdasdasd
        #       saveButton:1
        duration = (activity.EndTime - activity.StartTime)
        duration_formatted = "%dh:%dm:%ds" % (duration.seconds / 3600,
                                              duration.seconds % 3600 / 60,
                                              duration.seconds % (60))
        data = {
            edit_form_id + "_hf_0":
            "",
            "saveButton":
            "1",
            "validityToggle":
            "on",
            "calorieRecomputeToggle":
            "on",
            "startTime":
            activity.StartTime.strftime("%Y-%m-%d %H:%M"),
            "distance":
            "%s km" % activity.Stats.Distance.asUnits(
                ActivityStatisticUnit.Kilometers).Value,
            "sport":
            sportId,
            "duration":
            duration_formatted,
            "name":
            activity.Name,
        }
        if activity.Stats.Elevation.Gain is not None:
            data["metersAscent"] = int(round(activity.Stats.Elevation.Gain))
        if activity.Stats.Elevation.Gain is not None:
            data["metersDescent"] = int(round(activity.Stats.Elevation.Loss))
        if activity.Stats.HR.Average is not None:
            data["averageHeartRate"] = int(round(activity.Stats.HR.Average))
        if activity.Stats.HR.Max is not None:
            data["maximumHeartRate"] = int(round(activity.Stats.HR.Max))
        edit_result = requests.post("http://www.endomondo.com/" +
                                    edit_form_target,
                                    data=data,
                                    cookies=cookies)
        assert edit_result.status_code == 200 and "feedbackPanelERROR" not in edit_result.text

    def DeleteCachedData(self, serviceRecord):
        cachedb.endomondo_activity_cache.remove(
            {"Owner": serviceRecord.ExternalID})
Ejemplo n.º 16
0
class GarminConnectService(ServiceBase):
    ID = "garminconnect"
    DisplayName = "Garmin Connect"
    AuthenticationType = ServiceAuthenticationType.UsernamePassword
    RequiresExtendedAuthorizationDetails = True

    _activityMappings = {
                                "running": ActivityType.Running,
                                "cycling": ActivityType.Cycling,
                                "mountain_biking": ActivityType.MountainBiking,
                                "walking": ActivityType.Walking,
                                "hiking": ActivityType.Hiking,
                                "resort_skiing_snowboarding": ActivityType.DownhillSkiing,
                                "cross_country_skiing": ActivityType.CrossCountrySkiing,
                                "backcountry_skiing_snowboarding": ActivityType.CrossCountrySkiing,  # ish
                                "skating": ActivityType.Skating,
                                "swimming": ActivityType.Swimming,
                                "rowing": ActivityType.Rowing,
                                "elliptical": ActivityType.Elliptical,
                                "all": ActivityType.Other  # everything will eventually resolve to this
    }

    _reverseActivityMappings = {  # Removes ambiguities when mapping back to their activity types
                                "running": ActivityType.Running,
                                "cycling": ActivityType.Cycling,
                                "mountain_biking": ActivityType.MountainBiking,
                                "walking": ActivityType.Walking,
                                "hiking": ActivityType.Hiking,
                                "resort_skiing_snowboarding": ActivityType.DownhillSkiing,
                                "cross_country_skiing": ActivityType.CrossCountrySkiing,
                                "skating": ActivityType.Skating,
                                "swimming": ActivityType.Swimming,
                                "rowing": ActivityType.Rowing,
                                "elliptical": ActivityType.Elliptical,
                                "other": ActivityType.Other  # I guess? (vs. "all" that is)
    }

    SupportedActivities = list(_activityMappings.values())

    SupportsHR = SupportsCadence = True

    _sessionCache = SessionCache(lifetime=timedelta(minutes=30), freshen_on_get=True)

    def __init__(self):
        self._activityHierarchy = requests.get("http://connect.garmin.com/proxy/activity-service-1.2/json/activity_types").json()["dictionary"]

    def _get_cookies(self, record=None, email=None, password=None):
        from tapiriik.auth.credential_storage import CredentialStore
        if record:
            cached = self._sessionCache.Get(record.ExternalID)
            if cached:
                return cached
            #  longing for C style overloads...
            password = CredentialStore.Decrypt(record.ExtendedAuthorization["Password"])
            email = CredentialStore.Decrypt(record.ExtendedAuthorization["Email"])
        params = {"login": "******", "login:loginUsernameField": email, "login:password": password, "login:signInButton": "Sign In", "javax.faces.ViewState": "j_id1"}
        preResp = requests.get("https://connect.garmin.com/signin")
        resp = requests.post("https://connect.garmin.com/signin", data=params, allow_redirects=False, cookies=preResp.cookies)
        if resp.status_code >= 500 and resp.status_code<600:
            raise APIException("Remote API failure")
        if resp.status_code != 302:  # yep
            raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))
        if record:
            self._sessionCache.Set(record.ExternalID, preResp.cookies)
        return preResp.cookies

    def WebInit(self):
        self.UserAuthorizationURL = WEB_ROOT + reverse("auth_simple", kwargs={"service": self.ID})

    def Authorize(self, email, password):
        from tapiriik.auth.credential_storage import CredentialStore
        cookies = self._get_cookies(email=email, password=password)
        username = requests.get("http://connect.garmin.com/user/username", cookies=cookies).json()["username"]
        if not len(username):
            raise APIException("Unable to retrieve username", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True))
        return (username, {}, {"Email": CredentialStore.Encrypt(email), "Password": CredentialStore.Encrypt(password)})


    def _resolveActivityType(self, act_type):
        # Mostly there are two levels of a hierarchy, so we don't really need this as the parent is included in the listing.
        # But maybe they'll change that some day?
        while act_type not in self._activityMappings:
            try:
                act_type = [x["parent"]["key"] for x in self._activityHierarchy if x["key"] == act_type][0]
            except IndexError:
                raise ValueError("Activity type not found in activity hierarchy")
        return self._activityMappings[act_type]

    def DownloadActivityList(self, serviceRecord, exhaustive=False):
        #http://connect.garmin.com/proxy/activity-search-service-1.0/json/activities?&start=0&limit=50
        cookies = self._get_cookies(record=serviceRecord)
        page = 1
        pageSz = 50
        activities = []
        exclusions = []
        while True:
            logger.debug("Req with " + str({"start": (page - 1) * pageSz, "limit": pageSz}))
            res = requests.get("http://connect.garmin.com/proxy/activity-search-service-1.0/json/activities", params={"start": (page - 1) * pageSz, "limit": pageSz}, cookies=cookies)
            res = res.json()["results"]
            if "activities" not in res:
                break  # No activities on this page - empty account.
            for act in res["activities"]:
                act = act["activity"]
                if "beginLatitude" not in act or "endLatitude" not in act or (act["beginLatitude"] is act["endLatitude"] and act["beginLongitude"] is act["endLongitude"]):
                    exclusions.append(APIExcludeActivity("No points", activityId=act["activityId"]))
                    continue
                if "sumDistance" not in act:
                    exclusions.append(APIExcludeActivity("No distance", activityId=act["activityId"]))
                    continue
                activity = UploadedActivity()

                try:
                    activity.TZ = pytz.timezone(act["activityTimeZone"]["key"])
                except pytz.exceptions.UnknownTimeZoneError:
                    activity.TZ = pytz.FixedOffset(float(act["activityTimeZone"]["offset"]) * 60)

                logger.debug("Name " + act["activityName"]["value"] + ":")
                if len(act["activityName"]["value"].strip()) and act["activityName"]["value"] != "Untitled":
                    activity.Name = act["activityName"]["value"]
                # beginTimestamp/endTimestamp is in UTC
                activity.StartTime = pytz.utc.localize(datetime.utcfromtimestamp(float(act["beginTimestamp"]["millis"])/1000))
                if "sumElapsedDuration" in act:
                    activity.EndTime = activity.StartTime + timedelta(0, round(float(act["sumElapsedDuration"]["value"])))
                elif "sumDuration" in act:
                    activity.EndTime = activity.StartTime + timedelta(minutes=float(act["sumDuration"]["minutesSeconds"].split(":")[0]), seconds=float(act["sumDuration"]["minutesSeconds"].split(":")[1]))
                else:
                    activity.EndTime = pytz.utc.localize(datetime.utcfromtimestamp(float(act["endTimestamp"]["millis"])/1000))
                logger.debug("Activity s/t " + str(activity.StartTime) + " on page " + str(page))
                activity.AdjustTZ()
                # TODO: fix the distance stats to account for the fact that this incorrectly reported km instead of meters for the longest time.
                activity.Distance = float(act["sumDistance"]["value"]) * (1.60934 if act["sumDistance"]["uom"] == "mile" else 1) * 1000  # In meters...
                activity.Type = self._resolveActivityType(act["activityType"]["key"])

                activity.CalculateUID()
                activity.UploadedTo = [{"Connection": serviceRecord, "ActivityID": act["activityId"]}]
                activities.append(activity)
            logger.debug("Finished page " + str(page) + " of " + str(res["search"]["totalPages"]))
            if not exhaustive or int(res["search"]["totalPages"]) == page:
                break
            else:
                page += 1
        return activities, exclusions

    def DownloadActivity(self, serviceRecord, activity):
        #http://connect.garmin.com/proxy/activity-service-1.1/tcx/activity/#####?full=true
        activityID = [x["ActivityID"] for x in activity.UploadedTo if x["Connection"] == serviceRecord][0]
        cookies = self._get_cookies(record=serviceRecord)
        res = requests.get("http://connect.garmin.com/proxy/activity-service-1.1/tcx/activity/" + str(activityID) + "?full=true", cookies=cookies)
        try:
            TCXIO.Parse(res.content, activity)
        except ValueError as e:
            raise APIExcludeActivity("TCX parse error " + str(e))

        return activity

    def UploadActivity(self, serviceRecord, activity):
        #/proxy/upload-service-1.1/json/upload/.tcx
        activity.EnsureTZ()
        tcx_file = TCXIO.Dump(activity)
        files = {"data": ("tap-sync-" + str(os.getpid()) + "-" + activity.UID + ".tcx", tcx_file)}
        cookies = self._get_cookies(record=serviceRecord)
        res = requests.post("http://connect.garmin.com/proxy/upload-service-1.1/json/upload/.tcx", files=files, cookies=cookies)
        res = res.json()["detailedImportResult"]

        if len(res["successes"]) != 1:
            raise APIException("Unable to upload activity")
        actid = res["successes"][0]["internalId"]

        if activity.Type not in [ActivityType.Running, ActivityType.Cycling, ActivityType.Other]:
            # Set the legit activity type - whatever it is, it's not supported by the TCX schema
            acttype = [k for k, v in self._reverseActivityMappings.items() if v == activity.Type]
            if len(acttype) == 0:
                raise APIWarning("GarminConnect does not support activity type " + activity.Type)
            else:
                acttype = acttype[0]
            res = requests.post("http://connect.garmin.com/proxy/activity-service-1.2/json/type/" + str(actid), data={"value": acttype}, cookies=cookies)
            res = res.json()
            if "activityType" not in res or res["activityType"]["key"] != acttype:
                raise APIWarning("Unable to set activity type")



    def RevokeAuthorization(self, serviceRecord):
        # nothing to do here...
        pass

    def DeleteCachedData(self, serviceRecord):
        # nothing cached...
        pass