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 +1 -0
- package/README.md +11 -1
- package/io-package.json +40 -6
- package/lib/anthbotApi.js +174 -51
- package/main.js +124 -118
- package/package.json +11 -11
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
[](https://nodei.co/npm/iobroker.anthbot/)
|
|
10
10
|
|
|
11
|
-
**Tests:** 
|
|
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.
|
|
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/
|
|
64
|
-
"readme": "https://github.com/
|
|
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.
|
|
109
|
+
"admin": ">=7.7.22"
|
|
83
110
|
}
|
|
84
111
|
]
|
|
85
112
|
},
|
|
86
113
|
"native": {
|
|
87
114
|
"username": "",
|
|
88
115
|
"password": "",
|
|
89
|
-
"regionCode":
|
|
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(
|
|
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 {{
|
|
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 {{
|
|
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 {{
|
|
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}
|
|
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(
|
|
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(
|
|
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 {{
|
|
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 {{
|
|
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 {{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
this.log.error(
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
124
|
+
break;
|
|
119
125
|
}
|
|
120
126
|
|
|
121
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
224
|
+
// Ack command if verified valid above
|
|
225
|
+
if (ackState) {
|
|
226
|
+
await this.setState(id, ackState, true);
|
|
218
227
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "Connect with Anthbot devices such as their robot mowers",
|
|
5
|
-
"author":
|
|
6
|
-
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
"
|
|
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/
|
|
21
|
+
"url": "https://github.com/iobroker-community-adapters/ioBroker.anthbot.git"
|
|
22
22
|
},
|
|
23
23
|
"engines": {
|
|
24
|
-
"node": ">=
|
|
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/
|
|
67
|
+
"url": "https://github.com/iobroker-community-adapters/ioBroker.anthbot/issues"
|
|
68
68
|
},
|
|
69
69
|
"readmeFilename": "README.md"
|
|
70
70
|
}
|