fit_doctor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +113 -0
- data/lib/avo/concerns/events/verification.rb +9 -0
- data/lib/constans/events/verification.rb +8 -0
- data/lib/fit_doctor/configuration.rb +17 -0
- data/lib/fit_doctor/errors.rb +6 -0
- data/lib/fit_doctor/models/message.rb +22 -0
- data/lib/fit_doctor/parser.rb +601 -0
- data/lib/fit_doctor/profile.rb +366 -0
- data/lib/fit_doctor/version.rb +5 -0
- data/lib/fit_doctor.rb +26 -0
- metadata +64 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e671e26d71c72c773db9def073622d3f543f71e61b3081d2ee4dd60932e30043
|
|
4
|
+
data.tar.gz: 3c87d3926558355ee92d8a0103c57d4d388624190bb0a1787b68098ed0bacbff
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: '08373c8ff41002ba445d7acaf8571eb24495b989ed752cb48d6b23f4479ec254f16cb1b1f8004d4a3c7e9f8ba907a67cf1cf24b8d02af5f4bbc4e4d251dce864'
|
|
7
|
+
data.tar.gz: 11782fda2ee55559126654f27c8c9fb048ffe88ae5c316f79d862d39ee063f9a3a55aabbed6ee6dbee6b8989eb44010c8ecf71657c2fb83e21f1cd93da13d539
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 copilot-swe-agent[bot]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Fit Doctor
|
|
2
|
+
|
|
3
|
+
A Ruby gem for reading Garmin FIT files with a `gpx-doctor`-style interface.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "fit_doctor"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install directly:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gem install fit_doctor
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Configuration
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
FitDoctor.configure do |config|
|
|
23
|
+
config.decode_scaled_values = true # default true
|
|
24
|
+
config.convert_timestamps = true # default true
|
|
25
|
+
config.convert_coordinates = true # default true
|
|
26
|
+
config.symbolize_enums = true # default true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
FitDoctor.configuration.convert_timestamps # => true
|
|
30
|
+
FitDoctor.reset_configuration!
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Parsing
|
|
34
|
+
|
|
35
|
+
### From a file
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
result = FitDoctor::Parser.parse("path/to/activity.fit")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Example `.fit` files in this repository
|
|
42
|
+
|
|
43
|
+
You can use the bundled fixture files for local testing:
|
|
44
|
+
|
|
45
|
+
- `spec/fixtures/example_running.fit`
|
|
46
|
+
- `spec/fixtures/example_cycling.fit`
|
|
47
|
+
|
|
48
|
+
### From binary data
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
binary = File.binread("path/to/activity.fit")
|
|
52
|
+
result = FitDoctor::Parser.parse_data(binary)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Parse raw values (no conversion)
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
result = FitDoctor::Parser.parse("path/to/activity.fit", params: { raw_values: true })
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Result interface
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
result.file_id # => first :file_id message or nil
|
|
65
|
+
result.records # => array of :record messages
|
|
66
|
+
result.points # => alias for records
|
|
67
|
+
result.sessions # => array of :session messages
|
|
68
|
+
result.laps # => array of :lap messages
|
|
69
|
+
result.events # => array of :event messages
|
|
70
|
+
result.activities # => array of :activity messages
|
|
71
|
+
result.workouts # => array of :workout messages
|
|
72
|
+
result.courses # => array of :course messages
|
|
73
|
+
result.course_points # => array of :course_point messages
|
|
74
|
+
result.monitoring # => array of :monitoring messages
|
|
75
|
+
result.message_types # => all parsed message types
|
|
76
|
+
result.by_type(:device_info)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Each message is an object with named attributes and metadata:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
record = result.records.first
|
|
83
|
+
record.timestamp
|
|
84
|
+
record.position_lat
|
|
85
|
+
record.speed
|
|
86
|
+
record.fields # hash of all decoded fields
|
|
87
|
+
record.name # :record
|
|
88
|
+
record.global_message_number # 20
|
|
89
|
+
record.local_message_type # local definition number
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Supported decoding behavior
|
|
93
|
+
|
|
94
|
+
- FIT header parsing and validation (`.FIT` magic, optional header/data CRC checks)
|
|
95
|
+
- Definition and data message decoding (including compressed timestamp headers)
|
|
96
|
+
- Scale/offset conversions for common fields (speed, distance, altitude, times)
|
|
97
|
+
- Semicircle-to-degree coordinate conversion
|
|
98
|
+
- FIT epoch timestamp conversion (`1989-12-31`)
|
|
99
|
+
- Enum decoding for common profile values
|
|
100
|
+
- Developer field decoding when `field_description` metadata is present
|
|
101
|
+
|
|
102
|
+
The parser keeps unknown message/field data so FIT files with additional message types are still readable.
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
bundle install
|
|
108
|
+
bundle exec rspec
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FitDoctor
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :decode_scaled_values,
|
|
6
|
+
:convert_timestamps,
|
|
7
|
+
:convert_coordinates,
|
|
8
|
+
:symbolize_enums
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@decode_scaled_values = true
|
|
12
|
+
@convert_timestamps = true
|
|
13
|
+
@convert_coordinates = true
|
|
14
|
+
@symbolize_enums = true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ostruct"
|
|
4
|
+
|
|
5
|
+
module FitDoctor
|
|
6
|
+
module Models
|
|
7
|
+
class Message < OpenStruct
|
|
8
|
+
attr_reader :name, :global_message_number, :local_message_type
|
|
9
|
+
|
|
10
|
+
def initialize(name:, global_message_number:, local_message_type:, fields:)
|
|
11
|
+
@name = name
|
|
12
|
+
@global_message_number = global_message_number
|
|
13
|
+
@local_message_type = local_message_type
|
|
14
|
+
super(fields)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fields
|
|
18
|
+
to_h
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FitDoctor
|
|
4
|
+
class Parser
|
|
5
|
+
FIT_MAGIC = ".FIT"
|
|
6
|
+
MIN_HEADER_SIZE = 12
|
|
7
|
+
|
|
8
|
+
CRC_TABLE = [
|
|
9
|
+
0x0000, 0xCC01, 0xD801, 0x1400,
|
|
10
|
+
0xF001, 0x3C00, 0x2800, 0xE401,
|
|
11
|
+
0xA001, 0x6C00, 0x7800, 0xB401,
|
|
12
|
+
0x5000, 0x9C01, 0x8801, 0x4400
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
BASE_TYPES = {
|
|
16
|
+
0 => { size: 1, kind: :uint8, invalid: 0xFF },
|
|
17
|
+
1 => { size: 1, kind: :sint8, invalid: 0x7F },
|
|
18
|
+
2 => { size: 1, kind: :uint8, invalid: 0xFF },
|
|
19
|
+
3 => { size: 2, kind: :sint16, invalid: 0x7FFF },
|
|
20
|
+
4 => { size: 2, kind: :uint16, invalid: 0xFFFF },
|
|
21
|
+
5 => { size: 4, kind: :sint32, invalid: 0x7FFFFFFF },
|
|
22
|
+
6 => { size: 4, kind: :uint32, invalid: 0xFFFFFFFF },
|
|
23
|
+
7 => { size: 1, kind: :string, invalid: 0x00 },
|
|
24
|
+
8 => { size: 4, kind: :float32 },
|
|
25
|
+
9 => { size: 8, kind: :float64 },
|
|
26
|
+
10 => { size: 1, kind: :uint8, invalid: 0x00 },
|
|
27
|
+
11 => { size: 2, kind: :uint16, invalid: 0x0000 },
|
|
28
|
+
12 => { size: 4, kind: :uint32, invalid: 0x00000000 },
|
|
29
|
+
13 => { size: 1, kind: :byte, invalid: 0xFF },
|
|
30
|
+
14 => { size: 8, kind: :sint64, invalid: 0x7FFFFFFFFFFFFFFF },
|
|
31
|
+
15 => { size: 8, kind: :uint64, invalid: 0xFFFFFFFFFFFFFFFF },
|
|
32
|
+
16 => { size: 8, kind: :uint64, invalid: 0x0000000000000000 }
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
Result = Struct.new(:messages, :messages_by_type, keyword_init: true) do
|
|
36
|
+
def by_type(type)
|
|
37
|
+
messages_by_type.fetch(type.to_sym, [])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def message_types
|
|
41
|
+
messages_by_type.keys
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def file_id
|
|
45
|
+
by_type(:file_id).first
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def sessions
|
|
49
|
+
by_type(:session)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def laps
|
|
53
|
+
by_type(:lap)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def records
|
|
57
|
+
by_type(:record)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def points
|
|
61
|
+
records
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def events
|
|
65
|
+
by_type(:event)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def activities
|
|
69
|
+
by_type(:activity)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def workouts
|
|
73
|
+
by_type(:workout)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def courses
|
|
77
|
+
by_type(:course)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def course_points
|
|
81
|
+
by_type(:course_point)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def monitoring
|
|
85
|
+
by_type(:monitoring)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
DefinitionMessage = Struct.new(
|
|
90
|
+
:global_message_number,
|
|
91
|
+
:little_endian,
|
|
92
|
+
:fields,
|
|
93
|
+
:developer_fields,
|
|
94
|
+
:profile,
|
|
95
|
+
keyword_init: true
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
DefinitionField = Struct.new(:number, :size, :base_type, keyword_init: true)
|
|
99
|
+
|
|
100
|
+
DeveloperDefinitionField = Struct.new(
|
|
101
|
+
:number,
|
|
102
|
+
:size,
|
|
103
|
+
:developer_data_index,
|
|
104
|
+
keyword_init: true
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
class << self
|
|
108
|
+
def parse(file_path, params: {})
|
|
109
|
+
parse_data(File.binread(file_path), params: params)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def parse_data(binary_data, params: {})
|
|
113
|
+
new(binary_data, params: params).parse
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def initialize(binary_data, params: {})
|
|
118
|
+
@binary_data = binary_data
|
|
119
|
+
@params = params
|
|
120
|
+
@definitions = {}
|
|
121
|
+
@last_timestamps = {}
|
|
122
|
+
@developer_field_descriptions = {}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def parse
|
|
126
|
+
header = parse_header
|
|
127
|
+
validate_header_crc!(header)
|
|
128
|
+
validate_file_crc!(header)
|
|
129
|
+
|
|
130
|
+
data_section = read_exact(@binary_data, header[:header_size], header[:data_size], "FIT data section")
|
|
131
|
+
messages = parse_records(data_section)
|
|
132
|
+
by_type = Hash.new { |hash, key| hash[key] = [] }
|
|
133
|
+
messages.each { |message| by_type[message.name] << message }
|
|
134
|
+
|
|
135
|
+
Result.new(messages: messages, messages_by_type: by_type)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def parse_header
|
|
141
|
+
raise InvalidFitError, "FIT file is too short" if @binary_data.bytesize < MIN_HEADER_SIZE
|
|
142
|
+
|
|
143
|
+
header_size = @binary_data.getbyte(0)
|
|
144
|
+
raise InvalidFitError, "Invalid FIT header size" if header_size.nil? || header_size < MIN_HEADER_SIZE
|
|
145
|
+
raise InvalidFitError, "FIT header is truncated" if header_size > @binary_data.bytesize
|
|
146
|
+
|
|
147
|
+
data_type = read_exact(@binary_data, 8, 4, "FIT header data type")
|
|
148
|
+
raise InvalidFitError, "Invalid FIT header magic" unless data_type == FIT_MAGIC
|
|
149
|
+
|
|
150
|
+
protocol_version = @binary_data.getbyte(1)
|
|
151
|
+
profile_version = unpack_uint16(read_exact(@binary_data, 2, 2, "FIT profile version"), true)
|
|
152
|
+
data_size = unpack_uint32(read_exact(@binary_data, 4, 4, "FIT data size"), true)
|
|
153
|
+
|
|
154
|
+
expected_min_size = header_size + data_size
|
|
155
|
+
raise InvalidFitError, "FIT data section is truncated" if expected_min_size > @binary_data.bytesize
|
|
156
|
+
|
|
157
|
+
header_crc = nil
|
|
158
|
+
if header_size > MIN_HEADER_SIZE
|
|
159
|
+
header_crc = unpack_uint16(read_exact(@binary_data, header_size - 2, 2, "FIT header CRC"), true)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
header_size: header_size,
|
|
164
|
+
protocol_version: protocol_version,
|
|
165
|
+
profile_version: profile_version,
|
|
166
|
+
data_size: data_size,
|
|
167
|
+
header_crc: header_crc
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def validate_header_crc!(header)
|
|
172
|
+
return unless header[:header_crc]
|
|
173
|
+
|
|
174
|
+
crc_payload = read_exact(@binary_data, 0, header[:header_size] - 2, "FIT header CRC payload")
|
|
175
|
+
calculated = crc16(crc_payload)
|
|
176
|
+
return if calculated == header[:header_crc]
|
|
177
|
+
|
|
178
|
+
raise InvalidFitError, "FIT header CRC mismatch"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def validate_file_crc!(header)
|
|
182
|
+
data_start = header[:header_size]
|
|
183
|
+
data_end = data_start + header[:data_size]
|
|
184
|
+
return if @binary_data.bytesize < data_end + 2
|
|
185
|
+
|
|
186
|
+
expected_crc = unpack_uint16(read_exact(@binary_data, data_end, 2, "FIT file CRC"), true)
|
|
187
|
+
|
|
188
|
+
crc_data_only = crc16(read_exact(@binary_data, data_start, header[:data_size], "FIT data section"))
|
|
189
|
+
return if expected_crc == crc_data_only
|
|
190
|
+
|
|
191
|
+
crc_header_and_data = crc16(read_exact(@binary_data, 0, data_end, "FIT header and data"))
|
|
192
|
+
return if expected_crc == crc_header_and_data
|
|
193
|
+
|
|
194
|
+
raise InvalidFitError, "FIT file CRC mismatch"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def parse_records(data_section)
|
|
198
|
+
messages = []
|
|
199
|
+
offset = 0
|
|
200
|
+
|
|
201
|
+
while offset < data_section.bytesize
|
|
202
|
+
record_header = data_section.getbyte(offset)
|
|
203
|
+
raise InvalidFitError, "Invalid FIT record header" if record_header.nil?
|
|
204
|
+
|
|
205
|
+
offset += 1
|
|
206
|
+
|
|
207
|
+
if compressed_timestamp_header?(record_header)
|
|
208
|
+
local_message_type = (record_header >> 5) & 0x03
|
|
209
|
+
timestamp_offset = record_header & 0x1F
|
|
210
|
+
definition = fetch_definition(local_message_type)
|
|
211
|
+
fields, consumed, raw_timestamp = parse_data_message_fields(data_section, offset, definition)
|
|
212
|
+
offset += consumed
|
|
213
|
+
|
|
214
|
+
raw_timestamp ||= expand_compressed_timestamp(local_message_type, timestamp_offset)
|
|
215
|
+
fields[:timestamp] = convert_timestamp(raw_timestamp) unless fields.key?(:timestamp)
|
|
216
|
+
@last_timestamps[local_message_type] = raw_timestamp
|
|
217
|
+
|
|
218
|
+
message = build_message(definition, local_message_type, fields)
|
|
219
|
+
track_developer_field_description(message)
|
|
220
|
+
messages << message
|
|
221
|
+
next
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
local_message_type = record_header & 0x0F
|
|
225
|
+
if (record_header & 0x40) != 0
|
|
226
|
+
definition, consumed = parse_definition_message(data_section, offset, developer_data: (record_header & 0x20) != 0)
|
|
227
|
+
offset += consumed
|
|
228
|
+
@definitions[local_message_type] = definition
|
|
229
|
+
next
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
definition = fetch_definition(local_message_type)
|
|
233
|
+
fields, consumed, raw_timestamp = parse_data_message_fields(data_section, offset, definition)
|
|
234
|
+
offset += consumed
|
|
235
|
+
@last_timestamps[local_message_type] = raw_timestamp if raw_timestamp
|
|
236
|
+
|
|
237
|
+
message = build_message(definition, local_message_type, fields)
|
|
238
|
+
track_developer_field_description(message)
|
|
239
|
+
messages << message
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
messages
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def parse_definition_message(data_section, offset, developer_data:)
|
|
246
|
+
start_offset = offset
|
|
247
|
+
read_exact(data_section, offset, 5, "FIT definition message")
|
|
248
|
+
|
|
249
|
+
architecture = data_section.getbyte(offset + 1)
|
|
250
|
+
little_endian = architecture == 0
|
|
251
|
+
global_message_number = unpack_uint16(read_exact(data_section, offset + 2, 2, "FIT global message number"), little_endian)
|
|
252
|
+
number_of_fields = data_section.getbyte(offset + 4)
|
|
253
|
+
|
|
254
|
+
offset += 5
|
|
255
|
+
fields = []
|
|
256
|
+
number_of_fields.times do
|
|
257
|
+
field_blob = read_exact(data_section, offset, 3, "FIT field definition")
|
|
258
|
+
fields << DefinitionField.new(
|
|
259
|
+
number: field_blob.getbyte(0),
|
|
260
|
+
size: field_blob.getbyte(1),
|
|
261
|
+
base_type: field_blob.getbyte(2)
|
|
262
|
+
)
|
|
263
|
+
offset += 3
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
developer_fields = []
|
|
267
|
+
if developer_data
|
|
268
|
+
number_of_developer_fields = data_section.getbyte(offset)
|
|
269
|
+
raise InvalidFitError, "Invalid FIT developer field definition" if number_of_developer_fields.nil?
|
|
270
|
+
|
|
271
|
+
offset += 1
|
|
272
|
+
number_of_developer_fields.times do
|
|
273
|
+
field_blob = read_exact(data_section, offset, 3, "FIT developer field definition")
|
|
274
|
+
developer_fields << DeveloperDefinitionField.new(
|
|
275
|
+
number: field_blob.getbyte(0),
|
|
276
|
+
size: field_blob.getbyte(1),
|
|
277
|
+
developer_data_index: field_blob.getbyte(2)
|
|
278
|
+
)
|
|
279
|
+
offset += 3
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
definition = DefinitionMessage.new(
|
|
284
|
+
global_message_number: global_message_number,
|
|
285
|
+
little_endian: little_endian,
|
|
286
|
+
fields: fields,
|
|
287
|
+
developer_fields: developer_fields,
|
|
288
|
+
profile: Profile.message_profile(global_message_number)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
[definition, offset - start_offset]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def parse_data_message_fields(data_section, offset, definition)
|
|
295
|
+
start_offset = offset
|
|
296
|
+
fields = {}
|
|
297
|
+
raw_timestamp = nil
|
|
298
|
+
|
|
299
|
+
definition.fields.each do |field_definition|
|
|
300
|
+
value_blob = read_exact(data_section, offset, field_definition.size, "FIT field value")
|
|
301
|
+
offset += field_definition.size
|
|
302
|
+
|
|
303
|
+
base_type_id = base_type_id(field_definition.base_type)
|
|
304
|
+
raw_value = decode_base_value(value_blob, base_type_id, definition.little_endian)
|
|
305
|
+
|
|
306
|
+
field_profile = definition.profile[:fields][field_definition.number]
|
|
307
|
+
field_name = field_profile&.fetch(:name, nil) || :"field_#{field_definition.number}"
|
|
308
|
+
|
|
309
|
+
raw_timestamp = raw_value if field_name == :timestamp && raw_value
|
|
310
|
+
value = apply_field_conversion(raw_value, field_profile)
|
|
311
|
+
fields[field_name] = value unless value.nil?
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
definition.developer_fields.each do |field_definition|
|
|
315
|
+
value_blob = read_exact(data_section, offset, field_definition.size, "FIT developer field value")
|
|
316
|
+
offset += field_definition.size
|
|
317
|
+
|
|
318
|
+
descriptor = @developer_field_descriptions[[field_definition.developer_data_index, field_definition.number]]
|
|
319
|
+
base_type = descriptor&.dig(:fit_base_type_id)
|
|
320
|
+
|
|
321
|
+
raw_value = if base_type
|
|
322
|
+
decode_base_value(value_blob, base_type_id(base_type), definition.little_endian)
|
|
323
|
+
elsif field_definition.size == 1
|
|
324
|
+
value_blob.getbyte(0)
|
|
325
|
+
else
|
|
326
|
+
value_blob.bytes
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
field_name = developer_field_name(field_definition, descriptor)
|
|
330
|
+
value = apply_field_conversion(raw_value, descriptor)
|
|
331
|
+
fields[field_name] = value unless value.nil?
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
[fields, offset - start_offset, raw_timestamp]
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def build_message(definition, local_message_type, fields)
|
|
338
|
+
Models::Message.new(
|
|
339
|
+
name: definition.profile[:name],
|
|
340
|
+
global_message_number: definition.global_message_number,
|
|
341
|
+
local_message_type: local_message_type,
|
|
342
|
+
fields: fields
|
|
343
|
+
)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def fetch_definition(local_message_type)
|
|
347
|
+
definition = @definitions[local_message_type]
|
|
348
|
+
return definition if definition
|
|
349
|
+
|
|
350
|
+
raise InvalidFitError, "Missing FIT definition for local message type #{local_message_type}"
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def track_developer_field_description(message)
|
|
354
|
+
return unless message.name == :field_description
|
|
355
|
+
|
|
356
|
+
developer_data_index = message.developer_data_index
|
|
357
|
+
field_definition_number = message.field_definition_number
|
|
358
|
+
fit_base_type_id = message.fit_base_type_id
|
|
359
|
+
|
|
360
|
+
return if developer_data_index.nil? || field_definition_number.nil? || fit_base_type_id.nil?
|
|
361
|
+
|
|
362
|
+
@developer_field_descriptions[[developer_data_index, field_definition_number]] = {
|
|
363
|
+
field_name: message.field_name,
|
|
364
|
+
fit_base_type_id: fit_base_type_id,
|
|
365
|
+
units: message.units
|
|
366
|
+
}
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def developer_field_name(field_definition, descriptor)
|
|
370
|
+
normalized = normalize_field_name(descriptor&.dig(:field_name))
|
|
371
|
+
return normalized if normalized
|
|
372
|
+
|
|
373
|
+
:"developer_#{field_definition.developer_data_index}_field_#{field_definition.number}"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def normalize_field_name(value)
|
|
377
|
+
return nil if value.nil?
|
|
378
|
+
|
|
379
|
+
candidate = value.to_s.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_|_\z/, "")
|
|
380
|
+
return nil if candidate.empty?
|
|
381
|
+
|
|
382
|
+
candidate.to_sym
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def compressed_timestamp_header?(header)
|
|
386
|
+
(header & 0x80) != 0
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def expand_compressed_timestamp(local_message_type, offset)
|
|
390
|
+
base = @last_timestamps[local_message_type]
|
|
391
|
+
return offset unless base
|
|
392
|
+
|
|
393
|
+
expanded = (base & 0xFFFFFFE0) + offset
|
|
394
|
+
expanded += 0x20 if expanded < base
|
|
395
|
+
expanded
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def apply_field_conversion(value, field_profile)
|
|
399
|
+
return value if value.nil?
|
|
400
|
+
return value if raw_values?
|
|
401
|
+
|
|
402
|
+
converted = value
|
|
403
|
+
|
|
404
|
+
if convert_timestamps? && field_profile && %i[date_time local_date_time].include?(field_profile[:type])
|
|
405
|
+
converted = convert_timestamp(converted)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
if convert_coordinates? && field_profile && field_profile[:type] == :semicircles
|
|
409
|
+
converted = convert_semicircles(converted)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
if decode_scaled_values? && field_profile && (field_profile[:scale] || field_profile[:offset])
|
|
413
|
+
converted = convert_scaled_value(converted, field_profile)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
if symbolize_enums? && field_profile && field_profile[:enum]
|
|
417
|
+
converted = convert_enum_value(converted, field_profile[:enum])
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
converted
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def convert_timestamp(value)
|
|
424
|
+
convert_container(value) do |item|
|
|
425
|
+
next item unless item.is_a?(Numeric)
|
|
426
|
+
|
|
427
|
+
Profile::FIT_EPOCH + item
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def convert_semicircles(value)
|
|
432
|
+
convert_container(value) do |item|
|
|
433
|
+
next item unless item.is_a?(Numeric)
|
|
434
|
+
|
|
435
|
+
item.to_f * Profile::SEMICIRCLE_TO_DEGREES
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def convert_scaled_value(value, field_profile)
|
|
440
|
+
convert_container(value) do |item|
|
|
441
|
+
next item unless item.is_a?(Numeric)
|
|
442
|
+
|
|
443
|
+
converted = item.to_f
|
|
444
|
+
converted /= field_profile[:scale] if field_profile[:scale]
|
|
445
|
+
converted -= field_profile[:offset] if field_profile[:offset]
|
|
446
|
+
converted
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def convert_enum_value(value, enum_type)
|
|
451
|
+
mapping = Profile.enum_mapping(enum_type)
|
|
452
|
+
return value unless mapping
|
|
453
|
+
|
|
454
|
+
convert_container(value) { |item| mapping.fetch(item, item) }
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def convert_container(value)
|
|
458
|
+
if value.is_a?(Array)
|
|
459
|
+
value.map { |item| yield(item) }
|
|
460
|
+
else
|
|
461
|
+
yield(value)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def raw_values?
|
|
466
|
+
@params[:raw_values] == true
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def decode_scaled_values?
|
|
470
|
+
return false if raw_values?
|
|
471
|
+
|
|
472
|
+
param_or_config(:decode_scaled_values)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def convert_timestamps?
|
|
476
|
+
return false if raw_values?
|
|
477
|
+
|
|
478
|
+
param_or_config(:convert_timestamps)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def convert_coordinates?
|
|
482
|
+
return false if raw_values?
|
|
483
|
+
|
|
484
|
+
param_or_config(:convert_coordinates)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def symbolize_enums?
|
|
488
|
+
return false if raw_values?
|
|
489
|
+
|
|
490
|
+
param_or_config(:symbolize_enums)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def param_or_config(key)
|
|
494
|
+
return @params[key] unless @params[key].nil?
|
|
495
|
+
|
|
496
|
+
FitDoctor.configuration.public_send(key)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def read_exact(data, offset, size, label)
|
|
500
|
+
chunk = data.byteslice(offset, size)
|
|
501
|
+
if chunk.nil? || chunk.bytesize != size
|
|
502
|
+
raise InvalidFitError, "Unexpected end of FIT file while reading #{label}"
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
chunk
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def base_type_id(value)
|
|
509
|
+
value & 0x1F
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def decode_base_value(blob, base_type_id_value, little_endian)
|
|
513
|
+
definition = BASE_TYPES[base_type_id_value]
|
|
514
|
+
return blob.bytes if definition.nil?
|
|
515
|
+
|
|
516
|
+
kind = definition[:kind]
|
|
517
|
+
element_size = definition[:size]
|
|
518
|
+
|
|
519
|
+
if kind == :string
|
|
520
|
+
text = blob.delete("\0").strip
|
|
521
|
+
return text.empty? ? nil : text
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
if kind == :byte
|
|
525
|
+
return blob.bytes if blob.bytesize > 1
|
|
526
|
+
|
|
527
|
+
value = blob.getbyte(0)
|
|
528
|
+
return nil if value == definition[:invalid]
|
|
529
|
+
|
|
530
|
+
return value
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
if blob.bytesize == element_size
|
|
534
|
+
decode_scalar(blob, kind, definition[:invalid], little_endian)
|
|
535
|
+
elsif (blob.bytesize % element_size).zero?
|
|
536
|
+
values = blob.bytes.each_slice(element_size).map do |slice|
|
|
537
|
+
decode_scalar(slice.pack("C*"), kind, definition[:invalid], little_endian)
|
|
538
|
+
end
|
|
539
|
+
values.all?(&:nil?) ? nil : values
|
|
540
|
+
else
|
|
541
|
+
blob.bytes
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def decode_scalar(blob, kind, invalid_value, little_endian)
|
|
546
|
+
if %i[float32 float64].include?(kind) && blob.bytes.all?(0xFF)
|
|
547
|
+
return nil
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
unpack_directive = case kind
|
|
551
|
+
when :sint8 then "c"
|
|
552
|
+
when :uint8 then "C"
|
|
553
|
+
when :sint16 then little_endian ? "s<" : "s>"
|
|
554
|
+
when :uint16 then little_endian ? "S<" : "S>"
|
|
555
|
+
when :sint32 then little_endian ? "l<" : "l>"
|
|
556
|
+
when :uint32 then little_endian ? "L<" : "L>"
|
|
557
|
+
when :sint64 then little_endian ? "q<" : "q>"
|
|
558
|
+
when :uint64 then little_endian ? "Q<" : "Q>"
|
|
559
|
+
when :float32 then little_endian ? "e" : "g"
|
|
560
|
+
when :float64 then little_endian ? "E" : "G"
|
|
561
|
+
else
|
|
562
|
+
nil
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
return blob.bytes if unpack_directive.nil?
|
|
566
|
+
|
|
567
|
+
value = blob.unpack1(unpack_directive)
|
|
568
|
+
return nil if !invalid_value.nil? && value == invalid_value
|
|
569
|
+
|
|
570
|
+
value
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def unpack_uint16(blob, little_endian)
|
|
574
|
+
blob.unpack1(little_endian ? "S<" : "S>")
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def unpack_uint32(blob, little_endian)
|
|
578
|
+
blob.unpack1(little_endian ? "L<" : "L>")
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def crc16(data)
|
|
582
|
+
crc = 0
|
|
583
|
+
|
|
584
|
+
data.each_byte do |byte|
|
|
585
|
+
crc = crc_update(crc, byte)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
crc
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def crc_update(crc, byte)
|
|
592
|
+
tmp = CRC_TABLE[crc & 0x0F]
|
|
593
|
+
crc = (crc >> 4) & 0x0FFF
|
|
594
|
+
crc ^= tmp ^ CRC_TABLE[byte & 0x0F]
|
|
595
|
+
|
|
596
|
+
tmp = CRC_TABLE[crc & 0x0F]
|
|
597
|
+
crc = (crc >> 4) & 0x0FFF
|
|
598
|
+
crc ^ tmp ^ CRC_TABLE[(byte >> 4) & 0x0F]
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
end
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FitDoctor
|
|
4
|
+
module Profile
|
|
5
|
+
FIT_EPOCH = Time.utc(1989, 12, 31).freeze
|
|
6
|
+
SEMICIRCLE_TO_DEGREES = 180.0 / (2**31)
|
|
7
|
+
|
|
8
|
+
ENUMS = {
|
|
9
|
+
file: {
|
|
10
|
+
1 => :device,
|
|
11
|
+
2 => :settings,
|
|
12
|
+
3 => :sport,
|
|
13
|
+
4 => :activity,
|
|
14
|
+
5 => :workout,
|
|
15
|
+
6 => :course,
|
|
16
|
+
7 => :schedules,
|
|
17
|
+
9 => :weight,
|
|
18
|
+
10 => :totals,
|
|
19
|
+
11 => :goals,
|
|
20
|
+
14 => :blood_pressure,
|
|
21
|
+
15 => :monitoring_a,
|
|
22
|
+
20 => :activity_summary,
|
|
23
|
+
28 => :monitoring_daily,
|
|
24
|
+
32 => :monitoring_b
|
|
25
|
+
},
|
|
26
|
+
gender: {
|
|
27
|
+
0 => :female,
|
|
28
|
+
1 => :male
|
|
29
|
+
},
|
|
30
|
+
sport: {
|
|
31
|
+
0 => :generic,
|
|
32
|
+
1 => :running,
|
|
33
|
+
2 => :cycling,
|
|
34
|
+
3 => :transition,
|
|
35
|
+
4 => :fitness_equipment,
|
|
36
|
+
5 => :swimming,
|
|
37
|
+
6 => :basketball,
|
|
38
|
+
7 => :soccer,
|
|
39
|
+
8 => :tennis,
|
|
40
|
+
9 => :american_football,
|
|
41
|
+
10 => :training,
|
|
42
|
+
11 => :walking,
|
|
43
|
+
15 => :rowing,
|
|
44
|
+
16 => :mountaineering,
|
|
45
|
+
17 => :hiking,
|
|
46
|
+
18 => :multisport,
|
|
47
|
+
19 => :paddling
|
|
48
|
+
},
|
|
49
|
+
sub_sport: {
|
|
50
|
+
0 => :generic,
|
|
51
|
+
1 => :treadmill,
|
|
52
|
+
2 => :street,
|
|
53
|
+
3 => :trail,
|
|
54
|
+
4 => :track,
|
|
55
|
+
5 => :spin,
|
|
56
|
+
6 => :indoor_cycling,
|
|
57
|
+
17 => :lap_swimming,
|
|
58
|
+
18 => :open_water,
|
|
59
|
+
19 => :flexibility,
|
|
60
|
+
20 => :strength_training,
|
|
61
|
+
25 => :indoor_rowing,
|
|
62
|
+
28 => :high_intensity_interval_training
|
|
63
|
+
},
|
|
64
|
+
event: {
|
|
65
|
+
0 => :timer,
|
|
66
|
+
3 => :workout,
|
|
67
|
+
8 => :session,
|
|
68
|
+
9 => :lap,
|
|
69
|
+
10 => :course_point,
|
|
70
|
+
21 => :activity,
|
|
71
|
+
26 => :length
|
|
72
|
+
},
|
|
73
|
+
event_type: {
|
|
74
|
+
0 => :start,
|
|
75
|
+
1 => :stop,
|
|
76
|
+
2 => :consecutive_deprecated,
|
|
77
|
+
3 => :marker,
|
|
78
|
+
4 => :stop_all,
|
|
79
|
+
5 => :begin_deprecated,
|
|
80
|
+
6 => :end_deprecated,
|
|
81
|
+
7 => :end_all,
|
|
82
|
+
8 => :stop_disable,
|
|
83
|
+
9 => :stop_disable_all
|
|
84
|
+
},
|
|
85
|
+
activity: {
|
|
86
|
+
0 => :manual,
|
|
87
|
+
1 => :auto_multi_sport
|
|
88
|
+
},
|
|
89
|
+
battery_status: {
|
|
90
|
+
1 => :new,
|
|
91
|
+
2 => :good,
|
|
92
|
+
3 => :ok,
|
|
93
|
+
4 => :low,
|
|
94
|
+
5 => :critical
|
|
95
|
+
},
|
|
96
|
+
course_point: {
|
|
97
|
+
0 => :generic,
|
|
98
|
+
1 => :summit,
|
|
99
|
+
2 => :valley,
|
|
100
|
+
3 => :water,
|
|
101
|
+
4 => :food,
|
|
102
|
+
5 => :danger,
|
|
103
|
+
6 => :left,
|
|
104
|
+
7 => :right,
|
|
105
|
+
8 => :straight,
|
|
106
|
+
23 => :u_turn,
|
|
107
|
+
24 => :segment_start,
|
|
108
|
+
25 => :segment_end
|
|
109
|
+
},
|
|
110
|
+
manufacturer: {
|
|
111
|
+
1 => :garmin,
|
|
112
|
+
13 => :dynastream,
|
|
113
|
+
89 => :zwift,
|
|
114
|
+
255 => :development
|
|
115
|
+
}
|
|
116
|
+
}.freeze
|
|
117
|
+
|
|
118
|
+
MESSAGE_PROFILES = {
|
|
119
|
+
0 => {
|
|
120
|
+
name: :file_id,
|
|
121
|
+
fields: {
|
|
122
|
+
0 => { name: :type, enum: :file },
|
|
123
|
+
1 => { name: :manufacturer, enum: :manufacturer },
|
|
124
|
+
2 => { name: :product },
|
|
125
|
+
3 => { name: :serial_number },
|
|
126
|
+
4 => { name: :time_created, type: :date_time },
|
|
127
|
+
5 => { name: :number }
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
2 => {
|
|
131
|
+
name: :device_settings,
|
|
132
|
+
fields: {
|
|
133
|
+
0 => { name: :active_time_zone },
|
|
134
|
+
1 => { name: :utc_offset },
|
|
135
|
+
2 => { name: :time_offset }
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
3 => {
|
|
139
|
+
name: :user_profile,
|
|
140
|
+
fields: {
|
|
141
|
+
1 => { name: :friendly_name },
|
|
142
|
+
2 => { name: :gender, enum: :gender },
|
|
143
|
+
3 => { name: :age },
|
|
144
|
+
4 => { name: :height, scale: 100 },
|
|
145
|
+
5 => { name: :weight, scale: 10 }
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
12 => {
|
|
149
|
+
name: :sport,
|
|
150
|
+
fields: {
|
|
151
|
+
0 => { name: :sport, enum: :sport },
|
|
152
|
+
1 => { name: :sub_sport, enum: :sub_sport },
|
|
153
|
+
3 => { name: :name }
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
18 => {
|
|
157
|
+
name: :session,
|
|
158
|
+
fields: {
|
|
159
|
+
253 => { name: :timestamp, type: :date_time },
|
|
160
|
+
2 => { name: :start_time, type: :date_time },
|
|
161
|
+
5 => { name: :sport, enum: :sport },
|
|
162
|
+
6 => { name: :sub_sport, enum: :sub_sport },
|
|
163
|
+
7 => { name: :total_elapsed_time, scale: 1000 },
|
|
164
|
+
8 => { name: :total_timer_time, scale: 1000 },
|
|
165
|
+
9 => { name: :total_distance, scale: 100 },
|
|
166
|
+
11 => { name: :total_calories },
|
|
167
|
+
13 => { name: :avg_speed, scale: 1000 },
|
|
168
|
+
14 => { name: :max_speed, scale: 1000 },
|
|
169
|
+
15 => { name: :avg_heart_rate },
|
|
170
|
+
16 => { name: :max_heart_rate },
|
|
171
|
+
17 => { name: :avg_cadence },
|
|
172
|
+
18 => { name: :max_cadence },
|
|
173
|
+
19 => { name: :avg_power },
|
|
174
|
+
20 => { name: :max_power },
|
|
175
|
+
21 => { name: :total_ascent },
|
|
176
|
+
22 => { name: :total_descent },
|
|
177
|
+
48 => { name: :avg_temperature },
|
|
178
|
+
49 => { name: :max_temperature }
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
19 => {
|
|
182
|
+
name: :lap,
|
|
183
|
+
fields: {
|
|
184
|
+
253 => { name: :timestamp, type: :date_time },
|
|
185
|
+
2 => { name: :start_time, type: :date_time },
|
|
186
|
+
7 => { name: :total_elapsed_time, scale: 1000 },
|
|
187
|
+
8 => { name: :total_timer_time, scale: 1000 },
|
|
188
|
+
9 => { name: :total_distance, scale: 100 },
|
|
189
|
+
11 => { name: :total_calories },
|
|
190
|
+
13 => { name: :avg_speed, scale: 1000 },
|
|
191
|
+
14 => { name: :max_speed, scale: 1000 },
|
|
192
|
+
15 => { name: :avg_heart_rate },
|
|
193
|
+
16 => { name: :max_heart_rate },
|
|
194
|
+
17 => { name: :avg_cadence },
|
|
195
|
+
18 => { name: :max_cadence },
|
|
196
|
+
19 => { name: :avg_power },
|
|
197
|
+
20 => { name: :max_power },
|
|
198
|
+
21 => { name: :total_ascent },
|
|
199
|
+
22 => { name: :total_descent },
|
|
200
|
+
24 => { name: :lap_trigger }
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
20 => {
|
|
204
|
+
name: :record,
|
|
205
|
+
fields: {
|
|
206
|
+
253 => { name: :timestamp, type: :date_time },
|
|
207
|
+
0 => { name: :position_lat, type: :semicircles },
|
|
208
|
+
1 => { name: :position_long, type: :semicircles },
|
|
209
|
+
2 => { name: :altitude, scale: 5, offset: 500 },
|
|
210
|
+
3 => { name: :heart_rate },
|
|
211
|
+
4 => { name: :cadence },
|
|
212
|
+
5 => { name: :distance, scale: 100 },
|
|
213
|
+
6 => { name: :speed, scale: 1000 },
|
|
214
|
+
7 => { name: :power },
|
|
215
|
+
13 => { name: :temperature },
|
|
216
|
+
29 => { name: :enhanced_speed, scale: 1000 },
|
|
217
|
+
30 => { name: :enhanced_altitude, scale: 5, offset: 500 }
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
21 => {
|
|
221
|
+
name: :event,
|
|
222
|
+
fields: {
|
|
223
|
+
253 => { name: :timestamp, type: :date_time },
|
|
224
|
+
0 => { name: :event, enum: :event },
|
|
225
|
+
1 => { name: :event_type, enum: :event_type },
|
|
226
|
+
2 => { name: :data16 },
|
|
227
|
+
3 => { name: :data },
|
|
228
|
+
4 => { name: :event_group }
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
23 => {
|
|
232
|
+
name: :device_info,
|
|
233
|
+
fields: {
|
|
234
|
+
253 => { name: :timestamp, type: :date_time },
|
|
235
|
+
0 => { name: :device_index },
|
|
236
|
+
1 => { name: :device_type },
|
|
237
|
+
2 => { name: :manufacturer, enum: :manufacturer },
|
|
238
|
+
3 => { name: :serial_number },
|
|
239
|
+
4 => { name: :product },
|
|
240
|
+
5 => { name: :software_version, scale: 100 },
|
|
241
|
+
10 => { name: :battery_status, enum: :battery_status },
|
|
242
|
+
11 => { name: :battery_voltage, scale: 256 }
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
26 => {
|
|
246
|
+
name: :workout,
|
|
247
|
+
fields: {
|
|
248
|
+
4 => { name: :sport, enum: :sport },
|
|
249
|
+
5 => { name: :sub_sport, enum: :sub_sport },
|
|
250
|
+
6 => { name: :capabilities },
|
|
251
|
+
8 => { name: :num_valid_steps },
|
|
252
|
+
11 => { name: :wkt_name }
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
31 => {
|
|
256
|
+
name: :course,
|
|
257
|
+
fields: {
|
|
258
|
+
4 => { name: :sport, enum: :sport },
|
|
259
|
+
5 => { name: :name },
|
|
260
|
+
6 => { name: :capabilities }
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
32 => {
|
|
264
|
+
name: :course_point,
|
|
265
|
+
fields: {
|
|
266
|
+
1 => { name: :timestamp, type: :date_time },
|
|
267
|
+
2 => { name: :position_lat, type: :semicircles },
|
|
268
|
+
3 => { name: :position_long, type: :semicircles },
|
|
269
|
+
4 => { name: :distance, scale: 100 },
|
|
270
|
+
5 => { name: :type, enum: :course_point },
|
|
271
|
+
6 => { name: :name }
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
34 => {
|
|
275
|
+
name: :activity,
|
|
276
|
+
fields: {
|
|
277
|
+
253 => { name: :timestamp, type: :date_time },
|
|
278
|
+
0 => { name: :total_timer_time, scale: 1000 },
|
|
279
|
+
1 => { name: :num_sessions },
|
|
280
|
+
2 => { name: :type, enum: :activity },
|
|
281
|
+
3 => { name: :event, enum: :event },
|
|
282
|
+
4 => { name: :event_type, enum: :event_type },
|
|
283
|
+
5 => { name: :local_timestamp, type: :local_date_time },
|
|
284
|
+
6 => { name: :event_group },
|
|
285
|
+
7 => { name: :total_elapsed_time, scale: 1000 }
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
49 => {
|
|
289
|
+
name: :file_creator,
|
|
290
|
+
fields: {
|
|
291
|
+
0 => { name: :software_version },
|
|
292
|
+
1 => { name: :hardware_version }
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
55 => {
|
|
296
|
+
name: :monitoring,
|
|
297
|
+
fields: {
|
|
298
|
+
253 => { name: :timestamp, type: :date_time },
|
|
299
|
+
3 => { name: :activity_type },
|
|
300
|
+
4 => { name: :activity_subtype },
|
|
301
|
+
5 => { name: :activity_level },
|
|
302
|
+
24 => { name: :distance, scale: 100 },
|
|
303
|
+
25 => { name: :calories },
|
|
304
|
+
26 => { name: :active_time }
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
72 => {
|
|
308
|
+
name: :training_file,
|
|
309
|
+
fields: {
|
|
310
|
+
0 => { name: :timestamp, type: :date_time },
|
|
311
|
+
1 => { name: :type, enum: :file },
|
|
312
|
+
2 => { name: :manufacturer, enum: :manufacturer },
|
|
313
|
+
3 => { name: :product },
|
|
314
|
+
4 => { name: :serial_number },
|
|
315
|
+
5 => { name: :time_created, type: :date_time }
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
103 => {
|
|
319
|
+
name: :monitoring_info,
|
|
320
|
+
fields: {
|
|
321
|
+
253 => { name: :timestamp, type: :date_time },
|
|
322
|
+
0 => { name: :local_timestamp, type: :local_date_time },
|
|
323
|
+
1 => { name: :activity_type },
|
|
324
|
+
3 => { name: :cycles_to_distance, scale: 5000 },
|
|
325
|
+
4 => { name: :cycles_to_calories, scale: 5000 }
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
132 => {
|
|
329
|
+
name: :hr_zone,
|
|
330
|
+
fields: {
|
|
331
|
+
1 => { name: :high_bpm },
|
|
332
|
+
2 => { name: :name }
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
206 => {
|
|
336
|
+
name: :field_description,
|
|
337
|
+
fields: {
|
|
338
|
+
0 => { name: :developer_data_index },
|
|
339
|
+
1 => { name: :field_definition_number },
|
|
340
|
+
2 => { name: :fit_base_type_id },
|
|
341
|
+
3 => { name: :field_name },
|
|
342
|
+
8 => { name: :native_field_num },
|
|
343
|
+
14 => { name: :units }
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
207 => {
|
|
347
|
+
name: :developer_data_id,
|
|
348
|
+
fields: {
|
|
349
|
+
0 => { name: :developer_id },
|
|
350
|
+
1 => { name: :application_id },
|
|
351
|
+
2 => { name: :manufacturer_id, enum: :manufacturer },
|
|
352
|
+
3 => { name: :developer_data_index },
|
|
353
|
+
4 => { name: :application_version }
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}.freeze
|
|
357
|
+
|
|
358
|
+
def self.message_profile(global_message_number)
|
|
359
|
+
MESSAGE_PROFILES[global_message_number] || { name: :"message_#{global_message_number}", fields: {} }
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def self.enum_mapping(type)
|
|
363
|
+
ENUMS[type]
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
data/lib/fit_doctor.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fit_doctor/version"
|
|
4
|
+
require_relative "fit_doctor/errors"
|
|
5
|
+
require_relative "fit_doctor/configuration"
|
|
6
|
+
require_relative "fit_doctor/models/message"
|
|
7
|
+
require_relative "fit_doctor/profile"
|
|
8
|
+
require_relative "fit_doctor/parser"
|
|
9
|
+
require_relative "constans/events/verification"
|
|
10
|
+
require_relative "avo/concerns/events/verification"
|
|
11
|
+
|
|
12
|
+
module FitDoctor
|
|
13
|
+
class << self
|
|
14
|
+
def configure
|
|
15
|
+
yield(configuration)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def configuration
|
|
19
|
+
@configuration ||= Configuration.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def reset_configuration!
|
|
23
|
+
@configuration = Configuration.new
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: fit_doctor
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Poltrax
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rspec
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.13'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.13'
|
|
26
|
+
description: FIT Doctor parses Garmin FIT binary files and exposes typed message collections
|
|
27
|
+
with automatic unit conversions.
|
|
28
|
+
executables: []
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files: []
|
|
31
|
+
files:
|
|
32
|
+
- LICENSE.txt
|
|
33
|
+
- README.md
|
|
34
|
+
- lib/avo/concerns/events/verification.rb
|
|
35
|
+
- lib/constans/events/verification.rb
|
|
36
|
+
- lib/fit_doctor.rb
|
|
37
|
+
- lib/fit_doctor/configuration.rb
|
|
38
|
+
- lib/fit_doctor/errors.rb
|
|
39
|
+
- lib/fit_doctor/models/message.rb
|
|
40
|
+
- lib/fit_doctor/parser.rb
|
|
41
|
+
- lib/fit_doctor/profile.rb
|
|
42
|
+
- lib/fit_doctor/version.rb
|
|
43
|
+
homepage: https://github.com/Poltrax-live/fit-doctor
|
|
44
|
+
licenses:
|
|
45
|
+
- MIT
|
|
46
|
+
metadata: {}
|
|
47
|
+
rdoc_options: []
|
|
48
|
+
require_paths:
|
|
49
|
+
- lib
|
|
50
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.0'
|
|
55
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '0'
|
|
60
|
+
requirements: []
|
|
61
|
+
rubygems_version: 3.6.9
|
|
62
|
+
specification_version: 4
|
|
63
|
+
summary: Parse FIT files into Ruby objects
|
|
64
|
+
test_files: []
|