Julia bindings for Garmin's FIT C SDK — a single consolidated package with three layers, from raw C bindings to a rich activity analysis toolkit.
This package wraps Garmin's official FIT C SDK. It does not reimplement the FIT binary protocol. Protocol correctness (encoding, decoding, CRC, developer fields, message structure) is Garmin's responsibility; this package is a Julia translation.
Layer 1 Shared library libfit.{so, dylib}
Layer 2 LibFit Clang.jl-generated, 1:1 ccall bindings + structs (submodule)
Layer 3a FitCore Idiomatic Julia: typed message iteration, profile metadata,
developer-field resolution, raw fallback for unknown data (submodule)
Layer 3b FitUtils Rich toolkit: activities, DataFrames, unit profiles,
enums, manufacturers, CSV export, GPS, stats (top-level module)
All three layers live in one package. using FitUtils gives you everything.
Power users can access submodules directly:
using FitUtils # everything
using FitUtils.LibFit # raw ccall bindings only
using FitUtils.FitCore # FitStream, FitMessage, FitWriter, ProfileEach layer depends only on the one below it. FitUtils never calls LibFit or the C SDK directly — all protocol-level work goes through FitCore's API.
FitUtils.jl/
├── Project.toml
├── deps/
│ └── build.jl # Pkg.build() entry point — compiles C source
├── build/
│ ├── csrc/ # FitGen-generated C/H source (committed)
│ ├── gen/
│ │ ├── Profile.xlsx # Source of truth (not committed, download on SDK bump)
│ │ └── profile_parse.jl # Shared parser for all XLSX-based generators
│ ├── prep_fit.sh # SDK bump script (x86-64 only)
│ └── build.sh # Standalone compile script
├── src/
│ ├── FitUtils.jl # Top-level module — includes and re-exports
│ ├── LibFit/
│ │ ├── LibFit.jl # Hand-written wrapper, resolves shared library
│ │ ├── libfit_api.jl # GENERATED — Clang.jl ccall bindings
│ │ └── libfit_common.jl # GENERATED — Clang.jl structs/enums
│ ├── FitCore/
│ │ ├── FitCore.jl # Module definition
│ │ ├── fitstream.jl # FitStream iterator, FitMessage, field extraction
│ │ ├── fitwriter.jl # FitWriter — encode-side mirror
│ │ └── profile.jl # GENERATED — message/field metadata from Profile.xlsx
│ ├── activity.jl # FitFile, FitActivity, FitRecord, FitLap, FitSession
│ ├── units.jl # Unit conversion functions + type registry
│ ├── profiles.jl # UnitProfile struct, presets (METRIC/IMPERIAL/SCIENTIFIC)
│ ├── timezone.jl # UTC offset extraction, drift detection, to_localtime
│ ├── enums.jl # GENERATED — enum lookup tables (sport, event_type, etc.)
│ ├── manufacturers.jl # GENERATED — manufacturer/product name resolution
│ ├── inspect.jl # FitSummary, message_counts, field_inventory, show methods
│ ├── stats.jl # get_field, stats, total_ascent, time_gaps, has_*
│ ├── tables.jl # Tables.jl interface for record vectors
│ ├── gps.jl # gps_track, gps_extent, center_point
│ ├── filter.jl # Grouping, filtering, lap/session slicing, fill_forward
│ ├── devfields.jl # dev_field, dev_fields, dev_field_names, has_dev_field
│ ├── stream.jl # each_record, each_message (lazy iteration over FitStream)
│ ├── downsample.jl # sample_records (by count or time interval)
│ ├── concat.jl # concatenate activities/files
│ ├── export.jl # to_csv
│ ├── batch.jl # read_fit_dir, try_parse_activity, is_valid_fit
│ └── write.jl # create_activity, write_activity, copy_fit
├── ext/
│ └── FitUtilsDataFramesExt.jl # to_dataframe() when DataFrames is loaded
├── gen/
│ ├── libfit_gen/ # Clang.jl binding regeneration
│ ├── profile_gen/ # FitCore profile.jl regeneration
│ └── enums_gen/ # enums.jl + manufacturers.jl regeneration
├── test/
│ ├── runtests.jl # Master test runner
│ ├── libfit_test.jl # Layer 2 tests
│ ├── fitcore_test.jl # Layer 3a-i tests (Profile.jl)
│ ├── test_fitstream.jl # Layer 3a-ii tests (FitStream)
│ ├── test_fitwriter.jl # Layer 3a-v tests (FitWriter)
│ └── fitutils_test.jl # Layer 3b tests (100 tests)
└── data/ # Fixture FIT files
Mechanically generated Julia bindings for Garmin's C SDK. Every public C
function has a corresponding ccall wrapper. No hand-written bindings, no
interpretation — just structs and functions.
The C SDK (tag 21.213.0, FULL product) is compiled from FitGen-generated
source into a shared library (libfit.so/.dylib/.dll). The bindings
mirror the C headers exactly, including:
- CRC:
FitCRC_Calc16,FitCRC_Get16,FitCRC_Update16 - Decode:
FitConvert_Init,FitConvert_Read,FitConvert_ReadExt,FitConvert_GetMessageNumber,FitConvert_GetMessageData, etc. - Profile queries:
Fit_GetMesgDef,Fit_GetFieldOffset,Fit_InitMesg,Fit_GetMesgSize, etc.
Key ABI constraints:
-DFIT_USE_STDINT_His required at both compile and binding-generation time. Without it,uint32_tresolves toClong(8 bytes on 64-bit) instead ofUInt32(4 bytes), and every struct gets the wrong layout.FIT_CONVERT_MULTI_THREADis enabled — everyFitConvert_*function takes aPtr{FIT_CONVERT_STATE}as its first argument. This gives FitCore ownership of the state struct, enabling direct access toconvert_table,dev_data_sizes,has_dev_data, anddata_offset.FIT_LOCAL_MESGSmust not exceed 16 (hard SDK limit — the record header's local message type is masked with 0x0F).
An idiomatic but mechanical Julia surface over LibFit. Stateless across messages — every message is translated independently. Three sub-components:
Profile.jl (3a-i): Generated metadata mapping mesg_num to
(name, fields => (name, base_type, scale, offset, units)). Pure data, no
logic. Source: Profile.xlsx from garmin/fit-sdk-tools.
FitStream (3a-ii): Order-preserving iterator over a FIT file's decoded
messages. Each FitMessage separates fields into three independently keyed
channels:
named_fields— resolved via Profile.jl, keyed by field name (e.g.:heart_rate), scale/offset appliedraw_fields— no Profile.jl entry, keyed by numericfield_def_num, raw value, no transformdev_fields— keyed by(developer_data_index, field_def_num), carrying resolved name + raw value
The C SDK drops data it doesn't recognize. Unknown fields in known
messages get no convert_table entry. Unknown messages (mesg_num not in
the SDK's profile) are skipped entirely. In real-world files this is common
— real-world files from Garmin Edge devices can have 17+ unknown message types and
5+ unknown field numbers, totaling 30,000+ dropped messages out of 90,000+.
FitCore solves this with a two-phase approach:
-
Pre-scan (
scan_fit_file): Walks raw FIT bytes to build a definition timeline (one entry per definition message, with full field lists including fields the SDK will drop) and an unknown data records list (one entry per data message whose mesg_num is not in Profile.jl). -
Iteration with recovery: During iteration, the C SDK's
data_offsetfield tells us where the SDK is in the file. For unknown fields, we look up the raw definition from the timeline, compute field offsets, and read values directly from the input bytes. For unknown messages, we check for data records whose byte_pos falls between the previous and currentdata_offset, parse them from raw bytes, and yield them in file order.
FitWriter (3a-v): Encode-side mirror of the decode path. The C SDK has
no encode API — writing is done the way example-sdk/encode.c does it:
construct definition/data records via Fit_GetMesgDef, Fit_GetFieldOffset,
and Fit_InitMesg, with CRCs accumulated via FitCRC_Update16.
Three write paths mirror the three field channels:
write_message!(stream, :record; heart_rate=142, power=210)— named fieldswrite_raw_message!(stream, mesg_num, field_num, raw_value)— raw fieldswrite_dev_field!(stream, mesg_name, dev_data_index, field_def_num, value)— dev fields
Developer field resolution (3a-iv): Developer fields are formally specified
via developer_data_id (mesg 207) and field_description (mesg 206)
messages within the file itself. FitCore resolves them during iteration:
pre-scan records dev field layouts, description messages are collected
during iteration, and values are extracted from raw input bytes. Missing
descriptions fall back to raw bytes; conflicting descriptions throw.
The user-facing layer. Adds everything FitCore deliberately defers:
- Unit conversion: semicircles to degrees, FIT epoch to DateTime, m/s to km/h, etc. Canonical SI in core types; profiles apply at conversion time.
- Unit profiles:
METRIC,IMPERIAL,SCIENTIFICpresets. Per-quantity (speed, distance, altitude, temperature, etc.), not all-or-nothing. Applied atto_dataframe/convert_recordstime, never at parse time. - Activity parsing:
parse_activity()groups messages into typed collections (records, laps, sessions, events, device_infos, hrv). - Tables.jl interface: FitRecord vectors work with any Tables.jl consumer. Optional DataFrames extension via package extension.
- Timestamps: always UTC in core types. UTC offset extracted from
local_timestampfields. Offset drift detected and warned.to_localtime()for explicit conversion. - Stats:
get_field,stats,time_gaps,total_ascent,has_power,has_gps, etc. - GPS:
gps_track,gps_extent,center_point. - Lazy iteration:
each_record(path)yields typed records on demand without allocating a full vector. - Batch processing:
read_fit_dir,try_parse_activity,is_valid_fit. - CSV export:
to_csvwithout DataFrames dependency. - Write helpers:
create_activity,write_activity,copy_fit. - Enum/manufacturer resolution: Generated lookup tables from Profile.xlsx.
sport=2becomes:cycling,manufacturer=1becomes:garmin,product=20124becomes"Edge 830".
FitFile # all messages + derived metadata
FitActivity # grouped records, laps, sessions, events, device_infos, hrv
FitRecord # typed fields (timestamp, heart_rate, power, speed, etc.)
# + extras::NamedTuple for less common fields
# + _dev_fields::Dict{Symbol,Any} for developer fields
FitLap # aggregate lap values (device-computed)
FitSession # aggregate session values
FitSummary # one-line file summaryFitRecord stores canonical SI units (m/s, meters, Celsius, degrees). Unit profiles are values (not global state), applied at conversion time. This avoids the "what mode was this parsed in?" problem — core types always mean the same thing regardless of who calls them.
using FitUtils, Dates
using FitUtils: summary # disambiguate from Base.summary
# One-line file summary
s = summary("data/activity_multisport.fit")
# Parse an activity
act = parse_activity("data/activity_multisport.fit")
# FitActivity: cycling (elliptical), 27693 records, 22 laps, 1 session(s)
# Field statistics
stats(act.records, :power)
# FieldStats(mean=244.16, min=0, max=65535, std=3148.86, count=27593, missing=100)
# Extract a field as a vector
hr = get_field(act.records, :heart_rate)
# Convert to DataFrame (requires DataFrames loaded)
using DataFrames
df = to_dataframe(act)
# Lazy iteration for large files
for r in each_record("data/activity_multisport.fit")
r.heart_rate > 180 && println("Spike at ", r.timestamp)
end
# Low-level: iterate all messages in file order
for msg in FitUtils.FitCore.FitStream("data/activity_multisport.fit")
println(msg.mesg_num, " ", msg.name)
endRead BUILD.md for instructions to generate and build the library when new Garmin SDK is released or a new Profile.xslx is released.