iobroker.anthbot 0.0.4 → 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/README.md CHANGED
@@ -14,11 +14,48 @@
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
+
22
59
  ### 0.0.4 (2026-05-18)
23
60
  - (copilot) Adapter requires node.js >= 22 now
24
61
  - (copilot) Adapter requires admin >= 7.7.22 now
package/io-package.json CHANGED
@@ -1,8 +1,21 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "anthbot",
4
- "version": "0.0.4",
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
+ },
6
19
  "0.0.4": {
7
20
  "en": "Adapter requires node.js >= 22 now\nAdapter requires admin >= 7.7.22 now\nHandle temporary IoT access tokens (#9)",
8
21
  "de": "Adapter benötigt node.js >= 22 jetzt\nAdapter benötigt admin >= 7.7.22 jetzt\nHandle temporäre IoT-Zugriffstoken (#9)",
package/lib/anthbotApi.js CHANGED
@@ -138,7 +138,6 @@ class AnthbotCloudApiClient {
138
138
  * @param {string} username - Username
139
139
  * @param {string} password - Password
140
140
  * @param {number} areaCode - Country/region code
141
- * @returns {Promise<string>} Bearer token
142
141
  */
143
142
  async asyncLogin(username, password, areaCode) {
144
143
  const url = `https://${this.endpointHost}/api/v1/login`;
@@ -187,16 +186,48 @@ class AnthbotCloudApiClient {
187
186
  const bearerToken = `Bearer ${accessToken}`;
188
187
  this.bearerToken = bearerToken;
189
188
  this.authHeaders['Authorization'] = bearerToken;
190
- return bearerToken;
191
189
  }
192
190
 
193
191
  /**
194
- * Make sure we have valid bearerToken
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
195
197
  */
196
- checkToken() {
197
- if (!this.bearerToken) {
198
- throw new Error('Bearer token not configured');
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');
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}`);
199
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;
200
231
  }
201
232
 
202
233
  /**
@@ -205,19 +236,9 @@ class AnthbotCloudApiClient {
205
236
  * @returns {Promise<{alias: string, sn: string}[]>} - List of devices
206
237
  */
207
238
  async asyncGetBoundDevices() {
208
- this.checkToken();
209
-
210
239
  const url = `https://${this.endpointHost}/api/v1/device/bind/list`;
211
- const response = await this.fetch(url, {
212
- method: 'GET',
213
- headers: this.authHeaders,
214
- });
215
240
 
216
- if (response.status !== 200) {
217
- throw new Error(`Request to ${url} failed, response ${response.status}`);
218
- }
219
-
220
- return /** @type {{data?: any}} */ (await response.json()).data;
241
+ return /** @type {{alias: string, sn: string}[]} */ (await this.fetchPayloadData(url));
221
242
  }
222
243
 
223
244
  /**
@@ -230,21 +251,10 @@ class AnthbotCloudApiClient {
230
251
  * @returns {Promise<unknown>} Latest messages
231
252
  */
232
253
  async asyncGetCodeList(serialNumber, pageNum = 1, pageSize = 1) {
233
- this.checkToken();
234
-
235
254
  // TODO: allow language other than English?
236
255
  const url = `https://${this.endpointHost}/api/v1/device/v2/code/list?sn=${serialNumber}&pagenum=${pageNum}&pagesize=${pageSize}&language=English`;
237
256
 
238
- const response = await this.fetch(url, {
239
- method: 'GET',
240
- headers: this.authHeaders,
241
- });
242
-
243
- if (response.status !== 200) {
244
- throw new Error(`Request to ${url} failed, response ${response.status}`);
245
- }
246
-
247
- return /** @type {{data?: any}} */ (await response.json())?.data?.data;
257
+ return /** @type {unknown} */ ((await this.fetchPayloadData(url))?.data);
248
258
  }
249
259
 
250
260
  /**
@@ -270,8 +280,6 @@ class AnthbotCloudApiClient {
270
280
  * @returns {Promise<string>} - URL for the requested file
271
281
  */
272
282
  async asyncGetPresignedUrl(serialNumber, filename, category, sub_category) {
273
- this.checkToken();
274
-
275
283
  const params = new URLSearchParams({
276
284
  filename,
277
285
  sn: serialNumber,
@@ -280,16 +288,8 @@ class AnthbotCloudApiClient {
280
288
  verification_token: this.buildVerificationToken(serialNumber),
281
289
  });
282
290
  const url = `https://${this.endpointHost}/api/v1/device/v2/presigned_url?${params}`;
283
- const response = await this.fetch(url, {
284
- method: 'GET',
285
- headers: this.authHeaders,
286
- });
287
291
 
288
- if (response.status !== 200) {
289
- throw new Error(`Request to ${url} failed, response ${response.status}`);
290
- }
291
-
292
- return /** @type {{data?: any}} */ (await response.json()).data?.presigned_url;
292
+ return /** @type {string} */ ((await this.fetchPayloadData(url))?.presigned_url);
293
293
  }
294
294
 
295
295
  /**
@@ -394,35 +394,10 @@ class AnthbotCloudApiClient {
394
394
  this.verboseLogger(`Cache miss - fetching device region for ${serialNumber}`);
395
395
  }
396
396
 
397
- this.checkToken();
398
-
399
397
  const url = `https://${this.endpointHost}/api/v1/device/v2/region`;
400
398
  const params = new URLSearchParams({ sn: serialNumber });
401
- const response = await this.fetch(`${url}?${params}`, {
402
- method: 'GET',
403
- headers: this.authHeaders,
404
- });
405
-
406
- if (response.status !== 200) {
407
- const body = await response.text();
408
- throw new Error(`Device region failed (${response.status}): ${body.slice(0, 300)}`);
409
- }
399
+ const data = await this.fetchPayloadData(`${url}?${params}`);
410
400
 
411
- let payload;
412
- try {
413
- payload = /** @type {{code: number, data?: any}} */ (await response.json());
414
- } catch {
415
- throw new Error('Invalid JSON response from device region');
416
- }
417
-
418
- if (typeof payload !== 'object' || payload === null) {
419
- throw new Error('Invalid device region payload type');
420
- }
421
- if (payload.code !== 0) {
422
- throw new Error(`Device region returned code=${payload.code}`);
423
- }
424
-
425
- const data = payload.data;
426
401
  if (typeof data !== 'object' || data === null) {
427
402
  throw new Error('Device region payload missing data object');
428
403
  }
@@ -450,39 +425,17 @@ class AnthbotCloudApiClient {
450
425
  * @returns {Promise<object>} - IoT credentials and expiration
451
426
  */
452
427
  async asyncGetDeviceIotCredentials(serialNumber) {
453
- this.checkToken();
454
-
455
428
  const params = {
456
429
  sn: serialNumber,
457
430
  verification_token: this.buildVerificationToken(serialNumber),
458
431
  };
459
432
 
460
- const response = await this.fetch(`https://${this.endpointHost}/api/v1/device/v2/iot/sts/arn`, {
433
+ const data = await this.fetchPayloadData(`https://${this.endpointHost}/api/v1/device/v2/iot/sts/arn`, {
461
434
  method: 'POST',
462
- headers: { ...this.authHeaders, 'content-type': 'application/json' },
435
+ headers: { 'content-type': 'application/json' },
463
436
  body: JSON.stringify(params),
464
437
  });
465
438
 
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
439
  if (typeof data !== 'object' || data === null) {
487
440
  throw new Error('IoT credentials payload missing data object');
488
441
  }
@@ -544,7 +497,7 @@ class AnthbotCloudApiClient {
544
497
  * Get shadow reported state
545
498
  *
546
499
  * @param {string} serialNumber - Device serial number
547
- * @returns {Promise<unknown>} - Reported state
500
+ * @returns {Promise<any>} - Reported state
548
501
  */
549
502
  async asyncGetShadowReportedState(serialNumber) {
550
503
  await this.checkShadowClient(serialNumber);
package/main.js CHANGED
@@ -103,7 +103,7 @@ class Anthbot extends utils.Adapter {
103
103
  }
104
104
  }
105
105
 
106
- // Overlay elements from the state onto existing zones so user only has to set
106
+ // Overlay elements from the state onto existing custom areas so user only has to set
107
107
  // the items they are changing and rest will be preserved.
108
108
 
109
109
  // Variable named to match asyncSendServiceCommand data
@@ -112,7 +112,7 @@ class Anthbot extends utils.Adapter {
112
112
  if (!custom_areas) {
113
113
  this.log.error(`Bad area data in ${id}`);
114
114
  } else {
115
- // Write the given area (zone) data
115
+ // Write the given custom area data
116
116
  this.log.info(`${device.alias}: area_set ${JSON.stringify(customAreas)}`);
117
117
  await this.client.asyncSendServiceCommand(serialNumber, 'area_set', {
118
118
  custom_areas,
@@ -125,74 +125,62 @@ class Anthbot extends utils.Adapter {
125
125
  }
126
126
 
127
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
- }
128
+ const goodAreaList = await this.isGoodAreaList(device, `map.custom_areas.raw`);
129
+ if (goodAreaList) {
130
+ this.log.info(
131
+ `${device.alias}: custom_area_mow_start ${JSON.stringify(goodAreaList)}`,
132
+ );
133
+ await this.client.asyncSendServiceCommand(serialNumber, 'custom_area_mow_start', {
134
+ id: goodAreaList,
135
+ });
136
+ ackState = true;
148
137
  }
138
+ break;
139
+ }
149
140
 
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
- }
141
+ case 'ridable_mow_start': {
142
+ const goodAreaList = await this.isGoodAreaList(device, `map.ridable_areas.raw`);
143
+ if (goodAreaList) {
144
+ this.log.info(`${device.alias}: ridable_mow_start ${JSON.stringify(goodAreaList)}`);
145
+ await this.client.asyncSendServiceCommand(serialNumber, 'ridable_mow_start', {
146
+ id: goodAreaList,
147
+ });
148
+ ackState = true;
168
149
  }
169
150
  break;
170
151
  }
171
152
 
172
- case 'zone_list': {
173
- let zoneList;
153
+ case 'area_list': {
154
+ let areaList;
174
155
  // This will affect the next start command only.
175
156
  if (typeof state?.val === 'string' && state.val !== '') {
176
157
  // Some kind of non-blank value given
177
158
  try {
178
- zoneList = JSON.parse(state.val);
159
+ areaList = JSON.parse(state.val);
179
160
  } catch (error) {
180
- this.log.error(`Failed to parse zone list for ${id}: ${error.message}`);
161
+ this.log.error(`Failed to parse area list for ${id}: ${error.message}`);
181
162
  }
182
163
 
183
- // Make sure all IDs in list are valid
184
- if (!this.isGoodZoneList(device, zoneList)) {
164
+ // Make sure this is a list & is of valid custom or ridable area IDs
165
+ if (
166
+ !areaList ||
167
+ !Array.isArray(areaList) ||
168
+ !(
169
+ (await this.isGoodAreaList(device, 'map.custom_areas.raw', areaList)) ||
170
+ (await this.isGoodAreaList(device, 'map.ridable_areas.raw', areaList))
171
+ )
172
+ ) {
185
173
  // Set to null so we don't ack it
186
- zoneList = null;
174
+ areaList = null;
187
175
  }
188
176
  } else {
189
177
  // No value given, so ack an empty list
190
- zoneList = [];
178
+ areaList = [];
191
179
  }
192
180
 
193
181
  // Ack only if we now have a list
194
- if (Array.isArray(zoneList)) {
195
- ackState = JSON.stringify(zoneList);
182
+ if (Array.isArray(areaList)) {
183
+ ackState = JSON.stringify(areaList);
196
184
  // We don't need to sync after this as no command was actually sent yet
197
185
  doSync = false;
198
186
  }
@@ -318,16 +306,6 @@ class Anthbot extends utils.Adapter {
318
306
  // Wait a second for their backend
319
307
  await new Promise(resolve => this.setTimeout(resolve, 1000, null));
320
308
 
321
- // TODO: figure out how to tell when map changes and reload periodically?
322
- const deviceMapFiles = await this.client.asyncGetDeviceMap(device.sn);
323
-
324
- const areaSetting = deviceMapFiles['area_setting.json'];
325
- this.log.debug(`area_setting.json: ${JSON.stringify(areaSetting)}`);
326
- this.setZoneInfo(device, areaSetting?.content?.custom_areas);
327
-
328
- const timeSetting = deviceMapFiles['time_setting.json'];
329
- this.log.debug(`time_setting.json: ${JSON.stringify(timeSetting)}`);
330
-
331
309
  await this.pollDevice(device);
332
310
  this.pollingInterval = this.setInterval(async () => {
333
311
  this.pollDevice(device);
@@ -357,18 +335,24 @@ class Anthbot extends utils.Adapter {
357
335
  // Poll device
358
336
  async pollDevice(device) {
359
337
  if (this.checkClient()) {
338
+ // Map area ID for checking for changes
339
+ let area_id;
340
+
341
+ // Shadow state
360
342
  try {
361
343
  const shadowState = await this.client.asyncGetShadowReportedState(device.sn);
362
344
  this.log.debug(`Device shadow reported state:\n${JSON.stringify(shadowState)}`);
363
345
  await this.setShadowState(device, shadowState);
346
+ area_id = shadowState.map.area_id;
364
347
  } catch (err) {
365
348
  this.log.error(`Failed to fetch shadow state for device ${device.sn}: ${err.message}`);
366
349
  // TODO: If something goes wrong here, might not be serious, maybe don't do a full reconnect?
367
350
  this.retryConnection();
368
351
  }
369
352
 
353
+ // Code list
370
354
  try {
371
- const codeList = await this.client.asyncGetCodeList(device.sn);
355
+ const codeList = await this.client.asyncGetCodeList(device.sn, 1, 20 /* TODO: make configurable? */);
372
356
  this.log.debug(`Device code list:\n${JSON.stringify(codeList)}`);
373
357
  await this.setCodeList(device, codeList);
374
358
  } catch (err) {
@@ -376,243 +360,150 @@ class Anthbot extends utils.Adapter {
376
360
  // TODO: If something goes wrong here, might not be serious, maybe don't do a full reconnect?
377
361
  this.retryConnection();
378
362
  }
379
- }
380
- }
381
363
 
382
- // Create objects for device
383
- async createDeviceObjects(device) {
384
- await this.setObjectNotExistsAsync(device.sn, {
385
- type: 'device',
386
- common: {
387
- name: device.alias,
388
- },
389
- native: {},
390
- });
364
+ // Fetch map if changed
365
+ if (area_id != device.area_id) {
366
+ this.log.info(`Device map area_id change detected, fetching new map info`);
391
367
 
392
- // Shadow properties...
393
- await this.setObjectNotExistsAsync(`${device.sn}.elec`, {
394
- type: 'state',
395
- common: {
396
- name: 'elec',
397
- type: 'number',
398
- unit: '%',
399
- desc: 'Battery level',
400
- role: 'level.battery',
401
- read: true,
402
- write: false,
403
- },
404
- native: {},
405
- });
368
+ const deviceMapFiles = await this.client.asyncGetDeviceMap(device.sn);
406
369
 
407
- await this.setObjectNotExistsAsync(`${device.sn}.mode`, {
408
- type: 'state',
409
- common: {
410
- name: 'mode',
411
- type: 'string',
412
- role: 'text',
413
- desc: 'Current mode',
414
- read: true,
415
- write: false,
416
- },
417
- native: {},
418
- });
370
+ const areaSetting = deviceMapFiles['area_setting.json'];
371
+ this.log.debug(`area_setting.json: ${JSON.stringify(areaSetting)}`);
419
372
 
420
- await this.setObjectNotExistsAsync(`${device.sn}.mowing_area`, {
421
- type: 'state',
422
- common: {
423
- name: 'mowing_area',
424
- type: 'number',
425
- unit: 'm²',
426
- desc: 'Current mowing area',
427
- role: 'value',
428
- read: true,
429
- write: false,
430
- },
431
- native: {},
432
- });
373
+ const custom_areas = areaSetting?.content?.custom_areas;
374
+ this.log.debug(`custom_areas: ${JSON.stringify(custom_areas)}`);
433
375
 
434
- await this.setObjectNotExistsAsync(`${device.sn}.mowing_time`, {
435
- type: 'state',
436
- common: {
437
- name: 'mowing_time',
438
- type: 'number',
439
- unit: 's',
440
- role: 'time.span',
441
- desc: 'Current mowing time',
442
- read: true,
443
- write: false,
444
- },
445
- native: {},
446
- });
376
+ // Stash custom_areas in the passed device
377
+ device.customAreas = custom_areas;
447
378
 
448
- await this.setObjectNotExistsAsync(`${device.sn}.rtk_moved`, {
449
- type: 'state',
450
- common: {
451
- name: 'rtk_moved',
452
- type: 'boolean',
453
- role: 'sensor.motion',
454
- desc: 'RTK movement detected',
455
- read: true,
456
- write: false,
457
- },
458
- native: {},
459
- });
379
+ // And save the state
380
+ this.setStateChanged(`${device.sn}.map.custom_areas.raw`, {
381
+ val: JSON.stringify(custom_areas),
382
+ ack: true,
383
+ });
460
384
 
461
- await this.setObjectNotExistsAsync(`${device.sn}.rtk_state`, {
462
- type: 'state',
463
- common: {
464
- name: 'rtk_state',
465
- type: 'boolean',
466
- role: 'sensor',
467
- desc: 'RTK state',
468
- read: true,
469
- write: false,
470
- },
471
- native: {},
472
- });
385
+ // Save ridable areas state
386
+ this.setStateChanged(`${device.sn}.map.ridable_areas.raw`, {
387
+ val: JSON.stringify(areaSetting?.content?.ridable_areas),
388
+ ack: true,
389
+ });
473
390
 
474
- // Code list (aka. messages)
475
- await this.setObjectNotExistsAsync(`${device.sn}.last_code`, {
476
- type: 'state',
477
- common: {
478
- name: 'last_code',
479
- type: 'number',
480
- desc: 'Last code',
481
- role: 'value',
482
- read: true,
483
- write: false,
484
- },
485
- native: {},
486
- });
391
+ // TODO: plan is to create a channel for each area and store info for them indivicually.
487
392
 
488
- await this.setObjectNotExistsAsync(`${device.sn}.last_code_text`, {
489
- type: 'state',
490
- common: {
491
- name: 'last_code_text',
492
- type: 'string',
493
- role: 'text',
494
- desc: 'Last code text',
495
- read: true,
496
- write: false,
497
- },
498
- native: {},
499
- });
393
+ const timeSetting = deviceMapFiles['time_setting.json'];
394
+ this.log.debug(`time_setting.json: ${JSON.stringify(timeSetting)}`);
500
395
 
501
- await this.setObjectNotExistsAsync(`${device.sn}.last_code_type`, {
502
- type: 'state',
503
- common: {
504
- name: 'last_code_type',
505
- type: 'string',
506
- role: 'text',
507
- desc: 'Last code type (e.g. event, error, etc.)',
508
- read: true,
509
- write: false,
510
- },
511
- native: {},
512
- });
396
+ // And remember which area_id we loaded last
397
+ device.area_id = area_id;
398
+ }
399
+ }
400
+ }
513
401
 
514
- await this.setObjectNotExistsAsync(`${device.sn}.zone_info`, {
515
- type: 'state',
402
+ // Create objects for device
403
+ async createDeviceObjects(device) {
404
+ await this.setObjectNotExistsAsync(device.sn, {
405
+ type: 'device',
516
406
  common: {
517
- name: 'zone_info',
518
- type: 'string',
519
- role: 'json',
520
- desc: 'JSON object with zone information',
521
- read: true,
522
- write: false,
407
+ name: device.alias,
523
408
  },
524
409
  native: {},
525
410
  });
526
411
 
527
- // Command buttons
528
- await this.setObjectNotExistsAsync(`${device.sn}.command.custom_area_mow_start`, {
529
- type: 'state',
530
- common: {
531
- name: 'start',
532
- type: 'boolean',
533
- role: 'button.start',
534
- desc: 'Start zone mowing',
535
- read: false,
536
- write: true,
537
- },
538
- native: {},
539
- });
540
- await this.setObjectNotExistsAsync(`${device.sn}.command.mow_start`, {
541
- type: 'state',
542
- common: {
543
- name: 'start',
544
- type: 'boolean',
545
- role: 'button.start',
546
- desc: 'Start global mowing',
547
- read: false,
548
- write: true,
549
- },
550
- native: {},
551
- });
552
- await this.setObjectNotExistsAsync(`${device.sn}.command.stop_all_tasks`, {
553
- type: 'state',
554
- common: {
555
- name: 'stop',
556
- type: 'boolean',
557
- role: 'button.stop',
558
- desc: 'Stop',
559
- read: false,
560
- write: true,
561
- },
562
- native: {},
563
- });
564
- await this.setObjectNotExistsAsync(`${device.sn}.command.mow_pause`, {
565
- type: 'state',
566
- common: {
567
- name: 'pause',
568
- type: 'boolean',
569
- role: 'button.pause',
570
- desc: 'Pause',
571
- read: false,
572
- write: true,
573
- },
574
- native: {},
575
- });
576
- await this.setObjectNotExistsAsync(`${device.sn}.command.charge_start`, {
577
- type: 'state',
578
- common: {
579
- name: 'home',
580
- type: 'boolean',
581
- role: 'button',
582
- desc: 'Return home/start charging',
583
- read: false,
584
- write: true,
585
- },
586
- native: {},
587
- });
412
+ const channels = [
413
+ ['command', 'Commands'],
414
+ ['map', 'Map info'],
415
+ ['map.custom_areas', 'Custom areas, aka. Zones'],
416
+ ['map.ridable_areas', 'Ridable areas, aka. Edges'],
417
+ ];
418
+
419
+ for (const channel of channels) {
420
+ await this.setObjectNotExistsAsync(`${device.sn}.${channel[0]}`, {
421
+ type: 'channel',
422
+ common: {
423
+ name: channel[0],
424
+ desc: channel[1],
425
+ },
426
+ native: {},
427
+ });
428
+ }
588
429
 
589
- // Zone list for relevant commands
590
- await this.setObjectNotExistsAsync(`${device.sn}.command.zone_list`, {
591
- type: 'state',
592
- common: {
593
- name: 'zone_list',
594
- type: 'array',
595
- role: 'info.ids',
596
- desc: `Zone list for next command (array of zone IDs, e.g. '[101,120,132]')`,
597
- read: false,
598
- write: true,
599
- },
600
- native: {},
601
- });
430
+ const readOnlyStates = [
431
+ // Shadow properties
432
+ ['active_area', 'array', 'info.ids', 'List of areas currently being mowed'],
433
+ ['elec', 'number', 'level.battery', 'Battery level', '%'],
434
+ ['mode', 'string', 'text', 'Current mode'],
435
+ ['mowing_area', 'number', 'value', 'Current mowing area', 'm²'],
436
+ ['mowing_time', 'number', 'time.span', 'Current mowing time', 's'],
437
+ ['rtk_moved', 'boolean', 'sensor.motion', 'RTK movement detected'],
438
+ ['rtk_state', 'boolean', 'sensor', 'RTK state'],
439
+
440
+ // Code list (aka. messages)
441
+ // Full list for 'power users'
442
+ ['code_list', 'string', 'json', 'JSON object with last page of codes'],
443
+ // Last code for simplcity
444
+ ['last_code', 'number', 'value', 'Last code'],
445
+ ['last_code_text', 'string', 'text', 'Last code text'],
446
+ ['last_code_type', 'string', 'text', 'Last code type (e.g. event, error, etc.)'],
447
+
448
+ // Maps
449
+ ['map.area_id', 'string', 'text', 'ID of current map area'],
450
+ ['map.map_area', 'number', 'value', 'Surface area of map', 'm²'],
451
+ ['map.custom_areas.raw', 'string', 'json', 'JSON object with custom area (aka. zone) information'],
452
+ ['map.ridable_areas.raw', 'string', 'json', 'JSON object with ridable area (aka. edge) information'],
453
+ ];
454
+
455
+ for (const state of readOnlyStates) {
456
+ const common = {
457
+ name: state[0],
458
+ type: state[1],
459
+ role: state[2],
460
+ desc: state[3],
461
+ read: true,
462
+ write: false,
463
+ };
464
+ if (state[4]) {
465
+ common.unit = state[4];
466
+ }
467
+ // @ts-expect-error as 'type' below as a plain string doesn't check against ioBroker.CommonType
468
+ await this.setObjectNotExistsAsync(`${device.sn}.${state[0]}`, {
469
+ type: 'state',
470
+ common,
471
+ native: {},
472
+ });
473
+ }
602
474
 
603
- // For 'area_set'
604
- await this.setObjectNotExistsAsync(`${device.sn}.command.area_set`, {
605
- type: 'state',
606
- common: {
607
- name: 'area_set',
608
- type: 'string',
609
- role: 'json',
610
- desc: 'JSON object with zone information to write',
475
+ const commandStates = [
476
+ // Command buttons
477
+ ['mow_start', 'boolean', 'button.start', 'Start global mowing'],
478
+ ['stop_all_tasks', 'boolean', 'button.stop', 'Stop'],
479
+ ['mow_pause', 'boolean', 'button.pause', 'Pause'],
480
+ ['charge_start', 'boolean', 'button', 'Return home/start charging'],
481
+ ['custom_area_mow_start', 'boolean', 'button.start', 'Start custom area (aka. zone) mowing'],
482
+ ['ridable_mow_start', 'boolean', 'button.start', 'Start ridable area (aka. edge) mowing'],
483
+
484
+ // Area list for relevant commands
485
+ ['area_list', 'string', 'info.ids', `Areas for next command (array of IDs, e.g. '[101,120,132]')`],
486
+
487
+ // For 'area_set'
488
+ ['area_set', 'string', 'json', 'JSON object with custom area (aka. zone) information to write'],
489
+ ];
490
+
491
+ for (const state of commandStates) {
492
+ const common = {
493
+ name: state[0],
494
+ type: state[1],
495
+ role: state[2],
496
+ desc: state[3],
611
497
  read: false,
612
498
  write: true,
613
- },
614
- native: {},
615
- });
499
+ };
500
+ // @ts-expect-error as 'type' below as a plain string doesn't check against ioBroker.CommonType
501
+ await this.setObjectNotExistsAsync(`${device.sn}.command.${state[0]}`, {
502
+ type: 'state',
503
+ common,
504
+ native: {},
505
+ });
506
+ }
616
507
  }
617
508
 
618
509
  // Helper function to set shadow state values
@@ -623,15 +514,24 @@ class Anthbot extends utils.Adapter {
623
514
  this.setConnected(false);
624
515
  }
625
516
 
517
+ this.setStateChanged(`${device.sn}.active_area`, {
518
+ val: JSON.stringify(shadowState.active_area.id),
519
+ ack: true,
520
+ });
626
521
  this.setStateChanged(`${device.sn}.elec`, { val: shadowState.elec.value, ack: true });
627
522
  this.setStateChanged(`${device.sn}.mode`, { val: shadowState.mode.value, ack: true });
628
523
  this.setStateChanged(`${device.sn}.mowing_area`, { val: shadowState.mowing_area.value, ack: true });
629
524
  this.setStateChanged(`${device.sn}.mowing_time`, { val: shadowState.mowing_time.value, ack: true });
630
525
  this.setStateChanged(`${device.sn}.rtk_moved`, { val: shadowState.rtk.moved == 1, ack: true });
631
526
  this.setStateChanged(`${device.sn}.rtk_state`, { val: shadowState.rtk.state == 1, ack: true });
527
+
528
+ this.setStateChanged(`${device.sn}.map.area_id`, { val: shadowState.map.area_id, ack: true });
529
+ this.setStateChanged(`${device.sn}.map.map_area`, { val: shadowState.map.map_area, ack: true });
632
530
  }
633
531
 
634
532
  setCodeList(device, codeList) {
533
+ this.setStateChanged(`${device.sn}.code_list`, { val: JSON.stringify(codeList), ack: true });
534
+
635
535
  const lastCode = codeList[0];
636
536
  this.setStateChanged(`${device.sn}.last_code`, { val: lastCode.code, ack: true });
637
537
  this.setStateChanged(`${device.sn}.last_code_text`, { val: lastCode.event_message, ack: true });
@@ -647,7 +547,7 @@ class Anthbot extends utils.Adapter {
647
547
  for (const area of customAreas) {
648
548
  let outArea;
649
549
 
650
- const existingArea = device.zoneList.find(zone => zone.id === area.id);
550
+ const existingArea = device.customAreas.find(customArea => customArea.id === area.id);
651
551
  if (existingArea) {
652
552
  this.log.debug(`Found existing area ${area.id} for merge: ${JSON.stringify(existingArea)}`);
653
553
  outArea = { ...existingArea, ...area };
@@ -697,42 +597,72 @@ class Anthbot extends utils.Adapter {
697
597
  return outputAreas;
698
598
  }
699
599
 
700
- isGoodZoneList(device, zoneList) {
600
+ parseJsonList(jsonString) {
601
+ let out;
602
+ if (typeof jsonString !== 'string') {
603
+ this.log.error('JSON to parse is not a string');
604
+ } else {
605
+ try {
606
+ out = JSON.parse(jsonString);
607
+ } catch (error) {
608
+ this.log.error(`Failed to parse JSON list: ${error.message}`);
609
+ }
610
+ }
611
+
701
612
  // List to check must be an array
702
- if (!Array.isArray(zoneList)) {
703
- this.log.error(`Invalid zone list: not an array`);
613
+ if (!Array.isArray(out)) {
614
+ this.log.error(`Invalid JSON list: not an array`);
615
+ out = undefined;
616
+ }
617
+
618
+ // List to check must have at least one item
619
+ if (out && out.length < 1) {
620
+ this.log.error(`Invalid JSON list: array is empty`);
621
+ out = undefined;
622
+ }
623
+
624
+ return out;
625
+ }
626
+
627
+ /**
628
+ *
629
+ * @param {object} device Device object for the check or specific list
630
+ * @param {string} checkListStateId State ID holding raw JSON list to check against
631
+ * @param {array | null} passedToCheck Optional list to check instead of fetching from command state
632
+ * @returns {Promise<array | false>} List if good, false if not
633
+ */
634
+
635
+ async isGoodAreaList(device, checkListStateId, passedToCheck = null) {
636
+ const listToCheck = passedToCheck
637
+ ? passedToCheck
638
+ : this.parseJsonList((await this.getStateAsync(`${device.sn}.command.area_list`))?.val);
639
+
640
+ if (!listToCheck || !Array.isArray(listToCheck)) {
641
+ this.log.error('Area list to check is not valid');
704
642
  return false;
705
643
  }
706
644
 
707
- // If we don't even have zones for our device any list is bad
708
- if (!device.zoneList) {
709
- this.log.error('Invalid zone list: device has no zone info');
645
+ // Get IDs to check against
646
+ const checkAreas = this.parseJsonList((await this.getStateAsync(`${device.sn}.${checkListStateId}`))?.val);
647
+ // If we don't even have custom areas for our device any list is bad
648
+ if (!checkAreas) {
649
+ this.log.error(`Area list invalid: no IDs to check against in ${checkListStateId}`);
710
650
  return false;
711
651
  }
712
652
 
713
- // Each zone in array must be a known zone ID
714
- checkZone: for (const zoneId of zoneList) {
715
- for (const zone of device.zoneList) {
716
- if (zone.id === zoneId) {
717
- continue checkZone;
653
+ // Each area in array must be a known custom area ID
654
+ checkArea: for (const areaToCheck of listToCheck) {
655
+ for (const checkArea of checkAreas) {
656
+ if (checkArea.id === areaToCheck) {
657
+ continue checkArea;
718
658
  }
719
659
  }
720
- // If we didn't continue, then we didn't find the zoneId in our info list, so it's not good
721
- this.log.error(`Invalid zone list: ${zoneId} not found in device info`);
660
+ // If we didn't continue, then we didn't find the area ID in our info list, so it's not good
661
+ this.log.warn(`Invalid custom area list: ${areaToCheck} not found in check list ${checkListStateId}`);
722
662
  return false;
723
663
  }
724
664
 
725
- return true;
726
- }
727
-
728
- setZoneInfo(device, zoneInfo) {
729
- this.log.debug(`zone_info for ${device.sn}: ${JSON.stringify(zoneInfo)}`);
730
-
731
- // Stash in the passed device
732
- device.zoneList = zoneInfo;
733
-
734
- // And save the state
735
- this.setStateChanged(`${device.sn}.zone_info`, { val: JSON.stringify(zoneInfo), ack: true });
665
+ return listToCheck;
736
666
  }
737
667
 
738
668
  subscribeToDevice(device) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.anthbot",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Connect with Anthbot devices such as their robot mowers",
5
5
  "author": "Robin Rainton <robin@rainton.com>",
6
6
  "contributors": [