git-fit 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,648 @@
1
+ require_relative "base"
2
+ require_relative "../fit/decoder"
3
+ require "ostruct"
4
+ require "base64"
5
+ require "oauth"
6
+ require "oauth/request_proxy/net_http"
7
+ require "json"
8
+ require "net/http"
9
+ require "uri"
10
+ require "cgi"
11
+ require "http/cookie_jar"
12
+ require "yaml"
13
+ require "fileutils"
14
+ require "zip"
15
+ require "tzinfo"
16
+
17
+ module GitFit
18
+ module Sync
19
+ class AuthError < StandardError; end
20
+ class SyncError < StandardError; end
21
+
22
+ class GarminBase < Base
23
+ CLIENT_ID = "GCM_ANDROID_DARK"
24
+ OAUTH_CONSUMER_URL = "https://thegarth.s3.amazonaws.com/oauth_consumer.json"
25
+
26
+ SSO_HEADERS = {
27
+ "User-Agent" => "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
28
+ "Accept" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
29
+ "Accept-Language" => "en-US,en;q=0.9"
30
+ }.freeze
31
+
32
+ def initialize(...)
33
+ super
34
+ @domain = "garmin.com"
35
+ @email = @config["email"]
36
+ @password = @config["password"]
37
+ @secret = @config["secret"]
38
+ @ssl_verify = @config.key?("ssl_verify") ? @config["ssl_verify"] : true
39
+ end
40
+
41
+ attr_reader :generated_secret
42
+
43
+ def before_call
44
+ return false if @email.to_s.empty? && @secret.to_s.empty?
45
+ super
46
+ end
47
+
48
+ def authenticate
49
+ @oauth_consumer = fetch_oauth_consumer
50
+
51
+ if @secret
52
+ if parse_secret_string(@secret)
53
+ return true if access_token_valid?
54
+ begin
55
+ return true if refresh_oauth2_via_oauth1
56
+ rescue AuthError
57
+ end
58
+ end
59
+ end
60
+
61
+ begin
62
+ return true if load_tokens && access_token_valid?
63
+ return true if load_tokens && refresh_oauth2_via_oauth1
64
+ rescue AuthError
65
+ remove_stale_tokens
66
+ end
67
+
68
+ return false if @email.to_s.empty? || @password.to_s.empty?
69
+
70
+ ticket = sso_login
71
+ @oauth1_token = fetch_oauth1_token(ticket)
72
+ @access_token = exchange_oauth2
73
+ @generated_secret = dump_secret
74
+ unless verify_access_token
75
+ @generated_secret = nil
76
+ raise AuthError, "New tokens failed API verify — SSO result invalid"
77
+ end
78
+ save_secret_to_config
79
+ save_tokens
80
+ true
81
+ end
82
+
83
+ def pending_ids
84
+ existing = existing_run_ids
85
+ ids = []
86
+ start = 0
87
+ limit = 100
88
+ loop do
89
+ activities = fetch_activity_list(start, limit)
90
+ break if activities.nil? || activities.empty?
91
+ activities.each do |act|
92
+ act_id = act["activityId"].to_s
93
+ type = map_type(act["activityType"])
94
+ next if skip_type?(type)
95
+ next if existing.include?(act_id) && raw_file_exists?(act_id)
96
+ ids << { id: act_id, type: type }
97
+ end
98
+ start += limit
99
+ end
100
+ ids
101
+ end
102
+
103
+ def process_one(pending)
104
+ act_id = pending[:id]
105
+ type = pending[:type]
106
+
107
+ details = fetch_activity_detail(act_id)
108
+ return nil unless details
109
+
110
+ polyline = fetch_summary_polyline(act_id)
111
+
112
+ unless raw_file_exists?(act_id)
113
+ download_fit_raw(act_id)
114
+ l2 = fit_to_l2(act_id)
115
+ write_standardized_json(l2, act_id) if l2
116
+ end
117
+
118
+ attrs = build_attrs(details, act_id, type, polyline)
119
+
120
+ if (start_t = parse_garmin_time(details.dig("summaryDTO", "startTimeGMT")))
121
+ local_t = resolve_local_time(start_t, act_id, polyline)
122
+ attrs[:start_date_local] = local_t.iso8601 if local_t
123
+ end
124
+
125
+ upsert_activity(attrs)
126
+ attrs
127
+ end
128
+
129
+ private
130
+
131
+ def access_token_valid?
132
+ return false unless @access_token
133
+
134
+ expires_at = @access_token["expires_at"] || @saved_expires_at
135
+ expires_at.nil? || Time.now.to_i < expires_at - 60
136
+ end
137
+
138
+ def parse_secret_string(secret)
139
+ decoded = JSON.parse(Base64.strict_decode64(secret))
140
+ o1 = decoded[0]
141
+ o2 = decoded[1]
142
+
143
+ secret_domain = o1["domain"]
144
+ @domain = secret_domain if secret_domain
145
+ @oauth1_token = OpenStruct.new(
146
+ oauth_token: o1["oauth_token"],
147
+ oauth_token_secret: o1["oauth_token_secret"],
148
+ mfa_token: o1["mfa_token"],
149
+ domain: @domain
150
+ )
151
+ @access_token = o2
152
+ true
153
+ rescue => e
154
+ puts "Garmin: secret decode failed: #{e.message}"
155
+ puts " #{reauth_hint}"
156
+ false
157
+ end
158
+
159
+ def dump_secret
160
+ o1 = {
161
+ oauth_token: @oauth1_token.oauth_token,
162
+ oauth_token_secret: @oauth1_token.oauth_token_secret,
163
+ mfa_token: @oauth1_token.mfa_token,
164
+ mfa_expiration_timestamp: nil,
165
+ domain: @oauth1_token.domain
166
+ }
167
+ JSON.generate([o1, @access_token]).then { |s| Base64.strict_encode64(s) }
168
+ end
169
+
170
+ def save_secret_to_config
171
+ path = "config/config.yml"
172
+ return unless File.exist?(path)
173
+
174
+ cfg = YAML.safe_load(File.read(path)) || {}
175
+ source = @domain == "garmin.cn" ? "garmin_cn" : "garmin"
176
+ cfg["sync"] ||= {}
177
+ cfg["sync"][source] ||= {}
178
+ cfg["sync"][source]["secret"] = @generated_secret
179
+ File.write(path, YAML.dump(cfg))
180
+ rescue => e
181
+ puts "Warning: failed to save secret to config.yml: #{e.message}"
182
+ end
183
+
184
+ def verify_access_token
185
+ result = api_get("/activitylist-service/activities/search/activities?start=0&limit=1")
186
+ !result.nil?
187
+ rescue => e
188
+ puts "Garmin: API verify failed — token invalid: #{e.message}"
189
+ false
190
+ end
191
+
192
+ def remove_stale_tokens
193
+ path = token_path
194
+ File.delete(path) if File.exist?(path)
195
+ rescue => e
196
+ puts "Warning: failed to remove stale tokens: #{e.message}"
197
+ end
198
+
199
+ def refresh_oauth2_via_oauth1
200
+ return false unless @oauth1_token&.oauth_token
201
+
202
+ @access_token = exchange_oauth2
203
+ @access_token["expires_at"] = Time.now.to_i + (@access_token["expires_in"] || 21600).to_i
204
+ save_tokens
205
+ true
206
+ rescue => e
207
+ raise AuthError, "Garmin OAuth refresh failed: #{e.message}" +
208
+ " — #{reauth_hint}"
209
+ end
210
+
211
+ def fetch_oauth_consumer
212
+ uri = URI(OAUTH_CONSUMER_URL)
213
+ resp = Net::HTTP.get_response(uri)
214
+ JSON.parse(resp.body)
215
+ end
216
+
217
+ def sso_login
218
+ jar = cookie_jar
219
+ sso_base = "https://sso.#{@domain}"
220
+
221
+ sso_get(sso_base, "/mobile/sso/en/sign-in", { clientId: CLIENT_ID }, jar)
222
+
223
+ service_url = "https://mobile.integration.#{@domain}/gcm/android"
224
+ login_params = { clientId: CLIENT_ID, locale: "en-US", service: service_url }
225
+ login_body = JSON.generate(
226
+ username: @email, password: @password,
227
+ rememberMe: false, captchaToken: ""
228
+ )
229
+ resp = sso_post(sso_base, "/mobile/api/login", login_params, login_body, jar)
230
+ body = JSON.parse(resp.body)
231
+ status = body.dig("responseStatus", "type")
232
+
233
+ if status == "MFA_REQUIRED"
234
+ mfa_method = body.dig("customerMfaInfo", "mfaLastMethodUsed") || "email"
235
+ print "MFA code sent via #{mfa_method}. Enter code: "
236
+ $stdout.flush
237
+ mfa_code = $stdin.gets.strip
238
+ mfa_body = JSON.generate(
239
+ mfaMethod: mfa_method,
240
+ mfaVerificationCode: mfa_code,
241
+ rememberMyBrowser: false,
242
+ reconsentList: [],
243
+ mfaSetup: false
244
+ )
245
+ resp = sso_post(sso_base, "/mobile/api/mfa/verifyCode", login_params, mfa_body, jar)
246
+ body = JSON.parse(resp.body)
247
+ status = body.dig("responseStatus", "type")
248
+ end
249
+
250
+ unless status == "SUCCESSFUL"
251
+ raise "Garmin login failed (#{body.dig("responseStatus", "message") || status})"
252
+ end
253
+
254
+ body["serviceTicketId"]
255
+ end
256
+
257
+ def sso_get(base, path, params, jar)
258
+ uri = URI("#{base}#{path}")
259
+ uri.query = URI.encode_www_form(params) if params
260
+ req = Net::HTTP::Get.new(uri)
261
+ SSO_HEADERS.each { |k, v| req[k] = v }
262
+ add_cookies(req, uri, jar)
263
+ resp = send_http(uri, req)
264
+ update_jar(jar, resp, uri)
265
+ resp
266
+ end
267
+
268
+ def sso_post(base, path, params, body, jar)
269
+ uri = URI("#{base}#{path}")
270
+ uri.query = URI.encode_www_form(params) if params
271
+ req = Net::HTTP::Post.new(uri)
272
+ SSO_HEADERS.each { |k, v| req[k] = v }
273
+ req["Content-Type"] = "application/json"
274
+ add_cookies(req, uri, jar)
275
+ req.body = body
276
+ resp = send_http(uri, req)
277
+ update_jar(jar, resp, uri)
278
+ resp
279
+ end
280
+
281
+ def cookie_jar
282
+ HTTP::CookieJar.new
283
+ end
284
+
285
+ def add_cookies(request, uri, jar)
286
+ cookies = jar.cookies(uri)
287
+ return if cookies.empty?
288
+ request["Cookie"] = HTTP::Cookie.cookie_value(cookies)
289
+ end
290
+
291
+ def update_jar(jar, response, uri)
292
+ sets = response.get_fields("Set-Cookie") || []
293
+ sets.each { |c| jar.parse(c, uri) }
294
+ end
295
+
296
+ def fetch_oauth1_token(ticket)
297
+ base_url = "https://connectapi.#{@domain}/oauth-service/oauth/"
298
+ login_url = "https://mobile.integration.#{@domain}/gcm/android"
299
+ url = "#{base_url}preauthorized?ticket=#{CGI.escape(ticket)}&login-url=#{CGI.escape(login_url)}&accepts-mfa-tokens=true"
300
+
301
+ oauth_consumer = OAuth::Consumer.new(
302
+ @oauth_consumer["consumer_key"], @oauth_consumer["consumer_secret"],
303
+ site: "https://connectapi.#{@domain}",
304
+ scheme: :header, signature_method: "HMAC-SHA1"
305
+ )
306
+
307
+ sleep(3)
308
+
309
+ req = Net::HTTP::Get.new(URI(url))
310
+ req["User-Agent"] = "com.garmin.android.apps.connectmobile"
311
+ oauth_consumer.sign!(req, nil)
312
+
313
+ resp = retry_on_429(3, 5) { send_http(URI(url), req) }
314
+ raise "OAuth1 error: #{resp.code}" unless resp.code.to_i == 200
315
+
316
+ parsed = CGI.parse(resp.body).transform_keys(&:to_s).transform_values(&:first)
317
+
318
+ OpenStruct.new(
319
+ oauth_token: parsed["oauth_token"],
320
+ oauth_token_secret: parsed["oauth_token_secret"],
321
+ mfa_token: parsed["mfa_token"],
322
+ domain: @domain
323
+ )
324
+ end
325
+
326
+ def exchange_oauth2
327
+ url = "https://connectapi.#{@domain}/oauth-service/oauth/exchange/user/2.0"
328
+
329
+ oauth_consumer = OAuth::Consumer.new(
330
+ @oauth_consumer["consumer_key"], @oauth_consumer["consumer_secret"],
331
+ site: "https://connectapi.#{@domain}",
332
+ scheme: :header, signature_method: "HMAC-SHA1"
333
+ )
334
+
335
+ token = OAuth::Token.new(@oauth1_token.oauth_token, @oauth1_token.oauth_token_secret)
336
+ req = Net::HTTP::Post.new(URI(url))
337
+ req["User-Agent"] = "com.garmin.android.apps.connectmobile"
338
+ req["Content-Type"] = "application/x-www-form-urlencoded"
339
+ body_params = { audience: "GARMIN_CONNECT_MOBILE_ANDROID_DI" }
340
+ body_params[:mfa_token] = @oauth1_token.mfa_token if @oauth1_token.mfa_token
341
+ req.body = URI.encode_www_form(body_params)
342
+ oauth_consumer.sign!(req, token)
343
+
344
+ resp = retry_on_429 { send_http(URI(url), req) }
345
+ raise "OAuth2 exchange error: #{resp.code}" unless resp.code.to_i == 200
346
+
347
+ result = JSON.parse(resp.body)
348
+ result["expires_at"] = Time.now.to_i + (result["expires_in"] || 21600).to_i
349
+ result
350
+ end
351
+
352
+ def sync_activities
353
+ @start_time = Time.now
354
+ pending_ids.filter_map { |p| process_one(p) }.size
355
+ end
356
+
357
+ def existing_run_ids
358
+ @db[:activities].where(source: source_prefix).select_map(:run_id)
359
+ .map { |id| id.sub("#{source_prefix}_", "") }
360
+ end
361
+
362
+ def fetch_activity_list(start, limit)
363
+ api_get("/activitylist-service/activities/search/activities?start=#{start}&limit=#{limit}")
364
+ end
365
+
366
+ def fetch_activity_detail(activity_id)
367
+ api_get("/activity-service/activity/#{activity_id}")
368
+ end
369
+
370
+ def api_get(path)
371
+ uri = URI("#{connectapi_base}#{path}")
372
+ req = Net::HTTP::Get.new(uri)
373
+ req["Authorization"] = "Bearer #{@access_token["access_token"]}"
374
+ req["User-Agent"] = "GCM-iOS-5.22.1.4"
375
+ resp = retry_on_429 { send_http(uri, req) }
376
+ return nil unless resp.code.to_i == 200
377
+ JSON.parse(resp.body)
378
+ end
379
+
380
+ def connectapi_base
381
+ @connectapi_base ||= "https://connectapi.#{@domain}"
382
+ end
383
+
384
+ def send_http(uri, request)
385
+ http = Net::HTTP.new(uri.host, uri.port)
386
+ http.use_ssl = true
387
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE unless @ssl_verify
388
+ http.open_timeout = 30
389
+ http.read_timeout = 60
390
+ http.start { |h| h.request(request) }
391
+ end
392
+
393
+ def retry_on_429(max_retries = 3, base_delay = 2)
394
+ retries = 0
395
+ loop do
396
+ resp = yield
397
+ return resp unless resp.code.to_i == 429 && retries < max_retries
398
+
399
+ retries += 1
400
+ sleep(base_delay * (2 ** retries))
401
+ end
402
+ end
403
+
404
+ def tz_resolver
405
+ @tz_resolver ||= GitFit::Timezone::Resolver.new({})
406
+ end
407
+
408
+ def resolve_local_time(start_utc, activity_id, polyline = nil)
409
+ lat = lng = nil
410
+
411
+ lat, lng = first_gps_from_l2(activity_id)
412
+
413
+ if lat.nil? && polyline
414
+ decoded = GitFit::Geo::Polyline.decode(polyline) rescue nil
415
+ if decoded && decoded.any?
416
+ lat, lng = decoded.first
417
+ end
418
+ end
419
+
420
+ tz_string = tz_resolver.resolve(lat, lng)
421
+ tz = TZInfo::Timezone.get(tz_string)
422
+ tz.utc_to_local(start_utc)
423
+ rescue => e
424
+ warn "Garmin: timezone resolution failed for #{activity_id}: #{e.message}"
425
+ nil
426
+ end
427
+
428
+ def first_gps_from_l2(activity_id)
429
+ l2_path = std_path(activity_id)
430
+ return [nil, nil] unless File.exist?(l2_path)
431
+
432
+ l2 = JSON.parse(File.read(l2_path))
433
+ return [nil, nil] unless l2.is_a?(Array) && l2.any?
434
+
435
+ first = l2.find { |r|
436
+ lat = r["positionLat"]
437
+ lng = r["positionLong"]
438
+ lat && lng && lat.to_f != 0.0 && lng.to_f != 0.0
439
+ }
440
+ return [nil, nil] unless first
441
+
442
+ [first["positionLat"].to_f, first["positionLong"].to_f]
443
+ rescue JSON::ParserError
444
+ [nil, nil]
445
+ end
446
+
447
+ def source_prefix
448
+ "garmin"
449
+ end
450
+
451
+ def reauth_hint
452
+ "Re-auth: set sync.#{source_prefix}.secret in config/config.yml"
453
+ end
454
+
455
+ def build_attrs(detail, activity_id, type, polyline = nil)
456
+ summary = detail["summaryDTO"] || {}
457
+
458
+ start_t = parse_garmin_time(summary["startTimeGMT"])
459
+ moving_time = summary["movingDuration"]&.to_i || summary["duration"]&.to_i
460
+ elapsed_time = summary["elapsedDuration"]&.to_i || summary["duration"]&.to_i
461
+
462
+ {
463
+ run_id: "#{source_prefix}_#{activity_id}",
464
+ name: detail["activityName"] || "Garmin Activity",
465
+ distance: summary["distance"]&.to_f,
466
+ moving_time: moving_time,
467
+ elapsed_time: elapsed_time,
468
+ sport_category: type,
469
+ sport_type: type_str(detail["activityTypeDTO"]),
470
+ start_date: start_t&.utc&.iso8601,
471
+ start_date_local: start_t&.iso8601,
472
+ location_country: detail["locationName"],
473
+ summary_polyline: polyline,
474
+ average_heartrate: summary["averageHR"]&.to_f,
475
+ max_heartrate: summary["maxHR"]&.to_f,
476
+ average_cadence: summary["avgCadence"]&.to_f,
477
+ max_cadence: summary["maxCadence"]&.to_f,
478
+ average_power: summary["avgPower"]&.to_f,
479
+ max_power: summary["maxPower"]&.to_f,
480
+ calories: summary["calories"]&.to_i,
481
+ average_temperature: summary["avgTemperature"]&.to_f,
482
+ average_speed: summary["averageSpeed"]&.to_f,
483
+ elevation_gain: summary["elevationGain"]&.to_f,
484
+ source: source_prefix
485
+ }
486
+ end
487
+
488
+ def parse_garmin_time(time_str)
489
+ return nil unless time_str
490
+ t = Time.parse(time_str)
491
+ t.utc? ? t : t.utc
492
+ rescue
493
+ nil
494
+ end
495
+
496
+ def type_str(val)
497
+ return "" unless val
498
+ val.is_a?(Hash) ? (val["typeKey"] || "").downcase : val.to_s.downcase
499
+ end
500
+
501
+ def map_type(activity_type)
502
+ ts = type_str(activity_type)
503
+ return "other" if ts.empty?
504
+ case ts
505
+ when /running/ then "run"
506
+ when /cycling/, /biking/ then "ride"
507
+ when /hiking/ then "hike"
508
+ when /walking/ then "walk"
509
+ when /swimming/ then "swim"
510
+ when /skiing/, /snowboard/ then "ski"
511
+ when /workout/, /strength/, /fitness/ then "workout"
512
+ else "other"
513
+ end
514
+ end
515
+
516
+ def save_tokens
517
+ path = token_path
518
+ dir = File.dirname(path)
519
+ FileUtils.mkdir_p(dir) unless dir == "."
520
+ File.write(path, JSON.pretty_generate({
521
+ oauth1: {
522
+ oauth_token: @oauth1_token.oauth_token,
523
+ oauth_token_secret: @oauth1_token.oauth_token_secret,
524
+ mfa_token: @oauth1_token.mfa_token,
525
+ domain: @oauth1_token.domain || @domain
526
+ },
527
+ oauth2: @access_token
528
+ }))
529
+ rescue => e
530
+ puts "Failed to save Garmin tokens: #{e.message}"
531
+ end
532
+
533
+ def load_tokens
534
+ path = token_path
535
+ return false unless File.exist?(path)
536
+
537
+ data = JSON.parse(File.read(path))
538
+ o1 = data["oauth1"]
539
+ saved_domain = o1["domain"]
540
+ @domain = saved_domain if saved_domain
541
+ @oauth1_token = OpenStruct.new(
542
+ oauth_token: o1["oauth_token"],
543
+ oauth_token_secret: o1["oauth_token_secret"],
544
+ mfa_token: o1["mfa_token"],
545
+ domain: @domain
546
+ )
547
+ @access_token = data["oauth2"]
548
+ @saved_expires_at = @access_token&.dig("expires_at")
549
+ true
550
+ rescue => e
551
+ puts "Failed to load Garmin tokens: #{e.message}"
552
+ false
553
+ end
554
+
555
+ def token_path
556
+ @config["token_path"] || File.join("data", "auth", "garmin_#{@domain.tr(".", "_")}_tokens.json")
557
+ end
558
+
559
+ FIT_EPOCH = 631_065_600
560
+
561
+ def raw_path(platform_id)
562
+ File.join(raw_dir, "#{platform_id}.fit")
563
+ end
564
+
565
+ def fetch_summary_polyline(activity_id)
566
+ gps = api_get("/activity-service/activity/#{activity_id}/details")
567
+ polyline = gps&.dig("geoPolylineDTO", "polyline")
568
+
569
+ if polyline.is_a?(Array)
570
+ points = polyline.map { |pt| [pt["lat"], pt["lon"]] }
571
+ GitFit::Geo::Polyline.encode(points) if points.size >= 2
572
+ else
573
+ polyline
574
+ end
575
+ rescue => e
576
+ puts "Warning: failed to encode polyline for #{activity_id}: #{e.message}"
577
+ nil
578
+ end
579
+
580
+ def download_fit_raw(activity_id)
581
+ uri = URI("#{connectapi_base}/download-service/files/activity/#{activity_id}")
582
+ req = Net::HTTP::Get.new(uri)
583
+ req["Authorization"] = "Bearer #{@access_token.fetch("access_token")}"
584
+ req["User-Agent"] = "GCM-iOS-5.22.1.4"
585
+ resp = retry_on_429 { send_http(uri, req) }
586
+ return false unless resp.code.to_i == 200
587
+
588
+ zip_data = resp.body
589
+ fit_data = nil
590
+ Zip::File.open_buffer(zip_data) do |zip|
591
+ entry = zip.entries.first
592
+ fit_data = entry.get_input_stream.read if entry
593
+ end
594
+
595
+ return false unless fit_data
596
+
597
+ write_source_archive(fit_data, activity_id, "fit")
598
+ true
599
+ rescue => e
600
+ puts "Failed to download FIT for #{activity_id}: #{e.message}"
601
+ false
602
+ end
603
+
604
+ def fit_to_l2(activity_id)
605
+ fit_path = raw_path(activity_id)
606
+ return nil unless File.exist?(fit_path)
607
+
608
+ records = GitFit::FIT::Decoder.decode(fit_path)
609
+ return nil if records.empty?
610
+
611
+ records.map do |r|
612
+ lat = r["positionLat"]
613
+ lng = r["positionLong"]
614
+ next unless lat && lng && lat != 0.0 && lng != 0.0
615
+
616
+ ts = r["timestamp"]
617
+ abs_ts = ts ? Time.at(FIT_EPOCH + ts).utc.xmlschema : nil
618
+
619
+ {
620
+ positionLat: lat,
621
+ positionLong: lng,
622
+ altitude: r["altitude"],
623
+ heartRate: r["heartRate"],
624
+ cadence: r["cadence"],
625
+ power: r["power"],
626
+ speed: r["speed"],
627
+ timestamp: abs_ts,
628
+ distance: r["distance"],
629
+ temperature: r["temperature"]
630
+ }.compact
631
+ end.compact
632
+ rescue => e
633
+ puts "Failed to decode FIT for #{activity_id}: #{e.message}"
634
+ nil
635
+ end
636
+
637
+ def write_standardized_json(l2, activity_id)
638
+ return unless l2
639
+
640
+ dir = std_dir
641
+ FileUtils.mkdir_p(dir)
642
+ tmp = File.join(dir, ".#{activity_id}.json.tmp")
643
+ File.write(tmp, JSON.generate(l2))
644
+ File.rename(tmp, std_path(activity_id))
645
+ end
646
+ end
647
+ end
648
+ end