fit4ruby 0.0.4 → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -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