Packages

A high-performance Elixir library for decoding Garmin .fit files using a C++ NIF.

Current section

Files

Jump to
fit_decoder lib fit_decoder.ex
Raw

lib/fit_decoder.ex

defmodule FitDecoder do
@moduledoc """
A wrapper for the C++ FIT file decoder NIF.
This module provides functionality to decode FIT (Flexible and Interoperable Data Transfer) files,
which are commonly used by fitness devices and applications to store workout and activity data.
## Examples
# Decode an empty binary (returns empty list)
iex> FitDecoder.decode_fit_file(<<>>)
[]
# Decode invalid binary data (returns error atom)
iex> FitDecoder.decode_fit_file(<<1, 2, 3, 4>>)
:error_integrity_check_failed
# Decode a valid FIT file binary would return a list of maps:
# [%{timestamp: 1096265262, distance: 0.0, heart_rate: 95}, ...]
"""
# Define the module that contains the NIF functions.
# This MUST match the first argument to ERL_NIF_INIT in your C++ code.
defmodule NIF do
@on_load :load_nif
def load_nif do
nif_file = :filename.join(:code.priv_dir(:fit_decoder), ~c"nif")
case :erlang.load_nif(nif_file, 0) do
:ok ->
:ok
{:error, {:load_failed, reason}} ->
IO.puts("Failed to load NIF: #{inspect(reason)}")
IO.puts("NIF file path: #{nif_file}")
{:error, {:load_failed, reason}}
error ->
IO.puts("Unexpected error loading NIF: #{inspect(error)}")
IO.puts("NIF file path: #{nif_file}")
error
end
end
# Define a stub for the NIF.
# This will be replaced by the actual C++ function when the NIF is loaded.
def decode_fit_file(_binary), do: :erlang.nif_error(:nif_not_loaded)
end
@doc """
Decodes a FIT file binary and returns a list of maps, where each map
represents a "Record" message containing sensor data.
## Parameters
* `binary` - A binary containing FIT file data
## Returns
* A list of maps containing decoded record data on success
* An error atom (`:error_integrity_check_failed`, `:error_sdk_exception`) on failure
* An empty list if the file contains no record data
## Record Fields
Each record map may contain the following fields (only present if valid data exists):
* `:timestamp` - Unix timestamp (always present in valid records)
* `:distance` - Distance in meters (float)
* `:heart_rate` - Heart rate in beats per minute (integer)
* `:altitude` - Altitude in meters (float)
## Examples
iex> FitDecoder.decode_fit_file(<<>>)
[]
iex> FitDecoder.decode_fit_file(<<1, 2, 3, 4>>)
:error_integrity_check_failed
"""
def decode_fit_file(binary) when is_binary(binary) do
NIF.decode_fit_file(binary)
end
@doc """
Decodes a FIT file from a file path and returns the same data as `decode_fit_file/1`.
## Parameters
* `file_path` - Path to a FIT file on disk
## Returns
* Same as `decode_fit_file/1` but reads from file path
* `{:error, reason}` if file cannot be read
## Examples
iex> FitDecoder.decode_fit_file_from_path("/nonexistent/file.fit")
{:error, :enoent}
"""
def decode_fit_file_from_path(file_path) when is_binary(file_path) do
case File.read(file_path) do
{:ok, binary} -> decode_fit_file(binary)
{:error, reason} -> {:error, reason}
end
end
@doc """
Gets the activity date from decoded FIT file records.
Returns the date (as a `Date` struct) when the activity started based on the
earliest timestamp in the records.
## Parameters
* `records` - List of decoded FIT records from `decode_fit_file/1`
## Returns
* `{:ok, %Date{}}` - The date when the activity started
* `{:error, :no_records}` - If the records list is empty
* `{:error, :invalid_records}` - If records don't contain valid timestamps
## Examples
iex> records = [%{timestamp: 1745940695}]
iex> FitDecoder.get_activity_date(records)
{:ok, ~D[2025-04-29]}
"""
def get_activity_date(records) when is_list(records) do
case records do
[] ->
{:error, :no_records}
records ->
[first | _rest] = records
case get_record_timestamp(first) do
{:ok, timestamp} ->
date = DateTime.from_unix!(timestamp) |> DateTime.to_date()
{:ok, date}
{:error, _reason} ->
{:error, :no_records}
end
end
end
defp get_record_timestamp(record) when is_map_key(record, :timestamp),
do: {:ok, record.timestamp}
defp get_record_timestamp(_record), do: {:error, :key_not_found}
@doc """
Gets the activity duration from decoded FIT file records.
Returns the duration of the activity in seconds based on the difference
between the earliest and latest timestamps.
## Parameters
* `records` - List of decoded FIT records from `decode_fit_file/1`
## Returns
* `{:ok, duration_seconds}` - Duration in seconds (integer)
* `{:error, :no_records}` - If the records list is empty
* `{:error, :invalid_records}` - If records don't contain valid timestamps
* `{:error, :insufficient_data}` - If there's only one record
## Examples
iex> records = [%{timestamp: 1096265262}, %{timestamp: 1096265322}]
iex> FitDecoder.get_activity_duration(records)
{:ok, 60}
"""
def get_activity_duration(records) when is_list(records) do
case get_longest_continuous_session(records) do
{:ok, {start_timestamp, end_timestamp}} when start_timestamp != end_timestamp ->
{:ok, end_timestamp - start_timestamp}
{:ok, {_start_timestamp, _end_timestamp}} ->
{:error, :insufficient_data}
error ->
error
end
end
@doc """
Gets comprehensive activity information from decoded FIT file records.
This is a convenience function that extracts the most commonly needed
information from an activity in a single call.
## Parameters
* `records` - List of decoded FIT records from `decode_fit_file/1`
## Returns
* `{:ok, activity_info}` - A map containing activity information
* `{:error, reason}` - If records are invalid or empty
The activity_info map contains:
* `:date` - Date when activity started (`Date` struct)
* `:duration_seconds` - Duration in seconds (integer)
* `:total_distance` - Total distance in meters (float, may be nil)
* `:record_count` - Number of data records (integer)
* `:has_heart_rate` - Whether heart rate data is present (boolean)
* `:has_altitude` - Whether altitude data is present (boolean)
## Examples
iex> records = [%{timestamp: 1727321178, distance: 12543.2, heart_rate: 95}, %{timestamp: 1727321238, distance: 12543.2, heart_rate: 98}]
iex> FitDecoder.get_activity_info(records)
{:ok, %{
date: ~D[2024-09-26],
duration_seconds: 60,
total_distance: 12543.2,
record_count: 2,
has_heart_rate: true,
has_altitude: false
}}
"""
def get_activity_info(records) when is_list(records) do
with {:ok, date} <- get_activity_date(records),
{:ok, duration} <- get_activity_duration(records) do
# Get records from the longest continuous session for more accurate stats
session_records =
case get_longest_continuous_session_records(records) do
{:ok, session_records} -> session_records
_ -> records
end
total_distance = get_total_distance(session_records)
activity_info = %{
date: date,
duration_seconds: duration,
total_distance: total_distance,
record_count: length(session_records),
has_heart_rate: has_field?(session_records, :heart_rate),
has_altitude: has_field?(session_records, :altitude)
}
{:ok, activity_info}
end
end
@doc """
Convenience function that combines file reading, decoding, and info extraction.
This function implements the complete workflow: read file → decode → extract info.
## Parameters
* `file_path` - Path to a FIT file on disk
## Returns
* `{:ok, {activity_info, records}}` - Activity info and full record list
* `{:error, reason}` - If file reading, decoding, or parsing fails
* Error atoms from the decoder (e.g., `:error_integrity_check_failed`)
## Examples
iex> FitDecoder.decode_and_analyze("/nonexistent/file.fit")
{:error, :enoent}
"""
def decode_and_analyze(file_path) when is_binary(file_path) do
case decode_fit_file_from_path(file_path) do
records when is_list(records) ->
case get_activity_info(records) do
{:ok, activity_info} -> {:ok, {activity_info, records}}
error -> error
end
error_atom when is_atom(error_atom) ->
error_atom
{:error, reason} ->
{:error, reason}
end
end
# Private helper functions
defp get_longest_continuous_session(records) do
case find_sessions(records) do
[] ->
{:error, :no_records}
sessions ->
longest_session =
sessions
|> Enum.max_by(fn {start_ts, end_ts, _count} -> end_ts - start_ts end)
{start_ts, end_ts, _count} = longest_session
{:ok, {start_ts, end_ts}}
end
end
defp get_longest_continuous_session_records(records) do
case find_sessions(records) do
[] ->
{:error, :no_records}
sessions ->
# Find the session with the longest duration
longest_session =
sessions
|> Enum.max_by(fn {start_ts, end_ts, _count} -> end_ts - start_ts end)
{start_ts, end_ts, _count} = longest_session
# Filter records to only include those in the longest session
session_records =
records
|> Enum.filter(fn record ->
ts = Map.get(record, :timestamp)
is_integer(ts) and ts >= start_ts and ts <= end_ts
end)
{:ok, session_records}
end
end
defp find_sessions(records) do
timestamps =
records
|> Enum.map(&Map.get(&1, :timestamp))
|> Enum.filter(&(is_integer(&1) and &1 > 0))
|> Enum.sort()
case timestamps do
[] ->
[]
[single_timestamp] ->
[{single_timestamp, single_timestamp, 1}]
timestamps ->
# Group timestamps into sessions based on gaps
# Consider a gap of more than 1 hour (3600 seconds) as a session break
gap_threshold = 3600
{sessions, current_session} =
timestamps
|> Enum.reduce({[], {nil, nil, 0}}, fn ts, {sessions, {start_ts, prev_ts, count}} ->
cond do
# First timestamp
start_ts == nil ->
{sessions, {ts, ts, 1}}
# Gap detected - finish current session and start new one
ts - prev_ts > gap_threshold ->
completed_session = {start_ts, prev_ts, count}
{[completed_session | sessions], {ts, ts, 1}}
# Continue current session
true ->
{sessions, {start_ts, ts, count + 1}}
end
end)
# Add the final session
final_sessions = [current_session | sessions]
Enum.reverse(final_sessions)
end
end
defp get_total_distance(records) do
distances =
records
|> Enum.map(&Map.get(&1, :distance))
|> Enum.filter(&(is_float(&1) and &1 >= 0.0))
case distances do
[] -> nil
distances -> Enum.max(distances)
end
end
defp has_field?(records, field_name) do
Enum.any?(records, &Map.has_key?(&1, field_name))
end
end