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/lib/rubyfit.rb b/lib/rubyfit.rb index b4ece71..3c4844d 100644 --- a/lib/rubyfit.rb +++ b/lib/rubyfit.rb @@ -3,3 +3,5 @@ require 'rubyfit/writer' require 'rubyfit/helpers' +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 new file mode 100644 index 0000000..31fea5c --- /dev/null +++ b/lib/rubyfit/fit_parser.rb @@ -0,0 +1,577 @@ +require_relative 'validations' +require_relative 'helpers' +class RubyFit::FitFileParser + REQUIRED_CALLBACKS = [:definition_message, :get_definition, :data_message] + + def initialize + @definitions = {} + @fit_data = {} + @record_index = 0 + @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 + } + @use_last_message_only = [:wahoo_id, :workout] + 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, developer_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) + 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.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] } + # 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)) + else + # If the field is missing, we can either skip it or set it as nil + 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 = + 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 + + { message_type => readable_data } + end + + 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 } + # 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, modified, parsed_data = RubyFit::Validations.validate_message(message_type, readable_data, raw_data) + + [valid_data, modified, parsed_data] + end + + def parse(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 + if field_def.nil? || field_def.size < 3 + next + end + { 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} at #{buffer_io.pos} for parse" + next + end + developer_values[field[:id]] = value + end + + 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 + value[:sec] = @record_index + @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 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) + 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 + else + all_data[key] = value + end + end + end + end + end + yield all_data + end + + def parse_clm(data) + # Convert the array of bytes into a binary string + binary_data = data[:data].pack('C*') + clm_id = binary_data.unpack1('S values }, unpack_directive, raw) + if definition[:global_message_number] == 18 + processed_sessions = true + end + 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 } + + 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, parsed_message: parsed_data } + end + 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, 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, processed_workout, processed_wahoo_id) + 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[34][0] if original_data_info[34] + if activity_data && modified && activity_info + 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] + if events_data && modified && events_info && events_info.size == events_data.size + events_data.each_with_index do |event_data, index| + 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, invalid_offsets] + end + + + def edit_fit_file_raw(raw, invalid_offsets, modified_messages, added_messages) + 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" + # 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 + + 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 + modified_buffer << buffer_io.read(buffer_io.pos - record_start - 1) + 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 + + # 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 5e215d9..a1f7a6d 100644 --- a/lib/rubyfit/helpers.rb +++ b/lib/rubyfit/helpers.rb @@ -1,107 +1,151 @@ -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)") + 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 + + 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 - num = 2 ** (byte_count * 8) - num + result.reverse! unless big_endian + + result 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) + 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 - result.reverse! unless big_endian + # Converts an ASCII string into a byte array, truncating or right-filling + # with 0 to match byte_count + def str2bytes(str, byte_count) + if byte_count == 1 + [str.bytes.first || 0] + else + str.unpack("C#{byte_count - 1}").map { |v| v || 0 } + [0] + end + end - result - end + # 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 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 + # 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 - # 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 + def unix2fit_timestamp(timestamp) + timestamp - GARMIN_TIME_OFFSET + end - # 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.pack("C*") - end + def fit2unix_timestamp(timestamp) + timestamp + GARMIN_TIME_OFFSET + 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 deg2semicircles(degrees) + (degrees * DEGREES_TO_SEMICIRCLES).truncate + end - def fit2unix_timestamp(timestamp) - timestamp + GARMIN_TIME_OFFSET - 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 + result + end + def make_message_header(opts = {}) + result = 0 + result |= (1 << 6) if opts[:definition] + result |= (opts[:local_number] || 0) & 0xF + result + end - def deg2semicircles(degrees) - (degrees * DEGREES_TO_SEMICIRCLES).truncate - 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 - 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 self.calculate_timer_time(events) + total_time = 0 + start_time = nil + timer_on = false + + events.each do |event| + 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 - def make_message_header(opts = {}) - result = 0 - result |= (1 << 6) if opts[:definition] - result |= (opts[:local_number] || 0) & 0xF - result + 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 d6b4293..797fcba 100644 --- a/lib/rubyfit/message_constants.rb +++ b/lib/rubyfit/message_constants.rb @@ -26,7 +26,10 @@ module RubyFit::MessageConstants sharp_right: 22, u_turn: 23, segment_start: 24, - segment_end: 25 + segment_end: 25, + checkpoint: 35, + toilet: 39, + info: 53 }.freeze EVENT_TYPE = { @@ -80,4 +83,467 @@ 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, + 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 = { + 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 + + 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 + + LENGTH_TYPE = { + idle: 0, + active: 1 + }.freeze + + SWIM_STROKE = { + freestyle: 0, + backstroke: 1, + breaststroke: 2, + butterfly: 3, + drill: 4, + mixed: 5, + 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 + + 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 + + + MESSAGE_TYPE = { + file_id: 0, + event: 21, + record: 20, + lap: 19, + course: 31, + course_point: 32, + session: 18, + workout: 26, + hr_zone: 8, + pwr_zone: 9, + activity: 34, + device_info: 23, + sport: 12, + workout_step: 27, + segment_lap: 142, + 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 + + WAYPOINT_TYPE = { + 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, + segment_start: 66, + segment_end: 67, + unknown: 255, # Unknown waypoint type + }.freeze + + DEVELOPER_FIELDS = { + # TO DO: Change these to match the actual developer data IDs and field definitions + 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 + [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 cf6e82e..3057b08 100644 --- a/lib/rubyfit/message_writer.rb +++ b/lib/rubyfit/message_writer.rb @@ -1,6 +1,6 @@ -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,33 +12,68 @@ 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_* + 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 }, } }, + course: { id: 31, fields: { name: { id: 5, type: RubyFit::Type.string(16), required: true }, } }, + lap: { id: 19, fields: { timestamp: { id: 253, type: RubyFit::Type.timestamp, required: true}, + 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}, - 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 }, + 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_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 }, + 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.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.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.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 }, + 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 } }, }, + course_point: { id: 32, fields: { @@ -46,36 +81,289 @@ 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(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: { 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 }, - elevation: { id: 2, type: RubyFit::Type.altitude }, + 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.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 }, + 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}, + batt_soc_perc: { id: 81, type: RubyFit::Type.uint8_scale2 } } }, + event: { 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.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 }, + 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 }, + } + }, + + workout: { + id: 26, + fields: { + 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) }, + 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 } + } + }, + + sport: { + id: 12, + fields: { + 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 } + } + }, + + hr_zone: { + id: 8, + fields: { + message_index: { id: 254, type: RubyFit::Type.uint16 }, + high_hr_bpm: { id: 1, type: RubyFit::Type.uint8, required: true }, + name: { id: 2, type: RubyFit::Type.string(16), required: true } + } + }, + + pwr_zone: { + id: 9, + fields: { + message_index: { id: 254, type: RubyFit::Type.uint16 }, + high_pwr_watts: { 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 }, + device_type: { id: 1, type: RubyFit::Type.uint8 }, + serial_number: { id: 3, type: RubyFit::Type.uint32z }, + 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_code: { id: 25,type: RubyFit::Type.enum, values: RubyFit::MessageConstants::SOURCE_TYPE }, + product_name: { id: 27, type: RubyFit::Type.string(20) } + } + }, + + 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_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.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.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 }, + tot_cal: { id: 11, type: RubyFit::Type.uint16 }, + 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 }, + 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.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 }, + 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_rpe: { id: 193, type: RubyFit::Type.uint8_scale10 }, + } + }, + + activity: { + id: 34, + fields: { + 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.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 }, + } + }, + + length: { + id: 101, + 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 }, + 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 }, + 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 } + } + }, + + segment_lap: { + id: 142, + 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 }, + 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 }, + 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) } + } + }, + + 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: { + value: { id: 0, type: RubyFit::Type.float64, required: true }, + timestamp: { id: 1, type: RubyFit::Type.timestamp, required: false }, + sub_type_code: { id: 2, type: RubyFit::Type.uint16, required: true }, + type_code: { id: 3, type: RubyFit::Type.uint8, required: true } + } + }, + + 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(23), 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(32) }, + 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 } } } + } - 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 @@ -87,10 +375,23 @@ 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| + 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 << 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 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) @@ -103,27 +404,54 @@ 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 - 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| + 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 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 : field[:data] } : 0 + base_size + developer_fields_size end def self.file_header(data_byte_count = 0) @@ -145,9 +473,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/type.rb b/lib/rubyfit/type.rb index e44c2f8..fe4e673 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) @@ -20,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 @@ -45,14 +46,32 @@ 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 = {}) { + value = bytes2num(bytes, type.byte_count, unsigned, opts[:big_endian]) + value == default ? nil : value + }, }.merge(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 = {}) @@ -61,7 +80,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 @@ -126,37 +145,175 @@ def uint64z(opts = {}) def timestamp uint32({ - rb2fit: ->(val, type) { unix2fit_timestamp(val) }, - fit2rb: ->(val, type) { fit2unix_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 def semicircles sint32({ rb2fit: ->(val, type) { deg2semicircles(val) }, - fit2rb: ->(val, type) { semicircles2deg(val) } + fit2rb: ->(val, type) { val.nil? ? nil : semicircles2deg(val).round(6) } }) end def centimeters uint32({ - rb2fit: ->(val, type) { (val * 100).truncate }, - fit2rb: ->(val, type) { 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 uint16({ - rb2fit: ->(val, type) { ((val + 500) * 5).truncate }, - fit2rb: ->(val, type) { val / 5.0 - 500 } + rb2fit: ->(val, type) { + result = ((val + 500) * 5.0).truncate + result + }, + fit2rb: ->(val, type) { + result = val.nil? ? nil : (val / 5.0 - 500).round(1) + result + } + }) + end + + def ascent + uint16({ + rb2fit: ->(val, type) { (val).truncate }, + fit2rb: ->(val, type) { val } }) end def duration uint32({ rb2fit: ->(val, type) { (val * 1000).truncate }, - fit2rb: ->(val, type) { val / 1000.0 } + fit2rb: ->(val, type) { val.nil? ? nil : val / 1000.0 } }) end + + def enhanced_speed + uint32({ + rb2fit: ->(val, type) { (val * 1000).truncate }, + fit2rb: ->(val, type) { val.nil? ? nil : val / 1000.0 } + }) + end + + def speed + uint8({ + rb2fit: ->(val, type) { (val * 1000).truncate }, + fit2rb: ->(val, type) { val.nil? ? nil : val / 1000.0 } + }) + end + + def uint16_scale1000 + uint16({ + rb2fit: ->(val, type) { (val * 1000).truncate }, + fit2rb: ->(val, type) { val.nil? ? nil : val / 1000.0 } + }) + end + + + def grade + sint16({ + rb2fit: ->(val, type) { (val * 100).truncate }, + fit2rb: ->(val, type) { val.nil? ? nil : val / 100.0 } + }) + end + + def tss + uint16({ + rb2fit: ->(val, type) { (val * 10).truncate }, + fit2rb: ->(val, type) { val.nil? ? nil : val / 10.0 } + }) + end + + def if + uint16({ + 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.0).truncate }, + fit2rb: ->(val, type) { val.nil? ? nil : val / 2.0 } + }) + 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 }, + 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, + byte_count: 8, + default_bytes: [0xFF] * 8, + val2bytes: ->(val, type) { [val].pack("G").bytes }, + bytes2val: ->(bytes, type, opts = {}) { 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[0, length] + ([0xFF] * [length - val.length, 0].max) + }, + 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 = {}) { + 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 end diff --git a/lib/rubyfit/validations.rb b/lib/rubyfit/validations.rb new file mode 100644 index 0000000..b5b128f --- /dev/null +++ b/lib/rubyfit/validations.rb @@ -0,0 +1,291 @@ +class RubyFit::Validations + + def self.validate_message(message_type, data, raw_data) + if message_type == :lap + data, modified, parsed_data = self.lap(data) + elsif message_type == :activity + data, modified, parsed_data = self.activity(data) + elsif message_type == :session + data, modified, parsed_data = self.session(data) + end + [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" || (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 + else + # Lap is totally invalid, so we set it to nil + raw_lap = nil + end + modified = true + end + [raw_lap, modified, lap] + end + + def self.build_lap(parsed_data) + lap = {} + 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 + + [raw_lap, modified] + end + + def self.build_session(parsed_data) + session = {} + + 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(parsed_data) + lap = {} + session = {} + + records = parsed_data[:records] + events = parsed_data[:events] + sport = parsed_data[:sport].is_a?(Array) ? parsed_data[:sport].last : (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] + 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 + + [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 + 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 + + 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 + + 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')}" + 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 + + 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, activity] + 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.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) && 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) + data = RubyFit::MessageWriter.data_message(:event, 0, event) + + raw_event = definition + data + modified = true + raw_events << raw_event + else + raw_event = nil + raw_events << raw_event + end + end + + [raw_events, modified] + end + + def self.session(session) + raw_session = nil + modified = false + + 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/lib/rubyfit/writer.rb b/lib/rubyfit/writer.rb index fb1995f..6029d42 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 @@ -12,23 +12,47 @@ 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 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]) + 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" + 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, { time_created: opts[:time_created], - type: 6, # Course file - manufacturer: 1, # Garmin - product: PRODUCT_ID, + type_code: 6, # Course file + manufacturer_code: opts[:manufacturer], + product: opts[:product], serial_number: 0, }) @@ -37,19 +61,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] + tot_elapsed_time_sec: duration, + tot_timer_time_sec: duration, + 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], + 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 }) @@ -57,8 +84,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 }) @@ -66,6 +93,138 @@ 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_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_code: opts[:sport], + capabilities: opts[:capabilities], + num_valid_steps: opts[:num_valid_steps], + wkt_name: opts[:wkt_name], + sub_sport_code: 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 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], opts[:include_wahoo_id]) + write_data(RubyFit::MessageWriter.file_header(data_size)) + + write_message(:file_id, { + time_created: opts[:time_created], + type_code: 4, # activity file + manufacturer_code: opts[:manufacturer], + product: opts[:product], + serial_number: 0, + }) + + write_message(:activity, { + timestamp: opts[:timestamp], + tot_timer_time_sec: opts[:tot_timer_time_sec], + num_sessions: opts[:session_count], + type_code: opts[:type], + event_code: opts[:event], + event_type_code: opts[:event_type], + local_timestamp: opts[:local_timestamp] + }) + + write_message(:sport, { + sport_code: opts[:sport], + sub_sport_code: opts[:subsport] + }) + + write_message(:workout, { + sport_code: opts[:sport], + # capabilities: opts[:capabilities], + num_valid_steps: opts[:num_valid_steps], + wkt_name: opts[:name], + sub_sport_code: opts[:subsport], + # pool_length: opts[:pool_length], + # 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_code: :timer, + event_type_code: :start, + event_group: 0 + }) + + yield + + write_message(:event, { + timestamp: start_time + duration, + event_code: :timer, + event_type_code: :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 + end + def course_points raise "Can only start course points mode inside 'write' block" if @state != :write @state = :course_points @@ -80,6 +239,76 @@ 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 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 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 wahoo_custom_nums + 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) @@ -90,44 +319,183 @@ 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 + + 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 + + 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(:pwr_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 + + def wahoo_clm(values) + raise "Can only write wahoo clms inside 'wahoo_clms' block" if @state != :wahoo_clms + formatted_clm_values = format_clm(values) + write_message(:wahoo_clm, formatted_clm_values) + end + protected 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) @stream.write(data) - prev = @data_crc - @data_crc = RubyFit::CRC.update_crc(@data_crc, data) + @data_crc = update_crc(@data_crc, data) end - def calculate_data_size(course_point_count, track_point_count) + 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, 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 ? 2 : 0, course: 1, lap: 1, event: 2, course_point: course_point_count, record: track_point_count, + wahoo_clm: 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: 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 + + result = if count > 0 + def_size + data_size + else + 0 + end + 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, include_wahoo_id) + record_counts = { + file_id: 1, + sport: 1, + workout: 1, + activity: 1, + wahoo_id: include_wahoo_id, + lap: lap_count, + length: length_count, + event: event_count + 2, + workout_step: workout_step_count, + course_point: course_point_count, + record: record_count, + session: session_count, + device_info: device_info_count, + hr_zone: hr_zone_count, + pwr_zone: power_zone_count, + wahoo_custom_num: wahoo_custom_num_count, + wahoo_clm: 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 - result = def_size + data_size - puts "#{type}: #{result}" + 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(&:+) 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) + else + packed_data = clm_json[:data] || clm_json['data'] + end + { + timestamp: timestamp, + device_index: device_index, + data_len: 23, + data: packed_data + } + end end diff --git a/test/activity_test.rb b/test/activity_test.rb new file mode 100644 index 0000000..8f4283f --- /dev/null +++ b/test/activity_test.rb @@ -0,0 +1,175 @@ +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_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 + 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, + 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, + 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', + tot_dist_m: (json[:total_distance] || 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), + 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', + 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 + + # 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) + 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']).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'] + 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'], 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) + 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['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 + 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['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']).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'] + 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'], 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'] + 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 new file mode 100644 index 0000000..4dd637d --- /dev/null +++ b/test/fit_parser_test.rb @@ -0,0 +1,374 @@ +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' +require_relative '../lib/rubyfit/helpers' +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.parse(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' + raw = IO.read(fit_file_path) + + parser = RubyFit::FitFileParser.new + parser.parse(raw) do |data| + + json_output = JSON.parse(data.to_json) + + 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 14:30:57 UTC", json_output['file_id']['time_created']) + + 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']) + assert_equal(1, json_output['activity']['event_type_code']) + + 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']) + 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']) + 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, 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, 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]['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']) + 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) + 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['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_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_code']) + assert_equal(0, json_output['device_infos'][0]['product']) + assert_equal("WAHOO APP", json_output['device_infos'][0]['product_name']) + + 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_nil(json_output['activity']['local_timestamp']) + 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 + + 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_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(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']) + assert_equal("FID14 43761D44:0", json_output['wahoo_id']['workout_token']) + assert_equal(15, json_output['sessions'][0]['workout_type_code']) + 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(2.0, 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']) + 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 + + 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 + + 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 + + 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 + + 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 + + def test_wahoo_clm_workout_plan + fit_file_path = 'test/fixtures/running_assessment.fit' + raw = IO.read(fit_file_path) + parser = RubyFit::FitFileParser.new + parser.parse(raw) do |data| + json_output = JSON.parse(data.to_json) + assert_equal(1, json_output['CLM']['WORKOUT_PLAN_INFO'].size) + 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 diff --git a/test/fixtures/example_activity_json.json b/test/fixtures/example_activity_json.json new file mode 100644 index 0000000..2c3350c --- /dev/null +++ b/test/fixtures/example_activity_json.json @@ -0,0 +1,258 @@ +{ + "name": "Morning Run", + "manufacturer": 2, + "tot_dist_m": 5875.6, + "total_moving_time": 2310, + "total_elapsed_time": 2321, + "tot_timer_time_sec": 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", + "tot_elapsed_time_sec": 645, + "total_moving_time": 645, + "tot_timer_time_sec": 645, + "start_time": 1111065857, + "start_date_local": 1111065857, + "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, + "tot_ascent_m": 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": 1111066857, + "resource_state": 2, + "name": "Lap 2", + "tot_elapsed_time_sec": 650, + "total_moving_time": 650, + "tot_timer_time_sec": 650, + "start_time": 1111066857, + "start_time_local": 1111065857, + "tot_dist_m": 1609.3, + "tot_cal": 275, + "avg_spd_mps": 2.48, + "max_spd_mps": 3.2, + "lap_index": 2, + "split": 2, + "start_index": 208, + "end_index": 857, + "tot_ascent_m": 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", + "time_in_hr_zone_sec": [ + 202.88, + 46.528, + 0.0, + 0.0, + 0.0 + ], + "time_in_pwr_zone_sec": [ + 205.307, + 2.88, + 35.999, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + } + ], + "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, + "tot_dist_m": 5875, + "total_moving_time": 2310, + "total_elapsed_time": 2321, + "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, + "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_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", + "sub_sport": "generic", + "time_in_hr_zone_sec": [ + 202.88, + 46.528, + 0.0, + 0.0, + 0.0 + ], + "time_in_pwr_zone_sec": [ + 205.307, + 2.88, + 35.999, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + } + ], + "records": [ + { + "timestamp": 1111065857, + "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, + "batt_soc_perc": 0, + "grade_perc": 0 + }, + { + "timestamp": 1111065857, + "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, + "batt_soc_perc": 30, + "grade_perc": 5 + } + ], + "hr_zones": [ + { + "high_hr_bpm": 118, + "name": "Zone 0" + }, + { + "high_hr_bpm": 147, + "name": "Zone 1" + } + ], + "power_zones": [ + { + "high_pwr_watts": 68, + "name": "Zone 0" + }, + { + "high_pwr_watts": 87, + "name": "Zone 1" + } + ], + + "wahoo_id": { + "app_token": "WAHOOAPPIOS62BB", + "workout_num": 3, + "workout_type": 12 + }, + + "wahoo_custom_nums": [ + { + "value": 124, + "sub_type_code": 1, + "type_code": 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_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/fixtures/example_route_json.json b/test/fixtures/example_route_json.json new file mode 100644 index 0000000..698d1ab --- /dev/null +++ b/test/fixtures/example_route_json.json @@ -0,0 +1,56 @@ +{ + "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}, + {"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"}, + {"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" + }, + "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 new file mode 100644 index 0000000..421ab9f --- /dev/null +++ b/test/route_test.rb @@ -0,0 +1,153 @@ +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 + + 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, + 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 do |point| + point = point.transform_keys(&:to_sym).merge(type: point['type'].to_sym) + writer.course_point(point) + developer_fields = nil + 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) + 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: 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) + 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