fit-file-faker 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fit-file-faker might be problematic. Click here for more details.
- app.py +618 -0
- fit_file_faker-1.2.0.dist-info/LICENSE.md +7 -0
- fit_file_faker-1.2.0.dist-info/METADATA +305 -0
- fit_file_faker-1.2.0.dist-info/RECORD +7 -0
- fit_file_faker-1.2.0.dist-info/WHEEL +5 -0
- fit_file_faker-1.2.0.dist-info/entry_points.txt +2 -0
- fit_file_faker-1.2.0.dist-info/top_level.txt +1 -0
app.py
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
# ruff: noqa: E402
|
|
2
|
+
"""
|
|
3
|
+
Takes a .fit file produced and modifies the fields so that Garmin
|
|
4
|
+
will think it came from a Garmin device and use it to determine training effect.
|
|
5
|
+
|
|
6
|
+
Simulates an Edge 830 device
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import asdict, dataclass
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from tempfile import NamedTemporaryFile
|
|
20
|
+
from typing import Optional, cast
|
|
21
|
+
|
|
22
|
+
import questionary
|
|
23
|
+
import semver
|
|
24
|
+
from platformdirs import PlatformDirs
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
from rich.logging import RichHandler
|
|
27
|
+
from rich.traceback import install
|
|
28
|
+
from watchdog.events import PatternMatchingEventHandler
|
|
29
|
+
from watchdog.observers.polling import PollingObserver as Observer
|
|
30
|
+
|
|
31
|
+
_logger = logging.getLogger("garmin")
|
|
32
|
+
install()
|
|
33
|
+
|
|
34
|
+
# fit_tool configures logging for itself, so need to do this before importing it
|
|
35
|
+
logging.basicConfig(
|
|
36
|
+
level=logging.NOTSET,
|
|
37
|
+
format="%(message)s",
|
|
38
|
+
datefmt="[%X]",
|
|
39
|
+
handlers=[RichHandler(markup=True)],
|
|
40
|
+
)
|
|
41
|
+
logging.basicConfig()
|
|
42
|
+
_logger.setLevel(logging.INFO)
|
|
43
|
+
logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING)
|
|
44
|
+
logging.getLogger("oauth1_auth").setLevel(logging.WARNING)
|
|
45
|
+
|
|
46
|
+
from fit_tool.fit_file import FitFile
|
|
47
|
+
from fit_tool.fit_file_builder import FitFileBuilder
|
|
48
|
+
from fit_tool.profile.messages.device_info_message import DeviceInfoMessage
|
|
49
|
+
from fit_tool.profile.messages.file_id_message import FileIdMessage
|
|
50
|
+
from fit_tool.profile.profile_type import GarminProduct, Manufacturer
|
|
51
|
+
|
|
52
|
+
c = Console()
|
|
53
|
+
dirs = PlatformDirs("FitFileFaker", appauthor=False, ensure_exists=True)
|
|
54
|
+
FILES_UPLOADED_NAME = Path(".uploaded_files.json")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Config:
|
|
59
|
+
garmin_username: str | None = None
|
|
60
|
+
garmin_password: str | None = None
|
|
61
|
+
fitfiles_path: Path | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# set up config file and in-memory config store
|
|
65
|
+
_config_file = dirs.user_config_path / ".config.json"
|
|
66
|
+
_config_file.touch(exist_ok=True)
|
|
67
|
+
_config_keys = ["garmin_username", "garmin_password", "fitfiles_path"]
|
|
68
|
+
with _config_file.open("r") as f:
|
|
69
|
+
if _config_file.stat().st_size == 0:
|
|
70
|
+
_config = Config()
|
|
71
|
+
else:
|
|
72
|
+
_config = Config(**json.load(f))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FitFileLogFilter(logging.Filter):
|
|
76
|
+
"""Filter to remove specific warning from the fit_tool module"""
|
|
77
|
+
|
|
78
|
+
def filter(self, record):
|
|
79
|
+
res = "\n\tactual: " not in record.getMessage()
|
|
80
|
+
return res
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
logging.getLogger("fit_tool").addFilter(FitFileLogFilter())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class NewFileEventHandler(PatternMatchingEventHandler):
|
|
87
|
+
def __init__(self, dryrun: bool = False):
|
|
88
|
+
_logger.debug(f"Creating NewFileEventHandler with {dryrun=}")
|
|
89
|
+
super().__init__(
|
|
90
|
+
patterns=["*.fit"], ignore_directories=True, case_sensitive=False
|
|
91
|
+
)
|
|
92
|
+
self.dryrun = dryrun
|
|
93
|
+
|
|
94
|
+
def on_created(self, event) -> None:
|
|
95
|
+
_logger.info(
|
|
96
|
+
f'New file detected - "{event.src_path}"; sleeping for 5 seconds '
|
|
97
|
+
"to ensure TPV finishes writing file"
|
|
98
|
+
)
|
|
99
|
+
if not self.dryrun:
|
|
100
|
+
# Wait for a short time to make sure TPV has finished writing to the file
|
|
101
|
+
time.sleep(5)
|
|
102
|
+
# Run the upload all function
|
|
103
|
+
p = event.src_path
|
|
104
|
+
if isinstance(p, bytes):
|
|
105
|
+
p = p.decode()
|
|
106
|
+
p = cast(str, p)
|
|
107
|
+
upload_all(Path(p).parent.absolute())
|
|
108
|
+
else:
|
|
109
|
+
_logger.warning(
|
|
110
|
+
"Found new file, but not processing because dryrun was requested"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def print_message(prefix, message):
|
|
115
|
+
man = (
|
|
116
|
+
Manufacturer(message.manufacturer).name
|
|
117
|
+
if message.manufacturer in Manufacturer
|
|
118
|
+
else "BLANK"
|
|
119
|
+
)
|
|
120
|
+
gar_prod = (
|
|
121
|
+
GarminProduct(message.garmin_product)
|
|
122
|
+
if message.garmin_product in GarminProduct
|
|
123
|
+
else "BLANK"
|
|
124
|
+
)
|
|
125
|
+
_logger.debug(
|
|
126
|
+
f'{prefix} - manufacturer: {message.manufacturer} ("{man}") - '
|
|
127
|
+
f'product: {message.product} - garmin product: {message.garmin_product} ("{gar_prod}")'
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_fitfiles_path(existing_path: Path | None) -> Path:
|
|
132
|
+
"""
|
|
133
|
+
Will attempt to auto-find the FITFiles folder inside a TPVirtual data directory.
|
|
134
|
+
|
|
135
|
+
On OSX/Windows, TPVirtual data directory will be auto-detected. This folder can
|
|
136
|
+
be overridden using the ``TPV_DATA_PATH`` environment variable.
|
|
137
|
+
"""
|
|
138
|
+
_logger.info("Getting FITFiles folder")
|
|
139
|
+
|
|
140
|
+
TPVPath = get_tpv_folder(existing_path)
|
|
141
|
+
res = [f for f in os.listdir(TPVPath) if re.search(r"\A(\w){16}\Z", f)]
|
|
142
|
+
if len(res) == 0:
|
|
143
|
+
_logger.error(
|
|
144
|
+
'Cannot find a TP Virtual User folder in "%s", please check if you have previously logged into TP Virtual',
|
|
145
|
+
TPVPath,
|
|
146
|
+
)
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
elif len(res) == 1:
|
|
149
|
+
title = f'Found TP Virtual User directory at "{Path(TPVPath) / res[0]}", is this correct? '
|
|
150
|
+
option = questionary.select(title, choices=["yes", "no"]).ask()
|
|
151
|
+
if option == "no":
|
|
152
|
+
_logger.error(
|
|
153
|
+
'Failed to find correct TP Virtual User folder please manually configure "fitfiles_path" in config file: %s',
|
|
154
|
+
_config_file.absolute(),
|
|
155
|
+
)
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
else:
|
|
158
|
+
option = res[0]
|
|
159
|
+
else:
|
|
160
|
+
title = "Found multiple TP Virtual User directories, please select the directory for your user: "
|
|
161
|
+
option = questionary.select(title, choices=res).ask()
|
|
162
|
+
TPV_data_path = Path(TPVPath) / option
|
|
163
|
+
_logger.info(
|
|
164
|
+
f'Found TP Virtual User directory: "{str(TPV_data_path.absolute())}", '
|
|
165
|
+
'setting "fitfiles_path" in config file'
|
|
166
|
+
)
|
|
167
|
+
return TPV_data_path / "FITFiles"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_tpv_folder(default_path: Path | None) -> Path:
|
|
171
|
+
if os.environ.get("TPV_DATA_PATH", None):
|
|
172
|
+
p = str(os.environ.get("TPV_DATA_PATH"))
|
|
173
|
+
_logger.info(f'Using TPV_DATA_PATH value read from the environment: "{p}"')
|
|
174
|
+
return Path(p)
|
|
175
|
+
if sys.platform == "darwin":
|
|
176
|
+
TPVPath = os.path.expanduser("~/TPVirtual")
|
|
177
|
+
elif sys.platform == "win32":
|
|
178
|
+
TPVPath = os.path.expanduser("~/Documents/TPVirtual")
|
|
179
|
+
else:
|
|
180
|
+
_logger.warning(
|
|
181
|
+
"TrainingPeaks Virtual user folder can only be automatically detected on Windows and OSX"
|
|
182
|
+
)
|
|
183
|
+
TPVPath = questionary.path(
|
|
184
|
+
'Please enter your TrainingPeaks Virtual data folder (by default, ends with "TPVirtual"): ',
|
|
185
|
+
default=str(default_path) if default_path else "",
|
|
186
|
+
).ask()
|
|
187
|
+
return Path(TPVPath)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_date_from_fit(fit_path: Path) -> Optional[datetime]:
|
|
191
|
+
fit_file = FitFile.from_file(str(fit_path))
|
|
192
|
+
res = None
|
|
193
|
+
for i, record in enumerate(fit_file.records):
|
|
194
|
+
message = record.message
|
|
195
|
+
if message.global_id == FileIdMessage.ID:
|
|
196
|
+
if isinstance(message, FileIdMessage):
|
|
197
|
+
res = datetime.fromtimestamp(message.time_created / 1000.0) # type: ignore
|
|
198
|
+
break
|
|
199
|
+
return res
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def edit_fit(
|
|
203
|
+
fit_path: Path, output: Optional[Path] = None, dryrun: bool = False
|
|
204
|
+
) -> Path | None:
|
|
205
|
+
if dryrun:
|
|
206
|
+
_logger.warning('In "dryrun" mode; will not actually write new file.')
|
|
207
|
+
_logger.info(f'Processing "{fit_path}"')
|
|
208
|
+
try:
|
|
209
|
+
fit_file = FitFile.from_file(str(fit_path))
|
|
210
|
+
except Exception:
|
|
211
|
+
_logger.error("File does not appear to be a FIT file, skipping...")
|
|
212
|
+
# c.print_exception(show_locals=True)
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
if not output:
|
|
216
|
+
output = fit_path.parent / f"{fit_path.stem}_modified.fit"
|
|
217
|
+
|
|
218
|
+
builder = FitFileBuilder(auto_define=True)
|
|
219
|
+
dt = None
|
|
220
|
+
# loop through records, find the one we need to change, and modify the values:
|
|
221
|
+
for i, record in enumerate(fit_file.records):
|
|
222
|
+
message = record.message
|
|
223
|
+
|
|
224
|
+
# change file id to indicate file was saved by Edge 830
|
|
225
|
+
if message.global_id == FileIdMessage.ID:
|
|
226
|
+
if isinstance(message, FileIdMessage):
|
|
227
|
+
dt = datetime.fromtimestamp(message.time_created / 1000.0) # type: ignore
|
|
228
|
+
_logger.info(f'Activity timestamp is "{dt.isoformat()}"')
|
|
229
|
+
print_message(f"FileIdMessage Record: {i}", message)
|
|
230
|
+
if (
|
|
231
|
+
message.manufacturer == Manufacturer.DEVELOPMENT.value
|
|
232
|
+
or message.manufacturer == Manufacturer.ZWIFT.value
|
|
233
|
+
):
|
|
234
|
+
_logger.debug(" Modifying values")
|
|
235
|
+
message.product = GarminProduct.EDGE_830.value
|
|
236
|
+
message.manufacturer = Manufacturer.GARMIN.value
|
|
237
|
+
print_message(f" New Record: {i}", message)
|
|
238
|
+
|
|
239
|
+
# change device info messages
|
|
240
|
+
if message.global_id == DeviceInfoMessage.ID:
|
|
241
|
+
if isinstance(message, DeviceInfoMessage):
|
|
242
|
+
print_message(f"DeviceInfoMessage Record: {i}", message)
|
|
243
|
+
if (
|
|
244
|
+
message.manufacturer == Manufacturer.DEVELOPMENT.value
|
|
245
|
+
or message.manufacturer == 0
|
|
246
|
+
or message.manufacturer == Manufacturer.WAHOO_FITNESS.value
|
|
247
|
+
or message.manufacturer == Manufacturer.ZWIFT.value
|
|
248
|
+
):
|
|
249
|
+
_logger.debug(" Modifying values")
|
|
250
|
+
message.garmin_product = GarminProduct.EDGE_830.value
|
|
251
|
+
message.product = GarminProduct.EDGE_830.value # type: ignore
|
|
252
|
+
message.manufacturer = Manufacturer.GARMIN.value
|
|
253
|
+
print_message(f" New Record: {i}", message)
|
|
254
|
+
|
|
255
|
+
builder.add(message)
|
|
256
|
+
|
|
257
|
+
modified_file = builder.build()
|
|
258
|
+
if not dryrun:
|
|
259
|
+
_logger.info(f'Saving modified data to "{output}"')
|
|
260
|
+
modified_file.to_file(str(output))
|
|
261
|
+
else:
|
|
262
|
+
_logger.info(
|
|
263
|
+
f"Dryrun requested, so not saving data "
|
|
264
|
+
f'(would have written to "{output}")'
|
|
265
|
+
)
|
|
266
|
+
return output
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def upload(fn: Path, original_path: Optional[Path] = None, dryrun: bool = False):
|
|
270
|
+
# get credentials and login if needed
|
|
271
|
+
import garth
|
|
272
|
+
from garth.exc import GarthException, GarthHTTPError
|
|
273
|
+
|
|
274
|
+
garth_dir = dirs.user_cache_path / ".garth"
|
|
275
|
+
garth_dir.mkdir(exist_ok=True)
|
|
276
|
+
_logger.debug(f'Using "{garth_dir}" for garth credentials')
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
garth.resume(str(garth_dir.absolute()))
|
|
280
|
+
garth.client.username
|
|
281
|
+
_logger.debug(f'Using stored Garmin credentials from "{garth_dir}" directory')
|
|
282
|
+
except (GarthException, FileNotFoundError):
|
|
283
|
+
# Session is expired. You'll need to log in again
|
|
284
|
+
_logger.info("Authenticating to Garmin Connect")
|
|
285
|
+
email = _config.garmin_username
|
|
286
|
+
password = _config.garmin_password
|
|
287
|
+
if not email:
|
|
288
|
+
email = questionary.text(
|
|
289
|
+
'No "garmin_username" variable set; Enter email address: '
|
|
290
|
+
).ask()
|
|
291
|
+
_logger.debug(f'Using username "{email}"')
|
|
292
|
+
if not password:
|
|
293
|
+
password = questionary.password(
|
|
294
|
+
'No "garmin_password" variable set; Enter password: '
|
|
295
|
+
).ask()
|
|
296
|
+
_logger.debug("Using password from user input")
|
|
297
|
+
else:
|
|
298
|
+
_logger.debug('Using password stored in "garmin_password"')
|
|
299
|
+
garth.login(email, password)
|
|
300
|
+
garth.save(str(garth_dir.absolute()))
|
|
301
|
+
|
|
302
|
+
with fn.open("rb") as f:
|
|
303
|
+
try:
|
|
304
|
+
if not dryrun:
|
|
305
|
+
_logger.info(f'Uploading "{fn}" using garth')
|
|
306
|
+
garth.client.upload(f)
|
|
307
|
+
_logger.info(
|
|
308
|
+
f':white_check_mark: Successfully uploaded "{str(original_path)}"'
|
|
309
|
+
)
|
|
310
|
+
else:
|
|
311
|
+
_logger.info(f'Skipping upload of "{fn}" because dryrun was requested')
|
|
312
|
+
except GarthHTTPError as e:
|
|
313
|
+
if e.error.response.status_code == 409:
|
|
314
|
+
_logger.warning(
|
|
315
|
+
f':x: Received HTTP conflict (activity already exists) for "{str(original_path)}"'
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
raise e
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def upload_all(dir: Path, preinitialize: bool = False, dryrun: bool = False):
|
|
322
|
+
files_uploaded = dir.joinpath(FILES_UPLOADED_NAME)
|
|
323
|
+
if files_uploaded.exists():
|
|
324
|
+
# load uploaded file list from disk
|
|
325
|
+
with files_uploaded.open("r") as f:
|
|
326
|
+
uploaded_files = json.load(f)
|
|
327
|
+
else:
|
|
328
|
+
uploaded_files = []
|
|
329
|
+
with files_uploaded.open("w") as f:
|
|
330
|
+
# write blank file
|
|
331
|
+
json.dump(uploaded_files, f, indent=2)
|
|
332
|
+
_logger.debug(f"Found the following already uploaded files: {uploaded_files}")
|
|
333
|
+
|
|
334
|
+
# glob all .fit files in the current directory
|
|
335
|
+
files = [str(i) for i in dir.glob("*.fit", case_sensitive=False)]
|
|
336
|
+
# strip any leading/trailing slashes from filenames
|
|
337
|
+
files = [i.replace(str(dir), "").strip("/").strip("\\") for i in files]
|
|
338
|
+
# remove files matching what we may have already processed
|
|
339
|
+
files = [i for i in files if not i.endswith("_modified.fit")]
|
|
340
|
+
# remove files found in the "already uploaded" list
|
|
341
|
+
files = [i for i in files if i not in uploaded_files]
|
|
342
|
+
|
|
343
|
+
_logger.info(f"Found {len(files)} files to edit/upload")
|
|
344
|
+
_logger.debug(f"Files to upload: {files}")
|
|
345
|
+
|
|
346
|
+
if not files:
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
for f in files:
|
|
350
|
+
_logger.info(f'Processing "{f}"') # type: ignore
|
|
351
|
+
|
|
352
|
+
if not preinitialize:
|
|
353
|
+
with NamedTemporaryFile(delete=True, delete_on_close=False) as fp:
|
|
354
|
+
output = edit_fit(dir.joinpath(f), output=Path(fp.name))
|
|
355
|
+
if output:
|
|
356
|
+
_logger.info("Uploading modified file to Garmin Connect")
|
|
357
|
+
upload(output, original_path=Path(f), dryrun=dryrun)
|
|
358
|
+
_logger.debug(f'Adding "{f}" to "uploaded_files"')
|
|
359
|
+
else:
|
|
360
|
+
_logger.info(
|
|
361
|
+
"Preinitialize was requested, so just marking as uploaded (not actually processing)"
|
|
362
|
+
)
|
|
363
|
+
uploaded_files.append(f)
|
|
364
|
+
|
|
365
|
+
if not dryrun:
|
|
366
|
+
with files_uploaded.open("w") as f:
|
|
367
|
+
json.dump(uploaded_files, f, indent=2)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def monitor(watch_dir: Path, dryrun: bool = False):
|
|
371
|
+
event_handler = NewFileEventHandler(dryrun=dryrun)
|
|
372
|
+
observer = Observer()
|
|
373
|
+
observer.schedule(event_handler, str(watch_dir.absolute()), recursive=True)
|
|
374
|
+
observer.start()
|
|
375
|
+
if dryrun:
|
|
376
|
+
_logger.warning("Dryrun was requested, so will not actually take any actions")
|
|
377
|
+
_logger.info(f'Monitoring directory: "{watch_dir.absolute()}"')
|
|
378
|
+
try:
|
|
379
|
+
while observer.is_alive():
|
|
380
|
+
observer.join(1)
|
|
381
|
+
except KeyboardInterrupt:
|
|
382
|
+
_logger.info("Received keyboard interrupt, shutting down monitor")
|
|
383
|
+
finally:
|
|
384
|
+
observer.stop()
|
|
385
|
+
observer.join()
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def config_is_valid(excluded_keys=[]) -> bool:
|
|
389
|
+
missing_vals = []
|
|
390
|
+
for k in _config_keys:
|
|
391
|
+
if (
|
|
392
|
+
not hasattr(_config, k) or getattr(_config, k) is None
|
|
393
|
+
) and k not in excluded_keys:
|
|
394
|
+
missing_vals.append(k)
|
|
395
|
+
if missing_vals:
|
|
396
|
+
_logger.error(f"The following configuration values are missing: {missing_vals}")
|
|
397
|
+
return False
|
|
398
|
+
return True
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def build_config_file(
|
|
402
|
+
overwrite_existing_vals: bool = False,
|
|
403
|
+
rewrite_config: bool = True,
|
|
404
|
+
excluded_keys: list[str] = [],
|
|
405
|
+
):
|
|
406
|
+
for k in _config_keys:
|
|
407
|
+
if (
|
|
408
|
+
getattr(_config, k) is None or overwrite_existing_vals
|
|
409
|
+
) and k not in excluded_keys:
|
|
410
|
+
valid_input = False
|
|
411
|
+
while not valid_input:
|
|
412
|
+
try:
|
|
413
|
+
if not hasattr(_config, k) or getattr(_config, k) is None:
|
|
414
|
+
_logger.warning(f'Required value "{k}" not found in config')
|
|
415
|
+
msg = f'Enter value to use for "{k}"'
|
|
416
|
+
|
|
417
|
+
if hasattr(_config, k) and getattr(_config, k):
|
|
418
|
+
msg += f'\nor press enter to use existing value of "{getattr(_config, k)}"'
|
|
419
|
+
if k == "garmin_password":
|
|
420
|
+
msg = msg.replace(getattr(_config, k), "<**hidden**>")
|
|
421
|
+
|
|
422
|
+
if k != "fitfiles_path":
|
|
423
|
+
if "password" in k:
|
|
424
|
+
val = questionary.password(msg).unsafe_ask()
|
|
425
|
+
else:
|
|
426
|
+
val = questionary.text(msg).unsafe_ask()
|
|
427
|
+
else:
|
|
428
|
+
val = str(
|
|
429
|
+
get_fitfiles_path(
|
|
430
|
+
Path(_config.fitfiles_path).parent.parent
|
|
431
|
+
if _config.fitfiles_path
|
|
432
|
+
else None
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
if val:
|
|
436
|
+
valid_input = True
|
|
437
|
+
setattr(_config, k, val)
|
|
438
|
+
elif hasattr(_config, k) and getattr(_config, k):
|
|
439
|
+
valid_input = True
|
|
440
|
+
val = getattr(_config, k)
|
|
441
|
+
else:
|
|
442
|
+
_logger.warning(
|
|
443
|
+
"Entered input was not valid, please try again (or press Ctrl-C to cancel)"
|
|
444
|
+
)
|
|
445
|
+
except KeyboardInterrupt:
|
|
446
|
+
_logger.error("User canceled input; exiting!")
|
|
447
|
+
sys.exit(1)
|
|
448
|
+
if rewrite_config:
|
|
449
|
+
with open(_config_file, "w") as f:
|
|
450
|
+
json.dump(asdict(_config), f, indent=2)
|
|
451
|
+
config_content = json.dumps(asdict(_config), indent=2)
|
|
452
|
+
if (
|
|
453
|
+
hasattr(_config, "garmin_password")
|
|
454
|
+
and getattr(_config, "garmin_password") is not None
|
|
455
|
+
):
|
|
456
|
+
config_content = config_content.replace(
|
|
457
|
+
cast(str, _config.garmin_password), "<**hidden**>"
|
|
458
|
+
)
|
|
459
|
+
_logger.info(f"Config file is now:\n{config_content}")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def run():
|
|
463
|
+
v = sys.version_info
|
|
464
|
+
v_str = f"{v.major}.{v.minor}.{v.micro}"
|
|
465
|
+
min_ver = "3.12.0"
|
|
466
|
+
ver = semver.Version.parse(v_str)
|
|
467
|
+
if not ver >= semver.Version.parse(min_ver):
|
|
468
|
+
msg = f'This program requires Python "{min_ver}" or greater (current version is "{v_str}"). Please upgrade your python version.'
|
|
469
|
+
raise OSError(msg)
|
|
470
|
+
|
|
471
|
+
parser = argparse.ArgumentParser(
|
|
472
|
+
description="Tool to add Garmin device information to FIT files and upload them to Garmin Connect. "
|
|
473
|
+
"Currently, only FIT files produced by TrainingPeaks Virtual (https://www.trainingpeaks.com/virtual/) "
|
|
474
|
+
"and Zwift (https://www.zwift.com/) are supported, but it's possible others may work."
|
|
475
|
+
)
|
|
476
|
+
parser.add_argument(
|
|
477
|
+
"input_path",
|
|
478
|
+
nargs="?",
|
|
479
|
+
default=[],
|
|
480
|
+
help="the FIT file or directory to process. This argument can be omitted if the 'fitfiles_path' "
|
|
481
|
+
"config value is set (that directory will be used instead). By default, files will just be edited. "
|
|
482
|
+
'Specify the "-u" flag to also upload them to Garmin Connect.',
|
|
483
|
+
)
|
|
484
|
+
parser.add_argument(
|
|
485
|
+
"-s",
|
|
486
|
+
"--initial-setup",
|
|
487
|
+
help="Use this option to interactively initialize the configuration file (.config.json)",
|
|
488
|
+
action="store_true",
|
|
489
|
+
)
|
|
490
|
+
parser.add_argument(
|
|
491
|
+
"-u",
|
|
492
|
+
"--upload",
|
|
493
|
+
help="upload FIT file (after editing) to Garmin Connect",
|
|
494
|
+
action="store_true",
|
|
495
|
+
)
|
|
496
|
+
parser.add_argument(
|
|
497
|
+
"-ua",
|
|
498
|
+
"--upload-all",
|
|
499
|
+
action="store_true",
|
|
500
|
+
help='upload all FIT files in directory (if they are not in "already processed" list)',
|
|
501
|
+
)
|
|
502
|
+
parser.add_argument(
|
|
503
|
+
"-p",
|
|
504
|
+
"--preinitialize",
|
|
505
|
+
help="preinitialize the list of processed FIT files (mark all existing files in directory as already uploaded)",
|
|
506
|
+
action="store_true",
|
|
507
|
+
)
|
|
508
|
+
parser.add_argument(
|
|
509
|
+
"-m",
|
|
510
|
+
"--monitor",
|
|
511
|
+
help="monitor a directory and upload all newly created FIT files as they are found",
|
|
512
|
+
action="store_true",
|
|
513
|
+
)
|
|
514
|
+
parser.add_argument(
|
|
515
|
+
"-d",
|
|
516
|
+
"--dryrun",
|
|
517
|
+
help="perform a dry run, meaning any files processed will not be saved nor uploaded",
|
|
518
|
+
action="store_true",
|
|
519
|
+
)
|
|
520
|
+
parser.add_argument(
|
|
521
|
+
"-v", "--verbose", help="increase verbosity of log output", action="store_true"
|
|
522
|
+
)
|
|
523
|
+
args = parser.parse_args()
|
|
524
|
+
|
|
525
|
+
# setup logging before anything else
|
|
526
|
+
if args.verbose:
|
|
527
|
+
_logger.setLevel(logging.DEBUG)
|
|
528
|
+
for logger in [
|
|
529
|
+
"urllib3.connectionpool",
|
|
530
|
+
"oauthlib.oauth1.rfc5849",
|
|
531
|
+
"requests_oauthlib.oauth1_auth",
|
|
532
|
+
"asyncio",
|
|
533
|
+
"watchdog.observers.inotify_buffer",
|
|
534
|
+
]:
|
|
535
|
+
logging.getLogger(logger).setLevel(logging.INFO)
|
|
536
|
+
_logger.debug(f'Using "{_config_file}" as config file')
|
|
537
|
+
else:
|
|
538
|
+
_logger.setLevel(logging.INFO)
|
|
539
|
+
for logger in [
|
|
540
|
+
"urllib3.connectionpool",
|
|
541
|
+
"oauthlib.oauth1.rfc5849",
|
|
542
|
+
"requests_oauthlib.oauth1_auth",
|
|
543
|
+
"asyncio",
|
|
544
|
+
"watchdog.observers.inotify_buffer",
|
|
545
|
+
]:
|
|
546
|
+
logging.getLogger(logger).setLevel(logging.WARNING)
|
|
547
|
+
|
|
548
|
+
# if initial_setup, just do config file building
|
|
549
|
+
if args.initial_setup:
|
|
550
|
+
build_config_file(overwrite_existing_vals=True, rewrite_config=True)
|
|
551
|
+
_logger.info(
|
|
552
|
+
f'Config file has been written to "{_config_file}", now run one of the other options to '
|
|
553
|
+
'start editing/uploading files!'
|
|
554
|
+
)
|
|
555
|
+
sys.exit(0)
|
|
556
|
+
if not args.input_path and not (
|
|
557
|
+
args.upload_all or args.monitor or args.preinitialize
|
|
558
|
+
):
|
|
559
|
+
_logger.error(
|
|
560
|
+
'***************************\nSpecify either "--upload-all", "--monitor", "--preinitialize", or one input file/directory to use\n***************************\n'
|
|
561
|
+
)
|
|
562
|
+
parser.print_help()
|
|
563
|
+
sys.exit(1)
|
|
564
|
+
if args.monitor and args.upload_all:
|
|
565
|
+
_logger.error(
|
|
566
|
+
'***************************\nCannot use "--upload-all" and "--monitor" together\n***************************\n'
|
|
567
|
+
)
|
|
568
|
+
parser.print_help()
|
|
569
|
+
sys.exit(1)
|
|
570
|
+
|
|
571
|
+
# check configuration and prompt for values if needed
|
|
572
|
+
excluded_keys = ["fitfiles_path"] if args.input_path else []
|
|
573
|
+
if not config_is_valid(excluded_keys=excluded_keys):
|
|
574
|
+
_logger.warning(
|
|
575
|
+
"Config file was not valid, please fill out the following values."
|
|
576
|
+
)
|
|
577
|
+
build_config_file(
|
|
578
|
+
overwrite_existing_vals=False,
|
|
579
|
+
rewrite_config=True,
|
|
580
|
+
excluded_keys=excluded_keys,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
if args.input_path:
|
|
584
|
+
p = Path(args.input_path).absolute()
|
|
585
|
+
_logger.info(f'Using path "{p}" from command line input')
|
|
586
|
+
else:
|
|
587
|
+
if _config.fitfiles_path is None:
|
|
588
|
+
raise EnvironmentError
|
|
589
|
+
p = Path(_config.fitfiles_path).absolute()
|
|
590
|
+
_logger.info(f'Using path "{p}" from configuration file')
|
|
591
|
+
|
|
592
|
+
if not p.exists():
|
|
593
|
+
_logger.error(
|
|
594
|
+
f'Configured/selected path "{p}" does not exist, please check your configuration.'
|
|
595
|
+
)
|
|
596
|
+
sys.exit(1)
|
|
597
|
+
if p.is_file():
|
|
598
|
+
# if p is a single file, do edit and upload
|
|
599
|
+
_logger.debug(f'"{p}" is a single file')
|
|
600
|
+
output_path = edit_fit(p, dryrun=args.dryrun)
|
|
601
|
+
if (args.upload or args.upload_all) and output_path:
|
|
602
|
+
upload(output_path, original_path=p, dryrun=args.dryrun)
|
|
603
|
+
else:
|
|
604
|
+
_logger.debug(f'"{p}" is a directory')
|
|
605
|
+
# if p is directory, do other stuff
|
|
606
|
+
if args.upload_all or args.preinitialize:
|
|
607
|
+
upload_all(p, args.preinitialize, args.dryrun)
|
|
608
|
+
elif args.monitor:
|
|
609
|
+
monitor(p, args.dryrun)
|
|
610
|
+
else:
|
|
611
|
+
files_to_edit = list(p.glob("*.fit", case_sensitive=False))
|
|
612
|
+
_logger.info(f"Found {len(files_to_edit)} FIT files to edit")
|
|
613
|
+
for f in files_to_edit:
|
|
614
|
+
edit_fit(f, dryrun=args.dryrun)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
if __name__ == "__main__":
|
|
618
|
+
run()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2024, Joshua Taillon
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: fit-file-faker
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: A small tool to edit and upload FIT files to Garmin Connect
|
|
5
|
+
Author-email: Josh Taillon <jat255@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/jat255/Fit-File-Faker
|
|
7
|
+
Project-URL: Issues, https://github.com/jat255/Fit-File-Faker/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.12.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE.md
|
|
14
|
+
Requires-Dist: fit-tool>=0.9.13
|
|
15
|
+
Requires-Dist: garth>=0.5.2
|
|
16
|
+
Requires-Dist: platformdirs>=4.3.6
|
|
17
|
+
Requires-Dist: questionary>=2.1.0
|
|
18
|
+
Requires-Dist: rich>=13.9.4
|
|
19
|
+
Requires-Dist: semver>=3.0.2
|
|
20
|
+
Requires-Dist: watchdog>=6.0.0
|
|
21
|
+
|
|
22
|
+
# Fit File Faker - a FIT File editor and uploader
|
|
23
|
+
|
|
24
|
+
This repo contains a tool that will edit [FIT](https://developer.garmin.com/fit/overview/) files
|
|
25
|
+
to make them appear to come from a Garmin device (Edge 830, currently) and upload them to Garmin Connect
|
|
26
|
+
using the [`garth`](https://github.com/matin/garth/) library. The FIT editing
|
|
27
|
+
is done using Stages Cycling's [`fit_tool`](https://bitbucket.org/stagescycling/python_fit_tool/src/main/) library.
|
|
28
|
+
|
|
29
|
+
The primary use case for this is that [TrainingPeaks Virtual](https://www.trainingpeaks.com/virtual/) (previously
|
|
30
|
+
[indieVelo](https://indievelo.com/)) does not support (AFAIK, Garmin does not allow) automatic uploading to
|
|
31
|
+
[Garmin Connect](http://connect.garmin.com/). The files can be manually uploaded after the fact,
|
|
32
|
+
but since they are not "from Garmin", they will not be used to calculate Garmin's "Training Effect",
|
|
33
|
+
which is used for suggested workouts and other stuff. By changing the FIT file to appear to come
|
|
34
|
+
from a Garmin device, those features should be enabled.
|
|
35
|
+
|
|
36
|
+
Other users have reported using this tool to edit FIT files produced by [Zwift](https://www.zwift.com/)
|
|
37
|
+
prior to uploading to Garmin Connect so that activities on that platform will count towards Garmin Connect
|
|
38
|
+
badges and challenges (see [1](https://forums.zwift.com/t/garmin-disabled-zwift-rides-badges/528612) and
|
|
39
|
+
[2](https://forums.garmin.com/apps-software/mobile-apps-web/f/garmin-connect-web/251574/zwift-rides-no-longer-count-towards-challenges)).
|
|
40
|
+
|
|
41
|
+
## Contributors
|
|
42
|
+
|
|
43
|
+
- [jat255](https://github.com/jat255): Primary author
|
|
44
|
+
- [benjmarshall](https://github.com/benjmarshall): bug fixes, monitor mode, and other improvements
|
|
45
|
+
- [Kellett](https://github.com/Kellett): support for Zwift FIT files
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
Requires Python 3.12. If your system python is older than that,
|
|
50
|
+
[pyenv](https://github.com/pyenv/pyenv) or [uv](https://docs.astral.sh/uv/) can be
|
|
51
|
+
used to manage locally installed versions.
|
|
52
|
+
|
|
53
|
+
This tool should work cross-platform on Windows, MacOS, or Linux, though it is primarily
|
|
54
|
+
developed on Linux, so it's possible there are some cross-platform bugs.
|
|
55
|
+
|
|
56
|
+
### pipx install
|
|
57
|
+
|
|
58
|
+
If you have [pipx](https://pipx.pypa.io/latest/installation/) installed, a simple
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
$ pipx install fit-file-faker
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
will install the tool, and make the script named `fit-file-faker` available on your PATH.
|
|
65
|
+
|
|
66
|
+
### Manual virtual environment
|
|
67
|
+
|
|
68
|
+
You can also install manually using `pip`. If so, it's best to create a new
|
|
69
|
+
virtual environment:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
$ python -m venv .venv
|
|
73
|
+
$ source .venv/bin/activate
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Then install via pip:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
$ pip install fit-file-faker
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The pip package installs a script named `fit-file-faker` that should be available on your
|
|
83
|
+
path assuming the virtual envrionment is activated.
|
|
84
|
+
|
|
85
|
+
### Development install
|
|
86
|
+
|
|
87
|
+
If you want to install a development version, clone the repo, and use the
|
|
88
|
+
[uv](https://docs.astral.sh/uv/):
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
$ git clone https://github.com/jat255/fit_file_uploader.git
|
|
92
|
+
$ cd fit_file_uploader
|
|
93
|
+
$ uv sync # this installs the dependencies
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Configuration
|
|
97
|
+
|
|
98
|
+
The script uses a configuration file named `.config.json` stored in your system's user config directory
|
|
99
|
+
(as determined by the [`platformdirs`](https://github.com/tox-dev/platformdirs) library).
|
|
100
|
+
An example is provided in this repo in `.config.json.example`:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"garmin_username": "username",
|
|
105
|
+
"garmin_password": "password",
|
|
106
|
+
"fitfiles_path": "C:\\Users\\username\\Documents\\TPVirtual\\0123456789ABCDEF\\FITFiles"
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The best way to fill out this config file is to run the "initial setup"
|
|
111
|
+
option via the `-s` flag, which will allow you to define the three required values interactively:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
$ fit-file-faker -s
|
|
115
|
+
|
|
116
|
+
[13:50:02] WARNING Required value "garmin_username" not found in config app.py:404
|
|
117
|
+
? Enter value to use for "garmin_username" username
|
|
118
|
+
[13:50:05] WARNING Required value "garmin_password" not found in config app.py:404
|
|
119
|
+
? Enter value to use for "garmin_password" ********
|
|
120
|
+
[13:50:06] WARNING Required value "fitfiles_path" not found in config app.py:404
|
|
121
|
+
INFO Getting FITFiles folder app.py:133
|
|
122
|
+
WARNING TrainingPeaks Virtual user folder can only be automatically app.py:175
|
|
123
|
+
detected on Windows and OSX
|
|
124
|
+
? Please enter your TrainingPeaks Virtual data folder (by default, ends with "TPVirtual"):
|
|
125
|
+
/home/user/Documents/TPVirtual
|
|
126
|
+
? Found TP Virtual User directory at "/home/user/Documents/TPVirtual/0123456789ABCDEF", is this correct?
|
|
127
|
+
yes
|
|
128
|
+
[13:50:17] INFO Found TP Virtual User directory: "/home/user/Documents/TPVirtual app.py:158
|
|
129
|
+
sync/0123456789ABCEDF", setting "fitfiles_path" in config file
|
|
130
|
+
INFO Config file is now: app.py:440
|
|
131
|
+
{
|
|
132
|
+
"garmin_username": "username",
|
|
133
|
+
"garmin_password": "<**hidden**>",
|
|
134
|
+
"fitfiles_path": "/home/user/Documents/TPVirtual/0123456789ABCDEF/FITFiles"
|
|
135
|
+
}
|
|
136
|
+
INFO Config file has been written, now run one of the other options app.py:530
|
|
137
|
+
to start editing/uploading files!
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Usage
|
|
141
|
+
|
|
142
|
+
The script has a few options. To see the help, run with the `-h` flag:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
$ fit-file-faker -h
|
|
146
|
+
```
|
|
147
|
+
```
|
|
148
|
+
usage: fit-file-faker [-h] [-s] [-u] [-ua] [-p] [-m] [--dryrun] [-v] [input_path]
|
|
149
|
+
|
|
150
|
+
Tool to add Garmin device information to FIT files and upload them to Garmin Connect. Currently,
|
|
151
|
+
only FIT files produced by TrainingPeaks Virtual (https://www.trainingpeaks.com/virtual/) and
|
|
152
|
+
Zwift (https://www.zwift.com/) are supported, but it's possible others may work.
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
positional arguments:
|
|
156
|
+
input_path the FIT file or directory to process. This argument can be omitted if
|
|
157
|
+
the 'fitfiles_path' config value is set (that directory will be used
|
|
158
|
+
instead). By default, files will just be edited. Specify the "-u" flag
|
|
159
|
+
to also upload them to Garmin Connect.
|
|
160
|
+
|
|
161
|
+
options:
|
|
162
|
+
-h, --help show this help message and exit
|
|
163
|
+
-s, --initial-setup Use this option to interactively initialize the configuration file
|
|
164
|
+
(.config.json)
|
|
165
|
+
-u, --upload upload FIT file (after editing) to Garmin Connect
|
|
166
|
+
-ua, --upload-all upload all FIT files in directory (if they are not in "already
|
|
167
|
+
processed" list)
|
|
168
|
+
-p, --preinitialize preinitialize the list of processed FIT files (mark all existing files
|
|
169
|
+
in directory as already uploaded)
|
|
170
|
+
-m, --monitor monitor a directory and upload all newly created FIT files as they are
|
|
171
|
+
found
|
|
172
|
+
-d, --dryrun perform a dry run, meaning any files processed will not be saved nor
|
|
173
|
+
uploaded
|
|
174
|
+
-v, --verbose increase verbosity of log output
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Basic usage
|
|
178
|
+
|
|
179
|
+
The default behavior with no other options load a given FIT file, and output a file named `path_to_file_modified.fit`
|
|
180
|
+
that has been edited, and can be manually imported to Garmin Connect:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
$ fit-file-faker path_to_file.fit
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
If a directory is supplied rather than a single file, all FIT files in that directory will be processed in
|
|
187
|
+
the same way.
|
|
188
|
+
|
|
189
|
+
Supplying the `-u` option will attempt to upload the edited file to Garmin Connect. If
|
|
190
|
+
your credentials are not stored in the configuration file, the script will prompt you for them.
|
|
191
|
+
The OAuth credentials obtained for the Garmin web service will be stored in a directory
|
|
192
|
+
named `.garth` in your system's user cache folder (as determined by
|
|
193
|
+
[`platformdirs`](https://github.com/tox-dev/platformdirs)). See the `garth` library's
|
|
194
|
+
[documentation](https://github.com/matin/garth/?tab=readme-ov-file#authentication-and-stability)
|
|
195
|
+
for details:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
$ fit-file-faker -u path_to_file.fit
|
|
199
|
+
```
|
|
200
|
+
```
|
|
201
|
+
[12:14:06] INFO Activity timestamp is "2024-05-21T17:15:48" app.py:84
|
|
202
|
+
INFO Saving modified data to path_to_file_modified.fit app.py:106
|
|
203
|
+
[12:14:08] INFO ✅ Successfully uploaded "path_to_file.fit" app.py:137
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The `-v` flag can be used (with any of the other options) to provide more debugging output:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
$ fit-file-faker -u path_to_file.fit -v
|
|
210
|
+
```
|
|
211
|
+
```
|
|
212
|
+
[12:38:33] INFO Activity timestamp is "2024-05-21T17:15:48" app.py:84
|
|
213
|
+
DEBUG Record: 1 - manufacturer: 255 ("DEVELOPMENT") - product: 0 - garmin app.py:55
|
|
214
|
+
product: None ("BLANK")
|
|
215
|
+
DEBUG Modifying values app.py:87
|
|
216
|
+
DEBUG New Record: 1 - manufacturer: 1 ("GARMIN") - product: 3122 - garmin app.py:55
|
|
217
|
+
product: 3122 ("GarminProduct.EDGE_830")
|
|
218
|
+
DEBUG Record: 14 - manufacturer: 32 ("WAHOO_FITNESS") - product: 40 - garmin app.py:55
|
|
219
|
+
product: None ("BLANK")
|
|
220
|
+
DEBUG Modifying values app.py:97
|
|
221
|
+
DEBUG New Record: 14 - manufacturer: 1 ("GARMIN") - product: 3122 - garmin app.py:55
|
|
222
|
+
product: 3122 ("GarminProduct.EDGE_830")
|
|
223
|
+
DEBUG Record: 15 - manufacturer: 32 ("WAHOO_FITNESS") - product: 6 - garmin app.py:55
|
|
224
|
+
product: None ("BLANK")
|
|
225
|
+
DEBUG Modifying values app.py:97
|
|
226
|
+
DEBUG New Record: 15 - manufacturer: 1 ("GARMIN") - product: 3122 - garmin app.py:55
|
|
227
|
+
product: 3122 ("GarminProduct.EDGE_830")
|
|
228
|
+
DEBUG Record: 16 - manufacturer: 1 ("GARMIN") - product: 18 - garmin product: app.py:55
|
|
229
|
+
18 ("BLANK")
|
|
230
|
+
INFO Saving modified data to app.py:106
|
|
231
|
+
"path_to_file_modified.fit"
|
|
232
|
+
[12:38:34] DEBUG Using stored Garmin credentials from ".garth" directory app.py:118
|
|
233
|
+
[12:38:35] INFO ✅ Successfully uploaded "path_to_file.fit" app.py:137
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### "Upload all" and "monitor" modes
|
|
237
|
+
|
|
238
|
+
The `--upload-all` option will search for all FIT files eith in the directory given on the command line,
|
|
239
|
+
or in the one specified in the `fitfiles_path` config option. The script will compare the files found to a
|
|
240
|
+
list of files already seen (stored in that directory's `.uploaded_files.json` file), edit them, and upload
|
|
241
|
+
each to Garmin Connect. The edited files will be written into a temporary file and discarded when the
|
|
242
|
+
script finishes running, and the filenames will be stored into a JSON file in the current directory so
|
|
243
|
+
they are skipped the next time the script is run.
|
|
244
|
+
|
|
245
|
+
The upload all function can alternatively be automated using the `--monitor` option, which will start
|
|
246
|
+
watching the filesystem in the specified directory for any new FIT files, and continue running until
|
|
247
|
+
the user interrupts the process by pressing `ctrl-c`. Here is an example output when a new file named
|
|
248
|
+
`new_fit_file.fit` is detected:
|
|
249
|
+
|
|
250
|
+
```
|
|
251
|
+
$ fit-file-faker --monitor /home/user/Documents/TPVirtual/0123456789ABCEDF/FITFiles
|
|
252
|
+
|
|
253
|
+
[14:03:32] INFO Using path "/home/user/Documents/TPVirtual/ app.py:561
|
|
254
|
+
0123456789ABCEDF/FITFiles" from command line input
|
|
255
|
+
INFO Monitoring directory: "/home/user/Documents/TPVirtual/ app.py:367
|
|
256
|
+
0123456789ABCEDF/FITFiles"
|
|
257
|
+
[14:03:44] INFO New file detected - "/home/user/Documents/TPVirtual/ app.py:94
|
|
258
|
+
0123456789ABCEDF/FITFiles/new_fit_file.fit"; sleeping for
|
|
259
|
+
5 seconds to ensure TPV finishes writing file
|
|
260
|
+
[14:03:50] INFO Found 1 files to edit/upload app.py:333
|
|
261
|
+
INFO Processing "new_fit_file.fit" app.py:340
|
|
262
|
+
INFO Processing "/home/user/Documents/TPVirtual app.py:202
|
|
263
|
+
sync/0123456789ABCEDF/FITFiles/new_fit_file.fit"
|
|
264
|
+
[14:03:58] INFO Activity timestamp is "2025-01-03T17:01:45" app.py:223
|
|
265
|
+
[14:03:59] INFO Saving modified data to "/tmp/tmpsn4gvpkh" app.py:250
|
|
266
|
+
[14:04:00] INFO Uploading modified file to Garmin Connect app.py:346
|
|
267
|
+
[14:04:01] INFO Uploading "/tmp/tmpsn4gvpkh" using garth app.py:295
|
|
268
|
+
^C[14:04:46] INFO Received keyboard interrupt, shutting down monitor app.py:372
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
If your TrainingPeaks Virtual user data folder already contains FIT files which you have previously uploaded
|
|
272
|
+
to Garmin Connect using a different method then you can pre-initialise the list of uploaded files to avoid
|
|
273
|
+
any possibility of uploading duplicates (though these files *should* be rejected by Garmin Connect
|
|
274
|
+
if they're exact duplicates). Use the `--preinitialize` option to process a directory (defaults to
|
|
275
|
+
the configured TrainingPeaks Virtual user data directory) and add all files to the list of previous uploaded
|
|
276
|
+
files. After this any use of the `--upload-all` or `--monitor` options will ignore these pre-existing files.
|
|
277
|
+
|
|
278
|
+
### Already uploaded files
|
|
279
|
+
|
|
280
|
+
A note: if a file with the same timestamp already exists on the Garmin Connect account, Garmin
|
|
281
|
+
will reject the upload. This script will detect that, and output something like the following:
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
$ fit-file-faker -u path_to_file.fit -v
|
|
285
|
+
```
|
|
286
|
+
```
|
|
287
|
+
[13:32:48] INFO Activity timestamp is "2024-05-10T17:17:34" app.py:85
|
|
288
|
+
INFO Saving modified data to "path_to_file_modified.fit" app.py:107
|
|
289
|
+
[13:32:49] WARNING ❌ Received HTTP conflict (activity already exists) for app.py:143
|
|
290
|
+
"path_to_file.fit"
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Troubleshooting
|
|
294
|
+
|
|
295
|
+
If you run into problems, please
|
|
296
|
+
[create an issue](https://github.com/jat255/fit_file_uploader/issues/new/choose) on the GitHub
|
|
297
|
+
repo. As this is a side-project provided for free (as in speech and beer), support times may vary 😅.
|
|
298
|
+
|
|
299
|
+
## Disclaimer
|
|
300
|
+
|
|
301
|
+
The use of any registered or unregistered trademarks owned by third-parties are used only for
|
|
302
|
+
informational purposes and no endorsement of this software by the owners of such trademarks are
|
|
303
|
+
implied, explicitly or otherwise. The terms/trademarks indieVelo, TrainingPeaks, TrainingPeaks Virtual,
|
|
304
|
+
Garmin Connect, Stages Cycling, and any others are used under fair use doctrine solely to
|
|
305
|
+
facilitate understanding.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
app.py,sha256=SRbb_Urd8_eeYOohlUCzq1bROBa00Xgjof6gMykKJSE,23394
|
|
2
|
+
fit_file_faker-1.2.0.dist-info/LICENSE.md,sha256=_khkWosE532fguqb--gS0Uzs-vGBPSewsmRCuVgQ_Tc,1062
|
|
3
|
+
fit_file_faker-1.2.0.dist-info/METADATA,sha256=lTdxBKW_H6oTUqLfdobcepp58p4oaDEcjPEbNXOeb6I,15778
|
|
4
|
+
fit_file_faker-1.2.0.dist-info/WHEEL,sha256=A3WOREP4zgxI0fKrHUG8DC8013e3dK3n7a6HDbcEIwE,91
|
|
5
|
+
fit_file_faker-1.2.0.dist-info/entry_points.txt,sha256=8ChIxp2zbG5-i2nHPBgUoI8x2PX7MQlzhGpjbuN-rdg,43
|
|
6
|
+
fit_file_faker-1.2.0.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
|
|
7
|
+
fit_file_faker-1.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
app
|