outdooractive/gis-tools-fit
FIT (Flexible and Interoperable Data Transfer) file read and write support for Swift, built on top of gis-tools. Parses Garmin FIT activity files into typed FeatureCollection objects and writes them back to binary FIT, pure
Features
- Pure Swift FIT decoder and encoder — no external dependencies
- Reads and writes FIT 2.0 files with CRC validation
- Record points →
Feature<MultiLineString>with per-point sensor data as parallel arrays - Lap and Event messages → their own
Featuremarkers with full metadata - 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 - Content-preserving round-trip: read → write reproduces every message and field
- Enum name mapping: turn raw enum values (e.g.
event: 9) into names (e.g."lap") fitPointFeatures()→ expand track into individualPointfeatures with each record's sensor data- Time-window, distance-window, and fractional-window slicing of point data
Requirements
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.
Installation with Swift Package Manager
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"),
]),
]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 init:
guard let fc = FeatureCollection(fit: url) else { return }Writing
// Encode a FeatureCollection back to binary FIT data
let data = try FITCoder.write(from: fc)
// Or write directly to a file
try FITCoder.write(from: fc, to: url)
// Or via the FeatureCollection convenience method
try fc.writeFIT(to: url)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, and activity metadata are written back into the corresponding FIT messages. Round-trip read → write → read preserves coordinates, heart rate, cadence, power, speed, temperature, altitude, and timestamps.
The round-trip is content-preserving: every message (file_id, activity, session, lap, record, event, device_info) and every field is written back, so the re-encoded file decodes to the same values as the original.
Track geometry
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 sensor data (parallel arrays)
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)")
}
}Point feature conversion
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"] // ISO8601 date stringTime-based slicing
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")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
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.
FeatureCollection convenience properties
// 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. "running" (mapped from the raw sport value)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.
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.speed (converted) | | fitTemperatures | [Double?]? | record.temperature | | fitAltitudes | [Double?]? | record.altitude (converted) | | fitTimestamps | [Date?]? | record.timestamp |
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? | 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) |
FeatureCollection metadata reference
| 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(). |
Limitations
- 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
Contributing
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