f4r 0.1.0

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