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.
- checksums.yaml +4 -4
- data/lib/git-fit.rb +10 -3
- data/lib/git_fit/cli/sync.rb +23 -34
- data/lib/git_fit/config.rb +9 -2
- data/lib/git_fit/config_template.rb +12 -0
- data/lib/git_fit/sync/base.rb +64 -1
- data/lib/git_fit/sync/garmin.rb +16 -0
- data/lib/git_fit/sync/garmin_base.rb +648 -0
- data/lib/git_fit/sync/garmin_base_di.rb +186 -0
- data/lib/git_fit/sync/garmin_cn.rb +29 -0
- data/lib/git_fit/sync/igpsport.rb +225 -0
- data/lib/git_fit/sync/keep.rb +499 -0
- data/lib/git_fit/sync/lorem.rb +1 -0
- data/lib/git_fit/sync/runner.rb +322 -0
- data/lib/git_fit/sync/strava.rb +187 -0
- data/lib/git_fit/sync/xingzhe.rb +262 -0
- data/lib/git_fit/sync/xoss.rb +369 -0
- data/lib/git_fit/version.rb +1 -1
- metadata +66 -1
|
@@ -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
|