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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08c6d390104bc4f19e46f95d0726171890e2c0f3c41e165b1afca94031c0f243'
4
- data.tar.gz: 8225469c95d81af76180c1e6e1c4229f3caaa27cef9eb675e1c514ec208b1145
3
+ metadata.gz: 9e992aa41910f85ae0a2f2b656e09d28c529a82c44a0118a09a53765826d49ce
4
+ data.tar.gz: ee0d8526867d3f7d85c2742cfaa3ad36a73102d43be6eba2149b687719365885
5
5
  SHA512:
6
- metadata.gz: e3f740b9dda27508940450bacfe50f7ad74e060f2c9f97100500fcb1b200424cfb425efd0a187d61f5d55e03af04e3ada0b2029284a6542adbefb70f971c0ede
7
- data.tar.gz: 2609e8c7e033db637fa8358c409cda7d5d31c8018b6d43a7506a309140febaa90c7ce271a79600cdeea40b0a5f278304f7511ccb045cfe6b7b2ac706ada4e6e1
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.login(email: "you@example.com", password: "your-password")
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.login
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
- @display_name = profile&.dig("displayName")
166
- @full_name = profile&.dig("fullName")
167
- @user_profile_pk = profile&.dig("profileId") || settings&.dig("id")
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
- JSON.parse(resp.body)
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.body
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,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GarminConnect
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_garmin_connect
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - DRBragg