git-fit 0.25.1 → 0.26.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 +4 -4
- data/db/migrations/002_add_device_app_lineage.rb +29 -0
- data/lib/git-fit.rb +34 -0
- data/lib/git_fit/export/json.rb +3 -0
- data/lib/git_fit/fit/decoder.js +1 -1
- data/lib/git_fit/fit/decoder.rb +9 -0
- data/lib/git_fit/import/apple_health.rb +2 -1
- data/lib/git_fit/source/base.rb +8 -0
- data/lib/git_fit/sync/base.rb +8 -0
- data/lib/git_fit/sync/garmin_base.rb +37 -0
- data/lib/git_fit/sync/strava.rb +15 -0
- data/lib/git_fit/sync/xingzhe.rb +8 -0
- data/lib/git_fit/version.rb +1 -1
- metadata +2 -1
data/lib/git_fit/fit/decoder.rb
CHANGED
|
@@ -30,6 +30,15 @@ module GitFit
|
|
|
30
30
|
json = CTX.eval("FitDecoder.fitSession(#{bytes.inspect})")
|
|
31
31
|
json ? JSON.parse(json) : nil
|
|
32
32
|
end
|
|
33
|
+
|
|
34
|
+
# device_info messages (manufacturer/product/serial/sw-hw versions);
|
|
35
|
+
# `garminProduct` resolves to the model token (e.g. "edge540") when the
|
|
36
|
+
# bundled profile knows the product id, else stays numeric.
|
|
37
|
+
def self.device_info(path)
|
|
38
|
+
bytes = File.read(path, mode: 'rb').bytes
|
|
39
|
+
json = CTX.eval("FitDecoder.fitDeviceInfo(#{bytes.inspect})")
|
|
40
|
+
JSON.parse(json)
|
|
41
|
+
end
|
|
33
42
|
end
|
|
34
43
|
end
|
|
35
44
|
end
|
|
@@ -433,7 +433,7 @@ module GitFit
|
|
|
433
433
|
nil
|
|
434
434
|
end
|
|
435
435
|
|
|
436
|
-
def build_attrs(
|
|
436
|
+
def build_attrs(source_name, run_id, stats, meta, polyline,
|
|
437
437
|
workout_type, start_date, end_date, duration, duration_unit,
|
|
438
438
|
hr_avg: nil, hr_max: nil, elevation: {})
|
|
439
439
|
distance = nil
|
|
@@ -477,6 +477,7 @@ module GitFit
|
|
|
477
477
|
steps: steps_from_stats(stats),
|
|
478
478
|
average_temperature: parse_temperature(meta['HKWeatherTemperature']),
|
|
479
479
|
source: 'apple_health',
|
|
480
|
+
app: source_name,
|
|
480
481
|
external_id: meta['HKExternalUUID'],
|
|
481
482
|
}
|
|
482
483
|
end
|
data/lib/git_fit/source/base.rb
CHANGED
|
@@ -136,6 +136,8 @@ module GitFit
|
|
|
136
136
|
attrs['summary_polyline'] = @privacy.filter(attrs['summary_polyline'])
|
|
137
137
|
end
|
|
138
138
|
|
|
139
|
+
apply_lineage!(attrs)
|
|
140
|
+
|
|
139
141
|
existing = @db[:activities].where(run_id: attrs['run_id']).first
|
|
140
142
|
if existing
|
|
141
143
|
attrs.delete('created_at')
|
|
@@ -148,6 +150,12 @@ module GitFit
|
|
|
148
150
|
end
|
|
149
151
|
end
|
|
150
152
|
|
|
153
|
+
def apply_lineage!(attrs)
|
|
154
|
+
attrs['device'] = nil if attrs['device'].to_s.empty?
|
|
155
|
+
attrs['app'] = GitFit.default_app(attrs['app'], attrs['source'])
|
|
156
|
+
attrs['lineage'] = GitFit.build_lineage(attrs['device'], attrs['app'], attrs['source'])
|
|
157
|
+
end
|
|
158
|
+
|
|
151
159
|
def skip_type?(sport_type)
|
|
152
160
|
return false unless @activity_filter
|
|
153
161
|
!@activity_filter.include?(sport_type.to_s.downcase)
|
data/lib/git_fit/sync/base.rb
CHANGED
|
@@ -201,6 +201,8 @@ module GitFit
|
|
|
201
201
|
attrs['summary_polyline'] = @privacy.filter(attrs['summary_polyline'])
|
|
202
202
|
end
|
|
203
203
|
|
|
204
|
+
apply_lineage!(attrs)
|
|
205
|
+
|
|
204
206
|
existing = @db[:activities].where(run_id: attrs['run_id']).first
|
|
205
207
|
if existing
|
|
206
208
|
attrs.delete('created_at')
|
|
@@ -213,6 +215,12 @@ module GitFit
|
|
|
213
215
|
end
|
|
214
216
|
end
|
|
215
217
|
|
|
218
|
+
def apply_lineage!(attrs)
|
|
219
|
+
attrs['device'] = nil if attrs['device'].to_s.empty?
|
|
220
|
+
attrs['app'] = GitFit.default_app(attrs['app'], attrs['source'])
|
|
221
|
+
attrs['lineage'] = GitFit.build_lineage(attrs['device'], attrs['app'], attrs['source'])
|
|
222
|
+
end
|
|
223
|
+
|
|
216
224
|
def skip_type?(sport_type)
|
|
217
225
|
return false unless @activity_filter
|
|
218
226
|
!@activity_filter.include?(sport_type.to_s.downcase)
|
|
@@ -141,6 +141,7 @@ module GitFit
|
|
|
141
141
|
end
|
|
142
142
|
|
|
143
143
|
attrs = build_attrs(details, act_id, type, polyline, l2_data: l2)
|
|
144
|
+
attrs[:device] = extract_device(act_id)
|
|
144
145
|
|
|
145
146
|
if (start_t = parse_garmin_time(details.dig('summaryDTO', 'startTimeGMT')))
|
|
146
147
|
local_t = resolve_local_time(start_t, act_id, polyline)
|
|
@@ -159,6 +160,22 @@ module GitFit
|
|
|
159
160
|
self.class.source_prefix
|
|
160
161
|
end
|
|
161
162
|
|
|
163
|
+
def self.format_garmin_device(token)
|
|
164
|
+
# garminProduct token (e.g. "edge540") or unresolved numeric pk →
|
|
165
|
+
# human label ("Garmin Edge 540" / "Garmin 3307")
|
|
166
|
+
return "Garmin #{token}" if token.is_a?(Integer)
|
|
167
|
+
body = token.to_s.tr('_', ' ').split.map { |w| format_garmin_word(w) }.join(' ')
|
|
168
|
+
body.empty? ? nil : "Garmin #{body}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def self.format_garmin_word(word)
|
|
172
|
+
match = word.match(/\A(\D+?)(\d.*)\z/)
|
|
173
|
+
return GARMIN_DEVICE_PREFIXES.fetch(word, word.capitalize) unless match
|
|
174
|
+
|
|
175
|
+
prefix = GARMIN_DEVICE_PREFIXES.fetch(match[1], match[1].capitalize)
|
|
176
|
+
"#{prefix} #{match[2].sub(/x\z/, 'X')}"
|
|
177
|
+
end
|
|
178
|
+
|
|
162
179
|
private
|
|
163
180
|
|
|
164
181
|
def access_token_valid?
|
|
@@ -670,6 +687,9 @@ module GitFit
|
|
|
670
687
|
|
|
671
688
|
FIT_EPOCH = 631_065_600
|
|
672
689
|
|
|
690
|
+
# Garmin FIT garminProduct token prefix → display prefix (unlisted → capitalize)
|
|
691
|
+
GARMIN_DEVICE_PREFIXES = { 'fr' => 'Forerunner', 'hrm' => 'HRM' }.freeze
|
|
692
|
+
|
|
673
693
|
def raw_path(platform_id)
|
|
674
694
|
File.join(raw_dir, platform_id, 'activity.fit')
|
|
675
695
|
end
|
|
@@ -754,6 +774,23 @@ module GitFit
|
|
|
754
774
|
false
|
|
755
775
|
end
|
|
756
776
|
|
|
777
|
+
# FIT device_info creator message → "Garmin Edge 540"-style device label;
|
|
778
|
+
# product ids missing from the bundled profile fall back to "Garmin <pk>"
|
|
779
|
+
# (workouts#38 — Connect deviceTypePk has no public lookup table)
|
|
780
|
+
def extract_device(activity_id)
|
|
781
|
+
fit_path = raw_path(activity_id)
|
|
782
|
+
return nil unless File.exist?(fit_path)
|
|
783
|
+
|
|
784
|
+
infos = FIT::Decoder.device_info(fit_path)
|
|
785
|
+
info = infos.find { |d| d['deviceIndex'] == 'creator' } || infos.first
|
|
786
|
+
return nil unless info && info['garminProduct']
|
|
787
|
+
|
|
788
|
+
self.class.format_garmin_device(info['garminProduct'])
|
|
789
|
+
rescue StandardError => e
|
|
790
|
+
warn "Garmin: device_info decode failed for #{activity_id}: #{e.message}"
|
|
791
|
+
nil
|
|
792
|
+
end
|
|
793
|
+
|
|
757
794
|
def fit_to_l2(activity_id)
|
|
758
795
|
fit_path = raw_path(activity_id)
|
|
759
796
|
return nil unless File.exist?(fit_path)
|
data/lib/git_fit/sync/strava.rb
CHANGED
|
@@ -10,6 +10,14 @@ module GitFit
|
|
|
10
10
|
class Strava < Base
|
|
11
11
|
BASE_URL = 'https://www.strava.com/api/v3'
|
|
12
12
|
|
|
13
|
+
# device_name → app prefix rules (workouts#37); unmatched/absent → Strava mobile
|
|
14
|
+
DEVICE_APP_RULES = [
|
|
15
|
+
[/\AXOSS/i, 'XOSS'],
|
|
16
|
+
[/\AiGPSPORT/i, 'iGPSPORT'],
|
|
17
|
+
[/\AGarmin/i, 'Garmin Connect'],
|
|
18
|
+
[/Apple Watch/i, 'Apple Watch'],
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
13
21
|
register_adapter
|
|
14
22
|
register_config :client_id, :client_secret, :refresh_token,
|
|
15
23
|
%w[web_auth jwt], %w[web_auth auth_seed]
|
|
@@ -47,6 +55,11 @@ module GitFit
|
|
|
47
55
|
result
|
|
48
56
|
end
|
|
49
57
|
|
|
58
|
+
def self.app_for_device(name)
|
|
59
|
+
return 'Strava' if name.nil? || name.to_s.empty?
|
|
60
|
+
DEVICE_APP_RULES.find { |re, _| re.match?(name) }&.last || 'Strava'
|
|
61
|
+
end
|
|
62
|
+
|
|
50
63
|
private
|
|
51
64
|
|
|
52
65
|
def refresh_access_token
|
|
@@ -88,6 +101,8 @@ module GitFit
|
|
|
88
101
|
|
|
89
102
|
attrs = build_attrs(act, type, polyline, external_id: external_id,
|
|
90
103
|
elevation_loss: l2 ? elevation_loss_from_stream(l2) : nil)
|
|
104
|
+
attrs[:device] = detail['device_name']
|
|
105
|
+
attrs[:app] = self.class.app_for_device(attrs[:device])
|
|
91
106
|
upsert_activity(attrs)
|
|
92
107
|
attrs
|
|
93
108
|
end
|
data/lib/git_fit/sync/xingzhe.rb
CHANGED
|
@@ -312,6 +312,7 @@ module GitFit
|
|
|
312
312
|
# Prefer workout from full_detail for attrs that benefit from richer data
|
|
313
313
|
if detail_workout
|
|
314
314
|
attrs[:elevation_max] ||= detail_workout['max_altitude']&.to_f&.nonzero?
|
|
315
|
+
attrs[:device] = device_from_workout(detail_workout)
|
|
315
316
|
# equipment_info / power / grade etc. are archived in detail.json for downstream use
|
|
316
317
|
end
|
|
317
318
|
end
|
|
@@ -351,6 +352,13 @@ module GitFit
|
|
|
351
352
|
attrs
|
|
352
353
|
end
|
|
353
354
|
|
|
355
|
+
# workout.equipment_info[0].name — e.g. "行者小G(Gen.2)"; phone-recorded
|
|
356
|
+
# workouts have product=0 / empty equipment_info → nil (workouts#39)
|
|
357
|
+
def device_from_workout(workout)
|
|
358
|
+
name = workout&.[]('equipment_info')&.first&.[]('name')
|
|
359
|
+
name.to_s.empty? ? nil : name
|
|
360
|
+
end
|
|
361
|
+
|
|
354
362
|
def derive_elevation_min(stream, detail_workout)
|
|
355
363
|
if stream && (alt = stream['altitude']) && alt.is_a?(Array) && alt.size >= 2
|
|
356
364
|
alts = alt.compact.map(&:to_f)
|
data/lib/git_fit/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: git-fit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.26.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lax
|
|
@@ -271,6 +271,7 @@ extensions: []
|
|
|
271
271
|
extra_rdoc_files: []
|
|
272
272
|
files:
|
|
273
273
|
- db/migrations/001_full_schema.rb
|
|
274
|
+
- db/migrations/002_add_device_app_lineage.rb
|
|
274
275
|
- exe/git-fit
|
|
275
276
|
- lib/git-fit.rb
|
|
276
277
|
- lib/git_fit/ability.rb
|