From 597c6e62c0cf52dcc00026de14742e20a39ce229 Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 5 Mar 2025 12:46:28 -0500 Subject: [PATCH 001/104] WEB-5661 Add sport/subsport and manufacturer opts --- lib/rubyfit/message_constants.rb | 82 ++++++++++++++++++++++++++++++++ lib/rubyfit/message_writer.rb | 2 + lib/rubyfit/writer.rb | 8 ++-- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index d6b4293..2d578e0 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -80,4 +80,86 @@ module RubyFit::MessageConstants elev_low_alert: 46, # Group 0. Start / stop when in alert condition. comm_timeout: 47, # marker }.freeze + + SPORT = { + generic: 0, + running: 1, + cycling: 2, + transition: 3, + fitness_equipment: 4, + swimming: 5, + walking: 6, + sedentary: 8, + all: 254, + }.freeze + + SUBSPORT = { + generic: 0, + treadmill: 1, + street: 2, + trail: 3, + track: 4, + spin: 5, + indoor_cycling: 6, + road: 7, + mountain: 8, + downhill: 9, + recumbent: 10, + cyclocross: 11, + hand_cycling: 12, + track_cycling: 13, + indoor_rowing: 14, + elliptical: 15, + stair_climbing: 16, + lap_swimming: 17, + open_water: 18, + flexibility_training: 19, + strength_training: 20, + warm_up: 21, + match: 22, + exercise: 23, + challenge: 24, + indoor_skiing: 25, + cardio_training: 26, + indoor_walking: 27, + e_bike_fitness: 28, + bmx: 29, + casual_walking: 30, + speed_walking: 31, + bike_to_run_transition: 32, + run_to_bike_transition: 33, + swim_to_bike_transition: 34, + atv: 35, + motocross: 36, + backcountry: 37, + resort: 38, + rc_drone: 39, + wingsuit: 40, + whitewater: 41, + skate_skiing: 42, + yoga: 43, + pilates: 44, + indoor_running: 45, + gravel_cycling: 46, + e_bike_mountain: 47, + communting: 48, + mixed_surface: 49, + navigate: 50, + track_me: 51, + map: 52, + single_gas_diving: 53, + multi_gas_diving: 54, + gauge_diving: 55, + apnea_diving: 56, + apnea_hunting: 57, + virtual_activity: 58, + obstacle: 59, + breathing: 62, + sail_race: 65, + ultra: 67, + indoor_climbing: 68, + bouldering: 69, + all: 254 + }.freeze + end diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index cf6e82e..82091bf 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -37,6 +37,8 @@ class RubyFit::MessageWriter end_y: { id: 5, type: RubyFit::Type.semicircles }, end_x: { id: 6, type: RubyFit::Type.semicircles }, total_distance: { id: 9, type: RubyFit::Type.centimeters }, + sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, + subsport: { id: 24, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false } }, }, course_point: { diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index fb1995f..ea3b8f0 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -27,8 +27,8 @@ def write(stream, opts = {}) write_message(:file_id, { time_created: opts[:time_created], type: 6, # Course file - manufacturer: 1, # Garmin - product: PRODUCT_ID, + manufacturer: opts[:manufacturer], + product: opts[:product], serial_number: 0, }) @@ -43,7 +43,9 @@ def write(stream, opts = {}) start_y: opts[:start_y], end_x: opts[:end_x], end_y: opts[:end_y], - total_distance: opts[:total_distance] + total_distance: opts[:total_distance], + sport: opts[:sport], + subsport: opts[:subsport] }) write_message(:event, { From 7e53d6ec20d74ec958b3212e07852222860c7024 Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 5 Mar 2025 14:39:41 -0500 Subject: [PATCH 002/104] WEB-5661 change subsport id --- lib/rubyfit/message_writer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 82091bf..b4a4a80 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -38,7 +38,7 @@ class RubyFit::MessageWriter end_x: { id: 6, type: RubyFit::Type.semicircles }, total_distance: { id: 9, type: RubyFit::Type.centimeters }, sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, - subsport: { id: 24, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false } + subsport: { id: 26, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false } }, }, course_point: { From 8916f3bcc6316aea721bec3e1b19727a6ffd9f6c Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 5 Mar 2025 14:59:14 -0500 Subject: [PATCH 003/104] WEB-5661 change subsport id --- lib/rubyfit/message_writer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index b4a4a80..b66f185 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -38,7 +38,7 @@ class RubyFit::MessageWriter end_x: { id: 6, type: RubyFit::Type.semicircles }, total_distance: { id: 9, type: RubyFit::Type.centimeters }, sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, - subsport: { id: 26, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false } + subsport: { id: 32, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false } }, }, course_point: { From 2c178c8365f7c978122b72c5f3b7c60d26b6e392 Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 5 Mar 2025 15:02:47 -0500 Subject: [PATCH 004/104] WEB-5661 change subsport id --- lib/rubyfit/message_writer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index b66f185..5a6ed4b 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -38,7 +38,7 @@ class RubyFit::MessageWriter end_x: { id: 6, type: RubyFit::Type.semicircles }, total_distance: { id: 9, type: RubyFit::Type.centimeters }, sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, - subsport: { id: 32, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false } + subsport: { id: 39, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false } }, }, course_point: { From c8af9edc18168117556eae90a395e80d7c3612d9 Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 5 Mar 2025 15:11:42 -0500 Subject: [PATCH 005/104] WEB-5661 add ascent --- lib/rubyfit/message_writer.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 5a6ed4b..076e616 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -37,6 +37,7 @@ class RubyFit::MessageWriter end_y: { id: 5, type: RubyFit::Type.semicircles }, end_x: { id: 6, type: RubyFit::Type.semicircles }, total_distance: { id: 9, type: RubyFit::Type.centimeters }, + total_ascent: { id: 21, type: RubyFit::Type.centimeters }, sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, subsport: { id: 39, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false } }, From 3406396ae824b858a09722c26e24a2a0a5ccc4c6 Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 5 Mar 2025 15:16:08 -0500 Subject: [PATCH 006/104] WEB-5661 add ascent --- lib/rubyfit/writer.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index ea3b8f0..9c6a088 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -44,6 +44,7 @@ def write(stream, opts = {}) end_x: opts[:end_x], end_y: opts[:end_y], total_distance: opts[:total_distance], + total_ascent: opts[:total_ascent], sport: opts[:sport], subsport: opts[:subsport] }) From 091bef614e39a8d0ddbc21382809cf13ba806887 Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 5 Mar 2025 15:26:15 -0500 Subject: [PATCH 007/104] WEB-5661 add ascent --- lib/rubyfit/message_writer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 076e616..c3fc815 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -37,7 +37,7 @@ class RubyFit::MessageWriter end_y: { id: 5, type: RubyFit::Type.semicircles }, end_x: { id: 6, type: RubyFit::Type.semicircles }, total_distance: { id: 9, type: RubyFit::Type.centimeters }, - total_ascent: { id: 21, type: RubyFit::Type.centimeters }, + total_ascent: { id: 21, type: RubyFit::Type.altitude }, sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, subsport: { id: 39, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false } }, From 1fb67763e483c0ded2eaf5ac1a63b467e552289d Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 6 Mar 2025 14:14:39 -0500 Subject: [PATCH 008/104] WEB-5661 remove truncations --- lib/rubyfit/type.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index e44c2f8..96438b9 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -140,21 +140,21 @@ def semicircles def centimeters uint32({ - rb2fit: ->(val, type) { (val * 100).truncate }, + rb2fit: ->(val, type) { (val * 100) }, fit2rb: ->(val, type) { val / 100.0 } }) end def altitude uint16({ - rb2fit: ->(val, type) { ((val + 500) * 5).truncate }, + rb2fit: ->(val, type) { ((val + 500) * 5) }, fit2rb: ->(val, type) { val / 5.0 - 500 } }) end def duration uint32({ - rb2fit: ->(val, type) { (val * 1000).truncate }, + rb2fit: ->(val, type) { (val * 1000) }, fit2rb: ->(val, type) { val / 1000.0 } }) end From 8057f3329af4c95702a1e6513533c48d289cfb2b Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 6 Mar 2025 14:29:38 -0500 Subject: [PATCH 009/104] WEB-5661 remove truncations --- lib/rubyfit/helpers.rb | 2 +- lib/rubyfit/type.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/rubyfit/helpers.rb b/lib/rubyfit/helpers.rb index 5e215d9..a62ca33 100644 --- a/lib/rubyfit/helpers.rb +++ b/lib/rubyfit/helpers.rb @@ -88,7 +88,7 @@ def fit2unix_timestamp(timestamp) def deg2semicircles(degrees) - (degrees * DEGREES_TO_SEMICIRCLES).truncate + (degrees * DEGREES_TO_SEMICIRCLES) end def semicircles2deg(degrees) diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 96438b9..7609cd8 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -140,14 +140,14 @@ def semicircles def centimeters uint32({ - rb2fit: ->(val, type) { (val * 100) }, + rb2fit: ->(val, type) { (val * 100.0) }, fit2rb: ->(val, type) { val / 100.0 } }) end def altitude uint16({ - rb2fit: ->(val, type) { ((val + 500) * 5) }, + rb2fit: ->(val, type) { ((val + 500) * 5.0) }, fit2rb: ->(val, type) { val / 5.0 - 500 } }) end From 2608e49bd7e0c23b961ce334180c96f912a0dc97 Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 6 Mar 2025 14:56:45 -0500 Subject: [PATCH 010/104] WEB-5661 add truncations --- lib/rubyfit/helpers.rb | 2 +- lib/rubyfit/type.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/rubyfit/helpers.rb b/lib/rubyfit/helpers.rb index a62ca33..5e215d9 100644 --- a/lib/rubyfit/helpers.rb +++ b/lib/rubyfit/helpers.rb @@ -88,7 +88,7 @@ def fit2unix_timestamp(timestamp) def deg2semicircles(degrees) - (degrees * DEGREES_TO_SEMICIRCLES) + (degrees * DEGREES_TO_SEMICIRCLES).truncate end def semicircles2deg(degrees) diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 7609cd8..193fb01 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -140,14 +140,14 @@ def semicircles def centimeters uint32({ - rb2fit: ->(val, type) { (val * 100.0) }, + rb2fit: ->(val, type) { (val * 100).truncate }, fit2rb: ->(val, type) { val / 100.0 } }) end def altitude uint16({ - rb2fit: ->(val, type) { ((val + 500) * 5.0) }, + rb2fit: ->(val, type) { ((val + 500) * 5.0).truncate }, fit2rb: ->(val, type) { val / 5.0 - 500 } }) end From 2d062c38281726ad0a067a2a8402095066fa2b82 Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 11 Mar 2025 15:18:44 -0400 Subject: [PATCH 011/104] change course point name length max --- lib/rubyfit/message_writer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index c3fc815..b684683 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -49,7 +49,7 @@ class RubyFit::MessageWriter y: { id: 2, type: RubyFit::Type.semicircles, required: true }, x: { id: 3, type: RubyFit::Type.semicircles, required: true }, distance: { id: 4, type: RubyFit::Type.centimeters }, - name: { id: 6, type: RubyFit::Type.string(16) }, + name: { id: 6, type: RubyFit::Type.string(32) }, message_index: { id: 254, type: RubyFit::Type.uint16 }, type: { id: 5, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::COURSE_POINT_TYPE, required: true } }, From 7640746c8172ecd10ab064771a736fe2860331e1 Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 12 Mar 2025 08:27:45 -0400 Subject: [PATCH 012/104] change course point name length max --- lib/rubyfit/message_writer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index b684683..417e800 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -49,7 +49,7 @@ class RubyFit::MessageWriter y: { id: 2, type: RubyFit::Type.semicircles, required: true }, x: { id: 3, type: RubyFit::Type.semicircles, required: true }, distance: { id: 4, type: RubyFit::Type.centimeters }, - name: { id: 6, type: RubyFit::Type.string(32) }, + name: { id: 6, type: RubyFit::Type.string(48) }, message_index: { id: 254, type: RubyFit::Type.uint16 }, type: { id: 5, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::COURSE_POINT_TYPE, required: true } }, From 8f1b49f85f8f50f6259e27da316a4b45bdcb0d16 Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 13 Mar 2025 12:32:12 -0400 Subject: [PATCH 013/104] add more course point types --- lib/rubyfit/message_constants.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index 2d578e0..c1df623 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -26,6 +26,8 @@ module RubyFit::MessageConstants sharp_right: 22, u_turn: 23, segment_start: 24, + checkpoint: 35, + toilet: 39, segment_end: 25 }.freeze From 04aceec9c5db21c2692e912335eddc4bfb6a952a Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 20 Mar 2025 10:16:59 -0400 Subject: [PATCH 014/104] first commit for writing activity files --- lib/rubyfit/message_constants.rb | 88 +++++++++++++++++ lib/rubyfit/message_writer.rb | 130 ++++++++++++++++++++++++- lib/rubyfit/writer.rb | 160 ++++++++++++++++++++++++++++++- 3 files changed, 376 insertions(+), 2 deletions(-) diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index c1df623..8772677 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -164,4 +164,92 @@ module RubyFit::MessageConstants all: 254 }.freeze + DISPLAY_MEASURE = { + metric: 0, + stature: 1, + nautical: 2 + }.freeze + + DURATION_TYPE = { + time: 0, + distance: 1, + hr_less_than: 2, + hr_greater_than: 3, + calories: 4, + open: 5, + repeat_until_steps_cmplt: 6, + repeat_until_time: 7, + repeat_until_distance: 8, + repeat_until_calories: 9, + repeat_until_hr_less_than: 10, + repeat_until_hr_greater_than: 11, + repeat_until_power_less_than: 12, + repeat_until_power_greater_than: 13, + power_less_than: 14, + power_greater_than: 15, + training_peaks_tss: 16, + repeat_until_power_last_lap_less_than: 17, + repeat_until_max_power_last_lap_less_than: 18, + power_3s_less_than: 19, + power_10s_less_than: 20, + power_30s_less_than: 21, + power_3s_greater_than: 22, + power_10s_greater_than: 23, + power_30s_greater_than: 24, + power_lap_less_than: 25, + power_lap_greater_than: 26, + repeat_until_training_peaks_tss: 27, + repetition_time: 28, + reps: 29, + time_only: 31 + }.freeze + + TARGET_TYPE = { + speed: 0, + heart_rate: 1, + open: 2, + cadence: 3, + power: 4, + grade: 5, + resistance: 6, + power_3s: 7, + power_10s: 8, + power_30s: 9, + power_lap: 10, + swim_stroke: 11, + speed_lap: 12, + heart_rate_lap: 13 + }.freeze + + INTENSITY = { + active: 0, + rest: 1, + warmup: 2, + cooldown: 3, + recovery: 4, + interval: 5, + other: 6 + }.freeze + + WORKOUT_EQUIPMENT = { + none: 0, + swim_fins: 1, + swim_kickboard: 2, + swim_paddles: 3, + swim_pull_buoy: 4, + swim_snorkel: 5 + }.freeze + + ACTIVITY_TYPE = { + generic: 0, + running: 1, + cycling: 2, + transition: 3, + fitness_equipment: 4, + swimming: 5, + walking: 6, + sedentary: 8, + all: 254 + }.freeze + end diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 417e800..947dbee 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -39,7 +39,26 @@ class RubyFit::MessageWriter total_distance: { id: 9, type: RubyFit::Type.centimeters }, total_ascent: { id: 21, type: RubyFit::Type.altitude }, sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, - subsport: { id: 39, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false } + subsport: { id: 39, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false }, + + event: { id: 0, type: RubyFit::Type.enum(RubyFit::MessageConstants::EVENT), required: false }, + event_type: { id: 1, type: RubyFit::Type.enum(RubyFit::MessageConstants::EVENT_TYPE), required: false }, + avg_heart_rate: { id: 15, type: RubyFit::Type.uint8 }, + max_heart_rate: { id: 16, type: RubyFit::Type.uint8 }, + avg_cadence: { id: 17, type: RubyFit::Type.uint8 }, + max_cadence: { id: 18, type: RubyFit::Type.uint8 }, + avg_power: { id: 19, type: RubyFit::Type.uint16 }, + max_power: { id: 20, type: RubyFit::Type.uint16 }, + total_work: { id: 41, type: RubyFit::Type.uint32 }, + total_calories: { id: 11, type: RubyFit::Type.uint16 }, + lap_trigger: { id: 24, type: RubyFit::Type.enum(RubyFit::MessageConstants::LAP_TRIGGER) }, + normalized_power: { id: 33, type: RubyFit::Type.uint16 }, + total_moving_time: { id: 52, type: RubyFit::Type.duration }, + # time_in_hr_zone: { id: 57, type: RubyFit::Type.uint32 }, # should be array of hr_zone type + # time_in_power_zone: { id: 60, type: RubyFit::Type.uint32 }, # should be array of power_zone type + min_heart_rate: { id: 63, type: RubyFit::Type.uint8 }, + enhanced_avg_speed: { id: 65, type: RubyFit::Type.uint32 }, + enhanced_max_speed: { id: 66, type: RubyFit::Type.uint32 } }, }, course_point: { @@ -62,6 +81,12 @@ class RubyFit::MessageWriter x: { id: 1, type: RubyFit::Type.semicircles, required: true }, distance: { id: 5, type: RubyFit::Type.centimeters }, elevation: { id: 2, type: RubyFit::Type.altitude }, + heart_rate: { id: 3, type: RubyFit::Type.uint8 }, + cadence: { id: 4, type: RubyFit::Type.uint8 }, + power: { id: 7, type: RubyFit::Type.uint16 }, + calories: { id: 33, type: RubyFit::Type.uint16 }, + enhanced_speed: { id: 73, type: RubyFit::Type.uint32 }, + battery_soc: { id: 78, type: RubyFit::Type.uint8 }, } }, event: { @@ -72,6 +97,109 @@ class RubyFit::MessageWriter event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, event_group: { id: 4, type: RubyFit::Type.uint8 }, } + }, + workout: { + id: 26, + fields: { + sport: { id: 4, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: true }, + # capabilities: { id: 5, type: RubyFit::Type.uint32z, required: true }, # should be workout_capabilities type + num_valid_steps: { id: 6, type: RubyFit::Type.uint16 }, + wkt_name: { id: 8, type: RubyFit::Type.string(16) }, + sub_sport: { id: 11, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT }, + pool_length: { id: 14, type: RubyFit::Type.uint16 }, + pool_length_unit: { id: 15, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::DISPLAY_MEASURE } + } + }, + hr_zone: { + id: 8, + fields: { + high_bpm: { id: 1, type: RubyFit::Type.uint8, required: true }, + name: { id: 2, type: RubyFit::Type.string(16), required: true } + } + }, + power_zone: { + id: 9, + fields: { + high_value: { id: 1, type: RubyFit::Type.uint16, required: true }, + name: { id: 2, type: RubyFit::Type.string(16), required: true } + } + }, + device_info: { + id: 23, + fields: { + timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, + serial_number: { id: 3, type: RubyFit::Type.uint32z, required: true }, + manufacturer: { id: 2, type: RubyFit::Type.uint16 }, + product: { id: 4, type: RubyFit::Type.uint16 }, + software_version: { id: 5, type: RubyFit::Type.uint16 }, + hardware_version: { id: 6, type: RubyFit::Type.uint8 }, + battery_voltage: { id: 10, type: RubyFit::Type.uint16 }, + battery_status: { id: 11, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::BATTERY_STATUS }, + ant_device_number: { id: 21, type: RubyFit::Type.uint16 }, + device_index: { id: 0, type: RubyFit::Type.uint8 } + } + }, + workout_step: { + id: 27, + fields: { + message_index: { id: 254, type: RubyFit::Type.uint16 }, # should be message_index type + wkt_step_name: { id: 0, type: RubyFit::Type.string(16) }, + duration_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::DURATION_TYPE, required: true }, + duration_value: { id: 2, type: RubyFit::Type.uint32 }, + target_type: { id: 3, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::TARGET_TYPE }, + target_value: { id: 4, type: RubyFit::Type.uint32 }, + custom_target_value_low: { id: 5, type: RubyFit::Type.uint32 }, + custom_target_value_high: { id: 6, type: RubyFit::Type.uint32 }, + intensity: { id: 7, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::INTENSITY }, + notes: { id: 8, type: RubyFit::Type.string(50) }, + equipment: { id: 9, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::WORKOUT_EQUIPMENT } + } + }, + session: { + id: 18, + fields: { + timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, + event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, + event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, + start_time: { id: 2, type: RubyFit::Type.timestamp, required: true }, + sport: { id: 5, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: true }, + sub_sport: { id: 6, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT }, + total_elapsed_time: { id: 7, type: RubyFit::Type.duration }, + total_timer_time: { id: 8, type: RubyFit::Type.duration }, + total_distance: { id: 9, type: RubyFit::Type.centimeters }, + total_calories: { id: 11, type: RubyFit::Type.uint16 }, + avg_heart_rate: { id: 16, type: RubyFit::Type.uint8 }, + max_heart_rate: { id: 17, type: RubyFit::Type.uint8 }, + avg_cadence: { id: 18, type: RubyFit::Type.uint8 }, + max_cadence: { id: 19, type: RubyFit::Type.uint8 }, + avg_power: { id: 20, type: RubyFit::Type.uint16 }, + max_power: { id: 21, type: RubyFit::Type.uint16 }, + num_laps: { id: 26, type: RubyFit::Type.uint16 }, + normalized_power: { id: 34, type: RubyFit::Type.uint16 }, + training_stress_score: { id: 35, type: RubyFit::Type.uint16 }, + intensity_factor: { id: 36, type: RubyFit::Type.uint16 }, + threshold_power: { id: 45, type: RubyFit::Type.uint16 }, + total_work: { id: 48, type: RubyFit::Type.uint32 }, + total_moving_time: { id: 59, type: RubyFit::Type.duration }, + min_heart_rate: { id: 64, type: RubyFit::Type.uint8 }, + # time_in_hr_zone: { id: 65, type: RubyFit::Type.uint32 }, # should be array of hr_zone type + # time_in_power_zone: { id: 68, type: RubyFit::Type.uint32 }, # should be array of power_zone type + enhanced_avg_speed: { id: 124, type: RubyFit::Type.uint32 }, + enhanced_max_speed: { id: 125, type: RubyFit::Type.uint32 } + # workout_type: { id: 78, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::WORKOUT_TYPE } + } + }, + activity: { + id: 34, + fields: { + timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, + total_timer_time: { id: 0, type: RubyFit::Type.duration }, + num_sessions: { id: 1, type: RubyFit::Type.uint16 }, + type: { id: 2, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::ACTIVITY_TYPE }, + event: { id: 3, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT }, + event_type: { id: 4, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE }, + local_timestamp: { id: 5, type: RubyFit::Type.timestamp }, + } } } diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 9c6a088..acfbe37 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -69,6 +69,94 @@ def write(stream, opts = {}) @state = nil end + def write_workout_file(stream, opts = {}) + raise "Can't start write mode from #{@state}" if @state + @state = :write + @local_nums = {} + @last_local_num = -1 + + @stream = stream + + %i(start_time).each do |key| + raise ArgumentError.new("Missing required option #{key}") unless opts[key] + end + + @data_crc = 0 + + data_size = calculate_data_size(0, 0) + write_data(RubyFit::MessageWriter.file_header(data_size)) + + write_message(:file_id, { + time_created: opts[:time_created], + type: 5, # workout file + manufacturer: opts[:manufacturer], + product: opts[:product], + serial_number: 0, + }) + + # Every FIT Workout file MUST contain a Workout message as the second message + write_message(:workout, { + sport: opts[:sport], + capabilities: opts[:capabilities], + num_valid_steps: opts[:num_valid_steps], + wkt_name: opts[:wkt_name], + sub_sport: opts[:subsport], + pool_length: opts[:pool_length], + pool_length_unit: opts[:pool_length_unit] + }) + + # Every FIT Workout file MUST contain one or more Workout Step messages + yield + + # Update the data size in the header and calculate the CRC + write_data(RubyFit::MessageWriter.crc(@data_crc)) + @state = nil + end + + def write_activity_file(stream, opts = {}) + raise "Can't start write mode from #{@state}" if @state + @state = :write + @local_nums = {} + @last_local_num = -1 + + @stream = stream + + %i(start_time workout_step_count lap_count session_count event_count).each do |key| + raise ArgumentError.new("Missing required option #{key}") unless opts[key] + end + + @data_crc = 0 + + data_size = calculate_workout_data_size( 0, opts[lap_count], opts[session_count], opts[event_count],0, 0) + write_data(RubyFit::MessageWriter.file_header(data_size)) + + write_message(:file_id, { + time_created: opts[:time_created], + type: 5, # workout file + manufacturer: opts[:manufacturer], + product: opts[:product], + serial_number: 0, + }) + + # Every FIT activity file MUST contain an activity message as the second message + write_message(:activity, { + timestamp: opts[:timestamp], + total_timer_time: opts[:total_timer_time], + num_sessions: opts[:num_sessions], + type: opts[:type], + event: opts[:event], + event_type: opts[:event_type], + local_timestamp: opts[:local_timestamp] + }) + + # yield for sessions, laps (within a session), and records + yield + + # Update the data size in the header and calculate the CRC + write_data(RubyFit::MessageWriter.crc(@data_crc)) + @state = nil + end + def course_points raise "Can only start course points mode inside 'write' block" if @state != :write @state = :course_points @@ -83,6 +171,34 @@ def track_points @state = :write end + def workout_steps + raise "Can only write workout steps inside 'write' block" if @state != :write + @state = :workout_steps + yield + @state = :write + end + + def records + raise "Can only write records inside 'write' block" if @state != :write + @state = :records + yield + @state = :write + end + + def laps + raise "Can only write laps inside 'write' block" if @state != :write + @state = :laps + yield + @state = :write + end + + def sessions + raise "Can only write sessions inside 'write' block" if @state != :write + @state = :sessions + yield + @state = :write + end + def course_point(values) raise "Can only write course points inside 'course_points' block" if @state != :course_points write_message(:course_point, values) @@ -93,6 +209,26 @@ def track_point(values) write_message(:record, values) end + def workout_step(values) + raise "Can only write workout steps inside 'workout_steps' block" if @state != :workout_steps + write_message(:workout_step, values) + end + + def lap(values) + raise "Can only write laps inside 'laps' block" if @state != :laps + write_message(:lap, values) + end + + def record(values) + raise "Can only write records inside 'records' block" if @state != :records + write_message(:record, values) + end + + def session(values) + raise "Can only write sessions inside 'sessions' block" if @state != :sessions + write_message(:session, values) + end + protected def write_message(type, values) @@ -127,7 +263,29 @@ def calculate_data_size(course_point_count, track_point_count) def_size = RubyFit::MessageWriter.definition_message_size(type) data_size = RubyFit::MessageWriter.data_message_size(type) * count result = def_size + data_size - puts "#{type}: #{result}" + result + end + + data_sizes.reduce(&:+) + end + + + def calculate_workout_data_size(workout_step_count, lap_count, session_count, event_count, course_point_count, track_point_count) + record_counts = { + file_id: 1, + workout: 1, + lap: lap_count, + event: event_count, + workout_step: workout_step_count, + course_point: course_point_count, + record: track_point_count, + session: session_count + } + + data_sizes = record_counts.map do |type, count| + def_size = RubyFit::MessageWriter.definition_message_size(type) + data_size = RubyFit::MessageWriter.data_message_size(type) * count + result = def_size + data_size result end From 15f8692ba1a71f621efb983d4775768a146e707b Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 20 Mar 2025 10:51:19 -0400 Subject: [PATCH 015/104] secoond commit for writing activity files --- lib/rubyfit/message_writer.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 947dbee..7d61467 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -51,7 +51,7 @@ class RubyFit::MessageWriter max_power: { id: 20, type: RubyFit::Type.uint16 }, total_work: { id: 41, type: RubyFit::Type.uint32 }, total_calories: { id: 11, type: RubyFit::Type.uint16 }, - lap_trigger: { id: 24, type: RubyFit::Type.enum(RubyFit::MessageConstants::LAP_TRIGGER) }, + # lap_trigger: { id: 24, type: RubyFit::Type.enum(RubyFit::MessageConstants::LAP_TRIGGER) }, normalized_power: { id: 33, type: RubyFit::Type.uint16 }, total_moving_time: { id: 52, type: RubyFit::Type.duration }, # time_in_hr_zone: { id: 57, type: RubyFit::Type.uint32 }, # should be array of hr_zone type @@ -134,7 +134,7 @@ class RubyFit::MessageWriter software_version: { id: 5, type: RubyFit::Type.uint16 }, hardware_version: { id: 6, type: RubyFit::Type.uint8 }, battery_voltage: { id: 10, type: RubyFit::Type.uint16 }, - battery_status: { id: 11, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::BATTERY_STATUS }, + # battery_status: { id: 11, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::BATTERY_STATUS }, ant_device_number: { id: 21, type: RubyFit::Type.uint16 }, device_index: { id: 0, type: RubyFit::Type.uint8 } } From 546e831a71cb27f9902255dfc8927b0d2f03279b Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 20 Mar 2025 11:44:33 -0400 Subject: [PATCH 016/104] third commit for writing activity files --- lib/rubyfit/writer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index acfbe37..90969b6 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -127,7 +127,7 @@ def write_activity_file(stream, opts = {}) @data_crc = 0 - data_size = calculate_workout_data_size( 0, opts[lap_count], opts[session_count], opts[event_count],0, 0) + data_size = calculate_workout_data_size( opts[:workout_step_count], opts[:lap_count], opts[:session_count], opts[:event_count],0, 0) write_data(RubyFit::MessageWriter.file_header(data_size)) write_message(:file_id, { From 2cf9cba6a0e0bb6abf5b263ae977130baef1c4bd Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 20 Mar 2025 11:51:20 -0400 Subject: [PATCH 017/104] fourth commit for writing activity files --- lib/rubyfit/writer.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 90969b6..da0053f 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -144,9 +144,9 @@ def write_activity_file(stream, opts = {}) total_timer_time: opts[:total_timer_time], num_sessions: opts[:num_sessions], type: opts[:type], - event: opts[:event], - event_type: opts[:event_type], - local_timestamp: opts[:local_timestamp] + # event: opts[:event], + # event_type: opts[:event_type], + # local_timestamp: opts[:local_timestamp] }) # yield for sessions, laps (within a session), and records From 2974eddda1d9a8c731342f1bfb19a1f7ab8593ae Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 20 Mar 2025 12:57:50 -0400 Subject: [PATCH 018/104] fifth commit for writing activity files --- lib/rubyfit/writer.rb | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index da0053f..b198973 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -132,21 +132,21 @@ def write_activity_file(stream, opts = {}) write_message(:file_id, { time_created: opts[:time_created], - type: 5, # workout file + type: 4, # activity file manufacturer: opts[:manufacturer], product: opts[:product], serial_number: 0, }) - # Every FIT activity file MUST contain an activity message as the second message + # Every FIT activity file MUST contain an activity message write_message(:activity, { timestamp: opts[:timestamp], total_timer_time: opts[:total_timer_time], - num_sessions: opts[:num_sessions], + num_sessions: opts[:session_count], type: opts[:type], - # event: opts[:event], - # event_type: opts[:event_type], - # local_timestamp: opts[:local_timestamp] + event: opts[:event], + event_type: opts[:event_type], + local_timestamp: opts[:local_timestamp] }) # yield for sessions, laps (within a session), and records @@ -232,6 +232,7 @@ def session(values) protected def write_message(type, values) + puts("writing message", type, values) local_num = @local_nums[type] unless local_num @last_local_num += 1 From b5c790532ba5f7480d71ff1a0ed1ea339434ceb3 Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 20 Mar 2025 13:06:20 -0400 Subject: [PATCH 019/104] sixth commit for writing activity files --- lib/rubyfit/writer.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index b198973..cc1a142 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -238,13 +238,16 @@ def write_message(type, values) @last_local_num += 1 local_num = @last_local_num @local_nums[type] = local_num + puts("local_num1", local_num) write_data(RubyFit::MessageWriter.definition_message(type, local_num)) end + puts("local_num", local_num) write_data(RubyFit::MessageWriter.data_message(type, local_num, values)) end def write_data(data) + puts("writing data", data) @stream.write(data) prev = @data_crc @data_crc = RubyFit::CRC.update_crc(@data_crc, data) From bf6a27db48204e0c92c573ee81d89b272fae327e Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 20 Mar 2025 13:14:41 -0400 Subject: [PATCH 020/104] seventh commit for writing activity files --- lib/rubyfit/message_writer.rb | 3 ++- lib/rubyfit/writer.rb | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 7d61467..65a7636 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -238,8 +238,9 @@ def self.data_message(type, local_num, values) raise ArgumentError.new("Invalid value for '#{field}' in #{type} data message values") end end - + puts("value", value) value_bytes = value ? field_type.val2bytes(value) : field_type.default_bytes + puts("value_bytes", value_bytes) bytes.push(*value_bytes) end end diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index cc1a142..68aef93 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -242,7 +242,7 @@ def write_message(type, values) write_data(RubyFit::MessageWriter.definition_message(type, local_num)) end - puts("local_num", local_num) + puts("local_num", type, local_num, values) write_data(RubyFit::MessageWriter.data_message(type, local_num, values)) end From cf8f163f2225b4fd02f40abe05713e57dd6c7f38 Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 20 Mar 2025 15:10:29 -0400 Subject: [PATCH 021/104] add wkt section to activity file --- lib/rubyfit/message_writer.rb | 2 -- lib/rubyfit/writer.rb | 14 ++++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 65a7636..69c81f8 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -238,9 +238,7 @@ def self.data_message(type, local_num, values) raise ArgumentError.new("Invalid value for '#{field}' in #{type} data message values") end end - puts("value", value) value_bytes = value ? field_type.val2bytes(value) : field_type.default_bytes - puts("value_bytes", value_bytes) bytes.push(*value_bytes) end end diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 68aef93..5819424 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -149,6 +149,16 @@ def write_activity_file(stream, opts = {}) local_timestamp: opts[:local_timestamp] }) + write_message(:workout, { + sport: opts[:sport], + # capabilities: opts[:capabilities], + num_valid_steps: opts[:num_valid_steps], + wkt_name: opts[:name], + sub_sport: opts[:subsport], + # pool_length: opts[:pool_length], + # pool_length_unit: opts[:pool_length_unit] + }) + # yield for sessions, laps (within a session), and records yield @@ -232,22 +242,18 @@ def session(values) protected def write_message(type, values) - puts("writing message", type, values) local_num = @local_nums[type] unless local_num @last_local_num += 1 local_num = @last_local_num @local_nums[type] = local_num - puts("local_num1", local_num) write_data(RubyFit::MessageWriter.definition_message(type, local_num)) end - puts("local_num", type, local_num, values) write_data(RubyFit::MessageWriter.data_message(type, local_num, values)) end def write_data(data) - puts("writing data", data) @stream.write(data) prev = @data_crc @data_crc = RubyFit::CRC.update_crc(@data_crc, data) From 280f88b616eff2dddb48c7515104b15e497ff491 Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 20 Mar 2025 15:14:20 -0400 Subject: [PATCH 022/104] add wkt section to activity file --- lib/rubyfit/message_writer.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 69c81f8..da9eb8f 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -106,8 +106,8 @@ class RubyFit::MessageWriter num_valid_steps: { id: 6, type: RubyFit::Type.uint16 }, wkt_name: { id: 8, type: RubyFit::Type.string(16) }, sub_sport: { id: 11, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT }, - pool_length: { id: 14, type: RubyFit::Type.uint16 }, - pool_length_unit: { id: 15, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::DISPLAY_MEASURE } + # pool_length: { id: 14, type: RubyFit::Type.uint16 }, + # pool_length_unit: { id: 15, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::DISPLAY_MEASURE } } }, hr_zone: { From f8978b7ba4f1c3cd3e5057f92d09534372877b8a Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 21 Mar 2025 11:45:44 -0400 Subject: [PATCH 023/104] add lengths and device info --- lib/rubyfit/message_constants.rb | 15 +++++++++ lib/rubyfit/message_writer.rb | 32 ++++++++++++++++-- lib/rubyfit/writer.rb | 56 ++++++++++++++++++++++++++++---- 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index 8772677..5a3f296 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -252,4 +252,19 @@ module RubyFit::MessageConstants all: 254 }.freeze + LENGTH_TYPE = { + idle: 0, + active: 1 + }.freeze + + SWIM_STROKE = { + freestyle: 0, + backstroke: 1, + breaststroke: 2, + butterfly: 3, + drill: 4, + mixed: 5, + im: 6 + }.freeze + end diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index da9eb8f..698ea33 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -41,8 +41,8 @@ class RubyFit::MessageWriter sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, subsport: { id: 39, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false }, - event: { id: 0, type: RubyFit::Type.enum(RubyFit::MessageConstants::EVENT), required: false }, - event_type: { id: 1, type: RubyFit::Type.enum(RubyFit::MessageConstants::EVENT_TYPE), required: false }, + event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: false }, + event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: false }, avg_heart_rate: { id: 15, type: RubyFit::Type.uint8 }, max_heart_rate: { id: 16, type: RubyFit::Type.uint8 }, avg_cadence: { id: 17, type: RubyFit::Type.uint8 }, @@ -128,7 +128,7 @@ class RubyFit::MessageWriter id: 23, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, - serial_number: { id: 3, type: RubyFit::Type.uint32z, required: true }, + serial_number: { id: 3, type: RubyFit::Type.uint32z }, manufacturer: { id: 2, type: RubyFit::Type.uint16 }, product: { id: 4, type: RubyFit::Type.uint16 }, software_version: { id: 5, type: RubyFit::Type.uint16 }, @@ -200,6 +200,32 @@ class RubyFit::MessageWriter event_type: { id: 4, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE }, local_timestamp: { id: 5, type: RubyFit::Type.timestamp }, } + }, + + length: { + id: 101, + fields: { + timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, + event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, + event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, + start_time: { id: 2, type: RubyFit::Type.timestamp, required: true }, + total_elapsed_time: { id: 3, type: RubyFit::Type.duration }, + total_timer_time: { id: 4, type: RubyFit::Type.duration }, + total_strokes: { id: 5, type: RubyFit::Type.uint16 }, + avg_speed: { id: 6, type: RubyFit::Type.uint16 }, + swim_stroke: { id: 7, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SWIM_STROKE }, + avg_swimming_cadence: { id: 9, type: RubyFit::Type.uint8 }, + total_calories: { id: 11, type: RubyFit::Type.uint16 }, + length_type: { id: 12, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::LENGTH_TYPE }, + player_score: { id: 18, type: RubyFit::Type.uint16 }, + opponent_score: { id: 19, type: RubyFit::Type.uint16 }, + stroke_count: { id: 20, type: RubyFit::Type.uint16 }, + zone_count: { id: 21, type: RubyFit::Type.uint16 }, + enhanced_avg_respiration_rate: { id: 22, type: RubyFit::Type.uint16 }, + enhanced_max_respiration_rate: { id: 23, type: RubyFit::Type.uint16 }, + avg_respiration_rate: { id: 24, type: RubyFit::Type.uint8 }, + max_respiration_rate: { id: 25, type: RubyFit::Type.uint8 } + } } } diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 5819424..cc62414 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -121,13 +121,16 @@ def write_activity_file(stream, opts = {}) @stream = stream - %i(start_time workout_step_count lap_count session_count event_count).each do |key| + %i(start_time duration workout_step_count lap_count session_count event_count).each do |key| raise ArgumentError.new("Missing required option #{key}") unless opts[key] end + start_time = opts[:start_time].to_i + duration = opts[:duration].to_i + @data_crc = 0 - data_size = calculate_workout_data_size( opts[:workout_step_count], opts[:lap_count], opts[:session_count], opts[:event_count],0, 0) + data_size = calculate_workout_data_size( opts[:workout_step_count], opts[:lap_count], opts[:session_count], opts[:event_count],0, 0, opts[:device_info_count], opts[:length_count]) write_data(RubyFit::MessageWriter.file_header(data_size)) write_message(:file_id, { @@ -159,9 +162,23 @@ def write_activity_file(stream, opts = {}) # pool_length_unit: opts[:pool_length_unit] }) - # yield for sessions, laps (within a session), and records + write_message(:event, { + timestamp: start_time, + event: :timer, + event_type: :start, + event_group: 0 + }) + + # yield for sessions, laps, lengths, device_infos and records yield + write_message(:event, { + timestamp: start_time + duration, + event: :timer, + event_type: :stop_disable_all, + event_group: 0 + }) + # Update the data size in the header and calculate the CRC write_data(RubyFit::MessageWriter.crc(@data_crc)) @state = nil @@ -209,6 +226,20 @@ def sessions @state = :write end + def device_infos + raise "Can only write device infos inside 'write' block" if @state != :write + @state = :device_infos + yield + @state = :write + end + + def lengths + raise "Can only write lengths inside 'write' block" if @state != :write + @state = :lengths + yield + @state = :write + end + def course_point(values) raise "Can only write course points inside 'course_points' block" if @state != :course_points write_message(:course_point, values) @@ -239,6 +270,16 @@ def session(values) write_message(:session, values) end + def device_info(values) + raise "Can only write device infos inside 'device_infos' block" if @state != :device_infos + write_message(:device_info, values) + end + + def length(values) + raise "Can only write lengths inside 'lengths' block" if @state != :lengths + write_message(:length, values) + end + protected def write_message(type, values) @@ -280,16 +321,19 @@ def calculate_data_size(course_point_count, track_point_count) end - def calculate_workout_data_size(workout_step_count, lap_count, session_count, event_count, course_point_count, track_point_count) + def calculate_workout_data_size(workout_step_count, lap_count, session_count, event_count, course_point_count, track_point_count, device_info_count, length_count) record_counts = { file_id: 1, workout: 1, + activity: 1, lap: lap_count, - event: event_count, + length: length_count, + event: event_count + 2, workout_step: workout_step_count, course_point: course_point_count, record: track_point_count, - session: session_count + session: session_count, + device_info: device_info_count } data_sizes = record_counts.map do |type, count| From 74b05591111cc7256241a8eab3dce5ff28d18213 Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 21 Mar 2025 15:08:48 -0400 Subject: [PATCH 024/104] edit size calculation --- lib/rubyfit/message_constants.rb | 11 +++++++++++ lib/rubyfit/message_writer.rb | 15 ++++++++------- lib/rubyfit/writer.rb | 8 ++++---- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index 5a3f296..20fb40d 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -267,4 +267,15 @@ module RubyFit::MessageConstants im: 6 }.freeze + LAP_TRIGGER = { + manual: 0, + time: 1, + distance: 2, + position_start: 3, + position_lap: 4, + position_waypoint: 5, + position_marked: 6, + session_end: 7, + fitness_equipment: 8 + }.freeze end diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 698ea33..bf65ce4 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -38,9 +38,8 @@ class RubyFit::MessageWriter end_x: { id: 6, type: RubyFit::Type.semicircles }, total_distance: { id: 9, type: RubyFit::Type.centimeters }, total_ascent: { id: 21, type: RubyFit::Type.altitude }, - sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, - subsport: { id: 39, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false }, - + sport: { id: 25, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: false }, + subsport: { id: 39, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT, required: false }, event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: false }, event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: false }, avg_heart_rate: { id: 15, type: RubyFit::Type.uint8 }, @@ -51,7 +50,7 @@ class RubyFit::MessageWriter max_power: { id: 20, type: RubyFit::Type.uint16 }, total_work: { id: 41, type: RubyFit::Type.uint32 }, total_calories: { id: 11, type: RubyFit::Type.uint16 }, - # lap_trigger: { id: 24, type: RubyFit::Type.enum(RubyFit::MessageConstants::LAP_TRIGGER) }, + lap_trigger: { id: 24, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::LAP_TRIGGER }, normalized_power: { id: 33, type: RubyFit::Type.uint16 }, total_moving_time: { id: 52, type: RubyFit::Type.duration }, # time_in_hr_zone: { id: 57, type: RubyFit::Type.uint32 }, # should be array of hr_zone type @@ -87,6 +86,7 @@ class RubyFit::MessageWriter calories: { id: 33, type: RubyFit::Type.uint16 }, enhanced_speed: { id: 73, type: RubyFit::Type.uint32 }, battery_soc: { id: 78, type: RubyFit::Type.uint8 }, + grade: { id: 9, type: RubyFit::Type.sint16 }, } }, event: { @@ -104,7 +104,7 @@ class RubyFit::MessageWriter sport: { id: 4, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: true }, # capabilities: { id: 5, type: RubyFit::Type.uint32z, required: true }, # should be workout_capabilities type num_valid_steps: { id: 6, type: RubyFit::Type.uint16 }, - wkt_name: { id: 8, type: RubyFit::Type.string(16) }, + wkt_name: { id: 8, type: RubyFit::Type.string(64) }, sub_sport: { id: 11, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT }, # pool_length: { id: 14, type: RubyFit::Type.uint16 }, # pool_length_unit: { id: 15, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::DISPLAY_MEASURE } @@ -135,8 +135,9 @@ class RubyFit::MessageWriter hardware_version: { id: 6, type: RubyFit::Type.uint8 }, battery_voltage: { id: 10, type: RubyFit::Type.uint16 }, # battery_status: { id: 11, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::BATTERY_STATUS }, - ant_device_number: { id: 21, type: RubyFit::Type.uint16 }, - device_index: { id: 0, type: RubyFit::Type.uint8 } + # ant_device_number: { id: 21, type: RubyFit::Type.uint16 }, + device_index: { id: 0, type: RubyFit::Type.uint8 }, + product_name: { id: 27, type: RubyFit::Type.string(20) } } }, workout_step: { diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index cc62414..339b11c 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -121,7 +121,7 @@ def write_activity_file(stream, opts = {}) @stream = stream - %i(start_time duration workout_step_count lap_count session_count event_count).each do |key| + %i(start_time duration workout_step_count lap_count session_count event_count record_count).each do |key| raise ArgumentError.new("Missing required option #{key}") unless opts[key] end @@ -130,7 +130,7 @@ def write_activity_file(stream, opts = {}) @data_crc = 0 - data_size = calculate_workout_data_size( opts[:workout_step_count], opts[:lap_count], opts[:session_count], opts[:event_count],0, 0, opts[:device_info_count], opts[:length_count]) + data_size = calculate_workout_data_size( opts[:workout_step_count], opts[:lap_count], opts[:session_count], opts[:event_count],0, opts[:record_count], opts[:device_info_count], opts[:length_count]) write_data(RubyFit::MessageWriter.file_header(data_size)) write_message(:file_id, { @@ -321,7 +321,7 @@ def calculate_data_size(course_point_count, track_point_count) end - def calculate_workout_data_size(workout_step_count, lap_count, session_count, event_count, course_point_count, track_point_count, device_info_count, length_count) + def calculate_workout_data_size(workout_step_count, lap_count, session_count, event_count, course_point_count, record_count, device_info_count, length_count) record_counts = { file_id: 1, workout: 1, @@ -331,7 +331,7 @@ def calculate_workout_data_size(workout_step_count, lap_count, session_count, ev event: event_count + 2, workout_step: workout_step_count, course_point: course_point_count, - record: track_point_count, + record: record_count, session: session_count, device_info: device_info_count } From a6566b2fde1198bbdf7f4c5975dc38eaf3bd5270 Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 24 Mar 2025 08:17:41 -0400 Subject: [PATCH 025/104] add sport and zones --- lib/rubyfit/writer.rb | 42 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 339b11c..6716862 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -121,7 +121,7 @@ def write_activity_file(stream, opts = {}) @stream = stream - %i(start_time duration workout_step_count lap_count session_count event_count record_count).each do |key| + %i(start_time duration workout_step_count lap_count session_count event_count record_count power_zone_count hr_zone_count).each do |key| raise ArgumentError.new("Missing required option #{key}") unless opts[key] end @@ -130,7 +130,7 @@ def write_activity_file(stream, opts = {}) @data_crc = 0 - data_size = calculate_workout_data_size( opts[:workout_step_count], opts[:lap_count], opts[:session_count], opts[:event_count],0, opts[:record_count], opts[:device_info_count], opts[:length_count]) + data_size = calculate_workout_data_size( opts[:workout_step_count], opts[:lap_count], opts[:session_count], opts[:event_count],0, opts[:record_count], opts[:device_info_count], opts[:length_count], opts[:power_zone_count], opts[:hr_zone_count]) write_data(RubyFit::MessageWriter.file_header(data_size)) write_message(:file_id, { @@ -141,6 +141,11 @@ def write_activity_file(stream, opts = {}) serial_number: 0, }) + write_message(:sport, { + sport: opts[:sport], + sub_sport: opts[:subsport] + }) + # Every FIT activity file MUST contain an activity message write_message(:activity, { timestamp: opts[:timestamp], @@ -240,6 +245,20 @@ def lengths @state = :write end + def hr_zones + raise "Can only write lengths inside 'write' block" if @state != :write + @state = :hr_zones + yield + @state = :write + end + + def power_zones + raise "Can only write lengths inside 'write' block" if @state != :write + @state = :power_zones + yield + @state = :write + end + def course_point(values) raise "Can only write course points inside 'course_points' block" if @state != :course_points write_message(:course_point, values) @@ -280,6 +299,16 @@ def length(values) write_message(:length, values) end + def hr_zone(values) + raise "Can only write hr zones inside 'hr_zones' block" if @state != :hr_zones + write_message(:hr_zone, values) + end + + def power_zone(values) + raise "Can only write power zones inside 'power_zones' block" if @state != :power_zones + write_message(:power_zone, values) + end + protected def write_message(type, values) @@ -321,9 +350,10 @@ def calculate_data_size(course_point_count, track_point_count) end - def calculate_workout_data_size(workout_step_count, lap_count, session_count, event_count, course_point_count, record_count, device_info_count, length_count) + def calculate_workout_data_size(workout_step_count, lap_count, session_count, event_count, course_point_count, record_count, device_info_count, length_count, power_zone_count, hr_zone_count) record_counts = { file_id: 1, + sport: 1, workout: 1, activity: 1, lap: lap_count, @@ -333,13 +363,15 @@ def calculate_workout_data_size(workout_step_count, lap_count, session_count, ev course_point: course_point_count, record: record_count, session: session_count, - device_info: device_info_count + device_info: device_info_count, + hr_zone: hr_zone_count, + power_zone: power_zone_count } data_sizes = record_counts.map do |type, count| def_size = RubyFit::MessageWriter.definition_message_size(type) data_size = RubyFit::MessageWriter.data_message_size(type) * count - result = def_size + data_size + result = def_size + data_size if count > 0 result end From 00b44e980e26c452ddf5207749ab08875f3466af Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 24 Mar 2025 08:22:49 -0400 Subject: [PATCH 026/104] add sport message --- lib/rubyfit/message_writer.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index bf65ce4..db8070e 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -110,6 +110,15 @@ class RubyFit::MessageWriter # pool_length_unit: { id: 15, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::DISPLAY_MEASURE } } }, + sport: { + id: 12, + fields: { + sport: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: true }, + sub_sport: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT }, + name: { id: 3, type: RubyFit::Type.string(16) } + } + }, + hr_zone: { id: 8, fields: { From b3b057d16b24bb74679c3a5371a91867e937f330 Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 24 Mar 2025 08:27:50 -0400 Subject: [PATCH 027/104] add sport message --- lib/rubyfit/message_writer.rb | 3 +-- lib/rubyfit/writer.rb | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index db8070e..babacef 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -114,8 +114,7 @@ class RubyFit::MessageWriter id: 12, fields: { sport: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: true }, - sub_sport: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT }, - name: { id: 3, type: RubyFit::Type.string(16) } + sub_sport: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT } } }, diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 6716862..7a58cde 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -371,7 +371,7 @@ def calculate_workout_data_size(workout_step_count, lap_count, session_count, ev data_sizes = record_counts.map do |type, count| def_size = RubyFit::MessageWriter.definition_message_size(type) data_size = RubyFit::MessageWriter.data_message_size(type) * count - result = def_size + data_size if count > 0 + result = def_size + data_size result end From 1e511fbdd2aea4c0d75048685befb89806681a78 Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 24 Mar 2025 09:15:20 -0400 Subject: [PATCH 028/104] add comments for debugging --- lib/rubyfit/writer.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 7a58cde..cc4f699 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -372,9 +372,11 @@ def calculate_workout_data_size(workout_step_count, lap_count, session_count, ev def_size = RubyFit::MessageWriter.definition_message_size(type) data_size = RubyFit::MessageWriter.data_message_size(type) * count result = def_size + data_size + puts "#{type}: #{result}" result end + puts("data sizes", data_sizes.reduce(&:+)) data_sizes.reduce(&:+) end end From 8dab58e179e7bb0a3f7a5d1d5cf348f5907c13bc Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 24 Mar 2025 09:21:48 -0400 Subject: [PATCH 029/104] fix data size calc --- lib/rubyfit/writer.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index cc4f699..0019a2e 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -371,7 +371,11 @@ def calculate_workout_data_size(workout_step_count, lap_count, session_count, ev data_sizes = record_counts.map do |type, count| def_size = RubyFit::MessageWriter.definition_message_size(type) data_size = RubyFit::MessageWriter.data_message_size(type) * count - result = def_size + data_size + result = if count > 0 + def_size + data_size + else + 0 + end puts "#{type}: #{result}" result end From 9a78ea08e4b36397c5cda4d88750f0cf742566b4 Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 24 Mar 2025 10:00:32 -0400 Subject: [PATCH 030/104] move activity message --- lib/rubyfit/writer.rb | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 0019a2e..371133e 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -146,17 +146,6 @@ def write_activity_file(stream, opts = {}) sub_sport: opts[:subsport] }) - # Every FIT activity file MUST contain an activity message - write_message(:activity, { - timestamp: opts[:timestamp], - total_timer_time: opts[:total_timer_time], - num_sessions: opts[:session_count], - type: opts[:type], - event: opts[:event], - event_type: opts[:event_type], - local_timestamp: opts[:local_timestamp] - }) - write_message(:workout, { sport: opts[:sport], # capabilities: opts[:capabilities], @@ -184,6 +173,16 @@ def write_activity_file(stream, opts = {}) event_group: 0 }) + write_message(:activity, { + timestamp: opts[:timestamp], + total_timer_time: opts[:total_timer_time], + num_sessions: opts[:session_count], + type: opts[:type], + event: opts[:event], + event_type: opts[:event_type], + local_timestamp: opts[:local_timestamp] + }) + # Update the data size in the header and calculate the CRC write_data(RubyFit::MessageWriter.crc(@data_crc)) @state = nil From 94051152178fced96ca6a2f209992c4da1420ceb Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 24 Mar 2025 10:15:34 -0400 Subject: [PATCH 031/104] move activity message --- lib/rubyfit/writer.rb | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 371133e..8d29171 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -141,6 +141,16 @@ def write_activity_file(stream, opts = {}) serial_number: 0, }) + write_message(:activity, { + timestamp: opts[:timestamp], + total_timer_time: opts[:total_timer_time], + num_sessions: opts[:session_count], + type: opts[:type], + event: opts[:event], + event_type: opts[:event_type], + local_timestamp: opts[:local_timestamp] + }) + write_message(:sport, { sport: opts[:sport], sub_sport: opts[:subsport] @@ -173,16 +183,6 @@ def write_activity_file(stream, opts = {}) event_group: 0 }) - write_message(:activity, { - timestamp: opts[:timestamp], - total_timer_time: opts[:total_timer_time], - num_sessions: opts[:session_count], - type: opts[:type], - event: opts[:event], - event_type: opts[:event_type], - local_timestamp: opts[:local_timestamp] - }) - # Update the data size in the header and calculate the CRC write_data(RubyFit::MessageWriter.crc(@data_crc)) @state = nil From 904b2bececd9351d7d61a77d615f8b593cd64671 Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 24 Mar 2025 13:22:33 -0400 Subject: [PATCH 032/104] fix sport call in lap --- lib/rubyfit/message_writer.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index babacef..df70f42 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -38,8 +38,8 @@ class RubyFit::MessageWriter end_x: { id: 6, type: RubyFit::Type.semicircles }, total_distance: { id: 9, type: RubyFit::Type.centimeters }, total_ascent: { id: 21, type: RubyFit::Type.altitude }, - sport: { id: 25, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: false }, - subsport: { id: 39, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT, required: false }, + sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, + subsport: { id: 39, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false }, event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: false }, event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: false }, avg_heart_rate: { id: 15, type: RubyFit::Type.uint8 }, @@ -121,6 +121,7 @@ class RubyFit::MessageWriter hr_zone: { id: 8, fields: { + message_index: { id: 254, type: RubyFit::Type.uint16 }, high_bpm: { id: 1, type: RubyFit::Type.uint8, required: true }, name: { id: 2, type: RubyFit::Type.string(16), required: true } } @@ -128,6 +129,7 @@ class RubyFit::MessageWriter power_zone: { id: 9, fields: { + message_index: { id: 254, type: RubyFit::Type.uint16 }, high_value: { id: 1, type: RubyFit::Type.uint16, required: true }, name: { id: 2, type: RubyFit::Type.string(16), required: true } } From 031548daec1a2c469968413dfa10b09d50340420 Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 24 Mar 2025 13:29:41 -0400 Subject: [PATCH 033/104] separate route lap and wkt lap --- lib/rubyfit/message_writer.rb | 19 +++++++++++++++++++ lib/rubyfit/writer.rb | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index df70f42..5dbff98 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -25,7 +25,26 @@ class RubyFit::MessageWriter name: { id: 5, type: RubyFit::Type.string(16), required: true }, } }, + lap: { + id: 19, + fields: { + timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true}, + start_time: { id: 2, type: RubyFit::Type.timestamp, required: true}, + total_elapsed_time: { id: 7, type: RubyFit::Type.duration, required: true }, + total_timer_time: { id: 8, type: RubyFit::Type.duration, required: true }, + start_y: { id: 3, type: RubyFit::Type.semicircles }, + start_x: { id: 4, type: RubyFit::Type.semicircles }, + end_y: { id: 5, type: RubyFit::Type.semicircles }, + end_x: { id: 6, type: RubyFit::Type.semicircles }, + total_distance: { id: 9, type: RubyFit::Type.centimeters }, + total_ascent: { id: 21, type: RubyFit::Type.altitude }, + sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, + subsport: { id: 39, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false } + } + }, + + wkt_lap: { id: 19, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true}, diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 8d29171..fe95cc3 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -275,7 +275,7 @@ def workout_step(values) def lap(values) raise "Can only write laps inside 'laps' block" if @state != :laps - write_message(:lap, values) + write_message(:wkt_lap, values) end def record(values) @@ -355,7 +355,7 @@ def calculate_workout_data_size(workout_step_count, lap_count, session_count, ev sport: 1, workout: 1, activity: 1, - lap: lap_count, + wkt_lap: lap_count, length: length_count, event: event_count + 2, workout_step: workout_step_count, From ac8458103b5342d67cee6034fbc46950b17a1074 Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 24 Mar 2025 13:44:58 -0400 Subject: [PATCH 034/104] separate route lap and wkt lap --- lib/rubyfit/message_writer.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 5dbff98..af5d239 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -57,8 +57,8 @@ class RubyFit::MessageWriter end_x: { id: 6, type: RubyFit::Type.semicircles }, total_distance: { id: 9, type: RubyFit::Type.centimeters }, total_ascent: { id: 21, type: RubyFit::Type.altitude }, - sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, - subsport: { id: 39, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false }, + sport: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: false }, + sub_sport: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT, required: false}, event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: false }, event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: false }, avg_heart_rate: { id: 15, type: RubyFit::Type.uint8 }, From afa17117be0488d6f33e8a89b7c46939c7819526 Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 25 Mar 2025 10:55:59 -0400 Subject: [PATCH 035/104] add wahoo custom nums --- lib/rubyfit/message_constants.rb | 20 +++++++++++++++ lib/rubyfit/message_writer.rb | 43 ++++++++++++++++++++++++++++++++ lib/rubyfit/writer.rb | 13 +++++++++- 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index 20fb40d..eca32a5 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -278,4 +278,24 @@ module RubyFit::MessageConstants session_end: 7, fitness_equipment: 8 }.freeze + + FIT_BASE_TYPE = { + enum: 0, + sint8: 1, + uint8: 2, + sint16: 131, + uint16: 132, + sint32: 133, + uint32: 134, + string: 7, + float32: 136, + float64: 137, + uint8z: 10, + uint16z: 139, + uint32z: 140, + byte: 13, + sint64: 142, + uint64: 143, + uint64z: 144 + }.freeze end diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index af5d239..07bd47b 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -19,6 +19,7 @@ class RubyFit::MessageWriter type: { id: 0, type: RubyFit::Type.enum, required: true }, # See FIT_FILE_* } }, + course: { id: 31, fields: { @@ -79,6 +80,7 @@ class RubyFit::MessageWriter enhanced_max_speed: { id: 66, type: RubyFit::Type.uint32 } }, }, + course_point: { id: 32, fields: { @@ -91,6 +93,7 @@ class RubyFit::MessageWriter type: { id: 5, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::COURSE_POINT_TYPE, required: true } }, }, + record: { id: 20, fields: { @@ -108,6 +111,7 @@ class RubyFit::MessageWriter grade: { id: 9, type: RubyFit::Type.sint16 }, } }, + event: { id: 21, fields: { @@ -117,6 +121,7 @@ class RubyFit::MessageWriter event_group: { id: 4, type: RubyFit::Type.uint8 }, } }, + workout: { id: 26, fields: { @@ -129,6 +134,7 @@ class RubyFit::MessageWriter # pool_length_unit: { id: 15, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::DISPLAY_MEASURE } } }, + sport: { id: 12, fields: { @@ -145,6 +151,7 @@ class RubyFit::MessageWriter name: { id: 2, type: RubyFit::Type.string(16), required: true } } }, + power_zone: { id: 9, fields: { @@ -153,6 +160,7 @@ class RubyFit::MessageWriter name: { id: 2, type: RubyFit::Type.string(16), required: true } } }, + device_info: { id: 23, fields: { @@ -169,6 +177,7 @@ class RubyFit::MessageWriter product_name: { id: 27, type: RubyFit::Type.string(20) } } }, + workout_step: { id: 27, fields: { @@ -185,6 +194,7 @@ class RubyFit::MessageWriter equipment: { id: 9, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::WORKOUT_EQUIPMENT } } }, + session: { id: 18, fields: { @@ -219,6 +229,7 @@ class RubyFit::MessageWriter # workout_type: { id: 78, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::WORKOUT_TYPE } } }, + activity: { id: 34, fields: { @@ -256,6 +267,38 @@ class RubyFit::MessageWriter avg_respiration_rate: { id: 24, type: RubyFit::Type.uint8 }, max_respiration_rate: { id: 25, type: RubyFit::Type.uint8 } } + }, + + wahoo_custom_nums: { + id: 0xFF04, # Custom message ID, ensure it does not conflict with existing IDs + fields: { + value: { id: 0, type: RubyFit::Type.float64, required: true }, + timestamp: { id: 1, type: RubyFit::Type.timestamp, required: false }, + sub_type: { id: 2, type: RubyFit::Type.uint16, required: true }, + type: { id: 3, type: RubyFit::Type.uint8, required: true } + } + }, + + field_description: { + # Must be logged before developer field is used + id: 206, + fields: { + developer_data_index: { id: 0, type: RubyFit::Type.uint8 }, + field_definition_number: { id: 1, type: RubyFit::Type.uint8 }, + fit_base_type_id: { id: 2, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::FIT_BASE_TYPE }, + field_name: { id: 3, type: RubyFit::Type.string(16) }, + units: { id: 8, type: RubyFit::Type.string(16) }, + } + }, + + developer_data_id: { + # Must be logged before field description + id: 207, + fields: { + developer_id: { id: 0, type: RubyFit::Type.uint8 }, + manufacturer_id: { id: 2, type: RubyFit::Type.uint16 }, + developer_data_index: { id: 3, type: RubyFit::Type.uint8 } + } } } diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index fe95cc3..e9fd84c 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -173,7 +173,6 @@ def write_activity_file(stream, opts = {}) event_group: 0 }) - # yield for sessions, laps, lengths, device_infos and records yield write_message(:event, { @@ -258,6 +257,13 @@ def power_zones @state = :write end + def wahoo_custom_nums + raise "Can only write lengths inside 'write' block" if @state != :write + @state = :wahoo_custom_nums + yield + @state = :write + end + def course_point(values) raise "Can only write course points inside 'course_points' block" if @state != :course_points write_message(:course_point, values) @@ -308,6 +314,11 @@ def power_zone(values) write_message(:power_zone, values) end + def wahoo_custom_num(values) + raise "Can only write wahoo custom nums inside 'wahoo_custom_nums' block" if @state != :wahoo_custom_nums + write_message(:wahoo_custom_num, values) + end + protected def write_message(type, values) From 5d965bf874c47a03ed4dc621a0a71642b261e6bc Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 25 Mar 2025 11:08:43 -0400 Subject: [PATCH 036/104] add wahoo custom nums --- lib/rubyfit/message_writer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 07bd47b..8fc5c7b 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -272,7 +272,7 @@ class RubyFit::MessageWriter wahoo_custom_nums: { id: 0xFF04, # Custom message ID, ensure it does not conflict with existing IDs fields: { - value: { id: 0, type: RubyFit::Type.float64, required: true }, + value: { id: 0, type: RubyFit::Type.uint16, required: true }, timestamp: { id: 1, type: RubyFit::Type.timestamp, required: false }, sub_type: { id: 2, type: RubyFit::Type.uint16, required: true }, type: { id: 3, type: RubyFit::Type.uint8, required: true } From 653ddb175edbd67b0255aad39575c0bb4c2273aa Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 25 Mar 2025 11:22:51 -0400 Subject: [PATCH 037/104] add wahoo custom nums --- lib/rubyfit/writer.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index e9fd84c..40ec9b6 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -121,7 +121,7 @@ def write_activity_file(stream, opts = {}) @stream = stream - %i(start_time duration workout_step_count lap_count session_count event_count record_count power_zone_count hr_zone_count).each do |key| + %i(start_time duration workout_step_count lap_count session_count event_count record_count power_zone_count hr_zone_count wahoo_custom_num_count).each do |key| raise ArgumentError.new("Missing required option #{key}") unless opts[key] end @@ -130,7 +130,7 @@ def write_activity_file(stream, opts = {}) @data_crc = 0 - data_size = calculate_workout_data_size( opts[:workout_step_count], opts[:lap_count], opts[:session_count], opts[:event_count],0, opts[:record_count], opts[:device_info_count], opts[:length_count], opts[:power_zone_count], opts[:hr_zone_count]) + data_size = calculate_workout_data_size( opts[:workout_step_count], opts[:lap_count], opts[:session_count], opts[:event_count],0, opts[:record_count], opts[:device_info_count], opts[:length_count], opts[:power_zone_count], opts[:hr_zone_count], opts[:wahoo_custom_num_count]) write_data(RubyFit::MessageWriter.file_header(data_size)) write_message(:file_id, { @@ -360,7 +360,7 @@ def calculate_data_size(course_point_count, track_point_count) end - def calculate_workout_data_size(workout_step_count, lap_count, session_count, event_count, course_point_count, record_count, device_info_count, length_count, power_zone_count, hr_zone_count) + def calculate_workout_data_size(workout_step_count, lap_count, session_count, event_count, course_point_count, record_count, device_info_count, length_count, power_zone_count, hr_zone_count, wahoo_custom_num_count) record_counts = { file_id: 1, sport: 1, @@ -375,7 +375,8 @@ def calculate_workout_data_size(workout_step_count, lap_count, session_count, ev session: session_count, device_info: device_info_count, hr_zone: hr_zone_count, - power_zone: power_zone_count + power_zone: power_zone_count, + wahoo_custom_nums: wahoo_custom_num_count } data_sizes = record_counts.map do |type, count| From 29f3e8661f0eae054658164cf98c7f9a6848f854 Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 25 Mar 2025 11:31:30 -0400 Subject: [PATCH 038/104] add debugging statements --- lib/rubyfit/writer.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 40ec9b6..7370e38 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -322,11 +322,13 @@ def wahoo_custom_num(values) protected def write_message(type, values) + puts("writing message", type, values) local_num = @local_nums[type] unless local_num @last_local_num += 1 local_num = @last_local_num @local_nums[type] = local_num + puts("writing definition message", type, local_num) write_data(RubyFit::MessageWriter.definition_message(type, local_num)) end From f0247c036c679cc7d3a8b7988854b7616e821567 Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 25 Mar 2025 11:37:21 -0400 Subject: [PATCH 039/104] add debugging statements --- lib/rubyfit/message_writer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 8fc5c7b..bca7bbc 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -269,7 +269,7 @@ class RubyFit::MessageWriter } }, - wahoo_custom_nums: { + wahoo_custom_num: { id: 0xFF04, # Custom message ID, ensure it does not conflict with existing IDs fields: { value: { id: 0, type: RubyFit::Type.uint16, required: true }, From ce80a0838ee5d8638dca54b80eac2830edeaef88 Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 25 Mar 2025 11:40:22 -0400 Subject: [PATCH 040/104] add debugging statements --- lib/rubyfit/writer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 7370e38..ef25ed0 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -378,7 +378,7 @@ def calculate_workout_data_size(workout_step_count, lap_count, session_count, ev device_info: device_info_count, hr_zone: hr_zone_count, power_zone: power_zone_count, - wahoo_custom_nums: wahoo_custom_num_count + wahoo_custom_num: wahoo_custom_num_count } data_sizes = record_counts.map do |type, count| From 0fac18a2a36a0ce56a85ebfe8a11eee6fc1767d3 Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 25 Mar 2025 12:02:36 -0400 Subject: [PATCH 041/104] add wahoo nums --- lib/rubyfit/message_writer.rb | 2 +- lib/rubyfit/writer.rb | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index bca7bbc..fc302f7 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -272,7 +272,7 @@ class RubyFit::MessageWriter wahoo_custom_num: { id: 0xFF04, # Custom message ID, ensure it does not conflict with existing IDs fields: { - value: { id: 0, type: RubyFit::Type.uint16, required: true }, + value: { id: 3, type: RubyFit::Type.uint16, required: true }, timestamp: { id: 1, type: RubyFit::Type.timestamp, required: false }, sub_type: { id: 2, type: RubyFit::Type.uint16, required: true }, type: { id: 3, type: RubyFit::Type.uint8, required: true } diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index ef25ed0..53de0c6 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -322,13 +322,11 @@ def wahoo_custom_num(values) protected def write_message(type, values) - puts("writing message", type, values) local_num = @local_nums[type] unless local_num @last_local_num += 1 local_num = @last_local_num @local_nums[type] = local_num - puts("writing definition message", type, local_num) write_data(RubyFit::MessageWriter.definition_message(type, local_num)) end From 9d3097b96e663878d42b1390e7a062ec65b9a9e1 Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 25 Mar 2025 12:12:59 -0400 Subject: [PATCH 042/104] add wahoo nums --- lib/rubyfit/message_writer.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index fc302f7..c48a71e 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -273,9 +273,9 @@ class RubyFit::MessageWriter id: 0xFF04, # Custom message ID, ensure it does not conflict with existing IDs fields: { value: { id: 3, type: RubyFit::Type.uint16, required: true }, - timestamp: { id: 1, type: RubyFit::Type.timestamp, required: false }, - sub_type: { id: 2, type: RubyFit::Type.uint16, required: true }, - type: { id: 3, type: RubyFit::Type.uint8, required: true } + timestamp: { id: 0, type: RubyFit::Type.timestamp, required: false }, + sub_type: { id: 1, type: RubyFit::Type.uint16, required: true }, + type: { id: 2, type: RubyFit::Type.uint8, required: true } } }, From 2ca494de27d497b31f8557804d68ea3cb08e0800 Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 25 Mar 2025 12:18:58 -0400 Subject: [PATCH 043/104] add wahoo nums --- lib/rubyfit/message_writer.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index c48a71e..e588204 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -270,12 +270,12 @@ class RubyFit::MessageWriter }, wahoo_custom_num: { - id: 0xFF04, # Custom message ID, ensure it does not conflict with existing IDs + id: 0xFF04, fields: { - value: { id: 3, type: RubyFit::Type.uint16, required: true }, - timestamp: { id: 0, type: RubyFit::Type.timestamp, required: false }, - sub_type: { id: 1, type: RubyFit::Type.uint16, required: true }, - type: { id: 2, type: RubyFit::Type.uint8, required: true } + value: { id: 0, type: RubyFit::Type.uint16, required: true }, + timestamp: { id: 1, type: RubyFit::Type.timestamp, required: false }, + sub_type: { id: 2, type: RubyFit::Type.uint16, required: true }, + type: { id: 3, type: RubyFit::Type.uint8, required: true } } }, From 52c3ed5edc6860fcbeb250a9d162d0cada0085b3 Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 25 Mar 2025 12:26:06 -0400 Subject: [PATCH 044/104] add wahoo nums --- lib/rubyfit/message_writer.rb | 2 +- lib/rubyfit/type.rb | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index e588204..f6abbf7 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -272,7 +272,7 @@ class RubyFit::MessageWriter wahoo_custom_num: { id: 0xFF04, fields: { - value: { id: 0, type: RubyFit::Type.uint16, required: true }, + value: { id: 0, type: RubyFit::Type.float64, required: true }, timestamp: { id: 1, type: RubyFit::Type.timestamp, required: false }, sub_type: { id: 2, type: RubyFit::Type.uint16, required: true }, type: { id: 3, type: RubyFit::Type.uint8, required: true } diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 193fb01..f278d6f 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -158,5 +158,15 @@ def duration fit2rb: ->(val, type) { val / 1000.0 } }) end + + def float64(opts = {}) + new({ + fit_id: 0x88, + byte_count: 8, + default_bytes: [0xFF] * 8, + val2bytes: ->(val, type) { [val].pack("G").bytes }, + bytes2val: ->(bytes, type) { bytes.pack("C*").unpack1("G") }, + }.merge(opts)) + end end end From ee8c9cc028c4103ac09c19d8ec804c273dbed6bb Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 25 Mar 2025 12:42:50 -0400 Subject: [PATCH 045/104] add wahoo nums --- lib/rubyfit/type.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index f278d6f..69fb890 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -161,7 +161,7 @@ def duration def float64(opts = {}) new({ - fit_id: 0x88, + fit_id: 0x89, byte_count: 8, default_bytes: [0xFF] * 8, val2bytes: ->(val, type) { [val].pack("G").bytes }, From 78e9ee7ae8a88f91457f74236a0fb1151c22eb7b Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 25 Mar 2025 15:17:21 -0400 Subject: [PATCH 046/104] add wahoo clms --- lib/rubyfit/message_writer.rb | 10 ++++++++++ lib/rubyfit/type.rb | 10 ++++++++++ lib/rubyfit/writer.rb | 23 ++++++++++++++++++----- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index f6abbf7..07a1281 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -279,6 +279,16 @@ class RubyFit::MessageWriter } }, + wahoo_clm: { + id: 0xFF05, + fields: { + timestamp: { id: 0, type: RubyFit::Type.timestamp, required: false }, + device_index: { id: 1, type: RubyFit::Type.uint8, required: false }, + data_len: { id: 2, type: RubyFit::Type.uint8, required: true }, + data: { id: 3, type: RubyFit::Type.byte_array(50), required: true } + } + }, + field_description: { # Must be logged before developer field is used id: 206, diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 69fb890..c22b9d8 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -168,5 +168,15 @@ def float64(opts = {}) bytes2val: ->(bytes, type) { bytes.pack("C*").unpack1("G") }, }.merge(opts)) end + + def byte_array(length, opts = {}) + new({ + fit_id: 0x0D, + byte_count: length, + default_bytes: [0xFF] * length, + val2bytes: ->(val, type) { val }, + bytes2val: ->(bytes, type) { bytes }, + }.merge(opts)) + end end end diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 53de0c6..5988be5 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -121,7 +121,7 @@ def write_activity_file(stream, opts = {}) @stream = stream - %i(start_time duration workout_step_count lap_count session_count event_count record_count power_zone_count hr_zone_count wahoo_custom_num_count).each do |key| + %i(start_time duration workout_step_count lap_count session_count event_count record_count power_zone_count hr_zone_count wahoo_custom_num_count wahoo_clm_count).each do |key| raise ArgumentError.new("Missing required option #{key}") unless opts[key] end @@ -130,7 +130,7 @@ def write_activity_file(stream, opts = {}) @data_crc = 0 - data_size = calculate_workout_data_size( opts[:workout_step_count], opts[:lap_count], opts[:session_count], opts[:event_count],0, opts[:record_count], opts[:device_info_count], opts[:length_count], opts[:power_zone_count], opts[:hr_zone_count], opts[:wahoo_custom_num_count]) + data_size = calculate_workout_data_size( opts[:workout_step_count], opts[:lap_count], opts[:session_count], opts[:event_count],0, opts[:record_count], opts[:device_info_count], opts[:length_count], opts[:power_zone_count], opts[:hr_zone_count], opts[:wahoo_custom_num_count], opts[:wahoo_clm_count]) write_data(RubyFit::MessageWriter.file_header(data_size)) write_message(:file_id, { @@ -258,12 +258,19 @@ def power_zones end def wahoo_custom_nums - raise "Can only write lengths inside 'write' block" if @state != :write + raise "Can only write custom nums inside 'write' block" if @state != :write @state = :wahoo_custom_nums yield @state = :write end + def wahoo_clms + raise "Can only write clms inside 'write' block" if @state != :write + @state = :wahoo_clms + yield + @state = :write + end + def course_point(values) raise "Can only write course points inside 'course_points' block" if @state != :course_points write_message(:course_point, values) @@ -319,6 +326,11 @@ def wahoo_custom_num(values) write_message(:wahoo_custom_num, values) end + def wahoo_clm(values) + raise "Can only write wahoo clms inside 'wahoo_clms' block" if @state != :wahoo_clms + write_message(:wahoo_clm, values) + end + protected def write_message(type, values) @@ -360,7 +372,7 @@ def calculate_data_size(course_point_count, track_point_count) end - def calculate_workout_data_size(workout_step_count, lap_count, session_count, event_count, course_point_count, record_count, device_info_count, length_count, power_zone_count, hr_zone_count, wahoo_custom_num_count) + def calculate_workout_data_size(workout_step_count, lap_count, session_count, event_count, course_point_count, record_count, device_info_count, length_count, power_zone_count, hr_zone_count, wahoo_custom_num_count, wahoo_clm_count) record_counts = { file_id: 1, sport: 1, @@ -376,7 +388,8 @@ def calculate_workout_data_size(workout_step_count, lap_count, session_count, ev device_info: device_info_count, hr_zone: hr_zone_count, power_zone: power_zone_count, - wahoo_custom_num: wahoo_custom_num_count + wahoo_custom_num: wahoo_custom_num_count, + wahoo_clm: wahoo_clm_count } data_sizes = record_counts.map do |type, count| From b589a457bfdf5bf0ba9f5fb0460488bd396d5a4e Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 25 Mar 2025 16:23:13 -0400 Subject: [PATCH 047/104] add wahoo clms --- lib/rubyfit/message_writer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 07a1281..04d1582 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -285,7 +285,7 @@ class RubyFit::MessageWriter timestamp: { id: 0, type: RubyFit::Type.timestamp, required: false }, device_index: { id: 1, type: RubyFit::Type.uint8, required: false }, data_len: { id: 2, type: RubyFit::Type.uint8, required: true }, - data: { id: 3, type: RubyFit::Type.byte_array(50), required: true } + data: { id: 3, type: RubyFit::Type.byte_array(240), required: true } } }, From 2ec5afb397962b196601861b458df6cc63ad45df Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 26 Mar 2025 08:07:03 -0400 Subject: [PATCH 048/104] add wahoo clms --- lib/rubyfit/type.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index c22b9d8..e63675a 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -174,8 +174,12 @@ def byte_array(length, opts = {}) fit_id: 0x0D, byte_count: length, default_bytes: [0xFF] * length, - val2bytes: ->(val, type) { val }, - bytes2val: ->(bytes, type) { bytes }, + val2bytes: ->(val, type) { + val[0, length] + ([0xFF] * (length - val.length)) + }, + bytes2val: ->(bytes, type) { + bytes[0, length] + }, }.merge(opts)) end end From f6ad7993c67989bde83e77fa13f83207ef4519f4 Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 28 Mar 2025 15:08:26 -0400 Subject: [PATCH 049/104] Add parser and tests --- Gemfile | 4 + examples/fit_callbacks.rb | 86 ++++---- fit_data.json | 1 + lib/rubyfit/fit_parser.rb | 195 +++++++++++++++++ lib/rubyfit/helpers.rb | 170 +++++++-------- lib/rubyfit/message_constants.rb | 22 ++ lib/rubyfit/message_writer.rb | 75 ++++--- lib/rubyfit/type.rb | 31 ++- lib/rubyfit/writer.rb | 53 +++-- test/activity_test.rb | 260 +++++++++++++++++++++++ test/fixtures/example_activity_json.json | 224 +++++++++++++++++++ test/fixtures/example_route_json.json | 55 +++++ test/route_test.rb | 97 +++++++++ 13 files changed, 1095 insertions(+), 178 deletions(-) create mode 100644 fit_data.json create mode 100644 lib/rubyfit/fit_parser.rb create mode 100644 test/activity_test.rb create mode 100644 test/fixtures/example_activity_json.json create mode 100644 test/fixtures/example_route_json.json create mode 100644 test/route_test.rb diff --git a/Gemfile b/Gemfile index 2a2dd66..9c80299 100644 --- a/Gemfile +++ b/Gemfile @@ -2,3 +2,7 @@ source "http://rubygems.org" # Specify your gem's dependencies in rubyfit.gemspec gemspec +group :test do + gem 'minitest', '5.10.3' + gem 'json', '~> 2.7.2' +end \ No newline at end of file diff --git a/examples/fit_callbacks.rb b/examples/fit_callbacks.rb index 208b7c0..c1eebc9 100644 --- a/examples/fit_callbacks.rb +++ b/examples/fit_callbacks.rb @@ -1,50 +1,40 @@ class FitCallbacks - def initialize() - end - - def on_activity(msg) - #puts "activity: #{msg.inspect}" - end - - def on_lap(msg) - #puts "lap: #{msg.inspect}" - end - - #what is a session? seems to be another way of saying activity... - def on_session(msg) - #puts "session: #{msg.inspect}" - end - - def on_record(msg) - #puts "record: #{msg.inspect}" - cp = {} - if msg['position_lat'] and msg['position_long'] - cp[:y] = ("%0.6f" % msg['position_lat']).to_f - cp[:x] = ("%0.6f" % msg['position_long']).to_f - end - cp[:d] = msg['distance'] if msg['distance'] - cp[:e] = msg['altitude'] if msg['altitude'] - cp[:h] = msg['heart_rate'] if msg['heart_rate'] - cp[:t] = msg['timestamp'] - cp[:c] = msg['cadence'] if msg['cadence'] - cp[:p] = msg['power'] if msg['power'] - cp[:s] = msg['speed'] if msg['speed'] - cp[:T] = msg['temperature'] if msg['temperature'] - end - - def on_event(msg) - #puts "event: #{msg.inspect}" - end - - def on_device_info(msg) - #puts "device info: #{msg.inspect}" - end - - def on_user_profile(msg) - #puts "user profile: #{msg.inspect}" - end - - def on_weight_scale_info(msg) - #puts "weight scale info: #{msg.inspect}" - end + attr_reader :definitions, :fit_data + + def initialize + @definitions = {} + @fit_data = {} + end + + callbacks = { + definition_message: ->(local_num, global_message_number, fields, developer_fields) { + global_message_number = global_message_number.to_i + # Store the definition for the local number + @definitions[local_num] = { global_message_number: global_message_number, fields: fields, developer_fields: developer_fields } + }, + get_definition: ->(local_num) { + # Retrieve the definition for the local number + @definitions[local_num] || { fields: [] } + }, + data_message: ->(local_num, values) { + + formatted_values = values.map do |key, value| + formatted_value = if value.is_a?(String) + value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') # For byte arrays, convert each byte to hex + else + value.inspect # For non-byte arrays, just inspect the value + end + "#{key}: #{formatted_value}" + + end + + @fit_data[local_num] = formatted_values.join(', ') + }, + end_of_file: -> { + File.open('fit_data.json', 'r') do |file| + json_output = JSON.parse(file.read) + json_input = JSON.parse(json_input) + end + } + } end diff --git a/fit_data.json b/fit_data.json new file mode 100644 index 0000000..571c993 --- /dev/null +++ b/fit_data.json @@ -0,0 +1 @@ +{"file_id":{"serial_number":0,"time_created":4294969321,"manufacturer":32,"product":0,"type":6},"course":{"name":"Test"},"lap":{"timestamp":1743188718,"start_time":1743188718,"total_elapsed_time":0.0,"total_timer_time":0.0,"start_y":34.034969955682755,"start_x":-84.59214996546507,"end_y":34.038519943133,"end_x":-84.59528999403119,"total_distance":536.0,"total_ascent":-497.8,"sport":2,"sub_sport":0,"event":255,"event_type":255,"avg_heart_rate":255,"max_heart_rate":255,"avg_cadence":255,"max_cadence":255,"avg_power":65535,"max_power":65535,"total_work":4294967295,"total_calories":65535,"lap_trigger":255,"normalized_power":65535,"total_moving_time":4294967.295,"min_heart_rate":255,"enhanced_avg_speed":4294967295,"enhanced_max_speed":4294967295},"event":[{"timestamp":1743188718,"event":0,"event_type":0,"event_group":0},{"timestamp":1743188718,"event":0,"event_type":9,"event_group":0}],"record":[{"timestamp":1111065857,"y":34.034969955682755,"x":-84.59214996546507,"distance":0.0,"elevation":321.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":20.7,"elevation":322.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03573991730809,"x":-84.59249999374151,"distance":110.4,"elevation":324.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.036409966647625,"x":-84.59302998147905,"distance":199.6,"elevation":333.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.037079932168126,"x":-84.59356994368136,"distance":289.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03774998150766,"x":-84.5940999314189,"distance":378.5,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03796992264688,"x":-84.59429992362857,"distance":409.1,"elevation":336.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03811995871365,"x":-84.59446999244392,"distance":432.0,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03823998756707,"x":-84.59464995190501,"distance":453.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03841994702816,"x":-84.59484994411469,"distance":480.6,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":516.79,"elevation":336.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.038519943133,"x":-84.59528999403119,"distance":536.1,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67}],"course_point":[{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":42949672.95,"name":"","message_index":65535,"type":6},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":42949672.95,"name":"","message_index":65535,"type":6}]} \ No newline at end of file diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb new file mode 100644 index 0000000..23c0d98 --- /dev/null +++ b/lib/rubyfit/fit_parser.rb @@ -0,0 +1,195 @@ +module RubyFit + class FitParser + REQUIRED_CALLBACKS = [:definition_message, :get_definition, :data_message, :end_of_file] + + def initialize(callbacks) + @callbacks = callbacks + REQUIRED_CALLBACKS.each do |callback| + raise ArgumentError, "Missing required callback: #{callback}" unless @callbacks[callback] + end + end + + def convert_to_json(fit_data, unpack_directive) + # Define the message type to look up + type = RubyFit::MessageConstants::MESSAGE_TYPE.find { |key, value| value == fit_data.keys.first } + puts("Unknown message type: #{fit_data.keys.first}") unless type + return unless type + message_type = RubyFit::MessageConstants::MESSAGE_TYPE.find { |key, value| value == fit_data.keys.first }.first + message_definition = RubyFit::MessageWriter::MESSAGE_DEFINITIONS[message_type] + + # Convert each field in the raw FIT data to a readable format + readable_data = {} + raw_values = fit_data.values.first + puts("raw_values", raw_values) + # Iterate through the message definition fields + message_definition[:fields].each do |field_name, field_definition| + field_id = field_definition[:id] # This is the key we're looking for in the raw data + + # Check if the field ID is present in the raw FIT data + if raw_values.key?(field_id) + raw_value = raw_values[field_id].bytes + readable_data[field_name] = field_definition[:type].bytes2val(raw_value) + else + # If the field is missing, we can either skip it or set it as nil + readable_data[field_name] = nil + end + end + { message_type => readable_data } + end + + + def parse(raw) + # json_file = File.open("fit_data.json", "w") + all_data = {} + io = StringIO.new(raw) + + header_size = io.read(1)&.unpack1('C') + raise "Invalid FIT file: unable to read header size" unless header_size + + protocol_version = io.read(1)&.unpack1('C') + raise "Invalid FIT file: unable to read protocol version" unless protocol_version + + profile_version = io.read(2)&.unpack('v')&.first + raise "Invalid FIT file: unable to read profile version" unless profile_version + + data_size = io.read(4)&.unpack('V')&.first + raise "Invalid FIT file: unable to read data size" unless data_size + + data_type = io.read(4) + raise "Invalid FIT file: invalid data type" unless data_type == ".FIT" + + puts("current position", io.pos) + if io.pos < header_size + io.seek(header_size) + end + + unpack_directive = 'v' + while io.pos < header_size + data_size + record_header = io.read(1)&.unpack1('C') + raise "Invalid FIT file: unable to read record header" unless record_header + puts "Record header: #{record_header.to_s(2).rjust(8, '0')}" + + if record_header & 0x80 == 0x80 + # Handle compressed timestamp header + local_num = (record_header & 0x60) >> 5 + time_offset = record_header & 0x1F + + # Calculate timestamp with respect to the previous timestamp + if @previous_timestamp + if time_offset >= (@previous_timestamp & 0x1F) + timestamp = (@previous_timestamp & 0xFFFFFFE0) + time_offset + else + timestamp = (@previous_timestamp & 0xFFFFFFE0) + time_offset + 0x20 + end + else + timestamp = time_offset + end + + @previous_timestamp = timestamp + + definition = @callbacks[:get_definition].call(local_num) + raise "Unknown definition for local number #{local_num}" unless definition + + values = {} + definition[:fields].each do |field| + value = io.read(field[:size]) + if value.nil? || value.size < field[:size] + puts "Warning: Missing or incomplete field value for field ID #{field[:id]}" + next + else + values[field[:id]] = value + end + end + + # Check if the data message contains a timestamp (id: 253) + if values[253] + @previous_timestamp = values[253].unpack1('V') + end + + @callbacks[:data_message].call(local_num, values) + else + # Check if the record is a definition message by looking at the seventh bit (1 for definition, 0 for data) + if record_header & 0x40 == 0x40 + local_num = record_header & 0x0F + reserved = io.read(1) + architecture = io.read(1)&.unpack1('C') + + if architecture == 1 + unpack_directive = 'n' + end + global_message_number = io.read(2)&.unpack(unpack_directive)&.first + field_count = io.read(1)&.unpack1('C') + + puts("arch", architecture, "global", global_message_number, "field_count", field_count) + + raise "Invalid FIT file: unable to read definition message" unless architecture && global_message_number && field_count + + fields = [] + field_count.times do + field_def = io.read(3)&.unpack('C*') + raise "Invalid FIT file: unable to read field definition" unless field_def + fields << { id: field_def[0], size: field_def[1], type: field_def[2] } + end + + # developer data flag is set + developer_fields = [] + if (record_header & 0x20) == 0x20 + developer_field_count = io.read(1)&.unpack1('C') + developer_field_count.times do + developer_field_def = io.read(3)&.unpack('C*') + raise "Invalid FIT file: unable to read developer field definition" unless developer_field_def + developer_fields << { id: developer_field_def[0], size: developer_field_def[1], type: developer_field_def[2] } + end + end + + @callbacks[:definition_message].call(local_num, global_message_number, fields, developer_fields) + else + # Data Message + local_num = record_header & 0x0F + definition = @callbacks[:get_definition].call(local_num) + raise "Unknown definition for local number #{local_num}" unless definition + + values = {} + definition[:fields].each do |field| + value = io.read(field[:size]) + if value.nil? || value.size < field[:size] + puts "Warning: Missing or incomplete field value for field ID #{field[:id]}" + next + end + values[field[:id]] = value + end + + developer_values = {} + definition[:developer_fields]&.each do |field| + value = io.read(field[:size]) + if value.nil? || value.size < field[:size] + puts "Warning: Missing or incomplete developer field value for field ID #{field[:id]}" + next + end + developer_values[field[:id]] = value + end + + @callbacks[:data_message].call(local_num, values) + data = self.convert_to_json({ definition[:global_message_number] => values }, unpack_directive) + puts("data message decoded", data) + + data&.each do |key, value| + puts("key", key) + if all_data.key?(key) + puts("key exists", key) + all_data[key] = [all_data[key]] unless all_data[key].is_a?(Array) + all_data[key] << value + else + all_data[key] = value + end + end + end + end + end + File.open('fit_data.json', 'w') do |json_file| + json_file.write(all_data.to_json) + end + @callbacks[:end_of_file].call + end + end +end \ No newline at end of file diff --git a/lib/rubyfit/helpers.rb b/lib/rubyfit/helpers.rb index 5e215d9..ab89e29 100644 --- a/lib/rubyfit/helpers.rb +++ b/lib/rubyfit/helpers.rb @@ -1,107 +1,109 @@ -module RubyFit::Helpers +module RubyFit + module Helpers # Garmin timestamps start at 12:00:00 01-01-1989, 20 years after the unix epoch - GARMIN_TIME_OFFSET = 631065600 + GARMIN_TIME_OFFSET = 631065600 - DEGREES_TO_SEMICIRCLES = 2**31 / 180.0 + DEGREES_TO_SEMICIRCLES = 2**31 / 180.0 - # Converts a fixnum or bignum into a byte array, optionally - # truncating or right-filling with 0 to match a certain size - def num2bytes(num, byte_count, big_endian = true) - raise ArgumentError.new("num must be an integer") unless num.is_a?(Integer) - orig_num = num - # Convert negative numbers to two's complement (1-byte alignment) - if num < 0 - num = num.abs + # Converts a fixnum or bignum into a byte array, optionally + # truncating or right-filling with 0 to match a certain size + def num2bytes(num, byte_count, big_endian = true) + raise ArgumentError.new("num must be an integer") unless num.is_a?(Integer) + orig_num = num + # Convert negative numbers to two's complement (1-byte alignment) + if num < 0 + num = num.abs - if num > 2 ** (byte_count * 8 - 1) - STDERR.puts("RubyFit WARNING: Integer underflow for #{orig_num} (#{orig_num.bit_length + 1} bits) when fitting in #{byte_count} bytes (#{byte_count * 8} bits)") - end + if num > 2 ** (byte_count * 8 - 1) + STDERR.puts("RubyFit WARNING: Integer underflow for #{orig_num} (#{orig_num.bit_length + 1} bits) when fitting in #{byte_count} bytes (#{byte_count * 8} bits)") + end - num = 2 ** (byte_count * 8) - num - end + num = 2 ** (byte_count * 8) - num + end - hex = num.to_s(16) - # pack('H*') assumes the high nybble is first, which reverses nybbles in - # the most significant byte if it's only one hex char (<= 0xF). Prevent - # this by prepending a zero if the hex string is an odd length - hex = "0" + hex if hex.length.odd? - result = [hex] - .pack('H*') - .unpack("C*") - - if result.size > byte_count - STDERR.puts("RubyFit WARNING: Truncating #{orig_num} (#{orig_num.bit_length} bits) to fit in #{byte_count} bytes (#{byte_count * 8} bits)") - result = result.last(byte_count) - elsif result.size < byte_count - pad_bytes = [0] * (byte_count - result.size) - result.unshift(*pad_bytes) - end + hex = num.to_s(16) + # pack('H*') assumes the high nybble is first, which reverses nybbles in + # the most significant byte if it's only one hex char (<= 0xF). Prevent + # this by prepending a zero if the hex string is an odd length + hex = "0" + hex if hex.length.odd? + result = [hex] + .pack('H*') + .unpack("C*") + + if result.size > byte_count + STDERR.puts("RubyFit WARNING: Truncating #{orig_num} (#{orig_num.bit_length} bits) to fit in #{byte_count} bytes (#{byte_count * 8} bits)") + result = result.last(byte_count) + elsif result.size < byte_count + pad_bytes = [0] * (byte_count - result.size) + result.unshift(*pad_bytes) + end - result.reverse! unless big_endian + result.reverse! unless big_endian - result - end + result + end - def bytes2num(bytes, byte_count, unsigned = true, big_endian = true) - directive = { - 1 => "C", - 2 => "S", - 4 => "L", - 8 => "Q" - }[byte_count] - raise "Unsupported byte count: #{byte_count}" unless directive - directive << (big_endian ? ">" : "<") if byte_count > 1 - directive.downcase! unless unsigned - bytes.pack("C*").unpack(directive).first - end + def bytes2num(bytes, byte_count, unsigned = true, big_endian = true) + directive = { + 1 => "C", + 2 => "S", + 4 => "L", + 8 => "Q" + }[byte_count] + raise "Unsupported byte count: #{byte_count}" unless directive + directive << (big_endian ? ">" : "<") if byte_count > 1 + directive.downcase! unless unsigned + bytes.pack("C*").unpack(directive).first + end - # Converts an ASCII string into a byte array, truncating or right-filling - # with 0 to match byte_count - def str2bytes(str, byte_count) - str - .unpack("C#{byte_count - 1}") # Convert to n-1 bytes - .map{|v| v || 0} + [0] # Convert nils to 0 and add null terminator - end + # Converts an ASCII string into a byte array, truncating or right-filling + # with 0 to match byte_count + def str2bytes(str, byte_count) + str + .unpack("C#{byte_count - 1}") # Convert to n-1 bytes + .map{|v| v || 0} + [0] # Convert nils to 0 and add null terminator + end - # Converts a byte array to a string. Omits the last character of the byte - # array from the result if it is 0 + # Converts a byte array to a string. Omits the last character of the byte + # array from the result if it is 0 def bytes2str(bytes) - bytes = bytes[0...-1] if bytes.last == 0 + bytes.pop while bytes.last == 0 bytes.pack("C*") end - # Generates strings of hex bytes (for debugging) - def bytes2hex(bytes) - bytes - .map{|b| "0x#{b.to_s(16).ljust(2, "0")}"} - .each_slice(8) - .map{ |s| s.join(", ") } - end + # Generates strings of hex bytes (for debugging) + def bytes2hex(bytes) + bytes + .map{|b| "0x#{b.to_s(16).ljust(2, "0")}"} + .each_slice(8) + .map{ |s| s.join(", ") } + end - def unix2fit_timestamp(timestamp) - timestamp - GARMIN_TIME_OFFSET - end + def unix2fit_timestamp(timestamp) + timestamp - GARMIN_TIME_OFFSET + end - def fit2unix_timestamp(timestamp) - timestamp + GARMIN_TIME_OFFSET - end + def fit2unix_timestamp(timestamp) + timestamp + GARMIN_TIME_OFFSET + end - def deg2semicircles(degrees) - (degrees * DEGREES_TO_SEMICIRCLES).truncate - end + def deg2semicircles(degrees) + (degrees * DEGREES_TO_SEMICIRCLES).truncate + end - def semicircles2deg(degrees) - result = degrees / DEGREES_TO_SEMICIRCLES - result -= 360.0 if result > 180.0 - result += 360.0 if result < -180.0 - result - end + def semicircles2deg(degrees) + result = degrees / DEGREES_TO_SEMICIRCLES + result -= 360.0 if result > 180.0 + result += 360.0 if result < -180.0 + result + end - def make_message_header(opts = {}) - result = 0 - result |= (1 << 6) if opts[:definition] - result |= (opts[:local_number] || 0) & 0xF - result + def make_message_header(opts = {}) + result = 0 + result |= (1 << 6) if opts[:definition] + result |= (opts[:local_number] || 0) & 0xF + result + end end end diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index eca32a5..0bf46ac 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -298,4 +298,26 @@ module RubyFit::MessageConstants uint64: 143, uint64z: 144 }.freeze + + + MESSAGE_TYPE = { + file_id: 0, + event: 21, + record: 20, + lap: 19, + course: 31, + course_point: 32, + session: 18, + workout: 26, + hr_zone: 8, + power_zone: 9, + activity: 34, + device_info: 23, + sport: 12, + wahoo_custom_num: 65284, + wahoo_clm: 65285, + wahoo_id: 65281, + developer_data_id: 207, + field_description: 206 + }.freeze end diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 04d1582..a41a149 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -1,6 +1,10 @@ -require "rubyfit/type" -require "rubyfit/helpers" -require "rubyfit/message_constants" +# require "rubyfit/type" +# require "rubyfit/helpers" +# require "rubyfit/message_constants" + +require_relative 'type' +require_relative 'helpers' +require_relative 'message_constants' class RubyFit::MessageWriter extend RubyFit::Helpers @@ -12,7 +16,7 @@ class RubyFit::MessageWriter file_id: { id: 0, fields: { - serial_number: { id: 3, type: RubyFit::Type.uint32z, required: true }, + serial_number: { id: 3, type: RubyFit::Type.uint32z, required: false }, time_created: { id: 4, type: RubyFit::Type.timestamp, required: true }, manufacturer: { id: 1, type: RubyFit::Type.uint16 }, # See FIT_MANUFACTURER_* product: { id: 2, type: RubyFit::Type.uint16 }, @@ -27,25 +31,25 @@ class RubyFit::MessageWriter } }, - lap: { - id: 19, - fields: { - timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true}, - start_time: { id: 2, type: RubyFit::Type.timestamp, required: true}, - total_elapsed_time: { id: 7, type: RubyFit::Type.duration, required: true }, - total_timer_time: { id: 8, type: RubyFit::Type.duration, required: true }, - start_y: { id: 3, type: RubyFit::Type.semicircles }, - start_x: { id: 4, type: RubyFit::Type.semicircles }, - end_y: { id: 5, type: RubyFit::Type.semicircles }, - end_x: { id: 6, type: RubyFit::Type.semicircles }, - total_distance: { id: 9, type: RubyFit::Type.centimeters }, - total_ascent: { id: 21, type: RubyFit::Type.altitude }, - sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, - subsport: { id: 39, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false } - } - }, + # lap: { + # id: 19, + # fields: { + # timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true}, + # start_time: { id: 2, type: RubyFit::Type.timestamp, required: true}, + # total_elapsed_time: { id: 7, type: RubyFit::Type.duration, required: true }, + # total_timer_time: { id: 8, type: RubyFit::Type.duration, required: true }, + # start_y: { id: 3, type: RubyFit::Type.semicircles }, + # start_x: { id: 4, type: RubyFit::Type.semicircles }, + # end_y: { id: 5, type: RubyFit::Type.semicircles }, + # end_x: { id: 6, type: RubyFit::Type.semicircles }, + # total_distance: { id: 9, type: RubyFit::Type.centimeters }, + # total_ascent: { id: 21, type: RubyFit::Type.altitude }, + # sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, + # subsport: { id: 39, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false } + # } + # }, - wkt_lap: { + lap: { id: 19, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true}, @@ -58,10 +62,10 @@ class RubyFit::MessageWriter end_x: { id: 6, type: RubyFit::Type.semicircles }, total_distance: { id: 9, type: RubyFit::Type.centimeters }, total_ascent: { id: 21, type: RubyFit::Type.altitude }, - sport: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: false }, - sub_sport: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT, required: false}, - event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: false }, - event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: false }, + sport: { id: 25, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: false }, + sub_sport: { id: 39, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT, required: false}, + event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT }, + event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE }, avg_heart_rate: { id: 15, type: RubyFit::Type.uint8 }, max_heart_rate: { id: 16, type: RubyFit::Type.uint8 }, avg_cadence: { id: 17, type: RubyFit::Type.uint8 }, @@ -106,9 +110,9 @@ class RubyFit::MessageWriter cadence: { id: 4, type: RubyFit::Type.uint8 }, power: { id: 7, type: RubyFit::Type.uint16 }, calories: { id: 33, type: RubyFit::Type.uint16 }, - enhanced_speed: { id: 73, type: RubyFit::Type.uint32 }, + enhanced_speed: { id: 73, type: RubyFit::Type.speed}, battery_soc: { id: 78, type: RubyFit::Type.uint8 }, - grade: { id: 9, type: RubyFit::Type.sint16 }, + grade: { id: 9, type: RubyFit::Type.grade}, } }, @@ -207,6 +211,7 @@ class RubyFit::MessageWriter total_elapsed_time: { id: 7, type: RubyFit::Type.duration }, total_timer_time: { id: 8, type: RubyFit::Type.duration }, total_distance: { id: 9, type: RubyFit::Type.centimeters }, + total_ascent: { id: 22, type: RubyFit::Type.altitude }, total_calories: { id: 11, type: RubyFit::Type.uint16 }, avg_heart_rate: { id: 16, type: RubyFit::Type.uint8 }, max_heart_rate: { id: 17, type: RubyFit::Type.uint8 }, @@ -269,6 +274,16 @@ class RubyFit::MessageWriter } }, + wahoo_id: { + id: 0xFF01, + fields: { + app_token: { id: 0, type: RubyFit::Type.string(32), required: false }, + workout_num: { id: 1, type: RubyFit::Type.uint32, required: false }, + workout_type: { id: 2, type: RubyFit::Type.uint16, required: true }, + # workout_token: { id: 3, type: RubyFit::Type.string(32), required: true }, + } + }, + wahoo_custom_num: { id: 0xFF04, fields: { @@ -285,7 +300,7 @@ class RubyFit::MessageWriter timestamp: { id: 0, type: RubyFit::Type.timestamp, required: false }, device_index: { id: 1, type: RubyFit::Type.uint8, required: false }, data_len: { id: 2, type: RubyFit::Type.uint8, required: true }, - data: { id: 3, type: RubyFit::Type.byte_array(240), required: true } + data: { id: 3, type: RubyFit::Type.byte_array(26), required: true } } }, @@ -343,7 +358,7 @@ def self.data_message(type, local_num, values) if info[:values] value = info[:values][value] - if value.nil? + if info[:required] && value.nil? raise ArgumentError.new("Invalid value for '#{field}' in #{type} data message values") end end diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index e63675a..843c444 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -1,4 +1,5 @@ -require "rubyfit/helpers" +# require "rubyfit/helpers" +require_relative 'helpers' class RubyFit::Type attr_reader *%i(fit_id byte_count default_bytes) @@ -147,8 +148,16 @@ def centimeters def altitude uint16({ - rb2fit: ->(val, type) { ((val + 500) * 5.0).truncate }, - fit2rb: ->(val, type) { val / 5.0 - 500 } + rb2fit: ->(val, type) { + result = ((val + 500) * 5.0).truncate + puts "rb2fit: input=#{val}, output=#{result}" + result + }, + fit2rb: ->(val, type) { + result = val / 5.0 - 500 + puts "fit2rb: input=#{val}, output=#{result}" + result + } }) end @@ -159,6 +168,20 @@ def duration }) end + def speed + uint32({ + rb2fit: ->(val, type) { (val * 1000) }, + fit2rb: ->(val, type) { val / 1000.0 } + }) + end + + def grade + sint16({ + rb2fit: ->(val, type) { (val * 100) }, + fit2rb: ->(val, type) { val / 100.0 } + }) + end + def float64(opts = {}) new({ fit_id: 0x89, @@ -175,7 +198,7 @@ def byte_array(length, opts = {}) byte_count: length, default_bytes: [0xFF] * length, val2bytes: ->(val, type) { - val[0, length] + ([0xFF] * (length - val.length)) + val[0, length] + ([0xFF] * [length - val.length, 0].max) }, bytes2val: ->(bytes, type) { bytes[0, length] diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 5988be5..eb93f6e 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -1,5 +1,5 @@ -require "rubyfit/message_writer" - +# require "rubyfit/message_writer" +require_relative "message_writer" class RubyFit::Writer PRODUCT_ID = 65534 # Garmin Connect @@ -46,7 +46,7 @@ def write(stream, opts = {}) total_distance: opts[:total_distance], total_ascent: opts[:total_ascent], sport: opts[:sport], - subsport: opts[:subsport] + sub_sport: opts[:subsport] }) write_message(:event, { @@ -121,16 +121,22 @@ def write_activity_file(stream, opts = {}) @stream = stream - %i(start_time duration workout_step_count lap_count session_count event_count record_count power_zone_count hr_zone_count wahoo_custom_num_count wahoo_clm_count).each do |key| + %i(start_time duration workout_step_count lap_count session_count event_count record_count power_zone_count hr_zone_count wahoo_custom_num_count wahoo_clm_count include_wahoo_id).each do |key| raise ArgumentError.new("Missing required option #{key}") unless opts[key] end + if opts[:include_wahoo_id] + %i(app_token workout_num workout_type).each do |key| + raise ArgumentError.new("Missing required option #{key}") unless opts[key] + end + end + start_time = opts[:start_time].to_i duration = opts[:duration].to_i @data_crc = 0 - data_size = calculate_workout_data_size( opts[:workout_step_count], opts[:lap_count], opts[:session_count], opts[:event_count],0, opts[:record_count], opts[:device_info_count], opts[:length_count], opts[:power_zone_count], opts[:hr_zone_count], opts[:wahoo_custom_num_count], opts[:wahoo_clm_count]) + data_size = calculate_workout_data_size( opts[:workout_step_count], opts[:lap_count], opts[:session_count], opts[:event_count],0, opts[:record_count], opts[:device_info_count], opts[:length_count], opts[:power_zone_count], opts[:hr_zone_count], opts[:wahoo_custom_num_count], opts[:wahoo_clm_count], opts[:include_wahoo_id]) write_data(RubyFit::MessageWriter.file_header(data_size)) write_message(:file_id, { @@ -166,6 +172,14 @@ def write_activity_file(stream, opts = {}) # pool_length_unit: opts[:pool_length_unit] }) + if opts[:include_wahoo_id] == 1 + write_message(:wahoo_id, { + app_token: opts[:app_token], + workout_num: opts[:workout_num], + workout_type: opts[:workout_type] + }) + end + write_message(:event, { timestamp: start_time, event: :timer, @@ -278,6 +292,7 @@ def course_point(values) def track_point(values) raise "Can only write track points inside 'track_points' block" if @state != :track_points + puts("track_point: #{values}") write_message(:record, values) end @@ -288,7 +303,7 @@ def workout_step(values) def lap(values) raise "Can only write laps inside 'laps' block" if @state != :laps - write_message(:wkt_lap, values) + write_message(:lap, values) end def record(values) @@ -347,8 +362,23 @@ def write_message(type, values) def write_data(data) @stream.write(data) - prev = @data_crc - @data_crc = RubyFit::CRC.update_crc(@data_crc, data) + @data_crc = update_crc(@data_crc, data) + end + + def update_crc(crc, data) + crc_table = [0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, + 0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400] + data.each_byte do |byte| + # compute checksum of lower four bits of byte + tmp = crc_table[crc & 0xF] + crc = (crc >> 4) & 0x0FFF + crc = crc ^ tmp ^ crc_table[byte & 0xF] + # now compute checksum of upper four bits of byte + tmp = crc_table[crc & 0xF] + crc = (crc >> 4) & 0x0FFF + crc = crc ^ tmp ^ crc_table[(byte >> 4) & 0xF] + end + crc end def calculate_data_size(course_point_count, track_point_count) @@ -367,18 +397,18 @@ def calculate_data_size(course_point_count, track_point_count) result = def_size + data_size result end - data_sizes.reduce(&:+) end - def calculate_workout_data_size(workout_step_count, lap_count, session_count, event_count, course_point_count, record_count, device_info_count, length_count, power_zone_count, hr_zone_count, wahoo_custom_num_count, wahoo_clm_count) + def calculate_workout_data_size(workout_step_count, lap_count, session_count, event_count, course_point_count, record_count, device_info_count, length_count, power_zone_count, hr_zone_count, wahoo_custom_num_count, wahoo_clm_count, include_wahoo_id) record_counts = { file_id: 1, sport: 1, workout: 1, activity: 1, - wkt_lap: lap_count, + wahoo_id: include_wahoo_id, + lap: lap_count, length: length_count, event: event_count + 2, workout_step: workout_step_count, @@ -404,7 +434,6 @@ def calculate_workout_data_size(workout_step_count, lap_count, session_count, ev result end - puts("data sizes", data_sizes.reduce(&:+)) data_sizes.reduce(&:+) end end diff --git a/test/activity_test.rb b/test/activity_test.rb new file mode 100644 index 0000000..0fa7348 --- /dev/null +++ b/test/activity_test.rb @@ -0,0 +1,260 @@ +require 'minitest/autorun' +require 'json' +# require "rubyfit/message_writer" +# require "rubyfit/writer" +require_relative '../lib/rubyfit/writer' +require_relative '../lib/rubyfit/message_constants' +require_relative '../lib/rubyfit/fit_parser' +require_relative '../examples/fit_callbacks' +class RubyFitIntegrationTest < Minitest::Test + def test_rubyfit_integration + json_input = File.read('test/fixtures/example_activity_json.json') + fit_file_path = 'output.fit' + + # Parse JSON input + json = JSON.parse(json_input, symbolize_names: true) + json[:laps] = json[:laps].map { |lap| lap.transform_keys(&:to_sym).merge(sport: lap[:sport].to_sym, sub_sport: lap[:sub_sport].to_sym, event: lap[:event].to_sym, event_type: lap[:event_type].to_sym, lap_trigger: lap[:lap_trigger].to_sym) } + json[:sessions] = json[:sessions].map { |session| session.transform_keys(&:to_sym).merge(sport: session[:sport].to_sym, sub_sport: session[:sub_sport].to_sym, event: session[:event].to_sym, event_type: session[:event_type].to_sym) } + + # Write FIT file + writer = RubyFit::Writer.new + File.open(fit_file_path, 'wb') do |file| + writer.write_activity_file(file, { + start_time: (json[:start_time]).to_i, + include_wahoo_id: 1, + app_token: json[:wahoo_id][:app_token], + workout_num: json[:wahoo_id][:workout_num], + workout_type: json[:wahoo_id][:workout_type], + timestamp: (json[:timestamp]).to_i, + total_timer_time: (json[:total_timer_time]).to_i, + local_timestamp: (json[:local_timestamp]).to_i, + duration: json[:duration].to_i || 0, + sessions_count: (json[:sessions]&.size).to_i, + lap_count: (json[:laps]&.size).to_i, + power_zone_count: json[:power_zones]&.size || 0, + hr_zone_count: json[:hr_zones]&.size || 0, + length_count: json[:lengths]&.size || 0, + record_count: json[:records]&.size || 0, + device_info_count: json[:device_infos]&.size || 0, + wahoo_custom_num_count: json[:wahoo_custom_nums]&.size || 0, + wahoo_clm_count: json[:wahoo_clms]&.size || 0, + name: json[:name] || 'unnamed', + total_distance: (json[:total_distance] || 0), + total_ascent: (json[:total_ascent] || 0), + time_created: (json[:created_at] || Time.now).to_i, + start_x: (json[:first_lng] || 0), + start_y: (json[:first_lat] || 0), + end_x: (json[:last_lng] || 0), + end_y: (json[:last_lat] || 0), + manufacturer: json[:manufacturer], + product: 1, + product_name: json[:product_name] || 'unnamed', + sport: json[:sport]&.downcase&.to_sym, + subsport: json[:sub_sport]&.downcase&.to_sym, + intensity: json[:intensity] || 0, + session_count: json[:sessions]&.size || 0, + total_calories: json[:calories] || 0, + workout_step_count: json[:workout_steps]&.size || 0, + num_valid_steps: json[:num_valid_steps] || 0, + event_count: 0, + type: :generic, + event: :activity, + event_type: :stop + }) do + writer.records do + json[:records]&.each do |record| + writer.record(record) + end + end + writer.laps do + json[:laps]&.each do |lap| + writer.lap(lap) + end + end + writer.sessions do + json[:sessions]&.each do |session| + writer.session(session) + end + end + writer.lengths do + json[:lengths]&.each do |length| + writer.length(length) + end + end + writer.hr_zones do + json[:hr_zones]&.each do |zone| + writer.hr_zone(zone) + end + end + writer.power_zones do + json[:power_zones]&.each do |zone| + writer.power_zone(zone) + end + end + writer.device_infos do + json[:device_infos]&.each do |device| + writer.device_info(device) + end + end + writer.wahoo_custom_nums do + json[:wahoo_custom_nums]&.each do |num| + writer.wahoo_custom_num(num) + end + end + writer.wahoo_clms do + json[:wahoo_clms]&.each do |clm| + writer.wahoo_clm(clm) + end + end + end + end + + # this is a Wahoo fit file with clm and wahoo custom num messages + # fit_file_path = '2025-01-03-143057-WAHOOAPPIOS62BB-3-0.fit' + # Read FIT file + raw = IO.read(fit_file_path) + + definitions = {} + fit_data = {} + + callbacks = { + definition_message: ->(local_num, global_message_number, fields, developer_fields) { + global_message_number = global_message_number.to_i + # Store the definition for the local number + definitions[local_num] = { global_message_number: global_message_number, fields: fields, developer_fields: developer_fields } + }, + get_definition: ->(local_num) { + # Retrieve the definition for the local number + definitions[local_num] || { fields: [] } + }, + data_message: ->(local_num, values) { + + formatted_values = values.map do |key, value| + formatted_value = if value.is_a?(String) + value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') # For byte arrays, convert each byte to hex + else + value.inspect # For non-byte arrays, just inspect the value + end + "#{key}: #{formatted_value}" + + end + + fit_data[local_num] = formatted_values.join(', ') + }, + end_of_file: -> { + File.open('fit_data.json', 'r') do |file| + json_output = JSON.parse(file.read) + json_input = JSON.parse(json_input) + assert_equal json_output['file_id']['manufacturer'], json_input['manufacturer'] + assert_equal json_output['activity']['timestamp'], json_input['timestamp'] + assert_equal json_output['activity']['total_timer_time'], json_input['total_timer_time'] + assert_equal json_output['activity']['total_timer_time'], json_input['total_timer_time'] + assert_equal json_output['activity']['local_timestamp'], json_input['local_timestamp'] + assert_equal json_output['workout']['sport'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] + assert_equal json_output['workout']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] + assert_equal json_output['workout']['wkt_name'], json_input['name'] + assert_equal json_output['sport']['sport'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] + assert_equal json_output['sport']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] + assert_equal json_output['wahoo_id']['app_token'], json_input['wahoo_id']['app_token'] + assert_equal json_output['wahoo_id']['workout_num'], json_input['wahoo_id']['workout_num'] + assert_equal json_output['wahoo_id']['workout_type'], json_input['wahoo_id']['workout_type'] + if json_input['records'].size > 1 + puts(json_output['record']) + assert_equal json_output['record'].size, json_input['records'].size + assert_equal json_output['record'].last['timestamp'], json_input['records'].last['timestamp'] + assert_equal json_output['record'].last['y'].round(2), json_input['records'].last['y'].round(2) + assert_equal json_output['record'].last['x'].round(2), json_input['records'].last['x'].round(2) + assert_equal json_output['record'].last['distance'].round(2), json_input['records'].last['distance'].round(2) + assert_equal json_output['record'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) + assert_equal json_output['record'].last['heart_rate'], json_input['records'].last['heart_rate'] + assert_equal json_output['record'].last['cadence'], json_input['records'].last['cadence'] + assert_equal json_output['record'].last['power'], json_input['records'].last['power'] + assert_equal json_output['record'].last['enhanced_speed'], json_input['records'].last['enhanced_speed'] + assert_equal json_output['record'].last['battery_soc'], json_input['records'].last['battery_soc'] + assert_equal json_output['record'].last['grade'], json_input['records'].last['grade'] + elsif json_input['records'].size > 0 + assert_equal json_output['record']['timestamp'], json_input['records'].last['timestamp'] + assert_equal json_output['record']['y'].round(2), json_input['records'].last['y'].round(2) + assert_equal json_output['record']['x'].round(2), json_input['records'].last['x'].round(2) + assert_equal json_output['record'].last['distance'].round(2), json_input['records'].last['distance'].round(2) + assert_equal json_output['record'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) + assert_equal json_output['record']['heart_rate'], json_input['records'].last['heart_rate'] + assert_equal json_output['record']['cadence'], json_input['records'].last['cadence'] + assert_equal json_output['record']['power'], json_input['records'].last['power'] + assert_equal json_output['record']['enhanced_speed'], json_input['records'].last['enhanced_speed'] + assert_equal json_output['record']['battery_soc'], json_input['records'].last['battery_soc'] + assert_equal json_output['record']['grade'], json_input['records'].last['grade'] + + end + if json_input['laps'].size > 1 + assert_equal json_output['lap'].size, json_input['laps'].size + assert_equal json_output['lap'].last['start_time'], json_input['laps'].last['start_time'] + assert_equal json_output['lap'].last['total_timer_time'], json_input['laps'].last['total_timer_time'] + assert_equal json_output['lap'].last['total_distance'], json_input['laps'].last['total_distance'] + assert_equal json_output['lap'].last['total_ascent'], json_input['laps'].last['total_ascent'] + assert_equal json_output['lap'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] + assert_equal json_output['lap'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] + assert_equal json_output['lap'].last['total_calories'], json_input['laps'].last['total_calories'] + assert_equal json_output['lap'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] + assert_equal json_output['lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] + assert_equal json_output['lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] + elsif json_input['laps'].size > 0 + assert_equal json_output['wkt_lap']['start_time'], json_input['laps'].last['start_time'] + assert_equal json_output['wkt_lap']['total_timer_time'], json_input['laps'].last['total_timer_time'] + assert_equal json_output['wkt_lap']['total_distance'], json_input['laps'].last['total_distance'] + assert_equal json_output['wkt_lap']['total_ascent'], json_input['laps'].last['total_ascent'] + assert_equal json_output['wkt_lap'].last['total_calories'], json_input['laps'].last['total_calories'] + assert_equal json_output['wkt_lap'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] + assert_equal json_output['wkt_lap'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] + assert_equal json_output['wkt_lap'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] + assert_equal json_output['wkt_lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] + assert_equal json_output['wkt_lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] + end + if json_input['sessions'].size > 1 + assert_equal json_output['session'].size, json_input['sessions'].size + assert_equal json_output['session'].last['start_time'], json_input['sessions'].last['start_time'] + assert_equal json_output['session'].last['total_timer_time'], json_input['sessions'].last['total_timer_time'] + assert_equal json_output['session'].last['total_distance'], json_input['sessions'].last['total_distance'] + assert_equal json_output['session'].last['total_ascent'], json_input['sessions'].last['total_ascent'] + assert_equal json_output['session'].last['total_calories'], json_input['sessions'].last['total_calories'] + assert_equal json_output['session'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] + assert_equal json_output['session'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] + assert_equal json_output['session'].last['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] + assert_equal json_output['session'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] + elsif json_input['sessions'].size > 0 + assert_equal json_output['session']['start_time'], json_input['sessions'].last['start_time'] + assert_equal json_output['session']['total_timer_time'], json_input['sessions'].last['total_timer_time'] + assert_equal json_output['session']['total_distance'], json_input['sessions'].last['total_distance'] + assert_equal json_output['session']['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] + assert_equal json_output['session']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] + assert_equal json_output['session']['total_ascent'], json_input['sessions'].last['total_ascent'] + assert_equal json_output['session']['total_calories'], json_input['sessions'].last['total_calories'] + assert_equal json_output['session']['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] + assert_equal json_output['session']['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] + end + if json_input['device_infos'].size > 1 + assert_equal json_output['device_info'].size, json_input['device_infos'].size + assert_equal json_output['device_info'].last['timestamp'], json_input['device_infos'].last['timestamp'] + assert_equal json_output['device_info'].last['serial_number'], json_input['device_infos'].last['serial_number'] + assert_equal json_output['device_info'].last['manufacturer'], json_input['device_infos'].last['manufacturer'] + assert_equal json_output['device_info'].last['product'], json_input['device_infos'].last['product'] + assert_equal json_output['device_info'].last['software_version'], json_input['device_infos'].last['software_version'] + assert_equal json_output['device_info'].last['battery_voltage'], json_input['device_infos'].last['battery_voltage'] + assert_equal json_output['device_info'].last['device_index'], json_input['device_infos'].last['device_index'] + elsif json_input['device_infos'].size > 0 + assert_equal json_output['device_info']['timestamp'], json_input['device_infos'].last['timestamp'] + assert_equal json_output['device_info']['serial_number'], json_input['device_infos'].last['serial_number'] + assert_equal json_output['device_info']['manufacturer'], json_input['device_infos'].last['manufacturer'] + assert_equal json_output['device_info']['product'], json_input['device_infos'].last['product'] + assert_equal json_output['device_info']['software_version'], json_input['device_infos'].last['software_version'] + assert_equal json_output['device_info']['battery_voltage'], json_input['device_infos'].last['battery_voltage'] + assert_equal json_output['device_info']['device_index'], json_input['device_infos'].last['device_index'] + end + end + } + } + + parser = RubyFit::FitParser.new(callbacks) + parser.parse(raw) + end +end \ No newline at end of file diff --git a/test/fixtures/example_activity_json.json b/test/fixtures/example_activity_json.json new file mode 100644 index 0000000..6d9f3f6 --- /dev/null +++ b/test/fixtures/example_activity_json.json @@ -0,0 +1,224 @@ +{ + "name": "Morning Run", + "manufacturer": 2, + "total_distance": 5875.6, + "total_moving_time": 2310, + "total_elapsed_time": 2321, + "total_timer_time": 2309, + "total_ascent": 46.0, + "type": "running", + "sport": "generic", + "sub_sport": "generic", + "workout_type": null, + "id": 13912150324, + "start_time": 1111065857, + "timestamp": 1111065857, + "local_timestamp": 1111065857, + "start_time_local": "2025-03-16T08:24:17Z", + "timezone": "(GMT-06:00) America/Chicago", + "utc_offset": -18000.0, + "first_lng": -94.68175, + "first_lat": 38.998181, + "last_lat": 38.998181, + "last_lng": -94.68175, + "average_speed": 2.544, + "max_speed": 3.5, + "avg_cadence": 81.2, + "avg_power": 154.1, + "max_power": 212, + "normalized_power": 154, + "device_watts": true, + "kilojoules": 355.9, + "has_heartrate": false, + "heartrate_opt_out": false, + "display_hide_heartrate_option": false, + "elev_high": 330.0, + "elev_low": 305.0, + "upload_id": 14849573752, + "upload_id_str": "14849573752", + "external_id": "stripped_467683584539394059.fit", + "from_accepted_tag": false, + "pr_count": 0, + "total_photo_count": 0, + "has_kudoed": false, + "description": "", + "total_calories": 275.0, + "perceived_exertion": null, + "prefer_perceived_exertion": null, + "laps": [ + { + "id": 49308517261, + "timestamp": 1111065857, + "name": "Lap 1", + "total_elapsed_time": 645, + "total_moving_time": 645, + "total_timer_time": 645, + "start_time": 1111065857, + "start_date_local": 1111065857, + "total_distance": 1609, + "total_calories": 275, + "average_speed": 2.5, + "max_speed": 3.4, + "lap_index": 1, + "split": 1, + "start_index": 0, + "end_index": 207, + "total_ascent": 23, + "avg_cadence": 81, + "device_watts": true, + "avg_power": 156, + "pace_zone": 0, + "sport": "generic", + "sub_sport": "generic", + "event": "lap", + "event_type": "stop", + "lap_trigger": "manual" + }, + { + "id": 49308517263, + "timestamp": 1111065857, + "resource_state": 2, + "name": "Lap 2", + "total_elapsed_time": 650, + "total_moving_time": 650, + "total_timer_time": 650, + "start_time": 1111065857, + "start_time_local": 1111065857, + "total_distance": 1609.3, + "total_calories": 275, + "average_speed": 2.48, + "max_speed": 3.2, + "lap_index": 2, + "split": 2, + "start_index": 208, + "end_index": 857, + "total_ascent": 7, + "avg_cadence": 79, + "device_watts": true, + "avg_power": 146, + "pace_zone": 0, + "sport": "generic", + "sub_sport": "generic", + "event": "lap", + "event_type": "stop", + "lap_trigger": "manual" + } + ], + "device_infos": [ + { + "timestamp": 1111065857, + "manufacturer_code": 265, + "device_name": "COROS APEX", + "serial_number": 1234, + "manufacturer": 2, + "product": 0, + "software_version": 0, + "hardware_version": 0, + "battery_voltage": 0, + "device_index": 0, + "product_name": "Product Name" + } + ], + "sessions": [ + { + "timestamp": 1111065857, + "start_time": 1111065857, + "total_distance": 5875, + "total_moving_time": 2310, + "total_elapsed_time": 2321, + "total_timer_time": 2309, + "total_ascent": 46, + "start_time_local": "2025-03-16T08:24:17Z", + "timezone": "(GMT-06:00) America/Chicago", + "utc_offset": -18000.0, + "first_lng": -94.68175, + "first_lat": 38.998181, + "last_lat": 38.998181, + "last_lng": -94.68175, + "average_speed": 2.544, + "max_speed": 3.5, + "avg_cadence": 81, + "avg_power": 154, + "max_power": 212, + "normalized_power": 154, + "total_calories": 275, + "event": "session", + "event_type": "stop", + "sport": "generic", + "sub_sport": "generic" + } + ], + "records": [ + { + "timestamp": 1111065857, + "y": 38.998180, + "x": -94.681747, + "distance": 0, + "elevation": 309, + "heart_rate": 0, + "cadence": 0, + "power": 0, + "calories": 0, + "enhanced_speed": 0, + "battery_soc": 0, + "grade": 0 + }, + { + "timestamp": 1111065857, + "y": 38.998180, + "x": -94.681747, + "distance": 20, + "elevation": 309, + "heart_rate": 120, + "cadence": 81, + "power": 185, + "calories": 100, + "enhanced_speed": 20, + "battery_soc": 30, + "grade": 5 + } + ], + "hr_zones": [ + { + "high_bpm": 118, + "name": "Zone 0" + }, + { + "high_bpm": 147, + "name": "Zone 1" + } + ], + "power_zones": [ + { + "high_value": 68, + "name": "Zone 0" + }, + { + "high_value": 87, + "name": "Zone 1" + } + ], + + "wahoo_id": { + "app_token": "WAHOOAPPIOS62BB", + "workout_num": 3, + "workout_type": 12 + }, + + "wahoo_custom_nums": [ + { + "value": 124, + "sub_type": 1, + "type": 0 + } + ], + + "wahoo_clms": [ + { + "timestamp": 1104849057, + "device_index": 255, + "data_len": 26, + "data": [53,0,5,52,50,57,52,57,54,55,50,57,53,0,0,14,0,255,255,255,255,0,255,255,255,255] + } + ] +} diff --git a/test/fixtures/example_route_json.json b/test/fixtures/example_route_json.json new file mode 100644 index 0000000..54d9eef --- /dev/null +++ b/test/fixtures/example_route_json.json @@ -0,0 +1,55 @@ +{ + "id": 49877007, + "url": "https://ridewithgps.com/api/v1/routes/49877007.json", + "name": "Test", + "visibility": "private", + "description": "", + "locality": "Cobb County", + "administrative_area": "GA", + "country_code": "US", + "distance": 536, + "elevation_gain": 12, + "elevation_loss": 0, + "first_lat": 34.03497, + "first_lng": -84.59215, + "last_lat": 34.03852, + "last_lng": -84.59529, + "sw_lat": 34.03497, + "sw_lng": -84.59529, + "ne_lat": 34.03864, + "ne_lng": -84.59196, + "track_type": "point_to_point", + "terrain": "climbing", + "difficulty": "casual", + "unpaved_pct": 0, + "surface": "paved", + "activity_types": ["cycling"], + "created_at": "2025-03-05T14:42:55Z", + "updated_at": "2025-03-05T14:42:55Z", + "track_points": [ + {"x": -84.59215, "y": 34.03497, "elevation": 321.7, "distance": 0.0, "S": 0, "R": 6, "timestamp": 1111065857}, + {"x": -84.59196, "y": 34.03507, "elevation": 322.7, "distance": 20.7, "S": 1, "R": 4, "timestamp": 1111065857}, + {"x": -84.5925, "y": 34.03574, "elevation": 324.4, "distance": 110.4, "S": 1, "R": 4, "timestamp": 1111065857}, + {"x": -84.59303, "y": 34.03641, "elevation": 333.2, "distance": 199.6, "S": 1, "R": 4, "timestamp": 1111065857}, + {"x": -84.59357, "y": 34.03708, "elevation": 336.8, "distance": 289.3, "S": 1, "R": 4, "timestamp": 1111065857}, + {"x": -84.5941, "y": 34.03775, "elevation": 336.2, "distance": 378.5, "S": 1, "R": 4, "timestamp": 1111065857}, + {"x": -84.5943, "y": 34.03797, "elevation": 336.4, "distance": 409.1, "S": 1, "R": 4, "timestamp": 1111065857}, + {"x": -84.59447, "y": 34.03812, "elevation": 336.8, "distance": 432.0, "S": 1, "R": 4, "timestamp": 1111065857}, + {"x": -84.59465, "y": 34.03824, "elevation": 336.9, "distance": 453.3, "S": 1, "R": 4, "timestamp": 1111065857}, + {"x": -84.59485, "y": 34.03842, "elevation": 336.9, "distance": 480.6, "S": 1, "R": 4, "timestamp": 1111065857}, + {"x": -84.59514, "y": 34.03864, "elevation": 336.6, "distance": 516.8, "S": 0, "R": 6, "timestamp": 1111065857}, + {"x": -84.59529, "y": 34.03852, "elevation": 336.2, "distance": 536.1, "timestamp": 1111065857} + ], + "course_points": [ + {"x": -84.59196, "y": 34.03507, "d": 20.7, "i": 1, "t": "Left", "n": "Turn left onto McCollum Parkway Northwest", "timestamp": 1111065857, "type": "left"}, + {"x": -84.59514, "y": 34.03864, "d": 516.8, "i": 10, "t": "Left", "n": "Turn left onto Timberlake Road", "timestamp": 1111065857, "type": "left"} + ], + "points_of_interest": [], + "ascent": 12, + "sport_code": 2, + "file_id": { + "manufacturer_code": 1, + "product": 65534, + "manufacturer": "Garmin" + } +} \ No newline at end of file diff --git a/test/route_test.rb b/test/route_test.rb new file mode 100644 index 0000000..2fdd8f9 --- /dev/null +++ b/test/route_test.rb @@ -0,0 +1,97 @@ +require 'minitest/autorun' +require 'json' +require_relative '../lib/rubyfit/writer' +require_relative '../lib/rubyfit/message_constants' +require_relative '../lib/rubyfit/fit_parser' +require_relative '../examples/fit_callbacks' +class RubyFitIntegrationTest < Minitest::Test + def test_integration + json_input = File.read('test/fixtures/example_route_json.json') + fit_file_path = 'route.fit' + json = JSON.parse(json_input, symbolize_names: false) + + sport = RubyFit::MessageConstants::SPORT.key(json['sport_code']) + subsport = RubyFit::MessageConstants::SUBSPORT.key(json['subsport_code']) || :generic + puts("subby", subsport, sport) + + + writer = RubyFit::Writer.new + File.open(fit_file_path, 'wb') do |file| + writer.write(file, { + start_time: (json['start_time'] || Time.now).to_i, + duration: json['duration'].to_i || 0, + course_point_count: (json['course_points']&.size || 0).to_i, + track_point_count: (json['track_points']&.size || 0).to_i, + name: json['name'] || 'unnamed', + total_distance: (json['distance'] || 0), + total_ascent: (json['ascent'] / 5.0 - 500 || 0), + time_created: (json['created_at'] || Time.now).to_i, + start_x: (json['first_lng'] || 0), + start_y: (json['first_lat'] || 0), + end_x: (json['last_lng'] || 0), + end_y: (json['last_lat'] || 0), + manufacturer: json['manufacturer_code'] || 32, + product: json['product'] || 0, + sport: sport, + subsport: subsport + }) do + writer.track_points do + json['track_points']&.each do |record| + record = record.transform_keys(&:to_sym) + writer.track_point(record) + end + end + + writer.course_points do + json['course_points']&.each do |point| + point = point.transform_keys(&:to_sym).merge(type: point['type'].to_sym) + writer.course_point(point) + end + end + end + end + + raw = IO.read(fit_file_path) + + definitions = {} + fit_data = {} + + callbacks = { + definition_message: ->(local_num, global_message_number, fields, developer_fields) { + global_message_number = global_message_number.to_i + # Store the definition for the local number + definitions[local_num] = { global_message_number: global_message_number, fields: fields, developer_fields: developer_fields } + }, + get_definition: ->(local_num) { + # Retrieve the definition for the local number + definitions[local_num] || { fields: [] } + }, + data_message: ->(local_num, values) { + + formatted_values = values.map do |key, value| + formatted_value = if value.is_a?(String) + value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') # For byte arrays, convert each byte to hex + else + value.inspect # For non-byte arrays, just inspect the value + end + "#{key}: #{formatted_value}" + + end + + fit_data[local_num] = formatted_values.join(', ') + }, + end_of_file: -> { + File.open('fit_data.json', 'r') do |file| + json_output = JSON.parse(file.read) + json_input = JSON.parse(json_input) + + assert_equal(json_input['track_points'].size, json_output['record'].size) + assert_equal(json_input['course_points'].size, json_output['course_point'].size) + puts(json_output) + end + } + } + parser = RubyFit::FitParser.new(callbacks) + parser.parse(raw) + end +end \ No newline at end of file From d5046c21bc5584d560d462fe43682f5f644723fd Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 28 Mar 2025 16:12:17 -0400 Subject: [PATCH 050/104] remove puts statements --- fit_data.json | 2 +- lib/rubyfit/fit_parser.rb | 11 ++--------- lib/rubyfit/message_writer.rb | 18 ------------------ lib/rubyfit/type.rb | 2 -- lib/rubyfit/writer.rb | 2 -- test/activity_test.rb | 3 +-- test/fixtures/example_activity_json.json | 4 ++-- test/route_test.rb | 5 +---- 8 files changed, 7 insertions(+), 40 deletions(-) diff --git a/fit_data.json b/fit_data.json index 571c993..6dab5bc 100644 --- a/fit_data.json +++ b/fit_data.json @@ -1 +1 @@ -{"file_id":{"serial_number":0,"time_created":4294969321,"manufacturer":32,"product":0,"type":6},"course":{"name":"Test"},"lap":{"timestamp":1743188718,"start_time":1743188718,"total_elapsed_time":0.0,"total_timer_time":0.0,"start_y":34.034969955682755,"start_x":-84.59214996546507,"end_y":34.038519943133,"end_x":-84.59528999403119,"total_distance":536.0,"total_ascent":-497.8,"sport":2,"sub_sport":0,"event":255,"event_type":255,"avg_heart_rate":255,"max_heart_rate":255,"avg_cadence":255,"max_cadence":255,"avg_power":65535,"max_power":65535,"total_work":4294967295,"total_calories":65535,"lap_trigger":255,"normalized_power":65535,"total_moving_time":4294967.295,"min_heart_rate":255,"enhanced_avg_speed":4294967295,"enhanced_max_speed":4294967295},"event":[{"timestamp":1743188718,"event":0,"event_type":0,"event_group":0},{"timestamp":1743188718,"event":0,"event_type":9,"event_group":0}],"record":[{"timestamp":1111065857,"y":34.034969955682755,"x":-84.59214996546507,"distance":0.0,"elevation":321.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":20.7,"elevation":322.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03573991730809,"x":-84.59249999374151,"distance":110.4,"elevation":324.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.036409966647625,"x":-84.59302998147905,"distance":199.6,"elevation":333.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.037079932168126,"x":-84.59356994368136,"distance":289.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03774998150766,"x":-84.5940999314189,"distance":378.5,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03796992264688,"x":-84.59429992362857,"distance":409.1,"elevation":336.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03811995871365,"x":-84.59446999244392,"distance":432.0,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03823998756707,"x":-84.59464995190501,"distance":453.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03841994702816,"x":-84.59484994411469,"distance":480.6,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":516.79,"elevation":336.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.038519943133,"x":-84.59528999403119,"distance":536.1,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67}],"course_point":[{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":42949672.95,"name":"","message_index":65535,"type":6},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":42949672.95,"name":"","message_index":65535,"type":6}]} \ No newline at end of file +{"file_id":{"serial_number":0,"time_created":4294969321,"manufacturer":32,"product":0,"type":6},"course":{"name":"Test"},"lap":{"timestamp":1743192682,"start_time":1743192682,"total_elapsed_time":0.0,"total_timer_time":0.0,"start_y":34.034969955682755,"start_x":-84.59214996546507,"end_y":34.038519943133,"end_x":-84.59528999403119,"total_distance":536.0,"total_ascent":-497.8,"sport":2,"sub_sport":0,"event":255,"event_type":255,"avg_heart_rate":255,"max_heart_rate":255,"avg_cadence":255,"max_cadence":255,"avg_power":65535,"max_power":65535,"total_work":4294967295,"total_calories":65535,"lap_trigger":255,"normalized_power":65535,"total_moving_time":4294967.295,"min_heart_rate":255,"enhanced_avg_speed":4294967295,"enhanced_max_speed":4294967295},"event":[{"timestamp":1743192682,"event":0,"event_type":0,"event_group":0},{"timestamp":1743192682,"event":0,"event_type":9,"event_group":0}],"record":[{"timestamp":1111065857,"y":34.034969955682755,"x":-84.59214996546507,"distance":0.0,"elevation":321.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":20.7,"elevation":322.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03573991730809,"x":-84.59249999374151,"distance":110.4,"elevation":324.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.036409966647625,"x":-84.59302998147905,"distance":199.6,"elevation":333.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.037079932168126,"x":-84.59356994368136,"distance":289.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03774998150766,"x":-84.5940999314189,"distance":378.5,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03796992264688,"x":-84.59429992362857,"distance":409.1,"elevation":336.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03811995871365,"x":-84.59446999244392,"distance":432.0,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03823998756707,"x":-84.59464995190501,"distance":453.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03841994702816,"x":-84.59484994411469,"distance":480.6,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":516.79,"elevation":336.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.038519943133,"x":-84.59528999403119,"distance":536.1,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67}],"course_point":[{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":42949672.95,"name":"","message_index":65535,"type":6},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":42949672.95,"name":"","message_index":65535,"type":6}]} \ No newline at end of file diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 23c0d98..cd3cb03 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -1,5 +1,5 @@ module RubyFit - class FitParser + class FitFileParser REQUIRED_CALLBACKS = [:definition_message, :get_definition, :data_message, :end_of_file] def initialize(callbacks) @@ -20,7 +20,7 @@ def convert_to_json(fit_data, unpack_directive) # Convert each field in the raw FIT data to a readable format readable_data = {} raw_values = fit_data.values.first - puts("raw_values", raw_values) + # Iterate through the message definition fields message_definition[:fields].each do |field_name, field_definition| field_id = field_definition[:id] # This is the key we're looking for in the raw data @@ -58,7 +58,6 @@ def parse(raw) data_type = io.read(4) raise "Invalid FIT file: invalid data type" unless data_type == ".FIT" - puts("current position", io.pos) if io.pos < header_size io.seek(header_size) end @@ -67,7 +66,6 @@ def parse(raw) while io.pos < header_size + data_size record_header = io.read(1)&.unpack1('C') raise "Invalid FIT file: unable to read record header" unless record_header - puts "Record header: #{record_header.to_s(2).rjust(8, '0')}" if record_header & 0x80 == 0x80 # Handle compressed timestamp header @@ -120,8 +118,6 @@ def parse(raw) global_message_number = io.read(2)&.unpack(unpack_directive)&.first field_count = io.read(1)&.unpack1('C') - puts("arch", architecture, "global", global_message_number, "field_count", field_count) - raise "Invalid FIT file: unable to read definition message" unless architecture && global_message_number && field_count fields = [] @@ -171,12 +167,9 @@ def parse(raw) @callbacks[:data_message].call(local_num, values) data = self.convert_to_json({ definition[:global_message_number] => values }, unpack_directive) - puts("data message decoded", data) data&.each do |key, value| - puts("key", key) if all_data.key?(key) - puts("key exists", key) all_data[key] = [all_data[key]] unless all_data[key].is_a?(Array) all_data[key] << value else diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index a41a149..8a3cb16 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -31,24 +31,6 @@ class RubyFit::MessageWriter } }, - # lap: { - # id: 19, - # fields: { - # timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true}, - # start_time: { id: 2, type: RubyFit::Type.timestamp, required: true}, - # total_elapsed_time: { id: 7, type: RubyFit::Type.duration, required: true }, - # total_timer_time: { id: 8, type: RubyFit::Type.duration, required: true }, - # start_y: { id: 3, type: RubyFit::Type.semicircles }, - # start_x: { id: 4, type: RubyFit::Type.semicircles }, - # end_y: { id: 5, type: RubyFit::Type.semicircles }, - # end_x: { id: 6, type: RubyFit::Type.semicircles }, - # total_distance: { id: 9, type: RubyFit::Type.centimeters }, - # total_ascent: { id: 21, type: RubyFit::Type.altitude }, - # sport: { id: 25, type: RubyFit::Type.enum(RubyFit::MessageConstants::SPORT), required: false }, - # subsport: { id: 39, type: RubyFit::Type.enum(RubyFit::MessageConstants::SUBSPORT), required: false } - # } - # }, - lap: { id: 19, fields: { diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 843c444..4562c53 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -150,12 +150,10 @@ def altitude uint16({ rb2fit: ->(val, type) { result = ((val + 500) * 5.0).truncate - puts "rb2fit: input=#{val}, output=#{result}" result }, fit2rb: ->(val, type) { result = val / 5.0 - 500 - puts "fit2rb: input=#{val}, output=#{result}" result } }) diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index eb93f6e..aa89535 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -292,7 +292,6 @@ def course_point(values) def track_point(values) raise "Can only write track points inside 'track_points' block" if @state != :track_points - puts("track_point: #{values}") write_message(:record, values) end @@ -430,7 +429,6 @@ def calculate_workout_data_size(workout_step_count, lap_count, session_count, ev else 0 end - puts "#{type}: #{result}" result end diff --git a/test/activity_test.rb b/test/activity_test.rb index 0fa7348..c8a457b 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -159,7 +159,6 @@ def test_rubyfit_integration assert_equal json_output['wahoo_id']['workout_num'], json_input['wahoo_id']['workout_num'] assert_equal json_output['wahoo_id']['workout_type'], json_input['wahoo_id']['workout_type'] if json_input['records'].size > 1 - puts(json_output['record']) assert_equal json_output['record'].size, json_input['records'].size assert_equal json_output['record'].last['timestamp'], json_input['records'].last['timestamp'] assert_equal json_output['record'].last['y'].round(2), json_input['records'].last['y'].round(2) @@ -254,7 +253,7 @@ def test_rubyfit_integration } } - parser = RubyFit::FitParser.new(callbacks) + parser = RubyFit::FitFileParser.new(callbacks) parser.parse(raw) end end \ No newline at end of file diff --git a/test/fixtures/example_activity_json.json b/test/fixtures/example_activity_json.json index 6d9f3f6..24bd5ac 100644 --- a/test/fixtures/example_activity_json.json +++ b/test/fixtures/example_activity_json.json @@ -76,13 +76,13 @@ }, { "id": 49308517263, - "timestamp": 1111065857, + "timestamp": 1111066857, "resource_state": 2, "name": "Lap 2", "total_elapsed_time": 650, "total_moving_time": 650, "total_timer_time": 650, - "start_time": 1111065857, + "start_time": 1111066857, "start_time_local": 1111065857, "total_distance": 1609.3, "total_calories": 275, diff --git a/test/route_test.rb b/test/route_test.rb index 2fdd8f9..9397d3c 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -12,8 +12,6 @@ def test_integration sport = RubyFit::MessageConstants::SPORT.key(json['sport_code']) subsport = RubyFit::MessageConstants::SUBSPORT.key(json['subsport_code']) || :generic - puts("subby", subsport, sport) - writer = RubyFit::Writer.new File.open(fit_file_path, 'wb') do |file| @@ -87,11 +85,10 @@ def test_integration assert_equal(json_input['track_points'].size, json_output['record'].size) assert_equal(json_input['course_points'].size, json_output['course_point'].size) - puts(json_output) end } } - parser = RubyFit::FitParser.new(callbacks) + parser = RubyFit::FitFileParser.new(callbacks) parser.parse(raw) end end \ No newline at end of file From 0e6ecaa5ed16168b49e455ba184c97a11c667bc7 Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 28 Mar 2025 16:19:13 -0400 Subject: [PATCH 051/104] add fit_parser to rubyfit --- lib/rubyfit.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/rubyfit.rb b/lib/rubyfit.rb index b4ece71..1e54d0d 100644 --- a/lib/rubyfit.rb +++ b/lib/rubyfit.rb @@ -3,3 +3,4 @@ require 'rubyfit/writer' require 'rubyfit/helpers' +require 'rubyfit/fit_parser' \ No newline at end of file From 26751fcec4392e8600b093536263079f7893b437 Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 28 Mar 2025 16:22:09 -0400 Subject: [PATCH 052/104] add fit_parser to rubyfit --- fit_data.json | 2 +- lib/rubyfit/fit_parser.rb | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/fit_data.json b/fit_data.json index 6dab5bc..e664b5d 100644 --- a/fit_data.json +++ b/fit_data.json @@ -1 +1 @@ -{"file_id":{"serial_number":0,"time_created":4294969321,"manufacturer":32,"product":0,"type":6},"course":{"name":"Test"},"lap":{"timestamp":1743192682,"start_time":1743192682,"total_elapsed_time":0.0,"total_timer_time":0.0,"start_y":34.034969955682755,"start_x":-84.59214996546507,"end_y":34.038519943133,"end_x":-84.59528999403119,"total_distance":536.0,"total_ascent":-497.8,"sport":2,"sub_sport":0,"event":255,"event_type":255,"avg_heart_rate":255,"max_heart_rate":255,"avg_cadence":255,"max_cadence":255,"avg_power":65535,"max_power":65535,"total_work":4294967295,"total_calories":65535,"lap_trigger":255,"normalized_power":65535,"total_moving_time":4294967.295,"min_heart_rate":255,"enhanced_avg_speed":4294967295,"enhanced_max_speed":4294967295},"event":[{"timestamp":1743192682,"event":0,"event_type":0,"event_group":0},{"timestamp":1743192682,"event":0,"event_type":9,"event_group":0}],"record":[{"timestamp":1111065857,"y":34.034969955682755,"x":-84.59214996546507,"distance":0.0,"elevation":321.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":20.7,"elevation":322.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03573991730809,"x":-84.59249999374151,"distance":110.4,"elevation":324.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.036409966647625,"x":-84.59302998147905,"distance":199.6,"elevation":333.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.037079932168126,"x":-84.59356994368136,"distance":289.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03774998150766,"x":-84.5940999314189,"distance":378.5,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03796992264688,"x":-84.59429992362857,"distance":409.1,"elevation":336.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03811995871365,"x":-84.59446999244392,"distance":432.0,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03823998756707,"x":-84.59464995190501,"distance":453.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03841994702816,"x":-84.59484994411469,"distance":480.6,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":516.79,"elevation":336.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.038519943133,"x":-84.59528999403119,"distance":536.1,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67}],"course_point":[{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":42949672.95,"name":"","message_index":65535,"type":6},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":42949672.95,"name":"","message_index":65535,"type":6}]} \ No newline at end of file +{"file_id":{"serial_number":0,"time_created":4294969321,"manufacturer":32,"product":0,"type":6},"course":{"name":"Test"},"lap":{"timestamp":1743193285,"start_time":1743193285,"total_elapsed_time":0.0,"total_timer_time":0.0,"start_y":34.034969955682755,"start_x":-84.59214996546507,"end_y":34.038519943133,"end_x":-84.59528999403119,"total_distance":536.0,"total_ascent":-497.8,"sport":2,"sub_sport":0,"event":255,"event_type":255,"avg_heart_rate":255,"max_heart_rate":255,"avg_cadence":255,"max_cadence":255,"avg_power":65535,"max_power":65535,"total_work":4294967295,"total_calories":65535,"lap_trigger":255,"normalized_power":65535,"total_moving_time":4294967.295,"min_heart_rate":255,"enhanced_avg_speed":4294967295,"enhanced_max_speed":4294967295},"event":[{"timestamp":1743193285,"event":0,"event_type":0,"event_group":0},{"timestamp":1743193285,"event":0,"event_type":9,"event_group":0}],"record":[{"timestamp":1111065857,"y":34.034969955682755,"x":-84.59214996546507,"distance":0.0,"elevation":321.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":20.7,"elevation":322.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03573991730809,"x":-84.59249999374151,"distance":110.4,"elevation":324.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.036409966647625,"x":-84.59302998147905,"distance":199.6,"elevation":333.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.037079932168126,"x":-84.59356994368136,"distance":289.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03774998150766,"x":-84.5940999314189,"distance":378.5,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03796992264688,"x":-84.59429992362857,"distance":409.1,"elevation":336.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03811995871365,"x":-84.59446999244392,"distance":432.0,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03823998756707,"x":-84.59464995190501,"distance":453.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03841994702816,"x":-84.59484994411469,"distance":480.6,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":516.79,"elevation":336.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.038519943133,"x":-84.59528999403119,"distance":536.1,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67}],"course_point":[{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":42949672.95,"name":"","message_index":65535,"type":6},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":42949672.95,"name":"","message_index":65535,"type":6}]} \ No newline at end of file diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index cd3cb03..8dee4fa 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -1,5 +1,4 @@ -module RubyFit - class FitFileParser +class RubyFit::FitFileParser REQUIRED_CALLBACKS = [:definition_message, :get_definition, :data_message, :end_of_file] def initialize(callbacks) @@ -184,5 +183,4 @@ def parse(raw) end @callbacks[:end_of_file].call end - end end \ No newline at end of file From e74e5fe9f4c1e79db268d22b75768c4cb89b2bf8 Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 31 Mar 2025 08:03:05 -0400 Subject: [PATCH 053/104] fix data size calculation --- fit_data.json | 2 +- lib/rubyfit/writer.rb | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/fit_data.json b/fit_data.json index e664b5d..363597b 100644 --- a/fit_data.json +++ b/fit_data.json @@ -1 +1 @@ -{"file_id":{"serial_number":0,"time_created":4294969321,"manufacturer":32,"product":0,"type":6},"course":{"name":"Test"},"lap":{"timestamp":1743193285,"start_time":1743193285,"total_elapsed_time":0.0,"total_timer_time":0.0,"start_y":34.034969955682755,"start_x":-84.59214996546507,"end_y":34.038519943133,"end_x":-84.59528999403119,"total_distance":536.0,"total_ascent":-497.8,"sport":2,"sub_sport":0,"event":255,"event_type":255,"avg_heart_rate":255,"max_heart_rate":255,"avg_cadence":255,"max_cadence":255,"avg_power":65535,"max_power":65535,"total_work":4294967295,"total_calories":65535,"lap_trigger":255,"normalized_power":65535,"total_moving_time":4294967.295,"min_heart_rate":255,"enhanced_avg_speed":4294967295,"enhanced_max_speed":4294967295},"event":[{"timestamp":1743193285,"event":0,"event_type":0,"event_group":0},{"timestamp":1743193285,"event":0,"event_type":9,"event_group":0}],"record":[{"timestamp":1111065857,"y":34.034969955682755,"x":-84.59214996546507,"distance":0.0,"elevation":321.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":20.7,"elevation":322.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03573991730809,"x":-84.59249999374151,"distance":110.4,"elevation":324.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.036409966647625,"x":-84.59302998147905,"distance":199.6,"elevation":333.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.037079932168126,"x":-84.59356994368136,"distance":289.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03774998150766,"x":-84.5940999314189,"distance":378.5,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03796992264688,"x":-84.59429992362857,"distance":409.1,"elevation":336.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03811995871365,"x":-84.59446999244392,"distance":432.0,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03823998756707,"x":-84.59464995190501,"distance":453.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03841994702816,"x":-84.59484994411469,"distance":480.6,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":516.79,"elevation":336.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.038519943133,"x":-84.59528999403119,"distance":536.1,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67}],"course_point":[{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":42949672.95,"name":"","message_index":65535,"type":6},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":42949672.95,"name":"","message_index":65535,"type":6}]} \ No newline at end of file +{"file_id":{"serial_number":0,"time_created":4294969321,"manufacturer":32,"product":0,"type":6},"course":{"name":"Test"},"lap":{"timestamp":1743421983,"start_time":1743421983,"total_elapsed_time":0.0,"total_timer_time":0.0,"start_y":34.034969955682755,"start_x":-84.59214996546507,"end_y":34.038519943133,"end_x":-84.59528999403119,"total_distance":536.0,"total_ascent":-497.8,"sport":2,"sub_sport":0,"event":255,"event_type":255,"avg_heart_rate":255,"max_heart_rate":255,"avg_cadence":255,"max_cadence":255,"avg_power":65535,"max_power":65535,"total_work":4294967295,"total_calories":65535,"lap_trigger":255,"normalized_power":65535,"total_moving_time":4294967.295,"min_heart_rate":255,"enhanced_avg_speed":4294967295,"enhanced_max_speed":4294967295},"event":[{"timestamp":1743421983,"event":0,"event_type":0,"event_group":0},{"timestamp":1743421983,"event":0,"event_type":9,"event_group":0}],"record":[{"timestamp":1111065857,"y":34.034969955682755,"x":-84.59214996546507,"distance":0.0,"elevation":321.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":20.7,"elevation":322.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03573991730809,"x":-84.59249999374151,"distance":110.4,"elevation":324.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.036409966647625,"x":-84.59302998147905,"distance":199.6,"elevation":333.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.037079932168126,"x":-84.59356994368136,"distance":289.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03774998150766,"x":-84.5940999314189,"distance":378.5,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03796992264688,"x":-84.59429992362857,"distance":409.1,"elevation":336.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03811995871365,"x":-84.59446999244392,"distance":432.0,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03823998756707,"x":-84.59464995190501,"distance":453.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03841994702816,"x":-84.59484994411469,"distance":480.6,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":516.79,"elevation":336.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.038519943133,"x":-84.59528999403119,"distance":536.1,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67}],"course_point":[{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":42949672.95,"name":"","message_index":65535,"type":6},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":42949672.95,"name":"","message_index":65535,"type":6}]} \ No newline at end of file diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index aa89535..225b922 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -392,8 +392,12 @@ def calculate_data_size(course_point_count, track_point_count) data_sizes = record_counts.map do |type, count| def_size = RubyFit::MessageWriter.definition_message_size(type) - data_size = RubyFit::MessageWriter.data_message_size(type) * count - result = def_size + data_size + data_size = RubyFit::MessageWriter.data_message_size(type) * count + result = if count > 0 + def_size + data_size + else + 0 + end result end data_sizes.reduce(&:+) From 35a32e0f13f82af8a3e97a654cf610160f5b731a Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 31 Mar 2025 08:22:27 -0400 Subject: [PATCH 054/104] add file cleanup to parsing --- fit_data.json | 1 - lib/rubyfit/fit_parser.rb | 5 ++--- test/activity_test.rb | 8 ++++++++ test/route_test.rb | 8 ++++++++ 4 files changed, 18 insertions(+), 4 deletions(-) delete mode 100644 fit_data.json diff --git a/fit_data.json b/fit_data.json deleted file mode 100644 index 363597b..0000000 --- a/fit_data.json +++ /dev/null @@ -1 +0,0 @@ -{"file_id":{"serial_number":0,"time_created":4294969321,"manufacturer":32,"product":0,"type":6},"course":{"name":"Test"},"lap":{"timestamp":1743421983,"start_time":1743421983,"total_elapsed_time":0.0,"total_timer_time":0.0,"start_y":34.034969955682755,"start_x":-84.59214996546507,"end_y":34.038519943133,"end_x":-84.59528999403119,"total_distance":536.0,"total_ascent":-497.8,"sport":2,"sub_sport":0,"event":255,"event_type":255,"avg_heart_rate":255,"max_heart_rate":255,"avg_cadence":255,"max_cadence":255,"avg_power":65535,"max_power":65535,"total_work":4294967295,"total_calories":65535,"lap_trigger":255,"normalized_power":65535,"total_moving_time":4294967.295,"min_heart_rate":255,"enhanced_avg_speed":4294967295,"enhanced_max_speed":4294967295},"event":[{"timestamp":1743421983,"event":0,"event_type":0,"event_group":0},{"timestamp":1743421983,"event":0,"event_type":9,"event_group":0}],"record":[{"timestamp":1111065857,"y":34.034969955682755,"x":-84.59214996546507,"distance":0.0,"elevation":321.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":20.7,"elevation":322.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03573991730809,"x":-84.59249999374151,"distance":110.4,"elevation":324.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.036409966647625,"x":-84.59302998147905,"distance":199.6,"elevation":333.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.037079932168126,"x":-84.59356994368136,"distance":289.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03774998150766,"x":-84.5940999314189,"distance":378.5,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03796992264688,"x":-84.59429992362857,"distance":409.1,"elevation":336.4,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03811995871365,"x":-84.59446999244392,"distance":432.0,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03823998756707,"x":-84.59464995190501,"distance":453.3,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03841994702816,"x":-84.59484994411469,"distance":480.6,"elevation":336.79999999999995,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":516.79,"elevation":336.6,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67},{"timestamp":1111065857,"y":34.038519943133,"x":-84.59528999403119,"distance":536.1,"elevation":336.20000000000005,"heart_rate":255,"cadence":255,"power":65535,"calories":65535,"enhanced_speed":4294967.295,"battery_soc":255,"grade":327.67}],"course_point":[{"timestamp":1111065857,"y":34.03506995178759,"x":-84.59195994772017,"distance":42949672.95,"name":"","message_index":65535,"type":6},{"timestamp":1111065857,"y":34.03863997198641,"x":-84.59513995796442,"distance":42949672.95,"name":"","message_index":65535,"type":6}]} \ No newline at end of file diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 8dee4fa..523791d 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -178,9 +178,8 @@ def parse(raw) end end end - File.open('fit_data.json', 'w') do |json_file| - json_file.write(all_data.to_json) - end + @callbacks[:output_file].call(all_data) @callbacks[:end_of_file].call + @callbacks[:delete_file].call end end \ No newline at end of file diff --git a/test/activity_test.rb b/test/activity_test.rb index c8a457b..3c9b4cb 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -141,6 +141,11 @@ def test_rubyfit_integration fit_data[local_num] = formatted_values.join(', ') }, + output_file: -> (all_data) { + File.open('fit_data.json', 'w') do |json_file| + json_file.write(all_data.to_json) + end + }, end_of_file: -> { File.open('fit_data.json', 'r') do |file| json_output = JSON.parse(file.read) @@ -250,6 +255,9 @@ def test_rubyfit_integration assert_equal json_output['device_info']['device_index'], json_input['device_infos'].last['device_index'] end end + }, + delete_file: -> { + FileUtils.rm_rf('fit_data.json') } } diff --git a/test/route_test.rb b/test/route_test.rb index 9397d3c..57256cf 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -78,6 +78,11 @@ def test_integration fit_data[local_num] = formatted_values.join(', ') }, + output_file: -> (all_data) { + File.open('fit_data.json', 'w') do |json_file| + json_file.write(all_data.to_json) + end + }, end_of_file: -> { File.open('fit_data.json', 'r') do |file| json_output = JSON.parse(file.read) @@ -86,6 +91,9 @@ def test_integration assert_equal(json_input['track_points'].size, json_output['record'].size) assert_equal(json_input['course_points'].size, json_output['course_point'].size) end + }, + delete_file: -> { + FileUtils.rm_rf('fit_data.json') } } parser = RubyFit::FitFileParser.new(callbacks) From 92a1f838b31ebf2125fe451f3340cfea525dbe95 Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 31 Mar 2025 08:53:38 -0400 Subject: [PATCH 055/104] change to yield json data --- lib/rubyfit/fit_parser.rb | 9 +- test/activity_test.rb | 345 +++++++++++++++++++++++++------------- test/route_test.rb | 40 +++-- 3 files changed, 253 insertions(+), 141 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 523791d..14f0902 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -1,5 +1,5 @@ class RubyFit::FitFileParser - REQUIRED_CALLBACKS = [:definition_message, :get_definition, :data_message, :end_of_file] + REQUIRED_CALLBACKS = [:definition_message, :get_definition, :data_message] def initialize(callbacks) @callbacks = callbacks @@ -178,8 +178,9 @@ def parse(raw) end end end - @callbacks[:output_file].call(all_data) - @callbacks[:end_of_file].call - @callbacks[:delete_file].call + yield all_data + # @callbacks[:output_file].call(all_data) + # @callbacks[:end_of_file].call + # @callbacks[:delete_file].call end end \ No newline at end of file diff --git a/test/activity_test.rb b/test/activity_test.rb index 3c9b4cb..2e21192 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -141,127 +141,234 @@ def test_rubyfit_integration fit_data[local_num] = formatted_values.join(', ') }, - output_file: -> (all_data) { - File.open('fit_data.json', 'w') do |json_file| - json_file.write(all_data.to_json) - end - }, - end_of_file: -> { - File.open('fit_data.json', 'r') do |file| - json_output = JSON.parse(file.read) - json_input = JSON.parse(json_input) - assert_equal json_output['file_id']['manufacturer'], json_input['manufacturer'] - assert_equal json_output['activity']['timestamp'], json_input['timestamp'] - assert_equal json_output['activity']['total_timer_time'], json_input['total_timer_time'] - assert_equal json_output['activity']['total_timer_time'], json_input['total_timer_time'] - assert_equal json_output['activity']['local_timestamp'], json_input['local_timestamp'] - assert_equal json_output['workout']['sport'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] - assert_equal json_output['workout']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] - assert_equal json_output['workout']['wkt_name'], json_input['name'] - assert_equal json_output['sport']['sport'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] - assert_equal json_output['sport']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] - assert_equal json_output['wahoo_id']['app_token'], json_input['wahoo_id']['app_token'] - assert_equal json_output['wahoo_id']['workout_num'], json_input['wahoo_id']['workout_num'] - assert_equal json_output['wahoo_id']['workout_type'], json_input['wahoo_id']['workout_type'] - if json_input['records'].size > 1 - assert_equal json_output['record'].size, json_input['records'].size - assert_equal json_output['record'].last['timestamp'], json_input['records'].last['timestamp'] - assert_equal json_output['record'].last['y'].round(2), json_input['records'].last['y'].round(2) - assert_equal json_output['record'].last['x'].round(2), json_input['records'].last['x'].round(2) - assert_equal json_output['record'].last['distance'].round(2), json_input['records'].last['distance'].round(2) - assert_equal json_output['record'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) - assert_equal json_output['record'].last['heart_rate'], json_input['records'].last['heart_rate'] - assert_equal json_output['record'].last['cadence'], json_input['records'].last['cadence'] - assert_equal json_output['record'].last['power'], json_input['records'].last['power'] - assert_equal json_output['record'].last['enhanced_speed'], json_input['records'].last['enhanced_speed'] - assert_equal json_output['record'].last['battery_soc'], json_input['records'].last['battery_soc'] - assert_equal json_output['record'].last['grade'], json_input['records'].last['grade'] - elsif json_input['records'].size > 0 - assert_equal json_output['record']['timestamp'], json_input['records'].last['timestamp'] - assert_equal json_output['record']['y'].round(2), json_input['records'].last['y'].round(2) - assert_equal json_output['record']['x'].round(2), json_input['records'].last['x'].round(2) - assert_equal json_output['record'].last['distance'].round(2), json_input['records'].last['distance'].round(2) - assert_equal json_output['record'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) - assert_equal json_output['record']['heart_rate'], json_input['records'].last['heart_rate'] - assert_equal json_output['record']['cadence'], json_input['records'].last['cadence'] - assert_equal json_output['record']['power'], json_input['records'].last['power'] - assert_equal json_output['record']['enhanced_speed'], json_input['records'].last['enhanced_speed'] - assert_equal json_output['record']['battery_soc'], json_input['records'].last['battery_soc'] - assert_equal json_output['record']['grade'], json_input['records'].last['grade'] - - end - if json_input['laps'].size > 1 - assert_equal json_output['lap'].size, json_input['laps'].size - assert_equal json_output['lap'].last['start_time'], json_input['laps'].last['start_time'] - assert_equal json_output['lap'].last['total_timer_time'], json_input['laps'].last['total_timer_time'] - assert_equal json_output['lap'].last['total_distance'], json_input['laps'].last['total_distance'] - assert_equal json_output['lap'].last['total_ascent'], json_input['laps'].last['total_ascent'] - assert_equal json_output['lap'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] - assert_equal json_output['lap'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] - assert_equal json_output['lap'].last['total_calories'], json_input['laps'].last['total_calories'] - assert_equal json_output['lap'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] - assert_equal json_output['lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] - assert_equal json_output['lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] - elsif json_input['laps'].size > 0 - assert_equal json_output['wkt_lap']['start_time'], json_input['laps'].last['start_time'] - assert_equal json_output['wkt_lap']['total_timer_time'], json_input['laps'].last['total_timer_time'] - assert_equal json_output['wkt_lap']['total_distance'], json_input['laps'].last['total_distance'] - assert_equal json_output['wkt_lap']['total_ascent'], json_input['laps'].last['total_ascent'] - assert_equal json_output['wkt_lap'].last['total_calories'], json_input['laps'].last['total_calories'] - assert_equal json_output['wkt_lap'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] - assert_equal json_output['wkt_lap'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] - assert_equal json_output['wkt_lap'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] - assert_equal json_output['wkt_lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] - assert_equal json_output['wkt_lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] - end - if json_input['sessions'].size > 1 - assert_equal json_output['session'].size, json_input['sessions'].size - assert_equal json_output['session'].last['start_time'], json_input['sessions'].last['start_time'] - assert_equal json_output['session'].last['total_timer_time'], json_input['sessions'].last['total_timer_time'] - assert_equal json_output['session'].last['total_distance'], json_input['sessions'].last['total_distance'] - assert_equal json_output['session'].last['total_ascent'], json_input['sessions'].last['total_ascent'] - assert_equal json_output['session'].last['total_calories'], json_input['sessions'].last['total_calories'] - assert_equal json_output['session'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] - assert_equal json_output['session'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] - assert_equal json_output['session'].last['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] - assert_equal json_output['session'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] - elsif json_input['sessions'].size > 0 - assert_equal json_output['session']['start_time'], json_input['sessions'].last['start_time'] - assert_equal json_output['session']['total_timer_time'], json_input['sessions'].last['total_timer_time'] - assert_equal json_output['session']['total_distance'], json_input['sessions'].last['total_distance'] - assert_equal json_output['session']['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] - assert_equal json_output['session']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] - assert_equal json_output['session']['total_ascent'], json_input['sessions'].last['total_ascent'] - assert_equal json_output['session']['total_calories'], json_input['sessions'].last['total_calories'] - assert_equal json_output['session']['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] - assert_equal json_output['session']['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] - end - if json_input['device_infos'].size > 1 - assert_equal json_output['device_info'].size, json_input['device_infos'].size - assert_equal json_output['device_info'].last['timestamp'], json_input['device_infos'].last['timestamp'] - assert_equal json_output['device_info'].last['serial_number'], json_input['device_infos'].last['serial_number'] - assert_equal json_output['device_info'].last['manufacturer'], json_input['device_infos'].last['manufacturer'] - assert_equal json_output['device_info'].last['product'], json_input['device_infos'].last['product'] - assert_equal json_output['device_info'].last['software_version'], json_input['device_infos'].last['software_version'] - assert_equal json_output['device_info'].last['battery_voltage'], json_input['device_infos'].last['battery_voltage'] - assert_equal json_output['device_info'].last['device_index'], json_input['device_infos'].last['device_index'] - elsif json_input['device_infos'].size > 0 - assert_equal json_output['device_info']['timestamp'], json_input['device_infos'].last['timestamp'] - assert_equal json_output['device_info']['serial_number'], json_input['device_infos'].last['serial_number'] - assert_equal json_output['device_info']['manufacturer'], json_input['device_infos'].last['manufacturer'] - assert_equal json_output['device_info']['product'], json_input['device_infos'].last['product'] - assert_equal json_output['device_info']['software_version'], json_input['device_infos'].last['software_version'] - assert_equal json_output['device_info']['battery_voltage'], json_input['device_infos'].last['battery_voltage'] - assert_equal json_output['device_info']['device_index'], json_input['device_infos'].last['device_index'] - end - end - }, - delete_file: -> { - FileUtils.rm_rf('fit_data.json') - } + # output_file: -> (all_data) { + # File.open('fit_data.json', 'w') do |json_file| + # json_file.write(all_data.to_json) + # end + # }, + # end_of_file: -> { + # File.open('fit_data.json', 'r') do |file| + # json_output = JSON.parse(file.read) + # json_input = JSON.parse(json_input) + # assert_equal json_output['file_id']['manufacturer'], json_input['manufacturer'] + # assert_equal json_output['activity']['timestamp'], json_input['timestamp'] + # assert_equal json_output['activity']['total_timer_time'], json_input['total_timer_time'] + # assert_equal json_output['activity']['total_timer_time'], json_input['total_timer_time'] + # assert_equal json_output['activity']['local_timestamp'], json_input['local_timestamp'] + # assert_equal json_output['workout']['sport'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] + # assert_equal json_output['workout']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] + # assert_equal json_output['workout']['wkt_name'], json_input['name'] + # assert_equal json_output['sport']['sport'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] + # assert_equal json_output['sport']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] + # assert_equal json_output['wahoo_id']['app_token'], json_input['wahoo_id']['app_token'] + # assert_equal json_output['wahoo_id']['workout_num'], json_input['wahoo_id']['workout_num'] + # assert_equal json_output['wahoo_id']['workout_type'], json_input['wahoo_id']['workout_type'] + # if json_input['records'].size > 1 + # assert_equal json_output['record'].size, json_input['records'].size + # assert_equal json_output['record'].last['timestamp'], json_input['records'].last['timestamp'] + # assert_equal json_output['record'].last['y'].round(2), json_input['records'].last['y'].round(2) + # assert_equal json_output['record'].last['x'].round(2), json_input['records'].last['x'].round(2) + # assert_equal json_output['record'].last['distance'].round(2), json_input['records'].last['distance'].round(2) + # assert_equal json_output['record'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) + # assert_equal json_output['record'].last['heart_rate'], json_input['records'].last['heart_rate'] + # assert_equal json_output['record'].last['cadence'], json_input['records'].last['cadence'] + # assert_equal json_output['record'].last['power'], json_input['records'].last['power'] + # assert_equal json_output['record'].last['enhanced_speed'], json_input['records'].last['enhanced_speed'] + # assert_equal json_output['record'].last['battery_soc'], json_input['records'].last['battery_soc'] + # assert_equal json_output['record'].last['grade'], json_input['records'].last['grade'] + # elsif json_input['records'].size > 0 + # assert_equal json_output['record']['timestamp'], json_input['records'].last['timestamp'] + # assert_equal json_output['record']['y'].round(2), json_input['records'].last['y'].round(2) + # assert_equal json_output['record']['x'].round(2), json_input['records'].last['x'].round(2) + # assert_equal json_output['record'].last['distance'].round(2), json_input['records'].last['distance'].round(2) + # assert_equal json_output['record'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) + # assert_equal json_output['record']['heart_rate'], json_input['records'].last['heart_rate'] + # assert_equal json_output['record']['cadence'], json_input['records'].last['cadence'] + # assert_equal json_output['record']['power'], json_input['records'].last['power'] + # assert_equal json_output['record']['enhanced_speed'], json_input['records'].last['enhanced_speed'] + # assert_equal json_output['record']['battery_soc'], json_input['records'].last['battery_soc'] + # assert_equal json_output['record']['grade'], json_input['records'].last['grade'] + # + # end + # if json_input['laps'].size > 1 + # assert_equal json_output['lap'].size, json_input['laps'].size + # assert_equal json_output['lap'].last['start_time'], json_input['laps'].last['start_time'] + # assert_equal json_output['lap'].last['total_timer_time'], json_input['laps'].last['total_timer_time'] + # assert_equal json_output['lap'].last['total_distance'], json_input['laps'].last['total_distance'] + # assert_equal json_output['lap'].last['total_ascent'], json_input['laps'].last['total_ascent'] + # assert_equal json_output['lap'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] + # assert_equal json_output['lap'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] + # assert_equal json_output['lap'].last['total_calories'], json_input['laps'].last['total_calories'] + # assert_equal json_output['lap'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] + # assert_equal json_output['lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] + # assert_equal json_output['lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] + # elsif json_input['laps'].size > 0 + # assert_equal json_output['wkt_lap']['start_time'], json_input['laps'].last['start_time'] + # assert_equal json_output['wkt_lap']['total_timer_time'], json_input['laps'].last['total_timer_time'] + # assert_equal json_output['wkt_lap']['total_distance'], json_input['laps'].last['total_distance'] + # assert_equal json_output['wkt_lap']['total_ascent'], json_input['laps'].last['total_ascent'] + # assert_equal json_output['wkt_lap'].last['total_calories'], json_input['laps'].last['total_calories'] + # assert_equal json_output['wkt_lap'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] + # assert_equal json_output['wkt_lap'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] + # assert_equal json_output['wkt_lap'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] + # assert_equal json_output['wkt_lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] + # assert_equal json_output['wkt_lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] + # end + # if json_input['sessions'].size > 1 + # assert_equal json_output['session'].size, json_input['sessions'].size + # assert_equal json_output['session'].last['start_time'], json_input['sessions'].last['start_time'] + # assert_equal json_output['session'].last['total_timer_time'], json_input['sessions'].last['total_timer_time'] + # assert_equal json_output['session'].last['total_distance'], json_input['sessions'].last['total_distance'] + # assert_equal json_output['session'].last['total_ascent'], json_input['sessions'].last['total_ascent'] + # assert_equal json_output['session'].last['total_calories'], json_input['sessions'].last['total_calories'] + # assert_equal json_output['session'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] + # assert_equal json_output['session'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] + # assert_equal json_output['session'].last['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] + # assert_equal json_output['session'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] + # elsif json_input['sessions'].size > 0 + # assert_equal json_output['session']['start_time'], json_input['sessions'].last['start_time'] + # assert_equal json_output['session']['total_timer_time'], json_input['sessions'].last['total_timer_time'] + # assert_equal json_output['session']['total_distance'], json_input['sessions'].last['total_distance'] + # assert_equal json_output['session']['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] + # assert_equal json_output['session']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] + # assert_equal json_output['session']['total_ascent'], json_input['sessions'].last['total_ascent'] + # assert_equal json_output['session']['total_calories'], json_input['sessions'].last['total_calories'] + # assert_equal json_output['session']['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] + # assert_equal json_output['session']['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] + # end + # if json_input['device_infos'].size > 1 + # assert_equal json_output['device_info'].size, json_input['device_infos'].size + # assert_equal json_output['device_info'].last['timestamp'], json_input['device_infos'].last['timestamp'] + # assert_equal json_output['device_info'].last['serial_number'], json_input['device_infos'].last['serial_number'] + # assert_equal json_output['device_info'].last['manufacturer'], json_input['device_infos'].last['manufacturer'] + # assert_equal json_output['device_info'].last['product'], json_input['device_infos'].last['product'] + # assert_equal json_output['device_info'].last['software_version'], json_input['device_infos'].last['software_version'] + # assert_equal json_output['device_info'].last['battery_voltage'], json_input['device_infos'].last['battery_voltage'] + # assert_equal json_output['device_info'].last['device_index'], json_input['device_infos'].last['device_index'] + # elsif json_input['device_infos'].size > 0 + # assert_equal json_output['device_info']['timestamp'], json_input['device_infos'].last['timestamp'] + # assert_equal json_output['device_info']['serial_number'], json_input['device_infos'].last['serial_number'] + # assert_equal json_output['device_info']['manufacturer'], json_input['device_infos'].last['manufacturer'] + # assert_equal json_output['device_info']['product'], json_input['device_infos'].last['product'] + # assert_equal json_output['device_info']['software_version'], json_input['device_infos'].last['software_version'] + # assert_equal json_output['device_info']['battery_voltage'], json_input['device_infos'].last['battery_voltage'] + # assert_equal json_output['device_info']['device_index'], json_input['device_infos'].last['device_index'] + # end + # end + # }, + # delete_file: -> { + # FileUtils.rm_rf('fit_data.json') + # } } parser = RubyFit::FitFileParser.new(callbacks) - parser.parse(raw) + parser.parse(raw) do |data| + json_output = JSON.parse(data.to_json) + json_input = JSON.parse(json_input) + assert_equal json_output['file_id']['manufacturer'], json_input['manufacturer'] + assert_equal json_output['activity']['timestamp'], json_input['timestamp'] + assert_equal json_output['activity']['total_timer_time'], json_input['total_timer_time'] + assert_equal json_output['activity']['total_timer_time'], json_input['total_timer_time'] + assert_equal json_output['activity']['local_timestamp'], json_input['local_timestamp'] + assert_equal json_output['workout']['sport'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] + assert_equal json_output['workout']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] + assert_equal json_output['workout']['wkt_name'], json_input['name'] + assert_equal json_output['sport']['sport'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] + assert_equal json_output['sport']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] + assert_equal json_output['wahoo_id']['app_token'], json_input['wahoo_id']['app_token'] + assert_equal json_output['wahoo_id']['workout_num'], json_input['wahoo_id']['workout_num'] + assert_equal json_output['wahoo_id']['workout_type'], json_input['wahoo_id']['workout_type'] + if json_input['records'].size > 1 + assert_equal json_output['record'].size, json_input['records'].size + assert_equal json_output['record'].last['timestamp'], json_input['records'].last['timestamp'] + assert_equal json_output['record'].last['y'].round(2), json_input['records'].last['y'].round(2) + assert_equal json_output['record'].last['x'].round(2), json_input['records'].last['x'].round(2) + assert_equal json_output['record'].last['distance'].round(2), json_input['records'].last['distance'].round(2) + assert_equal json_output['record'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) + assert_equal json_output['record'].last['heart_rate'], json_input['records'].last['heart_rate'] + assert_equal json_output['record'].last['cadence'], json_input['records'].last['cadence'] + assert_equal json_output['record'].last['power'], json_input['records'].last['power'] + assert_equal json_output['record'].last['enhanced_speed'], json_input['records'].last['enhanced_speed'] + assert_equal json_output['record'].last['battery_soc'], json_input['records'].last['battery_soc'] + assert_equal json_output['record'].last['grade'], json_input['records'].last['grade'] + elsif json_input['records'].size > 0 + assert_equal json_output['record']['timestamp'], json_input['records'].last['timestamp'] + assert_equal json_output['record']['y'].round(2), json_input['records'].last['y'].round(2) + assert_equal json_output['record']['x'].round(2), json_input['records'].last['x'].round(2) + assert_equal json_output['record'].last['distance'].round(2), json_input['records'].last['distance'].round(2) + assert_equal json_output['record'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) + assert_equal json_output['record']['heart_rate'], json_input['records'].last['heart_rate'] + assert_equal json_output['record']['cadence'], json_input['records'].last['cadence'] + assert_equal json_output['record']['power'], json_input['records'].last['power'] + assert_equal json_output['record']['enhanced_speed'], json_input['records'].last['enhanced_speed'] + assert_equal json_output['record']['battery_soc'], json_input['records'].last['battery_soc'] + assert_equal json_output['record']['grade'], json_input['records'].last['grade'] + + end + if json_input['laps'].size > 1 + assert_equal json_output['lap'].size, json_input['laps'].size + assert_equal json_output['lap'].last['start_time'], json_input['laps'].last['start_time'] + assert_equal json_output['lap'].last['total_timer_time'], json_input['laps'].last['total_timer_time'] + assert_equal json_output['lap'].last['total_distance'], json_input['laps'].last['total_distance'] + assert_equal json_output['lap'].last['total_ascent'], json_input['laps'].last['total_ascent'] + assert_equal json_output['lap'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] + assert_equal json_output['lap'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] + assert_equal json_output['lap'].last['total_calories'], json_input['laps'].last['total_calories'] + assert_equal json_output['lap'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] + assert_equal json_output['lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] + assert_equal json_output['lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] + elsif json_input['laps'].size > 0 + assert_equal json_output['wkt_lap']['start_time'], json_input['laps'].last['start_time'] + assert_equal json_output['wkt_lap']['total_timer_time'], json_input['laps'].last['total_timer_time'] + assert_equal json_output['wkt_lap']['total_distance'], json_input['laps'].last['total_distance'] + assert_equal json_output['wkt_lap']['total_ascent'], json_input['laps'].last['total_ascent'] + assert_equal json_output['wkt_lap'].last['total_calories'], json_input['laps'].last['total_calories'] + assert_equal json_output['wkt_lap'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] + assert_equal json_output['wkt_lap'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] + assert_equal json_output['wkt_lap'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] + assert_equal json_output['wkt_lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] + assert_equal json_output['wkt_lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] + end + if json_input['sessions'].size > 1 + assert_equal json_output['session'].size, json_input['sessions'].size + assert_equal json_output['session'].last['start_time'], json_input['sessions'].last['start_time'] + assert_equal json_output['session'].last['total_timer_time'], json_input['sessions'].last['total_timer_time'] + assert_equal json_output['session'].last['total_distance'], json_input['sessions'].last['total_distance'] + assert_equal json_output['session'].last['total_ascent'], json_input['sessions'].last['total_ascent'] + assert_equal json_output['session'].last['total_calories'], json_input['sessions'].last['total_calories'] + assert_equal json_output['session'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] + assert_equal json_output['session'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] + assert_equal json_output['session'].last['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] + assert_equal json_output['session'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] + elsif json_input['sessions'].size > 0 + assert_equal json_output['session']['start_time'], json_input['sessions'].last['start_time'] + assert_equal json_output['session']['total_timer_time'], json_input['sessions'].last['total_timer_time'] + assert_equal json_output['session']['total_distance'], json_input['sessions'].last['total_distance'] + assert_equal json_output['session']['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] + assert_equal json_output['session']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] + assert_equal json_output['session']['total_ascent'], json_input['sessions'].last['total_ascent'] + assert_equal json_output['session']['total_calories'], json_input['sessions'].last['total_calories'] + assert_equal json_output['session']['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] + assert_equal json_output['session']['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] + end + if json_input['device_infos'].size > 1 + assert_equal json_output['device_info'].size, json_input['device_infos'].size + assert_equal json_output['device_info'].last['timestamp'], json_input['device_infos'].last['timestamp'] + assert_equal json_output['device_info'].last['serial_number'], json_input['device_infos'].last['serial_number'] + assert_equal json_output['device_info'].last['manufacturer'], json_input['device_infos'].last['manufacturer'] + assert_equal json_output['device_info'].last['product'], json_input['device_infos'].last['product'] + assert_equal json_output['device_info'].last['software_version'], json_input['device_infos'].last['software_version'] + assert_equal json_output['device_info'].last['battery_voltage'], json_input['device_infos'].last['battery_voltage'] + assert_equal json_output['device_info'].last['device_index'], json_input['device_infos'].last['device_index'] + elsif json_input['device_infos'].size > 0 + assert_equal json_output['device_info']['timestamp'], json_input['device_infos'].last['timestamp'] + assert_equal json_output['device_info']['serial_number'], json_input['device_infos'].last['serial_number'] + assert_equal json_output['device_info']['manufacturer'], json_input['device_infos'].last['manufacturer'] + assert_equal json_output['device_info']['product'], json_input['device_infos'].last['product'] + assert_equal json_output['device_info']['software_version'], json_input['device_infos'].last['software_version'] + assert_equal json_output['device_info']['battery_voltage'], json_input['device_infos'].last['battery_voltage'] + assert_equal json_output['device_info']['device_index'], json_input['device_infos'].last['device_index'] + end + end end end \ No newline at end of file diff --git a/test/route_test.rb b/test/route_test.rb index 57256cf..1820580 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -78,25 +78,29 @@ def test_integration fit_data[local_num] = formatted_values.join(', ') }, - output_file: -> (all_data) { - File.open('fit_data.json', 'w') do |json_file| - json_file.write(all_data.to_json) - end - }, - end_of_file: -> { - File.open('fit_data.json', 'r') do |file| - json_output = JSON.parse(file.read) - json_input = JSON.parse(json_input) - - assert_equal(json_input['track_points'].size, json_output['record'].size) - assert_equal(json_input['course_points'].size, json_output['course_point'].size) - end - }, - delete_file: -> { - FileUtils.rm_rf('fit_data.json') - } + # output_file: -> (all_data) { + # File.open('fit_data.json', 'w') do |json_file| + # json_file.write(all_data.to_json) + # end + # }, + # end_of_file: -> { + # File.open('fit_data.json', 'r') do |file| + # json_output = JSON.parse(file.read) + # json_input = JSON.parse(json_input) + # + # assert_equal(json_input['track_points'].size, json_output['record'].size) + # assert_equal(json_input['course_points'].size, json_output['course_point'].size) + # end + # }, + # delete_file: -> { + # FileUtils.rm_rf('fit_data.json') + # } } parser = RubyFit::FitFileParser.new(callbacks) - parser.parse(raw) + parser.parse(raw) do |data| + json_input = JSON.parse(json_input) + assert_equal(json_input['track_points'].size, data[:record].size) + assert_equal(json_input['course_points'].size, data[:course_point].size) + end end end \ No newline at end of file From 4f9c50a6c7ff3ea3ee33191ee1dbc49460990e44 Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 31 Mar 2025 09:09:21 -0400 Subject: [PATCH 056/104] remove callbacks --- lib/rubyfit/fit_parser.rb | 42 ++++++++--- test/activity_test.rb | 149 +------------------------------------- test/route_test.rb | 48 +----------- 3 files changed, 32 insertions(+), 207 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 14f0902..6df9541 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -1,13 +1,33 @@ class RubyFit::FitFileParser REQUIRED_CALLBACKS = [:definition_message, :get_definition, :data_message] - def initialize(callbacks) - @callbacks = callbacks - REQUIRED_CALLBACKS.each do |callback| - raise ArgumentError, "Missing required callback: #{callback}" unless @callbacks[callback] + def initialize + @definitions = {} + @fit_data = {} + end + + def definition_message(local_num, global_message_number, fields, developer_fields) + global_message_number = global_message_number.to_i + @definitions[local_num] = { global_message_number: global_message_number, fields: fields, developer_fields: developer_fields } + end + + def get_definition(local_num) + @definitions[local_num] || { fields: [] } + end + + def data_message(local_num, values) + formatted_values = values.map do |key, value| + formatted_value = if value.is_a?(String) + value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') + else + value.inspect + end + "#{key}: #{formatted_value}" end + @fit_data[local_num] = formatted_values.join(', ') end + def convert_to_json(fit_data, unpack_directive) # Define the message type to look up type = RubyFit::MessageConstants::MESSAGE_TYPE.find { |key, value| value == fit_data.keys.first } @@ -84,7 +104,7 @@ def parse(raw) @previous_timestamp = timestamp - definition = @callbacks[:get_definition].call(local_num) + definition = get_definition(local_num) raise "Unknown definition for local number #{local_num}" unless definition values = {} @@ -103,7 +123,7 @@ def parse(raw) @previous_timestamp = values[253].unpack1('V') end - @callbacks[:data_message].call(local_num, values) + data_message(local_num, values) else # Check if the record is a definition message by looking at the seventh bit (1 for definition, 0 for data) if record_header & 0x40 == 0x40 @@ -137,11 +157,12 @@ def parse(raw) end end - @callbacks[:definition_message].call(local_num, global_message_number, fields, developer_fields) + + definition_message(local_num, global_message_number, fields, developer_fields) else # Data Message local_num = record_header & 0x0F - definition = @callbacks[:get_definition].call(local_num) + definition = get_definition(local_num) raise "Unknown definition for local number #{local_num}" unless definition values = {} @@ -164,7 +185,7 @@ def parse(raw) developer_values[field[:id]] = value end - @callbacks[:data_message].call(local_num, values) + data_message(local_num, values) data = self.convert_to_json({ definition[:global_message_number] => values }, unpack_directive) data&.each do |key, value| @@ -179,8 +200,5 @@ def parse(raw) end end yield all_data - # @callbacks[:output_file].call(all_data) - # @callbacks[:end_of_file].call - # @callbacks[:delete_file].call end end \ No newline at end of file diff --git a/test/activity_test.rb b/test/activity_test.rb index 2e21192..1facb6b 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -114,154 +114,7 @@ def test_rubyfit_integration # Read FIT file raw = IO.read(fit_file_path) - definitions = {} - fit_data = {} - - callbacks = { - definition_message: ->(local_num, global_message_number, fields, developer_fields) { - global_message_number = global_message_number.to_i - # Store the definition for the local number - definitions[local_num] = { global_message_number: global_message_number, fields: fields, developer_fields: developer_fields } - }, - get_definition: ->(local_num) { - # Retrieve the definition for the local number - definitions[local_num] || { fields: [] } - }, - data_message: ->(local_num, values) { - - formatted_values = values.map do |key, value| - formatted_value = if value.is_a?(String) - value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') # For byte arrays, convert each byte to hex - else - value.inspect # For non-byte arrays, just inspect the value - end - "#{key}: #{formatted_value}" - - end - - fit_data[local_num] = formatted_values.join(', ') - }, - # output_file: -> (all_data) { - # File.open('fit_data.json', 'w') do |json_file| - # json_file.write(all_data.to_json) - # end - # }, - # end_of_file: -> { - # File.open('fit_data.json', 'r') do |file| - # json_output = JSON.parse(file.read) - # json_input = JSON.parse(json_input) - # assert_equal json_output['file_id']['manufacturer'], json_input['manufacturer'] - # assert_equal json_output['activity']['timestamp'], json_input['timestamp'] - # assert_equal json_output['activity']['total_timer_time'], json_input['total_timer_time'] - # assert_equal json_output['activity']['total_timer_time'], json_input['total_timer_time'] - # assert_equal json_output['activity']['local_timestamp'], json_input['local_timestamp'] - # assert_equal json_output['workout']['sport'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] - # assert_equal json_output['workout']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] - # assert_equal json_output['workout']['wkt_name'], json_input['name'] - # assert_equal json_output['sport']['sport'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] - # assert_equal json_output['sport']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] - # assert_equal json_output['wahoo_id']['app_token'], json_input['wahoo_id']['app_token'] - # assert_equal json_output['wahoo_id']['workout_num'], json_input['wahoo_id']['workout_num'] - # assert_equal json_output['wahoo_id']['workout_type'], json_input['wahoo_id']['workout_type'] - # if json_input['records'].size > 1 - # assert_equal json_output['record'].size, json_input['records'].size - # assert_equal json_output['record'].last['timestamp'], json_input['records'].last['timestamp'] - # assert_equal json_output['record'].last['y'].round(2), json_input['records'].last['y'].round(2) - # assert_equal json_output['record'].last['x'].round(2), json_input['records'].last['x'].round(2) - # assert_equal json_output['record'].last['distance'].round(2), json_input['records'].last['distance'].round(2) - # assert_equal json_output['record'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) - # assert_equal json_output['record'].last['heart_rate'], json_input['records'].last['heart_rate'] - # assert_equal json_output['record'].last['cadence'], json_input['records'].last['cadence'] - # assert_equal json_output['record'].last['power'], json_input['records'].last['power'] - # assert_equal json_output['record'].last['enhanced_speed'], json_input['records'].last['enhanced_speed'] - # assert_equal json_output['record'].last['battery_soc'], json_input['records'].last['battery_soc'] - # assert_equal json_output['record'].last['grade'], json_input['records'].last['grade'] - # elsif json_input['records'].size > 0 - # assert_equal json_output['record']['timestamp'], json_input['records'].last['timestamp'] - # assert_equal json_output['record']['y'].round(2), json_input['records'].last['y'].round(2) - # assert_equal json_output['record']['x'].round(2), json_input['records'].last['x'].round(2) - # assert_equal json_output['record'].last['distance'].round(2), json_input['records'].last['distance'].round(2) - # assert_equal json_output['record'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) - # assert_equal json_output['record']['heart_rate'], json_input['records'].last['heart_rate'] - # assert_equal json_output['record']['cadence'], json_input['records'].last['cadence'] - # assert_equal json_output['record']['power'], json_input['records'].last['power'] - # assert_equal json_output['record']['enhanced_speed'], json_input['records'].last['enhanced_speed'] - # assert_equal json_output['record']['battery_soc'], json_input['records'].last['battery_soc'] - # assert_equal json_output['record']['grade'], json_input['records'].last['grade'] - # - # end - # if json_input['laps'].size > 1 - # assert_equal json_output['lap'].size, json_input['laps'].size - # assert_equal json_output['lap'].last['start_time'], json_input['laps'].last['start_time'] - # assert_equal json_output['lap'].last['total_timer_time'], json_input['laps'].last['total_timer_time'] - # assert_equal json_output['lap'].last['total_distance'], json_input['laps'].last['total_distance'] - # assert_equal json_output['lap'].last['total_ascent'], json_input['laps'].last['total_ascent'] - # assert_equal json_output['lap'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] - # assert_equal json_output['lap'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] - # assert_equal json_output['lap'].last['total_calories'], json_input['laps'].last['total_calories'] - # assert_equal json_output['lap'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] - # assert_equal json_output['lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] - # assert_equal json_output['lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] - # elsif json_input['laps'].size > 0 - # assert_equal json_output['wkt_lap']['start_time'], json_input['laps'].last['start_time'] - # assert_equal json_output['wkt_lap']['total_timer_time'], json_input['laps'].last['total_timer_time'] - # assert_equal json_output['wkt_lap']['total_distance'], json_input['laps'].last['total_distance'] - # assert_equal json_output['wkt_lap']['total_ascent'], json_input['laps'].last['total_ascent'] - # assert_equal json_output['wkt_lap'].last['total_calories'], json_input['laps'].last['total_calories'] - # assert_equal json_output['wkt_lap'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] - # assert_equal json_output['wkt_lap'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] - # assert_equal json_output['wkt_lap'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] - # assert_equal json_output['wkt_lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] - # assert_equal json_output['wkt_lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] - # end - # if json_input['sessions'].size > 1 - # assert_equal json_output['session'].size, json_input['sessions'].size - # assert_equal json_output['session'].last['start_time'], json_input['sessions'].last['start_time'] - # assert_equal json_output['session'].last['total_timer_time'], json_input['sessions'].last['total_timer_time'] - # assert_equal json_output['session'].last['total_distance'], json_input['sessions'].last['total_distance'] - # assert_equal json_output['session'].last['total_ascent'], json_input['sessions'].last['total_ascent'] - # assert_equal json_output['session'].last['total_calories'], json_input['sessions'].last['total_calories'] - # assert_equal json_output['session'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] - # assert_equal json_output['session'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] - # assert_equal json_output['session'].last['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] - # assert_equal json_output['session'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] - # elsif json_input['sessions'].size > 0 - # assert_equal json_output['session']['start_time'], json_input['sessions'].last['start_time'] - # assert_equal json_output['session']['total_timer_time'], json_input['sessions'].last['total_timer_time'] - # assert_equal json_output['session']['total_distance'], json_input['sessions'].last['total_distance'] - # assert_equal json_output['session']['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] - # assert_equal json_output['session']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] - # assert_equal json_output['session']['total_ascent'], json_input['sessions'].last['total_ascent'] - # assert_equal json_output['session']['total_calories'], json_input['sessions'].last['total_calories'] - # assert_equal json_output['session']['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] - # assert_equal json_output['session']['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] - # end - # if json_input['device_infos'].size > 1 - # assert_equal json_output['device_info'].size, json_input['device_infos'].size - # assert_equal json_output['device_info'].last['timestamp'], json_input['device_infos'].last['timestamp'] - # assert_equal json_output['device_info'].last['serial_number'], json_input['device_infos'].last['serial_number'] - # assert_equal json_output['device_info'].last['manufacturer'], json_input['device_infos'].last['manufacturer'] - # assert_equal json_output['device_info'].last['product'], json_input['device_infos'].last['product'] - # assert_equal json_output['device_info'].last['software_version'], json_input['device_infos'].last['software_version'] - # assert_equal json_output['device_info'].last['battery_voltage'], json_input['device_infos'].last['battery_voltage'] - # assert_equal json_output['device_info'].last['device_index'], json_input['device_infos'].last['device_index'] - # elsif json_input['device_infos'].size > 0 - # assert_equal json_output['device_info']['timestamp'], json_input['device_infos'].last['timestamp'] - # assert_equal json_output['device_info']['serial_number'], json_input['device_infos'].last['serial_number'] - # assert_equal json_output['device_info']['manufacturer'], json_input['device_infos'].last['manufacturer'] - # assert_equal json_output['device_info']['product'], json_input['device_infos'].last['product'] - # assert_equal json_output['device_info']['software_version'], json_input['device_infos'].last['software_version'] - # assert_equal json_output['device_info']['battery_voltage'], json_input['device_infos'].last['battery_voltage'] - # assert_equal json_output['device_info']['device_index'], json_input['device_infos'].last['device_index'] - # end - # end - # }, - # delete_file: -> { - # FileUtils.rm_rf('fit_data.json') - # } - } - - parser = RubyFit::FitFileParser.new(callbacks) + parser = RubyFit::FitFileParser.new parser.parse(raw) do |data| json_output = JSON.parse(data.to_json) json_input = JSON.parse(json_input) diff --git a/test/route_test.rb b/test/route_test.rb index 1820580..b3324f0 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -50,53 +50,7 @@ def test_integration end raw = IO.read(fit_file_path) - - definitions = {} - fit_data = {} - - callbacks = { - definition_message: ->(local_num, global_message_number, fields, developer_fields) { - global_message_number = global_message_number.to_i - # Store the definition for the local number - definitions[local_num] = { global_message_number: global_message_number, fields: fields, developer_fields: developer_fields } - }, - get_definition: ->(local_num) { - # Retrieve the definition for the local number - definitions[local_num] || { fields: [] } - }, - data_message: ->(local_num, values) { - - formatted_values = values.map do |key, value| - formatted_value = if value.is_a?(String) - value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') # For byte arrays, convert each byte to hex - else - value.inspect # For non-byte arrays, just inspect the value - end - "#{key}: #{formatted_value}" - - end - - fit_data[local_num] = formatted_values.join(', ') - }, - # output_file: -> (all_data) { - # File.open('fit_data.json', 'w') do |json_file| - # json_file.write(all_data.to_json) - # end - # }, - # end_of_file: -> { - # File.open('fit_data.json', 'r') do |file| - # json_output = JSON.parse(file.read) - # json_input = JSON.parse(json_input) - # - # assert_equal(json_input['track_points'].size, json_output['record'].size) - # assert_equal(json_input['course_points'].size, json_output['course_point'].size) - # end - # }, - # delete_file: -> { - # FileUtils.rm_rf('fit_data.json') - # } - } - parser = RubyFit::FitFileParser.new(callbacks) + parser = RubyFit::FitFileParser.new parser.parse(raw) do |data| json_input = JSON.parse(json_input) assert_equal(json_input['track_points'].size, data[:record].size) From 92fa2195db31071845e600864a5c45aae0bedaa7 Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 2 Apr 2025 11:42:32 -0400 Subject: [PATCH 057/104] additional testing and type changes --- lib/rubyfit/fit_parser.rb | 271 ++++++++++++++++++++++- lib/rubyfit/helpers.rb | 1 + lib/rubyfit/message_constants.rb | 20 ++ lib/rubyfit/message_writer.rb | 35 +-- lib/rubyfit/type.rb | 58 ++++- test/activity_test.rb | 23 +- test/fit_parser_test.rb | 114 ++++++++++ test/fixtures/example_activity_json.json | 38 +++- 8 files changed, 522 insertions(+), 38 deletions(-) create mode 100644 test/fit_parser_test.rb diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 6df9541..be25c69 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -29,9 +29,10 @@ def data_message(local_num, values) def convert_to_json(fit_data, unpack_directive) + big_endian = unpack_directive == 'n' # Define the message type to look up type = RubyFit::MessageConstants::MESSAGE_TYPE.find { |key, value| value == fit_data.keys.first } - puts("Unknown message type: #{fit_data.keys.first}") unless type + # puts("Unknown message type: #{fit_data.keys.first}") unless type return unless type message_type = RubyFit::MessageConstants::MESSAGE_TYPE.find { |key, value| value == fit_data.keys.first }.first message_definition = RubyFit::MessageWriter::MESSAGE_DEFINITIONS[message_type] @@ -40,14 +41,21 @@ def convert_to_json(fit_data, unpack_directive) readable_data = {} raw_values = fit_data.values.first + # for debugging + # known_field_ids = message_definition[:fields].map { |_, field_definition| field_definition[:id] } + # unknown_keys = raw_values.keys - known_field_ids + # puts("Unknown raw data for message definition #{message_type}: #{unknown_keys}") unless unknown_keys.empty? + + # Iterate through the message definition fields message_definition[:fields].each do |field_name, field_definition| field_id = field_definition[:id] # This is the key we're looking for in the raw data + field_definition[:big_endian] = big_endian # Check if the field ID is present in the raw FIT data if raw_values.key?(field_id) raw_value = raw_values[field_id].bytes - readable_data[field_name] = field_definition[:type].bytes2val(raw_value) + readable_data[field_name] = field_definition[:type].bytes2val(raw_value, **field_definition.slice(:big_endian)) else # If the field is missing, we can either skip it or set it as nil readable_data[field_name] = nil @@ -201,4 +209,263 @@ def parse(raw) end yield all_data end + + def parse2(raw) + all_data = {} + io = StringIO.new(raw) + + header = io.read(12) + raise "Invalid FIT file: unable to read header" unless header && header.size == 12 + + header_size, protocol_version, profile_version, data_size, data_type = header.unpack('C C v V a4') + raise "Invalid FIT file: invalid data type" unless data_type == ".FIT" + + io.seek(header_size) if io.pos < header_size + + unpack_directive = 'v' + buffer = io.read(header_size + data_size - io.pos) + buffer_io = StringIO.new(buffer) + + while buffer_io.pos < buffer.size + record_header = buffer_io.read(1)&.unpack1('C') + raise "Invalid FIT file: unable to read record header" unless record_header + + if record_header & 0x80 == 0x80 + local_num = (record_header & 0x60) >> 5 + time_offset = record_header & 0x1F + + timestamp = if @previous_timestamp + if time_offset >= (@previous_timestamp & 0x1F) + (@previous_timestamp & 0xFFFFFFE0) + time_offset + else + (@previous_timestamp & 0xFFFFFFE0) + time_offset + 0x20 + end + else + time_offset + end + + @previous_timestamp = timestamp + + definition = get_definition(local_num) + raise "Unknown definition for local number #{local_num}" unless definition + + values = {} + definition[:fields].each do |field| + value = buffer_io.read(field[:size]) + if value.nil? || value.size < field[:size] + puts "Warning: Missing or incomplete field value for field ID #{field[:id]}" + next + end + values[field[:id]] = value + end + + @previous_timestamp = values[253].unpack1('V') if values[253] + + data_message(local_num, values) + else + if record_header & 0x40 == 0x40 + local_num = record_header & 0x0F + reserved, architecture = buffer_io.read(2).unpack('C C') + unpack_directive = 'n' if architecture == 1 + global_message_number, field_count = buffer_io.read(3).unpack("#{unpack_directive} C") + + raise "Invalid FIT file: unable to read definition message" unless global_message_number && field_count + + fields = field_count.times.map do + field_def = buffer_io.read(3)&.unpack('C*') + raise "Invalid FIT file: unable to read field definition" unless field_def + { id: field_def[0], size: field_def[1], type: field_def[2] } + end + + developer_fields = if record_header & 0x20 == 0x20 + developer_field_count = buffer_io.read(1)&.unpack1('C') + developer_field_count.times.map do + developer_field_def = buffer_io.read(3)&.unpack('C*') + raise "Invalid FIT file: unable to read developer field definition" unless developer_field_def + { id: developer_field_def[0], size: developer_field_def[1], type: developer_field_def[2] } + end + else + [] + end + + definition_message(local_num, global_message_number, fields, developer_fields) + else + local_num = record_header & 0x0F + definition = get_definition(local_num) + raise "Unknown definition for local number #{local_num}" unless definition + + values = {} + definition[:fields].each do |field| + value = buffer_io.read(field[:size]) + if value.nil? || value.size < field[:size] + puts "Warning: Missing or incomplete field value for field ID #{field[:id]}" + next + end + values[field[:id]] = value + end + + developer_values = {} + definition[:developer_fields]&.each do |field| + value = buffer_io.read(field[:size]) + if value.nil? || value.size < field[:size] + puts "Warning: Missing or incomplete developer field value for field ID #{field[:id]}" + next + end + developer_values[field[:id]] = value + end + + data_message(local_num, values) + data = convert_to_json({ definition[:global_message_number] => values }, unpack_directive) + + data&.each do |key, value| + if all_data.key?(key) + all_data[key] = [all_data[key]] unless all_data[key].is_a?(Array) + all_data[key] << value + else + all_data[key] = value + end + end + end + end + end + yield all_data + end + + def parse3(raw) + all_data = {} + io = StringIO.new(raw) + + header = io.read(12) + raise "Invalid FIT file: unable to read header" unless header && header.size == 12 + + header_size, protocol_version, profile_version, data_size, data_type = header.unpack('C C v V a4') + raise "Invalid FIT file: invalid data type" unless data_type == ".FIT" + + io.seek(header_size) if io.pos < header_size + + unpack_directive = 'v' + buffer = io.read(header_size + data_size - io.pos) + buffer_io = StringIO.new(buffer) + + while buffer_io.pos < buffer.size + record_header = buffer_io.read(1)&.unpack1('C') + raise "Invalid FIT file: unable to read record header" unless record_header + + if record_header & 0x80 == 0x80 + local_num = (record_header & 0x60) >> 5 + time_offset = record_header & 0x1F + + timestamp = if @previous_timestamp + if time_offset >= (@previous_timestamp & 0x1F) + (@previous_timestamp & 0xFFFFFFE0) + time_offset + else + (@previous_timestamp & 0xFFFFFFE0) + time_offset + 0x20 + end + else + time_offset + end + + @previous_timestamp = timestamp + + definition = @definitions[local_num] || { fields: [] } + raise "Unknown definition for local number #{local_num}" unless definition + + values = {} + definition[:fields].each do |field| + value = buffer_io.read(field[:size]) + if value.nil? || value.size < field[:size] + puts "Warning: Missing or incomplete field value for field ID #{field[:id]}" + next + end + values[field[:id]] = value + end + + @previous_timestamp = values[253].unpack1('V') if values[253] + + formatted_values = values.map do |key, value| + formatted_value = if value.is_a?(String) + value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') + else + value.inspect + end + "#{key}: #{formatted_value}" + end + @fit_data[local_num] = formatted_values.join(', ') + else + if record_header & 0x40 == 0x40 + local_num = record_header & 0x0F + reserved, architecture = buffer_io.read(2).unpack('C C') + unpack_directive = 'n' if architecture == 1 + global_message_number, field_count = buffer_io.read(3).unpack("#{unpack_directive} C") + + raise "Invalid FIT file: unable to read definition message" unless global_message_number && field_count + + fields = field_count.times.map do + field_def = buffer_io.read(3)&.unpack('C*') + raise "Invalid FIT file: unable to read field definition" unless field_def + { id: field_def[0], size: field_def[1], type: field_def[2] } + end + + developer_fields = if record_header & 0x20 == 0x20 + developer_field_count = buffer_io.read(1)&.unpack1('C') + developer_field_count.times.map do + developer_field_def = buffer_io.read(3)&.unpack('C*') + raise "Invalid FIT file: unable to read developer field definition" unless developer_field_def + { id: developer_field_def[0], size: developer_field_def[1], type: developer_field_def[2] } + end + else + [] + end + + @definitions[local_num] = { global_message_number: global_message_number, fields: fields, developer_fields: developer_fields } + else + local_num = record_header & 0x0F + definition = @definitions[local_num] || { fields: [] } + raise "Unknown definition for local number #{local_num}" unless definition + + values = {} + definition[:fields].each do |field| + value = buffer_io.read(field[:size]) + if value.nil? || value.size < field[:size] + puts "Warning: Missing or incomplete field value for field ID #{field[:id]}" + next + end + values[field[:id]] = value + end + + developer_values = {} + definition[:developer_fields]&.each do |field| + value = buffer_io.read(field[:size]) + if value.nil? || value.size < field[:size] + puts "Warning: Missing or incomplete developer field value for field ID #{field[:id]}" + next + end + developer_values[field[:id]] = value + end + + formatted_values = values.map do |key, value| + formatted_value = if value.is_a?(String) + value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') + else + value.inspect + end + "#{key}: #{formatted_value}" + end + @fit_data[local_num] = formatted_values.join(', ') + + data = convert_to_json({ definition[:global_message_number] => values }, unpack_directive) + + data&.each do |key, value| + if all_data.key?(key) + all_data[key] = [all_data[key]] unless all_data[key].is_a?(Array) + all_data[key] << value + else + all_data[key] = value + end + end + end + end + end + yield all_data + end end \ No newline at end of file diff --git a/lib/rubyfit/helpers.rb b/lib/rubyfit/helpers.rb index ab89e29..6ec8741 100644 --- a/lib/rubyfit/helpers.rb +++ b/lib/rubyfit/helpers.rb @@ -44,6 +44,7 @@ def num2bytes(num, byte_count, big_endian = true) end def bytes2num(bytes, byte_count, unsigned = true, big_endian = true) + puts("be", big_endian) directive = { 1 => "C", 2 => "S", diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index 0bf46ac..45118ed 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -314,10 +314,30 @@ module RubyFit::MessageConstants activity: 34, device_info: 23, sport: 12, + workout_step: 27, wahoo_custom_num: 65284, wahoo_clm: 65285, wahoo_id: 65281, developer_data_id: 207, field_description: 206 }.freeze + + BATTERY_STATUS = { + new: 1, + good: 2, + ok: 3, + low: 4, + critical: 5, + charging: 6, + unknown: 7 + }.freeze + + SOURCE_TYPE = { + ant: 0, + antplus: 1, + bluetooth: 2, + bluetooth_low_energy: 3, + wifi: 4, + local: 5 + }.freeze end diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 8a3cb16..b9085f8 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -1,7 +1,3 @@ -# require "rubyfit/type" -# require "rubyfit/helpers" -# require "rubyfit/message_constants" - require_relative 'type' require_relative 'helpers' require_relative 'message_constants' @@ -59,8 +55,8 @@ class RubyFit::MessageWriter lap_trigger: { id: 24, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::LAP_TRIGGER }, normalized_power: { id: 33, type: RubyFit::Type.uint16 }, total_moving_time: { id: 52, type: RubyFit::Type.duration }, - # time_in_hr_zone: { id: 57, type: RubyFit::Type.uint32 }, # should be array of hr_zone type - # time_in_power_zone: { id: 60, type: RubyFit::Type.uint32 }, # should be array of power_zone type + time_in_hr_zone: { id: 57, type: RubyFit::Type.uint32_array(5) }, + time_in_power_zone: { id: 60, type: RubyFit::Type.uint32_array(8) }, min_heart_rate: { id: 63, type: RubyFit::Type.uint8 }, enhanced_avg_speed: { id: 65, type: RubyFit::Type.uint32 }, enhanced_max_speed: { id: 66, type: RubyFit::Type.uint32 } @@ -87,14 +83,21 @@ class RubyFit::MessageWriter y: { id: 0, type: RubyFit::Type.semicircles, required: true }, x: { id: 1, type: RubyFit::Type.semicircles, required: true }, distance: { id: 5, type: RubyFit::Type.centimeters }, + speed: { id: 6, type: RubyFit::Type.speed }, elevation: { id: 2, type: RubyFit::Type.altitude }, heart_rate: { id: 3, type: RubyFit::Type.uint8 }, cadence: { id: 4, type: RubyFit::Type.uint8 }, power: { id: 7, type: RubyFit::Type.uint16 }, calories: { id: 33, type: RubyFit::Type.uint16 }, - enhanced_speed: { id: 73, type: RubyFit::Type.speed}, - battery_soc: { id: 78, type: RubyFit::Type.uint8 }, + enhanced_speed: { id: 73, type: RubyFit::Type.enhanced_speed}, + battery_soc: { id: 81, type: RubyFit::Type.uint8_scale2 }, grade: { id: 9, type: RubyFit::Type.grade}, + temperature: { id: 13, type: RubyFit::Type.sint8 }, + gps_accuracy: { id: 31, type: RubyFit::Type.uint8 }, + left_torque_effectiveness: { id: 43, type: RubyFit::Type.uint8_scale2 }, + right_torque_effectiveness: { id: 44, type: RubyFit::Type.uint8_scale2 }, + left_pedal_smoothness: { id: 45, type: RubyFit::Type.uint8_scale2 }, + right_pedal_smoothness: { id: 46, type: RubyFit::Type.uint8_scale2 } } }, @@ -104,6 +107,7 @@ class RubyFit::MessageWriter timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, + data: { id: 3, type: RubyFit::Type.uint32 }, event_group: { id: 4, type: RubyFit::Type.uint8 }, } }, @@ -151,15 +155,16 @@ class RubyFit::MessageWriter id: 23, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, + device_type: { id: 1, type: RubyFit::Type.uint8 }, serial_number: { id: 3, type: RubyFit::Type.uint32z }, manufacturer: { id: 2, type: RubyFit::Type.uint16 }, product: { id: 4, type: RubyFit::Type.uint16 }, software_version: { id: 5, type: RubyFit::Type.uint16 }, hardware_version: { id: 6, type: RubyFit::Type.uint8 }, - battery_voltage: { id: 10, type: RubyFit::Type.uint16 }, - # battery_status: { id: 11, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::BATTERY_STATUS }, - # ant_device_number: { id: 21, type: RubyFit::Type.uint16 }, + battery_status: { id: 11, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::BATTERY_STATUS }, + ant_device_number: { id: 21, type: RubyFit::Type.uint16 }, device_index: { id: 0, type: RubyFit::Type.uint8 }, + source_type: { id: 25,type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SOURCE_TYPE }, product_name: { id: 27, type: RubyFit::Type.string(20) } } }, @@ -203,14 +208,14 @@ class RubyFit::MessageWriter max_power: { id: 21, type: RubyFit::Type.uint16 }, num_laps: { id: 26, type: RubyFit::Type.uint16 }, normalized_power: { id: 34, type: RubyFit::Type.uint16 }, - training_stress_score: { id: 35, type: RubyFit::Type.uint16 }, - intensity_factor: { id: 36, type: RubyFit::Type.uint16 }, + training_stress_score: { id: 35, type: RubyFit::Type.tss }, + intensity_factor: { id: 36, type: RubyFit::Type.if }, threshold_power: { id: 45, type: RubyFit::Type.uint16 }, total_work: { id: 48, type: RubyFit::Type.uint32 }, total_moving_time: { id: 59, type: RubyFit::Type.duration }, min_heart_rate: { id: 64, type: RubyFit::Type.uint8 }, - # time_in_hr_zone: { id: 65, type: RubyFit::Type.uint32 }, # should be array of hr_zone type - # time_in_power_zone: { id: 68, type: RubyFit::Type.uint32 }, # should be array of power_zone type + time_in_hr_zone: { id: 65, type: RubyFit::Type.uint32_array(5) }, + time_in_power_zone: { id: 68, type: RubyFit::Type.uint32_array(8) }, enhanced_avg_speed: { id: 124, type: RubyFit::Type.uint32 }, enhanced_max_speed: { id: 125, type: RubyFit::Type.uint32 } # workout_type: { id: 78, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::WORKOUT_TYPE } diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 4562c53..536e93e 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -21,9 +21,9 @@ def val2bytes(val) result end - def bytes2val(bytes) + def bytes2val(bytes, **opts) result = bytes - result = @bytes2val.call(result, self) + result = @bytes2val.call(result, self, **opts) result = @fit2rb.call(result, self) if @fit2rb result end @@ -46,7 +46,8 @@ def integer(opts = {}) new({ default_bytes: num2bytes(default, opts[:byte_count]), val2bytes: ->(val, type) { num2bytes(val, type.byte_count) }, - bytes2val: ->(bytes, type) { bytes2num(bytes, type.byte_count, unsigned) }, + bytes2val: ->(bytes, type, opts = {}) { + bytes2num(bytes, type.byte_count, unsigned, opts[:big_endian]) }, }.merge(opts)) end @@ -62,7 +63,7 @@ def string(byte_count, opts = {}) byte_count: byte_count, default_bytes: [0x00] * byte_count, val2bytes: ->(val, type) { str2bytes(val, type.byte_count) }, - bytes2val: ->(bytes, type) { bytes2str(bytes) }, + bytes2val: ->(bytes, type, opts = {}) { bytes2str(bytes) }, }.merge(opts)) end @@ -166,13 +167,21 @@ def duration }) end - def speed + def enhanced_speed uint32({ rb2fit: ->(val, type) { (val * 1000) }, fit2rb: ->(val, type) { val / 1000.0 } }) end + def speed + uint8({ + rb2fit: ->(val, type) { (val * 1000) }, + fit2rb: ->(val, type) { val / 1000.0 } + }) + end + + def grade sint16({ rb2fit: ->(val, type) { (val * 100) }, @@ -180,13 +189,34 @@ def grade }) end + def tss + uint16({ + rb2fit: ->(val, type) { (val * 10) }, + fit2rb: ->(val, type) { val / 10.0 } + }) + end + + def if + uint16({ + rb2fit: ->(val, type) { (val * 1000) }, + fit2rb: ->(val, type) { val / 1000.0 } + }) + end + + def uint8_scale2 + uint8({ + rb2fit: ->(val, type) { (val * 2) }, + fit2rb: ->(val, type) { val / 2 } + }) + end + def float64(opts = {}) new({ fit_id: 0x89, byte_count: 8, default_bytes: [0xFF] * 8, val2bytes: ->(val, type) { [val].pack("G").bytes }, - bytes2val: ->(bytes, type) { bytes.pack("C*").unpack1("G") }, + bytes2val: ->(bytes, type, opts = {}) { bytes.pack("C*").unpack1("G") }, }.merge(opts)) end @@ -198,10 +228,24 @@ def byte_array(length, opts = {}) val2bytes: ->(val, type) { val[0, length] + ([0xFF] * [length - val.length, 0].max) }, - bytes2val: ->(bytes, type) { + bytes2val: ->(bytes, type, opts = {}) { bytes[0, length] }, }.merge(opts)) end end + + def self.uint32_array(length, opts = {}) + new({ + fit_id: 0x0D, # Assuming 0x0D is the correct fit_id for arrays + byte_count: length * 4, # Assuming each hr_zone value is 4 bytes + default_bytes: [0xFF] * (length * 4), + val2bytes: ->(val, type) { + val.flat_map { |v| [v * 1000].pack("L<").bytes } + ([0xFF] * [(length - val.length) * 4, 0].max) + }, + bytes2val: ->(bytes, type, opts = {}) { + bytes.each_slice(4).map { |slice| slice.pack("C*").unpack1("L<") / 1000.0 } + }, + }.merge(opts)) + end end diff --git a/test/activity_test.rb b/test/activity_test.rb index 1facb6b..3abb13d 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -110,7 +110,7 @@ def test_rubyfit_integration end # this is a Wahoo fit file with clm and wahoo custom num messages - # fit_file_path = '2025-01-03-143057-WAHOOAPPIOS62BB-3-0.fit' + # fit_file_path = 'test/fixtures/2025-01-03-143057-WAHOOAPPIOS62BB-3-0.fit' # Read FIT file raw = IO.read(fit_file_path) @@ -171,16 +171,16 @@ def test_rubyfit_integration assert_equal json_output['lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] assert_equal json_output['lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] elsif json_input['laps'].size > 0 - assert_equal json_output['wkt_lap']['start_time'], json_input['laps'].last['start_time'] - assert_equal json_output['wkt_lap']['total_timer_time'], json_input['laps'].last['total_timer_time'] - assert_equal json_output['wkt_lap']['total_distance'], json_input['laps'].last['total_distance'] - assert_equal json_output['wkt_lap']['total_ascent'], json_input['laps'].last['total_ascent'] - assert_equal json_output['wkt_lap'].last['total_calories'], json_input['laps'].last['total_calories'] - assert_equal json_output['wkt_lap'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] - assert_equal json_output['wkt_lap'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] - assert_equal json_output['wkt_lap'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] - assert_equal json_output['wkt_lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] - assert_equal json_output['wkt_lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] + assert_equal json_output['lap']['start_time'], json_input['laps'].last['start_time'] + assert_equal json_output['lap']['total_timer_time'], json_input['laps'].last['total_timer_time'] + assert_equal json_output['lap']['total_distance'], json_input['laps'].last['total_distance'] + assert_equal json_output['lap']['total_ascent'], json_input['laps'].last['total_ascent'] + assert_equal json_output['lap'].last['total_calories'], json_input['laps'].last['total_calories'] + assert_equal json_output['lap'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] + assert_equal json_output['lap'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] + assert_equal json_output['lap'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] + assert_equal json_output['lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] + assert_equal json_output['lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] end if json_input['sessions'].size > 1 assert_equal json_output['session'].size, json_input['sessions'].size @@ -219,7 +219,6 @@ def test_rubyfit_integration assert_equal json_output['device_info']['manufacturer'], json_input['device_infos'].last['manufacturer'] assert_equal json_output['device_info']['product'], json_input['device_infos'].last['product'] assert_equal json_output['device_info']['software_version'], json_input['device_infos'].last['software_version'] - assert_equal json_output['device_info']['battery_voltage'], json_input['device_infos'].last['battery_voltage'] assert_equal json_output['device_info']['device_index'], json_input['device_infos'].last['device_index'] end end diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb new file mode 100644 index 0000000..22e1200 --- /dev/null +++ b/test/fit_parser_test.rb @@ -0,0 +1,114 @@ +require 'minitest/autorun' +require 'json' +require_relative '../lib/rubyfit/writer' +require_relative '../lib/rubyfit/message_constants' +require_relative '../lib/rubyfit/fit_parser' +require_relative '../examples/fit_callbacks' +class FitParserTest < Minitest::Test + def test_extremely_large_file + start = Time.now + fit_file_path = 'test/fixtures/2025-03-29-143824-ELEMNT_BOLT_EAB9-2-0.fit' + raw = IO.read(fit_file_path) + parser = RubyFit::FitFileParser.new + parser.parse2(raw) do |data| + json_output = data + end + finish = Time.now + puts("Time to load test: #{finish - start}") + end + + def test_little_endian_file_decoding + fit_file_path = 'test/fixtures/2025-01-03-143057-WAHOOAPPIOS62BB-3-0.fit' + # Read FIT file + raw = IO.read(fit_file_path) + + parser = RubyFit::FitFileParser.new + parser.parse2(raw) do |data| + + json_output = JSON.parse(data.to_json) + + assert_equal(32, json_output['file_id']['manufacturer']) + assert_equal(4, json_output['file_id']['type']) + assert_equal(0, json_output['file_id']['product']) + assert_equal(1735914657, json_output['file_id']['time_created']) + + assert_equal(1735914905, json_output['activity']['timestamp']) + assert_equal(247.886, json_output['activity']['total_timer_time']) + assert_equal(1, json_output['activity']['num_sessions']) + assert_equal(26, json_output['activity']['event']) + assert_equal(1, json_output['activity']['event_type']) + + assert_equal(2, json_output['workout'][0]['sport']) + assert_equal(6, json_output['workout'][0]['sub_sport']) + assert_equal('Indoor Cycling', json_output['workout'][0]['wkt_name']) + + assert_equal('WAHOOAPPIOS62BB', json_output['wahoo_id'][0]['app_token']) + assert_equal(3, json_output['wahoo_id'][0]['workout_num']) + assert_equal(12, json_output['wahoo_id'][0]['workout_type']) + + assert_equal(2, json_output['session']['sport']) + assert_equal(6, json_output['session']['sub_sport']) + assert_equal(247.886, json_output['session']['total_timer_time']) + assert_equal(17, json_output['session']['total_calories']) + assert_equal(137, json_output['session']['max_heart_rate']) + assert_equal(108, json_output['session']['avg_heart_rate']) + assert_equal(124.2, json_output['session']['total_distance']) + assert_equal(2.1, json_output['session']['training_stress_score']) + assert_equal(0.592, json_output['session']['intensity_factor']) + assert_equal(124, json_output['session']['threshold_power']) + + assert_equal([202.88, 46.528, 0, 0, 0], json_output['lap']['time_in_hr_zone']) + assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0], json_output['lap']['time_in_power_zone']) + + assert_equal(2, json_output['lap']['sport']) + assert_equal(6, json_output['lap']['sub_sport']) + assert_equal(247.886, json_output['lap']['total_timer_time']) + assert_equal(17, json_output['lap']['total_calories']) + assert_equal(137, json_output['lap']['max_heart_rate']) + assert_equal(108, json_output['lap']['avg_heart_rate']) + assert_equal(124.2, json_output['lap']['total_distance']) + assert_equal(17304, json_output['lap']['total_work']) + assert_equal([202.88, 46.528, 0, 0, 0], json_output['lap']['time_in_hr_zone']) + assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0], json_output['lap']['time_in_power_zone']) + + assert_equal(98, json_output['record'][1]['heart_rate']) + assert_equal(0, json_output['record'][1]['power']) + assert_equal(0, json_output['record'][1]['calories']) + assert_equal(85, json_output['record'][0]['battery_soc']) + + assert_equal(85, json_output['record'][0]['battery_soc']) + assert_equal(110, json_output['record'][242]['power']) + assert_equal(124.2, json_output['record'][242]['distance']) + assert_equal(0.134, json_output['record'][242]['speed']) + + + assert_equal(5, json_output['hr_zone'].size) + assert_equal(0, json_output['hr_zone'][0]['message_index']) + assert_equal(1, json_output['hr_zone'][1]['message_index']) + assert_equal(2, json_output['hr_zone'][2]['message_index']) + assert_equal(3, json_output['hr_zone'][3]['message_index']) + assert_equal(4, json_output['hr_zone'][4]['message_index']) + + assert_equal(6, json_output['power_zone'].size) + assert_equal(0, json_output['power_zone'][0]['message_index']) + assert_equal(1, json_output['power_zone'][1]['message_index']) + assert_equal(2, json_output['power_zone'][2]['message_index']) + assert_equal(3, json_output['power_zone'][3]['message_index']) + assert_equal(4, json_output['power_zone'][4]['message_index']) + assert_equal(5, json_output['power_zone'][5]['message_index']) + + assert_equal(68, json_output['power_zone'][0]['high_value']) + assert_equal(87, json_output['power_zone'][1]['high_value']) + assert_equal(113, json_output['power_zone'][2]['high_value']) + assert_equal(119, json_output['power_zone'][3]['high_value']) + assert_equal(128, json_output['power_zone'][4]['high_value']) + assert_equal(65534, json_output['power_zone'][5]['high_value']) + + assert_equal(0, json_output['device_info'][0]['device_index']) + assert_equal(32, json_output['device_info'][0]['manufacturer']) + assert_equal(0, json_output['device_info'][0]['product']) + assert_equal("WAHOO APP", json_output['device_info'][0]['product_name']) + + end + end +end \ No newline at end of file diff --git a/test/fixtures/example_activity_json.json b/test/fixtures/example_activity_json.json index 24bd5ac..88f8619 100644 --- a/test/fixtures/example_activity_json.json +++ b/test/fixtures/example_activity_json.json @@ -101,7 +101,24 @@ "sub_sport": "generic", "event": "lap", "event_type": "stop", - "lap_trigger": "manual" + "lap_trigger": "manual", + "time_in_hr_zone": [ + 202.88, + 46.528, + 0.0, + 0.0, + 0.0 + ], + "time_in_power_zone": [ + 205.307, + 2.88, + 35.999, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] } ], "device_infos": [ @@ -145,7 +162,24 @@ "event": "session", "event_type": "stop", "sport": "generic", - "sub_sport": "generic" + "sub_sport": "generic", + "time_in_hr_zone": [ + 202.88, + 46.528, + 0.0, + 0.0, + 0.0 + ], + "time_in_power_zone": [ + 205.307, + 2.88, + 35.999, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] } ], "records": [ From 98712a6cf7c2bde069922941f224204545c315d6 Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 2 Apr 2025 12:29:27 -0400 Subject: [PATCH 058/104] skip invalid values when parsing --- lib/rubyfit/fit_parser.rb | 284 -------------------------------------- lib/rubyfit/helpers.rb | 2 +- lib/rubyfit/type.rb | 22 +-- test/activity_test.rb | 1 + test/fit_parser_test.rb | 4 +- 5 files changed, 16 insertions(+), 297 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index be25c69..27fdc10 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -64,153 +64,7 @@ def convert_to_json(fit_data, unpack_directive) { message_type => readable_data } end - def parse(raw) - # json_file = File.open("fit_data.json", "w") - all_data = {} - io = StringIO.new(raw) - - header_size = io.read(1)&.unpack1('C') - raise "Invalid FIT file: unable to read header size" unless header_size - - protocol_version = io.read(1)&.unpack1('C') - raise "Invalid FIT file: unable to read protocol version" unless protocol_version - - profile_version = io.read(2)&.unpack('v')&.first - raise "Invalid FIT file: unable to read profile version" unless profile_version - - data_size = io.read(4)&.unpack('V')&.first - raise "Invalid FIT file: unable to read data size" unless data_size - - data_type = io.read(4) - raise "Invalid FIT file: invalid data type" unless data_type == ".FIT" - - if io.pos < header_size - io.seek(header_size) - end - - unpack_directive = 'v' - while io.pos < header_size + data_size - record_header = io.read(1)&.unpack1('C') - raise "Invalid FIT file: unable to read record header" unless record_header - - if record_header & 0x80 == 0x80 - # Handle compressed timestamp header - local_num = (record_header & 0x60) >> 5 - time_offset = record_header & 0x1F - - # Calculate timestamp with respect to the previous timestamp - if @previous_timestamp - if time_offset >= (@previous_timestamp & 0x1F) - timestamp = (@previous_timestamp & 0xFFFFFFE0) + time_offset - else - timestamp = (@previous_timestamp & 0xFFFFFFE0) + time_offset + 0x20 - end - else - timestamp = time_offset - end - - @previous_timestamp = timestamp - - definition = get_definition(local_num) - raise "Unknown definition for local number #{local_num}" unless definition - - values = {} - definition[:fields].each do |field| - value = io.read(field[:size]) - if value.nil? || value.size < field[:size] - puts "Warning: Missing or incomplete field value for field ID #{field[:id]}" - next - else - values[field[:id]] = value - end - end - - # Check if the data message contains a timestamp (id: 253) - if values[253] - @previous_timestamp = values[253].unpack1('V') - end - - data_message(local_num, values) - else - # Check if the record is a definition message by looking at the seventh bit (1 for definition, 0 for data) - if record_header & 0x40 == 0x40 - local_num = record_header & 0x0F - reserved = io.read(1) - architecture = io.read(1)&.unpack1('C') - - if architecture == 1 - unpack_directive = 'n' - end - global_message_number = io.read(2)&.unpack(unpack_directive)&.first - field_count = io.read(1)&.unpack1('C') - - raise "Invalid FIT file: unable to read definition message" unless architecture && global_message_number && field_count - - fields = [] - field_count.times do - field_def = io.read(3)&.unpack('C*') - raise "Invalid FIT file: unable to read field definition" unless field_def - fields << { id: field_def[0], size: field_def[1], type: field_def[2] } - end - - # developer data flag is set - developer_fields = [] - if (record_header & 0x20) == 0x20 - developer_field_count = io.read(1)&.unpack1('C') - developer_field_count.times do - developer_field_def = io.read(3)&.unpack('C*') - raise "Invalid FIT file: unable to read developer field definition" unless developer_field_def - developer_fields << { id: developer_field_def[0], size: developer_field_def[1], type: developer_field_def[2] } - end - end - - - definition_message(local_num, global_message_number, fields, developer_fields) - else - # Data Message - local_num = record_header & 0x0F - definition = get_definition(local_num) - raise "Unknown definition for local number #{local_num}" unless definition - - values = {} - definition[:fields].each do |field| - value = io.read(field[:size]) - if value.nil? || value.size < field[:size] - puts "Warning: Missing or incomplete field value for field ID #{field[:id]}" - next - end - values[field[:id]] = value - end - - developer_values = {} - definition[:developer_fields]&.each do |field| - value = io.read(field[:size]) - if value.nil? || value.size < field[:size] - puts "Warning: Missing or incomplete developer field value for field ID #{field[:id]}" - next - end - developer_values[field[:id]] = value - end - - data_message(local_num, values) - data = self.convert_to_json({ definition[:global_message_number] => values }, unpack_directive) - - data&.each do |key, value| - if all_data.key?(key) - all_data[key] = [all_data[key]] unless all_data[key].is_a?(Array) - all_data[key] << value - else - all_data[key] = value - end - end - end - end - end - yield all_data - end - - def parse2(raw) all_data = {} io = StringIO.new(raw) @@ -330,142 +184,4 @@ def parse2(raw) end yield all_data end - - def parse3(raw) - all_data = {} - io = StringIO.new(raw) - - header = io.read(12) - raise "Invalid FIT file: unable to read header" unless header && header.size == 12 - - header_size, protocol_version, profile_version, data_size, data_type = header.unpack('C C v V a4') - raise "Invalid FIT file: invalid data type" unless data_type == ".FIT" - - io.seek(header_size) if io.pos < header_size - - unpack_directive = 'v' - buffer = io.read(header_size + data_size - io.pos) - buffer_io = StringIO.new(buffer) - - while buffer_io.pos < buffer.size - record_header = buffer_io.read(1)&.unpack1('C') - raise "Invalid FIT file: unable to read record header" unless record_header - - if record_header & 0x80 == 0x80 - local_num = (record_header & 0x60) >> 5 - time_offset = record_header & 0x1F - - timestamp = if @previous_timestamp - if time_offset >= (@previous_timestamp & 0x1F) - (@previous_timestamp & 0xFFFFFFE0) + time_offset - else - (@previous_timestamp & 0xFFFFFFE0) + time_offset + 0x20 - end - else - time_offset - end - - @previous_timestamp = timestamp - - definition = @definitions[local_num] || { fields: [] } - raise "Unknown definition for local number #{local_num}" unless definition - - values = {} - definition[:fields].each do |field| - value = buffer_io.read(field[:size]) - if value.nil? || value.size < field[:size] - puts "Warning: Missing or incomplete field value for field ID #{field[:id]}" - next - end - values[field[:id]] = value - end - - @previous_timestamp = values[253].unpack1('V') if values[253] - - formatted_values = values.map do |key, value| - formatted_value = if value.is_a?(String) - value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') - else - value.inspect - end - "#{key}: #{formatted_value}" - end - @fit_data[local_num] = formatted_values.join(', ') - else - if record_header & 0x40 == 0x40 - local_num = record_header & 0x0F - reserved, architecture = buffer_io.read(2).unpack('C C') - unpack_directive = 'n' if architecture == 1 - global_message_number, field_count = buffer_io.read(3).unpack("#{unpack_directive} C") - - raise "Invalid FIT file: unable to read definition message" unless global_message_number && field_count - - fields = field_count.times.map do - field_def = buffer_io.read(3)&.unpack('C*') - raise "Invalid FIT file: unable to read field definition" unless field_def - { id: field_def[0], size: field_def[1], type: field_def[2] } - end - - developer_fields = if record_header & 0x20 == 0x20 - developer_field_count = buffer_io.read(1)&.unpack1('C') - developer_field_count.times.map do - developer_field_def = buffer_io.read(3)&.unpack('C*') - raise "Invalid FIT file: unable to read developer field definition" unless developer_field_def - { id: developer_field_def[0], size: developer_field_def[1], type: developer_field_def[2] } - end - else - [] - end - - @definitions[local_num] = { global_message_number: global_message_number, fields: fields, developer_fields: developer_fields } - else - local_num = record_header & 0x0F - definition = @definitions[local_num] || { fields: [] } - raise "Unknown definition for local number #{local_num}" unless definition - - values = {} - definition[:fields].each do |field| - value = buffer_io.read(field[:size]) - if value.nil? || value.size < field[:size] - puts "Warning: Missing or incomplete field value for field ID #{field[:id]}" - next - end - values[field[:id]] = value - end - - developer_values = {} - definition[:developer_fields]&.each do |field| - value = buffer_io.read(field[:size]) - if value.nil? || value.size < field[:size] - puts "Warning: Missing or incomplete developer field value for field ID #{field[:id]}" - next - end - developer_values[field[:id]] = value - end - - formatted_values = values.map do |key, value| - formatted_value = if value.is_a?(String) - value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') - else - value.inspect - end - "#{key}: #{formatted_value}" - end - @fit_data[local_num] = formatted_values.join(', ') - - data = convert_to_json({ definition[:global_message_number] => values }, unpack_directive) - - data&.each do |key, value| - if all_data.key?(key) - all_data[key] = [all_data[key]] unless all_data[key].is_a?(Array) - all_data[key] << value - else - all_data[key] = value - end - end - end - end - end - yield all_data - end end \ No newline at end of file diff --git a/lib/rubyfit/helpers.rb b/lib/rubyfit/helpers.rb index 6ec8741..bd115f6 100644 --- a/lib/rubyfit/helpers.rb +++ b/lib/rubyfit/helpers.rb @@ -44,7 +44,6 @@ def num2bytes(num, byte_count, big_endian = true) end def bytes2num(bytes, byte_count, unsigned = true, big_endian = true) - puts("be", big_endian) directive = { 1 => "C", 2 => "S", @@ -94,6 +93,7 @@ def deg2semicircles(degrees) end def semicircles2deg(degrees) + return nil if degrees.nil? result = degrees / DEGREES_TO_SEMICIRCLES result -= 360.0 if result > 180.0 result += 360.0 if result < -180.0 diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 536e93e..5bc7d5e 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -47,7 +47,9 @@ def integer(opts = {}) default_bytes: num2bytes(default, opts[:byte_count]), val2bytes: ->(val, type) { num2bytes(val, type.byte_count) }, bytes2val: ->(bytes, type, opts = {}) { - bytes2num(bytes, type.byte_count, unsigned, opts[:big_endian]) }, + value = bytes2num(bytes, type.byte_count, unsigned, opts[:big_endian]) + value == default ? nil : value + }, }.merge(opts)) end @@ -129,7 +131,7 @@ def uint64z(opts = {}) def timestamp uint32({ rb2fit: ->(val, type) { unix2fit_timestamp(val) }, - fit2rb: ->(val, type) { fit2unix_timestamp(val) } + fit2rb: ->(val, type) { val.nil? ? nil : fit2unix_timestamp(val) } }) end @@ -143,7 +145,7 @@ def semicircles def centimeters uint32({ rb2fit: ->(val, type) { (val * 100).truncate }, - fit2rb: ->(val, type) { val / 100.0 } + fit2rb: ->(val, type) { val.nil? ? nil : val / 100.0 } }) end @@ -154,7 +156,7 @@ def altitude result }, fit2rb: ->(val, type) { - result = val / 5.0 - 500 + result = val.nil? ? nil : val / 5.0 - 500 result } }) @@ -163,7 +165,7 @@ def altitude def duration uint32({ rb2fit: ->(val, type) { (val * 1000) }, - fit2rb: ->(val, type) { val / 1000.0 } + fit2rb: ->(val, type) { val.nil? ? nil : val / 1000.0 } }) end @@ -177,7 +179,7 @@ def enhanced_speed def speed uint8({ rb2fit: ->(val, type) { (val * 1000) }, - fit2rb: ->(val, type) { val / 1000.0 } + fit2rb: ->(val, type) { val.nil? ? nil : val / 1000.0 } }) end @@ -185,28 +187,28 @@ def speed def grade sint16({ rb2fit: ->(val, type) { (val * 100) }, - fit2rb: ->(val, type) { val / 100.0 } + fit2rb: ->(val, type) { val.nil? ? nil : val / 100.0 } }) end def tss uint16({ rb2fit: ->(val, type) { (val * 10) }, - fit2rb: ->(val, type) { val / 10.0 } + fit2rb: ->(val, type) { val.nil? ? nil : val / 10.0 } }) end def if uint16({ rb2fit: ->(val, type) { (val * 1000) }, - fit2rb: ->(val, type) { val / 1000.0 } + fit2rb: ->(val, type) { val.nil? ? nil : val / 1000.0 } }) end def uint8_scale2 uint8({ rb2fit: ->(val, type) { (val * 2) }, - fit2rb: ->(val, type) { val / 2 } + fit2rb: ->(val, type) { val.nil? ? nil : val / 2 } }) end diff --git a/test/activity_test.rb b/test/activity_test.rb index 3abb13d..b03d642 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -117,6 +117,7 @@ def test_rubyfit_integration parser = RubyFit::FitFileParser.new parser.parse(raw) do |data| json_output = JSON.parse(data.to_json) + puts(json_output) json_input = JSON.parse(json_input) assert_equal json_output['file_id']['manufacturer'], json_input['manufacturer'] assert_equal json_output['activity']['timestamp'], json_input['timestamp'] diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 22e1200..2e7ea90 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -10,7 +10,7 @@ def test_extremely_large_file fit_file_path = 'test/fixtures/2025-03-29-143824-ELEMNT_BOLT_EAB9-2-0.fit' raw = IO.read(fit_file_path) parser = RubyFit::FitFileParser.new - parser.parse2(raw) do |data| + parser.parse(raw) do |data| json_output = data end finish = Time.now @@ -23,7 +23,7 @@ def test_little_endian_file_decoding raw = IO.read(fit_file_path) parser = RubyFit::FitFileParser.new - parser.parse2(raw) do |data| + parser.parse(raw) do |data| json_output = JSON.parse(data.to_json) From 91be0933f4f45fd19b9c7c43de472c223d0204ab Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 3 Apr 2025 13:09:32 -0400 Subject: [PATCH 059/104] add more fields from ace testing --- lib/rubyfit/fit_parser.rb | 2 +- lib/rubyfit/message_constants.rb | 1 + lib/rubyfit/message_writer.rb | 66 ++++++++++++++++++++---- lib/rubyfit/type.rb | 17 +++++- test/activity_test.rb | 3 -- test/fixtures/example_activity_json.json | 2 +- 6 files changed, 75 insertions(+), 16 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 27fdc10..f4c55ef 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -45,7 +45,7 @@ def convert_to_json(fit_data, unpack_directive) # known_field_ids = message_definition[:fields].map { |_, field_definition| field_definition[:id] } # unknown_keys = raw_values.keys - known_field_ids # puts("Unknown raw data for message definition #{message_type}: #{unknown_keys}") unless unknown_keys.empty? - + # # Iterate through the message definition fields message_definition[:fields].each do |field_name, field_definition| diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index 45118ed..0509a96 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -315,6 +315,7 @@ module RubyFit::MessageConstants device_info: 23, sport: 12, workout_step: 27, + segment_lap: 142, wahoo_custom_num: 65284, wahoo_clm: 65285, wahoo_id: 65281, diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index b9085f8..c55bff5 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -31,32 +31,43 @@ class RubyFit::MessageWriter id: 19, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true}, + event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT }, + event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE }, start_time: { id: 2, type: RubyFit::Type.timestamp, required: true}, - total_elapsed_time: { id: 7, type: RubyFit::Type.duration, required: true }, - total_timer_time: { id: 8, type: RubyFit::Type.duration, required: true }, start_y: { id: 3, type: RubyFit::Type.semicircles }, start_x: { id: 4, type: RubyFit::Type.semicircles }, end_y: { id: 5, type: RubyFit::Type.semicircles }, end_x: { id: 6, type: RubyFit::Type.semicircles }, + total_elapsed_time: { id: 7, type: RubyFit::Type.duration, required: true }, + total_timer_time: { id: 8, type: RubyFit::Type.duration, required: true }, total_distance: { id: 9, type: RubyFit::Type.centimeters }, - total_ascent: { id: 21, type: RubyFit::Type.altitude }, - sport: { id: 25, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: false }, - sub_sport: { id: 39, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT, required: false}, - event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT }, - event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE }, + total_calories: { id: 11, type: RubyFit::Type.uint16 }, + avg_speed: { id: 13, type: RubyFit::Type.uint32_scale100 }, + max_speed: { id: 14, type: RubyFit::Type.uint32_scale100 }, avg_heart_rate: { id: 15, type: RubyFit::Type.uint8 }, max_heart_rate: { id: 16, type: RubyFit::Type.uint8 }, avg_cadence: { id: 17, type: RubyFit::Type.uint8 }, max_cadence: { id: 18, type: RubyFit::Type.uint8 }, avg_power: { id: 19, type: RubyFit::Type.uint16 }, max_power: { id: 20, type: RubyFit::Type.uint16 }, - total_work: { id: 41, type: RubyFit::Type.uint32 }, - total_calories: { id: 11, type: RubyFit::Type.uint16 }, + total_ascent: { id: 21, type: RubyFit::Type.altitude }, + total_descent: { id: 22, type: RubyFit::Type.altitude }, lap_trigger: { id: 24, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::LAP_TRIGGER }, + sport: { id: 25, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: false }, normalized_power: { id: 33, type: RubyFit::Type.uint16 }, + sub_sport: { id: 39, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT, required: false}, + total_work: { id: 41, type: RubyFit::Type.uint32 }, + avg_altitude: { id: 42, type: RubyFit::Type.altitude }, + max_altitude: { id: 43, type: RubyFit::Type.altitude }, + avg_grade: { id: 45, type: RubyFit::Type.grade }, + max_pos_grade: { id: 48, type: RubyFit::Type.grade }, + max_neg_grade: { id: 49, type: RubyFit::Type.grade }, + avg_temperature: { id: 50, type: RubyFit::Type.sint8 }, + max_temperature: { id: 51, type: RubyFit::Type.sint8 }, total_moving_time: { id: 52, type: RubyFit::Type.duration }, time_in_hr_zone: { id: 57, type: RubyFit::Type.uint32_array(5) }, time_in_power_zone: { id: 60, type: RubyFit::Type.uint32_array(8) }, + min_altitude: { id: 62, type: RubyFit::Type.altitude }, min_heart_rate: { id: 63, type: RubyFit::Type.uint8 }, enhanced_avg_speed: { id: 65, type: RubyFit::Type.uint32 }, enhanced_max_speed: { id: 66, type: RubyFit::Type.uint32 } @@ -107,8 +118,13 @@ class RubyFit::MessageWriter timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, + data16: { id: 2, type: RubyFit::Type.uint16 }, data: { id: 3, type: RubyFit::Type.uint32 }, event_group: { id: 4, type: RubyFit::Type.uint8 }, + front_gear_num: { id: 9, type: RubyFit::Type.uint8z }, + front_gear: { id: 10, type: RubyFit::Type.uint8z }, + rear_gear_num: { id: 11, type: RubyFit::Type.uint8z }, + rear_gear: { id: 12, type: RubyFit::Type.uint8z }, } }, @@ -198,24 +214,35 @@ class RubyFit::MessageWriter total_elapsed_time: { id: 7, type: RubyFit::Type.duration }, total_timer_time: { id: 8, type: RubyFit::Type.duration }, total_distance: { id: 9, type: RubyFit::Type.centimeters }, - total_ascent: { id: 22, type: RubyFit::Type.altitude }, total_calories: { id: 11, type: RubyFit::Type.uint16 }, + avg_speed: { id: 14, type: RubyFit::Type.uint32_scale100 }, + max_speed: { id: 15, type: RubyFit::Type.uint32_scale100}, avg_heart_rate: { id: 16, type: RubyFit::Type.uint8 }, max_heart_rate: { id: 17, type: RubyFit::Type.uint8 }, avg_cadence: { id: 18, type: RubyFit::Type.uint8 }, max_cadence: { id: 19, type: RubyFit::Type.uint8 }, avg_power: { id: 20, type: RubyFit::Type.uint16 }, max_power: { id: 21, type: RubyFit::Type.uint16 }, + total_ascent: { id: 22, type: RubyFit::Type.altitude }, + total_descent: { id: 23, type: RubyFit::Type.altitude }, num_laps: { id: 26, type: RubyFit::Type.uint16 }, normalized_power: { id: 34, type: RubyFit::Type.uint16 }, training_stress_score: { id: 35, type: RubyFit::Type.tss }, intensity_factor: { id: 36, type: RubyFit::Type.if }, threshold_power: { id: 45, type: RubyFit::Type.uint16 }, total_work: { id: 48, type: RubyFit::Type.uint32 }, + avg_altitude: { id: 49, type: RubyFit::Type.altitude }, + max_altitude: { id: 50, type: RubyFit::Type.altitude }, + avg_grade: { id: 52, type: RubyFit::Type.grade }, + max_pos_grade: { id: 55, type: RubyFit::Type.grade }, + max_neg_grade: { id: 56, type: RubyFit::Type.grade }, + avg_temperature: { id: 57, type: RubyFit::Type.sint8 }, + max_temperature: { id: 58, type: RubyFit::Type.sint8 }, total_moving_time: { id: 59, type: RubyFit::Type.duration }, min_heart_rate: { id: 64, type: RubyFit::Type.uint8 }, time_in_hr_zone: { id: 65, type: RubyFit::Type.uint32_array(5) }, time_in_power_zone: { id: 68, type: RubyFit::Type.uint32_array(8) }, + min_altitude: { id: 71, type: RubyFit::Type.altitude }, enhanced_avg_speed: { id: 124, type: RubyFit::Type.uint32 }, enhanced_max_speed: { id: 125, type: RubyFit::Type.uint32 } # workout_type: { id: 78, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::WORKOUT_TYPE } @@ -261,6 +288,24 @@ class RubyFit::MessageWriter } }, + segment_lap: { + id: 142, + fields: { + timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, + event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, + event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, + start_time: { id: 2, type: RubyFit::Type.timestamp, required: true }, + start_position_lat: { id: 3, type: RubyFit::Type.semicircles }, + start_position_long: { id: 4, type: RubyFit::Type.semicircles }, + end_position_lat: { id: 5, type: RubyFit::Type.semicircles }, + end_position_long: { id: 6, type: RubyFit::Type.semicircles }, + total_elapsed_time: { id: 7, type: RubyFit::Type.duration }, + total_timer_time: { id: 8, type: RubyFit::Type.duration }, + name: { id: 29, type: RubyFit::Type.string(32) }, + uuid: { id: 65, type: RubyFit::Type.string(16) } + } + }, + wahoo_id: { id: 0xFF01, fields: { @@ -312,6 +357,7 @@ class RubyFit::MessageWriter developer_data_index: { id: 3, type: RubyFit::Type.uint8 } } } + } def self.definition_message(type, local_num) diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 5bc7d5e..3f3d338 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -172,7 +172,7 @@ def duration def enhanced_speed uint32({ rb2fit: ->(val, type) { (val * 1000) }, - fit2rb: ->(val, type) { val / 1000.0 } + fit2rb: ->(val, type) { val.nil? ? nil : val / 1000.0 } }) end @@ -212,6 +212,21 @@ def uint8_scale2 }) end + def uint16_scale100 + uint16({ + rb2fit: ->(val, type) { (val * 100) }, + fit2rb: ->(val, type) { val.nil? ? nil : val / 100 } + }) + end + + def uint32_scale100 + uint32({ + rb2fit: ->(val, type) { (val * 100).truncate }, + fit2rb: ->(val, type) { val.nil? ? nil : val / 100.0 } + }) + end + + def float64(opts = {}) new({ fit_id: 0x89, diff --git a/test/activity_test.rb b/test/activity_test.rb index b03d642..6717415 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -109,15 +109,12 @@ def test_rubyfit_integration end end - # this is a Wahoo fit file with clm and wahoo custom num messages - # fit_file_path = 'test/fixtures/2025-01-03-143057-WAHOOAPPIOS62BB-3-0.fit' # Read FIT file raw = IO.read(fit_file_path) parser = RubyFit::FitFileParser.new parser.parse(raw) do |data| json_output = JSON.parse(data.to_json) - puts(json_output) json_input = JSON.parse(json_input) assert_equal json_output['file_id']['manufacturer'], json_input['manufacturer'] assert_equal json_output['activity']['timestamp'], json_input['timestamp'] diff --git a/test/fixtures/example_activity_json.json b/test/fixtures/example_activity_json.json index 88f8619..be8a4b3 100644 --- a/test/fixtures/example_activity_json.json +++ b/test/fixtures/example_activity_json.json @@ -57,7 +57,7 @@ "start_date_local": 1111065857, "total_distance": 1609, "total_calories": 275, - "average_speed": 2.5, + "avg_speed": 2.5, "max_speed": 3.4, "lap_index": 1, "split": 1, From c0c6c0c219fda73fc2b28ac3c43469ec87285845 Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 3 Apr 2025 14:32:14 -0400 Subject: [PATCH 060/104] pluralize json parsed arrays --- lib/rubyfit/fit_parser.rb | 6 ++- test/activity_test.rb | 82 ++++++++++++++++++------------------- test/fit_parser_test.rb | 86 +++++++++++++++++++-------------------- test/route_test.rb | 4 +- 4 files changed, 91 insertions(+), 87 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index f4c55ef..a4bfd45 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -172,9 +172,13 @@ def parse(raw) data = convert_to_json({ definition[:global_message_number] => values }, unpack_directive) data&.each do |key, value| - if all_data.key?(key) + plural_key = (key.to_s + 's').to_sym + if all_data.key?(plural_key) + all_data[plural_key] << value + elsif all_data.key?(key) all_data[key] = [all_data[key]] unless all_data[key].is_a?(Array) all_data[key] << value + all_data[plural_key] = all_data.delete(key) else all_data[key] = value end diff --git a/test/activity_test.rb b/test/activity_test.rb index 6717415..f229d2a 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -130,18 +130,18 @@ def test_rubyfit_integration assert_equal json_output['wahoo_id']['workout_num'], json_input['wahoo_id']['workout_num'] assert_equal json_output['wahoo_id']['workout_type'], json_input['wahoo_id']['workout_type'] if json_input['records'].size > 1 - assert_equal json_output['record'].size, json_input['records'].size - assert_equal json_output['record'].last['timestamp'], json_input['records'].last['timestamp'] - assert_equal json_output['record'].last['y'].round(2), json_input['records'].last['y'].round(2) - assert_equal json_output['record'].last['x'].round(2), json_input['records'].last['x'].round(2) - assert_equal json_output['record'].last['distance'].round(2), json_input['records'].last['distance'].round(2) - assert_equal json_output['record'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) - assert_equal json_output['record'].last['heart_rate'], json_input['records'].last['heart_rate'] - assert_equal json_output['record'].last['cadence'], json_input['records'].last['cadence'] - assert_equal json_output['record'].last['power'], json_input['records'].last['power'] - assert_equal json_output['record'].last['enhanced_speed'], json_input['records'].last['enhanced_speed'] - assert_equal json_output['record'].last['battery_soc'], json_input['records'].last['battery_soc'] - assert_equal json_output['record'].last['grade'], json_input['records'].last['grade'] + assert_equal json_output['records'].size, json_input['records'].size + assert_equal json_output['records'].last['timestamp'], json_input['records'].last['timestamp'] + assert_equal json_output['records'].last['y'].round(2), json_input['records'].last['y'].round(2) + assert_equal json_output['records'].last['x'].round(2), json_input['records'].last['x'].round(2) + assert_equal json_output['records'].last['distance'].round(2), json_input['records'].last['distance'].round(2) + assert_equal json_output['records'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) + assert_equal json_output['records'].last['heart_rate'], json_input['records'].last['heart_rate'] + assert_equal json_output['records'].last['cadence'], json_input['records'].last['cadence'] + assert_equal json_output['records'].last['power'], json_input['records'].last['power'] + assert_equal json_output['records'].last['enhanced_speed'], json_input['records'].last['enhanced_speed'] + assert_equal json_output['records'].last['battery_soc'], json_input['records'].last['battery_soc'] + assert_equal json_output['records'].last['grade'], json_input['records'].last['grade'] elsif json_input['records'].size > 0 assert_equal json_output['record']['timestamp'], json_input['records'].last['timestamp'] assert_equal json_output['record']['y'].round(2), json_input['records'].last['y'].round(2) @@ -157,17 +157,17 @@ def test_rubyfit_integration end if json_input['laps'].size > 1 - assert_equal json_output['lap'].size, json_input['laps'].size - assert_equal json_output['lap'].last['start_time'], json_input['laps'].last['start_time'] - assert_equal json_output['lap'].last['total_timer_time'], json_input['laps'].last['total_timer_time'] - assert_equal json_output['lap'].last['total_distance'], json_input['laps'].last['total_distance'] - assert_equal json_output['lap'].last['total_ascent'], json_input['laps'].last['total_ascent'] - assert_equal json_output['lap'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] - assert_equal json_output['lap'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] - assert_equal json_output['lap'].last['total_calories'], json_input['laps'].last['total_calories'] - assert_equal json_output['lap'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] - assert_equal json_output['lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] - assert_equal json_output['lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] + assert_equal json_output['laps'].size, json_input['laps'].size + assert_equal json_output['laps'].last['start_time'], json_input['laps'].last['start_time'] + assert_equal json_output['laps'].last['total_timer_time'], json_input['laps'].last['total_timer_time'] + assert_equal json_output['laps'].last['total_distance'], json_input['laps'].last['total_distance'] + assert_equal json_output['laps'].last['total_ascent'], json_input['laps'].last['total_ascent'] + assert_equal json_output['laps'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] + assert_equal json_output['laps'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] + assert_equal json_output['laps'].last['total_calories'], json_input['laps'].last['total_calories'] + assert_equal json_output['laps'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] + assert_equal json_output['laps'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] + assert_equal json_output['laps'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] elsif json_input['laps'].size > 0 assert_equal json_output['lap']['start_time'], json_input['laps'].last['start_time'] assert_equal json_output['lap']['total_timer_time'], json_input['laps'].last['total_timer_time'] @@ -181,16 +181,16 @@ def test_rubyfit_integration assert_equal json_output['lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] end if json_input['sessions'].size > 1 - assert_equal json_output['session'].size, json_input['sessions'].size - assert_equal json_output['session'].last['start_time'], json_input['sessions'].last['start_time'] - assert_equal json_output['session'].last['total_timer_time'], json_input['sessions'].last['total_timer_time'] - assert_equal json_output['session'].last['total_distance'], json_input['sessions'].last['total_distance'] - assert_equal json_output['session'].last['total_ascent'], json_input['sessions'].last['total_ascent'] - assert_equal json_output['session'].last['total_calories'], json_input['sessions'].last['total_calories'] - assert_equal json_output['session'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] - assert_equal json_output['session'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] - assert_equal json_output['session'].last['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] - assert_equal json_output['session'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] + assert_equal json_output['sessions'].size, json_input['sessions'].size + assert_equal json_output['sessions'].last['start_time'], json_input['sessions'].last['start_time'] + assert_equal json_output['sessions'].last['total_timer_time'], json_input['sessions'].last['total_timer_time'] + assert_equal json_output['sessions'].last['total_distance'], json_input['sessions'].last['total_distance'] + assert_equal json_output['sessions'].last['total_ascent'], json_input['sessions'].last['total_ascent'] + assert_equal json_output['sessions'].last['total_calories'], json_input['sessions'].last['total_calories'] + assert_equal json_output['sessions'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] + assert_equal json_output['sessions'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] + assert_equal json_output['sessions'].last['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] + assert_equal json_output['sessions'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] elsif json_input['sessions'].size > 0 assert_equal json_output['session']['start_time'], json_input['sessions'].last['start_time'] assert_equal json_output['session']['total_timer_time'], json_input['sessions'].last['total_timer_time'] @@ -203,14 +203,14 @@ def test_rubyfit_integration assert_equal json_output['session']['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] end if json_input['device_infos'].size > 1 - assert_equal json_output['device_info'].size, json_input['device_infos'].size - assert_equal json_output['device_info'].last['timestamp'], json_input['device_infos'].last['timestamp'] - assert_equal json_output['device_info'].last['serial_number'], json_input['device_infos'].last['serial_number'] - assert_equal json_output['device_info'].last['manufacturer'], json_input['device_infos'].last['manufacturer'] - assert_equal json_output['device_info'].last['product'], json_input['device_infos'].last['product'] - assert_equal json_output['device_info'].last['software_version'], json_input['device_infos'].last['software_version'] - assert_equal json_output['device_info'].last['battery_voltage'], json_input['device_infos'].last['battery_voltage'] - assert_equal json_output['device_info'].last['device_index'], json_input['device_infos'].last['device_index'] + assert_equal json_output['device_infos'].size, json_input['device_infos'].size + assert_equal json_output['device_infos'].last['timestamp'], json_input['device_infos'].last['timestamp'] + assert_equal json_output['device_infos'].last['serial_number'], json_input['device_infos'].last['serial_number'] + assert_equal json_output['device_infos'].last['manufacturer'], json_input['device_infos'].last['manufacturer'] + assert_equal json_output['device_infos'].last['product'], json_input['device_infos'].last['product'] + assert_equal json_output['device_infos'].last['software_version'], json_input['device_infos'].last['software_version'] + assert_equal json_output['device_infos'].last['battery_voltage'], json_input['device_infos'].last['battery_voltage'] + assert_equal json_output['device_infos'].last['device_index'], json_input['device_infos'].last['device_index'] elsif json_input['device_infos'].size > 0 assert_equal json_output['device_info']['timestamp'], json_input['device_infos'].last['timestamp'] assert_equal json_output['device_info']['serial_number'], json_input['device_infos'].last['serial_number'] diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 2e7ea90..ca63cd9 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -38,13 +38,13 @@ def test_little_endian_file_decoding assert_equal(26, json_output['activity']['event']) assert_equal(1, json_output['activity']['event_type']) - assert_equal(2, json_output['workout'][0]['sport']) - assert_equal(6, json_output['workout'][0]['sub_sport']) - assert_equal('Indoor Cycling', json_output['workout'][0]['wkt_name']) + assert_equal(2, json_output['workouts'][0]['sport']) + assert_equal(6, json_output['workouts'][0]['sub_sport']) + assert_equal('Indoor Cycling', json_output['workouts'][0]['wkt_name']) - assert_equal('WAHOOAPPIOS62BB', json_output['wahoo_id'][0]['app_token']) - assert_equal(3, json_output['wahoo_id'][0]['workout_num']) - assert_equal(12, json_output['wahoo_id'][0]['workout_type']) + assert_equal('WAHOOAPPIOS62BB', json_output['wahoo_ids'][0]['app_token']) + assert_equal(3, json_output['wahoo_ids'][0]['workout_num']) + assert_equal(12, json_output['wahoo_ids'][0]['workout_type']) assert_equal(2, json_output['session']['sport']) assert_equal(6, json_output['session']['sub_sport']) @@ -71,43 +71,43 @@ def test_little_endian_file_decoding assert_equal([202.88, 46.528, 0, 0, 0], json_output['lap']['time_in_hr_zone']) assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0], json_output['lap']['time_in_power_zone']) - assert_equal(98, json_output['record'][1]['heart_rate']) - assert_equal(0, json_output['record'][1]['power']) - assert_equal(0, json_output['record'][1]['calories']) - assert_equal(85, json_output['record'][0]['battery_soc']) - - assert_equal(85, json_output['record'][0]['battery_soc']) - assert_equal(110, json_output['record'][242]['power']) - assert_equal(124.2, json_output['record'][242]['distance']) - assert_equal(0.134, json_output['record'][242]['speed']) - - - assert_equal(5, json_output['hr_zone'].size) - assert_equal(0, json_output['hr_zone'][0]['message_index']) - assert_equal(1, json_output['hr_zone'][1]['message_index']) - assert_equal(2, json_output['hr_zone'][2]['message_index']) - assert_equal(3, json_output['hr_zone'][3]['message_index']) - assert_equal(4, json_output['hr_zone'][4]['message_index']) - - assert_equal(6, json_output['power_zone'].size) - assert_equal(0, json_output['power_zone'][0]['message_index']) - assert_equal(1, json_output['power_zone'][1]['message_index']) - assert_equal(2, json_output['power_zone'][2]['message_index']) - assert_equal(3, json_output['power_zone'][3]['message_index']) - assert_equal(4, json_output['power_zone'][4]['message_index']) - assert_equal(5, json_output['power_zone'][5]['message_index']) - - assert_equal(68, json_output['power_zone'][0]['high_value']) - assert_equal(87, json_output['power_zone'][1]['high_value']) - assert_equal(113, json_output['power_zone'][2]['high_value']) - assert_equal(119, json_output['power_zone'][3]['high_value']) - assert_equal(128, json_output['power_zone'][4]['high_value']) - assert_equal(65534, json_output['power_zone'][5]['high_value']) - - assert_equal(0, json_output['device_info'][0]['device_index']) - assert_equal(32, json_output['device_info'][0]['manufacturer']) - assert_equal(0, json_output['device_info'][0]['product']) - assert_equal("WAHOO APP", json_output['device_info'][0]['product_name']) + assert_equal(98, json_output['records'][1]['heart_rate']) + assert_equal(0, json_output['records'][1]['power']) + assert_equal(0, json_output['records'][1]['calories']) + assert_equal(85, json_output['records'][0]['battery_soc']) + + assert_equal(85, json_output['records'][0]['battery_soc']) + assert_equal(110, json_output['records'][242]['power']) + assert_equal(124.2, json_output['records'][242]['distance']) + assert_equal(0.134, json_output['records'][242]['speed']) + + + assert_equal(5, json_output['hr_zones'].size) + assert_equal(0, json_output['hr_zones'][0]['message_index']) + assert_equal(1, json_output['hr_zones'][1]['message_index']) + assert_equal(2, json_output['hr_zones'][2]['message_index']) + assert_equal(3, json_output['hr_zones'][3]['message_index']) + assert_equal(4, json_output['hr_zones'][4]['message_index']) + + assert_equal(6, json_output['power_zones'].size) + assert_equal(0, json_output['power_zones'][0]['message_index']) + assert_equal(1, json_output['power_zones'][1]['message_index']) + assert_equal(2, json_output['power_zones'][2]['message_index']) + assert_equal(3, json_output['power_zones'][3]['message_index']) + assert_equal(4, json_output['power_zones'][4]['message_index']) + assert_equal(5, json_output['power_zones'][5]['message_index']) + + assert_equal(68, json_output['power_zones'][0]['high_value']) + assert_equal(87, json_output['power_zones'][1]['high_value']) + assert_equal(113, json_output['power_zones'][2]['high_value']) + assert_equal(119, json_output['power_zones'][3]['high_value']) + assert_equal(128, json_output['power_zones'][4]['high_value']) + assert_equal(65534, json_output['power_zones'][5]['high_value']) + + assert_equal(0, json_output['device_infos'][0]['device_index']) + assert_equal(32, json_output['device_infos'][0]['manufacturer']) + assert_equal(0, json_output['device_infos'][0]['product']) + assert_equal("WAHOO APP", json_output['device_infos'][0]['product_name']) end end diff --git a/test/route_test.rb b/test/route_test.rb index b3324f0..96c69cd 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -53,8 +53,8 @@ def test_integration parser = RubyFit::FitFileParser.new parser.parse(raw) do |data| json_input = JSON.parse(json_input) - assert_equal(json_input['track_points'].size, data[:record].size) - assert_equal(json_input['course_points'].size, data[:course_point].size) + assert_equal(json_input['track_points'].size, data[:records].size) + assert_equal(json_input['course_points'].size, data[:course_points].size) end end end \ No newline at end of file From 3ffa8e1d130e15eab206a18a9b6c7188830d960e Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 3 Apr 2025 15:00:50 -0400 Subject: [PATCH 061/104] pluralize json parsed arrays differently --- lib/rubyfit/fit_parser.rb | 22 ++++-- lib/rubyfit/message_constants.rb | 2 +- lib/rubyfit/message_writer.rb | 2 +- lib/rubyfit/writer.rb | 4 +- test/activity_test.rb | 130 ++++++++++--------------------- test/fit_parser_test.rb | 92 +++++++++++----------- 6 files changed, 107 insertions(+), 145 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index a4bfd45..bebd3e9 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -4,6 +4,18 @@ class RubyFit::FitFileParser def initialize @definitions = {} @fit_data = {} + @plural_message_types = {lap: :laps, + length: :lengths, + hr_zone: :hr_zones, + pwr_zone: :pwr_zones, + session: :sessions, + event: :events, + record: :records, + course_point: :course_points, + device_info: :device_infos, + segment_lap: :segment_laps, + wahoo_custom_num: :wahoo_custom_nums + } end def definition_message(local_num, global_message_number, fields, developer_fields) @@ -32,7 +44,7 @@ def convert_to_json(fit_data, unpack_directive) big_endian = unpack_directive == 'n' # Define the message type to look up type = RubyFit::MessageConstants::MESSAGE_TYPE.find { |key, value| value == fit_data.keys.first } - # puts("Unknown message type: #{fit_data.keys.first}") unless type + # puts("message type: #{fit_data.keys.first}") return unless type message_type = RubyFit::MessageConstants::MESSAGE_TYPE.find { |key, value| value == fit_data.keys.first }.first message_definition = RubyFit::MessageWriter::MESSAGE_DEFINITIONS[message_type] @@ -172,13 +184,13 @@ def parse(raw) data = convert_to_json({ definition[:global_message_number] => values }, unpack_directive) data&.each do |key, value| - plural_key = (key.to_s + 's').to_sym - if all_data.key?(plural_key) - all_data[plural_key] << value + if @plural_message_types.key?(key) + key = @plural_message_types[key] + all_data[key] = [] unless all_data[key].is_a?(Array) + all_data[key] << value elsif all_data.key?(key) all_data[key] = [all_data[key]] unless all_data[key].is_a?(Array) all_data[key] << value - all_data[plural_key] = all_data.delete(key) else all_data[key] = value end diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index 0509a96..ad03da4 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -310,7 +310,7 @@ module RubyFit::MessageConstants session: 18, workout: 26, hr_zone: 8, - power_zone: 9, + pwr_zone: 9, activity: 34, device_info: 23, sport: 12, diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index c55bff5..82c13bb 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -158,7 +158,7 @@ class RubyFit::MessageWriter } }, - power_zone: { + pwr_zone: { id: 9, fields: { message_index: { id: 254, type: RubyFit::Type.uint16 }, diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 225b922..affc07b 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -332,7 +332,7 @@ def hr_zone(values) def power_zone(values) raise "Can only write power zones inside 'power_zones' block" if @state != :power_zones - write_message(:power_zone, values) + write_message(:pwr_zone, values) end def wahoo_custom_num(values) @@ -420,7 +420,7 @@ def calculate_workout_data_size(workout_step_count, lap_count, session_count, ev session: session_count, device_info: device_info_count, hr_zone: hr_zone_count, - power_zone: power_zone_count, + pwr_zone: power_zone_count, wahoo_custom_num: wahoo_custom_num_count, wahoo_clm: wahoo_clm_count } diff --git a/test/activity_test.rb b/test/activity_test.rb index f229d2a..447afeb 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -129,96 +129,46 @@ def test_rubyfit_integration assert_equal json_output['wahoo_id']['app_token'], json_input['wahoo_id']['app_token'] assert_equal json_output['wahoo_id']['workout_num'], json_input['wahoo_id']['workout_num'] assert_equal json_output['wahoo_id']['workout_type'], json_input['wahoo_id']['workout_type'] - if json_input['records'].size > 1 - assert_equal json_output['records'].size, json_input['records'].size - assert_equal json_output['records'].last['timestamp'], json_input['records'].last['timestamp'] - assert_equal json_output['records'].last['y'].round(2), json_input['records'].last['y'].round(2) - assert_equal json_output['records'].last['x'].round(2), json_input['records'].last['x'].round(2) - assert_equal json_output['records'].last['distance'].round(2), json_input['records'].last['distance'].round(2) - assert_equal json_output['records'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) - assert_equal json_output['records'].last['heart_rate'], json_input['records'].last['heart_rate'] - assert_equal json_output['records'].last['cadence'], json_input['records'].last['cadence'] - assert_equal json_output['records'].last['power'], json_input['records'].last['power'] - assert_equal json_output['records'].last['enhanced_speed'], json_input['records'].last['enhanced_speed'] - assert_equal json_output['records'].last['battery_soc'], json_input['records'].last['battery_soc'] - assert_equal json_output['records'].last['grade'], json_input['records'].last['grade'] - elsif json_input['records'].size > 0 - assert_equal json_output['record']['timestamp'], json_input['records'].last['timestamp'] - assert_equal json_output['record']['y'].round(2), json_input['records'].last['y'].round(2) - assert_equal json_output['record']['x'].round(2), json_input['records'].last['x'].round(2) - assert_equal json_output['record'].last['distance'].round(2), json_input['records'].last['distance'].round(2) - assert_equal json_output['record'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) - assert_equal json_output['record']['heart_rate'], json_input['records'].last['heart_rate'] - assert_equal json_output['record']['cadence'], json_input['records'].last['cadence'] - assert_equal json_output['record']['power'], json_input['records'].last['power'] - assert_equal json_output['record']['enhanced_speed'], json_input['records'].last['enhanced_speed'] - assert_equal json_output['record']['battery_soc'], json_input['records'].last['battery_soc'] - assert_equal json_output['record']['grade'], json_input['records'].last['grade'] - - end - if json_input['laps'].size > 1 - assert_equal json_output['laps'].size, json_input['laps'].size - assert_equal json_output['laps'].last['start_time'], json_input['laps'].last['start_time'] - assert_equal json_output['laps'].last['total_timer_time'], json_input['laps'].last['total_timer_time'] - assert_equal json_output['laps'].last['total_distance'], json_input['laps'].last['total_distance'] - assert_equal json_output['laps'].last['total_ascent'], json_input['laps'].last['total_ascent'] - assert_equal json_output['laps'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] - assert_equal json_output['laps'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] - assert_equal json_output['laps'].last['total_calories'], json_input['laps'].last['total_calories'] - assert_equal json_output['laps'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] - assert_equal json_output['laps'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] - assert_equal json_output['laps'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] - elsif json_input['laps'].size > 0 - assert_equal json_output['lap']['start_time'], json_input['laps'].last['start_time'] - assert_equal json_output['lap']['total_timer_time'], json_input['laps'].last['total_timer_time'] - assert_equal json_output['lap']['total_distance'], json_input['laps'].last['total_distance'] - assert_equal json_output['lap']['total_ascent'], json_input['laps'].last['total_ascent'] - assert_equal json_output['lap'].last['total_calories'], json_input['laps'].last['total_calories'] - assert_equal json_output['lap'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] - assert_equal json_output['lap'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] - assert_equal json_output['lap'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] - assert_equal json_output['lap'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] - assert_equal json_output['lap'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] - end - if json_input['sessions'].size > 1 - assert_equal json_output['sessions'].size, json_input['sessions'].size - assert_equal json_output['sessions'].last['start_time'], json_input['sessions'].last['start_time'] - assert_equal json_output['sessions'].last['total_timer_time'], json_input['sessions'].last['total_timer_time'] - assert_equal json_output['sessions'].last['total_distance'], json_input['sessions'].last['total_distance'] - assert_equal json_output['sessions'].last['total_ascent'], json_input['sessions'].last['total_ascent'] - assert_equal json_output['sessions'].last['total_calories'], json_input['sessions'].last['total_calories'] - assert_equal json_output['sessions'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] - assert_equal json_output['sessions'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] - assert_equal json_output['sessions'].last['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] - assert_equal json_output['sessions'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] - elsif json_input['sessions'].size > 0 - assert_equal json_output['session']['start_time'], json_input['sessions'].last['start_time'] - assert_equal json_output['session']['total_timer_time'], json_input['sessions'].last['total_timer_time'] - assert_equal json_output['session']['total_distance'], json_input['sessions'].last['total_distance'] - assert_equal json_output['session']['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] - assert_equal json_output['session']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] - assert_equal json_output['session']['total_ascent'], json_input['sessions'].last['total_ascent'] - assert_equal json_output['session']['total_calories'], json_input['sessions'].last['total_calories'] - assert_equal json_output['session']['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] - assert_equal json_output['session']['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] - end - if json_input['device_infos'].size > 1 - assert_equal json_output['device_infos'].size, json_input['device_infos'].size - assert_equal json_output['device_infos'].last['timestamp'], json_input['device_infos'].last['timestamp'] - assert_equal json_output['device_infos'].last['serial_number'], json_input['device_infos'].last['serial_number'] - assert_equal json_output['device_infos'].last['manufacturer'], json_input['device_infos'].last['manufacturer'] - assert_equal json_output['device_infos'].last['product'], json_input['device_infos'].last['product'] - assert_equal json_output['device_infos'].last['software_version'], json_input['device_infos'].last['software_version'] - assert_equal json_output['device_infos'].last['battery_voltage'], json_input['device_infos'].last['battery_voltage'] - assert_equal json_output['device_infos'].last['device_index'], json_input['device_infos'].last['device_index'] - elsif json_input['device_infos'].size > 0 - assert_equal json_output['device_info']['timestamp'], json_input['device_infos'].last['timestamp'] - assert_equal json_output['device_info']['serial_number'], json_input['device_infos'].last['serial_number'] - assert_equal json_output['device_info']['manufacturer'], json_input['device_infos'].last['manufacturer'] - assert_equal json_output['device_info']['product'], json_input['device_infos'].last['product'] - assert_equal json_output['device_info']['software_version'], json_input['device_infos'].last['software_version'] - assert_equal json_output['device_info']['device_index'], json_input['device_infos'].last['device_index'] - end + assert_equal json_output['records'].size, json_input['records'].size + assert_equal json_output['records'].last['timestamp'], json_input['records'].last['timestamp'] + assert_equal json_output['records'].last['y'].round(2), json_input['records'].last['y'].round(2) + assert_equal json_output['records'].last['x'].round(2), json_input['records'].last['x'].round(2) + assert_equal json_output['records'].last['distance'].round(2), json_input['records'].last['distance'].round(2) + assert_equal json_output['records'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) + assert_equal json_output['records'].last['heart_rate'], json_input['records'].last['heart_rate'] + assert_equal json_output['records'].last['cadence'], json_input['records'].last['cadence'] + assert_equal json_output['records'].last['power'], json_input['records'].last['power'] + assert_equal json_output['records'].last['enhanced_speed'], json_input['records'].last['enhanced_speed'] + assert_equal json_output['records'].last['battery_soc'], json_input['records'].last['battery_soc'] + assert_equal json_output['records'].last['grade'], json_input['records'].last['grade'] + assert_equal json_output['laps'].size, json_input['laps'].size + assert_equal json_output['laps'].last['start_time'], json_input['laps'].last['start_time'] + assert_equal json_output['laps'].last['total_timer_time'], json_input['laps'].last['total_timer_time'] + assert_equal json_output['laps'].last['total_distance'], json_input['laps'].last['total_distance'] + assert_equal json_output['laps'].last['total_ascent'], json_input['laps'].last['total_ascent'] + assert_equal json_output['laps'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] + assert_equal json_output['laps'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] + assert_equal json_output['laps'].last['total_calories'], json_input['laps'].last['total_calories'] + assert_equal json_output['laps'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] + assert_equal json_output['laps'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] + assert_equal json_output['laps'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] + assert_equal json_output['sessions'].size, json_input['sessions'].size + assert_equal json_output['sessions'].last['start_time'], json_input['sessions'].last['start_time'] + assert_equal json_output['sessions'].last['total_timer_time'], json_input['sessions'].last['total_timer_time'] + assert_equal json_output['sessions'].last['total_distance'], json_input['sessions'].last['total_distance'] + assert_equal json_output['sessions'].last['total_ascent'], json_input['sessions'].last['total_ascent'] + assert_equal json_output['sessions'].last['total_calories'], json_input['sessions'].last['total_calories'] + assert_equal json_output['sessions'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] + assert_equal json_output['sessions'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] + assert_equal json_output['sessions'].last['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] + assert_equal json_output['sessions'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] + assert_equal json_output['device_infos'].size, json_input['device_infos'].size + assert_equal json_output['device_infos'].last['timestamp'], json_input['device_infos'].last['timestamp'] + assert_equal json_output['device_infos'].last['serial_number'], json_input['device_infos'].last['serial_number'] + assert_equal json_output['device_infos'].last['manufacturer'], json_input['device_infos'].last['manufacturer'] + assert_equal json_output['device_infos'].last['product'], json_input['device_infos'].last['product'] + assert_equal json_output['device_infos'].last['software_version'], json_input['device_infos'].last['software_version'] + assert_equal json_output['device_infos'].last['device_index'], json_input['device_infos'].last['device_index'] end end end \ No newline at end of file diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index ca63cd9..daaa262 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -38,38 +38,38 @@ def test_little_endian_file_decoding assert_equal(26, json_output['activity']['event']) assert_equal(1, json_output['activity']['event_type']) - assert_equal(2, json_output['workouts'][0]['sport']) - assert_equal(6, json_output['workouts'][0]['sub_sport']) - assert_equal('Indoor Cycling', json_output['workouts'][0]['wkt_name']) - - assert_equal('WAHOOAPPIOS62BB', json_output['wahoo_ids'][0]['app_token']) - assert_equal(3, json_output['wahoo_ids'][0]['workout_num']) - assert_equal(12, json_output['wahoo_ids'][0]['workout_type']) - - assert_equal(2, json_output['session']['sport']) - assert_equal(6, json_output['session']['sub_sport']) - assert_equal(247.886, json_output['session']['total_timer_time']) - assert_equal(17, json_output['session']['total_calories']) - assert_equal(137, json_output['session']['max_heart_rate']) - assert_equal(108, json_output['session']['avg_heart_rate']) - assert_equal(124.2, json_output['session']['total_distance']) - assert_equal(2.1, json_output['session']['training_stress_score']) - assert_equal(0.592, json_output['session']['intensity_factor']) - assert_equal(124, json_output['session']['threshold_power']) - - assert_equal([202.88, 46.528, 0, 0, 0], json_output['lap']['time_in_hr_zone']) - assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0], json_output['lap']['time_in_power_zone']) - - assert_equal(2, json_output['lap']['sport']) - assert_equal(6, json_output['lap']['sub_sport']) - assert_equal(247.886, json_output['lap']['total_timer_time']) - assert_equal(17, json_output['lap']['total_calories']) - assert_equal(137, json_output['lap']['max_heart_rate']) - assert_equal(108, json_output['lap']['avg_heart_rate']) - assert_equal(124.2, json_output['lap']['total_distance']) - assert_equal(17304, json_output['lap']['total_work']) - assert_equal([202.88, 46.528, 0, 0, 0], json_output['lap']['time_in_hr_zone']) - assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0], json_output['lap']['time_in_power_zone']) + assert_equal(2, json_output['workout'][0]['sport']) + assert_equal(6, json_output['workout'][0]['sub_sport']) + assert_equal('Indoor Cycling', json_output['workout'][0]['wkt_name']) + + assert_equal('WAHOOAPPIOS62BB', json_output['wahoo_id'][0]['app_token']) + assert_equal(3, json_output['wahoo_id'][0]['workout_num']) + assert_equal(12, json_output['wahoo_id'][0]['workout_type']) + + assert_equal(2, json_output['sessions'][0]['sport']) + assert_equal(6, json_output['sessions'][0]['sub_sport']) + assert_equal(247.886, json_output['sessions'][0]['total_timer_time']) + assert_equal(17, json_output['sessions'][0]['total_calories']) + assert_equal(137, json_output['sessions'][0]['max_heart_rate']) + assert_equal(108, json_output['sessions'][0]['avg_heart_rate']) + assert_equal(124.2, json_output['sessions'][0]['total_distance']) + assert_equal(2.1, json_output['sessions'][0]['training_stress_score']) + assert_equal(0.592, json_output['sessions'][0]['intensity_factor']) + assert_equal(124, json_output['sessions'][0]['threshold_power']) + + assert_equal([202.88, 46.528, 0, 0, 0], json_output['laps'][0]['time_in_hr_zone']) + assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0], json_output['laps'][0]['time_in_power_zone']) + + assert_equal(2, json_output['laps'][0]['sport']) + assert_equal(6, json_output['laps'][0]['sub_sport']) + assert_equal(247.886, json_output['laps'][0]['total_timer_time']) + assert_equal(17, json_output['laps'][0]['total_calories']) + assert_equal(137, json_output['laps'][0]['max_heart_rate']) + assert_equal(108, json_output['laps'][0]['avg_heart_rate']) + assert_equal(124.2, json_output['laps'][0]['total_distance']) + assert_equal(17304, json_output['laps'][0]['total_work']) + assert_equal([202.88, 46.528, 0, 0, 0], json_output['laps'][0]['time_in_hr_zone']) + assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0], json_output['laps'][0]['time_in_power_zone']) assert_equal(98, json_output['records'][1]['heart_rate']) assert_equal(0, json_output['records'][1]['power']) @@ -89,20 +89,20 @@ def test_little_endian_file_decoding assert_equal(3, json_output['hr_zones'][3]['message_index']) assert_equal(4, json_output['hr_zones'][4]['message_index']) - assert_equal(6, json_output['power_zones'].size) - assert_equal(0, json_output['power_zones'][0]['message_index']) - assert_equal(1, json_output['power_zones'][1]['message_index']) - assert_equal(2, json_output['power_zones'][2]['message_index']) - assert_equal(3, json_output['power_zones'][3]['message_index']) - assert_equal(4, json_output['power_zones'][4]['message_index']) - assert_equal(5, json_output['power_zones'][5]['message_index']) - - assert_equal(68, json_output['power_zones'][0]['high_value']) - assert_equal(87, json_output['power_zones'][1]['high_value']) - assert_equal(113, json_output['power_zones'][2]['high_value']) - assert_equal(119, json_output['power_zones'][3]['high_value']) - assert_equal(128, json_output['power_zones'][4]['high_value']) - assert_equal(65534, json_output['power_zones'][5]['high_value']) + assert_equal(6, json_output['pwr_zones'].size) + assert_equal(0, json_output['pwr_zones'][0]['message_index']) + assert_equal(1, json_output['pwr_zones'][1]['message_index']) + assert_equal(2, json_output['pwr_zones'][2]['message_index']) + assert_equal(3, json_output['pwr_zones'][3]['message_index']) + assert_equal(4, json_output['pwr_zones'][4]['message_index']) + assert_equal(5, json_output['pwr_zones'][5]['message_index']) + + assert_equal(68, json_output['pwr_zones'][0]['high_value']) + assert_equal(87, json_output['pwr_zones'][1]['high_value']) + assert_equal(113, json_output['pwr_zones'][2]['high_value']) + assert_equal(119, json_output['pwr_zones'][3]['high_value']) + assert_equal(128, json_output['pwr_zones'][4]['high_value']) + assert_equal(65534, json_output['pwr_zones'][5]['high_value']) assert_equal(0, json_output['device_infos'][0]['device_index']) assert_equal(32, json_output['device_infos'][0]['manufacturer']) From 18981c7586149d0ef891898cba53f769cbf19311 Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 4 Apr 2025 09:39:07 -0400 Subject: [PATCH 062/104] change naming of fields to match crux --- lib/rubyfit/message_writer.rb | 234 +++++++++++------------ lib/rubyfit/type.rb | 2 +- lib/rubyfit/writer.rb | 70 +++---- test/activity_test.rb | 96 +++++----- test/fit_parser_test.rb | 92 ++++----- test/fixtures/example_activity_json.json | 92 ++++----- test/fixtures/example_route_json.json | 24 +-- test/route_test.rb | 4 +- 8 files changed, 307 insertions(+), 307 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 82c13bb..7f99011 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -14,9 +14,9 @@ class RubyFit::MessageWriter fields: { serial_number: { id: 3, type: RubyFit::Type.uint32z, required: false }, time_created: { id: 4, type: RubyFit::Type.timestamp, required: true }, - manufacturer: { id: 1, type: RubyFit::Type.uint16 }, # See FIT_MANUFACTURER_* + manufacturer_code: { id: 1, type: RubyFit::Type.uint16 }, product: { id: 2, type: RubyFit::Type.uint16 }, - type: { id: 0, type: RubyFit::Type.enum, required: true }, # See FIT_FILE_* + type_code: { id: 0, type: RubyFit::Type.enum, required: true }, } }, @@ -31,44 +31,44 @@ class RubyFit::MessageWriter id: 19, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true}, - event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT }, - event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE }, + event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT }, + event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE }, start_time: { id: 2, type: RubyFit::Type.timestamp, required: true}, - start_y: { id: 3, type: RubyFit::Type.semicircles }, - start_x: { id: 4, type: RubyFit::Type.semicircles }, - end_y: { id: 5, type: RubyFit::Type.semicircles }, - end_x: { id: 6, type: RubyFit::Type.semicircles }, - total_elapsed_time: { id: 7, type: RubyFit::Type.duration, required: true }, - total_timer_time: { id: 8, type: RubyFit::Type.duration, required: true }, - total_distance: { id: 9, type: RubyFit::Type.centimeters }, - total_calories: { id: 11, type: RubyFit::Type.uint16 }, - avg_speed: { id: 13, type: RubyFit::Type.uint32_scale100 }, - max_speed: { id: 14, type: RubyFit::Type.uint32_scale100 }, - avg_heart_rate: { id: 15, type: RubyFit::Type.uint8 }, - max_heart_rate: { id: 16, type: RubyFit::Type.uint8 }, - avg_cadence: { id: 17, type: RubyFit::Type.uint8 }, - max_cadence: { id: 18, type: RubyFit::Type.uint8 }, - avg_power: { id: 19, type: RubyFit::Type.uint16 }, - max_power: { id: 20, type: RubyFit::Type.uint16 }, - total_ascent: { id: 21, type: RubyFit::Type.altitude }, - total_descent: { id: 22, type: RubyFit::Type.altitude }, - lap_trigger: { id: 24, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::LAP_TRIGGER }, - sport: { id: 25, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: false }, - normalized_power: { id: 33, type: RubyFit::Type.uint16 }, - sub_sport: { id: 39, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT, required: false}, - total_work: { id: 41, type: RubyFit::Type.uint32 }, - avg_altitude: { id: 42, type: RubyFit::Type.altitude }, - max_altitude: { id: 43, type: RubyFit::Type.altitude }, - avg_grade: { id: 45, type: RubyFit::Type.grade }, - max_pos_grade: { id: 48, type: RubyFit::Type.grade }, - max_neg_grade: { id: 49, type: RubyFit::Type.grade }, - avg_temperature: { id: 50, type: RubyFit::Type.sint8 }, - max_temperature: { id: 51, type: RubyFit::Type.sint8 }, - total_moving_time: { id: 52, type: RubyFit::Type.duration }, - time_in_hr_zone: { id: 57, type: RubyFit::Type.uint32_array(5) }, - time_in_power_zone: { id: 60, type: RubyFit::Type.uint32_array(8) }, - min_altitude: { id: 62, type: RubyFit::Type.altitude }, - min_heart_rate: { id: 63, type: RubyFit::Type.uint8 }, + start_lat_deg: { id: 3, type: RubyFit::Type.semicircles }, + start_lon_deg: { id: 4, type: RubyFit::Type.semicircles }, + end_lat_deg: { id: 5, type: RubyFit::Type.semicircles }, + end_lon_deg: { id: 6, type: RubyFit::Type.semicircles }, + tot_elapsed_time_sec: { id: 7, type: RubyFit::Type.duration, required: true }, + tot_timer_time_sec: { id: 8, type: RubyFit::Type.duration, required: true }, + tot_dist_m: { id: 9, type: RubyFit::Type.centimeters }, + tot_cal: { id: 11, type: RubyFit::Type.uint16 }, + avg_speed_mps: { id: 13, type: RubyFit::Type.uint32_scale100 }, + max_speed_mps: { id: 14, type: RubyFit::Type.uint32_scale100 }, + avg_hr_bpm: { id: 15, type: RubyFit::Type.uint8 }, + max_hr_bpm: { id: 16, type: RubyFit::Type.uint8 }, + avg_cad_rpm: { id: 17, type: RubyFit::Type.uint8 }, + max_cad_rpm: { id: 18, type: RubyFit::Type.uint8 }, + avg_pwr_watts: { id: 19, type: RubyFit::Type.uint16 }, + max_pwr_watts: { id: 20, type: RubyFit::Type.uint16 }, + tot_ascent_m: { id: 21, type: RubyFit::Type.altitude }, + tot_descent_m: { id: 22, type: RubyFit::Type.altitude }, + lap_trigger_code: { id: 24, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::LAP_TRIGGER }, + sport_code: { id: 25, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: false }, + norm_pwr_watts: { id: 33, type: RubyFit::Type.uint16 }, + sub_sport_code: { id: 39, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT, required: false}, + tot_work_j: { id: 41, type: RubyFit::Type.uint32 }, + avg_alt_m: { id: 42, type: RubyFit::Type.altitude }, + max_alt_m: { id: 43, type: RubyFit::Type.altitude }, + avg_grade_perc: { id: 45, type: RubyFit::Type.grade }, + max_pos_grade_perc: { id: 48, type: RubyFit::Type.grade }, + max_neg_grade_perc: { id: 49, type: RubyFit::Type.grade }, + avg_temp_deg_c: { id: 50, type: RubyFit::Type.sint8 }, + max_temp_deg_c: { id: 51, type: RubyFit::Type.sint8 }, + tot_moving_time_sec: { id: 52, type: RubyFit::Type.duration }, + time_in_hr_zone_sec: { id: 57, type: RubyFit::Type.uint32_array(5) }, + time_in_pwr_zone_sec: { id: 60, type: RubyFit::Type.uint32_array(8) }, + min_alt_m: { id: 62, type: RubyFit::Type.altitude }, + min_hr_bpm: { id: 63, type: RubyFit::Type.uint8 }, enhanced_avg_speed: { id: 65, type: RubyFit::Type.uint32 }, enhanced_max_speed: { id: 66, type: RubyFit::Type.uint32 } }, @@ -91,24 +91,24 @@ class RubyFit::MessageWriter id: 20, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, - y: { id: 0, type: RubyFit::Type.semicircles, required: true }, - x: { id: 1, type: RubyFit::Type.semicircles, required: true }, - distance: { id: 5, type: RubyFit::Type.centimeters }, - speed: { id: 6, type: RubyFit::Type.speed }, - elevation: { id: 2, type: RubyFit::Type.altitude }, - heart_rate: { id: 3, type: RubyFit::Type.uint8 }, - cadence: { id: 4, type: RubyFit::Type.uint8 }, - power: { id: 7, type: RubyFit::Type.uint16 }, - calories: { id: 33, type: RubyFit::Type.uint16 }, - enhanced_speed: { id: 73, type: RubyFit::Type.enhanced_speed}, - battery_soc: { id: 81, type: RubyFit::Type.uint8_scale2 }, - grade: { id: 9, type: RubyFit::Type.grade}, - temperature: { id: 13, type: RubyFit::Type.sint8 }, - gps_accuracy: { id: 31, type: RubyFit::Type.uint8 }, - left_torque_effectiveness: { id: 43, type: RubyFit::Type.uint8_scale2 }, - right_torque_effectiveness: { id: 44, type: RubyFit::Type.uint8_scale2 }, - left_pedal_smoothness: { id: 45, type: RubyFit::Type.uint8_scale2 }, - right_pedal_smoothness: { id: 46, type: RubyFit::Type.uint8_scale2 } + lat_deg: { id: 0, type: RubyFit::Type.semicircles, required: true }, + lon_deg: { id: 1, type: RubyFit::Type.semicircles, required: true }, + alt_m: { id: 2, type: RubyFit::Type.altitude }, + hr_bpm: { id: 3, type: RubyFit::Type.uint8 }, + cad_rpm: { id: 4, type: RubyFit::Type.uint8 }, + dist_m: { id: 5, type: RubyFit::Type.centimeters }, + spd_mps: { id: 6, type: RubyFit::Type.speed }, + pwr_watts: { id: 7, type: RubyFit::Type.uint16 }, + grade_perc: { id: 9, type: RubyFit::Type.grade}, + temp_deg_c: { id: 13, type: RubyFit::Type.sint8 }, + gps_acc_m: { id: 31, type: RubyFit::Type.uint8 }, + cal: { id: 33, type: RubyFit::Type.uint16 }, + left_torque_effect_perc: { id: 43, type: RubyFit::Type.uint8_scale2 }, + right_torque_effect_perc: { id: 44, type: RubyFit::Type.uint8_scale2 }, + left_pedal_smooth_perc: { id: 45, type: RubyFit::Type.uint8_scale2 }, + right_pedal_smooth_perc: { id: 46, type: RubyFit::Type.uint8_scale2 }, + enhanced_spd_mps: { id: 73, type: RubyFit::Type.enhanced_speed}, + battery_soc: { id: 81, type: RubyFit::Type.uint8_scale2 } } }, @@ -116,8 +116,8 @@ class RubyFit::MessageWriter id: 21, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, - event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, - event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, + event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, + event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, data16: { id: 2, type: RubyFit::Type.uint16 }, data: { id: 3, type: RubyFit::Type.uint32 }, event_group: { id: 4, type: RubyFit::Type.uint8 }, @@ -131,11 +131,11 @@ class RubyFit::MessageWriter workout: { id: 26, fields: { - sport: { id: 4, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: true }, + sport_code: { id: 4, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: true }, # capabilities: { id: 5, type: RubyFit::Type.uint32z, required: true }, # should be workout_capabilities type num_valid_steps: { id: 6, type: RubyFit::Type.uint16 }, wkt_name: { id: 8, type: RubyFit::Type.string(64) }, - sub_sport: { id: 11, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT }, + sub_sport_code: { id: 11, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT }, # pool_length: { id: 14, type: RubyFit::Type.uint16 }, # pool_length_unit: { id: 15, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::DISPLAY_MEASURE } } @@ -144,8 +144,8 @@ class RubyFit::MessageWriter sport: { id: 12, fields: { - sport: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: true }, - sub_sport: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT } + sport_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: true }, + sub_sport_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT } } }, @@ -153,7 +153,7 @@ class RubyFit::MessageWriter id: 8, fields: { message_index: { id: 254, type: RubyFit::Type.uint16 }, - high_bpm: { id: 1, type: RubyFit::Type.uint8, required: true }, + high_hr_bpm: { id: 1, type: RubyFit::Type.uint8, required: true }, name: { id: 2, type: RubyFit::Type.string(16), required: true } } }, @@ -162,7 +162,7 @@ class RubyFit::MessageWriter id: 9, fields: { message_index: { id: 254, type: RubyFit::Type.uint16 }, - high_value: { id: 1, type: RubyFit::Type.uint16, required: true }, + high_pwr_watts: { id: 1, type: RubyFit::Type.uint16, required: true }, name: { id: 2, type: RubyFit::Type.string(16), required: true } } }, @@ -206,43 +206,43 @@ class RubyFit::MessageWriter id: 18, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, - event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, - event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, + event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, + event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, start_time: { id: 2, type: RubyFit::Type.timestamp, required: true }, - sport: { id: 5, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: true }, - sub_sport: { id: 6, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT }, - total_elapsed_time: { id: 7, type: RubyFit::Type.duration }, - total_timer_time: { id: 8, type: RubyFit::Type.duration }, - total_distance: { id: 9, type: RubyFit::Type.centimeters }, - total_calories: { id: 11, type: RubyFit::Type.uint16 }, - avg_speed: { id: 14, type: RubyFit::Type.uint32_scale100 }, - max_speed: { id: 15, type: RubyFit::Type.uint32_scale100}, - avg_heart_rate: { id: 16, type: RubyFit::Type.uint8 }, - max_heart_rate: { id: 17, type: RubyFit::Type.uint8 }, - avg_cadence: { id: 18, type: RubyFit::Type.uint8 }, - max_cadence: { id: 19, type: RubyFit::Type.uint8 }, - avg_power: { id: 20, type: RubyFit::Type.uint16 }, - max_power: { id: 21, type: RubyFit::Type.uint16 }, - total_ascent: { id: 22, type: RubyFit::Type.altitude }, - total_descent: { id: 23, type: RubyFit::Type.altitude }, + sport_code: { id: 5, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: true }, + sub_sport_code: { id: 6, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT }, + tot_elapsed_time_sec: { id: 7, type: RubyFit::Type.duration }, + tot_timer_time_sec: { id: 8, type: RubyFit::Type.duration }, + tot_dist_m: { id: 9, type: RubyFit::Type.centimeters }, + tot_cal: { id: 11, type: RubyFit::Type.uint16 }, + avg_spd_mps: { id: 14, type: RubyFit::Type.uint32_scale100 }, + max_speed_mps: { id: 15, type: RubyFit::Type.uint32_scale100}, + avg_hr_bpm: { id: 16, type: RubyFit::Type.uint8 }, + max_hr_bpm: { id: 17, type: RubyFit::Type.uint8 }, + avg_cad_rpm: { id: 18, type: RubyFit::Type.uint8 }, + max_cad_rpm: { id: 19, type: RubyFit::Type.uint8 }, + avg_pwr_watts: { id: 20, type: RubyFit::Type.uint16 }, + max_pwr_watts: { id: 21, type: RubyFit::Type.uint16 }, + tot_ascent_m: { id: 22, type: RubyFit::Type.altitude }, + tot_descent_m: { id: 23, type: RubyFit::Type.altitude }, num_laps: { id: 26, type: RubyFit::Type.uint16 }, - normalized_power: { id: 34, type: RubyFit::Type.uint16 }, - training_stress_score: { id: 35, type: RubyFit::Type.tss }, - intensity_factor: { id: 36, type: RubyFit::Type.if }, - threshold_power: { id: 45, type: RubyFit::Type.uint16 }, - total_work: { id: 48, type: RubyFit::Type.uint32 }, - avg_altitude: { id: 49, type: RubyFit::Type.altitude }, - max_altitude: { id: 50, type: RubyFit::Type.altitude }, - avg_grade: { id: 52, type: RubyFit::Type.grade }, - max_pos_grade: { id: 55, type: RubyFit::Type.grade }, - max_neg_grade: { id: 56, type: RubyFit::Type.grade }, - avg_temperature: { id: 57, type: RubyFit::Type.sint8 }, - max_temperature: { id: 58, type: RubyFit::Type.sint8 }, - total_moving_time: { id: 59, type: RubyFit::Type.duration }, - min_heart_rate: { id: 64, type: RubyFit::Type.uint8 }, - time_in_hr_zone: { id: 65, type: RubyFit::Type.uint32_array(5) }, - time_in_power_zone: { id: 68, type: RubyFit::Type.uint32_array(8) }, - min_altitude: { id: 71, type: RubyFit::Type.altitude }, + norm_pwr_watts: { id: 34, type: RubyFit::Type.uint16 }, + tss: { id: 35, type: RubyFit::Type.tss }, + if: { id: 36, type: RubyFit::Type.if }, + ftp: { id: 45, type: RubyFit::Type.uint16 }, + tot_work_j: { id: 48, type: RubyFit::Type.uint32 }, + avg_alt_m: { id: 49, type: RubyFit::Type.altitude }, + max_alt_m: { id: 50, type: RubyFit::Type.altitude }, + avg_grade_perc: { id: 52, type: RubyFit::Type.grade }, + max_pos_grade_perc: { id: 55, type: RubyFit::Type.grade }, + max_neg_grade_perc: { id: 56, type: RubyFit::Type.grade }, + avg_temp_deg_c: { id: 57, type: RubyFit::Type.sint8 }, + max_temp_deg_c: { id: 58, type: RubyFit::Type.sint8 }, + tot_moving_time_sec: { id: 59, type: RubyFit::Type.duration }, + min_hr_bpm: { id: 64, type: RubyFit::Type.uint8 }, + time_in_hr_zone_sec: { id: 65, type: RubyFit::Type.uint32_array(5) }, + time_in_pwr_zone_sec: { id: 68, type: RubyFit::Type.uint32_array(8) }, + min_alt_m: { id: 71, type: RubyFit::Type.altitude }, enhanced_avg_speed: { id: 124, type: RubyFit::Type.uint32 }, enhanced_max_speed: { id: 125, type: RubyFit::Type.uint32 } # workout_type: { id: 78, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::WORKOUT_TYPE } @@ -253,11 +253,11 @@ class RubyFit::MessageWriter id: 34, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, - total_timer_time: { id: 0, type: RubyFit::Type.duration }, + tot_timer_time_sec: { id: 0, type: RubyFit::Type.duration }, num_sessions: { id: 1, type: RubyFit::Type.uint16 }, - type: { id: 2, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::ACTIVITY_TYPE }, - event: { id: 3, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT }, - event_type: { id: 4, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE }, + type_code: { id: 2, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::ACTIVITY_TYPE }, + event_code: { id: 3, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT }, + event_type_code: { id: 4, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE }, local_timestamp: { id: 5, type: RubyFit::Type.timestamp }, } }, @@ -266,11 +266,11 @@ class RubyFit::MessageWriter id: 101, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, - event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, - event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, + event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, + event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, start_time: { id: 2, type: RubyFit::Type.timestamp, required: true }, total_elapsed_time: { id: 3, type: RubyFit::Type.duration }, - total_timer_time: { id: 4, type: RubyFit::Type.duration }, + tot_timer_time_sec: { id: 4, type: RubyFit::Type.duration }, total_strokes: { id: 5, type: RubyFit::Type.uint16 }, avg_speed: { id: 6, type: RubyFit::Type.uint16 }, swim_stroke: { id: 7, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SWIM_STROKE }, @@ -292,15 +292,15 @@ class RubyFit::MessageWriter id: 142, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, - event: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, - event_type: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, + event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, + event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, start_time: { id: 2, type: RubyFit::Type.timestamp, required: true }, - start_position_lat: { id: 3, type: RubyFit::Type.semicircles }, - start_position_long: { id: 4, type: RubyFit::Type.semicircles }, - end_position_lat: { id: 5, type: RubyFit::Type.semicircles }, - end_position_long: { id: 6, type: RubyFit::Type.semicircles }, - total_elapsed_time: { id: 7, type: RubyFit::Type.duration }, - total_timer_time: { id: 8, type: RubyFit::Type.duration }, + start_lat_deg: { id: 3, type: RubyFit::Type.semicircles }, + start_lon_deg: { id: 4, type: RubyFit::Type.semicircles }, + end_lat_deg: { id: 5, type: RubyFit::Type.semicircles }, + end_lon_deg: { id: 6, type: RubyFit::Type.semicircles }, + tot_elapsed_time_sec: { id: 7, type: RubyFit::Type.duration }, + tot_timer_time_sec: { id: 8, type: RubyFit::Type.duration }, name: { id: 29, type: RubyFit::Type.string(32) }, uuid: { id: 65, type: RubyFit::Type.string(16) } } @@ -321,8 +321,8 @@ class RubyFit::MessageWriter fields: { value: { id: 0, type: RubyFit::Type.float64, required: true }, timestamp: { id: 1, type: RubyFit::Type.timestamp, required: false }, - sub_type: { id: 2, type: RubyFit::Type.uint16, required: true }, - type: { id: 3, type: RubyFit::Type.uint8, required: true } + sub_type_code: { id: 2, type: RubyFit::Type.uint16, required: true }, + type_code: { id: 3, type: RubyFit::Type.uint8, required: true } } }, diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 3f3d338..d456c7b 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -131,7 +131,7 @@ def uint64z(opts = {}) def timestamp uint32({ rb2fit: ->(val, type) { unix2fit_timestamp(val) }, - fit2rb: ->(val, type) { val.nil? ? nil : fit2unix_timestamp(val) } + fit2rb: ->(val, type) { val.nil? ? nil : Time.at(fit2unix_timestamp(val)) } }) end diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index affc07b..c5610e6 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -12,7 +12,7 @@ def write(stream, opts = {}) @stream = stream %i(start_time duration course_point_count track_point_count name - total_distance time_created start_x start_y end_x end_y).each do |key| + tot_dist_m time_created start_x start_y end_x end_y).each do |key| raise ArgumentError.new("Missing required option #{key}") unless opts[key] end @@ -26,8 +26,8 @@ def write(stream, opts = {}) write_message(:file_id, { time_created: opts[:time_created], - type: 6, # Course file - manufacturer: opts[:manufacturer], + type_code: 6, # Course file + manufacturer_code: opts[:manufacturer], product: opts[:product], serial_number: 0, }) @@ -37,22 +37,22 @@ def write(stream, opts = {}) write_message(:lap, { start_time: start_time, timestamp: start_time, - total_elapsed_time: duration, - total_timer_time: duration, - start_x: opts[:start_x], - start_y: opts[:start_y], - end_x: opts[:end_x], - end_y: opts[:end_y], - total_distance: opts[:total_distance], - total_ascent: opts[:total_ascent], - sport: opts[:sport], - sub_sport: opts[:subsport] + tot_elapsed_time_sec: duration, + tot_timer_time_sec: duration, + start_lat_deg: opts[:start_x], + start_lon_deg: opts[:start_y], + end_lat_deg: opts[:end_x], + end_lon_deg: opts[:end_y], + tot_dist_m: opts[:tot_dist_m], + tot_ascent_m: opts[:total_ascent], + sport_code: opts[:sport], + sub_sport_code: opts[:subsport] }) write_message(:event, { timestamp: start_time, - event: :timer, - event_type: :start, + event_code: :timer, + event_type_code: :start, event_group: 0 }) @@ -60,8 +60,8 @@ def write(stream, opts = {}) write_message(:event, { timestamp: start_time + duration, - event: :timer, - event_type: :stop_disable_all, + event_code: :timer, + event_type_code: :stop_disable_all, event_group: 0 }) @@ -88,19 +88,19 @@ def write_workout_file(stream, opts = {}) write_message(:file_id, { time_created: opts[:time_created], - type: 5, # workout file - manufacturer: opts[:manufacturer], + type_code: 5, # workout file + manufacturer_code: opts[:manufacturer], product: opts[:product], serial_number: 0, }) # Every FIT Workout file MUST contain a Workout message as the second message write_message(:workout, { - sport: opts[:sport], + sport_code: opts[:sport], capabilities: opts[:capabilities], num_valid_steps: opts[:num_valid_steps], wkt_name: opts[:wkt_name], - sub_sport: opts[:subsport], + sub_sport_code: opts[:subsport], pool_length: opts[:pool_length], pool_length_unit: opts[:pool_length_unit] }) @@ -141,33 +141,33 @@ def write_activity_file(stream, opts = {}) write_message(:file_id, { time_created: opts[:time_created], - type: 4, # activity file - manufacturer: opts[:manufacturer], + type_code: 4, # activity file + manufacturer_code: opts[:manufacturer], product: opts[:product], serial_number: 0, }) write_message(:activity, { timestamp: opts[:timestamp], - total_timer_time: opts[:total_timer_time], + tot_timer_time_sec: opts[:tot_timer_time_sec], num_sessions: opts[:session_count], - type: opts[:type], - event: opts[:event], - event_type: opts[:event_type], + type_code: opts[:type], + event_code: opts[:event], + event_type_code: opts[:event_type], local_timestamp: opts[:local_timestamp] }) write_message(:sport, { - sport: opts[:sport], - sub_sport: opts[:subsport] + sport_code: opts[:sport], + sub_sport_code: opts[:subsport] }) write_message(:workout, { - sport: opts[:sport], + sport_code: opts[:sport], # capabilities: opts[:capabilities], num_valid_steps: opts[:num_valid_steps], wkt_name: opts[:name], - sub_sport: opts[:subsport], + sub_sport_code: opts[:subsport], # pool_length: opts[:pool_length], # pool_length_unit: opts[:pool_length_unit] }) @@ -182,8 +182,8 @@ def write_activity_file(stream, opts = {}) write_message(:event, { timestamp: start_time, - event: :timer, - event_type: :start, + event_code: :timer, + event_type_code: :start, event_group: 0 }) @@ -191,8 +191,8 @@ def write_activity_file(stream, opts = {}) write_message(:event, { timestamp: start_time + duration, - event: :timer, - event_type: :stop_disable_all, + event_code: :timer, + event_type_code: :stop_disable_all, event_group: 0 }) diff --git a/test/activity_test.rb b/test/activity_test.rb index 447afeb..6fa3d98 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -13,8 +13,8 @@ def test_rubyfit_integration # Parse JSON input json = JSON.parse(json_input, symbolize_names: true) - json[:laps] = json[:laps].map { |lap| lap.transform_keys(&:to_sym).merge(sport: lap[:sport].to_sym, sub_sport: lap[:sub_sport].to_sym, event: lap[:event].to_sym, event_type: lap[:event_type].to_sym, lap_trigger: lap[:lap_trigger].to_sym) } - json[:sessions] = json[:sessions].map { |session| session.transform_keys(&:to_sym).merge(sport: session[:sport].to_sym, sub_sport: session[:sub_sport].to_sym, event: session[:event].to_sym, event_type: session[:event_type].to_sym) } + json[:laps] = json[:laps].map { |lap| lap.transform_keys(&:to_sym).merge(sport_code: lap[:sport].to_sym, sub_sport_code: lap[:sub_sport].to_sym, event_code: lap[:event].to_sym, event_type_code: lap[:event_type].to_sym, lap_trigger_code: lap[:lap_trigger].to_sym) } + json[:sessions] = json[:sessions].map { |session| session.transform_keys(&:to_sym).merge(sport_code: session[:sport].to_sym, sub_sport_code: session[:sub_sport].to_sym, event_code: session[:event].to_sym, event_type_code: session[:event_type].to_sym) } # Write FIT file writer = RubyFit::Writer.new @@ -26,7 +26,7 @@ def test_rubyfit_integration workout_num: json[:wahoo_id][:workout_num], workout_type: json[:wahoo_id][:workout_type], timestamp: (json[:timestamp]).to_i, - total_timer_time: (json[:total_timer_time]).to_i, + tot_timer_time_sec: (json[:tot_timer_time_sec]).to_i, local_timestamp: (json[:local_timestamp]).to_i, duration: json[:duration].to_i || 0, sessions_count: (json[:sessions]&.size).to_i, @@ -39,13 +39,13 @@ def test_rubyfit_integration wahoo_custom_num_count: json[:wahoo_custom_nums]&.size || 0, wahoo_clm_count: json[:wahoo_clms]&.size || 0, name: json[:name] || 'unnamed', - total_distance: (json[:total_distance] || 0), - total_ascent: (json[:total_ascent] || 0), + tot_dist_m: (json[:total_distance] || 0), + tot_ascent_m: (json[:total_ascent] || 0), time_created: (json[:created_at] || Time.now).to_i, - start_x: (json[:first_lng] || 0), - start_y: (json[:first_lat] || 0), - end_x: (json[:last_lng] || 0), - end_y: (json[:last_lat] || 0), + start_lat_deg: (json[:first_lng] || 0), + start_lon_deg: (json[:first_lat] || 0), + end_lat_deg: (json[:last_lng] || 0), + end_lon_deg: (json[:last_lat] || 0), manufacturer: json[:manufacturer], product: 1, product_name: json[:product_name] || 'unnamed', @@ -116,54 +116,54 @@ def test_rubyfit_integration parser.parse(raw) do |data| json_output = JSON.parse(data.to_json) json_input = JSON.parse(json_input) - assert_equal json_output['file_id']['manufacturer'], json_input['manufacturer'] - assert_equal json_output['activity']['timestamp'], json_input['timestamp'] - assert_equal json_output['activity']['total_timer_time'], json_input['total_timer_time'] - assert_equal json_output['activity']['total_timer_time'], json_input['total_timer_time'] - assert_equal json_output['activity']['local_timestamp'], json_input['local_timestamp'] - assert_equal json_output['workout']['sport'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] - assert_equal json_output['workout']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] + assert_equal json_output['file_id']['manufacturer_code'], json_input['manufacturer'] + assert_equal json_output['activity']['timestamp'], Time.at(json_input['timestamp']).to_s + assert_equal json_output['activity']['tot_timer_time_sec'], json_input['tot_timer_time_sec'] + assert_equal json_output['activity']['tot_timer_time_sec'], json_input['tot_timer_time_sec'] + assert_equal json_output['activity']['local_timestamp'], Time.at(json_input['local_timestamp']).to_s + assert_equal json_output['workout']['sport_code'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] + assert_equal json_output['workout']['sub_sport_code'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] assert_equal json_output['workout']['wkt_name'], json_input['name'] - assert_equal json_output['sport']['sport'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] - assert_equal json_output['sport']['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] + assert_equal json_output['sport']['sport_code'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] + assert_equal json_output['sport']['sub_sport_code'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] assert_equal json_output['wahoo_id']['app_token'], json_input['wahoo_id']['app_token'] assert_equal json_output['wahoo_id']['workout_num'], json_input['wahoo_id']['workout_num'] assert_equal json_output['wahoo_id']['workout_type'], json_input['wahoo_id']['workout_type'] assert_equal json_output['records'].size, json_input['records'].size - assert_equal json_output['records'].last['timestamp'], json_input['records'].last['timestamp'] - assert_equal json_output['records'].last['y'].round(2), json_input['records'].last['y'].round(2) - assert_equal json_output['records'].last['x'].round(2), json_input['records'].last['x'].round(2) - assert_equal json_output['records'].last['distance'].round(2), json_input['records'].last['distance'].round(2) - assert_equal json_output['records'].last['elevation'].round(2), json_input['records'].last['elevation'].round(2) - assert_equal json_output['records'].last['heart_rate'], json_input['records'].last['heart_rate'] - assert_equal json_output['records'].last['cadence'], json_input['records'].last['cadence'] - assert_equal json_output['records'].last['power'], json_input['records'].last['power'] - assert_equal json_output['records'].last['enhanced_speed'], json_input['records'].last['enhanced_speed'] + assert_equal json_output['records'].last['timestamp'], Time.at(json_input['records'].last['timestamp']).to_s + assert_equal json_output['records'].last['lat_deg'].round(2), json_input['records'].last['lat_deg'].round(2) + assert_equal json_output['records'].last['lon_deg'].round(2), json_input['records'].last['lon_deg'].round(2) + assert_equal json_output['records'].last['dist_m'].round(2), json_input['records'].last['dist_m'].round(2) + assert_equal json_output['records'].last['alt_m'].round(2), json_input['records'].last['alt_m'].round(2) + assert_equal json_output['records'].last['hr_bpm'], json_input['records'].last['hr_bpm'] + assert_equal json_output['records'].last['cad_rpm'], json_input['records'].last['cad_rpm'] + assert_equal json_output['records'].last['pwr_watts'], json_input['records'].last['pwr_watts'] + assert_equal json_output['records'].last['enhanced_spd_mps'], json_input['records'].last['enhanced_spd_mps'] assert_equal json_output['records'].last['battery_soc'], json_input['records'].last['battery_soc'] - assert_equal json_output['records'].last['grade'], json_input['records'].last['grade'] + assert_equal json_output['records'].last['grade_perc'], json_input['records'].last['grade_perc'] assert_equal json_output['laps'].size, json_input['laps'].size - assert_equal json_output['laps'].last['start_time'], json_input['laps'].last['start_time'] - assert_equal json_output['laps'].last['total_timer_time'], json_input['laps'].last['total_timer_time'] - assert_equal json_output['laps'].last['total_distance'], json_input['laps'].last['total_distance'] - assert_equal json_output['laps'].last['total_ascent'], json_input['laps'].last['total_ascent'] - assert_equal json_output['laps'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] - assert_equal json_output['laps'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] - assert_equal json_output['laps'].last['total_calories'], json_input['laps'].last['total_calories'] - assert_equal json_output['laps'].last['event'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] - assert_equal json_output['laps'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] - assert_equal json_output['laps'].last['lap_trigger'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] + assert_equal json_output['laps'].last['start_time'], Time.at(json_input['laps'].last['start_time']).to_s + assert_equal json_output['laps'].last['tot_timer_time_sec'], json_input['laps'].last['tot_timer_time_sec'] + assert_equal json_output['laps'].last['tot_dist_m'], json_input['laps'].last['tot_dist_m'] + assert_equal json_output['laps'].last['tot_ascent_m'], json_input['laps'].last['tot_ascent_m'] + assert_equal json_output['laps'].last['sport_code'], RubyFit::MessageConstants::SPORT[json_input['laps'].last['sport'].to_sym] + assert_equal json_output['laps'].last['sub_sport_code'], RubyFit::MessageConstants::SUBSPORT[json_input['laps'].last['sub_sport'].to_sym] + assert_equal json_output['laps'].last['tot_cal'], json_input['laps'].last['tot_cal'] + assert_equal json_output['laps'].last['event_code'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] + assert_equal json_output['laps'].last['event_type_code'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] + assert_equal json_output['laps'].last['lap_trigger_code'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] assert_equal json_output['sessions'].size, json_input['sessions'].size - assert_equal json_output['sessions'].last['start_time'], json_input['sessions'].last['start_time'] - assert_equal json_output['sessions'].last['total_timer_time'], json_input['sessions'].last['total_timer_time'] - assert_equal json_output['sessions'].last['total_distance'], json_input['sessions'].last['total_distance'] - assert_equal json_output['sessions'].last['total_ascent'], json_input['sessions'].last['total_ascent'] - assert_equal json_output['sessions'].last['total_calories'], json_input['sessions'].last['total_calories'] - assert_equal json_output['sessions'].last['sport'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] - assert_equal json_output['sessions'].last['sub_sport'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] - assert_equal json_output['sessions'].last['event'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] - assert_equal json_output['sessions'].last['event_type'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] + assert_equal json_output['sessions'].last['start_time'], Time.at(json_input['sessions'].last['start_time']).to_s + assert_equal json_output['sessions'].last['tot_timer_time_sec'], json_input['sessions'].last['tot_timer_time_sec'] + assert_equal json_output['sessions'].last['tot_dist_m'], json_input['sessions'].last['tot_dist_m'] + assert_equal json_output['sessions'].last['tot_ascent_m'], json_input['sessions'].last['tot_ascent_m'] + assert_equal json_output['sessions'].last['tot_cal'], json_input['sessions'].last['tot_cal'] + assert_equal json_output['sessions'].last['sport_code'], RubyFit::MessageConstants::SPORT[json_input['sessions'].last['sport'].to_sym] + assert_equal json_output['sessions'].last['sub_sport_code'], RubyFit::MessageConstants::SUBSPORT[json_input['sessions'].last['sub_sport'].to_sym] + assert_equal json_output['sessions'].last['event_code'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] + assert_equal json_output['sessions'].last['event_type_code'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] assert_equal json_output['device_infos'].size, json_input['device_infos'].size - assert_equal json_output['device_infos'].last['timestamp'], json_input['device_infos'].last['timestamp'] + assert_equal json_output['device_infos'].last['timestamp'], Time.at(json_input['device_infos'].last['timestamp']).to_s assert_equal json_output['device_infos'].last['serial_number'], json_input['device_infos'].last['serial_number'] assert_equal json_output['device_infos'].last['manufacturer'], json_input['device_infos'].last['manufacturer'] assert_equal json_output['device_infos'].last['product'], json_input['device_infos'].last['product'] diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index daaa262..d3fae3f 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -27,59 +27,59 @@ def test_little_endian_file_decoding json_output = JSON.parse(data.to_json) - assert_equal(32, json_output['file_id']['manufacturer']) - assert_equal(4, json_output['file_id']['type']) + assert_equal(32, json_output['file_id']['manufacturer_code']) + assert_equal(4, json_output['file_id']['type_code']) assert_equal(0, json_output['file_id']['product']) - assert_equal(1735914657, json_output['file_id']['time_created']) + assert_equal("2025-01-03 09:30:57 -0500", json_output['file_id']['time_created']) - assert_equal(1735914905, json_output['activity']['timestamp']) - assert_equal(247.886, json_output['activity']['total_timer_time']) + assert_equal("2025-01-03 09:35:05 -0500", json_output['activity']['timestamp']) + assert_equal(247.886, json_output['activity']['tot_timer_time_sec']) assert_equal(1, json_output['activity']['num_sessions']) - assert_equal(26, json_output['activity']['event']) - assert_equal(1, json_output['activity']['event_type']) + assert_equal(26, json_output['activity']['event_code']) + assert_equal(1, json_output['activity']['event_type_code']) - assert_equal(2, json_output['workout'][0]['sport']) - assert_equal(6, json_output['workout'][0]['sub_sport']) + assert_equal(2, json_output['workout'][0]['sport_code']) + assert_equal(6, json_output['workout'][0]['sub_sport_code']) assert_equal('Indoor Cycling', json_output['workout'][0]['wkt_name']) assert_equal('WAHOOAPPIOS62BB', json_output['wahoo_id'][0]['app_token']) assert_equal(3, json_output['wahoo_id'][0]['workout_num']) assert_equal(12, json_output['wahoo_id'][0]['workout_type']) - assert_equal(2, json_output['sessions'][0]['sport']) - assert_equal(6, json_output['sessions'][0]['sub_sport']) - assert_equal(247.886, json_output['sessions'][0]['total_timer_time']) - assert_equal(17, json_output['sessions'][0]['total_calories']) - assert_equal(137, json_output['sessions'][0]['max_heart_rate']) - assert_equal(108, json_output['sessions'][0]['avg_heart_rate']) - assert_equal(124.2, json_output['sessions'][0]['total_distance']) - assert_equal(2.1, json_output['sessions'][0]['training_stress_score']) - assert_equal(0.592, json_output['sessions'][0]['intensity_factor']) - assert_equal(124, json_output['sessions'][0]['threshold_power']) - - assert_equal([202.88, 46.528, 0, 0, 0], json_output['laps'][0]['time_in_hr_zone']) - assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0], json_output['laps'][0]['time_in_power_zone']) - - assert_equal(2, json_output['laps'][0]['sport']) - assert_equal(6, json_output['laps'][0]['sub_sport']) - assert_equal(247.886, json_output['laps'][0]['total_timer_time']) - assert_equal(17, json_output['laps'][0]['total_calories']) - assert_equal(137, json_output['laps'][0]['max_heart_rate']) - assert_equal(108, json_output['laps'][0]['avg_heart_rate']) - assert_equal(124.2, json_output['laps'][0]['total_distance']) - assert_equal(17304, json_output['laps'][0]['total_work']) - assert_equal([202.88, 46.528, 0, 0, 0], json_output['laps'][0]['time_in_hr_zone']) - assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0], json_output['laps'][0]['time_in_power_zone']) - - assert_equal(98, json_output['records'][1]['heart_rate']) - assert_equal(0, json_output['records'][1]['power']) - assert_equal(0, json_output['records'][1]['calories']) + assert_equal(2, json_output['sessions'][0]['sport_code']) + assert_equal(6, json_output['sessions'][0]['sub_sport_code']) + assert_equal(247.886, json_output['sessions'][0]['tot_timer_time_sec']) + assert_equal(17, json_output['sessions'][0]['tot_cal']) + assert_equal(137, json_output['sessions'][0]['max_hr_bpm']) + assert_equal(108, json_output['sessions'][0]['avg_hr_bpm']) + assert_equal(124.2, json_output['sessions'][0]['tot_dist_m']) + assert_equal(2.1, json_output['sessions'][0]['tss']) + assert_equal(0.592, json_output['sessions'][0]['if']) + assert_equal(124, json_output['sessions'][0]['ftp']) + + assert_equal([202.88, 46.528, 0, 0, 0], json_output['laps'][0]['time_in_hr_zone_sec']) + assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0], json_output['laps'][0]['time_in_pwr_zone_sec']) + + assert_equal(2, json_output['laps'][0]['sport_code']) + assert_equal(6, json_output['laps'][0]['sub_sport_code']) + assert_equal(247.886, json_output['laps'][0]['tot_timer_time_sec']) + assert_equal(17, json_output['laps'][0]['tot_cal']) + assert_equal(137, json_output['laps'][0]['max_hr_bpm']) + assert_equal(108, json_output['laps'][0]['avg_hr_bpm']) + assert_equal(124.2, json_output['laps'][0]['tot_dist_m']) + assert_equal(17304, json_output['laps'][0]['tot_work_j']) + assert_equal([202.88, 46.528, 0, 0, 0], json_output['laps'][0]['time_in_hr_zone_sec']) + assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0], json_output['laps'][0]['time_in_pwr_zone_sec']) + + assert_equal(98, json_output['records'][1]['hr_bpm']) + assert_equal(0, json_output['records'][1]['pwr_watts']) + assert_equal(0, json_output['records'][1]['cal']) assert_equal(85, json_output['records'][0]['battery_soc']) assert_equal(85, json_output['records'][0]['battery_soc']) - assert_equal(110, json_output['records'][242]['power']) - assert_equal(124.2, json_output['records'][242]['distance']) - assert_equal(0.134, json_output['records'][242]['speed']) + assert_equal(110, json_output['records'][242]['pwr_watts']) + assert_equal(124.2, json_output['records'][242]['dist_m']) + assert_equal(0.134, json_output['records'][242]['spd_mps']) assert_equal(5, json_output['hr_zones'].size) @@ -97,12 +97,12 @@ def test_little_endian_file_decoding assert_equal(4, json_output['pwr_zones'][4]['message_index']) assert_equal(5, json_output['pwr_zones'][5]['message_index']) - assert_equal(68, json_output['pwr_zones'][0]['high_value']) - assert_equal(87, json_output['pwr_zones'][1]['high_value']) - assert_equal(113, json_output['pwr_zones'][2]['high_value']) - assert_equal(119, json_output['pwr_zones'][3]['high_value']) - assert_equal(128, json_output['pwr_zones'][4]['high_value']) - assert_equal(65534, json_output['pwr_zones'][5]['high_value']) + assert_equal(68, json_output['pwr_zones'][0]['high_pwr_watts']) + assert_equal(87, json_output['pwr_zones'][1]['high_pwr_watts']) + assert_equal(113, json_output['pwr_zones'][2]['high_pwr_watts']) + assert_equal(119, json_output['pwr_zones'][3]['high_pwr_watts']) + assert_equal(128, json_output['pwr_zones'][4]['high_pwr_watts']) + assert_equal(65534, json_output['pwr_zones'][5]['high_pwr_watts']) assert_equal(0, json_output['device_infos'][0]['device_index']) assert_equal(32, json_output['device_infos'][0]['manufacturer']) diff --git a/test/fixtures/example_activity_json.json b/test/fixtures/example_activity_json.json index be8a4b3..93cddb3 100644 --- a/test/fixtures/example_activity_json.json +++ b/test/fixtures/example_activity_json.json @@ -1,10 +1,10 @@ { "name": "Morning Run", "manufacturer": 2, - "total_distance": 5875.6, + "tot_dist_m": 5875.6, "total_moving_time": 2310, "total_elapsed_time": 2321, - "total_timer_time": 2309, + "tot_timer_time_sec": 2309, "total_ascent": 46.0, "type": "running", "sport": "generic", @@ -50,20 +50,20 @@ "id": 49308517261, "timestamp": 1111065857, "name": "Lap 1", - "total_elapsed_time": 645, + "tot_elapsed_time_sec": 645, "total_moving_time": 645, - "total_timer_time": 645, + "tot_timer_time_sec": 645, "start_time": 1111065857, "start_date_local": 1111065857, - "total_distance": 1609, - "total_calories": 275, + "tot_dist_m": 1609, + "tot_cal": 275, "avg_speed": 2.5, "max_speed": 3.4, "lap_index": 1, "split": 1, "start_index": 0, "end_index": 207, - "total_ascent": 23, + "tot_ascent_m": 23, "avg_cadence": 81, "device_watts": true, "avg_power": 156, @@ -79,20 +79,20 @@ "timestamp": 1111066857, "resource_state": 2, "name": "Lap 2", - "total_elapsed_time": 650, + "tot_elapsed_time_sec": 650, "total_moving_time": 650, - "total_timer_time": 650, + "tot_timer_time_sec": 650, "start_time": 1111066857, "start_time_local": 1111065857, - "total_distance": 1609.3, - "total_calories": 275, + "tot_dist_m": 1609.3, + "tot_cal": 275, "average_speed": 2.48, "max_speed": 3.2, "lap_index": 2, "split": 2, "start_index": 208, "end_index": 857, - "total_ascent": 7, + "tot_ascent_m": 7, "avg_cadence": 79, "device_watts": true, "avg_power": 146, @@ -140,11 +140,11 @@ { "timestamp": 1111065857, "start_time": 1111065857, - "total_distance": 5875, + "tot_dist_m": 5875, "total_moving_time": 2310, "total_elapsed_time": 2321, - "total_timer_time": 2309, - "total_ascent": 46, + "tot_timer_time_sec": 2309, + "tot_ascent_m": 46, "start_time_local": "2025-03-16T08:24:17Z", "timezone": "(GMT-06:00) America/Chicago", "utc_offset": -18000.0, @@ -154,11 +154,11 @@ "last_lng": -94.68175, "average_speed": 2.544, "max_speed": 3.5, - "avg_cadence": 81, - "avg_power": 154, - "max_power": 212, - "normalized_power": 154, - "total_calories": 275, + "avg_cad_rpm": 81, + "avg_pwr_watts": 154, + "max_pwr_watts": 212, + "norm_pwr_watts": 154, + "tot_cal": 275, "event": "session", "event_type": "stop", "sport": "generic", @@ -185,50 +185,50 @@ "records": [ { "timestamp": 1111065857, - "y": 38.998180, - "x": -94.681747, - "distance": 0, - "elevation": 309, - "heart_rate": 0, - "cadence": 0, - "power": 0, - "calories": 0, - "enhanced_speed": 0, + "lat_deg": 38.998180, + "lon_deg": -94.681747, + "dist_m": 0, + "alt_m": 309, + "hr_bpm": 0, + "cad_rpm": 0, + "pwr_watts": 0, + "cal": 0, + "enhanced_spd_mps": 0, "battery_soc": 0, - "grade": 0 + "grade_perc": 0 }, { "timestamp": 1111065857, - "y": 38.998180, - "x": -94.681747, - "distance": 20, - "elevation": 309, - "heart_rate": 120, - "cadence": 81, - "power": 185, - "calories": 100, - "enhanced_speed": 20, + "lat_deg": 38.998180, + "lon_deg": -94.681747, + "dist_m": 20, + "alt_m": 309, + "hr_bpm": 120, + "cad_rpm": 81, + "pwr_watts": 185, + "cal": 100, + "enhanced_spd_mps": 20, "battery_soc": 30, - "grade": 5 + "grade_perc": 5 } ], "hr_zones": [ { - "high_bpm": 118, + "high_hr_bpm": 118, "name": "Zone 0" }, { - "high_bpm": 147, + "high_hr_bpm": 147, "name": "Zone 1" } ], "power_zones": [ { - "high_value": 68, + "high_pwr_watts": 68, "name": "Zone 0" }, { - "high_value": 87, + "high_pwr_watts": 87, "name": "Zone 1" } ], @@ -242,8 +242,8 @@ "wahoo_custom_nums": [ { "value": 124, - "sub_type": 1, - "type": 0 + "sub_type_code": 1, + "type_code": 0 } ], diff --git a/test/fixtures/example_route_json.json b/test/fixtures/example_route_json.json index 54d9eef..64271c7 100644 --- a/test/fixtures/example_route_json.json +++ b/test/fixtures/example_route_json.json @@ -27,18 +27,18 @@ "created_at": "2025-03-05T14:42:55Z", "updated_at": "2025-03-05T14:42:55Z", "track_points": [ - {"x": -84.59215, "y": 34.03497, "elevation": 321.7, "distance": 0.0, "S": 0, "R": 6, "timestamp": 1111065857}, - {"x": -84.59196, "y": 34.03507, "elevation": 322.7, "distance": 20.7, "S": 1, "R": 4, "timestamp": 1111065857}, - {"x": -84.5925, "y": 34.03574, "elevation": 324.4, "distance": 110.4, "S": 1, "R": 4, "timestamp": 1111065857}, - {"x": -84.59303, "y": 34.03641, "elevation": 333.2, "distance": 199.6, "S": 1, "R": 4, "timestamp": 1111065857}, - {"x": -84.59357, "y": 34.03708, "elevation": 336.8, "distance": 289.3, "S": 1, "R": 4, "timestamp": 1111065857}, - {"x": -84.5941, "y": 34.03775, "elevation": 336.2, "distance": 378.5, "S": 1, "R": 4, "timestamp": 1111065857}, - {"x": -84.5943, "y": 34.03797, "elevation": 336.4, "distance": 409.1, "S": 1, "R": 4, "timestamp": 1111065857}, - {"x": -84.59447, "y": 34.03812, "elevation": 336.8, "distance": 432.0, "S": 1, "R": 4, "timestamp": 1111065857}, - {"x": -84.59465, "y": 34.03824, "elevation": 336.9, "distance": 453.3, "S": 1, "R": 4, "timestamp": 1111065857}, - {"x": -84.59485, "y": 34.03842, "elevation": 336.9, "distance": 480.6, "S": 1, "R": 4, "timestamp": 1111065857}, - {"x": -84.59514, "y": 34.03864, "elevation": 336.6, "distance": 516.8, "S": 0, "R": 6, "timestamp": 1111065857}, - {"x": -84.59529, "y": 34.03852, "elevation": 336.2, "distance": 536.1, "timestamp": 1111065857} + {"lon_deg": -84.59215, "lat_deg": 34.03497, "alt_m": 321.7, "dist_m": 0.0, "S": 0, "R": 6, "timestamp": 1111065857}, + {"lon_deg": -84.59196, "lat_deg": 34.03507, "alt_m": 322.7, "dist_m": 20.7, "S": 1, "R": 4, "timestamp": 1111065857}, + {"lon_deg": -84.5925, "lat_deg": 34.03574, "alt_m": 324.4, "dist_m": 110.4, "S": 1, "R": 4, "timestamp": 1111065857}, + {"lon_deg": -84.59303, "lat_deg": 34.03641, "alt_m": 333.2, "dist_m": 199.6, "S": 1, "R": 4, "timestamp": 1111065857}, + {"lon_deg": -84.59357, "lat_deg": 34.03708, "alt_m": 336.8, "dist_m": 289.3, "S": 1, "R": 4, "timestamp": 1111065857}, + {"lon_deg": -84.5941, "lat_deg": 34.03775, "alt_m": 336.2, "dist_m": 378.5, "S": 1, "R": 4, "timestamp": 1111065857}, + {"lon_deg": -84.5943, "lat_deg": 34.03797, "alt_m": 336.4, "dist_m": 409.1, "S": 1, "R": 4, "timestamp": 1111065857}, + {"lon_deg": -84.59447, "lat_deg": 34.03812, "alt_m": 336.8, "dist_m": 432.0, "S": 1, "R": 4, "timestamp": 1111065857}, + {"lon_deg": -84.59465, "lat_deg": 34.03824, "alt_m": 336.9, "dist_m": 453.3, "S": 1, "R": 4, "timestamp": 1111065857}, + {"lon_deg": -84.59485, "lat_deg": 34.03842, "alt_m": 336.9, "dist_m": 480.6, "S": 1, "R": 4, "timestamp": 1111065857}, + {"lon_deg": -84.59514, "lat_deg": 34.03864, "alt_m": 336.6, "dist_m": 516.8, "S": 0, "R": 6, "timestamp": 1111065857}, + {"lon_deg": -84.59529, "lat_deg": 34.03852, "alt_m": 336.2, "dist_m": 536.1, "timestamp": 1111065857} ], "course_points": [ {"x": -84.59196, "y": 34.03507, "d": 20.7, "i": 1, "t": "Left", "n": "Turn left onto McCollum Parkway Northwest", "timestamp": 1111065857, "type": "left"}, diff --git a/test/route_test.rb b/test/route_test.rb index 96c69cd..f39a04b 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -21,8 +21,8 @@ def test_integration course_point_count: (json['course_points']&.size || 0).to_i, track_point_count: (json['track_points']&.size || 0).to_i, name: json['name'] || 'unnamed', - total_distance: (json['distance'] || 0), - total_ascent: (json['ascent'] / 5.0 - 500 || 0), + tot_dist_m: (json['distance'] || 0), + tot_ascent_m: (json['ascent'] / 5.0 - 500 || 0), time_created: (json['created_at'] || Time.now).to_i, start_x: (json['first_lng'] || 0), start_y: (json['first_lat'] || 0), From 713fc422254cb295aec051a72570d48856de170c Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 4 Apr 2025 10:40:43 -0400 Subject: [PATCH 063/104] additional naming changes --- lib/rubyfit/message_writer.rb | 6 +++--- lib/rubyfit/type.rb | 4 ++-- test/activity_test.rb | 5 +++-- test/fit_parser_test.rb | 6 +++--- test/fixtures/example_activity_json.json | 12 ++++++------ 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 7f99011..5af15f4 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -108,7 +108,7 @@ class RubyFit::MessageWriter left_pedal_smooth_perc: { id: 45, type: RubyFit::Type.uint8_scale2 }, right_pedal_smooth_perc: { id: 46, type: RubyFit::Type.uint8_scale2 }, enhanced_spd_mps: { id: 73, type: RubyFit::Type.enhanced_speed}, - battery_soc: { id: 81, type: RubyFit::Type.uint8_scale2 } + battery_soc_perc: { id: 81, type: RubyFit::Type.uint8_scale2 } } }, @@ -173,14 +173,14 @@ class RubyFit::MessageWriter timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, device_type: { id: 1, type: RubyFit::Type.uint8 }, serial_number: { id: 3, type: RubyFit::Type.uint32z }, - manufacturer: { id: 2, type: RubyFit::Type.uint16 }, + manufacturer_code: { id: 2, type: RubyFit::Type.uint16 }, product: { id: 4, type: RubyFit::Type.uint16 }, software_version: { id: 5, type: RubyFit::Type.uint16 }, hardware_version: { id: 6, type: RubyFit::Type.uint8 }, battery_status: { id: 11, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::BATTERY_STATUS }, ant_device_number: { id: 21, type: RubyFit::Type.uint16 }, device_index: { id: 0, type: RubyFit::Type.uint8 }, - source_type: { id: 25,type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SOURCE_TYPE }, + source_type_code: { id: 25,type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SOURCE_TYPE }, product_name: { id: 27, type: RubyFit::Type.string(20) } } }, diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index d456c7b..0ce0d39 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -152,11 +152,11 @@ def centimeters def altitude uint16({ rb2fit: ->(val, type) { - result = ((val + 500) * 5.0).truncate + result = (val).truncate result }, fit2rb: ->(val, type) { - result = val.nil? ? nil : val / 5.0 - 500 + result = val.nil? ? nil : val result } }) diff --git a/test/activity_test.rb b/test/activity_test.rb index 6fa3d98..b446aab 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -139,7 +139,7 @@ def test_rubyfit_integration assert_equal json_output['records'].last['cad_rpm'], json_input['records'].last['cad_rpm'] assert_equal json_output['records'].last['pwr_watts'], json_input['records'].last['pwr_watts'] assert_equal json_output['records'].last['enhanced_spd_mps'], json_input['records'].last['enhanced_spd_mps'] - assert_equal json_output['records'].last['battery_soc'], json_input['records'].last['battery_soc'] + assert_equal json_output['records'].last['battery_soc_perc'], json_input['records'].last['battery_soc_perc'] assert_equal json_output['records'].last['grade_perc'], json_input['records'].last['grade_perc'] assert_equal json_output['laps'].size, json_input['laps'].size assert_equal json_output['laps'].last['start_time'], Time.at(json_input['laps'].last['start_time']).to_s @@ -152,6 +152,7 @@ def test_rubyfit_integration assert_equal json_output['laps'].last['event_code'], RubyFit::MessageConstants::EVENT[json_input['laps'].last['event'].to_sym] assert_equal json_output['laps'].last['event_type_code'], RubyFit::MessageConstants::EVENT_TYPE[json_input['laps'].last['event_type'].to_sym] assert_equal json_output['laps'].last['lap_trigger_code'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] + assert_equal json_output['laps'].last['time_in_hr_zone_sec'], json_input['laps'].last['time_in_hr_zone_sec'] assert_equal json_output['sessions'].size, json_input['sessions'].size assert_equal json_output['sessions'].last['start_time'], Time.at(json_input['sessions'].last['start_time']).to_s assert_equal json_output['sessions'].last['tot_timer_time_sec'], json_input['sessions'].last['tot_timer_time_sec'] @@ -165,7 +166,7 @@ def test_rubyfit_integration assert_equal json_output['device_infos'].size, json_input['device_infos'].size assert_equal json_output['device_infos'].last['timestamp'], Time.at(json_input['device_infos'].last['timestamp']).to_s assert_equal json_output['device_infos'].last['serial_number'], json_input['device_infos'].last['serial_number'] - assert_equal json_output['device_infos'].last['manufacturer'], json_input['device_infos'].last['manufacturer'] + assert_equal json_output['device_infos'].last['manufacturer_code'], json_input['device_infos'].last['manufacturer_code'] assert_equal json_output['device_infos'].last['product'], json_input['device_infos'].last['product'] assert_equal json_output['device_infos'].last['software_version'], json_input['device_infos'].last['software_version'] assert_equal json_output['device_infos'].last['device_index'], json_input['device_infos'].last['device_index'] diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index d3fae3f..a827a4d 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -74,9 +74,9 @@ def test_little_endian_file_decoding assert_equal(98, json_output['records'][1]['hr_bpm']) assert_equal(0, json_output['records'][1]['pwr_watts']) assert_equal(0, json_output['records'][1]['cal']) - assert_equal(85, json_output['records'][0]['battery_soc']) + assert_equal(85, json_output['records'][0]['battery_soc_perc']) - assert_equal(85, json_output['records'][0]['battery_soc']) + assert_equal(85, json_output['records'][0]['battery_soc_perc']) assert_equal(110, json_output['records'][242]['pwr_watts']) assert_equal(124.2, json_output['records'][242]['dist_m']) assert_equal(0.134, json_output['records'][242]['spd_mps']) @@ -105,7 +105,7 @@ def test_little_endian_file_decoding assert_equal(65534, json_output['pwr_zones'][5]['high_pwr_watts']) assert_equal(0, json_output['device_infos'][0]['device_index']) - assert_equal(32, json_output['device_infos'][0]['manufacturer']) + assert_equal(32, json_output['device_infos'][0]['manufacturer_code']) assert_equal(0, json_output['device_infos'][0]['product']) assert_equal("WAHOO APP", json_output['device_infos'][0]['product_name']) diff --git a/test/fixtures/example_activity_json.json b/test/fixtures/example_activity_json.json index 93cddb3..2a47db4 100644 --- a/test/fixtures/example_activity_json.json +++ b/test/fixtures/example_activity_json.json @@ -102,14 +102,14 @@ "event": "lap", "event_type": "stop", "lap_trigger": "manual", - "time_in_hr_zone": [ + "time_in_hr_zone_sec": [ 202.88, 46.528, 0.0, 0.0, 0.0 ], - "time_in_power_zone": [ + "time_in_pwr_zone_sec": [ 205.307, 2.88, 35.999, @@ -163,14 +163,14 @@ "event_type": "stop", "sport": "generic", "sub_sport": "generic", - "time_in_hr_zone": [ + "time_in_hr_zone_sec": [ 202.88, 46.528, 0.0, 0.0, 0.0 ], - "time_in_power_zone": [ + "time_in_pwr_zone_sec": [ 205.307, 2.88, 35.999, @@ -194,7 +194,7 @@ "pwr_watts": 0, "cal": 0, "enhanced_spd_mps": 0, - "battery_soc": 0, + "battery_soc_perc": 0, "grade_perc": 0 }, { @@ -208,7 +208,7 @@ "pwr_watts": 185, "cal": 100, "enhanced_spd_mps": 20, - "battery_soc": 30, + "battery_soc_perc": 30, "grade_perc": 5 } ], From 45bcb5dfd5755c947ec9d5cf4dd1168957269e38 Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 4 Apr 2025 11:46:37 -0400 Subject: [PATCH 064/104] change timestamp format --- lib/rubyfit/type.rb | 4 ++-- test/activity_test.rb | 16 ++++++++-------- test/fit_parser_test.rb | 4 ++-- test/route_test.rb | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 0ce0d39..671b4c1 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -131,14 +131,14 @@ def uint64z(opts = {}) def timestamp uint32({ rb2fit: ->(val, type) { unix2fit_timestamp(val) }, - fit2rb: ->(val, type) { val.nil? ? nil : Time.at(fit2unix_timestamp(val)) } + fit2rb: ->(val, type) { val.nil? ? nil : Time.at(fit2unix_timestamp(val)).utc } }) end def semicircles sint32({ rb2fit: ->(val, type) { deg2semicircles(val) }, - fit2rb: ->(val, type) { semicircles2deg(val) } + fit2rb: ->(val, type) { val.nil? ? nil : semicircles2deg(val).round(5) } }) end diff --git a/test/activity_test.rb b/test/activity_test.rb index b446aab..6afd200 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -117,10 +117,10 @@ def test_rubyfit_integration json_output = JSON.parse(data.to_json) json_input = JSON.parse(json_input) assert_equal json_output['file_id']['manufacturer_code'], json_input['manufacturer'] - assert_equal json_output['activity']['timestamp'], Time.at(json_input['timestamp']).to_s - assert_equal json_output['activity']['tot_timer_time_sec'], json_input['tot_timer_time_sec'] - assert_equal json_output['activity']['tot_timer_time_sec'], json_input['tot_timer_time_sec'] - assert_equal json_output['activity']['local_timestamp'], Time.at(json_input['local_timestamp']).to_s + # assert_equal json_output['activity']['timestamp'], Time.at(json_input['timestamp']).to_s + # assert_equal json_output['activity']['tot_timer_time_sec'], json_input['tot_timer_time_sec'] + # assert_equal json_output['activity']['tot_timer_time_sec'], json_input['tot_timer_time_sec'] + # assert_equal json_output['activity']['local_timestamp'], Time.at(json_input['local_timestamp']).to_s assert_equal json_output['workout']['sport_code'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] assert_equal json_output['workout']['sub_sport_code'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] assert_equal json_output['workout']['wkt_name'], json_input['name'] @@ -130,7 +130,7 @@ def test_rubyfit_integration assert_equal json_output['wahoo_id']['workout_num'], json_input['wahoo_id']['workout_num'] assert_equal json_output['wahoo_id']['workout_type'], json_input['wahoo_id']['workout_type'] assert_equal json_output['records'].size, json_input['records'].size - assert_equal json_output['records'].last['timestamp'], Time.at(json_input['records'].last['timestamp']).to_s + # assert_equal json_output['records'].last['timestamp'], Time.at(json_input['records'].last['timestamp']).to_s assert_equal json_output['records'].last['lat_deg'].round(2), json_input['records'].last['lat_deg'].round(2) assert_equal json_output['records'].last['lon_deg'].round(2), json_input['records'].last['lon_deg'].round(2) assert_equal json_output['records'].last['dist_m'].round(2), json_input['records'].last['dist_m'].round(2) @@ -142,7 +142,7 @@ def test_rubyfit_integration assert_equal json_output['records'].last['battery_soc_perc'], json_input['records'].last['battery_soc_perc'] assert_equal json_output['records'].last['grade_perc'], json_input['records'].last['grade_perc'] assert_equal json_output['laps'].size, json_input['laps'].size - assert_equal json_output['laps'].last['start_time'], Time.at(json_input['laps'].last['start_time']).to_s + # assert_equal json_output['laps'].last['start_time'], Time.at(json_input['laps'].last['start_time']).to_s assert_equal json_output['laps'].last['tot_timer_time_sec'], json_input['laps'].last['tot_timer_time_sec'] assert_equal json_output['laps'].last['tot_dist_m'], json_input['laps'].last['tot_dist_m'] assert_equal json_output['laps'].last['tot_ascent_m'], json_input['laps'].last['tot_ascent_m'] @@ -154,7 +154,7 @@ def test_rubyfit_integration assert_equal json_output['laps'].last['lap_trigger_code'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] assert_equal json_output['laps'].last['time_in_hr_zone_sec'], json_input['laps'].last['time_in_hr_zone_sec'] assert_equal json_output['sessions'].size, json_input['sessions'].size - assert_equal json_output['sessions'].last['start_time'], Time.at(json_input['sessions'].last['start_time']).to_s + # assert_equal json_output['sessions'].last['start_time'], Time.at(json_input['sessions'].last['start_time']).to_s assert_equal json_output['sessions'].last['tot_timer_time_sec'], json_input['sessions'].last['tot_timer_time_sec'] assert_equal json_output['sessions'].last['tot_dist_m'], json_input['sessions'].last['tot_dist_m'] assert_equal json_output['sessions'].last['tot_ascent_m'], json_input['sessions'].last['tot_ascent_m'] @@ -164,7 +164,7 @@ def test_rubyfit_integration assert_equal json_output['sessions'].last['event_code'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] assert_equal json_output['sessions'].last['event_type_code'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] assert_equal json_output['device_infos'].size, json_input['device_infos'].size - assert_equal json_output['device_infos'].last['timestamp'], Time.at(json_input['device_infos'].last['timestamp']).to_s + # assert_equal json_output['device_infos'].last['timestamp'], Time.at(json_input['device_infos'].last['timestamp']).to_s assert_equal json_output['device_infos'].last['serial_number'], json_input['device_infos'].last['serial_number'] assert_equal json_output['device_infos'].last['manufacturer_code'], json_input['device_infos'].last['manufacturer_code'] assert_equal json_output['device_infos'].last['product'], json_input['device_infos'].last['product'] diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index a827a4d..0989c6f 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -30,9 +30,9 @@ def test_little_endian_file_decoding assert_equal(32, json_output['file_id']['manufacturer_code']) assert_equal(4, json_output['file_id']['type_code']) assert_equal(0, json_output['file_id']['product']) - assert_equal("2025-01-03 09:30:57 -0500", json_output['file_id']['time_created']) + # assert_equal("2025-01-03 09:30:57 -0500", json_output['file_id']['time_created']) - assert_equal("2025-01-03 09:35:05 -0500", json_output['activity']['timestamp']) + # assert_equal("2025-01-03 09:35:05 -0500", json_output['activity']['timestamp']) assert_equal(247.886, json_output['activity']['tot_timer_time_sec']) assert_equal(1, json_output['activity']['num_sessions']) assert_equal(26, json_output['activity']['event_code']) diff --git a/test/route_test.rb b/test/route_test.rb index f39a04b..9ab954d 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -22,7 +22,7 @@ def test_integration track_point_count: (json['track_points']&.size || 0).to_i, name: json['name'] || 'unnamed', tot_dist_m: (json['distance'] || 0), - tot_ascent_m: (json['ascent'] / 5.0 - 500 || 0), + tot_ascent_m: (json['ascent'] || 0), time_created: (json['created_at'] || Time.now).to_i, start_x: (json['first_lng'] || 0), start_y: (json['first_lat'] || 0), From 8a7744ddc0c8c7dbbee0ee295a899d8cc52f99bd Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 4 Apr 2025 12:17:36 -0400 Subject: [PATCH 065/104] change back altitude calculation --- lib/rubyfit/type.rb | 6 +++--- test/activity_test.rb | 18 +++++++++--------- test/fit_parser_test.rb | 4 ++-- test/route_test.rb | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 671b4c1..5872ce4 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -138,7 +138,7 @@ def timestamp def semicircles sint32({ rb2fit: ->(val, type) { deg2semicircles(val) }, - fit2rb: ->(val, type) { val.nil? ? nil : semicircles2deg(val).round(5) } + fit2rb: ->(val, type) { val.nil? ? nil : semicircles2deg(val).round(6) } }) end @@ -152,11 +152,11 @@ def centimeters def altitude uint16({ rb2fit: ->(val, type) { - result = (val).truncate + result = ((val + 500) * 5.0).truncate result }, fit2rb: ->(val, type) { - result = val.nil? ? nil : val + result = val.nil? ? nil : val / 5.0 - 500 result } }) diff --git a/test/activity_test.rb b/test/activity_test.rb index 6afd200..137c3d9 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -40,7 +40,7 @@ def test_rubyfit_integration wahoo_clm_count: json[:wahoo_clms]&.size || 0, name: json[:name] || 'unnamed', tot_dist_m: (json[:total_distance] || 0), - tot_ascent_m: (json[:total_ascent] || 0), + tot_ascent_m: (json[:total_ascent] / 5.0 - 500 || 0), time_created: (json[:created_at] || Time.now).to_i, start_lat_deg: (json[:first_lng] || 0), start_lon_deg: (json[:first_lat] || 0), @@ -117,10 +117,10 @@ def test_rubyfit_integration json_output = JSON.parse(data.to_json) json_input = JSON.parse(json_input) assert_equal json_output['file_id']['manufacturer_code'], json_input['manufacturer'] - # assert_equal json_output['activity']['timestamp'], Time.at(json_input['timestamp']).to_s - # assert_equal json_output['activity']['tot_timer_time_sec'], json_input['tot_timer_time_sec'] - # assert_equal json_output['activity']['tot_timer_time_sec'], json_input['tot_timer_time_sec'] - # assert_equal json_output['activity']['local_timestamp'], Time.at(json_input['local_timestamp']).to_s + assert_equal json_output['activity']['timestamp'], Time.at(json_input['timestamp']).utc.to_s + assert_equal json_output['activity']['tot_timer_time_sec'], json_input['tot_timer_time_sec'] + assert_equal json_output['activity']['tot_timer_time_sec'], json_input['tot_timer_time_sec'] + assert_equal json_output['activity']['local_timestamp'], Time.at(json_input['local_timestamp']).utc.to_s assert_equal json_output['workout']['sport_code'], RubyFit::MessageConstants::SPORT[json_input['sport'].to_sym] assert_equal json_output['workout']['sub_sport_code'], RubyFit::MessageConstants::SUBSPORT[json_input['sub_sport'].to_sym] assert_equal json_output['workout']['wkt_name'], json_input['name'] @@ -130,7 +130,7 @@ def test_rubyfit_integration assert_equal json_output['wahoo_id']['workout_num'], json_input['wahoo_id']['workout_num'] assert_equal json_output['wahoo_id']['workout_type'], json_input['wahoo_id']['workout_type'] assert_equal json_output['records'].size, json_input['records'].size - # assert_equal json_output['records'].last['timestamp'], Time.at(json_input['records'].last['timestamp']).to_s + assert_equal json_output['records'].last['timestamp'], Time.at(json_input['records'].last['timestamp']).utc.to_s assert_equal json_output['records'].last['lat_deg'].round(2), json_input['records'].last['lat_deg'].round(2) assert_equal json_output['records'].last['lon_deg'].round(2), json_input['records'].last['lon_deg'].round(2) assert_equal json_output['records'].last['dist_m'].round(2), json_input['records'].last['dist_m'].round(2) @@ -142,7 +142,7 @@ def test_rubyfit_integration assert_equal json_output['records'].last['battery_soc_perc'], json_input['records'].last['battery_soc_perc'] assert_equal json_output['records'].last['grade_perc'], json_input['records'].last['grade_perc'] assert_equal json_output['laps'].size, json_input['laps'].size - # assert_equal json_output['laps'].last['start_time'], Time.at(json_input['laps'].last['start_time']).to_s + assert_equal json_output['laps'].last['start_time'], Time.at(json_input['laps'].last['start_time']).utc.to_s assert_equal json_output['laps'].last['tot_timer_time_sec'], json_input['laps'].last['tot_timer_time_sec'] assert_equal json_output['laps'].last['tot_dist_m'], json_input['laps'].last['tot_dist_m'] assert_equal json_output['laps'].last['tot_ascent_m'], json_input['laps'].last['tot_ascent_m'] @@ -154,7 +154,7 @@ def test_rubyfit_integration assert_equal json_output['laps'].last['lap_trigger_code'], RubyFit::MessageConstants::LAP_TRIGGER[json_input['laps'].last['lap_trigger'].to_sym] assert_equal json_output['laps'].last['time_in_hr_zone_sec'], json_input['laps'].last['time_in_hr_zone_sec'] assert_equal json_output['sessions'].size, json_input['sessions'].size - # assert_equal json_output['sessions'].last['start_time'], Time.at(json_input['sessions'].last['start_time']).to_s + assert_equal json_output['sessions'].last['start_time'], Time.at(json_input['sessions'].last['start_time']).utc.to_s assert_equal json_output['sessions'].last['tot_timer_time_sec'], json_input['sessions'].last['tot_timer_time_sec'] assert_equal json_output['sessions'].last['tot_dist_m'], json_input['sessions'].last['tot_dist_m'] assert_equal json_output['sessions'].last['tot_ascent_m'], json_input['sessions'].last['tot_ascent_m'] @@ -164,7 +164,7 @@ def test_rubyfit_integration assert_equal json_output['sessions'].last['event_code'], RubyFit::MessageConstants::EVENT[json_input['sessions'].last['event'].to_sym] assert_equal json_output['sessions'].last['event_type_code'], RubyFit::MessageConstants::EVENT_TYPE[json_input['sessions'].last['event_type'].to_sym] assert_equal json_output['device_infos'].size, json_input['device_infos'].size - # assert_equal json_output['device_infos'].last['timestamp'], Time.at(json_input['device_infos'].last['timestamp']).to_s + assert_equal json_output['device_infos'].last['timestamp'], Time.at(json_input['device_infos'].last['timestamp']).utc.to_s assert_equal json_output['device_infos'].last['serial_number'], json_input['device_infos'].last['serial_number'] assert_equal json_output['device_infos'].last['manufacturer_code'], json_input['device_infos'].last['manufacturer_code'] assert_equal json_output['device_infos'].last['product'], json_input['device_infos'].last['product'] diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 0989c6f..585a4c0 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -30,9 +30,9 @@ def test_little_endian_file_decoding assert_equal(32, json_output['file_id']['manufacturer_code']) assert_equal(4, json_output['file_id']['type_code']) assert_equal(0, json_output['file_id']['product']) - # assert_equal("2025-01-03 09:30:57 -0500", json_output['file_id']['time_created']) + assert_equal("2025-01-03 14:30:57 UTC", json_output['file_id']['time_created']) - # assert_equal("2025-01-03 09:35:05 -0500", json_output['activity']['timestamp']) + assert_equal("2025-01-03 14:35:05 UTC", json_output['activity']['timestamp']) assert_equal(247.886, json_output['activity']['tot_timer_time_sec']) assert_equal(1, json_output['activity']['num_sessions']) assert_equal(26, json_output['activity']['event_code']) diff --git a/test/route_test.rb b/test/route_test.rb index 9ab954d..f39a04b 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -22,7 +22,7 @@ def test_integration track_point_count: (json['track_points']&.size || 0).to_i, name: json['name'] || 'unnamed', tot_dist_m: (json['distance'] || 0), - tot_ascent_m: (json['ascent'] || 0), + tot_ascent_m: (json['ascent'] / 5.0 - 500 || 0), time_created: (json['created_at'] || Time.now).to_i, start_x: (json['first_lng'] || 0), start_y: (json['first_lat'] || 0), From e0aa8d1aae9b7237830a0185662d49dfa8592545 Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 4 Apr 2025 12:24:17 -0400 Subject: [PATCH 066/104] change ascent type --- lib/rubyfit/message_writer.rb | 8 ++++---- lib/rubyfit/type.rb | 2 +- test/activity_test.rb | 2 +- test/route_test.rb | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 5af15f4..8a1c9f5 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -50,8 +50,8 @@ class RubyFit::MessageWriter max_cad_rpm: { id: 18, type: RubyFit::Type.uint8 }, avg_pwr_watts: { id: 19, type: RubyFit::Type.uint16 }, max_pwr_watts: { id: 20, type: RubyFit::Type.uint16 }, - tot_ascent_m: { id: 21, type: RubyFit::Type.altitude }, - tot_descent_m: { id: 22, type: RubyFit::Type.altitude }, + tot_ascent_m: { id: 21, type: RubyFit::Type.uint16 }, + tot_descent_m: { id: 22, type: RubyFit::Type.uint16 }, lap_trigger_code: { id: 24, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::LAP_TRIGGER }, sport_code: { id: 25, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: false }, norm_pwr_watts: { id: 33, type: RubyFit::Type.uint16 }, @@ -223,8 +223,8 @@ class RubyFit::MessageWriter max_cad_rpm: { id: 19, type: RubyFit::Type.uint8 }, avg_pwr_watts: { id: 20, type: RubyFit::Type.uint16 }, max_pwr_watts: { id: 21, type: RubyFit::Type.uint16 }, - tot_ascent_m: { id: 22, type: RubyFit::Type.altitude }, - tot_descent_m: { id: 23, type: RubyFit::Type.altitude }, + tot_ascent_m: { id: 22, type: RubyFit::Type.uint16 }, + tot_descent_m: { id: 23, type: RubyFit::Type.uint16 }, num_laps: { id: 26, type: RubyFit::Type.uint16 }, norm_pwr_watts: { id: 34, type: RubyFit::Type.uint16 }, tss: { id: 35, type: RubyFit::Type.tss }, diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 5872ce4..c304512 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -156,7 +156,7 @@ def altitude result }, fit2rb: ->(val, type) { - result = val.nil? ? nil : val / 5.0 - 500 + result = val.nil? ? nil : (val / 5.0 - 500).round(1) result } }) diff --git a/test/activity_test.rb b/test/activity_test.rb index 137c3d9..387a83c 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -40,7 +40,7 @@ def test_rubyfit_integration wahoo_clm_count: json[:wahoo_clms]&.size || 0, name: json[:name] || 'unnamed', tot_dist_m: (json[:total_distance] || 0), - tot_ascent_m: (json[:total_ascent] / 5.0 - 500 || 0), + tot_ascent_m: (json[:total_ascent] || 0), time_created: (json[:created_at] || Time.now).to_i, start_lat_deg: (json[:first_lng] || 0), start_lon_deg: (json[:first_lat] || 0), diff --git a/test/route_test.rb b/test/route_test.rb index f39a04b..9ab954d 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -22,7 +22,7 @@ def test_integration track_point_count: (json['track_points']&.size || 0).to_i, name: json['name'] || 'unnamed', tot_dist_m: (json['distance'] || 0), - tot_ascent_m: (json['ascent'] / 5.0 - 500 || 0), + tot_ascent_m: (json['ascent'] || 0), time_created: (json['created_at'] || Time.now).to_i, start_x: (json['first_lng'] || 0), start_y: (json['first_lat'] || 0), From a5e30a7d2a2a801c492f1dcbdcb2307ad06715fd Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 4 Apr 2025 12:54:07 -0400 Subject: [PATCH 067/104] change typos --- lib/rubyfit/message_writer.rb | 6 +++--- lib/rubyfit/type.rb | 2 +- test/fixtures/example_route_json.json | 2 +- test/route_test.rb | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 8a1c9f5..a763772 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -42,8 +42,8 @@ class RubyFit::MessageWriter tot_timer_time_sec: { id: 8, type: RubyFit::Type.duration, required: true }, tot_dist_m: { id: 9, type: RubyFit::Type.centimeters }, tot_cal: { id: 11, type: RubyFit::Type.uint16 }, - avg_speed_mps: { id: 13, type: RubyFit::Type.uint32_scale100 }, - max_speed_mps: { id: 14, type: RubyFit::Type.uint32_scale100 }, + avg_spd_mps: { id: 13, type: RubyFit::Type.uint32_scale100 }, + max_spd_mps: { id: 14, type: RubyFit::Type.uint32_scale100 }, avg_hr_bpm: { id: 15, type: RubyFit::Type.uint8 }, max_hr_bpm: { id: 16, type: RubyFit::Type.uint8 }, avg_cad_rpm: { id: 17, type: RubyFit::Type.uint8 }, @@ -216,7 +216,7 @@ class RubyFit::MessageWriter tot_dist_m: { id: 9, type: RubyFit::Type.centimeters }, tot_cal: { id: 11, type: RubyFit::Type.uint16 }, avg_spd_mps: { id: 14, type: RubyFit::Type.uint32_scale100 }, - max_speed_mps: { id: 15, type: RubyFit::Type.uint32_scale100}, + max_spd_mps: { id: 15, type: RubyFit::Type.uint32_scale100}, avg_hr_bpm: { id: 16, type: RubyFit::Type.uint8 }, max_hr_bpm: { id: 17, type: RubyFit::Type.uint8 }, avg_cad_rpm: { id: 18, type: RubyFit::Type.uint8 }, diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index c304512..f0c3002 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -137,7 +137,7 @@ def timestamp def semicircles sint32({ - rb2fit: ->(val, type) { deg2semicircles(val) }, + rb2fit: ->(val, type) { deg2semicircles(val).truncate }, fit2rb: ->(val, type) { val.nil? ? nil : semicircles2deg(val).round(6) } }) end diff --git a/test/fixtures/example_route_json.json b/test/fixtures/example_route_json.json index 64271c7..297c021 100644 --- a/test/fixtures/example_route_json.json +++ b/test/fixtures/example_route_json.json @@ -13,7 +13,7 @@ "first_lat": 34.03497, "first_lng": -84.59215, "last_lat": 34.03852, - "last_lng": -84.59529, + "last_lng": -84.6307, "sw_lat": 34.03497, "sw_lng": -84.59529, "ne_lat": 34.03864, diff --git a/test/route_test.rb b/test/route_test.rb index 9ab954d..ed9775e 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -22,7 +22,7 @@ def test_integration track_point_count: (json['track_points']&.size || 0).to_i, name: json['name'] || 'unnamed', tot_dist_m: (json['distance'] || 0), - tot_ascent_m: (json['ascent'] || 0), + total_ascent: (json['ascent'] || 0), time_created: (json['created_at'] || Time.now).to_i, start_x: (json['first_lng'] || 0), start_y: (json['first_lat'] || 0), From 543ef2b82a60fa25cbf0c447f29393ca541d29e5 Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 4 Apr 2025 13:01:26 -0400 Subject: [PATCH 068/104] add truncate --- lib/rubyfit/type.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index f0c3002..1c23392 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -137,7 +137,7 @@ def timestamp def semicircles sint32({ - rb2fit: ->(val, type) { deg2semicircles(val).truncate }, + rb2fit: ->(val, type) { deg2semicircles(val) }, fit2rb: ->(val, type) { val.nil? ? nil : semicircles2deg(val).round(6) } }) end @@ -164,21 +164,21 @@ def altitude def duration uint32({ - rb2fit: ->(val, type) { (val * 1000) }, + rb2fit: ->(val, type) { (val * 1000).truncate }, fit2rb: ->(val, type) { val.nil? ? nil : val / 1000.0 } }) end def enhanced_speed uint32({ - rb2fit: ->(val, type) { (val * 1000) }, + rb2fit: ->(val, type) { (val * 1000).truncate }, fit2rb: ->(val, type) { val.nil? ? nil : val / 1000.0 } }) end def speed uint8({ - rb2fit: ->(val, type) { (val * 1000) }, + rb2fit: ->(val, type) { (val * 1000).truncate }, fit2rb: ->(val, type) { val.nil? ? nil : val / 1000.0 } }) end @@ -186,35 +186,35 @@ def speed def grade sint16({ - rb2fit: ->(val, type) { (val * 100) }, + rb2fit: ->(val, type) { (val * 100).truncate }, fit2rb: ->(val, type) { val.nil? ? nil : val / 100.0 } }) end def tss uint16({ - rb2fit: ->(val, type) { (val * 10) }, + rb2fit: ->(val, type) { (val * 10).truncate }, fit2rb: ->(val, type) { val.nil? ? nil : val / 10.0 } }) end def if uint16({ - rb2fit: ->(val, type) { (val * 1000) }, + rb2fit: ->(val, type) { (val * 1000).truncate }, fit2rb: ->(val, type) { val.nil? ? nil : val / 1000.0 } }) end def uint8_scale2 uint8({ - rb2fit: ->(val, type) { (val * 2) }, + rb2fit: ->(val, type) { (val * 2).truncate }, fit2rb: ->(val, type) { val.nil? ? nil : val / 2 } }) end def uint16_scale100 uint16({ - rb2fit: ->(val, type) { (val * 100) }, + rb2fit: ->(val, type) { (val * 100).truncate }, fit2rb: ->(val, type) { val.nil? ? nil : val / 100 } }) end From 66de98579f530a07a09442648a6da23d481f9d9b Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 4 Apr 2025 13:09:29 -0400 Subject: [PATCH 069/104] change ascent type --- lib/rubyfit/message_writer.rb | 8 ++++---- lib/rubyfit/type.rb | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index a763772..346951b 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -50,8 +50,8 @@ class RubyFit::MessageWriter max_cad_rpm: { id: 18, type: RubyFit::Type.uint8 }, avg_pwr_watts: { id: 19, type: RubyFit::Type.uint16 }, max_pwr_watts: { id: 20, type: RubyFit::Type.uint16 }, - tot_ascent_m: { id: 21, type: RubyFit::Type.uint16 }, - tot_descent_m: { id: 22, type: RubyFit::Type.uint16 }, + tot_ascent_m: { id: 21, type: RubyFit::Type.ascent}, + tot_descent_m: { id: 22, type: RubyFit::Type.ascent }, lap_trigger_code: { id: 24, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::LAP_TRIGGER }, sport_code: { id: 25, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: false }, norm_pwr_watts: { id: 33, type: RubyFit::Type.uint16 }, @@ -223,8 +223,8 @@ class RubyFit::MessageWriter max_cad_rpm: { id: 19, type: RubyFit::Type.uint8 }, avg_pwr_watts: { id: 20, type: RubyFit::Type.uint16 }, max_pwr_watts: { id: 21, type: RubyFit::Type.uint16 }, - tot_ascent_m: { id: 22, type: RubyFit::Type.uint16 }, - tot_descent_m: { id: 23, type: RubyFit::Type.uint16 }, + tot_ascent_m: { id: 22, type: RubyFit::Type.ascent }, + tot_descent_m: { id: 23, type: RubyFit::Type.ascent }, num_laps: { id: 26, type: RubyFit::Type.uint16 }, norm_pwr_watts: { id: 34, type: RubyFit::Type.uint16 }, tss: { id: 35, type: RubyFit::Type.tss }, diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 1c23392..dee6898 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -162,6 +162,13 @@ def altitude }) end + def ascent + uint16({ + rb2fit: ->(val, type) { (val).truncate }, + fit2rb: ->(val, type) { val } + }) + end + def duration uint32({ rb2fit: ->(val, type) { (val * 1000).truncate }, From 06ceaa9b3878e4f4b7778186bc47ba992462366c Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 4 Apr 2025 13:29:01 -0400 Subject: [PATCH 070/104] change avg_spd type --- lib/rubyfit/message_writer.rb | 4 ++-- test/fixtures/example_activity_json.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 346951b..6678fbd 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -215,8 +215,8 @@ class RubyFit::MessageWriter tot_timer_time_sec: { id: 8, type: RubyFit::Type.duration }, tot_dist_m: { id: 9, type: RubyFit::Type.centimeters }, tot_cal: { id: 11, type: RubyFit::Type.uint16 }, - avg_spd_mps: { id: 14, type: RubyFit::Type.uint32_scale100 }, - max_spd_mps: { id: 15, type: RubyFit::Type.uint32_scale100}, + avg_spd_mps: { id: 14, type: RubyFit::Type.speed }, + max_spd_mps: { id: 15, type: RubyFit::Type.speed}, avg_hr_bpm: { id: 16, type: RubyFit::Type.uint8 }, max_hr_bpm: { id: 17, type: RubyFit::Type.uint8 }, avg_cad_rpm: { id: 18, type: RubyFit::Type.uint8 }, diff --git a/test/fixtures/example_activity_json.json b/test/fixtures/example_activity_json.json index 2a47db4..e6565b4 100644 --- a/test/fixtures/example_activity_json.json +++ b/test/fixtures/example_activity_json.json @@ -86,8 +86,8 @@ "start_time_local": 1111065857, "tot_dist_m": 1609.3, "tot_cal": 275, - "average_speed": 2.48, - "max_speed": 3.2, + "avg_spd_mps": 2.48, + "max_spd_mps": 3.2, "lap_index": 2, "split": 2, "start_index": 208, From 60194c9663819a4fbe60019439d0e2935cf4fa2f Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 4 Apr 2025 13:41:27 -0400 Subject: [PATCH 071/104] change avg_spd type --- lib/rubyfit/message_writer.rb | 8 ++++---- lib/rubyfit/type.rb | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 6678fbd..2ec7d91 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -42,8 +42,8 @@ class RubyFit::MessageWriter tot_timer_time_sec: { id: 8, type: RubyFit::Type.duration, required: true }, tot_dist_m: { id: 9, type: RubyFit::Type.centimeters }, tot_cal: { id: 11, type: RubyFit::Type.uint16 }, - avg_spd_mps: { id: 13, type: RubyFit::Type.uint32_scale100 }, - max_spd_mps: { id: 14, type: RubyFit::Type.uint32_scale100 }, + avg_spd_mps: { id: 13, type: RubyFit::Type.uint16_scale1000 }, + max_spd_mps: { id: 14, type: RubyFit::Type.uint16_scale1000 }, avg_hr_bpm: { id: 15, type: RubyFit::Type.uint8 }, max_hr_bpm: { id: 16, type: RubyFit::Type.uint8 }, avg_cad_rpm: { id: 17, type: RubyFit::Type.uint8 }, @@ -215,8 +215,8 @@ class RubyFit::MessageWriter tot_timer_time_sec: { id: 8, type: RubyFit::Type.duration }, tot_dist_m: { id: 9, type: RubyFit::Type.centimeters }, tot_cal: { id: 11, type: RubyFit::Type.uint16 }, - avg_spd_mps: { id: 14, type: RubyFit::Type.speed }, - max_spd_mps: { id: 15, type: RubyFit::Type.speed}, + avg_spd_mps: { id: 14, type: RubyFit::Type.uint16_scale1000}, + max_spd_mps: { id: 15, type: RubyFit::Type.uint16_scale1000}, avg_hr_bpm: { id: 16, type: RubyFit::Type.uint8 }, max_hr_bpm: { id: 17, type: RubyFit::Type.uint8 }, avg_cad_rpm: { id: 18, type: RubyFit::Type.uint8 }, diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index dee6898..c9e26dc 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -190,6 +190,13 @@ def speed }) end + def uint16_scale1000 + uint16({ + rb2fit: ->(val, type) { (val * 1000).truncate }, + fit2rb: ->(val, type) { val.nil? ? nil : val / 1000.0 } + }) + end + def grade sint16({ From 2fb5a469079a00653850721e65d155a63a30912b Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 4 Apr 2025 13:55:32 -0400 Subject: [PATCH 072/104] fill in blank array with 0s --- lib/rubyfit/type.rb | 6 ++++-- test/fit_parser_test.rb | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index c9e26dc..89caa2f 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -221,7 +221,7 @@ def if def uint8_scale2 uint8({ - rb2fit: ->(val, type) { (val * 2).truncate }, + rb2fit: ->(val, type) { (val * 2.0).truncate }, fit2rb: ->(val, type) { val.nil? ? nil : val / 2 } }) end @@ -275,7 +275,9 @@ def self.uint32_array(length, opts = {}) val.flat_map { |v| [v * 1000].pack("L<").bytes } + ([0xFF] * [(length - val.length) * 4, 0].max) }, bytes2val: ->(bytes, type, opts = {}) { - bytes.each_slice(4).map { |slice| slice.pack("C*").unpack1("L<") / 1000.0 } + result = bytes.each_slice(4).map { |slice| slice.pack("C*").unpack1("L<") / 1000.0 } + result.fill(0.0, result.length...length) # Ensure the array has at least 8 elements + result }, }.merge(opts)) end diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 585a4c0..8a8bf7c 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -58,7 +58,7 @@ def test_little_endian_file_decoding assert_equal(124, json_output['sessions'][0]['ftp']) assert_equal([202.88, 46.528, 0, 0, 0], json_output['laps'][0]['time_in_hr_zone_sec']) - assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0], json_output['laps'][0]['time_in_pwr_zone_sec']) + assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0, 0.0, 0.0], json_output['laps'][0]['time_in_pwr_zone_sec']) assert_equal(2, json_output['laps'][0]['sport_code']) assert_equal(6, json_output['laps'][0]['sub_sport_code']) @@ -69,14 +69,14 @@ def test_little_endian_file_decoding assert_equal(124.2, json_output['laps'][0]['tot_dist_m']) assert_equal(17304, json_output['laps'][0]['tot_work_j']) assert_equal([202.88, 46.528, 0, 0, 0], json_output['laps'][0]['time_in_hr_zone_sec']) - assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0], json_output['laps'][0]['time_in_pwr_zone_sec']) + assert_equal([205.307, 2.88, 35.999, 0.0, 0.0, 0.0, 0.0, 0.0], json_output['laps'][0]['time_in_pwr_zone_sec']) assert_equal(98, json_output['records'][1]['hr_bpm']) assert_equal(0, json_output['records'][1]['pwr_watts']) assert_equal(0, json_output['records'][1]['cal']) assert_equal(85, json_output['records'][0]['battery_soc_perc']) - assert_equal(85, json_output['records'][0]['battery_soc_perc']) + assert_equal(85.0, json_output['records'][0]['battery_soc_perc']) assert_equal(110, json_output['records'][242]['pwr_watts']) assert_equal(124.2, json_output['records'][242]['dist_m']) assert_equal(0.134, json_output['records'][242]['spd_mps']) From 9726fd2378b8c119962b3733941768555b1b48ba Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 4 Apr 2025 14:06:44 -0400 Subject: [PATCH 073/104] add decimal to percents --- lib/rubyfit/type.rb | 2 +- test/fit_parser_test.rb | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 89caa2f..32de90e 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -222,7 +222,7 @@ def if def uint8_scale2 uint8({ rb2fit: ->(val, type) { (val * 2.0).truncate }, - fit2rb: ->(val, type) { val.nil? ? nil : val / 2 } + fit2rb: ->(val, type) { val.nil? ? nil : val / 2.0 } }) end diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 8a8bf7c..912755b 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -19,7 +19,6 @@ def test_extremely_large_file def test_little_endian_file_decoding fit_file_path = 'test/fixtures/2025-01-03-143057-WAHOOAPPIOS62BB-3-0.fit' - # Read FIT file raw = IO.read(fit_file_path) parser = RubyFit::FitFileParser.new From 82e2538e92344a14299bfb5501b7462fd2596b7c Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 24 Apr 2025 15:44:16 -0400 Subject: [PATCH 074/104] miscellaneous changes --- lib/rubyfit/message_writer.rb | 2 +- lib/rubyfit/writer.rb | 8 ++++---- test/activity_test.rb | 2 +- test/fit_parser_test.rb | 4 ++-- test/fixtures/example_activity_json.json | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 2ec7d91..f0bf016 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -108,7 +108,7 @@ class RubyFit::MessageWriter left_pedal_smooth_perc: { id: 45, type: RubyFit::Type.uint8_scale2 }, right_pedal_smooth_perc: { id: 46, type: RubyFit::Type.uint8_scale2 }, enhanced_spd_mps: { id: 73, type: RubyFit::Type.enhanced_speed}, - battery_soc_perc: { id: 81, type: RubyFit::Type.uint8_scale2 } + batt_soc_perc: { id: 81, type: RubyFit::Type.uint8_scale2 } } }, diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index c5610e6..31045d0 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -39,10 +39,10 @@ def write(stream, opts = {}) timestamp: start_time, tot_elapsed_time_sec: duration, tot_timer_time_sec: duration, - start_lat_deg: opts[:start_x], - start_lon_deg: opts[:start_y], - end_lat_deg: opts[:end_x], - end_lon_deg: opts[:end_y], + start_lat_deg: opts[:start_y], + start_lon_deg: opts[:start_x], + end_lat_deg: opts[:end_y], + end_lon_deg: opts[:end_x], tot_dist_m: opts[:tot_dist_m], tot_ascent_m: opts[:total_ascent], sport_code: opts[:sport], diff --git a/test/activity_test.rb b/test/activity_test.rb index 387a83c..8f4283f 100644 --- a/test/activity_test.rb +++ b/test/activity_test.rb @@ -139,7 +139,7 @@ def test_rubyfit_integration assert_equal json_output['records'].last['cad_rpm'], json_input['records'].last['cad_rpm'] assert_equal json_output['records'].last['pwr_watts'], json_input['records'].last['pwr_watts'] assert_equal json_output['records'].last['enhanced_spd_mps'], json_input['records'].last['enhanced_spd_mps'] - assert_equal json_output['records'].last['battery_soc_perc'], json_input['records'].last['battery_soc_perc'] + assert_equal json_output['records'].last['batt_soc_perc'], json_input['records'].last['batt_soc_perc'] assert_equal json_output['records'].last['grade_perc'], json_input['records'].last['grade_perc'] assert_equal json_output['laps'].size, json_input['laps'].size assert_equal json_output['laps'].last['start_time'], Time.at(json_input['laps'].last['start_time']).utc.to_s diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 912755b..25ee264 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -73,9 +73,9 @@ def test_little_endian_file_decoding assert_equal(98, json_output['records'][1]['hr_bpm']) assert_equal(0, json_output['records'][1]['pwr_watts']) assert_equal(0, json_output['records'][1]['cal']) - assert_equal(85, json_output['records'][0]['battery_soc_perc']) + assert_equal(85, json_output['records'][0]['batt_soc_perc']) - assert_equal(85.0, json_output['records'][0]['battery_soc_perc']) + assert_equal(85.0, json_output['records'][0]['batt_soc_perc']) assert_equal(110, json_output['records'][242]['pwr_watts']) assert_equal(124.2, json_output['records'][242]['dist_m']) assert_equal(0.134, json_output['records'][242]['spd_mps']) diff --git a/test/fixtures/example_activity_json.json b/test/fixtures/example_activity_json.json index e6565b4..2c3350c 100644 --- a/test/fixtures/example_activity_json.json +++ b/test/fixtures/example_activity_json.json @@ -194,7 +194,7 @@ "pwr_watts": 0, "cal": 0, "enhanced_spd_mps": 0, - "battery_soc_perc": 0, + "batt_soc_perc": 0, "grade_perc": 0 }, { @@ -208,7 +208,7 @@ "pwr_watts": 185, "cal": 100, "enhanced_spd_mps": 20, - "battery_soc_perc": 30, + "batt_soc_perc": 30, "grade_perc": 5 } ], From 32a010dd491ceb633c08c9b797b5c42204fbae0b Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 1 May 2025 16:04:15 -0400 Subject: [PATCH 075/104] add repair function --- lib/rubyfit.rb | 3 +- lib/rubyfit/fit_parser.rb | 177 ++++++++++++++++++++++++++++++++++ lib/rubyfit/helpers.rb | 16 +++ lib/rubyfit/message_writer.rb | 22 ++--- lib/rubyfit/type.rb | 24 ++++- lib/rubyfit/validations.rb | 21 ++++ test/fit_parser_test.rb | 20 ++++ 7 files changed, 268 insertions(+), 15 deletions(-) create mode 100644 lib/rubyfit/validations.rb diff --git a/lib/rubyfit.rb b/lib/rubyfit.rb index 1e54d0d..3c4844d 100644 --- a/lib/rubyfit.rb +++ b/lib/rubyfit.rb @@ -3,4 +3,5 @@ require 'rubyfit/writer' require 'rubyfit/helpers' -require 'rubyfit/fit_parser' \ No newline at end of file +require 'rubyfit/fit_parser' +require 'rubyfit/validations' \ No newline at end of file diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index bebd3e9..b7b3465 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -1,3 +1,5 @@ +require_relative 'validations' +require_relative 'helpers' class RubyFit::FitFileParser REQUIRED_CALLBACKS = [:definition_message, :get_definition, :data_message] @@ -76,6 +78,43 @@ def convert_to_json(fit_data, unpack_directive) { message_type => readable_data } end + def convert_to_json_with_validations(fit_data, unpack_directive) + big_endian = unpack_directive == 'n' + # Define the message type to look up + type = RubyFit::MessageConstants::MESSAGE_TYPE.find { |key, value| value == fit_data.keys.first } + # puts("message type: #{fit_data.keys.first}") + return unless type + message_type = RubyFit::MessageConstants::MESSAGE_TYPE.find { |key, value| value == fit_data.keys.first }.first + message_definition = RubyFit::MessageWriter::MESSAGE_DEFINITIONS[message_type] + + # Convert each field in the raw FIT data to a readable format + readable_data = {} + raw_values = fit_data.values.first + + # for debugging + # known_field_ids = message_definition[:fields].map { |_, field_definition| field_definition[:id] } + # unknown_keys = raw_values.keys - known_field_ids + # puts("Unknown raw data for message definition #{message_type}: #{unknown_keys}") unless unknown_keys.empty? + # + + # Iterate through the message definition fields + message_definition[:fields].each do |field_name, field_definition| + field_id = field_definition[:id] # This is the key we're looking for in the raw data + field_definition[:big_endian] = big_endian + + # Check if the field ID is present in the raw FIT data + if raw_values.key?(field_id) + raw_value = raw_values[field_id].bytes + readable_data[field_name] = field_definition[:type].bytes2val(raw_value, **field_definition.slice(:big_endian)) + end + end + + valid_data = RubyFit::Validations.validate_message(message_type, readable_data) + return if valid_data.nil? + + { message_type => valid_data } + end + def parse(raw) all_data = {} io = StringIO.new(raw) @@ -200,4 +239,142 @@ def parse(raw) end yield all_data end + + + def repair_fit_file(raw) + invalid_offsets = [] # To store offsets and lengths of invalid messages + io = StringIO.new(raw) + + header = io.read(12) + raise "Invalid FIT file: unable to read header" unless header && header.size == 12 + + header_size, protocol_version, profile_version, data_size, data_type = header.unpack('C C v V a4') + raise "Invalid FIT file: invalid data type" unless data_type == ".FIT" + + io.seek(header_size) if io.pos < header_size + + unpack_directive = 'v' + buffer = io.read(header_size + data_size - io.pos) + buffer_io = StringIO.new(buffer) + + while buffer_io.pos < buffer.size + record_start = buffer_io.pos # Track the start of the record + record_header = buffer_io.read(1)&.unpack1('C') + raise "Invalid FIT file: unable to read record header" unless record_header + + if record_header & 0x40 == 0x40 + local_num = record_header & 0x0F + reserved, architecture = buffer_io.read(2).unpack('C C') + unpack_directive = 'n' if architecture == 1 + global_message_number, field_count = buffer_io.read(3).unpack("#{unpack_directive} C") + + fields = field_count.times.map do + field_def = buffer_io.read(3)&.unpack('C*') + { id: field_def[0], size: field_def[1], type: field_def[2] } + end + + developer_fields = if record_header & 0x20 == 0x20 + developer_field_count = buffer_io.read(1)&.unpack1('C') + developer_field_count.times.map do + developer_field_def = buffer_io.read(3)&.unpack('C*') + { id: developer_field_def[0], size: developer_field_def[1], type: developer_field_def[2] } + end + else + [] + end + + definition_message(local_num, global_message_number, fields, developer_fields) + else + local_num = record_header & 0x0F + definition = get_definition(local_num) + raise "Unknown definition for local number #{local_num}" unless definition + + values = {} + definition[:fields].each do |field| + value = buffer_io.read(field[:size]) + if value.nil? || value.size < field[:size] + puts "Warning: Missing or incomplete field value for field ID #{field[:id]}" + next + end + values[field[:id]] = value + end + + developer_values = {} + definition[:developer_fields]&.each do |field| + value = buffer_io.read(field[:size]) + if value.nil? || value.size < field[:size] + puts "Warning: Missing or incomplete developer field value for field ID #{field[:id]}" + next + end + developer_values[field[:id]] = value + end + + data_message(local_num, values) + data = convert_to_json_with_validations({ definition[:global_message_number] => values }, unpack_directive) + if data.nil? + # Record the offset and length of the invalid message + invalid_offsets << { start: record_start, length: buffer_io.pos - record_start } + end + end + end + + # Pass invalid_offsets to the edit_fit_file_raw function + yield edit_fit_file_raw(raw, invalid_offsets) + end + + + def edit_fit_file_raw(raw, invalid_offsets) + io = StringIO.new(raw) + + # Read and parse the header + header = io.read(12) + raise "Invalid FIT file: unable to read header" unless header && header.size == 12 + + header_size, protocol_version, profile_version, data_size, data_type = header.unpack('C C v V a4') + raise "Invalid FIT file: invalid data type" unless data_type == ".FIT" + puts("Header size: #{header_size}, Protocol version: #{protocol_version}, Profile version: #{profile_version}, Data size: #{data_size}, Data type: #{data_type}") + # Parse the data section + io.seek(header_size) + buffer = io.read(data_size) + buffer_io = StringIO.new(buffer) + + # Rebuild the data section, skipping invalid offsets + modified_buffer = "" + while buffer_io.pos < buffer.size + record_start = buffer_io.pos + record_header = buffer_io.read(1) + break unless record_header + + # Check if this record is invalid + invalid = invalid_offsets.find do |offset| + record_start >= offset[:start] && record_start < (offset[:start] + offset[:length]) + end + + if invalid + # Skip the invalid record + buffer_io.seek(invalid[:start] + invalid[:length]) + else + # Include the valid record + modified_buffer << record_header + modified_buffer << buffer_io.read(buffer_io.pos - record_start - 1) + end + end + + # Recalculate the data size + new_data_size = modified_buffer.bytesize + + # Update the header with the new data size + new_header = [header_size, protocol_version, profile_version, new_data_size, data_type].pack('C C v V a4') + + new_header_crc = RubyFit::Helpers.update_crc(0, new_header) + new_header += [new_header_crc].pack('v') + + # Recalculate the CRC for the modified data + new_crc = RubyFit::Helpers.update_crc(0, new_header + modified_buffer) + + # Combine the new header, modified data, and CRC + repaired_fit_file = new_header + modified_buffer + [new_crc].pack('v') + + repaired_fit_file + end end \ No newline at end of file diff --git a/lib/rubyfit/helpers.rb b/lib/rubyfit/helpers.rb index bd115f6..21a463c 100644 --- a/lib/rubyfit/helpers.rb +++ b/lib/rubyfit/helpers.rb @@ -106,5 +106,21 @@ def make_message_header(opts = {}) result |= (opts[:local_number] || 0) & 0xF result end + + def self.update_crc(crc, data) + crc_table = [0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, + 0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400] + data.each_byte do |byte| + # compute checksum of lower four bits of byte + tmp = crc_table[crc & 0xF] + crc = (crc >> 4) & 0x0FFF + crc = crc ^ tmp ^ crc_table[byte & 0xF] + # now compute checksum of upper four bits of byte + tmp = crc_table[crc & 0xF] + crc = (crc >> 4) & 0x0FFF + crc = crc ^ tmp ^ crc_table[(byte >> 4) & 0xF] + end + crc + end end end diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index f0bf016..6893aa3 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -116,8 +116,8 @@ class RubyFit::MessageWriter id: 21, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, - event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, - event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, + event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT.merge(RubyFit::MessageConstants::EVENT.values.map { |v| [v, v] }.to_h), required: true }, + event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE.merge(RubyFit::MessageConstants::EVENT_TYPE.values.map { |v| [v, v] }.to_h), required: true }, data16: { id: 2, type: RubyFit::Type.uint16 }, data: { id: 3, type: RubyFit::Type.uint32 }, event_group: { id: 4, type: RubyFit::Type.uint8 }, @@ -131,7 +131,7 @@ class RubyFit::MessageWriter workout: { id: 26, fields: { - sport_code: { id: 4, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: true }, + sport_code: { id: 4, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT.merge(RubyFit::MessageConstants::SPORT.values.map { |v| [v, v] }.to_h), required: true }, # capabilities: { id: 5, type: RubyFit::Type.uint32z, required: true }, # should be workout_capabilities type num_valid_steps: { id: 6, type: RubyFit::Type.uint16 }, wkt_name: { id: 8, type: RubyFit::Type.string(64) }, @@ -144,7 +144,7 @@ class RubyFit::MessageWriter sport: { id: 12, fields: { - sport_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: true }, + sport_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT.merge(RubyFit::MessageConstants::SPORT.values.map { |v| [v, v] }.to_h), required: true }, sub_sport_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT } } }, @@ -206,10 +206,10 @@ class RubyFit::MessageWriter id: 18, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, - event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, - event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, + event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT.merge(RubyFit::MessageConstants::EVENT.values.map { |v| [v, v] }.to_h), required: true }, + event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE.merge(RubyFit::MessageConstants::EVENT_TYPE.values.map { |v| [v, v] }.to_h), required: true }, start_time: { id: 2, type: RubyFit::Type.timestamp, required: true }, - sport_code: { id: 5, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: true }, + sport_code: { id: 5, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT.merge(RubyFit::MessageConstants::SPORT.values.map { |v| [v, v] }.to_h), required: true }, sub_sport_code: { id: 6, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT }, tot_elapsed_time_sec: { id: 7, type: RubyFit::Type.duration }, tot_timer_time_sec: { id: 8, type: RubyFit::Type.duration }, @@ -266,8 +266,8 @@ class RubyFit::MessageWriter id: 101, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, - event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, - event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, + event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT.merge(RubyFit::MessageConstants::EVENT.values.map { |v| [v, v] }.to_h), required: true }, + event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE.merge(RubyFit::MessageConstants::EVENT_TYPE.values.map { |v| [v, v] }.to_h), required: true }, start_time: { id: 2, type: RubyFit::Type.timestamp, required: true }, total_elapsed_time: { id: 3, type: RubyFit::Type.duration }, tot_timer_time_sec: { id: 4, type: RubyFit::Type.duration }, @@ -292,8 +292,8 @@ class RubyFit::MessageWriter id: 142, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, - event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT, required: true }, - event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE, required: true }, + event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT.merge(RubyFit::MessageConstants::EVENT.values.map { |v| [v, v] }.to_h), required: true }, + event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE.merge(RubyFit::MessageConstants::EVENT_TYPE.values.map { |v| [v, v] }.to_h), required: true }, start_time: { id: 2, type: RubyFit::Type.timestamp, required: true }, start_lat_deg: { id: 3, type: RubyFit::Type.semicircles }, start_lon_deg: { id: 4, type: RubyFit::Type.semicircles }, diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 32de90e..f2b434b 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -54,9 +54,24 @@ def integer(opts = {}) end # Base Types # - + def enum(opts = {}) - uint8(fit_id: 0x00) + uint8({ + rb2fit: ->(val, type) { + if opts[:values] && val.is_a?(Symbol) + opts[:values][val] || (raise ArgumentError, "Invalid enum value: #{val}") + else + val + end + }, + fit2rb: ->(val, type) { + if opts[:values] + opts[:values].key(val) || val + else + val + end + } + }) end def string(byte_count, opts = {}) @@ -130,7 +145,10 @@ def uint64z(opts = {}) def timestamp uint32({ - rb2fit: ->(val, type) { unix2fit_timestamp(val) }, + rb2fit: ->(val, type) { + val = val.to_i if val.is_a?(Time) # Convert Time to Unix timestamp + unix2fit_timestamp(val) + }, fit2rb: ->(val, type) { val.nil? ? nil : Time.at(fit2unix_timestamp(val)).utc } }) end diff --git a/lib/rubyfit/validations.rb b/lib/rubyfit/validations.rb new file mode 100644 index 0000000..45c07c7 --- /dev/null +++ b/lib/rubyfit/validations.rb @@ -0,0 +1,21 @@ +class RubyFit::Validations + + def self.validate_message(message_type, data) + if message_type == :lap + data = self.lap(data) + end + data + end + + + def self.lap(lap) + if lap[:timestamp].nil? || lap[:timestamp] == 0 || lap[:timestamp].to_s == "1989-12-31 00:00:00 UTC" + if !lap[:start_time].nil? && !lap[:tot_elapsed_time_sec].nil? + lap[:timestamp] = lap[:start_time] + lap[:tot_elapsed_time_sec] + else + lap = nil + end + end + lap + end +end \ No newline at end of file diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 25ee264..5355368 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -110,4 +110,24 @@ def test_little_endian_file_decoding end end + + def test_fit_file_with_invalid_lap + fit_file_path = 'test/fixtures/zwift-activity.fit' + new_fit_file_path = 'test/fixtures/zwift-activity-new.fit' + raw = IO.read(fit_file_path) + + parser = RubyFit::FitFileParser.new + parser.repair_fit_file(raw) do |data| + new_file_string = data + File.open(new_fit_file_path, 'wb') do |file| + file.write(new_file_string) + end + end + + raw = IO.read(new_fit_file_path) + parser.parse(raw) do |data| + json_output = JSON.parse(data.to_json) + assert_equal(1, json_output['laps'].size) + end + end end \ No newline at end of file From b9cc1591a9560084003350e1a24d0923f126268b Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 2 May 2025 12:14:16 -0400 Subject: [PATCH 076/104] add repair activity rule --- lib/rubyfit/fit_parser.rb | 26 ++++++++++++++++-------- lib/rubyfit/message_writer.rb | 6 +++--- lib/rubyfit/validations.rb | 37 +++++++++++++++++++++++++++++++---- test/fit_parser_test.rb | 1 + 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index b7b3465..0e5068c 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -78,7 +78,7 @@ def convert_to_json(fit_data, unpack_directive) { message_type => readable_data } end - def convert_to_json_with_validations(fit_data, unpack_directive) + def get_valid_data(fit_data, unpack_directive) big_endian = unpack_directive == 'n' # Define the message type to look up type = RubyFit::MessageConstants::MESSAGE_TYPE.find { |key, value| value == fit_data.keys.first } @@ -109,10 +109,9 @@ def convert_to_json_with_validations(fit_data, unpack_directive) end end - valid_data = RubyFit::Validations.validate_message(message_type, readable_data) - return if valid_data.nil? + valid_data, modified = RubyFit::Validations.validate_message(message_type, readable_data) - { message_type => valid_data } + [valid_data, modified] end def parse(raw) @@ -243,6 +242,7 @@ def parse(raw) def repair_fit_file(raw) invalid_offsets = [] # To store offsets and lengths of invalid messages + modified_messages = [] # To store modified messages io = StringIO.new(raw) header = io.read(12) @@ -310,20 +310,22 @@ def repair_fit_file(raw) end data_message(local_num, values) - data = convert_to_json_with_validations({ definition[:global_message_number] => values }, unpack_directive) - if data.nil? + data, modified = get_valid_data({ definition[:global_message_number] => values }, unpack_directive) + if data.nil? && modified # Record the offset and length of the invalid message invalid_offsets << { start: record_start, length: buffer_io.pos - record_start } + elsif data && modified + modified_messages << { start: record_start, length: buffer_io.pos - record_start, new_data: data } end end end # Pass invalid_offsets to the edit_fit_file_raw function - yield edit_fit_file_raw(raw, invalid_offsets) + yield edit_fit_file_raw(raw, invalid_offsets, modified_messages) end - def edit_fit_file_raw(raw, invalid_offsets) + def edit_fit_file_raw(raw, invalid_offsets, modified_messages) io = StringIO.new(raw) # Read and parse the header @@ -350,9 +352,17 @@ def edit_fit_file_raw(raw, invalid_offsets) record_start >= offset[:start] && record_start < (offset[:start] + offset[:length]) end + modified = modified_messages.find do |offset| + record_start >= offset[:start] && record_start < (offset[:start] + offset[:length]) + end + if invalid # Skip the invalid record buffer_io.seek(invalid[:start] + invalid[:length]) + elsif modified + # Replace the invalid record with the modified one + modified_buffer << modified[:new_data] + buffer_io.seek(modified[:start] + modified[:length]) else # Include the valid record modified_buffer << record_header diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 6893aa3..38f0d7e 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -255,9 +255,9 @@ class RubyFit::MessageWriter timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, tot_timer_time_sec: { id: 0, type: RubyFit::Type.duration }, num_sessions: { id: 1, type: RubyFit::Type.uint16 }, - type_code: { id: 2, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::ACTIVITY_TYPE }, - event_code: { id: 3, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT }, - event_type_code: { id: 4, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE }, + type_code: { id: 2, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::ACTIVITY_TYPE.merge(RubyFit::MessageConstants::ACTIVITY_TYPE.values.map { |v| [v, v] }.to_h) }, + event_code: { id: 3, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT.merge(RubyFit::MessageConstants::EVENT.values.map { |v| [v, v] }.to_h) }, + event_type_code: { id: 4, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE.merge(RubyFit::MessageConstants::EVENT_TYPE.values.map { |v| [v, v] }.to_h) }, local_timestamp: { id: 5, type: RubyFit::Type.timestamp }, } }, diff --git a/lib/rubyfit/validations.rb b/lib/rubyfit/validations.rb index 45c07c7..0eadb20 100644 --- a/lib/rubyfit/validations.rb +++ b/lib/rubyfit/validations.rb @@ -2,20 +2,49 @@ class RubyFit::Validations def self.validate_message(message_type, data) if message_type == :lap - data = self.lap(data) + data, modified = self.lap(data) + elsif message_type == :activity + data, modified = self.activity(data) end - data + [data, modified] end def self.lap(lap) + raw_lap = nil + modified = false if lap[:timestamp].nil? || lap[:timestamp] == 0 || lap[:timestamp].to_s == "1989-12-31 00:00:00 UTC" if !lap[:start_time].nil? && !lap[:tot_elapsed_time_sec].nil? lap[:timestamp] = lap[:start_time] + lap[:tot_elapsed_time_sec] + + definition = RubyFit::MessageWriter.definition_message(:lap, 0) + data = RubyFit::MessageWriter.data_message(:lap, 0, lap) + raw_lap = definition + data else - lap = nil + # Lap is totally invalid, so we set it to nil + raw_lap = nil + end + modified = true + end + [raw_lap, modified] + end + + def self.activity(activity) + raw_activity = nil + modified = false + + if activity[:local_timestamp].to_s == "1989-12-31 00:00:00 UTC" + if activity[:timestamp] + # Remove the local timestamp to avoid confusion in CRUX + activity[:local_timestamp] = nil + + definition = RubyFit::MessageWriter.definition_message(:activity, 0) + data = RubyFit::MessageWriter.data_message(:activity, 0, activity) + raw_activity = definition + data + modified = true end end - lap + + [raw_activity, modified] end end \ No newline at end of file diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 5355368..d647f61 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -127,6 +127,7 @@ def test_fit_file_with_invalid_lap raw = IO.read(new_fit_file_path) parser.parse(raw) do |data| json_output = JSON.parse(data.to_json) + assert_nil(json_output['activity']['local_timestamp']) assert_equal(1, json_output['laps'].size) end end From c8ccf27a57505f2d0ef27339a401e7406fe3043b Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 9 May 2025 08:30:55 -0400 Subject: [PATCH 077/104] add repair file function to add missing session block --- lib/rubyfit/fit_parser.rb | 28 ++++++++++++++++++++++------ lib/rubyfit/message_writer.rb | 2 +- lib/rubyfit/validations.rb | 32 +++++++++++++++++++++++++++++++- test/fit_parser_test.rb | 20 ++++++++++++++++++++ 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 0e5068c..8113d05 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -78,7 +78,7 @@ def convert_to_json(fit_data, unpack_directive) { message_type => readable_data } end - def get_valid_data(fit_data, unpack_directive) + def get_valid_data(fit_data, unpack_directive, raw_data) big_endian = unpack_directive == 'n' # Define the message type to look up type = RubyFit::MessageConstants::MESSAGE_TYPE.find { |key, value| value == fit_data.keys.first } @@ -109,7 +109,7 @@ def get_valid_data(fit_data, unpack_directive) end end - valid_data, modified = RubyFit::Validations.validate_message(message_type, readable_data) + valid_data, modified = RubyFit::Validations.validate_message(message_type, readable_data, raw_data) [valid_data, modified] end @@ -243,6 +243,9 @@ def parse(raw) def repair_fit_file(raw) invalid_offsets = [] # To store offsets and lengths of invalid messages modified_messages = [] # To store modified messages + added_messages = [] # To store added messages + + processed_sessions = false io = StringIO.new(raw) header = io.read(12) @@ -310,7 +313,11 @@ def repair_fit_file(raw) end data_message(local_num, values) - data, modified = get_valid_data({ definition[:global_message_number] => values }, unpack_directive) + data, modified = get_valid_data({ definition[:global_message_number] => values }, unpack_directive, raw) + if definition[:global_message_number] == 18 + processed_sessions = true + end + if data.nil? && modified # Record the offset and length of the invalid message invalid_offsets << { start: record_start, length: buffer_io.pos - record_start } @@ -320,12 +327,16 @@ def repair_fit_file(raw) end end - # Pass invalid_offsets to the edit_fit_file_raw function - yield edit_fit_file_raw(raw, invalid_offsets, modified_messages) + unless processed_sessions + data, modified = RubyFit::Validations.validate_message(:session, {}, raw) + added_messages << { start: header_size + data_size, length: data.size, new_data: data } if data && modified + end + + yield edit_fit_file_raw(raw, invalid_offsets, modified_messages, added_messages) end - def edit_fit_file_raw(raw, invalid_offsets, modified_messages) + def edit_fit_file_raw(raw, invalid_offsets, modified_messages, added_messages) io = StringIO.new(raw) # Read and parse the header @@ -370,6 +381,11 @@ def edit_fit_file_raw(raw, invalid_offsets, modified_messages) end end + # Append added messages + added_messages.each do |message| + modified_buffer << message[:new_data] + end + # Recalculate the data size new_data_size = modified_buffer.bytesize diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 38f0d7e..7dc19a4 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -210,7 +210,7 @@ class RubyFit::MessageWriter event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE.merge(RubyFit::MessageConstants::EVENT_TYPE.values.map { |v| [v, v] }.to_h), required: true }, start_time: { id: 2, type: RubyFit::Type.timestamp, required: true }, sport_code: { id: 5, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT.merge(RubyFit::MessageConstants::SPORT.values.map { |v| [v, v] }.to_h), required: true }, - sub_sport_code: { id: 6, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT }, + sub_sport_code: { id: 6, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT.merge(RubyFit::MessageConstants::SUBSPORT.values.map { |v| [v, v] }.to_h) }, tot_elapsed_time_sec: { id: 7, type: RubyFit::Type.duration }, tot_timer_time_sec: { id: 8, type: RubyFit::Type.duration }, tot_dist_m: { id: 9, type: RubyFit::Type.centimeters }, diff --git a/lib/rubyfit/validations.rb b/lib/rubyfit/validations.rb index 0eadb20..4b5417a 100644 --- a/lib/rubyfit/validations.rb +++ b/lib/rubyfit/validations.rb @@ -1,10 +1,17 @@ class RubyFit::Validations - def self.validate_message(message_type, data) + def self.validate_message(message_type, data, raw_data) if message_type == :lap data, modified = self.lap(data) elsif message_type == :activity data, modified = self.activity(data) + elsif message_type == :session + if data == {} + fit_parser = RubyFit::FitFileParser.new + fit_parser.parse(raw_data) do |parsed_data| + data, modified = self.session(data, parsed_data[:laps], parsed_data[:sport]) + end + end end [data, modified] end @@ -47,4 +54,27 @@ def self.activity(activity) [raw_activity, modified] end + + def self.session(session, laps, sport) + raw_session = nil + if session == {} + # calculates session values and sets them in the session hash if there is no session + session[:timestamp] = laps.last[:timestamp] + session[:start_time] = laps.first[:start_time] + session[:tot_elapsed_time_sec] = laps.sum { |lap| lap[:tot_elapsed_time_sec] } + session[:tot_timer_time_sec] = laps.sum { |lap| lap[:tot_timer_time_sec] } + session[:tot_dist_m] = laps.sum { |lap| lap[:tot_dist_m] } + session[:event_code] = 8 + session[:event_type_code] = 0 + session[:sport_code] = sport[:sport_code] || 2 + session[:sub_sport_code] = sport[:sub_sport_code] || 0 + + definition = RubyFit::MessageWriter.definition_message(:session, 0) + data = RubyFit::MessageWriter.data_message(:session, 0, session) + raw_session = definition + data + modified = true + end + [raw_session, modified] + end + end \ No newline at end of file diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index d647f61..72e2187 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -131,4 +131,24 @@ def test_fit_file_with_invalid_lap assert_equal(1, json_output['laps'].size) end end + + def test_fit_file_with_no_session + fit_file_path = 'test/fixtures/2025-05-08-114933-ELEMNT_ACE_115C-42-0.fit' + new_fit_file_path = 'test/fixtures/2025-05-08-114933-ELEMNT_ACE_115C-42-0-new.fit' + raw = IO.read(fit_file_path) + + parser = RubyFit::FitFileParser.new + parser.repair_fit_file(raw) do |data| + new_file_string = data + File.open(new_fit_file_path, 'wb') do |file| + file.write(new_file_string) + end + end + + raw = IO.read(new_fit_file_path) + parser.parse(raw) do |data| + json_output = JSON.parse(data.to_json) + assert_equal(1, json_output['sessions'].size) + end + end end \ No newline at end of file From c2adb4281aa90aadf78452ede79b8b33902f4afe Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 4 Jun 2025 07:42:57 -0400 Subject: [PATCH 078/104] make wahoo id a hash instead of array in parsing --- lib/rubyfit/fit_parser.rb | 3 ++- test/fit_parser_test.rb | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 8113d05..45d6b64 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -18,6 +18,7 @@ def initialize segment_lap: :segment_laps, wahoo_custom_num: :wahoo_custom_nums } + @use_last_message_only = [:wahoo_id] end def definition_message(local_num, global_message_number, fields, developer_fields) @@ -226,7 +227,7 @@ def parse(raw) key = @plural_message_types[key] all_data[key] = [] unless all_data[key].is_a?(Array) all_data[key] << value - elsif all_data.key?(key) + elsif all_data.key?(key) && !@use_last_message_only.include?(key) all_data[key] = [all_data[key]] unless all_data[key].is_a?(Array) all_data[key] << value else diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 72e2187..977dabc 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -41,9 +41,9 @@ def test_little_endian_file_decoding assert_equal(6, json_output['workout'][0]['sub_sport_code']) assert_equal('Indoor Cycling', json_output['workout'][0]['wkt_name']) - assert_equal('WAHOOAPPIOS62BB', json_output['wahoo_id'][0]['app_token']) - assert_equal(3, json_output['wahoo_id'][0]['workout_num']) - assert_equal(12, json_output['wahoo_id'][0]['workout_type']) + assert_equal('WAHOOAPPIOS62BB', json_output['wahoo_id']['app_token']) + assert_equal(3, json_output['wahoo_id']['workout_num']) + assert_equal(12, json_output['wahoo_id']['workout_type']) assert_equal(2, json_output['sessions'][0]['sport_code']) assert_equal(6, json_output['sessions'][0]['sub_sport_code']) From 795b0ec54fbbe8e963c81124014ec55f3dfe463a Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 5 Jun 2025 08:21:24 -0400 Subject: [PATCH 079/104] add build lap and session methods to repair function --- lib/rubyfit/fit_parser.rb | 23 ++++-- lib/rubyfit/helpers.rb | 52 ++++++++----- lib/rubyfit/message_writer.rb | 2 +- lib/rubyfit/validations.rb | 137 ++++++++++++++++++++++++++++------ test/fit_parser_test.rb | 47 ++++++++++++ 5 files changed, 212 insertions(+), 49 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 45d6b64..4c02d25 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -213,7 +213,7 @@ def parse(raw) definition[:developer_fields]&.each do |field| value = buffer_io.read(field[:size]) if value.nil? || value.size < field[:size] - puts "Warning: Missing or incomplete developer field value for field ID #{field[:id]}" + puts "Warning: Missing or incomplete developer field value for field ID #{field} at #{buffer_io.pos} for parse" next end developer_values[field[:id]] = value @@ -247,6 +247,7 @@ def repair_fit_file(raw) added_messages = [] # To store added messages processed_sessions = false + processed_laps = false io = StringIO.new(raw) header = io.read(12) @@ -286,7 +287,6 @@ def repair_fit_file(raw) else [] end - definition_message(local_num, global_message_number, fields, developer_fields) else local_num = record_header & 0x0F @@ -307,7 +307,7 @@ def repair_fit_file(raw) definition[:developer_fields]&.each do |field| value = buffer_io.read(field[:size]) if value.nil? || value.size < field[:size] - puts "Warning: Missing or incomplete developer field value for field ID #{field[:id]}" + puts "Warning: Missing or incomplete developer field value for field ID #{field[:id]} at #{buffer_io.pos}" next end developer_values[field[:id]] = value @@ -318,6 +318,9 @@ def repair_fit_file(raw) if definition[:global_message_number] == 18 processed_sessions = true end + if definition[:global_message_number] == 19 + processed_laps = true + end if data.nil? && modified # Record the offset and length of the invalid message @@ -328,9 +331,16 @@ def repair_fit_file(raw) end end - unless processed_sessions - data, modified = RubyFit::Validations.validate_message(:session, {}, raw) - added_messages << { start: header_size + data_size, length: data.size, new_data: data } if data && modified + if !processed_laps && !processed_sessions + lap_data, session_data, modified = RubyFit::Validations.build_lap_and_session(raw) + added_messages << { new_data: lap_data } if lap_data && modified + added_messages << { new_data: session_data } if session_data && modified + elsif !processed_laps + lap_data, modified = RubyFit::Validations.build_lap(raw) + added_messages << { new_data: lap_data } if lap_data && modified + elsif !processed_sessions + session_data, modified = RubyFit::Validations.build_session(raw) + added_messages << { new_data: session_data } if session_data && modified end yield edit_fit_file_raw(raw, invalid_offsets, modified_messages, added_messages) @@ -346,7 +356,6 @@ def edit_fit_file_raw(raw, invalid_offsets, modified_messages, added_messages) header_size, protocol_version, profile_version, data_size, data_type = header.unpack('C C v V a4') raise "Invalid FIT file: invalid data type" unless data_type == ".FIT" - puts("Header size: #{header_size}, Protocol version: #{protocol_version}, Profile version: #{profile_version}, Data size: #{data_size}, Data type: #{data_type}") # Parse the data section io.seek(header_size) buffer = io.read(data_size) diff --git a/lib/rubyfit/helpers.rb b/lib/rubyfit/helpers.rb index 21a463c..a5eb174 100644 --- a/lib/rubyfit/helpers.rb +++ b/lib/rubyfit/helpers.rb @@ -66,10 +66,10 @@ def str2bytes(str, byte_count) # Converts a byte array to a string. Omits the last character of the byte # array from the result if it is 0 - def bytes2str(bytes) - bytes.pop while bytes.last == 0 - bytes.pack("C*") - end + def bytes2str(bytes) + bytes.pop while bytes.last == 0 + bytes.pack("C*") + end # Generates strings of hex bytes (for debugging) def bytes2hex(bytes) @@ -107,20 +107,36 @@ def make_message_header(opts = {}) result end - def self.update_crc(crc, data) - crc_table = [0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, - 0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400] - data.each_byte do |byte| - # compute checksum of lower four bits of byte - tmp = crc_table[crc & 0xF] - crc = (crc >> 4) & 0x0FFF - crc = crc ^ tmp ^ crc_table[byte & 0xF] - # now compute checksum of upper four bits of byte - tmp = crc_table[crc & 0xF] - crc = (crc >> 4) & 0x0FFF - crc = crc ^ tmp ^ crc_table[(byte >> 4) & 0xF] + def self.update_crc(crc, data) + crc_table = [0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, + 0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400] + data.each_byte do |byte| + # compute checksum of lower four bits of byte + tmp = crc_table[crc & 0xF] + crc = (crc >> 4) & 0x0FFF + crc = crc ^ tmp ^ crc_table[byte & 0xF] + # now compute checksum of upper four bits of byte + tmp = crc_table[crc & 0xF] + crc = (crc >> 4) & 0x0FFF + crc = crc ^ tmp ^ crc_table[(byte >> 4) & 0xF] + end + crc + end + + def self.calculate_timer_time(events) + total_time = 0 + start_time = nil + + events.each do |event| + if event[:event_type_code] == :start + start_time = event[:timestamp] + elsif event[:event_type_code] == :stop && start_time + total_time += event[:timestamp] - start_time + start_time = nil + end + end + + total_time end - crc - end end end diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 7dc19a4..5412d52 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -245,7 +245,7 @@ class RubyFit::MessageWriter min_alt_m: { id: 71, type: RubyFit::Type.altitude }, enhanced_avg_speed: { id: 124, type: RubyFit::Type.uint32 }, enhanced_max_speed: { id: 125, type: RubyFit::Type.uint32 } - # workout_type: { id: 78, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::WORKOUT_TYPE } + # workout_rpe: { id: 193, type: RubyFit::Type.uint8 }, } }, diff --git a/lib/rubyfit/validations.rb b/lib/rubyfit/validations.rb index 4b5417a..db8418b 100644 --- a/lib/rubyfit/validations.rb +++ b/lib/rubyfit/validations.rb @@ -2,16 +2,11 @@ class RubyFit::Validations def self.validate_message(message_type, data, raw_data) if message_type == :lap - data, modified = self.lap(data) + data, modified = self.lap(data) elsif message_type == :activity data, modified = self.activity(data) elsif message_type == :session - if data == {} - fit_parser = RubyFit::FitFileParser.new - fit_parser.parse(raw_data) do |parsed_data| - data, modified = self.session(data, parsed_data[:laps], parsed_data[:sport]) - end - end + data, modified = self.session(data) end [data, modified] end @@ -36,29 +31,49 @@ def self.lap(lap) [raw_lap, modified] end - def self.activity(activity) - raw_activity = nil + def self.build_lap(raw_data) + lap = {} modified = false + raw_lap = nil - if activity[:local_timestamp].to_s == "1989-12-31 00:00:00 UTC" - if activity[:timestamp] - # Remove the local timestamp to avoid confusion in CRUX - activity[:local_timestamp] = nil + fit_parser = RubyFit::FitFileParser.new + fit_parser.parse(raw_data) do |parsed_data| + records = parsed_data[:records] + events = parsed_data[:events] + sport = parsed_data[:sport] || {} - definition = RubyFit::MessageWriter.definition_message(:activity, 0) - data = RubyFit::MessageWriter.data_message(:activity, 0, activity) - raw_activity = definition + data - modified = true - end - end + # calculates lap values and sets them in the lap hash if there is no lap + lap[:timestamp] = records.first[:timestamp] + lap[:start_time] = records.first[:timestamp] + lap[:start_lat_deg] = records.first[:lat_deg] + lap[:start_lon_deg] = records.first[:lon_deg] + lap[:tot_elapsed_time_sec] = records.last[:timestamp].to_i - records.first[:timestamp].to_i + lap[:tot_timer_time_sec] = RubyFit::Helpers.calculate_timer_time(events) + lap[:tot_dist_m] = records.last[:dist_m] + lap[:event_code] = 9 + lap[:event_type_code] = 1 + lap[:sport_code] = sport[:sport_code] || 2 + lap[:sub_sport_code] = sport[:sub_sport_code] || 0 - [raw_activity, modified] + definition = RubyFit::MessageWriter.definition_message(:lap, 0) + data = RubyFit::MessageWriter.data_message(:lap, 0, lap) + raw_lap = definition + data + modified = true + end + [raw_lap, modified] end - def self.session(session, laps, sport) + def self.build_session(raw_data) + session = {} + modified = false raw_session = nil - if session == {} - # calculates session values and sets them in the session hash if there is no session + + fit_parser = RubyFit::FitFileParser.new + fit_parser.parse(raw_data) do |parsed_data| + laps = parsed_data[:laps] + return if laps.empty? + sport = parsed_data[:sport] || {} + session[:timestamp] = laps.last[:timestamp] session[:start_time] = laps.first[:start_time] session[:tot_elapsed_time_sec] = laps.sum { |lap| lap[:tot_elapsed_time_sec] } @@ -77,4 +92,80 @@ def self.session(session, laps, sport) [raw_session, modified] end + def self.build_lap_and_session(raw_data) + lap = {} + session = {} + + modified = false + raw_lap = nil + raw_session = nil + + fit_parser = RubyFit::FitFileParser.new + fit_parser.parse(raw_data) do |parsed_data| + records = parsed_data[:records] + events = parsed_data[:events] + sport = parsed_data[:sport] || {} + + lap[:timestamp] = records.first[:timestamp] + lap[:start_time] = records.first[:timestamp] + lap[:start_lat_deg] = records.first[:lat_deg] + lap[:start_lon_deg] = records.first[:lon_deg] + lap[:tot_elapsed_time_sec] = records.last[:timestamp].to_i - records.first[:timestamp].to_i + lap[:tot_elapsed_time_sec] = 0 + lap[:tot_timer_time_sec] = RubyFit::Helpers.calculate_timer_time(events) + lap[:tot_dist_m] = records.last[:dist_m] + lap[:event_code] = 9 + lap[:event_type_code] = 1 + lap[:sport_code] = sport[:sport_code] || 2 + lap[:sub_sport_code] = sport[:sub_sport_code] || 0 + + session[:timestamp] = lap[:timestamp] + session[:start_time] = lap[:start_time] + session[:tot_elapsed_time_sec] = lap[:tot_elapsed_time_sec] + session[:tot_timer_time_sec] = lap[:tot_timer_time_sec] + session[:tot_dist_m] = lap[:tot_dist_m] + session[:event_code] = 8 + session[:event_type_code] = 1 + session[:sport_code] = sport[:sport_code] || 2 + session[:sub_sport_code] = sport[:sub_sport_code] || 0 + + definition = RubyFit::MessageWriter.definition_message(:lap, 0) + data = RubyFit::MessageWriter.data_message(:lap, 0, lap) + raw_lap = definition + data + + definition = RubyFit::MessageWriter.definition_message(:session, 0) + data = RubyFit::MessageWriter.data_message(:session, 0, session) + raw_session = definition + data + + modified = true + end + [raw_lap, raw_session, modified] + end + + def self.activity(activity) + raw_activity = nil + modified = false + + if activity[:local_timestamp].to_s == "1989-12-31 00:00:00 UTC" + if activity[:timestamp] + # Remove the local timestamp to avoid confusion in CRUX + activity[:local_timestamp] = nil + + definition = RubyFit::MessageWriter.definition_message(:activity, 0) + data = RubyFit::MessageWriter.data_message(:activity, 0, activity) + raw_activity = definition + data + modified = true + end + end + + [raw_activity, modified] + end + + def self.session(session) + raw_session = nil + modified = false + + [raw_session, modified] + end + end \ No newline at end of file diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 977dabc..881a70d 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -4,6 +4,7 @@ require_relative '../lib/rubyfit/message_constants' require_relative '../lib/rubyfit/fit_parser' require_relative '../examples/fit_callbacks' +require_relative '../lib/rubyfit/helpers' class FitParserTest < Minitest::Test def test_extremely_large_file start = Time.now @@ -151,4 +152,50 @@ def test_fit_file_with_no_session assert_equal(1, json_output['sessions'].size) end end + + def test_fit_file_with_no_session_and_no_laps + fit_file_path = 'test/fixtures/2025-05-24-145948-WAHOOAPPIOS010F-131-0_fixed.fit' + new_fit_file_path = 'test/fixtures/2025-05-24-145948-WAHOOAPPIOS010F-131-0_fixed-new.fit' + + raw = IO.read(fit_file_path) + + parser = RubyFit::FitFileParser.new + parser.repair_fit_file(raw) do |data| + new_file_string = data + File.open(new_fit_file_path, 'wb') do |file| + file.write(new_file_string) + end + end + + raw = IO.read(new_fit_file_path) + parser.parse(raw) do |data| + json_output = JSON.parse(data.to_json) + refute_nil(json_output) + + assert_equal(1, json_output['sessions'].size) + assert_equal(1, json_output['laps'].size) + end + end + + # def test_fit_file_with_rpe + # fit_file_path = 'test/fixtures/2-very-strong.fit' + # new_fit_file_path = 'test/fixtures/2-very-strong-new.fit' + # raw = IO.read(fit_file_path) + # + # parser = RubyFit::FitFileParser.new + # parser.repair_fit_file(raw) do |data| + # new_file_string = data + # File.open(new_fit_file_path, 'wb') do |file| + # file.write(new_file_string) + # end + # end + # + # raw = IO.read(new_fit_file_path) + # parser.parse(raw) do |data| + # json_output = JSON.parse(data.to_json) + # refute_nil(json_output) + # assert_equal(1, json_output['sessions'].size) + # assert_equal(20, json_output['sessions'][0]['workout_rpe']) + # end + # end end \ No newline at end of file From b03259cecf841574f33da11ab0aeefa171298754 Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 5 Jun 2025 08:27:31 -0400 Subject: [PATCH 080/104] add rpe to session --- lib/rubyfit/message_writer.rb | 6 ++--- test/fit_parser_test.rb | 42 +++++++++++++++++------------------ 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 5412d52..bdc9ef1 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -244,9 +244,9 @@ class RubyFit::MessageWriter time_in_pwr_zone_sec: { id: 68, type: RubyFit::Type.uint32_array(8) }, min_alt_m: { id: 71, type: RubyFit::Type.altitude }, enhanced_avg_speed: { id: 124, type: RubyFit::Type.uint32 }, - enhanced_max_speed: { id: 125, type: RubyFit::Type.uint32 } - # workout_rpe: { id: 193, type: RubyFit::Type.uint8 }, - } + enhanced_max_speed: { id: 125, type: RubyFit::Type.uint32 }, + workout_rpe: { id: 193, type: RubyFit::Type.uint8 } + } }, activity: { diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 881a70d..1cd8e32 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -177,25 +177,25 @@ def test_fit_file_with_no_session_and_no_laps end end - # def test_fit_file_with_rpe - # fit_file_path = 'test/fixtures/2-very-strong.fit' - # new_fit_file_path = 'test/fixtures/2-very-strong-new.fit' - # raw = IO.read(fit_file_path) - # - # parser = RubyFit::FitFileParser.new - # parser.repair_fit_file(raw) do |data| - # new_file_string = data - # File.open(new_fit_file_path, 'wb') do |file| - # file.write(new_file_string) - # end - # end - # - # raw = IO.read(new_fit_file_path) - # parser.parse(raw) do |data| - # json_output = JSON.parse(data.to_json) - # refute_nil(json_output) - # assert_equal(1, json_output['sessions'].size) - # assert_equal(20, json_output['sessions'][0]['workout_rpe']) - # end - # end + def test_fit_file_with_rpe + fit_file_path = 'test/fixtures/2-very-strong.fit' + new_fit_file_path = 'test/fixtures/2-very-strong-new.fit' + raw = IO.read(fit_file_path) + + parser = RubyFit::FitFileParser.new + parser.repair_fit_file(raw) do |data| + new_file_string = data + File.open(new_fit_file_path, 'wb') do |file| + file.write(new_file_string) + end + end + + raw = IO.read(new_fit_file_path) + parser.parse(raw) do |data| + json_output = JSON.parse(data.to_json) + refute_nil(json_output) + assert_equal(1, json_output['sessions'].size) + assert_equal(20, json_output['sessions'][0]['workout_rpe']) + end + end end \ No newline at end of file From b8f34e9e40bcb3d28e1c08c4277df101284e282e Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 6 Jun 2025 13:06:35 -0400 Subject: [PATCH 081/104] add validation for activity timer time --- lib/rubyfit/fit_parser.rb | 39 +++++--- lib/rubyfit/helpers.rb | 12 ++- lib/rubyfit/validations.rb | 191 +++++++++++++++++++------------------ test/fit_parser_test.rb | 22 +++++ 4 files changed, 153 insertions(+), 111 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 4c02d25..e8638df 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -245,6 +245,7 @@ def repair_fit_file(raw) invalid_offsets = [] # To store offsets and lengths of invalid messages modified_messages = [] # To store modified messages added_messages = [] # To store added messages + original_data_info = {} processed_sessions = false processed_laps = false @@ -322,6 +323,8 @@ def repair_fit_file(raw) processed_laps = true end + original_data_info[:global_message_number] = {start: record_start, length: buffer_io.pos - record_start} + if data.nil? && modified # Record the offset and length of the invalid message invalid_offsets << { start: record_start, length: buffer_io.pos - record_start } @@ -331,21 +334,33 @@ def repair_fit_file(raw) end end - if !processed_laps && !processed_sessions - lap_data, session_data, modified = RubyFit::Validations.build_lap_and_session(raw) - added_messages << { new_data: lap_data } if lap_data && modified - added_messages << { new_data: session_data } if session_data && modified - elsif !processed_laps - lap_data, modified = RubyFit::Validations.build_lap(raw) - added_messages << { new_data: lap_data } if lap_data && modified - elsif !processed_sessions - session_data, modified = RubyFit::Validations.build_session(raw) - added_messages << { new_data: session_data } if session_data && modified - end - + added_messages, modified_messages = post_parse_repairs(raw, processed_laps, processed_sessions, added_messages, modified_messages, original_data_info) yield edit_fit_file_raw(raw, invalid_offsets, modified_messages, added_messages) end + def post_parse_repairs(raw, processed_laps, processed_sessions, added_messages, modified_messages, original_data_info) + parser = RubyFit::FitFileParser.new + parser.parse(raw) do |parsed_data| + if !processed_laps && !processed_sessions + lap_data, session_data, modified = RubyFit::Validations.build_lap_and_session(parsed_data) + added_messages << { new_data: lap_data } if lap_data && modified + added_messages << { new_data: session_data } if session_data && modified + elsif !processed_laps + lap_data, modified = RubyFit::Validations.build_lap(parsed_data) + added_messages << { new_data: lap_data } if lap_data && modified + elsif !processed_sessions + session_data, modified = RubyFit::Validations.build_session(parsed_data) + added_messages << { new_data: session_data } if session_data && modified + end + activity_data, modified = RubyFit::Validations.post_parsed_activity(parsed_data) + activity_info = original_data_info[:global_message_number] + if activity_data && modified && activity_info + modified_messages << { start: activity_info[:start], length: activity_info[:length], new_data: activity_data } + end + end + [added_messages, modified_messages] + end + def edit_fit_file_raw(raw, invalid_offsets, modified_messages, added_messages) io = StringIO.new(raw) diff --git a/lib/rubyfit/helpers.rb b/lib/rubyfit/helpers.rb index a5eb174..6d61db6 100644 --- a/lib/rubyfit/helpers.rb +++ b/lib/rubyfit/helpers.rb @@ -126,13 +126,15 @@ def self.update_crc(crc, data) def self.calculate_timer_time(events) total_time = 0 start_time = nil + timer_on = false events.each do |event| - if event[:event_type_code] == :start - start_time = event[:timestamp] - elsif event[:event_type_code] == :stop && start_time - total_time += event[:timestamp] - start_time - start_time = nil + if event[:event_type_code] == 0 + timer_on = true + start_time = event[:timestamp].to_i + elsif (event[:event_type_code] == 1 || event[:event_type_code] == 4 || event[:event_type_code] == 8) && timer_on + total_time += event[:timestamp].to_i - start_time + timer_on = false end end diff --git a/lib/rubyfit/validations.rb b/lib/rubyfit/validations.rb index db8418b..608f2bd 100644 --- a/lib/rubyfit/validations.rb +++ b/lib/rubyfit/validations.rb @@ -31,114 +31,99 @@ def self.lap(lap) [raw_lap, modified] end - def self.build_lap(raw_data) + def self.build_lap(parsed_data) lap = {} - modified = false - raw_lap = nil + records = parsed_data[:records] + events = parsed_data[:events] + sport = parsed_data[:sport] || {} + + # calculates lap values and sets them in the lap hash if there is no lap + lap[:timestamp] = records.first[:timestamp] + lap[:start_time] = records.first[:timestamp] + lap[:start_lat_deg] = records.first[:lat_deg] + lap[:start_lon_deg] = records.first[:lon_deg] + lap[:tot_elapsed_time_sec] = records.last[:timestamp].to_i - records.first[:timestamp].to_i + lap[:tot_timer_time_sec] = RubyFit::Helpers.calculate_timer_time(events) + lap[:tot_dist_m] = records.last[:dist_m] + lap[:event_code] = 9 + lap[:event_type_code] = 1 + lap[:sport_code] = sport[:sport_code] || 2 + lap[:sub_sport_code] = sport[:sub_sport_code] || 0 + + definition = RubyFit::MessageWriter.definition_message(:lap, 0) + data = RubyFit::MessageWriter.data_message(:lap, 0, lap) + raw_lap = definition + data + modified = true - fit_parser = RubyFit::FitFileParser.new - fit_parser.parse(raw_data) do |parsed_data| - records = parsed_data[:records] - events = parsed_data[:events] - sport = parsed_data[:sport] || {} - - # calculates lap values and sets them in the lap hash if there is no lap - lap[:timestamp] = records.first[:timestamp] - lap[:start_time] = records.first[:timestamp] - lap[:start_lat_deg] = records.first[:lat_deg] - lap[:start_lon_deg] = records.first[:lon_deg] - lap[:tot_elapsed_time_sec] = records.last[:timestamp].to_i - records.first[:timestamp].to_i - lap[:tot_timer_time_sec] = RubyFit::Helpers.calculate_timer_time(events) - lap[:tot_dist_m] = records.last[:dist_m] - lap[:event_code] = 9 - lap[:event_type_code] = 1 - lap[:sport_code] = sport[:sport_code] || 2 - lap[:sub_sport_code] = sport[:sub_sport_code] || 0 - - definition = RubyFit::MessageWriter.definition_message(:lap, 0) - data = RubyFit::MessageWriter.data_message(:lap, 0, lap) - raw_lap = definition + data - modified = true - end [raw_lap, modified] end - def self.build_session(raw_data) + def self.build_session(parsed_data) session = {} - modified = false - raw_session = nil - fit_parser = RubyFit::FitFileParser.new - fit_parser.parse(raw_data) do |parsed_data| - laps = parsed_data[:laps] - return if laps.empty? - sport = parsed_data[:sport] || {} - - session[:timestamp] = laps.last[:timestamp] - session[:start_time] = laps.first[:start_time] - session[:tot_elapsed_time_sec] = laps.sum { |lap| lap[:tot_elapsed_time_sec] } - session[:tot_timer_time_sec] = laps.sum { |lap| lap[:tot_timer_time_sec] } - session[:tot_dist_m] = laps.sum { |lap| lap[:tot_dist_m] } - session[:event_code] = 8 - session[:event_type_code] = 0 - session[:sport_code] = sport[:sport_code] || 2 - session[:sub_sport_code] = sport[:sub_sport_code] || 0 - - definition = RubyFit::MessageWriter.definition_message(:session, 0) - data = RubyFit::MessageWriter.data_message(:session, 0, session) - raw_session = definition + data - modified = true - end + laps = parsed_data[:laps] + return if laps.empty? + sport = parsed_data[:sport] || {} + + session[:timestamp] = laps.last[:timestamp] + session[:start_time] = laps.first[:start_time] + session[:tot_elapsed_time_sec] = laps.sum { |lap| lap[:tot_elapsed_time_sec] } + session[:tot_timer_time_sec] = laps.sum { |lap| lap[:tot_timer_time_sec] } + session[:tot_dist_m] = laps.sum { |lap| lap[:tot_dist_m] } + session[:event_code] = 8 + session[:event_type_code] = 0 + session[:sport_code] = sport[:sport_code] || 2 + session[:sub_sport_code] = sport[:sub_sport_code] || 0 + + definition = RubyFit::MessageWriter.definition_message(:session, 0) + data = RubyFit::MessageWriter.data_message(:session, 0, session) + raw_session = definition + data + modified = true + [raw_session, modified] end - def self.build_lap_and_session(raw_data) + def self.build_lap_and_session(parsed_data) lap = {} session = {} - modified = false - raw_lap = nil - raw_session = nil - - fit_parser = RubyFit::FitFileParser.new - fit_parser.parse(raw_data) do |parsed_data| - records = parsed_data[:records] - events = parsed_data[:events] - sport = parsed_data[:sport] || {} - - lap[:timestamp] = records.first[:timestamp] - lap[:start_time] = records.first[:timestamp] - lap[:start_lat_deg] = records.first[:lat_deg] - lap[:start_lon_deg] = records.first[:lon_deg] - lap[:tot_elapsed_time_sec] = records.last[:timestamp].to_i - records.first[:timestamp].to_i - lap[:tot_elapsed_time_sec] = 0 - lap[:tot_timer_time_sec] = RubyFit::Helpers.calculate_timer_time(events) - lap[:tot_dist_m] = records.last[:dist_m] - lap[:event_code] = 9 - lap[:event_type_code] = 1 - lap[:sport_code] = sport[:sport_code] || 2 - lap[:sub_sport_code] = sport[:sub_sport_code] || 0 - - session[:timestamp] = lap[:timestamp] - session[:start_time] = lap[:start_time] - session[:tot_elapsed_time_sec] = lap[:tot_elapsed_time_sec] - session[:tot_timer_time_sec] = lap[:tot_timer_time_sec] - session[:tot_dist_m] = lap[:tot_dist_m] - session[:event_code] = 8 - session[:event_type_code] = 1 - session[:sport_code] = sport[:sport_code] || 2 - session[:sub_sport_code] = sport[:sub_sport_code] || 0 - - definition = RubyFit::MessageWriter.definition_message(:lap, 0) - data = RubyFit::MessageWriter.data_message(:lap, 0, lap) - raw_lap = definition + data - - definition = RubyFit::MessageWriter.definition_message(:session, 0) - data = RubyFit::MessageWriter.data_message(:session, 0, session) - raw_session = definition + data + records = parsed_data[:records] + events = parsed_data[:events] + sport = parsed_data[:sport] || {} + + lap[:timestamp] = records.first[:timestamp] + lap[:start_time] = records.first[:timestamp] + lap[:start_lat_deg] = records.first[:lat_deg] + lap[:start_lon_deg] = records.first[:lon_deg] + lap[:tot_elapsed_time_sec] = records.last[:timestamp].to_i - records.first[:timestamp].to_i + lap[:tot_elapsed_time_sec] = 0 + lap[:tot_timer_time_sec] = RubyFit::Helpers.calculate_timer_time(events) + lap[:tot_dist_m] = records.last[:dist_m] + lap[:event_code] = 9 + lap[:event_type_code] = 1 + lap[:sport_code] = sport[:sport_code] || 2 + lap[:sub_sport_code] = sport[:sub_sport_code] || 0 + + session[:timestamp] = lap[:timestamp] + session[:start_time] = lap[:start_time] + session[:tot_elapsed_time_sec] = lap[:tot_elapsed_time_sec] + session[:tot_timer_time_sec] = lap[:tot_timer_time_sec] + session[:tot_dist_m] = lap[:tot_dist_m] + session[:event_code] = 8 + session[:event_type_code] = 1 + session[:sport_code] = sport[:sport_code] || 2 + session[:sub_sport_code] = sport[:sub_sport_code] || 0 + + definition = RubyFit::MessageWriter.definition_message(:lap, 0) + data = RubyFit::MessageWriter.data_message(:lap, 0, lap) + raw_lap = definition + data + + definition = RubyFit::MessageWriter.definition_message(:session, 0) + data = RubyFit::MessageWriter.data_message(:session, 0, session) + raw_session = definition + data + + modified = true - modified = true - end [raw_lap, raw_session, modified] end @@ -161,6 +146,24 @@ def self.activity(activity) [raw_activity, modified] end + def self.post_parsed_activity(parsed_data) + activity = parsed_data[:activity] || {} + sessions = parsed_data[:sessions] || [] + total_timer_time_from_sessions = sessions.sum { |s| s[:tot_timer_time_sec] || 0 } + + if activity[:tot_timer_time_sec].present? && activity[:tot_timer_time_sec] != total_timer_time_from_sessions + activity[:tot_timer_time_sec] = total_timer_time_from_sessions + definition = RubyFit::MessageWriter.definition_message(:activity, 0) + data = RubyFit::MessageWriter.data_message(:activity, 0, activity) + raw_activity = definition + data + modified = true + else + raw_activity = nil + modified = false + end + [raw_activity, modified] + end + def self.session(session) raw_session = nil modified = false diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 1cd8e32..1c105ee 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -198,4 +198,26 @@ def test_fit_file_with_rpe assert_equal(20, json_output['sessions'][0]['workout_rpe']) end end + + + def test_total_vs_timer_time + fit_file_path = 'test/fixtures/chip_zwift_run.fit' + new_fit_file_path = 'test/fixtures/chip_zwift_run-new.fit' + raw = IO.read(fit_file_path) + + parser = RubyFit::FitFileParser.new + parser.repair_fit_file(raw) do |data| + new_file_string = data + File.open(new_fit_file_path, 'wb') do |file| + file.write(new_file_string) + end + end + + raw = IO.read(new_fit_file_path) + parser.parse(raw) do |data| + json_output = JSON.parse(data.to_json) + refute_nil(json_output) + assert_equal(4116, json_output['activity']['tot_timer_time_sec']) + end + end end \ No newline at end of file From bfcc28e24b939da7923019c6986b12cda0901b47 Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 9 Jun 2025 08:20:17 -0400 Subject: [PATCH 082/104] add sec field to records in parsing --- lib/rubyfit/fit_parser.rb | 6 ++++++ test/fit_parser_test.rb | 1 + 2 files changed, 7 insertions(+) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index e8638df..3ab35dd 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -6,6 +6,7 @@ class RubyFit::FitFileParser def initialize @definitions = {} @fit_data = {} + @record_index = 0 @plural_message_types = {lap: :laps, length: :lengths, hr_zone: :hr_zones, @@ -223,6 +224,11 @@ def parse(raw) data = convert_to_json({ definition[:global_message_number] => values }, unpack_directive) data&.each do |key, value| + if key == :record + value[:sec] = @record_index + @record_index += 1 + end + if @plural_message_types.key?(key) key = @plural_message_types[key] all_data[key] = [] unless all_data[key].is_a?(Array) diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 1c105ee..6072222 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -75,6 +75,7 @@ def test_little_endian_file_decoding assert_equal(0, json_output['records'][1]['pwr_watts']) assert_equal(0, json_output['records'][1]['cal']) assert_equal(85, json_output['records'][0]['batt_soc_perc']) + assert_equal(0, json_output['records'][0]['sec']) assert_equal(85.0, json_output['records'][0]['batt_soc_perc']) assert_equal(110, json_output['records'][242]['pwr_watts']) From 84c5e4adad6a1f4eb21978b6ce6155e5ee9a09da Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 10 Jun 2025 13:46:43 -0400 Subject: [PATCH 083/104] add clm formatting and post parsing event validations --- lib/rubyfit/fit_parser.rb | 13 ++++++++-- lib/rubyfit/message_writer.rb | 6 ++--- lib/rubyfit/validations.rb | 27 ++++++++++++++++++++ lib/rubyfit/writer.rb | 36 ++++++++++++++++++++++++--- test/fit_parser_test.rb | 23 +++++++++++++++++ test/fixtures/example_route_json.json | 3 ++- test/route_test.rb | 8 ++++++ 7 files changed, 107 insertions(+), 9 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 3ab35dd..9a74fd1 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -329,7 +329,8 @@ def repair_fit_file(raw) processed_laps = true end - original_data_info[:global_message_number] = {start: record_start, length: buffer_io.pos - record_start} + original_data_info[definition[:global_message_number]] ||= [] + original_data_info[definition[:global_message_number]] << { start: record_start, length: buffer_io.pos - record_start } if data.nil? && modified # Record the offset and length of the invalid message @@ -359,10 +360,18 @@ def post_parse_repairs(raw, processed_laps, processed_sessions, added_messages, added_messages << { new_data: session_data } if session_data && modified end activity_data, modified = RubyFit::Validations.post_parsed_activity(parsed_data) - activity_info = original_data_info[:global_message_number] + activity_info = original_data_info[34][0] if original_data_info[34] if activity_data && modified && activity_info modified_messages << { start: activity_info[:start], length: activity_info[:length], new_data: activity_data } end + + events_data, modified = RubyFit::Validations.post_parsed_events(parsed_data) + events_info = original_data_info[21] + if events_data && modified && events_info && events_info.size == events_data.size + events_data.each_with_index do |event_data, index| + modified_messages << { start: events_info[index][:start], length: events_info[index][:length], new_data: event_data } + end + end end [added_messages, modified_messages] end diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index bdc9ef1..4189f43 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -116,8 +116,8 @@ class RubyFit::MessageWriter id: 21, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, - event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT.merge(RubyFit::MessageConstants::EVENT.values.map { |v| [v, v] }.to_h), required: true }, - event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE.merge(RubyFit::MessageConstants::EVENT_TYPE.values.map { |v| [v, v] }.to_h), required: true }, + event_code: { id: 0, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT.merge(RubyFit::MessageConstants::EVENT.values.map { |v| [v, v] }.to_h), required: false }, + event_type_code: { id: 1, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::EVENT_TYPE.merge(RubyFit::MessageConstants::EVENT_TYPE.values.map { |v| [v, v] }.to_h), required: false }, data16: { id: 2, type: RubyFit::Type.uint16 }, data: { id: 3, type: RubyFit::Type.uint32 }, event_group: { id: 4, type: RubyFit::Type.uint8 }, @@ -332,7 +332,7 @@ class RubyFit::MessageWriter timestamp: { id: 0, type: RubyFit::Type.timestamp, required: false }, device_index: { id: 1, type: RubyFit::Type.uint8, required: false }, data_len: { id: 2, type: RubyFit::Type.uint8, required: true }, - data: { id: 3, type: RubyFit::Type.byte_array(26), required: true } + data: { id: 3, type: RubyFit::Type.byte_array(23), required: true } } }, diff --git a/lib/rubyfit/validations.rb b/lib/rubyfit/validations.rb index 608f2bd..77e23d4 100644 --- a/lib/rubyfit/validations.rb +++ b/lib/rubyfit/validations.rb @@ -164,6 +164,33 @@ def self.post_parsed_activity(parsed_data) [raw_activity, modified] end + def self.post_parsed_events(parsed_data) + events = parsed_data[:events] || [] + records = parsed_data[:records] || [] + raw_events = [] + modified = false + + events.each do |event| + if (event[:event_type_code] == 4 || event[:event_type_code] == 1) && event[:timestamp] > (records.last[:timestamp] + 5) + event[:timestamp] = records.last[:timestamp] + + definition = RubyFit::MessageWriter.definition_message(:event, 0) + data = RubyFit::MessageWriter.data_message(:event, 0, event) + + raw_event = definition + data + modified = true + raw_events << raw_event + else + definition = RubyFit::MessageWriter.definition_message(:event, 0) + data = RubyFit::MessageWriter.data_message(:event, 0, event) + raw_event = definition + data + raw_events << raw_event + end + end + + [raw_events, modified] + end + def self.session(session) raw_session = nil modified = false diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 31045d0..f1a8ab6 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -21,7 +21,7 @@ def write(stream, opts = {}) @data_crc = 0 - data_size = calculate_data_size(opts[:course_point_count], opts[:track_point_count]) + data_size = calculate_data_size(opts[:course_point_count], opts[:track_point_count], opts[:wahoo_clm_count] || 0) write_data(RubyFit::MessageWriter.file_header(data_size)) write_message(:file_id, { @@ -342,7 +342,8 @@ def wahoo_custom_num(values) def wahoo_clm(values) raise "Can only write wahoo clms inside 'wahoo_clms' block" if @state != :wahoo_clms - write_message(:wahoo_clm, values) + formatted_clm_values = format_clm(values) + write_message(:wahoo_clm, formatted_clm_values) end protected @@ -380,7 +381,7 @@ def update_crc(crc, data) crc end - def calculate_data_size(course_point_count, track_point_count) + def calculate_data_size(course_point_count, track_point_count, wahoo_clm_count = 0) record_counts = { file_id: 1, course: 1, @@ -388,6 +389,7 @@ def calculate_data_size(course_point_count, track_point_count) event: 2, course_point: course_point_count, record: track_point_count, + wahoo_clm: wahoo_clm_count } data_sizes = record_counts.map do |type, count| @@ -438,4 +440,32 @@ def calculate_workout_data_size(workout_step_count, lap_count, session_count, ev data_sizes.reduce(&:+) end + + def format_clm(clm_json) + timestamp = clm_json['timestamp'] || Time.now.to_i # Example timestamp, replace with actual logic + device_index = 255 # Example device index, replace with actual logic + + if clm_json['clm_id'] == 73 + data = clm_json['data'] + # Pack each value into its appropriate byte representation + packed_data = [] + packed_data += [73].pack('S<').bytes # clm_id as UINT8 + packed_data += [(data['wind_is_headwind'] ? 1 : 0)].pack('C').bytes # Boolean as UINT8 + packed_data += [(data['dist_m'] * 100).to_i].pack('L<').bytes # UINT32 (scaled) + packed_data += [(data['duration_sec']).to_i].pack('S<').bytes # UINT16 (scaled) + packed_data += [data['pwr_watts'].to_i].pack('S<').bytes # UINT16 (scaled) + packed_data += [(data['spd_mps'] * 1000).to_i].pack('S<').bytes # UINT16 (scaled) + packed_data += [(data['grade_perc'] * 100).to_i].pack('s<').bytes # UINT16 (scaled) + packed_data += [(data['wind_spd_mps'] * 1000).to_i].pack('S<').bytes # UINT16 (scaled) + packed_data += [(data['wind_resist_coef'] * 1000).to_i].pack('S<').bytes # UINT16 (scaled) + packed_data += [(data['roll_resist_coef'] * 10000).to_i].pack('S<').bytes # UINT16 (scaled) + packed_data += [(data['weight_kg'] * 10).to_i].pack('S<').bytes # UINT16 (scaled) + end + { + timestamp: timestamp, + device_index: device_index, + data_len: 23, + data: packed_data + } + end end diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 6072222..05ec224 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -219,6 +219,29 @@ def test_total_vs_timer_time json_output = JSON.parse(data.to_json) refute_nil(json_output) assert_equal(4116, json_output['activity']['tot_timer_time_sec']) + assert_equal(1, json_output['laps'].size) + end + end + + def test_event_time + fit_file_path = 'test/fixtures/zwift-activity-bad-events.fit' + new_fit_file_path = 'test/fixtures/zwift-activity-bad-events-new.fit' + raw = IO.read(fit_file_path) + + parser = RubyFit::FitFileParser.new + parser.repair_fit_file(raw) do |data| + new_file_string = data + File.open(new_fit_file_path, 'wb') do |file| + file.write(new_file_string) + end + end + + raw = IO.read(new_fit_file_path) + parser.parse(raw) do |data| + json_output = JSON.parse(data.to_json) + refute_nil(json_output) + assert_equal("2025-06-09 21:02:05 UTC", json_output['events'][1]['timestamp']) + # assert_equal(1, json_output['laps'].size) end end end \ No newline at end of file diff --git a/test/fixtures/example_route_json.json b/test/fixtures/example_route_json.json index 297c021..698d1ab 100644 --- a/test/fixtures/example_route_json.json +++ b/test/fixtures/example_route_json.json @@ -51,5 +51,6 @@ "manufacturer_code": 1, "product": 65534, "manufacturer": "Garmin" - } + }, + "wahoo_clms": [{"clm_id": 73, "timestamp": 1104849057, "data": {"dist_m": 1279.116, "duration_sec": 404.2188, "pwr_watts": 141, "spd_mps": 3.174, "grade_perc": 4.972, "wind_spd_mps": 1.343, "wind_is_headwind": true, "wind_resist_coef": 0.32156836800000005, "roll_resist_coef": 0.00251, "weight_kg": 78.47}}, {"clm_id": 73, "data": {"dist_m": 1269.067, "duration_sec": 101.4372, "pwr_watts": 66, "spd_mps": 14.395, "grade_perc": -4.665, "wind_spd_mps": 1.069, "wind_is_headwind": true, "wind_resist_coef": 0.32101122200000004, "roll_resist_coef": 0.00251, "weight_kg": 78.47}}] } \ No newline at end of file diff --git a/test/route_test.rb b/test/route_test.rb index ed9775e..3342745 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -20,6 +20,7 @@ def test_integration duration: json['duration'].to_i || 0, course_point_count: (json['course_points']&.size || 0).to_i, track_point_count: (json['track_points']&.size || 0).to_i, + wahoo_clm_count: (json['wahoo_clms']&.size || 0).to_i, name: json['name'] || 'unnamed', tot_dist_m: (json['distance'] || 0), total_ascent: (json['ascent'] || 0), @@ -46,6 +47,12 @@ def test_integration writer.course_point(point) end end + + writer.wahoo_clms do + json['wahoo_clms']&.each do |clm| + writer.wahoo_clm(clm) + end + end end end @@ -55,6 +62,7 @@ def test_integration json_input = JSON.parse(json_input) assert_equal(json_input['track_points'].size, data[:records].size) assert_equal(json_input['course_points'].size, data[:course_points].size) + assert_equal(json_input['wahoo_clms'].size, data[:wahoo_clm].size) end end end \ No newline at end of file From a3153d3c29bc92a11490a785efb3d57bea08d789 Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 11 Jun 2025 08:36:41 -0400 Subject: [PATCH 084/104] fix event validations --- lib/rubyfit/fit_parser.rb | 16 ++++++++++------ lib/rubyfit/validations.rb | 4 +--- test/fit_parser_test.rb | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 9a74fd1..eee3266 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -341,11 +341,11 @@ def repair_fit_file(raw) end end - added_messages, modified_messages = post_parse_repairs(raw, processed_laps, processed_sessions, added_messages, modified_messages, original_data_info) + added_messages, modified_messages, invalid_offsets = post_parse_repairs(raw, processed_laps, processed_sessions, added_messages, modified_messages, original_data_info, invalid_offsets) yield edit_fit_file_raw(raw, invalid_offsets, modified_messages, added_messages) end - def post_parse_repairs(raw, processed_laps, processed_sessions, added_messages, modified_messages, original_data_info) + def post_parse_repairs(raw, processed_laps, processed_sessions, added_messages, modified_messages, original_data_info, invalid_offsets) parser = RubyFit::FitFileParser.new parser.parse(raw) do |parsed_data| if !processed_laps && !processed_sessions @@ -362,18 +362,22 @@ def post_parse_repairs(raw, processed_laps, processed_sessions, added_messages, activity_data, modified = RubyFit::Validations.post_parsed_activity(parsed_data) activity_info = original_data_info[34][0] if original_data_info[34] if activity_data && modified && activity_info - modified_messages << { start: activity_info[:start], length: activity_info[:length], new_data: activity_data } + added_messages << { new_data: activity_data } if activity_data && modified + invalid_offsets << { start: activity_info[:start], length: activity_info[:length] } if invalid_offsets.empty? || !invalid_offsets.any? { |offset| offset[:start] == activity_info[:start] && offset[:length] == activity_info[:length] } end events_data, modified = RubyFit::Validations.post_parsed_events(parsed_data) - events_info = original_data_info[21] + events_info = original_data_info[21] if original_data_info[21] if events_data && modified && events_info && events_info.size == events_data.size events_data.each_with_index do |event_data, index| - modified_messages << { start: events_info[index][:start], length: events_info[index][:length], new_data: event_data } + if event_data.present? + added_messages << { new_data: event_data } if event_data && modified + invalid_offsets << { start: events_info[index][:start], length: events_info[index][:length] } if invalid_offsets.empty? || !invalid_offsets.any? { |offset| offset[:start] == events_info[index][:start] && offset[:length] == events_info[index][:length] } + end end end end - [added_messages, modified_messages] + [added_messages, modified_messages, invalid_offsets] end diff --git a/lib/rubyfit/validations.rb b/lib/rubyfit/validations.rb index 77e23d4..e793703 100644 --- a/lib/rubyfit/validations.rb +++ b/lib/rubyfit/validations.rb @@ -181,9 +181,7 @@ def self.post_parsed_events(parsed_data) modified = true raw_events << raw_event else - definition = RubyFit::MessageWriter.definition_message(:event, 0) - data = RubyFit::MessageWriter.data_message(:event, 0, event) - raw_event = definition + data + raw_event = nil raw_events << raw_event end end diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 05ec224..4c16515 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -241,7 +241,7 @@ def test_event_time json_output = JSON.parse(data.to_json) refute_nil(json_output) assert_equal("2025-06-09 21:02:05 UTC", json_output['events'][1]['timestamp']) - # assert_equal(1, json_output['laps'].size) + assert_equal(1, json_output['laps'].size) end end end \ No newline at end of file From a97daa9d5bfb4287a5bd12d8a70cbb8591713b2b Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 12 Jun 2025 09:22:51 -0400 Subject: [PATCH 085/104] add clm special parsing --- lib/rubyfit/fit_parser.rb | 35 +++++++++++++++++++++++++++++++++++ test/route_test.rb | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index eee3266..31fc0e0 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -233,6 +233,11 @@ def parse(raw) key = @plural_message_types[key] all_data[key] = [] unless all_data[key].is_a?(Array) all_data[key] << value + elsif key == :wahoo_clm + key, clm = parse_clm(value) + all_data[:CLM] ||= {} + all_data[:CLM][key] ||= [] + all_data[:CLM][key] << clm elsif all_data.key?(key) && !@use_last_message_only.include?(key) all_data[key] = [all_data[key]] unless all_data[key].is_a?(Array) all_data[key] << value @@ -246,6 +251,36 @@ def parse(raw) yield all_data end + def parse_clm(data) + # Convert the array of bytes into a binary string + binary_data = data[:data].pack('C*') + + # Unpack the binary data using the same format as encoding + clm_id, wind_is_headwind, dist_m, duration_sec, pwr_watts, spd_mps, grade_perc, wind_spd_mps, wind_resist_coef, roll_resist_coef, weight_kg = + binary_data.unpack('S Date: Wed, 2 Jul 2025 15:18:15 -0400 Subject: [PATCH 086/104] add developer field architecture to routes --- lib/rubyfit/fit_parser.rb | 24 ++++++-- lib/rubyfit/message_constants.rb | 51 ++++++++++++++++- lib/rubyfit/message_writer.rb | 51 +++++++++++++---- lib/rubyfit/writer.rb | 36 +++++++++--- test/fit_parser_test.rb | 10 ++++ test/fixtures/example_dev_fields.json | 42 ++++++++++++++ test/route_test.rb | 80 +++++++++++++++++++++++++++ 7 files changed, 270 insertions(+), 24 deletions(-) create mode 100644 test/fixtures/example_dev_fields.json diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 31fc0e0..1cd1afa 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -31,7 +31,7 @@ def get_definition(local_num) @definitions[local_num] || { fields: [] } end - def data_message(local_num, values) + def data_message(local_num, values, developer_values = []) formatted_values = values.map do |key, value| formatted_value = if value.is_a?(String) value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') @@ -55,7 +55,8 @@ def convert_to_json(fit_data, unpack_directive) # Convert each field in the raw FIT data to a readable format readable_data = {} - raw_values = fit_data.values.first + raw_values = fit_data.values.first.first + raw_dev_values = fit_data.values.first.last if fit_data.values.first.size > 1 # for debugging # known_field_ids = message_definition[:fields].map { |_, field_definition| field_definition[:id] } @@ -77,6 +78,21 @@ def convert_to_json(fit_data, unpack_directive) readable_data[field_name] = nil end end + + # Process developer fields + if raw_dev_values + raw_dev_values.each do |field_id, raw_value| + # Check if the field ID exists in DEVELOPER_FIELDS + field_name = RubyFit::MessageConstants::DEVELOPER_FIELDS.key(field_id) + next unless field_name + + # Convert raw value to readable format + readable_value = raw_value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') + readable_data[field_name] = readable_value + end + end + + { message_type => readable_data } end @@ -220,8 +236,8 @@ def parse(raw) developer_values[field[:id]] = value end - data_message(local_num, values) - data = convert_to_json({ definition[:global_message_number] => values }, unpack_directive) + data_message(local_num, values, developer_values) + data = convert_to_json({ definition[:global_message_number] => [values, developer_values] }, unpack_directive) data&.each do |key, value| if key == :record diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index ad03da4..76cd22c 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -26,9 +26,10 @@ module RubyFit::MessageConstants sharp_right: 22, u_turn: 23, segment_start: 24, + segment_end: 25, checkpoint: 35, toilet: 39, - segment_end: 25 + info: 53 }.freeze EVENT_TYPE = { @@ -341,4 +342,52 @@ module RubyFit::MessageConstants wifi: 4, local: 5 }.freeze + + WAYPOINT_TYPE = { + atm: 1, + aid_station: 1, # aid station, alert + art: 2, + attraction: 3, + bar: 4, + swimming: 5, # beach, swimming, pool + bike_parking: 6, + bike_share: 7, + bike_shop: 8, # bike shop, bike repair + camping: 9, + chairlift: 10, # chair lift, cable car + checkpoint: 11, # checkpoint, control point, stamp station + coffee: 12, + distance_marker: 13, + dog_park: 14, + e_bike_charging: 15, + ferry: 16, # ferry, boat + gas_station: 17, # gas station, petrol station + generic: 18, + geocache: 19, + grocery: 20, # grocery store, convenience store + hospital: 21, + info: 22, + park: 23, + parking: 24, + peak: 25, # peak, summit, mountain top + pharmacy: 26, + rest_area: 27, # rest area, picnic area, shelter, benches + restaurant: 28, # restaurant, food, food and drink + restroom: 29, # restroom + segment_end: 30, + segment_start: 31, + shopping: 32, + shower: 33, + trailhead: 34, + transit: 35, # transit, bus stop, train station + transition_zone: 36, + viewpoint: 37, # viewpoint, lookout, scenic view + water: 38, # water, water fountain, water tap + winery: 39, # winery, vineyard + }.freeze + + DEVELOPER_FIELDS = { + # TO DO: Change these to match the actual developer data IDs and field definitions + course_point_type: 0 + } end diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 4189f43..c22df73 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -84,7 +84,7 @@ class RubyFit::MessageWriter name: { id: 6, type: RubyFit::Type.string(48) }, message_index: { id: 254, type: RubyFit::Type.uint16 }, type: { id: 5, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::COURSE_POINT_TYPE, required: true } - }, + } }, record: { @@ -343,7 +343,7 @@ class RubyFit::MessageWriter developer_data_index: { id: 0, type: RubyFit::Type.uint8 }, field_definition_number: { id: 1, type: RubyFit::Type.uint8 }, fit_base_type_id: { id: 2, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::FIT_BASE_TYPE }, - field_name: { id: 3, type: RubyFit::Type.string(16) }, + field_name: { id: 3, type: RubyFit::Type.string(32) }, units: { id: 8, type: RubyFit::Type.string(16) }, } }, @@ -360,10 +360,10 @@ class RubyFit::MessageWriter } - def self.definition_message(type, local_num) + def self.definition_message(type, local_num, developer_fields = nil) pack_bytes do |bytes| message_data = MESSAGE_DEFINITIONS[type] - bytes << header_byte(local_num, true) + bytes << header_byte(local_num, true, developer_fields&.present?) bytes << 0x00 # Reserved uint8 bytes << 0x01 # Big endian bytes.push(*num2bytes(message_data[:id], 2)) # Global message ID @@ -375,10 +375,19 @@ def self.definition_message(type, local_num) bytes << type.byte_count bytes << type.fit_id end + + if developer_fields + bytes << developer_fields.length # Developer field count + developer_fields.each do |field| + bytes << field[:field_definition_number]&.to_i # Field number: Maps to the field_definition_number of a field_description Message + bytes << 1 # Data Size: Size (in bytes) of the specified FIT message’s field + bytes << field[:developer_data_index]&.to_i # Developer Data Index: Maps to the developer_data_index of a developer_data_id Message + end + end end end - def self.data_message(type, local_num, values) + def self.data_message(type, local_num, values, developer_fields = nil) pack_bytes do |bytes| message_data = MESSAGE_DEFINITIONS[type] bytes << header_byte(local_num, false) @@ -398,19 +407,38 @@ def self.data_message(type, local_num, values) value_bytes = value ? field_type.val2bytes(value) : field_type.default_bytes bytes.push(*value_bytes) end + + # Add developer fields if provided + if developer_fields + developer_fields.each do |field| + bytes.push(*field[:data]) + end + end end end - def self.definition_message_size(type) + def self.definition_message_size(type, developer_fields = nil) message_data = MESSAGE_DEFINITIONS[type] raise ArgumentError.new("Unknown message type '#{type}'") unless message_data - 6 + message_data[:fields].count * 3 + + # Base size: header (6 bytes) + fields (3 bytes per field) + base_size = 6 + message_data[:fields].count * 3 + + # Add developer fields size (1 byte to store the count then 3 bytes per developer field) + developer_fields_size = developer_fields ? 1 + developer_fields.size * 3 : 0 + base_size + developer_fields_size end - def self.data_message_size(type) + def self.data_message_size(type, developer_fields = nil) message_data = MESSAGE_DEFINITIONS[type] raise ArgumentError.new("Unknown message type '#{type}'") unless message_data - 1 + message_data[:fields].values.map{|info| info[:type].byte_count}.reduce(&:+) + + # Base size: header (1 byte) + field data sizes + base_size = 1 + message_data[:fields].values.map { |info| info[:type].byte_count }.reduce(&:+) + + # Add developer field data sizes + developer_fields_size = developer_fields ? developer_fields.sum { |field| field[:data].is_a?(Array) ? field[:data].size : 1 } : 0 + base_size + developer_fields_size end def self.file_header(data_byte_count = 0) @@ -432,9 +460,8 @@ def self.crc(crc_value) end # Internal - - def self.header_byte(local_number, definition) - local_number & 0xF | (definition ? 0x40 : 0x00) + def self.header_byte(local_number, definition, developer = false) + local_number & 0xF | (definition ? 0x40 : 0x00) | (developer ? 0x20 : 0x00) end def self.pack_bytes diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index f1a8ab6..29073db 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -18,12 +18,30 @@ def write(stream, opts = {}) start_time = opts[:start_time].to_i duration = opts[:duration].to_i + course_point_dev_field_count = opts[:course_point_dev_field_count] || 0 @data_crc = 0 - data_size = calculate_data_size(opts[:course_point_count], opts[:track_point_count], opts[:wahoo_clm_count] || 0) + data_size = calculate_data_size(opts[:course_point_count], opts[:track_point_count], opts[:wahoo_clm_count] || 0, course_point_dev_field_count) write_data(RubyFit::MessageWriter.file_header(data_size)) + if course_point_dev_field_count > 0 + # Write developer data ID + write_message(:developer_data_id, { + manufacturer_id: opts[:manufacturer_id] || 32, + developer_data_index: opts[:developer_data_index] || 0 + }) + + # Write field description for "course_point_type" + # TO DO: Change name and field_definition_number to match CRUX + write_message(:field_description, { + developer_data_index: opts[:developer_data_index] || 0, + field_definition_number: 0, + fit_base_type_id: :uint8, + field_name: "course_point_type" + }) + end + write_message(:file_id, { time_created: opts[:time_created], type_code: 6, # Course file @@ -350,14 +368,14 @@ def wahoo_clm(values) def write_message(type, values) local_num = @local_nums[type] + developer_fields = values[:developer_fields] unless local_num @last_local_num += 1 local_num = @last_local_num @local_nums[type] = local_num - write_data(RubyFit::MessageWriter.definition_message(type, local_num)) + write_data(RubyFit::MessageWriter.definition_message(type, local_num, developer_fields)) end - - write_data(RubyFit::MessageWriter.data_message(type, local_num, values)) + write_data(RubyFit::MessageWriter.data_message(type, local_num, values, developer_fields)) end def write_data(data) @@ -381,9 +399,11 @@ def update_crc(crc, data) crc end - def calculate_data_size(course_point_count, track_point_count, wahoo_clm_count = 0) + def calculate_data_size(course_point_count, track_point_count, wahoo_clm_count = 0, course_point_dev_field_count = 0) record_counts = { file_id: 1, + developer_data_id: course_point_dev_field_count > 0 ? 1 : 0, + field_description: course_point_dev_field_count > 0 ? 1 : 0, course: 1, lap: 1, event: 2, @@ -393,8 +413,10 @@ def calculate_data_size(course_point_count, track_point_count, wahoo_clm_count = } data_sizes = record_counts.map do |type, count| - def_size = RubyFit::MessageWriter.definition_message_size(type) - data_size = RubyFit::MessageWriter.data_message_size(type) * count + developer_fields = (type == :course_point && course_point_dev_field_count > 0) ? [{ developer_data_index: 0, field_definition_number: 0, data: 1 }] : nil + def_size = RubyFit::MessageWriter.definition_message_size(type, developer_fields) + data_size = RubyFit::MessageWriter.data_message_size(type, developer_fields) * count + result = if count > 0 def_size + data_size else diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 4c16515..60e9460 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -244,4 +244,14 @@ def test_event_time assert_equal(1, json_output['laps'].size) end end + + def test_dev_fields + fit_file_path = 'test/fixtures/DeveloperData.fit' + raw = IO.read(fit_file_path) + parser = RubyFit::FitFileParser.new + parser.parse(raw) do |data| + json_output = JSON.parse(data.to_json) + puts(json_output.inspect) + end + end end \ No newline at end of file diff --git a/test/fixtures/example_dev_fields.json b/test/fixtures/example_dev_fields.json new file mode 100644 index 0000000..26c0611 --- /dev/null +++ b/test/fixtures/example_dev_fields.json @@ -0,0 +1,42 @@ +{ + "id": 49877007, + "url": "https://ridewithgps.com/api/v1/routes/49877007.json", + "name": "Test", + "visibility": "private", + "description": "", + "locality": "Cobb County", + "administrative_area": "GA", + "country_code": "US", + "distance": 536, + "elevation_gain": 12, + "elevation_loss": 0, + "first_lat": 34.03497, + "first_lng": -84.59215, + "last_lat": 34.03852, + "last_lng": -84.6307, + "sw_lat": 34.03497, + "sw_lng": -84.59529, + "ne_lat": 34.03864, + "ne_lng": -84.59196, + "track_type": "point_to_point", + "terrain": "climbing", + "difficulty": "casual", + "unpaved_pct": 0, + "surface": "paved", + "activity_types": ["cycling"], + "created_at": "2025-03-05T14:42:55Z", + "updated_at": "2025-03-05T14:42:55Z", + "track_points": [ + {"lon_deg": -84.59215, "lat_deg": 34.03497, "alt_m": 321.7, "dist_m": 0.0, "S": 0, "R": 6, "timestamp": 1111065857}, + {"lon_deg": -84.59196, "lat_deg": 34.03507, "alt_m": 322.7, "dist_m": 20.7, "S": 1, "R": 4, "timestamp": 1111065857}, + {"lon_deg": -84.5925, "lat_deg": 34.03574, "alt_m": 324.4, "dist_m": 110.4, "S": 1, "R": 4, "timestamp": 1111065857} + ], + "points_of_interest": [], + "ascent": 12, + "sport_code": 2, + "file_id": { + "manufacturer_code": 15, + "product": 9001, + "manufacturer": "Garmin" + } +} \ No newline at end of file diff --git a/test/route_test.rb b/test/route_test.rb index c79f032..24b08fd 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -45,6 +45,7 @@ def test_integration json['course_points']&.each do |point| point = point.transform_keys(&:to_sym).merge(type: point['type'].to_sym) writer.course_point(point) + developer_fields = nil end end @@ -65,4 +66,83 @@ def test_integration assert_equal(data[:CLM][:ROUTE_COURSE_SECTOR].size, json_input['wahoo_clms'].size) end end + + def test_integration_with_developer_fields + json_input = File.read('test/fixtures/example_route_json.json') + fit_file_path = 'route_with_developer_fields.fit' + json = JSON.parse(json_input, symbolize_names: false) + + sport = RubyFit::MessageConstants::SPORT.key(json['sport_code']) + subsport = RubyFit::MessageConstants::SUBSPORT.key(json['subsport_code']) || :generic + + writer = RubyFit::Writer.new + File.open(fit_file_path, 'wb') do |file| + writer.write(file, { + start_time: (json['start_time'] || Time.now).to_i, + duration: json['duration'].to_i || 0, + course_point_count: (json['course_points']&.size || 0).to_i, + track_point_count: (json['track_points']&.size || 0).to_i, + wahoo_clm_count: (json['wahoo_clms']&.size || 0).to_i, + course_point_dev_field_count: 1, + name: json['name'] || 'unnamed', + tot_dist_m: (json['distance'] || 0), + total_ascent: (json['ascent'] || 0), + time_created: (json['created_at'] || Time.now).to_i, + start_x: (json['first_lng'] || 0), + start_y: (json['first_lat'] || 0), + end_x: (json['last_lng'] || 0), + end_y: (json['last_lat'] || 0), + manufacturer: json['manufacturer_code'] || 32, + product: json['product'] || 0, + sport: sport, + subsport: subsport + }) do + writer.track_points do + json['track_points']&.each do |record| + record = record.transform_keys(&:to_sym) + writer.track_point(record) + end + end + + writer.course_points do + json['course_points']&.each_with_index do |point, index| + point = point.transform_keys(&:to_sym).merge(type: point['type'].to_sym) + # Add developer fields for testing + # Define developer fields directly + developer_fields = [ + { + developer_data_index: 0, # Matches the developer_data_id + field_definition_number: 0, # Matches the field_description + data: index + 1 # Example value (1-3 digit number) + } + ] + point = point.merge(developer_fields: developer_fields) + writer.course_point(point) + end + end + + writer.wahoo_clms do + json['wahoo_clms']&.each do |clm| + writer.wahoo_clm(clm) + end + end + end + end + + raw = IO.read(fit_file_path) + parser = RubyFit::FitFileParser.new + parser.parse(raw) do |data| + json_input = JSON.parse(json_input) + assert_equal(json_input['track_points'].size, data[:records].size) + assert_equal(json_input['course_points'].size, data[:course_points].size) + assert_equal(data[:CLM][:ROUTE_COURSE_SECTOR].size, json_input['wahoo_clms'].size) + + puts(data) + # Verify developer fields + # data[:course_points].each_with_index do |course_point, index| + # assert(course_point[:developer_fields], "Developer fields missing for course point #{index}") + # assert_equal([index], course_point[:developer_fields].first[:data]) + # end + end + end end \ No newline at end of file From 634e7c7d57a046c5eaffa9b54a2abb1b2dc721b7 Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 2 Jul 2025 15:46:23 -0400 Subject: [PATCH 087/104] fix parsing --- lib/rubyfit/fit_parser.rb | 3 ++- test/route_test.rb | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 1cd1afa..e31ff53 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -87,7 +87,8 @@ def convert_to_json(fit_data, unpack_directive) next unless field_name # Convert raw value to readable format - readable_value = raw_value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') + # readable_value = raw_value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') + readable_value = raw_value.unpack1('C') readable_data[field_name] = readable_value end end diff --git a/test/route_test.rb b/test/route_test.rb index 24b08fd..11d93d3 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -113,7 +113,7 @@ def test_integration_with_developer_fields { developer_data_index: 0, # Matches the developer_data_id field_definition_number: 0, # Matches the field_description - data: index + 1 # Example value (1-3 digit number) + data: 18 # Example value (1-3 digit number) } ] point = point.merge(developer_fields: developer_fields) From 58183fd7e96e0c4d4eaee9099537cd9ffe9a9517 Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 3 Jul 2025 07:28:24 -0400 Subject: [PATCH 088/104] updated waypoint mappings --- lib/rubyfit/message_constants.rb | 146 ++++++++++++++++++++++--------- lib/rubyfit/writer.rb | 2 +- test/route_test.rb | 2 +- 3 files changed, 108 insertions(+), 42 deletions(-) diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index 76cd22c..44151cd 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -344,46 +344,112 @@ module RubyFit::MessageConstants }.freeze WAYPOINT_TYPE = { - atm: 1, - aid_station: 1, # aid station, alert - art: 2, - attraction: 3, - bar: 4, - swimming: 5, # beach, swimming, pool - bike_parking: 6, - bike_share: 7, - bike_shop: 8, # bike shop, bike repair - camping: 9, - chairlift: 10, # chair lift, cable car - checkpoint: 11, # checkpoint, control point, stamp station - coffee: 12, - distance_marker: 13, - dog_park: 14, - e_bike_charging: 15, - ferry: 16, # ferry, boat - gas_station: 17, # gas station, petrol station - generic: 18, - geocache: 19, - grocery: 20, # grocery store, convenience store - hospital: 21, - info: 22, - park: 23, - parking: 24, - peak: 25, # peak, summit, mountain top - pharmacy: 26, - rest_area: 27, # rest area, picnic area, shelter, benches - restaurant: 28, # restaurant, food, food and drink - restroom: 29, # restroom - segment_end: 30, - segment_start: 31, - shopping: 32, - shower: 33, - trailhead: 34, - transit: 35, # transit, bus stop, train station - transition_zone: 36, - viewpoint: 37, # viewpoint, lookout, scenic view - water: 38, # water, water fountain, water tap - winery: 39, # winery, vineyard + # atm: 1, + # aid_station: 1, # aid station, alert + # art: 2, + # attraction: 3, + # bar: 4, + # swimming: 5, # beach, swimming, pool + # bike_parking: 6, + # bike_share: 7, + # bike_shop: 8, # bike shop, bike repair + # camping: 9, + # chairlift: 10, # chair lift, cable car + # checkpoint: 11, # checkpoint, control point, stamp station + # coffee: 12, + # distance_marker: 13, + # dog_park: 14, + # e_bike_charging: 15, + # ferry: 16, # ferry, boat + # gas_station: 17, # gas station, petrol station + # generic: 18, + # geocache: 19, + # grocery: 20, # grocery store, convenience store + # hospital: 21, + # info: 22, + # park: 23, + # parking: 24, + # peak: 25, # peak, summit, mountain top + # pharmacy: 26, + # rest_area: 27, # rest area, picnic area, shelter, benches + # restaurant: 28, # restaurant, food, food and drink + # restroom: 29, # restroom + # segment_end: 30, + # segment_start: 31, + # shopping: 32, + # shower: 33, + # trailhead: 34, + # transit: 35, # transit, bus stop, train station + # transition_zone: 36, + # viewpoint: 37, # viewpoint, lookout, scenic view + # water: 38, # water, water fountain, water tap + # winery: 39, # winery, vineyard + other: 0, + slight_right: 1, + right: 2, + sharp_right: 3, + u_turn: 4, + slight_left: 5, + left: 6, + sharp_left: 7, + depart: 9, + arrive: 10, + roundabout: 11, + way_point: 12, + warning: 13, # warning, caution + summit: 14, # summit, peak, mountain top + valley: 15, # valley, low point + water: 16, # water fountain, water tap + food: 17, # food, restaurant, cafe + first_aid: 18, # first aid, medical assistance + climb_4th_cat: 19, # climb 4th category + climb_3rd_cat: 20, # climb 3rd category + climb_2nd_cat: 21, # climb 2nd category + climb_1st_cat: 22, # climb 1st category + climb_hors_cat: 23, # climb hors category + sprint: 24, # sprint point + roundabout_right: 25, + roundabout_left: 26, + atm: 27, # ATM + art: 28, # Art installation + attraction: 29, # Attraction + bar: 30, # Bar + swimming: 31, # Swimming area + bike_parking: 32, # Bike parking + bike_share: 33, # Bike share station + bike_shop: 34, # Bike shop + campsite: 35, # Camping area + chairlift: 36, # Chair lift + checkpoint: 37, + coffee: 38, + distance_marker: 39, # Distance marker + dog_park: 40, # Dog park + e_bike_charging: 41, # E-bike charging station + ferry: 42, # Ferry + gas_station: 43, # Gas station + geocache: 44, + groceries: 45, # Grocery store + hospital: 46, # Hospital + info: 47, # Information point + internet: 48, # Internet access point + for_kids: 49, # For kids + library: 50, # Library + lodging: 51, # Lodging + meeting_spot: 52, # Meeting point + monument: 53, + park: 54, + parking: 55, # Parking area + pharmacy: 56, # Pharmacy + rest_area: 57, # Rest area + shopping: 58, # Shopping area + toilet: 59, # Toilet + shower: 60, # Shower + trailhead: 61, # Trailhead + transition: 62, + transit: 63, # Transit stop + viewpoint: 64, # Viewpoint + winery: 65, + unknown: 255, # Unknown waypoint type }.freeze DEVELOPER_FIELDS = { diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 29073db..08ac562 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -36,7 +36,7 @@ def write(stream, opts = {}) # TO DO: Change name and field_definition_number to match CRUX write_message(:field_description, { developer_data_index: opts[:developer_data_index] || 0, - field_definition_number: 0, + field_definition_number: 16, fit_base_type_id: :uint8, field_name: "course_point_type" }) diff --git a/test/route_test.rb b/test/route_test.rb index 11d93d3..96443fb 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -112,7 +112,7 @@ def test_integration_with_developer_fields developer_fields = [ { developer_data_index: 0, # Matches the developer_data_id - field_definition_number: 0, # Matches the field_description + field_definition_number: 16, # Matches the field_description data: 18 # Example value (1-3 digit number) } ] From 5e19f4682654a6e445087dda2d5690796960d64e Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 3 Jul 2025 09:36:35 -0400 Subject: [PATCH 089/104] update parser for dev field --- lib/rubyfit/fit_parser.rb | 5 +++-- lib/rubyfit/message_constants.rb | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index e31ff53..cea42d2 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -87,13 +87,14 @@ def convert_to_json(fit_data, unpack_directive) next unless field_name # Convert raw value to readable format - # readable_value = raw_value.bytes.map { |byte| sprintf('%02X', byte) }.join(' ') readable_value = raw_value.unpack1('C') + if RubyFit::MessageConstants::WAYPOINT_TYPE.value?(readable_value) + readable_value = RubyFit::MessageConstants::WAYPOINT_TYPE.key(readable_value) + end readable_data[field_name] = readable_value end end - { message_type => readable_data } end diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index 44151cd..ade92a6 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -454,6 +454,6 @@ module RubyFit::MessageConstants DEVELOPER_FIELDS = { # TO DO: Change these to match the actual developer data IDs and field definitions - course_point_type: 0 + course_point_type: 16 } end From edfca8b27405e33195e0a65f211de2a2fae9f16a Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 21 Jul 2025 12:16:47 -0400 Subject: [PATCH 090/104] updated message fields --- lib/rubyfit/message_constants.rb | 66 ++++++++++++++++++++++++++++++-- lib/rubyfit/message_writer.rb | 6 +-- lib/rubyfit/writer.rb | 2 + 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index ade92a6..cf27eda 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -91,9 +91,69 @@ module RubyFit::MessageConstants transition: 3, fitness_equipment: 4, swimming: 5, - walking: 6, - sedentary: 8, - all: 254, + basketball: 6, + soccer: 7, + tennis: 8, + american_football: 9, + training: 10, + walking: 11, + cross_country_skiing: 12, + alpine_skiing: 13, + snowboarding: 14, + rowing: 15, + mountaineering: 16, + hiking: 17, + multisport: 18, + paddling: 19, + flying: 20, + e_biking: 21, + motorcycling: 22, + boating: 23, + driving: 24, + golf: 25, + hang_gliding: 26, + horseback_riding: 27, + hunting: 28, + fishing: 29, + inline_skating: 30, + rock_climbing: 31, + sailing: 32, + ice_skating: 33, + sky_diving: 34, + snowshoeing: 35, + snowmobiling: 36, + stand_up_paddleboarding: 37, + surfing: 38, + wakeboarding: 39, + water_skiing: 40, + kayaking: 41, + rafting: 42, + windsurfing: 43, + kitesurfing: 44, + tactical: 45, + jumpmaster: 46, + boxing: 47, + floor_climbing: 48, + baseball: 49, + diving: 53, + hiit: 62, + racket: 64, + wheelchair_push_walk: 65, + wheelchair_push_run: 66, + meditation: 67, + disc_golf: 69, + cricket: 71, + rugby: 72, + hockey: 73, + lacrosse: 74, + volleyball: 75, + water_tubing: 76, + wakesurfing: 77, + mixed_martial_arts: 80, + snorkeling: 82, + dance: 83, + jump_rope: 84, + all: 254 }.freeze SUBSPORT = { diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index c22df73..4a386cb 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -91,13 +91,13 @@ class RubyFit::MessageWriter id: 20, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true }, - lat_deg: { id: 0, type: RubyFit::Type.semicircles, required: true }, - lon_deg: { id: 1, type: RubyFit::Type.semicircles, required: true }, + lat_deg: { id: 0, type: RubyFit::Type.semicircles }, + lon_deg: { id: 1, type: RubyFit::Type.semicircles }, alt_m: { id: 2, type: RubyFit::Type.altitude }, hr_bpm: { id: 3, type: RubyFit::Type.uint8 }, cad_rpm: { id: 4, type: RubyFit::Type.uint8 }, dist_m: { id: 5, type: RubyFit::Type.centimeters }, - spd_mps: { id: 6, type: RubyFit::Type.speed }, + spd_mps: { id: 6, type: RubyFit::Type.uint16_scale1000 }, pwr_watts: { id: 7, type: RubyFit::Type.uint16 }, grade_perc: { id: 9, type: RubyFit::Type.grade}, temp_deg_c: { id: 13, type: RubyFit::Type.sint8 }, diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index 08ac562..baab14e 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -482,6 +482,8 @@ def format_clm(clm_json) packed_data += [(data['wind_resist_coef'] * 1000).to_i].pack('S<').bytes # UINT16 (scaled) packed_data += [(data['roll_resist_coef'] * 10000).to_i].pack('S<').bytes # UINT16 (scaled) packed_data += [(data['weight_kg'] * 10).to_i].pack('S<').bytes # UINT16 (scaled) + else + packed_data = clm_json[:data] || clm_json['data'] end { timestamp: timestamp, From 857c25052279462a7a479f46cae58da221b0a05e Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 7 Aug 2025 08:23:33 -0400 Subject: [PATCH 091/104] updated rpe scale --- lib/rubyfit/message_writer.rb | 2 +- lib/rubyfit/type.rb | 7 +++++++ test/fit_parser_test.rb | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 4a386cb..029989e 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -245,7 +245,7 @@ class RubyFit::MessageWriter min_alt_m: { id: 71, type: RubyFit::Type.altitude }, enhanced_avg_speed: { id: 124, type: RubyFit::Type.uint32 }, enhanced_max_speed: { id: 125, type: RubyFit::Type.uint32 }, - workout_rpe: { id: 193, type: RubyFit::Type.uint8 } + workout_rpe: { id: 193, type: RubyFit::Type.uint8_scale10 }, } }, diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index f2b434b..0b967cb 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -244,6 +244,13 @@ def uint8_scale2 }) end + def uint8_scale10 + uint8({ + rb2fit: ->(val, type) { (val * 10.0).truncate }, + fit2rb: ->(val, type) { val.nil? ? nil : val / 10.0 } + }) + end + def uint16_scale100 uint16({ rb2fit: ->(val, type) { (val * 100).truncate }, diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 60e9460..41cae36 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -196,7 +196,7 @@ def test_fit_file_with_rpe json_output = JSON.parse(data.to_json) refute_nil(json_output) assert_equal(1, json_output['sessions'].size) - assert_equal(20, json_output['sessions'][0]['workout_rpe']) + assert_equal(2.0, json_output['sessions'][0]['workout_rpe']) end end From 05c61aab95a778302c3ed8ad2946ab77f087df18 Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 4 Sep 2025 14:28:00 -0400 Subject: [PATCH 092/104] add description developer field --- lib/rubyfit/fit_parser.rb | 13 ++++++--- lib/rubyfit/helpers.rb | 8 +++--- lib/rubyfit/message_constants.rb | 45 +++----------------------------- lib/rubyfit/message_writer.rb | 19 +++++++++++--- lib/rubyfit/writer.rb | 12 ++++++--- test/route_test.rb | 5 ++++ 6 files changed, 47 insertions(+), 55 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index cea42d2..52b16d1 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -87,10 +87,15 @@ def convert_to_json(fit_data, unpack_directive) next unless field_name # Convert raw value to readable format - readable_value = raw_value.unpack1('C') - if RubyFit::MessageConstants::WAYPOINT_TYPE.value?(readable_value) - readable_value = RubyFit::MessageConstants::WAYPOINT_TYPE.key(readable_value) - end + readable_value = + if field_id == 16 + value = raw_value.unpack1('C') + RubyFit::MessageConstants::WAYPOINT_TYPE.value?(value) ? RubyFit::MessageConstants::WAYPOINT_TYPE.key(value) : value + elsif field_id == 17 + raw_value.delete("\u0000").force_encoding('UTF-8') + else + raw_value + end readable_data[field_name] = readable_value end end diff --git a/lib/rubyfit/helpers.rb b/lib/rubyfit/helpers.rb index 6d61db6..c6be156 100644 --- a/lib/rubyfit/helpers.rb +++ b/lib/rubyfit/helpers.rb @@ -59,9 +59,11 @@ def bytes2num(bytes, byte_count, unsigned = true, big_endian = true) # Converts an ASCII string into a byte array, truncating or right-filling # with 0 to match byte_count def str2bytes(str, byte_count) - str - .unpack("C#{byte_count - 1}") # Convert to n-1 bytes - .map{|v| v || 0} + [0] # Convert nils to 0 and add null terminator + if byte_count == 1 + [str.bytes.first || 0] + else + str.unpack("C#{byte_count - 1}").map { |v| v || 0 } + [0] + end end # Converts a byte array to a string. Omits the last character of the byte diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index cf27eda..eda92fc 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -404,46 +404,6 @@ module RubyFit::MessageConstants }.freeze WAYPOINT_TYPE = { - # atm: 1, - # aid_station: 1, # aid station, alert - # art: 2, - # attraction: 3, - # bar: 4, - # swimming: 5, # beach, swimming, pool - # bike_parking: 6, - # bike_share: 7, - # bike_shop: 8, # bike shop, bike repair - # camping: 9, - # chairlift: 10, # chair lift, cable car - # checkpoint: 11, # checkpoint, control point, stamp station - # coffee: 12, - # distance_marker: 13, - # dog_park: 14, - # e_bike_charging: 15, - # ferry: 16, # ferry, boat - # gas_station: 17, # gas station, petrol station - # generic: 18, - # geocache: 19, - # grocery: 20, # grocery store, convenience store - # hospital: 21, - # info: 22, - # park: 23, - # parking: 24, - # peak: 25, # peak, summit, mountain top - # pharmacy: 26, - # rest_area: 27, # rest area, picnic area, shelter, benches - # restaurant: 28, # restaurant, food, food and drink - # restroom: 29, # restroom - # segment_end: 30, - # segment_start: 31, - # shopping: 32, - # shower: 33, - # trailhead: 34, - # transit: 35, # transit, bus stop, train station - # transition_zone: 36, - # viewpoint: 37, # viewpoint, lookout, scenic view - # water: 38, # water, water fountain, water tap - # winery: 39, # winery, vineyard other: 0, slight_right: 1, right: 2, @@ -514,6 +474,7 @@ module RubyFit::MessageConstants DEVELOPER_FIELDS = { # TO DO: Change these to match the actual developer data IDs and field definitions - course_point_type: 16 - } + course_point_type: 16, + course_point_description: 17 + }.freeze end diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 029989e..1c9d964 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -379,8 +379,12 @@ def self.definition_message(type, local_num, developer_fields = nil) if developer_fields bytes << developer_fields.length # Developer field count developer_fields.each do |field| + field_size = 1 + if field[:field_definition_number]&.to_i == 17 + field_size = 48 + end bytes << field[:field_definition_number]&.to_i # Field number: Maps to the field_definition_number of a field_description Message - bytes << 1 # Data Size: Size (in bytes) of the specified FIT message’s field + bytes << field_size bytes << field[:developer_data_index]&.to_i # Developer Data Index: Maps to the developer_data_index of a developer_data_id Message end end @@ -411,7 +415,16 @@ def self.data_message(type, local_num, values, developer_fields = nil) # Add developer fields if provided if developer_fields developer_fields.each do |field| - bytes.push(*field[:data]) + if field[:field_definition_number]&.to_i == 17 + type = RubyFit::Type.string(48) + elsif field[:field_definition_number]&.to_i == 16 + type = RubyFit::Type.uint8 + else + type = nil + end + value = field[:data] + value_bytes = type ? type.val2bytes(value) : [value].pack("C*").bytes + bytes.push(*value_bytes) end end end @@ -437,7 +450,7 @@ def self.data_message_size(type, developer_fields = nil) base_size = 1 + message_data[:fields].values.map { |info| info[:type].byte_count }.reduce(&:+) # Add developer field data sizes - developer_fields_size = developer_fields ? developer_fields.sum { |field| field[:data].is_a?(Array) ? field[:data].size : 1 } : 0 + developer_fields_size = developer_fields ? developer_fields.sum { |field| field[:data].is_a?(Array) ? field[:data].size : field[:data] } : 0 base_size + developer_fields_size end diff --git a/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index baab14e..6029d42 100644 --- a/lib/rubyfit/writer.rb +++ b/lib/rubyfit/writer.rb @@ -33,13 +33,19 @@ def write(stream, opts = {}) }) # Write field description for "course_point_type" - # TO DO: Change name and field_definition_number to match CRUX write_message(:field_description, { developer_data_index: opts[:developer_data_index] || 0, field_definition_number: 16, fit_base_type_id: :uint8, field_name: "course_point_type" }) + + write_message(:field_description, { + developer_data_index: opts[:developer_data_index] || 0, + field_definition_number: 17, + fit_base_type_id: :string, + field_name: "course_point_description" + }) end write_message(:file_id, { @@ -403,7 +409,7 @@ def calculate_data_size(course_point_count, track_point_count, wahoo_clm_count = record_counts = { file_id: 1, developer_data_id: course_point_dev_field_count > 0 ? 1 : 0, - field_description: course_point_dev_field_count > 0 ? 1 : 0, + field_description: course_point_dev_field_count > 0 ? 2 : 0, course: 1, lap: 1, event: 2, @@ -413,7 +419,7 @@ def calculate_data_size(course_point_count, track_point_count, wahoo_clm_count = } data_sizes = record_counts.map do |type, count| - developer_fields = (type == :course_point && course_point_dev_field_count > 0) ? [{ developer_data_index: 0, field_definition_number: 0, data: 1 }] : nil + developer_fields = (type == :course_point && course_point_dev_field_count > 0) ? [{ developer_data_index: 0, field_definition_number: 16, data: 1 }, { developer_data_index: 0, field_definition_number: 17, data: 48 }] : nil def_size = RubyFit::MessageWriter.definition_message_size(type, developer_fields) data_size = RubyFit::MessageWriter.data_message_size(type, developer_fields) * count diff --git a/test/route_test.rb b/test/route_test.rb index 96443fb..421ab9f 100644 --- a/test/route_test.rb +++ b/test/route_test.rb @@ -114,6 +114,11 @@ def test_integration_with_developer_fields developer_data_index: 0, # Matches the developer_data_id field_definition_number: 16, # Matches the field_description data: 18 # Example value (1-3 digit number) + }, + { + developer_data_index: 0, # Matches the developer_data_id + field_definition_number: 17, # Matches the field_description + data: "this is my longer description" # Example value (string) } ] point = point.merge(developer_fields: developer_fields) From 41f5bd14a1415e34f7831fd05aad1df995bba410 Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 5 Sep 2025 14:00:29 -0400 Subject: [PATCH 093/104] fix parsing and encoding of invalid distance --- lib/rubyfit/type.rb | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/rubyfit/type.rb b/lib/rubyfit/type.rb index 0b967cb..fe4e673 100644 --- a/lib/rubyfit/type.rb +++ b/lib/rubyfit/type.rb @@ -162,9 +162,19 @@ def semicircles def centimeters uint32({ - rb2fit: ->(val, type) { (val * 100).truncate }, - fit2rb: ->(val, type) { val.nil? ? nil : val / 100.0 } - }) + rb2fit: ->(val, type) { + val == 0xFFFFFFFF ? val : (val * 100).truncate + }, + fit2rb: ->(val, type) { + if val.nil? + nil + elsif val == 0xFFFFFFFF + 0xFFFFFFFF + else + val / 100.0 + end + } + }) end def altitude From b1119f615f611e8c57ff7d66cd8cf5f27e8e1de7 Mon Sep 17 00:00:00 2001 From: annahuller Date: Wed, 8 Oct 2025 15:41:46 -0400 Subject: [PATCH 094/104] make workout parsing singular --- lib/rubyfit/fit_parser.rb | 2 +- test/fit_parser_test.rb | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 52b16d1..631f8d9 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -19,7 +19,7 @@ def initialize segment_lap: :segment_laps, wahoo_custom_num: :wahoo_custom_nums } - @use_last_message_only = [:wahoo_id] + @use_last_message_only = [:wahoo_id, :workout] end def definition_message(local_num, global_message_number, fields, developer_fields) diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 41cae36..0960b07 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -38,9 +38,9 @@ def test_little_endian_file_decoding assert_equal(26, json_output['activity']['event_code']) assert_equal(1, json_output['activity']['event_type_code']) - assert_equal(2, json_output['workout'][0]['sport_code']) - assert_equal(6, json_output['workout'][0]['sub_sport_code']) - assert_equal('Indoor Cycling', json_output['workout'][0]['wkt_name']) + assert_equal(2, json_output['workout']['sport_code']) + assert_equal(6, json_output['workout']['sub_sport_code']) + assert_equal('Indoor Cycling', json_output['workout']['wkt_name']) assert_equal('WAHOOAPPIOS62BB', json_output['wahoo_id']['app_token']) assert_equal(3, json_output['wahoo_id']['workout_num']) From 3a48e0479c7a8f01e179a7d89255e92e93d665ad Mon Sep 17 00:00:00 2001 From: annahuller Date: Fri, 17 Oct 2025 15:53:04 -0400 Subject: [PATCH 095/104] add segment_start and segment_end --- lib/rubyfit/message_constants.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index eda92fc..3291750 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -469,6 +469,8 @@ module RubyFit::MessageConstants transit: 63, # Transit stop viewpoint: 64, # Viewpoint winery: 65, + segment_start: 66, + segment_end: 67, unknown: 255, # Unknown waypoint type }.freeze From 6da7cd832ba2f6647ee7416f950840612c70fe15 Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 28 Oct 2025 08:47:35 -0400 Subject: [PATCH 096/104] improve error protections --- lib/rubyfit/fit_parser.rb | 10 ++++++++-- lib/rubyfit/validations.rb | 2 +- test/fit_parser_test.rb | 26 ++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 631f8d9..bd55d00 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -203,6 +203,9 @@ def parse(raw) fields = field_count.times.map do field_def = buffer_io.read(3)&.unpack('C*') raise "Invalid FIT file: unable to read field definition" unless field_def + if field_def.nil? || field_def.size < 3 + next + end { id: field_def[0], size: field_def[1], type: field_def[2] } end @@ -340,8 +343,11 @@ def repair_fit_file(raw) fields = field_count.times.map do field_def = buffer_io.read(3)&.unpack('C*') + if field_def.nil? || field_def.size < 3 + next + end { id: field_def[0], size: field_def[1], type: field_def[2] } - end + end.compact developer_fields = if record_header & 0x20 == 0x20 developer_field_count = buffer_io.read(1)&.unpack1('C') @@ -369,7 +375,7 @@ def repair_fit_file(raw) end developer_values = {} - definition[:developer_fields]&.each do |field| + (definition[:developer_fields] || []).each do |field| value = buffer_io.read(field[:size]) if value.nil? || value.size < field[:size] puts "Warning: Missing or incomplete developer field value for field ID #{field[:id]} at #{buffer_io.pos}" diff --git a/lib/rubyfit/validations.rb b/lib/rubyfit/validations.rb index e793703..e468629 100644 --- a/lib/rubyfit/validations.rb +++ b/lib/rubyfit/validations.rb @@ -171,7 +171,7 @@ def self.post_parsed_events(parsed_data) modified = false events.each do |event| - if (event[:event_type_code] == 4 || event[:event_type_code] == 1) && event[:timestamp] > (records.last[:timestamp] + 5) + if (event[:event_type_code] == 4 || event[:event_type_code] == 1) && records.last && event[:timestamp] > (records.last[:timestamp] + 5) event[:timestamp] = records.last[:timestamp] definition = RubyFit::MessageWriter.definition_message(:event, 0) diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 0960b07..594b4e7 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -254,4 +254,30 @@ def test_dev_fields puts(json_output.inspect) end end + + def test_undefined_method_in_field_definition_error_handling + fit_file_path = 'test/fixtures/2025-04-04-052736-WAHOOAPPIOS1568-146-0.fit' + new_fit_file_path = 'test/fixtures/2025-04-04-052736-WAHOOAPPIOS1568-146-0-new.fit' + raw = IO.read(fit_file_path) + parser = RubyFit::FitFileParser.new + parser.repair_fit_file(raw) do |data| + new_file_string = data + File.open(new_fit_file_path, 'wb') do |file| + file.write(new_file_string) + end + end + end + + def test_undefined_method_in_validations_error_handling + fit_file_path = 'test/fixtures/2025-10-25-163604-ManualSummaryFit9639-7357631-0.fit' + new_fit_file_path = 'test/fixtures/2025-10-25-163604-ManualSummaryFit9639-7357631-0-new.fit' + raw = IO.read(fit_file_path) + parser = RubyFit::FitFileParser.new + parser.repair_fit_file(raw) do |data| + new_file_string = data + File.open(new_fit_file_path, 'wb') do |file| + file.write(new_file_string) + end + end + end end \ No newline at end of file From 7235d4397762d9aff65972be6ff75af79c5150ce Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 28 Oct 2025 09:02:33 -0400 Subject: [PATCH 097/104] improve error protections --- lib/rubyfit/validations.rb | 2 ++ test/fit_parser_test.rb | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/lib/rubyfit/validations.rb b/lib/rubyfit/validations.rb index e468629..fa064fc 100644 --- a/lib/rubyfit/validations.rb +++ b/lib/rubyfit/validations.rb @@ -91,6 +91,8 @@ def self.build_lap_and_session(parsed_data) events = parsed_data[:events] sport = parsed_data[:sport] || {} + return [nil, nil, false] if records.nil? + lap[:timestamp] = records.first[:timestamp] lap[:start_time] = records.first[:timestamp] lap[:start_lat_deg] = records.first[:lat_deg] diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index 594b4e7..f8dd63b 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -280,4 +280,17 @@ def test_undefined_method_in_validations_error_handling end end end + + def test_fit_file_with_no_laps_and_no_records + fit_file_path = 'test/fixtures/2025-10-24-170242-WAHOOAPPIOSFFAE-397-0.fit' + new_fit_file_path = 'test/fixtures/2025-10-24-170242-WAHOOAPPIOSFFAE-397-0-new.fit' + raw = IO.read(fit_file_path) + parser = RubyFit::FitFileParser.new + parser.repair_fit_file(raw) do |data| + new_file_string = data + File.open(new_fit_file_path, 'wb') do |file| + file.write(new_file_string) + end + end + end end \ No newline at end of file From 6f75858555bd0bfcc1d4040b5c862b7a86d81ee1 Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 24 Nov 2025 14:30:11 -0500 Subject: [PATCH 098/104] insert wahoo id and workout sections in repair function --- lib/rubyfit/fit_parser.rb | 20 +++++++++- lib/rubyfit/helpers.rb | 5 +++ lib/rubyfit/message_constants.rb | 68 ++++++++++++++++++++++++++++++++ lib/rubyfit/message_writer.rb | 2 +- lib/rubyfit/validations.rb | 57 ++++++++++++++++++++++++++ test/fit_parser_test.rb | 28 +++++++++++++ 6 files changed, 177 insertions(+), 3 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index bd55d00..406cf7f 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -316,6 +316,8 @@ def repair_fit_file(raw) processed_sessions = false processed_laps = false + processed_workout = false + processed_wahoo_id = false io = StringIO.new(raw) header = io.read(12) @@ -392,6 +394,12 @@ def repair_fit_file(raw) if definition[:global_message_number] == 19 processed_laps = true end + if definition[:global_message_number] == 26 + processed_workout = true + end + if definition[:global_message_number] == 65281 + processed_wahoo_id = true + end original_data_info[definition[:global_message_number]] ||= [] original_data_info[definition[:global_message_number]] << { start: record_start, length: buffer_io.pos - record_start } @@ -405,11 +413,11 @@ def repair_fit_file(raw) end end - added_messages, modified_messages, invalid_offsets = post_parse_repairs(raw, processed_laps, processed_sessions, added_messages, modified_messages, original_data_info, invalid_offsets) + added_messages, modified_messages, invalid_offsets = post_parse_repairs(raw, processed_laps, processed_sessions, added_messages, modified_messages, original_data_info, invalid_offsets, processed_workout, processed_wahoo_id) yield edit_fit_file_raw(raw, invalid_offsets, modified_messages, added_messages) end - def post_parse_repairs(raw, processed_laps, processed_sessions, added_messages, modified_messages, original_data_info, invalid_offsets) + def post_parse_repairs(raw, processed_laps, processed_sessions, added_messages, modified_messages, original_data_info, invalid_offsets, processed_workout, processed_wahoo_id) parser = RubyFit::FitFileParser.new parser.parse(raw) do |parsed_data| if !processed_laps && !processed_sessions @@ -429,6 +437,14 @@ def post_parse_repairs(raw, processed_laps, processed_sessions, added_messages, added_messages << { new_data: activity_data } if activity_data && modified invalid_offsets << { start: activity_info[:start], length: activity_info[:length] } if invalid_offsets.empty? || !invalid_offsets.any? { |offset| offset[:start] == activity_info[:start] && offset[:length] == activity_info[:length] } end + unless processed_workout + workout_data, modified = RubyFit::Validations.build_workout(parsed_data) + added_messages << { new_data: workout_data } if workout_data && modified + end + unless processed_wahoo_id + wahoo_id_data, modified = RubyFit::Validations.build_wahoo_id(parsed_data) + added_messages << { new_data: wahoo_id_data } if wahoo_id_data && modified + end events_data, modified = RubyFit::Validations.post_parsed_events(parsed_data) events_info = original_data_info[21] if original_data_info[21] diff --git a/lib/rubyfit/helpers.rb b/lib/rubyfit/helpers.rb index c6be156..a1f7a6d 100644 --- a/lib/rubyfit/helpers.rb +++ b/lib/rubyfit/helpers.rb @@ -142,5 +142,10 @@ def self.calculate_timer_time(events) total_time end + + def self.get_workout_type_from_sport_and_subsport(sport_code, subsport_code) + workout_type = RubyFit::MessageConstants::SPORT_SUBSPORT_TO_WORKOUT_ID[[sport_code, subsport_code]] || 47 + workout_type + end end end diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index 3291750..3a94025 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -479,4 +479,72 @@ module RubyFit::MessageConstants course_point_type: 16, course_point_description: 17 }.freeze + + + SPORT_SUBSPORT_TO_WORKOUT_ID = { + [2, 0] => 0, # Cycling, Generic + [1, 0] => 1, # Running, Generic + [4, 0] => 2, # Fitness Equip, Generic + [1, 4] => 3, # Running, Track + [1, 3] => 4, # Running, Trail + [1, 1] => 5, # Running, Treadmill + [11, 0] => 6, # Walking, Generic + [11, 31] => 7, # Walking, Speed Walking + [11, 30] => 8, # Walking, Nordic Walking + [17, 0] => 9, # Hiking, Generic + [16, 0] => 10, # Mountaineering, Generic + [2, 11] => 11, # Cycling, Cyclocross + [2, 6] => 12, # Cycling, Indoor Cycling + [2, 8] => 13, # Cycling, Mountain + [2, 10] => 14, # Cycling, Recumbent + [2, 7] => 15, # Cycling, Road + [2, 13] => 16, # Cycling, Track Cycling + [22, 0] => 17, # Motorcycling, Generic + [4, 0] => 18, # Fitness Equip, Generic + [1, 1] => 19, # Running, Treadmill + [4, 15] => 20, # Fitness Equip, Elliptical + [2, 6] => 21, # Cycling, Indoor Cycling + [4, 14] => 22, # Fitness Equip, Indoor Rowing + [4, 16] => 23, # Fitness Equip, Stair Climbing + [5, 0] => 24, # Swimming, Generic + [5, 17] => 25, # Swimming, Lap Swimming + [5, 18] => 26, # Swimming, Open Water + [14, 0] => 27, # Snowboarding, Generic + [13, 0] => 28, # Alpine Skiing, Generic + [13, 9] => 29, # Alpine Skiing, Downhill + [12, 0] => 30, # Cross Country Skiing, Generic + [31, 0] => 31, # Skating, Generic + [33, 0] => 32, # Ice Skating, Generic + [30, 0] => 33, # Inline Skating, Generic + [31, 0] => 34, # Skating, Long Boarding + [32, 0] => 35, # Sailing, Generic + [43, 0] => 36, # Windsurfing, Generic + [19, 0] => 37, # Paddling, Canoeing + [41, 0] => 38, # Kayaking, Generic + [15, 0] => 39, # Rowing, Generic + [44, 0] => 40, # Kitesurfing, Generic + [37, 0] => 41, # Stand Up Paddleboarding, Generic + [10, 20] => 42, # Training, Strength Training + [10, 26] => 43, # Training, Cardio Training + [4, 16] => 44, # Fitness Equip, Stair Climbing + [65, 0] => 45, # Wheelchair Push Walk, Generic + [25, 0] => 46, # Golf, Generic + [0, 0] => 47, # Generic, Generic + [2, 5] => 49, # Cycling, Spin + [11, 1] => 56, # Walking, Treadmill + [2, 6] => 61, # Cycling, Indoor Trainer + [18, 0] => 62, # Multisport, Generic + [3, 0] => 63, # Transition, Generic + [21, 0] => 64, # E Biking, Generic + [0, 23] => 65, # Generic, Excercise + [10, 43] => 66, # Training, Yoga + [1, 0] => 67, # Running, Race Running + [2, 58] => 68, # Cycling, Virtual Activity + [69, 0] => 69, # Mental Strength Training, Generic + [2, 12] => 70, # Cycling, Handcycling + [1, 58] => 71, # Running, Virtual Activity + [10, 0] => 72, # Training, Generic + [0, 0] => 73, # Generic, Generic + [255, 0] => 255 # Unknown + }.freeze end diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 1c9d964..050e74e 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -135,7 +135,7 @@ class RubyFit::MessageWriter # capabilities: { id: 5, type: RubyFit::Type.uint32z, required: true }, # should be workout_capabilities type num_valid_steps: { id: 6, type: RubyFit::Type.uint16 }, wkt_name: { id: 8, type: RubyFit::Type.string(64) }, - sub_sport_code: { id: 11, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT }, + sub_sport_code: { id: 11, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT.merge(RubyFit::MessageConstants::SUBSPORT.values.map { |v| [v, v] }.to_h) }, # pool_length: { id: 14, type: RubyFit::Type.uint16 }, # pool_length_unit: { id: 15, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::DISPLAY_MEASURE } } diff --git a/lib/rubyfit/validations.rb b/lib/rubyfit/validations.rb index fa064fc..aada357 100644 --- a/lib/rubyfit/validations.rb +++ b/lib/rubyfit/validations.rb @@ -129,6 +129,63 @@ def self.build_lap_and_session(parsed_data) [raw_lap, raw_session, modified] end + def self.build_workout(parsed_data) + workout = {} + + return if parsed_data[:sessions].nil? + + sport = parsed_data[:sport] || {} + sport_code = sport&.[](:sport_code) || parsed_data[:sessions]&.first[:sport_code] || 2 + sub_sport_code = sport&.[](:sub_sport_code) || parsed_data[:sessions]&.first[:sub_sport_code] || 0 + + workout[:sport_code] = sport_code + workout[:sub_sport_code] = sub_sport_code + + if sub_sport_code == 0 + workout[:wkt_name] = RubyFit::MessageConstants::SPORT.key(sport_code).to_s.capitalize + else + subsport_name = RubyFit::MessageConstants::SUBSPORT.key(sub_sport_code).to_s + if subsport_name.include?('_') + workout[:wkt_name] = subsport_name.split('_').map(&:capitalize).join(' ') + else + workout[:wkt_name] = subsport_name.capitalize + ' ' + RubyFit::MessageConstants::SPORT.key(sport_code).to_s.capitalize + end + end + + definition = RubyFit::MessageWriter.definition_message(:workout, 0) + data = RubyFit::MessageWriter.data_message(:workout, 0, workout) + raw_workout = definition + data + modified = true + + [raw_workout, modified] + end + + def self.build_wahoo_id(parsed_data) + return if parsed_data[:file_id].nil? || (parsed_data[:sessions].nil? && parsed_data[:sport].nil?) + + wahoo_id = {} + + time_created = parsed_data[:file_id][:time_created] || Time.now + fit_epoch = Time.utc(1989, 12, 31, 0, 0, 0) + time_created_fit = (time_created - fit_epoch).to_i + + sport = parsed_data[:sport] || {} + sport_code = sport&.[](:sport_code) || parsed_data[:sessions]&.first[:sport_code] || 2 + sub_sport_code = sport&.[](:sub_sport_code) || parsed_data[:sessions]&.first[:sub_sport_code] || 0 + workout_type = RubyFit::Helpers.get_workout_type_from_sport_and_subsport(sport_code, sub_sport_code) || 47 + + wahoo_id[:app_token] = "FID14 #{time_created_fit.to_i.to_s(16).upcase.rjust(8, '0')}" + wahoo_id[:workout_num] = 0 + wahoo_id[:workout_type] = workout_type + + definition = RubyFit::MessageWriter.definition_message(:wahoo_id, 0) + data = RubyFit::MessageWriter.data_message(:wahoo_id, 0, wahoo_id) + raw_wahoo_id = definition + data + modified = true + + [raw_wahoo_id, modified] + end + def self.activity(activity) raw_activity = nil modified = false diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index f8dd63b..e45a827 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -178,6 +178,34 @@ def test_fit_file_with_no_session_and_no_laps end end + def test_file_with_no_workout_and_no_wahoo_id + fit_file_path = 'test/fixtures/tp-371176.2025-11-11-21-31-03-088Z.GarminPing.AAAAAGkTqxbgWQ9T.FIT' + new_fit_file_path = 'test/fixtures/tp-371176.2025-11-11-21-31-03-088Z.GarminPing.AAAAAGkTqxbgWQ9T-repaired.FIT' + + raw = IO.read(fit_file_path) + + parser = RubyFit::FitFileParser.new + parser.repair_fit_file(raw) do |data| + new_file_string = data + File.open(new_fit_file_path, 'wb') do |file| + file.write(new_file_string) + end + end + + raw = IO.read(new_fit_file_path) + parser.parse(raw) do |data| + json_output = JSON.parse(data.to_json) + refute_nil(json_output) + + assert_equal(4, json_output['workout'].size) + assert_equal(3, json_output['wahoo_id'].size) + + assert_equal("Road Cycling", json_output['workout']['wkt_name']) + assert_equal("FID14 43761D44", json_output['wahoo_id']['app_token']) + assert_equal(15, json_output['wahoo_id']['workout_type']) + end + end + def test_fit_file_with_rpe fit_file_path = 'test/fixtures/2-very-strong.fit' new_fit_file_path = 'test/fixtures/2-very-strong-new.fit' From cdce51a8a34a90e99026ed431b7b1737f6299fa2 Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 24 Nov 2025 15:10:43 -0500 Subject: [PATCH 099/104] add special parsing to session block --- lib/rubyfit/fit_parser.rb | 4 ++++ test/fit_parser_test.rb | 2 ++ 2 files changed, 6 insertions(+) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 406cf7f..2a01c54 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -255,6 +255,10 @@ def parse(raw) @record_index += 1 end + if key == :session + value[:workout_type_code] = RubyFit::Helpers.get_workout_type_from_sport_and_subsport(value[:sport_code], value[:sub_sport_code]) + end + if @plural_message_types.key?(key) key = @plural_message_types[key] all_data[key] = [] unless all_data[key].is_a?(Array) diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index e45a827..c1f7c02 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -203,6 +203,8 @@ def test_file_with_no_workout_and_no_wahoo_id assert_equal("Road Cycling", json_output['workout']['wkt_name']) assert_equal("FID14 43761D44", json_output['wahoo_id']['app_token']) assert_equal(15, json_output['wahoo_id']['workout_type']) + puts(json_output['sessions'][0].inspect) + assert_equal(15, json_output['sessions'][0]['workout_type_code']) end end From 57d9d4135cf041829e8783771b42509ed1ef21ea Mon Sep 17 00:00:00 2001 From: annahuller Date: Tue, 25 Nov 2025 12:52:40 -0500 Subject: [PATCH 100/104] update parsing for wahoo id to include workout token --- lib/rubyfit/fit_parser.rb | 4 ++++ test/fit_parser_test.rb | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 2a01c54..48835d1 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -259,6 +259,10 @@ def parse(raw) value[:workout_type_code] = RubyFit::Helpers.get_workout_type_from_sport_and_subsport(value[:sport_code], value[:sub_sport_code]) end + if key == :wahoo_id + value[:workout_token] = value[:app_token] + ':' + value[:workout_num].to_s + end + if @plural_message_types.key?(key) key = @plural_message_types[key] all_data[key] = [] unless all_data[key].is_a?(Array) diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index c1f7c02..c26e722 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -198,12 +198,12 @@ def test_file_with_no_workout_and_no_wahoo_id refute_nil(json_output) assert_equal(4, json_output['workout'].size) - assert_equal(3, json_output['wahoo_id'].size) + assert_equal(4, json_output['wahoo_id'].size) assert_equal("Road Cycling", json_output['workout']['wkt_name']) assert_equal("FID14 43761D44", json_output['wahoo_id']['app_token']) assert_equal(15, json_output['wahoo_id']['workout_type']) - puts(json_output['sessions'][0].inspect) + assert_equal("FID14 43761D44:0", json_output['wahoo_id']['workout_token']) assert_equal(15, json_output['sessions'][0]['workout_type_code']) end end From 7971d945b4b3e89a1565b3e9bed395401265a6d7 Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 18 Dec 2025 10:22:23 -0500 Subject: [PATCH 101/104] protect against errors --- lib/rubyfit/message_writer.rb | 4 ++-- lib/rubyfit/validations.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/rubyfit/message_writer.rb b/lib/rubyfit/message_writer.rb index 050e74e..3057b08 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -53,9 +53,9 @@ class RubyFit::MessageWriter tot_ascent_m: { id: 21, type: RubyFit::Type.ascent}, tot_descent_m: { id: 22, type: RubyFit::Type.ascent }, lap_trigger_code: { id: 24, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::LAP_TRIGGER }, - sport_code: { id: 25, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT, required: false }, + sport_code: { id: 25, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SPORT.merge(RubyFit::MessageConstants::SPORT.values.map { |v| [v, v] }.to_h), required: false }, norm_pwr_watts: { id: 33, type: RubyFit::Type.uint16 }, - sub_sport_code: { id: 39, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT, required: false}, + sub_sport_code: { id: 39, type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SUBSPORT.merge(RubyFit::MessageConstants::SUBSPORT.values.map { |v| [v, v] }.to_h), required: false}, tot_work_j: { id: 41, type: RubyFit::Type.uint32 }, avg_alt_m: { id: 42, type: RubyFit::Type.altitude }, max_alt_m: { id: 43, type: RubyFit::Type.altitude }, diff --git a/lib/rubyfit/validations.rb b/lib/rubyfit/validations.rb index aada357..84d63ac 100644 --- a/lib/rubyfit/validations.rb +++ b/lib/rubyfit/validations.rb @@ -230,7 +230,7 @@ def self.post_parsed_events(parsed_data) modified = false events.each do |event| - if (event[:event_type_code] == 4 || event[:event_type_code] == 1) && records.last && event[:timestamp] > (records.last[:timestamp] + 5) + if (event[:event_type_code] == 4 || event[:event_type_code] == 1) && records.last && records.last[:timestamp] && event[:timestamp] && event[:timestamp] > (records.last[:timestamp] + 5) event[:timestamp] = records.last[:timestamp] definition = RubyFit::MessageWriter.definition_message(:event, 0) From ba7612a0b7414683a899462ad28590fa8b8777ca Mon Sep 17 00:00:00 2001 From: annahuller Date: Thu, 18 Dec 2025 11:51:26 -0500 Subject: [PATCH 102/104] protect against errors --- lib/rubyfit/validations.rb | 2 +- test/fit_parser_test.rb | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/rubyfit/validations.rb b/lib/rubyfit/validations.rb index 84d63ac..7aa9bd1 100644 --- a/lib/rubyfit/validations.rb +++ b/lib/rubyfit/validations.rb @@ -89,7 +89,7 @@ def self.build_lap_and_session(parsed_data) records = parsed_data[:records] events = parsed_data[:events] - sport = parsed_data[:sport] || {} + sport = parsed_data[:sport].is_a?(Array) ? parsed_data[:sport].last : (parsed_data[:sport] || {}) return [nil, nil, false] if records.nil? diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index c26e722..0e87c49 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -323,4 +323,17 @@ def test_fit_file_with_no_laps_and_no_records end end end + + def test_conversion_error + fit_file_path = 'test/fixtures/2025-08-24-091308-ELEMNT_BOLT_F73E-3-0.fit' + new_fit_file_path = 'test/fixtures/2025-08-24-091308-ELEMNT_BOLT_F73E-3-0-new.fit' + raw = IO.read(fit_file_path) + parser = RubyFit::FitFileParser.new + parser.repair_fit_file(raw) do |data| + new_file_string = data + File.open(new_fit_file_path, 'wb') do |file| + file.write(new_file_string) + end + end + end end \ No newline at end of file From e731ad65fe4cc0f18427e2a82b045b6b4a6597fd Mon Sep 17 00:00:00 2001 From: annahuller Date: Mon, 9 Feb 2026 10:21:36 -0500 Subject: [PATCH 103/104] add workout plan clm parsing --- lib/rubyfit/fit_parser.rb | 49 +++++++++++++++++++++++++++++++++++---- test/fit_parser_test.rb | 11 +++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index 48835d1..f250df2 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -288,13 +288,12 @@ def parse(raw) def parse_clm(data) # Convert the array of bytes into a binary string binary_data = data[:data].pack('C*') - - # Unpack the binary data using the same format as encoding - clm_id, wind_is_headwind, dist_m, duration_sec, pwr_watts, spd_mps, grade_perc, wind_spd_mps, wind_resist_coef, roll_resist_coef, weight_kg = - binary_data.unpack('S Date: Tue, 17 Feb 2026 14:29:13 -0500 Subject: [PATCH 104/104] add repair for coros treadmill --- lib/rubyfit/fit_parser.rb | 8 +++--- lib/rubyfit/message_constants.rb | 1 - lib/rubyfit/validations.rb | 49 ++++++++++++++++++++++++++------ test/fit_parser_test.rb | 24 ++++++++++++++++ 4 files changed, 69 insertions(+), 13 deletions(-) diff --git a/lib/rubyfit/fit_parser.rb b/lib/rubyfit/fit_parser.rb index f250df2..31fea5c 100644 --- a/lib/rubyfit/fit_parser.rb +++ b/lib/rubyfit/fit_parser.rb @@ -134,9 +134,9 @@ def get_valid_data(fit_data, unpack_directive, raw_data) end end - valid_data, modified = RubyFit::Validations.validate_message(message_type, readable_data, raw_data) + valid_data, modified, parsed_data = RubyFit::Validations.validate_message(message_type, readable_data, raw_data) - [valid_data, modified] + [valid_data, modified, parsed_data] end def parse(raw) @@ -434,7 +434,7 @@ def repair_fit_file(raw) end data_message(local_num, values) - data, modified = get_valid_data({ definition[:global_message_number] => values }, unpack_directive, raw) + data, modified, parsed_data = get_valid_data({ definition[:global_message_number] => values }, unpack_directive, raw) if definition[:global_message_number] == 18 processed_sessions = true end @@ -455,7 +455,7 @@ def repair_fit_file(raw) # Record the offset and length of the invalid message invalid_offsets << { start: record_start, length: buffer_io.pos - record_start } elsif data && modified - modified_messages << { start: record_start, length: buffer_io.pos - record_start, new_data: data } + modified_messages << { start: record_start, length: buffer_io.pos - record_start, new_data: data, parsed_message: parsed_data } end end end diff --git a/lib/rubyfit/message_constants.rb b/lib/rubyfit/message_constants.rb index 3a94025..797fcba 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -501,7 +501,6 @@ module RubyFit::MessageConstants [2, 13] => 16, # Cycling, Track Cycling [22, 0] => 17, # Motorcycling, Generic [4, 0] => 18, # Fitness Equip, Generic - [1, 1] => 19, # Running, Treadmill [4, 15] => 20, # Fitness Equip, Elliptical [2, 6] => 21, # Cycling, Indoor Cycling [4, 14] => 22, # Fitness Equip, Indoor Rowing diff --git a/lib/rubyfit/validations.rb b/lib/rubyfit/validations.rb index 7aa9bd1..b5b128f 100644 --- a/lib/rubyfit/validations.rb +++ b/lib/rubyfit/validations.rb @@ -2,23 +2,32 @@ class RubyFit::Validations def self.validate_message(message_type, data, raw_data) if message_type == :lap - data, modified = self.lap(data) + data, modified, parsed_data = self.lap(data) elsif message_type == :activity - data, modified = self.activity(data) + data, modified, parsed_data = self.activity(data) elsif message_type == :session - data, modified = self.session(data) + data, modified, parsed_data = self.session(data) end - [data, modified] + [data, modified, parsed_data] end def self.lap(lap) raw_lap = nil modified = false - if lap[:timestamp].nil? || lap[:timestamp] == 0 || lap[:timestamp].to_s == "1989-12-31 00:00:00 UTC" + if lap[:timestamp].nil? || lap[:timestamp] == 0 || lap[:timestamp].to_s == "1989-12-31 00:00:00 UTC" || (lap[:sport_code] == 1 && lap[:sub_sport_code] == 45) if !lap[:start_time].nil? && !lap[:tot_elapsed_time_sec].nil? lap[:timestamp] = lap[:start_time] + lap[:tot_elapsed_time_sec] + if lap[:sport_code] == 1 && lap[:sub_sport_code] == 45 + lap[:sub_sport_code] = 1 + if lap[:event_type_code].nil? + lap[:event_type_code] = 1 + end + if lap[:event_code].nil? + lap[:event_code] = 9 + end + end definition = RubyFit::MessageWriter.definition_message(:lap, 0) data = RubyFit::MessageWriter.data_message(:lap, 0, lap) raw_lap = definition + data @@ -28,7 +37,7 @@ def self.lap(lap) end modified = true end - [raw_lap, modified] + [raw_lap, modified, lap] end def self.build_lap(parsed_data) @@ -137,6 +146,10 @@ def self.build_workout(parsed_data) sport = parsed_data[:sport] || {} sport_code = sport&.[](:sport_code) || parsed_data[:sessions]&.first[:sport_code] || 2 sub_sport_code = sport&.[](:sub_sport_code) || parsed_data[:sessions]&.first[:sub_sport_code] || 0 + if sport_code == 1 && sub_sport_code == 45 + sport_code = 1 + sub_sport_code = 1 + end workout[:sport_code] = sport_code workout[:sub_sport_code] = sub_sport_code @@ -172,6 +185,12 @@ def self.build_wahoo_id(parsed_data) sport = parsed_data[:sport] || {} sport_code = sport&.[](:sport_code) || parsed_data[:sessions]&.first[:sport_code] || 2 sub_sport_code = sport&.[](:sub_sport_code) || parsed_data[:sessions]&.first[:sub_sport_code] || 0 + + if sport_code == 1 && sub_sport_code == 45 + sport_code = 1 + sub_sport_code = 1 + end + workout_type = RubyFit::Helpers.get_workout_type_from_sport_and_subsport(sport_code, sub_sport_code) || 47 wahoo_id[:app_token] = "FID14 #{time_created_fit.to_i.to_s(16).upcase.rjust(8, '0')}" @@ -202,7 +221,7 @@ def self.activity(activity) end end - [raw_activity, modified] + [raw_activity, modified, activity] end def self.post_parsed_activity(parsed_data) @@ -252,7 +271,21 @@ def self.session(session) raw_session = nil modified = false - [raw_session, modified] + if session[:sport_code] == 1 && session[:sub_sport_code] == 45 + session[:sub_sport_code] = 1 + if session[:event_type_code].nil? + session[:event_type_code] = 1 + end + if session[:event_code].nil? + session[:event_code] = 8 + end + definition = RubyFit::MessageWriter.definition_message(:session, 0) + data = RubyFit::MessageWriter.data_message(:session, 0, session) + raw_session = definition + data + modified = true + end + + [raw_session, modified, session] end end \ No newline at end of file diff --git a/test/fit_parser_test.rb b/test/fit_parser_test.rb index db76d9a..4dd637d 100644 --- a/test/fit_parser_test.rb +++ b/test/fit_parser_test.rb @@ -347,4 +347,28 @@ def test_wahoo_clm_workout_plan assert_equal(10781682, json_output['CLM']['WORKOUT_PLAN_INFO'][0]['clm']['plan_cloud_id']) end end + + def test_coros_indoor_running_repair + fit_file_path = 'test/fixtures/coros_running_indoor.fit' + new_fit_file_path = 'test/fixtures/coros_indoor_run-new.fit' + raw = IO.read(fit_file_path) + parser = RubyFit::FitFileParser.new + parser.repair_fit_file(raw) do |data| + new_file_string = data + File.open(new_fit_file_path, 'wb') do |file| + file.write(new_file_string) + end + end + + raw = IO.read(new_fit_file_path) + parser.parse(raw) do |data| + json_output = JSON.parse(data.to_json) + assert_equal(1, json_output['sessions'].size) + assert_equal(1, json_output['laps'].size) + assert_equal(1, json_output['sessions'][0]['sport_code']) + assert_equal(1, json_output['sessions'][0]['sub_sport_code']) + assert_equal(1, json_output['laps'][0]['sport_code']) + assert_equal(1, json_output['laps'][0]['sub_sport_code']) + end + end end \ No newline at end of file