From 2425c5f42f12eec9efadc21242383246b4410546 Mon Sep 17 00:00:00 2001 From: "Thomas Rasch (OpenCode)" Date: Tue, 11 Aug 2026 19:21:15 +0000 Subject: [PATCH 1/2] Fix FIT round-trip fidelity and add enum name mapping - Fix FITProfile message numbers and field metadata to match the FIT spec - Add missing decoder base types (uint8z, uint16z, uint32z, byte, 64-bit) - Make FIT->GeoJSON->FIT round-trip content-preserving via a generic profile-driven encoder (laps/events/device_info no longer dropped) - Convert timestamps to ISO8601 in GeoJSON and back on encode - Add FIT enum value->name lookup (FIT.name) with field-name aliases - Add sample FIT test fixture and comprehensive decode/round-trip tests --- .gitignore | 1 + Package.swift | 5 +- Scripts/generate_sample_fit.py | 346 +++++++++ Sources/GISToolsFIT/FITCoder.swift | 475 +++++------- Sources/GISToolsFIT/FITDecoder.swift | 79 ++ Sources/GISToolsFIT/FITEncoder.swift | 73 ++ Sources/GISToolsFIT/FITEnum.swift | 279 +++++++ Sources/GISToolsFIT/FITProfile.swift | 734 ++++++++++++------ Sources/GISToolsFIT/FITTypes.swift | 48 +- Sources/GISToolsFIT/Feature+FIT.swift | 41 +- .../GISToolsFIT/FeatureCollection+FIT.swift | 19 +- .../SampleFITDecodeTests.swift | 374 +++++++++ Tests/GISToolsFITTests/TestData/sample.fit | Bin 0 -> 4447 bytes 13 files changed, 1904 insertions(+), 570 deletions(-) create mode 100644 Scripts/generate_sample_fit.py create mode 100644 Sources/GISToolsFIT/FITEnum.swift create mode 100644 Tests/GISToolsFITTests/SampleFITDecodeTests.swift create mode 100644 Tests/GISToolsFITTests/TestData/sample.fit diff --git a/.gitignore b/.gitignore index 52fe2f7..d8c7ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ playground.xcworkspace # .swiftpm .build/ +.build # CocoaPods # diff --git a/Package.swift b/Package.swift index e3f63a8..2d62a41 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,9 @@ let package = Package( ]), .testTarget( name: "GISToolsFITTests", - dependencies: ["GISToolsFIT"]), + dependencies: ["GISToolsFIT"], + resources: [ + .copy("TestData"), + ]), ] ) diff --git a/Scripts/generate_sample_fit.py b/Scripts/generate_sample_fit.py new file mode 100644 index 0000000..3b86f91 --- /dev/null +++ b/Scripts/generate_sample_fit.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +"""Generate a sample FIT activity file using the Garmin FIT Python SDK. + +This writes an activity file and populates every common field of the main +message types (file_id, activity, session, lap, record, event, device_info). +It is a convenient sample to exercise FIT parsers/encoders. + +Usage: + python3 generate_sample_fit.py [output.fit] +""" + +import sys +from datetime import datetime, timedelta + +from garmin_fit_sdk import Encoder + +# Garmin FIT epoch = 1989-12-31 00:00:00 UTC +FIT_EPOCH = datetime(1989, 12, 31) +START = datetime(2024, 6, 1, 7, 30, 0) + + +def dt(value): + """Convert a datetime to a FIT timestamp (seconds since FIT epoch).""" + return int((value - FIT_EPOCH).total_seconds()) + + +def main(output_path="sample.fit"): + encoder = Encoder() + + # ---- File ID (must be the first message) -------------------------------- + encoder.write_mesg({ + "mesg_num": 0, + "type": "activity", + "manufacturer": "garmin", + "garmin_product": "fr255", + "serial_number": 0x0123456789ABCDEF, + "time_created": dt(START), + "number": 1, + "product_name": "Garmin Forerunner 255", + }) + + # ---- Activity ----------------------------------------------------------- + encoder.write_mesg({ + "mesg_num": 34, + "timestamp": dt(START), + "total_timer_time": 3600.0, + "num_sessions": 1, + "type": "manual", + "event": "activity", + "event_type": "stop", + "local_timestamp": dt(START), + "event_group": 1, + }) + + # ---- Session ------------------------------------------------------------ + encoder.write_mesg({ + "mesg_num": 18, + "message_index": 0, + "timestamp": dt(START + timedelta(seconds=3600)), + "event": "session", + "event_type": "stop", + "start_time": dt(START), + "start_position_lat": 510000000, + "start_position_long": 90000000, + "sport": "running", + "sub_sport": "generic", + "total_elapsed_time": 3660.0, + "total_timer_time": 3600.0, + "total_distance": 12000.0, + "total_cycles": 3600, + "total_calories": 800, + "total_fat_calories": 120, + "avg_speed": 3.333, + "max_speed": 5.0, + "avg_heart_rate": 145, + "max_heart_rate": 175, + "avg_cadence": 170, + "avg_running_cadence": 85, + "max_cadence": 92, + "max_running_cadence": 92, + "avg_power": 250, + "max_power": 320, + "total_ascent": 150, + "total_descent": 120, + "total_training_effect": 3.5, + "first_lap_index": 0, + "num_laps": 3, + "event_group": 1, + "trigger": "activity_end", + "nec_lat": 510500000, + "nec_long": 90050000, + "swc_lat": 509500000, + "swc_long": 89950000, + "num_lengths": 0, + "normalized_power": 240, + "training_stress_score": 45.0, + "intensity_factor": 0.8, + "left_right_balance": 5000, + "end_position_lat": 510200000, + "end_position_long": 90010000, + "avg_stroke_count": 0, + "avg_stroke_distance": 0, + "pool_length": 0, + "threshold_power": 280, + "pool_length_unit": "metric", + "total_work": 900000, + "avg_altitude": 100.0, + "max_altitude": 140.0, + "gps_accuracy": 3, + "avg_grade": 0.5, + "avg_pos_grade": 2.0, + "avg_neg_grade": 1.5, + "max_pos_grade": 5.0, + "max_neg_grade": 4.0, + "avg_temperature": 20, + "max_temperature": 23, + "total_moving_time": 3500.0, + "avg_pos_vertical_speed": 0.5, + "avg_neg_vertical_speed": 0.4, + "max_pos_vertical_speed": 2.0, + "max_neg_vertical_speed": 1.8, + "min_heart_rate": 90, + "time_in_hr_zone": [600.0, 900.0, 1200.0, 600.0, 300.0], + "time_in_speed_zone": [1800.0, 1800.0], + "time_in_cadence_zone": [900.0, 2700.0], + "time_in_power_zone": [600.0, 1200.0, 1200.0, 600.0], + "avg_lap_time": 1200.0, + "best_lap_index": 1, + "min_altitude": 60.0, + "active_time": 3600.0, + "enhanced_avg_speed": 3.333, + "enhanced_max_speed": 5.0, + "enhanced_avg_altitude": 100.0, + "enhanced_min_altitude": 60.0, + "enhanced_max_altitude": 140.0, + "avg_vertical_ratio": 6.0, + "avg_stance_time_balance": 49.0, + "avg_step_length": 95.0, + "total_anaerobic_training_effect": 2.0, + "avg_vam": 12.0, + "training_load_peak": 120.0, + "total_grit": 20.0, + "total_flow": 30.0, + "jump_count": 0, + "avg_grit": 5.0, + "avg_flow": 7.5, + "workout_feel": 7, + "workout_rpe": 14, + "avg_spo2": 97, + "avg_stress": 45, + "metabolic_calories": 700, + "sdrr_hrv": 40, + "rmssd_hrv": 55, + "total_fractional_ascent": 0.5, + "total_fractional_descent": 0.25, + "avg_core_temperature": 37.5, + "min_core_temperature": 37.0, + "max_core_temperature": 38.0, + }) + + # ---- Lap (x3) ----------------------------------------------------------- + for lap_index in range(3): + encoder.write_mesg({ + "mesg_num": 19, + "message_index": lap_index, + "timestamp": dt(START + timedelta(seconds=1200 * (lap_index + 1))), + "event": "lap", + "event_type": "stop", + "start_time": dt(START + timedelta(seconds=1200 * lap_index)), + "start_position_lat": 510000000, + "start_position_long": 90000000, + "end_position_lat": 510100000, + "end_position_long": 90005000, + "total_elapsed_time": 1220.0, + "total_timer_time": 1200.0, + "total_distance": 4000.0, + "total_cycles": 1200, + "total_strides": 1200, + "total_calories": 260, + "total_fat_calories": 40, + "avg_speed": 3.333, + "max_speed": 5.0, + "avg_heart_rate": 144, + "max_heart_rate": 175, + "avg_cadence": 170, + "avg_running_cadence": 85, + "max_cadence": 92, + "max_running_cadence": 92, + "avg_power": 248, + "max_power": 320, + "total_ascent": 50, + "total_descent": 40, + "intensity": "active", + "lap_trigger": "distance", + "sport": "running", + "event_group": 1, + "normalized_power": 240, + "left_right_balance": 5000, + "avg_stroke_distance": 0, + "sub_sport": "generic", + "total_work": 300000, + "avg_altitude": 100.0, + "max_altitude": 120.0, + "gps_accuracy": 3, + "avg_grade": 0.5, + "avg_pos_grade": 2.0, + "avg_neg_grade": 1.5, + "max_pos_grade": 4.0, + "max_neg_grade": 3.0, + "avg_temperature": 20, + "max_temperature": 22, + "total_moving_time": 1180.0, + "avg_pos_vertical_speed": 0.5, + "avg_neg_vertical_speed": 0.4, + "max_pos_vertical_speed": 1.8, + "max_neg_vertical_speed": 1.6, + "time_in_hr_zone": [200.0, 300.0, 400.0, 200.0, 100.0], + "time_in_speed_zone": [600.0, 600.0], + "time_in_cadence_zone": [300.0, 900.0], + "time_in_power_zone": [200.0, 400.0, 400.0, 200.0], + "min_altitude": 80.0, + "min_heart_rate": 95, + "active_time": 1200.0, + "avg_vertical_ratio": 6.0, + "avg_stance_time_percent": 25.0, + "avg_stance_time": 220.0, + "avg_fractional_cadence": 0.5, + "max_fractional_cadence": 0.5, + "total_fractional_cycles": 0.5, + "avg_left_torque_effectiveness": 50.0, + "avg_right_torque_effectiveness": 52.0, + "avg_left_pedal_smoothness": 40.0, + "avg_right_pedal_smoothness": 42.0, + "avg_combined_pedal_smoothness": 41.0, + "enhanced_avg_speed": 3.333, + "enhanced_max_speed": 5.0, + "enhanced_avg_altitude": 100.0, + "enhanced_min_altitude": 80.0, + "enhanced_max_altitude": 120.0, + "avg_vertical_ratio": 6.0, + "avg_stance_time_balance": 49.0, + "avg_step_length": 95.0, + "avg_vam": 12.0, + "total_grit": 6.0, + "total_flow": 10.0, + "jump_count": 0, + "avg_grit": 5.0, + "avg_flow": 8.0, + "total_fractional_ascent": 0.2, + "total_fractional_descent": 0.1, + }) + + # ---- Record samples ----------------------------------------------------- + for i in range(30): + seconds = i * 120 + timestamp = START + timedelta(seconds=seconds) + distance = i * 400.0 + encoder.write_mesg({ + "mesg_num": 20, + "timestamp": dt(timestamp), + "position_lat": 510000000 + i * 1000, + "position_long": 90000000 + i * 500, + "altitude": 100.0 + i * 0.5, + "heart_rate": 140 + (i % 30), + "cadence": 170 + (i % 10), + "distance": distance, + "speed": 3.333, + "power": 250 + (i % 50), + "grade": 0.5, + "temperature": 20, + "cycles": 1000, + "total_cycles": i * 4000, + "accumulated_power": i * 12000, + "left_right_balance": 5000, + "gps_accuracy": 3, + "vertical_speed": 0.5, + "calories": 25 + i, + "vertical_oscillation": 6.0, + "stance_time_percent": 25.0, + "stance_time": 220.0, + "activity_type": "running", + "cadence256": 43690, + "fractional_cadence": 0.5, + "device_index": 0, + "enhanced_speed": 3.333, + "enhanced_altitude": 100.0 + i * 0.5, + "vertical_ratio": 6.0, + "stance_time_balance": 49.0, + "step_length": 95.0, + "cycle_length16": 250.0, + "respiration_rate": 20, + "enhanced_respiration_rate": 20.0, + "grit": 5.0, + "flow": 8.0, + "current_stress": 40.0, + "core_temperature": 37.5, + }) + + # ---- Events ------------------------------------------------------------- + encoder.write_mesg({ + "mesg_num": 21, + "timestamp": dt(START), + "event": "timer", + "event_type": "start", + "timer_trigger": "manual", + "event_group": 1, + }) + encoder.write_mesg({ + "mesg_num": 21, + "timestamp": dt(START + timedelta(seconds=3600)), + "event": "timer", + "event_type": "stop_all", + "timer_trigger": "auto", + "event_group": 1, + }) + + # ---- Device Info -------------------------------------------------------- + encoder.write_mesg({ + "mesg_num": 23, + "timestamp": dt(START), + "device_index": 0, + "device_type": 1, + "manufacturer": "garmin", + "serial_number": 0x0123456789ABCDEF, + "garmin_product": "fr255", + "software_version": 12.34, + "hardware_version": 1, + "cum_operating_time": 100000, + "battery_voltage": 4.1, + "battery_status": "good", + "sensor_position": "left_leg", + "descriptor": "Forerunner 255", + "product_name": "Garmin Forerunner 255", + }) + + # ---- Close and write file ---------------------------------------------- + data = encoder.close() + with open(output_path, "wb") as f: + f.write(data) + + print(f"Wrote {len(data)} bytes to {output_path}") + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "sample.fit" + main(path) diff --git a/Sources/GISToolsFIT/FITCoder.swift b/Sources/GISToolsFIT/FITCoder.swift index 0d74315..0448b83 100644 --- a/Sources/GISToolsFIT/FITCoder.swift +++ b/Sources/GISToolsFIT/FITCoder.swift @@ -100,10 +100,12 @@ public enum FITCoder { private static func convertToGeoJSON(_ messages: [FITDecoder.RawMessage]) -> FeatureCollection { var allRecords: [RecordInfo] = [] - var lapStarts: [Int] = [] + var lapMessages: [FITDecoder.RawMessage] = [] + var eventMessages: [FITDecoder.RawMessage] = [] var sessionMeta: [String: Sendable] = [:] var activityMeta: [String: Sendable] = [:] - var deviceMeta: [String: Sendable] = [:] + var fileIdMeta: [String: Sendable] = [:] + var deviceInfoMeta: [String: Sendable] = [:] for msg in messages { let messageType = FITProfile.MesgNum(msg.globalMessageNumber) @@ -119,7 +121,10 @@ public enum FITCoder { fields: msg.fields)) case .lap: - lapStarts.append(allRecords.count) + lapMessages.append(msg) + + case .event: + eventMessages.append(msg) case .session: sessionMeta = applyFields(msg.fields, mesgNum: messageType.rawValue) @@ -128,7 +133,10 @@ public enum FITCoder { activityMeta = applyFields(msg.fields, mesgNum: messageType.rawValue) case .fileId: - deviceMeta = applyFields(msg.fields, mesgNum: messageType.rawValue) + fileIdMeta = applyFields(msg.fields, mesgNum: messageType.rawValue) + + case .deviceInfo: + deviceInfoMeta = applyFields(msg.fields, mesgNum: messageType.rawValue) default: break @@ -146,14 +154,16 @@ public enum FITCoder { ? allRecords.filter { $0.lat != nil && $0.lon != nil } : allRecords - // Build per-point sensor arrays aligned to `trackRecords`. + // Build per-point sensor arrays aligned to `trackRecords`. Every record + // field is captured as a per-point array so records round-trip fully. var heartRates: [Int?] = [] var cadences: [Int?] = [] var powers: [Int?] = [] var speeds: [Double?] = [] var temperatures: [Double?] = [] var altitudes: [Double?] = [] - var timestamps: [UInt32?] = [] + var timestamps: [String?] = [] + var perFieldArrays: [String: [Sendable?]] = [:] for record in trackRecords { let fields = record.fields @@ -165,31 +175,22 @@ public enum FITCoder { else { powers.append(nil) } if let v = fields[6] as? UInt16 { speeds.append(Double(v) / 1000.0) } else { speeds.append(nil) } - if let v = fields[139] as? Int8 { temperatures.append(Double(v)) } + if let v = fields[13] as? Int8 { temperatures.append(Double(v)) } else { temperatures.append(nil) } if let v = fields[2] as? UInt16 { altitudes.append(Double(v) / 5.0 - 500.0) } else { altitudes.append(nil) } - if let v = fields[253] as? UInt32 { timestamps.append(v) } + if let v = fields[253] as? UInt32 { + timestamps.append(FIT.iso8601String(from: FIT.dateFromTimestamp(v))) + } else { timestamps.append(nil) } - } - // Lap starts need to be remapped to indices into `trackRecords` when - // GPS-bearing records have been filtered. - let trackRecordIndices = anyGPS - ? Set(allRecords.indices.filter { allRecords[$0].lat != nil && allRecords[$0].lon != nil }) - : Set(allRecords.indices) - let indexMap: [Int: Int] = { - var map: [Int: Int] = [:] - var mapped = 0 - for i in allRecords.indices { - if trackRecordIndices.contains(i) { - map[i] = mapped - mapped += 1 - } + // Capture every other record field as a per-point array. + let meta = applyFields(fields, mesgNum: FITProfile.MesgNum.record.rawValue) + for (name, value) in meta { + if name == "position_lat" || name == "position_long" || name == "timestamp" { continue } + perFieldArrays[name, default: []].append(value) } - return map - }() - let trackLapStarts: [Int] = lapStarts.compactMap { indexMap[$0] } + } // Build track Feature with MultiLineString (possibly empty for indoor // activities — sensor data is still preserved on the Feature). @@ -198,7 +199,7 @@ public enum FITCoder { let segments: [[Coordinate3D]] = buildSegments( trackRecords, altitudes: altitudes, - lapStarts: trackLapStarts) + lapStarts: []) let lineStrings = segments.map { LineString(unchecked: $0) } let multiLine: MultiLineString if lineStrings.isEmpty { @@ -222,12 +223,44 @@ public enum FITCoder { if temperatures.contains(where: { $0 != nil }) { feature.properties["fit_temperatures"] = temperatures } if timestamps.contains(where: { $0 != nil }) { feature.properties["fit_timestamps"] = timestamps } + // Store every other per-record field as a per-point array. + for (name, array) in perFieldArrays { + if array.contains(where: { $0 != nil }) { + feature.properties["fit_record_\(name)"] = array + } + } + features.append(feature) } - // Merge metadata into foreign members + // Emit each lap as its own Feature carrying its full metadata. The + // marker is placed at the lap's start position when available, falling + // back to the nearest record coordinate. + for lap in lapMessages { + var lapFeature = Feature(Point(markerCoordinate(for: lap, records: allRecords))) + lapFeature.properties["fit_type"] = FITRecordType.lap.rawValue + let meta = applyFields(lap.fields, mesgNum: FITProfile.MesgNum.lap.rawValue) + lapFeature.properties.merge(meta) { (_, new) in new } + features.append(lapFeature) + } + + // Emit each event as its own Feature carrying its full metadata. The + // marker is placed at the record nearest the event's timestamp. + for event in eventMessages { + var eventFeature = Feature(Point(markerCoordinate(for: event, records: allRecords))) + eventFeature.properties["fit_type"] = FITRecordType.event.rawValue + let meta = applyFields(event.fields, mesgNum: FITProfile.MesgNum.event.rawValue) + eventFeature.properties.merge(meta) { (_, new) in new } + features.append(eventFeature) + } + + // Merge metadata into foreign members. `fit_device` combines the + // file_id and device_info messages; `fit_activity` carries the + // activity message. var foreign: [String: Sendable] = [:] if !activityMeta.isEmpty { foreign["fit_activity"] = activityMeta as Sendable } + var deviceMeta = fileIdMeta + deviceMeta.merge(deviceInfoMeta) { (_, new) in new } if !deviceMeta.isEmpty { foreign["fit_device"] = deviceMeta as Sendable } // Build result (features is private(set), rebuild through init) @@ -238,6 +271,55 @@ public enum FITCoder { // MARK: - Segment building + /// Determines a valid coordinate for a marker Feature (lap/event). + /// + /// - For a lap, the lap's own `start_position_lat`/`start_position_long` + /// fields are used when present. + /// - Otherwise the record whose timestamp is closest to the marker's + /// timestamp is used, so the marker sits on the track. + /// - Falls back to the first GPS-bearing record. + private static func markerCoordinate( + for message: FITDecoder.RawMessage, + records: [RecordInfo] + ) -> Coordinate3D { + // Lap start position, if present. + if let lat = message.fields[3] as? Int32, + let lon = message.fields[4] as? Int32, + lat != Int32.max, lon != Int32.max { + return Coordinate3D( + x: FIT.degreesFromSemicircles(lon), + y: FIT.degreesFromSemicircles(lat), + projection: .epsg4326) + } + + // Nearest record by timestamp. + let markerTime = message.fields[253] as? UInt32 + let gpsRecords = records.filter { $0.lat != nil && $0.lon != nil } + if let markerTime, !gpsRecords.isEmpty { + let nearest = gpsRecords.min { a, b in + let ta = a.fields[253] as? UInt32 ?? UInt32.max + let tb = b.fields[253] as? UInt32 ?? UInt32.max + return abs(Int(ta) - Int(markerTime)) < abs(Int(tb) - Int(markerTime)) + } + if let nearest, let lat = nearest.lat, let lon = nearest.lon { + return Coordinate3D( + x: FIT.degreesFromSemicircles(lon), + y: FIT.degreesFromSemicircles(lat), + projection: .epsg4326) + } + } + + // Fall back to the first GPS-bearing record. + if let first = gpsRecords.first, let lat = first.lat, let lon = first.lon { + return Coordinate3D( + x: FIT.degreesFromSemicircles(lon), + y: FIT.degreesFromSemicircles(lat), + projection: .epsg4326) + } + + return Coordinate3D(latitude: 0, longitude: 0) + } + private static func buildSegments( _ points: [RecordInfo], altitudes: [Double?], @@ -304,9 +386,37 @@ public enum FITCoder { if let i16 = value as? Int16 { return Int(i16) } if let i8 = value as? Int8 { return Int(i8) } if let f = value as? Float { return Double(f) } + if let arr = value as? [Any] { return arr.map { asSendable($0) } } return "\(value)" } + // MARK: - Generic message encoding + + /// Reconstructs a FIT message from a dictionary of user-friendly values + /// (as produced by ``applyFields(_:mesgNum:)``) using the FIT Profile + /// metadata. Every field present in the dictionary is written back, so the + /// round-trip preserves the full content of the message. + private static func buildMessage( + mesgNum: UInt16, + from values: [String: Sendable] + ) -> FITEncoder.EncodedMessage { + let fields = FITProfile.fields(for: mesgNum) + var encoded: [FITEncoder.EncodedField] = [] + + for (fieldNum, meta) in fields.sorted(by: { $0.key < $1.key }) { + guard let value = values[meta.name] else { continue } + guard let raw = meta.rawValue(from: value) else { continue } + encoded.append(FITEncoder.EncodedField( + num: UInt8(fieldNum), + baseType: meta.baseType, + data: FITEncoder.encode(raw: raw, baseType: meta.baseType))) + } + + return FITEncoder.EncodedMessage( + globalMessageNumber: mesgNum, + fields: encoded) + } + // MARK: - Conversion from GeoJSON /// Converts a ``FeatureCollection`` back into encoded FIT messages. @@ -320,7 +430,9 @@ public enum FITCoder { var messages: [FITEncoder.EncodedMessage] = [] // file_id (from device metadata) - messages.append(buildFileIdMessage(from: fc)) + if let device = fc.fitDevice { + messages.append(buildMessage(mesgNum: FITProfile.MesgNum.fileId.rawValue, from: device)) + } // Find the track Feature guard let track = fc.features.first(where: { $0.fitType == .record }), @@ -338,7 +450,7 @@ public enum FITCoder { let powers = track.fitPowers ?? Array(repeating: nil, count: allCoords.count) let speeds = track.fitSpeeds ?? Array(repeating: nil, count: allCoords.count) let temperatures = track.fitTemperatures ?? Array(repeating: nil, count: allCoords.count) - let rawTimestamps = track.properties["fit_timestamps"] as? [UInt32?] ?? Array(repeating: nil, count: allCoords.count) + let rawTimestamps = track.properties["fit_timestamps"] as? [String?] ?? Array(repeating: nil, count: allCoords.count) // Total record count. For GPS-bearing tracks every sensor array is // sized to `allCoords.count`. For indoor activities where the @@ -354,273 +466,78 @@ public enum FITCoder { rawTimestamps.count ].max() ?? 0 - // Lap boundaries: one lap per LineString segment. When the track has - // no GPS coordinates at all (indoor), ensure at least a single lap - // covers all sensor records. - var lapStarts: [Int] = [] - var index = 0 - for lineString in multiLine.lineStrings { - lapStarts.append(index) - index += lineString.coordinates.count - } - if lapStarts.isEmpty, recordCount > 0 { - lapStarts = [0] - } - - // Record messages + // Record messages — reconstruct each record from the per-point arrays. + let recordFields = FITProfile.fields(for: FITProfile.MesgNum.record.rawValue) for i in 0.. 0 { + messages.append(buildMessage(mesgNum: FITProfile.MesgNum.lap.rawValue, from: track.properties)) } // Session message (from track properties) - messages.append(buildSessionMessage(from: track, totalRecords: recordCount)) + messages.append(buildMessage(mesgNum: FITProfile.MesgNum.session.rawValue, from: track.properties)) // Activity message (from foreign members) if let activityMeta = fc.fitActivity { - messages.append(buildActivityMessage(from: activityMeta)) + messages.append(buildMessage(mesgNum: FITProfile.MesgNum.activity.rawValue, from: activityMeta)) } - return messages - } - - // MARK: - Message builders - - /// Builds the `file_id` message (global 0). - private static func buildFileIdMessage( - from fc: FeatureCollection - ) -> FITEncoder.EncodedMessage { - let device = fc.fitDevice ?? [:] - - let fileType: UInt8 = uint8Value(device["type"]) ?? 4 // default: activity - let manufacturer: UInt16 = uint16Value(device["manufacturer"]) ?? 1 - let product: UInt16 = uint16Value(device["product"]) ?? 0 - let serial: UInt32 = uint32Value(device["serial_number"]) ?? 0 - let timeCreated: UInt32 = uint32Value(device["time_created"]) ?? 0 - - return FITEncoder.EncodedMessage( - globalMessageNumber: FITProfile.MesgNum.fileId.rawValue, - fields: [ - .init(num: 0, baseType: .enumType, data: Data([fileType])), - .init(num: 1, baseType: .uint16, data: FITEncoder.le16(manufacturer)), - .init(num: 2, baseType: .uint16, data: FITEncoder.le16(product)), - .init(num: 3, baseType: .uint32z, data: FITEncoder.le32(serial)), - .init(num: 253, baseType: .uint32, data: FITEncoder.le32(timeCreated)), - ]) - } - - /// Builds a `record` message (global 20). - private static func buildRecordMessage( - coordinate: Coordinate3D?, - heartRate: Int?, - cadence: Int?, - power: Int?, - speed: Double?, - temperature: Double?, - timestamp: UInt32? - ) -> FITEncoder.EncodedMessage { - var fields: [FITEncoder.EncodedField] = [] - - if let coordinate { - let lat = FIT.semicirclesFromDegrees(coordinate.latitude) - let lon = FIT.semicirclesFromDegrees(coordinate.longitude) - fields.append(.init(num: 0, baseType: .sint32, data: FITEncoder.le32(lat))) - fields.append(.init(num: 1, baseType: .sint32, data: FITEncoder.le32(lon))) - - // Altitude: convert back to raw uint16 (scaled by 5, offset 500) - if let altitude = coordinate.altitude { - let raw = UInt32((altitude + 500.0) * 5.0) - fields.append(.init( - num: 2, - baseType: .uint16, - data: FITEncoder.le16(UInt16(min(raw, UInt32(UInt16.max)))))) - } + // Event messages — one per event Feature. + for eventFeature in fc.features.filter({ $0.fitType == .event }) { + messages.append(buildMessage(mesgNum: FITProfile.MesgNum.event.rawValue, from: eventFeature.properties)) } - if let heartRate { - fields.append(.init( - num: 3, - baseType: .uint8, - data: Data([UInt8(min(heartRate, Int(UInt8.max)))]))) - } - if let cadence { - fields.append(.init( - num: 4, - baseType: .uint8, - data: Data([UInt8(min(cadence, Int(UInt8.max)))]))) - } - if let speed { - let raw = UInt32(speed * 1000.0) - fields.append(.init( - num: 6, - baseType: .uint16, - data: FITEncoder.le16(UInt16(min(raw, UInt32(UInt16.max)))))) + // Device info message (from foreign members). + if let deviceMeta = fc.fitDevice { + messages.append(buildMessage(mesgNum: FITProfile.MesgNum.deviceInfo.rawValue, from: deviceMeta)) } - if let power { - fields.append(.init( - num: 7, - baseType: .uint16, - data: FITEncoder.le16(UInt16(min(power, Int(UInt16.max)))))) - } - if let temperature { - fields.append(.init( - num: 139, - baseType: .sint8, - data: Data([UInt8(bitPattern: Int8(temperature))]))) - } - if let timestamp { - fields.append(.init( - num: 253, - baseType: .uint32, - data: FITEncoder.le32(timestamp))) - } - - return FITEncoder.EncodedMessage( - globalMessageNumber: FITProfile.MesgNum.record.rawValue, - fields: fields) - } - - /// Builds a `lap` message (global 9). - private static func buildLapMessage( - lapStart: Int, - totalRecords: Int, - track: Feature - ) -> FITEncoder.EncodedMessage { - let startTime: UInt32 = uint32Value(track.properties["start_time"]) ?? 0 - let totalElapsedTime: Double = doubleValue(track.properties["total_elapsed_time"]) ?? 0.0 - let totalTimerTime: Double = doubleValue(track.properties["total_timer_time"]) ?? totalElapsedTime - let totalDistance: Double = doubleValue(track.properties["total_distance"]) ?? 0.0 - let totalCalories: Int = intValue(track.properties["total_calories"]) ?? 0 - let avgSpeed: Double = doubleValue(track.properties["avg_speed"]) ?? 0.0 - let maxSpeed: Double = doubleValue(track.properties["max_speed"]) ?? 0.0 - let avgHeartRate: Int = intValue(track.properties["avg_heart_rate"]) ?? 0 - let maxHeartRate: Int = intValue(track.properties["max_heart_rate"]) ?? 0 - let avgCadence: Int = intValue(track.properties["avg_cadence"]) ?? 0 - let maxCadence: Int = intValue(track.properties["max_cadence"]) ?? 0 - let avgPower: Int = intValue(track.properties["avg_power"]) ?? 0 - let maxPower: Int = intValue(track.properties["max_power"]) ?? 0 - let totalAscent: Double = doubleValue(track.properties["total_ascent"]) ?? 0.0 - let totalDescent: Double = doubleValue(track.properties["total_descent"]) ?? 0.0 - - // event = lap (9), event_type = stop (1) - let timestamp = uint32Value(track.properties["fit_timestamp"]) ?? uint32Value(track.properties["timestamp"]) ?? 0 - - let fields: [FITEncoder.EncodedField] = [ - .init(num: 253, baseType: .uint32, data: FITEncoder.le32(timestamp)), - .init(num: 0, baseType: .uint32, data: FITEncoder.le32(startTime)), - .init(num: 5, baseType: .uint32, data: FITEncoder.le32(UInt32(totalElapsedTime * 1000.0))), - .init(num: 6, baseType: .uint32, data: FITEncoder.le32(UInt32(totalTimerTime * 1000.0))), - .init(num: 7, baseType: .uint32, data: FITEncoder.le32(UInt32(totalDistance * 100.0))), - .init(num: 9, baseType: .uint16, data: FITEncoder.le16(UInt16(min(totalCalories, Int(UInt16.max))))), - .init(num: 11, baseType: .uint16, data: FITEncoder.le16(UInt16(UInt32(avgSpeed * 1000.0)))), - .init(num: 12, baseType: .uint16, data: FITEncoder.le16(UInt16(UInt32(maxSpeed * 1000.0)))), - .init(num: 13, baseType: .uint8, data: Data([UInt8(min(avgHeartRate, Int(UInt8.max)))])), - .init(num: 14, baseType: .uint8, data: Data([UInt8(min(maxHeartRate, Int(UInt8.max)))])), - .init(num: 15, baseType: .uint8, data: Data([UInt8(min(avgCadence, Int(UInt8.max)))])), - .init(num: 16, baseType: .uint8, data: Data([UInt8(min(maxCadence, Int(UInt8.max)))])), - .init(num: 17, baseType: .uint16, data: FITEncoder.le16(UInt16(min(avgPower, Int(UInt16.max))))), - .init(num: 18, baseType: .uint16, data: FITEncoder.le16(UInt16(min(maxPower, Int(UInt16.max))))), - .init(num: 19, baseType: .uint16, data: FITEncoder.le16(UInt16(UInt32((totalAscent + 500.0) * 5.0)))), - .init(num: 20, baseType: .uint16, data: FITEncoder.le16(UInt16(UInt32((totalDescent + 500.0) * 5.0)))), - .init(num: 24, baseType: .enumType, data: Data([9])), // event = lap - .init(num: 25, baseType: .enumType, data: Data([1])), // event_type = stop - ] - - _ = lapStart - _ = totalRecords - - return FITEncoder.EncodedMessage( - globalMessageNumber: FITProfile.MesgNum.lap.rawValue, - fields: fields) - } - - /// Builds a `session` message (global 18). - private static func buildSessionMessage( - from track: Feature, - totalRecords: Int - ) -> FITEncoder.EncodedMessage { - let startTime: UInt32 = uint32Value(track.properties["start_time"]) ?? 0 - let totalElapsedTime: Double = doubleValue(track.properties["total_elapsed_time"]) ?? 0.0 - let totalTimerTime: Double = doubleValue(track.properties["total_timer_time"]) ?? totalElapsedTime - let totalDistance: Double = doubleValue(track.properties["total_distance"]) ?? 0.0 - let totalCalories: Int = intValue(track.properties["total_calories"]) ?? 0 - let avgSpeed: Double = doubleValue(track.properties["avg_speed"]) ?? 0.0 - let maxSpeed: Double = doubleValue(track.properties["max_speed"]) ?? 0.0 - let avgHeartRate: Int = intValue(track.properties["avg_heart_rate"]) ?? 0 - let maxHeartRate: Int = intValue(track.properties["max_heart_rate"]) ?? 0 - let avgCadence: Int = intValue(track.properties["avg_cadence"]) ?? 0 - let maxCadence: Int = intValue(track.properties["max_cadence"]) ?? 0 - let avgPower: Int = intValue(track.properties["avg_power"]) ?? 0 - let maxPower: Int = intValue(track.properties["max_power"]) ?? 0 - let totalAscent: Double = doubleValue(track.properties["total_ascent"]) ?? 0.0 - let totalDescent: Double = doubleValue(track.properties["total_descent"]) ?? 0.0 - let sport: UInt8 = uint8Value(track.properties["sport"]) ?? 0 - let timestamp = uint32Value(track.properties["fit_timestamp"]) ?? uint32Value(track.properties["timestamp"]) ?? 0 - let numLaps: UInt16 = 1 - - _ = totalRecords - - return FITEncoder.EncodedMessage( - globalMessageNumber: FITProfile.MesgNum.session.rawValue, - fields: [ - .init(num: 253, baseType: .uint32, data: FITEncoder.le32(timestamp)), - .init(num: 0, baseType: .uint32, data: FITEncoder.le32(startTime)), - .init(num: 5, baseType: .uint32, data: FITEncoder.le32(UInt32(totalElapsedTime * 1000.0))), - .init(num: 6, baseType: .uint32, data: FITEncoder.le32(UInt32(totalTimerTime * 1000.0))), - .init(num: 7, baseType: .uint32, data: FITEncoder.le32(UInt32(totalDistance * 100.0))), - .init(num: 9, baseType: .uint16, data: FITEncoder.le16(UInt16(min(totalCalories, Int(UInt16.max))))), - .init(num: 11, baseType: .uint16, data: FITEncoder.le16(UInt16(UInt32(avgSpeed * 1000.0)))), - .init(num: 12, baseType: .uint16, data: FITEncoder.le16(UInt16(UInt32(maxSpeed * 1000.0)))), - .init(num: 13, baseType: .uint8, data: Data([UInt8(min(avgHeartRate, Int(UInt8.max)))])), - .init(num: 14, baseType: .uint8, data: Data([UInt8(min(maxHeartRate, Int(UInt8.max)))])), - .init(num: 15, baseType: .uint8, data: Data([UInt8(min(avgCadence, Int(UInt8.max)))])), - .init(num: 16, baseType: .uint8, data: Data([UInt8(min(maxCadence, Int(UInt8.max)))])), - .init(num: 17, baseType: .uint16, data: FITEncoder.le16(UInt16(min(avgPower, Int(UInt16.max))))), - .init(num: 18, baseType: .uint16, data: FITEncoder.le16(UInt16(min(maxPower, Int(UInt16.max))))), - .init(num: 19, baseType: .uint16, data: FITEncoder.le16(UInt16(UInt32(totalAscent * 5.0)))), - .init(num: 20, baseType: .uint16, data: FITEncoder.le16(UInt16(UInt32(totalDescent * 5.0)))), - .init(num: 23, baseType: .enumType, data: Data([sport])), - .init(num: 35, baseType: .uint16, data: FITEncoder.le16(numLaps)), - ]) - } - - /// Builds an `activity` message (global 34). - private static func buildActivityMessage( - from activityMeta: [String: Sendable] - ) -> FITEncoder.EncodedMessage { - let totalTimerTime: Double = doubleValue(activityMeta["total_timer_time"]) ?? 0.0 - let numSessions: UInt16 = uint16Value(activityMeta["num_sessions"]) ?? 1 - let activityType: UInt8 = uint8Value(activityMeta["type"]) ?? 0 - let timestamp = uint32Value(activityMeta["timestamp"]) ?? 0 - let localTimestamp = uint32Value(activityMeta["local_timestamp"]) ?? timestamp - return FITEncoder.EncodedMessage( - globalMessageNumber: FITProfile.MesgNum.activity.rawValue, - fields: [ - .init(num: 253, baseType: .uint32, data: FITEncoder.le32(timestamp)), - .init(num: 0, baseType: .uint32, data: FITEncoder.le32(UInt32(totalTimerTime * 1000.0))), - .init(num: 1, baseType: .uint16, data: FITEncoder.le16(numSessions)), - .init(num: 2, baseType: .enumType, data: Data([activityType])), - .init(num: 5, baseType: .uint32, data: FITEncoder.le32(localTimestamp)), - ]) + return messages } - // MARK: - Property extraction helpers private static func intValue(_ value: Sendable?) -> Int? { guard let value else { return nil } diff --git a/Sources/GISToolsFIT/FITDecoder.swift b/Sources/GISToolsFIT/FITDecoder.swift index 53d9655..a4c9784 100644 --- a/Sources/GISToolsFIT/FITDecoder.swift +++ b/Sources/GISToolsFIT/FITDecoder.swift @@ -16,6 +16,18 @@ enum FITDecoder { /// Decoded field values keyed by field number. var fields: [Int: Any] = [:] + + /// The decoded fields in definition order, each carrying its field + /// number, base type and decoded value. Preserving order and base + /// type allows a lossless re-encode of the message. + var orderedFields: [RawField] = [] + } + + /// A decoded field in definition order: field number, base type and value. + struct RawField { + var num: UInt8 + var baseType: UInt8 + var value: Any } /// A decoded definition message describing the layout of subsequent data messages. @@ -211,6 +223,7 @@ enum FITDecoder { let fieldBytes = try readBytes(Int(fd.size)) let value = decodeField(fieldBytes, baseType: fd.baseType, le: isLE) rawMsg.fields[Int(fd.num)] = value + rawMsg.orderedFields.append(RawField(num: fd.num, baseType: fd.baseType, value: value)) } // Read and skip developer field data @@ -243,6 +256,7 @@ enum FITDecoder { let fieldBytes = try readBytes(Int(fd.size)) let value = decodeField(fieldBytes, baseType: fd.baseType, le: isLE) rawMsg.fields[Int(fd.num)] = value + rawMsg.orderedFields.append(RawField(num: fd.num, baseType: fd.baseType, value: value)) } // Read and skip developer field data @@ -383,6 +397,71 @@ enum FITDecoder { | (UInt64(bytes[safe: 1] ?? 0) << 48) | (UInt64(bytes[safe: 0] ?? 0) << 56) } return Float64(bitPattern: raw) + case 10: // uint8z + return bytes.first ?? 0 + case 11: // uint16z + let val = le + ? UInt16(bytes[safe: 0] ?? 0) | (UInt16(bytes[safe: 1] ?? 0) << 8) + : UInt16(bytes[safe: 1] ?? 0) | (UInt16(bytes[safe: 0] ?? 0) << 8) + return val + case 12: // uint32z + let val: UInt32 + if le { + val = UInt32(bytes[safe: 0] ?? 0) | (UInt32(bytes[safe: 1] ?? 0) << 8) + | (UInt32(bytes[safe: 2] ?? 0) << 16) | (UInt32(bytes[safe: 3] ?? 0) << 24) + } + else { + val = UInt32(bytes[safe: 3] ?? 0) | (UInt32(bytes[safe: 2] ?? 0) << 8) + | (UInt32(bytes[safe: 1] ?? 0) << 16) | (UInt32(bytes[safe: 0] ?? 0) << 24) + } + return val + case 13: // byte + return bytes.first ?? 0 + case 14: // sint64 + let raw: UInt64 + if le { + raw = UInt64(bytes[safe: 0] ?? 0) | (UInt64(bytes[safe: 1] ?? 0) << 8) + | (UInt64(bytes[safe: 2] ?? 0) << 16) | (UInt64(bytes[safe: 3] ?? 0) << 24) + | (UInt64(bytes[safe: 4] ?? 0) << 32) | (UInt64(bytes[safe: 5] ?? 0) << 40) + | (UInt64(bytes[safe: 6] ?? 0) << 48) | (UInt64(bytes[safe: 7] ?? 0) << 56) + } + else { + raw = UInt64(bytes[safe: 7] ?? 0) | (UInt64(bytes[safe: 6] ?? 0) << 8) + | (UInt64(bytes[safe: 5] ?? 0) << 16) | (UInt64(bytes[safe: 4] ?? 0) << 24) + | (UInt64(bytes[safe: 3] ?? 0) << 32) | (UInt64(bytes[safe: 2] ?? 0) << 40) + | (UInt64(bytes[safe: 1] ?? 0) << 48) | (UInt64(bytes[safe: 0] ?? 0) << 56) + } + return Int64(bitPattern: raw) + case 15: // uint64 + let val: UInt64 + if le { + val = UInt64(bytes[safe: 0] ?? 0) | (UInt64(bytes[safe: 1] ?? 0) << 8) + | (UInt64(bytes[safe: 2] ?? 0) << 16) | (UInt64(bytes[safe: 3] ?? 0) << 24) + | (UInt64(bytes[safe: 4] ?? 0) << 32) | (UInt64(bytes[safe: 5] ?? 0) << 40) + | (UInt64(bytes[safe: 6] ?? 0) << 48) | (UInt64(bytes[safe: 7] ?? 0) << 56) + } + else { + val = UInt64(bytes[safe: 7] ?? 0) | (UInt64(bytes[safe: 6] ?? 0) << 8) + | (UInt64(bytes[safe: 5] ?? 0) << 16) | (UInt64(bytes[safe: 4] ?? 0) << 24) + | (UInt64(bytes[safe: 3] ?? 0) << 32) | (UInt64(bytes[safe: 2] ?? 0) << 40) + | (UInt64(bytes[safe: 1] ?? 0) << 48) | (UInt64(bytes[safe: 0] ?? 0) << 56) + } + return val + case 16: // uint64z + let val: UInt64 + if le { + val = UInt64(bytes[safe: 0] ?? 0) | (UInt64(bytes[safe: 1] ?? 0) << 8) + | (UInt64(bytes[safe: 2] ?? 0) << 16) | (UInt64(bytes[safe: 3] ?? 0) << 24) + | (UInt64(bytes[safe: 4] ?? 0) << 32) | (UInt64(bytes[safe: 5] ?? 0) << 40) + | (UInt64(bytes[safe: 6] ?? 0) << 48) | (UInt64(bytes[safe: 7] ?? 0) << 56) + } + else { + val = UInt64(bytes[safe: 7] ?? 0) | (UInt64(bytes[safe: 6] ?? 0) << 8) + | (UInt64(bytes[safe: 5] ?? 0) << 16) | (UInt64(bytes[safe: 4] ?? 0) << 24) + | (UInt64(bytes[safe: 3] ?? 0) << 32) | (UInt64(bytes[safe: 2] ?? 0) << 40) + | (UInt64(bytes[safe: 1] ?? 0) << 48) | (UInt64(bytes[safe: 0] ?? 0) << 56) + } + return val default: return Int(bytes.first ?? 0) } diff --git a/Sources/GISToolsFIT/FITEncoder.swift b/Sources/GISToolsFIT/FITEncoder.swift index d39fdc4..07741b5 100644 --- a/Sources/GISToolsFIT/FITEncoder.swift +++ b/Sources/GISToolsFIT/FITEncoder.swift @@ -36,6 +36,79 @@ enum FITEncoder { /// The fields in definition order. let fields: [EncodedField] + + /// Creates an encoded message from a global message number and fields. + init(globalMessageNumber: UInt16, fields: [EncodedField]) { + self.globalMessageNumber = globalMessageNumber + self.fields = fields + } + } + + // MARK: - String encoding + + static func encodeString(_ s: String) -> Data { + var data = Data(s.utf8) + data.append(0) // null terminator + return data + } + + // MARK: - Raw value encoding + + /// Encodes a raw value (as produced by ``FITProfile/FieldMeta/rawValue(from:)``) + /// into the little-endian bytes for a base type. + static func encode(raw: Any, baseType: FITBaseType) -> Data { + if let arr = raw as? [Any] { + var data = Data() + for element in arr { + data.append(encode(raw: element, baseType: baseType)) + } + return data + } + if let s = raw as? String { + return encodeString(s) + } + if let d = raw as? Double { + return encodeDouble(d, baseType: baseType) + } + if let i = raw as? Int64 { + return encodeInt64(i, baseType: baseType) + } + if let i = raw as? Int { + return encodeInt64(Int64(i), baseType: baseType) + } + return Data() + } + + private static func encodeInt64(_ v: Int64, baseType: FITBaseType) -> Data { + switch baseType { + case .sint8: return Data([UInt8(bitPattern: Int8(truncatingIfNeeded: v))]) + case .sint16: return le16(UInt16(bitPattern: Int16(truncatingIfNeeded: v))) + case .sint32: return le32(Int32(truncatingIfNeeded: v)) + case .sint64: return le64(UInt64(bitPattern: v)) + case .enumType, .uint8, .uint8z, .byte: return Data([UInt8(truncatingIfNeeded: v)]) + case .uint16, .uint16z: return le16(UInt16(truncatingIfNeeded: v)) + case .uint32, .uint32z: return le32(UInt32(truncatingIfNeeded: v)) + case .uint64, .uint64z: return le64(UInt64(truncatingIfNeeded: v)) + case .float32: return le32(Float(v).bitPattern) + case .float64: return le64(Float64(v).bitPattern) + case .string: return encodeString("\(v)") + } + } + + private static func encodeDouble(_ v: Double, baseType: FITBaseType) -> Data { + switch baseType { + case .float32: return le32(Float(v).bitPattern) + case .float64: return le64(Float64(v).bitPattern) + case .sint8: return Data([UInt8(bitPattern: Int8(truncatingIfNeeded: Int64(v)))]) + case .sint16: return le16(UInt16(bitPattern: Int16(truncatingIfNeeded: Int64(v)))) + case .sint32: return le32(Int32(truncatingIfNeeded: Int64(v))) + case .sint64: return le64(UInt64(bitPattern: Int64(v))) + case .enumType, .uint8, .uint8z, .byte: return Data([UInt8(truncatingIfNeeded: Int64(v))]) + case .uint16, .uint16z: return le16(UInt16(truncatingIfNeeded: Int64(v))) + case .uint32, .uint32z: return le32(UInt32(truncatingIfNeeded: Int64(v))) + case .uint64, .uint64z: return le64(UInt64(truncatingIfNeeded: Int64(v))) + case .string: return encodeString("\(v)") + } } // MARK: - Public API diff --git a/Sources/GISToolsFIT/FITEnum.swift b/Sources/GISToolsFIT/FITEnum.swift new file mode 100644 index 0000000..e60605c --- /dev/null +++ b/Sources/GISToolsFIT/FITEnum.swift @@ -0,0 +1,279 @@ +import Foundation + +/// FIT enum value → name lookup. +/// +/// FIT stores enum-typed fields (e.g. `event`, `sport`, `event_type`) as raw +/// integers. These methods provide the value → name lookup so users can turn a +/// decoded value like `event: 9` into `"lap"`. +extension FIT { + + /// Returns the human-readable name for an enum value, or `nil` if the + /// value is not a known member of the given field type. + /// + /// - Parameters: + /// - fieldType: The FIT field type name (e.g. `"event"`, `"sport"`). + /// Field names that differ from their type name (e.g. `"trigger"` for + /// `session_trigger`) are resolved automatically. + /// - value: The raw integer value. + /// - Returns: The name, or `nil` if unknown. + public static func name(for fieldType: String, value: Int) -> String? { + let type = fieldAliases[fieldType] ?? fieldType + guard let table = tables[type] else { return nil } + return table[value] + } + + /// Returns the human-readable name for an enum value, or `nil`. + public static func name(for fieldType: String, value: UInt8) -> String? { + name(for: fieldType, value: Int(value)) + } + + /// Returns the human-readable name for an enum value, or `nil`. + public static func name(for fieldType: String, value: UInt16) -> String? { + name(for: fieldType, value: Int(value)) + } + + /// The value → name tables for the enum field types used by the FIT + /// messages this library decodes. + private static let tables: [String: [Int: String]] = [ + "event": [ + 0: "timer", 3: "workout", 4: "workout_step", 5: "power_down", + 6: "power_up", 7: "off_course", 8: "session", 9: "lap", + 10: "course_point", 11: "battery", 12: "virtual_partner_pace", + 13: "hr_high_alert", 14: "hr_low_alert", 15: "speed_high_alert", + 16: "speed_low_alert", 17: "cad_high_alert", 18: "cad_low_alert", + 19: "power_high_alert", 20: "power_low_alert", 21: "recovery_hr", + 22: "battery_low", 23: "time_duration_alert", + 24: "distance_duration_alert", 25: "calorie_duration_alert", + 26: "activity", 27: "fitness_equipment", 28: "length", + 32: "user_marker", 33: "sport_point", 36: "calibration", + 42: "front_gear_change", 43: "rear_gear_change", + 44: "rider_position_change", 45: "elev_high_alert", + 46: "elev_low_alert", 47: "comm_timeout", 54: "auto_activity_detect", + 56: "dive_alert", 57: "dive_gas_switched", 71: "tank_pressure_reserve", + 72: "tank_pressure_critical", 73: "tank_lost", + 75: "radar_threat_alert", 76: "tank_battery_low", + 81: "tank_pod_connected", 82: "tank_pod_disconnected", + ], + "event_type": [ + 0: "start", 1: "stop", 2: "consecutive_depreciated", 3: "marker", + 4: "stop_all", 5: "begin_depreciated", 6: "end_depreciated", + 7: "end_all_depreciated", 8: "stop_disable", 9: "stop_disable_all", + ], + "sport": [ + 0: "generic", 1: "running", 2: "cycling", 3: "transition", + 4: "fitness_equipment", 5: "swimming", 6: "basketball", + 7: "soccer", 8: "tennis", 9: "american_football", 10: "training", + 11: "walking", 12: "cross_country_skiing", 13: "alpine_skiing", + 14: "snowboarding", 15: "rowing", 16: "mountaineering", + 17: "hiking", 18: "multisport", 19: "paddling", 20: "flying", + 21: "e_biking", 22: "motorcycling", 23: "boating", 24: "driving", + 25: "golf", 26: "hang_gliding", 27: "horseback_riding", + 28: "hunting", 29: "fishing", 30: "inline_skating", + 31: "rock_climbing", 32: "sailing", 33: "ice_skating", + 34: "sky_diving", 35: "snowshoeing", 36: "snowmobiling", + 37: "stand_up_paddleboarding", 38: "surfing", 39: "wakeboarding", + 40: "water_skiing", 41: "kayaking", 42: "rafting", + 43: "windsurfing", 44: "kitesurfing", 45: "tactical", + 46: "jumpmaster", 47: "boxing", 48: "floor_climbing", + 49: "baseball", 53: "diving", 56: "shooting", 58: "winter_sport", + 59: "grinding", 62: "hiit", 63: "video_gaming", 64: "racket", + 65: "wheelchair_push_walk", 66: "wheelchair_push_run", + 67: "meditation", 68: "para_sport", 69: "disc_golf", + 70: "team_sport", 71: "cricket", 72: "rugby", 73: "hockey", + 74: "lacrosse", 75: "volleyball", 76: "water_tubing", + 77: "wakesurfing", 78: "water_sport", 79: "archery", + 80: "mixed_martial_arts", 81: "motor_sports", 82: "snorkeling", + 83: "dance", 84: "jump_rope", 85: "pool_apnea", 86: "mobility", + 87: "geocaching", 88: "canoeing", 254: "all", + ], + "sub_sport": [ + 0: "generic", 1: "treadmill", 2: "street", 3: "trail", 4: "track", + 5: "spin", 6: "indoor_cycling", 7: "road", 8: "mountain", + 9: "downhill", 10: "recumbent", 11: "cyclocross", + 12: "hand_cycling", 13: "track_cycling", 14: "indoor_rowing", + 15: "elliptical", 16: "stair_climbing", 17: "lap_swimming", + 18: "open_water", 19: "flexibility_training", + 20: "strength_training", 21: "warm_up", 22: "match", + 23: "exercise", 24: "challenge", 25: "indoor_skiing", + 26: "cardio_training", 27: "indoor_walking", 28: "e_bike_fitness", + 29: "bmx", 30: "casual_walking", 31: "speed_walking", + 32: "bike_to_run_transition", 33: "run_to_bike_transition", + 34: "swim_to_bike_transition", 35: "atv", 36: "motocross", + 37: "backcountry", 38: "resort", 39: "rc_drone", 40: "wingsuit", + 41: "whitewater", 42: "skate_skiing", 43: "yoga", 44: "pilates", + 45: "indoor_running", 46: "gravel_cycling", 47: "e_bike_mountain", + 48: "commuting", 49: "mixed_surface", 50: "navigate", + 51: "track_me", 52: "map", 53: "single_gas_diving", + 54: "multi_gas_diving", 55: "gauge_diving", 56: "apnea_diving", + 57: "apnea_hunting", 58: "virtual_activity", 59: "obstacle", + 62: "breathing", 63: "ccr_diving", 65: "sail_race", + 66: "expedition", 67: "ultra", 68: "indoor_climbing", + 69: "bouldering", 70: "hiit", 71: "indoor_grinding", + 72: "hunting_with_dogs", 73: "amrap", 74: "emom", 75: "tabata", + 77: "esport", 78: "triathlon", 79: "duathlon", 80: "brick", + 81: "swim_run", 82: "adventure_race", 83: "trucker_workout", + 84: "pickleball", 85: "padel", 86: "indoor_wheelchair_walk", + 87: "indoor_wheelchair_run", 88: "indoor_hand_cycling", + 90: "field", 91: "ice", 92: "ultimate", 93: "platform", + 94: "squash", 95: "badminton", 96: "racquetball", + 97: "table_tennis", 98: "overland", 99: "trolling_motor", + 110: "fly_canopy", 111: "fly_paraglide", 112: "fly_paramotor", + 113: "fly_pressurized", 114: "fly_navigate", 115: "fly_timer", + 116: "fly_altimeter", 117: "fly_wx", 118: "fly_vfr", + 119: "fly_ifr", 121: "dynamic_apnea", 123: "enduro", + 124: "rucking", 125: "rally", 126: "pool_triathlon", + 127: "e_bike_enduro", 254: "all", + ], + "intensity": [ + 0: "active", 1: "rest", 2: "warmup", 3: "cooldown", + 4: "recovery", 5: "interval", 6: "other", + ], + "lap_trigger": [ + 0: "manual", 1: "time", 2: "distance", 3: "position_start", + 4: "position_lap", 5: "position_waypoint", 6: "position_marked", + 7: "session_end", 8: "fitness_equipment", + ], + "session_trigger": [ + 0: "activity_end", 1: "manual", 2: "auto_multi_sport", + 3: "fitness_equipment", + ], + "activity": [0: "manual", 1: "auto_multi_sport"], + "activity_type": [ + 0: "generic", 1: "running", 2: "cycling", 3: "transition", + 4: "fitness_equipment", 5: "swimming", 6: "walking", + 8: "sedentary", 13: "wheelchair_pushing", 254: "all", + ], + "battery_status": [ + 1: "new", 2: "good", 3: "ok", 4: "low", 5: "critical", + 6: "charging", 7: "unknown", + ], + "body_location": [ + 0: "left_leg", 1: "left_calf", 2: "left_shin", + 3: "left_hamstring", 4: "left_quad", 5: "left_glute", + 6: "right_leg", 7: "right_calf", 8: "right_shin", + 9: "right_hamstring", 10: "right_quad", 11: "right_glute", + 12: "torso_back", 13: "left_lower_back", 14: "left_upper_back", + 15: "right_lower_back", 16: "right_upper_back", 17: "torso_front", + 18: "left_abdomen", 19: "left_chest", 20: "right_abdomen", + 21: "right_chest", 22: "left_arm", 23: "left_shoulder", + 24: "left_bicep", 25: "left_tricep", 26: "left_brachioradialis", + 27: "left_forearm_extensors", 28: "right_arm", + 29: "right_shoulder", 30: "right_bicep", 31: "right_tricep", + 32: "right_brachioradialis", 33: "right_forearm_extensors", + 34: "neck", 35: "throat", 36: "waist_mid_back", 37: "waist_front", + 38: "waist_left", 39: "waist_right", + ], + "display_measure": [0: "metric", 1: "statute", 2: "nautical"], + "swim_stroke": [ + 0: "freestyle", 1: "backstroke", 2: "breaststroke", + 3: "butterfly", 4: "drill", 5: "mixed", 6: "im", + 7: "im_by_round", 8: "rimo", + ], + "source_type": [ + 0: "ant", 1: "antplus", 2: "bluetooth", + 3: "bluetooth_low_energy", 4: "wifi", 5: "local", + ], + "ant_network": [0: "public", 1: "antplus", 2: "antfs", 3: "private"], + "file": [ + 1: "device", 2: "settings", 3: "sport", 4: "activity", + 5: "workout", 6: "course", 7: "schedules", 9: "weight", + 10: "totals", 11: "goals", 14: "blood_pressure", + 15: "monitoring_a", 20: "activity_summary", 28: "monitoring_daily", + 32: "monitoring_b", 34: "segment", 35: "segment_list", + 40: "exd_configuration", 247: "mfg_range_min", 254: "mfg_range_max", + ], + "gender": [0: "female", 1: "male"], + "stroke_type": [ + 0: "no_event", 1: "other", 2: "serve", 3: "forehand", + 4: "backhand", 5: "smash", + ], + "radar_threat_level_type": [ + 0: "threat_unknown", 1: "threat_none", 2: "threat_approaching", + 3: "threat_approaching_fast", + ], + "manufacturer": [ + 1: "garmin", 2: "garmin_fr405_antfs", 3: "zephyr", 4: "dayton", + 5: "idt", 6: "srm", 7: "quarq", 8: "ibike", 9: "saris", + 10: "spark_hk", 11: "tanita", 12: "echowell", 13: "dynastream_oem", + 14: "nautilus", 15: "dynastream", 16: "timex", 17: "metrigear", + 18: "xelic", 19: "beurer", 20: "cardiosport", 21: "a_and_d", + 22: "hmm", 23: "suunto", 24: "thita_elektronik", 25: "gpulse", + 26: "clean_mobile", 27: "pedal_brain", 28: "peaksware", + 29: "saxonar", 30: "lemond_fitness", 31: "dexcom", + 32: "wahoo_fitness", 33: "octane_fitness", 34: "archinoetics", + 35: "the_hurt_box", 36: "citizen_systems", 37: "magellan", + 38: "osynce", 39: "holux", 40: "concept2", 41: "shimano", + 42: "one_giant_leap", 43: "ace_sensor", 44: "brim_brothers", + 45: "xplova", 46: "perception_digital", 47: "bf1systems", + 48: "pioneer", 49: "spantec", 50: "metalogics", 51: "4iiiis", + 52: "seiko_epson", 53: "seiko_epson_oem", 54: "ifor_powell", + 55: "maxwell_guider", 56: "star_trac", 57: "breakaway", + 58: "alatech_technology_ltd", 59: "mio_technology_europe", + 60: "rotor", 61: "geonaute", 62: "id_bike", 63: "specialized", + 64: "wtek", 65: "physical_enterprises", 66: "north_pole_engineering", + 67: "bkool", 68: "cateye", 69: "stages_cycling", 70: "sigmasport", + 71: "tomtom", 72: "peripedal", 73: "wattbike", 76: "moxy", + 77: "ciclosport", 78: "powerbahn", 79: "acorn_projects_aps", + 80: "lifebeam", 81: "bontrager", 82: "wellgo", 83: "scosche", + 84: "magura", 85: "woodway", 86: "elite", 87: "nielsen_kellerman", + 88: "dk_city", 89: "tacx", 90: "direction_technology", + 91: "magtonic", 92: "1partcarbon", 93: "inside_ride_technologies", + 94: "sound_of_motion", 95: "stryd", 96: "icg", 97: "MiPulse", + 98: "bsx_athletics", 99: "look", 100: "campagnolo_srl", + 101: "body_bike_smart", 102: "praxisworks", 103: "limits_technology", + 104: "topaction_technology", 105: "cosinuss", 106: "fitcare", + 107: "magene", 108: "giant_manufacturing_co", 109: "tigrasport", + 110: "salutron", 111: "technogym", 112: "bryton_sensors", + 113: "latitude_limited", 114: "soaring_technology", 115: "igpsport", + 116: "thinkrider", 117: "gopher_sport", 118: "waterrower", + 119: "orangetheory", 120: "inpeak", 121: "kinetic", + 122: "johnson_health_tech", 123: "polar_electro", 124: "seesense", + 125: "nci_technology", 126: "iqsquare", 127: "leomo", + 128: "ifit_com", 129: "coros_byte", 130: "versa_design", + 131: "chileaf", 132: "cycplus", 133: "gravaa_byte", 134: "sigeyi", + 135: "coospo", 136: "geoid", 137: "bosch", 138: "kyto", + 139: "kinetic_sports", 140: "decathlon_byte", 141: "tq_systems", + 142: "tag_heuer", 143: "keiser_fitness", 144: "zwift_byte", + 145: "porsche_ep", 146: "blackbird", 147: "meilan_byte", + 148: "ezon", 149: "laisi", 150: "myzone", 151: "abawo", + 152: "bafang", 153: "luhong_technology", 255: "development", + 257: "healthandlife", 258: "lezyne", 259: "scribe_labs", + 260: "zwift", 261: "watteam", 262: "recon", 263: "favero_electronics", + 264: "dynovelo", 265: "strava", 266: "precor", 267: "bryton", + 268: "sram", 269: "navman", 270: "cobi", 271: "spivi", + 272: "mio_magellan", 273: "evesports", 274: "sensitivus_gauge", + 275: "podoon", 276: "life_time_fitness", 277: "falco_e_motors", + 278: "minoura", 279: "cycliq", 280: "luxottica", 281: "trainer_road", + 282: "the_sufferfest", 283: "fullspeedahead", 284: "virtualtraining", + 285: "feedbacksports", 286: "omata", 287: "vdo", 288: "magneticdays", + 289: "hammerhead", 290: "kinetic_by_kurt", 291: "shapelog", + 292: "dabuziduo", 293: "jetblack", 294: "coros", 295: "virtugo", + 296: "velosense", 297: "cycligentinc", 298: "trailforks", + 299: "mahle_ebikemotion", 300: "nurvv", 301: "microprogram", + 302: "zone5cloud", 303: "greenteg", 304: "yamaha_motors", + 305: "whoop", 306: "gravaa", 307: "onelap", 308: "monark_exercise", + 309: "form", 310: "decathlon", 311: "syncros", 312: "heatup", + 313: "cannondale", 314: "true_fitness", 315: "RGT_cycling", + 316: "vasa", 317: "race_republic", 318: "fazua", 319: "oreka_training", + 320: "lsec", 321: "lululemon_studio", 322: "shanyue", + 323: "spinning_mda", 324: "hilldating", 325: "aero_sensor", + 326: "nike", 327: "magicshine", 328: "ictrainer", + 329: "absolute_cycling", 330: "eo_swimbetter", 331: "mywhoosh", + 332: "ravemen", 333: "tektro_racing_products", + 334: "darad_innovation_corporation", 335: "cycloptim", 337: "runna", + 339: "zepp", 340: "peloton", 341: "carv", 342: "tissot", + 345: "real_velo", 346: "wetech", 347: "jespr", 348: "huawei", + 349: "gotoes", 350: "cadence_app", 351: "una_watch", + 5759: "actigraphcorp", + ], + ] + + /// Maps field names that differ from their enum type name to the type name. + private static let fieldAliases: [String: String] = [ + "trigger": "session_trigger", + "pool_length_unit": "display_measure", + "sensor_position": "body_location", + "radar_threat_level_max": "radar_threat_level_type", + "type": "file", + ] +} diff --git a/Sources/GISToolsFIT/FITProfile.swift b/Sources/GISToolsFIT/FITProfile.swift index 1b3ca7f..73afcba 100644 --- a/Sources/GISToolsFIT/FITProfile.swift +++ b/Sources/GISToolsFIT/FITProfile.swift @@ -8,13 +8,13 @@ enum FITProfile { enum MesgNum: UInt16 { case fileId = 0 - case capabilities = 2 - case deviceSettings = 4 - case userProfile = 5 - case hrmProfile = 6 - case sdmProfile = 7 - case bikeProfile = 8 - case lap = 9 + case capabilities = 1 + case deviceSettings = 2 + case userProfile = 3 + case hrmProfile = 4 + case sdmProfile = 5 + case bikeProfile = 6 + case lap = 19 case session = 18 case record = 20 case event = 21 @@ -36,16 +36,32 @@ enum FITProfile { let scale: Double let offset: Double let fieldType: String // for documentation + let baseType: FITBaseType + let isDate: Bool - static func plain(_ name: String, units: String = "", fieldType: String = "") -> FieldMeta { - FieldMeta(name: name, units: units, scale: 1.0, offset: 0.0, fieldType: fieldType) + static func plain(_ name: String, units: String = "", fieldType: String = "", baseType: FITBaseType = .uint8, isDate: Bool = false) -> FieldMeta { + FieldMeta(name: name, units: units, scale: 1.0, offset: 0.0, fieldType: fieldType, baseType: baseType, isDate: isDate) } - static func scaled(_ name: String, units: String, scale: Double, offset: Double = 0.0) -> FieldMeta { - FieldMeta(name: name, units: units, scale: scale, offset: offset, fieldType: "") + static func scaled(_ name: String, units: String, scale: Double, offset: Double = 0.0, baseType: FITBaseType = .uint16, isDate: Bool = false) -> FieldMeta { + FieldMeta(name: name, units: units, scale: scale, offset: offset, fieldType: "", baseType: baseType, isDate: isDate) } func formatValue(_ raw: Any) -> Any? { + // Arrays are formatted element-wise. + if let arr = raw as? [Any] { + return arr.compactMap { formatValue($0) } + } + // Date fields: convert the raw FIT timestamp to an ISO8601 string. + if isDate { + if let u32 = raw as? UInt32 { + return FIT.iso8601String(from: FIT.dateFromTimestamp(u32)) + } + if let i = raw as? Int { return FIT.iso8601String(from: FIT.dateFromTimestamp(UInt32(i))) } + if let i64 = raw as? Int64 { return FIT.iso8601String(from: FIT.dateFromTimestamp(UInt32(i64))) } + if let u64 = raw as? UInt64 { return FIT.iso8601String(from: FIT.dateFromTimestamp(UInt32(u64))) } + return nil + } guard let num = raw as? Double else { // Already a string, return as-is if let s = raw as? String { return s } @@ -81,6 +97,51 @@ enum FITProfile { // Keep as Double since we applied scale/offset return result } + + /// Converts a user-friendly value (as produced by ``formatValue(_:)``) + /// back into the raw integer/float value stored in the FIT file. + func rawValue(from formatted: Any) -> Any? { + // Arrays are converted element-wise. + if let arr = formatted as? [Any] { + return arr.compactMap { rawValue(from: $0) } + } + // Date fields: convert the ISO8601 string back to a FIT timestamp. + if isDate { + if let s = formatted as? String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: s) { + return Int64(date.timeIntervalSince(FIT.epoch)) + } + } + return nil + } + // Strings pass through unchanged. + if let s = formatted as? String { return s } + + let numeric: Double + if let d = formatted as? Double { numeric = d } + else if let i = formatted as? Int { numeric = Double(i) } + else if let i32 = formatted as? Int32 { numeric = Double(i32) } + else if let i64 = formatted as? Int64 { numeric = Double(i64) } + else if let u32 = formatted as? UInt32 { numeric = Double(u32) } + else if let u16 = formatted as? UInt16 { numeric = Double(u16) } + else if let u8 = formatted as? UInt8 { numeric = Double(u8) } + else if let i16 = formatted as? Int16 { numeric = Double(i16) } + else if let i8 = formatted as? Int8 { numeric = Double(i8) } + else if let u64 = formatted as? UInt64 { numeric = Double(u64) } + else if let f = formatted as? Float { numeric = Double(f) } + else { return nil } + + // Reverse of applyScale: raw = (value + offset) * scale + let raw = (numeric + offset) * scale + switch baseType { + case .float32, .float64: + return raw + default: + return Int64(raw.rounded()) + } + } } // MARK: - Field lookup @@ -91,6 +152,11 @@ enum FITProfile { return msg[fieldNum] } + /// Returns all field metadata for a message, keyed by field number. + static func fields(for mesgNum: UInt16) -> [Int: FieldMeta] { + knownMessages[MesgNum(mesgNum)] ?? [:] + } + static func messageName(mesgNum: UInt16) -> String { switch MesgNum(mesgNum) { case .fileId: return "file_id" @@ -124,255 +190,449 @@ enum FITProfile { // Message 0: file_id private static let fileIdFields: [Int: FieldMeta] = [ - 0: .plain("type", fieldType: "fit_file"), - 1: .plain("manufacturer", fieldType: "manufacturer"), - 2: .plain("product", fieldType: "uint16"), - 3: .plain("serial_number", fieldType: "uint32z"), - 4: .plain("time_created", fieldType: "date_time"), - 5: .plain("number", fieldType: "uint16"), - 8: .plain("product_name", fieldType: "string"), + 0: .plain("type", units: "", baseType: .enumType), + 1: .plain("manufacturer", units: "", baseType: .uint16), + 2: .plain("product", units: "", baseType: .uint16), + 3: .plain("serial_number", units: "", baseType: .uint32z), + 4: .plain("time_created", units: "", baseType: .uint32, isDate: true), + 5: .plain("number", units: "", baseType: .uint16), + 8: .plain("product_name", units: "", baseType: .string), ] // Message 20: record private static let recordFields: [Int: FieldMeta] = [ - 253: .plain("timestamp", fieldType: "date_time"), - 0: .plain("position_lat", units: "semicircles"), - 1: .plain("position_long", units: "semicircles"), - 2: .scaled("altitude", units: "m", scale: 5.0, offset: 500.0), - 3: .plain("heart_rate", units: "bpm"), - 4: .plain("cadence", units: "rpm"), - 5: .scaled("distance", units: "m", scale: 100.0), - 6: .scaled("speed", units: "m/s", scale: 1000.0), - 7: .plain("power", units: "watts"), - 8: .scaled("enhanced_speed", units: "m/s", scale: 1000.0), - 9: .scaled("enhanced_altitude", units: "m", scale: 5.0, offset: 500.0), - 11: .plain("grade", units: "%"), - 12: .plain("accumulated_power", units: "watts"), - 13: .plain("left_right_balance", fieldType: "uint8"), - 17: .plain("compressed_accumulated_power", fieldType: "uint16"), - 18: .scaled("vertical_oscillation", units: "mm", scale: 10.0), - 19: .scaled("stance_time", units: "ms", scale: 10.0), - 28: .plain("stance_time_balance", units: "%"), - 29: .plain("accelerometer_frequency", fieldType: "uint16"), - 30: .plain("linear_acceleration_vertical", fieldType: "float32"), - 31: .plain("grit", fieldType: "float32"), - 32: .plain("flow", fieldType: "float32"), - 39: .plain("vertical_ratio", fieldType: "float32"), - 40: .plain("stance_time_ratio", fieldType: "float32"), - 41: .scaled("step_length", units: "mm", scale: 10.0), - 42: .scaled("cycle_length16", units: "mm", scale: 5.0), - 43: .scaled("absolute_pressure", units: "Pa", scale: 1.0), - 44: .scaled("depth", units: "m", scale: 1000.0), - 45: .scaled("next_stop_depth", units: "m", scale: 1000.0), - 46: .plain("next_stop_time", units: "min"), - 47: .plain("next_stop_distance", units: "m"), - 48: .plain("time_underwater", units: "s"), - 49: .plain("time_above_water", units: "s"), - 50: .plain("next_stop_gas", fieldType: "uint8"), - 51: .scaled("maximum_depth", units: "m", scale: 1000.0), - 52: .plain("gas_type", fieldType: "uint8"), - 53: .scaled("gas_usage_rate", units: "L/min", scale: 100.0), - 54: .scaled("gas_consumed", units: "L", scale: 100.0), - 55: .scaled("salinity", units: "ppt", scale: 1000.0), - 56: .plain("heart_rate_alert", fieldType: "uint8"), - 57: .plain("cadence_alert", fieldType: "uint8"), - 58: .plain("speed_alert", fieldType: "uint8"), - 59: .plain("power_alert", fieldType: "uint8"), - 60: .plain("heart_rate_zone", fieldType: "uint8"), - 61: .plain("cadence_zone", fieldType: "uint8"), - 62: .plain("speed_zone", fieldType: "uint8"), - 63: .plain("power_zone", fieldType: "uint8"), - 64: .scaled("ball_speed", units: "m/s", scale: 100.0), - 65: .scaled("cadence256", units: "rpm", scale: 256.0), - 66: .scaled("fractional_cadence", units: "rpm", scale: 128.0), - 67: .scaled("total_hemoglobin_conc", units: "g/dL", scale: 100.0), - 68: .scaled("total_hemoglobin_conc_min", units: "g/dL", scale: 100.0), - 69: .scaled("total_hemoglobin_conc_max", units: "g/dL", scale: 100.0), - 70: .scaled("saturated_hemoglobin_percent", units: "%", scale: 10.0), - 71: .scaled("saturated_hemoglobin_percent_min", units: "%", scale: 10.0), - 72: .scaled("saturated_hemoglobin_percent_max", units: "%", scale: 10.0), - 73: .plain("device_index", fieldType: "uint8"), - 75: .plain("min_heart_rate", fieldType: "uint8"), - 76: .plain("avg_heart_rate", fieldType: "uint8"), - 77: .plain("max_heart_rate", fieldType: "uint8"), - 78: .plain("temperature", units: "C"), - 83: .plain("activity_type", fieldType: "activity_type"), - 84: .plain("left_torque_effectiveness", units: "%"), - 85: .plain("right_torque_effectiveness", units: "%"), - 86: .plain("left_pedal_smoothness", units: "%"), - 87: .plain("right_pedal_smoothness", units: "%"), - 88: .plain("combined_pedal_smoothness", units: "%"), - 89: .scaled("time128", units: "s", scale: 128.0), - 90: .plain("stroke_type", fieldType: "stroke_type"), - 91: .plain("zone", fieldType: "uint8"), - 92: .plain("event", fieldType: "event"), - 93: .plain("event_type", fieldType: "event_type"), - 94: .scaled("avg_vertical_oscillation", units: "mm", scale: 10.0), - 95: .scaled("avg_stance_time", units: "ms", scale: 10.0), - 96: .plain("avg_stance_time_balance", units: "%"), - 98: .scaled("avg_step_length", units: "mm", scale: 10.0), - 99: .scaled("total_anaerobic_training_effect", units: "FE", scale: 10.0), - 100: .scaled("avg_vam", units: "m/s", scale: 1000.0), - 101: .plain("timing_code", fieldType: "timing_code"), + 253: .plain("timestamp", units: "s", baseType: .uint32, isDate: true), + 0: .plain("position_lat", units: "semicircles", baseType: .sint32), + 1: .plain("position_long", units: "semicircles", baseType: .sint32), + 2: .scaled("altitude", units: "m", scale: 5, offset: 500, baseType: .uint16), + 3: .plain("heart_rate", units: "bpm", baseType: .uint8), + 4: .plain("cadence", units: "rpm", baseType: .uint8), + 5: .scaled("distance", units: "m", scale: 100, offset: 0, baseType: .uint32), + 6: .scaled("speed", units: "m/s", scale: 1000, offset: 0, baseType: .uint16), + 7: .plain("power", units: "watts", baseType: .uint16), + 8: .scaled("compressed_speed_distance", units: "m/s", scale: 100, offset: 0, baseType: .byte), + 9: .scaled("grade", units: "%", scale: 100, offset: 0, baseType: .sint16), + 10: .plain("resistance", units: "", baseType: .uint8), + 11: .scaled("time_from_course", units: "s", scale: 1000, offset: 0, baseType: .sint32), + 12: .scaled("cycle_length", units: "m", scale: 100, offset: 0, baseType: .uint8), + 13: .plain("temperature", units: "C", baseType: .sint8), + 17: .scaled("speed_1s", units: "m/s", scale: 16, offset: 0, baseType: .uint8), + 18: .plain("cycles", units: "cycles", baseType: .uint8), + 19: .plain("total_cycles", units: "cycles", baseType: .uint32), + 28: .plain("compressed_accumulated_power", units: "watts", baseType: .uint16), + 29: .plain("accumulated_power", units: "watts", baseType: .uint32), + 30: .plain("left_right_balance", units: "", baseType: .uint8), + 31: .plain("gps_accuracy", units: "m", baseType: .uint8), + 32: .scaled("vertical_speed", units: "m/s", scale: 1000, offset: 0, baseType: .sint16), + 33: .plain("calories", units: "kcal", baseType: .uint16), + 39: .scaled("vertical_oscillation", units: "mm", scale: 10, offset: 0, baseType: .uint16), + 40: .scaled("stance_time_percent", units: "percent", scale: 100, offset: 0, baseType: .uint16), + 41: .scaled("stance_time", units: "ms", scale: 10, offset: 0, baseType: .uint16), + 42: .plain("activity_type", units: "", baseType: .enumType), + 43: .scaled("left_torque_effectiveness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 44: .scaled("right_torque_effectiveness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 45: .scaled("left_pedal_smoothness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 46: .scaled("right_pedal_smoothness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 47: .scaled("combined_pedal_smoothness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 48: .scaled("time128", units: "s", scale: 128, offset: 0, baseType: .uint8), + 49: .plain("stroke_type", units: "", baseType: .enumType), + 50: .plain("zone", units: "", baseType: .uint8), + 51: .scaled("ball_speed", units: "m/s", scale: 100, offset: 0, baseType: .uint16), + 52: .scaled("cadence256", units: "rpm", scale: 256, offset: 0, baseType: .uint16), + 53: .scaled("fractional_cadence", units: "rpm", scale: 128, offset: 0, baseType: .uint8), + 54: .scaled("total_hemoglobin_conc", units: "g/dL", scale: 100, offset: 0, baseType: .uint16), + 55: .scaled("total_hemoglobin_conc_min", units: "g/dL", scale: 100, offset: 0, baseType: .uint16), + 56: .scaled("total_hemoglobin_conc_max", units: "g/dL", scale: 100, offset: 0, baseType: .uint16), + 57: .scaled("saturated_hemoglobin_percent", units: "%", scale: 10, offset: 0, baseType: .uint16), + 58: .scaled("saturated_hemoglobin_percent_min", units: "%", scale: 10, offset: 0, baseType: .uint16), + 59: .scaled("saturated_hemoglobin_percent_max", units: "%", scale: 10, offset: 0, baseType: .uint16), + 62: .plain("device_index", units: "", baseType: .uint8), + 67: .plain("left_pco", units: "mm", baseType: .sint8), + 68: .plain("right_pco", units: "mm", baseType: .sint8), + 69: .scaled("left_power_phase", units: "degrees", scale: 0.7111111, offset: 0, baseType: .uint8), + 70: .scaled("left_power_phase_peak", units: "degrees", scale: 0.7111111, offset: 0, baseType: .uint8), + 71: .scaled("right_power_phase", units: "degrees", scale: 0.7111111, offset: 0, baseType: .uint8), + 72: .scaled("right_power_phase_peak", units: "degrees", scale: 0.7111111, offset: 0, baseType: .uint8), + 73: .scaled("enhanced_speed", units: "m/s", scale: 1000, offset: 0, baseType: .uint32), + 78: .scaled("enhanced_altitude", units: "m", scale: 5, offset: 500, baseType: .uint32), + 81: .scaled("battery_soc", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 82: .plain("motor_power", units: "watts", baseType: .uint16), + 83: .scaled("vertical_ratio", units: "percent", scale: 100, offset: 0, baseType: .uint16), + 84: .scaled("stance_time_balance", units: "percent", scale: 100, offset: 0, baseType: .uint16), + 85: .scaled("step_length", units: "mm", scale: 10, offset: 0, baseType: .uint16), + 87: .scaled("cycle_length16", units: "m", scale: 100, offset: 0, baseType: .uint16), + 91: .plain("absolute_pressure", units: "Pa", baseType: .uint32), + 92: .scaled("depth", units: "m", scale: 1000, offset: 0, baseType: .uint32), + 93: .scaled("next_stop_depth", units: "m", scale: 1000, offset: 0, baseType: .uint32), + 94: .plain("next_stop_time", units: "s", baseType: .uint32), + 95: .plain("time_to_surface", units: "s", baseType: .uint32), + 96: .plain("ndl_time", units: "s", baseType: .uint32), + 97: .plain("cns_load", units: "percent", baseType: .uint8), + 98: .plain("n2_load", units: "percent", baseType: .uint16), + 99: .plain("respiration_rate", units: "s", baseType: .uint8), + 108: .scaled("enhanced_respiration_rate", units: "Breaths/min", scale: 100, offset: 0, baseType: .uint16), + 114: .plain("grit", units: "", baseType: .float32), + 115: .plain("flow", units: "", baseType: .float32), + 116: .scaled("current_stress", units: "", scale: 100, offset: 0, baseType: .uint16), + 117: .plain("ebike_travel_range", units: "km", baseType: .uint16), + 118: .plain("ebike_battery_level", units: "percent", baseType: .uint8), + 119: .plain("ebike_assist_mode", units: "depends on sensor", baseType: .uint8), + 120: .plain("ebike_assist_level_percent", units: "percent", baseType: .uint8), + 123: .plain("air_time_remaining", units: "s", baseType: .uint32), + 124: .scaled("pressure_sac", units: "bar/min", scale: 100, offset: 0, baseType: .uint16), + 125: .scaled("volume_sac", units: "L/min", scale: 100, offset: 0, baseType: .uint16), + 126: .scaled("rmv", units: "L/min", scale: 100, offset: 0, baseType: .uint16), + 127: .scaled("ascent_rate", units: "m/s", scale: 1000, offset: 0, baseType: .sint32), + 129: .scaled("po2", units: "percent", scale: 100, offset: 0, baseType: .uint8), + 139: .scaled("core_temperature", units: "C", scale: 100, offset: 0, baseType: .uint16), ] - // Message 9: lap + // Message 19: lap private static let lapFields: [Int: FieldMeta] = [ - 253: .plain("timestamp", fieldType: "date_time"), - 0: .plain("start_time", fieldType: "date_time"), - 1: .plain("start_position_lat", units: "semicircles"), - 2: .plain("start_position_long", units: "semicircles"), - 3: .plain("end_position_lat", units: "semicircles"), - 4: .plain("end_position_long", units: "semicircles"), - 5: .scaled("total_elapsed_time", units: "s", scale: 1000.0), - 6: .scaled("total_timer_time", units: "s", scale: 1000.0), - 7: .scaled("total_distance", units: "m", scale: 100.0), - 8: .scaled("total_cycles", units: "", scale: 1.0), - 9: .plain("total_calories", units: "kcal"), - 10: .plain("total_fat_calories", units: "kcal"), - 11: .scaled("avg_speed", units: "m/s", scale: 1000.0), - 12: .scaled("max_speed", units: "m/s", scale: 1000.0), - 13: .plain("avg_heart_rate", units: "bpm"), - 14: .plain("max_heart_rate", units: "bpm"), - 15: .plain("avg_cadence", units: "rpm"), - 16: .plain("max_cadence", units: "rpm"), - 17: .plain("avg_power", units: "watts"), - 18: .plain("max_power", units: "watts"), - 19: .scaled("total_ascent", units: "m", scale: 5.0, offset: 500.0), - 20: .scaled("total_descent", units: "m", scale: 5.0, offset: 500.0), - 21: .plain("num_attachments", fieldType: "uint16"), - 22: .plain("num_lengths", fieldType: "uint16"), - 23: .plain("num_splits", fieldType: "uint16"), - 24: .plain("event", fieldType: "event"), - 25: .plain("event_type", fieldType: "event_type"), - 26: .plain("sub_sport", fieldType: "sub_sport"), - 27: .plain("avg_grade", units: "%"), - 28: .plain("avg_vertical_ratio", fieldType: "float32"), - 29: .plain("avg_stance_time_balance", units: "%"), - 30: .scaled("avg_step_length", units: "mm", scale: 10.0), - 31: .scaled("total_anaerobic_effect", units: "FE", scale: 10.0), - 32: .scaled("avg_vam", units: "m/s", scale: 1000.0), - 33: .plain("avg_grit", fieldType: "float32"), - 34: .plain("avg_flow", fieldType: "float32"), - 35: .scaled("avg_fractional_cadence", units: "rpm", scale: 128.0), - 36: .scaled("max_fractional_cadence", units: "rpm", scale: 128.0), - 37: .scaled("total_fractional_cycles", units: "", scale: 128.0), - 38: .scaled("player_score", units: "", scale: 100.0), - 39: .scaled("avg_total_hemoglobin_conc", units: "g/dL", scale: 100.0), - 40: .scaled("min_total_hemoglobin_conc", units: "g/dL", scale: 100.0), - 41: .scaled("max_total_hemoglobin_conc", units: "g/dL", scale: 100.0), - 42: .scaled("avg_saturated_hemoglobin_percent", units: "%", scale: 10.0), - 43: .scaled("min_saturated_hemoglobin_percent", units: "%", scale: 10.0), - 44: .scaled("max_saturated_hemoglobin_percent", units: "%", scale: 10.0), - 45: .plain("avg_left_torque_effectiveness", units: "%"), - 46: .plain("avg_right_torque_effectiveness", units: "%"), - 47: .plain("avg_left_pedal_smoothness", units: "%"), - 48: .plain("avg_right_pedal_smoothness", units: "%"), - 49: .plain("avg_combined_pedal_smoothness", units: "%"), - 50: .scaled("time_in_hr_zone", units: "s", scale: 1000.0), - 51: .scaled("time_in_speed_zone", units: "s", scale: 1000.0), - 52: .scaled("time_in_cadence_zone", units: "s", scale: 1000.0), - 53: .scaled("time_in_power_zone", units: "s", scale: 1000.0), - 54: .scaled("avg_lap_time", units: "s", scale: 1000.0), - 55: .plain("stand_count", fieldType: "uint16"), - 56: .scaled("avg_vertical_oscillation", units: "mm", scale: 10.0), - 57: .scaled("avg_stance_time", units: "ms", scale: 10.0), - 58: .plain("avg_stance_time_percent", units: "%"), - 59: .plain("avg_stance_time_balance", units: "%"), - 60: .plain("num_active_lengths", fieldType: "uint16"), - 61: .scaled("avg_total_hemoglobin_conc", units: "g/dL", scale: 100.0), - 62: .scaled("min_total_hemoglobin_conc", units: "g/dL", scale: 100.0), - 63: .scaled("max_total_hemoglobin_conc", units: "g/dL", scale: 100.0), - 64: .scaled("avg_saturated_hemoglobin_percent", units: "%", scale: 10.0), - 65: .scaled("min_saturated_hemoglobin_percent", units: "%", scale: 10.0), - 66: .scaled("max_saturated_hemoglobin_percent", units: "%", scale: 10.0), + 254: .plain("message_index", units: "", baseType: .uint16), + 253: .plain("timestamp", units: "s", baseType: .uint32, isDate: true), + 0: .plain("event", units: "", baseType: .enumType), + 1: .plain("event_type", units: "", baseType: .enumType), + 2: .plain("start_time", units: "", baseType: .uint32, isDate: true), + 3: .plain("start_position_lat", units: "semicircles", baseType: .sint32), + 4: .plain("start_position_long", units: "semicircles", baseType: .sint32), + 5: .plain("end_position_lat", units: "semicircles", baseType: .sint32), + 6: .plain("end_position_long", units: "semicircles", baseType: .sint32), + 7: .scaled("total_elapsed_time", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 8: .scaled("total_timer_time", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 9: .scaled("total_distance", units: "m", scale: 100, offset: 0, baseType: .uint32), + 10: .plain("total_cycles", units: "cycles", baseType: .uint32), + 11: .plain("total_calories", units: "kcal", baseType: .uint16), + 12: .plain("total_fat_calories", units: "kcal", baseType: .uint16), + 13: .scaled("avg_speed", units: "m/s", scale: 1000, offset: 0, baseType: .uint16), + 14: .scaled("max_speed", units: "m/s", scale: 1000, offset: 0, baseType: .uint16), + 15: .plain("avg_heart_rate", units: "bpm", baseType: .uint8), + 16: .plain("max_heart_rate", units: "bpm", baseType: .uint8), + 17: .plain("avg_cadence", units: "rpm", baseType: .uint8), + 18: .plain("max_cadence", units: "rpm", baseType: .uint8), + 19: .plain("avg_power", units: "watts", baseType: .uint16), + 20: .plain("max_power", units: "watts", baseType: .uint16), + 21: .plain("total_ascent", units: "m", baseType: .uint16), + 22: .plain("total_descent", units: "m", baseType: .uint16), + 23: .plain("intensity", units: "", baseType: .enumType), + 24: .plain("lap_trigger", units: "", baseType: .enumType), + 25: .plain("sport", units: "", baseType: .enumType), + 26: .plain("event_group", units: "", baseType: .uint8), + 32: .plain("num_lengths", units: "lengths", baseType: .uint16), + 33: .plain("normalized_power", units: "watts", baseType: .uint16), + 34: .plain("left_right_balance", units: "", baseType: .uint16), + 35: .plain("first_length_index", units: "", baseType: .uint16), + 37: .scaled("avg_stroke_distance", units: "m", scale: 100, offset: 0, baseType: .uint16), + 38: .plain("swim_stroke", units: "", baseType: .enumType), + 39: .plain("sub_sport", units: "", baseType: .enumType), + 40: .plain("num_active_lengths", units: "lengths", baseType: .uint16), + 41: .plain("total_work", units: "J", baseType: .uint32), + 42: .scaled("avg_altitude", units: "m", scale: 5, offset: 500, baseType: .uint16), + 43: .scaled("max_altitude", units: "m", scale: 5, offset: 500, baseType: .uint16), + 44: .plain("gps_accuracy", units: "m", baseType: .uint8), + 45: .scaled("avg_grade", units: "%", scale: 100, offset: 0, baseType: .sint16), + 46: .scaled("avg_pos_grade", units: "%", scale: 100, offset: 0, baseType: .sint16), + 47: .scaled("avg_neg_grade", units: "%", scale: 100, offset: 0, baseType: .sint16), + 48: .scaled("max_pos_grade", units: "%", scale: 100, offset: 0, baseType: .sint16), + 49: .scaled("max_neg_grade", units: "%", scale: 100, offset: 0, baseType: .sint16), + 50: .plain("avg_temperature", units: "C", baseType: .sint8), + 51: .plain("max_temperature", units: "C", baseType: .sint8), + 52: .scaled("total_moving_time", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 53: .scaled("avg_pos_vertical_speed", units: "m/s", scale: 1000, offset: 0, baseType: .sint16), + 54: .scaled("avg_neg_vertical_speed", units: "m/s", scale: 1000, offset: 0, baseType: .sint16), + 55: .scaled("max_pos_vertical_speed", units: "m/s", scale: 1000, offset: 0, baseType: .sint16), + 56: .scaled("max_neg_vertical_speed", units: "m/s", scale: 1000, offset: 0, baseType: .sint16), + 57: .scaled("time_in_hr_zone", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 58: .scaled("time_in_speed_zone", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 59: .scaled("time_in_cadence_zone", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 60: .scaled("time_in_power_zone", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 61: .plain("repetition_num", units: "", baseType: .uint16), + 62: .scaled("min_altitude", units: "m", scale: 5, offset: 500, baseType: .uint16), + 63: .plain("min_heart_rate", units: "bpm", baseType: .uint8), + 70: .scaled("active_time", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 71: .plain("wkt_step_index", units: "", baseType: .uint16), + 74: .plain("opponent_score", units: "", baseType: .uint16), + 75: .plain("stroke_count", units: "counts", baseType: .uint16), + 76: .plain("zone_count", units: "counts", baseType: .uint16), + 77: .scaled("avg_vertical_oscillation", units: "mm", scale: 10, offset: 0, baseType: .uint16), + 78: .scaled("avg_stance_time_percent", units: "percent", scale: 100, offset: 0, baseType: .uint16), + 79: .scaled("avg_stance_time", units: "ms", scale: 10, offset: 0, baseType: .uint16), + 80: .scaled("avg_fractional_cadence", units: "rpm", scale: 128, offset: 0, baseType: .uint8), + 81: .scaled("max_fractional_cadence", units: "rpm", scale: 128, offset: 0, baseType: .uint8), + 82: .scaled("total_fractional_cycles", units: "cycles", scale: 128, offset: 0, baseType: .uint8), + 83: .plain("player_score", units: "", baseType: .uint16), + 84: .scaled("avg_total_hemoglobin_conc", units: "g/dL", scale: 100, offset: 0, baseType: .uint16), + 85: .scaled("min_total_hemoglobin_conc", units: "g/dL", scale: 100, offset: 0, baseType: .uint16), + 86: .scaled("max_total_hemoglobin_conc", units: "g/dL", scale: 100, offset: 0, baseType: .uint16), + 87: .scaled("avg_saturated_hemoglobin_percent", units: "%", scale: 10, offset: 0, baseType: .uint16), + 88: .scaled("min_saturated_hemoglobin_percent", units: "%", scale: 10, offset: 0, baseType: .uint16), + 89: .scaled("max_saturated_hemoglobin_percent", units: "%", scale: 10, offset: 0, baseType: .uint16), + 91: .scaled("avg_left_torque_effectiveness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 92: .scaled("avg_right_torque_effectiveness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 93: .scaled("avg_left_pedal_smoothness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 94: .scaled("avg_right_pedal_smoothness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 95: .scaled("avg_combined_pedal_smoothness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 98: .scaled("time_standing", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 99: .plain("stand_count", units: "", baseType: .uint16), + 100: .plain("avg_left_pco", units: "mm", baseType: .sint8), + 101: .plain("avg_right_pco", units: "mm", baseType: .sint8), + 102: .scaled("avg_left_power_phase", units: "degrees", scale: 0.7111111, offset: 0, baseType: .uint8), + 103: .scaled("avg_left_power_phase_peak", units: "degrees", scale: 0.7111111, offset: 0, baseType: .uint8), + 104: .scaled("avg_right_power_phase", units: "degrees", scale: 0.7111111, offset: 0, baseType: .uint8), + 105: .scaled("avg_right_power_phase_peak", units: "degrees", scale: 0.7111111, offset: 0, baseType: .uint8), + 106: .plain("avg_power_position", units: "watts", baseType: .uint16), + 107: .plain("max_power_position", units: "watts", baseType: .uint16), + 108: .plain("avg_cadence_position", units: "rpm", baseType: .uint8), + 109: .plain("max_cadence_position", units: "rpm", baseType: .uint8), + 110: .scaled("enhanced_avg_speed", units: "m/s", scale: 1000, offset: 0, baseType: .uint32), + 111: .scaled("enhanced_max_speed", units: "m/s", scale: 1000, offset: 0, baseType: .uint32), + 112: .scaled("enhanced_avg_altitude", units: "m", scale: 5, offset: 500, baseType: .uint32), + 113: .scaled("enhanced_min_altitude", units: "m", scale: 5, offset: 500, baseType: .uint32), + 114: .scaled("enhanced_max_altitude", units: "m", scale: 5, offset: 500, baseType: .uint32), + 115: .plain("avg_lev_motor_power", units: "watts", baseType: .uint16), + 116: .plain("max_lev_motor_power", units: "watts", baseType: .uint16), + 117: .scaled("lev_battery_consumption", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 118: .scaled("avg_vertical_ratio", units: "percent", scale: 100, offset: 0, baseType: .uint16), + 119: .scaled("avg_stance_time_balance", units: "percent", scale: 100, offset: 0, baseType: .uint16), + 120: .scaled("avg_step_length", units: "mm", scale: 10, offset: 0, baseType: .uint16), + 121: .scaled("avg_vam", units: "m/s", scale: 1000, offset: 0, baseType: .uint16), + 122: .scaled("avg_depth", units: "m", scale: 1000, offset: 0, baseType: .uint32), + 123: .scaled("max_depth", units: "m", scale: 1000, offset: 0, baseType: .uint32), + 124: .plain("min_temperature", units: "C", baseType: .sint8), + 136: .scaled("enhanced_avg_respiration_rate", units: "Breaths/min", scale: 100, offset: 0, baseType: .uint16), + 137: .scaled("enhanced_max_respiration_rate", units: "Breaths/min", scale: 100, offset: 0, baseType: .uint16), + 147: .plain("avg_respiration_rate", units: "", baseType: .uint8), + 148: .plain("max_respiration_rate", units: "", baseType: .uint8), + 149: .plain("total_grit", units: "kGrit", baseType: .float32), + 150: .plain("total_flow", units: "Flow", baseType: .float32), + 151: .plain("jump_count", units: "", baseType: .uint16), + 153: .plain("avg_grit", units: "kGrit", baseType: .float32), + 154: .plain("avg_flow", units: "Flow", baseType: .float32), + 156: .scaled("total_fractional_ascent", units: "m", scale: 100, offset: 0, baseType: .uint8), + 157: .scaled("total_fractional_descent", units: "m", scale: 100, offset: 0, baseType: .uint8), + 158: .scaled("avg_core_temperature", units: "C", scale: 100, offset: 0, baseType: .uint16), + 159: .scaled("min_core_temperature", units: "C", scale: 100, offset: 0, baseType: .uint16), + 160: .scaled("max_core_temperature", units: "C", scale: 100, offset: 0, baseType: .uint16), ] // Message 18: session private static let sessionFields: [Int: FieldMeta] = [ - 253: .plain("timestamp", fieldType: "date_time"), - 0: .plain("start_time", fieldType: "date_time"), - 1: .plain("start_position_lat", units: "semicircles"), - 2: .plain("start_position_long", units: "semicircles"), - 3: .plain("end_position_lat", units: "semicircles"), - 4: .plain("end_position_long", units: "semicircles"), - 5: .scaled("total_elapsed_time", units: "s", scale: 1000.0), - 6: .scaled("total_timer_time", units: "s", scale: 1000.0), - 7: .scaled("total_distance", units: "m", scale: 100.0), - 8: .scaled("total_cycles", units: "", scale: 1.0), - 9: .plain("total_calories", units: "kcal"), - 10: .plain("total_fat_calories", units: "kcal"), - 11: .scaled("avg_speed", units: "m/s", scale: 1000.0), - 12: .scaled("max_speed", units: "m/s", scale: 1000.0), - 13: .plain("avg_heart_rate", units: "bpm"), - 14: .plain("max_heart_rate", units: "bpm"), - 15: .plain("avg_cadence", units: "rpm"), - 16: .plain("max_cadence", units: "rpm"), - 17: .plain("avg_power", units: "watts"), - 18: .plain("max_power", units: "watts"), - 19: .scaled("total_ascent", units: "m", scale: 5.0), - 20: .scaled("total_descent", units: "m", scale: 5.0), - 21: .plain("event", fieldType: "event"), - 22: .plain("event_type", fieldType: "event_type"), - 23: .plain("sport", fieldType: "sport"), - 24: .plain("sub_sport", fieldType: "sub_sport"), - 25: .plain("avg_heart_rate", units: "bpm"), - 26: .plain("max_heart_rate", units: "bpm"), - 27: .plain("avg_cadence", units: "rpm"), - 28: .plain("max_cadence", units: "rpm"), - 29: .plain("avg_power", units: "watts"), - 30: .plain("max_power", units: "watts"), - 31: .scaled("total_anaerobic_effect", units: "FE", scale: 10.0), - 32: .scaled("avg_vertical_oscillation", units: "mm", scale: 10.0), - 33: .scaled("avg_stance_time", units: "ms", scale: 10.0), - 34: .plain("avg_stance_time_balance", units: "%"), - 35: .plain("num_laps", fieldType: "uint16"), - 36: .plain("num_attachments", fieldType: "uint16"), - 37: .scaled("avg_step_length", units: "mm", scale: 10.0), + 254: .plain("message_index", units: "", baseType: .uint16), + 253: .plain("timestamp", units: "s", baseType: .uint32, isDate: true), + 0: .plain("event", units: "", baseType: .enumType), + 1: .plain("event_type", units: "", baseType: .enumType), + 2: .plain("start_time", units: "", baseType: .uint32, isDate: true), + 3: .plain("start_position_lat", units: "semicircles", baseType: .sint32), + 4: .plain("start_position_long", units: "semicircles", baseType: .sint32), + 5: .plain("sport", units: "", baseType: .enumType), + 6: .plain("sub_sport", units: "", baseType: .enumType), + 7: .scaled("total_elapsed_time", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 8: .scaled("total_timer_time", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 9: .scaled("total_distance", units: "m", scale: 100, offset: 0, baseType: .uint32), + 10: .plain("total_cycles", units: "cycles", baseType: .uint32), + 11: .plain("total_calories", units: "kcal", baseType: .uint16), + 13: .plain("total_fat_calories", units: "kcal", baseType: .uint16), + 14: .scaled("avg_speed", units: "m/s", scale: 1000, offset: 0, baseType: .uint16), + 15: .scaled("max_speed", units: "m/s", scale: 1000, offset: 0, baseType: .uint16), + 16: .plain("avg_heart_rate", units: "bpm", baseType: .uint8), + 17: .plain("max_heart_rate", units: "bpm", baseType: .uint8), + 18: .plain("avg_cadence", units: "rpm", baseType: .uint8), + 19: .plain("max_cadence", units: "rpm", baseType: .uint8), + 20: .plain("avg_power", units: "watts", baseType: .uint16), + 21: .plain("max_power", units: "watts", baseType: .uint16), + 22: .plain("total_ascent", units: "m", baseType: .uint16), + 23: .plain("total_descent", units: "m", baseType: .uint16), + 24: .scaled("total_training_effect", units: "", scale: 10, offset: 0, baseType: .uint8), + 25: .plain("first_lap_index", units: "", baseType: .uint16), + 26: .plain("num_laps", units: "", baseType: .uint16), + 27: .plain("event_group", units: "", baseType: .uint8), + 28: .plain("trigger", units: "", baseType: .enumType), + 29: .plain("nec_lat", units: "semicircles", baseType: .sint32), + 30: .plain("nec_long", units: "semicircles", baseType: .sint32), + 31: .plain("swc_lat", units: "semicircles", baseType: .sint32), + 32: .plain("swc_long", units: "semicircles", baseType: .sint32), + 33: .plain("num_lengths", units: "lengths", baseType: .uint16), + 34: .plain("normalized_power", units: "watts", baseType: .uint16), + 35: .scaled("training_stress_score", units: "tss", scale: 10, offset: 0, baseType: .uint16), + 36: .scaled("intensity_factor", units: "if", scale: 1000, offset: 0, baseType: .uint16), + 37: .plain("left_right_balance", units: "", baseType: .uint16), + 38: .plain("end_position_lat", units: "semicircles", baseType: .sint32), + 39: .plain("end_position_long", units: "semicircles", baseType: .sint32), + 41: .scaled("avg_stroke_count", units: "strokes/lap", scale: 10, offset: 0, baseType: .uint32), + 42: .scaled("avg_stroke_distance", units: "m", scale: 100, offset: 0, baseType: .uint16), + 43: .plain("swim_stroke", units: "swim_stroke", baseType: .enumType), + 44: .scaled("pool_length", units: "m", scale: 100, offset: 0, baseType: .uint16), + 45: .plain("threshold_power", units: "watts", baseType: .uint16), + 46: .plain("pool_length_unit", units: "", baseType: .enumType), + 47: .plain("num_active_lengths", units: "lengths", baseType: .uint16), + 48: .plain("total_work", units: "J", baseType: .uint32), + 49: .scaled("avg_altitude", units: "m", scale: 5, offset: 500, baseType: .uint16), + 50: .scaled("max_altitude", units: "m", scale: 5, offset: 500, baseType: .uint16), + 51: .plain("gps_accuracy", units: "m", baseType: .uint8), + 52: .scaled("avg_grade", units: "%", scale: 100, offset: 0, baseType: .sint16), + 53: .scaled("avg_pos_grade", units: "%", scale: 100, offset: 0, baseType: .sint16), + 54: .scaled("avg_neg_grade", units: "%", scale: 100, offset: 0, baseType: .sint16), + 55: .scaled("max_pos_grade", units: "%", scale: 100, offset: 0, baseType: .sint16), + 56: .scaled("max_neg_grade", units: "%", scale: 100, offset: 0, baseType: .sint16), + 57: .plain("avg_temperature", units: "C", baseType: .sint8), + 58: .plain("max_temperature", units: "C", baseType: .sint8), + 59: .scaled("total_moving_time", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 60: .scaled("avg_pos_vertical_speed", units: "m/s", scale: 1000, offset: 0, baseType: .sint16), + 61: .scaled("avg_neg_vertical_speed", units: "m/s", scale: 1000, offset: 0, baseType: .sint16), + 62: .scaled("max_pos_vertical_speed", units: "m/s", scale: 1000, offset: 0, baseType: .sint16), + 63: .scaled("max_neg_vertical_speed", units: "m/s", scale: 1000, offset: 0, baseType: .sint16), + 64: .plain("min_heart_rate", units: "bpm", baseType: .uint8), + 65: .scaled("time_in_hr_zone", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 66: .scaled("time_in_speed_zone", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 67: .scaled("time_in_cadence_zone", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 68: .scaled("time_in_power_zone", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 69: .scaled("avg_lap_time", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 70: .plain("best_lap_index", units: "", baseType: .uint16), + 71: .scaled("min_altitude", units: "m", scale: 5, offset: 500, baseType: .uint16), + 78: .scaled("active_time", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 82: .plain("player_score", units: "", baseType: .uint16), + 83: .plain("opponent_score", units: "", baseType: .uint16), + 84: .plain("opponent_name", units: "", baseType: .string), + 85: .plain("stroke_count", units: "counts", baseType: .uint16), + 86: .plain("zone_count", units: "counts", baseType: .uint16), + 87: .scaled("max_ball_speed", units: "m/s", scale: 100, offset: 0, baseType: .uint16), + 88: .scaled("avg_ball_speed", units: "m/s", scale: 100, offset: 0, baseType: .uint16), + 89: .scaled("avg_vertical_oscillation", units: "mm", scale: 10, offset: 0, baseType: .uint16), + 90: .scaled("avg_stance_time_percent", units: "percent", scale: 100, offset: 0, baseType: .uint16), + 91: .scaled("avg_stance_time", units: "ms", scale: 10, offset: 0, baseType: .uint16), + 92: .scaled("avg_fractional_cadence", units: "rpm", scale: 128, offset: 0, baseType: .uint8), + 93: .scaled("max_fractional_cadence", units: "rpm", scale: 128, offset: 0, baseType: .uint8), + 94: .scaled("total_fractional_cycles", units: "cycles", scale: 128, offset: 0, baseType: .uint8), + 95: .scaled("avg_total_hemoglobin_conc", units: "g/dL", scale: 100, offset: 0, baseType: .uint16), + 96: .scaled("min_total_hemoglobin_conc", units: "g/dL", scale: 100, offset: 0, baseType: .uint16), + 97: .scaled("max_total_hemoglobin_conc", units: "g/dL", scale: 100, offset: 0, baseType: .uint16), + 98: .scaled("avg_saturated_hemoglobin_percent", units: "%", scale: 10, offset: 0, baseType: .uint16), + 99: .scaled("min_saturated_hemoglobin_percent", units: "%", scale: 10, offset: 0, baseType: .uint16), + 100: .scaled("max_saturated_hemoglobin_percent", units: "%", scale: 10, offset: 0, baseType: .uint16), + 101: .scaled("avg_left_torque_effectiveness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 102: .scaled("avg_right_torque_effectiveness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 103: .scaled("avg_left_pedal_smoothness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 104: .scaled("avg_right_pedal_smoothness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 105: .scaled("avg_combined_pedal_smoothness", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 110: .plain("sport_profile_name", units: "", baseType: .string), + 111: .plain("sport_index", units: "", baseType: .uint8), + 112: .scaled("time_standing", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 113: .plain("stand_count", units: "", baseType: .uint16), + 114: .plain("avg_left_pco", units: "mm", baseType: .sint8), + 115: .plain("avg_right_pco", units: "mm", baseType: .sint8), + 116: .scaled("avg_left_power_phase", units: "degrees", scale: 0.7111111, offset: 0, baseType: .uint8), + 117: .scaled("avg_left_power_phase_peak", units: "degrees", scale: 0.7111111, offset: 0, baseType: .uint8), + 118: .scaled("avg_right_power_phase", units: "degrees", scale: 0.7111111, offset: 0, baseType: .uint8), + 119: .scaled("avg_right_power_phase_peak", units: "degrees", scale: 0.7111111, offset: 0, baseType: .uint8), + 120: .plain("avg_power_position", units: "watts", baseType: .uint16), + 121: .plain("max_power_position", units: "watts", baseType: .uint16), + 122: .plain("avg_cadence_position", units: "rpm", baseType: .uint8), + 123: .plain("max_cadence_position", units: "rpm", baseType: .uint8), + 124: .scaled("enhanced_avg_speed", units: "m/s", scale: 1000, offset: 0, baseType: .uint32), + 125: .scaled("enhanced_max_speed", units: "m/s", scale: 1000, offset: 0, baseType: .uint32), + 126: .scaled("enhanced_avg_altitude", units: "m", scale: 5, offset: 500, baseType: .uint32), + 127: .scaled("enhanced_min_altitude", units: "m", scale: 5, offset: 500, baseType: .uint32), + 128: .scaled("enhanced_max_altitude", units: "m", scale: 5, offset: 500, baseType: .uint32), + 129: .plain("avg_lev_motor_power", units: "watts", baseType: .uint16), + 130: .plain("max_lev_motor_power", units: "watts", baseType: .uint16), + 131: .scaled("lev_battery_consumption", units: "percent", scale: 2, offset: 0, baseType: .uint8), + 132: .scaled("avg_vertical_ratio", units: "percent", scale: 100, offset: 0, baseType: .uint16), + 133: .scaled("avg_stance_time_balance", units: "percent", scale: 100, offset: 0, baseType: .uint16), + 134: .scaled("avg_step_length", units: "mm", scale: 10, offset: 0, baseType: .uint16), + 137: .scaled("total_anaerobic_training_effect", units: "", scale: 10, offset: 0, baseType: .uint8), + 139: .scaled("avg_vam", units: "m/s", scale: 1000, offset: 0, baseType: .uint16), + 140: .scaled("avg_depth", units: "m", scale: 1000, offset: 0, baseType: .uint32), + 141: .scaled("max_depth", units: "m", scale: 1000, offset: 0, baseType: .uint32), + 142: .plain("surface_interval", units: "s", baseType: .uint32), + 143: .plain("start_cns", units: "percent", baseType: .uint8), + 144: .plain("end_cns", units: "percent", baseType: .uint8), + 145: .plain("start_n2", units: "percent", baseType: .uint16), + 146: .plain("end_n2", units: "percent", baseType: .uint16), + 147: .plain("avg_respiration_rate", units: "", baseType: .uint8), + 148: .plain("max_respiration_rate", units: "", baseType: .uint8), + 149: .plain("min_respiration_rate", units: "", baseType: .uint8), + 150: .plain("min_temperature", units: "C", baseType: .sint8), + 155: .plain("o2_toxicity", units: "OTUs", baseType: .uint16), + 156: .plain("dive_number", units: "", baseType: .uint32), + 168: .scaled("training_load_peak", units: "", scale: 65536, offset: 0, baseType: .sint32), + 169: .scaled("enhanced_avg_respiration_rate", units: "Breaths/min", scale: 100, offset: 0, baseType: .uint16), + 170: .scaled("enhanced_max_respiration_rate", units: "Breaths/min", scale: 100, offset: 0, baseType: .uint16), + 180: .scaled("enhanced_min_respiration_rate", units: "", scale: 100, offset: 0, baseType: .uint16), + 181: .plain("total_grit", units: "kGrit", baseType: .float32), + 182: .plain("total_flow", units: "Flow", baseType: .float32), + 183: .plain("jump_count", units: "", baseType: .uint16), + 186: .plain("avg_grit", units: "kGrit", baseType: .float32), + 187: .plain("avg_flow", units: "Flow", baseType: .float32), + 192: .plain("workout_feel", units: "", baseType: .uint8), + 193: .plain("workout_rpe", units: "", baseType: .uint8), + 194: .plain("avg_spo2", units: "percent", baseType: .uint8), + 195: .plain("avg_stress", units: "percent", baseType: .uint8), + 196: .plain("metabolic_calories", units: "kcal", baseType: .uint16), + 197: .plain("sdrr_hrv", units: "mS", baseType: .uint8), + 198: .plain("rmssd_hrv", units: "mS", baseType: .uint8), + 199: .scaled("total_fractional_ascent", units: "m", scale: 100, offset: 0, baseType: .uint8), + 200: .scaled("total_fractional_descent", units: "m", scale: 100, offset: 0, baseType: .uint8), + 208: .scaled("avg_core_temperature", units: "C", scale: 100, offset: 0, baseType: .uint16), + 209: .scaled("min_core_temperature", units: "C", scale: 100, offset: 0, baseType: .uint16), + 210: .scaled("max_core_temperature", units: "C", scale: 100, offset: 0, baseType: .uint16), ] // Message 21: event private static let eventFields: [Int: FieldMeta] = [ - 253: .plain("timestamp", fieldType: "date_time"), - 0: .plain("event", fieldType: "event"), - 1: .plain("event_type", fieldType: "event_type"), - 2: .scaled("data16", units: "", scale: 1.0), - 3: .scaled("data", units: "", scale: 1.0), - 4: .scaled("data_extension", units: "", scale: 1.0), - 5: .scaled("unknown_extension", units: "", scale: 1.0), + 253: .plain("timestamp", units: "s", baseType: .uint32, isDate: true), + 0: .plain("event", units: "", baseType: .enumType), + 1: .plain("event_type", units: "", baseType: .enumType), + 2: .plain("data16", units: "", baseType: .uint16), + 3: .plain("data", units: "", baseType: .uint32), + 4: .plain("event_group", units: "", baseType: .uint8), + 7: .plain("score", units: "", baseType: .uint16), + 8: .plain("opponent_score", units: "", baseType: .uint16), + 9: .plain("front_gear_num", units: "", baseType: .uint8z), + 10: .plain("front_gear", units: "", baseType: .uint8z), + 11: .plain("rear_gear_num", units: "", baseType: .uint8z), + 12: .plain("rear_gear", units: "", baseType: .uint8z), + 13: .plain("device_index", units: "", baseType: .uint8), + 14: .plain("activity_type", units: "", baseType: .enumType), + 15: .plain("start_timestamp", units: "s", baseType: .uint32, isDate: true), + 21: .plain("radar_threat_level_max", units: "", baseType: .enumType), + 22: .plain("radar_threat_count", units: "", baseType: .uint8), + 23: .scaled("radar_threat_avg_approach_speed", units: "m/s", scale: 10, offset: 0, baseType: .uint8), + 24: .scaled("radar_threat_max_approach_speed", units: "m/s", scale: 10, offset: 0, baseType: .uint8), ] // Message 34: activity private static let activityFields: [Int: FieldMeta] = [ - 253: .plain("timestamp", fieldType: "date_time"), - 0: .scaled("total_timer_time", units: "s", scale: 1000.0), - 1: .scaled("num_sessions", units: "", scale: 1.0), - 2: .plain("type", fieldType: "activity"), - 3: .plain("event", fieldType: "event"), - 4: .plain("event_type", fieldType: "event_type"), - 5: .plain("local_timestamp", fieldType: "date_time"), - 6: .plain("num_lengths", fieldType: "uint16"), + 253: .plain("timestamp", units: "", baseType: .uint32, isDate: true), + 0: .scaled("total_timer_time", units: "s", scale: 1000, offset: 0, baseType: .uint32), + 1: .plain("num_sessions", units: "", baseType: .uint16), + 2: .plain("type", units: "", baseType: .enumType), + 3: .plain("event", units: "", baseType: .enumType), + 4: .plain("event_type", units: "", baseType: .enumType), + 5: .plain("local_timestamp", units: "", baseType: .uint32, isDate: true), + 6: .plain("event_group", units: "", baseType: .uint8), ] // Message 23: device_info private static let deviceInfoFields: [Int: FieldMeta] = [ - 253: .plain("timestamp", fieldType: "date_time"), - 0: .plain("device_index", fieldType: "uint8"), - 1: .plain("device_type", fieldType: "uint8"), - 2: .plain("manufacturer", fieldType: "manufacturer"), - 3: .plain("serial_number", fieldType: "uint32z"), - 4: .plain("product", fieldType: "uint16"), - 5: .scaled("software_version", units: "", scale: 100.0), - 6: .plain("hardware_version", fieldType: "uint8"), - 7: .scaled("battery_voltage", units: "V", scale: 256.0), - 8: .plain("battery_status", fieldType: "battery_status"), - 9: .plain("sensor_position", fieldType: "body_location"), - 10: .plain("descriptor", fieldType: "string"), - 11: .plain("ant_transmission_type", fieldType: "uint8z"), - 12: .plain("ant_device_number", fieldType: "uint16z"), - 13: .plain("ant_network", fieldType: "ant_network"), - 14: .plain("source_type", fieldType: "source_type"), - 15: .plain("product_name", fieldType: "string"), + 253: .plain("timestamp", units: "s", baseType: .uint32, isDate: true), + 0: .plain("device_index", units: "", baseType: .uint8), + 1: .plain("device_type", units: "", baseType: .uint8), + 2: .plain("manufacturer", units: "", baseType: .uint16), + 3: .plain("serial_number", units: "", baseType: .uint32z), + 4: .plain("product", units: "", baseType: .uint16), + 5: .scaled("software_version", units: "", scale: 100, offset: 0, baseType: .uint16), + 6: .plain("hardware_version", units: "", baseType: .uint8), + 7: .plain("cum_operating_time", units: "s", baseType: .uint32), + 10: .scaled("battery_voltage", units: "V", scale: 256, offset: 0, baseType: .uint16), + 11: .plain("battery_status", units: "", baseType: .uint8), + 18: .plain("sensor_position", units: "", baseType: .enumType), + 19: .plain("descriptor", units: "", baseType: .string), + 20: .plain("ant_transmission_type", units: "", baseType: .uint8z), + 21: .plain("ant_device_number", units: "", baseType: .uint16z), + 22: .plain("ant_network", units: "", baseType: .enumType), + 25: .plain("source_type", units: "", baseType: .enumType), + 27: .plain("product_name", units: "", baseType: .string), + 32: .plain("battery_level", units: "%", baseType: .uint8), ] } diff --git a/Sources/GISToolsFIT/FITTypes.swift b/Sources/GISToolsFIT/FITTypes.swift index e2bcfe7..95bfb21 100644 --- a/Sources/GISToolsFIT/FITTypes.swift +++ b/Sources/GISToolsFIT/FITTypes.swift @@ -50,47 +50,6 @@ public enum FITRecordType: String, Equatable, Sendable { } -// MARK: - Known manufacturer and sport types - -/// Manufacturer identifiers from the FIT Profile. -public enum FITManufacturer: UInt16, Equatable, Sendable { - case garmin = 1 - case garminFr405 = 2 - case garminFr305 = 3 - case garminFr310xt = 4 - case garminFr405mx = 5 - case garminFr50 = 6 - case garminFr10 = 7 - case garminFr110 = 8 - case dynastream = 9 - case dynastreamOem = 10 - case polar = 12 - case suunto = 17 - case bryton = 20 - case wahooFitness = 26 - case stryd = 44 - case unknown = 0xFFFF - - public init(_ value: UInt16) { - self = FITManufacturer(rawValue: value) ?? .unknown - } -} - -/// Sport types from the FIT Profile. -public enum FITSportType: UInt8, Equatable, Sendable { - case generic = 0 - case running = 1 - case cycling = 2 - case swimming = 4 - case hiking = 7 - case walking = 9 - case unknown = 0xFF - - public init(_ value: UInt8) { - self = FITSportType(rawValue: value) ?? .unknown - } -} - // MARK: - Semicircle conversion /// FIT math and conversion helpers. @@ -109,6 +68,13 @@ public enum FIT { epoch.addingTimeInterval(TimeInterval(timestamp)) } + /// Converts a Date to an ISO8601 string (with fractional seconds). + public static func iso8601String(from date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } + /// Converts a FIT semicircle value to decimal degrees. /// - Parameter semicircles: The raw semicircle value (sint32). /// - Returns: Degrees in WGS84. diff --git a/Sources/GISToolsFIT/Feature+FIT.swift b/Sources/GISToolsFIT/Feature+FIT.swift index f2e6f30..a780686 100644 --- a/Sources/GISToolsFIT/Feature+FIT.swift +++ b/Sources/GISToolsFIT/Feature+FIT.swift @@ -19,6 +19,26 @@ extension Feature { } } + /// Returns the human-readable name for an enum-typed property value. + /// + /// FIT stores enum fields (e.g. `event`, `sport`, `event_type`) as raw + /// integers. This maps a property's integer value to its name. + /// + /// - Parameter key: The property key (e.g. `"event"`). + /// - Returns: The enum name (e.g. `"lap"`), or `nil` if the property is + /// not an enum or the value is unknown. + public func fitEnumName(for key: String) -> String? { + guard let value = properties[key] else { return nil } + let intValue: Int + if let i = value as? Int { intValue = i } + else if let i64 = value as? Int64 { intValue = Int(i64) } + else if let u8 = value as? UInt8 { intValue = Int(u8) } + else if let u16 = value as? UInt16 { intValue = Int(u16) } + else if let u32 = value as? UInt32 { intValue = Int(u32) } + else { return nil } + return FIT.name(for: key, value: intValue) + } + /// Timestamp of this record (seconds since FIT epoch). public var fitTimestamp: Date? { get { dateProp("fit_timestamp") } @@ -178,10 +198,12 @@ extension Feature { return alts.contains { $0 != nil } ? alts : nil } - /// Raw FIT timestamps per track point (Date). Maps nil timestamps to nil. + /// Timestamps per track point (Date). Maps nil timestamps to nil. public var fitTimestamps: [Date?]? { - guard let raw = properties["fit_timestamps"] as? [UInt32?] else { return nil } - return raw.map { $0.map { FIT.dateFromTimestamp($0) } } + guard let raw = properties["fit_timestamps"] as? [String?] else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return raw.map { $0.flatMap { formatter.date(from: $0) } } } // MARK: - Point feature conversion @@ -199,7 +221,7 @@ extension Feature { let spd = fitSpeeds ?? [] let tmp = fitTemperatures ?? [] let alt = fitAltitudes ?? [] - let ts = properties["fit_timestamps"] as? [UInt32?] ?? [] + let ts = properties["fit_timestamps"] as? [String?] ?? [] var features: [Feature] = [] for i in 0.. FeatureCollection { - guard let ts = properties["fit_timestamps"] as? [UInt32?] else { + guard let ts = properties["fit_timestamps"] as? [String?] else { return fitPointFeatures() } - let startSec = UInt32(startDate.timeIntervalSince(FIT.epoch)) - let endSec = UInt32(endDate.timeIntervalSince(FIT.epoch)) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let startSec = startDate.timeIntervalSince(FIT.epoch) + let endSec = endDate.timeIntervalSince(FIT.epoch) let indices = ts.indices.filter { i in - guard let t = ts[i] else { return false } + guard let s = ts[i], let date = formatter.date(from: s) else { return false } + let t = date.timeIntervalSince(FIT.epoch) return t >= startSec && t <= endSec } return sliceFeatures(at: indices) diff --git a/Sources/GISToolsFIT/FeatureCollection+FIT.swift b/Sources/GISToolsFIT/FeatureCollection+FIT.swift index 314b90a..c1e1b1c 100644 --- a/Sources/GISToolsFIT/FeatureCollection+FIT.swift +++ b/Sources/GISToolsFIT/FeatureCollection+FIT.swift @@ -67,7 +67,7 @@ extension FeatureCollection { var powers: [Int?] = [] var speeds: [Double?] = [] var temperatures: [Double?] = [] - var timestamps: [UInt32?] = [] + var timestamps: [String?] = [] for pf in pointFeatures { let p = pf.properties @@ -76,7 +76,7 @@ extension FeatureCollection { powers.append(p["power"] as? Int) speeds.append(p["speed"] as? Double) temperatures.append(p["temperature"] as? Double) - timestamps.append(p["timestamp"] as? UInt32) + timestamps.append(p["timestamp"] as? String) } if heartRates.contains(where: { $0 != nil }) { feature.properties["fit_heart_rates"] = heartRates } @@ -103,9 +103,20 @@ extension FeatureCollection { /// The sport type of the activity (from session data). public var fitSport: String? { + // Session metadata (including sport) is merged onto the record track + // Feature, so look there first. + if let track = fitRecords.first, + let sport = track.properties["sport"] { + return "\(sport)" + } let act = foreignMembers["fit_activity"] as? [String: Sendable] - return act?["sport"] as? String ?? - act?["sub_sport"] as? String + if let sport = act?["sport"] { + return "\(sport)" + } + if let sub = act?["sub_sport"] { + return "\(sub)" + } + return nil } } diff --git a/Tests/GISToolsFITTests/SampleFITDecodeTests.swift b/Tests/GISToolsFITTests/SampleFITDecodeTests.swift new file mode 100644 index 0000000..abee0bd --- /dev/null +++ b/Tests/GISToolsFITTests/SampleFITDecodeTests.swift @@ -0,0 +1,374 @@ +import Foundation +import Testing +@testable import GISTools +@testable import GISToolsFIT + +/// Decodes the bundled `TestData/sample.fit` (generated with the Garmin FIT +/// Python SDK) and verifies that every value written into the file is decoded +/// back correctly by the library's clean-room decoder. +struct SampleFITDecodeTests { + + /// Loads the bundled sample FIT file. + private func sampleData() throws -> Data { + guard let url = Bundle.module.url(forResource: "sample", withExtension: "fit", subdirectory: "TestData") else { + throw TestError.missingResource + } + return try Data(contentsOf: url) + } + + enum TestError: Error { + case missingResource + } + + // MARK: - Message counts + + @Test + func decodesAllMessages() async throws { + let data = try sampleData() + let messages = try FITDecoder.decode(data: data, verifyCRC: true) + + let counts = Dictionary(grouping: messages, by: { $0.globalMessageNumber }) + #expect(counts[FITProfile.MesgNum.fileId.rawValue]?.count == 1) + #expect(counts[FITProfile.MesgNum.activity.rawValue]?.count == 1) + #expect(counts[FITProfile.MesgNum.session.rawValue]?.count == 1) + #expect(counts[19]?.count == 3) // lap (global 19) + #expect(counts[FITProfile.MesgNum.record.rawValue]?.count == 30) + #expect(counts[FITProfile.MesgNum.event.rawValue]?.count == 2) + #expect(counts[FITProfile.MesgNum.deviceInfo.rawValue]?.count == 1) + } + + // MARK: - File ID + + @Test + func decodesFileId() async throws { + let data = try sampleData() + let messages = try FITDecoder.decode(data: data, verifyCRC: true) + let fileId = try #require(messages.first { $0.globalMessageNumber == FITProfile.MesgNum.fileId.rawValue }) + + // type = activity (4) + #expect(fileId.fields[0] as? UInt8 == 4) + // manufacturer = garmin (1) + #expect(fileId.fields[1] as? UInt16 == 1) + // serial_number = 0x0123456789ABCDEF truncated to uint32z + #expect(fileId.fields[3] as? UInt32 == 0x89ABCDEF) + // number = 1 + #expect(fileId.fields[5] as? UInt16 == 1) + // product_name = "Garmin Forerunner 255" + #expect(fileId.fields[8] as? String == "Garmin Forerunner 255") + } + + // MARK: - Session + + @Test + func decodesSession() async throws { + let data = try sampleData() + let messages = try FITDecoder.decode(data: data, verifyCRC: true) + let session = try #require(messages.first { $0.globalMessageNumber == FITProfile.MesgNum.session.rawValue }) + + // sport = running (1) + #expect(session.fields[5] as? UInt8 == 1) + // total_distance = 12000.0 m -> raw 1200000 (scale 100) + #expect(session.fields[9] as? UInt32 == 1_200_000) + // avg_heart_rate = 145 + #expect(session.fields[16] as? UInt8 == 145) + // max_heart_rate = 175 + #expect(session.fields[17] as? UInt8 == 175) + // total_calories = 800 + #expect(session.fields[11] as? UInt16 == 800) + // num_laps = 3 + #expect(session.fields[26] as? UInt16 == 3) + } + + // MARK: - Record + + @Test + func decodesRecord() async throws { + let data = try sampleData() + let messages = try FITDecoder.decode(data: data, verifyCRC: true) + let records = messages.filter { $0.globalMessageNumber == FITProfile.MesgNum.record.rawValue } + #expect(records.count == 30) + + let first = try #require(records.first) + // position_lat = 510000000 + #expect(first.fields[0] as? Int32 == 510_000_000) + // position_long = 90000000 + #expect(first.fields[1] as? Int32 == 90_000_000) + // altitude = 100.0 m -> raw 3000 (scale 5, offset 500) + #expect(first.fields[2] as? UInt16 == 3000) + // heart_rate = 140 + #expect(first.fields[3] as? UInt8 == 140) + // cadence = 170 + #expect(first.fields[4] as? UInt8 == 170) + // speed = 3.333 m/s -> raw 3333 (scale 1000) + #expect(first.fields[6] as? UInt16 == 3333) + // power = 250 + #expect(first.fields[7] as? UInt16 == 250) + // temperature = 20 C + #expect(first.fields[13] as? Int8 == 20) + // enhanced_speed = 3.333 -> raw 3333 + #expect(first.fields[73] as? UInt32 == 3333) + // enhanced_altitude = 100.0 -> raw 3000 + #expect(first.fields[78] as? UInt32 == 3000) + } + + // MARK: - Device Info + + @Test + func decodesDeviceInfo() async throws { + let data = try sampleData() + let messages = try FITDecoder.decode(data: data, verifyCRC: true) + let device = try #require(messages.first { $0.globalMessageNumber == FITProfile.MesgNum.deviceInfo.rawValue }) + + // device_index = 0 + #expect(device.fields[0] as? UInt8 == 0) + // manufacturer = garmin (1) + #expect(device.fields[2] as? UInt16 == 1) + // software_version = 12.34 -> raw 1234 (scale 100) + #expect(device.fields[5] as? UInt16 == 1234) + // hardware_version = 1 + #expect(device.fields[6] as? UInt8 == 1) + // battery_voltage = 4.1 V -> raw 1050 (scale 256) + #expect(device.fields[10] as? UInt16 == 1050) + // battery_status = good (2) + #expect(device.fields[11] as? UInt8 == 2) + // sensor_position = left_leg (0) + #expect(device.fields[18] as? UInt8 == 0) + // descriptor = "Forerunner 255" + #expect(device.fields[19] as? String == "Forerunner 255") + // product_name = "Garmin Forerunner 255" + #expect(device.fields[27] as? String == "Garmin Forerunner 255") + } + + // MARK: - FITCoder integration + + @Test + func fitCoderReadsSample() async throws { + let data = try sampleData() + let fc = try FITCoder.read(from: data) + + let tracks = fc.features.filter { $0.properties["fit_type"] as? String == "record" } + #expect(tracks.count == 1) + guard let track = tracks.first else { + #expect(Bool(false), "Missing record track") + return + } + + // 30 GPS-bearing records -> 30 coordinates + let multiline = try #require(track.geometry as? MultiLineString) + let coords = multiline.lineStrings.flatMap { $0.coordinates } + #expect(coords.count == 30) + + // First point: lat 510000000 semicircles, lon 90000000 semicircles + let first = try #require(coords.first) + #expect(abs(first.latitude - FIT.degreesFromSemicircles(510_000_000)) < 0.0001) + #expect(abs(first.longitude - FIT.degreesFromSemicircles(90_000_000)) < 0.0001) + // altitude 100.0 m + #expect(abs((first.altitude ?? 0) - 100.0) < 0.01) + + // Sensor arrays + #expect(track.fitHeartRates?.first == 140) + #expect(track.fitCadences?.first == 170) + #expect(track.fitPowers?.first == 250) + if let speed = track.fitSpeeds?.first ?? nil { + #expect(abs(speed - 3.333) < 0.001) + } else { + #expect(Bool(false), "Missing speed") + } + if let temp = track.fitTemperatures?.first ?? nil { + #expect(abs(temp - 20.0) < 0.001) + } else { + #expect(Bool(false), "Missing temperature") + } + } + + // MARK: - Session metadata mapping + + @Test + func sessionMetadataMappedToTrack() async throws { + let data = try sampleData() + let fc = try FITCoder.read(from: data) + guard let track = fc.features.first(where: { $0.fitType == .record }) else { + #expect(Bool(false), "Missing record track") + return + } + + // sport = running (1) + #expect(track.properties["sport"] as? Int64 == 1) + // total_distance = 12000.0 m + #expect(track.properties["total_distance"] as? Double == 12000.0) + // avg_heart_rate = 145 + #expect(track.properties["avg_heart_rate"] as? Int64 == 145) + // num_laps = 3 + #expect(track.properties["num_laps"] as? Int64 == 3) + // total_calories = 800 + #expect(track.properties["total_calories"] as? Int64 == 800) + } + + @Test + func fitSportFromSession() async throws { + let data = try sampleData() + let fc = try FITCoder.read(from: data) + #expect(fc.fitSport == "1") // sport = running (1) + } + + // MARK: - Complete projection + + @Test + func lapsAreEmittedAsFeatures() async throws { + let data = try sampleData() + let fc = try FITCoder.read(from: data) + let laps = fc.features.filter { $0.fitType == .lap } + #expect(laps.count == 3) + // Each lap carries its metadata. + #expect(laps[0].properties["total_distance"] != nil) + #expect(laps[0].properties["avg_heart_rate"] != nil) + } + + @Test + func eventsAreEmittedAsFeatures() async throws { + let data = try sampleData() + let fc = try FITCoder.read(from: data) + let events = fc.features.filter { $0.fitType == .event } + #expect(events.count == 2) + // First event: timer start. + #expect(events[0].properties["event"] as? Int64 == 0) // timer + #expect(events[0].properties["event_type"] as? Int64 == 0) // start + } + + @Test + func deviceInfoIsFullyWritten() async throws { + let data = try sampleData() + let fc = try FITCoder.read(from: data) + let device = fc.fitDevice ?? [:] + // device_info fields are now present (previously dropped). + #expect(device["manufacturer"] != nil) + #expect(device["serial_number"] != nil) + #expect(device["software_version"] != nil) + #expect(device["battery_voltage"] != nil) + #expect(device["battery_status"] != nil) + #expect(device["sensor_position"] != nil) + #expect(device["descriptor"] as? String == "Forerunner 255") + #expect(device["product_name"] as? String == "Garmin Forerunner 255") + } + + @Test + func fileIdIsFullyWritten() async throws { + let data = try sampleData() + let fc = try FITCoder.read(from: data) + let device = fc.fitDevice ?? [:] + #expect(device["type"] != nil) + #expect(device["manufacturer"] != nil) + #expect(device["serial_number"] != nil) + #expect(device["time_created"] != nil) + #expect(device["number"] != nil) + #expect(device["product_name"] as? String == "Garmin Forerunner 255") + } + + @Test + func sessionFieldsAreFullyWritten() async throws { + let data = try sampleData() + let fc = try FITCoder.read(from: data) + guard let track = fc.features.first(where: { $0.fitType == .record }) else { + #expect(Bool(false), "Missing record track") + return + } + // A broad set of session fields should be present on the track. + #expect(track.properties["sport"] != nil) + #expect(track.properties["total_distance"] != nil) + #expect(track.properties["avg_heart_rate"] != nil) + #expect(track.properties["max_heart_rate"] != nil) + #expect(track.properties["total_calories"] != nil) + #expect(track.properties["total_ascent"] != nil) + #expect(track.properties["total_descent"] != nil) + #expect(track.properties["avg_power"] != nil) + #expect(track.properties["max_power"] != nil) + #expect(track.properties["avg_speed"] != nil) + #expect(track.properties["max_speed"] != nil) + #expect(track.properties["avg_cadence"] != nil) + #expect(track.properties["num_laps"] != nil) + #expect(track.properties["total_training_effect"] != nil) + #expect(track.properties["training_stress_score"] != nil) + #expect(track.properties["intensity_factor"] != nil) + #expect(track.properties["normalized_power"] != nil) + #expect(track.properties["avg_altitude"] != nil) + #expect(track.properties["max_altitude"] != nil) + #expect(track.properties["min_altitude"] != nil) + #expect(track.properties["avg_grade"] != nil) + #expect(track.properties["avg_temperature"] != nil) + #expect(track.properties["max_temperature"] != nil) + #expect(track.properties["total_moving_time"] != nil) + #expect(track.properties["min_heart_rate"] != nil) + #expect(track.properties["avg_spo2"] != nil) + #expect(track.properties["avg_stress"] != nil) + #expect(track.properties["metabolic_calories"] != nil) + #expect(track.properties["sdrr_hrv"] != nil) + #expect(track.properties["rmssd_hrv"] != nil) + #expect(track.properties["avg_core_temperature"] != nil) + } + + @Test + func roundTripPreservesMessageCounts() async throws { + let original = try sampleData() + let fc = try FITCoder.read(from: original) + let reencoded = try FITCoder.write(from: fc) + + let redecoded = try FITDecoder.decode(data: reencoded, verifyCRC: true) + let originalMessages = try FITDecoder.decode(data: original, verifyCRC: true) + + // Same total message count. + #expect(redecoded.count == originalMessages.count) + + // Same per-type counts. + func count(_ msgs: [FITDecoder.RawMessage], _ num: UInt16) -> Int { + msgs.filter { $0.globalMessageNumber == num }.count + } + #expect(count(redecoded, 0) == count(originalMessages, 0)) // file_id + #expect(count(redecoded, 34) == count(originalMessages, 34)) // activity + #expect(count(redecoded, 18) == count(originalMessages, 18)) // session + #expect(count(redecoded, 19) == count(originalMessages, 19)) // lap + #expect(count(redecoded, 20) == count(originalMessages, 20)) // record + #expect(count(redecoded, 21) == count(originalMessages, 21)) // event + #expect(count(redecoded, 23) == count(originalMessages, 23)) // device_info + } + + // MARK: - Enum name mapping + + @Test + func enumNamesAreMapped() async throws { + let data = try sampleData() + let fc = try FITCoder.read(from: data) + + // Direct lookup. + #expect(FIT.name(for: "event", value: 9) == "lap") + #expect(FIT.name(for: "event_type", value: 1) == "stop") + #expect(FIT.name(for: "sport", value: 1) == "running") + #expect(FIT.name(for: "event", value: 999) == nil) + + // Via a Feature's properties. + let event = try #require(fc.features.first { $0.fitType == .event }) + #expect(event.fitEnumName(for: "event") == "timer") + #expect(event.fitEnumName(for: "event_type") == "start") + + let lap = try #require(fc.features.first { $0.fitType == .lap }) + #expect(lap.fitEnumName(for: "sport") == "running") + } + + @Test + func enumNameFieldAliasesResolve() async throws { + // Field names that differ from their enum type name resolve correctly. + #expect(FIT.name(for: "trigger", value: 0) == "activity_end") + #expect(FIT.name(for: "pool_length_unit", value: 0) == "metric") + #expect(FIT.name(for: "sensor_position", value: 0) == "left_leg") + #expect(FIT.name(for: "radar_threat_level_max", value: 1) == "threat_none") + #expect(FIT.name(for: "type", value: 4) == "activity") + } + + @Test + func manufacturerNamesAreMapped() async throws { + #expect(FIT.name(for: "manufacturer", value: 1) == "garmin") + #expect(FIT.name(for: "manufacturer", value: 15) == "dynastream") + #expect(FIT.name(for: "manufacturer", value: 32) == "wahoo_fitness") + #expect(FIT.name(for: "manufacturer", value: 95) == "stryd") + #expect(FIT.name(for: "manufacturer", value: 9999) == nil) + } +} diff --git a/Tests/GISToolsFITTests/TestData/sample.fit b/Tests/GISToolsFITTests/TestData/sample.fit new file mode 100644 index 0000000000000000000000000000000000000000..d6699dc9125f91edda4d0d2d4bd58fa096ee273b GIT binary patch literal 4447 zcmdtmk8>2|83*v^dG394muxNx$q^vHa`H1E#1IHjL|hOMS_=_lrIdwaFbhoi8L>oL zW>qq;jMR zq!@|bBq~YtA<-A9ABidw{gDPB3}h*jr3SH7oTY9fF_^><5<^K0BQcz%MzGXK=8hsU zn#33qH3+rL9ZRB)#5kn!l&q)Z1WHb%LFy!Nj>J3igaSm`vsT1Alb^PjiErETJmtG4f;z8> zdF4=60uT?i0gt9Do!r?cB)l*;qEN4;epxN&UtnNu z=&#>u>wMiv{ki)7<~r&xLQj#}V;%)aSAhH#xg$jYiDG~SG_>MqD3-4Qx!V948LA%n zmP?jgvce_JWBy3h6YKizDp_CBRdNqN1=V4WDO2BiCu<|F+8X%Ui*v>0g#(rV_eVeVSyu48U1b2pG^Bk>rC7g>56OTR?o zWtM)0rFS6hL<#|PxLK?4D|v`Nwaknxws~f!cg_=41#pmo9Ea69740$=S7j>dpc;S> zdvpbK>5dr#ZWg;8M`NtA3Cne{BVnt+iiY$`%2Uf37jNK^AOL$Ec1p@kQppX~#%< zTo5hSWf zj3hA%VLXX?q^U@Am@C`t10)(rJV@dp5=)U*l4xb=M_7723E5_uo^rkj%`s!^Zq-M4 z^cvJbtXnOsS2}bB`@s}Y8AaG_>8_+YlGlkknq!?|ulvBXOzEx?w)Uy4wfyrIbNzib zKW;p;$J(LLPc@n;1yDg&Xq6v59)NaBv?1h}%jeoWYdpI*F-gvw(d!zu2Fa0OduV?@ znhijkl38m~*fAG1*gR)Ew=Z{Ip`U3~?PX@GhYs+gxd60FqH)798|K^mq|v?~){#VS zXf!cZHt`c4I?#`v3_u%%oYzVnb4H`hPZ`f2P@RNFKi6n{EI4>kShsQ~nhM0?5| zbJF*0e%koi!Nltd1=;#y-RgW=vdgPN4f3O>15hiKwYn-DbMyk6zh%5|C^wTy6gAqk zO17`r9vb(fZv~*oB$}&o%=ki^pEZ7d7%nQ5GzuGKNH=)sZGQA@09q?MW{2sRu|+n2 z+j#Ma^#qq_NTaIq`PV#jupfOp099?ceXygam~+Oq-Ky2|DATAK2r6RgoDT7$=K|39 z(^;!+xMN;lV)OIHOM7xh70NZ5-y?GxB8?99qvr$A?GkMo<(NIqHhIc8Uj z&EGR#*`L@Fk!VDtxqrxD7orkQZ zB8eIr%@oS(+W{e6{ZW4OVgS1Qr&+6WnqzjXvibYQtA`WU6pkZ4q+`D%H^{J4jX_M_basG9UMa~ys3eaXn|hWcWOmS|MX z0;+4N5jDn-UJ5`vC7S-OV~R&?{(Kt6_EbqY72$M?VfgtDntUWzCK`d!x-iF?JnHTu^9Cqt-T=(~}-L&X0Z) zfMz6m^Mp&2JbThoMg| ziB@Vf@rKN4oa@7{_oFug&~}NQS?QQVlk(QH`1K>!5ry{AX#7o?(|H~`!H>eNTgCso zK${8B8`!N!3hE+LT?YanSGUjdPG}ZDABgHT(;}VW>)7#Rz;4KfeBv$iFxJ7ZAiXd;kCd literal 0 HcmV?d00001 From 5c1b1c412039aa54c13cebde3962eace64cd51b3 Mon Sep 17 00:00:00 2001 From: "Thomas Rasch (OpenCode)" Date: Tue, 11 Aug 2026 19:25:12 +0000 Subject: [PATCH 2/2] Update README and map fitSport to enum name --- README.md | 35 +++++++++++++++++-- .../GISToolsFIT/FeatureCollection+FIT.swift | 25 +++++++++---- .../SampleFITDecodeTests.swift | 2 +- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 200ce1c..c083fb2 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,12 @@ FIT (Flexible and Interoperable Data Transfer) file read **and write** support f - Pure Swift FIT decoder and encoder — no external dependencies - Reads and writes FIT 2.0 files with CRC validation - Record points → `Feature` with per-point sensor data as parallel arrays -- Lap boundaries split the track into `MultiLineString` segments +- Lap and Event messages → their own `Feature` markers with full metadata - Session, Lap, Event, File-ID, and Activity messages → `Feature` properties + `FeatureCollection` metadata - **Per-point arrays**: heart rate, cadence, power, speed, temperature, altitude, timestamps - **Typed convenience API** on `Feature` and `FeatureCollection` — 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 individual `Point` features with each record's sensor data - Time-window, distance-window, and fractional-window slicing of point data @@ -74,6 +76,10 @@ into the corresponding FIT messages. Round-trip read → write → read preserve 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 ```swift @@ -118,7 +124,7 @@ 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) +pts.features[0].properties["timestamp"] // ISO8601 date string ``` ### Time-based slicing @@ -180,9 +186,32 @@ let device = fc.fitDevice print(device?["manufacturer"]) // e.g. "Garmin" let activity = fc.fitActivity -print(fc.fitSport) // e.g. "Cycling" +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: + +```swift +// 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 ```swift diff --git a/Sources/GISToolsFIT/FeatureCollection+FIT.swift b/Sources/GISToolsFIT/FeatureCollection+FIT.swift index c1e1b1c..6853b39 100644 --- a/Sources/GISToolsFIT/FeatureCollection+FIT.swift +++ b/Sources/GISToolsFIT/FeatureCollection+FIT.swift @@ -101,22 +101,33 @@ extension FeatureCollection { set { foreignMembers["fit_device"] = newValue } } - /// The sport type of the activity (from session data). + /// The sport type of the activity (from session data), mapped to its + /// human-readable name (e.g. `"running"`). public var fitSport: String? { // Session metadata (including sport) is merged onto the record track // Feature, so look there first. if let track = fitRecords.first, - let sport = track.properties["sport"] { - return "\(sport)" + let sport = track.properties["sport"], + let intValue = intValue(sport) { + return FIT.name(for: "sport", value: intValue) } let act = foreignMembers["fit_activity"] as? [String: Sendable] - if let sport = act?["sport"] { - return "\(sport)" + if let sport = act?["sport"], let intValue = intValue(sport) { + return FIT.name(for: "sport", value: intValue) } - if let sub = act?["sub_sport"] { - return "\(sub)" + if let sub = act?["sub_sport"], let intValue = intValue(sub) { + return FIT.name(for: "sub_sport", value: intValue) } return nil } + private func intValue(_ value: Sendable) -> Int? { + if let i = value as? Int { return i } + if let i64 = value as? Int64 { return Int(i64) } + if let u8 = value as? UInt8 { return Int(u8) } + if let u16 = value as? UInt16 { return Int(u16) } + if let u32 = value as? UInt32 { return Int(u32) } + return nil + } + } diff --git a/Tests/GISToolsFITTests/SampleFITDecodeTests.swift b/Tests/GISToolsFITTests/SampleFITDecodeTests.swift index abee0bd..ed5563a 100644 --- a/Tests/GISToolsFITTests/SampleFITDecodeTests.swift +++ b/Tests/GISToolsFITTests/SampleFITDecodeTests.swift @@ -208,7 +208,7 @@ struct SampleFITDecodeTests { func fitSportFromSession() async throws { let data = try sampleData() let fc = try FITCoder.read(from: data) - #expect(fc.fitSport == "1") // sport = running (1) + #expect(fc.fitSport == "running") // sport = running (1) } // MARK: - Complete projection