ruby_garmin_connect 0.1.0 → 0.2.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/CHANGELOG.md +36 -0
- data/README.md +4 -2
- data/lib/garmin_connect/api/body_composition.rb +37 -0
- data/lib/garmin_connect/api/metrics.rb +1 -1
- data/lib/garmin_connect/api/workouts.rb +81 -0
- data/lib/garmin_connect/client.rb +22 -3
- data/lib/garmin_connect/connection.rb +15 -3
- data/lib/garmin_connect/errors.rb +3 -0
- data/lib/garmin_connect/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9e992aa41910f85ae0a2f2b656e09d28c529a82c44a0118a09a53765826d49ce
|
|
4
|
+
data.tar.gz: ee0d8526867d3f7d85c2742cfaa3ad36a73102d43be6eba2149b687719365885
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d20db74f11c8102571dd3c08a3ffeec7e095075de9562035adbb7255d7d75a134d879e075010897557e143a2f28ab6392da82ecbedd2e86ffbebced25ae3e0ca
|
|
7
|
+
data.tar.gz: 8baf66019a7832c3d1579c9b164f01beef5a3271b91357120572b345cf7da0b71f331b7f295533bfcb8342b641794d6dc6cc63ae4ece8159d8efe9814a34c4b9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1 (2026-02-11)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `training_readiness` raising `TypeError: no implicit conversion of String into Integer` when Garmin returns a response with a UTF-8 BOM or unparseable JSON body
|
|
8
|
+
- `display_name` and `full_name` returning `nil` on login when the profile endpoint nests data under `socialProfile` or uses `userName` instead of `displayName`
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `ParseError` exception class — raised when a response claims `application/json` content-type but the body can't be parsed (instead of silently returning a raw string)
|
|
13
|
+
- UTF-8 BOM stripping in response parsing
|
|
14
|
+
- Fallback profile extraction: tries `displayName` → `socialProfile.displayName` → `userName`
|
|
15
|
+
|
|
16
|
+
### Improved
|
|
17
|
+
|
|
18
|
+
- Test coverage increased to 205 examples (up from 199)
|
|
19
|
+
|
|
20
|
+
## 0.2.0 (2026-02-11)
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `add_body_composition` - upload body scale data (body fat %, muscle mass, etc.) as a FIT file
|
|
25
|
+
- `add_weigh_in_with_timestamps` - add a weigh-in with explicit local and GMT timestamps
|
|
26
|
+
- `delete_weigh_ins` - batch delete all weigh-ins for a given date
|
|
27
|
+
- Typed workout creation helpers:
|
|
28
|
+
- `create_running_workout`
|
|
29
|
+
- `create_cycling_workout`
|
|
30
|
+
- `create_swimming_workout`
|
|
31
|
+
- `create_walking_workout`
|
|
32
|
+
- `create_hiking_workout`
|
|
33
|
+
|
|
34
|
+
### Improved
|
|
35
|
+
|
|
36
|
+
- Comprehensive test coverage for all 9 API modules (199 examples, up from 48)
|
|
37
|
+
- User, Health, Activities, Body Composition, Metrics, Devices, Badges, Workouts, Wellness
|
|
38
|
+
|
|
3
39
|
## 0.1.0 (2026-02-11)
|
|
4
40
|
|
|
5
41
|
- Initial release
|
data/README.md
CHANGED
|
@@ -24,10 +24,12 @@ gem install garmin_connect
|
|
|
24
24
|
require "garmin_connect"
|
|
25
25
|
|
|
26
26
|
# Login with credentials (tokens are saved to ~/.garminconnect automatically)
|
|
27
|
-
client = GarminConnect.
|
|
27
|
+
client = GarminConnect::Client.new(email: "you@example.com", password: "your-password")
|
|
28
|
+
client.login
|
|
28
29
|
|
|
29
30
|
# Subsequent sessions resume from saved tokens (no re-login for ~1 year)
|
|
30
|
-
client = GarminConnect.
|
|
31
|
+
client = GarminConnect::Client.new
|
|
32
|
+
client.login
|
|
31
33
|
|
|
32
34
|
# Get today's stats
|
|
33
35
|
puts client.daily_summary
|
|
@@ -54,6 +54,31 @@ module GarminConnect
|
|
|
54
54
|
)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Add a weigh-in with explicit timestamps.
|
|
58
|
+
# @param value [Float] weight value
|
|
59
|
+
# @param date_timestamp [String] local timestamp (e.g., "2026-02-11T08:30:00.000")
|
|
60
|
+
# @param gmt_timestamp [String] GMT timestamp (e.g., "2026-02-11T14:30:00.000")
|
|
61
|
+
# @param unit_key [String] "kg" or "lbs"
|
|
62
|
+
def add_weigh_in_with_timestamps(value, date_timestamp:, gmt_timestamp:, unit_key: "kg")
|
|
63
|
+
connection.post(
|
|
64
|
+
"/weight-service/user-weight",
|
|
65
|
+
body: {
|
|
66
|
+
"dateTimestamp" => date_timestamp,
|
|
67
|
+
"gmtTimestamp" => gmt_timestamp,
|
|
68
|
+
"unitKey" => unit_key,
|
|
69
|
+
"sourceType" => "MANUAL",
|
|
70
|
+
"value" => value
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Upload body composition data as a FIT file.
|
|
76
|
+
# This is used for full body scale data (body fat %, muscle mass, bone mass, etc.).
|
|
77
|
+
# @param file_path [String] path to the FIT file containing body composition data
|
|
78
|
+
def add_body_composition(file_path)
|
|
79
|
+
connection.upload("/upload-service/upload", file_path: file_path)
|
|
80
|
+
end
|
|
81
|
+
|
|
57
82
|
# Delete a specific weigh-in.
|
|
58
83
|
# @param date [Date, String]
|
|
59
84
|
# @param weight_pk [String, Integer] the weigh-in version/pk
|
|
@@ -61,6 +86,18 @@ module GarminConnect
|
|
|
61
86
|
connection.delete("/weight-service/weight/#{format_date(date)}/byversion/#{weight_pk}")
|
|
62
87
|
end
|
|
63
88
|
|
|
89
|
+
# Delete all weigh-ins for a specific date.
|
|
90
|
+
# Fetches all weigh-ins for the day, then deletes each one.
|
|
91
|
+
# @param date [Date, String]
|
|
92
|
+
def delete_weigh_ins(date)
|
|
93
|
+
day_data = daily_weigh_ins(date)
|
|
94
|
+
entries = day_data&.dig("dateWeightList") || []
|
|
95
|
+
entries.each do |entry|
|
|
96
|
+
weight_pk = entry["version"] || entry["samplePk"]
|
|
97
|
+
delete_weigh_in(date, weight_pk) if weight_pk
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
64
101
|
# --- Hydration ---
|
|
65
102
|
|
|
66
103
|
# Get daily hydration data.
|
|
@@ -23,7 +23,7 @@ module GarminConnect
|
|
|
23
23
|
data = training_readiness(date)
|
|
24
24
|
return data unless data.is_a?(Array)
|
|
25
25
|
|
|
26
|
-
data.select { |entry| entry["calendarDate"] == format_date(date) }
|
|
26
|
+
data.select { |entry| entry.is_a?(Hash) && entry["calendarDate"] == format_date(date) }
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
# Get aggregated training status.
|
|
@@ -39,6 +39,48 @@ module GarminConnect
|
|
|
39
39
|
connection.get("/workout-service/schedule/#{scheduled_workout_id}")
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# --- Typed Workout Helpers ---
|
|
43
|
+
|
|
44
|
+
# Create a running workout.
|
|
45
|
+
# @param name [String] workout name
|
|
46
|
+
# @param steps [Array<Hash>] workout step definitions
|
|
47
|
+
# @param description [String, nil] optional description
|
|
48
|
+
def create_running_workout(name, steps: [], description: nil)
|
|
49
|
+
create_typed_workout(name, sport_type: running_sport_type, steps: steps, description: description)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Create a cycling workout.
|
|
53
|
+
# @param name [String] workout name
|
|
54
|
+
# @param steps [Array<Hash>] workout step definitions
|
|
55
|
+
# @param description [String, nil] optional description
|
|
56
|
+
def create_cycling_workout(name, steps: [], description: nil)
|
|
57
|
+
create_typed_workout(name, sport_type: cycling_sport_type, steps: steps, description: description)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Create a swimming workout.
|
|
61
|
+
# @param name [String] workout name
|
|
62
|
+
# @param steps [Array<Hash>] workout step definitions
|
|
63
|
+
# @param description [String, nil] optional description
|
|
64
|
+
def create_swimming_workout(name, steps: [], description: nil)
|
|
65
|
+
create_typed_workout(name, sport_type: swimming_sport_type, steps: steps, description: description)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Create a walking workout.
|
|
69
|
+
# @param name [String] workout name
|
|
70
|
+
# @param steps [Array<Hash>] workout step definitions
|
|
71
|
+
# @param description [String, nil] optional description
|
|
72
|
+
def create_walking_workout(name, steps: [], description: nil)
|
|
73
|
+
create_typed_workout(name, sport_type: walking_sport_type, steps: steps, description: description)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Create a hiking workout.
|
|
77
|
+
# @param name [String] workout name
|
|
78
|
+
# @param steps [Array<Hash>] workout step definitions
|
|
79
|
+
# @param description [String, nil] optional description
|
|
80
|
+
def create_hiking_workout(name, steps: [], description: nil)
|
|
81
|
+
create_typed_workout(name, sport_type: hiking_sport_type, steps: steps, description: description)
|
|
82
|
+
end
|
|
83
|
+
|
|
42
84
|
# --- Training Plans ---
|
|
43
85
|
|
|
44
86
|
# Get all available training plans.
|
|
@@ -57,6 +99,45 @@ module GarminConnect
|
|
|
57
99
|
def adaptive_training_plan(plan_id)
|
|
58
100
|
connection.get("/trainingplan-service/trainingplan/fbt-adaptive/#{plan_id}")
|
|
59
101
|
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def create_typed_workout(name, sport_type:, steps: [], description: nil)
|
|
106
|
+
payload = {
|
|
107
|
+
"workoutName" => name,
|
|
108
|
+
"sportType" => sport_type,
|
|
109
|
+
"workoutSegments" => [
|
|
110
|
+
{
|
|
111
|
+
"segmentOrder" => 1,
|
|
112
|
+
"sportType" => sport_type,
|
|
113
|
+
"workoutSteps" => steps
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
payload["description"] = description if description
|
|
118
|
+
|
|
119
|
+
create_workout(payload)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def running_sport_type
|
|
123
|
+
{ "sportTypeId" => 1, "sportTypeKey" => "running" }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def cycling_sport_type
|
|
127
|
+
{ "sportTypeId" => 2, "sportTypeKey" => "cycling" }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def swimming_sport_type
|
|
131
|
+
{ "sportTypeId" => 5, "sportTypeKey" => "swimming" }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def walking_sport_type
|
|
135
|
+
{ "sportTypeId" => 9, "sportTypeKey" => "walking" }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def hiking_sport_type
|
|
139
|
+
{ "sportTypeId" => 3, "sportTypeKey" => "hiking" }
|
|
140
|
+
end
|
|
60
141
|
end
|
|
61
142
|
end
|
|
62
143
|
end
|
|
@@ -162,13 +162,32 @@ module GarminConnect
|
|
|
162
162
|
@unit_system = settings&.dig("userData", "measurementSystem")
|
|
163
163
|
|
|
164
164
|
profile = connection.get("/userprofile-service/userprofile/profile")
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
@user_profile_pk = profile
|
|
165
|
+
extract_profile_info(profile)
|
|
166
|
+
|
|
167
|
+
@user_profile_pk = extract_profile_pk(profile, settings)
|
|
168
168
|
rescue HTTPError
|
|
169
169
|
# Non-fatal: display_name may not be available
|
|
170
170
|
end
|
|
171
171
|
|
|
172
|
+
def extract_profile_info(profile)
|
|
173
|
+
return unless profile.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
@display_name = profile.dig("displayName") ||
|
|
176
|
+
profile.dig("socialProfile", "displayName") ||
|
|
177
|
+
profile.dig("userName")
|
|
178
|
+
|
|
179
|
+
@full_name = profile.dig("fullName") ||
|
|
180
|
+
profile.dig("socialProfile", "fullName")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def extract_profile_pk(profile, settings)
|
|
184
|
+
return nil unless profile.is_a?(Hash) || settings.is_a?(Hash)
|
|
185
|
+
|
|
186
|
+
profile&.dig("profileId") ||
|
|
187
|
+
profile&.dig("socialProfile", "profileId") ||
|
|
188
|
+
settings&.dig("id")
|
|
189
|
+
end
|
|
190
|
+
|
|
172
191
|
# Exposed for API modules that need it.
|
|
173
192
|
def user_profile_pk
|
|
174
193
|
@user_profile_pk
|
|
@@ -124,11 +124,23 @@ module GarminConnect
|
|
|
124
124
|
|
|
125
125
|
def parse_response(resp)
|
|
126
126
|
return nil if resp.status == 204
|
|
127
|
-
return resp.body if resp.body.nil? || resp.body.empty?
|
|
128
127
|
|
|
129
|
-
|
|
128
|
+
body = resp.body
|
|
129
|
+
return body if body.nil? || body.empty?
|
|
130
|
+
|
|
131
|
+
# Strip UTF-8 BOM if present (some Garmin endpoints include it)
|
|
132
|
+
body = body.b.sub(/\A\xEF\xBB\xBF/n, "").force_encoding("UTF-8")
|
|
133
|
+
|
|
134
|
+
JSON.parse(body)
|
|
130
135
|
rescue JSON::ParserError
|
|
131
|
-
resp.
|
|
136
|
+
content_type = resp.headers["content-type"].to_s
|
|
137
|
+
# If the server said it was JSON but we can't parse it, raise rather than
|
|
138
|
+
# returning a raw string that callers will misuse.
|
|
139
|
+
if content_type.include?("application/json")
|
|
140
|
+
raise ParseError, "Failed to parse JSON response: #{body[0..200]}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
body
|
|
132
144
|
end
|
|
133
145
|
|
|
134
146
|
def handle_errors!(resp)
|
|
@@ -35,6 +35,9 @@ module GarminConnect
|
|
|
35
35
|
class TooManyRequestsError < HTTPError; end
|
|
36
36
|
class ServerError < HTTPError; end
|
|
37
37
|
|
|
38
|
+
# Response parsing errors
|
|
39
|
+
class ParseError < Error; end
|
|
40
|
+
|
|
38
41
|
# Maps HTTP status codes to error classes
|
|
39
42
|
HTTP_ERRORS = {
|
|
40
43
|
400 => BadRequestError,
|