A Python library for reading and writing Garmin FIT (Flexible and Interoperable Data Transfer) files, enhanced with support for undocumented messages and field types.
- Fork History
- Key Enhancements in This Fork
- Features
- Background
- Installation
- How to Merge and Generate New Profiles
- Command Line Interface
- Library Usage
- Original: stagescycling/python_fit_tool (last updated October 2022)
- Previous Fork: Stuart.Lynne@gmail.com (2024-09-17)
- Current Fork: Enhanced with undocumented messages/types support
This fork includes support for undocumented FIT messages and types that are not part of the official Garmin SDK specification:
Undocumented Messages:
- Message 162 (mesg_162)
- Message 233 (mesg_233)
- Message 288 (mesg_288)
- Message 324 (mesg_324)
- Message 325 (mesg_325)
- Message 327 (mesg_327)
New Message Types Added:
- sport_settings (13)
- data_screen (14)
- alert (16)
- range_alert (17)
- device_used (22)
- location (29)
- map_layer (70)
- routing (71)
- user_metrics (79)
- open_water_event (89)
- device_status (104)
- best_effort (113)
- personal_record (114)
- activity_metrics (140)
- epo_status (141)
- multisport_settings (143)
- multisport_activity (144)
- sensor_settings (147)
- metronome (152)
- connect_iq_field (170)
- clubs (173)
- waypoint_handling (189)
- golf_course (190)
- golf_stats (191)
- score (192)
- hole (193)
- shot (194)
- music_info (243)
- mtb_cx (309)
- race (310)
- split_time (311)
- power_mode (321)
- gps_event (326)
- race_event (358)
- sleep_schedule (379)
- cpe_status (394)
- workout_schedule (428)
New Field Types Added:
- Additional file types: locations, records, multi_sport, clubs, score_card, metrics, sleep, pace_band, calendar, hrv_status, lha_backup, ptd_backup, schedule
- Extended event types: performance_condition_alert
- New sport/activity enums: exercise categories (move, pose, banded_exercises)
- GPS modes and types with multi-band support
- Enhanced navigation and routing types
- Custom field definitions preserved in
Additions.xlsx
- Merge and Generate Script:
merge_and_generate.pycombines official Garmin profiles with custom additions - Automatic Deployment: Generated files are automatically copied to the appropriate directories
- Command-line Support: Flexible profile generation with custom input files
- Error Handling: Robust validation and error reporting during profile generation
- Corrected altitude and enhanced_altitude field scaling in location messages
- Proper handling of semicircle to degrees conversion
- Custom scale/offset values preserved through profile regeneration
- Profile version: 21.171 (latest as of 2025)
- Protocol version: 2.3
- Includes all recent message types and field definitions from Garmin
- Read and Write: Full support for reading and writing FIT files
- Type Safety: Auto-generated message classes with proper typing
- Developer Fields: Support for custom developer-defined fields
- CSV Export: Convert FIT files to human-readable CSV format
- Programmatic Creation: Build FIT files programmatically with
FitFileBuilder - Examples: Comprehensive examples for common use cases
The Flexible and Interoperable Data Transfer (FIT) protocol is designed specifically for the storing and sharing of data that originates from sport, fitness and health devices. The FIT protocol defines a set of data storage templates (FIT messages) that can be used to store information such as user profiles, activity data, courses, and workouts. It is specifically designed to be compact, interoperable and extensible.
git clone <your-repo-url> cd python_fit_tool pip install -e .pip install openpyxl==2.5.12 bitstruct==8.11.1# Install with development dependencies pip install -e ".[dev]"This fork includes an enhanced profile generation system that combines official Garmin FIT profiles with custom additions. Here's how to use it:
cd fit_tool/gen python merge_and_generate.pyThis will:
- Merge
Profile_21.171.xlsx(official Garmin profile) withAdditions.xlsx(custom messages/types) - Generate Python message classes in
./messages/directory - Generate profile types in
profile.py - Automatically copy generated files to
fit_tool/profile/directory
# Use a different Garmin profile version python merge_and_generate.py --profile Profile_22.0.xlsx # Use different additions file python merge_and_generate.py --additions MyCustom.xlsx # Use both custom files python merge_and_generate.py --profile Profile_22.0.xlsx --additions MyCustom.xlsx-
Edit Additions.xlsx: Open
fit_tool/gen/Additions.xlsxin Excel or LibreOffice -
Add Messages (Messages sheet):
- Column A: Message Name (e.g., "my_custom_message")
- Column B: Field Def # (field number within message)
- Column C: Field Name (e.g., "timestamp", "data_value")
- Column D: Field Type (e.g., "date_time", "uint16", "sint32")
- Column E: Array (TRUE/FALSE for array fields)
- Column F: Components (semicolon-separated for complex fields)
- Column G: Scale (scaling factor, e.g., 100)
- Column H: Offset (offset value)
- Column I: Units (e.g., "m", "s", "deg")
- Column Q: Global Message Number (unique message ID)
-
Add Types (Types sheet):
- Column A: Type Name (e.g., "my_custom_enum")
- Column B: Base Type (e.g., "enum", "uint8")
- Column C: Value Name (enum value name)
- Column D: Value (numeric value for enum)
- Column E: Comment (optional description)
-
Regenerate: Run
python merge_and_generate.pyto apply changes
# In Messages sheet: Message Name | Field Def # | Field Name | Field Type | Global Message Number my_sensor_data | 253 | timestamp | date_time | 500 my_sensor_data | 0 | sensor_value | uint16 | 500 my_sensor_data | 1 | sensor_type | enum | 500 # In Types sheet: Type Name | Base Type | Value Name | Value sensor_type | enum | temperature | 0 sensor_type | enum | humidity | 1 sensor_type | enum | pressure | 2 After running the merge script, you'll get:
- Message Classes:
fit_tool/profile/messages/my_sensor_data_message.py - Updated Profile Types: Enhanced
fit_tool/profile/profile_type.pywith new enums - Message Factory: Updated
fit_tool/profile/messages/message_factory.py
The script includes comprehensive validation:
- Checks for duplicate message numbers
- Validates field types against known base types
- Reports missing required columns
- Warns about potential conflicts
- Backup First: Always backup your
Additions.xlsxbefore major changes - Unique Message Numbers: Use message numbers > 400 to avoid conflicts with future Garmin updates
- Test After Generation: Run tests to ensure generated code compiles correctly
- Document Changes: Add comments in the Excel file to document custom additions
usage: fittool [-h] [-v] [-o OUTPUT] [-l LOG] [-t TYPE] FILE Tool for managing FIT files. positional arguments: FILE FIT file to process optional arguments: -h, --help show this help message and exit -v, --verbose specify verbose output -o OUTPUT, --output OUTPUT Output filename. -l LOG, --log LOG Log filename. -t TYPE, --type TYPE Output format type. Options: csv, fit../bin/fittool oldstage.fit The following code reads all the bytes from an activity FIT file and then decodes these bytes to create a FIT file object. We then convert the FIT data to a human-readable CSV file.
from fit_tool.fit_file import FitFile def main(): """ The following code reads all the bytes from a FIT formatted file and then decodes these bytes to create a FIT file object. We then convert the FIT data to a human-readable CSV file. """ path = '../tests/data/sdk/Activity.fit' fit_file = FitFile.from_file(path) out_path = '../tests/data/sdk/Activity.csv' fit_file.to_csv(out_path) if __name__ == "__main__": main()import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np from fit_tool.fit_file import FitFile from fit_tool.profile.messages.record_message import RecordMessage def main(): """ Analyze a FIT file """ mpl.style.use('seaborn') print(f'Loading activity file...') app_fit = FitFile.from_file('./activity_20211102_133232.fit') timestamp1 = [] power1 = [] distance1 = [] speed1 = [] cadence1 = [] for record in app_fit.records: message = record.message if isinstance(message, RecordMessage): timestamp1.append(message.timestamp) distance1.append(message.distance) power1.append(message.power) speed1.append(message.speed) cadence1.append(message.cadence) start_timestamp = timestamp1[0] time1 = np.array(timestamp1) power1 = np.array(power1) speed1 = np.array(speed1) cadence1 = np.array(cadence1) time1 = (time1 - start_timestamp) / 1000.0 # seconds # # Plot the data # ax1 = plt.subplot(311) ax1.plot(time1, power1, '-o', label='app [W]') ax1.legend(loc="upper right") plt.xlabel('Time (s)') plt.ylabel('Power (W)') plt.subplot(312, sharex=ax1) plt.plot(time1, speed1, '-o', label='app [m/s]') plt.legend(loc="upper right") plt.xlabel('Time (s)') plt.ylabel('speed (m/s)') plt.subplot(313, sharex=ax1) plt.plot(time1, cadence1, '-o', label='app [rpm]') plt.legend(loc="upper right") plt.xlabel('Time (s)') plt.ylabel('cadence (rpm)') plt.show() if __name__ == "__main__": main()import datetime from fit_tool.fit_file_builder import FitFileBuilder from fit_tool.profile.messages.file_id_message import FileIdMessage from fit_tool.profile.messages.workout_message import WorkoutMessage from fit_tool.profile.messages.workout_step_message import WorkoutStepMessage from fit_tool.profile.profile_type import Sport, Intensity, WorkoutStepDuration, WorkoutStepTarget, Manufacturer, FileType def main(): file_id_message = FileIdMessage() file_id_message.type = FileType.WORKOUT file_id_message.manufacturer = Manufacturer.DEVELOPMENT.value file_id_message.product = 0 file_id_message.time_created = round(datetime.datetime.now().timestamp() * 1000) file_id_message.serial_number = 0x12345678 workout_steps = [] step = WorkoutStepMessage() step.workout_step_name = 'Warm up 10min in Heart Rate Zone 1' step.intensity = Intensity.WARMUP step.duration_type = WorkoutStepDuration.TIME step.duration_time = 600.0 step.target_type = WorkoutStepTarget.HEART_RATE step.target_hr_zone = 1 workout_steps.append(step) step = WorkoutStepMessage() step.workout_step_name = 'Bike 40min Power Zone 3' step.intensity = Intensity.ACTIVE step.duration_type = WorkoutStepDuration.TIME step.duration_time = 24000.0 step.target_type = WorkoutStepTarget.POWER step.target_power_zone = 3 workout_steps.append(step) step = WorkoutStepMessage() step.workout_step_name = 'Cool Down Until Lap Button Pressed' step.intensity = Intensity.COOLDOWN step.duration_type = WorkoutStepDuration.OPEN step.durationValue = 0 step.target_type = WorkoutStepTarget.OPEN step.target_value = 0 workout_steps.append(step) workout_message = WorkoutMessage() workout_message.workoutName = 'Tempo Bike' workout_message.sport = Sport.CYCLING workout_message.num_valid_steps = len(workout_steps) # We set autoDefine to true, so that the builder creates the required # Definition Messages for us. builder = FitFileBuilder(auto_define=True, min_string_size=50) builder.add(file_id_message) builder.add(workout_message) builder.add_all(workout_steps) fit_file = builder.build() out_path = '../tests/out/tempo_bike_workout.fit' fit_file.to_file(out_path) if __name__ == "__main__": main()import datetime import gpxpy from geopy.distance import geodesic from fit_tool.fit_file_builder import FitFileBuilder from fit_tool.profile.messages.course_message import CourseMessage from fit_tool.profile.messages.course_point_message import CoursePointMessage from fit_tool.profile.messages.event_message import EventMessage from fit_tool.profile.messages.file_id_message import FileIdMessage from fit_tool.profile.messages.lap_message import LapMessage from fit_tool.profile.messages.record_message import RecordMessage from fit_tool.profile.profile_type import FileType, Manufacturer, Sport, Event, EventType, CoursePoint def main(): # Set auto_define to true, so that the builder creates the required Definition Messages for us. builder = FitFileBuilder(auto_define=True, min_string_size=50) # Read position data from a GPX file gpx_file = open('../tests/data/old_stage_left_hand_lee.gpx', 'r') gpx = gpxpy.parse(gpx_file) message = FileIdMessage() message.type = FileType.COURSE message.manufacturer = Manufacturer.DEVELOPMENT.value message.product = 0 message.timeCreated = round(datetime.datetime.now().timestamp() * 1000) message.serialNumber = 0x12345678 builder.add(message) # Every FIT course file MUST contain a Course message message = CourseMessage() message.courseName = 'old stage' message.sport = Sport.CYCLING builder.add(message) # Timer Events are REQUIRED for FIT course files start_timestamp = round(datetime.datetime.now().timestamp() * 1000) message = EventMessage() message.event = Event.TIMER message.event_type = EventType.START message.timestamp = start_timestamp builder.add(message) distance = 0.0 timestamp = start_timestamp course_records = [] # track points prev_coordinate = None for track_point in gpx.tracks[0].segments[0].points: current_coordinate = (track_point.latitude, track_point.longitude) # calculate distance from previous coordinate and accumulate distance if prev_coordinate: delta = geodesic(prev_coordinate, current_coordinate).meters else: delta = 0.0 distance += delta message = RecordMessage() message.position_lat = track_point.latitude message.position_long = track_point.longitude message.distance = distance message.timestamp = timestamp course_records.append(message) timestamp += 10000 prev_coordinate = current_coordinate builder.add_all(course_records) # Add start and end course points (i.e. way points) # message = CoursePointMessage() message.timestamp = course_records[0].timestamp message.position_lat = course_records[0].position_lat message.position_long = course_records[0].position_long message.type = CoursePoint.SEGMENT_START message.course_point_name = 'start' builder.add(message) message = CoursePointMessage() message.timestamp = course_records[-1].timestamp message.position_lat = course_records[-1].position_lat message.position_long = course_records[-1].position_long message.type = CoursePoint.SEGMENT_END message.course_point_name = 'end' builder.add(message) # stop event message = EventMessage() message.event = Event.TIMER message.eventType = EventType.STOP_ALL message.timestamp = timestamp builder.add(message) # Every FIT course file MUST contain a Lap message elapsed_time = timestamp - start_timestamp message = LapMessage() message.timestamp = timestamp message.start_time = start_timestamp message.total_elapsed_time = elapsed_time message.total_timer_time = elapsed_time message.start_position_lat = course_records[0].position_lat message.start_position_long = course_records[0].position_long message.end_position_lat = course_records[-1].position_lat message.endPositionLong = course_records[-1].position_long message.total_distance = course_records[-1].distance # Finally build the FIT file object and write it to a file fit_file = builder.build() out_path = '../tests/out/old_stage_course.fit' fit_file.to_file(out_path) csv_path = '../tests/out/old_stage_course.csv' fit_file.to_csv(csv_path) if __name__ == "__main__": main()