Skip to content

Commit

Permalink
feat: add strava_to_garmin sync for #146
Browse files Browse the repository at this point in the history
  • Loading branch information
yihong0618 committed Jun 25, 2021
1 parent dc31a50 commit b11d846
Show file tree
Hide file tree
Showing 8 changed files with 206 additions and 13 deletions.
12 changes: 11 additions & 1 deletion .github/workflows/run_data_sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ on:

env:
# please change to your own config.
RUN_TYPE: pass # support strava/nike/garmin/garmin_cn/keep/only_gpx/nike_to_strava, Please change the 'pass' it to your own
RUN_TYPE: pass # support strava/nike/garmin/garmin_cn/keep/only_gpx/nike_to_strava/strava_to_garmin/strava_to_garmin_cn, Please change the 'pass' it to your own
ATHLETE: yihong0618
TITLE: Yihong0618 Running
TITLE_GRID: Over 10km Runs
Expand Down Expand Up @@ -93,6 +93,16 @@ jobs:
run: |
python scripts/gpx_sync.py
- name: Run sync Strava to Garmin(Run with strava(or others upload to strava) data backup in Garmin)
if: env.RUN_TYPE == 'strava_to_garmin'
run: |
python scripts/strava_to_garmin.py ${{ secrets.STRAVA_CLIENT_ID }} ${{ secrets.STRAVA_CLIENT_SECRET }} ${{ secrets.STRAVA_CLIENT_REFRESH_TOKEN }} ${{ secrets.GRAMIN_EMAIL }} ${{ secrets.GARMIN_PASSWORD }}
- name: Run sync Strava to Garmin-cn(Run with strava(or others upload to strava) data backup in Garmin-cn)
if: env.RUN_TYPE == 'strava_to_garmin'
run: |
python scripts/strava_to_garmin.py ${{ secrets.STRAVA_CLIENT_ID }} ${{ secrets.STRAVA_CLIENT_SECRET }} ${{ secrets.STRAVA_CLIENT_REFRESH_TOKEN }} ${{ secrets.GRAMIN_EMAIL }} ${{ secrets.GARMIN_PASSWORD }} --is-cn
- name: Make svg GitHub profile
if: env.RUN_TYPE != 'pass'
run: |
Expand Down
2 changes: 2 additions & 0 deletions README-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ R.I.P. 希望大家都能健康顺利的跑过终点,逝者安息。
- **[咕咚](#codoon咕咚)** (因咕咚限制单个设备原因,无法自动化)
- **[GPX](#GPX)**
- **[Nike+Strava(Using NRC Run, Strava backup data)](#nikestrava)**
- **[Strava_to_Garmin(Using Strava Run, Garmin backup data)](#)**

## 下载

Expand Down Expand Up @@ -525,6 +526,7 @@ Actions [源码](https://github.com/yihong0618/running_page/blob/master/.github/
- [x] 清理整个项目
- [x] 完善前端代码
- [x] better actions
- [ ] tests
- [ ] 支持不同的运动类型

# 参与项目
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ English | [简体中文](https://github.com/yihong0618/running_page/blob/master/
- **[Strava](#strava)**
- **[GPX](#GPX)**
- **[Nike_to_Strava(Using NRC Run, Strava backup data)](#Nike_to_Strava)**
- **[Strava_to_Garmin(Using Strava Run, Garmin backup data)](#)**

## Download

Expand Down
11 changes: 11 additions & 0 deletions scripts/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
from collections import namedtuple
from re import M

import yaml

Expand Down Expand Up @@ -35,3 +36,13 @@ def safeget(dct, *keys):
return dct

return safeget(_config, *keys)


# add more type here
STRAVA_GARMIN_TYPE_DICT = {
"Hike": "hiking",
"Run": "running",
"EBikeRide": "cycling",
"Walk": "walking",
"Swim": "swimming",
}
45 changes: 45 additions & 0 deletions scripts/garmin_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import logging
import os
import re
import json
import sys
import time
import traceback
Expand All @@ -34,6 +35,8 @@
"MODERN_URL": "https://connect.garmin.com",
"SIGNIN_URL": "https://sso.garmin.com/sso/signin",
"CSS_URL": "https://static.garmincdn.com/com.garmin.connect/ui/css/gauth-custom-v1.2-min.css",
"UPLOAD_URL": "https://connect.garmin.com/modern/proxy/upload-service/upload/.gpx",
"ACTIVITY_URL": "https://connect.garmin.com/proxy/activity-service/activity/{activity_id}",
}

GARMIN_CN_URL_DICT = {
Expand All @@ -44,6 +47,8 @@
"MODERN_URL": "https://connect.garmin.cn",
"SIGNIN_URL": "https://sso.garmin.cn/sso/signin",
"CSS_URL": "https://static.garmincdn.cn/cn.garmin.connect/ui/css/gauth-custom-v1.2-min.css",
"UPLOAD_URL": "https://connect.garmin.cn/modern/proxy/upload-service/upload/.gpx",
"ACTIVITY_URL": "https://connect.garmin.cn/proxy/activity-service/activity/{activity_id}",
}


Expand All @@ -68,6 +73,9 @@ def __init__(self, email, password, auth_domain, is_only_running=False):
"origin": self.URL_DICT.get("SSO_URL_ORIGIN"),
}
self.is_only_running = is_only_running
self.upload_url = self.URL_DICT.get("UPLOAD_URL")
self.activity_url = self.URL_DICT.get("ACTIVITY_URL")
self.is_login = False

def login(self):
"""
Expand Down Expand Up @@ -129,6 +137,7 @@ def login(self):
if response.status_code == 429:
raise GarminConnectTooManyRequestsError("Too many requests")
response.raise_for_status()
self.is_login = True
except Exception as err:
raise GarminConnectConnectionError("Error connecting") from err

Expand Down Expand Up @@ -162,6 +171,8 @@ async def get_activities(self, start, limit):
"""
Fetch available activities
"""
if not self.is_login:
self.login()
url = f"{self.modern_url}/proxy/activitylist-service/activities/search/activities?start={start}&limit={limit}"
if self.is_only_running:
url = url + "&activityType=running"
Expand All @@ -174,6 +185,39 @@ async def download_activity(self, activity_id):
response.raise_for_status()
return response.read()

async def upload_activities(self, files):
if not self.is_login:
self.login()
for file, garmin_type in files:
files = {"data": ("file.gpx", file)}

try:
res = await self.req.post(
self.upload_url, files=files, headers={"nk": "NT"}
)
except Exception as e:
print(str(e))
# just pass for now
continue
try:
resp = res.json()["detailedImportResult"]
except Exception as e:
print(e)
raise Exception("failed to upload")
# change the type
if resp["successes"]:
activity_id = resp["successes"][0]["internalId"]
print(f"id {activity_id} uploaded...")
data = {"activityTypeDTO": {"typeKey": garmin_type}}
encoding_headers = {"Content-Type": "application/json; charset=UTF-8"}
r = await self.req.put(
self.activity_url.format(activity_id=activity_id),
data=json.dumps(data),
headers=encoding_headers,
)
r.raise_for_status()
await self.req.aclose()


class GarminConnectHttpError(Exception):
def __init__(self, status):
Expand Down Expand Up @@ -290,6 +334,7 @@ async def download_new_activities():
)
print(f"Download finished. Elapsed {time.time()-start_time} seconds")
make_activities_file(SQL_FILE, GPX_FOLDER, JSON_FILE)
await client.req.aclose()

loop = asyncio.get_event_loop()
future = asyncio.ensure_future(download_new_activities())
Expand Down
14 changes: 2 additions & 12 deletions scripts/nike_to_strava_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

from config import OUTPUT_DIR
from nike_sync import make_new_gpxs, run
from utils import make_strava_client
from strava_sync import run_strava_sync
from stravalib.client import Client


def get_last_time(client):
Expand Down Expand Up @@ -42,16 +42,6 @@ def get_to_generate_files(last_time):
]


def make_client(client_id, client_secret, refresh_token):
client = Client()

refresh_response = client.refresh_access_token(
client_id=client_id, client_secret=client_secret, refresh_token=refresh_token
)
client.access_token = refresh_response["access_token"]
return client


def upload_gpx(client, file_name):
with open(file_name, "rb") as f:
r = client.upload_activity(activity_file=f, data_type="gpx")
Expand All @@ -74,7 +64,7 @@ def upload_gpx(client, file_name):
time.sleep(2)

# upload new gpx to strava
client = make_client(
client = make_strava_client(
options.client_id, options.client_secret, options.strava_refresh_token
)
last_time = get_last_time(client)
Expand Down
123 changes: 123 additions & 0 deletions scripts/strava_to_garmin_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import argparse
import asyncio
from io import BytesIO
import gpxpy
from xml.etree import ElementTree
import gpxpy.gpx
from datetime import datetime, timedelta
from utils import make_strava_client
from garmin_sync import Garmin

from config import STRAVA_GARMIN_TYPE_DICT


def generate_strava_run_points(start_time, strava_streams):
"""
strava return same len data list
"""
if not (strava_streams.get("time") and strava_streams.get("latlng")):
return None
points_dict_list = []
time_list = strava_streams["time"].data
time_list = [start_time + timedelta(seconds=int(i)) for i in time_list]
latlng_list = strava_streams["latlng"].data

for i, j in zip(time_list, latlng_list):
points_dict_list.append(
{
"latitude": j[0],
"longitude": j[1],
"time": i,
}
)
# add heart rate
if strava_streams.get("heartrate"):
heartrate_list = strava_streams.get("heartrate").data
for index, h in enumerate(heartrate_list):
points_dict_list[index]["heart_rate"] = h
# add altitude
if strava_streams.get("altitude"):
heartrate_list = strava_streams.get("altitude").data
for index, h in enumerate(heartrate_list):
points_dict_list[index]["elevation"] = h
return points_dict_list


def make_gpx_from_points(title, points_dict_list):
gpx = gpxpy.gpx.GPX()
gpx.nsmap["gpxtpx"] = "http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
gpx_track = gpxpy.gpx.GPXTrack()
gpx_track.name = title
gpx_track.type = "Run"
gpx.tracks.append(gpx_track)

# Create first segment in our GPX track:
gpx_segment = gpxpy.gpx.GPXTrackSegment()
gpx_track.segments.append(gpx_segment)
for p in points_dict_list:
if p.get("heart_rate") is None:
point = gpxpy.gpx.GPXTrackPoint(**p)
else:
heart_rate_num = p.pop("heart_rate")
point = gpxpy.gpx.GPXTrackPoint(**p)
gpx_extension_hr = ElementTree.fromstring(
f"""<gpxtpx:TrackPointExtension xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1">
<gpxtpx:hr>{heart_rate_num}</gpxtpx:hr>
</gpxtpx:TrackPointExtension>
"""
)
point.extensions.append(gpx_extension_hr)
gpx_segment.points.append(point)
return gpx.to_xml()


async def upload_to_activities(garmin_client, strava_client):
last_activity = await garmin_client.get_activities(0, 1)
if not last_activity:
filters = {}
else:
# is this startTimeGMT must have ?
after_datetime_str = last_activity[0]["startTimeGMT"]
after_datetime = datetime.strptime(after_datetime_str, "%Y-%m-%d %H:%M:%S")
filters = {"after": after_datetime}
strava_activities = list(strava_client.get_activities(**filters))
files_list = []
# strava rate limit
for i in strava_activities[:50]:
start_time = i.start_date
activity_type = i.type
types = ["time", "latlng", "altitude", "heartrate", "velocity_smooth", "temp"]
s = strava_client.get_activity_streams(i.id, types=types, resolution="medium")
points = generate_strava_run_points(start_time, s)
if points:
garmin_type = STRAVA_GARMIN_TYPE_DICT.get(activity_type, "running")
gpx_doc = make_gpx_from_points("test", points)
file = BytesIO(bytes(gpx_doc, "utf8"))
files_list.append((file, garmin_type))
await garmin_client.upload_activities(files_list)
return files_list


if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("client_id", help="strava client id")
parser.add_argument("client_secret", help="strava client secret")
parser.add_argument("strava_refresh_token", help="strava refresh token")
parser.add_argument("email", nargs="?", help="email of garmin")
parser.add_argument("password", nargs="?", help="password of garmin")
parser.add_argument(
"--is-cn",
dest="is_cn",
action="store_true",
help="if garmin accout is com",
)
options = parser.parse_args()
strava_client = make_strava_client(
options.client_id, options.client_secret, options.strava_refresh_token
)
auth_domain = "CN" if options.is_cn else ""

garmin_client = Garmin(options.email, options.password, auth_domain)
loop = asyncio.get_event_loop()
future = asyncio.ensure_future(upload_to_activities(garmin_client, strava_client))
loop.run_until_complete(future)
11 changes: 11 additions & 0 deletions scripts/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
from datetime import datetime

from stravalib.client import Client
import pytz
from generator import Generator

Expand All @@ -19,3 +20,13 @@ def make_activities_file(sql_file, gpx_dir, json_file):
activities_list = generator.load()
with open(json_file, "w") as f:
json.dump(activities_list, f)


def make_strava_client(client_id, client_secret, refresh_token):
client = Client()

refresh_response = client.refresh_access_token(
client_id=client_id, client_secret=client_secret, refresh_token=refresh_token
)
client.access_token = refresh_response["access_token"]
return client

1 comment on commit b11d846

@vercel
Copy link

@vercel vercel bot commented on b11d846 Jun 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.