@gooin/garmin-connect 1.4.4
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.
- package/LICENSE +21 -0
- package/README.md +236 -0
- package/dist/common/CFClient.js +140 -0
- package/dist/common/Client.js +189 -0
- package/dist/common/DateUtils.js +10 -0
- package/dist/garmin/GarminConnect.js +438 -0
- package/dist/garmin/Urls.js +102 -0
- package/dist/garmin/workouts/Running.js +53 -0
- package/dist/garmin/workouts/index.js +5 -0
- package/dist/garmin/workouts/templates/RunningTemplate.js +48 -0
- package/dist/index.js +3 -0
- package/examples/example.js +19 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020 Oskar Bernberg
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
# garmin-connect
|
|
4
|
+
A powerful JavaScript library for connecting to Garmin Connect for sending and receiving health and workout data. It comes with some predefined methods to get and set different kinds of data for your Garmin account, but also have the possibility to make [custom requests](#custom-requests) `GET`, `POST` and `PUT` are currently supported. This makes it easy to implement whatever may be missing to suite your needs.
|
|
5
|
+
|
|
6
|
+
## Prerequisites
|
|
7
|
+
This library will require you to add a configuration file to your project root called `garmin.config.json` containing your username and password for the Garmin Connect service.
|
|
8
|
+
```json
|
|
9
|
+
{
|
|
10
|
+
"username": "my.email@example.com",
|
|
11
|
+
"password": "MySecretPassword"
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
## How to install
|
|
15
|
+
```shell
|
|
16
|
+
$ npm install garmin-connect
|
|
17
|
+
```
|
|
18
|
+
## How to use
|
|
19
|
+
```js
|
|
20
|
+
const { GarminConnect } = require('garmin-connect');
|
|
21
|
+
// Create a new Garmin Connect Client
|
|
22
|
+
const GCClient = new GarminConnect();
|
|
23
|
+
// Uses credentials from garmin.config.json or uses supplied params
|
|
24
|
+
await GCClient.login('my.email@example.com', 'MySecretPassword');
|
|
25
|
+
const userInfo = await GCClient.getUserInfo();
|
|
26
|
+
```
|
|
27
|
+
Now you can check `userInfo.emailAddress` to verify that your login was successful.
|
|
28
|
+
|
|
29
|
+
## Reusing your session
|
|
30
|
+
This is an experimental feature and might not yet provide full stability.
|
|
31
|
+
|
|
32
|
+
After a successful login the ```sessionJson``` getter and setter can be used to export and restore your session.
|
|
33
|
+
```js
|
|
34
|
+
// Exporting the session
|
|
35
|
+
const session = GCClient.sessionJson;
|
|
36
|
+
|
|
37
|
+
// Use this instead of GCClient.login() to restore the session
|
|
38
|
+
// This will throw an error if the stored session cannot be reused
|
|
39
|
+
GCClient.restore(session);
|
|
40
|
+
```
|
|
41
|
+
The exported session should be serializable and can be stored as a JSON string.
|
|
42
|
+
|
|
43
|
+
A stored session can only be reused once and will need to be stored after each request. This can be done by attaching some storage to the ```sessionChange``` event.
|
|
44
|
+
```js
|
|
45
|
+
GCClient.onSessionChange(session => {
|
|
46
|
+
/*
|
|
47
|
+
Your choice of storage here
|
|
48
|
+
node-persist will probably work in most cases
|
|
49
|
+
*/
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Login fallback
|
|
54
|
+
To make sure to use a stored session if possible, but fallback to regular login, one can use the ```restoreOrLogin``` method.
|
|
55
|
+
The arguments ```username``` and ```password``` are both optional and the regular ```.login()``` will be
|
|
56
|
+
called if session restore fails.
|
|
57
|
+
```js
|
|
58
|
+
await GCClient.restoreOrLogin(session, username, password);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Events
|
|
62
|
+
|
|
63
|
+
* ```sessionChange``` will trigger on a change in the current ```sessionJson```
|
|
64
|
+
|
|
65
|
+
To attach a listener to an event, use the ```.on()``` method.
|
|
66
|
+
```js
|
|
67
|
+
GCClient.on('sessionChange', session => console.log(session));
|
|
68
|
+
```
|
|
69
|
+
There's currently no way of removing listeners.
|
|
70
|
+
|
|
71
|
+
## Reading data
|
|
72
|
+
### User info
|
|
73
|
+
Receive basic user information
|
|
74
|
+
```js
|
|
75
|
+
GCClient.getUserInfo();
|
|
76
|
+
```
|
|
77
|
+
### Social Profile
|
|
78
|
+
Receive social user information
|
|
79
|
+
```js
|
|
80
|
+
GCClient.getSocialProfile();
|
|
81
|
+
```
|
|
82
|
+
### Social Connections
|
|
83
|
+
Get a list of all social connections
|
|
84
|
+
```js
|
|
85
|
+
GCClient.getSocialConnections();
|
|
86
|
+
```
|
|
87
|
+
### Device info
|
|
88
|
+
Get a list of all registered devices including model numbers and firmware versions.
|
|
89
|
+
```js
|
|
90
|
+
GCClient.getDeviceInfo();
|
|
91
|
+
```
|
|
92
|
+
### Activities
|
|
93
|
+
To get a list of recent activities, use the `getActivities` method. This function takes two arguments, *start* and *limit*, which is used for pagination. Both are optional and will default to whatever Garmin Connect is using. To be sure to get all activities, use this correctly.
|
|
94
|
+
```js
|
|
95
|
+
// Get a list of default length with most recent activities
|
|
96
|
+
GCClient.getActivities();
|
|
97
|
+
// Get activities 10 through 15. (start 10, limit 5)
|
|
98
|
+
GCClient.getActivities(10, 5);
|
|
99
|
+
```
|
|
100
|
+
### Activity details
|
|
101
|
+
Use the activityId to get details about that specific activity.
|
|
102
|
+
```js
|
|
103
|
+
const activities = await GCClient.getActivities(0, 1);
|
|
104
|
+
const id = activities[0].activityId;
|
|
105
|
+
// Use the id as a parameter
|
|
106
|
+
GCClient.getActivity({ activityId: id });
|
|
107
|
+
// Or the whole activity response
|
|
108
|
+
GCClient.getActivity(activities[0]);
|
|
109
|
+
```
|
|
110
|
+
### Activities
|
|
111
|
+
To get a list of activities in your news feed, use the `getNewsFeed` method. This function takes two arguments, *start* and *limit*, which is used for pagination. Both are optional and will default to whatever Garmin Connect is using. To be sure to get all activities, use this correctly.
|
|
112
|
+
```js
|
|
113
|
+
// Get the news feed with a default length with most recent activities
|
|
114
|
+
GCClient.getNewsFeed();
|
|
115
|
+
// Get activities in feed, 10 through 15. (start 10, limit 5)
|
|
116
|
+
GCClient.getNewsFeed(10, 5);
|
|
117
|
+
```
|
|
118
|
+
### Download original activity data
|
|
119
|
+
Use the activityId to download the original activity data. Usually this is supplied as a .zip file.
|
|
120
|
+
```js
|
|
121
|
+
const [activity] = await GCClient.getActivities(0, 1);
|
|
122
|
+
// Directory path is optional and defaults to the current working directory.
|
|
123
|
+
// Downloads filename will be supplied by Garmin.
|
|
124
|
+
GCClient.downloadOriginalActivityData(activity, './some/path/that/exists');
|
|
125
|
+
```
|
|
126
|
+
### Upload activity file
|
|
127
|
+
Uploads an activity file as a new Activity. The file can be a `gpx`, `tcx`, or `fit` file. If the activity already exists, the result will have a status code of 409.
|
|
128
|
+
Upload fixed in 1.4.4, Garmin changed the upload api, the response `detailedImportResult` doesn't contain the new activityId.
|
|
129
|
+
```js
|
|
130
|
+
|
|
131
|
+
const upload = await GCClient.uploadActivity('./some/path/to/file.fit');
|
|
132
|
+
// not working
|
|
133
|
+
const activityId = upload.detailedImportResult.successes[0].internalId;
|
|
134
|
+
|
|
135
|
+
const uploadId = upload.detailedImportResult.uploadId;
|
|
136
|
+
```
|
|
137
|
+
### Step count
|
|
138
|
+
Get timestamp and number of steps taken for a specific date.
|
|
139
|
+
```js
|
|
140
|
+
// This will default to today if no date is supplied
|
|
141
|
+
const steps = await GCClient.getSteps(new Date('2020-03-24'));
|
|
142
|
+
```
|
|
143
|
+
### Heart rate
|
|
144
|
+
Get heart rate for a specific date.
|
|
145
|
+
```js
|
|
146
|
+
// This will default to today if no date is supplied
|
|
147
|
+
const heartRate = await GCClient.getHeartRate(new Date('2020-03-24'));
|
|
148
|
+
```
|
|
149
|
+
### Sleep summary
|
|
150
|
+
Get the summary of how well you've slept for a specific date.
|
|
151
|
+
```js
|
|
152
|
+
// This will default to today if no date is supplied
|
|
153
|
+
const sleep = await GCClient.getSleep(new Date('2020-03-24'));
|
|
154
|
+
```
|
|
155
|
+
### Detailed sleep data
|
|
156
|
+
Get the details of your sleep for a specific date.
|
|
157
|
+
```js
|
|
158
|
+
// This will default to today if no date is supplied
|
|
159
|
+
const detailedSleep = await GCClient.getSleepData(new Date('2020-03-24'));
|
|
160
|
+
```
|
|
161
|
+
## Modifying data
|
|
162
|
+
### Update activity
|
|
163
|
+
```js
|
|
164
|
+
const activities = await GCClient.getActivities(0, 1);
|
|
165
|
+
const activity = activities[0];
|
|
166
|
+
activity['activityName'] = 'The Updated Name';
|
|
167
|
+
await GCClient.updateActivity(activity);
|
|
168
|
+
```
|
|
169
|
+
### Delete an activity
|
|
170
|
+
Deletes an activty.
|
|
171
|
+
```js
|
|
172
|
+
const activities = await GCClient.getActivities(0, 1);
|
|
173
|
+
const activity = activities[0];
|
|
174
|
+
await GCClient.deleteActivity(activity);
|
|
175
|
+
```
|
|
176
|
+
### Add weight
|
|
177
|
+
To add a new weight measurement, use `setBodyWeight`. Here you specify your weight in *kg*.
|
|
178
|
+
```js
|
|
179
|
+
GCClient.setBodyWeight(81.4);
|
|
180
|
+
```
|
|
181
|
+
Will set your current weight to 81.4kg. The unit used might be tied to your preferred weight settings.
|
|
182
|
+
### Add workout
|
|
183
|
+
To add a custom workout, use the `addWorkout` or more specifically `addRunningWorkout`.
|
|
184
|
+
```js
|
|
185
|
+
GCClient.addRunningWorkout('My 5k run', 5000, 'Some description');
|
|
186
|
+
```
|
|
187
|
+
Will add a running workout of 5km called 'My 5k run' and return a JSON object representing the saved workout.
|
|
188
|
+
### Schedule workout
|
|
189
|
+
To add a workout to your calendar, first find your workout and then add it to a specific date.
|
|
190
|
+
```js
|
|
191
|
+
const workouts = await GCClient.getWorkouts();
|
|
192
|
+
const id = workouts[0].workoutId;
|
|
193
|
+
GCClient.scheduleWorkout({ workoutId: id }, new Date('2020-03-24'));
|
|
194
|
+
```
|
|
195
|
+
This will add the workout to a specific date in your calendar and make it show up automatically if you're using any of the Garmin watches.
|
|
196
|
+
### Delete workout
|
|
197
|
+
Deleting a workout is very similar to [scheduling](#schedule-workout) one.
|
|
198
|
+
```js
|
|
199
|
+
const workouts = await GCClient.getWorkouts();
|
|
200
|
+
const id = workouts[0].workoutId;
|
|
201
|
+
GCClient.deleteWorkout({ workoutId: id });
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Custom requests
|
|
205
|
+
This library will handle custom requests to your active Garmin Connect session. There are a lot of different url's that is used, which means that this library probably wont cover them all. By using the network analyze tool you can find url's that are used by Garmin Connect to fetch data.
|
|
206
|
+
|
|
207
|
+
Let's assume I found a `GET` requests to the following url:
|
|
208
|
+
```
|
|
209
|
+
https://connect.garmin.com/modern/proxy/wellness-service/wellness/dailyHeartRate/22f5f84c-de9d-4ad6-97f2-201097b3b983?date=2020-03-24
|
|
210
|
+
```
|
|
211
|
+
The request can be sent using `GCClient` by running
|
|
212
|
+
```js
|
|
213
|
+
// You can get your displayName by using the getUserInfo method;
|
|
214
|
+
const displayName = '22f5f84c-de9d-4ad6-97f2-201097b3b983';
|
|
215
|
+
const url = 'https://connect.garmin.com/modern/proxy/wellness-service/wellness/dailyHeartRate/';
|
|
216
|
+
const dateString = '2020-03-24';
|
|
217
|
+
GCClient.get(url + displayName, { date: dateString });
|
|
218
|
+
```
|
|
219
|
+
and will net you the same result as using the provided way
|
|
220
|
+
```js
|
|
221
|
+
GCClient.getHeartRate();
|
|
222
|
+
```
|
|
223
|
+
Notice how the client will keep track of the url's, your user information as well as keeping the session alive.
|
|
224
|
+
## Limitations
|
|
225
|
+
For now, this library only supports the following:
|
|
226
|
+
* Get user info
|
|
227
|
+
* Get social user info
|
|
228
|
+
* Get heart rate
|
|
229
|
+
* Set body weight
|
|
230
|
+
* Get list of workouts
|
|
231
|
+
* Add new workouts
|
|
232
|
+
* Add workouts to you calendar
|
|
233
|
+
* Remove previously added workouts
|
|
234
|
+
* Get list of activities
|
|
235
|
+
* Get details about one specific activity
|
|
236
|
+
* Get the step count
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const cloudscraper = require('cloudscraper');
|
|
2
|
+
const qs = require('qs');
|
|
3
|
+
const request = require('request');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const asJson = (body) => {
|
|
8
|
+
try {
|
|
9
|
+
const jsonBody = JSON.parse(body);
|
|
10
|
+
return jsonBody;
|
|
11
|
+
} catch (e) {
|
|
12
|
+
// Do nothing
|
|
13
|
+
}
|
|
14
|
+
return body;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
class CFClient {
|
|
18
|
+
constructor(headers) {
|
|
19
|
+
this.cloudscraper = cloudscraper;
|
|
20
|
+
this.queryString = qs;
|
|
21
|
+
this.cookies = request.jar();
|
|
22
|
+
this.headers = headers || {};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
serializeCookies() {
|
|
26
|
+
// eslint-disable-next-line no-underscore-dangle
|
|
27
|
+
return this.cookies._jar.serializeSync();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
importCookies(cookies) {
|
|
31
|
+
// eslint-disable-next-line no-underscore-dangle
|
|
32
|
+
const deserialized = this.cookies._jar.constructor.deserializeSync(cookies);
|
|
33
|
+
this.cookies = request.jar();
|
|
34
|
+
// eslint-disable-next-line no-underscore-dangle
|
|
35
|
+
this.cookies._jar = deserialized;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async scraper(options) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
this.cloudscraper(
|
|
41
|
+
options,
|
|
42
|
+
(err, res) => {
|
|
43
|
+
resolve(res);
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} downloadDir
|
|
51
|
+
* @param {string} url
|
|
52
|
+
* @param {*} data
|
|
53
|
+
*/
|
|
54
|
+
async downloadBlob(downloadDir = '', url, data) {
|
|
55
|
+
const queryData = this.queryString.stringify(data);
|
|
56
|
+
const queryDataString = queryData ? `?${queryData}` : '';
|
|
57
|
+
const options = {
|
|
58
|
+
method: 'GET',
|
|
59
|
+
jar: this.cookies,
|
|
60
|
+
uri: `${url}${queryDataString}`,
|
|
61
|
+
headers: this.headers,
|
|
62
|
+
encoding: 0,
|
|
63
|
+
};
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
this.cloudscraper(
|
|
66
|
+
options,
|
|
67
|
+
async (err, response, body) => {
|
|
68
|
+
const { headers } = response || {};
|
|
69
|
+
const { 'content-disposition': contentDisposition } = headers || {};
|
|
70
|
+
const downloadDirNormalized = path.normalize(downloadDir);
|
|
71
|
+
if (contentDisposition) {
|
|
72
|
+
const defaultName = `garmin_connect_download_${Date.now()}`;
|
|
73
|
+
const [, fileName = defaultName] = contentDisposition.match(/filename="?([^"]+)"?/);
|
|
74
|
+
const filePath = path.resolve(downloadDirNormalized, fileName);
|
|
75
|
+
fs.writeFileSync(filePath, body);
|
|
76
|
+
resolve(filePath);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async get(url, data) {
|
|
84
|
+
const queryData = this.queryString.stringify(data);
|
|
85
|
+
const queryDataString = queryData ? `?${queryData}` : '';
|
|
86
|
+
const options = {
|
|
87
|
+
method: 'GET',
|
|
88
|
+
jar: this.cookies,
|
|
89
|
+
uri: `${url}${queryDataString}`,
|
|
90
|
+
headers: this.headers,
|
|
91
|
+
};
|
|
92
|
+
const { body } = await this.scraper(options);
|
|
93
|
+
return asJson(body);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async post(url, data) {
|
|
97
|
+
const options = {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
uri: url,
|
|
100
|
+
jar: this.cookies,
|
|
101
|
+
formData: data,
|
|
102
|
+
headers: this.headers,
|
|
103
|
+
};
|
|
104
|
+
const { body } = await this.scraper(options);
|
|
105
|
+
return asJson(body);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async postJson(url, data, params, headers) {
|
|
109
|
+
const options = {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
uri: url,
|
|
112
|
+
jar: this.cookies,
|
|
113
|
+
json: data,
|
|
114
|
+
headers: {
|
|
115
|
+
...this.headers,
|
|
116
|
+
...headers,
|
|
117
|
+
'Content-Type': 'application/json',
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
const { body } = await this.scraper(options);
|
|
121
|
+
return asJson(body);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async putJson(url, data) {
|
|
125
|
+
const options = {
|
|
126
|
+
method: 'PUT',
|
|
127
|
+
uri: url,
|
|
128
|
+
jar: this.cookies,
|
|
129
|
+
json: data,
|
|
130
|
+
headers: {
|
|
131
|
+
...this.headers,
|
|
132
|
+
'Content-Type': 'application/json',
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
const { body } = await this.scraper(options);
|
|
136
|
+
return asJson(body);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = CFClient;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const qs = require('qs');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const stream = require('stream');
|
|
5
|
+
const util = require('util');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const pipeline = util.promisify(stream.pipeline);
|
|
9
|
+
|
|
10
|
+
class Client {
|
|
11
|
+
constructor(headers) {
|
|
12
|
+
this.axios = axios;
|
|
13
|
+
this.queryString = qs;
|
|
14
|
+
this.fs = fs;
|
|
15
|
+
this.headers = headers || {};
|
|
16
|
+
this.cookies = {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
setCookie(name, value) {
|
|
20
|
+
this.cookies[name] = value;
|
|
21
|
+
this.headers.Cookie = this.getCookieString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
parseCookies(response) {
|
|
25
|
+
const setCookies = response && response.headers && response.headers['set-cookie'];
|
|
26
|
+
if (setCookies) {
|
|
27
|
+
setCookies.forEach((c) => {
|
|
28
|
+
const [cookieValue] = c.split(';');
|
|
29
|
+
const [name, value] = cookieValue.split('=');
|
|
30
|
+
this.setCookie(name, value);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return response;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getCookie(name) {
|
|
37
|
+
return this.cookies[name];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getCookieString() {
|
|
41
|
+
return Object.entries(this.cookies).map((e) => `${e[0]}=${e[1]}`).join('; ');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
post(url, data, params) {
|
|
45
|
+
return this.axios({
|
|
46
|
+
method: 'POST',
|
|
47
|
+
params,
|
|
48
|
+
url,
|
|
49
|
+
data: this.queryString.stringify(data),
|
|
50
|
+
headers: {
|
|
51
|
+
...this.headers,
|
|
52
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
53
|
+
},
|
|
54
|
+
maxRedirects: 0,
|
|
55
|
+
})
|
|
56
|
+
.catch((r) => {
|
|
57
|
+
const { response } = r || {};
|
|
58
|
+
this.parseCookies(response);
|
|
59
|
+
if (response.status === 302 || response.status === 301) {
|
|
60
|
+
if (response.headers && response.headers.location) {
|
|
61
|
+
return this.post(response.headers.location, data, params);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return r;
|
|
65
|
+
})
|
|
66
|
+
.then((r) => this.parseCookies(r))
|
|
67
|
+
.then((r) => r && r.data);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
postJson(url, data, params, headers = {}) {
|
|
71
|
+
return this.axios({
|
|
72
|
+
method: 'POST',
|
|
73
|
+
params,
|
|
74
|
+
url,
|
|
75
|
+
data: JSON.stringify(data, null, 4),
|
|
76
|
+
headers: {
|
|
77
|
+
...this.headers,
|
|
78
|
+
...headers,
|
|
79
|
+
'Content-Type': 'application/json',
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
.then((r) => this.parseCookies(r))
|
|
83
|
+
.then((r) => r && r.data);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
postBlob(url, formData, params, headers = {}) {
|
|
87
|
+
return this.axios({
|
|
88
|
+
method: 'POST',
|
|
89
|
+
params,
|
|
90
|
+
url,
|
|
91
|
+
data: formData,
|
|
92
|
+
headers: {
|
|
93
|
+
...this.headers,
|
|
94
|
+
...headers,
|
|
95
|
+
...formData.getHeaders(),
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
.then((r) => this.parseCookies(r))
|
|
99
|
+
.then((r) => r && r.data);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
putJson(url, data, params) {
|
|
103
|
+
return this.axios({
|
|
104
|
+
method: 'PUT',
|
|
105
|
+
params,
|
|
106
|
+
url,
|
|
107
|
+
data: JSON.stringify(data, null, 4),
|
|
108
|
+
headers: {
|
|
109
|
+
...this.headers,
|
|
110
|
+
'Content-Type': 'application/json',
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
.then((r) => this.parseCookies(r))
|
|
114
|
+
.then((r) => r && r.data);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
downloadBlob(downloadDir = '', url, data, params) {
|
|
118
|
+
const queryData = this.queryString.stringify(data);
|
|
119
|
+
const queryDataString = queryData ? `?${queryData}` : '';
|
|
120
|
+
return this.axios({
|
|
121
|
+
method: 'GET',
|
|
122
|
+
params,
|
|
123
|
+
responseType: 'stream',
|
|
124
|
+
url: `${url}${queryDataString}`,
|
|
125
|
+
headers: this.headers,
|
|
126
|
+
maxRedirects: 0,
|
|
127
|
+
})
|
|
128
|
+
.catch((r) => {
|
|
129
|
+
const { response } = r || {};
|
|
130
|
+
const { status, headers } = response || {};
|
|
131
|
+
const { location } = headers || {};
|
|
132
|
+
this.parseCookies(response);
|
|
133
|
+
if (status === 302 || status === 301) {
|
|
134
|
+
if (headers && location) {
|
|
135
|
+
return this.downloadBlob(location, data, params);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return r;
|
|
139
|
+
})
|
|
140
|
+
.then((r) => this.parseCookies(r))
|
|
141
|
+
.then(async (r) => {
|
|
142
|
+
const { headers } = r || {};
|
|
143
|
+
const { 'content-disposition': contentDisposition } = headers || {};
|
|
144
|
+
const downloadDirNormalized = path.normalize(downloadDir);
|
|
145
|
+
if (contentDisposition) {
|
|
146
|
+
const defaultName = `garmin_connect_download_${Date.now()}`;
|
|
147
|
+
const [, fileName = defaultName] = contentDisposition.match(/filename="(.+)"/);
|
|
148
|
+
const filePath = path.resolve(downloadDir, fileName);
|
|
149
|
+
await pipeline(r.data, this.fs.createWriteStream(filePath));
|
|
150
|
+
return filePath;
|
|
151
|
+
}
|
|
152
|
+
throw new Error(`Could not download file ${url} to ${downloadDirNormalized}`);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
get(url, data, params) {
|
|
157
|
+
const queryData = this.queryString.stringify(data);
|
|
158
|
+
const queryDataString = queryData ? `?${queryData}` : '';
|
|
159
|
+
return this.axios({
|
|
160
|
+
method: 'GET',
|
|
161
|
+
params,
|
|
162
|
+
url: `${url}${queryDataString}`,
|
|
163
|
+
headers: this.headers,
|
|
164
|
+
maxRedirects: 0,
|
|
165
|
+
})
|
|
166
|
+
.catch((r) => {
|
|
167
|
+
const { response } = r || {};
|
|
168
|
+
const { status, headers } = response || {};
|
|
169
|
+
const { location } = headers || {};
|
|
170
|
+
this.parseCookies(response);
|
|
171
|
+
if (status === 302 || status === 301) {
|
|
172
|
+
if (headers && location) {
|
|
173
|
+
return this.get(location, data, params);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return r;
|
|
177
|
+
})
|
|
178
|
+
.then((r) => this.parseCookies(r))
|
|
179
|
+
.then((r) => {
|
|
180
|
+
if (typeof r === 'string') {
|
|
181
|
+
return r;
|
|
182
|
+
}
|
|
183
|
+
return r && r.data;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
module.exports = Client;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const toDateString = (date) => {
|
|
2
|
+
const offset = date.getTimezoneOffset();
|
|
3
|
+
const offsetDate = new Date(date.getTime() - (offset * 60 * 1000));
|
|
4
|
+
const [dateString] = offsetDate.toISOString().split('T');
|
|
5
|
+
return dateString;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
toDateString,
|
|
10
|
+
};
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
const appRoot = require('app-root-path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
4
|
+
let config = {};
|
|
5
|
+
try {
|
|
6
|
+
// eslint-disable-next-line
|
|
7
|
+
config = require(`${appRoot}/garmin.config.json`);
|
|
8
|
+
} catch (e) {
|
|
9
|
+
// Do nothing
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const CFClient = require('../common/CFClient');
|
|
13
|
+
const { Running } = require('./workouts');
|
|
14
|
+
const { toDateString } = require('../common/DateUtils');
|
|
15
|
+
const urls = require('./Urls');
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
username: configUsername,
|
|
19
|
+
password: configPassword,
|
|
20
|
+
} = config;
|
|
21
|
+
|
|
22
|
+
const credentials = {
|
|
23
|
+
username: configUsername,
|
|
24
|
+
password: configPassword,
|
|
25
|
+
embed: 'false',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
class GarminConnect {
|
|
29
|
+
constructor() {
|
|
30
|
+
const headers = {
|
|
31
|
+
origin: urls.GARMIN_SSO_ORIGIN,
|
|
32
|
+
nk: 'NT',
|
|
33
|
+
};
|
|
34
|
+
this.client = new CFClient(headers);
|
|
35
|
+
this.userHash = undefined;
|
|
36
|
+
this.listeners = {};
|
|
37
|
+
this.events = { sessionChange: 'sessionChange' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get sessionJson() {
|
|
41
|
+
const cookies = this.client.serializeCookies();
|
|
42
|
+
return { cookies, userHash: this.userHash };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
set sessionJson(json) {
|
|
46
|
+
const {
|
|
47
|
+
cookies,
|
|
48
|
+
userHash,
|
|
49
|
+
} = json || {};
|
|
50
|
+
if (cookies && userHash) {
|
|
51
|
+
this.userHash = userHash;
|
|
52
|
+
this.client.importCookies(cookies);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Add an event listener callback
|
|
58
|
+
* @param event
|
|
59
|
+
* @param callback
|
|
60
|
+
*/
|
|
61
|
+
on(event, callback) {
|
|
62
|
+
if (event && callback && typeof event === 'string' && typeof callback === 'function') {
|
|
63
|
+
if (!this.listeners[event]) {
|
|
64
|
+
this.listeners[event] = [];
|
|
65
|
+
}
|
|
66
|
+
this.listeners[event].push(callback);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Method for triggering any event
|
|
72
|
+
* @param event
|
|
73
|
+
* @param data
|
|
74
|
+
*/
|
|
75
|
+
triggerEvent(event, data) {
|
|
76
|
+
const callbacks = this.listeners[event] || [];
|
|
77
|
+
callbacks.forEach((cb) => cb(data));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Add a callback to the 'sessionChange' event
|
|
82
|
+
* @param callback
|
|
83
|
+
*/
|
|
84
|
+
onSessionChange(callback) {
|
|
85
|
+
this.on(this.events.sessionChange, callback);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Restore an old session from storage and fallback to regular login
|
|
90
|
+
* @param json
|
|
91
|
+
* @param username
|
|
92
|
+
* @param password
|
|
93
|
+
* @returns {Promise<GarminConnect>}
|
|
94
|
+
*/
|
|
95
|
+
async restoreOrLogin(json, username, password) {
|
|
96
|
+
return this.restore(json).catch((e) => {
|
|
97
|
+
console.warn(e);
|
|
98
|
+
return this.login(username, password);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Restore an old session from storage
|
|
104
|
+
* @param json
|
|
105
|
+
* @returns {Promise<GarminConnect>}
|
|
106
|
+
*/
|
|
107
|
+
async restore(json) {
|
|
108
|
+
this.sessionJson = json;
|
|
109
|
+
try {
|
|
110
|
+
const info = await this.getUserInfo();
|
|
111
|
+
const { displayName } = info || {};
|
|
112
|
+
if (displayName && displayName === this.userHash) {
|
|
113
|
+
// Session restoration was successful
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
throw new Error('Unable to restore session, user hash do not match');
|
|
117
|
+
} catch (e) {
|
|
118
|
+
throw new Error(`Unable to restore session due to: ${e}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Login to Garmin Connect
|
|
124
|
+
* @param username
|
|
125
|
+
* @param password
|
|
126
|
+
* @returns {Promise<*>}
|
|
127
|
+
*/
|
|
128
|
+
async login(username, password) {
|
|
129
|
+
let tempCredentials = { ...credentials, rememberme: 'on' };
|
|
130
|
+
if (username && password) {
|
|
131
|
+
tempCredentials = {
|
|
132
|
+
...credentials, username, password, rememberme: 'on',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
await this.client.get(urls.SIGNIN_URL);
|
|
136
|
+
await this.client.post(urls.SIGNIN_URL, tempCredentials);
|
|
137
|
+
const userPreferences = await this.getUserInfo();
|
|
138
|
+
const { displayName } = userPreferences;
|
|
139
|
+
this.userHash = displayName;
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// User info
|
|
144
|
+
/**
|
|
145
|
+
* Get basic user information
|
|
146
|
+
* @returns {Promise<*>}
|
|
147
|
+
*/
|
|
148
|
+
async getUserInfo() {
|
|
149
|
+
return this.get(urls.userInfo());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get social user information
|
|
154
|
+
* @returns {Promise<*>}
|
|
155
|
+
*/
|
|
156
|
+
async getSocialProfile() {
|
|
157
|
+
return this.get(urls.socialProfile(this.userHash));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get a list of all social connections
|
|
162
|
+
* @returns {Promise<*>}
|
|
163
|
+
*/
|
|
164
|
+
async getSocialConnections() {
|
|
165
|
+
return this.get(urls.socialConnections(this.userHash));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Devices
|
|
169
|
+
/**
|
|
170
|
+
* Get a list of all registered devices
|
|
171
|
+
* @returns {Promise<*>}
|
|
172
|
+
*/
|
|
173
|
+
async getDeviceInfo() {
|
|
174
|
+
return this.get(urls.deviceInfo(this.userHash));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Sleep data
|
|
178
|
+
/**
|
|
179
|
+
* Get detailed sleep data for a specific date
|
|
180
|
+
* @param date
|
|
181
|
+
* @returns {Promise<*>}
|
|
182
|
+
*/
|
|
183
|
+
async getSleepData(date = new Date()) {
|
|
184
|
+
const dateString = toDateString(date);
|
|
185
|
+
return this.get(urls.dailySleepData(this.userHash), { date: dateString });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get sleep data summary for a specific date
|
|
190
|
+
* @param date
|
|
191
|
+
* @returns {Promise<*>}
|
|
192
|
+
*/
|
|
193
|
+
async getSleep(date = new Date()) {
|
|
194
|
+
const dateString = toDateString(date);
|
|
195
|
+
return this.get(urls.dailySleep(), { date: dateString });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Heart rate
|
|
199
|
+
/**
|
|
200
|
+
* Get heart rate measurements for a specific date
|
|
201
|
+
* @param date
|
|
202
|
+
* @returns {Promise<*>}
|
|
203
|
+
*/
|
|
204
|
+
async getHeartRate(date = new Date()) {
|
|
205
|
+
const dateString = toDateString(date);
|
|
206
|
+
return this.get(urls.dailyHeartRate(this.userHash), { date: dateString });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Weight
|
|
210
|
+
/**
|
|
211
|
+
* Post a new body weight
|
|
212
|
+
* @param weight
|
|
213
|
+
* @returns {Promise<*>}
|
|
214
|
+
*/
|
|
215
|
+
async setBodyWeight(weight) {
|
|
216
|
+
if (weight) {
|
|
217
|
+
const roundWeight = Math.round(weight * 1000);
|
|
218
|
+
const data = { userData: { weight: roundWeight } };
|
|
219
|
+
return this.put(urls.userSettings(), data);
|
|
220
|
+
}
|
|
221
|
+
return Promise.reject();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Activites
|
|
225
|
+
/**
|
|
226
|
+
* Get list of activites
|
|
227
|
+
* @param start
|
|
228
|
+
* @param limit
|
|
229
|
+
* @returns {Promise<*>}
|
|
230
|
+
*/
|
|
231
|
+
async getActivities(start, limit) {
|
|
232
|
+
return this.get(urls.activities(), { start, limit });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Get details about an activity
|
|
237
|
+
* @param activity
|
|
238
|
+
* @param maxChartSize
|
|
239
|
+
* @param maxPolylineSize
|
|
240
|
+
* @returns {Promise<*>}
|
|
241
|
+
*/
|
|
242
|
+
async getActivity(activity, maxChartSize, maxPolylineSize) {
|
|
243
|
+
const { activityId } = activity || {};
|
|
244
|
+
if (activityId) {
|
|
245
|
+
return this.get(urls.activityDetails(activityId), { maxChartSize, maxPolylineSize });
|
|
246
|
+
}
|
|
247
|
+
return Promise.reject();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Get weather data from an activity
|
|
252
|
+
* @param activity
|
|
253
|
+
* @returns {Promise<*>}
|
|
254
|
+
*/
|
|
255
|
+
async getActivityWeather(activity) {
|
|
256
|
+
const { activityId } = activity || {};
|
|
257
|
+
if (activityId) {
|
|
258
|
+
return this.get(urls.weather(activityId));
|
|
259
|
+
}
|
|
260
|
+
return Promise.reject();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Updates an activity
|
|
265
|
+
* @param activity
|
|
266
|
+
* @returns {Promise<*>}
|
|
267
|
+
*/
|
|
268
|
+
async updateActivity(activity) {
|
|
269
|
+
return this.put(urls.activity(activity.activityId), activity);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Deletes an activity
|
|
274
|
+
* @param activity
|
|
275
|
+
* @returns {Promise<*>}
|
|
276
|
+
*/
|
|
277
|
+
async deleteActivity(activity) {
|
|
278
|
+
const { activityId } = activity || {};
|
|
279
|
+
if (activityId) {
|
|
280
|
+
const headers = { 'x-http-method-override': 'DELETE' };
|
|
281
|
+
return this.client.postJson(urls.activity(activityId), undefined, undefined, headers);
|
|
282
|
+
}
|
|
283
|
+
return Promise.reject();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Get list of activities in your news feed
|
|
288
|
+
* @param start
|
|
289
|
+
* @param limit
|
|
290
|
+
* @returns {Promise<*>}
|
|
291
|
+
*/
|
|
292
|
+
async getNewsFeed(start, limit) {
|
|
293
|
+
return this.get(urls.newsFeed(), { start, limit });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Steps
|
|
297
|
+
/**
|
|
298
|
+
* Get step count for a specific date
|
|
299
|
+
* @param date
|
|
300
|
+
* @returns {Promise<*>}
|
|
301
|
+
*/
|
|
302
|
+
async getSteps(date = new Date()) {
|
|
303
|
+
const dateString = toDateString(date);
|
|
304
|
+
return this.get(urls.dailySummaryChart(this.userHash), { date: dateString });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Workouts
|
|
308
|
+
/**
|
|
309
|
+
* Get list of workouts
|
|
310
|
+
* @param start
|
|
311
|
+
* @param limit
|
|
312
|
+
* @returns {Promise<*>}
|
|
313
|
+
*/
|
|
314
|
+
async getWorkouts(start, limit) {
|
|
315
|
+
return this.get(urls.workouts(), { start, limit });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Download original activity data to disk as zip
|
|
320
|
+
* Resolves to absolute path for the downloaded file
|
|
321
|
+
* @param activity : any
|
|
322
|
+
* @param dir Will default to current working directory
|
|
323
|
+
* @param type : string - Will default to 'zip'. Other possible values are 'tcx', 'gpx' or 'kml'.
|
|
324
|
+
* @returns {Promise<*>}
|
|
325
|
+
*/
|
|
326
|
+
async downloadOriginalActivityData(activity, dir, type = '') {
|
|
327
|
+
const { activityId } = activity || {};
|
|
328
|
+
if (activityId) {
|
|
329
|
+
const url = type === '' || type === 'zip'
|
|
330
|
+
? urls.originalFile(activityId)
|
|
331
|
+
: urls.exportFile(activityId, type);
|
|
332
|
+
return this.client.downloadBlob(dir, url);
|
|
333
|
+
}
|
|
334
|
+
return Promise.reject();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Uploads an activity file ('gpx', 'tcx', or 'fit')
|
|
339
|
+
* @param file the file to upload
|
|
340
|
+
* @param format the format of the file. If undefined, the extension of the file will be used.
|
|
341
|
+
* @returns {Promise<*>}
|
|
342
|
+
*/
|
|
343
|
+
async uploadActivity(file, format) {
|
|
344
|
+
// throw new Error('uploadActivity method is disabled in this version');
|
|
345
|
+
|
|
346
|
+
const detectedFormat = format || `.${file.split('.').pop()}`;
|
|
347
|
+
if (detectedFormat !== '.gpx' && detectedFormat !== '.tcx' && detectedFormat !== '.fit') {
|
|
348
|
+
Promise.reject();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const fileBinary = fs.createReadStream(file);
|
|
352
|
+
return this.client.post(urls.upload(format), {
|
|
353
|
+
file: fileBinary,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Adds a running workout with one step of completeing a set distance.
|
|
359
|
+
* @param name
|
|
360
|
+
* @param meters
|
|
361
|
+
* @param description
|
|
362
|
+
* @returns {Promise<*>}
|
|
363
|
+
*/
|
|
364
|
+
async addRunningWorkout(name, meters, description) {
|
|
365
|
+
const running = new Running();
|
|
366
|
+
running.name = name;
|
|
367
|
+
running.distance = meters;
|
|
368
|
+
running.description = description;
|
|
369
|
+
return this.addWorkout(running);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Add a new workout preset.
|
|
374
|
+
* @param workout
|
|
375
|
+
* @returns {Promise<*>}
|
|
376
|
+
*/
|
|
377
|
+
async addWorkout(workout) {
|
|
378
|
+
if (workout.isValid()) {
|
|
379
|
+
const data = { ...workout.toJson() };
|
|
380
|
+
if (!data.description) {
|
|
381
|
+
data.description = 'Added by garmin-connect for Node.js';
|
|
382
|
+
}
|
|
383
|
+
return this.post(urls.workout(), data);
|
|
384
|
+
}
|
|
385
|
+
return Promise.reject();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Add a workout to your workout calendar.
|
|
390
|
+
* @param workout
|
|
391
|
+
* @param date
|
|
392
|
+
* @returns {Promise<*>}
|
|
393
|
+
*/
|
|
394
|
+
async scheduleWorkout(workout, date) {
|
|
395
|
+
const { workoutId } = workout || {};
|
|
396
|
+
if (workoutId && date) {
|
|
397
|
+
const dateString = toDateString(date);
|
|
398
|
+
return this.post(urls.schedule(workoutId), { date: dateString });
|
|
399
|
+
}
|
|
400
|
+
return Promise.reject();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Delete a workout based on a workout object.
|
|
405
|
+
* @param workout
|
|
406
|
+
* @returns {Promise<*>}
|
|
407
|
+
*/
|
|
408
|
+
async deleteWorkout(workout) {
|
|
409
|
+
const { workoutId } = workout || {};
|
|
410
|
+
if (workoutId) {
|
|
411
|
+
const headers = { 'x-http-method-override': 'DELETE' };
|
|
412
|
+
return this.client.postJson(urls.workout(workoutId), undefined, undefined, headers);
|
|
413
|
+
}
|
|
414
|
+
return Promise.reject();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// General methods
|
|
418
|
+
|
|
419
|
+
async get(url, data) {
|
|
420
|
+
const response = await this.client.get(url, data);
|
|
421
|
+
this.triggerEvent(this.events.sessionChange, this.sessionJson);
|
|
422
|
+
return response;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async post(url, data) {
|
|
426
|
+
const response = await this.client.postJson(url, data);
|
|
427
|
+
this.triggerEvent(this.events.sessionChange, this.sessionJson);
|
|
428
|
+
return response;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async put(url, data) {
|
|
432
|
+
const response = await this.client.putJson(url, data);
|
|
433
|
+
this.triggerEvent(this.events.sessionChange, this.sessionJson);
|
|
434
|
+
return response;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
module.exports = GarminConnect;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const GC_MODERN = 'https://connect.garmin.com/modern';
|
|
2
|
+
const GARMIN_SSO_ORIGIN = 'https://sso.garmin.com';
|
|
3
|
+
const GARMIN_SSO = `${GARMIN_SSO_ORIGIN}/sso`;
|
|
4
|
+
const BASE_URL = `${GC_MODERN}/proxy`;
|
|
5
|
+
const SIGNIN_URL = `${GARMIN_SSO}/signin`;
|
|
6
|
+
const LOGIN_URL = `${GARMIN_SSO}/login`;
|
|
7
|
+
|
|
8
|
+
const ACTIVITY_SERVICE = `${BASE_URL}/activity-service`;
|
|
9
|
+
const ACTIVITYLIST_SERVICE = `${BASE_URL}/activitylist-service`;
|
|
10
|
+
const CURRENT_USER_SERVICE = `${GC_MODERN}/currentuser-service/user/info`;
|
|
11
|
+
const DEVICE_SERVICE = `${BASE_URL}/device-service`;
|
|
12
|
+
const DOWNLOAD_SERVICE = `${BASE_URL}/download-service`;
|
|
13
|
+
const USERPROFILE_SERVICE = `${BASE_URL}/userprofile-service`;
|
|
14
|
+
const WELLNESS_SERVICE = `${BASE_URL}/wellness-service`;
|
|
15
|
+
const WORKOUT_SERVICE = `${BASE_URL}/workout-service`;
|
|
16
|
+
const UPLOAD_SERVICE = `${BASE_URL}/upload-service`;
|
|
17
|
+
|
|
18
|
+
const USER_SETTINGS = `${USERPROFILE_SERVICE}/userprofile/user-settings/`;
|
|
19
|
+
|
|
20
|
+
const activity = (id) => `${ACTIVITY_SERVICE}/activity/${id}`;
|
|
21
|
+
|
|
22
|
+
const weather = (id) => `${activity(id)}/weather`;
|
|
23
|
+
|
|
24
|
+
const activityDetails = (id) => `${activity(id)}/details`;
|
|
25
|
+
|
|
26
|
+
const activities = () => `${ACTIVITYLIST_SERVICE}/activities/search/activities`;
|
|
27
|
+
|
|
28
|
+
const dailyHeartRate = (userHash) => `${WELLNESS_SERVICE}/wellness/dailyHeartRate/${userHash}`;
|
|
29
|
+
|
|
30
|
+
const dailySleep = () => `${WELLNESS_SERVICE}/wellness/dailySleep`;
|
|
31
|
+
|
|
32
|
+
const dailySleepData = (userHash) => `${WELLNESS_SERVICE}/wellness/dailySleepData/${userHash}`;
|
|
33
|
+
|
|
34
|
+
const dailySummaryChart = (userHash) => `${WELLNESS_SERVICE}/wellness/dailySummaryChart/${userHash}`;
|
|
35
|
+
|
|
36
|
+
const deviceInfo = (userHash) => `${DEVICE_SERVICE}/deviceservice/device-info/all/${userHash}`;
|
|
37
|
+
|
|
38
|
+
const schedule = (id) => `${WORKOUT_SERVICE}/schedule/${id}`;
|
|
39
|
+
|
|
40
|
+
const userInfo = () => CURRENT_USER_SERVICE;
|
|
41
|
+
|
|
42
|
+
const socialProfile = (userHash) => `${USERPROFILE_SERVICE}/socialProfile/${userHash}`;
|
|
43
|
+
|
|
44
|
+
const userSettings = () => USER_SETTINGS;
|
|
45
|
+
|
|
46
|
+
const originalFile = (id) => `${DOWNLOAD_SERVICE}/files/activity/${id}`;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
*
|
|
50
|
+
* @param id {string}
|
|
51
|
+
* @param type "tcx" | "gpx" | "kml"
|
|
52
|
+
* @return {`${string}/export/${string}/activity/${string}`}
|
|
53
|
+
*/
|
|
54
|
+
const exportFile = (id, type) => `${DOWNLOAD_SERVICE}/export/${type}/activity/${id}`;
|
|
55
|
+
|
|
56
|
+
const workout = (id) => {
|
|
57
|
+
if (id) {
|
|
58
|
+
return `${WORKOUT_SERVICE}/workout/${id}`;
|
|
59
|
+
}
|
|
60
|
+
return `${WORKOUT_SERVICE}/workout`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const workouts = () => `${WORKOUT_SERVICE}/workouts`;
|
|
64
|
+
|
|
65
|
+
const socialConnections = (userHash) => `${USERPROFILE_SERVICE}/socialProfile/connections/${userHash}`;
|
|
66
|
+
|
|
67
|
+
const newsFeed = () => `${ACTIVITYLIST_SERVICE}/activities/subscriptionFeed`;
|
|
68
|
+
|
|
69
|
+
const upload = (format) => `${UPLOAD_SERVICE}/upload/${format}`;
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
GC_MODERN,
|
|
73
|
+
GARMIN_SSO_ORIGIN,
|
|
74
|
+
GARMIN_SSO,
|
|
75
|
+
BASE_URL,
|
|
76
|
+
SIGNIN_URL,
|
|
77
|
+
LOGIN_URL,
|
|
78
|
+
CURRENT_USER_SERVICE,
|
|
79
|
+
USERPROFILE_SERVICE,
|
|
80
|
+
WELLNESS_SERVICE,
|
|
81
|
+
WORKOUT_SERVICE,
|
|
82
|
+
activity,
|
|
83
|
+
weather,
|
|
84
|
+
activityDetails,
|
|
85
|
+
activities,
|
|
86
|
+
dailyHeartRate,
|
|
87
|
+
dailySleep,
|
|
88
|
+
dailySleepData,
|
|
89
|
+
dailySummaryChart,
|
|
90
|
+
deviceInfo,
|
|
91
|
+
schedule,
|
|
92
|
+
userInfo,
|
|
93
|
+
socialProfile,
|
|
94
|
+
userSettings,
|
|
95
|
+
workout,
|
|
96
|
+
workouts,
|
|
97
|
+
originalFile,
|
|
98
|
+
exportFile,
|
|
99
|
+
socialConnections,
|
|
100
|
+
newsFeed,
|
|
101
|
+
upload,
|
|
102
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const template = require('./templates/RunningTemplate');
|
|
2
|
+
|
|
3
|
+
class Running {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.data = template();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
get name() {
|
|
9
|
+
return this.data.workoutName;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
set name(name) {
|
|
13
|
+
this.data.workoutName = `${name}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get distance() {
|
|
17
|
+
return this.data.workoutSegments[0].workoutSteps[0].endConditionValue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
set distance(meters) {
|
|
21
|
+
this.data.workoutSegments[0].workoutSteps[0].endConditionValue = Math.round(meters);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get workoutId() {
|
|
25
|
+
return this.data.workoutId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
set workoutId(workoutId) {
|
|
29
|
+
this.data.workoutId = workoutId;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get description() {
|
|
33
|
+
return this.data.description;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
set description(description) {
|
|
37
|
+
this.data.description = description;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
isValid() {
|
|
41
|
+
return !!(this.name && this.distance);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
toJson() {
|
|
45
|
+
return this.data;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
toString() {
|
|
49
|
+
return `${this.name}, ${(this.distance / 1000).toFixed(2)}km`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = Running;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module.exports = () => (
|
|
2
|
+
{
|
|
3
|
+
sportType: {
|
|
4
|
+
sportTypeId: 1,
|
|
5
|
+
sportTypeKey: 'running',
|
|
6
|
+
},
|
|
7
|
+
workoutName: null,
|
|
8
|
+
workoutSegments: [
|
|
9
|
+
{
|
|
10
|
+
segmentOrder: 1,
|
|
11
|
+
sportType: {
|
|
12
|
+
sportTypeId: 1,
|
|
13
|
+
sportTypeKey: 'running',
|
|
14
|
+
},
|
|
15
|
+
workoutSteps: [
|
|
16
|
+
{
|
|
17
|
+
type: 'ExecutableStepDTO',
|
|
18
|
+
stepId: null,
|
|
19
|
+
stepOrder: 1,
|
|
20
|
+
childStepId: null,
|
|
21
|
+
description: null,
|
|
22
|
+
stepType: {
|
|
23
|
+
stepTypeId: 3,
|
|
24
|
+
stepTypeKey: 'interval',
|
|
25
|
+
},
|
|
26
|
+
endCondition: {
|
|
27
|
+
conditionTypeKey: 'distance',
|
|
28
|
+
conditionTypeId: 3,
|
|
29
|
+
},
|
|
30
|
+
preferredEndConditionUnit: {
|
|
31
|
+
unitKey: 'kilometer',
|
|
32
|
+
},
|
|
33
|
+
endConditionValue: null,
|
|
34
|
+
endConditionCompare: null,
|
|
35
|
+
endConditionZone: null,
|
|
36
|
+
targetType: {
|
|
37
|
+
workoutTargetTypeId: 1,
|
|
38
|
+
workoutTargetTypeKey: 'no.target',
|
|
39
|
+
},
|
|
40
|
+
targetValueOne: null,
|
|
41
|
+
targetValueTwo: null,
|
|
42
|
+
zoneNumber: null,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
);
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const { GarminConnect } = require('garmin-connect');
|
|
2
|
+
|
|
3
|
+
// Has to be run in an async function to be able to use the await keyword
|
|
4
|
+
const main = async () => {
|
|
5
|
+
// Create a new Garmin Connect Client
|
|
6
|
+
const GCClient = new GarminConnect();
|
|
7
|
+
|
|
8
|
+
// Uses credentials from garmin.config.json or uses supplied params
|
|
9
|
+
await GCClient.login('my.email@example.com', 'MySecretPassword');
|
|
10
|
+
|
|
11
|
+
// Get user info
|
|
12
|
+
const info = await GCClient.getUserInfo();
|
|
13
|
+
|
|
14
|
+
// Log info to make sure signin was successful
|
|
15
|
+
console.log(info);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Run the code
|
|
19
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gooin/garmin-connect",
|
|
3
|
+
"version": "1.4.4",
|
|
4
|
+
"description": "Makes it simple to interface with Garmin Connect to get or set any data point",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "rm -rf ./dist/ && mkdir ./dist/ && cp -r ./src/* ./dist/",
|
|
8
|
+
"prepack": "npm run build"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/Pythe1337N/garmin-connect.git"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"garmin",
|
|
16
|
+
"connect",
|
|
17
|
+
"scraper",
|
|
18
|
+
"weight",
|
|
19
|
+
"running",
|
|
20
|
+
"workout",
|
|
21
|
+
"activity",
|
|
22
|
+
"activities",
|
|
23
|
+
"download",
|
|
24
|
+
"downloader"
|
|
25
|
+
],
|
|
26
|
+
"author": "Oskar Bernberg (Pythe1337N)",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"files": [
|
|
29
|
+
"dist/*",
|
|
30
|
+
"examples/*"
|
|
31
|
+
],
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"eslint": "^8.13.0",
|
|
34
|
+
"eslint-config-airbnb-base": "^15.0.0",
|
|
35
|
+
"eslint-plugin-import": "^2.26.0",
|
|
36
|
+
"eslint-plugin-jest": "^26.1.4",
|
|
37
|
+
"pre-commit": "^1.2.2"
|
|
38
|
+
},
|
|
39
|
+
"precommit": "build",
|
|
40
|
+
"homepage": "https://github.com/Pythe1337N/garmin-connect#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/Pythe1337N/garmin-connect/issues"
|
|
43
|
+
},
|
|
44
|
+
"runkitExampleFilename": "./examples/example.js",
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"app-root-path": "^3.0.0",
|
|
47
|
+
"axios": "^0.26.1",
|
|
48
|
+
"cloudscraper": "^4.6.0",
|
|
49
|
+
"form-data": "^4.0.0",
|
|
50
|
+
"qs": "^6.10.3",
|
|
51
|
+
"request": "^2.88.2"
|
|
52
|
+
}
|
|
53
|
+
}
|