Skip to content

About

Wrapper to Garmin Fit C-SDK

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

8 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

FitUtils.jl

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.

Architecture

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, Profile

Each 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.

Module structure

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

How It Works

Layer 2 — LibFit (ccall bindings)

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_H is required at both compile and binding-generation time. Without it, uint32_t resolves to Clong (8 bytes on 64-bit) instead of UInt32 (4 bytes), and every struct gets the wrong layout.
  • FIT_CONVERT_MULTI_THREAD is enabled — every FitConvert_* function takes a Ptr{FIT_CONVERT_STATE} as its first argument. This gives FitCore ownership of the state struct, enabling direct access to convert_table, dev_data_sizes, has_dev_data, and data_offset.
  • FIT_LOCAL_MESGS must not exceed 16 (hard SDK limit — the record header's local message type is masked with 0x0F).

Layer 3a — FitCore (protocol-level Julia)

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:

  1. named_fields — resolved via Profile.jl, keyed by field name (e.g. :heart_rate), scale/offset applied
  2. raw_fields — no Profile.jl entry, keyed by numeric field_def_num, raw value, no transform
  3. dev_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:

  1. 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).

  2. Iteration with recovery: During iteration, the C SDK's data_offset field 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 current data_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 fields
  • write_raw_message!(stream, mesg_num, field_num, raw_value) — raw fields
  • write_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.

Layer 3b — FitUtils (high-level toolkit)

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, SCIENTIFIC presets. Per-quantity (speed, distance, altitude, temperature, etc.), not all-or-nothing. Applied at to_dataframe/convert_records time, 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_timestamp fields. 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_csv without DataFrames dependency.
  • Write helpers: create_activity, write_activity, copy_fit.
  • Enum/manufacturer resolution: Generated lookup tables from Profile.xlsx. sport=2 becomes :cycling, manufacturer=1 becomes :garmin, product=20124 becomes "Edge 830".

Core types

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 summary

FitRecord 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.

Quick Start

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)
end

Building

Read BUILD.md for instructions to generate and build the library when new Garmin SDK is released or a new Profile.xslx is released.

About

Wrapper to Garmin Fit C-SDK

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages