FitDecoder

FitDecoder is a high-performance Elixir library for decoding Garmin .fit files. It uses a C++ NIF (Native Implemented Function) to wrap the official Garmin FIT C++ SDK, providing a fast and efficient way to extract data from activity files directly within the Elixir ecosystem.

The primary goal of this library is to parse the "Record" messages from a FIT file, which contain moment-by-moment sensor data like GPS coordinates, altitude, heart rate, power, cadence, and many more fields covering everything from basic activity tracking to advanced physiological metrics.

Requirements

To build and use this library, you will need the following installed on your system (macOS or Linux):

  • Elixir (~> 1.15 or newer)
  • Erlang/OTP (~> 25.0 or newer)
  • A C++ compiler (e.g., g++ or clang++)
  • make

Build Instructions

1. Clone the Repository

git clone <your-repo-url>
cd fit_decoder

2. Download the Garmin FIT SDK

  1. Go to the FIT SDK download page and download the latest version
  2. Unzip the downloaded file
  3. Locate the cpp directory inside the unzipped folder
  4. Copy the entire cpp directory into this project's c_src/fit_sdk/ directory

The final path should be c_src/fit_sdk/cpp.

3. Fetch Dependencies and Compile

Run the following commands to fetch the Elixir dependencies and compile the project. This will also compile the C++ NIF.

mix deps.get
mix compile

If the compilation is successful, you are ready to use the library.

Usage

The library provides both low-level decoding functions and high-level helper functions for common workflows. Most application developers will want to use the helper functions for a streamlined experience.

Quick Demo

To quickly see what data is available in your FIT files, use the built-in demo:

# Start IEx
iex -S mix

# Run the demo (will look for test.fit in common locations)
FitDecoder.FieldDemo.run()

# Or specify a specific file
FitDecoder.FieldDemo.run("/path/to/your/activity.fit")

# Just show available fields
FitDecoder.FieldDemo.show_fields("/path/to/your/activity.fit")

# Get statistics for a specific field
FitDecoder.FieldDemo.field_stats(:heart_rate, "/path/to/your/activity.fit")

For most applications, use the helper functions that provide a complete workflow:

# Complete workflow in one function call
case FitDecoder.decode_and_analyze("/path/to/activity.fit") do
  {:ok, {activity_info, records}} ->
    # Get comprehensive activity information
    IO.puts("Date: #{activity_info.date}")
    IO.puts("Duration: #{activity_info.duration_seconds} seconds") 
    IO.puts("Distance: #{activity_info.total_distance} meters")
    IO.puts("Records: #{activity_info.record_count}")
    IO.puts("Has heart rate: #{activity_info.has_heart_rate}")
    
    # Access individual records for detailed analysis
    Enum.each(records, fn record ->
      if Map.has_key?(record, :heart_rate) do
        IO.puts("HR: #{record.heart_rate} bpm")
      end
    end)
    
  {:error, reason} ->
    IO.puts("Failed to process FIT file: #{reason}")
    
  error_atom when is_atom(error_atom) ->
    IO.puts("FIT decoding error: #{error_atom}")
end

Step-by-Step Workflow

If you prefer more control, use the individual helper functions:

# Method 1: From file path
case FitDecoder.decode_fit_file_from_path("/path/to/activity.fit") do
  records when is_list(records) ->
    # Get activity date
    {:ok, date} = FitDecoder.get_activity_date(records)
    IO.puts("Activity date: #{date}")
    
    # Get activity duration
    {:ok, duration} = FitDecoder.get_activity_duration(records)
    IO.puts("Duration: #{duration} seconds")
    
    # Get comprehensive info
    {:ok, info} = FitDecoder.get_activity_info(records)
    IO.inspect(info)
    
  error -> IO.puts("Error: #{error}")
end

# Method 2: From binary data
{:ok, fit_binary} = File.read("/path/to/activity.fit")
records = FitDecoder.decode_fit_file(fit_binary)
{:ok, activity_info} = FitDecoder.get_activity_info(records)

Low-Level Usage

For direct access to the decoder:

# Read and decode manually
{:ok, fit_binary} = File.read("/path/to/activity.fit")
records = FitDecoder.decode_fit_file(fit_binary)

IO.puts("Found #{length(records)} records.")
IO.inspect(hd(records), label: "First Record")

Advanced Usage

Access any of the 96+ available fields:

# Basic activity data
Enum.each(records, fn record ->
  IO.puts("Time: #{record.timestamp}")
  if Map.has_key?(record, :distance), do: IO.puts("Distance: #{record.distance}m")
  if Map.has_key?(record, :heart_rate), do: IO.puts("HR: #{record.heart_rate} bpm")
end)

# Power analysis (cycling)
power_records = Enum.filter(records, &Map.has_key?(&1, :power))
if length(power_records) > 0 do
  avg_power = power_records |> Enum.map(& &1.power) |> Enum.sum() |> div(length(power_records))
  IO.puts("Average power: #{avg_power}W")
end

# GPS tracking
gps_points = records
|> Enum.filter(fn record ->
  Map.has_key?(record, :position_lat) && Map.has_key?(record, :position_long)
end)
|> Enum.map(fn record ->
  %{
    lat: record.position_lat / :math.pow(2, 31) * 180,
    lng: record.position_long / :math.pow(2, 31) * 180,
    altitude: Map.get(record, :altitude),
    timestamp: record.timestamp
  }
end)

# Running dynamics
running_data = records
|> Enum.filter(&Map.has_key?(&1, :vertical_oscillation))
|> Enum.map(fn record ->
  %{
    cadence: Map.get(record, :cadence),
    vertical_oscillation: record.vertical_oscillation,
    stance_time: Map.get(record, :stance_time),
    step_length: Map.get(record, :step_length)
  }
end)

Return Value

The decode_fit_file/1 function returns a list of maps. Each map represents a single "Record" message from the FIT file and can contain any of 96+ available fields depending on your device and activity type.

Core Fields

Always present when valid:

  • :timestamp - (Integer) The FIT timestamp for the data point

Common Fields

  • :altitude - (Float) Altitude in meters
  • :distance - (Float) Total distance traveled in meters
  • :heart_rate - (Integer) Heart rate in beats per minute
  • :speed - (Float) Speed in meters per second
  • :cadence - (Integer) Cadence in RPM
  • :power - (Integer) Power in watts
  • :position_lat - (Integer) Latitude in semicircles
  • :position_long - (Integer) Longitude in semicircles

Advanced Fields

  • Running Dynamics: :vertical_oscillation, :stance_time, :step_length
  • Cycling Metrics: :left_torque_effectiveness, :pedal_smoothness, :left_right_balance
  • Physiological: :respiration_rate, :current_stress, :core_temperature
  • E-bike: :battery_soc, :motor_power, :ebike_assist_mode
  • Diving: :depth, :absolute_pressure, :air_time_remaining
  • Blood/Oxygen: :saturated_hemoglobin_percent, :total_hemoglobin_conc

Example Records

Basic cycling record:

%{
  timestamp: 978318654,
  altitude: 153.2,
  distance: 5.96,
  heart_rate: 111,
  speed: 4.2,
  power: 185,
  cadence: 87
}

Advanced running record:

%{
  timestamp: 978318655,
  distance: 1250.5,
  heart_rate: 145,
  speed: 3.8,
  vertical_oscillation: 8.2,
  stance_time: 245.0,
  step_length: 1.45,
  cadence: 180
}

GPS-enabled record:

%{
  timestamp: 978318656,
  position_lat: 407745893,  # Convert: lat_degrees = value / 2^31 * 180
  position_long: -1221066674,
  altitude: 156.8,
  enhanced_speed: 2.1,
  gps_accuracy: 3
}

See FIELDS.md for complete field documentation.

Field Availability

Not all fields will be present in every FIT file. Field availability depends on:

  1. Device capabilities - Only devices with specific sensors can record certain metrics
  2. Activity type - Swimming fields won't appear in cycling activities
  3. Recording settings - Some fields may be disabled to save battery/storage
  4. FIT file version - Newer fields may not exist in older files

Always check for field presence using Map.has_key?/2 before accessing values.

Supported Activity Types

This decoder supports FIT files from various devices and activities:

  • Cycling: Road, mountain, indoor, e-bike
  • Running: Road, trail, treadmill, track
  • Swimming: Pool, open water
  • Fitness: Gym workouts, strength training
  • Outdoor Activities: Hiking, skiing, diving
  • Multi-sport: Triathlon, adventure racing

Helper Functions

The library provides several helper functions to make common workflows easier:

Core Functions

Example Helper Function Usage

# Get just the activity date
{:ok, records} = FitDecoder.decode_fit_file_from_path("activity.fit")
{:ok, date} = FitDecoder.get_activity_date(records)
# => {:ok, ~D[2024-09-26]}

# Get activity duration
{:ok, duration} = FitDecoder.get_activity_duration(records)  
# => {:ok, 1932}  # seconds

# Get comprehensive activity info
{:ok, info} = FitDecoder.get_activity_info(records)
# => {:ok, %{
#      date: ~D[2024-09-26],
#      duration_seconds: 1932,
#      total_distance: 1985.8,
#      record_count: 387,
#      has_heart_rate: true,
#      has_altitude: false
#    }}

Multi-Session Support

The helper functions automatically detect and handle FIT files containing multiple activity sessions, using the longest continuous session for duration and date calculations.

Installation

If available in Hex, the package can be installed by adding fit_decoder to your list of dependencies in mix.exs:

def deps do
  [
    {:fit_decoder, "~> 0.1.0"}
  ]
end

Documentation

Documentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/fit_decoder.