garminconnect 0.2.2__py3-none-any.whl

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,934 @@
1
+ """Python 3 API wrapper for Garmin Connect to get your statistics."""
2
+
3
+ import logging
4
+ import os
5
+ from datetime import datetime
6
+ from enum import Enum, auto
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import garth
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Garmin:
15
+ """Class for fetching data from Garmin Connect."""
16
+
17
+ def __init__(self, email=None, password=None, is_cn=False):
18
+ """Create a new class instance."""
19
+ self.username = email
20
+ self.password = password
21
+ self.is_cn = is_cn
22
+
23
+ self.garmin_connect_devices_url = (
24
+ "/device-service/deviceregistration/devices"
25
+ )
26
+ self.garmin_connect_device_url = "/device-service/deviceservice"
27
+ self.garmin_connect_weight_url = "/weight-service"
28
+ self.garmin_connect_daily_summary_url = (
29
+ "/usersummary-service/usersummary/daily"
30
+ )
31
+ self.garmin_connect_metrics_url = (
32
+ "/metrics-service/metrics/maxmet/daily"
33
+ )
34
+ self.garmin_connect_daily_hydration_url = (
35
+ "/usersummary-service/usersummary/hydration/daily"
36
+ )
37
+ self.garmin_connect_daily_stats_steps_url = (
38
+ "/usersummary-service/stats/steps/daily"
39
+ )
40
+ self.garmin_connect_personal_record_url = (
41
+ "/personalrecord-service/personalrecord/prs"
42
+ )
43
+ self.garmin_connect_earned_badges_url = "/badge-service/badge/earned"
44
+ self.garmin_connect_adhoc_challenges_url = (
45
+ "/adhocchallenge-service/adHocChallenge/historical"
46
+ )
47
+ self.garmin_connect_badge_challenges_url = (
48
+ "/badgechallenge-service/badgeChallenge/completed"
49
+ )
50
+ self.garmin_connect_available_badge_challenges_url = (
51
+ "/badgechallenge-service/badgeChallenge/available"
52
+ )
53
+ self.garmin_connect_non_completed_badge_challenges_url = (
54
+ "/badgechallenge-service/badgeChallenge/non-completed"
55
+ )
56
+ self.garmin_connect_inprogress_virtual_challenges_url = (
57
+ "/badgechallenge-service/virtualChallenge/inProgress"
58
+ )
59
+ self.garmin_connect_daily_sleep_url = (
60
+ "/wellness-service/wellness/dailySleepData"
61
+ )
62
+ self.garmin_connect_daily_stress_url = (
63
+ "/wellness-service/wellness/dailyStress"
64
+ )
65
+ self.garmin_connect_hill_score_url = (
66
+ "/metrics-service/metrics/hillscore"
67
+ )
68
+
69
+ self.garmin_connect_daily_body_battery_url = (
70
+ "/wellness-service/wellness/bodyBattery/reports/daily"
71
+ )
72
+
73
+ self.garmin_connect_blood_pressure_endpoint = (
74
+ "/bloodpressure-service/bloodpressure/range"
75
+ )
76
+ self.garmin_connect_endurance_score_url = (
77
+ "/metrics-service/metrics/endurancescore"
78
+ )
79
+
80
+ self.garmin_connect_goals_url = "/goal-service/goal/goals"
81
+
82
+ self.garmin_connect_rhr_url = "/userstats-service/wellness/daily"
83
+
84
+ self.garmin_connect_hrv_url = "/hrv-service/hrv"
85
+
86
+ self.garmin_connect_training_readiness_url = (
87
+ "/metrics-service/metrics/trainingreadiness"
88
+ )
89
+
90
+ self.garmin_connect_training_status_url = (
91
+ "/metrics-service/metrics/trainingstatus/aggregated"
92
+ )
93
+
94
+ self.garmin_connect_user_summary_chart = (
95
+ "/wellness-service/wellness/dailySummaryChart"
96
+ )
97
+ self.garmin_connect_floors_chart_daily_url = (
98
+ "/wellness-service/wellness/floorsChartData/daily"
99
+ )
100
+ self.garmin_connect_heartrates_daily_url = (
101
+ "/wellness-service/wellness/dailyHeartRate"
102
+ )
103
+ self.garmin_connect_daily_respiration_url = (
104
+ "/wellness-service/wellness/daily/respiration"
105
+ )
106
+ self.garmin_connect_daily_spo2_url = (
107
+ "/wellness-service/wellness/daily/spo2"
108
+ )
109
+ self.garmin_connect_activities = (
110
+ "/activitylist-service/activities/search/activities"
111
+ )
112
+ self.garmin_connect_activity = "/activity-service/activity"
113
+ self.garmin_connect_activity_types = (
114
+ "/activity-service/activity/activityTypes"
115
+ )
116
+
117
+ self.garmin_connect_fitnessstats = "/fitnessstats-service/activity"
118
+
119
+ self.garmin_connect_fit_download = "/download-service/files/activity"
120
+ self.garmin_connect_tcx_download = (
121
+ "/download-service/export/tcx/activity"
122
+ )
123
+ self.garmin_connect_gpx_download = (
124
+ "/download-service/export/gpx/activity"
125
+ )
126
+ self.garmin_connect_kml_download = (
127
+ "/download-service/export/kml/activity"
128
+ )
129
+ self.garmin_connect_csv_download = (
130
+ "/download-service/export/csv/activity"
131
+ )
132
+
133
+ self.garmin_connect_upload = "/upload-service/upload"
134
+
135
+ self.garmin_connect_gear = "/gear-service/gear/filterGear"
136
+ self.garmin_connect_gear_baseurl = "/gear-service/gear/"
137
+
138
+ self.garmin_connect_logout = "auth/logout/?url="
139
+
140
+ self.garth = garth.Client(
141
+ domain="garmin.cn" if is_cn else "garmin.com"
142
+ )
143
+
144
+ self.display_name = None
145
+ self.full_name = None
146
+ self.unit_system = None
147
+
148
+ def connectapi(self, path, **kwargs):
149
+ return self.garth.connectapi(path, **kwargs)
150
+
151
+ def download(self, path, **kwargs):
152
+ return self.garth.download(path, **kwargs)
153
+
154
+ def login(self, /, tokenstore: Optional[str] = None):
155
+ """Log in using Garth."""
156
+ tokenstore = tokenstore or os.getenv("GARMINTOKENS")
157
+
158
+ if tokenstore:
159
+ self.garth.load(tokenstore)
160
+ else:
161
+ self.garth.login(self.username, self.password)
162
+
163
+ self.display_name = self.garth.profile["displayName"]
164
+ self.full_name = self.garth.profile["fullName"]
165
+
166
+ settings = self.garth.connectapi(
167
+ "/userprofile-service/userprofile/user-settings"
168
+ )
169
+ self.unit_system = settings["userData"]["measurementSystem"]
170
+
171
+ return True
172
+
173
+ def get_full_name(self):
174
+ """Return full name."""
175
+
176
+ return self.full_name
177
+
178
+ def get_unit_system(self):
179
+ """Return unit system."""
180
+
181
+ return self.unit_system
182
+
183
+ def get_stats(self, cdate: str) -> Dict[str, Any]:
184
+ """
185
+ Return user activity summary for 'cdate' format 'YYYY-MM-DD'
186
+ (compat for garminconnect).
187
+ """
188
+
189
+ return self.get_user_summary(cdate)
190
+
191
+ def get_user_summary(self, cdate: str) -> Dict[str, Any]:
192
+ """Return user activity summary for 'cdate' format 'YYYY-MM-DD'."""
193
+
194
+ url = f"{self.garmin_connect_daily_summary_url}/{self.display_name}"
195
+ params = {"calendarDate": str(cdate)}
196
+ logger.debug("Requesting user summary")
197
+
198
+ response = self.connectapi(url, params=params)
199
+
200
+ if response["privacyProtected"] is True:
201
+ raise GarminConnectAuthenticationError("Authentication error")
202
+
203
+ return response
204
+
205
+ def get_steps_data(self, cdate):
206
+ """Fetch available steps data 'cDate' format 'YYYY-MM-DD'."""
207
+
208
+ url = f"{self.garmin_connect_user_summary_chart}/{self.display_name}"
209
+ params = {"date": str(cdate)}
210
+ logger.debug("Requesting steps data")
211
+
212
+ return self.connectapi(url, params=params)
213
+
214
+ def get_floors(self, cdate):
215
+ """Fetch available floors data 'cDate' format 'YYYY-MM-DD'."""
216
+
217
+ url = f"{self.garmin_connect_floors_chart_daily_url}/{cdate}"
218
+ logger.debug("Requesting floors data")
219
+
220
+ return self.connectapi(url)
221
+
222
+ def get_daily_steps(self, start, end):
223
+ """Fetch available steps data 'start' and 'end' format 'YYYY-MM-DD'."""
224
+
225
+ url = f"{self.garmin_connect_daily_stats_steps_url}/{start}/{end}"
226
+ logger.debug("Requesting daily steps data")
227
+
228
+ return self.connectapi(url)
229
+
230
+ def get_heart_rates(self, cdate):
231
+ """Fetch available heart rates data 'cDate' format 'YYYY-MM-DD'."""
232
+
233
+ url = f"{self.garmin_connect_heartrates_daily_url}/{self.display_name}"
234
+ params = {"date": str(cdate)}
235
+ logger.debug("Requesting heart rates")
236
+
237
+ return self.connectapi(url, params=params)
238
+
239
+ def get_stats_and_body(self, cdate):
240
+ """Return activity data and body composition (compat for garminconnect)."""
241
+
242
+ return {
243
+ **self.get_stats(cdate),
244
+ **self.get_body_composition(cdate)["totalAverage"],
245
+ }
246
+
247
+ def get_body_composition(
248
+ self, startdate: str, enddate=None
249
+ ) -> Dict[str, Any]:
250
+ """
251
+ Return available body composition data for 'startdate' format
252
+ 'YYYY-MM-DD' through enddate 'YYYY-MM-DD'.
253
+ """
254
+
255
+ if enddate is None:
256
+ enddate = startdate
257
+ url = f"{self.garmin_connect_weight_url}/weight/dateRange"
258
+ params = {"startDate": str(startdate), "endDate": str(enddate)}
259
+ logger.debug("Requesting body composition")
260
+
261
+ return self.connectapi(url, params=params)
262
+
263
+ def add_weigh_in(
264
+ self, weight: int, unitKey: str = "kg", timestamp: str = ""
265
+ ):
266
+ """Add a weigh-in (default to kg)"""
267
+
268
+ url = f"{self.garmin_connect_weight_url}/user-weight"
269
+ dt = datetime.fromisoformat(timestamp) if timestamp else datetime.now()
270
+ # Apply timezone offset to get UTC/GMT time
271
+ dtGMT = dt - dt.astimezone().tzinfo.utcoffset(dt)
272
+ payload = {
273
+ "dateTimestamp": dt.isoformat()[:22] + ".00",
274
+ "gmtTimestamp": dtGMT.isoformat()[:22] + ".00",
275
+ "unitKey": unitKey,
276
+ "sourceType": "MANUAL",
277
+ "value": weight,
278
+ }
279
+ logger.debug("Adding weigh-in")
280
+
281
+ return self.garth.post("connectapi", url, json=payload)
282
+
283
+ def get_weigh_ins(self, startdate: str, enddate: str):
284
+ """Get weigh-ins between startdate and enddate using format 'YYYY-MM-DD'."""
285
+
286
+ url = f"{self.garmin_connect_weight_url}/weight/range/{startdate}/{enddate}"
287
+ params = {"includeAll": True}
288
+ logger.debug("Requesting weigh-ins")
289
+
290
+ return self.connectapi(url, params=params)
291
+
292
+ def get_daily_weigh_ins(self, cdate: str):
293
+ """Get weigh-ins for 'cdate' format 'YYYY-MM-DD'."""
294
+
295
+ url = f"{self.garmin_connect_weight_url}/weight/dayview/{cdate}"
296
+ params = {"includeAll": True}
297
+ logger.debug("Requesting weigh-ins")
298
+
299
+ return self.connectapi(url, params=params)
300
+
301
+ def delete_weigh_in(self, weight_pk: str, cdate: str):
302
+ """Delete specific weigh-in."""
303
+ url = f"{self.garmin_connect_weight_url}/weight/{cdate}/byversion/{weight_pk}"
304
+ logger.debug("Deleting weigh-in")
305
+
306
+ return self.garth.request(
307
+ "DELETE",
308
+ "connectapi",
309
+ url,
310
+ api=True,
311
+ )
312
+
313
+ # return self.garth.post(
314
+ # "connectapi", url, {"x-http-method-override": "DELETE"}
315
+ # )
316
+
317
+ def delete_weigh_ins(self, cdate: str, delete_all: bool = False):
318
+ """
319
+ Delete weigh-in for 'cdate' format 'YYYY-MM-DD'.
320
+ Includes option to delete all weigh-ins for that date.
321
+ """
322
+
323
+ daily_weigh_ins = self.get_daily_weigh_ins(cdate)
324
+ weigh_ins = daily_weigh_ins.get("dateWeightList", [])
325
+ if not weigh_ins or len(weigh_ins) == 0:
326
+ logger.warning(f"No weigh-ins found on {cdate}")
327
+ return
328
+ elif len(weigh_ins) > 1:
329
+ logger.warning(f"Multiple weigh-ins found for {cdate}")
330
+ if not delete_all:
331
+ logger.warning(
332
+ f"Set delete_all to True to delete all {len(weigh_ins)} weigh-ins"
333
+ )
334
+ return
335
+
336
+ for w in weigh_ins:
337
+ self.delete_weigh_in(w["samplePk"], cdate)
338
+
339
+ return len(weigh_ins)
340
+
341
+ def get_body_battery(
342
+ self, startdate: str, enddate=None
343
+ ) -> List[Dict[str, Any]]:
344
+ """
345
+ Return body battery values by day for 'startdate' format
346
+ 'YYYY-MM-DD' through enddate 'YYYY-MM-DD'
347
+ """
348
+
349
+ if enddate is None:
350
+ enddate = startdate
351
+ url = self.garmin_connect_daily_body_battery_url
352
+ params = {"startDate": str(startdate), "endDate": str(enddate)}
353
+ logger.debug("Requesting body battery data")
354
+
355
+ return self.connectapi(url, params=params)
356
+
357
+ def get_blood_pressure(
358
+ self, startdate: str, enddate=None
359
+ ) -> Dict[str, Any]:
360
+ """
361
+ Returns blood pressure by day for 'startdate' format
362
+ 'YYYY-MM-DD' through enddate 'YYYY-MM-DD'
363
+ """
364
+
365
+ if enddate is None:
366
+ enddate = startdate
367
+ url = f"{self.garmin_connect_blood_pressure_endpoint}/{startdate}/{enddate}"
368
+ params = {"includeAll": True}
369
+ logger.debug("Requesting blood pressure data")
370
+
371
+ return self.connectapi(url, params=params)
372
+
373
+ def get_max_metrics(self, cdate: str) -> Dict[str, Any]:
374
+ """Return available max metric data for 'cdate' format 'YYYY-MM-DD'."""
375
+
376
+ url = f"{self.garmin_connect_metrics_url}/{cdate}/{cdate}"
377
+ logger.debug("Requesting max metrics")
378
+
379
+ return self.connectapi(url)
380
+
381
+ def get_hydration_data(self, cdate: str) -> Dict[str, Any]:
382
+ """Return available hydration data 'cdate' format 'YYYY-MM-DD'."""
383
+
384
+ url = f"{self.garmin_connect_daily_hydration_url}/{cdate}"
385
+ logger.debug("Requesting hydration data")
386
+
387
+ return self.connectapi(url)
388
+
389
+ def get_respiration_data(self, cdate: str) -> Dict[str, Any]:
390
+ """Return available respiration data 'cdate' format 'YYYY-MM-DD'."""
391
+
392
+ url = f"{self.garmin_connect_daily_respiration_url}/{cdate}"
393
+ logger.debug("Requesting respiration data")
394
+
395
+ return self.connectapi(url)
396
+
397
+ def get_spo2_data(self, cdate: str) -> Dict[str, Any]:
398
+ """Return available SpO2 data 'cdate' format 'YYYY-MM-DD'."""
399
+
400
+ url = f"{self.garmin_connect_daily_spo2_url}/{cdate}"
401
+ logger.debug("Requesting SpO2 data")
402
+
403
+ return self.connectapi(url)
404
+
405
+ def get_personal_record(self) -> Dict[str, Any]:
406
+ """Return personal records for current user."""
407
+
408
+ url = f"{self.garmin_connect_personal_record_url}/{self.display_name}"
409
+ logger.debug("Requesting personal records for user")
410
+
411
+ return self.connectapi(url)
412
+
413
+ def get_earned_badges(self) -> Dict[str, Any]:
414
+ """Return earned badges for current user."""
415
+
416
+ url = self.garmin_connect_earned_badges_url
417
+ logger.debug("Requesting earned badges for user")
418
+
419
+ return self.connectapi(url)
420
+
421
+ def get_adhoc_challenges(self, start, limit) -> Dict[str, Any]:
422
+ """Return adhoc challenges for current user."""
423
+
424
+ url = self.garmin_connect_adhoc_challenges_url
425
+ params = {"start": str(start), "limit": str(limit)}
426
+ logger.debug("Requesting adhoc challenges for user")
427
+
428
+ return self.connectapi(url, params=params)
429
+
430
+ def get_badge_challenges(self, start, limit) -> Dict[str, Any]:
431
+ """Return badge challenges for current user."""
432
+
433
+ url = self.garmin_connect_badge_challenges_url
434
+ params = {"start": str(start), "limit": str(limit)}
435
+ logger.debug("Requesting badge challenges for user")
436
+
437
+ return self.connectapi(url, params=params)
438
+
439
+ def get_available_badge_challenges(self, start, limit) -> Dict[str, Any]:
440
+ """Return available badge challenges."""
441
+
442
+ url = self.garmin_connect_available_badge_challenges_url
443
+ params = {"start": str(start), "limit": str(limit)}
444
+ logger.debug("Requesting available badge challenges")
445
+
446
+ return self.connectapi(url, params=params)
447
+
448
+ def get_non_completed_badge_challenges(
449
+ self, start, limit
450
+ ) -> Dict[str, Any]:
451
+ """Return badge non-completed challenges for current user."""
452
+
453
+ url = self.garmin_connect_non_completed_badge_challenges_url
454
+ params = {"start": str(start), "limit": str(limit)}
455
+ logger.debug("Requesting badge challenges for user")
456
+
457
+ return self.connectapi(url, params=params)
458
+
459
+ def get_inprogress_virtual_challenges(
460
+ self, start, limit
461
+ ) -> Dict[str, Any]:
462
+ """Return in-progress virtual challenges for current user."""
463
+
464
+ url = self.garmin_connect_inprogress_virtual_challenges_url
465
+ params = {"start": str(start), "limit": str(limit)}
466
+ logger.debug("Requesting in-progress virtual challenges for user")
467
+
468
+ return self.connectapi(url, params=params)
469
+
470
+ def get_sleep_data(self, cdate: str) -> Dict[str, Any]:
471
+ """Return sleep data for current user."""
472
+
473
+ url = f"{self.garmin_connect_daily_sleep_url}/{self.display_name}"
474
+ params = {"date": str(cdate), "nonSleepBufferMinutes": 60}
475
+ logger.debug("Requesting sleep data")
476
+
477
+ return self.connectapi(url, params=params)
478
+
479
+ def get_stress_data(self, cdate: str) -> Dict[str, Any]:
480
+ """Return stress data for current user."""
481
+
482
+ url = f"{self.garmin_connect_daily_stress_url}/{cdate}"
483
+ logger.debug("Requesting stress data")
484
+
485
+ return self.connectapi(url)
486
+
487
+ def get_rhr_day(self, cdate: str) -> Dict[str, Any]:
488
+ """Return resting heartrate data for current user."""
489
+
490
+ url = f"{self.garmin_connect_rhr_url}/{self.display_name}"
491
+ params = {
492
+ "fromDate": str(cdate),
493
+ "untilDate": str(cdate),
494
+ "metricId": 60,
495
+ }
496
+ logger.debug("Requesting resting heartrate data")
497
+
498
+ return self.connectapi(url, params=params)
499
+
500
+ def get_hrv_data(self, cdate: str) -> Dict[str, Any]:
501
+ """Return Heart Rate Variability (hrv) data for current user."""
502
+
503
+ url = f"{self.garmin_connect_hrv_url}/{cdate}"
504
+ logger.debug("Requesting Heart Rate Variability (hrv) data")
505
+
506
+ return self.connectapi(url)
507
+
508
+ def get_training_readiness(self, cdate: str) -> Dict[str, Any]:
509
+ """Return training readiness data for current user."""
510
+
511
+ url = f"{self.garmin_connect_training_readiness_url}/{cdate}"
512
+ logger.debug("Requesting training readiness data")
513
+
514
+ return self.connectapi(url)
515
+
516
+ def get_endurance_score(self, startdate: str, enddate=None):
517
+ """
518
+ Return endurance score by day for 'startdate' format 'YYYY-MM-DD'
519
+ through enddate 'YYYY-MM-DD'.
520
+ Using a single day returns the precise values for that day.
521
+ Using a range returns the aggregated weekly values for that week.
522
+ """
523
+
524
+ if enddate is None:
525
+ url = self.garmin_connect_endurance_score_url
526
+ params = {"calendarDate": str(startdate)}
527
+ logger.debug("Requesting endurance score data for a single day")
528
+
529
+ return self.connectapi(url, params=params)
530
+ else:
531
+ url = f"{self.garmin_connect_endurance_score_url}/stats"
532
+ params = {
533
+ "startDate": str(startdate),
534
+ "endDate": str(enddate),
535
+ "aggregation": "weekly",
536
+ }
537
+ logger.debug("Requesting endurance score data for a range of days")
538
+
539
+ return self.connectapi(url, params=params)
540
+
541
+ def get_training_status(self, cdate: str) -> Dict[str, Any]:
542
+ """Return training status data for current user."""
543
+
544
+ url = f"{self.garmin_connect_training_status_url}/{cdate}"
545
+ logger.debug("Requesting training status data")
546
+
547
+ return self.connectapi(url)
548
+
549
+ def get_hill_score(self, startdate: str, enddate=None):
550
+ """
551
+ Return hill score by day from 'startdate' format 'YYYY-MM-DD'
552
+ to enddate 'YYYY-MM-DD'
553
+ """
554
+
555
+ if enddate is None:
556
+ url = self.garmin_connect_hill_score_url
557
+ params = {"calendarDate": str(startdate)}
558
+ logger.debug("Requesting hill score data for a single day")
559
+
560
+ return self.connectapi(url, params=params)
561
+
562
+ else:
563
+ url = f"{self.garmin_connect_hill_score_url}/stats"
564
+ params = {
565
+ "startDate": str(startdate),
566
+ "endDate": str(enddate),
567
+ "aggregation": "daily",
568
+ }
569
+ logger.debug("Requesting hill score data for a range of days")
570
+
571
+ return self.connectapi(url, params=params)
572
+
573
+ def get_devices(self) -> Dict[str, Any]:
574
+ """Return available devices for the current user account."""
575
+
576
+ url = self.garmin_connect_devices_url
577
+ logger.debug("Requesting devices")
578
+
579
+ return self.connectapi(url)
580
+
581
+ def get_device_settings(self, device_id: str) -> Dict[str, Any]:
582
+ """Return device settings for device with 'device_id'."""
583
+
584
+ url = f"{self.garmin_connect_device_url}/device-info/settings/{device_id}"
585
+ logger.debug("Requesting device settings")
586
+
587
+ return self.connectapi(url)
588
+
589
+ def get_device_alarms(self) -> Dict[str, Any]:
590
+ """Get list of active alarms from all devices."""
591
+
592
+ logger.debug("Requesting device alarms")
593
+
594
+ alarms = []
595
+ devices = self.get_devices()
596
+ for device in devices:
597
+ device_settings = self.get_device_settings(device["deviceId"])
598
+ device_alarms = device_settings["alarms"]
599
+ if device_alarms is not None:
600
+ alarms += device_alarms
601
+ return alarms
602
+
603
+ def get_device_last_used(self):
604
+ """Return device last used."""
605
+
606
+ url = f"{self.garmin_connect_device_url}/mylastused"
607
+ logger.debug("Requesting device last used")
608
+
609
+ return self.connectapi(url)
610
+
611
+ def get_activities(self, start, limit):
612
+ """Return available activities."""
613
+
614
+ url = self.garmin_connect_activities
615
+ params = {"start": str(start), "limit": str(limit)}
616
+ logger.debug("Requesting activities")
617
+
618
+ return self.connectapi(url, params=params)
619
+
620
+ def get_last_activity(self):
621
+ """Return last activity."""
622
+
623
+ activities = self.get_activities(0, 1)
624
+ if activities:
625
+ return activities[-1]
626
+
627
+ return None
628
+
629
+ def upload_activity(self, activity_path: str):
630
+ """Upload activity in fit format from file."""
631
+ # This code is borrowed from python-garminconnect-enhanced ;-)
632
+
633
+ file_base_name = os.path.basename(activity_path)
634
+ file_extension = file_base_name.split(".")[-1]
635
+ allowed_file_extension = (
636
+ file_extension.upper() in Garmin.ActivityUploadFormat.__members__
637
+ )
638
+
639
+ if allowed_file_extension:
640
+ files = {
641
+ "file": (file_base_name, open(activity_path, "rb" or "r")),
642
+ }
643
+ url = self.garmin_connect_upload
644
+ return self.garth.post("connectapi", url, files=files)
645
+ else:
646
+ raise GarminConnectInvalidFileFormatError(
647
+ f"Could not upload {activity_path}"
648
+ )
649
+
650
+ def get_activities_by_date(self, startdate, enddate, activitytype=None):
651
+ """
652
+ Fetch available activities between specific dates
653
+ :param startdate: String in the format YYYY-MM-DD
654
+ :param enddate: String in the format YYYY-MM-DD
655
+ :param activitytype: (Optional) Type of activity you are searching
656
+ Possible values are [cycling, running, swimming,
657
+ multi_sport, fitness_equipment, hiking, walking, other]
658
+ :return: list of JSON activities
659
+ """
660
+
661
+ activities = []
662
+ start = 0
663
+ limit = 20
664
+ # mimicking the behavior of the web interface that fetches
665
+ # 20 activities at a time
666
+ # and automatically loads more on scroll
667
+ url = self.garmin_connect_activities
668
+ params = {
669
+ "startDate": str(startdate),
670
+ "endDate": str(enddate),
671
+ "start": str(start),
672
+ "limit": str(limit),
673
+ }
674
+ if activitytype:
675
+ params["activityType"] = str(activitytype)
676
+
677
+ logger.debug(
678
+ f"Requesting activities by date from {startdate} to {enddate}"
679
+ )
680
+ while True:
681
+ params["start"] = str(start)
682
+ logger.debug(f"Requesting activities {start} to {start+limit}")
683
+ act = self.connectapi(url, params=params)
684
+ if act:
685
+ activities.extend(act)
686
+ start = start + limit
687
+ else:
688
+ break
689
+
690
+ return activities
691
+
692
+ def get_progress_summary_between_dates(
693
+ self, startdate, enddate, metric="distance"
694
+ ):
695
+ """
696
+ Fetch progress summary data between specific dates
697
+ :param startdate: String in the format YYYY-MM-DD
698
+ :param enddate: String in the format YYYY-MM-DD
699
+ :param metric: metric to be calculated in the summary:
700
+ "elevationGain", "duration", "distance", "movingDuration"
701
+ :return: list of JSON activities with their aggregated progress summary
702
+ """
703
+
704
+ url = self.garmin_connect_fitnessstats
705
+ params = {
706
+ "startDate": str(startdate),
707
+ "endDate": str(enddate),
708
+ "aggregation": "lifetime",
709
+ "groupByParentActivityType": "true",
710
+ "metric": str(metric),
711
+ }
712
+
713
+ logger.debug(
714
+ f"Requesting fitnessstats by date from {startdate} to {enddate}"
715
+ )
716
+ return self.connectapi(url, params=params)
717
+
718
+ def get_activity_types(self):
719
+ url = self.garmin_connect_activity_types
720
+ logger.debug("Requesting activy types")
721
+ return self.connectapi(url)
722
+
723
+ def get_goals(self, status="active", start=1, limit=30):
724
+ """
725
+ Fetch all goals based on status
726
+ :param status: Status of goals (valid options are "active", "future", or "past")
727
+ :type status: str
728
+ :param start: Initial goal index
729
+ :type start: int
730
+ :param limit: Pagination limit when retrieving goals
731
+ :type limit: int
732
+ :return: list of goals in JSON format
733
+ """
734
+
735
+ goals = []
736
+ url = self.garmin_connect_goals_url
737
+ params = {
738
+ "status": status,
739
+ "start": str(start),
740
+ "limit": str(limit),
741
+ "sortOrder": "asc",
742
+ }
743
+
744
+ logger.debug(f"Requesting {status} goals")
745
+ while True:
746
+ params["start"] = str(start)
747
+ logger.debug(
748
+ f"Requesting {status} goals {start} to {start + limit - 1}"
749
+ )
750
+ goals_json = self.connectapi(url, params=params)
751
+ if goals_json:
752
+ goals.extend(goals_json)
753
+ start = start + limit
754
+ else:
755
+ break
756
+
757
+ return goals
758
+
759
+ def get_gear(self, userProfileNumber):
760
+ """Return all user gear."""
761
+ url = f"{self.garmin_connect_gear}?userProfilePk={userProfileNumber}"
762
+ logger.debug("Requesting gear for user %s", userProfileNumber)
763
+
764
+ return self.connectapi(url)
765
+
766
+ def get_gear_stats(self, gearUUID):
767
+ url = f"{self.garmin_connect_gear_baseurl}stats/{gearUUID}"
768
+ logger.debug("Requesting gear stats for gearUUID %s", gearUUID)
769
+ return self.connectapi(url)
770
+
771
+ def get_gear_defaults(self, userProfileNumber):
772
+ url = (
773
+ f"{self.garmin_connect_gear_baseurl}user/"
774
+ f"{userProfileNumber}/activityTypes"
775
+ )
776
+ logger.debug("Requesting gear for user %s", userProfileNumber)
777
+ return self.connectapi(url)
778
+
779
+ def set_gear_default(self, activityType, gearUUID, defaultGear=True):
780
+ defaultGearString = "/default/true" if defaultGear else ""
781
+ method_override = "PUT" if defaultGear else "DELETE"
782
+ url = (
783
+ f"{self.garmin_connect_gear_baseurl}{gearUUID}/"
784
+ f"activityType/{activityType}{defaultGearString}"
785
+ )
786
+ return self.garth.post(
787
+ "connectapi", url, {"x-http-method-override": method_override}
788
+ )
789
+
790
+ class ActivityDownloadFormat(Enum):
791
+ """Activity variables."""
792
+
793
+ ORIGINAL = auto()
794
+ TCX = auto()
795
+ GPX = auto()
796
+ KML = auto()
797
+ CSV = auto()
798
+
799
+ class ActivityUploadFormat(Enum):
800
+ FIT = auto()
801
+ GPX = auto()
802
+ TCX = auto()
803
+
804
+ def download_activity(
805
+ self, activity_id, dl_fmt=ActivityDownloadFormat.TCX
806
+ ):
807
+ """
808
+ Downloads activity in requested format and returns the raw bytes. For
809
+ "Original" will return the zip file content, up to user to extract it.
810
+ "CSV" will return a csv of the splits.
811
+ """
812
+ activity_id = str(activity_id)
813
+ urls = {
814
+ Garmin.ActivityDownloadFormat.ORIGINAL: f"{self.garmin_connect_fit_download}/{activity_id}",
815
+ Garmin.ActivityDownloadFormat.TCX: f"{self.garmin_connect_tcx_download}/{activity_id}",
816
+ Garmin.ActivityDownloadFormat.GPX: f"{self.garmin_connect_gpx_download}/{activity_id}",
817
+ Garmin.ActivityDownloadFormat.KML: f"{self.garmin_connect_kml_download}/{activity_id}",
818
+ Garmin.ActivityDownloadFormat.CSV: f"{self.garmin_connect_csv_download}/{activity_id}",
819
+ }
820
+ if dl_fmt not in urls:
821
+ raise ValueError(f"Unexpected value {dl_fmt} for dl_fmt")
822
+ url = urls[dl_fmt]
823
+
824
+ logger.debug("Downloading activities from %s", url)
825
+
826
+ return self.download(url)
827
+
828
+ def get_activity_splits(self, activity_id):
829
+ """Return activity splits."""
830
+
831
+ activity_id = str(activity_id)
832
+ url = f"{self.garmin_connect_activity}/{activity_id}/splits"
833
+ logger.debug("Requesting splits for activity id %s", activity_id)
834
+
835
+ return self.connectapi(url)
836
+
837
+ def get_activity_split_summaries(self, activity_id):
838
+ """Return activity split summaries."""
839
+
840
+ activity_id = str(activity_id)
841
+ url = f"{self.garmin_connect_activity}/{activity_id}/split_summaries"
842
+ logger.debug(
843
+ "Requesting split summaries for activity id %s", activity_id
844
+ )
845
+
846
+ return self.connectapi(url)
847
+
848
+ def get_activity_weather(self, activity_id):
849
+ """Return activity weather."""
850
+
851
+ activity_id = str(activity_id)
852
+ url = f"{self.garmin_connect_activity}/{activity_id}/weather"
853
+ logger.debug("Requesting weather for activity id %s", activity_id)
854
+
855
+ return self.connectapi(url)
856
+
857
+ def get_activity_hr_in_timezones(self, activity_id):
858
+ """Return activity heartrate in timezones."""
859
+
860
+ activity_id = str(activity_id)
861
+ url = f"{self.garmin_connect_activity}/{activity_id}/hrTimeInZones"
862
+ logger.debug(
863
+ "Requesting split summaries for activity id %s", activity_id
864
+ )
865
+
866
+ return self.connectapi(url)
867
+
868
+ def get_activity_evaluation(self, activity_id):
869
+ """Return activity self evaluation details."""
870
+
871
+ activity_id = str(activity_id)
872
+ url = f"{self.garmin_connect_activity}/{activity_id}"
873
+ logger.debug(
874
+ "Requesting self evaluation data for activity id %s", activity_id
875
+ )
876
+
877
+ return self.connectapi(url)
878
+
879
+ def get_activity_details(self, activity_id, maxchart=2000, maxpoly=4000):
880
+ """Return activity details."""
881
+
882
+ activity_id = str(activity_id)
883
+ params = {
884
+ "maxChartSize": str(maxchart),
885
+ "maxPolylineSize": str(maxpoly),
886
+ }
887
+ url = f"{self.garmin_connect_activity}/{activity_id}/details"
888
+ logger.debug("Requesting details for activity id %s", activity_id)
889
+
890
+ return self.connectapi(url, params=params)
891
+
892
+ def get_activity_exercise_sets(self, activity_id):
893
+ """Return activity exercise sets."""
894
+
895
+ activity_id = str(activity_id)
896
+ url = f"{self.garmin_connect_activity}/{activity_id}/exerciseSets"
897
+ logger.debug(
898
+ "Requesting exercise sets for activity id %s", activity_id
899
+ )
900
+
901
+ return self.connectapi(url)
902
+
903
+ def get_activity_gear(self, activity_id):
904
+ """Return gears used for activity id."""
905
+
906
+ activity_id = str(activity_id)
907
+ params = {
908
+ "activityId": str(activity_id),
909
+ }
910
+ url = self.garmin_connect_gear
911
+ logger.debug("Requesting gear for activity_id %s", activity_id)
912
+
913
+ return self.connectapi(url, params=params)
914
+
915
+ def logout(self):
916
+ """Log user out of session."""
917
+
918
+ self.connectapi(self.garmin_connect_logout)
919
+
920
+
921
+ class GarminConnectConnectionError(Exception):
922
+ """Raised when communication ended in error."""
923
+
924
+
925
+ class GarminConnectTooManyRequestsError(Exception):
926
+ """Raised when rate limit is exceeded."""
927
+
928
+
929
+ class GarminConnectAuthenticationError(Exception):
930
+ """Raised when authentication is failed."""
931
+
932
+
933
+ class GarminConnectInvalidFileFormatError(Exception):
934
+ """Raised when an invalid file format is passed to upload."""
@@ -0,0 +1 @@
1
+ __version__ = "0.2.2"
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.1
2
+ Name: garminconnect
3
+ Version: 0.2.2
4
+ Summary: Python 3 API wrapper for Garmin Connect
5
+ Keywords: garmin connect api garmin
6
+ Author-Email: Ron Klinkien <ron@cyberjunky.nl>
7
+ License: MIT
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: MacOS :: MacOS X
11
+ Classifier: Operating System :: Microsoft :: Windows
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Project-URL: Homepage, https://github.com/cyberjunky/python-garminconnect
14
+ Project-URL: Bug tracker, https://github.com/cyberjunky/python-garminconnect/issues
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: garth>=0.4.23
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Python: Garmin Connect
20
+
21
+ ![image](https://github.com/cyberjunky/python-garminconnect/assets/5447161/c7ed7155-0f8c-4fdc-8369-1281759dc5c9)
22
+
23
+ [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.me/cyberjunkynl/)
24
+
25
+ Python 3 API wrapper for Garmin Connect to get your statistics.
26
+
27
+ ## NOTE: For developers using this package
28
+ From `version 0.2.1 onwards`, this package uses `garth` to authenticate and perform API calls.
29
+ This requires minor changes to your login code, look at the code in `example.py` or the `reference.ipynb` file how to do that.
30
+ It fixes a lot of stability issues, so it's well worth the effort!
31
+
32
+ ## About
33
+
34
+ This package allows you to request garmin device, activity and health data from your Garmin Connect account.
35
+ See <https://connect.garmin.com/>
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip3 install garminconnect
41
+ ```
42
+
43
+ ## Authentication
44
+
45
+ The library uses the same authentication method as the app using [Garth](https://github.com/matin/garth).
46
+ The login credentials generated with Garth are valid for a year to avoid needing to login each time.
47
+
48
+ ## Testing
49
+
50
+ ```bash
51
+ sudo apt install python3-pytest (some distros)
52
+
53
+ make install-test
54
+ make test
55
+ ```
56
+
57
+ ## Development
58
+
59
+ The tests provide examples of how to use the library.
60
+ There is a Jupyter notebook called `reference.ipynb` provided [here](https://github.com/cyberjunky/python-garminconnect/blob/master/reference.ipynb).
61
+ And you can check out the `example.py` code you can find [here](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/example.py), you can run it like so:
62
+ ```
63
+ pip3 install -r requirements-dev.txt
64
+ ./example.py
65
+ ```
66
+
67
+ ## Credits
68
+
69
+ :heart: Special thanks to all people contibuted, either by asking questions, reporting bugs, coming up with great ideas, or even by creating whole Pull Requests to add new features!
70
+ This project deserves more attention, but I'm struggling to free up time sometimes, so thank you for your patience too!
71
+
72
+ ## Donations
73
+
74
+ [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.me/cyberjunkynl/)
@@ -0,0 +1,6 @@
1
+ garminconnect-0.2.2.dist-info/METADATA,sha256=_FUckDgViy0pBNV-mBGAE80E1F22AiolbcWOAtXOSO4,2780
2
+ garminconnect-0.2.2.dist-info/WHEEL,sha256=1D4d-_ODszlK6-qFybc5BLF-ZhZvee-uKVh2hLa3IIc,90
3
+ garminconnect-0.2.2.dist-info/licenses/LICENSE,sha256=jQMjDyLxbdDjqjfKlFTmV3GNjZfdn5QrSlqegiQw8m0,1074
4
+ garminconnect/__init__.py,sha256=sMEb8Qrx49KKQhuiqPDXGyNaQ-YJgPKwiToeJB_-qrs,32560
5
+ garminconnect/version.py,sha256=m6kyaNpwBcP1XYcqrelX2oS3PJuOnElOcRdBa9pEb8c,22
6
+ garminconnect-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.1.6)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020-2023 Ron Klinkien
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.