Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
304 changes: 28 additions & 276 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,19 @@ Command line interface
=======================

```console
usage: uvx fit-tool [-h] [-v] [-o OUTPUT] [-l LOG] [-t TYPE] FILE
usage: fit-tool [-h] [-v] [-o OUTPUT] [-l LOG] [-t {csv,fit}] FILE

Tool for managing FIT files.

positional arguments:
FILE FIT file to process

optional arguments:
options:
-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.
-o, --output OUTPUT Output filename.
-l, --log LOG Log filename.
-t, --type {csv,fit} Output format type. Options: csv, fit.
```

### Convert file to CSV
Expand All @@ -57,290 +56,43 @@ fit-tool oldstage.fit
Library Usage
=======================

### Reading a FIT file

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.
### Minimal read/convert example

```python
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()
```
from pathlib import Path

### Reading a FIT file and plotting some data
```python
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)')
root = Path.cwd()
in_file = root / "fit_tool" / "tests" / "data" / "sdk" / "Activity.fit"
out_file = root / "fit_tool" / "tests" / "out" / "Activity.csv"
out_file.parent.mkdir(parents=True, exist_ok=True)

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()
fit_file = FitFile.from_file(str(in_file))
fit_file.to_csv(str(out_file))
```

### Writing a Workout

```python
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)
### Runnable examples in this repository

fit_file = builder.build()
These examples are synchronized with the current codebase and are runnable from the repository root:

out_path = '../tests/out/tempo_bike_workout.fit'
fit_file.to_file(out_path)


if __name__ == "__main__":
main()
```bash
uv run python fit_tool/examples/read_activity_example.py
uv run python fit_tool/examples/modify_activity_example.py
uv run python fit_tool/examples/write_workout_example.py
```

### Writing a Course

```python
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)
Output files are written to `fit_tool/tests/out/`.

distance = 0.0
timestamp = start_timestamp
### Optional examples that require extra packages

course_records = [] # track points
`write_activity_example.py` and `write_course_example.py` depend on `gpxpy` and `geopy`.
The plotting workflow depends on `numpy` and `matplotlib`.

prev_coordinate = None
Install extras first, then run:

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()
```bash
uv add gpxpy geopy numpy matplotlib
uv run python fit_tool/examples/write_activity_example.py
uv run python fit_tool/examples/write_course_example.py
```
2 changes: 1 addition & 1 deletion fit_tool/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
PROTOCOL_VERSION = '2.4'
SDK_VERSION = '21.188.0'
SDK_VERSION = '21.194.0'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

This version update requires the corresponding profile file Profile_21.194.0.xlsx to be present in the fit_tool/gen/ directory. Without this file, the profile loading mechanism in fit_tool/gen/profile.py and the test in fit_tool/tests/test_profile.py will fail. Please add the required file to this pull request to ensure the project remains buildable.

FIT_DATA_TYPE = b'.FIT'
27 changes: 20 additions & 7 deletions fit_tool/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
from fit_tool.utils.logging import logger


SUPPORTED_FORMATS = {"csv", "fit"}


def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
Expand All @@ -30,12 +33,28 @@ def parse_args():
parser.add_argument("-l", "--log", help="Log filename.")
parser.add_argument(
"-t", "--type",
choices=sorted(SUPPORTED_FORMATS),
help="Output format type. Options: csv, fit."
)

return parser.parse_args()


def resolve_format_type(args) -> str:
if args.type:
return args.type.lower()

if args.output:
_, out_ext = os.path.splitext(os.path.basename(args.output))
inferred_type = out_ext.lstrip('.').lower()
if inferred_type:
if inferred_type not in SUPPORTED_FORMATS:
raise ValueError(f'Unsupported output format "{inferred_type}". Supported formats: csv, fit.')
return inferred_type

return 'csv'


def main():
"""Main entry point."""
args = parse_args()
Expand All @@ -56,13 +75,7 @@ def main():

fit_file = FitFile.from_file(args.fitfile)

if args.type:
format_type = args.type
elif args.output:
_, out_ext = os.path.splitext(os.path.basename(args.output))
format_type = out_ext.lstrip('.')
else:
format_type = 'csv'
format_type = resolve_format_type(args)

basename_noext, _ = os.path.splitext(os.path.basename(args.fitfile))
output_filename = args.output or f'{basename_noext}.{format_type}'
Expand Down
Loading
Loading