diff --git a/.github/workflows/run_data_sync.yml b/.github/workflows/run_data_sync.yml index 6216b46ea3f..c8adcd2581b 100644 --- a/.github/workflows/run_data_sync.yml +++ b/.github/workflows/run_data_sync.yml @@ -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 @@ -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: | diff --git a/README-CN.md b/README-CN.md index 554c1215e11..e9dc460283a 100644 --- a/README-CN.md +++ b/README-CN.md @@ -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)](#)** ## 下载 @@ -525,6 +526,7 @@ Actions [源码](https://github.com/yihong0618/running_page/blob/master/.github/ - [x] 清理整个项目 - [x] 完善前端代码 - [x] better actions +- [ ] tests - [ ] 支持不同的运动类型 # 参与项目 diff --git a/README.md b/README.md index a82943e5181..6afe2a2bec0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/scripts/config.py b/scripts/config.py index dbbd11ed3e8..fdbcc486097 100644 --- a/scripts/config.py +++ b/scripts/config.py @@ -1,5 +1,6 @@ import os from collections import namedtuple +from re import M import yaml @@ -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", +} diff --git a/scripts/garmin_sync.py b/scripts/garmin_sync.py index 7d7558aed23..ad5966f6b06 100755 --- a/scripts/garmin_sync.py +++ b/scripts/garmin_sync.py @@ -10,6 +10,7 @@ import logging import os import re +import json import sys import time import traceback @@ -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 = { @@ -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}", } @@ -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): """ @@ -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 @@ -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" @@ -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): @@ -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()) diff --git a/scripts/nike_to_strava_sync.py b/scripts/nike_to_strava_sync.py index 9e6ea04f0e5..996d402dfd0 100755 --- a/scripts/nike_to_strava_sync.py +++ b/scripts/nike_to_strava_sync.py @@ -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): @@ -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") @@ -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) diff --git a/scripts/strava_to_garmin_sync.py b/scripts/strava_to_garmin_sync.py new file mode 100644 index 00000000000..a59bb248e2c --- /dev/null +++ b/scripts/strava_to_garmin_sync.py @@ -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""" + {heart_rate_num} + + """ + ) + 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) diff --git a/scripts/utils.py b/scripts/utils.py index b93cac0b797..a07a12ee78d 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -4,6 +4,7 @@ import json from datetime import datetime +from stravalib.client import Client import pytz from generator import Generator @@ -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