FIT (Flexible and Interoperable Data Transfer) file read support for Swift, built on top of gis-tools. Parses Garmin FIT activity files into typed FeatureCollection objects, pure Swift implementation based on the public FIT protocol specification.
- Pure Swift FIT decoder — no external dependencies
- Reads FIT 2.0 files with CRC validation
- Record points →
Feature<MultiLineString>with per-point sensor data as parallel arrays - Lap boundaries split the track into
MultiLineStringsegments - Session, Lap, Event, File-ID, and Activity messages →
Featureproperties +FeatureCollectionmetadata - Per-point arrays: heart rate, cadence, power, speed, temperature, altitude, timestamps
- Typed convenience API on
FeatureandFeatureCollection— no manual dictionary casting fitPointFeatures()→ expand track into individualPointfeatures with each record's sensor data- Time-window, distance-window, and fractional-window slicing of point data
Swift 6.1 or higher. Compiles on iOS (≥ iOS 15), macOS (≥ macOS 15), tvOS (≥ tvOS 15), watchOS (≥ watchOS 7), Linux, Android and Wasm. No external dependencies beyond the base gis-tools package.
dependencies: [
.package(url: "https://github.com/Outdooractive/gis-tools-fit", from: "1.0.0"),
.package(url: "https://github.com/Outdooractive/gis-tools", from: "2.0.0"),
],
targets: [
.target(name: "MyTarget", dependencies: [
.product(name: "GISToolsFIT", package: "gis-tools-fit"),
.product(name: "GISTools", package: "gis-tools"),
]),
]import GISTools
import GISToolsFIT
let url = URL(fileURLWithPath: "/path/to/activity.fit")
let fc = try FITCoder.read(from: url)
// Or via the convenience init:
guard let fc = FeatureCollection(fit: url) else { return }let track = fc.features.first!
let multiLine = track.geometry as! MultiLineString
print("\(multiLine.lineStrings.count) lap segments")
for (i, seg) in multiLine.lineStrings.enumerated() {
print(" Lap \(i): \(seg.coordinates.count) points")
}
print("\(multiLine.lineStrings.flatMap { $0.coordinates }.count) total points")Per-point arrays are indexed parallel to the flattened MultiLineString coordinates. For Garmin devices, every record contains all sensor fields, so the arrays always align:
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?]? — speed in m/s
let tmp = track.fitTemperatures // [Double?]? — temperature in °C
let alt = track.fitAltitudes // [Double?]? — altitude in meters
let ts = track.fitTimestamps // [Date?]? — timestamps per point
// Index i in the arrays → coordinate[i] in the flattened MultiLineString
// nil means no data recorded for that sensor at that point
for i in 0..<(track.fitHeartRates?.count ?? 0) {
if let hr = hr[i], let cad = cad[i], let pw = pw[i] {
print("Point \(i): HR=\(hr), cad=\(cad), power=\(pw)")
}
}Convert the multi-point track into individual Point features, each carrying its own sensor data:
// All track points as individual Features
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["timestamp"] // 500100 (raw FIT timestamp)import Foundation
let start = FITDateFromTimestamp(500100)
let end = FITDateFromTimestamp(500200)
let segment = track.fitPointFeatures(from: start, to: end)
print("\(segment.features.count) points in this window")// 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)// Middle third of the track
let midThird = track.fitPointFeatures(fraction: 0.33, to: 0.66)Convert a FeatureCollection of Point features (from fitPointFeatures()) back into a Feature<MultiLineString> track with per-point sensor arrays rebuilt:
let pts = track.fitPointFeatures()
// Via FeatureCollection:
let rebuilt = pts.fitTrackFromPointFeatures()
rebuilt?.fitHeartRates?[0] // 120
rebuilt?.fitPowers?[2] // 280
// Via Feature convenience init:
let track = Feature(fitTrackFrom: pts)
track?.fitCadences?[1] // 90Non-Point features are silently skipped. Returns nil if no Point features exist.
// Quick access to record features
let records = fc.fitRecords
records[0].properties["heart_rate"] // 120
// Device and activity metadata
let device = fc.fitDevice
print(device?["manufacturer"]) // e.g. "Garmin"
let activity = fc.fitActivity
print(fc.fitSport) // e.g. "Cycling"// 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? — kcal| Property | Type | FIT Field |
|---|---|---|
fitHeartRates |
[Int?]? |
record.heart_rate |
fitCadences |
[Int?]? |
record.cadence |
fitPowers |
[Int?]? |
record.power |
fitSpeeds |
[Double?]? |
record.speed (converted) |
fitTemperatures |
[Double?]? |
record.temperature |
fitAltitudes |
[Double?]? |
record.altitude (converted) |
fitTimestamps |
[Date?]? |
record.timestamp |
| 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? |
falls back to enhanced_altitude |
fitDistance |
Double? |
record.distance (converted) |
fitPositionLat / fitPositionLon |
CLLocationDegrees? |
converted from semicircles |
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) |
| Property | Type | Source |
|---|---|---|
fitRecords |
[Feature] |
features filtered by fitType == .record |
fitActivity |
[String: Sendable]? |
activity message |
fitDevice |
[String: Sendable]? |
file_id + device_info |
fitSport |
String? |
session sport type |
Methods on FeatureCollection:
| Method | Returns | Description |
|---|---|---|
fitTrackFromPointFeatures() |
Feature? |
Reconstructs a Feature<MultiLineString> from Point features produced by fitPointFeatures(). Sensor data is re-accumulated into per-point arrays. 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(). |
- Read-only: FIT writing is not yet supported
- Compressed timestamps: basic support; time-offset accumulation is minimal
- Developer fields: parsed but not exposed via typed API
- 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; edge cases in vendor-specific extensions may not be handled
Please create an issue or open a pull request with a fix or enhancement.
MIT
Thomas Rasch, Outdooractive
Built on top of gis-tools.