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,186 @@
1
+ require_relative "garmin_base"
2
+ require "open3"
3
+
4
+ module GitFit
5
+ module Sync
6
+ class GarminBaseDI < GarminBase
7
+ DI_CLIENT_IDS = %w[
8
+ GARMIN_CONNECT_MOBILE_ANDROID_DI_2025Q2
9
+ GARMIN_CONNECT_MOBILE_ANDROID_DI_2024Q4
10
+ GARMIN_CONNECT_MOBILE_ANDROID_DI
11
+ ].freeze
12
+
13
+ def authenticate
14
+ @oauth_consumer = fetch_oauth_consumer
15
+
16
+ if load_tokens
17
+ return true if access_token_valid?
18
+ begin
19
+ return true if refresh_token
20
+ rescue AuthError
21
+ remove_stale_tokens
22
+ end
23
+ end
24
+
25
+ if load_auth_seed
26
+ return true if access_token_valid?
27
+ begin
28
+ return true if refresh_token
29
+ rescue AuthError
30
+ @access_token = nil
31
+ end
32
+ end
33
+
34
+ puts "Garmin: no valid DI token found"
35
+ puts " First time: ./scripts/garmin_auth_local.sh"
36
+ puts " Refresh: ./scripts/garmin_auth_local.sh --sync"
37
+ false
38
+ end
39
+
40
+ def before_call
41
+ if @config["auth_seed"].to_s.empty? && !File.exist?(token_path)
42
+ puts "Garmin: auth_seed not configured and no cached token"
43
+ puts " Run: ./scripts/garmin_auth_local.sh"
44
+ return false
45
+ end
46
+ @start_time = Time.now
47
+ true
48
+ end
49
+
50
+ def reauth_hint
51
+ "Re-auth: ./scripts/garmin_auth_local.sh --sync"
52
+ end
53
+
54
+ private
55
+
56
+ def auth_base
57
+ "https://diauth.#{@domain}"
58
+ end
59
+
60
+ def load_tokens
61
+ path = token_path
62
+ return false unless File.exist?(path)
63
+
64
+ data = JSON.parse(File.read(path))
65
+ @access_token = data
66
+ true
67
+ rescue => e
68
+ puts "Failed to load Garmin tokens: #{e.message}"
69
+ false
70
+ end
71
+
72
+ def save_tokens
73
+ return unless @access_token&.dig("access_token")
74
+
75
+ path = token_path
76
+ FileUtils.mkdir_p(File.dirname(path))
77
+ File.write(path, JSON.pretty_generate(@access_token))
78
+ rescue => e
79
+ puts "Failed to save Garmin tokens: #{e.message}"
80
+ end
81
+
82
+ def remove_stale_tokens
83
+ path = token_path
84
+ File.delete(path) if File.exist?(path)
85
+ rescue => e
86
+ puts "Warning: failed to remove stale tokens: #{e.message}"
87
+ end
88
+
89
+ def load_auth_seed
90
+ seed = @config["auth_seed"]
91
+ return false unless seed && !seed.empty?
92
+
93
+ decoded = JSON.parse(Base64.strict_decode64(seed))
94
+ @access_token = decoded
95
+ save_tokens
96
+ true
97
+ rescue JSON::ParserError
98
+ puts "Garmin: auth_seed decode failed — invalid Base64 or JSON"
99
+ puts " Fix: ./scripts/garmin_auth_local.sh --sync"
100
+ false
101
+ rescue => e
102
+ puts "Garmin: auth_seed error: #{e.message}"
103
+ false
104
+ end
105
+
106
+ def refresh_token
107
+ return false unless @access_token&.dig("refresh_token")
108
+
109
+ client_id = @access_token["di_client_id"] || DI_CLIENT_IDS.first
110
+ basic = Base64.strict_encode64("#{client_id}:")
111
+
112
+ uri = URI("#{auth_base}/di-oauth2-service/oauth/token")
113
+ req = Net::HTTP::Post.new(uri)
114
+ req["Authorization"] = "Basic #{basic}"
115
+ req["Content-Type"] = "application/x-www-form-urlencoded"
116
+ req["Cache-Control"] = "no-cache"
117
+ req["User-Agent"] = "GCM-Android-5.23"
118
+ req.body = URI.encode_www_form(
119
+ grant_type: "refresh_token",
120
+ client_id: client_id,
121
+ refresh_token: @access_token["refresh_token"]
122
+ )
123
+
124
+ resp = retry_on_429 { send_http(uri, req) }
125
+ unless resp.code.to_i == 200
126
+ msg = "DI refresh error: #{resp.code}"
127
+ if resp.code.to_i == 400
128
+ msg += " — refresh_token expired across rotation"
129
+ msg += ", re-run: scripts/garmin_auth_local.sh"
130
+ elsif resp.code.to_i == 401
131
+ msg += " — DI client_id rotated by Garmin"
132
+ msg += ", re-run: scripts/garmin_auth_local.sh"
133
+ end
134
+ raise AuthError, msg
135
+ end
136
+
137
+ result = JSON.parse(resp.body)
138
+ result["expires_at"] = Time.now.to_i + (result["expires_in"] || 64800).to_i
139
+ if result["refresh_token_expires_in"]
140
+ result["refresh_token_expires_at"] = Time.now.to_i + result["refresh_token_expires_in"].to_i
141
+ end
142
+ @access_token = result
143
+ save_tokens
144
+ true
145
+ end
146
+
147
+ # @deprecated Kept for local script reference only.
148
+ # email+password SSO strategies are not called by `authenticate`
149
+ # because garmin.com DI auth requires MFA, handled by
150
+ # scripts/garmin_auth_local.sh outside Ruby.
151
+ #
152
+ # Note: method naming (strategy_a = curl_cffi, strategy_b = Playwright) is
153
+ # historical. In scripts/garmin_auth_local.sh, Playwright runs first (cookies
154
+ # persist 365d → no repeat MFA), curl_cffi is the fallback.
155
+ def strategy_a_login
156
+ script = File.expand_path("../../../scripts/garmin_auth.py", __dir__)
157
+ return nil unless File.exist?(script)
158
+
159
+ stdout, stderr, status = Open3.capture3(
160
+ {"SYNC__GARMIN__EMAIL" => @email, "SYNC__GARMIN__PASSWORD" => @password, "SYNC__GARMIN__DOMAIN" => @domain},
161
+ "python3", script
162
+ )
163
+ warn stderr unless stderr.empty?
164
+ JSON.parse(stdout) if status.success?
165
+ rescue => e
166
+ puts "Strategy A (curl_cffi) failed: #{e.message}"
167
+ nil
168
+ end
169
+
170
+ def strategy_b_login
171
+ script = File.expand_path("../../../scripts/garmin_auth_playwright.py", __dir__)
172
+ return nil unless File.exist?(script)
173
+
174
+ stdout, stderr, status = Open3.capture3(
175
+ {"SYNC__GARMIN__EMAIL" => @email, "SYNC__GARMIN__PASSWORD" => @password, "SYNC__GARMIN__DOMAIN" => @domain},
176
+ "python3", script
177
+ )
178
+ warn stderr unless stderr.empty?
179
+ JSON.parse(stdout) if status.success?
180
+ rescue => e
181
+ puts "Strategy B (Playwright) failed: #{e.message}"
182
+ nil
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,29 @@
1
+ require_relative "garmin_base"
2
+
3
+ module GitFit
4
+ module Sync
5
+ class GarminCN < GarminBase
6
+ register_adapter
7
+ register_config :email, :password, :secret
8
+ config_key "garmin_cn"
9
+
10
+ def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
11
+ super
12
+ @domain = "garmin.cn"
13
+ @ssl_verify = config.key?("ssl_verify") ? config["ssl_verify"] : false
14
+ end
15
+
16
+ def raw_dir
17
+ File.join("data", "raw", "garmin_cn")
18
+ end
19
+
20
+ def std_dir
21
+ File.join("data", "std", "garmin_cn")
22
+ end
23
+
24
+ def source_prefix
25
+ "garmin_cn"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,225 @@
1
+ require_relative "base"
2
+ require "faraday"
3
+
4
+ module GitFit
5
+ module Sync
6
+ class IGPSPORT < Base
7
+ BASE_URL = "https://prod.zh.igpsport.com/service"
8
+ LOGIN_URL = "#{BASE_URL}/auth/account/login"
9
+ QUERY_URL = "#{BASE_URL}/web-gateway/web-analyze/activity/queryMyActivity"
10
+ DOWNLOAD_URL = "#{BASE_URL}/web-gateway/web-analyze/activity/getDownloadUrl"
11
+
12
+ EXERCISE_MAP = {
13
+ 0 => "ride", 1 => "run", 2 => "hike", 3 => "walk",
14
+ 4 => "swim", 5 => "ski", 6 => "workout"
15
+ }.freeze
16
+
17
+ RAW_EXT = "fit"
18
+
19
+ register_adapter
20
+ register_config :phone, :password
21
+
22
+ def initialize(...)
23
+ super
24
+ @phone = @config["phone"]
25
+ @password = @config["password"]
26
+ end
27
+
28
+ def before_call
29
+ return false if @phone.nil? || @password.nil?
30
+ super
31
+ end
32
+
33
+ def authenticate
34
+ login
35
+ end
36
+
37
+ def pending_ids
38
+ existing = existing_run_ids
39
+ ids = []
40
+ page = 1
41
+ loop do
42
+ acts = fetch_page(page)
43
+ break if acts.nil? || acts.empty?
44
+ acts.each do |act|
45
+ ride_id = act["rideId"].to_s
46
+ next if existing.include?(ride_id) && raw_file_exists?(ride_id)
47
+ type = map_sport(act["exerciseType"])
48
+ next if skip_type?(type)
49
+ ids << { id: ride_id, type: type, summary: act }
50
+ end
51
+ page += 1
52
+ end
53
+ ids
54
+ end
55
+
56
+ def process_one(pending)
57
+ sync_activity(pending[:summary], pending[:id])
58
+ end
59
+
60
+ private
61
+
62
+ def login
63
+ resp = Faraday.post(LOGIN_URL,
64
+ JSON.generate({ appId: "igpsport-web", username: @phone, password: @password }),
65
+ { "Content-Type" => "application/json" }
66
+ )
67
+ return false unless resp.status == 200
68
+ data = JSON.parse(resp.body)
69
+ @token = data.dig("data", "access_token")
70
+ return false unless @token
71
+ @auth_headers = { "Authorization" => "Bearer #{@token}" }
72
+ true
73
+ end
74
+
75
+ def sync_activities
76
+ pending_ids.filter_map { |p| process_one(p) }.size
77
+ end
78
+
79
+ def sync_activity(act, ride_id)
80
+ url = get_download_url(ride_id)
81
+ return false unless url
82
+
83
+ download_fit(url, ride_id)
84
+
85
+ fit_path = raw_path(ride_id)
86
+ return false unless File.exist?(fit_path)
87
+
88
+ wgs_points = FIT::Decoder.decode(fit_path)
89
+ return false if wgs_points.empty?
90
+
91
+ first_ts = wgs_points.first["timestamp"]
92
+ if first_ts
93
+ fit_epoch = Time.new(1989, 12, 31, 0, 0, 0, "+00:00").to_i
94
+ t = Time.at(fit_epoch + first_ts)
95
+ @fit_start_utc = t.utc
96
+ @fit_start_local = t.getlocal("+08:00")
97
+ end
98
+
99
+ write_standardized_json(wgs_points, ride_id)
100
+ polyline = Geo::Polyline.encode(wgs_points.map { |pt| [pt["positionLat"], pt["positionLong"]] })
101
+ sensor_summary = compute_sensor_summary(wgs_points)
102
+
103
+ attrs = build_attrs(act, ride_id, polyline, sensor_summary)
104
+ return false unless attrs
105
+
106
+ upsert_activity(attrs)
107
+ attrs
108
+ end
109
+
110
+ def get_download_url(ride_id)
111
+ resp = Faraday.get("#{DOWNLOAD_URL}/#{ride_id}", {}, @auth_headers)
112
+ return nil unless resp.status == 200
113
+ JSON.parse(resp.body)["data"]
114
+ end
115
+
116
+ def download_fit(url, ride_id)
117
+ resp = Faraday.get(url)
118
+ return false unless resp.status == 200
119
+
120
+ write_source_archive(resp.body, ride_id, RAW_EXT)
121
+ true
122
+ end
123
+
124
+ def fit_to_polyline(ride_id)
125
+ fit_path = raw_path(ride_id)
126
+ return nil unless File.exist?(fit_path)
127
+
128
+ wgs_points = FIT::Decoder.decode(fit_path)
129
+ return nil if wgs_points.empty?
130
+
131
+ Geo::Polyline.encode(wgs_points.map { |pt| [pt["positionLat"], pt["positionLong"]] })
132
+ end
133
+
134
+ def write_standardized_json(wgs_points, ride_id)
135
+ dir = std_dir
136
+ FileUtils.mkdir_p(dir)
137
+ tmp = File.join(dir, ".#{ride_id}.json.tmp")
138
+ final = File.join(dir, "#{ride_id}.json")
139
+ File.write(tmp, JSON.generate(wgs_points))
140
+ File.rename(tmp, final)
141
+ end
142
+
143
+ def fetch_page(page)
144
+ resp = Faraday.get(QUERY_URL,
145
+ { pageNo: page, pageSize: 20, sort: 1, reqType: 0 },
146
+ @auth_headers
147
+ )
148
+ return nil unless resp.status == 200
149
+ JSON.parse(resp.body).dig("data", "rows")
150
+ end
151
+
152
+ def build_attrs(act, ride_id, polyline = nil, sensor = {})
153
+ type = map_sport(act["exerciseType"])
154
+ start_t = @fit_start_utc || parse_date(act["startTime"])
155
+ start_t_local = @fit_start_local || start_t
156
+ distance = act["rideDistance"].to_f
157
+ moving_time = (act["totalMovingTime"] || act["recordTime"]).to_i
158
+ avg_speed = act["avgSpeed"].to_f
159
+
160
+ {
161
+ run_id: "igpsport_#{ride_id}",
162
+ name: act["title"] || "iGPSPORT Activity",
163
+ distance: distance,
164
+ moving_time: moving_time > 0 ? moving_time : nil,
165
+ elapsed_time: moving_time > 0 ? moving_time : nil,
166
+ sport_category: type,
167
+ sport_type: EXERCISE_MAP[act["exerciseType"]] || "ride",
168
+ start_date: start_t.iso8601,
169
+ start_date_local: start_t_local.iso8601,
170
+ location_country: nil,
171
+ average_heartrate: act["avgHrm"]&.to_f&.nonzero?,
172
+ max_heartrate: act["maxHrm"]&.to_f&.nonzero?,
173
+ average_cadence: sensor[:average_cadence],
174
+ max_cadence: sensor[:max_cadence],
175
+ average_power: sensor[:average_power],
176
+ max_power: sensor[:max_power],
177
+ calories: sensor[:calories],
178
+ average_temperature: sensor[:average_temperature],
179
+ average_speed: avg_speed > 0 ? avg_speed : nil,
180
+ elevation_gain: act["totalAscent"].to_f.nonzero?,
181
+ summary_polyline: polyline,
182
+ source: "igpsport"
183
+ }
184
+ end
185
+
186
+ def compute_sensor_summary(records)
187
+ return {} if records.empty?
188
+
189
+ cad = records.map { |r| r["cadence"] }.compact
190
+ pow = records.map { |r| r["power"] }.compact
191
+ tmp = records.map { |r| r["temperature"] }.compact
192
+
193
+ {
194
+ average_cadence: cad.any? ? (cad.sum.to_f / cad.size).round(1) : nil,
195
+ max_cadence: cad.max,
196
+ average_power: pow.any? ? (pow.sum.to_f / pow.size).round(1) : nil,
197
+ max_power: pow.max,
198
+ average_temperature: tmp.any? ? (tmp.sum.to_f / tmp.size).round(1) : nil,
199
+ calories: nil
200
+ }
201
+ end
202
+
203
+ def map_sport(exercise_type)
204
+ EXERCISE_MAP[exercise_type] || "other"
205
+ end
206
+
207
+ def parse_date(str)
208
+ return nil unless str
209
+ normalized = str.to_s.tr(".", "-")
210
+ Time.parse(normalized)
211
+ rescue
212
+ nil
213
+ end
214
+
215
+ def existing_run_ids
216
+ @db[:activities].where(source: "igpsport").select_map(:run_id)
217
+ .map { |id| id.sub("igpsport_", "") }
218
+ end
219
+
220
+ def raw_path(platform_id)
221
+ File.join(raw_dir, "#{platform_id}.#{RAW_EXT}")
222
+ end
223
+ end
224
+ end
225
+ end