f4r 0.1.0
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.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.travis.yml +7 -0
- data/CHANGELOG.md +39 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +178 -0
- data/Rakefile +11 -0
- data/config/base_types.csv +19 -0
- data/config/profile_messages.csv +1364 -0
- data/config/profile_types.csv +3350 -0
- data/config/undocumented_messages.csv +570 -0
- data/config/undocumented_types.csv +31 -0
- data/f4r.gemspec +31 -0
- data/lib/f4r.rb +1747 -0
- metadata +172 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Type Name,Base Type,Value Name,Value,Comment
|
|
2
|
+
mesg_num,uint16,,,
|
|
3
|
+
,,undocumented_13,13,
|
|
4
|
+
,,data_sources,22,
|
|
5
|
+
,,undocumented_24,24,
|
|
6
|
+
,,location,29,
|
|
7
|
+
,,user_data,79,
|
|
8
|
+
,,battery,104,
|
|
9
|
+
,,personal_records,113,
|
|
10
|
+
,,undocumented_125,125,
|
|
11
|
+
,,physiological_metrics,140,
|
|
12
|
+
,,epo_data,141,
|
|
13
|
+
,,sensor_settings,147,
|
|
14
|
+
,,undocumented_188,188,
|
|
15
|
+
,,undocumented_211,211,
|
|
16
|
+
,,heart_rate_zones,216,
|
|
17
|
+
,,undocumented_229,229,
|
|
18
|
+
,,training_status,232,
|
|
19
|
+
,,undocumented_233,233,
|
|
20
|
+
,,metrics,241,
|
|
21
|
+
,,media_player_info,243,
|
|
22
|
+
,,undocumented_244,244,
|
|
23
|
+
,,undocumented_261,261,
|
|
24
|
+
,,undocumented_269,269,
|
|
25
|
+
,,undocumented_279,279,
|
|
26
|
+
,,training_load,284,
|
|
27
|
+
,,undocumented_288,288,
|
|
28
|
+
,,undocumented_1024,1024,
|
|
29
|
+
,,,,
|
|
30
|
+
garmin_product,uint16,,,
|
|
31
|
+
,,instinct,3126,
|
data/f4r.gemspec
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
lib = File.expand_path('lib', __dir__)
|
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
3
|
+
require 'f4r'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'f4r'
|
|
7
|
+
spec.version = F4R::VERSION
|
|
8
|
+
spec.authors = ['jpablobr']
|
|
9
|
+
spec.email = ['xjpablobrx@gmail.com']
|
|
10
|
+
spec.homepage = 'https://github.com/jpablobr/f4r'
|
|
11
|
+
spec.summary = 'Simple .FIT file encoder/decoder'
|
|
12
|
+
spec.license = 'MIT'
|
|
13
|
+
|
|
14
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
15
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
|
16
|
+
spec.metadata['changelog_uri'] = spec.homepage
|
|
17
|
+
|
|
18
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
|
19
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
20
|
+
end
|
|
21
|
+
spec.require_paths = ['lib']
|
|
22
|
+
|
|
23
|
+
spec.add_dependency 'bindata', '2.4.4'
|
|
24
|
+
spec.add_dependency 'csv', '3.1.2'
|
|
25
|
+
spec.add_development_dependency 'bundler', '~> 2.0'
|
|
26
|
+
spec.add_development_dependency 'rake', '~> 12.3.3'
|
|
27
|
+
spec.add_development_dependency 'minitest', '~> 5.0'
|
|
28
|
+
spec.add_development_dependency 'minitest-autotest', '~> 1.1.1'
|
|
29
|
+
spec.add_development_dependency 'minitest-line', '~> 0.6.5'
|
|
30
|
+
spec.add_development_dependency 'pry', '~> 0.12'
|
|
31
|
+
end
|
data/lib/f4r.rb
ADDED
|
@@ -0,0 +1,1747 @@
|
|
|
1
|
+
require 'csv'
|
|
2
|
+
require 'bindata'
|
|
3
|
+
require 'logger'
|
|
4
|
+
require 'singleton'
|
|
5
|
+
|
|
6
|
+
module F4R
|
|
7
|
+
|
|
8
|
+
VERSION = '0.1.0'
|
|
9
|
+
|
|
10
|
+
##
|
|
11
|
+
# Fit Profile revision for the messages and types in the {Config.directory}.
|
|
12
|
+
|
|
13
|
+
FIT_PROFILE_REV = '2.3'
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# Class for application wide configurations.
|
|
17
|
+
|
|
18
|
+
class Config
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# Directory for all FIT Profile (defined and undefined) definitions.
|
|
24
|
+
#
|
|
25
|
+
# @return [File] @@directory
|
|
26
|
+
|
|
27
|
+
def directory
|
|
28
|
+
@@directory ||= get_directory
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# @param [File] dir
|
|
33
|
+
|
|
34
|
+
def directory=(dir)
|
|
35
|
+
@@directory = dir
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# Directory for all message and type definitions.
|
|
42
|
+
#
|
|
43
|
+
# @return [File] directory
|
|
44
|
+
|
|
45
|
+
def get_directory
|
|
46
|
+
local_dir = File.expand_path('~/.f4r')
|
|
47
|
+
if File.directory?(local_dir)
|
|
48
|
+
local_dir
|
|
49
|
+
else
|
|
50
|
+
File.expand_path('../config', __dir__)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# Exception for all F4R errors.
|
|
60
|
+
|
|
61
|
+
class Error < StandardError ; end
|
|
62
|
+
|
|
63
|
+
##
|
|
64
|
+
# Open ::Logger to add ENCODE and DECODE (debugging) log levels.
|
|
65
|
+
|
|
66
|
+
class Logger < ::Logger
|
|
67
|
+
|
|
68
|
+
SEV_LABEL = %w(DEBUG INFO WARN ERROR FATAL ANY ENCODE DECODE)
|
|
69
|
+
|
|
70
|
+
def format_severity(severity)
|
|
71
|
+
SEV_LABEL[severity] || 'ANY'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def encode(progname = nil, &block)
|
|
75
|
+
add(6, nil, progname, &block)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def decode(progname = nil, &block)
|
|
79
|
+
add(7, nil, progname, &block)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
##
|
|
85
|
+
# Singleton to provide a common logging mechanism for all objects. It
|
|
86
|
+
# exposes essentially the same interface as the Logger class but just as a
|
|
87
|
+
# singleton and with some additional methods like 'debug', 'warn', 'info'.
|
|
88
|
+
#
|
|
89
|
+
# It also facilitates configurable log output redirection based on severity
|
|
90
|
+
# levels to help reduce noise in the different output devices.
|
|
91
|
+
|
|
92
|
+
class F4RLogger
|
|
93
|
+
|
|
94
|
+
include Singleton
|
|
95
|
+
|
|
96
|
+
##
|
|
97
|
+
# @example
|
|
98
|
+
# F4R::Log.logger = F4R::Logger.new($stdout)
|
|
99
|
+
#
|
|
100
|
+
# @param [Logger] logger
|
|
101
|
+
# @return [Logger] +@logger+
|
|
102
|
+
|
|
103
|
+
def logger=(logger)
|
|
104
|
+
log_formater(logger) && @logger = logger
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
##
|
|
108
|
+
# @example
|
|
109
|
+
# F4R::Log.encode_logger = F4R::Logger.new($stdout)
|
|
110
|
+
#
|
|
111
|
+
# @param [Logger] logger
|
|
112
|
+
# @return [Logger] +@encode_logger+
|
|
113
|
+
|
|
114
|
+
def encode_logger=(logger)
|
|
115
|
+
log_formater(logger) && @encode_logger = logger
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
##
|
|
119
|
+
# @example
|
|
120
|
+
# F4R::Log.decode_logger = F4R::Logger.new($stdout)
|
|
121
|
+
#
|
|
122
|
+
# @param [Logger] logger
|
|
123
|
+
# @return [Logger] +@decode_logger+
|
|
124
|
+
|
|
125
|
+
def decode_logger=(logger)
|
|
126
|
+
log_formater(logger) && @decode_logger = logger
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
##
|
|
130
|
+
# @return [Logger] +@logger+
|
|
131
|
+
|
|
132
|
+
def logger
|
|
133
|
+
@logger ||= Logger.new($stdout)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
##
|
|
137
|
+
# @return [Logger] +@encode_logger+
|
|
138
|
+
|
|
139
|
+
def encode_logger
|
|
140
|
+
@encode_logger ||= Logger.new('/tmp/f4r-encode.log')
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
##
|
|
144
|
+
# @return [Logger] +@decode_logger+
|
|
145
|
+
|
|
146
|
+
def decode_logger
|
|
147
|
+
@decode_logger ||= Logger.new('/tmp/f4r-decode.log')
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
##
|
|
151
|
+
# Method for setting the severity level for all loggers.
|
|
152
|
+
#
|
|
153
|
+
# @example
|
|
154
|
+
# F4R::Log.level = :error
|
|
155
|
+
#
|
|
156
|
+
# @param [Symbol, String, Integer] level
|
|
157
|
+
|
|
158
|
+
def level=(level)
|
|
159
|
+
[
|
|
160
|
+
logger,
|
|
161
|
+
decode_logger,
|
|
162
|
+
encode_logger
|
|
163
|
+
].each { |lgr| lgr.level = level}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
##
|
|
167
|
+
# Severity level for all [F4RLogger] loggers.
|
|
168
|
+
#
|
|
169
|
+
# @return [Symbol, String, Integer] @@level
|
|
170
|
+
|
|
171
|
+
def level
|
|
172
|
+
@level ||= :error
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
##
|
|
176
|
+
# Allow other programs to enable or disable colour output.
|
|
177
|
+
#
|
|
178
|
+
# @example
|
|
179
|
+
# F4R::Log.color = true
|
|
180
|
+
#
|
|
181
|
+
# @param [Boolean] bool
|
|
182
|
+
|
|
183
|
+
def color=(bool)
|
|
184
|
+
@color = bool
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
##
|
|
188
|
+
# When set to True enables logger colour output.
|
|
189
|
+
#
|
|
190
|
+
# @return [Boolean] +@color+
|
|
191
|
+
|
|
192
|
+
def color?
|
|
193
|
+
@color ||= false
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
##
|
|
197
|
+
# DEBUG level messages.
|
|
198
|
+
#
|
|
199
|
+
# @param [String, Array<String>] msg
|
|
200
|
+
#
|
|
201
|
+
# Mostly used to locate or describe items in the +items+ parameter.
|
|
202
|
+
#
|
|
203
|
+
# String: Simple text message.
|
|
204
|
+
#
|
|
205
|
+
# Array<String>: List of key words to be concatenated with a '#' inside
|
|
206
|
+
# '<>' (see: {format_message}). Meant to be used for describing the class
|
|
207
|
+
# and method where the log message was called from.
|
|
208
|
+
#
|
|
209
|
+
# Example:
|
|
210
|
+
# >> ['F4R::Record', 'fields'] #=> '<F4R::Record#fields>'
|
|
211
|
+
#
|
|
212
|
+
# @param [Hash] items
|
|
213
|
+
#
|
|
214
|
+
# Key/Value list of items for debugging.
|
|
215
|
+
#
|
|
216
|
+
# @yield [block] passed directly to the [F4RLogger] logger.
|
|
217
|
+
# @return [String] formatted message.
|
|
218
|
+
#
|
|
219
|
+
# Example:
|
|
220
|
+
# >> Log.debug [self.class, __method__], {a:1, b:2}
|
|
221
|
+
# => DEBUG <F4R::Record#fields> a: 1 b: 2
|
|
222
|
+
|
|
223
|
+
def debug(msg = '', items = {}, &block)
|
|
224
|
+
logger.debug(format_message(msg, items), &block)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
##
|
|
228
|
+
# INFO level messages.
|
|
229
|
+
#
|
|
230
|
+
# @param [String] msg passed directly to the [F4RLogger] logger
|
|
231
|
+
# @yield [block] passed directly to the [F4RLogger] logger
|
|
232
|
+
# @return [String] formatted message
|
|
233
|
+
|
|
234
|
+
def info(msg, &block)
|
|
235
|
+
logger.info(msg, &block)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
##
|
|
239
|
+
# WARN level messages.
|
|
240
|
+
#
|
|
241
|
+
# @param [String] msg
|
|
242
|
+
#
|
|
243
|
+
# Passed directly to the [F4RLogger] logger after removing all newlines.
|
|
244
|
+
#
|
|
245
|
+
# @yield [block] passed directly to the [F4RLogger] logger
|
|
246
|
+
# @return [String] formatted message
|
|
247
|
+
|
|
248
|
+
def warn(msg, &block)
|
|
249
|
+
logger.warn(msg.gsub(/\n/, ' '), &block)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
##
|
|
253
|
+
# ERROR level messages.
|
|
254
|
+
#
|
|
255
|
+
# Raises [F4R::ERROR].
|
|
256
|
+
#
|
|
257
|
+
# @param [String] msg Passed directly to the [F4RLogger] logger.
|
|
258
|
+
# @yield [block] passed directly to the [F4RLogger] logger.
|
|
259
|
+
# @raise [F4R::Error] with formatted message.
|
|
260
|
+
|
|
261
|
+
def error(msg, &block)
|
|
262
|
+
logger.error(msg, &block)
|
|
263
|
+
raise Error, msg
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
##
|
|
267
|
+
# ENCODE level messages.
|
|
268
|
+
#
|
|
269
|
+
# Similar to {debug} but with its specific [F4RLogger] logger
|
|
270
|
+
#
|
|
271
|
+
# @param [String, Array<String>] msg
|
|
272
|
+
# @param [Hash] items
|
|
273
|
+
# @yield [block] passed directly to the [F4RLogger] logger
|
|
274
|
+
# @return [String] formatted message
|
|
275
|
+
|
|
276
|
+
def encode(msg, items = {}, &block)
|
|
277
|
+
decode_logger.encode(format_message(msg, items), &block)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
##
|
|
281
|
+
# DECODE level messages.
|
|
282
|
+
#
|
|
283
|
+
# Similar to {debug} but with its specific [F4RLogger] logger.
|
|
284
|
+
#
|
|
285
|
+
# @param [String, Array<String>] msg
|
|
286
|
+
# @param [Hash] items
|
|
287
|
+
# @yield [block] passed directly to the [F4RLogger] logger
|
|
288
|
+
# @return [String] formatted message
|
|
289
|
+
|
|
290
|
+
def decode(msg, items = {}, &block)
|
|
291
|
+
decode_logger.decode(format_message(msg, items), &block)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
##
|
|
295
|
+
# Simple colour codes mapping.
|
|
296
|
+
#
|
|
297
|
+
# @param [Symbol] clr to define colour code to use
|
|
298
|
+
# @param [String] text to be coloured
|
|
299
|
+
# @return [String] text with the proper colour code
|
|
300
|
+
|
|
301
|
+
def tint(clr, text)
|
|
302
|
+
codes = {
|
|
303
|
+
none: 0, bright: 1, black: 30, red: 31,
|
|
304
|
+
green: 32, yellow: 33, blue: 34,
|
|
305
|
+
magenta: 35, cyan: 36, white: 37, default: 39,
|
|
306
|
+
}
|
|
307
|
+
["\x1B[", codes[clr].to_s, 'm', text.to_s, "\x1B[0m"].join
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
##
|
|
311
|
+
# Formats message and items for the [F4RLogger] logger output.
|
|
312
|
+
# It also adds colour when {color?} has been set to +true+.
|
|
313
|
+
#
|
|
314
|
+
# @param [String, Array<String, Object>] msg
|
|
315
|
+
# @param [Hash] items
|
|
316
|
+
# @return [String] formatted message
|
|
317
|
+
|
|
318
|
+
def format_message(msg, items)
|
|
319
|
+
if msg.is_a?(Array)
|
|
320
|
+
if Log.color?
|
|
321
|
+
msg = Log.tint(:blue, "<#{msg.join('#')}>")
|
|
322
|
+
else
|
|
323
|
+
msg = "<#{msg.join('#')}>"
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
items.each do |k, v|
|
|
328
|
+
k = Log.color? ? Log.tint(:green, k.to_s): k.to_s
|
|
329
|
+
msg += " #{k}: #{v.to_s}"
|
|
330
|
+
end
|
|
331
|
+
msg
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
##
|
|
335
|
+
# Logger formatter configuration
|
|
336
|
+
|
|
337
|
+
def log_formater(logger)
|
|
338
|
+
logger.formatter = proc do |severity, _, _, msg|
|
|
339
|
+
|
|
340
|
+
if Log.color?
|
|
341
|
+
sc = {
|
|
342
|
+
'DEBUG' => :magenta,
|
|
343
|
+
'INFO' => :blue,
|
|
344
|
+
'WARN' => :yellow,
|
|
345
|
+
'ERROR' => :red,
|
|
346
|
+
'ENCODE' => :green,
|
|
347
|
+
'DECODE' => :cyan,
|
|
348
|
+
}
|
|
349
|
+
Log.tint(sc[severity], "#{'%-6s' % severity} ") + "#{msg}\n"
|
|
350
|
+
else
|
|
351
|
+
severity + " #{msg}\n"
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
##
|
|
360
|
+
# Single F4RLogger instance
|
|
361
|
+
|
|
362
|
+
Log = F4RLogger.instance
|
|
363
|
+
|
|
364
|
+
##
|
|
365
|
+
# Provides the FIT SDK global definition for all objects. Sometimes more
|
|
366
|
+
# information is needed in order to be able to decode FIT files so definitions
|
|
367
|
+
# for these undocumented messages and types (based on guesses ) is also
|
|
368
|
+
# provided.
|
|
369
|
+
|
|
370
|
+
module GlobalFit
|
|
371
|
+
|
|
372
|
+
##
|
|
373
|
+
# Collection of defined (FIT SDK) and undefined (F4R) messages.
|
|
374
|
+
#
|
|
375
|
+
# Message fields without +field_def+ (e.i., field's number/id within the
|
|
376
|
+
# message) which usually mean that they are either sub-fields or not
|
|
377
|
+
# defined properly (e.i., invalid) get filtered out. Results come from
|
|
378
|
+
# {Helper#get_messages}.
|
|
379
|
+
#
|
|
380
|
+
# @example GlobalFit.messages
|
|
381
|
+
# [
|
|
382
|
+
# {
|
|
383
|
+
# :name=>"file_id",
|
|
384
|
+
# :number=>0,
|
|
385
|
+
# :fields=> [...]
|
|
386
|
+
# },
|
|
387
|
+
# {
|
|
388
|
+
# :name=>"file_creator",
|
|
389
|
+
# :number=>49,
|
|
390
|
+
# :fields=> [...]
|
|
391
|
+
# }
|
|
392
|
+
# ...
|
|
393
|
+
# ]
|
|
394
|
+
#
|
|
395
|
+
# @return [Array<Hash>] of FIT messages
|
|
396
|
+
|
|
397
|
+
def self.messages
|
|
398
|
+
@@messages ||= Helper.new.get_messages.freeze
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
##
|
|
402
|
+
# Collection of defined (FIT SDK) and undefined (F4R) types.
|
|
403
|
+
# Results come from {Helper#get_types}.
|
|
404
|
+
#
|
|
405
|
+
# @example GlobalFit.types
|
|
406
|
+
# {
|
|
407
|
+
# file:
|
|
408
|
+
# {
|
|
409
|
+
# base_type: :enum,
|
|
410
|
+
# values: [
|
|
411
|
+
# {value_name: "device",
|
|
412
|
+
# value: 1,
|
|
413
|
+
# comment: "Read only, single file. Must be in root directory."},
|
|
414
|
+
# {
|
|
415
|
+
# value_name: "settings",
|
|
416
|
+
# value: 2,
|
|
417
|
+
# comment: "Read/write, single file. Directory=Settings"},
|
|
418
|
+
# ...
|
|
419
|
+
# ]
|
|
420
|
+
# },
|
|
421
|
+
# tissue_model_type:
|
|
422
|
+
# {
|
|
423
|
+
# base_type: :enum,
|
|
424
|
+
# values: [
|
|
425
|
+
# {
|
|
426
|
+
# value_name: "zhl_16c",
|
|
427
|
+
# value: 0,
|
|
428
|
+
# comment: "Buhlmann's decompression algorithm, version C"}]},
|
|
429
|
+
# ...
|
|
430
|
+
# }
|
|
431
|
+
#
|
|
432
|
+
# @return [Hash] Fit Profile types.
|
|
433
|
+
|
|
434
|
+
def self.types
|
|
435
|
+
@@types ||= Helper.new.get_types.freeze
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
##
|
|
439
|
+
# Type definitions provide a FIT to F4R (BinData) type conversion table.
|
|
440
|
+
#
|
|
441
|
+
# @return [Array<Hash>] data types.
|
|
442
|
+
|
|
443
|
+
def self.base_types
|
|
444
|
+
@@base_types ||= Helper.new.get_base_types.freeze
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
##
|
|
448
|
+
# Helper class to get all types and messages in a usable format for F4R.
|
|
449
|
+
|
|
450
|
+
class Helper
|
|
451
|
+
|
|
452
|
+
##
|
|
453
|
+
# Provides messages to {GlobalFit.messages}.
|
|
454
|
+
#
|
|
455
|
+
# @return [Array<Hash>]
|
|
456
|
+
|
|
457
|
+
def get_messages
|
|
458
|
+
messages = {}
|
|
459
|
+
|
|
460
|
+
profile_messages.keys.each do |name|
|
|
461
|
+
messages[name] = []
|
|
462
|
+
if undocumented_messages[name]
|
|
463
|
+
messages[name] = profile_messages[name] | undocumented_messages[name]
|
|
464
|
+
else
|
|
465
|
+
messages[name] = profile_messages[name]
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
(undocumented_messages.keys - messages.keys).each do |name|
|
|
470
|
+
messages[name] = undocumented_messages[name]
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
messages.keys.inject([]) do |r, name|
|
|
474
|
+
type = GlobalFit.types[:mesg_num][:values].find { |v| v[:value_name] == name }
|
|
475
|
+
source = undocumented_types[:mesg_num][:values].
|
|
476
|
+
find { |t| t[:value_name] == name }
|
|
477
|
+
|
|
478
|
+
unless type
|
|
479
|
+
Log.error <<~ERROR
|
|
480
|
+
Message "#{name}" not found in FIT profile or undocumented messages types.
|
|
481
|
+
ERROR
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
r << {
|
|
485
|
+
name: name,
|
|
486
|
+
number: type[:value].to_i,
|
|
487
|
+
source: source ? "F4R #{VERSION}" : "FIT SDK #{FIT_PROFILE_REV}",
|
|
488
|
+
fields: messages[name.to_sym].select { |f| f[:field_def] }
|
|
489
|
+
};r
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
##
|
|
494
|
+
# Provides types to {GlobalFit.types}.
|
|
495
|
+
#
|
|
496
|
+
# @return [Hash]
|
|
497
|
+
|
|
498
|
+
def get_types
|
|
499
|
+
types = {}
|
|
500
|
+
|
|
501
|
+
profile_types.keys.each do |name|
|
|
502
|
+
types[name] = {}
|
|
503
|
+
if undocumented_types[name]
|
|
504
|
+
values = profile_types[name][:values] | undocumented_types[name][:values]
|
|
505
|
+
types[name][:values] = values
|
|
506
|
+
else
|
|
507
|
+
types[name] = profile_types[name]
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
types
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
##
|
|
515
|
+
# Provides base types to {GlobalFit.base_types}.
|
|
516
|
+
#
|
|
517
|
+
# @return [Hash]
|
|
518
|
+
|
|
519
|
+
def get_base_types
|
|
520
|
+
csv = CSV.read(Config.directory + '/base_types.csv', converters: %i[numeric])
|
|
521
|
+
csv[1..-1].inject([]) do |r, row|
|
|
522
|
+
r << {
|
|
523
|
+
number: row[0],
|
|
524
|
+
fit: row[1].to_sym,
|
|
525
|
+
bindata: row[2].to_sym,
|
|
526
|
+
bindata_en: row[3].to_sym,
|
|
527
|
+
endian: row[4],
|
|
528
|
+
bytes: row[5],
|
|
529
|
+
undef: row[6],
|
|
530
|
+
};r
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
private
|
|
535
|
+
|
|
536
|
+
##
|
|
537
|
+
# Provides FIT SDK messages to {GlobalFit.messages}.
|
|
538
|
+
#
|
|
539
|
+
# @return [Hash]
|
|
540
|
+
|
|
541
|
+
def profile_messages
|
|
542
|
+
@profile_messages ||= messages_csv_to_hash(
|
|
543
|
+
CSV.read(
|
|
544
|
+
Config.directory + '/profile_messages.csv',
|
|
545
|
+
converters: %i[numeric]),
|
|
546
|
+
"FIT SDK #{FIT_PROFILE_REV}")
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
##
|
|
550
|
+
# Provides undocumented messages to {GlobalFit.messages}.
|
|
551
|
+
#
|
|
552
|
+
# @return [Hash]
|
|
553
|
+
|
|
554
|
+
def undocumented_messages
|
|
555
|
+
@undocumented_messages ||= messages_csv_to_hash(
|
|
556
|
+
CSV.read(
|
|
557
|
+
Config.directory + '/undocumented_messages.csv',
|
|
558
|
+
converters: %i[numeric]),
|
|
559
|
+
"F4R #{VERSION}")
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
##
|
|
563
|
+
# Provides FIT SDK types to {GlobalFit.types}.
|
|
564
|
+
#
|
|
565
|
+
# @return [Hash]
|
|
566
|
+
|
|
567
|
+
def profile_types
|
|
568
|
+
@profile_types ||= types_csv_to_hash(
|
|
569
|
+
CSV.read(
|
|
570
|
+
Config.directory + '/profile_types.csv',
|
|
571
|
+
converters: %i[numeric]),
|
|
572
|
+
"FIT SDK #{FIT_PROFILE_REV}")
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
##
|
|
576
|
+
# Provides undocumented types to {GlobalFit.types}.
|
|
577
|
+
#
|
|
578
|
+
# @return [Hash]
|
|
579
|
+
|
|
580
|
+
def undocumented_types
|
|
581
|
+
@undocumented_types ||= types_csv_to_hash(
|
|
582
|
+
CSV.read(
|
|
583
|
+
Config.directory + '/undocumented_types.csv',
|
|
584
|
+
converters: %i[numeric]),
|
|
585
|
+
"F4R #{VERSION}")
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
##
|
|
589
|
+
# Converts CSV messages into a Hash.
|
|
590
|
+
#
|
|
591
|
+
# @return [Hash]
|
|
592
|
+
|
|
593
|
+
def messages_csv_to_hash(csv, source)
|
|
594
|
+
current_message = ''
|
|
595
|
+
csv[2..-1].inject({}) do |r, row|
|
|
596
|
+
if row[0].is_a? String
|
|
597
|
+
current_message = row[0].to_sym
|
|
598
|
+
r[current_message] = []
|
|
599
|
+
else
|
|
600
|
+
if row[1] && row[2]
|
|
601
|
+
r[current_message] << {
|
|
602
|
+
source: source,
|
|
603
|
+
field_def: row[1],
|
|
604
|
+
field_name: row[2].to_sym,
|
|
605
|
+
field_type: row[3].to_sym,
|
|
606
|
+
array: row[4],
|
|
607
|
+
components: row[5],
|
|
608
|
+
scale: row[6],
|
|
609
|
+
offset: row[7],
|
|
610
|
+
units: row[8],
|
|
611
|
+
bits: row[9],
|
|
612
|
+
accumulate: row[10],
|
|
613
|
+
ref_field_name: row[11],
|
|
614
|
+
ref_field_value: row[12],
|
|
615
|
+
comment: row[13],
|
|
616
|
+
products: row[14],
|
|
617
|
+
example: row[15]
|
|
618
|
+
}
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
r
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
##
|
|
626
|
+
# Converts CSV types into a Hash.
|
|
627
|
+
#
|
|
628
|
+
# @return [Hash]
|
|
629
|
+
|
|
630
|
+
def types_csv_to_hash(csv, source)
|
|
631
|
+
current_type = ''
|
|
632
|
+
csv[1..-1].inject({}) do |r, row|
|
|
633
|
+
if row[0].is_a? String
|
|
634
|
+
current_type = row[0].to_sym
|
|
635
|
+
r[current_type] = {
|
|
636
|
+
base_type: row[1].to_sym,
|
|
637
|
+
values: []
|
|
638
|
+
}
|
|
639
|
+
else
|
|
640
|
+
unless row.compact.size.zero?
|
|
641
|
+
r[current_type][:values] << {
|
|
642
|
+
source: source,
|
|
643
|
+
value_name: row[2].to_sym,
|
|
644
|
+
value: row[3],
|
|
645
|
+
comment: row[4]
|
|
646
|
+
}
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
r
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
##
|
|
658
|
+
# See CRC section in the FIT SDK for more info and CRC16 examples.
|
|
659
|
+
|
|
660
|
+
class CRC16
|
|
661
|
+
|
|
662
|
+
##
|
|
663
|
+
# CRC16 table
|
|
664
|
+
|
|
665
|
+
@@table = [
|
|
666
|
+
0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401,
|
|
667
|
+
0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400
|
|
668
|
+
].freeze
|
|
669
|
+
|
|
670
|
+
##
|
|
671
|
+
# Compute checksum over given IO.
|
|
672
|
+
#
|
|
673
|
+
# @param [IO] io
|
|
674
|
+
#
|
|
675
|
+
# @return [crc] crc
|
|
676
|
+
# Checksum of lower and upper four bits for all bytes in IO
|
|
677
|
+
|
|
678
|
+
def self.crc(io)
|
|
679
|
+
crc = 0
|
|
680
|
+
io.each_byte do |byte|
|
|
681
|
+
[byte, (byte >> 4)].each do |sb|
|
|
682
|
+
crc = ((crc >> 4) & 0x0FFF) ^ @@table[(crc ^ sb) & 0xF]
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
crc
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
##
|
|
690
|
+
# BinData definitions for the supported FIT data structures.
|
|
691
|
+
#
|
|
692
|
+
# module Definition
|
|
693
|
+
# class Header
|
|
694
|
+
# class RecordHeader
|
|
695
|
+
# class RecordField
|
|
696
|
+
# class Record
|
|
697
|
+
|
|
698
|
+
module Definition
|
|
699
|
+
|
|
700
|
+
##
|
|
701
|
+
# Main header for FIT files.
|
|
702
|
+
#
|
|
703
|
+
# | Byte | Parameter | Description | Size (Bytes) |
|
|
704
|
+
# |------+---------------------+-------------------------+--------------|
|
|
705
|
+
# | 0 | Header Size | Length of file header | 1 |
|
|
706
|
+
# | 1 | Protocol Version | Provided by SDK | 1 |
|
|
707
|
+
# | 2 | Profile Version LSB | Provided by SDK | 2 |
|
|
708
|
+
# | 3 | Profile Version MSB | Provided by SDK | |
|
|
709
|
+
# | 4 | Data Size LSB | Length of data records | 4 |
|
|
710
|
+
# | 5 | Data Size | Minus header or CRC | |
|
|
711
|
+
# | 6 | Data Size | | |
|
|
712
|
+
# | 7 | Data Size MSB | | |
|
|
713
|
+
# | 8 | Data Type Byte [0] | ASCII values for ".FIT" | 4 |
|
|
714
|
+
# | 9 | Data Type Byte [1] | | |
|
|
715
|
+
# | 10 | Data Type Byte [2] | | |
|
|
716
|
+
# | 11 | Data Type Byte [3] | | |
|
|
717
|
+
# | 12 | CRC LSB | CRC | 2 |
|
|
718
|
+
# | 13 | CRC MSB | | |
|
|
719
|
+
|
|
720
|
+
class Header < BinData::Record
|
|
721
|
+
|
|
722
|
+
endian :little
|
|
723
|
+
uint8 :header_size, initial_value: 14
|
|
724
|
+
uint8 :protocol_version, initial_value: 16
|
|
725
|
+
uint16 :profile_version, initial_value: 2093
|
|
726
|
+
uint32 :data_size, initial_value: 0
|
|
727
|
+
string :data_type, read_length: 4, initial_value: '.FIT'
|
|
728
|
+
uint16 :crc, initial_value: 0
|
|
729
|
+
|
|
730
|
+
##
|
|
731
|
+
# Data validation should happen as soon as possible.
|
|
732
|
+
#
|
|
733
|
+
# @param [IO] io
|
|
734
|
+
|
|
735
|
+
def read(io)
|
|
736
|
+
super
|
|
737
|
+
|
|
738
|
+
case
|
|
739
|
+
when !supported_header?
|
|
740
|
+
Log.error "Unsupported header size: #{header_size.snapshot}."
|
|
741
|
+
when data_type.snapshot != '.FIT'
|
|
742
|
+
Log.error "Unknown file type: #{data_type.snapshot}."
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
crc_mismatch?(io)
|
|
746
|
+
|
|
747
|
+
Log.decode [self.class, __method__], to_log_s
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
##
|
|
751
|
+
# Write header and its CRC to IO
|
|
752
|
+
#
|
|
753
|
+
# @param [IO] io
|
|
754
|
+
|
|
755
|
+
def write(io)
|
|
756
|
+
super
|
|
757
|
+
io.rewind
|
|
758
|
+
crc_16 = CRC16.crc(io.read(header_size.snapshot - 2))
|
|
759
|
+
BinData::Uint16le.new(crc_16).write(io)
|
|
760
|
+
|
|
761
|
+
Log.encode [self.class, __method__], to_log_s
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
##
|
|
765
|
+
# @return [Boolean]
|
|
766
|
+
|
|
767
|
+
def supported_header?
|
|
768
|
+
[12, 14].include? header_size.snapshot
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
##
|
|
772
|
+
# CRC validations
|
|
773
|
+
#
|
|
774
|
+
# @param [IO] io
|
|
775
|
+
# @return [Boolean]
|
|
776
|
+
|
|
777
|
+
def crc_mismatch?(io)
|
|
778
|
+
unless crc.snapshot.zero?
|
|
779
|
+
io.rewind
|
|
780
|
+
crc_16 = CRC16.crc(io.read(header_size.snapshot - 2))
|
|
781
|
+
unless crc_16 == crc.snapshot
|
|
782
|
+
Log.error "CRC mismatch: Computed #{crc_16} instead of #{crc.snapshot}."
|
|
783
|
+
end
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
start_pos = header_size.snapshot == 14 ? header_size : 0
|
|
787
|
+
|
|
788
|
+
crc_16 = CRC16.crc(IO.binread(io, file_size, start_pos))
|
|
789
|
+
crc_ref = io.readbyte.to_i | (io.readbyte.to_i << 8)
|
|
790
|
+
|
|
791
|
+
unless crc_16 = crc_ref
|
|
792
|
+
Log.error "crc mismatch: computed #{crc_16} instead of #{crc_ref}."
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
io.seek(header_size)
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
##
|
|
799
|
+
# @return [Integer]
|
|
800
|
+
|
|
801
|
+
def file_size
|
|
802
|
+
header_size.snapshot + data_size.snapshot
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
##
|
|
806
|
+
# Header format for log output
|
|
807
|
+
#
|
|
808
|
+
# Example:
|
|
809
|
+
# HS: 14 PlV: 32 PeV: 1012 DS: 1106 DT: .FIT CRC:0
|
|
810
|
+
#
|
|
811
|
+
# @return [String]
|
|
812
|
+
|
|
813
|
+
def to_log_s
|
|
814
|
+
{
|
|
815
|
+
file_header: [
|
|
816
|
+
('%-8s' % "HS: #{header_size.snapshot}"),
|
|
817
|
+
('%-8s' % "PlV:#{protocol_version.snapshot}"),
|
|
818
|
+
('%-8s' % "PeV:#{profile_version.snapshot}"),
|
|
819
|
+
('%-8s' % "DS: #{data_size.snapshot}"),
|
|
820
|
+
('%-8s' % "DT: #{data_type.snapshot}"),
|
|
821
|
+
('%-8s' % "CRC:#{crc.snapshot}"),
|
|
822
|
+
].join(' ')
|
|
823
|
+
}
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
##
|
|
829
|
+
# Record header
|
|
830
|
+
#
|
|
831
|
+
# | Bit | Value | Description |
|
|
832
|
+
# |-----+-------------+-----------------------|
|
|
833
|
+
# | 7 | 0 | Normal Header |
|
|
834
|
+
# | 6 | 0 or 1 | Message Type: |
|
|
835
|
+
# | | | 1: Definition |
|
|
836
|
+
# | | | 2: Data |
|
|
837
|
+
# | 5 | 0 (default) | Message Type Specific |
|
|
838
|
+
# | 4 | 0 | Reserved |
|
|
839
|
+
# | 0-3 | 0-15 | Local Message Type |
|
|
840
|
+
|
|
841
|
+
class RecordHeader < BinData::Record
|
|
842
|
+
bit1 :normal
|
|
843
|
+
bit1 :message_type
|
|
844
|
+
bit1 :developer_data_flag
|
|
845
|
+
bit1 :reserved
|
|
846
|
+
|
|
847
|
+
choice :local_message_type, selection: :normal do
|
|
848
|
+
bit4 0
|
|
849
|
+
bit2 1
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
##
|
|
853
|
+
# Serves as first place for validating data.
|
|
854
|
+
#
|
|
855
|
+
# @param [IO] io
|
|
856
|
+
|
|
857
|
+
def read(io)
|
|
858
|
+
super
|
|
859
|
+
|
|
860
|
+
if compressed?
|
|
861
|
+
Log.error "Compressed Timestamp Headers are not supported. #{inspect}"
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
Log.decode [self.class, __method__], to_log_s
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
##
|
|
868
|
+
# @param [io] io
|
|
869
|
+
|
|
870
|
+
def write(io)
|
|
871
|
+
super
|
|
872
|
+
|
|
873
|
+
Log.encode [self.class, __method__], to_log_s
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
##
|
|
877
|
+
# @return [Boolean]
|
|
878
|
+
|
|
879
|
+
def compressed?
|
|
880
|
+
normal.snapshot == 1
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
##
|
|
884
|
+
# @return [Boolean]
|
|
885
|
+
|
|
886
|
+
def for_new_definition?
|
|
887
|
+
normal.snapshot.zero? && message_type.snapshot == 1
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
##
|
|
891
|
+
# Header format for log output
|
|
892
|
+
#
|
|
893
|
+
# @example:
|
|
894
|
+
# record_{data}_header: N: 0 MT: 1 DDF: 0 R: 0 LMT: 6
|
|
895
|
+
#
|
|
896
|
+
# @return [String]
|
|
897
|
+
|
|
898
|
+
def to_log_s
|
|
899
|
+
{
|
|
900
|
+
"#{message_type.snapshot.zero? ? 'record_data' : 'record'}_header" => [
|
|
901
|
+
('%-8s' % "N: #{normal.snapshot}"),
|
|
902
|
+
('%-8s' % "MT: #{message_type.snapshot}"),
|
|
903
|
+
('%-8s' % "DDF:#{developer_data_flag.snapshot}"),
|
|
904
|
+
('%-8s' % "R: #{reserved.snapshot}"),
|
|
905
|
+
('%-8s' % "LMT:#{local_message_type.snapshot}"),
|
|
906
|
+
].join(' ')
|
|
907
|
+
}
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
##
|
|
911
|
+
# Helper method for writing data headers
|
|
912
|
+
#
|
|
913
|
+
# @param [IO] io
|
|
914
|
+
# @param [Record] record
|
|
915
|
+
|
|
916
|
+
def write_data_header(io, record)
|
|
917
|
+
data_header = self.new
|
|
918
|
+
data_header.normal = 0
|
|
919
|
+
data_header.message_type = 0
|
|
920
|
+
data_header.local_message_type = record[:local_message_number]
|
|
921
|
+
data_header.write(io)
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
##
|
|
927
|
+
# Record Field
|
|
928
|
+
#
|
|
929
|
+
# | Bit | Name | Description |
|
|
930
|
+
# |-----+------------------+-------------------------------------|
|
|
931
|
+
# | 7 | Endian Ability | 0 - for single byte data |
|
|
932
|
+
# | | | 1 - if base type has endianness |
|
|
933
|
+
# | | | (i.e. base type is 2 or more bytes) |
|
|
934
|
+
# | 5-6 | Reserved | Reserved |
|
|
935
|
+
# | 0-4 | Base Type Number | Number assigned to Base Type |
|
|
936
|
+
|
|
937
|
+
class RecordField < BinData::Record
|
|
938
|
+
|
|
939
|
+
hide :reserved
|
|
940
|
+
|
|
941
|
+
uint8 :field_definition_number
|
|
942
|
+
uint8 :byte_count
|
|
943
|
+
bit1 :endian_ability
|
|
944
|
+
bit2 :reserved
|
|
945
|
+
bit5 :base_type_number
|
|
946
|
+
|
|
947
|
+
##
|
|
948
|
+
# @return [String]
|
|
949
|
+
|
|
950
|
+
def name
|
|
951
|
+
global_message_field[:field_name]
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
##
|
|
955
|
+
# @return [Integer]
|
|
956
|
+
|
|
957
|
+
def number
|
|
958
|
+
global_message_field[:field_def]
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
##
|
|
962
|
+
# Returns field in [BinData::Struct] format.
|
|
963
|
+
# Field identifier is its number[String] since some field names
|
|
964
|
+
# (e.g., 'type') are reserved [BinData::Struct] keywords.
|
|
965
|
+
#
|
|
966
|
+
# @example
|
|
967
|
+
# [:uint8, '1']
|
|
968
|
+
# [:string, '2', {length: 8}]
|
|
969
|
+
# [:array, '3', {type: uint8, initial_length: 4}]
|
|
970
|
+
#
|
|
971
|
+
# @return [Array]
|
|
972
|
+
|
|
973
|
+
def to_bindata_struct
|
|
974
|
+
type = base_type_definition[:bindata]
|
|
975
|
+
bytes = base_type_definition[:bytes]
|
|
976
|
+
|
|
977
|
+
case
|
|
978
|
+
when type == :string
|
|
979
|
+
[type, number.to_s, {length: byte_count.snapshot}]
|
|
980
|
+
when byte_count.snapshot > bytes # array
|
|
981
|
+
if byte_count.snapshot % bytes != 0
|
|
982
|
+
Log.error <<~ERROR
|
|
983
|
+
Total bytes ("#{total_bytes}") must be multiple of base type
|
|
984
|
+
bytes ("#{bytes}") of type "#{type}" in global FIT message "#{name}".
|
|
985
|
+
ERROR
|
|
986
|
+
end
|
|
987
|
+
length = byte_count.snapshot / bytes
|
|
988
|
+
[:array, number.to_s, {type: type, initial_length: length}]
|
|
989
|
+
else
|
|
990
|
+
[type, number.to_s]
|
|
991
|
+
end
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
##
|
|
995
|
+
# Global message field with all its properties
|
|
996
|
+
#
|
|
997
|
+
# @return [Hash]
|
|
998
|
+
|
|
999
|
+
def global_message_field
|
|
1000
|
+
@global_message_field ||= global_message[:fields].
|
|
1001
|
+
find { |f| f[:field_def] == field_definition_number.snapshot }
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
##
|
|
1005
|
+
# Global message for field.
|
|
1006
|
+
#
|
|
1007
|
+
# @return [Hash]
|
|
1008
|
+
|
|
1009
|
+
def global_message
|
|
1010
|
+
@global_message ||= parent.parent.global_message
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
##
|
|
1014
|
+
# Base type definitions for field
|
|
1015
|
+
#
|
|
1016
|
+
# @return [Hash]
|
|
1017
|
+
|
|
1018
|
+
def base_type_definition
|
|
1019
|
+
@base_type_definition ||= get_base_type_definition
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
##
|
|
1023
|
+
# Field log output
|
|
1024
|
+
#
|
|
1025
|
+
# @example:
|
|
1026
|
+
# FDN:2 BC: 4 EA: 1 R: 0 BTN:4 uint16 message_# field_#: 0 65535
|
|
1027
|
+
#
|
|
1028
|
+
# @param [String,Integer] value
|
|
1029
|
+
# @return [String]
|
|
1030
|
+
|
|
1031
|
+
def to_log_s(value)
|
|
1032
|
+
[
|
|
1033
|
+
('%-8s' % "FDN:#{field_definition_number.snapshot}"),
|
|
1034
|
+
('%-8s' % "BC: #{byte_count.snapshot}"),
|
|
1035
|
+
('%-8s' % "EA: #{endian_ability.snapshot}"),
|
|
1036
|
+
('%-8s' % "R: #{reserved.snapshot}"),
|
|
1037
|
+
('%-8s' % "BTN:#{base_type_number.snapshot}"),
|
|
1038
|
+
('%-8s' % (base_type_definition[:fit])),
|
|
1039
|
+
global_message[:name],
|
|
1040
|
+
" #{name}: ",
|
|
1041
|
+
value,
|
|
1042
|
+
].join(' ')
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
private
|
|
1046
|
+
|
|
1047
|
+
##
|
|
1048
|
+
# Find base type definition for field
|
|
1049
|
+
#
|
|
1050
|
+
# @return [Hash]
|
|
1051
|
+
|
|
1052
|
+
def get_base_type_definition
|
|
1053
|
+
field_type = global_message_field[:field_type].to_sym
|
|
1054
|
+
global_type = GlobalFit.types[field_type]
|
|
1055
|
+
|
|
1056
|
+
type_definition = GlobalFit.base_types.find do |dt|
|
|
1057
|
+
dt[:fit] == (global_type ? global_type[:base_type].to_sym : field_type)
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
unless type_definition
|
|
1061
|
+
Log.warn <<~WARN
|
|
1062
|
+
Data type "#{global_message_field[:field_type]}" is not a valid
|
|
1063
|
+
type for field field "#{global_message_field[:field_name]}
|
|
1064
|
+
(#{global_message_field[:filed_number]})" in message
|
|
1065
|
+
number "#{field_definition_number.snapshot}".
|
|
1066
|
+
WARN
|
|
1067
|
+
end
|
|
1068
|
+
|
|
1069
|
+
type_definition
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
##
|
|
1075
|
+
# Record
|
|
1076
|
+
#
|
|
1077
|
+
# | Byte | Description | Length | Value |
|
|
1078
|
+
# |-----------------+-----------------------+------------+---------------|
|
|
1079
|
+
# | 0 | Reserved | 1 Byte | 0 |
|
|
1080
|
+
# | 1 | Architecture | 1 Byte | Arch Type: |
|
|
1081
|
+
# | | | | 0: Little |
|
|
1082
|
+
# | | | | 1: Big |
|
|
1083
|
+
# | 2-3 | Global Message # | 2 Bytes | 0: 65535 |
|
|
1084
|
+
# | 4 | Fields | 1 Byte | # of fields |
|
|
1085
|
+
# | 5- | Field Definition | 3 Bytes | Field content |
|
|
1086
|
+
# | 4 + Fields * 3 | | per field | |
|
|
1087
|
+
# | 5 + Fields * 3 | # of Developer Fields | 1 Byte | # of Fields |
|
|
1088
|
+
# | 6 + Fields * 3- | Developer Field Def. | 3 Bytes | |
|
|
1089
|
+
# | END | | per feld | Field content |
|
|
1090
|
+
|
|
1091
|
+
class Record < BinData::Record
|
|
1092
|
+
hide :reserved
|
|
1093
|
+
|
|
1094
|
+
uint8 :reserved, initial_value: 0
|
|
1095
|
+
uint8 :architecture, initial_value: 0, assert: lambda { value <= 1 }
|
|
1096
|
+
|
|
1097
|
+
choice :global_message_number, selection: :architecture do
|
|
1098
|
+
uint16le 0
|
|
1099
|
+
uint16be :default
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
uint8 :field_count
|
|
1103
|
+
array :data_fields, type: RecordField, initial_length: :field_count
|
|
1104
|
+
|
|
1105
|
+
##
|
|
1106
|
+
# Serves as first place for validating data.
|
|
1107
|
+
#
|
|
1108
|
+
# @param [IO] io
|
|
1109
|
+
|
|
1110
|
+
def read(io)
|
|
1111
|
+
super
|
|
1112
|
+
|
|
1113
|
+
unless global_message
|
|
1114
|
+
Log.error <<~ERROR
|
|
1115
|
+
Undefined global message: "#{global_message_number.snapshot}".
|
|
1116
|
+
ERROR
|
|
1117
|
+
end
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
##
|
|
1121
|
+
# Helper for getting the architecture
|
|
1122
|
+
#
|
|
1123
|
+
# @return [Symbol]
|
|
1124
|
+
|
|
1125
|
+
def endian
|
|
1126
|
+
@endion ||= architecture.zero? ? :little : :big
|
|
1127
|
+
end
|
|
1128
|
+
|
|
1129
|
+
##
|
|
1130
|
+
# Helper for getting the message global message
|
|
1131
|
+
#
|
|
1132
|
+
# @return [Hash] @global_message
|
|
1133
|
+
|
|
1134
|
+
def global_message
|
|
1135
|
+
@global_message ||= GlobalFit.messages.find do |m|
|
|
1136
|
+
m[:number] == global_message_number.snapshot
|
|
1137
|
+
end
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1140
|
+
##
|
|
1141
|
+
# Read data belonging to the same definition.
|
|
1142
|
+
#
|
|
1143
|
+
# @param [IO] io
|
|
1144
|
+
# @return [BinData::Struct] data
|
|
1145
|
+
|
|
1146
|
+
def read_data(io)
|
|
1147
|
+
data = to_bindata_struct.read(io)
|
|
1148
|
+
|
|
1149
|
+
Log.decode [self.class, __method__],
|
|
1150
|
+
pos: io.pos, record: to_log_s
|
|
1151
|
+
|
|
1152
|
+
data_fields.each do |df|
|
|
1153
|
+
Log.decode [self.class, __method__],
|
|
1154
|
+
field: df.to_log_s(data[df.number].snapshot)
|
|
1155
|
+
end
|
|
1156
|
+
|
|
1157
|
+
data
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
##
|
|
1161
|
+
# Write data belonging to the same definition.
|
|
1162
|
+
#
|
|
1163
|
+
# @param [IO] io
|
|
1164
|
+
# @param [Record] record
|
|
1165
|
+
|
|
1166
|
+
def write_data(io, record)
|
|
1167
|
+
struct = to_bindata_struct
|
|
1168
|
+
|
|
1169
|
+
record[:fields].each do |name, field|
|
|
1170
|
+
struct[field[:definition].number] = field[:value]
|
|
1171
|
+
|
|
1172
|
+
Log.encode [self.class, __method__],
|
|
1173
|
+
pos: io.pos,
|
|
1174
|
+
field: field[:definition].to_log_s(field[:value])
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
struct.write(io)
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
##
|
|
1181
|
+
# Create [BinData::Struct] to contain and read and write
|
|
1182
|
+
# the data belonging to the same definition.
|
|
1183
|
+
#
|
|
1184
|
+
# @return [BinData::Struct]
|
|
1185
|
+
|
|
1186
|
+
def to_bindata_struct
|
|
1187
|
+
opts = {
|
|
1188
|
+
endian: endian,
|
|
1189
|
+
fields: data_fields.map(&:to_bindata_struct)
|
|
1190
|
+
}
|
|
1191
|
+
BinData::Struct.new(opts)
|
|
1192
|
+
end
|
|
1193
|
+
|
|
1194
|
+
private
|
|
1195
|
+
|
|
1196
|
+
##
|
|
1197
|
+
# Definition log output
|
|
1198
|
+
#
|
|
1199
|
+
# @example:
|
|
1200
|
+
# R: 0 A: 0 GM: 18 FC: 95
|
|
1201
|
+
#
|
|
1202
|
+
# @return [String]
|
|
1203
|
+
|
|
1204
|
+
def to_log_s
|
|
1205
|
+
[
|
|
1206
|
+
('%-8s' % "R: #{reserved.snapshot}"),
|
|
1207
|
+
('%-8s' % "A: #{architecture.snapshot}"),
|
|
1208
|
+
('%-8s' % "GM: #{global_message_number.snapshot}"),
|
|
1209
|
+
('%-8s' % "FC: #{field_count.snapshot}"),
|
|
1210
|
+
('%-8s' % global_message[:value_name]),
|
|
1211
|
+
].join(' ')
|
|
1212
|
+
end
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1215
|
+
end
|
|
1216
|
+
|
|
1217
|
+
##
|
|
1218
|
+
# Stores records and meta data for encoding and decoding
|
|
1219
|
+
|
|
1220
|
+
class Registry
|
|
1221
|
+
|
|
1222
|
+
##
|
|
1223
|
+
# Main file header
|
|
1224
|
+
#
|
|
1225
|
+
# @return [BinData::RecordHeader] header
|
|
1226
|
+
attr_reader :header
|
|
1227
|
+
|
|
1228
|
+
##
|
|
1229
|
+
# Storage for all records including their meta data
|
|
1230
|
+
#
|
|
1231
|
+
# @return [Hash]
|
|
1232
|
+
|
|
1233
|
+
attr_accessor :records
|
|
1234
|
+
|
|
1235
|
+
##
|
|
1236
|
+
# Definitions for all records
|
|
1237
|
+
#
|
|
1238
|
+
# @return [Array<Hash>]
|
|
1239
|
+
|
|
1240
|
+
attr_accessor :definitions
|
|
1241
|
+
|
|
1242
|
+
def initialize(header)
|
|
1243
|
+
@header = header
|
|
1244
|
+
@records = []
|
|
1245
|
+
@definitions = []
|
|
1246
|
+
end
|
|
1247
|
+
|
|
1248
|
+
##
|
|
1249
|
+
# Add record to +@records+ [Array<Hash>]
|
|
1250
|
+
#
|
|
1251
|
+
# @param [Hash] record
|
|
1252
|
+
# @param [Integer] local_message_number
|
|
1253
|
+
|
|
1254
|
+
def add(record, local_message_number)
|
|
1255
|
+
@records << {
|
|
1256
|
+
index: @records.size,
|
|
1257
|
+
message_name: record.message[:name],
|
|
1258
|
+
message_number: record.message[:number],
|
|
1259
|
+
message_source: record.message[:source],
|
|
1260
|
+
local_message_number: local_message_number,
|
|
1261
|
+
fields: record.fields
|
|
1262
|
+
}
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1265
|
+
##
|
|
1266
|
+
# Helper method to find the associated definitions with an specific record
|
|
1267
|
+
#
|
|
1268
|
+
# @param [Hash] record
|
|
1269
|
+
# @return [Hash]
|
|
1270
|
+
|
|
1271
|
+
def definition(record)
|
|
1272
|
+
definitions.find do |d|
|
|
1273
|
+
d[:local_message_number] == record[:local_message_number] &&
|
|
1274
|
+
d[:message_name] == record[:message_name]
|
|
1275
|
+
end
|
|
1276
|
+
end
|
|
1277
|
+
|
|
1278
|
+
end
|
|
1279
|
+
|
|
1280
|
+
##
|
|
1281
|
+
# +Record+ is where each data message gets stored including meta data.
|
|
1282
|
+
|
|
1283
|
+
class Record
|
|
1284
|
+
|
|
1285
|
+
##
|
|
1286
|
+
# Where all fields for the specific record get stored
|
|
1287
|
+
#
|
|
1288
|
+
# @example
|
|
1289
|
+
# {
|
|
1290
|
+
# field_1: {
|
|
1291
|
+
# value: value,
|
|
1292
|
+
# base_type: base_type,
|
|
1293
|
+
# message_name: 'file_id',
|
|
1294
|
+
# message_number: 0,
|
|
1295
|
+
# properties: {...}, # copy of global message's field
|
|
1296
|
+
# },
|
|
1297
|
+
# field_2: {
|
|
1298
|
+
# value: value,
|
|
1299
|
+
# base_type: base_type,
|
|
1300
|
+
# message_name: 'file_id',
|
|
1301
|
+
# message_number: 0,
|
|
1302
|
+
# properties: {...}, # copy of global message's field
|
|
1303
|
+
# },
|
|
1304
|
+
# ...
|
|
1305
|
+
# }
|
|
1306
|
+
#
|
|
1307
|
+
# @return [Hash] current message fields.
|
|
1308
|
+
|
|
1309
|
+
attr_reader :fields
|
|
1310
|
+
|
|
1311
|
+
def initialize(message_name)
|
|
1312
|
+
@message_name = message_name
|
|
1313
|
+
@fields = {}
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1316
|
+
##
|
|
1317
|
+
# Global message
|
|
1318
|
+
#
|
|
1319
|
+
# @return [Hash] copy of associated global message.
|
|
1320
|
+
|
|
1321
|
+
def message
|
|
1322
|
+
@message ||= GlobalFit.messages.find { |m| m[:name] == @message_name }
|
|
1323
|
+
end
|
|
1324
|
+
|
|
1325
|
+
##
|
|
1326
|
+
# Sets the +value+ attribute for the passed field.
|
|
1327
|
+
#
|
|
1328
|
+
# @param [RecordField] definition
|
|
1329
|
+
# @param [String, Integer] value
|
|
1330
|
+
|
|
1331
|
+
def set_field_value(definition, value)
|
|
1332
|
+
if fields[definition.name]
|
|
1333
|
+
@fields[definition.name][:value] = value
|
|
1334
|
+
@fields[definition.name][:definition] = definition
|
|
1335
|
+
else
|
|
1336
|
+
@fields[definition.name] = {
|
|
1337
|
+
value: value,
|
|
1338
|
+
base_type: definition.base_type_definition,
|
|
1339
|
+
message_name: definition.global_message[:name],
|
|
1340
|
+
message_number: definition.global_message[:number],
|
|
1341
|
+
definition: definition,
|
|
1342
|
+
properties: definition.global_message_field,
|
|
1343
|
+
}
|
|
1344
|
+
end
|
|
1345
|
+
end
|
|
1346
|
+
|
|
1347
|
+
end
|
|
1348
|
+
|
|
1349
|
+
##
|
|
1350
|
+
# FIT binary file Encoder/Writer
|
|
1351
|
+
|
|
1352
|
+
module Encoder
|
|
1353
|
+
|
|
1354
|
+
##
|
|
1355
|
+
# Encode/Write binary FIT file
|
|
1356
|
+
#
|
|
1357
|
+
# @param [String] file_name path for new FIT file
|
|
1358
|
+
# @param [Hash,Registry] records
|
|
1359
|
+
#
|
|
1360
|
+
# @param [String] source
|
|
1361
|
+
# Optional source FIT file to be used as a reference for
|
|
1362
|
+
# structuring the binary data.
|
|
1363
|
+
#
|
|
1364
|
+
# @return [File] binary FIT file
|
|
1365
|
+
|
|
1366
|
+
def self.encode(file_name, records, source)
|
|
1367
|
+
io = ::File.open(file_name, 'wb+')
|
|
1368
|
+
|
|
1369
|
+
if records.is_a? Registry
|
|
1370
|
+
registry = records
|
|
1371
|
+
else
|
|
1372
|
+
registry = RegistryBuilder.new(records, source).registry
|
|
1373
|
+
end
|
|
1374
|
+
|
|
1375
|
+
begin
|
|
1376
|
+
start_pos = registry.header.header_size
|
|
1377
|
+
|
|
1378
|
+
io.seek(start_pos)
|
|
1379
|
+
|
|
1380
|
+
local_messages = []
|
|
1381
|
+
last_local_message_number = nil
|
|
1382
|
+
registry.records.each do |record|
|
|
1383
|
+
|
|
1384
|
+
local_message = local_messages.find do |lm|
|
|
1385
|
+
lm[:local_message_number] == record[:local_message_number] &&
|
|
1386
|
+
lm[:message_name] == record[:message_name]
|
|
1387
|
+
end
|
|
1388
|
+
|
|
1389
|
+
unless local_message ||
|
|
1390
|
+
record[:local_message_number] == last_local_message_number
|
|
1391
|
+
|
|
1392
|
+
local_messages << {
|
|
1393
|
+
local_message_number: record[:local_message_number],
|
|
1394
|
+
message_name: record[:message_name]
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
definition = registry.definition(record)
|
|
1398
|
+
definition[:header].write(io)
|
|
1399
|
+
definition[:record].write(io)
|
|
1400
|
+
end
|
|
1401
|
+
|
|
1402
|
+
definition = registry.definition(record)
|
|
1403
|
+
definition[:header].write_data_header(io, record)
|
|
1404
|
+
definition[:record].write_data(io, record)
|
|
1405
|
+
|
|
1406
|
+
last_local_message_number = record[:local_message_number]
|
|
1407
|
+
end
|
|
1408
|
+
|
|
1409
|
+
end_pos = io.pos
|
|
1410
|
+
BinData::Uint16le.new(CRC16.crc(IO.binread(io, end_pos, start_pos))).write(io)
|
|
1411
|
+
registry.header.data_size = end_pos - start_pos
|
|
1412
|
+
io.rewind
|
|
1413
|
+
registry.header.write(io)
|
|
1414
|
+
ensure
|
|
1415
|
+
io.close
|
|
1416
|
+
end
|
|
1417
|
+
|
|
1418
|
+
file_name
|
|
1419
|
+
end
|
|
1420
|
+
|
|
1421
|
+
##
|
|
1422
|
+
# {Encoder} requires a properly built {Registry} to be able to encode.
|
|
1423
|
+
|
|
1424
|
+
class RegistryBuilder
|
|
1425
|
+
|
|
1426
|
+
##
|
|
1427
|
+
# @return [Registry]
|
|
1428
|
+
|
|
1429
|
+
attr_reader :registry
|
|
1430
|
+
|
|
1431
|
+
def initialize(records, source)
|
|
1432
|
+
@records = records
|
|
1433
|
+
@source = source
|
|
1434
|
+
source ? clone_definitions : build_definitions
|
|
1435
|
+
end
|
|
1436
|
+
|
|
1437
|
+
private
|
|
1438
|
+
|
|
1439
|
+
##
|
|
1440
|
+
# Decode source FIT file that will be used to provide binary
|
|
1441
|
+
# structure for the FIT file to be created.
|
|
1442
|
+
#
|
|
1443
|
+
# @param [String] source path to FIT file
|
|
1444
|
+
|
|
1445
|
+
def clone_definitions
|
|
1446
|
+
io = ::File.open(@source, 'rb')
|
|
1447
|
+
|
|
1448
|
+
begin
|
|
1449
|
+
until io.eof?
|
|
1450
|
+
offset = io.pos
|
|
1451
|
+
|
|
1452
|
+
header = Definition::Header.read(io)
|
|
1453
|
+
@registry = Registry.new(header)
|
|
1454
|
+
|
|
1455
|
+
while io.pos < offset + header.file_size
|
|
1456
|
+
record_header = Definition::RecordHeader.read(io)
|
|
1457
|
+
|
|
1458
|
+
local_message_number = record_header.local_message_type.snapshot
|
|
1459
|
+
|
|
1460
|
+
if record_header.for_new_definition?
|
|
1461
|
+
definition = Definition::Record.read(io)
|
|
1462
|
+
|
|
1463
|
+
@registry.definitions << {
|
|
1464
|
+
local_message_number: local_message_number,
|
|
1465
|
+
message_name: definition.global_message[:name],
|
|
1466
|
+
header: record_header,
|
|
1467
|
+
record: definition,
|
|
1468
|
+
}
|
|
1469
|
+
else
|
|
1470
|
+
@registry.definitions.reverse.find do |d|
|
|
1471
|
+
d[:local_message_number] == local_message_number
|
|
1472
|
+
end[:record].read_data(io)
|
|
1473
|
+
end
|
|
1474
|
+
end
|
|
1475
|
+
|
|
1476
|
+
io.seek(2, :CUR)
|
|
1477
|
+
end
|
|
1478
|
+
ensure
|
|
1479
|
+
io.close
|
|
1480
|
+
end
|
|
1481
|
+
|
|
1482
|
+
build_records
|
|
1483
|
+
end
|
|
1484
|
+
|
|
1485
|
+
##
|
|
1486
|
+
# Try to build definitions with the most accurate binary structure.
|
|
1487
|
+
|
|
1488
|
+
def build_definitions
|
|
1489
|
+
@registry = Registry.new(Definition::Header.new)
|
|
1490
|
+
|
|
1491
|
+
largest_records = @records.
|
|
1492
|
+
group_by { |record| record[:message_name] }.
|
|
1493
|
+
inject({}) do |r, rcrds|
|
|
1494
|
+
r[rcrds[0]] = rcrds[1].sort_by { |rf| rf[:fields].count }.last
|
|
1495
|
+
r
|
|
1496
|
+
end
|
|
1497
|
+
|
|
1498
|
+
largest_records.each do |name, record|
|
|
1499
|
+
global_message = GlobalFit.messages.find do |m|
|
|
1500
|
+
m[:name] == name
|
|
1501
|
+
end
|
|
1502
|
+
|
|
1503
|
+
definition = @registry.definition(record)
|
|
1504
|
+
|
|
1505
|
+
unless definition
|
|
1506
|
+
|
|
1507
|
+
record_header = Definition::RecordHeader.new
|
|
1508
|
+
record_header.normal = 0
|
|
1509
|
+
record_header.message_type = 1
|
|
1510
|
+
record_header.local_message_type = record[:local_message_number]
|
|
1511
|
+
|
|
1512
|
+
definition = Definition::Record.new
|
|
1513
|
+
definition.field_count = record[:fields].count
|
|
1514
|
+
definition.global_message_number = global_message[:number]
|
|
1515
|
+
|
|
1516
|
+
record[:fields].each_with_index do |(field_name, _), index|
|
|
1517
|
+
global_field = global_message[:fields].
|
|
1518
|
+
find { |f| f[:field_name] == field_name }
|
|
1519
|
+
|
|
1520
|
+
field_type = global_field[:field_type].to_sym
|
|
1521
|
+
global_type = GlobalFit.types[field_type]
|
|
1522
|
+
|
|
1523
|
+
# Check in GlobalFit first as types can be anything form
|
|
1524
|
+
# strings to files, exercise, water, etc...
|
|
1525
|
+
base_type = GlobalFit.base_types.find do |dt|
|
|
1526
|
+
dt[:fit] == (global_type ? global_type[:base_type].to_sym : field_type)
|
|
1527
|
+
end
|
|
1528
|
+
|
|
1529
|
+
unless base_type
|
|
1530
|
+
Log.warn <<~WARN
|
|
1531
|
+
Data type "#{field[:field_type]}" is not a valid type for field
|
|
1532
|
+
"#{field[:field_name]} (#{field[:filed_number]})".
|
|
1533
|
+
WARN
|
|
1534
|
+
end
|
|
1535
|
+
|
|
1536
|
+
field = definition.data_fields[index]
|
|
1537
|
+
|
|
1538
|
+
field.field_definition_number = global_field[:field_def]
|
|
1539
|
+
field.byte_count = 0 # set on build_records
|
|
1540
|
+
field.endian_ability = base_type[:endian]
|
|
1541
|
+
field.base_type_number = base_type[:number]
|
|
1542
|
+
end
|
|
1543
|
+
|
|
1544
|
+
@registry.definitions << {
|
|
1545
|
+
local_message_number: record[:local_message_number],
|
|
1546
|
+
message_name: definition.global_message[:name],
|
|
1547
|
+
header: record_header,
|
|
1548
|
+
record: definition
|
|
1549
|
+
}
|
|
1550
|
+
end
|
|
1551
|
+
end
|
|
1552
|
+
|
|
1553
|
+
build_records
|
|
1554
|
+
end
|
|
1555
|
+
|
|
1556
|
+
##
|
|
1557
|
+
# Build and add/fix records' binary data/format.
|
|
1558
|
+
#
|
|
1559
|
+
# @return [Hash] fixed/validated records
|
|
1560
|
+
|
|
1561
|
+
def build_records
|
|
1562
|
+
fixed_strings = {}
|
|
1563
|
+
|
|
1564
|
+
@records.each do |record|
|
|
1565
|
+
definition = registry.definition(record)
|
|
1566
|
+
definition = definition && definition[:record]
|
|
1567
|
+
|
|
1568
|
+
fields = {}
|
|
1569
|
+
|
|
1570
|
+
definition.data_fields.each do |field|
|
|
1571
|
+
record_field = record[:fields][field.name]
|
|
1572
|
+
|
|
1573
|
+
if record_field && !record_field[:value].nil?
|
|
1574
|
+
value = record_field[:value]
|
|
1575
|
+
else
|
|
1576
|
+
value = field.base_type_definition[:undef]
|
|
1577
|
+
|
|
1578
|
+
sibling = field_sibling(field)
|
|
1579
|
+
if sibling.is_a?(Array)
|
|
1580
|
+
value = [field.base_type_definition[:undef]] * sibling.size
|
|
1581
|
+
end
|
|
1582
|
+
end
|
|
1583
|
+
|
|
1584
|
+
unless from_source?
|
|
1585
|
+
field.byte_count = field.base_type_definition[:bytes]
|
|
1586
|
+
|
|
1587
|
+
if field.base_type_definition[:bindata] == :string
|
|
1588
|
+
if fixed_strings[record[:message_name]] &&
|
|
1589
|
+
fixed_strings[record[:message_name]][field.name]
|
|
1590
|
+
largest_string = fixed_strings[record[:message_name]][field.name]
|
|
1591
|
+
else
|
|
1592
|
+
largest_string = @records.
|
|
1593
|
+
select {|rd| rd[:message_name] == record[:message_name] }.
|
|
1594
|
+
map do |rd|
|
|
1595
|
+
rd[:fields][field.name] &&
|
|
1596
|
+
rd[:fields][field.name][:value].to_s.length
|
|
1597
|
+
end.compact.sort.last
|
|
1598
|
+
|
|
1599
|
+
fixed_strings[record[:message_name]] = {}
|
|
1600
|
+
fixed_strings[record[:message_name]][field.name] = largest_string
|
|
1601
|
+
end
|
|
1602
|
+
|
|
1603
|
+
field.byte_count = ((largest_string / 8) * 8) + 8
|
|
1604
|
+
end
|
|
1605
|
+
|
|
1606
|
+
if value.is_a?(Array)
|
|
1607
|
+
field.byte_count *= value.size
|
|
1608
|
+
end
|
|
1609
|
+
end
|
|
1610
|
+
|
|
1611
|
+
if field.base_type_definition[:bindata] == :string
|
|
1612
|
+
opts = {length: field.byte_count.snapshot}
|
|
1613
|
+
value = BinData::String.new(value, opts).snapshot
|
|
1614
|
+
end
|
|
1615
|
+
|
|
1616
|
+
fields[field.name] = {
|
|
1617
|
+
value: value,
|
|
1618
|
+
base_type: field.base_type_definition,
|
|
1619
|
+
properties: field.global_message_field,
|
|
1620
|
+
definition: field
|
|
1621
|
+
}
|
|
1622
|
+
end
|
|
1623
|
+
|
|
1624
|
+
registry.records << {
|
|
1625
|
+
message_name: definition.global_message[:name],
|
|
1626
|
+
message_number: definition.global_message[:number],
|
|
1627
|
+
local_message_number: record[:local_message_number],
|
|
1628
|
+
fields: fields
|
|
1629
|
+
}
|
|
1630
|
+
end
|
|
1631
|
+
end
|
|
1632
|
+
|
|
1633
|
+
##
|
|
1634
|
+
# Helper method for finding a field's sibling.
|
|
1635
|
+
#
|
|
1636
|
+
# This is mostly because we can't trust base_type on arrays.
|
|
1637
|
+
#
|
|
1638
|
+
# @param [RecordField] field
|
|
1639
|
+
# @return [Array,Integer,String] sibling
|
|
1640
|
+
|
|
1641
|
+
def field_sibling(field)
|
|
1642
|
+
sibling = @records.find do |rd|
|
|
1643
|
+
rd[:message_name] == field.global_message[:name] &&
|
|
1644
|
+
rd[:fields].keys.include?(field.name)
|
|
1645
|
+
end
|
|
1646
|
+
|
|
1647
|
+
sibling && sibling[:fields][field.name][:value]
|
|
1648
|
+
end
|
|
1649
|
+
|
|
1650
|
+
##
|
|
1651
|
+
# @return [Boolean]
|
|
1652
|
+
|
|
1653
|
+
def from_source?
|
|
1654
|
+
!@source.nil?
|
|
1655
|
+
end
|
|
1656
|
+
|
|
1657
|
+
end
|
|
1658
|
+
|
|
1659
|
+
end
|
|
1660
|
+
|
|
1661
|
+
##
|
|
1662
|
+
# Decode/Read binary FIT file and return data in a {Registry}.
|
|
1663
|
+
|
|
1664
|
+
class Decoder
|
|
1665
|
+
|
|
1666
|
+
##
|
|
1667
|
+
# FIT binary file decoder/reader by providing data
|
|
1668
|
+
# in a human readable format [Hash]
|
|
1669
|
+
#
|
|
1670
|
+
# @param [String] file_name path for file to be read
|
|
1671
|
+
|
|
1672
|
+
def self.decode(file_name)
|
|
1673
|
+
io = ::File.open(file_name, 'rb')
|
|
1674
|
+
|
|
1675
|
+
begin
|
|
1676
|
+
until io.eof?
|
|
1677
|
+
offset = io.pos
|
|
1678
|
+
|
|
1679
|
+
registry = Registry.new(Definition::Header.read(io))
|
|
1680
|
+
|
|
1681
|
+
while io.pos < offset + registry.header.file_size
|
|
1682
|
+
record_header = Definition::RecordHeader.read(io)
|
|
1683
|
+
|
|
1684
|
+
local_message_number = record_header.local_message_type.snapshot
|
|
1685
|
+
|
|
1686
|
+
if record_header.for_new_definition?
|
|
1687
|
+
record_definition = Definition::Record.read(io)
|
|
1688
|
+
|
|
1689
|
+
registry.definitions << {
|
|
1690
|
+
local_message_number: local_message_number,
|
|
1691
|
+
message_name: record_definition.global_message[:name],
|
|
1692
|
+
header: record_header,
|
|
1693
|
+
record: record_definition
|
|
1694
|
+
}
|
|
1695
|
+
else
|
|
1696
|
+
record_definition = registry.definitions.reverse.find do |d|
|
|
1697
|
+
d[:local_message_number] == local_message_number
|
|
1698
|
+
end[:record]
|
|
1699
|
+
|
|
1700
|
+
data = record_definition.read_data(io)
|
|
1701
|
+
|
|
1702
|
+
record = Record.new(record_definition.global_message[:name])
|
|
1703
|
+
|
|
1704
|
+
record_definition.data_fields.each do |field|
|
|
1705
|
+
value = data[field.number].snapshot
|
|
1706
|
+
record.set_field_value(field, value)
|
|
1707
|
+
end
|
|
1708
|
+
|
|
1709
|
+
registry.add record, local_message_number
|
|
1710
|
+
end
|
|
1711
|
+
end
|
|
1712
|
+
|
|
1713
|
+
io.seek(2, :CUR)
|
|
1714
|
+
end
|
|
1715
|
+
ensure
|
|
1716
|
+
io.close
|
|
1717
|
+
end
|
|
1718
|
+
|
|
1719
|
+
Log.info "Finished reading #{file_name} file."
|
|
1720
|
+
|
|
1721
|
+
registry
|
|
1722
|
+
end
|
|
1723
|
+
|
|
1724
|
+
end
|
|
1725
|
+
|
|
1726
|
+
##
|
|
1727
|
+
# @param [String] file path to file to be decoded.
|
|
1728
|
+
|
|
1729
|
+
def self.decode(file)
|
|
1730
|
+
Log.info "Reading #{file} file."
|
|
1731
|
+
Decoder.decode(file)
|
|
1732
|
+
end
|
|
1733
|
+
|
|
1734
|
+
##
|
|
1735
|
+
# @param [String] file path for new FIT file
|
|
1736
|
+
# @param [Hash,Registry] records
|
|
1737
|
+
#
|
|
1738
|
+
# @param [String] source
|
|
1739
|
+
# Optional source FIT file to be used as a reference for
|
|
1740
|
+
# structuring the binary data.
|
|
1741
|
+
|
|
1742
|
+
def self.encode(file, records, source = nil)
|
|
1743
|
+
Log.info "Writing to #{file} file."
|
|
1744
|
+
Encoder.encode(file, records, source)
|
|
1745
|
+
end
|
|
1746
|
+
|
|
1747
|
+
end
|