iobroker.sunseeker 0.0.1

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/main.js ADDED
@@ -0,0 +1,1301 @@
1
+ "use strict";
2
+
3
+ /*
4
+ * ioBroker.sunseeker
5
+ *
6
+ * Polls device list + per-device status/settings from the Sunseeker cloud
7
+ * (server.sk-robot.com for "Old", wirefree-specific[-us].sk-robot.com for
8
+ * "New" / X / V / S / V1 models) and subscribes to MQTT for realtime push.
9
+ *
10
+ * State tree: json2iob writes the raw API payloads under <sn>.list / .status /
11
+ * .settings / .mqtt. Commands are dispatched via REST when <sn>.remote.* is set.
12
+ */
13
+
14
+ const utils = require("@iobroker/adapter-core");
15
+ const crypto = require("node:crypto");
16
+ const fs = require("node:fs");
17
+ const path = require("node:path");
18
+ const axios = require("axios");
19
+ const mqtt = require("mqtt");
20
+ const Json2iob = require("json2iob");
21
+
22
+ const URL_OLD = "https://server.sk-robot.com/api";
23
+ const HOST_OLD = "server.sk-robot.com";
24
+ const URL_XV_EU = "https://wirefree-specific.sk-robot.com/api";
25
+ const HOST_XV_EU = "wirefree-specific.sk-robot.com";
26
+ const URL_XV_US = "https://wirefree-specific-us.sk-robot.com/api";
27
+ const HOST_XV_US = "wirefree-specific-us.sk-robot.com";
28
+
29
+ const APP_ID = "0123456789abcdef";
30
+
31
+ const CMDURL_SXV = "/iot_mower/wireless/device/";
32
+ const CMDURL_V1 = "/app_wirelessv1_mower/wirelessv1/device/";
33
+
34
+ const MQTT_OLD_HOST = "mqtts.sk-robot.com";
35
+ const MQTT_OLD_PORT = 1883;
36
+ const MQTT_OLD_USER = "app";
37
+ const MQTT_OLD_PASS = "h4ijwkTnyrA";
38
+
39
+ const PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
40
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0f7mbMVc/YIYQbR8Ty3u
41
+ 7yx0cKX6Gt7JkVQrWynI7xM6/yVPMC1I7nXdjMlVPpc06UXoc5ClQNsTbQ4vumFg
42
+ 2RZPQwAOc7yL1Y8t1W0b9jMTztu32ZzlobfzIVkIO1R7x1I+pkyp6QDm/MnvWyeu
43
+ CM77gS2bDv47H9COQn/gy/fy9uecyWCY3u+dXQhujLPrSJ2FFs6SwD0t5QEJjdrC
44
+ ftkKQFsflm+i5RQZBMNGT3LdAMnPK4avG642Afum0SzmNrEZrIo7pr2w0fvokbWB
45
+ SOOeEdGAx7UVI1kHssOohqW37yJzzFMIlahZSEJ0A3Dm6yrtgobp2mQlCisqsVW4
46
+ XwIDAQAB
47
+ -----END PUBLIC KEY-----`;
48
+
49
+ const NEW_ACTIONS = {
50
+ start: { cmd: "start", cmdid: "startWork" },
51
+ pause: { cmd: "pause", cmdid: "pauseWork" },
52
+ dock: { cmd: "start_find_charger", cmdid: "startFindCharger" },
53
+ stop_find_charger: { cmd: "stop_find_charger", cmdid: "stopFindCharger" },
54
+ border: { cmd: "follow_border", cmdid: "followBorder" },
55
+ stop: { cmd: "stop", cmdid: "stopWork" },
56
+ stop_task: { cmd: "stop_task", cmdid: "stopTask" },
57
+ restart: { cmd: "restart", cmdid: "restartWork" },
58
+ };
59
+
60
+ const OLD_MODES = { start: 1, pause: 0, dock: 2, border: 4, stop: 4 };
61
+ const V1_MODES = { start: 1, pause: 0, dock: 2, border: 4, stop: 4 };
62
+
63
+ const ERRORTYPE_LABELS = {
64
+ 0: "normal",
65
+ 2: "Trapped",
66
+ 16: "No border",
67
+ 32: "Started outside border",
68
+ 262144: "Charging power to high",
69
+ };
70
+
71
+ class Sunseeker extends utils.Adapter {
72
+ /**
73
+ * @param {Partial<utils.AdapterOptions>} [options] - Adapter options
74
+ */
75
+ constructor(options) {
76
+ super({ ...options, name: "sunseeker" });
77
+ this.on("ready", this.onReady.bind(this));
78
+ this.on("stateChange", this.onStateChange.bind(this));
79
+ this.on("unload", this.onUnload.bind(this));
80
+
81
+ this.session = null;
82
+ this.devicesRaw = {};
83
+ this.deviceMeta = {};
84
+ this.pollInterval = undefined;
85
+ this.refreshInterval = undefined;
86
+ this.mqttRetryTimer = undefined;
87
+ this.mqttOldRetryTimer = undefined;
88
+ this.mqttClient = null;
89
+ this.mqttOldClient = null;
90
+ this.mqttPassword = undefined;
91
+ this.unloading = false;
92
+ this.json2iob = new Json2iob(this);
93
+ this.eventCodes = {};
94
+ this.v1EventCodes = {};
95
+ }
96
+
97
+ async onReady() {
98
+ this.setState("info.connection", false, true);
99
+
100
+ const cfg = this.config;
101
+ if (!cfg.username || !cfg.password) {
102
+ this.log.error("Bitte Benutzername und Passwort in den Adapter-Einstellungen setzen");
103
+ return;
104
+ }
105
+ cfg.region = (cfg.region || "EU").toUpperCase();
106
+ cfg.apptype = cfg.apptype || "New";
107
+ cfg.interval = Number(cfg.interval) > 0 ? Number(cfg.interval) : 300;
108
+ cfg.language = cfg.language || "de-DE";
109
+
110
+ this.loadEventCodes(cfg.language);
111
+
112
+ this.subscribeStates("*");
113
+
114
+ try {
115
+ await this.login();
116
+ } catch (err) {
117
+ this.log.error(`Login fehlgeschlagen: ${err.message}`);
118
+ return;
119
+ }
120
+ this.setState("info.connection", true, true);
121
+
122
+ try {
123
+ await this.loadDevices();
124
+ } catch (err) {
125
+ this.log.error(`Geräteliste laden fehlgeschlagen: ${err.message}`);
126
+ }
127
+
128
+ await this.updateAllDevices();
129
+
130
+ const intervalMs = cfg.interval * 1000;
131
+ this.pollInterval = this.setInterval(() => {
132
+ this.updateAllDevices().catch(err => this.log.warn(`Polling: ${err.message}`));
133
+ }, intervalMs);
134
+
135
+ const ttlSec = (this.session && this.session.expires_in ? Number(this.session.expires_in) : 3600) - 60;
136
+ this.refreshInterval = this.setInterval(
137
+ () => {
138
+ this.refreshToken().catch(err => this.log.error(`Token-Refresh: ${err.message}`));
139
+ },
140
+ Math.max(60, ttlSec) * 1000,
141
+ );
142
+
143
+ if (cfg.apptype === "New") {
144
+ this.startMqttNew();
145
+ } else {
146
+ this.startMqttOld();
147
+ }
148
+ }
149
+
150
+ onUnload(callback) {
151
+ try {
152
+ this.unloading = true;
153
+ if (this.pollInterval) {
154
+ this.clearInterval(this.pollInterval);
155
+ }
156
+ if (this.refreshInterval) {
157
+ this.clearInterval(this.refreshInterval);
158
+ }
159
+ if (this.mqttRetryTimer) {
160
+ this.clearTimeout(this.mqttRetryTimer);
161
+ }
162
+ if (this.mqttOldRetryTimer) {
163
+ this.clearTimeout(this.mqttOldRetryTimer);
164
+ }
165
+ for (const meta of Object.values(this.deviceMeta)) {
166
+ if (meta.refreshTimer) {
167
+ this.clearTimeout(meta.refreshTimer);
168
+ }
169
+ }
170
+ for (const client of [this.mqttClient, this.mqttOldClient]) {
171
+ if (client) {
172
+ try {
173
+ client.end(true);
174
+ } catch (e) {
175
+ this.log.debug(`mqtt end error: ${e.message}`);
176
+ }
177
+ }
178
+ }
179
+ this.mqttClient = null;
180
+ this.mqttOldClient = null;
181
+ this.setState("info.connection", false, true);
182
+ callback();
183
+ } catch (error) {
184
+ this.log.error(`Error during unloading: ${error.message}`);
185
+ callback();
186
+ }
187
+ }
188
+
189
+ loadEventCodes(language) {
190
+ const lang = String(language || "de")
191
+ .toLowerCase()
192
+ .slice(0, 2);
193
+ try {
194
+ const file = path.join(__dirname, "lib", "eventcodes.json");
195
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
196
+ const fallback = "en";
197
+ this.eventCodes = data.events[lang] || data.events[fallback] || {};
198
+ this.v1EventCodes = data.v1Events[lang] || data.v1Events[fallback] || {};
199
+ } catch (err) {
200
+ this.log.debug(`Event-Codes nicht ladbar: ${err.message}`);
201
+ this.eventCodes = {};
202
+ this.v1EventCodes = {};
203
+ }
204
+ }
205
+
206
+ statesForDevice(sn) {
207
+ const meta = this.deviceMeta[sn];
208
+ const isV1 = meta && (meta.modelClass === "V1" || this.config.apptype === "Old");
209
+ const events = isV1 ? this.v1EventCodes : this.eventCodes;
210
+ return {
211
+ event_code: { ...events },
212
+ errortype: { ...ERRORTYPE_LABELS },
213
+ };
214
+ }
215
+
216
+ /**
217
+ * @param {string} id
218
+ * @param {ioBroker.State | null | undefined} state
219
+ */
220
+ async onStateChange(id, state) {
221
+ if (!state || state.ack) {
222
+ return;
223
+ }
224
+ const parts = id.split(".");
225
+ const scheduleIdx = parts.indexOf("schedule");
226
+ if (scheduleIdx > 0 && parts[scheduleIdx + 1]) {
227
+ const sn = parts[scheduleIdx - 1];
228
+ const leaf = parts[scheduleIdx + 1];
229
+ if (leaf === "set") {
230
+ try {
231
+ await this.setSchedule(sn);
232
+ this.setTimeout(() => this.updateDevice(sn).catch(() => {}), 1500);
233
+ this.setState(id, { val: false, ack: true });
234
+ } catch (err) {
235
+ this.log.error(`Zeitplan für ${sn} fehlgeschlagen: ${err.message}`);
236
+ }
237
+ return;
238
+ }
239
+ this.setState(id, { val: state.val, ack: true });
240
+ return;
241
+ }
242
+ const settingsIdx = parts.indexOf("settings");
243
+ if (settingsIdx > 0 && parts[settingsIdx + 1]) {
244
+ const sn = parts[settingsIdx - 1];
245
+ const leaf = parts[settingsIdx + 1];
246
+ if (leaf === "bladeSpeed" || leaf === "bladeHeight") {
247
+ const key = leaf === "bladeSpeed" ? "speed" : "height";
248
+ try {
249
+ await this.setBlade(sn, key, Number(state.val));
250
+ this.setTimeout(() => this.updateDevice(sn).catch(() => {}), 1500);
251
+ this.setState(id, { val: state.val, ack: true });
252
+ } catch (err) {
253
+ this.log.error(`Klingen-${key} für ${sn} fehlgeschlagen: ${err.message}`);
254
+ }
255
+ return;
256
+ }
257
+ }
258
+ const remoteIdx = parts.indexOf("remote");
259
+ if (remoteIdx < 0 || remoteIdx + 1 >= parts.length) {
260
+ return;
261
+ }
262
+ const sn = parts[remoteIdx - 1];
263
+ const command = parts[remoteIdx + 1];
264
+ const dev = this.devicesRaw[sn];
265
+ if (!dev) {
266
+ this.log.warn(`onStateChange: Gerät ${sn} unbekannt`);
267
+ return;
268
+ }
269
+ try {
270
+ if (command === "refresh") {
271
+ await this.updateDevice(sn);
272
+ } else {
273
+ await this.sendCommand(sn, command, state.val);
274
+ this.setTimeout(() => this.updateDevice(sn).catch(() => {}), 1500);
275
+ }
276
+ this.setState(id, { val: state.val, ack: true });
277
+ } catch (err) {
278
+ this.log.error(`Befehl ${command} für ${sn} fehlgeschlagen: ${err.message}`);
279
+ }
280
+ }
281
+
282
+ // ------------------------------ REST ------------------------------
283
+
284
+ getBase() {
285
+ if (this.config.apptype === "Old") {
286
+ return { url: URL_OLD, host: HOST_OLD };
287
+ }
288
+ if (this.config.region === "US") {
289
+ return { url: URL_XV_US, host: HOST_XV_US };
290
+ }
291
+ return { url: URL_XV_EU, host: HOST_XV_EU };
292
+ }
293
+
294
+ authHeaders() {
295
+ const base = this.getBase();
296
+ return {
297
+ "Accept-Language": this.config.language,
298
+ Authorization: `bearer ${this.session.access_token}`,
299
+ Host: base.host,
300
+ Connection: "Keep-Alive",
301
+ "User-Agent": "okhttp/4.4.1",
302
+ };
303
+ }
304
+
305
+ /**
306
+ * @param {string} method
307
+ * @param {string} path
308
+ * @param {Record<string, string>} headers
309
+ * @param {any} [data]
310
+ */
311
+ async request(method, path, headers, data) {
312
+ const base = this.getBase();
313
+ const url = `${base.url}${path}`;
314
+ const res = await axios({
315
+ method,
316
+ url,
317
+ headers,
318
+ data,
319
+ timeout: 15000,
320
+ validateStatus: () => true,
321
+ });
322
+ if (res.status === 401) {
323
+ this.log.warn("HTTP 401 - Token wird erneuert");
324
+ await this.refreshToken();
325
+ throw new Error(`${method} ${path}: 401`);
326
+ }
327
+ if (res.data && typeof res.data === "object") {
328
+ return { status: res.status, json: res.data };
329
+ }
330
+ const preview = typeof res.data === "string" ? res.data.slice(0, 200) : String(res.data);
331
+ throw new Error(`${method} ${path} non-JSON (HTTP ${res.status}): ${preview}`);
332
+ }
333
+
334
+ async login() {
335
+ const base = this.getBase();
336
+ const body = new URLSearchParams({
337
+ username: this.config.username,
338
+ password: this.config.password,
339
+ grant_type: "password",
340
+ scope: "server",
341
+ }).toString();
342
+ const res = await axios({
343
+ method: "POST",
344
+ url: `${base.url}/auth/oauth/token`,
345
+ headers: {
346
+ "Accept-Language": this.config.language,
347
+ Authorization: "Basic YXBwOmFwcA==",
348
+ "Content-Type": "application/x-www-form-urlencoded",
349
+ Connection: "Keep-Alive",
350
+ "User-Agent": "okhttp/4.8.1",
351
+ },
352
+ data: body,
353
+ timeout: 15000,
354
+ validateStatus: () => true,
355
+ });
356
+ const json = res.data;
357
+ if (!json || typeof json !== "object" || !json.access_token) {
358
+ throw new Error(`Login: kein access_token (HTTP ${res.status}): ${JSON.stringify(json)}`);
359
+ }
360
+ this.session = json;
361
+ this.log.info(`Login OK user_id=${json.user_id}`);
362
+ }
363
+
364
+ async refreshToken() {
365
+ if (!this.session || !this.session.refresh_token) {
366
+ await this.login();
367
+ return;
368
+ }
369
+ const base = this.getBase();
370
+ if (this.config.apptype === "New") {
371
+ const url = `${base.url}/admin/new-oauth/oauth2-new/token?refresh_token=${encodeURIComponent(this.session.refresh_token)}`;
372
+ const res = await axios({
373
+ method: "GET",
374
+ url,
375
+ headers: {
376
+ Authorization: "Basic YXBwOmFwcA==",
377
+ "accept-encoding": "gzip",
378
+ Connection: "Keep-Alive",
379
+ "User-Agent": "okhttp/4.8.1",
380
+ },
381
+ timeout: 15000,
382
+ validateStatus: () => true,
383
+ });
384
+ const json = res.data;
385
+ if (json && json.access_token) {
386
+ this.session = json;
387
+ this.log.info("Token erneuert (new-oauth)");
388
+ return;
389
+ }
390
+ this.log.warn("Refresh fehlgeschlagen, erneuter Login");
391
+ await this.login();
392
+ return;
393
+ }
394
+ const body = new URLSearchParams({
395
+ refresh_token: this.session.refresh_token,
396
+ grant_type: "refresh_token",
397
+ scope: "server",
398
+ }).toString();
399
+ const res = await axios({
400
+ method: "POST",
401
+ url: `${base.url}/auth/oauth/token`,
402
+ headers: {
403
+ "Accept-Language": this.config.language,
404
+ Authorization: "Basic YXBwOmFwcA==",
405
+ "Content-Type": "application/x-www-form-urlencoded",
406
+ Connection: "Keep-Alive",
407
+ "User-Agent": "okhttp/4.8.1",
408
+ },
409
+ data: body,
410
+ timeout: 15000,
411
+ validateStatus: () => true,
412
+ });
413
+ const json = res.data;
414
+ if (json && json.access_token) {
415
+ this.session = json;
416
+ this.log.info("Token erneuert");
417
+ } else {
418
+ this.log.warn("Refresh fehlgeschlagen, erneuter Login");
419
+ await this.login();
420
+ }
421
+ }
422
+
423
+ classifyModel(modelName) {
424
+ if (!modelName) {
425
+ return "S";
426
+ }
427
+ if (/^V18/.test(modelName) || /^V3/.test(modelName)) {
428
+ return "V";
429
+ }
430
+ if (/^V1/.test(modelName)) {
431
+ return "V1";
432
+ }
433
+ if (/^V/.test(modelName)) {
434
+ return "V";
435
+ }
436
+ if (/^X/.test(modelName)) {
437
+ return "X";
438
+ }
439
+ return "S";
440
+ }
441
+
442
+ async loadDevices() {
443
+ const path =
444
+ this.config.apptype === "Old"
445
+ ? "/mower/device-user/list"
446
+ : "/app_wireless_mower/device-user/getCustomDevice?all=true";
447
+ const { json } = await this.request("GET", path, {
448
+ "Content-Type": "application/json",
449
+ ...this.authHeaders(),
450
+ });
451
+ if (!Array.isArray(json.data)) {
452
+ this.log.warn(`Geräteliste leer: ${JSON.stringify(json)}`);
453
+ return;
454
+ }
455
+ for (const d of json.data) {
456
+ const sn = d.deviceSn;
457
+ this.devicesRaw[sn] = d;
458
+ this.deviceMeta[sn] = {
459
+ modelClass: this.classifyModel(d.modelName),
460
+ refreshTimer: undefined,
461
+ };
462
+ this.deviceMeta[sn].cmdurl = this.deviceMeta[sn].modelClass === "V1" ? CMDURL_V1 : CMDURL_SXV;
463
+
464
+ await this.extendObject(sn, {
465
+ type: "device",
466
+ common: { name: d.deviceName || sn },
467
+ native: {},
468
+ });
469
+ await this.delObjectAsync(`${sn}.list`, { recursive: true }).catch(() => {});
470
+ await this.json2iob.parse(`${sn}.general`, d, {
471
+ channelName: "Allgemein",
472
+ forceIndex: false,
473
+ });
474
+ await this.ensureRemoteButtons(sn);
475
+ await this.ensureScheduleStates(sn);
476
+ this.log.info(`Gerät: sn=${sn} model=${d.modelName} name=${d.deviceName}`);
477
+ }
478
+ }
479
+
480
+ async ensureRemoteButtons(sn) {
481
+ await this.extendObject(`${sn}.remote`, {
482
+ type: "channel",
483
+ common: { name: "Befehle" },
484
+ native: {},
485
+ });
486
+ const buttons = [
487
+ ["start", "Mähen starten"],
488
+ ["pause", "Pause"],
489
+ ["dock", "Zur Ladestation"],
490
+ ["stop_find_charger", "Heimfahrt abbrechen"],
491
+ ["border", "Kantenfahrt"],
492
+ ["stop", "Stopp"],
493
+ ["stop_task", "Aufgabe abbrechen"],
494
+ ["restart", "Neustart der Aufgabe"],
495
+ ["refresh", "Status neu laden"],
496
+ ];
497
+ for (const [id, name] of buttons) {
498
+ await this.extendObject(`${sn}.remote.${id}`, {
499
+ type: "state",
500
+ common: {
501
+ name,
502
+ type: "boolean",
503
+ role: "button",
504
+ read: false,
505
+ write: true,
506
+ def: false,
507
+ },
508
+ native: {},
509
+ });
510
+ }
511
+ }
512
+
513
+ async ensureScheduleStates(sn) {
514
+ await this.extendObject(`${sn}.schedule`, {
515
+ type: "channel",
516
+ common: { name: "Zeitplan" },
517
+ native: {},
518
+ });
519
+ const days = [
520
+ ["monday", "Montag"],
521
+ ["tuesday", "Dienstag"],
522
+ ["wednesday", "Mittwoch"],
523
+ ["thursday", "Donnerstag"],
524
+ ["friday", "Freitag"],
525
+ ["saturday", "Samstag"],
526
+ ["sunday", "Sonntag"],
527
+ ];
528
+ for (const [key, label] of days) {
529
+ await this.extendObject(`${sn}.schedule.${key}`, {
530
+ type: "state",
531
+ common: {
532
+ name: `${label} (HH:MM-HH:MM, leer = aus)`,
533
+ type: "string",
534
+ role: "text",
535
+ read: true,
536
+ write: true,
537
+ def: "",
538
+ },
539
+ native: {},
540
+ });
541
+ }
542
+ await this.extendObject(`${sn}.schedule.pause`, {
543
+ type: "state",
544
+ common: {
545
+ name: "Zeitplan pausiert",
546
+ type: "boolean",
547
+ role: "switch",
548
+ read: true,
549
+ write: true,
550
+ def: false,
551
+ },
552
+ native: {},
553
+ });
554
+ await this.extendObject(`${sn}.schedule.set`, {
555
+ type: "state",
556
+ common: {
557
+ name: "Zeitplan senden",
558
+ type: "boolean",
559
+ role: "button",
560
+ read: false,
561
+ write: true,
562
+ def: false,
563
+ },
564
+ native: {},
565
+ });
566
+ }
567
+
568
+ async updateAllDevices() {
569
+ for (const sn of Object.keys(this.devicesRaw)) {
570
+ try {
571
+ await this.updateDevice(sn);
572
+ } catch (err) {
573
+ this.log.warn(`Update ${sn}: ${err.message}`);
574
+ }
575
+ }
576
+ }
577
+
578
+ async updateDevice(sn) {
579
+ const dev = this.devicesRaw[sn];
580
+ if (!dev) {
581
+ throw new Error(`Gerät ${sn} unbekannt`);
582
+ }
583
+ const statusPath =
584
+ this.config.apptype === "Old"
585
+ ? `/mower/device/getBysn?sn=${encodeURIComponent(sn)}`
586
+ : `/app_wireless_mower/device/getBysn?sn=${encodeURIComponent(sn)}`;
587
+ const settingsPath =
588
+ this.config.apptype === "Old"
589
+ ? `/mower/device-setting/${encodeURIComponent(sn)}`
590
+ : `/app_wireless_mower/device/info/${encodeURIComponent(dev.deviceId)}`;
591
+
592
+ const states = this.statesForDevice(sn);
593
+ const status = await this.request("GET", statusPath, this.authHeaders());
594
+ if (status.json && status.json.data) {
595
+ await this.json2iob.parse(`${sn}.status`, status.json.data, {
596
+ channelName: "Status",
597
+ forceIndex: false,
598
+ states,
599
+ });
600
+ }
601
+ const settings = await this.request("GET", settingsPath, this.authHeaders());
602
+ if (settings.json && settings.json.data) {
603
+ await this.json2iob.parse(`${sn}.settings`, settings.json.data, {
604
+ channelName: "Einstellungen",
605
+ forceIndex: false,
606
+ states,
607
+ });
608
+ await this.ensureBladeWritable(sn, settings.json.data);
609
+ }
610
+ if (this.config.apptype !== "Old") {
611
+ const meta = this.deviceMeta[sn];
612
+ if (meta && (meta.modelClass === "S" || meta.modelClass === "X")) {
613
+ await this.fetchMap(sn).catch(err => this.log.debug(`Map ${sn}: ${err.message}`));
614
+ }
615
+ }
616
+ }
617
+
618
+ /**
619
+ * @param {string} sn
620
+ * @param {Record<string, any>} settingsData
621
+ */
622
+ async ensureBladeWritable(sn, settingsData) {
623
+ if (this.config.apptype === "Old") {
624
+ return;
625
+ }
626
+ if (settingsData && Object.prototype.hasOwnProperty.call(settingsData, "bladeSpeed")) {
627
+ await this.extendObject(`${sn}.settings.bladeSpeed`, {
628
+ type: "state",
629
+ common: {
630
+ name: "Klingen-Drehzahl",
631
+ type: "number",
632
+ role: "level",
633
+ min: 2800,
634
+ max: 3000,
635
+ step: 100,
636
+ unit: "rpm",
637
+ read: true,
638
+ write: true,
639
+ },
640
+ native: {},
641
+ });
642
+ }
643
+ if (settingsData && Object.prototype.hasOwnProperty.call(settingsData, "bladeHeight")) {
644
+ await this.extendObject(`${sn}.settings.bladeHeight`, {
645
+ type: "state",
646
+ common: {
647
+ name: "Schnitthöhe",
648
+ type: "number",
649
+ role: "level",
650
+ min: 20,
651
+ max: 100,
652
+ step: 5,
653
+ unit: "mm",
654
+ read: true,
655
+ write: true,
656
+ },
657
+ native: {},
658
+ });
659
+ }
660
+ }
661
+
662
+ // ------------------------------ Commands ------------------------------
663
+
664
+ async sendCommand(sn, command, value) {
665
+ const dev = this.devicesRaw[sn];
666
+ const meta = this.deviceMeta[sn];
667
+ if (this.config.apptype === "Old") {
668
+ return this.sendCommandOld(dev, command);
669
+ }
670
+ if (meta.modelClass === "V1") {
671
+ return this.sendCommandV1(dev, meta, command);
672
+ }
673
+ return this.sendCommandNew(dev, meta, command, value);
674
+ }
675
+
676
+ async sendCommandNew(dev, meta, command, value) {
677
+ const action = NEW_ACTIONS[command];
678
+ if (!action) {
679
+ throw new Error(`Unbekannter Befehl: ${command}`);
680
+ }
681
+ const data = {
682
+ appId: String(dev.appUserId || this.session.user_id),
683
+ cmd: action.cmd,
684
+ deviceSn: dev.deviceSn,
685
+ id: action.cmdid,
686
+ method: "action",
687
+ };
688
+ if (command === "border" && meta.modelClass === "V") {
689
+ data.value = true;
690
+ }
691
+ if (command === "start" && value && typeof value !== "boolean") {
692
+ data.work_id = value;
693
+ }
694
+ const res = await this.request(
695
+ "POST",
696
+ `${meta.cmdurl}action`,
697
+ { ...this.authHeaders(), "Content-Type": "application/json" },
698
+ JSON.stringify(data),
699
+ );
700
+ if (res.json && res.json.ok === false) {
701
+ throw new Error(`API: ${res.json.msg}`);
702
+ }
703
+ }
704
+
705
+ async sendCommandV1(dev, meta, command) {
706
+ const mode = V1_MODES[command];
707
+ if (mode === undefined) {
708
+ throw new Error(`V1: Befehl ${command} nicht unterstützt`);
709
+ }
710
+ const data = {
711
+ appId: String(dev.appUserId || this.session.user_id),
712
+ deviceSn: dev.deviceSn,
713
+ method: "setWorkStatus",
714
+ mode,
715
+ };
716
+ const res = await this.request(
717
+ "POST",
718
+ `${meta.cmdurl}setProperty`,
719
+ { ...this.authHeaders(), "Content-Type": "application/json" },
720
+ JSON.stringify(data),
721
+ );
722
+ if (res.json && res.json.ok === false) {
723
+ throw new Error(`API: ${res.json.msg}`);
724
+ }
725
+ }
726
+
727
+ async sendCommandOld(dev, command) {
728
+ const mode = OLD_MODES[command];
729
+ if (mode === undefined) {
730
+ throw new Error(`Old: Befehl ${command} nicht unterstützt`);
731
+ }
732
+ const data = {
733
+ appId: String(dev.appUserId || this.session.user_id),
734
+ deviceSn: dev.deviceSn,
735
+ mode,
736
+ };
737
+ const res = await this.request(
738
+ "POST",
739
+ "/app_mower/device/setWorkStatus",
740
+ { ...this.authHeaders(), "Content-Type": "application/json" },
741
+ JSON.stringify(data),
742
+ );
743
+ if (res.json && res.json.ok === false) {
744
+ throw new Error(`API: ${res.json.msg}`);
745
+ }
746
+ }
747
+
748
+ /**
749
+ * @param {string} sn
750
+ * @param {"speed"|"height"} key
751
+ * @param {number} value
752
+ */
753
+ async setBlade(sn, key, value) {
754
+ const dev = this.devicesRaw[sn];
755
+ const meta = this.deviceMeta[sn];
756
+ if (!dev || !meta) {
757
+ throw new Error(`Gerät ${sn} unbekannt`);
758
+ }
759
+ if (this.config.apptype === "Old") {
760
+ throw new Error("Klingen-Steuerung wird für Alt-API nicht unterstützt");
761
+ }
762
+ const intVal = Math.round(Number(value));
763
+ if (!Number.isFinite(intVal)) {
764
+ throw new Error(`Ungültiger Wert: ${value}`);
765
+ }
766
+ const data = {
767
+ appId: String(dev.appUserId || this.session.user_id),
768
+ deviceSn: dev.deviceSn,
769
+ id: "setDevBlade",
770
+ key: "blade",
771
+ method: "set_property",
772
+ [key]: intVal,
773
+ };
774
+ const endpoint = meta.modelClass === "V1" ? "setProperty" : "set_property";
775
+ const res = await this.request(
776
+ "POST",
777
+ `${meta.cmdurl}${endpoint}`,
778
+ { ...this.authHeaders(), "Content-Type": "application/json" },
779
+ JSON.stringify(data),
780
+ );
781
+ if (res.json && res.json.ok === false) {
782
+ throw new Error(`API: ${res.json.msg}`);
783
+ }
784
+ }
785
+
786
+ /**
787
+ * @param {string} value
788
+ * @returns {{startSec: number, endSec: number} | null}
789
+ */
790
+ parseScheduleDay(value) {
791
+ const trimmed = String(value || "").trim();
792
+ if (!trimmed) {
793
+ return null;
794
+ }
795
+ const m = trimmed.match(/^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/);
796
+ if (!m) {
797
+ throw new Error(`Format "HH:MM-HH:MM" erwartet, war: "${trimmed}"`);
798
+ }
799
+ const sh = Number(m[1]);
800
+ const sm = Number(m[2]);
801
+ const eh = Number(m[3]);
802
+ const em = Number(m[4]);
803
+ if (sh > 23 || eh > 23 || sm > 59 || em > 59) {
804
+ throw new Error(`Ungültige Uhrzeit: "${trimmed}"`);
805
+ }
806
+ return { startSec: sh * 3600 + sm * 60, endSec: eh * 3600 + em * 60 };
807
+ }
808
+
809
+ /**
810
+ * @param {number} sec
811
+ */
812
+ secToHms(sec) {
813
+ const h = Math.floor(sec / 3600);
814
+ const m = Math.floor((sec % 3600) / 60);
815
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:00`;
816
+ }
817
+
818
+ /**
819
+ * @param {string} sn
820
+ */
821
+ async setSchedule(sn) {
822
+ const dev = this.devicesRaw[sn];
823
+ const meta = this.deviceMeta[sn];
824
+ if (!dev || !meta) {
825
+ throw new Error(`Gerät ${sn} unbekannt`);
826
+ }
827
+ const days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
828
+ const parsed = [];
829
+ for (let i = 0; i < days.length; i++) {
830
+ const st = await this.getStateAsync(`${sn}.schedule.${days[i]}`);
831
+ const window = this.parseScheduleDay(st && st.val ? String(st.val) : "");
832
+ parsed.push({ dayIndex: i + 1, key: days[i], window });
833
+ }
834
+ const pauseSt = await this.getStateAsync(`${sn}.schedule.pause`);
835
+ const pause = !!(pauseSt && pauseSt.val);
836
+ const appId = String(dev.appUserId || this.session.user_id);
837
+
838
+ if (this.config.apptype === "Old") {
839
+ const bos = parsed.map(p => ({
840
+ dayOfWeek: p.dayIndex,
841
+ startAt: p.window ? this.secToHms(p.window.startSec) : "00:00:00",
842
+ endAt: p.window ? this.secToHms(p.window.endSec) : "00:00:00",
843
+ trimFlag: !!p.window,
844
+ }));
845
+ const res = await this.request(
846
+ "POST",
847
+ "/app_mower/device-schedule/setScheduling",
848
+ { ...this.authHeaders(), "Content-Type": "application/json" },
849
+ JSON.stringify({ appId, autoFlag: !pause, deviceScheduleBOS: bos, deviceSn: sn }),
850
+ );
851
+ if (res.json && res.json.ok === false) {
852
+ throw new Error(`API: ${res.json.msg}`);
853
+ }
854
+ return;
855
+ }
856
+
857
+ if (meta.modelClass === "V1") {
858
+ const bos = [];
859
+ for (const p of parsed) {
860
+ if (!p.window) {
861
+ continue;
862
+ }
863
+ bos.push({
864
+ dayOfWeek: p.dayIndex,
865
+ startAt: this.secToHms(p.window.startSec),
866
+ endAt: this.secToHms(p.window.endSec),
867
+ trimFlag: true,
868
+ });
869
+ }
870
+ const res = await this.request(
871
+ "POST",
872
+ `${meta.cmdurl}setProperty`,
873
+ { ...this.authHeaders(), "Content-Type": "application/json" },
874
+ JSON.stringify({
875
+ appId,
876
+ deviceSn: sn,
877
+ autoFlag: !pause,
878
+ method: "setSchedule",
879
+ deviceScheduleBOS: bos,
880
+ pause,
881
+ }),
882
+ );
883
+ if (res.json && res.json.ok === false) {
884
+ throw new Error(`API: ${res.json.msg}`);
885
+ }
886
+ return;
887
+ }
888
+
889
+ // S/X/V — set_property time_tactics. Mon=1..Sat=6, Sun=0
890
+ const dayPeriod = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 };
891
+ const time = [];
892
+ for (const p of parsed) {
893
+ if (!p.window) {
894
+ continue;
895
+ }
896
+ time.push({
897
+ unlock: true,
898
+ period: [dayPeriod[p.key]],
899
+ start: p.window.startSec,
900
+ active: true,
901
+ end: p.window.endSec,
902
+ need_fllow_boader: false,
903
+ });
904
+ }
905
+ const res = await this.request(
906
+ "POST",
907
+ `${meta.cmdurl}set_property`,
908
+ { ...this.authHeaders(), "Content-Type": "application/json" },
909
+ JSON.stringify({
910
+ appId,
911
+ deviceSn: sn,
912
+ id: "setTimeTactics",
913
+ key: "time_tactics",
914
+ method: "set_property",
915
+ time,
916
+ time_custom_flag: true,
917
+ recommended_time_flag: false,
918
+ time_zone: 3600,
919
+ pause,
920
+ }),
921
+ );
922
+ if (res.json && res.json.ok === false) {
923
+ throw new Error(`API: ${res.json.msg}`);
924
+ }
925
+ }
926
+
927
+ // ------------------------------ MQTT (New API only) ------------------------------
928
+
929
+ encryptRsa(plaintext) {
930
+ const buf = crypto.publicEncrypt(
931
+ { key: PUBLIC_KEY_PEM, padding: crypto.constants.RSA_PKCS1_PADDING },
932
+ Buffer.from(plaintext, "utf8"),
933
+ );
934
+ return buf.toString("base64");
935
+ }
936
+
937
+ randomMqttPassword() {
938
+ return crypto.randomBytes(16).toString("hex").slice(0, 24);
939
+ }
940
+
941
+ mqttBroker() {
942
+ const first = Object.values(this.deviceMeta)[0];
943
+ const v1 = first && first.modelClass === "V1";
944
+ if (this.config.region === "US") {
945
+ return v1
946
+ ? { host: "app.mqttv1-us.sk-robot.com", port: 32884 }
947
+ : { host: "wfsmqtt-specific-us.sk-robot.com", port: 1884 };
948
+ }
949
+ return v1
950
+ ? { host: "app.mqttv1-eu.sk-robot.com", port: 32884 }
951
+ : { host: "wfsmqtt-specific.sk-robot.com", port: 1884 };
952
+ }
953
+
954
+ async editMqttPassword() {
955
+ this.mqttPassword = this.randomMqttPassword();
956
+ const encrypted = this.encryptRsa(this.mqttPassword);
957
+ const res = await this.request(
958
+ "PUT",
959
+ "/admin/user/edit",
960
+ { ...this.authHeaders(), "Content-Type": "application/json" },
961
+ JSON.stringify({
962
+ appIdCode: APP_ID,
963
+ appType: 2,
964
+ mqttsPassword: encrypted,
965
+ operatingSystemCode: "android",
966
+ }),
967
+ );
968
+ if (res.json && res.json.ok === false) {
969
+ throw new Error(`MQTT-Passwort: ${res.json.msg}`);
970
+ }
971
+ }
972
+
973
+ async startMqttNew() {
974
+ if (this.unloading) {
975
+ return;
976
+ }
977
+ try {
978
+ await this.editMqttPassword();
979
+ } catch (err) {
980
+ this.log.error(`MQTT-Passwort setzen fehlgeschlagen: ${err.message}`);
981
+ return;
982
+ }
983
+ this.connectMqtt();
984
+ }
985
+
986
+ connectMqtt() {
987
+ if (this.mqttClient) {
988
+ try {
989
+ this.mqttClient.end(true);
990
+ } catch {
991
+ /* ignore */
992
+ }
993
+ this.mqttClient = null;
994
+ }
995
+ const broker = this.mqttBroker();
996
+ const url = `mqtts://${broker.host}:${broker.port}`;
997
+ const username = `${this.session.username || this.config.username}${APP_ID}`;
998
+ const clientId = `${crypto.randomUUID()}new`;
999
+ this.log.info(`MQTT verbinde ${url} username=${username}`);
1000
+ const client = mqtt.connect(url, {
1001
+ clientId,
1002
+ username,
1003
+ password: this.mqttPassword,
1004
+ protocolVersion: 4,
1005
+ keepalive: 60,
1006
+ clean: true,
1007
+ reconnectPeriod: 0,
1008
+ connectTimeout: 15000,
1009
+ rejectUnauthorized: true,
1010
+ });
1011
+ this.mqttClient = client;
1012
+
1013
+ client.on("connect", () => {
1014
+ this.log.info("MQTT verbunden");
1015
+ this.setState("info.connection", true, true);
1016
+ const userId = this.session.user_id;
1017
+ const topics = [`/wirelessdevice/${userId}/get`, `/wirelessmower/${userId}/get`];
1018
+ for (const t of topics) {
1019
+ client.subscribe(t, { qos: 0 }, (err, granted) => {
1020
+ if (err) {
1021
+ this.log.error(`MQTT subscribe ${t}: ${err.message}`);
1022
+ } else {
1023
+ this.log.debug(`MQTT subscribed: ${JSON.stringify(granted)}`);
1024
+ }
1025
+ });
1026
+ }
1027
+ this.fetchInitialProperties().catch(err => this.log.debug(`Initial-Properties: ${err.message}`));
1028
+ });
1029
+
1030
+ client.on("message", (topic, payload) => this.onMqttMessage(topic, payload));
1031
+
1032
+ client.on("error", err => {
1033
+ this.log.warn(`MQTT error: ${err.message}`);
1034
+ const code = /** @type {any} */ (err).code;
1035
+ if (code === 4 || code === 5 || /not authorized|bad user/i.test(err.message)) {
1036
+ this.scheduleMqttRetry();
1037
+ }
1038
+ });
1039
+
1040
+ client.on("close", () => {
1041
+ this.log.debug("MQTT geschlossen");
1042
+ if (!this.unloading) {
1043
+ this.scheduleMqttRetry();
1044
+ }
1045
+ });
1046
+ }
1047
+
1048
+ scheduleMqttRetry() {
1049
+ if (this.unloading || this.mqttRetryTimer) {
1050
+ return;
1051
+ }
1052
+ this.mqttRetryTimer = this.setTimeout(() => {
1053
+ this.mqttRetryTimer = undefined;
1054
+ this.startMqttNew().catch(err => this.log.error(`MQTT-Reconnect: ${err.message}`));
1055
+ }, 30000);
1056
+ }
1057
+
1058
+ onMqttMessage(topic, payload) {
1059
+ let data;
1060
+ try {
1061
+ data = JSON.parse(payload.toString("utf8"));
1062
+ } catch {
1063
+ this.log.debug(`MQTT non-JSON: ${payload.toString("utf8").slice(0, 200)}`);
1064
+ return;
1065
+ }
1066
+ const sn = data.deviceSn;
1067
+ if (!sn || !this.devicesRaw[sn]) {
1068
+ this.log.debug(`MQTT für unbekanntes Gerät: ${topic} ${JSON.stringify(data).slice(0, 120)}`);
1069
+ return;
1070
+ }
1071
+ const statusData = data.data && typeof data.data === "object" ? data.data : data;
1072
+ this.json2iob.parse(`${sn}.status`, statusData, {
1073
+ channelName: "Status",
1074
+ forceIndex: false,
1075
+ states: this.statesForDevice(sn),
1076
+ });
1077
+ const meta = this.deviceMeta[sn];
1078
+ if (meta.refreshTimer) {
1079
+ this.clearTimeout(meta.refreshTimer);
1080
+ }
1081
+ meta.refreshTimer = this.setTimeout(() => {
1082
+ meta.refreshTimer = undefined;
1083
+ this.updateDevice(sn).catch(err => this.log.debug(`Refresh nach MQTT (${sn}): ${err.message}`));
1084
+ }, 1500);
1085
+ }
1086
+
1087
+ // ------------------------------ MQTT (Old API) ------------------------------
1088
+
1089
+ startMqttOld() {
1090
+ if (this.unloading) {
1091
+ return;
1092
+ }
1093
+ if (this.mqttOldClient) {
1094
+ try {
1095
+ this.mqttOldClient.end(true);
1096
+ } catch {
1097
+ /* ignore */
1098
+ }
1099
+ this.mqttOldClient = null;
1100
+ }
1101
+ const url = `mqtt://${MQTT_OLD_HOST}:${MQTT_OLD_PORT}`;
1102
+ const userId = this.session.user_id;
1103
+ this.log.info(`MQTT (Old) verbinde ${url}`);
1104
+ const client = mqtt.connect(url, {
1105
+ username: MQTT_OLD_USER,
1106
+ password: MQTT_OLD_PASS,
1107
+ clientId: `${crypto.randomUUID()}old`,
1108
+ protocolVersion: 4,
1109
+ keepalive: 60,
1110
+ clean: true,
1111
+ reconnectPeriod: 0,
1112
+ connectTimeout: 15000,
1113
+ });
1114
+ this.mqttOldClient = client;
1115
+ client.on("connect", () => {
1116
+ this.log.info("MQTT (Old) verbunden");
1117
+ const topic = `/app/${userId}/get`;
1118
+ client.subscribe(topic, { qos: 0 }, err => {
1119
+ if (err) {
1120
+ this.log.error(`MQTT (Old) subscribe ${topic}: ${err.message}`);
1121
+ }
1122
+ });
1123
+ });
1124
+ client.on("message", (topic, payload) => this.onMqttMessage(topic, payload));
1125
+ client.on("error", err => this.log.warn(`MQTT (Old) error: ${err.message}`));
1126
+ client.on("close", () => {
1127
+ this.log.debug("MQTT (Old) geschlossen");
1128
+ if (!this.unloading && !this.mqttOldRetryTimer) {
1129
+ this.mqttOldRetryTimer = this.setTimeout(() => {
1130
+ this.mqttOldRetryTimer = undefined;
1131
+ this.startMqttOld();
1132
+ }, 30000);
1133
+ }
1134
+ });
1135
+ }
1136
+
1137
+ // ------------------------------ Post-MQTT property requests (New) ------------------------------
1138
+
1139
+ async getDeviceProperty(sn, body) {
1140
+ const meta = this.deviceMeta[sn];
1141
+ const dev = this.devicesRaw[sn];
1142
+ if (!meta || !dev) {
1143
+ return;
1144
+ }
1145
+ const data = {
1146
+ appId: String(dev.appUserId || this.session.user_id),
1147
+ deviceSn: sn,
1148
+ method: "get_property",
1149
+ ...body,
1150
+ };
1151
+ try {
1152
+ await this.request(
1153
+ "POST",
1154
+ `${meta.cmdurl}get_property`,
1155
+ { ...this.authHeaders(), "Content-Type": "application/json" },
1156
+ JSON.stringify(data),
1157
+ );
1158
+ } catch (err) {
1159
+ this.log.debug(`get_property ${body.id} (${sn}): ${err.message}`);
1160
+ }
1161
+ }
1162
+
1163
+ async fetchInitialProperties() {
1164
+ for (const sn of Object.keys(this.deviceMeta)) {
1165
+ const meta = this.deviceMeta[sn];
1166
+ const cls = meta.modelClass;
1167
+ if (cls === "V1") {
1168
+ continue;
1169
+ }
1170
+ if (cls === "S" || cls === "X") {
1171
+ await this.fetchMap(sn).catch(err => this.log.debug(`Map ${sn}: ${err.message}`));
1172
+ }
1173
+ await this.getDeviceProperty(sn, {
1174
+ id: "getDevAllProperties",
1175
+ key: "all_property",
1176
+ });
1177
+ await this.getDeviceProperty(sn, {
1178
+ id: "getSelectRegionID",
1179
+ key: "region",
1180
+ });
1181
+ if (cls === "S" || cls === "X") {
1182
+ const mapid = meta.mapid || 0;
1183
+ const mapFile = mapid ? `Wireless_${sn}_${mapid}.json` : `Wireless_${sn}.json`;
1184
+ await this.getDeviceProperty(sn, {
1185
+ id: "getAllPath",
1186
+ key: "all_path",
1187
+ map_file: mapFile,
1188
+ });
1189
+ }
1190
+ if (cls === "V") {
1191
+ await this.getDeviceProperty(sn, {
1192
+ id: "getConsumableItems",
1193
+ key: "consumable_items",
1194
+ });
1195
+ await this.getDeviceProperty(sn, {
1196
+ id: "getFcState",
1197
+ key: "fc_state",
1198
+ });
1199
+ }
1200
+ }
1201
+ }
1202
+
1203
+ /**
1204
+ * @param {string} sn
1205
+ */
1206
+ async fetchMap(sn) {
1207
+ if (this.config.apptype === "Old") {
1208
+ return;
1209
+ }
1210
+ const meta = this.deviceMeta[sn];
1211
+ if (!meta || (meta.modelClass !== "S" && meta.modelClass !== "X")) {
1212
+ return;
1213
+ }
1214
+ if (meta.mapInFlight) {
1215
+ return;
1216
+ }
1217
+ meta.mapInFlight = true;
1218
+ try {
1219
+ const info = await this.request(
1220
+ "GET",
1221
+ `/wireless_map/wireless_device/get?deviceSn=${encodeURIComponent(sn)}`,
1222
+ this.authHeaders(),
1223
+ );
1224
+ const data = info.json && info.json.data;
1225
+ if (!data) {
1226
+ return;
1227
+ }
1228
+ await this.json2iob.parse(`${sn}.map.info`, data, {
1229
+ channelName: "Karte",
1230
+ forceIndex: false,
1231
+ });
1232
+ const newMapId = data.mapModifyTime;
1233
+ if (newMapId !== undefined && newMapId === meta.mapid) {
1234
+ return;
1235
+ }
1236
+ if (newMapId !== undefined) {
1237
+ meta.mapid = newMapId;
1238
+ }
1239
+
1240
+ const heat = await this.request(
1241
+ "GET",
1242
+ `/wireless_map/wireless_device/getHeatMap?deviceSn=${encodeURIComponent(sn)}`,
1243
+ this.authHeaders(),
1244
+ );
1245
+ const heatData = heat.json && heat.json.data;
1246
+ if (!heatData) {
1247
+ return;
1248
+ }
1249
+ await this.fetchMapImage(sn, "image", heatData.url);
1250
+ await this.fetchMapImage(sn, "wifi", heatData.wifiUrl);
1251
+ await this.fetchMapImage(sn, "net", heatData.netUrl);
1252
+ } finally {
1253
+ meta.mapInFlight = false;
1254
+ }
1255
+ }
1256
+
1257
+ /**
1258
+ * @param {string} sn
1259
+ * @param {string} name
1260
+ * @param {string} url
1261
+ */
1262
+ async fetchMapImage(sn, name, url) {
1263
+ if (!url) {
1264
+ return;
1265
+ }
1266
+ const res = await axios.get(url, {
1267
+ timeout: 30000,
1268
+ responseType: "arraybuffer",
1269
+ validateStatus: () => true,
1270
+ });
1271
+ if (res.status !== 200 || !res.data) {
1272
+ return;
1273
+ }
1274
+ const ct = String(res.headers["content-type"] || "image/png")
1275
+ .split(";")[0]
1276
+ .trim();
1277
+ const b64 = Buffer.from(res.data).toString("base64");
1278
+ const dataUrl = `data:${ct};base64,${b64}`;
1279
+ await this.extendObject(`${sn}.map.${name}`, {
1280
+ type: "state",
1281
+ common: {
1282
+ name: `Karten-${name} (data URL)`,
1283
+ type: "string",
1284
+ role: "value",
1285
+ read: true,
1286
+ write: false,
1287
+ },
1288
+ native: {},
1289
+ });
1290
+ this.setState(`${sn}.map.${name}`, dataUrl, true);
1291
+ }
1292
+ }
1293
+
1294
+ if (require.main !== module) {
1295
+ /**
1296
+ * @param {Partial<utils.AdapterOptions>} [options] - Adapter options
1297
+ */
1298
+ module.exports = options => new Sunseeker(options);
1299
+ } else {
1300
+ new Sunseeker();
1301
+ }