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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.7.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fit-file-faker = app:run
@@ -0,0 +1 @@
1
+ app