iobroker.anthbot 0.0.3 → 0.0.5
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 +1 -0
- package/README.md +44 -0
- package/io-package.json +38 -4
- package/lib/anthbotApi.js +187 -111
- package/main.js +304 -368
- package/package.json +6 -7
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -14,11 +14,53 @@
|
|
|
14
14
|
|
|
15
15
|
Connect with Anthbot devices such as their robot mowers.
|
|
16
16
|
|
|
17
|
+
### Monitoring
|
|
18
|
+
|
|
19
|
+
Battery level is reported in the `elec` state.
|
|
20
|
+
|
|
21
|
+
View a device's status in the `mode` state (charging, mowing, standby, etc).
|
|
22
|
+
|
|
23
|
+
The last status message & it's severity (event, error, etc) are shown in the `last_code`, `last_code_text` and `last_code_type` states. For users looking for more history, the `code_list` state holds a JSON array with the a larger number of messages.
|
|
24
|
+
|
|
25
|
+
The rest of the states should be self explanatory.
|
|
26
|
+
|
|
27
|
+
### Commands
|
|
28
|
+
|
|
29
|
+
`stop_all_tasks` equates to hiting 'Stop' in the Anthbot app.
|
|
30
|
+
|
|
31
|
+
`charge_start` equates to the 'Recharge' icon in the Anthbot app.
|
|
32
|
+
|
|
33
|
+
`mow_start` equates to start when in 'Full maps' mode.
|
|
34
|
+
|
|
35
|
+
`custom_area_mow_start` equates to custom area (aka 'Zones') mode. For this to work a valid list of area IDs must already be set in the `area_list` state. Area IDs are not the same as names the Anthbot app shows. Valid area IDs can be found in the `map.custom_areas.raw` state (this will be improved later).
|
|
36
|
+
|
|
37
|
+
Ie. to start mowing one or more zones:
|
|
38
|
+
|
|
39
|
+
- Set the `area_list` state to an array of IDs. Eg: `[102, 117]`
|
|
40
|
+
- Trigger the `custom_area_mow_start` state.
|
|
41
|
+
|
|
42
|
+
`ridable_mow_start` equates to edge mowing mode. As with `custom_area_mow_start`, set the `area_list` with a valid list of ridable areas (aka. edge) IDs. Valid ridable area IDs can be found in the `map.ridable_areas.raw` state.
|
|
43
|
+
|
|
44
|
+
### Map & area (aka. zone) editing
|
|
45
|
+
|
|
46
|
+
With `area_set` it is possible to edit one or more areas. Take the JSON representation from a desired entry from the `map.custom_areas.raw_list`, modify it as required and save this the `area_set` state as a JSON array.
|
|
47
|
+
|
|
48
|
+
Note that when using `area_set` it is not necessary to define all parameters and only those provided will be changed. Eg: `[{"mow_head":10,"id":117}]` will change the angle of mowing in area 117 to 10 degrees and leave the other parameters as is.
|
|
49
|
+
|
|
17
50
|
## Changelog
|
|
18
51
|
<!--
|
|
19
52
|
Placeholder for the next version (at the beginning of the line):
|
|
20
53
|
### **WORK IN PROGRESS**
|
|
21
54
|
-->
|
|
55
|
+
### 0.0.5 (2026-05-20)
|
|
56
|
+
- (raintonr) Added brief usage tips in readme
|
|
57
|
+
- (raintonr) Added active_area, code_list & map states and ridable_mow_start command
|
|
58
|
+
|
|
59
|
+
### 0.0.4 (2026-05-18)
|
|
60
|
+
- (copilot) Adapter requires node.js >= 22 now
|
|
61
|
+
- (copilot) Adapter requires admin >= 7.7.22 now
|
|
62
|
+
- (raintonr) Handle temporary IoT access tokens (#9)
|
|
63
|
+
|
|
22
64
|
### 0.0.3 (2026-04-25)
|
|
23
65
|
* (raintonr) adapter checker issues
|
|
24
66
|
|
|
@@ -28,6 +70,8 @@ Connect with Anthbot devices such as their robot mowers.
|
|
|
28
70
|
## License
|
|
29
71
|
MIT License
|
|
30
72
|
|
|
73
|
+
|
|
74
|
+
Copyright (c) 2026 iobroker-community-adapters <iobroker-community-adapters@gmx.de>
|
|
31
75
|
Copyright (c) 2026 Robin Rainton <robin@rainton.com>
|
|
32
76
|
|
|
33
77
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
package/io-package.json
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "anthbot",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.5",
|
|
5
5
|
"news": {
|
|
6
|
+
"0.0.5": {
|
|
7
|
+
"en": "Added brief usage tips in readme\nAdded active_area, code_list & map states and ridable_mow_start command",
|
|
8
|
+
"de": "Kurze Nutzungstipps in readme hinzugefügt\nActive area, code list & map Zustände und ridable mow start Befehl hinzugefügt",
|
|
9
|
+
"ru": "Добавлены краткие советы по использованию в Readme\nДобавлено состояние active area, code list & map и команда ridable mow start",
|
|
10
|
+
"pt": "Adicionado breves dicas de uso no readme\nAdicionado ative area, code list & map states and ridable mow start command",
|
|
11
|
+
"nl": "Korte gebruik tips toegevoegd in readme\nActive area, code list & map states en ridable mow start commando toegevoegd",
|
|
12
|
+
"fr": "Ajout de brèves conseils d'utilisation dans readme\nAjout de la commande active area, code list & map et ridable mow start",
|
|
13
|
+
"it": "Aggiunto breve utilizzo suggerimenti in readme\nAggiunto active area, code list & mappa stati e comando ridable mow start",
|
|
14
|
+
"es": "Consejos de uso breves adicionales en el readme\nAñadido active area, code list & map states and ridable mow start command",
|
|
15
|
+
"pl": "Dodano krótkie wskazówki użycia w readme\nDodano active _ area, code _ list & map states oraz polecenie ridable _ mow _ start",
|
|
16
|
+
"uk": "Додайте поради щодо короткого використання в читме\nДодано активний area, code list & map States і ridable mow start команди",
|
|
17
|
+
"zh-cn": "在readme中添加简短的使用提示\n添加活动区域、 代码列表状态和可清除的 mow start 命令"
|
|
18
|
+
},
|
|
19
|
+
"0.0.4": {
|
|
20
|
+
"en": "Adapter requires node.js >= 22 now\nAdapter requires admin >= 7.7.22 now\nHandle temporary IoT access tokens (#9)",
|
|
21
|
+
"de": "Adapter benötigt node.js >= 22 jetzt\nAdapter benötigt admin >= 7.7.22 jetzt\nHandle temporäre IoT-Zugriffstoken (#9)",
|
|
22
|
+
"ru": "Адаптер требует node.js >= 22 сейчас\nАдаптер требует администратора >= 7.7.22\nОбработка временных токенов доступа к IoT (#9)",
|
|
23
|
+
"pt": "Adaptador requer nod.js >= 22 agora\nAdaptador requer admin >= 7.7.22 agora\nLidar com tokens de acesso temporário de IoT (# 9)",
|
|
24
|
+
"nl": "Voor de adapter zijn node.js < 22 nu nodig\nAdapter vereist admin < 7.7.22 nu\nTijdelijke IoT toegang tokens hanteren (#9)",
|
|
25
|
+
"fr": "Adaptateur nécessite node.js >= 22 maintenant\nAdaptateur nécessite admin >= 7.7.22 maintenant\nPoignez des jetons d'accès IoT temporaires (#9)",
|
|
26
|
+
"it": "Adattatore richiede node.js >= 22 ora\nAdattatore richiede admin >= 7.7.22 ora\nMantenere i gettoni di accesso IoT temporanei (#9)",
|
|
27
|
+
"es": "Adaptador requiere node.js ю= 22 ahora\nEl adaptador requiere administrador= 7.7.22 ahora\nHandle temporary IoT access tokens (#9)",
|
|
28
|
+
"pl": "Adapter wymaga node.js > = 22\nAdapter wymaga admin > = 7.7.22\nObsługa tymczasowych żetonów dostępu IoT (# 9)",
|
|
29
|
+
"uk": "Адаптер вимагає node.js >= 22 тепер\nАдаптер вимагає адмін >= 7.7.22 тепер\nЗручності для доступу до Інтернету речей (#9)",
|
|
30
|
+
"zh-cn": "适配器需要节点.js 现在22\n适任者需要管理员 \\ 7.7.22 现在\n处理临时 IOT 访问符 (# 9)"
|
|
31
|
+
},
|
|
6
32
|
"0.0.3": {
|
|
7
33
|
"en": "adapter checker issues",
|
|
8
34
|
"de": "adapter checker probleme",
|
|
@@ -57,7 +83,8 @@
|
|
|
57
83
|
"zh-cn": "与 Anthbot 设备连接,例如机器人割草机"
|
|
58
84
|
},
|
|
59
85
|
"authors": [
|
|
60
|
-
"Robin Rainton <robin@rainton.com>"
|
|
86
|
+
"Robin Rainton <robin@rainton.com>",
|
|
87
|
+
"iobroker-community-adapters <iobroker-community-adapters@gmx.de>"
|
|
61
88
|
],
|
|
62
89
|
"keywords": [
|
|
63
90
|
"robot",
|
|
@@ -92,15 +119,22 @@
|
|
|
92
119
|
],
|
|
93
120
|
"globalDependencies": [
|
|
94
121
|
{
|
|
95
|
-
"admin": ">=7.
|
|
122
|
+
"admin": ">=7.7.22"
|
|
96
123
|
}
|
|
97
124
|
]
|
|
98
125
|
},
|
|
99
126
|
"native": {
|
|
100
127
|
"username": "",
|
|
101
128
|
"password": "",
|
|
102
|
-
"regionCode":
|
|
129
|
+
"regionCode": 61
|
|
103
130
|
},
|
|
131
|
+
"encryptedNative": [
|
|
132
|
+
"password"
|
|
133
|
+
],
|
|
134
|
+
"protectedNative": [
|
|
135
|
+
"username",
|
|
136
|
+
"password"
|
|
137
|
+
],
|
|
104
138
|
"objects": [],
|
|
105
139
|
"instanceObjects": [
|
|
106
140
|
{
|
package/lib/anthbotApi.js
CHANGED
|
@@ -34,7 +34,7 @@ const REQUEST_TIMEOUT = 15000; // 15 seconds
|
|
|
34
34
|
// Shared methods
|
|
35
35
|
class AnthotCloudApi {
|
|
36
36
|
/**
|
|
37
|
-
* @param {{ verboseLogger?: ((message: string) => void) | null}} options
|
|
37
|
+
* @param {{ verboseLogger?: ((message: string) => void) | null }} options - Configuration options
|
|
38
38
|
*/
|
|
39
39
|
constructor({ verboseLogger = null }) {
|
|
40
40
|
this.verboseLogger = verboseLogger;
|
|
@@ -117,7 +117,7 @@ class AnthotCloudApi {
|
|
|
117
117
|
* Client for Anthbot cloud account endpoints
|
|
118
118
|
*/
|
|
119
119
|
class AnthbotCloudApiClient {
|
|
120
|
-
/** @param {{ verboseLogger?: ((message: string) => void) | null }} options */
|
|
120
|
+
/** @param {{ verboseLogger?: ((message: string) => void) | null }} options - Configuration options */
|
|
121
121
|
constructor({ verboseLogger = null }) {
|
|
122
122
|
this.endpointHost = DEFAULT_API_HOST;
|
|
123
123
|
this.authHeaders = {
|
|
@@ -134,8 +134,12 @@ class AnthbotCloudApiClient {
|
|
|
134
134
|
|
|
135
135
|
/**
|
|
136
136
|
* Login and return bearer token
|
|
137
|
+
*
|
|
138
|
+
* @param {string} username - Username
|
|
139
|
+
* @param {string} password - Password
|
|
140
|
+
* @param {number} areaCode - Country/region code
|
|
137
141
|
*/
|
|
138
|
-
async asyncLogin(
|
|
142
|
+
async asyncLogin(username, password, areaCode) {
|
|
139
143
|
const url = `https://${this.endpointHost}/api/v1/login`;
|
|
140
144
|
const headers = {
|
|
141
145
|
Accept: 'application/json, text/plain, */*',
|
|
@@ -158,15 +162,14 @@ class AnthbotCloudApiClient {
|
|
|
158
162
|
|
|
159
163
|
let data;
|
|
160
164
|
try {
|
|
161
|
-
data = /** @type {{
|
|
165
|
+
data = /** @type {{code: number, data?: any}} */ (await response.json());
|
|
162
166
|
} catch {
|
|
163
167
|
throw new Error('Invalid JSON response from login');
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
if (typeof data !== 'object' || data === null) {
|
|
167
171
|
throw new Error('Invalid login payload type');
|
|
168
|
-
}
|
|
169
|
-
if (data.code !== 0) {
|
|
172
|
+
} else if (data.code !== 0) {
|
|
170
173
|
throw new Error(`Login rejected: code=${JSON.stringify(data.code)}`);
|
|
171
174
|
}
|
|
172
175
|
|
|
@@ -183,56 +186,83 @@ class AnthbotCloudApiClient {
|
|
|
183
186
|
const bearerToken = `Bearer ${accessToken}`;
|
|
184
187
|
this.bearerToken = bearerToken;
|
|
185
188
|
this.authHeaders['Authorization'] = bearerToken;
|
|
186
|
-
return bearerToken;
|
|
187
189
|
}
|
|
188
190
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Fetch JSON and resolve the service-level payload data.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} url URL to fetch from
|
|
195
|
+
* @param {RequestInit} [options] Request options (method, headers, body, etc.)
|
|
196
|
+
* @returns {Promise<any>} Payload data from the response
|
|
197
|
+
*/
|
|
198
|
+
async fetchPayloadData(url, options = {}) {
|
|
199
|
+
// GET is default
|
|
200
|
+
if (!options.method) {
|
|
201
|
+
options.method = 'GET';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Always add our auth headers
|
|
205
|
+
options.headers = { ...this.authHeaders, ...options.headers };
|
|
206
|
+
|
|
207
|
+
// Check headers given (or added above) have the Authorization (sic.)
|
|
208
|
+
if (!Object.prototype.hasOwnProperty.call(options.headers, 'Authorization')) {
|
|
209
|
+
throw new Error('Missing Authorization header');
|
|
192
210
|
}
|
|
211
|
+
|
|
212
|
+
const response = await this.fetch(url, options);
|
|
213
|
+
if (response.status !== 200) {
|
|
214
|
+
const body = await response.text();
|
|
215
|
+
throw new Error(`Request to ${url} failed (${response.status}): ${body.slice(0, 300)}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let payload;
|
|
219
|
+
try {
|
|
220
|
+
payload = /** @type {{code: number, data?: any}} */ (await response.json());
|
|
221
|
+
} catch {
|
|
222
|
+
throw new Error(`Invalid JSON response from ${url}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
226
|
+
throw new Error(`Invalid API payload from ${url}`);
|
|
227
|
+
} else if (payload.code !== 0) {
|
|
228
|
+
throw new Error(`API returned code=${payload.code} from ${url}`);
|
|
229
|
+
}
|
|
230
|
+
return payload.data;
|
|
193
231
|
}
|
|
194
232
|
|
|
195
233
|
/**
|
|
196
234
|
* Fetch account-bound Anthbot devices
|
|
235
|
+
*
|
|
236
|
+
* @returns {Promise<{alias: string, sn: string}[]>} - List of devices
|
|
197
237
|
*/
|
|
198
238
|
async asyncGetBoundDevices() {
|
|
199
|
-
this.checkToken();
|
|
200
|
-
|
|
201
239
|
const url = `https://${this.endpointHost}/api/v1/device/bind/list`;
|
|
202
|
-
const response = await this.fetch(url, {
|
|
203
|
-
method: 'GET',
|
|
204
|
-
headers: this.authHeaders,
|
|
205
|
-
});
|
|
206
240
|
|
|
207
|
-
|
|
208
|
-
throw new Error(`Request to ${url} failed, response ${response.status}`);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
return /** @type {{ data: {alias: string, sn: string}[] }} */ (await response.json()).data;
|
|
241
|
+
return /** @type {{alias: string, sn: string}[]} */ (await this.fetchPayloadData(url));
|
|
212
242
|
}
|
|
213
243
|
|
|
214
244
|
/**
|
|
215
245
|
* Fetch latest messages
|
|
216
246
|
* Returns only last message by default
|
|
247
|
+
*
|
|
248
|
+
* @param {string} serialNumber - Device serial number
|
|
249
|
+
* @param {number} pageNum - Page number
|
|
250
|
+
* @param {number} pageSize - Page size
|
|
251
|
+
* @returns {Promise<unknown>} Latest messages
|
|
217
252
|
*/
|
|
218
253
|
async asyncGetCodeList(serialNumber, pageNum = 1, pageSize = 1) {
|
|
219
|
-
this.checkToken();
|
|
220
|
-
|
|
221
254
|
// TODO: allow language other than English?
|
|
222
255
|
const url = `https://${this.endpointHost}/api/v1/device/v2/code/list?sn=${serialNumber}&pagenum=${pageNum}&pagesize=${pageSize}&language=English`;
|
|
223
256
|
|
|
224
|
-
|
|
225
|
-
method: 'GET',
|
|
226
|
-
headers: this.authHeaders,
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
if (response.status !== 200) {
|
|
230
|
-
throw new Error(`Request to ${url} failed, response ${response.status}`);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
return /** @type {{ data: { data: unknown } }} */ (await response.json())?.data?.data;
|
|
257
|
+
return /** @type {unknown} */ ((await this.fetchPayloadData(url))?.data);
|
|
234
258
|
}
|
|
235
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Get verification token for presigned URL retrieval
|
|
262
|
+
*
|
|
263
|
+
* @param {string} serialNumber - Device serial number
|
|
264
|
+
* @returns {string} - Verification token
|
|
265
|
+
*/
|
|
236
266
|
buildVerificationToken(serialNumber) {
|
|
237
267
|
const unixTimestamp = Math.floor(Date.now() / 1000);
|
|
238
268
|
const tokenSuffix = unixTimestamp.toString();
|
|
@@ -243,42 +273,31 @@ class AnthbotCloudApiClient {
|
|
|
243
273
|
/**
|
|
244
274
|
* Fetch account-bound Anthbot devices
|
|
245
275
|
*
|
|
246
|
-
* @param {string}
|
|
247
|
-
* @param {string} filename
|
|
248
|
-
* @param {string} category
|
|
249
|
-
* @param {string} sub_category
|
|
250
|
-
* @returns {Promise<string>} URL for the requested file
|
|
276
|
+
* @param {string} serialNumber - Device serial number
|
|
277
|
+
* @param {string} filename - File name
|
|
278
|
+
* @param {string} category - Category
|
|
279
|
+
* @param {string} sub_category - Sub category
|
|
280
|
+
* @returns {Promise<string>} - URL for the requested file
|
|
251
281
|
*/
|
|
252
|
-
async asyncGetPresignedUrl(
|
|
253
|
-
this.checkToken();
|
|
254
|
-
|
|
282
|
+
async asyncGetPresignedUrl(serialNumber, filename, category, sub_category) {
|
|
255
283
|
const params = new URLSearchParams({
|
|
256
284
|
filename,
|
|
257
|
-
sn,
|
|
285
|
+
sn: serialNumber,
|
|
258
286
|
category,
|
|
259
287
|
sub_category,
|
|
260
|
-
verification_token: this.buildVerificationToken(
|
|
288
|
+
verification_token: this.buildVerificationToken(serialNumber),
|
|
261
289
|
});
|
|
262
290
|
const url = `https://${this.endpointHost}/api/v1/device/v2/presigned_url?${params}`;
|
|
263
|
-
const response = await this.fetch(url, {
|
|
264
|
-
method: 'GET',
|
|
265
|
-
headers: this.authHeaders,
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
if (response.status !== 200) {
|
|
269
|
-
throw new Error(`Request to ${url} failed, response ${response.status}`);
|
|
270
|
-
}
|
|
271
291
|
|
|
272
|
-
return /** @type {
|
|
292
|
+
return /** @type {string} */ ((await this.fetchPayloadData(url))?.presigned_url);
|
|
273
293
|
}
|
|
274
294
|
|
|
275
295
|
/**
|
|
276
296
|
* Decodes a TAR.GZ archive from a Buffer and returns an array of file entries with filename, type and content.
|
|
277
297
|
*
|
|
278
|
-
* @param {Buffer} buffer
|
|
279
|
-
* @returns {Promise<Array<{ filename: string; type: 'json' | 'blob'; content: unknown }>>}
|
|
298
|
+
* @param {Buffer} buffer - The TAR.GZ buffer to decode
|
|
299
|
+
* @returns {Promise<Array<{ filename: string; type: 'json' | 'blob'; content: unknown }>>} - Array of file contents
|
|
280
300
|
*/
|
|
281
|
-
|
|
282
301
|
decodeTgzBuffer(buffer) {
|
|
283
302
|
return new Promise((resolve, reject) => {
|
|
284
303
|
const results = [];
|
|
@@ -337,6 +356,12 @@ class AnthbotCloudApiClient {
|
|
|
337
356
|
});
|
|
338
357
|
}
|
|
339
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Fetch device map
|
|
361
|
+
*
|
|
362
|
+
* @param {string} serialNumber - Device serial number
|
|
363
|
+
* @returns {Promise<Array<{ filename: string; type: 'json' | 'blob'; content: unknown }>>} - Decoded map data
|
|
364
|
+
*/
|
|
340
365
|
async asyncGetDeviceMap(serialNumber) {
|
|
341
366
|
const url = await this.asyncGetPresignedUrl(
|
|
342
367
|
serialNumber,
|
|
@@ -360,43 +385,19 @@ class AnthbotCloudApiClient {
|
|
|
360
385
|
|
|
361
386
|
/**
|
|
362
387
|
* Fetch device cloud region metadata
|
|
388
|
+
*
|
|
389
|
+
* @param {string} serialNumber - Device serial number
|
|
390
|
+
* @returns {Promise<{regionName: string; iotEndpoint: string}>} - Region metadata
|
|
363
391
|
*/
|
|
364
392
|
async asyncGetDeviceRegion(serialNumber) {
|
|
365
393
|
if (this.verboseLogger) {
|
|
366
394
|
this.verboseLogger(`Cache miss - fetching device region for ${serialNumber}`);
|
|
367
395
|
}
|
|
368
396
|
|
|
369
|
-
this.checkToken();
|
|
370
|
-
|
|
371
397
|
const url = `https://${this.endpointHost}/api/v1/device/v2/region`;
|
|
372
398
|
const params = new URLSearchParams({ sn: serialNumber });
|
|
373
|
-
const
|
|
374
|
-
method: 'GET',
|
|
375
|
-
headers: this.authHeaders,
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
if (response.status !== 200) {
|
|
379
|
-
const body = await response.text();
|
|
380
|
-
throw new Error(`Device region failed (${response.status}): ${body.slice(0, 300)}`);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
let payload;
|
|
384
|
-
try {
|
|
385
|
-
payload = /** @type {{ code: number; data: {region_name: string; iot_endpoint: string } }} */ (
|
|
386
|
-
await response.json()
|
|
387
|
-
);
|
|
388
|
-
} catch {
|
|
389
|
-
throw new Error('Invalid JSON response from device region');
|
|
390
|
-
}
|
|
399
|
+
const data = await this.fetchPayloadData(`${url}?${params}`);
|
|
391
400
|
|
|
392
|
-
if (typeof payload !== 'object' || payload === null) {
|
|
393
|
-
throw new Error('Invalid device region payload type');
|
|
394
|
-
}
|
|
395
|
-
if (payload.code !== 0) {
|
|
396
|
-
throw new Error(`Device region returned code=${payload.code}`);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const data = payload.data;
|
|
400
401
|
if (typeof data !== 'object' || data === null) {
|
|
401
402
|
throw new Error('Device region payload missing data object');
|
|
402
403
|
}
|
|
@@ -417,6 +418,52 @@ class AnthbotCloudApiClient {
|
|
|
417
418
|
return deviceRegion;
|
|
418
419
|
}
|
|
419
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Retrieve temporary IoT credentials for a device.
|
|
423
|
+
*
|
|
424
|
+
* @param {string} serialNumber - Device serial number
|
|
425
|
+
* @returns {Promise<object>} - IoT credentials and expiration
|
|
426
|
+
*/
|
|
427
|
+
async asyncGetDeviceIotCredentials(serialNumber) {
|
|
428
|
+
const params = {
|
|
429
|
+
sn: serialNumber,
|
|
430
|
+
verification_token: this.buildVerificationToken(serialNumber),
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const data = await this.fetchPayloadData(`https://${this.endpointHost}/api/v1/device/v2/iot/sts/arn`, {
|
|
434
|
+
method: 'POST',
|
|
435
|
+
headers: { 'content-type': 'application/json' },
|
|
436
|
+
body: JSON.stringify(params),
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
if (typeof data !== 'object' || data === null) {
|
|
440
|
+
throw new Error('IoT credentials payload missing data object');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const expiration = data.expiration;
|
|
444
|
+
const expiresAt =
|
|
445
|
+
expiration == null ? null : expiration > 2000000000 ? expiration * 1000 : Date.now() + expiration * 1000;
|
|
446
|
+
|
|
447
|
+
const iotCredentials = {
|
|
448
|
+
accessKeyId: data.access_key_id,
|
|
449
|
+
secretAccessKey: data.secret_access_key,
|
|
450
|
+
sessionToken: data.session_token,
|
|
451
|
+
regionName: data.region_name,
|
|
452
|
+
endpoint: data.endpoint,
|
|
453
|
+
expiresAt,
|
|
454
|
+
};
|
|
455
|
+
if (this.verboseLogger) {
|
|
456
|
+
this.verboseLogger(`IoT credentials for ${serialNumber}: ${JSON.stringify(iotCredentials)}`);
|
|
457
|
+
}
|
|
458
|
+
return iotCredentials;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Ensure shadow client is initialized
|
|
463
|
+
*
|
|
464
|
+
* @param {string} serialNumber - Device serial number
|
|
465
|
+
* @returns {Promise<void>}
|
|
466
|
+
*/
|
|
420
467
|
async checkShadowClient(serialNumber) {
|
|
421
468
|
// TODO: Handle multiple devices with different shadow clients instead of caching just one
|
|
422
469
|
if (!this.shadowClient) {
|
|
@@ -431,13 +478,40 @@ class AnthbotCloudApiClient {
|
|
|
431
478
|
verboseLogger: this.verboseLogger,
|
|
432
479
|
});
|
|
433
480
|
}
|
|
481
|
+
|
|
482
|
+
// Get new credentials if we have none or they have expired
|
|
483
|
+
if (
|
|
484
|
+
!this.iotCredentials ||
|
|
485
|
+
this.iotCredentials.expiresAt === null ||
|
|
486
|
+
this.iotCredentials.expiresAt - Date.now() <= 60000
|
|
487
|
+
) {
|
|
488
|
+
if (this.verboseLogger) {
|
|
489
|
+
this.verboseLogger(`No/expired IoT Credentaials for ${serialNumber}, fetching them`);
|
|
490
|
+
}
|
|
491
|
+
this.iotCredentials = await this.asyncGetDeviceIotCredentials(serialNumber);
|
|
492
|
+
this.shadowClient.iotCredentials = this.iotCredentials;
|
|
493
|
+
}
|
|
434
494
|
}
|
|
435
495
|
|
|
496
|
+
/**
|
|
497
|
+
* Get shadow reported state
|
|
498
|
+
*
|
|
499
|
+
* @param {string} serialNumber - Device serial number
|
|
500
|
+
* @returns {Promise<any>} - Reported state
|
|
501
|
+
*/
|
|
436
502
|
async asyncGetShadowReportedState(serialNumber) {
|
|
437
503
|
await this.checkShadowClient(serialNumber);
|
|
438
504
|
return await this.shadowClient?.asyncGetShadowReportedState();
|
|
439
505
|
}
|
|
440
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Send service command
|
|
509
|
+
*
|
|
510
|
+
* @param {string} serialNumber - Device serial number
|
|
511
|
+
* @param {string} cmd - Command
|
|
512
|
+
* @param {unknown} data - ommand data
|
|
513
|
+
* @returns {Promise<void>}
|
|
514
|
+
*/
|
|
441
515
|
async asyncSendServiceCommand(serialNumber, cmd, data) {
|
|
442
516
|
await this.checkShadowClient(serialNumber);
|
|
443
517
|
return await this.shadowClient?.asyncPublishServiceCommand({ cmd, data });
|
|
@@ -449,12 +523,13 @@ class AnthbotCloudApiClient {
|
|
|
449
523
|
*/
|
|
450
524
|
class AnthbotShadowApiClient {
|
|
451
525
|
/**
|
|
452
|
-
* @param {{serialNumber: string, regionName?: string | null, iotEndpoint?: string | null, verboseLogger?: ((message: string) => void) | null}} options
|
|
526
|
+
* @param {{serialNumber: string, regionName?: string | null, iotEndpoint?: string | null, verboseLogger?: ((message: string) => void) | null}} options - Configuration options
|
|
453
527
|
*/
|
|
454
528
|
constructor({ serialNumber, regionName = null, iotEndpoint = null, verboseLogger = null }) {
|
|
455
529
|
this._serialNumber = serialNumber;
|
|
456
530
|
this._regionName = typeof regionName === 'string' && regionName ? regionName : null;
|
|
457
531
|
this._iotEndpoint = AnthbotShadowApiClient._normalizeEndpoint(iotEndpoint);
|
|
532
|
+
this._iotCredentials = null;
|
|
458
533
|
this.verboseLogger = verboseLogger;
|
|
459
534
|
this.fetch = new AnthotCloudApi({ verboseLogger }).fetch;
|
|
460
535
|
|
|
@@ -491,6 +566,10 @@ class AnthbotShadowApiClient {
|
|
|
491
566
|
return AnthbotShadowApiClient._guessRegionFromEndpoint(iotEndpoint);
|
|
492
567
|
}
|
|
493
568
|
|
|
569
|
+
set iotCredentials(iotCredentals) {
|
|
570
|
+
this._iotCredentials = iotCredentals;
|
|
571
|
+
}
|
|
572
|
+
|
|
494
573
|
get serialNumber() {
|
|
495
574
|
return this._serialNumber;
|
|
496
575
|
}
|
|
@@ -508,6 +587,9 @@ class AnthbotShadowApiClient {
|
|
|
508
587
|
}
|
|
509
588
|
|
|
510
589
|
_accessKeyId() {
|
|
590
|
+
if (this._iotCredentials?.accessKeyId) {
|
|
591
|
+
return this._iotCredentials.accessKeyId;
|
|
592
|
+
}
|
|
511
593
|
if (this._iotEndpoint === CN_NORTHWEST_IOT_ENDPOINT) {
|
|
512
594
|
return AWS_ACCESS_KEY_CN_NORTHWEST;
|
|
513
595
|
}
|
|
@@ -518,6 +600,9 @@ class AnthbotShadowApiClient {
|
|
|
518
600
|
}
|
|
519
601
|
|
|
520
602
|
_secretAccessKey() {
|
|
603
|
+
if (this._iotCredentials?.secretAccessKey) {
|
|
604
|
+
return this._iotCredentials.secretAccessKey;
|
|
605
|
+
}
|
|
521
606
|
if (this._iotEndpoint === CN_NORTHWEST_IOT_ENDPOINT) {
|
|
522
607
|
return AWS_SECRET_KEY_CN_NORTHWEST;
|
|
523
608
|
}
|
|
@@ -527,6 +612,10 @@ class AnthbotShadowApiClient {
|
|
|
527
612
|
return AWS_SECRET_KEY_DEFAULT;
|
|
528
613
|
}
|
|
529
614
|
|
|
615
|
+
_sessionToken() {
|
|
616
|
+
return this._iotCredentials?.sessionToken;
|
|
617
|
+
}
|
|
618
|
+
|
|
530
619
|
static _sign(key, msg) {
|
|
531
620
|
if (typeof key === 'string') {
|
|
532
621
|
key = Buffer.from(key, 'utf-8');
|
|
@@ -546,18 +635,11 @@ class AnthbotShadowApiClient {
|
|
|
546
635
|
const algorithm = 'AWS4-HMAC-SHA256';
|
|
547
636
|
const signedHeaders = AnthbotShadowApiClient._signedHeadersFromRequest(canonicalRequest);
|
|
548
637
|
const credentialScope = `${dateStamp}/${this.signingRegion}/iotdata/aws4_request`;
|
|
549
|
-
const stringToSign =
|
|
550
|
-
`${algorithm}\n` +
|
|
551
|
-
`${amzDate}\n` +
|
|
552
|
-
`${credentialScope}\n` +
|
|
553
|
-
crypto.createHash('sha256').update(canonicalRequest).digest('hex');
|
|
638
|
+
const stringToSign = `${algorithm}\n${amzDate}\n${credentialScope}\n${crypto.createHash('sha256').update(canonicalRequest).digest('hex')}`;
|
|
554
639
|
|
|
555
640
|
const signature = crypto.createHmac('sha256', this._signingKey(dateStamp)).update(stringToSign).digest('hex');
|
|
556
641
|
|
|
557
|
-
return (
|
|
558
|
-
`${algorithm} Credential=${this._accessKeyId()}/${credentialScope}, ` +
|
|
559
|
-
`SignedHeaders=${signedHeaders}, Signature=${signature}`
|
|
560
|
-
);
|
|
642
|
+
return `${algorithm} Credential=${this._accessKeyId()}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
561
643
|
}
|
|
562
644
|
|
|
563
645
|
static _normalizeHeaderValue(value) {
|
|
@@ -632,17 +714,12 @@ class AnthbotShadowApiClient {
|
|
|
632
714
|
host: this._iotEndpoint,
|
|
633
715
|
'x-amz-content-sha256': payloadHash,
|
|
634
716
|
'x-amz-date': amzDate,
|
|
717
|
+
'x-amz-security-token': this._sessionToken(),
|
|
635
718
|
};
|
|
636
719
|
|
|
637
720
|
const [canonicalHeaders, signedHeaders] = AnthbotShadowApiClient._canonicalHeaders(signedHeaderValues);
|
|
638
721
|
|
|
639
|
-
const canonicalRequest =
|
|
640
|
-
`GET\n` +
|
|
641
|
-
`${canonicalUri}\n` +
|
|
642
|
-
`${canonicalQuery}\n` +
|
|
643
|
-
`${canonicalHeaders}\n` +
|
|
644
|
-
`${signedHeaders}\n` +
|
|
645
|
-
`${payloadHash}`;
|
|
722
|
+
const canonicalRequest = `GET\n${canonicalUri}\n${canonicalQuery}\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`;
|
|
646
723
|
|
|
647
724
|
const authorization = this._buildAuthorization(amzDate, dateStamp, canonicalRequest);
|
|
648
725
|
|
|
@@ -652,6 +729,7 @@ class AnthbotShadowApiClient {
|
|
|
652
729
|
Host: this._iotEndpoint,
|
|
653
730
|
'x-amz-date': amzDate,
|
|
654
731
|
'x-amz-content-sha256': payloadHash,
|
|
732
|
+
'x-amz-security-token': this._sessionToken(),
|
|
655
733
|
Authorization: authorization,
|
|
656
734
|
'User-Agent': CLIENT_USER_AGENT,
|
|
657
735
|
};
|
|
@@ -668,7 +746,7 @@ class AnthbotShadowApiClient {
|
|
|
668
746
|
|
|
669
747
|
let payload;
|
|
670
748
|
try {
|
|
671
|
-
payload = /** @type {{
|
|
749
|
+
payload = /** @type {{state?: any}} */ (await response.json());
|
|
672
750
|
} catch {
|
|
673
751
|
throw new Error('Invalid JSON response from shadow request');
|
|
674
752
|
}
|
|
@@ -688,6 +766,9 @@ class AnthbotShadowApiClient {
|
|
|
688
766
|
|
|
689
767
|
/**
|
|
690
768
|
* Encode path component for AWS SigV4
|
|
769
|
+
*
|
|
770
|
+
* @param {string} component - Component to encode
|
|
771
|
+
* @returns {string} - Encoded component
|
|
691
772
|
*/
|
|
692
773
|
_encodePathComponent(component) {
|
|
693
774
|
return encodeURIComponent(component).replace(/[!'()*]/g, ch => {
|
|
@@ -705,7 +786,6 @@ class AnthbotShadowApiClient {
|
|
|
705
786
|
|
|
706
787
|
// Different AWS clients canonicalize the URI slightly differently.
|
|
707
788
|
// Try the app-observed mode first, then fall back to alternatives.
|
|
708
|
-
/** @type {[string, boolean, string | null, boolean][]} */
|
|
709
789
|
const attempts = [
|
|
710
790
|
// 1) SDK headers + encoded URI + app-style canonical URI (trace match)
|
|
711
791
|
[requestUriEncoded, true, null, true],
|
|
@@ -763,7 +843,7 @@ class AnthbotShadowApiClient {
|
|
|
763
843
|
}
|
|
764
844
|
|
|
765
845
|
if (this.verboseLogger) {
|
|
766
|
-
this.verboseLogger(`Anthbot command publish attempt failed (${status}`);
|
|
846
|
+
this.verboseLogger(`Anthbot command publish attempt failed (${status})`);
|
|
767
847
|
}
|
|
768
848
|
}
|
|
769
849
|
|
|
@@ -776,7 +856,7 @@ class AnthbotShadowApiClient {
|
|
|
776
856
|
}
|
|
777
857
|
|
|
778
858
|
/**
|
|
779
|
-
* @param {{requestUri: string, canonicalQuery: string, payloadBytes: Buffer, includeSdkHeaders: boolean, canonicalUriOverride?: string | null, signContentLength?: boolean}} options
|
|
859
|
+
* @param {{requestUri: string | boolean | null, canonicalQuery: string, payloadBytes: Buffer, includeSdkHeaders: string | boolean | null, canonicalUriOverride?: string | boolean | null, signContentLength?: string | boolean | null}} options - Request options
|
|
780
860
|
*/
|
|
781
861
|
async _asyncSignedPost({
|
|
782
862
|
requestUri,
|
|
@@ -800,6 +880,7 @@ class AnthbotShadowApiClient {
|
|
|
800
880
|
'content-type': 'application/octet-stream',
|
|
801
881
|
'x-amz-content-sha256': payloadHash,
|
|
802
882
|
'x-amz-date': amzDate,
|
|
883
|
+
'x-amz-security-token': this._sessionToken(),
|
|
803
884
|
};
|
|
804
885
|
|
|
805
886
|
const headers = {
|
|
@@ -808,6 +889,7 @@ class AnthbotShadowApiClient {
|
|
|
808
889
|
'Content-Type': 'application/octet-stream',
|
|
809
890
|
'x-amz-content-sha256': payloadHash,
|
|
810
891
|
'x-amz-date': amzDate,
|
|
892
|
+
'x-amz-security-token': this._sessionToken(),
|
|
811
893
|
};
|
|
812
894
|
|
|
813
895
|
if (signContentLength) {
|
|
@@ -836,13 +918,7 @@ class AnthbotShadowApiClient {
|
|
|
836
918
|
? canonicalUriOverride
|
|
837
919
|
: AnthbotShadowApiClient._canonicalUriForSigv4(requestUri);
|
|
838
920
|
|
|
839
|
-
const canonicalRequest =
|
|
840
|
-
`POST\n` +
|
|
841
|
-
`${canonicalUri}\n` +
|
|
842
|
-
`${canonicalQuery}\n` +
|
|
843
|
-
`${canonicalHeaders}\n` +
|
|
844
|
-
`${signedHeaders}\n` +
|
|
845
|
-
`${payloadHash}`;
|
|
921
|
+
const canonicalRequest = `POST\n${canonicalUri}\n${canonicalQuery}\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`;
|
|
846
922
|
|
|
847
923
|
headers['Authorization'] = this._buildAuthorization(amzDate, dateStamp, canonicalRequest);
|
|
848
924
|
|