ruby_garmin_connect 0.1.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.
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module GarminConnect
6
+ # The main entry point for interacting with the Garmin Connect API.
7
+ #
8
+ # @example Basic usage
9
+ # client = GarminConnect::Client.new(email: "user@example.com", password: "secret")
10
+ # client.login
11
+ # puts client.daily_summary
12
+ #
13
+ # @example Resume from saved tokens
14
+ # client = GarminConnect::Client.new(token_dir: "~/.garminconnect")
15
+ # client.login
16
+ # puts client.heart_rates
17
+ #
18
+ # @example With MFA handler
19
+ # client = GarminConnect::Client.new(
20
+ # email: "user@example.com",
21
+ # password: "secret",
22
+ # mfa_handler: -> { print "MFA code: "; gets.chomp }
23
+ # )
24
+ # client.login
25
+ class Client
26
+ include API::User
27
+ include API::Health
28
+ include API::Activities
29
+ include API::BodyComposition
30
+ include API::Metrics
31
+ include API::Devices
32
+ include API::Badges
33
+ include API::Workouts
34
+ include API::Wellness
35
+
36
+ DEFAULT_TOKEN_DIR = File.expand_path("~/.garminconnect")
37
+
38
+ attr_reader :connection
39
+
40
+ # @param email [String, nil] Garmin account email
41
+ # @param password [String, nil] Garmin account password
42
+ # @param domain [String] "garmin.com" or "garmin.cn"
43
+ # @param token_dir [String, nil] directory for token persistence (default: ~/.garminconnect)
44
+ # @param token_string [String, nil] base64-encoded token string (alternative to token_dir)
45
+ # @param mfa_handler [Proc, nil] called when MFA is needed; must return the code
46
+ def initialize(email: nil, password: nil, domain: "garmin.com",
47
+ token_dir: DEFAULT_TOKEN_DIR, token_string: nil,
48
+ mfa_handler: nil)
49
+ @email = email
50
+ @password = password
51
+ @domain = domain
52
+ @token_dir = token_dir ? File.expand_path(token_dir) : nil
53
+ @token_string = token_string
54
+ @mfa_handler = mfa_handler
55
+
56
+ @display_name = nil
57
+ @full_name = nil
58
+ @unit_system = nil
59
+ @user_profile_pk = nil
60
+ end
61
+
62
+ # Authenticate with Garmin Connect.
63
+ # Attempts to resume from saved tokens first, falling back to a fresh login.
64
+ #
65
+ # @return [self]
66
+ def login
67
+ if resume_from_tokens
68
+ setup_user_info
69
+ elsif @email && @password
70
+ fresh_login
71
+ else
72
+ raise AuthenticationError, "No credentials or saved tokens available"
73
+ end
74
+
75
+ self
76
+ end
77
+
78
+ # Check if the client is authenticated with valid tokens.
79
+ def authenticated?
80
+ @connection && !@oauth2_token.nil?
81
+ end
82
+
83
+ # Save the current tokens to the configured token directory.
84
+ # @param directory [String, nil] override the default token directory
85
+ def save_tokens(directory = @token_dir)
86
+ raise Error, "No tokens to save" unless @oauth1_token && @oauth2_token
87
+
88
+ Auth::TokenStore.save(
89
+ directory || DEFAULT_TOKEN_DIR,
90
+ oauth1_token: @oauth1_token,
91
+ oauth2_token: @oauth2_token
92
+ )
93
+ end
94
+
95
+ # Serialize tokens to a base64-encoded string.
96
+ # @return [String]
97
+ def dump_tokens
98
+ raise Error, "No tokens to dump" unless @oauth1_token && @oauth2_token
99
+
100
+ Auth::TokenStore.dumps(oauth1_token: @oauth1_token, oauth2_token: @oauth2_token)
101
+ end
102
+
103
+ # Log out (clears in-memory tokens).
104
+ def logout
105
+ @oauth1_token = nil
106
+ @oauth2_token = nil
107
+ @connection = nil
108
+ @display_name = nil
109
+ @full_name = nil
110
+ @unit_system = nil
111
+ @user_profile_pk = nil
112
+ end
113
+
114
+ private
115
+
116
+ def resume_from_tokens
117
+ oauth1, oauth2 = load_tokens
118
+ return false unless oauth1 && oauth2
119
+
120
+ @oauth1_token = oauth1
121
+ @oauth2_token = oauth2
122
+ @domain = oauth1.domain || @domain
123
+ build_connection
124
+
125
+ true
126
+ rescue GarminConnect::Error
127
+ false
128
+ end
129
+
130
+ def load_tokens
131
+ if @token_string
132
+ Auth::TokenStore.loads(@token_string)
133
+ elsif @token_dir && Dir.exist?(@token_dir)
134
+ Auth::TokenStore.load(@token_dir)
135
+ end
136
+ end
137
+
138
+ def fresh_login
139
+ @oauth1_token, @oauth2_token = Auth::SSO.login(
140
+ email: @email,
141
+ password: @password,
142
+ domain: @domain,
143
+ mfa_handler: @mfa_handler
144
+ )
145
+
146
+ build_connection
147
+ save_tokens if @token_dir
148
+ setup_user_info
149
+ end
150
+
151
+ def build_connection
152
+ @connection = Connection.new(
153
+ oauth1_token: @oauth1_token,
154
+ oauth2_token: @oauth2_token,
155
+ domain: @domain,
156
+ token_dir: @token_dir
157
+ )
158
+ end
159
+
160
+ def setup_user_info
161
+ settings = user_settings
162
+ @unit_system = settings&.dig("userData", "measurementSystem")
163
+
164
+ profile = connection.get("/userprofile-service/userprofile/profile")
165
+ @display_name = profile&.dig("displayName")
166
+ @full_name = profile&.dig("fullName")
167
+ @user_profile_pk = profile&.dig("profileId") || settings&.dig("id")
168
+ rescue HTTPError
169
+ # Non-fatal: display_name may not be available
170
+ end
171
+
172
+ # Exposed for API modules that need it.
173
+ def user_profile_pk
174
+ @user_profile_pk
175
+ end
176
+
177
+ # --- Date helpers ---
178
+
179
+ def today
180
+ Date.today.strftime("%Y-%m-%d")
181
+ end
182
+
183
+ def format_date(date)
184
+ case date
185
+ when Date, Time, DateTime
186
+ date.strftime("%Y-%m-%d")
187
+ when String
188
+ date
189
+ else
190
+ date.to_s
191
+ end
192
+ end
193
+
194
+ # Auto-chunk large date ranges into smaller windows.
195
+ # @param start_date [Date, String]
196
+ # @param end_date [Date, String]
197
+ # @param max_days [Integer] maximum days per chunk
198
+ def chunked_request(start_date, end_date, max_days)
199
+ start_d = start_date.is_a?(Date) ? start_date : Date.parse(start_date.to_s)
200
+ end_d = end_date.is_a?(Date) ? end_date : Date.parse(end_date.to_s)
201
+ results = []
202
+
203
+ while start_d <= end_d
204
+ chunk_end = [start_d + max_days - 1, end_d].min
205
+ chunk = yield(start_d, chunk_end)
206
+ results.concat(Array(chunk))
207
+ start_d = chunk_end + 1
208
+ end
209
+
210
+ results
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+
7
+ module GarminConnect
8
+ # Handles authenticated HTTP requests to the Garmin Connect API.
9
+ # Automatically refreshes expired OAuth2 tokens using the OAuth1 token.
10
+ class Connection
11
+ API_BASE = "https://connectapi.garmin.com"
12
+ USER_AGENT = "GCM-iOS-5.19.1.2"
13
+
14
+ RETRY_OPTIONS = {
15
+ max: 3,
16
+ interval: 0.5,
17
+ backoff_factor: 2,
18
+ retry_statuses: [408, 500, 502, 503, 504]
19
+ }.freeze
20
+
21
+ attr_reader :domain
22
+
23
+ def initialize(oauth1_token:, oauth2_token:, domain: "garmin.com", token_dir: nil)
24
+ @oauth1_token = oauth1_token
25
+ @oauth2_token = oauth2_token
26
+ @domain = domain || "garmin.com"
27
+ @token_dir = token_dir
28
+ end
29
+
30
+ # Make an authenticated GET request.
31
+ def get(path, params: {})
32
+ request(:get, path, params: params)
33
+ end
34
+
35
+ # Make an authenticated POST request with a JSON body.
36
+ def post(path, body: nil, params: {})
37
+ request(:post, path, body: body, params: params)
38
+ end
39
+
40
+ # Make an authenticated PUT request with a JSON body.
41
+ def put(path, body: nil, params: {})
42
+ request(:put, path, body: body, params: params)
43
+ end
44
+
45
+ # Make an authenticated DELETE request.
46
+ def delete(path, params: {})
47
+ request(:delete, path, params: params)
48
+ end
49
+
50
+ # Download raw bytes (for FIT/GPX/TCX file downloads).
51
+ def download(path, params: {})
52
+ ensure_valid_token!
53
+ resp = connection.get(path) do |req|
54
+ req.headers["Authorization"] = @oauth2_token.authorization_header
55
+ req.params = params unless params.empty?
56
+ end
57
+ handle_errors!(resp)
58
+ resp.body
59
+ end
60
+
61
+ # Upload a file (multipart form).
62
+ def upload(path, file_path:, file_name: nil)
63
+ ensure_valid_token!
64
+ name = file_name || File.basename(file_path)
65
+ payload = { file: Faraday::Multipart::FilePart.new(file_path, nil, name) }
66
+
67
+ resp = upload_connection.post(path) do |req|
68
+ req.headers["Authorization"] = @oauth2_token.authorization_header
69
+ req.body = payload
70
+ end
71
+ handle_errors!(resp)
72
+ parse_response(resp)
73
+ end
74
+
75
+ private
76
+
77
+ def request(method, path, body: nil, params: {})
78
+ ensure_valid_token!
79
+
80
+ resp = connection.public_send(method, path) do |req|
81
+ req.headers["Authorization"] = @oauth2_token.authorization_header
82
+ req.params = params unless params.empty?
83
+
84
+ if body && %i[post put].include?(method)
85
+ req.headers["Content-Type"] = "application/json"
86
+ req.body = body.is_a?(String) ? body : JSON.generate(body)
87
+ end
88
+ end
89
+
90
+ handle_errors!(resp)
91
+ parse_response(resp)
92
+ end
93
+
94
+ def ensure_valid_token!
95
+ return unless @oauth2_token.expired?
96
+
97
+ @oauth2_token = Auth::SSO.refresh(@oauth1_token, domain: @domain)
98
+ Auth::TokenStore.save_oauth2(@token_dir, oauth2_token: @oauth2_token) if @token_dir
99
+ end
100
+
101
+ def connection
102
+ @connection ||= Faraday.new(url: api_base) do |f|
103
+ f.request :retry, **RETRY_OPTIONS
104
+ f.adapter Faraday.default_adapter
105
+ end.tap do |conn|
106
+ conn.headers["User-Agent"] = USER_AGENT
107
+ conn.headers["Accept"] = "application/json"
108
+ end
109
+ end
110
+
111
+ def upload_connection
112
+ @upload_connection ||= Faraday.new(url: api_base) do |f|
113
+ f.request :multipart
114
+ f.request :retry, **RETRY_OPTIONS
115
+ f.adapter Faraday.default_adapter
116
+ end.tap do |conn|
117
+ conn.headers["User-Agent"] = USER_AGENT
118
+ end
119
+ end
120
+
121
+ def api_base
122
+ "https://connectapi.#{@domain}"
123
+ end
124
+
125
+ def parse_response(resp)
126
+ return nil if resp.status == 204
127
+ return resp.body if resp.body.nil? || resp.body.empty?
128
+
129
+ JSON.parse(resp.body)
130
+ rescue JSON::ParserError
131
+ resp.body
132
+ end
133
+
134
+ def handle_errors!(resp)
135
+ return if resp.success?
136
+
137
+ error_class = HTTP_ERRORS.fetch(resp.status) do
138
+ resp.status >= 500 ? ServerError : HTTPError
139
+ end
140
+
141
+ raise error_class.new(
142
+ "Garmin API error: HTTP #{resp.status}",
143
+ status: resp.status,
144
+ body: resp.body
145
+ )
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GarminConnect
4
+ class Error < StandardError; end
5
+
6
+ # Authentication & login errors
7
+ class AuthenticationError < Error; end
8
+ class LoginError < AuthenticationError; end
9
+ class MFARequiredError < AuthenticationError
10
+ attr_reader :mfa_html
11
+
12
+ def initialize(message = "MFA code required", mfa_html: nil)
13
+ @mfa_html = mfa_html
14
+ super(message)
15
+ end
16
+ end
17
+
18
+ class TokenExpiredError < AuthenticationError; end
19
+
20
+ # HTTP response errors
21
+ class HTTPError < Error
22
+ attr_reader :status, :body
23
+
24
+ def initialize(message = nil, status: nil, body: nil)
25
+ @status = status
26
+ @body = body
27
+ super(message || "HTTP #{status}")
28
+ end
29
+ end
30
+
31
+ class BadRequestError < HTTPError; end
32
+ class UnauthorizedError < HTTPError; end
33
+ class ForbiddenError < HTTPError; end
34
+ class NotFoundError < HTTPError; end
35
+ class TooManyRequestsError < HTTPError; end
36
+ class ServerError < HTTPError; end
37
+
38
+ # Maps HTTP status codes to error classes
39
+ HTTP_ERRORS = {
40
+ 400 => BadRequestError,
41
+ 401 => UnauthorizedError,
42
+ 403 => ForbiddenError,
43
+ 404 => NotFoundError,
44
+ 429 => TooManyRequestsError
45
+ }.freeze
46
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GarminConnect
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "garmin_connect/version"
4
+ require_relative "garmin_connect/errors"
5
+ require_relative "garmin_connect/auth/oauth1_token"
6
+ require_relative "garmin_connect/auth/oauth2_token"
7
+ require_relative "garmin_connect/auth/token_store"
8
+ require_relative "garmin_connect/auth/sso"
9
+ require_relative "garmin_connect/connection"
10
+ require_relative "garmin_connect/api/user"
11
+ require_relative "garmin_connect/api/health"
12
+ require_relative "garmin_connect/api/activities"
13
+ require_relative "garmin_connect/api/body_composition"
14
+ require_relative "garmin_connect/api/metrics"
15
+ require_relative "garmin_connect/api/devices"
16
+ require_relative "garmin_connect/api/badges"
17
+ require_relative "garmin_connect/api/workouts"
18
+ require_relative "garmin_connect/api/wellness"
19
+ require_relative "garmin_connect/client"
20
+
21
+ module GarminConnect
22
+ class << self
23
+ # Convenience method to create and login a client in one step.
24
+ #
25
+ # @example
26
+ # client = GarminConnect.login(email: "user@example.com", password: "secret")
27
+ # client.daily_summary
28
+ #
29
+ # @example Resume from tokens
30
+ # client = GarminConnect.login(token_dir: "~/.garminconnect")
31
+ def login(**options)
32
+ Client.new(**options).login
33
+ end
34
+ end
35
+ end
metadata ADDED
@@ -0,0 +1,136 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_garmin_connect
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - DRBragg
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday-cookie_jar
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.0.7
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.0.7
40
+ - !ruby/object:Gem::Dependency
41
+ name: faraday-follow_redirects
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.3'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.3'
54
+ - !ruby/object:Gem::Dependency
55
+ name: faraday-retry
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: oauth
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.1'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.1'
82
+ description: A comprehensive Ruby wrapper for the Garmin Connect API, providing access
83
+ to health, fitness, activity, and device data.
84
+ email:
85
+ - drbragg@gmail.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - CHANGELOG.md
91
+ - LICENSE
92
+ - README.md
93
+ - lib/garmin_connect.rb
94
+ - lib/garmin_connect/api/activities.rb
95
+ - lib/garmin_connect/api/badges.rb
96
+ - lib/garmin_connect/api/body_composition.rb
97
+ - lib/garmin_connect/api/devices.rb
98
+ - lib/garmin_connect/api/health.rb
99
+ - lib/garmin_connect/api/metrics.rb
100
+ - lib/garmin_connect/api/user.rb
101
+ - lib/garmin_connect/api/wellness.rb
102
+ - lib/garmin_connect/api/workouts.rb
103
+ - lib/garmin_connect/auth/oauth1_token.rb
104
+ - lib/garmin_connect/auth/oauth2_token.rb
105
+ - lib/garmin_connect/auth/sso.rb
106
+ - lib/garmin_connect/auth/token_store.rb
107
+ - lib/garmin_connect/client.rb
108
+ - lib/garmin_connect/connection.rb
109
+ - lib/garmin_connect/errors.rb
110
+ - lib/garmin_connect/version.rb
111
+ homepage: https://github.com/drbragg/garmin_connect
112
+ licenses:
113
+ - MIT
114
+ metadata:
115
+ homepage_uri: https://github.com/drbragg/garmin_connect
116
+ source_code_uri: https://github.com/drbragg/garmin_connect
117
+ changelog_uri: https://github.com/drbragg/garmin_connect/blob/main/CHANGELOG.md
118
+ rubygems_mfa_required: 'true'
119
+ rdoc_options: []
120
+ require_paths:
121
+ - lib
122
+ required_ruby_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '3.1'
127
+ required_rubygems_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ requirements: []
133
+ rubygems_version: 3.6.9
134
+ specification_version: 4
135
+ summary: Ruby client for the Garmin Connect API
136
+ test_files: []