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.
@@ -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(_source_name, run_id, stats, meta, polyline,
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
@@ -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)
@@ -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)
@@ -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
@@ -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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.25.1'
4
+ VERSION = '0.26.0'
5
5
  end
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.25.1
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