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.py
combines 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.py
This 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.xlsx
in 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.py
to 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.py
with 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.xlsx
before 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()