Skip to content

API Reference

This page documents the public API for Fit File Faker.

Main Application interface (app.py)

Main application module for Fit File Faker.

This module provides the command-line interface and core application logic for modifying FIT files and uploading them to Garmin Connect. It simulates a Garmin Edge 830 device (by default) to enable Training Effect calculations for activities from non-Garmin sources.

The module includes:

  • CLI argument parsing and validation
  • FIT file upload to Garmin Connect with OAuth authentication
  • Batch processing of multiple FIT files
  • Directory monitoring for automatic processing of new files
  • Rich console output with colored logs

Typical usage:

$ fit-file-faker --config-menu         # Initial setup
$ fit-file-faker activity.fit          # Edit single file
$ fit-file-faker -u activity.fit       # Edit and upload
$ fit-file-faker -ua                   # Upload all new files
$ fit-file-faker -m                    # Monitor directory

NewFileEventHandler

NewFileEventHandler(profile: Profile, dryrun: bool = False)

Bases: PatternMatchingEventHandler

Event handler for monitoring directory changes and processing new FIT files.

Extends watchdog's PatternMatchingEventHandler to automatically process and upload new FIT files as they're created in the monitored directory. Also handles file modification events for MyWhoosh files that follow the pattern "MyNewActivity-*.fit", as MyWhoosh overwrites the same file on completion rather than creating a new file.

Includes a 5-second delay to ensure the file is fully written before processing.

Attributes:

Name Type Description
dryrun

If True, detects files but doesn't process them. Useful for testing.

profile

The profile to use for uploading files.

Examples:

>>> # Typically used via monitor() function, but can be instantiated directly:
>>> from watchdog.observers.polling import PollingObserver as Observer
>>> handler = NewFileEventHandler(profile=profile, dryrun=False)
>>> observer = Observer()
>>> observer.schedule(handler, "/path/to/fitfiles", recursive=True)
>>> observer.start()

Initialize the file event handler.

Parameters:

Name Type Description Default
profile Profile

The profile to use for uploading files.

required
dryrun bool

If True, log file detections but don't process them. Defaults to False.

False
Source code in fit_file_faker/app.py
def __init__(self, profile: Profile, dryrun: bool = False):
    """Initialize the file event handler.

    Args:
        profile: The profile to use for uploading files.
        dryrun: If `True`, log file detections but don't process them.
            Defaults to `False`.
    """
    _logger.debug(f"Creating NewFileEventHandler with {dryrun=}")
    super().__init__(
        patterns=["*.fit", "MyNewActivity-*.fit"],
        ignore_directories=True,
        case_sensitive=False,
    )
    self.profile = profile
    self.dryrun = dryrun

on_created

on_created(event: FileCreatedEvent) -> None

Handle file creation events.

Called by watchdog when a new .fit file is created in the monitored directory. Waits 5 seconds to ensure the file is fully written, then processes all new files in the directory via upload_all().

Parameters:

Name Type Description Default
event FileCreatedEvent

The file system event containing the path to the new file.

required
Note

The 5-second delay is necessary because TrainingPeaks Virtual may still be writing to the file when the creation event fires. Without this delay, the file might be incomplete or corrupt.

Source code in fit_file_faker/app.py
def on_created(self, event: FileCreatedEvent) -> None:
    """Handle file creation events.

    Called by watchdog when a new `.fit` file is created in the monitored
    directory. Waits 5 seconds to ensure the file is fully written, then
    processes all new files in the directory via
    [`upload_all()`][fit_file_faker.app.upload_all].

    Args:
        event: The file system event containing the path to the new file.

    Note:
        The 5-second delay is necessary because TrainingPeaks Virtual may
        still be writing to the file when the creation event fires. Without
        this delay, the file might be incomplete or corrupt.
    """
    _logger.info(
        f'New file detected - "{event.src_path}"; sleeping for 5 seconds '
        "to ensure TPV finishes writing file"
    )
    if not self.dryrun:
        # Wait for a short time to make sure TPV has finished writing to the file
        time.sleep(5)
        # Run the upload all function
        p = event.src_path
        if isinstance(p, bytes):
            p = p.decode()  # pragma: no cover
        p = cast(str, p)
        upload_all(Path(p).parent.absolute(), profile=self.profile)
    else:
        _logger.warning(
            "Found new file, but not processing because dryrun was requested"
        )

on_modified

on_modified(event: FileModifiedEvent) -> None

Handle file modification events.

Called by watchdog when a .fit file is modified in the monitored directory. This is specifically useful for MyWhoosh files that follow the pattern "MyNewActivity-*.fit", as MyWhoosh overwrites the same file on completion rather than creating a new file.

Parameters:

Name Type Description Default
event FileModifiedEvent

The file system event containing the path to the modified file.

required
Note

Waits 5 seconds to ensure the file is fully written, similar to the creation event handler. This handles the case where MyWhoosh overwrites existing files. Only processes the specific modified file rather than all files in the directory.

Source code in fit_file_faker/app.py
def on_modified(self, event: FileModifiedEvent) -> None:
    """Handle file modification events.

    Called by watchdog when a `.fit` file is modified in the monitored
    directory. This is specifically useful for MyWhoosh files that follow
    the pattern "MyNewActivity-*.fit", as MyWhoosh overwrites the same file
    on completion rather than creating a new file.

    Args:
        event: The file system event containing the path to the modified file.

    Note:
        Waits 5 seconds to ensure the file is fully written, similar to
        the creation event handler. This handles the case where MyWhoosh
        overwrites existing files. Only processes the specific modified file
        rather than all files in the directory.
    """
    # Only process MyWhoosh files that match the pattern
    if "MyNewActivity-" in event.src_path:
        _logger.info(
            f'File modified detected - "{event.src_path}"; sleeping for 5 seconds '
            "to ensure MyWhoosh finishes writing file"
        )
        if not self.dryrun:
            # Wait for a short time to make sure MyWhoosh has finished writing to the file
            time.sleep(5)
            # Process only the modified file
            p = event.src_path
            if isinstance(p, bytes):
                p = p.decode()  # pragma: no cover
            p = cast(str, p)
            source_file = Path(p).absolute()

            # Edit the file and upload it
            with NamedTemporaryFile(delete=True, delete_on_close=False) as fp:
                fit_editor.set_profile(self.profile)
                output = fit_editor.edit_fit(source_file, output=Path(fp.name))
                if output:
                    _logger.info(
                        f"Uploading modified file ({output}) to Garmin Connect"
                    )
                    upload(
                        output,
                        profile=self.profile,
                        original_path=source_file,
                        dryrun=self.dryrun,
                    )

                    # Track uploaded file to prevent re-processing
                    uploaded_list = source_file.parent / FILES_UPLOADED_NAME
                    uploaded_files = []
                    if uploaded_list.exists():
                        with uploaded_list.open("r") as f:
                            uploaded_files = json.load(f)

                    filename = source_file.name
                    if filename not in uploaded_files:
                        uploaded_files.append(filename)
                        with uploaded_list.open("w") as f:
                            json.dump(uploaded_files, f, indent=2)
                        _logger.debug(f'Added "{filename}" to uploaded files list')
        else:
            _logger.warning(
                "Found modified file, but not processing because dryrun was requested"
            )

get_garth_dir

get_garth_dir(profile_name: str) -> Path

Get profile-specific garth directory for credential isolation.

Each profile gets its own garth directory to prevent credential conflicts when managing multiple Garmin accounts. The profile name is sanitized to ensure filesystem compatibility.

Parameters:

Name Type Description Default
profile_name str

The name of the profile.

required

Returns:

Type Description
Path

Path to the profile-specific garth directory.

Examples:

>>> get_garth_dir("tpv")
PosixPath('/Users/josh/Library/Caches/FitFileFaker/.garth_tpv')
>>>
>>> get_garth_dir("work-account")
PosixPath('/Users/josh/Library/Caches/FitFileFaker/.garth_work-account')
Note

The directory is automatically created if it doesn't exist. Profile names with special characters are sanitized (replaced with '_').

Source code in fit_file_faker/app.py
def get_garth_dir(profile_name: str) -> Path:
    """Get profile-specific garth directory for credential isolation.

    Each profile gets its own garth directory to prevent credential conflicts
    when managing multiple Garmin accounts. The profile name is sanitized to
    ensure filesystem compatibility.

    Args:
        profile_name: The name of the profile.

    Returns:
        Path to the profile-specific garth directory.

    Examples:
        >>> get_garth_dir("tpv")
        PosixPath('/Users/josh/Library/Caches/FitFileFaker/.garth_tpv')
        >>>
        >>> get_garth_dir("work-account")
        PosixPath('/Users/josh/Library/Caches/FitFileFaker/.garth_work-account')

    Note:
        The directory is automatically created if it doesn't exist.
        Profile names with special characters are sanitized (replaced with '_').
    """
    safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in profile_name)
    garth_dir = dirs.user_cache_path / f".garth_{safe_name}"
    garth_dir.mkdir(exist_ok=True, parents=True)
    return garth_dir

monitor

monitor(
    watch_dir: Path, profile: Profile, dryrun: bool = False
)

Monitor a directory for new FIT files and automatically process them.

Uses watchdog's PollingObserver to watch for new .fit files in the specified directory. When a new file is detected, waits 5 seconds to ensure it's fully written, then processes and uploads it via upload_all().

The monitor runs until interrupted by Ctrl-C (KeyboardInterrupt).

Parameters:

Name Type Description Default
watch_dir Path

Path to the directory to monitor.

required
profile Profile

The profile to use for authentication and upload.

required
dryrun bool

If True, detects new files but doesn't process them. Defaults to False.

False

Examples:

>>> from pathlib import Path
>>>
>>> # Monitor a directory
>>> monitor(Path("/home/user/TPVirtual/abc123/FITFiles"), profile=my_profile)
Monitoring directory: "/home/user/TPVirtual/abc123/FITFiles"
# Press Ctrl-C to stop
Note

Uses PollingObserver for cross-platform compatibility. This may be less efficient than platform-specific observers but works consistently across macOS, Windows, and Linux.

Source code in fit_file_faker/app.py
def monitor(watch_dir: Path, profile: Profile, dryrun: bool = False):
    """Monitor a directory for new FIT files and automatically process them.

    Uses watchdog's PollingObserver to watch for new .fit files in the specified
    directory. When a new file is detected, waits 5 seconds to ensure it's fully
    written, then processes and uploads it via [`upload_all()`][fit_file_faker.app.upload_all].

    The monitor runs until interrupted by Ctrl-C (`KeyboardInterrupt`).

    Args:
        watch_dir: Path to the directory to monitor.
        profile: The profile to use for authentication and upload.
        dryrun: If `True`, detects new files but doesn't process them.
            Defaults to `False`.

    Examples:
        >>> from pathlib import Path
        >>>
        >>> # Monitor a directory
        >>> monitor(Path("/home/user/TPVirtual/abc123/FITFiles"), profile=my_profile)
        Monitoring directory: "/home/user/TPVirtual/abc123/FITFiles"
        # Press Ctrl-C to stop

    Note:
        Uses `PollingObserver` for cross-platform compatibility. This may be
        less efficient than platform-specific observers but works consistently
        across macOS, Windows, and Linux.
    """
    event_handler = NewFileEventHandler(profile=profile, dryrun=dryrun)
    observer = Observer()
    observer.schedule(event_handler, str(watch_dir.absolute()), recursive=True)
    observer.start()
    if dryrun:  # pragma: no cover
        _logger.warning("Dryrun was requested, so will not actually take any actions")
    _logger.info(f'Monitoring directory: "{watch_dir.absolute()}"')
    try:
        while observer.is_alive():
            observer.join(1)
    except KeyboardInterrupt:
        _logger.info("Received keyboard interrupt, shutting down monitor")
    finally:
        observer.stop()
        observer.join()

run

run()

Main entry point for the fit-file-faker command-line application.

Parses command-line arguments, validates configuration, and executes the appropriate operation (edit, upload, batch upload, or monitor). This function is registered as the console script entry point in pyproject.toml.

Command-line options:

--profile: Specify which profile to use
--list-profiles: List all available profiles
--config-menu: Launch the interactive profile management menu
--show-dirs: Show directories used for configuration and cache
-u, --upload: Upload file after editing
-ua, --upload-all: Batch upload all new files
-p, --preinitialize: Mark all existing files as already uploaded
-m, --monitor: Monitor directory for new files
-d, --dryrun: Perform dry run (no file writes or uploads)
-v, --verbose: Enable verbose debug logging

Raises:

Type Description
SystemExit

If configuration is invalid, required arguments are missing, or conflicting arguments are provided.

Examples:

# run() is called automatically when running the installed command:
$ fit-file-faker --config-menu
$ fit-file-faker --show-dirs
$ fit-file-faker -u activity.fit
$ fit-file-faker -ua
$ fit-file-faker -m
Note

Requires Python 3.12 or higher. Exits with error if Python version requirement is not met.

Source code in fit_file_faker/app.py
def run():
    """Main entry point for the fit-file-faker command-line application.

    Parses command-line arguments, validates configuration, and executes the
    appropriate operation (edit, upload, batch upload, or monitor). This function
    is registered as the console script entry point in pyproject.toml.

    Command-line options:

        --profile: Specify which profile to use
        --list-profiles: List all available profiles
        --config-menu: Launch the interactive profile management menu
        --show-dirs: Show directories used for configuration and cache
        -u, --upload: Upload file after editing
        -ua, --upload-all: Batch upload all new files
        -p, --preinitialize: Mark all existing files as already uploaded
        -m, --monitor: Monitor directory for new files
        -d, --dryrun: Perform dry run (no file writes or uploads)
        -v, --verbose: Enable verbose debug logging

    Raises:
        SystemExit: If configuration is invalid, required arguments are missing,
            or conflicting arguments are provided.

    Examples:

        # run() is called automatically when running the installed command:
        $ fit-file-faker --config-menu
        $ fit-file-faker --show-dirs
        $ fit-file-faker -u activity.fit
        $ fit-file-faker -ua
        $ fit-file-faker -m

    Note:
        Requires Python 3.12 or higher. Exits with error if Python version
        requirement is not met.
    """
    v = sys.version_info
    v_str = f"{v.major}.{v.minor}.{v.micro}"
    min_ver = "3.12.0"
    ver = semver.Version.parse(v_str)
    if not ver >= semver.Version.parse(min_ver):
        msg = f'This program requires Python "{min_ver}" or greater (current version is "{v_str}"). Please upgrade your python version.'
        raise OSError(msg)

    parser = argparse.ArgumentParser(
        description="Tool to add Garmin device information to FIT files and upload them to Garmin Connect. "
        "Currently, only FIT files produced by TrainingPeaks Virtual (https://www.trainingpeaks.com/virtual/), "
        "Zwift (https://www.zwift.com/), and MyWhoosh (https://mywhoosh.com/) are supported, but it's "
        "possible others may work."
    )
    parser.add_argument(
        "input_path",
        nargs="?",
        default=[],
        help="the FIT file or directory to process. This argument can be omitted if the 'fitfiles_path' "
        "config value is set (that directory will be used instead). By default, files will just be edited. "
        'Specify the "-u" flag to also upload them to Garmin Connect.',
    )
    parser.add_argument(
        "--profile",
        help="specify which profile to use (if not specified, uses default profile)",
        type=str,
        default=None,
    )
    parser.add_argument(
        "--list-profiles",
        help="list all available profiles and exit",
        action="store_true",
    )
    parser.add_argument(
        "--config-menu",
        help="launch the interactive profile management menu",
        action="store_true",
    )
    parser.add_argument(
        "--show-dirs",
        help="show the directories used by Fit File Faker for configuration and cache",
        action="store_true",
    )
    parser.add_argument(
        "-u",
        "--upload",
        help="upload FIT file (after editing) to Garmin Connect",
        action="store_true",
    )
    parser.add_argument(
        "-ua",
        "--upload-all",
        action="store_true",
        help='upload all FIT files in directory (if they are not in "already processed" list)',
    )
    parser.add_argument(
        "-p",
        "--preinitialize",
        help="preinitialize the list of processed FIT files (mark all existing files in directory as already uploaded)",
        action="store_true",
    )
    parser.add_argument(
        "-m",
        "--monitor",
        help="monitor a directory and upload all newly created FIT files as they are found",
        action="store_true",
    )
    parser.add_argument(
        "-d",
        "--dryrun",
        help="perform a dry run, meaning any files processed will not be saved nor uploaded",
        action="store_true",
    )
    parser.add_argument(
        "-v", "--verbose", help="increase verbosity of log output", action="store_true"
    )
    parser.add_argument(
        "--version",
        action="version",
        version=f"%(prog)s {version('fit-file-faker')} (released {__version_date__})",
        help="show program version and exit",
    )
    args = parser.parse_args()

    # setup logging before anything else
    if args.verbose:
        _logger.setLevel(logging.DEBUG)
        for logger in [
            "urllib3.connectionpool",
            "oauthlib.oauth1.rfc5849",
            "requests_oauthlib.oauth1_auth",
            "asyncio",
            "watchdog.observers.inotify_buffer",
        ]:
            logging.getLogger(logger).setLevel(logging.INFO)
        _logger.debug(f'Using "{config_manager.get_config_file_path()}" as config file')
    else:
        _logger.setLevel(logging.INFO)
        for logger in [
            "urllib3.connectionpool",
            "oauthlib.oauth1.rfc5849",
            "requests_oauthlib.oauth1_auth",
            "asyncio",
            "watchdog.observers.inotify_buffer",
        ]:
            logging.getLogger(logger).setLevel(logging.WARNING)

    # Handle --list-profiles
    if args.list_profiles:
        if not config_manager.config.profiles:
            _logger.info(
                "No profiles configured. Run with --config-menu to create one."
            )
        else:
            profile_manager.display_profiles_table()
        sys.exit(0)

    # Handle --config-menu
    if args.config_menu:
        profile_manager.interactive_menu()
        sys.exit(0)

    # Handle --show-dirs
    if args.show_dirs:
        from fit_file_faker.config import dirs as config_dirs

        console = Console()
        console.print("\n[bold cyan]Fit File Faker - Directories[/bold cyan]\n")

        # Show executable path
        console.print(f'[green]Executable:[/green] [yellow]"{sys.executable}"[/yellow]')
        console.print(
            f'  [dim]fit-file-faker command:[/dim] [yellow]"{sys.argv[0]}"[/yellow]'
        )

        # Show config directory
        console.print(
            f'\n[green]Config directory:[/green] [yellow]"{config_dirs.user_config_path}"[/yellow]'
        )
        console.print(
            f'  [dim]Configuration file:[/dim] [yellow]"{config_manager.get_config_file_path()}"[/yellow]'
        )

        # Show cache directory
        console.print(
            f'\n[green]Cache directory:[/green] [yellow]"{config_dirs.user_cache_path}"[/yellow]'
        )

        # Find and list actual .garth directories
        garth_dirs = sorted(config_dirs.user_cache_path.glob(".garth_*"))
        if garth_dirs:
            console.print("  [dim]Garmin credential directories:[/dim]")
            for garth_dir in garth_dirs:
                console.print(f'    [yellow]"{garth_dir}"[/yellow]')
        else:
            console.print(
                "  [dim]No Garmin credential directories found (will be created on first use)[/dim]"
            )

        console.print()
        sys.exit(0)

    if not args.input_path and not (
        args.upload_all or args.monitor or args.preinitialize
    ):
        _logger.error(
            '***************************\nSpecify either "--upload-all", "--monitor", "--preinitialize", or one input file/directory to use\n***************************\n'
        )
        parser.print_help()
        sys.exit(1)
    if args.monitor and args.upload_all:
        _logger.error(
            '***************************\nCannot use "--upload-all" and "--monitor" together\n***************************\n'
        )
        parser.print_help()
        sys.exit(1)

    # Select profile to use
    try:
        profile = select_profile(args.profile)
    except ValueError as e:
        _logger.error(str(e))
        sys.exit(1)

    # Determine path to use (from input_path or profile's fitfiles_path)
    if args.input_path:
        p = Path(args.input_path).absolute()
        _logger.info(f'Using path "{p}" from command line input')
    else:
        if profile.fitfiles_path is None:
            _logger.error(
                f'Profile "{profile.name}" does not have a fitfiles_path configured. '
                f"Please update the profile with --config-menu or provide a path as an argument."
            )
            sys.exit(1)
        p = Path(profile.fitfiles_path).absolute()
        _logger.info(f'Using path "{p}" from profile "{profile.name}" configuration')

    if not p.exists():
        _logger.error(
            f'Configured/selected path "{p}" does not exist, please check your configuration.'
        )
        sys.exit(1)
    if p.is_file():
        # if p is a single file, do edit and upload
        _logger.debug(f'"{p}" is a single file')
        fit_editor.set_profile(profile)
        output_path = fit_editor.edit_fit(p, dryrun=args.dryrun)
        if (args.upload or args.upload_all) and output_path:
            upload(output_path, profile=profile, original_path=p, dryrun=args.dryrun)
    else:
        _logger.debug(f'"{p}" is a directory')
        # if p is directory, do other stuff
        if args.upload_all or args.preinitialize:
            upload_all(
                p, profile=profile, preinitialize=args.preinitialize, dryrun=args.dryrun
            )
        elif args.monitor:
            monitor(p, profile=profile, dryrun=args.dryrun)
        else:
            files_to_edit = list(p.glob("*.fit", case_sensitive=False))
            _logger.info(f"Found {len(files_to_edit)} FIT files to edit")
            fit_editor.set_profile(profile)
            for f in files_to_edit:
                fit_editor.edit_fit(f, dryrun=args.dryrun)

select_profile

select_profile(
    profile_name: Optional[str] = None,
) -> Profile

Select a profile to use for the current operation.

Uses the following priority: 1. If profile_name is provided, use that profile (error if not found) 2. Use the default profile if one is set 3. If only one profile exists, use it 4. If multiple profiles exist, prompt the user to select one 5. If no profiles exist, raise an error

Parameters:

Name Type Description Default
profile_name Optional[str]

Optional name of the profile to use. If not provided, uses the default profile or prompts the user.

None

Returns:

Type Description
Profile

The selected Profile object.

Raises:

Type Description
ValueError

If the specified profile is not found or no profiles are configured.

Examples:

>>> # Use a specific profile
>>> profile = select_profile("tpv")
>>>
>>> # Use default profile (or prompt if no default)
>>> profile = select_profile()
Source code in fit_file_faker/app.py
def select_profile(profile_name: Optional[str] = None) -> Profile:
    """Select a profile to use for the current operation.

    Uses the following priority:
    1. If profile_name is provided, use that profile (error if not found)
    2. Use the default profile if one is set
    3. If only one profile exists, use it
    4. If multiple profiles exist, prompt the user to select one
    5. If no profiles exist, raise an error

    Args:
        profile_name: Optional name of the profile to use. If not provided,
            uses the default profile or prompts the user.

    Returns:
        The selected Profile object.

    Raises:
        ValueError: If the specified profile is not found or no profiles are configured.

    Examples:
        >>> # Use a specific profile
        >>> profile = select_profile("tpv")
        >>>
        >>> # Use default profile (or prompt if no default)
        >>> profile = select_profile()
    """
    if profile_name:
        profile = profile_manager.get_profile(profile_name)
        if not profile:
            raise ValueError(
                f'Profile "{profile_name}" not found. '
                f"Run with --list-profiles to see available profiles."
            )
        _logger.info(f'Using profile: "{profile.name}"')
        return profile

    # Try to get default profile
    default = config_manager.config.get_default_profile()
    if default:
        _logger.info(f'Using default profile: "{default.name}"')
        return default

    # Check if any profiles exist
    if not config_manager.config.profiles:
        raise ValueError(
            "No profiles configured. Run with --config-menu to create a profile."
        )

    # If only one profile, use it
    if len(config_manager.config.profiles) == 1:
        profile = config_manager.config.profiles[0]
        _logger.info(f'Using only available profile: "{profile.name}"')
        return profile

    # Multiple profiles, no default - prompt user
    profile_choices = [p.name for p in config_manager.config.profiles]
    selected_name = questionary.select(
        "Multiple profiles found. Select profile to use:", choices=profile_choices
    ).ask()

    if not selected_name:
        raise ValueError("No profile selected")

    profile = profile_manager.get_profile(selected_name)
    if not profile:  # pragma: no cover
        raise ValueError(f'Profile "{selected_name}" not found')

    _logger.info(f'Using selected profile: "{profile.name}"')
    return profile

upload

upload(
    fn: Path,
    profile: Profile,
    original_path: Optional[Path] = None,
    dryrun: bool = False,
)

Upload a FIT file to Garmin Connect.

Authenticates to Garmin Connect using credentials from the specified profile, then uploads the specified FIT file. Credentials are cached in a profile-specific cache directory for future use.

Parameters:

Name Type Description Default
fn Path

Path to the (modified) FIT file to upload.

required
profile Profile

The profile to use for authentication and upload.

required
original_path Optional[Path]

Optional path to the original file for logging purposes. Defaults to None.

None
dryrun bool

If True, authenticates but doesn't actually upload the file. Defaults to False.

False

Raises:

Type Description
GarthHTTPError

If upload fails with an HTTP error. 409 (conflict/duplicate) errors are caught and logged as warnings, but other HTTP errors are re-raised.

Examples:

>>> from pathlib import Path
>>> # Upload a modified file
>>> upload(Path("activity_modified.fit"), profile=my_profile)
>>>
>>> # Dry run (authenticate but don't upload)
>>> upload(Path("activity_modified.fit"), profile=my_profile, dryrun=True)
Note

Garmin Connect credentials are read from the profile. Credentials are cached in profile-specific directories like ~/.cache/FitFileFaker/.garth_ (location varies by platform).

Source code in fit_file_faker/app.py
def upload(
    fn: Path,
    profile: Profile,
    original_path: Optional[Path] = None,
    dryrun: bool = False,
):
    """Upload a FIT file to Garmin Connect.

    Authenticates to Garmin Connect using credentials from the specified profile,
    then uploads the specified FIT file. Credentials are cached in a profile-specific
    cache directory for future use.

    Args:
        fn: Path to the (modified) FIT file to upload.
        profile: The profile to use for authentication and upload.
        original_path: Optional path to the original file for logging purposes.
            Defaults to `None`.
        dryrun: If `True`, authenticates but doesn't actually upload the file.
            Defaults to `False`.

    Raises:
        GarthHTTPError: If upload fails with an HTTP error. 409 (conflict/duplicate)
            errors are caught and logged as warnings, but other HTTP errors are re-raised.

    Examples:
        >>> from pathlib import Path
        >>> # Upload a modified file
        >>> upload(Path("activity_modified.fit"), profile=my_profile)
        >>>
        >>> # Dry run (authenticate but don't upload)
        >>> upload(Path("activity_modified.fit"), profile=my_profile, dryrun=True)

    Note:
        Garmin Connect credentials are read from the profile. Credentials are cached
        in profile-specific directories like ~/.cache/FitFileFaker/.garth_<profile_name>
        (location varies by platform).
    """
    # get credentials and login if needed
    import garth
    from garth.exc import GarthException, GarthHTTPError

    garth_dir = get_garth_dir(profile.name)
    _logger.debug(f'Using "{garth_dir}" for garth credentials')

    try:
        garth.resume(str(garth_dir.absolute()))
        garth.client.username
        _logger.debug(f'Using stored Garmin credentials from "{garth_dir}" directory')
    except (GarthException, FileNotFoundError):
        # Session is expired. You'll need to log in again
        _logger.info("Authenticating to Garmin Connect")
        email = profile.garmin_username
        password = profile.garmin_password
        if not email:
            email = questionary.text(
                'No "garmin_username" variable set; Enter email address: '
            ).ask()
        _logger.debug(f'Using username "{email}"')
        if not password:
            password = questionary.password(
                'No "garmin_password" variable set; Enter password: '
            ).ask()
            _logger.debug("Using password from user input")
        else:
            _logger.debug('Using password stored in "garmin_password"')
        garth.login(email, password)
        garth.save(str(garth_dir.absolute()))

    with fn.open("rb") as f:
        try:
            if not dryrun:
                _logger.info(f'Uploading "{fn}" using garth')
                garth.client.upload(f)
                _logger.info(
                    f':white_check_mark: Successfully uploaded "{str(original_path)}"'
                )
            else:
                _logger.info(f'Skipping upload of "{fn}" because dryrun was requested')
        except GarthHTTPError as e:
            if e.error.response.status_code == 409:
                _logger.warning(
                    f':x: Received HTTP conflict (activity already exists) for "{str(original_path)}"'
                )
            else:
                raise e

upload_all

upload_all(
    dir: Path,
    profile: Profile,
    preinitialize: bool = False,
    dryrun: bool = False,
)

Batch process and upload all new FIT files in a directory.

Scans the directory for FIT files that haven't been processed yet, edits them to appear as Garmin Edge 830 files, and uploads them to Garmin Connect. Maintains a .uploaded_files.json file to track which files have been processed.

Parameters:

Name Type Description Default
dir Path

Path to the directory containing FIT files to process.

required
profile Profile

The profile to use for authentication and upload.

required
preinitialize bool

If True, marks all existing files as already uploaded without actually processing them. Useful for initializing the tracking file. Defaults to False.

False
dryrun bool

If True, processes files but doesn't upload or update the tracking file. Defaults to False.

False
Note

Files ending in "_modified.fit" are automatically excluded to avoid re-processing previously modified files. Temporary files are used for uploads and are automatically deleted afterwards.

Examples:

>>> from pathlib import Path
>>>
>>> # Process and upload all new files
>>> upload_all(Path("/home/user/TPVirtual/abc123/FITFiles"), profile=my_profile)
>>>
>>> # Initialize tracking without processing
>>> upload_all(Path("/path/to/fitfiles"), profile=my_profile, preinitialize=True)
>>>
>>> # Dry run (no uploads or tracking updates)
>>> upload_all(Path("/path/to/fitfiles"), profile=my_profile, dryrun=True)
Source code in fit_file_faker/app.py
def upload_all(
    dir: Path, profile: Profile, preinitialize: bool = False, dryrun: bool = False
):
    """Batch process and upload all new FIT files in a directory.

    Scans the directory for FIT files that haven't been processed yet, edits them
    to appear as Garmin Edge 830 files, and uploads them to Garmin Connect. Maintains
    a `.uploaded_files.json` file to track which files have been processed.

    Args:
        dir: Path to the directory containing FIT files to process.
        profile: The profile to use for authentication and upload.
        preinitialize: If `True`, marks all existing files as already uploaded
            without actually processing them. Useful for initializing the tracking
            file. Defaults to `False`.
        dryrun: If `True`, processes files but doesn't upload or update the tracking
            file. Defaults to `False`.

    Note:
        Files ending in "_modified.fit" are automatically excluded to avoid
        re-processing previously modified files. Temporary files are used for
        uploads and are automatically deleted afterwards.

    Examples:
        >>> from pathlib import Path
        >>>
        >>> # Process and upload all new files
        >>> upload_all(Path("/home/user/TPVirtual/abc123/FITFiles"), profile=my_profile)
        >>>
        >>> # Initialize tracking without processing
        >>> upload_all(Path("/path/to/fitfiles"), profile=my_profile, preinitialize=True)
        >>>
        >>> # Dry run (no uploads or tracking updates)
        >>> upload_all(Path("/path/to/fitfiles"), profile=my_profile, dryrun=True)
    """
    files_uploaded = dir.joinpath(FILES_UPLOADED_NAME)
    if files_uploaded.exists():
        # load uploaded file list from disk
        with files_uploaded.open("r") as f:
            uploaded_files = json.load(f)
    else:
        uploaded_files = []
        with files_uploaded.open("w") as f:
            # write blank file
            json.dump(uploaded_files, f, indent=2)
    _logger.debug(f"Found the following already uploaded files: {uploaded_files}")

    # glob all .fit files in the current directory
    files = [str(i) for i in dir.glob("*.fit", case_sensitive=False)]
    # strip any leading/trailing slashes from filenames
    files = [i.replace(str(dir), "").strip("/").strip("\\") for i in files]
    # remove files matching what we may have already processed
    files = [i for i in files if not i.endswith("_modified.fit")]
    # remove files found in the "already uploaded" list
    files = [i for i in files if i not in uploaded_files]

    _logger.info(f"Found {len(files)} files to edit/upload")
    _logger.debug(f"Files to upload: {files}")

    if not files:
        return

    for f in files:
        _logger.info(f'Processing "{f}"')  # type: ignore

        if not preinitialize:
            with NamedTemporaryFile(delete=True, delete_on_close=False) as fp:
                fit_editor.set_profile(profile)
                output = fit_editor.edit_fit(dir.joinpath(f), output=Path(fp.name))
                if output:
                    _logger.info("Uploading modified file to Garmin Connect")
                    upload(
                        output, profile=profile, original_path=Path(f), dryrun=dryrun
                    )
                    _logger.debug(f'Adding "{f}" to "uploaded_files"')
        else:
            _logger.info(
                "Preinitialize was requested, so just marking as uploaded (not actually processing)"
            )
        uploaded_files.append(f)

    if not dryrun:
        with files_uploaded.open("w") as f:
            json.dump(uploaded_files, f, indent=2)

FIT Editor (fit_editor.py)

FitEditor

FitEditor(profile=None)

Handles FIT file editing and manipulation.

This class provides methods to read, modify, and save FIT files from various cycling platforms (TrainingPeaks Virtual, Zwift, COROS, etc.), converting them to appear as if they came from a Garmin Edge 830 device (or a custom device if configured via profile).

The editor modifies only device metadata (manufacturer, product IDs) while preserving all activity data including records, laps, and sessions. This enables Garmin Connect's Training Effect calculations for activities from non-Garmin sources.

Examples:

>>> from fit_file_faker.fit_editor import fit_editor
>>> from pathlib import Path
>>>
>>> # Edit a single file
>>> output = fit_editor.edit_fit(Path("activity.fit"))
>>>
>>> # Dry run mode (no file written)
>>> output = fit_editor.edit_fit(Path("activity.fit"), dryrun=True)
>>>
>>> # Custom output location
>>> output = fit_editor.edit_fit(
...     Path("activity.fit"),
...     output=Path("modified_activity.fit")
... )

Initialize the FIT editor.

Parameters:

Name Type Description Default
profile

Optional Profile object for device simulation settings. If None, defaults to Garmin Edge 830.

None

Applies a logging filter to suppress verbose fit_tool warnings.

Source code in fit_file_faker/fit_editor.py
def __init__(self, profile=None):
    """Initialize the FIT editor.

    Args:
        profile: Optional Profile object for device simulation settings.
            If None, defaults to Garmin Edge 830.

    Applies a logging filter to suppress verbose fit_tool warnings.
    """
    # Apply the log filter to suppress noisy fit_tool warnings
    logging.getLogger("fit_tool").addFilter(FitFileLogFilter())
    self.profile = profile

edit_fit

edit_fit(
    fit_input: Path | FitFile,
    output: Optional[Path] = None,
    dryrun: bool = False,
) -> Path | None

Edit a FIT file to appear as if it came from a Garmin Edge 830.

This is the primary method for converting FIT files from virtual cycling platforms to Garmin-compatible format. It modifies device metadata (manufacturer and product IDs) while preserving all activity data.

The method performs the following transformations:

  1. Strips unknown field definitions to prevent corruption
  2. Rewrites FileIdMessage with Garmin Edge 830 metadata
  3. Adds a FileCreatorMessage with Edge 830 software/hardware versions
  4. Modifies DeviceInfoMessage records to match Edge 830
  5. Reorders Activity messages to end of file (COROS compatibility)

Parameters:

Name Type Description Default
fit_input Path | FitFile

Either a Path to the input FIT file OR a pre-parsed FitFile object. Using a Path is recommended for most cases.

required
output Optional[Path]

Optional output path. Defaults to {original}_modified.fit when fit_input is a Path. Required if fit_input is a FitFile object.

None
dryrun bool

If True, performs all processing but doesn't write the output file. Useful for validation and testing.

False

Returns:

Type Description
Path | None

Path to the output file if successful, or None if processing

Path | None

failed (e.g., invalid FIT file).

Raises:

Type Description
None

Errors are logged but not raised. Returns None on failure.

Examples:

>>> from pathlib import Path
>>> from fit_file_faker.fit_editor import fit_editor
>>>
>>> # Basic usage
>>> output = fit_editor.edit_fit(Path("activity.fit"))
>>> print(f"Modified file: {output}")
>>>
>>> # Custom output path
>>> output = fit_editor.edit_fit(
...     Path("activity.fit"),
...     output=Path("custom_output.fit")
... )
>>>
>>> # Dry run (no file written)
>>> output = fit_editor.edit_fit(Path("activity.fit"), dryrun=True)
Note

Only modifies device metadata. All activity data (records, laps, sessions, heart rate, power, etc.) is preserved exactly as-is.

Source code in fit_file_faker/fit_editor.py
def edit_fit(
    self,
    fit_input: Path | FitFile,
    output: Optional[Path] = None,
    dryrun: bool = False,
) -> Path | None:
    """Edit a FIT file to appear as if it came from a Garmin Edge 830.

    This is the primary method for converting FIT files from virtual cycling
    platforms to Garmin-compatible format. It modifies device metadata
    (manufacturer and product IDs) while preserving all activity data.

    The method performs the following transformations:

    1. Strips unknown field definitions to prevent corruption
    2. Rewrites `FileIdMessage` with Garmin Edge 830 metadata
    3. Adds a `FileCreatorMessage` with Edge 830 software/hardware versions
    4. Modifies `DeviceInfoMessage` records to match Edge 830
    5. Reorders `Activity` messages to end of file (COROS compatibility)

    Args:
        fit_input: Either a `Path` to the input FIT file OR a pre-parsed
            `FitFile` object. Using a `Path` is recommended for most cases.
        output: Optional output path. Defaults to {original}_modified.fit
            when `fit_input` is a `Path`. Required if `fit_input` is a `FitFile`
            object.
        dryrun: If `True`, performs all processing but doesn't write the
            output file. Useful for validation and testing.

    Returns:
        Path to the output file if successful, or `None` if processing
        failed (e.g., invalid FIT file).

    Raises:
        None: Errors are logged but not raised. Returns `None` on failure.

    Examples:
        >>> from pathlib import Path
        >>> from fit_file_faker.fit_editor import fit_editor
        >>>
        >>> # Basic usage
        >>> output = fit_editor.edit_fit(Path("activity.fit"))
        >>> print(f"Modified file: {output}")
        >>>
        >>> # Custom output path
        >>> output = fit_editor.edit_fit(
        ...     Path("activity.fit"),
        ...     output=Path("custom_output.fit")
        ... )
        >>>
        >>> # Dry run (no file written)
        >>> output = fit_editor.edit_fit(Path("activity.fit"), dryrun=True)

    Note:
        Only modifies device metadata. All activity data (records, laps,
        sessions, heart rate, power, etc.) is preserved exactly as-is.
    """
    if dryrun:
        _logger.warning('In "dryrun" mode; will not actually write new file.')

    # Handle both Path and FitFile inputs
    if isinstance(fit_input, Path):
        fit_path = fit_input
        _logger.info(f'Processing "{fit_path}"')

        try:
            fit_file = FitFile.from_file(str(fit_path))
        except Exception:
            _logger.error("File does not appear to be a FIT file, skipping...")
            return None
    elif isinstance(fit_input, FitFile):
        fit_file = fit_input
        fit_path = None  # No source path available
        _logger.info("Processing parsed FIT file")
    else:
        _logger.error(f"Invalid input type: {type(fit_input)}")
        return None

    # Strip unknown field definitions to prevent corruption when rewriting
    self.strip_unknown_fields(fit_file)

    if not output:
        if fit_path:
            output = fit_path.parent / f"{fit_path.stem}_modified.fit"
        else:
            _logger.error("Output path required when using parsed FIT file")
            return None

    builder = FitFileBuilder(auto_define=True)
    skipped_device_type_zero = False

    # Collect Activity messages to write at the end (fixes COROS file ordering)
    activity_messages = []

    # Loop through records, find the ones we need to change, and modify the values
    for i, record in enumerate(fit_file.records):
        message = record.message

        # Defer Activity messages until the end to ensure proper ordering
        if isinstance(message, ActivityMessage):
            activity_messages.append(message)
            continue

        # Change file id to indicate file was saved by Edge 830
        if message.global_id == FileIdMessage.ID:
            if isinstance(message, DefinitionMessage):
                # If this is the definition message for the FileIdMessage, skip it
                # since we're going to write a new one
                continue
            if isinstance(message, FileIdMessage):
                # Rewrite the FileIdMessage and its definition and add to builder
                def_message, message = self.rewrite_file_id_message(message, i)
                builder.add(def_message)
                builder.add(message)
                # Add FileCreatorMessage only if profile has software_version set
                if self.profile and self.profile.software_version is not None:
                    creator_message = FileCreatorMessage()
                    creator_message.software_version = self.profile.software_version
                    builder.add(
                        DefinitionMessage.from_data_message(creator_message)
                    )
                    builder.add(creator_message)
                continue

        if message.global_id == FileCreatorMessage.ID:
            # Skip any existing file creator message
            continue

        # Change device info messages
        if message.global_id == DeviceInfoMessage.ID:
            if isinstance(message, DeviceInfoMessage):
                self.print_message(f"DeviceInfoMessage Record: {i}", message)
                if message.device_type == 0:
                    _logger.debug("    Skipping device_type 0")
                    skipped_device_type_zero = True
                    continue

                # Renumber device_index if we skipped device_type 0
                if skipped_device_type_zero and message.device_index is not None:
                    _logger.debug(
                        f"    Renumbering device_index from {message.device_index} to {message.device_index - 1}"
                    )
                    message.device_index = message.device_index - 1

                if self._should_modify_device_info(message.manufacturer):
                    _logger.debug("    Modifying values")
                    _logger.debug(f"garmin_product: {message.garmin_product}")
                    _logger.debug(f"product: {message.product}")

                    # Use profile device settings if available, otherwise defaults
                    if self.profile:
                        target_manufacturer = self.profile.manufacturer
                        target_device = self.profile.device
                    else:
                        target_manufacturer = Manufacturer.GARMIN.value
                        target_device = GarminProduct.EDGE_830.value

                    # have not seen this set explicitly in testing, but probable good to set regardless
                    if message.garmin_product:  # pragma: no cover
                        message.garmin_product = target_device
                    if message.product:
                        message.product = target_device  # type: ignore
                    if message.manufacturer:
                        message.manufacturer = target_manufacturer
                    message.product_name = ""
                    self.print_message(f"    New Record: {i}", message)

        builder.add(message)

    # Add Activity messages at the end to ensure proper FIT file structure
    if activity_messages:
        _logger.debug(
            f"Adding {len(activity_messages)} Activity message(s) at the end"
        )
        for activity_msg in activity_messages:
            builder.add(activity_msg)

    modified_file = builder.build()

    if not dryrun:
        _logger.info(f'Saving modified data to "{output}"')
        modified_file.to_file(str(output))
    else:
        _logger.info(
            f"Dryrun requested, so not saving data "
            f'(would have written to "{output}")'
        )

    return output

rewrite_file_id_message

rewrite_file_id_message(
    m: FileIdMessage, message_num: int
) -> tuple[DefinitionMessage, FileIdMessage]

Rewrite FileIdMessage to appear as if from Garmin Edge 830.

Creates a new FileIdMessage with Garmin Edge 830 manufacturer and product IDs while preserving the original timestamp, type, and serial number. This is the primary transformation that enables Garmin Connect to recognize and process the activity.

Parameters:

Name Type Description Default
m FileIdMessage

The original FileIdMessage to rewrite.

required
message_num int

The record number for logging purposes.

required

Returns:

Type Description
tuple[DefinitionMessage, FileIdMessage]

A tuple containing:

  • DefinitionMessage: Auto-generated definition for the new message.
  • FileIdMessage: The rewritten message with Garmin Edge 830 metadata.
Note

The product_name field is intentionally not copied as Garmin devices typically don't set this field. Only files from supported manufacturers (DEVELOPMENT, ZWIFT, WAHOO_FITNESS, PEAKSWARE, HAMMERHEAD, COROS, MYWHOOSH) are modified; others are returned unchanged.

Source code in fit_file_faker/fit_editor.py
def rewrite_file_id_message(
    self,
    m: FileIdMessage,
    message_num: int,
) -> tuple[DefinitionMessage, FileIdMessage]:
    """Rewrite FileIdMessage to appear as if from Garmin Edge 830.

    Creates a new FileIdMessage with Garmin Edge 830 manufacturer and
    product IDs while preserving the original timestamp, type, and serial
    number. This is the primary transformation that enables Garmin Connect
    to recognize and process the activity.

    Args:
        m: The original FileIdMessage to rewrite.
        message_num: The record number for logging purposes.

    Returns:
        A tuple containing:

            - `DefinitionMessage`: Auto-generated definition for the new message.
            - `FileIdMessage`: The rewritten message with Garmin Edge 830 metadata.

    Note:
        The product_name field is intentionally not copied as Garmin devices
        typically don't set this field. Only files from supported manufacturers
        (`DEVELOPMENT`, `ZWIFT`, `WAHOO_FITNESS`, `PEAKSWARE`, `HAMMERHEAD`, `COROS`,
        `MYWHOOSH`) are modified; others are returned unchanged.
    """
    dt = datetime.fromtimestamp(m.time_created / 1000.0)  # type: ignore
    _logger.info(f'Activity timestamp is "{dt.isoformat()}"')
    self.print_message(f"FileIdMessage Record: {message_num}", m)

    new_m = FileIdMessage()
    new_m.time_created = (
        m.time_created if m.time_created else int(datetime.now().timestamp() * 1000)
    )
    if m.type:
        new_m.type = m.type
    # Use profile serial number if available, otherwise use default
    if self.profile and self.profile.serial_number:
        new_m.serial_number = self.profile.serial_number
    else:
        # Fallback to default for backwards compatibility
        new_m.serial_number = 1234567890

    _logger.debug(f"Using serial number: {new_m.serial_number}")
    if m.product_name:
        # garmin does not appear to define product_name, so don't copy it over
        pass

    if self._should_modify_manufacturer(m.manufacturer):
        # Use profile device settings if available, otherwise defaults
        if self.profile:
            new_m.manufacturer = self.profile.manufacturer
            new_m.product = self.profile.device
        else:
            new_m.manufacturer = Manufacturer.GARMIN.value
            new_m.product = GarminProduct.EDGE_830.value
        _logger.debug("    Modifying values")
        self.print_message(f"    New Record: {message_num}", new_m)

    return (DefinitionMessage.from_data_message(new_m), new_m)

get_date_from_fit

get_date_from_fit(fit_path: Path) -> Optional[datetime]

Extract the creation date from a FIT file.

Reads the FIT file and extracts the timestamp from the FileIdMessage, which indicates when the activity was recorded.

Parameters:

Name Type Description Default
fit_path Path

Path to the FIT file to read.

required

Returns:

Type Description
Optional[datetime]

The activity creation datetime, or None if no FileIdMessage with

Optional[datetime]

a valid timestamp was found.

Note

The timestamp in FIT files is stored in milliseconds since the FIT epoch, which is converted to a standard Python datetime object.

Source code in fit_file_faker/fit_editor.py
def get_date_from_fit(self, fit_path: Path) -> Optional[datetime]:
    """Extract the creation date from a FIT file.

    Reads the FIT file and extracts the timestamp from the `FileIdMessage`,
    which indicates when the activity was recorded.

    Args:
        fit_path: `Path` to the FIT file to read.

    Returns:
        The activity creation datetime, or `None` if no `FileIdMessage` with
        a valid timestamp was found.

    Note:
        The timestamp in FIT files is stored in milliseconds since the
        FIT epoch, which is converted to a standard Python datetime object.
    """
    fit_file = FitFile.from_file(str(fit_path))
    res = None
    for i, record in enumerate(fit_file.records):
        message = record.message
        if message.global_id == FileIdMessage.ID:
            if isinstance(message, FileIdMessage):
                res = datetime.fromtimestamp(message.time_created / 1000.0)  # type: ignore
                break
    return res

strip_unknown_fields

strip_unknown_fields(fit_file: FitFile) -> None

Force regeneration of definition messages for messages with unknown fields.

This fixes a bug where fit_tool skips unknown fields (like Zwift's field 193) during reading but keeps them in the definition, causing a mismatch when writing. Without this fix, the file would be corrupted when written back out.

The method sets definition_message to None for affected messages, forcing FitFileBuilder to regenerate clean definitions based only on fields that actually exist in the message.

Parameters:

Name Type Description Default
fit_file FitFile

The parsed FIT file to process. Messages are modified in place.

required
Note

This is called automatically by edit_fit() before processing any FIT file. It's essential for handling files from platforms like Zwift that use custom/unknown field IDs.

Source code in fit_file_faker/fit_editor.py
def strip_unknown_fields(self, fit_file: FitFile) -> None:
    """Force regeneration of definition messages for messages with unknown fields.

    This fixes a bug where `fit_tool` skips unknown fields (like Zwift's field 193)
    during reading but keeps them in the definition, causing a mismatch when writing.
    Without this fix, the file would be corrupted when written back out.

    The method sets `definition_message` to `None` for affected messages, forcing
    `FitFileBuilder` to regenerate clean definitions based only on fields that
    actually exist in the message.

    Args:
        fit_file: The parsed FIT file to process. Messages are modified in place.

    Note:
        This is called automatically by
        [`edit_fit()`][fit_file_faker.fit_editor.FitEditor.edit_fit] before
        processing any FIT file. It's essential for handling files from platforms
        like Zwift that use custom/unknown field IDs.
    """
    for record in fit_file.records:
        message = record.message
        if (
            not hasattr(message, "definition_message")
            or message.definition_message is None
        ):
            continue
        if not hasattr(message, "fields"):  # pragma: no cover
            continue

        # Get the set of field IDs that actually exist in the message
        existing_field_ids = {
            field.field_id for field in message.fields if field.is_valid()
        }

        # Check if definition has fields that don't exist in the message
        definition_field_ids = {
            fd.field_id for fd in message.definition_message.field_definitions
        }

        unknown_fields = definition_field_ids - existing_field_ids
        if unknown_fields:
            _logger.debug(
                f"Clearing definition for {message.name} (global_id={message.global_id}) "
                f"to force regeneration (had {len(unknown_fields)} unknown field(s))"
            )
            # Set to None to force FitFileBuilder to regenerate it
            message.definition_message = None

_should_modify_manufacturer

_should_modify_manufacturer(
    manufacturer: int | None,
) -> bool

Check if manufacturer should be modified to Garmin.

Determines whether a FIT file's manufacturer should be changed to Garmin based on whether it's from a supported virtual cycling platform.

Parameters:

Name Type Description Default
manufacturer int | None

The manufacturer code from the FIT file, or None.

required

Returns:

Type Description
bool

True if the manufacturer is from a supported platform and should

bool

be modified, False otherwise.

Note

Supported manufacturers include: DEVELOPMENT (TrainingPeaks Virtual), ZWIFT, WAHOO_FITNESS, PEAKSWARE, HAMMERHEAD, COROS, and MYWHOOSH (331).

Source code in fit_file_faker/fit_editor.py
def _should_modify_manufacturer(self, manufacturer: int | None) -> bool:
    """Check if manufacturer should be modified to Garmin.

    Determines whether a FIT file's manufacturer should be changed to
    Garmin based on whether it's from a supported virtual cycling platform.

    Args:
        manufacturer: The manufacturer code from the FIT file, or `None`.

    Returns:
        True if the manufacturer is from a supported platform and should
        be modified, False otherwise.

    Note:
        Supported manufacturers include: `DEVELOPMENT` (TrainingPeaks Virtual),
        `ZWIFT`, `WAHOO_FITNESS`, `PEAKSWARE`, `HAMMERHEAD`, `COROS`, and
        `MYWHOOSH` (`331`).
    """
    if manufacturer is None:
        return False
    return manufacturer in [
        Manufacturer.DEVELOPMENT.value,
        Manufacturer.ZWIFT.value,
        Manufacturer.WAHOO_FITNESS.value,
        Manufacturer.PEAKSWARE.value,
        Manufacturer.HAMMERHEAD.value,
        Manufacturer.COROS.value,
        331,  # MYWHOOSH is unknown to fit_tools
    ]

_should_modify_device_info

_should_modify_device_info(
    manufacturer: int | None,
) -> bool

Check if device info should be modified to Garmin Edge 830.

Similar to _should_modify_manufacturer but also includes blank/unknown manufacturers (code 0) for DeviceInfoMessage records.

Parameters:

Name Type Description Default
manufacturer int | None

The manufacturer code from the DeviceInfoMessage, or None.

required

Returns:

Type Description
bool

True if the device info should be modified to Garmin Edge 830,

bool

False otherwise.

Note

This includes all manufacturers from _should_modify_manufacturer() plus manufacturer code 0 (blank/unknown).

Source code in fit_file_faker/fit_editor.py
def _should_modify_device_info(self, manufacturer: int | None) -> bool:
    """Check if device info should be modified to Garmin Edge 830.

    Similar to _should_modify_manufacturer but also includes blank/unknown
    manufacturers (code 0) for `DeviceInfoMessage` records.

    Args:
        manufacturer: The manufacturer code from the `DeviceInfoMessage`, or `None`.

    Returns:
        True if the device info should be modified to Garmin Edge 830,
        False otherwise.

    Note:
        This includes all manufacturers from
        [`_should_modify_manufacturer()`][fit_file_faker.fit_editor.FitEditor._should_modify_manufacturer]
        plus manufacturer code 0 (blank/unknown).
    """
    if manufacturer is None:
        return False
    return manufacturer in [
        Manufacturer.DEVELOPMENT.value,
        0,  # Blank/unknown manufacturer
        Manufacturer.WAHOO_FITNESS.value,
        Manufacturer.ZWIFT.value,
        Manufacturer.PEAKSWARE.value,
        Manufacturer.HAMMERHEAD.value,
        Manufacturer.COROS.value,
        331,  # MYWHOOSH is unknown to fit_tools
    ]

Configuration (config.py)

Configuration management for Fit File Faker.

This module handles all configuration file operations including creation, validation, loading, and saving. Configuration is stored in a platform-specific user configuration directory using platformdirs.

The configuration includes Garmin Connect credentials and the path to the directory containing FIT files to process. Depending on the trainer app selected in the profile, the FIT files directory is auto-detected (but can be overridden).

Typical usage example:

from fit_file_faker.config import config_manager

# Check if config is valid
if not config_manager.is_valid():
    config_manager.build_config_file()

# Access configuration values
username = config_manager.config.garmin_username
fit_path = config_manager.config.fitfiles_path

AppType

Bases: Enum

Supported trainer/cycling applications.

Each app type has associated directory detection logic and display names. Used to identify the source application for FIT files and enable platform-specific auto-detection.

Attributes:

Name Type Description
TP_VIRTUAL

TrainingPeaks Virtual (formerly indieVelo)

ZWIFT

Zwift virtual cycling platform

MYWHOOSH

MyWhoosh virtual cycling platform

CUSTOM

Custom/manual path specification

Config dataclass

Config(
    profiles: list[Profile],
    default_profile: str | None = None,
)

Multi-profile configuration container for Fit File Faker.

Stores multiple profile configurations, each with independent Garmin credentials and FIT files directory. Supports backward compatibility with single-profile configs via automatic migration.

Attributes:

Name Type Description
profiles list[Profile]

List of Profile objects, each representing a complete configuration for a trainer app and Garmin account.

default_profile str | None

Name of the default profile to use when no profile is explicitly specified. If None, the first profile is used.

Examples:

>>> from pathlib import Path
>>> config = Config(
...     profiles=[
...         Profile(
...             name="tpv",
...             app_type=AppType.TP_VIRTUAL,
...             garmin_username="user@example.com",
...             garmin_password="secret",
...             fitfiles_path=Path("/home/user/TPVirtual/abc123/FITFiles")
...         )
...     ],
...     default_profile="tpv"
... )
>>> profile = config.get_profile("tpv")
>>> default = config.get_default_profile()

__post_init__

__post_init__()

Convert dict profiles to Profile objects after initialization.

Handles deserialization from JSON where profiles may be dictionaries instead of Profile objects.

Source code in fit_file_faker/config.py
def __post_init__(self):
    """Convert dict profiles to Profile objects after initialization.

    Handles deserialization from JSON where profiles may be dictionaries
    instead of Profile objects.
    """
    # Convert dict profiles to Profile objects
    if self.profiles and isinstance(self.profiles[0], dict):
        self.profiles = [Profile(**p) for p in self.profiles]

get_default_profile

get_default_profile() -> Profile | None

Get the default profile or first profile if no default set.

Returns:

Type Description
Profile | None

The default Profile object, or the first profile if no default

Profile | None

is set, or None if no profiles exist.

Examples:

>>> config = Config(profiles=[...], default_profile="tpv")
>>> profile = config.get_default_profile()
Source code in fit_file_faker/config.py
def get_default_profile(self) -> Profile | None:
    """Get the default profile or first profile if no default set.

    Returns:
        The default Profile object, or the first profile if no default
        is set, or None if no profiles exist.

    Examples:
        >>> config = Config(profiles=[...], default_profile="tpv")
        >>> profile = config.get_default_profile()
    """
    if self.default_profile:
        return self.get_profile(self.default_profile)
    return self.profiles[0] if self.profiles else None

get_profile

get_profile(name: str) -> Profile | None

Get profile by name.

Parameters:

Name Type Description Default
name str

The name of the profile to retrieve.

required

Returns:

Type Description
Profile | None

Profile object if found, None otherwise.

Examples:

>>> config = Config(profiles=[Profile(name="test", ...)])
>>> profile = config.get_profile("test")
Source code in fit_file_faker/config.py
def get_profile(self, name: str) -> Profile | None:
    """Get profile by name.

    Args:
        name: The name of the profile to retrieve.

    Returns:
        Profile object if found, None otherwise.

    Examples:
        >>> config = Config(profiles=[Profile(name="test", ...)])
        >>> profile = config.get_profile("test")
    """
    return next((p for p in self.profiles if p.name == name), None)

ConfigManager

ConfigManager()

Manages configuration file operations and validation.

Handles loading, saving, and validating configuration stored in a platform-specific user configuration directory. Provides interactive configuration building for missing or invalid values.

The configuration file is stored as .config.json in the user's config directory (location varies by platform).

Attributes:

Name Type Description
config_file

Path to the JSON configuration file.

config_keys

List of required configuration keys.

config

Current Config object loaded from file.

Examples:

>>> from fit_file_faker.config import config_manager
>>>
>>> # Check if config is valid
>>> if not config_manager.is_valid():
...     print(f"Config file: {config_manager.get_config_file_path()}")
...     config_manager.build_config_file()
>>>
>>> # Access config values
>>> username = config_manager.config.garmin_username

Initialize the configuration manager.

Creates the config file if it doesn't exist and loads existing configuration or creates a new empty Config object.

Source code in fit_file_faker/config.py
def __init__(self):
    """Initialize the configuration manager.

    Creates the config file if it doesn't exist and loads existing
    configuration or creates a new empty Config object.
    """
    self.config_file = dirs.user_config_path / ".config.json"
    self.config_keys = ["garmin_username", "garmin_password", "fitfiles_path"]
    self.config = self._load_config()

_load_config

_load_config() -> Config

Load configuration from file or create new Config if file doesn't exist.

Automatically migrates legacy single-profile configs (v1.2.4 and earlier) to multi-profile format. The migration is transparent and preserves all existing settings in a "default" profile. Migrated configs are automatically saved back to disk in the new format.

Returns:

Type Description
Config

Loaded Config object if file exists and contains valid JSON,

Config

otherwise a new empty Config object with no profiles.

Note

Creates an empty config file if one doesn't exist.

Source code in fit_file_faker/config.py
def _load_config(self) -> Config:
    """Load configuration from file or create new Config if file doesn't exist.

    Automatically migrates legacy single-profile configs (v1.2.4 and earlier)
    to multi-profile format. The migration is transparent and preserves all
    existing settings in a "default" profile. Migrated configs are automatically
    saved back to disk in the new format.

    Returns:
        Loaded Config object if file exists and contains valid JSON,
        otherwise a new empty Config object with no profiles.

    Note:
        Creates an empty config file if one doesn't exist.
    """
    self.config_file.touch(exist_ok=True)

    with self.config_file.open("r") as f:
        if self.config_file.stat().st_size == 0:
            # Empty file - return empty config
            return Config(profiles=[], default_profile=None)
        else:
            # Load from JSON and migrate if necessary
            config_dict = json.load(f)
            was_legacy = "profiles" not in config_dict
            config = migrate_legacy_config(config_dict)

            # Save migrated config back to file if migration occurred
            if was_legacy:
                _logger.debug("Saving migrated config to file")
                with self.config_file.open("w") as fw:
                    json.dump(asdict(config), fw, indent=2, cls=PathEncoder)

            # Migrate profiles without serial numbers
            migrated = False
            for profile in config.profiles:
                if profile.serial_number is None:
                    import random

                    profile.serial_number = random.randint(
                        1_000_000_000, 4_294_967_295
                    )
                    migrated = True
                    _logger.info(
                        f'Generated serial number for profile "{profile.name}": {profile.serial_number}'
                    )

            # Save migrated config if serial numbers were added
            if migrated:
                _logger.debug("Saving config with new serial numbers to file")
                with self.config_file.open("w") as fw:
                    json.dump(asdict(config), fw, indent=2, cls=PathEncoder)

            return config

build_config_file

build_config_file(
    overwrite_existing_vals: bool = False,
    rewrite_config: bool = True,
    excluded_keys: list[str] | None = None,
) -> None

Interactively build configuration file.

Prompts the user for missing or invalid configuration values using questionary for an interactive CLI experience. Passwords are masked during input, and the FIT files path is auto-detected for TrainingPeaks Virtual users when possible.

Parameters:

Name Type Description Default
overwrite_existing_vals bool

If True, prompts for all values even if they already exist. If False, only prompts for missing values. Defaults to False.

False
rewrite_config bool

If True, saves the configuration to disk after building. If False, only updates the in-memory config object. Defaults to True.

True
excluded_keys list[str] | None

Optional list of keys to skip during interactive building. Useful for partial configuration.

None

Raises:

Type Description
SystemExit

If user presses Ctrl-C to cancel configuration.

Examples:

>>> # Interactive setup for missing values only
>>> config_manager.build_config_file()
>>>
>>> # Rebuild entire configuration
>>> config_manager.build_config_file(overwrite_existing_vals=True)
>>>
>>> # Update only credentials (skip fitfiles_path)
>>> config_manager.build_config_file(
...     excluded_keys=["fitfiles_path"]
... )
Note

Passwords are masked in both user input and log output for security. The final configuration is logged with passwords hidden.

Source code in fit_file_faker/config.py
def build_config_file(
    self,
    overwrite_existing_vals: bool = False,
    rewrite_config: bool = True,
    excluded_keys: list[str] | None = None,
) -> None:
    """Interactively build configuration file.

    Prompts the user for missing or invalid configuration values using
    questionary for an interactive CLI experience. Passwords are masked
    during input, and the FIT files path is auto-detected for TrainingPeaks
    Virtual users when possible.

    Args:
        overwrite_existing_vals: If `True`, prompts for all values even if
            they already exist. If `False`, only prompts for missing values.
            Defaults to `False`.
        rewrite_config: If `True`, saves the configuration to disk after
            building. If `False`, only updates the in-memory config object.
            Defaults to `True`.
        excluded_keys: Optional list of keys to skip during interactive
            building. Useful for partial configuration.

    Raises:
        SystemExit: If user presses Ctrl-C to cancel configuration.

    Examples:
        >>> # Interactive setup for missing values only
        >>> config_manager.build_config_file()
        >>>
        >>> # Rebuild entire configuration
        >>> config_manager.build_config_file(overwrite_existing_vals=True)
        >>>
        >>> # Update only credentials (skip fitfiles_path)
        >>> config_manager.build_config_file(
        ...     excluded_keys=["fitfiles_path"]
        ... )

    Note:
        Passwords are masked in both user input and log output for security.
        The final configuration is logged with passwords hidden.
    """
    if excluded_keys is None:
        excluded_keys = []

    # Get or create default profile
    default_profile = self.config.get_default_profile()
    if not default_profile:
        # Create a default profile if none exists
        default_profile = Profile(
            name="default",
            app_type=AppType.TP_VIRTUAL,
            garmin_username="",
            garmin_password="",
            fitfiles_path=Path.home(),
        )
        self.config.profiles.append(default_profile)
        self.config.default_profile = "default"

    for k in self.config_keys:
        if (
            getattr(default_profile, k) is None
            or not getattr(default_profile, k)
            or overwrite_existing_vals
        ) and k not in excluded_keys:
            valid_input = False
            while not valid_input:
                try:
                    if (
                        not hasattr(default_profile, k)
                        or getattr(default_profile, k) is None
                    ):
                        _logger.warning(f'Required value "{k}" not found in config')
                    msg = f'Enter value to use for "{k}"'

                    if hasattr(default_profile, k) and getattr(default_profile, k):
                        msg += f'\nor press enter to use existing value of "{getattr(default_profile, k)}"'
                        if k == "garmin_password":
                            msg = msg.replace(
                                getattr(default_profile, k), "<**hidden**>"
                            )

                    if k != "fitfiles_path":
                        if "password" in k:
                            val = questionary.password(msg).unsafe_ask()
                        else:
                            val = questionary.text(msg).unsafe_ask()
                    else:
                        val = str(
                            get_fitfiles_path(
                                Path(
                                    getattr(default_profile, "fitfiles_path")
                                ).parent.parent
                                if getattr(default_profile, "fitfiles_path")
                                else None
                            )
                        )

                    if val:
                        valid_input = True
                        setattr(default_profile, k, val)
                    elif hasattr(default_profile, k) and getattr(
                        default_profile, k
                    ):
                        valid_input = True
                        val = getattr(default_profile, k)
                    else:
                        _logger.warning(
                            "Entered input was not valid, please try again (or press Ctrl-C to cancel)"
                        )
                except KeyboardInterrupt:
                    _logger.error("User canceled input; exiting!")
                    sys.exit(1)

    if rewrite_config:
        self.save_config()

    config_content = json.dumps(asdict(self.config), indent=2, cls=PathEncoder)
    if (
        hasattr(default_profile, "garmin_password")
        and getattr(default_profile, "garmin_password") is not None
    ):
        config_content = config_content.replace(
            cast(str, default_profile.garmin_password), "<**hidden**>"
        )
    _logger.info(f"Config file is now:\n{config_content}")

get_config_file_path

get_config_file_path() -> Path

Get the path to the configuration file.

Returns:

Type Description
Path

Path to the .config.json file in the platform-specific user

Path

configuration directory.

Examples:

>>> path = config_manager.get_config_file_path()
>>> print(f"Config file: {path}")
Config file: /home/user/.config/FitFileFaker/.config.json
Source code in fit_file_faker/config.py
def get_config_file_path(self) -> Path:
    """Get the path to the configuration file.

    Returns:
        Path to the .config.json file in the platform-specific user
        configuration directory.

    Examples:
        >>> path = config_manager.get_config_file_path()
        >>> print(f"Config file: {path}")
        Config file: /home/user/.config/FitFileFaker/.config.json
    """
    return self.config_file

is_valid

is_valid(excluded_keys: list[str] | None = None) -> bool

Check if configuration is valid (all required keys have values).

Parameters:

Name Type Description Default
excluded_keys list[str] | None

Optional list of keys to exclude from validation. Useful when certain config values aren't needed for specific operations (e.g., fitfiles_path when path is provided via CLI).

None

Returns:

Type Description
bool

True if all required (non-excluded) keys have non-None values,

bool

False otherwise. Logs missing keys as errors.

Examples:

>>> # Check all keys
>>> if not config_manager.is_valid():
...     print("Configuration incomplete")
>>>
>>> # Exclude fitfiles_path from validation
>>> if not config_manager.is_valid(excluded_keys=["fitfiles_path"]):
...     print("Missing Garmin credentials")
Source code in fit_file_faker/config.py
def is_valid(self, excluded_keys: list[str] | None = None) -> bool:
    """Check if configuration is valid (all required keys have values).

    Args:
        excluded_keys: Optional list of keys to exclude from validation.
            Useful when certain config values aren't needed for specific
            operations (e.g., fitfiles_path when path is provided via CLI).

    Returns:
        True if all required (non-excluded) keys have non-None values,
        False otherwise. Logs missing keys as errors.

    Examples:
        >>> # Check all keys
        >>> if not config_manager.is_valid():
        ...     print("Configuration incomplete")
        >>>
        >>> # Exclude fitfiles_path from validation
        >>> if not config_manager.is_valid(excluded_keys=["fitfiles_path"]):
        ...     print("Missing Garmin credentials")
    """
    if excluded_keys is None:
        excluded_keys = []

    # Get default profile for validation
    default_profile = self.config.get_default_profile()
    if not default_profile:
        _logger.error("No default profile configured")
        return False

    missing_vals = []
    for k in self.config_keys:
        if (
            not hasattr(default_profile, k) or getattr(default_profile, k) is None
        ) and k not in excluded_keys:
            missing_vals.append(k)

    if missing_vals:
        _logger.error(
            f"The following configuration values are missing: {missing_vals}"
        )
        return False
    return True

save_config

save_config() -> None

Save current configuration to file.

Serializes the current Config object to JSON and writes it to the config file with 2-space indentation. Path objects are automatically converted to strings via PathEncoder.

Source code in fit_file_faker/config.py
def save_config(self) -> None:
    """Save current configuration to file.

    Serializes the current Config object to JSON and writes it to the
    config file with 2-space indentation. Path objects are automatically
    converted to strings via PathEncoder.
    """
    with self.config_file.open("w") as f:
        json.dump(asdict(self.config), f, indent=2, cls=PathEncoder)

GarminDeviceInfo dataclass

GarminDeviceInfo(
    name: str,
    product_id: int,
    category: str,
    year_released: int,
    is_common: bool,
    description: str,
    software_version: int | None = None,
    software_date: str | None = None,
)

Metadata for a Garmin device (supplemental to fit_tool's enum).

Provides enhanced device information for modern Garmin devices not fully represented in fit_tool's GarminProduct enum, or to add curation metadata for devices that already exist.

Attributes:

Name Type Description
name str

Human-readable device name (e.g., "Edge 1050")

product_id int

FIT file product ID (integer)

category str

Device category ("bike_computer", "multisport_watch", "trainer")

year_released int

Release year for sorting (integer)

is_common bool

Show in first-level menu (boolean)

description str

Brief description for UI display

software_version int | None

Latest stable firmware version in FIT format (int, e.g., 2922 = v29.22)

software_date str | None

Latest firmware release date (YYYY-MM-DD format)

PathEncoder

Bases: JSONEncoder

JSON encoder that handles pathlib.Path and Enum objects.

Extends json.JSONEncoder to automatically convert Path and Enum objects to strings when serializing configuration to JSON format.

Examples:

>>> import json
>>> from pathlib import Path
>>> data = {"path": Path("/home/user"), "type": AppType.ZWIFT}
>>> json.dumps(data, cls=PathEncoder)
'{"path": "/home/user", "type": "zwift"}'

Profile dataclass

Profile(
    name: str,
    app_type: AppType,
    garmin_username: str,
    garmin_password: str,
    fitfiles_path: Path,
    manufacturer: int | None = None,
    device: int | None = None,
    serial_number: int | None = None,
    software_version: int | None = None,
)

Single profile configuration.

Represents a complete configuration profile with app type, credentials, and FIT files directory. Each profile is independent with isolated Garmin Connect credentials.

Attributes:

Name Type Description
name str

Unique profile identifier (used for display and garth dir naming)

app_type AppType

Type of trainer app (for auto-detection and validation)

garmin_username str

Garmin Connect account email address

garmin_password str

Garmin Connect account password

fitfiles_path Path

Path to directory containing FIT files to process

manufacturer int | None

Manufacturer ID to use for device simulation (defaults to Garmin)

device int | None

Device/product ID to use for device simulation (defaults to Edge 830)

serial_number int | None

Device serial number (should be the device's Unit ID; auto-generated if not specified)

software_version int | None

Firmware version in FIT format (e.g., 2922 = v29.22). If None, no FileCreatorMessage will be added to FIT files.

Examples:

>>> from pathlib import Path
>>> profile = Profile(
...     name="zwift",
...     app_type=AppType.ZWIFT,
...     garmin_username="user@example.com",
...     garmin_password="secret",
...     fitfiles_path=Path("/Users/user/Documents/Zwift/Activities")
... )

__post_init__

__post_init__()

Convert string types to proper objects after initialization.

Handles deserialization from JSON where app_type may be a string and fitfiles_path may be a string path. Also sets default values for manufacturer and device if not specified.

Source code in fit_file_faker/config.py
def __post_init__(self):
    """Convert string types to proper objects after initialization.

    Handles deserialization from JSON where app_type may be a string
    and fitfiles_path may be a string path. Also sets default values
    for manufacturer and device if not specified.
    """
    from fit_file_faker.vendor.fit_tool.profile.profile_type import (
        GarminProduct,
        Manufacturer,
    )

    if isinstance(self.app_type, str):
        self.app_type = AppType(self.app_type)
    if isinstance(self.fitfiles_path, str):
        self.fitfiles_path = Path(self.fitfiles_path)

    # Set defaults for manufacturer and device if not specified
    if self.manufacturer is None:
        self.manufacturer = Manufacturer.GARMIN.value
    if self.device is None:
        self.device = GarminProduct.EDGE_830.value

    # Generate serial number if Unit ID not specified
    if self.serial_number is None:
        import random

        self.serial_number = random.randint(1_000_000_000, 4_294_967_295)

get_device_name

get_device_name() -> str

Get human-readable device name.

Returns:

Type Description
str

Device name if found in GarminProduct enum or supplemental registry,

str

otherwise "UNKNOWN (id)".

Examples:

>>> profile.get_device_name()
'EDGE_830'
>>> # For supplemental device
>>> profile.device = 4440
>>> profile.get_device_name()
'Edge 1050'
Source code in fit_file_faker/config.py
def get_device_name(self) -> str:
    """Get human-readable device name.

    Returns:
        Device name if found in GarminProduct enum or supplemental registry,
        otherwise "UNKNOWN (id)".

    Examples:
        >>> profile.get_device_name()
        'EDGE_830'
        >>> # For supplemental device
        >>> profile.device = 4440
        >>> profile.get_device_name()
        'Edge 1050'
    """
    from fit_file_faker.vendor.fit_tool.profile.profile_type import GarminProduct

    # Try fit_tool enum first
    try:
        return GarminProduct(self.device).name
    except ValueError:
        # Fallback to supplemental registry
        for device_info in SUPPLEMENTAL_GARMIN_DEVICES:
            if device_info.product_id == self.device:
                return device_info.name
        # Unknown device
        return f"UNKNOWN ({self.device})"

get_manufacturer_name

get_manufacturer_name() -> str

Get human-readable manufacturer name.

Returns:

Type Description
str

Manufacturer name if found in enum, otherwise "UNKNOWN (id)".

Examples:

>>> profile.get_manufacturer_name()
'GARMIN'
Source code in fit_file_faker/config.py
def get_manufacturer_name(self) -> str:
    """Get human-readable manufacturer name.

    Returns:
        Manufacturer name if found in enum, otherwise "UNKNOWN (id)".

    Examples:
        >>> profile.get_manufacturer_name()
        'GARMIN'
    """
    from fit_file_faker.vendor.fit_tool.profile.profile_type import Manufacturer

    try:
        return Manufacturer(self.manufacturer).name
    except ValueError:
        return f"UNKNOWN ({self.manufacturer})"

validate_serial_number

validate_serial_number() -> bool

Validate that serial_number is valid for FIT spec (uint32z).

Returns:

Type Description
bool

True if serial_number is valid (1,000,000,000 to 4,294,967,295), False otherwise.

Examples:

>>> profile.serial_number = 1234567890
>>> profile.validate_serial_number()
True
Source code in fit_file_faker/config.py
def validate_serial_number(self) -> bool:
    """Validate that serial_number is valid for FIT spec (uint32z).

    Returns:
        True if serial_number is valid (1,000,000,000 to 4,294,967,295), False otherwise.

    Examples:
        >>> profile.serial_number = 1234567890
        >>> profile.validate_serial_number()
        True
    """
    if self.serial_number is None:
        return False
    if not isinstance(self.serial_number, int):
        return False
    return 1_000_000_000 <= self.serial_number <= 4_294_967_295

ProfileManager

ProfileManager(config_manager: ConfigManager)

Manages profile CRUD operations and TUI interactions.

Provides methods for creating, reading, updating, and deleting profiles, as well as interactive TUI wizards for profile management.

Attributes:

Name Type Description
config_manager

Reference to the global ConfigManager instance.

Initialize ProfileManager with config manager reference.

Parameters:

Name Type Description Default
config_manager ConfigManager

The ConfigManager instance to use for persistence.

required
Source code in fit_file_faker/config.py
def __init__(self, config_manager: ConfigManager):
    """Initialize ProfileManager with config manager reference.

    Args:
        config_manager: The ConfigManager instance to use for persistence.
    """
    self.config_manager = config_manager

create_profile

create_profile(
    name: str,
    app_type: AppType,
    garmin_username: str,
    garmin_password: str,
    fitfiles_path: Path,
    manufacturer: int | None = None,
    device: int | None = None,
    serial_number: int | None = None,
    software_version: int | None = None,
) -> Profile

Create a new profile and add it to config.

Parameters:

Name Type Description Default
name str

Unique profile name.

required
app_type AppType

Type of trainer application.

required
garmin_username str

Garmin Connect email.

required
garmin_password str

Garmin Connect password.

required
fitfiles_path Path

Path to FIT files directory.

required
manufacturer int | None

Manufacturer ID for device simulation (defaults to Garmin).

None
device int | None

Device/product ID for device simulation (defaults to Edge 830).

None
serial_number int | None

Device serial number (defaults to auto-generated 10-digit number).

None
software_version int | None

Firmware version in FIT format (e.g., 2922 = v29.22). If None, no FileCreatorMessage will be added to FIT files.

None

Returns:

Type Description
Profile

The newly created Profile object.

Raises:

Type Description
ValueError

If profile name already exists.

Examples:

>>> manager = ProfileManager(config_manager)
>>> profile = manager.create_profile(
...     "zwift",
...     AppType.ZWIFT,
...     "user@example.com",
...     "secret",
...     Path("/path/to/fitfiles")
... )
Source code in fit_file_faker/config.py
def create_profile(
    self,
    name: str,
    app_type: AppType,
    garmin_username: str,
    garmin_password: str,
    fitfiles_path: Path,
    manufacturer: int | None = None,
    device: int | None = None,
    serial_number: int | None = None,
    software_version: int | None = None,
) -> Profile:
    """Create a new profile and add it to config.

    Args:
        name: Unique profile name.
        app_type: Type of trainer application.
        garmin_username: Garmin Connect email.
        garmin_password: Garmin Connect password.
        fitfiles_path: Path to FIT files directory.
        manufacturer: Manufacturer ID for device simulation (defaults to Garmin).
        device: Device/product ID for device simulation (defaults to Edge 830).
        serial_number: Device serial number (defaults to auto-generated 10-digit number).
        software_version: Firmware version in FIT format (e.g., 2922 = v29.22). If None,
            no FileCreatorMessage will be added to FIT files.

    Returns:
        The newly created Profile object.

    Raises:
        ValueError: If profile name already exists.

    Examples:
        >>> manager = ProfileManager(config_manager)
        >>> profile = manager.create_profile(
        ...     "zwift",
        ...     AppType.ZWIFT,
        ...     "user@example.com",
        ...     "secret",
        ...     Path("/path/to/fitfiles")
        ... )
    """
    # Check if profile name already exists
    if self.config_manager.config.get_profile(name):
        raise ValueError(f'Profile "{name}" already exists')

    # Auto-lookup software_version from device if not provided
    if software_version is None and device is not None:
        device_info = next(
            (d for d in SUPPLEMENTAL_GARMIN_DEVICES if d.product_id == device), None
        )
        if device_info and device_info.software_version:
            software_version = device_info.software_version

    # Create new profile
    profile = Profile(
        name=name,
        app_type=app_type,
        garmin_username=garmin_username,
        garmin_password=garmin_password,
        fitfiles_path=fitfiles_path,
        manufacturer=manufacturer,
        device=device,
        serial_number=serial_number,
        software_version=software_version,
    )

    # Validate serial number if provided
    if serial_number is not None and not profile.validate_serial_number():
        import random

        _logger.warning(
            f"Invalid serial number {serial_number}, generating a new one"
        )
        profile.serial_number = random.randint(1_000_000_000, 4_294_967_295)

    # Add to config and save
    self.config_manager.config.profiles.append(profile)
    self.config_manager.save_config()

    _logger.info(f'Created profile "{name}"')
    return profile

create_profile_wizard

create_profile_wizard() -> Profile | None

Interactive wizard for creating a new profile.

Follows app-first flow: 1. Select app type 2. Auto-detect directory (with confirm/override) 3. Enter Garmin credentials 4. Enter profile name

Returns:

Type Description
Profile | None

The newly created Profile, or None if cancelled.

Source code in fit_file_faker/config.py
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
def create_profile_wizard(self) -> Profile | None:
    """Interactive wizard for creating a new profile.

    Follows app-first flow:
    1. Select app type
    2. Auto-detect directory (with confirm/override)
    3. Enter Garmin credentials
    4. Enter profile name

    Returns:
        The newly created Profile, or None if cancelled.
    """
    from fit_file_faker.app_registry import get_detector

    console = Console()
    console.print("\n[bold cyan]Create New Profile[/bold cyan]")

    # Step 1: Select app type
    app_choices = [
        questionary.Choice("TrainingPeaks Virtual", AppType.TP_VIRTUAL),
        questionary.Choice("Zwift", AppType.ZWIFT),
        questionary.Choice("MyWhoosh", AppType.MYWHOOSH),
        questionary.Choice("Custom (manual path)", AppType.CUSTOM),
    ]

    app_type = questionary.select(
        "Which trainer app will this profile use?", choices=app_choices
    ).ask()

    if not app_type:
        return None

    # Step 2: Directory detection
    detector = get_detector(app_type)
    suggested_path = detector.get_default_path()

    if suggested_path:
        console.print(
            f"\n[green]✓ Found {detector.get_display_name()} directory:[/green]"
        )
        console.print(f"  {suggested_path}")
        use_detected = questionary.confirm(
            "Use this directory?", default=True
        ).ask()

        if use_detected:
            fitfiles_path = suggested_path
        else:
            path_input = questionary.path("Enter FIT files directory path:").ask()
            if not path_input:
                return None
            fitfiles_path = Path(path_input)
    else:
        console.print(
            f"\n[yellow]Could not auto-detect {detector.get_display_name()} directory[/yellow]"
        )
        path_input = questionary.path("Enter FIT files directory path:").ask()
        if not path_input:
            return None
        fitfiles_path = Path(path_input)

    # Step 3: Garmin credentials
    garmin_username = questionary.text(
        "Enter Garmin Connect email:", validate=lambda x: len(x) > 0
    ).ask()
    if not garmin_username:
        return None

    garmin_password = questionary.password(
        "Enter Garmin Connect password:", validate=lambda x: len(x) > 0
    ).ask()
    if not garmin_password:
        return None

    # Step 4: Device customization (optional)
    manufacturer = None
    device = None
    serial_number = None
    software_version = None
    customize_device = questionary.confirm(
        "Customize device simulation? (default: Garmin Edge 830)", default=False
    ).ask()

    if customize_device:
        # Two-level menu: common devices first, then "View all devices" option
        show_all = False
        device_selected = False
        selected_device_name = None

        while not device_selected:
            # Get list of supported devices (common or all based on show_all flag)
            supported_devices = get_supported_garmin_devices(show_all=show_all)

            # Build device choices for the menu
            device_choices = []

            if not show_all:
                # Level 1: Common devices grouped by category
                # Bike computers
                bike_computers = [
                    (name, device_id, desc)
                    for name, device_id, desc in supported_devices
                    if any(
                        d.product_id == device_id and d.category == "bike_computer"
                        for d in SUPPLEMENTAL_GARMIN_DEVICES
                    )
                ]
                for name, device_id, desc in bike_computers:
                    device_choices.append(
                        questionary.Choice(
                            f"{name} ({device_id})", (name, device_id)
                        )
                    )

                # Add separator
                device_choices.append(
                    questionary.Separator("───────────────────────────")
                )

                # Multisport watches
                watches = [
                    (name, device_id, desc)
                    for name, device_id, desc in supported_devices
                    if any(
                        d.product_id == device_id
                        and d.category == "multisport_watch"
                        for d in SUPPLEMENTAL_GARMIN_DEVICES
                    )
                ]
                for name, device_id, desc in watches:
                    device_choices.append(
                        questionary.Choice(
                            f"{name} ({device_id})", (name, device_id)
                        )
                    )

                # Add separator and special options
                device_choices.append(
                    questionary.Separator("───────────────────────────")
                )
                device_choices.append(
                    questionary.Choice(
                        "View all devices (70+ options)...", ("VIEW_ALL", None)
                    )
                )
                device_choices.append(
                    questionary.Choice(
                        "Custom (enter numeric ID)", ("CUSTOM", None)
                    )
                )
            else:
                # Level 2: All devices
                # Group by category
                categories = {}
                for name, device_id, desc in supported_devices:
                    # Determine category
                    category = "Other"
                    for d in SUPPLEMENTAL_GARMIN_DEVICES:
                        if d.product_id == device_id:
                            category = d.category.replace("_", " ").title()
                            break

                    if category not in categories:
                        categories[category] = []

                    display = f"{name} ({device_id})"
                    categories[category].append((display, (name, device_id)))

                # Add devices by category
                for category in sorted(categories.keys()):
                    device_choices.append(
                        questionary.Separator(f"─── {category} ───")
                    )
                    for display, value in categories[category]:
                        device_choices.append(questionary.Choice(display, value))

                # Add separator and special options
                device_choices.append(
                    questionary.Separator("───────────────────────────")
                )
                device_choices.append(
                    questionary.Choice("Back to common devices", ("BACK", None))
                )
                device_choices.append(
                    questionary.Choice(
                        "Custom (enter numeric ID)", ("CUSTOM", None)
                    )
                )

            # Show the menu
            selected = questionary.select(
                "Select Garmin device to simulate:", choices=device_choices
            ).ask()

            if not selected:
                return None

            # Extract value from Choice object if necessary (for testing)
            if hasattr(selected, "value"):
                selected = selected.value

            device_name, device_id = selected

            if device_name == "VIEW_ALL":
                # Switch to showing all devices
                show_all = True
                continue
            elif device_name == "BACK":
                # Switch back to common devices
                show_all = False
                continue
            elif device_name == "CUSTOM":
                # Allow custom numeric ID
                device_input = questionary.text(
                    "Enter numeric device ID:",
                    validate=lambda x: x.isdigit() and int(x) > 0,
                ).ask()

                if not device_input:
                    return None

                device = int(device_input)
                device_selected = True

                # Warn if device ID not in enum or supplemental registry
                from fit_file_faker.vendor.fit_tool.profile.profile_type import (
                    GarminProduct,
                )

                try:
                    GarminProduct(device)
                except ValueError:
                    # Check supplemental registry
                    found = any(
                        d.product_id == device for d in SUPPLEMENTAL_GARMIN_DEVICES
                    )
                    if not found:
                        console.print(
                            f"\n[yellow]⚠ Warning: Device ID {device} is not recognized in the "
                            f"GarminProduct enum or supplemental registry. The profile will still be created.[/yellow]"
                        )
            else:
                # Device selected
                device = device_id
                selected_device_name = device_name
                device_selected = True

        # Look up software_version from supplemental registry
        if device is not None:
            device_info = next(
                (d for d in SUPPLEMENTAL_GARMIN_DEVICES if d.product_id == device),
                None,
            )
            if device_info and device_info.software_version:
                software_version = device_info.software_version

        # Always use Garmin manufacturer for now
        from fit_file_faker.vendor.fit_tool.profile.profile_type import Manufacturer

        manufacturer = Manufacturer.GARMIN.value

        # Ask about serial number customization
        console.print(
            "\n[yellow]⚠️  Important:[/yellow] For full Garmin Connect features (Training Effect, "
            "challenges, badges),\n"
            "   the serial number should match your actual Garmin device.\n"
            "   Random serial numbers may cause activities to not count properly.\n"
        )
        customize_serial = questionary.confirm(
            "Customize serial number for this device?", default=False
        ).ask()

        if customize_serial:
            # Show instructions for finding device serial number
            console.print(
                '\n[dim]The "serial number" value should be set to your device\'s Unit ID[/dim]'
            )
            console.print("\n[dim]To find your device's Unit ID:[/dim]")
            console.print(
                "[dim]  On device: Settings → About → Copyright Info → Unit ID[/dim]"
            )
            console.print(
                "[dim]  On Garmin Connect (may not work for all devices): Device settings page → System → About[/dim]\n"
            )

            serial_input = questionary.text(
                "Enter 10-digit serial number:",
                validate=lambda x: (
                    x.isdigit()
                    and len(x) == 10
                    and 1_000_000_000 <= int(x) <= 4_294_967_295
                )
                or "Must be a 10-digit number between 1000000000 and 4294967295",
            ).ask()

            if serial_input and serial_input.isdigit():
                serial_number = int(serial_input)

        if serial_number is None:
            # User declined customization, generate random
            import random

            serial_number = random.randint(1_000_000_000, 4_294_967_295)
    else:
        # User declined device customization, still generate serial for default device
        import random

        serial_number = random.randint(1_000_000_000, 4_294_967_295)

    # Display final device configuration before profile creation
    if device is None:
        device_display = '"Edge 830" (3122)'
    elif selected_device_name:
        device_display = f'"{selected_device_name}" ({device})'
    else:
        device_display = f"Device {device}"
    console.print(f"\n[cyan]Device:[/cyan] [yellow]{device_display}[/yellow]")
    console.print(f"[cyan]Serial Number:[/cyan] [yellow]{serial_number}[/yellow]")
    console.print(
        "[dim](You can change these later via the edit profile menu)[/dim]"
    )

    # Step 5: Profile name
    suggested_name = app_type.value.split("_")[0].lower()
    profile_name = questionary.text(
        "Enter profile name:", default=suggested_name, validate=lambda x: len(x) > 0
    ).ask()
    if not profile_name:
        return None

    # Create the profile
    try:
        profile = self.create_profile(
            name=profile_name,
            app_type=app_type,
            garmin_username=garmin_username,
            garmin_password=garmin_password,
            fitfiles_path=fitfiles_path,
            manufacturer=manufacturer,
            device=device,
            serial_number=serial_number,
            software_version=software_version,
        )
        console.print(
            f"\n[green]✓ Profile '{profile_name}' created successfully![/green]"
        )
        return profile
    except ValueError as e:
        console.print(f"\n[red]✗ Error: {e}[/red]")
        return None

delete_profile

delete_profile(name: str) -> None

Delete a profile.

Parameters:

Name Type Description Default
name str

Name of profile to delete.

required

Raises:

Type Description
ValueError

If profile not found or trying to delete the only profile.

Source code in fit_file_faker/config.py
def delete_profile(self, name: str) -> None:
    """Delete a profile.

    Args:
        name: Name of profile to delete.

    Raises:
        ValueError: If profile not found or trying to delete the only profile.
    """
    profile = self.get_profile(name)
    if not profile:
        raise ValueError(f'Profile "{name}" not found')

    # Prevent deleting the only profile
    if len(self.config_manager.config.profiles) == 1:
        raise ValueError("Cannot delete the only profile")

    # Remove from profiles list
    self.config_manager.config.profiles.remove(profile)

    # Update default if we deleted the default profile
    if self.config_manager.config.default_profile == name:
        # Set first remaining profile as default
        self.config_manager.config.default_profile = (
            self.config_manager.config.profiles[0].name
        )

    self.config_manager.save_config()
    _logger.info(f'Deleted profile "{name}"')

delete_profile_wizard

delete_profile_wizard() -> None

Interactive wizard for deleting a profile with confirmation.

Source code in fit_file_faker/config.py
def delete_profile_wizard(self) -> None:
    """Interactive wizard for deleting a profile with confirmation."""
    console = Console()

    profiles = self.list_profiles()
    if not profiles:
        console.print("[yellow]No profiles to delete.[/yellow]")
        return

    if len(profiles) == 1:
        console.print("[yellow]Cannot delete the only profile.[/yellow]")
        return

    # Select profile to delete
    profile_choices = [p.name for p in profiles]
    profile_name = questionary.select(
        "Select profile to delete:", choices=profile_choices
    ).ask()

    if not profile_name:
        return

    # Confirm deletion
    confirm = questionary.confirm(
        f'Are you sure you want to delete profile "{profile_name}"?',
        default=False,
    ).ask()

    if not confirm:
        console.print("[yellow]Deletion cancelled.[/yellow]")
        return

    # Delete the profile
    try:
        self.delete_profile(profile_name)
        console.print(
            f"\n[green]✓ Profile '{profile_name}' deleted successfully![/green]"
        )
    except ValueError as e:
        console.print(f"\n[red]✗ Error: {e}[/red]")

display_profiles_table

display_profiles_table() -> None

Display all profiles in a Rich table.

Shows profile name, app type, device, Garmin username, and FIT files path in a formatted table. Marks the default profile with ⭐.

Source code in fit_file_faker/config.py
def display_profiles_table(self) -> None:
    """Display all profiles in a Rich table.

    Shows profile name, app type, device, Garmin username, and FIT files path
    in a formatted table. Marks the default profile with ⭐.
    """
    console = Console()
    table = Table(
        title="📋 FIT File Faker - Profiles",
        show_header=True,
        header_style="bold cyan",
    )

    table.add_column("Name", style="green", no_wrap=True)
    table.add_column("App", style="blue")
    table.add_column("Device", style="cyan")
    table.add_column("Serial #", style="bright_blue")
    table.add_column("Garmin User", style="yellow")
    table.add_column("FIT Path", style="magenta")

    profiles = self.list_profiles()
    if not profiles:
        console.print("[yellow]No profiles configured yet.[/yellow]")
        return

    for profile in profiles:
        # Mark default profile with star
        name_display = profile.name
        if profile.name == self.config_manager.config.default_profile:
            name_display = f"{profile.name} ⭐"

        # Format app type for display using detector's short name
        from fit_file_faker.app_registry import get_detector

        detector = get_detector(profile.app_type)
        app_display = detector.get_short_name()

        # Get device name
        device_display = profile.get_device_name()

        # Format serial number
        serial_display = (
            str(profile.serial_number) if profile.serial_number else "N/A"
        )

        # Truncate long paths
        path_str = str(profile.fitfiles_path)
        if len(path_str) > 40:
            path_str = "..." + path_str[-37:]

        table.add_row(
            name_display,
            app_display,
            device_display,
            serial_display,
            profile.garmin_username,
            path_str,
        )

    console.print(table)

edit_profile_wizard

edit_profile_wizard() -> None

Interactive wizard for editing an existing profile.

Source code in fit_file_faker/config.py
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
def edit_profile_wizard(self) -> None:
    """Interactive wizard for editing an existing profile."""
    console = Console()

    profiles = self.list_profiles()
    if not profiles:
        console.print("[yellow]No profiles to edit.[/yellow]")
        return

    # Select profile to edit
    profile_choices = [p.name for p in profiles]
    profile_name = questionary.select(
        "Select profile to edit:", choices=profile_choices
    ).ask()

    if not profile_name:
        return

    profile = self.get_profile(profile_name)
    if not profile:
        return

    console.print(f"\n[bold cyan]Editing Profile: {profile_name}[/bold cyan]")
    console.print("[dim]Leave blank to keep current value[/dim]\n")

    # Ask which fields to update
    new_name = questionary.text(f"Profile name [{profile.name}]:", default="").ask()

    new_username = questionary.text(
        f"Garmin username [{profile.garmin_username}]:", default=""
    ).ask()

    new_password = questionary.password("Garmin password [****]:", default="").ask()

    new_path = questionary.path(
        f"FIT files path [{profile.fitfiles_path}]:", default=""
    ).ask()

    # Ask about device simulation
    new_manufacturer = None
    new_device = None
    new_serial = None
    new_software_version = None
    current_device = profile.get_device_name()
    current_serial = profile.serial_number if profile.serial_number else "N/A"
    edit_device = questionary.confirm(
        f"Edit device simulation? (current: {current_device}, serial: {current_serial})",
        default=False,
    ).ask()

    if edit_device:
        # Two-level menu: common devices first, then "View all devices" option
        show_all = False
        device_selected = False

        while not device_selected:
            # Get list of supported devices (common or all based on show_all flag)
            supported_devices = get_supported_garmin_devices(show_all=show_all)

            # Build device choices for the menu
            device_choices = []

            if not show_all:
                # Level 1: Common devices grouped by category
                # Bike computers
                bike_computers = [
                    (name, device_id, desc)
                    for name, device_id, desc in supported_devices
                    if any(
                        d.product_id == device_id and d.category == "bike_computer"
                        for d in SUPPLEMENTAL_GARMIN_DEVICES
                    )
                ]
                for name, device_id, desc in bike_computers:
                    device_choices.append(
                        questionary.Choice(
                            f"{name} ({device_id})", (name, device_id)
                        )
                    )

                # Add separator
                device_choices.append(
                    questionary.Separator("───────────────────────────")
                )

                # Multisport watches
                watches = [
                    (name, device_id, desc)
                    for name, device_id, desc in supported_devices
                    if any(
                        d.product_id == device_id
                        and d.category == "multisport_watch"
                        for d in SUPPLEMENTAL_GARMIN_DEVICES
                    )
                ]
                for name, device_id, desc in watches:
                    device_choices.append(
                        questionary.Choice(
                            f"{name} ({device_id})", (name, device_id)
                        )
                    )

                # Add separator and special options
                device_choices.append(
                    questionary.Separator("───────────────────────────")
                )
                device_choices.append(
                    questionary.Choice(
                        "View all devices (70+ options)...", ("VIEW_ALL", None)
                    )
                )
                device_choices.append(
                    questionary.Choice(
                        "Custom (enter numeric ID)", ("CUSTOM", None)
                    )
                )
            else:
                # Level 2: All devices
                # Group by category
                categories = {}
                for name, device_id, desc in supported_devices:
                    # Determine category
                    category = "Other"
                    for d in SUPPLEMENTAL_GARMIN_DEVICES:
                        if d.product_id == device_id:
                            category = d.category.replace("_", " ").title()
                            break

                    if category not in categories:
                        categories[category] = []

                    display = f"{name} ({device_id})"
                    categories[category].append((display, (name, device_id)))

                # Add devices by category
                for category in sorted(categories.keys()):
                    device_choices.append(
                        questionary.Separator(f"─── {category} ───")
                    )
                    for display, value in categories[category]:
                        device_choices.append(questionary.Choice(display, value))

                # Add separator and special options
                device_choices.append(
                    questionary.Separator("───────────────────────────")
                )
                device_choices.append(
                    questionary.Choice("Back to common devices", ("BACK", None))
                )
                device_choices.append(
                    questionary.Choice(
                        "Custom (enter numeric ID)", ("CUSTOM", None)
                    )
                )

            # Show the menu
            selected = questionary.select(
                "Select Garmin device to simulate:", choices=device_choices
            ).ask()

            if not selected:
                device_selected = True
                continue

            # Extract value from Choice object if necessary (for testing)
            if hasattr(selected, "value"):
                selected = selected.value

            device_name, device_id = selected

            if device_name == "VIEW_ALL":
                # Switch to showing all devices
                show_all = True
                continue
            elif device_name == "BACK":
                # Switch back to common devices
                show_all = False
                continue
            elif device_name == "CUSTOM":
                # Allow custom numeric ID
                device_input = questionary.text(
                    "Enter numeric device ID:",
                    validate=lambda x: x.isdigit() and int(x) > 0,
                ).ask()

                if device_input:
                    new_device = int(device_input)
                    device_selected = True

                    # Warn if device ID not in enum or supplemental registry
                    from fit_file_faker.vendor.fit_tool.profile.profile_type import (
                        GarminProduct,
                    )

                    try:
                        GarminProduct(new_device)
                    except ValueError:
                        # Check supplemental registry
                        found = any(
                            d.product_id == new_device
                            for d in SUPPLEMENTAL_GARMIN_DEVICES
                        )
                        if not found:
                            console.print(
                                f"\n[yellow]⚠ Warning: Device ID {new_device} is not recognized in the "
                                f"GarminProduct enum or supplemental registry. The profile will still be updated.[/yellow]"
                            )
            else:
                # Device selected
                new_device = device_id
                device_selected = True

        # Look up software_version from supplemental registry
        if new_device is not None:
            device_info = next(
                (
                    d
                    for d in SUPPLEMENTAL_GARMIN_DEVICES
                    if d.product_id == new_device
                ),
                None,
            )
            if device_info and device_info.software_version:
                new_software_version = device_info.software_version

            # Always use Garmin manufacturer
            from fit_file_faker.vendor.fit_tool.profile.profile_type import (
                Manufacturer,
            )

            new_manufacturer = Manufacturer.GARMIN.value

        # Ask about serial number editing
        console.print(
            "\n[yellow]⚠️  Important:[/yellow] For full Garmin Connect features (Training Effect, "
            "challenges, badges),\n"
            '   the serial number should match the "Unit ID" of an actual Garmin device.\n'
            "   Random serial numbers may cause activities to not count properly.\n"
        )
        edit_serial = questionary.confirm(
            f"Edit serial number? (current: {current_serial})", default=False
        ).ask()

        if edit_serial:
            # Ask if user wants to enter custom or generate random
            serial_choice = questionary.select(
                "How would you like to set the serial number?",
                choices=[
                    questionary.Choice(
                        "Enter custom serial number (recommended)", "custom"
                    ),
                    questionary.Choice("Generate random serial number", "random"),
                ],
            ).ask()

            if serial_choice == "random":
                import random

                new_serial = random.randint(1_000_000_000, 4_294_967_295)
                console.print(
                    f"\n[green]Generated new serial number: {new_serial}[/green]"
                )
                console.print(
                    "[yellow]Note: Random serial numbers may not work properly with Garmin Connect features.[/yellow]"
                )
            elif serial_choice == "custom":
                # Show instructions for finding device serial number
                console.print(
                    '\n[dim]The "serial number" value should be set to your device\'s Unit ID[/dim]'
                )
                console.print("\n[dim]To find your device's Unit ID:[/dim]")
                console.print(
                    "[dim]  On device: Settings → About → Copyright Info → Unit ID[/dim]"
                )
                console.print(
                    "[dim]  On Garmin Connect (may not work for all devices): Device settings page → System → About[/dim]\n"
                )

                serial_input = questionary.text(
                    "Enter new 10-digit serial number:",
                    default=str(profile.serial_number)
                    if profile.serial_number
                    else "",
                    validate=lambda x: (
                        x.isdigit()
                        and len(x) == 10
                        and 1_000_000_000 <= int(x) <= 4_294_967_295
                    )
                    or "Must be a 10-digit number between 1000000000 and 4294967295",
                ).ask()

                if serial_input and serial_input.isdigit():
                    new_serial = int(serial_input)

    # Update profile with provided values
    try:
        self.update_profile(
            name=profile_name,
            new_name=new_name if new_name else None,
            garmin_username=new_username if new_username else None,
            garmin_password=new_password if new_password else None,
            fitfiles_path=Path(new_path) if new_path else None,
            manufacturer=new_manufacturer,
            device=new_device,
            serial_number=new_serial,
            software_version=new_software_version,
        )
        console.print("\n[green]✓ Profile updated successfully![/green]")
    except ValueError as e:
        console.print(f"\n[red]✗ Error: {e}[/red]")

get_profile

get_profile(name: str) -> Profile | None

Get profile by name.

Parameters:

Name Type Description Default
name str

The profile name to retrieve.

required

Returns:

Type Description
Profile | None

Profile object if found, None otherwise.

Source code in fit_file_faker/config.py
def get_profile(self, name: str) -> Profile | None:
    """Get profile by name.

    Args:
        name: The profile name to retrieve.

    Returns:
        Profile object if found, None otherwise.
    """
    return self.config_manager.config.get_profile(name)

interactive_menu

interactive_menu() -> None

Display interactive profile management menu.

Shows profile table and presents menu options for creating, editing, deleting profiles, and setting default.

Source code in fit_file_faker/config.py
def interactive_menu(self) -> None:
    """Display interactive profile management menu.

    Shows profile table and presents menu options for creating,
    editing, deleting profiles, and setting default.
    """
    while True:
        console = Console()
        console.print()  # Blank line
        self.display_profiles_table()
        console.print()  # Blank line

        choices = [
            "Create new profile",
            "Edit existing profile",
            "Delete profile",
            "Set default profile",
            "Exit",
        ]

        action = questionary.select(
            "What would you like to do?",
            choices=choices,
            style=questionary.Style([("highlighted", "fg:cyan bold")]),
        ).ask()

        if not action or action == "Exit":
            break

        try:
            if action == "Create new profile":
                self.create_profile_wizard()
            elif action == "Edit existing profile":
                self.edit_profile_wizard()
            elif action == "Delete profile":
                self.delete_profile_wizard()
            elif action == "Set default profile":
                self.set_default_wizard()
        except (KeyboardInterrupt, EOFError):
            console.print("\n[yellow]Operation cancelled.[/yellow]")
            continue

list_profiles

list_profiles() -> list[Profile]

Get list of all profiles.

Returns:

Type Description
list[Profile]

List of all Profile objects.

Source code in fit_file_faker/config.py
def list_profiles(self) -> list[Profile]:
    """Get list of all profiles.

    Returns:
        List of all Profile objects.
    """
    return self.config_manager.config.profiles

set_default_profile

set_default_profile(name: str) -> None

Set a profile as the default.

Parameters:

Name Type Description Default
name str

Name of profile to set as default.

required

Raises:

Type Description
ValueError

If profile not found.

Source code in fit_file_faker/config.py
def set_default_profile(self, name: str) -> None:
    """Set a profile as the default.

    Args:
        name: Name of profile to set as default.

    Raises:
        ValueError: If profile not found.
    """
    profile = self.get_profile(name)
    if not profile:
        raise ValueError(f'Profile "{name}" not found')

    self.config_manager.config.default_profile = name
    self.config_manager.save_config()
    _logger.info(f'Set "{name}" as default profile')

set_default_wizard

set_default_wizard() -> None

Interactive wizard for setting the default profile.

Source code in fit_file_faker/config.py
def set_default_wizard(self) -> None:
    """Interactive wizard for setting the default profile."""
    console = Console()

    profiles = self.list_profiles()
    if not profiles:
        console.print("[yellow]No profiles available.[/yellow]")
        return

    # Select profile to set as default
    profile_choices = [p.name for p in profiles]
    current_default = self.config_manager.config.default_profile

    profile_name = questionary.select(
        f"Select default profile (current: {current_default}):",
        choices=profile_choices,
    ).ask()

    if not profile_name:
        return

    # Set as default
    try:
        self.set_default_profile(profile_name)
        console.print(
            f"\n[green]✓ '{profile_name}' is now the default profile![/green]"
        )
    except ValueError as e:
        console.print(f"\n[red]✗ Error: {e}[/red]")

update_profile

update_profile(
    name: str,
    app_type: AppType | None = None,
    garmin_username: str | None = None,
    garmin_password: str | None = None,
    fitfiles_path: Path | None = None,
    new_name: str | None = None,
    manufacturer: int | None = None,
    device: int | None = None,
    serial_number: int | None = None,
    software_version: int | None = None,
) -> Profile

Update an existing profile.

Parameters:

Name Type Description Default
name str

Name of profile to update.

required
app_type AppType | None

New app type (optional).

None
garmin_username str | None

New Garmin username (optional).

None
garmin_password str | None

New Garmin password (optional).

None
fitfiles_path Path | None

New FIT files path (optional).

None
new_name str | None

New profile name (optional).

None
manufacturer int | None

New manufacturer ID (optional).

None
device int | None

New device ID (optional).

None
serial_number int | None

New serial number (optional).

None
software_version int | None

New firmware version in FIT format (optional).

None

Returns:

Type Description
Profile

The updated Profile object.

Raises:

Type Description
ValueError

If profile not found or new name already exists.

Source code in fit_file_faker/config.py
def update_profile(
    self,
    name: str,
    app_type: AppType | None = None,
    garmin_username: str | None = None,
    garmin_password: str | None = None,
    fitfiles_path: Path | None = None,
    new_name: str | None = None,
    manufacturer: int | None = None,
    device: int | None = None,
    serial_number: int | None = None,
    software_version: int | None = None,
) -> Profile:
    """Update an existing profile.

    Args:
        name: Name of profile to update.
        app_type: New app type (optional).
        garmin_username: New Garmin username (optional).
        garmin_password: New Garmin password (optional).
        fitfiles_path: New FIT files path (optional).
        new_name: New profile name (optional).
        manufacturer: New manufacturer ID (optional).
        device: New device ID (optional).
        serial_number: New serial number (optional).
        software_version: New firmware version in FIT format (optional).

    Returns:
        The updated Profile object.

    Raises:
        ValueError: If profile not found or new name already exists.
    """
    profile = self.get_profile(name)
    if not profile:
        raise ValueError(f'Profile "{name}" not found')

    # Check if new name conflicts
    if new_name and new_name != name:
        if self.get_profile(new_name):
            raise ValueError(f'Profile "{new_name}" already exists')
        profile.name = new_name

    # Update fields if provided
    if app_type is not None:
        profile.app_type = app_type
    if garmin_username is not None:
        profile.garmin_username = garmin_username
    if garmin_password is not None:
        profile.garmin_password = garmin_password
    if fitfiles_path is not None:
        profile.fitfiles_path = fitfiles_path
    if manufacturer is not None:
        profile.manufacturer = manufacturer
    if device is not None:
        profile.device = device
        # Auto-lookup software_version from device if not explicitly provided
        if software_version is None:
            device_info = next(
                (d for d in SUPPLEMENTAL_GARMIN_DEVICES if d.product_id == device),
                None,
            )
            if device_info and device_info.software_version:
                software_version = device_info.software_version
    if serial_number is not None:
        # Validate serial number
        temp_profile = Profile(
            name="temp",
            app_type=profile.app_type,
            garmin_username="",
            garmin_password="",
            fitfiles_path=Path(),
            serial_number=serial_number,
        )
        if not temp_profile.validate_serial_number():
            raise ValueError(
                f"Invalid serial number {serial_number}. Must be a 10-digit integer."
            )
        profile.serial_number = serial_number
    if software_version is not None:
        profile.software_version = software_version

    # Update default_profile if name changed
    if new_name and self.config_manager.config.default_profile == name:
        self.config_manager.config.default_profile = new_name

    self.config_manager.save_config()
    _logger.info(f'Updated profile "{new_name or name}"')
    return profile

get_fitfiles_path

get_fitfiles_path(existing_path: Path | None) -> Path

Auto-find the FITFiles folder inside a TrainingPeaks Virtual directory.

Attempts to automatically locate the user's TrainingPeaks Virtual FITFiles directory. On macOS/Windows, the TPVirtual data directory is auto-detected. On Linux, the user is prompted to provide the path.

If multiple user directories exist, the user is prompted to select one.

Parameters:

Name Type Description Default
existing_path Path | None

Optional path to use as default. If provided, this path's parent.parent is used as the TPVirtual base directory.

required

Returns:

Type Description
Path

Path to the FITFiles directory (e.g., ~/TPVirtual/abc123def/FITFiles).

Raises:

Type Description
SystemExit

If no TP Virtual user folder is found, the user rejects the auto-detected folder, or the user cancels the selection.

Note

The TPVirtual folder location can be overridden using the TPV_DATA_PATH environment variable. User directories are identified by 16-character hexadecimal folder names.

Examples:

>>> # Auto-detect FITFiles path
>>> path = get_fitfiles_path(None)
>>> print(path)
/Users/me/TPVirtual/a1b2c3d4e5f6g7h8/FITFiles
Source code in fit_file_faker/config.py
def get_fitfiles_path(existing_path: Path | None) -> Path:
    """Auto-find the FITFiles folder inside a TrainingPeaks Virtual directory.

    Attempts to automatically locate the user's TrainingPeaks Virtual FITFiles
    directory. On macOS/Windows, the TPVirtual data directory is auto-detected.
    On Linux, the user is prompted to provide the path.

    If multiple user directories exist, the user is prompted to select one.

    Args:
        existing_path: Optional path to use as default. If provided, this path's
            `parent.parent` is used as the TPVirtual base directory.

    Returns:
        Path to the FITFiles directory (e.g., `~/TPVirtual/abc123def/FITFiles`).

    Raises:
        SystemExit: If no TP Virtual user folder is found, the user rejects
            the auto-detected folder, or the user cancels the selection.

    Note:
        The TPVirtual folder location can be overridden using the
        `TPV_DATA_PATH` environment variable. User directories are identified
        by 16-character hexadecimal folder names.

    Examples:
        >>> # Auto-detect FITFiles path
        >>> path = get_fitfiles_path(None)
        >>> print(path)
        /Users/me/TPVirtual/a1b2c3d4e5f6g7h8/FITFiles
    """
    _logger.info("Getting FITFiles folder")

    TPVPath = get_tpv_folder(existing_path)
    res = [f for f in os.listdir(TPVPath) if re.search(r"\A(\w){16}\Z", f)]
    if len(res) == 0:
        _logger.error(
            'Cannot find a TP Virtual User folder in "%s", please check if you have previously logged into TP Virtual',
            TPVPath,
        )
        sys.exit(1)
    elif len(res) == 1:
        title = f'Found TP Virtual User directory at "{Path(TPVPath) / res[0]}", is this correct? '
        option = questionary.select(title, choices=["yes", "no"]).ask()
        if option == "no":
            # Get config manager instance to access config file path
            config_manager = ConfigManager()
            _logger.error(
                'Failed to find correct TP Virtual User folder please manually configure "fitfiles_path" in config file: %s',
                config_manager.get_config_file_path().absolute(),
            )
            sys.exit(1)
        else:
            option = res[0]
    else:
        title = "Found multiple TP Virtual User directories, please select the directory for your user: "
        option = questionary.select(title, choices=res).ask()
    TPV_data_path = Path(TPVPath) / option
    _logger.info(
        f'Found TP Virtual User directory: "{str(TPV_data_path.absolute())}", '
        'setting "fitfiles_path" in config file'
    )
    return TPV_data_path / "FITFiles"

get_supported_garmin_devices

get_supported_garmin_devices(
    show_all: bool = False,
) -> list[tuple[str, int, str]]

Get list of Garmin devices for picker UI.

Combines devices from fit_tool's GarminProduct enum (filtered to cycling/training devices with "EDGE", "TACX", or "TRAINING" in their names) with the supplemental device registry containing modern devices with metadata.

Parameters:

Name Type Description Default
show_all bool

If False, return only common devices (is_common=True). If True, return all devices. Defaults to False.

False

Returns:

Type Description
list[tuple[str, int, str]]

List of tuples containing (display_name, product_id, description).

list[tuple[str, int, str]]

Sorted by: is_common (desc), year_released (desc), name (asc).

Examples:

>>> # Get common devices only
>>> devices = get_supported_garmin_devices(show_all=False)
>>> print(devices[0])
('Edge 1050', 4440, 'Latest flagship bike computer - 2024')
>>>
>>> # Get all devices
>>> all_devices = get_supported_garmin_devices(show_all=True)
>>> len(all_devices) > len(devices)
True
Source code in fit_file_faker/config.py
def get_supported_garmin_devices(show_all: bool = False) -> list[tuple[str, int, str]]:
    """Get list of Garmin devices for picker UI.

    Combines devices from fit_tool's GarminProduct enum (filtered to cycling/training
    devices with "EDGE", "TACX", or "TRAINING" in their names) with the supplemental
    device registry containing modern devices with metadata.

    Args:
        show_all: If False, return only common devices (is_common=True). If True,
            return all devices. Defaults to False.

    Returns:
        List of tuples containing (display_name, product_id, description).
        Sorted by: is_common (desc), year_released (desc), name (asc).

    Examples:
        >>> # Get common devices only
        >>> devices = get_supported_garmin_devices(show_all=False)
        >>> print(devices[0])
        ('Edge 1050', 4440, 'Latest flagship bike computer - 2024')
        >>>
        >>> # Get all devices
        >>> all_devices = get_supported_garmin_devices(show_all=True)
        >>> len(all_devices) > len(devices)
        True
    """
    from fit_file_faker.vendor.fit_tool.profile.profile_type import GarminProduct

    # Step 1: Get devices from fit_tool enum (filtered to cycling/training)
    fit_tool_devices = {}
    for attr_name in dir(GarminProduct):
        if not attr_name.startswith("_") and attr_name.isupper():
            if any(kw in attr_name for kw in ["EDGE", "TACX", "TRAINING"]):
                try:
                    value = getattr(GarminProduct, attr_name).value
                    # Convert enum name to readable format (e.g., EDGE_1030 -> Edge 1030)
                    display_name = attr_name.replace("_", " ").title()
                    fit_tool_devices[value] = (display_name, value, "")
                except AttributeError:  # pragma: no cover
                    continue

    # Step 2: Get devices from supplemental registry
    supplemental_devices = {}
    for device in SUPPLEMENTAL_GARMIN_DEVICES:
        if not show_all and not device.is_common:
            continue
        supplemental_devices[device.product_id] = (
            device.name,
            device.product_id,
            device.description,
        )

    # Step 3: Merge (supplemental overrides fit_tool for duplicate IDs)
    merged_devices = {**fit_tool_devices, **supplemental_devices}

    # Step 4: Sort by is_common (desc), year (desc), name (asc)
    # Create lookup for sorting metadata
    device_meta = {d.product_id: d for d in SUPPLEMENTAL_GARMIN_DEVICES}

    def sort_key(item):
        name, product_id, description = item
        meta = device_meta.get(product_id)
        if meta:
            # Supplemental device - use metadata
            return (not meta.is_common, -meta.year_released, meta.name)
        else:
            # fit_tool device - sort after common devices
            return (True, 0, name)

    return sorted(merged_devices.values(), key=sort_key)

get_tpv_folder

get_tpv_folder(default_path: Path | None) -> Path

Get the TrainingPeaks Virtual base folder path.

Auto-detects the TPVirtual directory based on platform, or prompts the user to provide it if auto-detection is not available.

Platform-specific default locations:

  • macOS: ~/TPVirtual
  • Windows: ~/Documents/TPVirtual
  • Linux: User is prompted (no auto-detection)

Parameters:

Name Type Description Default
default_path Path | None

Optional default path to show in the prompt for Linux users.

required

Returns:

Type Description
Path

Path to the TPVirtual base directory (not the FITFiles subdirectory).

Note

The auto-detected path can be overridden by setting the TPV_DATA_PATH environment variable.

Examples:

>>> # macOS
>>> path = get_tpv_folder(None)
>>> print(path)
/Users/me/TPVirtual
>>>
>>> # Linux (prompts user)
>>> path = get_tpv_folder(Path("/home/me/custom/path"))
Please enter your TrainingPeaks Virtual data folder: /home/me/TPVirtual
Source code in fit_file_faker/config.py
def get_tpv_folder(default_path: Path | None) -> Path:
    """Get the TrainingPeaks Virtual base folder path.

    Auto-detects the TPVirtual directory based on platform, or prompts the
    user to provide it if auto-detection is not available.

    Platform-specific default locations:

    - macOS: `~/TPVirtual`
    - Windows: `~/Documents/TPVirtual`
    - Linux: User is prompted (no auto-detection)

    Args:
        default_path: Optional default path to show in the prompt for Linux users.

    Returns:
        Path to the `TPVirtual` base directory (not the `FITFiles` subdirectory).

    Note:
        The auto-detected path can be overridden by setting the `TPV_DATA_PATH`
        environment variable.

    Examples:
        >>> # macOS
        >>> path = get_tpv_folder(None)
        >>> print(path)
        /Users/me/TPVirtual
        >>>
        >>> # Linux (prompts user)
        >>> path = get_tpv_folder(Path("/home/me/custom/path"))
        Please enter your TrainingPeaks Virtual data folder: /home/me/TPVirtual
    """
    if os.environ.get("TPV_DATA_PATH", None):
        p = str(os.environ.get("TPV_DATA_PATH"))
        _logger.info(f'Using TPV_DATA_PATH value read from the environment: "{p}"')
        return Path(p)
    if sys.platform == "darwin":
        TPVPath = os.path.expanduser("~/TPVirtual")
    elif sys.platform == "win32":
        TPVPath = os.path.expanduser("~/Documents/TPVirtual")
    else:
        _logger.warning(
            "TrainingPeaks Virtual user folder can only be automatically detected on Windows and OSX"
        )
        TPVPath = questionary.path(
            'Please enter your TrainingPeaks Virtual data folder (by default, ends with "TPVirtual"): ',
            default=str(default_path) if default_path else "",
        ).ask()
    return Path(TPVPath)

migrate_legacy_config

migrate_legacy_config(old_config: dict) -> Config

Migrate single-profile config to multi-profile format.

Detects legacy config structure (v1.2.4 and earlier) and converts to multi-profile format. Creates a "default" profile with existing values and sets it as the default profile.

Parameters:

Name Type Description Default
old_config dict

Dictionary containing either legacy single-profile config (keys: garmin_username, garmin_password, fitfiles_path) or new multi-profile config (keys: profiles, default_profile).

required

Returns:

Type Description
Config

Config object in multi-profile format. If already migrated, returns

Config

as-is. Otherwise, creates new Config with "default" profile.

Examples:

>>> legacy = {
...     "garmin_username": "user@example.com",
...     "garmin_password": "secret",
...     "fitfiles_path": "/path/to/fitfiles"
... }
>>> config = migrate_legacy_config(legacy)
>>> config.profiles[0].name
'default'
>>> config.default_profile
'default'
Source code in fit_file_faker/config.py
def migrate_legacy_config(old_config: dict) -> Config:
    """Migrate single-profile config to multi-profile format.

    Detects legacy config structure (v1.2.4 and earlier) and converts to
    multi-profile format. Creates a "default" profile with existing values
    and sets it as the default profile.

    Args:
        old_config: Dictionary containing either legacy single-profile config
            (keys: garmin_username, garmin_password, fitfiles_path) or new
            multi-profile config (keys: profiles, default_profile).

    Returns:
        Config object in multi-profile format. If already migrated, returns
        as-is. Otherwise, creates new Config with "default" profile.

    Examples:
        >>> legacy = {
        ...     "garmin_username": "user@example.com",
        ...     "garmin_password": "secret",
        ...     "fitfiles_path": "/path/to/fitfiles"
        ... }
        >>> config = migrate_legacy_config(legacy)
        >>> config.profiles[0].name
        'default'
        >>> config.default_profile
        'default'
    """
    # Check if already migrated (has 'profiles' key)
    if "profiles" in old_config:
        _logger.debug("Config already in multi-profile format")
        return Config(**old_config)

    # Legacy config detected - migrate to multi-profile format
    _logger.info(
        "Detected legacy single-profile config, migrating to multi-profile format"
    )

    # Extract legacy values
    garmin_username = old_config.get("garmin_username")
    garmin_password = old_config.get("garmin_password")
    fitfiles_path = old_config.get("fitfiles_path")

    # Create default profile from legacy values
    # Default to TP_VIRTUAL as that was the original use case
    profile = Profile(
        name="default",
        app_type=AppType.TP_VIRTUAL,
        garmin_username=garmin_username or "",
        garmin_password=garmin_password or "",
        fitfiles_path=Path(fitfiles_path) if fitfiles_path else Path.home(),
    )

    # Create new multi-profile config
    new_config = Config(profiles=[profile], default_profile="default")

    _logger.info(
        'Migration complete. Your existing settings are now in the "default" profile.'
    )
    return new_config

Utilities (utils.py)

utils

Utility functions for Fit File Faker.

This module provides utility functions including a monkey patch for fit_tool to handle malformed FIT files from certain manufacturers (e.g., COROS) and a CRC-16 checksum calculation function.

The fit_tool patch is automatically applied when the fit_editor module is imported, making it transparent to users of the library.

_lenient_get_length_from_size

_lenient_get_length_from_size(
    base_type: BaseType, size: int
) -> int

Lenient field length calculator that truncates instead of raising exceptions.

This is a replacement for fit_tool's Field.get_length_from_size that handles malformed FIT files more gracefully. Some manufacturers (e.g., COROS) create FIT files where field sizes are not exact multiples of their base type size. Instead of failing with an exception, this function truncates to the nearest valid length.

Parameters:

Name Type Description Default
base_type BaseType

The BaseType of the field (STRING, BYTE, UINT8, etc.).

required
size int

The declared size of the field in bytes.

required

Returns:

Name Type Description
length int

The field length (number of values, not bytes). For STRING and BYTE types, returns 0 for size 0 and 1 otherwise. For other types, returns size // base_type.size (truncated integer division).

Note

When truncation occurs (size not a multiple of base_type.size), a debug message is logged. This typically indicates a malformed FIT file but allows processing to continue.

Examples:

>>> # Normal case: 8 bytes for UINT32 (4 bytes each) = length 2
>>> _lenient_get_length_from_size(BaseType.UINT32, 8)
2
>>> # Malformed case: 7 bytes for UINT32 = length 1 (truncated)
>>> _lenient_get_length_from_size(BaseType.UINT32, 7)
1
Source code in fit_file_faker/utils.py
def _lenient_get_length_from_size(base_type: "BaseType", size: int) -> int:
    """Lenient field length calculator that truncates instead of raising exceptions.

    This is a replacement for `fit_tool`'s `Field.get_length_from_size` that handles
    malformed FIT files more gracefully. Some manufacturers (e.g., COROS) create
    FIT files where field sizes are not exact multiples of their base type size.
    Instead of failing with an exception, this function truncates to the nearest
    valid length.

    Args:
        base_type: The `BaseType` of the field (`STRING`, `BYTE`, `UINT8`, etc.).
        size: The declared size of the field in bytes.

    Returns:
        length: The field length (number of values, not bytes). For `STRING` and `BYTE`
            types, returns 0 for size 0 and 1 otherwise. For other types, returns
            `size // base_type.size` (truncated integer division).

    Note:
        When truncation occurs (size not a multiple of `base_type.size`), a debug
        message is logged. This typically indicates a malformed FIT file but
        allows processing to continue.

    Examples:
        >>> # Normal case: 8 bytes for UINT32 (4 bytes each) = length 2
        >>> _lenient_get_length_from_size(BaseType.UINT32, 8)
        2

        >>> # Malformed case: 7 bytes for UINT32 = length 1 (truncated)
        >>> _lenient_get_length_from_size(BaseType.UINT32, 7)
        1
    """
    if base_type == BaseType.STRING or base_type == BaseType.BYTE:
        return 0 if size == 0 else 1
    else:
        length = size // base_type.size

        if length * base_type.size != size:
            _logger.debug(
                f"Field size ({size}) not multiple of type size ({base_type.size}), "
                f"truncating to length {length}"
            )
            return length

        return length

_lenient_read_strings_from_bytes

_lenient_read_strings_from_bytes(self, bytes_buffer: bytes)

Lenient string decoder that handles non-UTF-8 encoded strings.

This is a replacement for fit_tool's Field.read_strings_from_bytes that handles FIT files with non-UTF-8 encoded strings more gracefully. Some devices use Windows-1252, Latin-1, or other encodings instead of UTF-8.

The function tries multiple decoding strategies: 1. UTF-8 (standard) 2. Latin-1 / ISO-8859-1 (fallback) 3. Replace invalid bytes with � (last resort)

Parameters:

Name Type Description Default
bytes_buffer bytes

Raw bytes containing null-terminated strings.

required
Note

When non-UTF-8 encoding is detected, a debug message is logged. This allows processing to continue even with malformed string data.

Source code in fit_file_faker/utils.py
def _lenient_read_strings_from_bytes(self, bytes_buffer: bytes):
    """Lenient string decoder that handles non-UTF-8 encoded strings.

    This is a replacement for `fit_tool`'s `Field.read_strings_from_bytes` that
    handles FIT files with non-UTF-8 encoded strings more gracefully. Some devices
    use Windows-1252, Latin-1, or other encodings instead of UTF-8.

    The function tries multiple decoding strategies:
    1. UTF-8 (standard)
    2. Latin-1 / ISO-8859-1 (fallback)
    3. Replace invalid bytes with � (last resort)

    Args:
        bytes_buffer: Raw bytes containing null-terminated strings.

    Note:
        When non-UTF-8 encoding is detected, a debug message is logged.
        This allows processing to continue even with malformed string data.
    """
    # Try UTF-8 first (standard FIT specification)
    try:
        string_container = bytes_buffer.decode("utf-8")
    except UnicodeDecodeError:
        # Try Latin-1 (ISO-8859-1) which accepts all byte values
        # This is common for devices that don't properly encode UTF-8
        try:
            _logger.debug("Failed to decode string as UTF-8, trying Latin-1 encoding")
            string_container = bytes_buffer.decode("latin-1")
        except Exception:
            # Last resort: replace invalid bytes
            _logger.warning(
                "Failed to decode string with UTF-8 and Latin-1, using replacement characters"
            )
            string_container = bytes_buffer.decode("utf-8", errors="replace")

    strings = string_container.split("\u0000")
    strings = strings[:-1]
    strings = [x for x in strings if x]
    self.encoded_values = []
    self.encoded_values.extend(strings)

apply_fit_tool_patch

apply_fit_tool_patch()

Apply monkey patch to fit_tool to handle malformed FIT files.

Replaces fit_tool's Field.get_length_from_size method with a more lenient version that truncates field lengths instead of raising exceptions when field sizes aren't exact multiples of their base type size.

Also replaces Field.read_strings_from_bytes to handle non-UTF-8 encoded strings gracefully by falling back to Latin-1 or replacement characters.

This patch is essential for processing FIT files from manufacturers like COROS that don't strictly follow the FIT specification. Without it, fit_tool would raise exceptions and refuse to process these files.

The patch is automatically applied when the fit_editor module is imported, so users don't need to call this function manually.

Note

This is a global monkey patch that affects all subsequent fit_tool operations in the same Python process. It's applied once at module import time.

Examples:

>>> # Typically called automatically, but can be invoked manually:
>>> from fit_file_faker.utils import apply_fit_tool_patch
>>> apply_fit_tool_patch()
>>> # Now fit_tool can handle COROS files without errors
Source code in fit_file_faker/utils.py
def apply_fit_tool_patch():
    """Apply monkey patch to `fit_tool` to handle malformed FIT files.

    Replaces `fit_tool`'s `Field.get_length_from_size` method with a more lenient
    version that truncates field lengths instead of raising exceptions when
    field sizes aren't exact multiples of their base type size.

    Also replaces `Field.read_strings_from_bytes` to handle non-UTF-8 encoded
    strings gracefully by falling back to Latin-1 or replacement characters.

    This patch is essential for processing FIT files from manufacturers like
    COROS that don't strictly follow the FIT specification. Without it,
    fit_tool would raise exceptions and refuse to process these files.

    The patch is automatically applied when the fit_editor module is imported,
    so users don't need to call this function manually.

    Note:
        This is a global monkey patch that affects all subsequent `fit_tool`
        operations in the same Python process. It's applied once at module
        import time.

    Examples:
        >>> # Typically called automatically, but can be invoked manually:
        >>> from fit_file_faker.utils import apply_fit_tool_patch
        >>> apply_fit_tool_patch()
        >>> # Now fit_tool can handle COROS files without errors
    """
    Field.get_length_from_size = staticmethod(_lenient_get_length_from_size)
    Field.read_strings_from_bytes = _lenient_read_strings_from_bytes

fit_crc_get16

fit_crc_get16(crc: int, byte: int) -> int

Calculate FIT file CRC-16 checksum for a single byte.

Implements the CRC-16 algorithm used by FIT files. This function processes one byte at a time and should be called repeatedly for each byte in the data to calculate a complete checksum.

The algorithm uses a lookup table and processes the byte in two 4-bit nibbles (lower 4 bits first, then upper 4 bits) to compute the CRC.

Parameters:

Name Type Description Default
crc int

Current CRC value (16-bit unsigned integer). Use 0 for the first byte in the sequence.

required
byte int

Byte value to add to the checksum (8-bit unsigned integer, 0-255).

required

Returns:

Type Description
int

Updated CRC value (16-bit unsigned integer) after processing this byte.

Examples:

>>> # Calculate CRC for a single byte
>>> crc = fit_crc_get16(0, 0x42)
>>> print(f"CRC: {crc:#06x}")
CRC: 0x...
>>>
>>> # Calculate CRC for a byte array
>>> def calculate_fit_crc(data: bytes) -> int:
...     '''Calculate CRC-16 for FIT file data.'''
...     crc = 0
...     for byte in data:
...         crc = fit_crc_get16(crc, byte)
...     return crc
>>>
>>> data = b"\x0e\x10\x43\x08\x28\x06\x00\x00"
>>> checksum = calculate_fit_crc(data)
Note

This is the standard CRC-16 algorithm used in FIT file headers and data records. It's primarily used for validation but is not currently required by fit_file_faker since fit_tool handles CRC calculation automatically.

Source code in fit_file_faker/utils.py
def fit_crc_get16(crc: int, byte: int) -> int:
    """Calculate FIT file CRC-16 checksum for a single byte.

    Implements the CRC-16 algorithm used by FIT files. This function processes
    one byte at a time and should be called repeatedly for each byte in the
    data to calculate a complete checksum.

    The algorithm uses a lookup table and processes the byte in two 4-bit
    nibbles (lower 4 bits first, then upper 4 bits) to compute the CRC.

    Args:
        crc: Current CRC value (16-bit unsigned integer). Use 0 for the
            first byte in the sequence.
        byte: Byte value to add to the checksum (8-bit unsigned integer,
            0-255).

    Returns:
        Updated CRC value (16-bit unsigned integer) after processing this byte.

    Examples:
        >>> # Calculate CRC for a single byte
        >>> crc = fit_crc_get16(0, 0x42)
        >>> print(f"CRC: {crc:#06x}")
        CRC: 0x...
        >>>
        >>> # Calculate CRC for a byte array
        >>> def calculate_fit_crc(data: bytes) -> int:
        ...     '''Calculate CRC-16 for FIT file data.'''
        ...     crc = 0
        ...     for byte in data:
        ...         crc = fit_crc_get16(crc, byte)
        ...     return crc
        >>>
        >>> data = b"\\x0e\\x10\\x43\\x08\\x28\\x06\\x00\\x00"
        >>> checksum = calculate_fit_crc(data)

    Note:
        This is the standard CRC-16 algorithm used in FIT file headers
        and data records. It's primarily used for validation but is not
        currently required by `fit_file_faker` since `fit_tool` handles CRC
        calculation automatically.
    """
    crc_table = [
        0x0000,
        0xCC01,
        0xD801,
        0x1400,
        0xF001,
        0x3C00,
        0x2800,
        0xE401,
        0xA001,
        0x6C00,
        0x7800,
        0xB401,
        0x5000,
        0x9C01,
        0x8801,
        0x4400,
    ]

    # Compute checksum of lower four bits of byte
    tmp = crc_table[crc & 0xF]
    crc = (crc >> 4) & 0x0FFF
    crc = crc ^ tmp ^ crc_table[byte & 0xF]

    # Now compute checksum of upper four bits of byte
    tmp = crc_table[crc & 0xF]
    crc = (crc >> 4) & 0x0FFF
    crc = crc ^ tmp ^ crc_table[(byte >> 4) & 0xF]

    return crc