fit4ruby 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,16 +12,25 @@
12
12
 
13
13
  module Fit4Ruby
14
14
 
15
+ # The FIT file maps GlobalFitMessage numbers to local numbers. Due to
16
+ # restrictions in the format, only 16 local messages can be active at any
17
+ # point in the file. If a GlobalFitMessage is needed that is currently not
18
+ # mapped, a new entry is generated and the least recently used message is
19
+ # evicted. The FitMessageIdMapper is the objects that stores those 16 active
20
+ # entries and can map global to local message numbers.
15
21
  class FitMessageIdMapper
16
22
 
17
- class Entry < Struct.new(:global_id, :last_use)
23
+ # The entry in the mapper.
24
+ class Entry < Struct.new(:global_message, :last_use)
18
25
  end
19
26
 
20
27
  def initialize
21
28
  @entries = Array.new(16, nil)
22
29
  end
23
30
 
24
- def add_global(id)
31
+ # Add a new GlobalFitMessage to the mapper and return the local message
32
+ # number.
33
+ def add_global(message)
25
34
  unless (slot = @entries.index { |e| e.nil? })
26
35
  # No more free slots. We have to find the least recently used one.
27
36
  slot = 0
@@ -31,14 +40,16 @@ module Fit4Ruby
31
40
  end
32
41
  end
33
42
  end
34
- @entries[slot] = Entry.new(id, Time.now)
43
+ @entries[slot] = Entry.new(message, Time.now)
35
44
 
36
45
  slot
37
46
  end
38
47
 
39
- def get_local(id)
48
+ # Get the local message number for a given GlobalFitMessage. If there is
49
+ # no message number, nil is returned.
50
+ def get_local(message)
40
51
  0.upto(15) do |i|
41
- if (entry = @entries[i]) && entry.global_id == id
52
+ if (entry = @entries[i]) && entry.global_message == message
42
53
  entry.last_use = Time.now
43
54
  return i
44
55
  end
@@ -3,7 +3,7 @@
3
3
  #
4
4
  # = FitMessageRecord.rb -- Fit4Ruby - FIT file processing library for Ruby
5
5
  #
6
- # Copyright (c) 2014 by Chris Schlaeger <cs@taskjuggler.org>
6
+ # Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org>
7
7
  #
8
8
  # This program is free software; you can redistribute it and/or modify
9
9
  # it under the terms of version 2 of the GNU General Public License as
@@ -13,9 +13,16 @@
13
13
  require 'bindata'
14
14
  require 'fit4ruby/Log'
15
15
  require 'fit4ruby/GlobalFitMessage'
16
+ require 'fit4ruby/FitFileEntity'
16
17
 
17
18
  module Fit4Ruby
18
19
 
20
+ # The FitMessageRecord models a part of the FIT file that contains the
21
+ # FIT message records. Each message record has a number, a local type and a
22
+ # set of data fields. The content of a FitMessageRecord is defined by the
23
+ # FitDefinition. This class is only used for reading data from a FIT file.
24
+ # For writing FIT message records, the class FitDataRecord and its
25
+ # decendents are used.
19
26
  class FitMessageRecord
20
27
 
21
28
  attr_reader :global_message_number, :name, :message_record
@@ -33,35 +40,108 @@ module Fit4Ruby
33
40
  @message_record = produce(definition)
34
41
  end
35
42
 
36
- def read(io, activity, filter = nil, fields_dump = nil)
43
+ def read(io, entity, filter = nil, fields_dump = nil)
37
44
  @message_record.read(io)
38
45
 
39
- obj = @name == 'activity' ? activity : activity.new_fit_data_record(@name)
46
+ if @name == 'file_id'
47
+ unless (entity_type = @message_record['type'].snapshot)
48
+ Log.fatal "Corrupted FIT file: file_id record has no type definition"
49
+ end
50
+ entity.set_type(entity_type)
51
+ end
52
+ obj = entity.new_fit_data_record(@name)
53
+
54
+ # It's important to ensure that alternative fields processed after the
55
+ # regular fields so that the decision field is already set.
56
+ sorted_fields = @definition.fields.sort do |f1, f2|
57
+ f1alt = is_alt_field?(f1)
58
+ f2alt = is_alt_field?(f2)
59
+ f1alt == f2alt ?
60
+ f1.field_definition_number.snapshot <=>
61
+ f2.field_definition_number.snapshot :
62
+ f1alt ? 1 : -1
63
+ end
40
64
 
41
- @definition.fields.each do |field|
65
+ sorted_fields.each do |field|
42
66
  value = @message_record[field.name].snapshot
43
- obj.set(field.name, field.to_machine(value)) if obj
67
+ # Strings are null byte terminated. There may be more bytes in the
68
+ # file, but we have to discard all bytes from the first null byte
69
+ # onwards.
70
+ if value.is_a?(String) && (null_byte = value.index("\0"))
71
+ value = value[0..(null_byte - 1)]
72
+ end
73
+
74
+ field_name, field_def = get_field_name_and_global_def(field, obj)
75
+ obj.set(field_name, field.to_machine(value)) if obj
76
+
44
77
  if filter && fields_dump &&
45
78
  (filter.field_names.nil? ||
46
- filter.field_names.include?(field.name)) &&
79
+ filter.field_names.include?(field_name)) &&
47
80
  (value != field.undefined_value || !filter.ignore_undef)
48
- fields_dump[field] = value
81
+ fields_dump << [ field.type(true), field_name,
82
+ (field_def ? field_def : field).to_s(value) ]
49
83
  end
50
84
  end
51
85
  end
52
86
 
53
87
  private
54
88
 
89
+ def is_alt_field?(field)
90
+ return false unless @gfm
91
+
92
+ field_def_number = field.field_definition_number.snapshot
93
+ field_def = @gfm.fields_by_number[field_def_number]
94
+ field_def.is_a?(GlobalFitMessage::AltField)
95
+ end
96
+
97
+ def get_field_name_and_global_def(field, obj)
98
+ # If we don't have a corresponding GlobalFitMessage definition, we can't
99
+ # tell if the field is an alternative or not. We don't treat it as such.
100
+ return [ field.name, nil ] unless @gfm
101
+
102
+ field_def_number = field.field_definition_number.snapshot
103
+ # Get the corresponding GlobalFitMessage field definition.
104
+ field_def = @gfm.fields_by_number[field_def_number]
105
+ # If it's not an AltField, we just use the already given name.
106
+ unless field_def.is_a?(GlobalFitMessage::AltField)
107
+ return [ field.name, nil ]
108
+ end
109
+
110
+ # We have an AltField. Now we need to find the selection field and its
111
+ # value.
112
+ ref_field = field_def.ref_field
113
+ ref_value = obj ? obj.get(ref_field) : :default
114
+
115
+ # Based on that value, we select the Field of the AltField.
116
+ selected_field = field_def.fields[ref_value] ||
117
+ field_def.fields[:default]
118
+ Log.fatal "The value #{ref_value} of field #{ref_field} does not match " +
119
+ "any selection of alternative field #{field_def_number} in " +
120
+ "GlobalFitMessage #{@gfm.name}" unless selected_field
121
+
122
+ [ selected_field.name, selected_field ]
123
+ end
124
+
55
125
  def produce(definition)
56
126
  fields = []
57
127
  definition.fields.each do |field|
58
- fields << [ field.type, field.name ]
128
+ field_def = [ field.type, field.name ]
129
+ if field.type == 'string'
130
+ # Strings need special handling. We need to also include the length
131
+ # of the String.
132
+ field_def << { :read_length => field.total_bytes }
133
+ elsif field.is_array?
134
+ field_def = [ :array, field.name,
135
+ { :type => field.type.intern,
136
+ :initial_length => field.total_bytes /
137
+ field.base_type_bytes } ]
138
+ end
139
+ fields << field_def
59
140
  end
60
141
 
61
142
  BinData::Struct.new(:endian => definition.endian, :fields => fields)
62
143
  end
63
144
 
64
-
65
145
  end
66
146
 
67
147
  end
@@ -3,7 +3,7 @@
3
3
  #
4
4
  # = FitRecord.rb -- Fit4Ruby - FIT file processing library for Ruby
5
5
  #
6
- # Copyright (c) 2014 by Chris Schlaeger <cs@taskjuggler.org>
6
+ # Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org>
7
7
  #
8
8
  # This program is free software; you can redistribute it and/or modify
9
9
  # it under the terms of version 2 of the GNU General Public License as
@@ -15,10 +15,14 @@ require 'fit4ruby/FitRecordHeader'
15
15
  require 'fit4ruby/FitDefinition'
16
16
  require 'fit4ruby/FitMessageRecord'
17
17
  require 'fit4ruby/FitFilter'
18
- require 'fit4ruby/Activity'
18
+ require 'fit4ruby/FitFileEntity'
19
19
 
20
20
  module Fit4Ruby
21
21
 
22
+ # The FitRecord is a basic building block of a FIT file. It can either
23
+ # contain a definition of a message record or an actual message record with
24
+ # FIT data. A FIT record always starts with a header that describes what
25
+ # kind of record this is.
22
26
  class FitRecord
23
27
 
24
28
  def initialize(definitions)
@@ -26,13 +30,13 @@ module Fit4Ruby
26
30
  @name = @number = @fields = nil
27
31
  end
28
32
 
29
- def read(io, activity, filter, record_counters)
33
+ def read(io, entity, filter, record_counters)
30
34
  header = FitRecordHeader.read(io)
31
35
 
32
- if header.normal?
36
+ if !header.compressed?
33
37
  # process normal headers
34
38
  local_message_type = header.local_message_type.snapshot
35
- if header.message_type == 1
39
+ if header.message_type.snapshot == 1
36
40
  # process definition message
37
41
  definition = FitDefinition.read(io)
38
42
  @definitions[local_message_type] = FitMessageRecord.new(definition)
@@ -45,15 +49,19 @@ module Fit4Ruby
45
49
  if filter
46
50
  @number = @definitions[local_message_type].global_message_number
47
51
  index = (record_counters[@number] += 1)
52
+
53
+ # Check if we have a filter defined to collect raw dumps of the
54
+ # data in the message records. The dump is collected in @fields
55
+ # for later output.
48
56
  if (filter.record_numbers.nil? ||
49
57
  filter.record_numbers.include?(@number)) &&
50
58
  (filter.record_indexes.nil? ||
51
59
  filter.record_indexes.include?(index))
52
60
  @name = definition.name
53
- @fields = {}
61
+ @fields = []
54
62
  end
55
63
  end
56
- definition.read(io, activity, filter, @fields)
64
+ definition.read(io, entity, filter, @fields)
57
65
  end
58
66
  else
59
67
  # process compressed timestamp header
@@ -67,10 +75,13 @@ module Fit4Ruby
67
75
  def dump
68
76
  return unless @fields
69
77
 
70
- puts "Message #{@number}: #{@name}" unless @fields.empty?
71
- @fields.each do |field, value|
72
- puts " [#{"%-7s" % field.type(true)}] #{field.name}: " +
73
- "#{field.to_s(value)}"
78
+ begin
79
+ puts "Message #{@number}: #{@name}" unless @fields.empty?
80
+ @fields.each do |type, name, value|
81
+ puts " [#{"%-7s" % type}] #{name}: " + "#{value}"
82
+ end
83
+ rescue Errno::EPIPE
84
+ # Avoid ugly error message when aborting a less/more pipe.
74
85
  end
75
86
  end
76
87
 
@@ -27,11 +27,11 @@ module Fit4Ruby
27
27
  bit5 :time_offset, :onlyif => :compressed?
28
28
 
29
29
  def normal?
30
- normal == 0
30
+ normal.snapshot == 0
31
31
  end
32
32
 
33
33
  def compressed?
34
- normal == 1
34
+ normal.snapshot == 1
35
35
  end
36
36
 
37
37
  end
@@ -26,6 +26,12 @@ module Fit4Ruby
26
26
  entry 6, 'walking'
27
27
  entry 254, 'all'
28
28
 
29
+ dict 'ant_network'
30
+ entry 0, 'public'
31
+ entry 1, 'antplus'
32
+ entry 2, 'antfs'
33
+ entry 3, 'private'
34
+
29
35
  dict 'battery_status'
30
36
  entry 1, 'new'
31
37
  entry 2, 'good'
@@ -53,6 +59,10 @@ module Fit4Ruby
53
59
  entry 123, 'bike_speed'
54
60
  entry 124, 'stride_speed_distance'
55
61
 
62
+ dict 'display_measure'
63
+ entry 0, 'metric'
64
+ entry 1, 'statute'
65
+
56
66
  dict 'event'
57
67
  entry 0, 'timer'
58
68
  entry 3, 'workout'
@@ -86,6 +96,7 @@ module Fit4Ruby
86
96
  entry 36, 'calibration'
87
97
  entry 37, 'vo2max' # guess
88
98
  entry 38, 'recovery_time' # guess (in minutes)
99
+ entry 39, 'recovery_info' # guess (in minutes, < 24 good, > 24h poor)
89
100
  entry 42, 'front_gear_change'
90
101
  entry 43, 'rear_gear_change'
91
102
 
@@ -118,6 +129,23 @@ module Fit4Ruby
118
129
  entry 28, 'monitoring_daily'
119
130
  entry 32, 'monitoring_b'
120
131
 
132
+ dict 'garmin_product'
133
+ entry 8, 'hrm_run_single_byte_product_id'
134
+ entry 1551, 'fenix'
135
+ entry 1623, 'fr620'
136
+ entry 1632, 'fr220'
137
+ entry 1752, 'hrm_run'
138
+ entry 1765, 'fr920xt'
139
+ entry 1928, 'fr620_japan'
140
+ entry 1929, 'fr620_china'
141
+ entry 1930, 'fr220_japan'
142
+ entry 1931, 'fr220_china'
143
+ entry 1967, 'fenix2'
144
+ entry 10007, 'sdm4'
145
+ entry 20119, 'training_center'
146
+ entry 65532, 'android_antplus_plugin'
147
+ entry 65534, 'connect'
148
+
121
149
  dict 'gender'
122
150
  entry 0, 'female'
123
151
  entry 1, 'male'
@@ -139,8 +167,12 @@ module Fit4Ruby
139
167
  entry 7, 'session_end'
140
168
  entry 8, 'fitness_equipment'
141
169
 
170
+ dict 'left_right_balance_100'
171
+ entry 0x3FFF, 'mask'
172
+ entry 0x8000, 'right'
173
+
142
174
  dict 'manufacturer'
143
- entry 1, 'Garmin'
175
+ entry 1, 'garmin'
144
176
  entry 2, 'garmin_fr405_antfs'
145
177
  entry 3, 'zephyr'
146
178
  entry 4, 'dayton'
@@ -232,6 +264,14 @@ module Fit4Ruby
232
264
  entry 2, 'auto_multi_sport'
233
265
  entry 3, 'fitness_equipment'
234
266
 
267
+ dict 'source_type'
268
+ entry 0, 'ant'
269
+ entry 1, 'antplus'
270
+ entry 2, 'bluetooth'
271
+ entry 3, 'bluetooth_low_enegery'
272
+ entry 4, 'wifi'
273
+ entry 5, 'local'
274
+
235
275
  dict 'sport'
236
276
  entry 0, 'generic'
237
277
  entry 1, 'running'
@@ -255,6 +295,15 @@ module Fit4Ruby
255
295
  entry 19, 'paddling'
256
296
  entry 254, 'all'
257
297
 
298
+ dict 'swim_stroke'
299
+ entry 0, 'freestyle'
300
+ entry 1, 'backstroke'
301
+ entry 2, 'breaststrike'
302
+ entry 3, 'butterfly'
303
+ entry 4, 'drill'
304
+ entry 5, 'mixed'
305
+ entry 6, 'im'
306
+
258
307
  dict 'sub_sport'
259
308
  entry 0, 'generic'
260
309
  entry 1, 'treadmill'
@@ -285,17 +334,6 @@ module Fit4Ruby
285
334
  entry 26, 'cardio_training'
286
335
  entry 254, 'all'
287
336
 
288
- dict 'product'
289
- entry 8, 'hrm_run_single_byte_product_id'
290
- entry 1623, 'fr620'
291
- entry 1632, 'fr220'
292
- entry 1752, 'hrm_run'
293
- entry 1928, 'fr620_japan'
294
- entry 1929, 'fr620_china'
295
- entry 1930, 'fr220_japan'
296
- entry 1931 , 'fr220_china'
297
- entry 10007, 'sdm4'
298
-
299
337
  end
300
338
 
301
339
  end
@@ -16,10 +16,16 @@ require 'fit4ruby/FitDefinitionField'
16
16
 
17
17
  module Fit4Ruby
18
18
 
19
+ # The GlobalFitMessage stores an abstract description of a particular
20
+ # FitMessage. It holds information like the name, the global ID number and
21
+ # the data fields of the message.
19
22
  class GlobalFitMessage
20
23
 
21
- attr_reader :name, :number, :fields
24
+ attr_reader :name, :number, :fields_by_name, :fields_by_number
22
25
 
26
+ # The Field objects describe the name, type and optional attributes of a
27
+ # FitMessage definition field. It also provides methods to convert field
28
+ # values into various formats.
23
29
  class Field
24
30
 
25
31
  include Converters
@@ -131,24 +137,128 @@ module Fit4Ruby
131
137
 
132
138
  end
133
139
 
140
+ # A GlobalFitMessage may have Field entries that are dependent on the
141
+ # value of another Field. These alternative fields all depend on the value
142
+ # of a specific other Field of the GlobalFitMessage and their presense is
143
+ # mutually exclusive. An AltField object models such a group of Field
144
+ # objects.
145
+ class AltField
146
+
147
+ attr_reader :fields, :ref_field
148
+
149
+ # Create a new AltField object.
150
+ # @param message [GlobalFitMessage] reference to the GlobalFitMessage
151
+ # this field belongs to.
152
+ # @param ref_field [String] The name of the field that is used to select
153
+ # the alternative.
154
+ def initialize(message, ref_field, &block)
155
+ @message = message
156
+ @ref_field = ref_field
157
+ @fields = {}
158
+
159
+ instance_eval(&block) if block_given?
160
+ end
161
+
162
+ def field(ref_value, type, name, opts = {})
163
+ field = Field.new(type, name, opts)
164
+ if ref_value.respond_to?('each')
165
+ ref_value.each do |rv|
166
+ @fields[rv] = field
167
+ end
168
+ else
169
+ @fields[ref_value] = field
170
+ end
171
+ @message.register_field_by_name(field, name)
172
+ end
173
+
174
+ # Select the alternative field based on the actual field values of the
175
+ # FitMessageRecord.
176
+ def select(field_values_by_name)
177
+ unless (value = field_values_by_name[@ref_field])
178
+ Log.fatal "The selection field #{@ref_field} for the alternative " +
179
+ "field is undefined in global message #{@message.name}: " +
180
+ field_values_by_name.inspect
181
+ end
182
+ @fields.each do |ref_value, field|
183
+ return field if ref_value == value
184
+ end
185
+ return @fields[:default] if @fields[:default]
186
+
187
+ Log.fatal "The selector value #{value} for the alternative field " +
188
+ "is not supported in global message #{@message.name}."
189
+ end
190
+
191
+ end
192
+
193
+ # Create a new GlobalFitMessage definition.
194
+ # @param name [String] name of the FIT message
195
+ # @param number [Fixnum] global message number
134
196
  def initialize(name, number)
135
197
  @name = name
136
198
  @number = number
137
- @fields = {}
199
+ # Field names must be unique. A name always matches a single Field.
200
+ @fields_by_name = {}
201
+ # Field numbers are not unique. A group of alternative fields shares the
202
+ # same number and is stored as an AltField. Otherwise as Field.
203
+ @fields_by_number = {}
138
204
  end
139
205
 
206
+ # Two GlobalFitMessage objects are considered equal if they have the same
207
+ # number, name and list of named fields.
208
+ def ==(m)
209
+ @number == m.number && @name == m.name &&
210
+ @fields_by_name.keys.sort == m.fields_by_name.keys.sort
211
+ end
212
+
213
+ # Define a new Field for this message definition.
140
214
  def field(number, type, name, opts = {})
141
- if @fields.include?(number)
215
+ field = Field.new(type, name, opts)
216
+ register_field_by_name(field, name)
217
+ register_field_by_number(field, number)
218
+ end
219
+
220
+ # Define a new set of Field alternatives for this message definition.
221
+ def alt_field(number, ref_field, &block)
222
+ unless @fields_by_name.include?(ref_field)
223
+ raise "Unknown ref_field: #{ref_field}"
224
+ end
225
+
226
+ field = AltField.new(self, ref_field, &block)
227
+ register_field_by_number(field, number)
228
+ end
229
+
230
+ def register_field_by_name(field, name)
231
+ if @fields_by_name.include?(name)
232
+ raise "Field '#{name}' has already been defined"
233
+ end
234
+
235
+ @fields_by_name[name] = field
236
+ end
237
+
238
+ def register_field_by_number(field, number)
239
+ if @fields_by_name.include?(number)
142
240
  raise "Field #{number} has already been defined"
143
241
  end
144
- @fields[number] = Field.new(type, name, opts)
242
+
243
+ @fields_by_number[number] = field
145
244
  end
146
245
 
147
- def find_by_name(field_name)
148
- @fields.values.find { |f| f.name == field_name }
246
+ def construct(field_values_by_name)
247
+ gfm = GlobalFitMessage.new(@name, @number)
248
+
249
+ @fields_by_number.each do |number, field|
250
+ if field.is_a?(AltField)
251
+ # For alternative fields, we need to look at the value of the
252
+ # selector field and pick the corresponding Field.
253
+ field = field.select(field_values_by_name)
254
+ end
255
+ gfm.field(number, field.type, field.name, field.opts)
256
+ end
257
+
258
+ gfm
149
259
  end
150
260
 
151
- def write(io, local_message_type)
261
+ def write(io, local_message_type, global_fit_message = self)
152
262
  header = FitRecordHeader.new
153
263
  header.normal = 0
154
264
  header.message_type = 1
@@ -157,7 +267,7 @@ module Fit4Ruby
157
267
 
158
268
  definition = FitDefinition.new
159
269
  definition.global_message_number = @number
160
- definition.setup(self)
270
+ definition.setup(global_fit_message)
161
271
  definition.write(io)
162
272
  end
163
273
 
@@ -212,6 +322,13 @@ module Fit4Ruby
212
322
  @current_message.field(number, type, name, opts)
213
323
  end
214
324
 
325
+ def alt_field(number, ref_field, &block)
326
+ unless @current_message
327
+ raise "You must define a message first"
328
+ end
329
+ @current_message.alt_field(number, ref_field, &block)
330
+ end
331
+
215
332
  def [](number)
216
333
  @messages[number]
217
334
  end