iobroker.anthbot 0.0.2 → 0.0.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 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
@@ -8,7 +8,7 @@
8
8
 
9
9
  [![NPM](https://nodei.co/npm/iobroker.anthbot.png?downloads=true)](https://nodei.co/npm/iobroker.anthbot/)
10
10
 
11
- **Tests:** ![Test and Release](https://github.com/raintonr/ioBroker.anthbot/workflows/Test%20and%20Release/badge.svg)
11
+ **Tests:** ![Test and Release](https://github.com/iobroker-community-adapters/ioBroker.anthbot/workflows/Test%20and%20Release/badge.svg)
12
12
 
13
13
  ## anthbot adapter for ioBroker
14
14
 
@@ -19,12 +19,22 @@ Connect with Anthbot devices such as their robot mowers.
19
19
  Placeholder for the next version (at the beginning of the line):
20
20
  ### **WORK IN PROGRESS**
21
21
  -->
22
+ ### 0.0.4 (2026-05-18)
23
+ - (copilot) Adapter requires node.js >= 22 now
24
+ - (copilot) Adapter requires admin >= 7.7.22 now
25
+ - (raintonr) Handle temporary IoT access tokens (#9)
26
+
27
+ ### 0.0.3 (2026-04-25)
28
+ * (raintonr) adapter checker issues
29
+
22
30
  ### 0.0.2 (2026-04-25)
23
31
  * (raintonr) initial release
24
32
 
25
33
  ## License
26
34
  MIT License
27
35
 
36
+
37
+ Copyright (c) 2026 iobroker-community-adapters <iobroker-community-adapters@gmx.de>
28
38
  Copyright (c) 2026 Robin Rainton <robin@rainton.com>
29
39
 
30
40
  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.2",
4
+ "version": "0.0.4",
5
5
  "news": {
6
+ "0.0.4": {
7
+ "en": "Adapter requires node.js >= 22 now\nAdapter requires admin >= 7.7.22 now\nHandle temporary IoT access tokens (#9)",
8
+ "de": "Adapter benötigt node.js >= 22 jetzt\nAdapter benötigt admin >= 7.7.22 jetzt\nHandle temporäre IoT-Zugriffstoken (#9)",
9
+ "ru": "Адаптер требует node.js >= 22 сейчас\nАдаптер требует администратора >= 7.7.22\nОбработка временных токенов доступа к IoT (#9)",
10
+ "pt": "Adaptador requer nod.js >= 22 agora\nAdaptador requer admin >= 7.7.22 agora\nLidar com tokens de acesso temporário de IoT (# 9)",
11
+ "nl": "Voor de adapter zijn node.js < 22 nu nodig\nAdapter vereist admin < 7.7.22 nu\nTijdelijke IoT toegang tokens hanteren (#9)",
12
+ "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)",
13
+ "it": "Adattatore richiede node.js >= 22 ora\nAdattatore richiede admin >= 7.7.22 ora\nMantenere i gettoni di accesso IoT temporanei (#9)",
14
+ "es": "Adaptador requiere node.js ю= 22 ahora\nEl adaptador requiere administrador= 7.7.22 ahora\nHandle temporary IoT access tokens (#9)",
15
+ "pl": "Adapter wymaga node.js > = 22\nAdapter wymaga admin > = 7.7.22\nObsługa tymczasowych żetonów dostępu IoT (# 9)",
16
+ "uk": "Адаптер вимагає node.js >= 22 тепер\nАдаптер вимагає адмін >= 7.7.22 тепер\nЗручності для доступу до Інтернету речей (#9)",
17
+ "zh-cn": "适配器需要节点.js 现在22\n适任者需要管理员 \\ 7.7.22 现在\n处理临时 IOT 访问符 (# 9)"
18
+ },
19
+ "0.0.3": {
20
+ "en": "adapter checker issues",
21
+ "de": "adapter checker probleme",
22
+ "ru": "проблемы проверки адаптера",
23
+ "pt": "problemas com o verificador de adaptadores",
24
+ "nl": "problemen met de adaptercontrole",
25
+ "fr": "problèmes de vérification de l'adaptateur",
26
+ "it": "adattatore checker",
27
+ "es": "problemas de control de adaptador",
28
+ "pl": "problemy z sprawdzaniem adaptera",
29
+ "uk": "питання перевірки адаптера",
30
+ "zh-cn": "适配器检查器问题"
31
+ },
6
32
  "0.0.2": {
7
33
  "en": "initial release",
8
34
  "de": "Erstveröffentlichung",
@@ -44,7 +70,8 @@
44
70
  "zh-cn": "与 Anthbot 设备连接,例如机器人割草机"
45
71
  },
46
72
  "authors": [
47
- "Robin Rainton <robin@rainton.com>"
73
+ "Robin Rainton <robin@rainton.com>",
74
+ "iobroker-community-adapters <iobroker-community-adapters@gmx.de>"
48
75
  ],
49
76
  "keywords": [
50
77
  "robot",
@@ -60,8 +87,8 @@
60
87
  "platform": "Javascript/Node.js",
61
88
  "icon": "anthbot.png",
62
89
  "enabled": true,
63
- "extIcon": "https://raw.githubusercontent.com/raintonr/ioBroker.anthbot/main/admin/anthbot.png",
64
- "readme": "https://github.com/raintonr/ioBroker.anthbot/blob/main/README.md",
90
+ "extIcon": "https://raw.githubusercontent.com/iobroker-community-adapters/ioBroker.anthbot/master/admin/anthbot.png",
91
+ "readme": "https://github.com/iobroker-community-adapters/ioBroker.anthbot/blob/master/README.md",
65
92
  "loglevel": "info",
66
93
  "tier": 3,
67
94
  "mode": "daemon",
@@ -79,15 +106,22 @@
79
106
  ],
80
107
  "globalDependencies": [
81
108
  {
82
- "admin": ">=7.0.23"
109
+ "admin": ">=7.7.22"
83
110
  }
84
111
  ]
85
112
  },
86
113
  "native": {
87
114
  "username": "",
88
115
  "password": "",
89
- "regionCode": null
116
+ "regionCode": 61
90
117
  },
118
+ "encryptedNative": [
119
+ "password"
120
+ ],
121
+ "protectedNative": [
122
+ "username",
123
+ "password"
124
+ ],
91
125
  "objects": [],
92
126
  "instanceObjects": [
93
127
  {
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,13 @@ 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
141
+ * @returns {Promise<string>} Bearer token
137
142
  */
138
- async asyncLogin({ username, password, areaCode }) {
143
+ async asyncLogin(username, password, areaCode) {
139
144
  const url = `https://${this.endpointHost}/api/v1/login`;
140
145
  const headers = {
141
146
  Accept: 'application/json, text/plain, */*',
@@ -158,15 +163,14 @@ class AnthbotCloudApiClient {
158
163
 
159
164
  let data;
160
165
  try {
161
- data = /** @type {{ code: number; data: { access_token: string } }} */ (await response.json());
166
+ data = /** @type {{code: number, data?: any}} */ (await response.json());
162
167
  } catch {
163
168
  throw new Error('Invalid JSON response from login');
164
169
  }
165
170
 
166
171
  if (typeof data !== 'object' || data === null) {
167
172
  throw new Error('Invalid login payload type');
168
- }
169
- if (data.code !== 0) {
173
+ } else if (data.code !== 0) {
170
174
  throw new Error(`Login rejected: code=${JSON.stringify(data.code)}`);
171
175
  }
172
176
 
@@ -186,6 +190,9 @@ class AnthbotCloudApiClient {
186
190
  return bearerToken;
187
191
  }
188
192
 
193
+ /**
194
+ * Make sure we have valid bearerToken
195
+ */
189
196
  checkToken() {
190
197
  if (!this.bearerToken) {
191
198
  throw new Error('Bearer token not configured');
@@ -194,6 +201,8 @@ class AnthbotCloudApiClient {
194
201
 
195
202
  /**
196
203
  * Fetch account-bound Anthbot devices
204
+ *
205
+ * @returns {Promise<{alias: string, sn: string}[]>} - List of devices
197
206
  */
198
207
  async asyncGetBoundDevices() {
199
208
  this.checkToken();
@@ -208,12 +217,17 @@ class AnthbotCloudApiClient {
208
217
  throw new Error(`Request to ${url} failed, response ${response.status}`);
209
218
  }
210
219
 
211
- return /** @type {{ data: {alias: string, sn: string}[] }} */ (await response.json()).data;
220
+ return /** @type {{data?: any}} */ (await response.json()).data;
212
221
  }
213
222
 
214
223
  /**
215
224
  * Fetch latest messages
216
225
  * Returns only last message by default
226
+ *
227
+ * @param {string} serialNumber - Device serial number
228
+ * @param {number} pageNum - Page number
229
+ * @param {number} pageSize - Page size
230
+ * @returns {Promise<unknown>} Latest messages
217
231
  */
218
232
  async asyncGetCodeList(serialNumber, pageNum = 1, pageSize = 1) {
219
233
  this.checkToken();
@@ -230,9 +244,15 @@ class AnthbotCloudApiClient {
230
244
  throw new Error(`Request to ${url} failed, response ${response.status}`);
231
245
  }
232
246
 
233
- return /** @type {{ data: { data: unknown } }} */ (await response.json())?.data?.data;
247
+ return /** @type {{data?: any}} */ (await response.json())?.data?.data;
234
248
  }
235
249
 
250
+ /**
251
+ * Get verification token for presigned URL retrieval
252
+ *
253
+ * @param {string} serialNumber - Device serial number
254
+ * @returns {string} - Verification token
255
+ */
236
256
  buildVerificationToken(serialNumber) {
237
257
  const unixTimestamp = Math.floor(Date.now() / 1000);
238
258
  const tokenSuffix = unixTimestamp.toString();
@@ -243,21 +263,21 @@ class AnthbotCloudApiClient {
243
263
  /**
244
264
  * Fetch account-bound Anthbot devices
245
265
  *
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
266
+ * @param {string} serialNumber - Device serial number
267
+ * @param {string} filename - File name
268
+ * @param {string} category - Category
269
+ * @param {string} sub_category - Sub category
270
+ * @returns {Promise<string>} - URL for the requested file
251
271
  */
252
- async asyncGetPresignedUrl(sn, filename, category, sub_category) {
272
+ async asyncGetPresignedUrl(serialNumber, filename, category, sub_category) {
253
273
  this.checkToken();
254
274
 
255
275
  const params = new URLSearchParams({
256
276
  filename,
257
- sn,
277
+ sn: serialNumber,
258
278
  category,
259
279
  sub_category,
260
- verification_token: this.buildVerificationToken(sn),
280
+ verification_token: this.buildVerificationToken(serialNumber),
261
281
  });
262
282
  const url = `https://${this.endpointHost}/api/v1/device/v2/presigned_url?${params}`;
263
283
  const response = await this.fetch(url, {
@@ -269,16 +289,15 @@ class AnthbotCloudApiClient {
269
289
  throw new Error(`Request to ${url} failed, response ${response.status}`);
270
290
  }
271
291
 
272
- return /** @type {{ data: {presigned_url: string } }} */ (await response.json()).data?.presigned_url;
292
+ return /** @type {{data?: any}} */ (await response.json()).data?.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,6 +385,9 @@ 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) {
@@ -382,9 +410,7 @@ class AnthbotCloudApiClient {
382
410
 
383
411
  let payload;
384
412
  try {
385
- payload = /** @type {{ code: number; data: {region_name: string; iot_endpoint: string } }} */ (
386
- await response.json()
387
- );
413
+ payload = /** @type {{code: number, data?: any}} */ (await response.json());
388
414
  } catch {
389
415
  throw new Error('Invalid JSON response from device region');
390
416
  }
@@ -417,6 +443,74 @@ class AnthbotCloudApiClient {
417
443
  return deviceRegion;
418
444
  }
419
445
 
446
+ /**
447
+ * Retrieve temporary IoT credentials for a device.
448
+ *
449
+ * @param {string} serialNumber - Device serial number
450
+ * @returns {Promise<object>} - IoT credentials and expiration
451
+ */
452
+ async asyncGetDeviceIotCredentials(serialNumber) {
453
+ this.checkToken();
454
+
455
+ const params = {
456
+ sn: serialNumber,
457
+ verification_token: this.buildVerificationToken(serialNumber),
458
+ };
459
+
460
+ const response = await this.fetch(`https://${this.endpointHost}/api/v1/device/v2/iot/sts/arn`, {
461
+ method: 'POST',
462
+ headers: { ...this.authHeaders, 'content-type': 'application/json' },
463
+ body: JSON.stringify(params),
464
+ });
465
+
466
+ if (response.status !== 200) {
467
+ const body = await response.text();
468
+ throw new Error(`IoT credentials failed (${response.status}): ${body.slice(0, 300)}`);
469
+ }
470
+
471
+ let payload;
472
+ try {
473
+ payload = /** @type {{code: number, data?: any}} */ (await response.json());
474
+ } catch {
475
+ throw new Error('Invalid JSON response from IoT credentials');
476
+ }
477
+
478
+ if (typeof payload !== 'object' || payload === null) {
479
+ throw new Error('Invalid IoT credentials payload type');
480
+ }
481
+ if (payload.code !== 0) {
482
+ throw new Error(`IoT credentials returned code=${payload.code}`);
483
+ }
484
+
485
+ const data = payload.data;
486
+ if (typeof data !== 'object' || data === null) {
487
+ throw new Error('IoT credentials payload missing data object');
488
+ }
489
+
490
+ const expiration = data.expiration;
491
+ const expiresAt =
492
+ expiration == null ? null : expiration > 2000000000 ? expiration * 1000 : Date.now() + expiration * 1000;
493
+
494
+ const iotCredentials = {
495
+ accessKeyId: data.access_key_id,
496
+ secretAccessKey: data.secret_access_key,
497
+ sessionToken: data.session_token,
498
+ regionName: data.region_name,
499
+ endpoint: data.endpoint,
500
+ expiresAt,
501
+ };
502
+ if (this.verboseLogger) {
503
+ this.verboseLogger(`IoT credentials for ${serialNumber}: ${JSON.stringify(iotCredentials)}`);
504
+ }
505
+ return iotCredentials;
506
+ }
507
+
508
+ /**
509
+ * Ensure shadow client is initialized
510
+ *
511
+ * @param {string} serialNumber - Device serial number
512
+ * @returns {Promise<void>}
513
+ */
420
514
  async checkShadowClient(serialNumber) {
421
515
  // TODO: Handle multiple devices with different shadow clients instead of caching just one
422
516
  if (!this.shadowClient) {
@@ -431,13 +525,40 @@ class AnthbotCloudApiClient {
431
525
  verboseLogger: this.verboseLogger,
432
526
  });
433
527
  }
528
+
529
+ // Get new credentials if we have none or they have expired
530
+ if (
531
+ !this.iotCredentials ||
532
+ this.iotCredentials.expiresAt === null ||
533
+ this.iotCredentials.expiresAt - Date.now() <= 60000
534
+ ) {
535
+ if (this.verboseLogger) {
536
+ this.verboseLogger(`No/expired IoT Credentaials for ${serialNumber}, fetching them`);
537
+ }
538
+ this.iotCredentials = await this.asyncGetDeviceIotCredentials(serialNumber);
539
+ this.shadowClient.iotCredentials = this.iotCredentials;
540
+ }
434
541
  }
435
542
 
543
+ /**
544
+ * Get shadow reported state
545
+ *
546
+ * @param {string} serialNumber - Device serial number
547
+ * @returns {Promise<unknown>} - Reported state
548
+ */
436
549
  async asyncGetShadowReportedState(serialNumber) {
437
550
  await this.checkShadowClient(serialNumber);
438
551
  return await this.shadowClient?.asyncGetShadowReportedState();
439
552
  }
440
553
 
554
+ /**
555
+ * Send service command
556
+ *
557
+ * @param {string} serialNumber - Device serial number
558
+ * @param {string} cmd - Command
559
+ * @param {unknown} data - ommand data
560
+ * @returns {Promise<void>}
561
+ */
441
562
  async asyncSendServiceCommand(serialNumber, cmd, data) {
442
563
  await this.checkShadowClient(serialNumber);
443
564
  return await this.shadowClient?.asyncPublishServiceCommand({ cmd, data });
@@ -449,12 +570,13 @@ class AnthbotCloudApiClient {
449
570
  */
450
571
  class AnthbotShadowApiClient {
451
572
  /**
452
- * @param {{serialNumber: string, regionName?: string | null, iotEndpoint?: string | null, verboseLogger?: ((message: string) => void) | null}} options
573
+ * @param {{serialNumber: string, regionName?: string | null, iotEndpoint?: string | null, verboseLogger?: ((message: string) => void) | null}} options - Configuration options
453
574
  */
454
575
  constructor({ serialNumber, regionName = null, iotEndpoint = null, verboseLogger = null }) {
455
576
  this._serialNumber = serialNumber;
456
577
  this._regionName = typeof regionName === 'string' && regionName ? regionName : null;
457
578
  this._iotEndpoint = AnthbotShadowApiClient._normalizeEndpoint(iotEndpoint);
579
+ this._iotCredentials = null;
458
580
  this.verboseLogger = verboseLogger;
459
581
  this.fetch = new AnthotCloudApi({ verboseLogger }).fetch;
460
582
 
@@ -491,6 +613,10 @@ class AnthbotShadowApiClient {
491
613
  return AnthbotShadowApiClient._guessRegionFromEndpoint(iotEndpoint);
492
614
  }
493
615
 
616
+ set iotCredentials(iotCredentals) {
617
+ this._iotCredentials = iotCredentals;
618
+ }
619
+
494
620
  get serialNumber() {
495
621
  return this._serialNumber;
496
622
  }
@@ -508,6 +634,9 @@ class AnthbotShadowApiClient {
508
634
  }
509
635
 
510
636
  _accessKeyId() {
637
+ if (this._iotCredentials?.accessKeyId) {
638
+ return this._iotCredentials.accessKeyId;
639
+ }
511
640
  if (this._iotEndpoint === CN_NORTHWEST_IOT_ENDPOINT) {
512
641
  return AWS_ACCESS_KEY_CN_NORTHWEST;
513
642
  }
@@ -518,6 +647,9 @@ class AnthbotShadowApiClient {
518
647
  }
519
648
 
520
649
  _secretAccessKey() {
650
+ if (this._iotCredentials?.secretAccessKey) {
651
+ return this._iotCredentials.secretAccessKey;
652
+ }
521
653
  if (this._iotEndpoint === CN_NORTHWEST_IOT_ENDPOINT) {
522
654
  return AWS_SECRET_KEY_CN_NORTHWEST;
523
655
  }
@@ -527,6 +659,10 @@ class AnthbotShadowApiClient {
527
659
  return AWS_SECRET_KEY_DEFAULT;
528
660
  }
529
661
 
662
+ _sessionToken() {
663
+ return this._iotCredentials?.sessionToken;
664
+ }
665
+
530
666
  static _sign(key, msg) {
531
667
  if (typeof key === 'string') {
532
668
  key = Buffer.from(key, 'utf-8');
@@ -546,18 +682,11 @@ class AnthbotShadowApiClient {
546
682
  const algorithm = 'AWS4-HMAC-SHA256';
547
683
  const signedHeaders = AnthbotShadowApiClient._signedHeadersFromRequest(canonicalRequest);
548
684
  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');
685
+ const stringToSign = `${algorithm}\n${amzDate}\n${credentialScope}\n${crypto.createHash('sha256').update(canonicalRequest).digest('hex')}`;
554
686
 
555
687
  const signature = crypto.createHmac('sha256', this._signingKey(dateStamp)).update(stringToSign).digest('hex');
556
688
 
557
- return (
558
- `${algorithm} Credential=${this._accessKeyId()}/${credentialScope}, ` +
559
- `SignedHeaders=${signedHeaders}, Signature=${signature}`
560
- );
689
+ return `${algorithm} Credential=${this._accessKeyId()}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
561
690
  }
562
691
 
563
692
  static _normalizeHeaderValue(value) {
@@ -632,17 +761,12 @@ class AnthbotShadowApiClient {
632
761
  host: this._iotEndpoint,
633
762
  'x-amz-content-sha256': payloadHash,
634
763
  'x-amz-date': amzDate,
764
+ 'x-amz-security-token': this._sessionToken(),
635
765
  };
636
766
 
637
767
  const [canonicalHeaders, signedHeaders] = AnthbotShadowApiClient._canonicalHeaders(signedHeaderValues);
638
768
 
639
- const canonicalRequest =
640
- `GET\n` +
641
- `${canonicalUri}\n` +
642
- `${canonicalQuery}\n` +
643
- `${canonicalHeaders}\n` +
644
- `${signedHeaders}\n` +
645
- `${payloadHash}`;
769
+ const canonicalRequest = `GET\n${canonicalUri}\n${canonicalQuery}\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`;
646
770
 
647
771
  const authorization = this._buildAuthorization(amzDate, dateStamp, canonicalRequest);
648
772
 
@@ -652,6 +776,7 @@ class AnthbotShadowApiClient {
652
776
  Host: this._iotEndpoint,
653
777
  'x-amz-date': amzDate,
654
778
  'x-amz-content-sha256': payloadHash,
779
+ 'x-amz-security-token': this._sessionToken(),
655
780
  Authorization: authorization,
656
781
  'User-Agent': CLIENT_USER_AGENT,
657
782
  };
@@ -668,7 +793,7 @@ class AnthbotShadowApiClient {
668
793
 
669
794
  let payload;
670
795
  try {
671
- payload = /** @type {{ state: { reported: unknown } }} */ (await response.json());
796
+ payload = /** @type {{state?: any}} */ (await response.json());
672
797
  } catch {
673
798
  throw new Error('Invalid JSON response from shadow request');
674
799
  }
@@ -688,6 +813,9 @@ class AnthbotShadowApiClient {
688
813
 
689
814
  /**
690
815
  * Encode path component for AWS SigV4
816
+ *
817
+ * @param {string} component - Component to encode
818
+ * @returns {string} - Encoded component
691
819
  */
692
820
  _encodePathComponent(component) {
693
821
  return encodeURIComponent(component).replace(/[!'()*]/g, ch => {
@@ -705,7 +833,6 @@ class AnthbotShadowApiClient {
705
833
 
706
834
  // Different AWS clients canonicalize the URI slightly differently.
707
835
  // Try the app-observed mode first, then fall back to alternatives.
708
- /** @type {[string, boolean, string | null, boolean][]} */
709
836
  const attempts = [
710
837
  // 1) SDK headers + encoded URI + app-style canonical URI (trace match)
711
838
  [requestUriEncoded, true, null, true],
@@ -763,7 +890,7 @@ class AnthbotShadowApiClient {
763
890
  }
764
891
 
765
892
  if (this.verboseLogger) {
766
- this.verboseLogger(`Anthbot command publish attempt failed (${status}`);
893
+ this.verboseLogger(`Anthbot command publish attempt failed (${status})`);
767
894
  }
768
895
  }
769
896
 
@@ -776,7 +903,7 @@ class AnthbotShadowApiClient {
776
903
  }
777
904
 
778
905
  /**
779
- * @param {{requestUri: string, canonicalQuery: string, payloadBytes: Buffer, includeSdkHeaders: boolean, canonicalUriOverride?: string | null, signContentLength?: boolean}} options
906
+ * @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
907
  */
781
908
  async _asyncSignedPost({
782
909
  requestUri,
@@ -800,6 +927,7 @@ class AnthbotShadowApiClient {
800
927
  'content-type': 'application/octet-stream',
801
928
  'x-amz-content-sha256': payloadHash,
802
929
  'x-amz-date': amzDate,
930
+ 'x-amz-security-token': this._sessionToken(),
803
931
  };
804
932
 
805
933
  const headers = {
@@ -808,6 +936,7 @@ class AnthbotShadowApiClient {
808
936
  'Content-Type': 'application/octet-stream',
809
937
  'x-amz-content-sha256': payloadHash,
810
938
  'x-amz-date': amzDate,
939
+ 'x-amz-security-token': this._sessionToken(),
811
940
  };
812
941
 
813
942
  if (signContentLength) {
@@ -836,13 +965,7 @@ class AnthbotShadowApiClient {
836
965
  ? canonicalUriOverride
837
966
  : AnthbotShadowApiClient._canonicalUriForSigv4(requestUri);
838
967
 
839
- const canonicalRequest =
840
- `POST\n` +
841
- `${canonicalUri}\n` +
842
- `${canonicalQuery}\n` +
843
- `${canonicalHeaders}\n` +
844
- `${signedHeaders}\n` +
845
- `${payloadHash}`;
968
+ const canonicalRequest = `POST\n${canonicalUri}\n${canonicalQuery}\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`;
846
969
 
847
970
  headers['Authorization'] = this._buildAuthorization(amzDate, dateStamp, canonicalRequest);
848
971
 
package/main.js CHANGED
@@ -50,7 +50,7 @@ class Anthbot extends utils.Adapter {
50
50
  // Verify we have credentials
51
51
  if (this.config.username == '' || this.config.password == '' || !this.config.regionCode) {
52
52
  this.log.error('Incomplete adapter configuration! Please check settings.');
53
- this.terminate();
53
+ // Don't actually terminate - when the adapter config is updated that will trigger a restart
54
54
  } else {
55
55
  this.loginAndStart();
56
56
  }
@@ -82,143 +82,153 @@ class Anthbot extends utils.Adapter {
82
82
  idParts.pop();
83
83
 
84
84
  const serialNumber = idParts.pop();
85
- const device = this.devices.find(checkDevice => checkDevice.sn === serialNumber);
86
-
87
- if (!device) {
88
- this.log.error(`Could not find device for command with serial number: ${serialNumber}`);
85
+ if (!serialNumber) {
86
+ this.log.error(`No serial number found in command ${id}`);
89
87
  } else {
90
- switch (command) {
91
- case 'area_set': {
92
- let customAreas;
93
- if (typeof state?.val !== 'string') {
94
- this.log.error('Command custom_areas for ${serialNumber} is not a string');
95
- } else {
96
- try {
97
- customAreas = JSON.parse(state.val);
98
- } catch (error) {
99
- this.log.error(`Failed to parse for ${id}: ${error.message}`);
88
+ const device = this.devices.find(checkDevice => checkDevice.sn === serialNumber);
89
+
90
+ if (!serialNumber || !device) {
91
+ this.log.error(`Could not find device for command with serial number: ${serialNumber}`);
92
+ } else {
93
+ switch (command) {
94
+ case 'area_set': {
95
+ let customAreas;
96
+ if (typeof state?.val !== 'string') {
97
+ this.log.error('Command custom_areas for ${serialNumber} is not a string');
98
+ } else {
99
+ try {
100
+ customAreas = JSON.parse(state.val);
101
+ } catch (error) {
102
+ this.log.error(`Failed to parse for ${id}: ${error.message}`);
103
+ }
100
104
  }
101
- }
102
105
 
103
- // Overlay elements from the state onto existing zones so user only has to set
104
- // the items they are changing and rest will be preserved.
106
+ // Overlay elements from the state onto existing zones so user only has to set
107
+ // the items they are changing and rest will be preserved.
105
108
 
106
- // Variable named to match asyncSendServiceCommand data
107
- const custom_areas = this.validateCustomAreas(device, customAreas);
109
+ // Variable named to match asyncSendServiceCommand data
110
+ const custom_areas = this.validateCustomAreas(device, customAreas);
111
+
112
+ if (!custom_areas) {
113
+ this.log.error(`Bad area data in ${id}`);
114
+ } else {
115
+ // Write the given area (zone) data
116
+ this.log.info(`${device.alias}: area_set ${JSON.stringify(customAreas)}`);
117
+ await this.client.asyncSendServiceCommand(serialNumber, 'area_set', {
118
+ custom_areas,
119
+ });
108
120
 
109
- if (!custom_areas) {
110
- this.log.error(`Bad area data in ${id}`);
111
- } else {
112
- // Write the given area (zone) data
113
- this.log.info(`${device.alias}: area_set ${JSON.stringify(customAreas)}`);
114
- await this.client.asyncSendServiceCommand(serialNumber, 'area_set', {
115
- custom_areas,
116
- });
121
+ ackState = JSON.stringify(customAreas);
122
+ }
117
123
 
118
- ackState = JSON.stringify(customAreas);
124
+ break;
119
125
  }
120
126
 
121
- break;
122
- }
127
+ case 'custom_area_mow_start': {
128
+ // Get/check command zone_list
129
+ // This could be done in one shot, but get the state first for debug logging
130
+ const command_zone_list_state = await this.getStateAsync(
131
+ `${device.sn}.command.zone_list`,
132
+ );
133
+ this.log.debug(
134
+ `Current command.zone_list state: ${JSON.stringify(command_zone_list_state)}`,
135
+ );
136
+
137
+ let command_zone_list;
138
+ if (typeof command_zone_list_state?.val !== 'string') {
139
+ this.log.error('Command zone list for ${serialNumber} is not a string');
140
+ } else {
141
+ try {
142
+ command_zone_list = JSON.parse(command_zone_list_state.val);
143
+ } catch (error) {
144
+ this.log.error(
145
+ `Failed to parse command zone list for ${serialNumber}: ${error.message}`,
146
+ );
147
+ }
148
+ }
123
149
 
124
- case 'custom_area_mow_start': {
125
- // Get/check command zone_list
126
- // This could be done in one shot, but get the state first for debug logging
127
- const command_zone_list_state = await this.getStateAsync(`${device.sn}.command.zone_list`);
128
- this.log.debug(
129
- `Current command.zone_list state: ${JSON.stringify(command_zone_list_state)}`,
130
- );
131
-
132
- let command_zone_list;
133
- if (typeof command_zone_list_state?.val !== 'string') {
134
- this.log.error('Command zone list for ${serialNumber} is not a string');
135
- } else {
136
- try {
137
- command_zone_list = JSON.parse(command_zone_list_state.val);
138
- } catch (error) {
139
- this.log.error(
140
- `Failed to parse command zone list for ${serialNumber}: ${error.message}`,
141
- );
150
+ if (Array.isArray(command_zone_list) && command_zone_list.length > 0) {
151
+ if (!this.isGoodZoneList(device, command_zone_list)) {
152
+ this.log.error(
153
+ 'Cannot start custom_area_mow_start due to invalid command.zone_list',
154
+ );
155
+ } else {
156
+ this.log.info(
157
+ `${device.alias}: custom_area_mow_start ${JSON.stringify(command_zone_list)}`,
158
+ );
159
+ await this.client.asyncSendServiceCommand(
160
+ serialNumber,
161
+ 'custom_area_mow_start',
162
+ {
163
+ id: command_zone_list,
164
+ },
165
+ );
166
+ ackState = true;
167
+ }
142
168
  }
169
+ break;
143
170
  }
144
171
 
145
- if (Array.isArray(command_zone_list) && command_zone_list.length > 0) {
146
- if (!this.isGoodZoneList(device, command_zone_list)) {
147
- this.log.error(
148
- 'Cannot start custom_area_mow_start due to invalid command.zone_list',
149
- );
172
+ case 'zone_list': {
173
+ let zoneList;
174
+ // This will affect the next start command only.
175
+ if (typeof state?.val === 'string' && state.val !== '') {
176
+ // Some kind of non-blank value given
177
+ try {
178
+ zoneList = JSON.parse(state.val);
179
+ } catch (error) {
180
+ this.log.error(`Failed to parse zone list for ${id}: ${error.message}`);
181
+ }
182
+
183
+ // Make sure all IDs in list are valid
184
+ if (!this.isGoodZoneList(device, zoneList)) {
185
+ // Set to null so we don't ack it
186
+ zoneList = null;
187
+ }
150
188
  } else {
151
- this.log.info(
152
- `${device.alias}: custom_area_mow_start ${JSON.stringify(command_zone_list)}`,
153
- );
154
- await this.client.asyncSendServiceCommand(serialNumber, 'custom_area_mow_start', {
155
- id: command_zone_list,
156
- });
157
- ackState = true;
189
+ // No value given, so ack an empty list
190
+ zoneList = [];
158
191
  }
159
- }
160
- break;
161
- }
162
192
 
163
- case 'zone_list': {
164
- let zoneList;
165
- // This will affect the next start command only.
166
- if (typeof state?.val === 'string' && state.val !== '') {
167
- // Some kind of non-blank value given
168
- try {
169
- zoneList = JSON.parse(state.val);
170
- } catch (error) {
171
- this.log.error(`Failed to parse zone list for ${id}: ${error.message}`);
193
+ // Ack only if we now have a list
194
+ if (Array.isArray(zoneList)) {
195
+ ackState = JSON.stringify(zoneList);
196
+ // We don't need to sync after this as no command was actually sent yet
197
+ doSync = false;
172
198
  }
173
199
 
174
- // Make sure all IDs in list are valid
175
- if (!this.isGoodZoneList(device, zoneList)) {
176
- // Set to null so we don't ack it
177
- zoneList = null;
178
- }
179
- } else {
180
- // No value given, so ack an empty list
181
- zoneList = [];
200
+ break;
182
201
  }
183
202
 
184
- // Ack only if we now have a list
185
- if (Array.isArray(zoneList)) {
186
- ackState = JSON.stringify(zoneList);
187
- // We don't need to sync after this as no command was actually sent yet
188
- doSync = false;
203
+ case 'mow_start':
204
+ // To start mowing have to put app_state first.
205
+ await this.client.asyncSendServiceCommand(serialNumber, 'app_state', 1);
206
+ // Purposfully fall through to send the actual command!
207
+
208
+ // Generic one-shot commands
209
+ /* falls through */
210
+ case 'charge_start':
211
+ case 'mow_pause':
212
+ case 'stop_all_tasks': {
213
+ this.log.info(`${device.alias}: ${command}`);
214
+ await this.client.asyncSendServiceCommand(serialNumber, command, 1);
215
+ ackState = true;
216
+ break;
189
217
  }
190
218
 
191
- break;
192
- }
193
-
194
- case 'mow_start':
195
- // To start mowing have to put app_state first.
196
- await this.client.asyncSendServiceCommand(serialNumber, 'app_state', 1);
197
- // Purposfully fall through to send the actual command!
198
-
199
- // Generic one-shot commands
200
- /* falls through */
201
- case 'charge_start':
202
- case 'mow_pause':
203
- case 'stop_all_tasks': {
204
- this.log.info(`${device.alias}: ${command}`);
205
- await this.client.asyncSendServiceCommand(serialNumber, command, 1);
206
- ackState = true;
207
- break;
219
+ default:
220
+ this.log.warn(`Unknown command: ${command}`);
208
221
  }
209
-
210
- default:
211
- this.log.warn(`Unknown command: ${command}`);
212
222
  }
213
- }
214
223
 
215
- // Ack command if verified valid above
216
- if (ackState) {
217
- await this.setState(id, ackState, true);
224
+ // Ack command if verified valid above
225
+ if (ackState) {
226
+ await this.setState(id, ackState, true);
218
227
 
219
- // Sync device if no explicitally set not to
220
- if (doSync) {
221
- this.syncDevice(device);
228
+ // Sync device if no explicitally set not to
229
+ if (doSync) {
230
+ this.syncDevice(device);
231
+ }
222
232
  }
223
233
  }
224
234
  }
@@ -261,11 +271,7 @@ class Anthbot extends utils.Adapter {
261
271
 
262
272
  this.log.info('Connecting to Anthbot cloud...');
263
273
  try {
264
- await this.client.asyncLogin({
265
- username: this.config.username,
266
- password: this.config.password,
267
- areaCode: this.config.regionCode,
268
- });
274
+ await this.client.asyncLogin(this.config.username, this.config.password, this.config.regionCode);
269
275
  } catch (error) {
270
276
  this.log.error(`Failed to login to Anthbot cloud: ${error.message}`);
271
277
  await this.retryConnection();
package/package.json CHANGED
@@ -1,27 +1,27 @@
1
1
  {
2
2
  "name": "iobroker.anthbot",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Connect with Anthbot devices such as their robot mowers",
5
- "author": {
6
- "name": "Robin Rainton",
7
- "email": "robin@rainton.com"
8
- },
9
- "contributors": [],
10
- "homepage": "https://github.com/raintonr/ioBroker.anthbot",
5
+ "author": "Robin Rainton <robin@rainton.com>",
6
+ "contributors": [
7
+ "iobroker-community-adapters <iobroker-community-adapters@gmx.de>"
8
+ ],
9
+ "homepage": "https://github.com/iobroker-community-adapters/ioBroker.anthbot",
11
10
  "license": "MIT",
12
11
  "keywords": [
13
12
  "robot",
14
13
  "mower",
15
14
  "IoT",
16
15
  "Anthbot",
17
- "lawn"
16
+ "lawn",
17
+ "ioBroker"
18
18
  ],
19
19
  "repository": {
20
20
  "type": "git",
21
- "url": "https://github.com/raintonr/ioBroker.anthbot.git"
21
+ "url": "https://github.com/iobroker-community-adapters/ioBroker.anthbot.git"
22
22
  },
23
23
  "engines": {
24
- "node": ">= 20"
24
+ "node": ">= 22"
25
25
  },
26
26
  "dependencies": {
27
27
  "@iobroker/adapter-core": "^3.3.2",
@@ -64,7 +64,7 @@
64
64
  "dev-server": "dev-server"
65
65
  },
66
66
  "bugs": {
67
- "url": "https://github.com/raintonr/ioBroker.anthbot/issues"
67
+ "url": "https://github.com/iobroker-community-adapters/ioBroker.anthbot/issues"
68
68
  },
69
69
  "readmeFilename": "README.md"
70
70
  }