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 CHANGED
@@ -1,5 +1,6 @@
1
1
  MIT License
2
2
 
3
+ Copyright (c) 2026 iobroker-community-adapters <iobroker-community-adapters@gmx.de>
3
4
  Copyright (c) 2026 Robin Rainton <robin@rainton.com>
4
5
 
5
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
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.3",
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.0.23"
122
+ "admin": ">=7.7.22"
96
123
  }
97
124
  ]
98
125
  },
99
126
  "native": {
100
127
  "username": "",
101
128
  "password": "",
102
- "regionCode": null
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({ username, password, areaCode }) {
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 {{ code: number; data: { access_token: string } }} */ (await response.json());
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
- checkToken() {
190
- if (!this.bearerToken) {
191
- throw new Error('Bearer token not configured');
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
- if (response.status !== 200) {
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
- const response = await this.fetch(url, {
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} sn
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(sn, filename, category, sub_category) {
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(sn),
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 {{ data: {presigned_url: string } }} */ (await response.json()).data?.presigned_url;
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 response = await this.fetch(`${url}?${params}`, {
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 {{ state: { reported: unknown } }} */ (await response.json());
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