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.
@@ -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,
@@ -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
@@ -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