outdooractive/gis-tools-fit
FIT (Flexible and Interoperable Data Transfer) file read and write support
Features
- Pure Swift FIT 2.0 decoder and encoder — no external dependencies beyond
gis-tools
- File CRC validation, optional header CRC validation and optional header CRC
writing
- Correct handling of little- and big-endian definitions, invalid field
sentinels and compressed-timestamp messages
- Record points →
Feature<MultiLineString>with one line string per lap - Per-point sensor arrays: heart rate, cadence, power, enhanced/legacy speed,
temperature, altitude and timestamps
- Every other record field preserved as a
fit_record_<name>array - Lap and Event messages → their own
Featuremarkers with full metadata - Session, Activity, File-ID and Device-Info metadata on the collection
- Multiple session and device-info messages preserved
- Typed convenience API on
FeatureandFeatureCollection - Content-preserving round-trip for supported messages and fields
- Enum name mapping: turn raw enum values (e.g.
event: 9) into names
(e.g. "lap")
fitPointFeatures()expands a track intoPointfeatures with per-point
data
- Time-window, distance-window and fractional-window slicing
- DocC catalog with guides and API reference
Requirements
Swift 6.1 or higher. Compiles on iOS (≥ iOS 15), macOS (≥ macOS 15), tvOS (≥ tvOS 15), watchOS (≥ watchOS 7), Linux, Android and Wasm.
Installation with Swift Package Manager
dependencies: [
.package(
url: "https://github.com/Outdooractive/gis-tools",
from: "3.0.0"),
.package(
url: "https://github.com/Outdooractive/gis-tools-fit",
from: "3.0.0"),
],
targets: [
.target(name: "MyTarget", dependencies: [
.product(name: "GISTools", package: "gis-tools"),
.product(name: "GISToolsFIT", package: "gis-tools-fit"),
]),
]Usage
Reading
import GISTools
import GISToolsFIT
let url = URL(fileURLWithPath: "/path/to/activity.fit")
let fc = try FITCoder.read(from: url)
// Or via the convenience initializer:
guard let fc = FeatureCollection(fit: url) else { return }Read options control CRC validation:
let fc = try FITCoder.read(
from: url,
options: FITReadOptions(verifyCRC: false))Writing
// Encode a FeatureCollection back to binary FIT data
let data = try FITCoder.write(fc)
// Or write directly to a file
try FITCoder.write(fc, to: url)
// Or via the FeatureCollection convenience methods
let encoded = try fc.fitData()
try fc.writeFIT(to: url)The old FITCoder.write(from:) and FITCoder.write(from:to:) spellings are deprecated and remain available as overloads. New code should use FITCoder.write(:) and FITCoder.write(:to:) to match the CSV, GPX and Shapefile coders.
The FeatureCollection must contain a track Feature with a MultiLineString geometry and fit_type == "record", as produced by FITCoder.read(from:). Per-point sensor arrays, session, lap, activity and device metadata are written back into the corresponding FIT messages.
Write options are available for protocol version, profile version and the optional 14-byte header with header CRC:
let data = try FITCoder.write(
fc,
options: FITWriteOptions(includeHeaderCRC: true))Track geometry
The record track is a MultiLineString. Each line string is one lap segment:
let track = fc.features.first { $0.fitType == .record }
let multiLine = track?.geometry as? MultiLineString
print("\(multiLine?.lineStrings.count ?? 0) lap segments")
for (i, seg) in (multiLine?.lineStrings ?? []).enumerated() {
print(" Lap \(i): \(seg.coordinates.count) points")
}Coordinates are always EPSG:4326. Altitude is stored in Coordinate3D.altitude.
Per-point sensor data (parallel arrays)
Per-point arrays are indexed parallel to the flattened MultiLineString coordinates. nil means the field was invalid or absent for that record.
let hr = track?.fitHeartRates // [Int?]? — heart rate per track point
let cad = track?.fitCadences // [Int?]? — cadence per track point
let pw = track?.fitPowers // [Int?]? — power in watts
let spd = track?.fitSpeeds // [Double?]? — m/s, enhanced fallback
let tmp = track?.fitTemperatures // [Double?]? — temperature in °C
let alt = track?.fitAltitudes // [Double?]? — altitude in meters
let ts = track?.fitTimestamps // [Date?]? — timestamps per pointfitSpeeds prefers enhanced_speed and falls back to speed. fitAltitudes reads the Z value of the coordinates; the encoder prefers enhanced_altitude when a record provides it.
Every other record field is preserved as an array under fit_record_<field_name>:
let distances = track?.properties["fit_record_distance"] as? [Sendable?]
let grades = track?.properties["fit_record_grade"] as? [Sendable?]When an activity contains at least one GPS fix, records without a fix are omitted so the arrays stay aligned with the coordinates. Indoor activities with no GPS at all keep every record and use an empty MultiLineString.
Point feature conversion
Convert the multi-point track into individual Point features, each carrying its own sensor data and other record fields:
let pts = track?.fitPointFeatures()
pts?.features.count // e.g. 5283
pts?.features[0].properties["heart_rate"] // 120
pts?.features[0].properties["power"] // 150
pts?.features[0].properties["distance"] // from fit_record_distance
pts?.features[0].properties["timestamp"] // ISO8601 date stringTime-based slicing
import Foundation
let start = FIT.dateFromTimestamp(500100)
let end = FIT.dateFromTimestamp(500200)
let segment = track?.fitPointFeatures(from: start, to: end)
print("\(segment?.features.count ?? 0) points in this window")Distance-based slicing
// Points between 1 km and 5 km along the track
let middle = track?.fitPointFeatures(from: 1000.0, to: 5000.0)
// Last kilometer
let lastKm = track?.fitPointFeatures(from: totalMeters - 1000.0, to: totalMeters)Fractional slicing
// Middle third of the track
let midThird = track?.fitPointFeatures(fraction: 0.33, to: 0.66)Reconstructing a track from point features
let pts = track?.fitPointFeatures()
// Via FeatureCollection:
let rebuilt = pts?.fitTrackFromPointFeatures()
rebuilt?.fitHeartRates?[0] // 120
rebuilt?.fitPowers?[2] // 280
// Via Feature convenience initializer:
let rebuiltViaInitializer = Feature(fitTrackFrom: pts)
rebuiltViaInitializer?.fitCadences?[1] // 90Non-Point features are silently skipped. Reconstruction returns nil if no Point features exist. It produces a single line string, so the original lap split is not restored.
FeatureCollection convenience properties
// Quick access to record features
let records = fc.fitRecords
records.first?.properties["heart_rate"] // 120
// Device and activity metadata
let device = fc.fitDevice // file_id + first device_info
let devices = fc.fitDevices // every device_info message
let sessions = fc.fitSessions // every session message
let activity = fc.fitActivity
print(fc.fitSport) // e.g. "running"fitDevice combines the file-ID fields with the first device-info message. fitDevices preserves every device-info message, including sensor devices. The last session's fields are also merged onto the record track for backwards compatibility.
Enum name mapping
FIT stores enum-typed fields (e.g. event, sport, event_type) as raw integers. Use FIT.name(for:value:) to turn a value into its human-readable name, or fitEnumName(for:) on a Feature to map a property directly:
// Direct lookup
FIT.name(for: "event", value: 9) // "lap"
FIT.name(for: "event_type", value: 1) // "stop"
FIT.name(for: "sport", value: 1) // "running"
FIT.name(for: "manufacturer", value: 1) // "garmin"
// Via a Feature's properties
let event = fc.features.first { $0.fitType == .event }
event.fitEnumName(for: "event") // "timer"
event.fitEnumName(for: "event_type") // "start"Field names that differ from their enum type name (e.g. trigger → session_trigger, sensor_position → body_location) are resolved automatically. The property name type is shared by file_id and activity; file-ID names win when a value is valid in both tables.
Summary data
// Session metadata (avg/max HR, speed, distance, calories)
print(track?.fitAvgHeartRate) // Int? — e.g. 145
print(track?.fitMaxHeartRate) // Int?
print(track?.fitTotalDistance) // Double? — meters
print(track?.fitTotalCalories) // Int? — kcalPer-point array reference
| Property | Type | FIT Field | |---|---|---| | fitHeartRates | [Int?]? | record.heart_rate | | fitCadences | [Int?]? | record.cadence | | fitPowers | [Int?]? | record.power | | fitSpeeds | [Double?]? | record.enhanced_speed, fallback record.speed | | fitTemperatures | [Double?]? | record.temperature | | fitAltitudes | [Double?]? | coordinate Z (encoder: enhanced_altitude preferred) | | fitTimestamps | [Date?]? | record.timestamp | | properties["fit_record_<name>"] | [Sendable?] | every other record field |
Single-value property reference
| Property | Type | Source | |---|---|---| | fitType | FITRecordType? | .record / .lap / .session | | fitHeartRate | Int? | single-record summary | | fitCadence | Int? | single-record summary | | fitPower | Int? | single-record summary | | fitSpeed | Double? | falls back to enhanced_speed | | fitTemperature | Double? | | | fitAltitude | Double? | Point Z, falls back to enhanced_altitude | | fitDistance | Double? | record.distance (converted) | | fitPositionLat / fitPositionLon | CLLocationDegrees? | Point coordinate or position_lat/start_position_lat in degrees | | fitTimestamp / fitStartTime | Date? | ISO 8601 round-trip | | fitAvgHeartRate / fitMaxHeartRate | Int? | session summary | | fitAvgSpeed / fitMaxSpeed | Double? | session summary | | fitTotalDistance | Double? | session total (meters) | | fitTotalElapsedTime | Double? | session total (seconds) | | fitTotalCalories | Int? | session total (kcal) | | fitTotalAscent / fitTotalDescent | Double? | session total (meters) |
FeatureCollection metadata reference
| Property | Type | Source | |---|---|---| | fitRecords | [Feature] | features filtered by fitType == .record | | fitActivity | [String: Sendable]? | activity message | | fitDevice | [String: Sendable]? | file_id + first device_info | | fitDevices | [[String: Sendable]]? | every device_info message | | fitSessions | [[String: Sendable]]? | every session message | | fitSport | String? | session sport/sub-sport mapped to a name |
Methods on FeatureCollection:
| Method | Returns | Description | |---|---|---| | fitData(options:) | Data | Encodes the receiver to binary FIT | | writeFIT(to:options:) | Void | Writes the receiver as a FIT file | | fitTrackFromPointFeatures() | Feature? | Reconstructs a track from Point features produced by fitPointFeatures(). Returns nil if no Point features exist. |
Convenience initializer on Feature:
| Initializer | Description | |---|---| | Feature(fitTrackFrom:) | Creates a track Feature from a FeatureCollection of Point features. Same as fitTrackFromPointFeatures(). |
Errors
All public operations throw FITError:
.fileReadError— the file could not be read.fileWriteError— the encoded data could not be written.parseError— the bytes are not a decodable FIT file.invalidFile— the collection cannot be encoded as FIT
Limitations
- Developer fields: read and skipped correctly, but not exposed through the
typed API and not written back.
- Unknown messages/fields: messages outside the supported set are ignored;
unknown fields are kept as field_<number> strings and are dropped on re-encode.
- GPS gaps: records without a GPS fix are omitted when other records have
one, so the per-point arrays stay aligned with the coordinates.
- Single record track: a collection with several record features encodes
the first one.
- Lap split after point conversion: reconstruction produces one line
string; the original lap boundaries are lost.
compressed_speed_distance: exposed as raw bytes so it round-trips; it is
not unpacked into speed/distance values.
- Large files: all messages are decoded in memory at once; files with
500K+ records may use significant memory.
- No Garmin SDK: this is a clean-room implementation of the FIT protocol;
vendor-specific extensions may not be interpreted.
Documentation
The DocC catalog lives in Sources/GISToolsFIT/GISToolsFIT.docc/ and contains articles for getting started, reading tracks and sensor data, writing and round-trips, and limitations. Build it locally with:
swift build --target GISToolsFIT \
-Xswiftc -emit-symbol-graph \
-Xswiftc -emit-symbol-graph-dir \
-Xswiftc "$PWD/.build/symbol-graphs"
mkdir -p .build/docc-symbols
cp .build/symbol-graphs/GISToolsFIT.symbols.json .build/docc-symbols/
docc convert Sources/GISToolsFIT/GISToolsFIT.docc \
--additional-symbol-graph-dir .build/docc-symbols \
--fallback-display-name GISToolsFIT \
--fallback-bundle-identifier GISToolsFIT \
--output-path .build/docs \
--warnings-as-errorsContributing
Please create an issue or open a pull request with a fix or enhancement.
License
MIT
Package Metadata
Repository: outdooractive/gis-tools-fit
Default branch: main
README: README.md