diff --git a/.github/workflows/run_data_sync.yml b/.github/workflows/run_data_sync.yml index 6f07d2f5630..065a175262f 100644 --- a/.github/workflows/run_data_sync.yml +++ b/.github/workflows/run_data_sync.yml @@ -17,13 +17,15 @@ on: - run_page/keep_sync.py - run_page/gpx_sync.py - run_page/tcx_sync.py + - run_page/tcx_to_garmin_sync.py - run_page/garmin_to_strava_sync.py - run_page/keep_to_strava_sync.py + - run_page/oppo_sync.py - requirements.txt env: # please change to your own config. - RUN_TYPE: pass # support strava/nike/garmin/coros/garmin_cn/garmin_sync_cn_global/keep/only_gpx/only_fit/nike_to_strava/strava_to_garmin/strava_to_garmin_cn/garmin_to_strava/garmin_to_strava_cn/codoon, Please change the 'pass' it to your own + RUN_TYPE: pass # support strava/nike/garmin/coros/garmin_cn/garmin_sync_cn_global/keep/only_gpx/only_fit/nike_to_strava/strava_to_garmin/tcx_to_garmin/strava_to_garmin_cn/garmin_to_strava/garmin_to_strava_cn/codoon/oppo, Please change the 'pass' it to your own ATHLETE: yihong0618 TITLE: Yihong0618 Running MIN_GRID_DISTANCE: 10 # change min distance here @@ -121,6 +123,12 @@ jobs: run: | python run_page/codoon_sync.py ${{ secrets.CODOON_MOBILE }} ${{ secrets.CODOON_PASSWORD }} + - name: Run sync tcx to Garmin script + if: env.RUN_TYPE == 'tcx_to_garmin' + run: | + # python run_page/tcx_to_garmin_sync.py ${{ secrets.GARMIN_SECRET_STRING }} + python run_page/tcx_to_garmin_sync.py ${{ secrets.GARMIN_SECRET_STRING_CN }} --is-cn + # for garmin if you want generate `tcx` you can add --tcx command in the args. - name: Run sync Garmin script if: env.RUN_TYPE == 'garmin' @@ -187,6 +195,13 @@ jobs: run: | python run_page/tulipsport_sync.py ${{ secrets.TULIPSPORT_TOKEN }} --with-gpx + - name: Run sync Oppo heytap script, note currently this script is not worked + if: env.RUN_TYPE == 'oppo' + run: | + python run_page/oppo_sync.py ${{ secrets.OPPO_ID }} ${{ secrets.OPPO_CLIENT_SECRET }} ${{ secrets.OPPO_CLIENT_REFRESH_TOKEN }} --with-tcx + # If you want to sync fit activity in gpx format, please consider the following script: + # python run_page/oppo_sync.py ${{ secrets.OPPO_ID }} ${{ secrets.OPPO_CLIENT_SECRET }} ${{ secrets.OPPO_CLIENT_REFRESH_TOKEN }} --with-gpx + - name: Make svg GitHub profile if: env.RUN_TYPE != 'pass' run: | diff --git a/README-CN.md b/README-CN.md index e1142e84eae..933db5a4b37 100644 --- a/README-CN.md +++ b/README-CN.md @@ -145,6 +145,7 @@ R.I.P. 希望大家都能健康顺利的跑过终点,逝者安息。 - **[FIT](#fit)** - **[佳明国内同步国际](#Garmin-CN-to-Garmin)** - **[Tcx+Strava(upload all tcx data to strava)](#tcx_to_strava)** +- **[Tcx+Garmin(upload all tcx data to Garmin)](#tcx_to_garmin)** - **[Gpx+Strava(upload all tcx data to strava)](#gpx_to_strava)** - **[Nike+Strava(Using NRC Run, Strava backup data)](#nikestrava)** - **[Garmin_to_Strava(Using Garmin Run, Strava backup data)](#garmin_to_strava)** @@ -779,6 +780,31 @@ python3(python) run_page/tcx_to_strava_sync.py xxx xxx xxx --all > 如果你已经上传过需要跳过判断增加参数 `--all` +### TCX_to_Garmin + +
+上传所有的 tcx 格式的跑步数据到 Garmin + +
+ +1. 完成 garmin 的步骤 +2. 把 tcx 文件全部拷贝到 TCX_OUT 中 +3. 在项目根目录下执行: + +```bash +python3 run_page/tcx_to_garmin_sync.py ${{ secrets.GARMIN_SECRET_STRING_CN }} --is-cn +``` + +示例: + +```bash +python run_page/tcx_to_garmin_sync.py xxx --is-cn +或佳明国际 +python run_page/tcx_to_garmin_sync.py xxx +``` + +> 如果你已经上传过需要跳过判断增加参数 `--all` +
### GPX_to_Strava diff --git a/README.md b/README.md index 7531c839fce..40ffc1bc26d 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ English | [简体中文](https://github.com/yihong0618/running_page/blob/master/ - **[Garmin-CN_to_Garmin(Sync Garmin-CN activities to Garmin Global)](#garmin-cn-to-garmin)** - **[Nike_to_Strava(Using NRC Run, Strava backup data)](#nike_to_strava)** - **[Tcx_to_Strava(upload all tcx data to strava)](#tcx_to_strava)** +- **[Tcx_to_Garmin(upload all tcx data to Garmin)](#tcx_to_garmin)** - **[Gpx_to_Strava(upload all gpx data to strava)](#gpx_to_strava)** - **[Garmin_to_Strava(Using Garmin Run, Strava backup data)](#garmin_to_strava)** - **[Strava_to_Garmin(Using Strava Run, Garmin backup data)](#strava_to_garmin)** @@ -587,6 +588,33 @@ python3(python) run_page/tcx_to_strava_sync.py xxx xxx xxx --all +### TCX_to_Garmin + +
+upload all tcx files to garmin + +
+ +1. follow the garmin steps +2. copy all your tcx files to TCX_OUT +3. Execute in the root directory: + +```bash +python3 run_page/tcx_to_garmin_sync.py ${{ secrets.GARMIN_SECRET_STRING_CN }} --is-cn +``` + +example: + +```bash +python run_page/tcx_to_garmin_sync.py xxx --is-cn +or Garmin Global +python run_page/tcx_to_garmin_sync.py xxx +``` + +4. if you want to all files add args `--all` + +
+ ### GPX_to_Strava
diff --git a/run_page/codoon_sync.py b/run_page/codoon_sync.py index df179959312..c91a69e6169 100755 --- a/run_page/codoon_sync.py +++ b/run_page/codoon_sync.py @@ -9,6 +9,7 @@ import xml.etree.ElementTree as ET from collections import namedtuple from datetime import datetime, timedelta +from xml.dom import minidom import eviltransform import gpxpy @@ -45,6 +46,8 @@ # device info user_agent = "CodoonSport(8.9.0 1170;Android 7;Sony XZ1)" did = "24-00000000-03e1-7dd7-0033-c5870033c588" +# May be Forerunner 945? +CONNECT_API_PART_NUMBER = "006-D2449-00" # fixed params base_url = "https://api.codoon.com" @@ -61,9 +64,9 @@ # for tcx type TCX_TYPE_DICT = { - 0: "Hike", + 0: "Hiking", 1: "Running", - 2: "Ride", + 2: "Biking", } # only for running sports, if you want others, please change the True to False @@ -127,6 +130,9 @@ def formated_input( def tcx_output(fit_array, run_data): + """ + If you want to make a more detailed tcx file, please refer to oppo_sync.py + """ # route ID fit_id = str(run_data["id"]) # local time @@ -149,7 +155,7 @@ def tcx_output(fit_array, run_data): }, ) # xml tree - tree = ET.ElementTree(training_center_database) + ET.ElementTree(training_center_database) # Activities activities = ET.Element("Activities") training_center_database.append(activities) @@ -163,12 +169,15 @@ def tcx_output(fit_array, run_data): activity_id.text = fit_start_time # Codoon use start_time as ID activity.append(activity_id) # Creator - activity_creator = ET.Element("Creator") + activity_creator = ET.Element("Creator", {"xsi:type": "Device_t"}) activity.append(activity_creator) # Name activity_creator_name = ET.Element("Name") - activity_creator_name.text = "咕咚" + activity_creator_name.text = "Codoon" activity_creator.append(activity_creator_name) + activity_creator_product = ET.Element("ProductID") + activity_creator_product.text = "3441" + activity_creator.append(activity_creator_product) # Lap activity_lap = ET.Element("Lap", {"StartTime": fit_start_time}) activity.append(activity_lap) @@ -215,11 +224,22 @@ def tcx_output(fit_array, run_data): altitude_meters = ET.Element("AltitudeMeters") altitude_meters.text = bytes.decode(i["elevation"]) tp.append(altitude_meters) - + # Author + author = ET.Element("Author", {"xsi:type": "Application_t"}) + training_center_database.append(author) + author_name = ET.Element("Name") + author_name.text = "Connect Api" + author.append(author_name) + author_lang = ET.Element("LangID") + author_lang.text = "en" + author.append(author_lang) + author_part = ET.Element("PartNumber") + author_part.text = CONNECT_API_PART_NUMBER + author.append(author_part) # write to TCX file - tree.write( - TCX_FOLDER + "/" + fit_id + ".tcx", encoding="utf-8", xml_declaration=True - ) + xml_str = minidom.parseString(ET.tostring(training_center_database)).toprettyxml() + with open(TCX_FOLDER + "/" + fit_id + ".tcx", "w") as f: + f.write(str(xml_str)) # TODO time complexity is too heigh, need to be reduced diff --git a/run_page/config.py b/run_page/config.py index cbc2031302d..1548d5ea80c 100644 --- a/run_page/config.py +++ b/run_page/config.py @@ -25,7 +25,7 @@ BASE_TIMEZONE = "Asia/Shanghai" - +UTC_TIMEZONE = "UTC" start_point = namedtuple("start_point", "lat lon") run_map = namedtuple("polyline", "summary_polyline") diff --git a/run_page/generator/__init__.py b/run_page/generator/__init__.py index 9d91210de70..9c798718a6f 100644 --- a/run_page/generator/__init__.py +++ b/run_page/generator/__init__.py @@ -156,3 +156,16 @@ def get_old_tracks_ids(self): # pass the error print(f"something wrong with {str(e)}") return [] + + def get_old_tracks_dates(self): + try: + activities = ( + self.session.query(Activity) + .order_by(Activity.start_date_local.desc()) + .all() + ) + return [str(a.start_date_local) for a in activities] + except Exception as e: + # pass the error + print(f"something wrong with {str(e)}") + return [] diff --git a/run_page/oppo_sync.py b/run_page/oppo_sync.py new file mode 100644 index 00000000000..cf5bf803f6b --- /dev/null +++ b/run_page/oppo_sync.py @@ -0,0 +1,727 @@ +import argparse +import hashlib +import json +import os +import time +import xml.etree.ElementTree as ET +from collections import namedtuple +from datetime import datetime, timedelta +from xml.dom import minidom + +import gpxpy +import polyline +import requests +from tzlocal import get_localzone + +from config import ( + GPX_FOLDER, + JSON_FILE, + SQL_FILE, + run_map, + start_point, + TCX_FOLDER, + UTC_TIMEZONE, +) +from generator import Generator +from utils import adjust_time + +TOKEN_REFRESH_URL = "https://sport.health.heytapmobi.com/open/v1/oauth/token" +OPPO_HEADERS = { + "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0", + "Content-Type": "application/json", + "Accept": "application/json", +} + +# Query brief version of sports records +# The query range cannot exceed one month! +""" +Return value is like: +[ + { + "dataType": 2,//运动数类型 1=健身类 2=其他运动类 + "startTime": 1630323565000, //开始时间 单位毫秒 + "endTime": 1630337130000,//结束时间 单位毫秒 + "sportMode": 10,//运动模式 室内跑 详情见文档附录 + "otherSportData": { + "avgHeartRate": 153,//平均心率 单位:count/min + "avgPace": 585,//平均配速 单位s/km + "avgStepRate": 115,//平均步频 单位step/min + "bestStepRate": 135,//最佳步频 单位step/min + "bestPace": 572,//最佳配速 单位s/km + "totalCalories": 2176000,//总消耗 单位卡 + "totalDistance": 23175,//总距离 单位米 + "totalSteps": 26062,//总步数 + "totalTime": 13562000,//总时长,单位:毫秒 + "totalClimb": 100//累计爬升高度,单位:米 + }, + }, + { + "dataType": 1,//运动数类型 1=健身类 2=其他运动类 + "startTime": 1630293981497 //开始时间 单位毫秒 + "endTime": 1630294218127,//结束时间 单位毫秒 + "sportMode": 9,//运动模式 健身 详情见文档附录 + "fitnessData": { + "avgHeartRate": 90,//平均心率 单位:count/min + "courseName": "零基础减脂碎片练习",//课程名称 + "finishNumber": 1,//课程完成次数 + "trainedCalorie": 13554,//训练消耗的卡路里,单位:卡 + "trainedDuration": 176000//实际训练时间,单位:ms + }, + } +] +""" +BRIEF_SPORT_DATA_API = "https://sport.health.heytapmobi.com/open/v1/data/sport/record?startTimeMillis={start_time}&endTimeMillis={end_time}" + +# Query detailed sports records +# The query range cannot exceed one day! +DETAILED_SPORT_DATA_API = "https://sport.health.heytapmobi.com/open/v2/data/sport/record?startTimeMillis={start_time}&endTimeMillis={end_time}" + +TIMESTAMP_THRESHOLD_IN_MILLISECOND = 5000 + +# If your points need trans from gcj02 to wgs84 coordinate which use by Mapbox +TRANS_GCJ02_TO_WGS84 = True + +# May be Forerunner 945? +CONNECT_API_PART_NUMBER = "006-D2449-00" + +AVAILABLE_OUTDOOR_SPORT_MODE = [ + 1, # WALK + 2, # RUN + 3, # RIDE + 13, # OUTDOOR_PHYSICAL_RUN + 15, # OUTDOOR_5KM_RELAX_RUN + 17, # OUTDOOR_FAT_REDUCE_RUN + 22, # MARATHON + 36, # MOUNTAIN_CLIMBING + 37, # CROSS_COUNTRY +] + +AVAILABLE_INDOOR_SPORT_MODE = [ + 10, # INDOOR_RUN + 14, # INDOOR_PHYSICAL_RUN + 16, # INDOOR_5KM_RELAX_RUN + 18, # INDOOR_FAT_REDUCE_RUN + 19, # INDOOR_FITNESS_WALK + 21, # TREADMILL_RUN +] + + +def get_access_token(session, client_id, client_secret, refresh_token): + headers = { + "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0", + "Content-Type": "application/json", + "Accept": "application/json", + } + data = { + "clientId": client_id, + "clientSecret": client_secret, + "refreshToken": refresh_token, + "grantType": "refreshToken", + } + r = session.post(TOKEN_REFRESH_URL, headers=headers, json=data) + if r.ok: + token = r.json()["body"]["accessToken"] + headers["access-token"] = token + return session, headers + + +def get_to_download_runs_ranges(session, sync_months, headers, start_timestamp): + result = [] + current_time = datetime.now() + start_datatime = datetime.fromtimestamp(start_timestamp / 1000) + + if start_datatime < current_time + timedelta(days=-30 * sync_months): + """retrieve the data of last 6 months.""" + while sync_months >= 0: + temp_end = int(current_time.timestamp() * 1000) + current_time = current_time + timedelta(days=-30) + temp_start = int(current_time.timestamp() * 1000) + sync_months = sync_months - 1 + result.extend( + parse_brief_sport_data(session, headers, temp_start, temp_end) + ) + else: + while start_datatime < current_time: + temp_start = int(start_datatime.timestamp() * 1000) + start_datatime = start_datatime + timedelta(days=30) + temp_end = int(start_datatime.timestamp() * 1000) + result.extend( + parse_brief_sport_data(session, headers, temp_start, temp_end) + ) + return result + + +def parse_brief_sport_data(session, headers, temp_start, temp_end): + result = [] + r = session.get( + BRIEF_SPORT_DATA_API.format(end_time=temp_end, start_time=temp_start), + headers=headers, + ) + if r.ok: + sport_logs = r.json()["body"] + for i in sport_logs: + if ( + i["sportMode"] in AVAILABLE_INDOOR_SPORT_MODE + or i["sportMode"] in AVAILABLE_OUTDOOR_SPORT_MODE + ): + result.append((i["startTime"], i["endTime"])) + print(f"sync record: start_time: " + str(i["startTime"])) + time.sleep(1) # spider rule + return result + + +def get_single_run_data(session, headers, start, end): + r = session.get( + DETAILED_SPORT_DATA_API.format(end_time=end, start_time=start), headers=headers + ) + if r.ok: + return r.json() + + +def parse_raw_data_to_name_tuple(sport_data, with_gpx, with_tcx): + sport_data = sport_data["body"][0] + m = hashlib.md5() + m.update(str.encode(str(sport_data))) + oppo_id_str = str(int(m.hexdigest(), 16))[0:16] + oppo_id = int(oppo_id_str) + + sport_data["id"] = oppo_id + start_time = sport_data["startTime"] + other_data = sport_data["otherSportData"] + avg_heart_rate = None + if other_data: + avg_heart_rate = other_data.get("avgHeartRate", None) + # fix #66 + if avg_heart_rate and avg_heart_rate < 0: + avg_heart_rate = None + + # if TRANS_GCJ02_TO_WGS84: + # run_points_data = [ + # list(eviltransform.gcj2wgs(p["latitude"], p["longitude"])) + # for p in run_points_data + # ] + # for i, p in enumerate(run_points_data_gpx): + # p["latitude"] = run_points_data[i][0] + # p["longitude"] = run_points_data[i][1] + + point_dict = prepare_track_points(sport_data, with_gpx) + + if with_gpx is True: + gpx_data = parse_points_to_gpx(sport_data, point_dict) + download_keep_gpx(gpx_data, str(oppo_id)) + if with_tcx is True: + parse_points_to_tcx(sport_data, point_dict) + + else: + print(f"ID {oppo_id} no gps data") + + gps_data = [ + (item["latitude"], item["longitude"]) for item in other_data["gpsPoint"] + ] + polyline_str = polyline.encode(gps_data) if gps_data else "" + start_latlng = start_point(*gps_data[0]) if gps_data else None + start_date = datetime.utcfromtimestamp(start_time / 1000) + start_date_local = adjust_time(start_date, str(get_localzone())) + end = datetime.utcfromtimestamp(sport_data["endTime"] / 1000) + end_local = adjust_time(end, str(get_localzone())) + location_country = None + if not other_data["totalTime"]: + print(f"ID {oppo_id} has no total time just ignore please check") + return + d = { + "id": int(oppo_id), + "name": "activity from oppo", + # future to support others workout now only for run + "type": map_oppo_fit_type_to_strava_activity_type(sport_data["sportMode"]), + "start_date": datetime.strftime(start_date, "%Y-%m-%d %H:%M:%S"), + "end": datetime.strftime(end, "%Y-%m-%d %H:%M:%S"), + "start_date_local": datetime.strftime(start_date_local, "%Y-%m-%d %H:%M:%S"), + "end_local": datetime.strftime(end_local, "%Y-%m-%d %H:%M:%S"), + "length": other_data["totalDistance"], + "average_heartrate": int(avg_heart_rate) if avg_heart_rate else None, + "map": run_map(polyline_str), + "start_latlng": start_latlng, + "distance": other_data["totalDistance"], + "moving_time": timedelta(seconds=other_data["totalTime"]), + "elapsed_time": timedelta( + seconds=int((sport_data["endTime"] - sport_data["startTime"]) / 1000) + ), + "average_speed": other_data["totalDistance"] / other_data["totalTime"] * 1000, + "location_country": location_country, + "source": sport_data["deviceName"], + } + return namedtuple("x", d.keys())(*d.values()) + + +def get_all_oppo_tracks( + client_id, + client_secret, + refresh_token, + sync_months, + last_track_date, + with_download_gpx, + with_download_tcx, +): + if with_download_gpx and not os.path.exists(GPX_FOLDER): + os.mkdir(GPX_FOLDER) + s = requests.Session() + s, headers = get_access_token(s, client_id, client_secret, refresh_token) + + last_timestamp = ( + 0 + if (last_track_date == 0) + else int( + datetime.timestamp(datetime.strptime(last_track_date, "%Y-%m-%d %H:%M:%S")) + * 1000 + ) + ) + + runs = get_to_download_runs_ranges(s, sync_months, headers, last_timestamp + 1000) + print(f"{len(runs)} new oppo runs to generate") + tracks = [] + for start, end in runs: + print(f"parsing oppo id {str(start)}-{str(end)}") + try: + run_data = get_single_run_data(s, headers, start, end) + track = parse_raw_data_to_name_tuple( + run_data, with_download_gpx, with_download_tcx + ) + tracks.append(track) + except Exception as e: + print(f"Something wrong paring keep id {str(start)}-{str(end)}" + str(e)) + return tracks + + +def switch(v): + yield lambda *c: v in c + + +def map_oppo_fit_type_to_gpx_type(oppo_type): + for case in switch(oppo_type): + if case(1): # WALK + return "Walking" + if case(2, 13, 15, 17, 22, 10, 14, 16, 18, 21, 37): + # RUN | + # OUTDOOR_PHYSICAL_RUN | + # OUTDOOR_5KM_RELAX_RUN | + # OUTDOOR_FAT_REDUCE_RUN | + # MARATHON + # INDOOR_RUN, etc. + # CROSS_COUNTRY + return "Running" + if case(19): # MOUNTAIN_CLIMBING + return "Hiking" + if case(3): # Ride + return "Biking" + + +def map_oppo_fit_type_to_strava_activity_type(oppo_type): + """ + Note: should consider the supported strava activity type: + Link: https://developers.strava.com/docs/reference/#api-models-ActivityType + """ + for case in switch(oppo_type): + if case(1): # WALK + return "Walk" + if case(2, 13, 15, 17, 22, 10, 14, 16, 18, 21, 37): + # RUN | + # OUTDOOR_PHYSICAL_RUN | + # OUTDOOR_5KM_RELAX_RUN | + # OUTDOOR_FAT_REDUCE_RUN | + # MARATHON + # INDOOR_RUN, etc. + # CROSS_COUNTRY + return "Run" + if case(19): # MOUNTAIN_CLIMBING + return "Hike" + if case(3): # Ride + return "Ride" + + +def parse_points_to_gpx(sport_data, 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 = f"""gpx from {sport_data["deviceName"]}""" + gpx_track.type = map_oppo_fit_type_to_gpx_type(sport_data["sportMode"]) + 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: + point = gpxpy.gpx.GPXTrackPoint( + latitude=p["latitude"], + longitude=p["longitude"], + time=p["time"], + elevation=p.get("elevation"), + ) + hr = p.get("hr") + cad = p.get("cad") + if hr is not None or cad is not None: + hr_str = f"""{hr}""" if hr is not None else "" + cad_str = ( + f"""{p["cad"]}""" if cad is not None else "" + ) + gpx_extension = ET.fromstring( + f""" + {hr_str} + {cad_str} + + """ + ) + point.extensions.append(gpx_extension) + gpx_segment.points.append(point) + return gpx.to_xml() + + +def download_keep_gpx(gpx_data, keep_id): + try: + print(f"downloading keep_id {str(keep_id)} gpx") + file_path = os.path.join(GPX_FOLDER, str(keep_id) + ".gpx") + with open(file_path, "w") as fb: + fb.write(gpx_data) + except: + print(f"wrong id {keep_id}") + pass + + +def prepare_track_points(sport_data, with_gpx): + """ + Convert run points data to GPX format. + + Args: + sport_data (map of dict): A map of run data points. + with_gpx (boolean): export to gpx file or not. + + Returns: + points_dict_list (list): data with need to parse. + """ + other_data = sport_data["otherSportData"] + decoded_hr_data = other_data.get("heartRate", None) + points_dict_list = [] + + if other_data.get("gpsPoint"): + timestamp_list = [item["timestamp"] for item in decoded_hr_data] + other_data = sport_data["otherSportData"] + value_size = len(other_data.get("gpsPoint", None)) + + for i in range(value_size): + temp_timestamp = other_data.get("gpsPoint")[i]["timestamp"] + j = timestamp_list.index(temp_timestamp) + + points_dict = { + "latitude": other_data.get("gpsPoint")[i]["latitude"], + "longitude": other_data.get("gpsPoint")[i]["longitude"], + "time": datetime.utcfromtimestamp(temp_timestamp / 1000), + "hr": other_data.get("heartRate")[j]["value"], + } + points_dict_list.append(get_value(j, points_dict, other_data)) + elif with_gpx is False: + value_size = len(other_data.get("heartRate", None)) + + for i in range(value_size): + temp_timestamp = other_data.get("heartRate")[i]["timestamp"] + temp_date = datetime.utcfromtimestamp(temp_timestamp / 1000) + points_dict = { + "time": temp_date, + "hr": other_data.get("heartRate")[i]["value"], + } + points_dict_list.append(get_value(i, points_dict, other_data)) + + return points_dict_list + + +def get_value(index, points_dict, other_data): + if other_data.get("pace"): + pace = other_data.get("pace")[index]["value"] + points_dict["speed"] = 0 if pace == 0 else 1000 / pace + if other_data.get("frequency"): + points_dict["cad"] = other_data.get("frequency")[index]["value"] + if other_data.get("distance"): + points_dict["distance"] = other_data.get("distance")[index]["value"] + if other_data.get("elevation"): + points_dict["elevation"] = other_data.get("elevation")[index]["value"] + return points_dict + + +def parse_points_to_tcx(sport_data, points_dict_list): + # route ID + fit_id = str(sport_data["id"]) + # local time + start_time = sport_data["startTime"] + start_date = datetime.utcfromtimestamp(start_time / 1000) + fit_start_time = datetime.strftime( + adjust_time(start_date, UTC_TIMEZONE), "%Y-%m-%dT%H:%M:%SZ" + ) + + # Root node + training_center_database = ET.Element( + "TrainingCenterDatabase", + { + "xmlns": "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2", + "xmlns:ns5": "http://www.garmin.com/xmlschemas/ActivityGoals/v1", + "xmlns:ns3": "http://www.garmin.com/xmlschemas/ActivityExtension/v2", + "xmlns:ns2": "http://www.garmin.com/xmlschemas/UserProfile/v2", + "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "xmlns:ns4": "http://www.garmin.com/xmlschemas/ProfileExtension/v1", + "xsi:schemaLocation": "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2 http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd", + }, + ) + # xml tree + ET.ElementTree(training_center_database) + # Activities + activities = ET.Element("Activities") + training_center_database.append(activities) + # sport type + sports_type = map_oppo_fit_type_to_gpx_type(sport_data["sportMode"]) + # activity + activity = ET.Element("Activity", {"Sport": sports_type}) + activities.append(activity) + # Id + activity_id = ET.Element("Id") + activity_id.text = fit_start_time # Codoon use start_time as ID + activity.append(activity_id) + # Creator + activity_creator = ET.Element("Creator", {"xsi:type": "Device_t"}) + activity.append(activity_creator) + # Name + activity_creator_name = ET.Element("Name") + activity_creator_name.text = sport_data["deviceName"] + activity_creator.append(activity_creator_name) + activity_creator_product = ET.Element("ProductID") + activity_creator_product.text = "3441" + activity_creator.append(activity_creator_product) + + """ + first, find distance split index + """ + lap_split_indexes = [0] + points_dict_list_chunks = [] + + for idx, item in enumerate(points_dict_list): + size = len(lap_split_indexes) + if sports_type == "Running": + target_distance = 1000 * size + elif sports_type == "Biking": + target_distance = 5000 * size + else: + break + + if idx + 1 != len(points_dict_list): + if ( + item["distance"] + < target_distance + <= points_dict_list[idx + 1]["distance"] + ): + lap_split_indexes.append(idx) + + if len(lap_split_indexes) == 1: + points_dict_list_chunks = [points_dict_list] + else: + for idx, item in enumerate(lap_split_indexes): + if idx + 1 == len(lap_split_indexes): + points_dict_list_chunks.append( + points_dict_list[item : len(points_dict_list) - 1] + ) + else: + points_dict_list_chunks.append( + points_dict_list[item : lap_split_indexes[idx + 1]] + ) + + current_distance = 0 + current_time = start_date + + for item in points_dict_list_chunks: + # Lap + lap_start_time = datetime.strftime( + adjust_time(item[0]["time"], UTC_TIMEZONE), "%Y-%m-%dT%H:%M:%SZ" + ) + activity_lap = ET.Element("Lap", {"StartTime": lap_start_time}) + activity.append(activity_lap) + + # DistanceMeters + total_distance_node = ET.Element("DistanceMeters") + total_distance_node.text = str(item[-1]["distance"] - current_distance) + current_distance = item[-1]["distance"] + activity_lap.append(total_distance_node) + # TotalTimeSeconds + chile_node = ET.Element("TotalTimeSeconds") + chile_node.text = str((item[-1]["time"] - current_time).total_seconds()) + current_time = item[-1]["time"] + activity_lap.append(chile_node) + # MaximumSpeed + chile_node = ET.Element("MaximumSpeed") + chile_node.text = str(max(node["speed"] for node in item)) + activity_lap.append(chile_node) + # # Calories + # chile_node = ET.Element("Calories") + # chile_node.text = str(int(other_data["totalCalories"] / 1000)) + # activity_lap.append(chile_node) + # AverageHeartRateBpm + # bpm = ET.Element("AverageHeartRateBpm") + # bpm_value = ET.Element("Value") + # bpm.append(bpm_value) + # bpm_value.text = str(other_data["avgHeartRate"]) + # heartrate_list = [item["value"] for item in other_data["heartRate"]] + # bpm_value.text = str(round(statistics.mean(heartrate_list))) + # activity_lap.append(bpm) + # # MaximumHeartRateBpm + # bpm = ET.Element("MaximumHeartRateBpm") + # bpm_value = ET.Element("Value") + # bpm.append(bpm_value) + # bpm_value.text = str(max(node["hr"] for node in item)) + # activity_lap.append(bpm) + + # Track + track = ET.Element("Track") + activity_lap.append(track) + + for p in item: + tp = ET.Element("Trackpoint") + track.append(tp) + # Time + time_stamp = datetime.strftime( + adjust_time(p["time"], UTC_TIMEZONE), "%Y-%m-%dT%H:%M:%SZ" + ) + time_label = ET.Element("Time") + time_label.text = time_stamp + + tp.append(time_label) + if sports_type == "Biking" and p.get("cad"): + cadence_label = ET.Element("Cadence") + cadence_label.text = str(p["cad"]) + tp.append(cadence_label) + if p.get("distance"): + distance_label = ET.Element("DistanceMeters") + distance_label.text = str(p["distance"]) + tp.append(distance_label) + # HeartRateBpm + # None was converted to bytes by np.dtype, becoming a string "None" after decode...-_- + # as well as LatitudeDegrees and LongitudeDegrees below + if p.get("hr"): + bpm = ET.Element("HeartRateBpm") + bpm_value = ET.Element("Value") + bpm.append(bpm_value) + bpm_value.text = str(p["hr"]) + tp.append(bpm) + # AltitudeMeters + if p.get("elevation"): + altitude_meters = ET.Element("AltitudeMeters") + altitude_meters.text = str(p["elevation"] / 10) + tp.append(altitude_meters) + if p.get("latitude"): + position = ET.Element("Position") + tp.append(position) + # LatitudeDegrees + lati = ET.Element("LatitudeDegrees") + lati.text = str(p["latitude"]) + position.append(lati) + # LongitudeDegrees + longi = ET.Element("LongitudeDegrees") + longi.text = str(p["longitude"]) + position.append(longi) + # Extensions + if p.get("speed") is not None or ( + p.get("cad") is not None and sports_type == "Running" + ): + extensions = ET.Element("Extensions") + tp.append(extensions) + tpx = ET.Element("ns3:TPX") + extensions.append(tpx) + # LatitudeDegrees + # LatitudeDegrees + if p.get("speed") is not None: + speed = ET.Element("ns3:Speed") + speed.text = str(p["speed"]) + tpx.append(speed) + if p.get("cad") is not None and sports_type == "Running": + cad = ET.Element("ns3:RunCadence") + cad.text = str(round(p["cad"] / 2)) + tpx.append(cad) + # Author + author = ET.Element("Author", {"xsi:type": "Application_t"}) + training_center_database.append(author) + author_name = ET.Element("Name") + author_name.text = "Connect Api" + author.append(author_name) + author_lang = ET.Element("LangID") + author_lang.text = "en" + author.append(author_lang) + author_part = ET.Element("PartNumber") + author_part.text = CONNECT_API_PART_NUMBER + author.append(author_part) + # write to TCX file + xml_str = minidom.parseString(ET.tostring(training_center_database)).toprettyxml() + with open(TCX_FOLDER + "/" + fit_id + ".tcx", "w") as f: + f.write(str(xml_str)) + + +def formated_input( + run_data, run_data_label, tcx_label +): # load run_data from run_data_label, parse to tcx_label, return xml node + fit_data = str(run_data[run_data_label]) + chile_node = ET.Element(tcx_label) + chile_node.text = fit_data + return chile_node + + +def run_oppo_sync( + client_id, + client_secret, + refresh_token, + sync_months=6, + with_download_gpx=False, + with_download_tcx=True, +): + generator = Generator(SQL_FILE) + old_tracks_dates = generator.get_old_tracks_dates() + new_tracks = get_all_oppo_tracks( + client_id, + client_secret, + refresh_token, + sync_months, + old_tracks_dates[0] if old_tracks_dates else 0, + with_download_gpx, + with_download_tcx, + ) + generator.sync_from_app(new_tracks) + + activities_list = generator.load() + with open(JSON_FILE, "w") as f: + json.dump(activities_list, f, indent=0) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("client_id", help="oppo heytap fit client id") + parser.add_argument("client_secret", help="oppo heytap fit client secret") + parser.add_argument("refresh_token", help="oppo heytap fit refresh token") + parser.add_argument( + "--with-gpx", + dest="with_gpx", + action="store_true", + help="get all oppo fit data to gpx and download", + ) + parser.add_argument( + "--with-tcx", + dest="with_tcx", + action="store_true", + help="get all oppo fit data to tcx and download", + ) + parser.add_argument( + "-m" "--months", + type=int, + default=6, + dest="sync_months", + help="oppo has limited the data retrieve, so the default months we can sync is 6.", + ) + options = parser.parse_args() + run_oppo_sync( + options.client_id, + options.client_secret, + options.refresh_token, + options.sync_months, + options.with_gpx, + options.with_tcx, + ) diff --git a/run_page/tcx_to_garmin_sync.py b/run_page/tcx_to_garmin_sync.py new file mode 100644 index 00000000000..6a3a42ba64a --- /dev/null +++ b/run_page/tcx_to_garmin_sync.py @@ -0,0 +1,81 @@ +import argparse +import asyncio +import os +from datetime import datetime + +from tcxreader.tcxreader import TCXReader + +from config import TCX_FOLDER +from garmin_sync import Garmin + + +def get_to_generate_files(last_time): + """ + return to one sorted list for next time upload + """ + file_names = os.listdir(TCX_FOLDER) + tcx = TCXReader() + tcx_files = [ + ( + tcx.read(os.path.join(TCX_FOLDER, i), only_gps=False), + os.path.join(TCX_FOLDER, i), + ) + for i in file_names + if i.endswith(".tcx") + ] + tcx_files_dict = { + int(i[0].trackpoints[0].time.timestamp()): i[1] + for i in tcx_files + if len(i[0].trackpoints) > 0 + and int(i[0].trackpoints[0].time.timestamp()) > last_time + } + + dict(sorted(tcx_files_dict.items())) + + return tcx_files_dict.values() + + +async def upload_tcx_files_to_garmin(options): + print("Need to load all tcx files maybe take some time") + garmin_auth_domain = "CN" if options.is_cn else "" + garmin_client = Garmin(options.secret_string, garmin_auth_domain) + + last_time = 0 + if not options.all: + print("upload new tcx to Garmin") + last_activity = await garmin_client.get_activities(0, 1) + if not last_activity: + print("no garmin activity") + else: + after_datetime_str = last_activity[0]["startTimeGMT"] + after_datetime = datetime.strptime(after_datetime_str, "%Y-%m-%d %H:%M:%S") + last_time = datetime.timestamp(after_datetime) + else: + print("Need to load all tcx files maybe take some time") + to_upload_dict = get_to_generate_files(last_time) + + await garmin_client.upload_activities_files(to_upload_dict) + + +if __name__ == "__main__": + if not os.path.exists(TCX_FOLDER): + os.mkdir(TCX_FOLDER) + parser = argparse.ArgumentParser() + parser.add_argument( + "secret_string", nargs="?", help="secret_string fro get_garmin_secret.py" + ) + parser.add_argument( + "--all", + dest="all", + action="store_true", + help="if upload to strava all without check last time", + ) + parser.add_argument( + "--is-cn", + dest="is_cn", + action="store_true", + help="if garmin account is cn", + ) + loop = asyncio.get_event_loop() + future = asyncio.ensure_future(upload_tcx_files_to_garmin(parser.parse_args())) + loop.run_until_complete(future)