iobroker.al-ko 0.2.2
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 +21 -0
- package/README.md +161 -0
- package/admin/LICENSE +21 -0
- package/admin/al-ko.png +0 -0
- package/admin/i18n/de/translations.json +5 -0
- package/admin/i18n/en/translations.json +5 -0
- package/admin/i18n/es/translations.json +5 -0
- package/admin/i18n/fr/translations.json +5 -0
- package/admin/i18n/it/translations.json +5 -0
- package/admin/i18n/nl/translations.json +5 -0
- package/admin/i18n/pl/translations.json +5 -0
- package/admin/i18n/pt/translations.json +5 -0
- package/admin/i18n/ru/translations.json +5 -0
- package/admin/i18n/uk/translations.json +5 -0
- package/admin/i18n/zh-cn/translations.json +5 -0
- package/admin/jsonConfig.json +60 -0
- package/io-package.json +155 -0
- package/lib/adapter-config.d.ts +19 -0
- package/main.js +599 -0
- package/package.json +94 -0
- package/whitelist.json +133 -0
package/main.js
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const axios = require("axios");
|
|
4
|
+
const utils = require("@iobroker/adapter-core");
|
|
5
|
+
const WebSocket = require("ws");
|
|
6
|
+
|
|
7
|
+
// Whitelist laden (z. B. mowingWindows.monday.window_1.startHour)
|
|
8
|
+
let whitelist = [];
|
|
9
|
+
try {
|
|
10
|
+
whitelist = require("./whitelist.json");
|
|
11
|
+
if (!Array.isArray(whitelist)) {
|
|
12
|
+
whitelist = [];
|
|
13
|
+
}
|
|
14
|
+
} catch (_e) {
|
|
15
|
+
console.warn("Whitelist nicht gefunden, arbeite mit leerer Whitelist.");
|
|
16
|
+
whitelist = [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class AlKoAdapter extends utils.Adapter {
|
|
20
|
+
constructor(options) {
|
|
21
|
+
super({ ...options, name: "al-ko" });
|
|
22
|
+
|
|
23
|
+
this.accessToken = null;
|
|
24
|
+
this.refreshToken = null;
|
|
25
|
+
this.tokenExpiresAt = null;
|
|
26
|
+
this.tokenInterval = null;
|
|
27
|
+
|
|
28
|
+
this.deviceStates = {}; // Cache für letzte bekannte States
|
|
29
|
+
this.pushableStates = new Set(); // Nur whitelisted States
|
|
30
|
+
this.lastStateValues = {}; // Vergleichswert für Änderungen
|
|
31
|
+
this.adapterSetStates = new Set();
|
|
32
|
+
this.pendingPushes = new Set();
|
|
33
|
+
this.webSockets = {}; // offene WebSocket-Verbindungen pro Gerät
|
|
34
|
+
this.reconnectTimeouts = {}; // hier speichern wir alle offenen Reconnect-Timeouts
|
|
35
|
+
|
|
36
|
+
this._stopRequested = false;
|
|
37
|
+
|
|
38
|
+
this.on("ready", this.onReady.bind(this));
|
|
39
|
+
this.on("unload", this.onUnload.bind(this));
|
|
40
|
+
this.on("stateChange", this.onStateChange.bind(this));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------- Adapter-Start ----------------
|
|
44
|
+
async onReady() {
|
|
45
|
+
this.log.info(`ℹ️ Adapter läuft mit Namespace: ${this.namespace}`);
|
|
46
|
+
|
|
47
|
+
const { clientId, clientSecret, username, password } = this.config;
|
|
48
|
+
if (!clientId || !clientSecret || !username || !password) {
|
|
49
|
+
this.log.error("❌ Bitte alle Zugangsdaten eintragen");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
this.clientId = clientId;
|
|
53
|
+
this.clientSecret = clientSecret;
|
|
54
|
+
this.username = username;
|
|
55
|
+
this.password = password;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await this.authenticate();
|
|
59
|
+
this.scheduleTokenRefresh();
|
|
60
|
+
await this.fetchAndCreateDeviceStates();
|
|
61
|
+
|
|
62
|
+
// Ausgabe aller pushableStates ins Log
|
|
63
|
+
this.log.info(
|
|
64
|
+
`🔔 Abonniert ${this.pushableStates.size} schreibbare States für Push-Erkennung.`,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
this.log.info("✅ Adapter bereit");
|
|
68
|
+
} catch (err) {
|
|
69
|
+
this.log.error(
|
|
70
|
+
`❌ Fehler beim Start: ${err.response?.data || err.message || err}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------- Authentifizierung ----------------
|
|
76
|
+
async authenticate() {
|
|
77
|
+
this.log.info("Authentifiziere bei AL-KO API…");
|
|
78
|
+
const url = "https://idp.al-ko.com/connect/token";
|
|
79
|
+
const params = new URLSearchParams();
|
|
80
|
+
params.append("grant_type", "password");
|
|
81
|
+
params.append("username", this.username);
|
|
82
|
+
params.append("password", this.password);
|
|
83
|
+
params.append("client_id", this.clientId);
|
|
84
|
+
params.append("client_secret", this.clientSecret);
|
|
85
|
+
params.append(
|
|
86
|
+
"scope",
|
|
87
|
+
"alkoCustomerId alkoCulture offline_access introspection",
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const res = await axios.post(url, params, {
|
|
91
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
this.accessToken = res.data.access_token;
|
|
95
|
+
this.refreshToken = res.data.refresh_token;
|
|
96
|
+
this.tokenExpiresAt = Date.now() + res.data.expires_in * 1000;
|
|
97
|
+
|
|
98
|
+
this.log.info("✅ Login erfolgreich");
|
|
99
|
+
}
|
|
100
|
+
async refreshAuth() {
|
|
101
|
+
if (!this.refreshToken || Date.now() >= this.tokenExpiresAt - 60000) {
|
|
102
|
+
this.log.info("🔄 Erneuere Access-Token…");
|
|
103
|
+
const url = "https://idp.al-ko.com/connect/token";
|
|
104
|
+
const params = new URLSearchParams();
|
|
105
|
+
params.append("grant_type", "refresh_token");
|
|
106
|
+
params.append("refresh_token", this.refreshToken);
|
|
107
|
+
params.append("client_id", this.clientId);
|
|
108
|
+
params.append("client_secret", this.clientSecret);
|
|
109
|
+
params.append(
|
|
110
|
+
"scope",
|
|
111
|
+
"alkoCustomerId alkoCulture offline_access introspection",
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const res = await axios.post(url, params, {
|
|
115
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
116
|
+
});
|
|
117
|
+
this.accessToken = res.data.access_token;
|
|
118
|
+
this.refreshToken = res.data.refresh_token;
|
|
119
|
+
this.tokenExpiresAt = Date.now() + res.data.expires_in * 1000;
|
|
120
|
+
this.log.info("✅ Token erfolgreich erneuert");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
scheduleTokenRefresh() {
|
|
125
|
+
if (this.tokenInterval) {
|
|
126
|
+
clearInterval(this.tokenInterval);
|
|
127
|
+
}
|
|
128
|
+
this.tokenInterval = setInterval(() => this.refreshAuth(), 30 * 60 * 1000);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------- Geräte & States ----------------
|
|
132
|
+
async fetchAndCreateDeviceStates() {
|
|
133
|
+
await this.refreshAuth();
|
|
134
|
+
|
|
135
|
+
const url = "https://api.al-ko.com/v1/iot/things";
|
|
136
|
+
const res = await axios.get(url, {
|
|
137
|
+
headers: {
|
|
138
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
139
|
+
Accept: "application/json",
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const devices = res.data;
|
|
144
|
+
if (devices && devices.length) {
|
|
145
|
+
for (const device of devices) {
|
|
146
|
+
const deviceId = device.thingName || device.name || device.id;
|
|
147
|
+
const stateData = await this.getDeviceStatus(deviceId);
|
|
148
|
+
if (stateData?.state) {
|
|
149
|
+
this.deviceStates[deviceId] =
|
|
150
|
+
stateData.state.reported || stateData.state;
|
|
151
|
+
await this.createStatesRecursive(
|
|
152
|
+
`al-ko.0.${deviceId}.state`,
|
|
153
|
+
this.deviceStates[deviceId],
|
|
154
|
+
"",
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// WebSocket starten
|
|
158
|
+
this.connectWebSocket(deviceId);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// --- NACH dem Anlegen der States und pro Gerät aufrufen ---
|
|
163
|
+
if (this.pushableStates.size) {
|
|
164
|
+
let count = 0;
|
|
165
|
+
for (const id of this.pushableStates) {
|
|
166
|
+
try {
|
|
167
|
+
this.subscribeStates(id);
|
|
168
|
+
count++;
|
|
169
|
+
} catch (e) {
|
|
170
|
+
this.log.debug(
|
|
171
|
+
`Konnte State nicht abonnieren: ${id} -> ${e.message}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
this.log.info(
|
|
176
|
+
`🔔 Abonniert ${count} schreibbare States für Push-Erkennung.`,
|
|
177
|
+
);
|
|
178
|
+
} else {
|
|
179
|
+
const pattern = `${this.namespace}.*`;
|
|
180
|
+
this.subscribeStates(pattern);
|
|
181
|
+
this.log.warn(
|
|
182
|
+
`⚠️ Keine pushbaren States erkannt – abonniere Fallback "${pattern}".`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async getDeviceStatus(deviceId) {
|
|
188
|
+
await this.refreshAuth();
|
|
189
|
+
const url = `https://api.al-ko.com/v1/iot/things/${encodeURIComponent(deviceId)}/state`;
|
|
190
|
+
const res = await axios.get(url, {
|
|
191
|
+
headers: {
|
|
192
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
193
|
+
Accept: "application/json",
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
this.log.info(`📥 Status abgerufen für: ${deviceId}`);
|
|
197
|
+
return res.data;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------- WebSocket ----------------
|
|
201
|
+
connectWebSocket(deviceId) {
|
|
202
|
+
if (!this.accessToken) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const url = `wss://socket.al-ko.com/v1?Authorization=${this.accessToken}&thingName=${deviceId}`;
|
|
207
|
+
const ws = new WebSocket(url);
|
|
208
|
+
|
|
209
|
+
ws.on("open", () => {
|
|
210
|
+
this.log.info(`🔗 WebSocket verbunden für Gerät: ${deviceId}`);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
ws.on("message", async (msg) => {
|
|
214
|
+
if (this.config.wsDebug) {
|
|
215
|
+
this.log.info(`🌐 WS-Nachricht (${deviceId}): ${msg}`);
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const data = JSON.parse(msg.toString());
|
|
219
|
+
if (data && data.state) {
|
|
220
|
+
const newState = data.state.reported || data.state;
|
|
221
|
+
|
|
222
|
+
// NEU: Cache nicht überschreiben, sondern mergen
|
|
223
|
+
this.deviceStates[deviceId] = this.deepMerge(
|
|
224
|
+
this.deviceStates[deviceId] || {},
|
|
225
|
+
newState,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
await this.createStatesRecursive(
|
|
229
|
+
`al-ko.0.${deviceId}.state`,
|
|
230
|
+
this.deviceStates[deviceId],
|
|
231
|
+
"",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
} catch (e) {
|
|
235
|
+
this.log.error(
|
|
236
|
+
`❌ Fehler beim Verarbeiten der WS-Nachricht (${deviceId}): ${e.message}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
ws.on("close", () => {
|
|
242
|
+
this.log.warn(
|
|
243
|
+
`⚠️ WebSocket geschlossen für ${deviceId}, erneuter Versuch in 10s`,
|
|
244
|
+
);
|
|
245
|
+
// Timeout merken, damit wir ihn im onUnload wieder aufräumen können
|
|
246
|
+
this.reconnectTimeouts[deviceId] = setTimeout(
|
|
247
|
+
() => this.connectWebSocket(deviceId),
|
|
248
|
+
10000,
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
ws.on("error", (err) => {
|
|
253
|
+
this.log.error(`❌ WebSocket-Fehler (${deviceId}): ${err.message}`);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
this.webSockets[deviceId] = ws;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------- Deep Merge ----------------
|
|
260
|
+
deepMerge(target, source) {
|
|
261
|
+
if (typeof target !== "object" || target === null) {
|
|
262
|
+
return JSON.parse(JSON.stringify(source));
|
|
263
|
+
}
|
|
264
|
+
if (typeof source !== "object" || source === null) {
|
|
265
|
+
return target;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const output = { ...target };
|
|
269
|
+
for (const key of Object.keys(source)) {
|
|
270
|
+
if (typeof source[key] === "object" && !Array.isArray(source[key])) {
|
|
271
|
+
output[key] = this.deepMerge(target[key], source[key]);
|
|
272
|
+
} else {
|
|
273
|
+
output[key] = source[key];
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return output;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------- State-Erzeugung ----------------
|
|
280
|
+
|
|
281
|
+
async createStatesRecursive(basePath, obj, relPath) {
|
|
282
|
+
if (!obj || typeof obj !== "object") {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const key of Object.keys(obj)) {
|
|
287
|
+
const val = obj[key];
|
|
288
|
+
if (val === null || val === undefined) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const currentRel = relPath ? `${relPath}.${key}` : key;
|
|
293
|
+
const fullId = `${basePath}.${key}`;
|
|
294
|
+
|
|
295
|
+
if (typeof val === "object" && !Array.isArray(val)) {
|
|
296
|
+
await this.setObjectNotExistsAsync(fullId, {
|
|
297
|
+
type: "channel",
|
|
298
|
+
common: { name: key },
|
|
299
|
+
native: {},
|
|
300
|
+
});
|
|
301
|
+
await this.createStatesRecursive(fullId, val, currentRel);
|
|
302
|
+
} else if (Array.isArray(val)) {
|
|
303
|
+
await this.setObjectNotExistsAsync(fullId, {
|
|
304
|
+
type: "channel",
|
|
305
|
+
common: { name: key },
|
|
306
|
+
native: {},
|
|
307
|
+
});
|
|
308
|
+
for (let i = 0; i < val.length; i++) {
|
|
309
|
+
const idxId = `${fullId}.${i}`;
|
|
310
|
+
if (typeof val[i] === "object") {
|
|
311
|
+
await this.setObjectNotExistsAsync(idxId, {
|
|
312
|
+
type: "channel",
|
|
313
|
+
common: { name: `${i}` },
|
|
314
|
+
native: {},
|
|
315
|
+
});
|
|
316
|
+
await this.createStatesRecursive(
|
|
317
|
+
idxId,
|
|
318
|
+
val[i],
|
|
319
|
+
`${currentRel}.${i}`,
|
|
320
|
+
);
|
|
321
|
+
} else {
|
|
322
|
+
const writable = this.isRelPathWhitelisted(`${currentRel}.${i}`);
|
|
323
|
+
await this.setStateIfChanged(
|
|
324
|
+
idxId,
|
|
325
|
+
val[i],
|
|
326
|
+
true,
|
|
327
|
+
writable,
|
|
328
|
+
`${currentRel}.${i}`,
|
|
329
|
+
);
|
|
330
|
+
if (writable) {
|
|
331
|
+
this.pushableStates.add(idxId);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
const writable = this.isRelPathWhitelisted(currentRel);
|
|
337
|
+
await this.setStateIfChanged(fullId, val, true, writable, currentRel);
|
|
338
|
+
if (writable) {
|
|
339
|
+
this.pushableStates.add(fullId);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
isRelPathWhitelisted(relPath) {
|
|
346
|
+
return whitelist.includes(relPath);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async setStateIfChanged(
|
|
350
|
+
id,
|
|
351
|
+
value,
|
|
352
|
+
ack = true,
|
|
353
|
+
write = false,
|
|
354
|
+
relPath = null,
|
|
355
|
+
) {
|
|
356
|
+
let type;
|
|
357
|
+
if (typeof value === "boolean") {
|
|
358
|
+
type = "boolean";
|
|
359
|
+
} else if (typeof value === "number") {
|
|
360
|
+
type = "number";
|
|
361
|
+
} else {
|
|
362
|
+
type = "string";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
await this.setObjectNotExistsAsync(id, {
|
|
366
|
+
type: "state",
|
|
367
|
+
common: { type, role: "state", read: true, write },
|
|
368
|
+
native: {},
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const prev = this.lastStateValues[id];
|
|
372
|
+
if (prev !== value) {
|
|
373
|
+
this.lastStateValues[id] = value;
|
|
374
|
+
this.adapterSetStates.add(id);
|
|
375
|
+
try {
|
|
376
|
+
await this.setStateAsync(id, { val: value, ack });
|
|
377
|
+
} finally {
|
|
378
|
+
setImmediate(() => this.adapterSetStates.delete(id));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ---------------- State-Änderungen (Push) ----------------
|
|
384
|
+
async onStateChange(id, state) {
|
|
385
|
+
//this.log.info(`INFO: onStateChange ausgelöst für ${id}, state=${JSON.stringify(state)}`);
|
|
386
|
+
|
|
387
|
+
if (!state || this._stopRequested) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (this.adapterSetStates.has(id)) {
|
|
391
|
+
//this.log.info(`INFO: Ignoriere eigenes Adapter-Update für ${id}`);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (!this.pushableStates.has(id)) {
|
|
395
|
+
this.log.info(`INFO: Änderung an nicht-pushbarem State ${id} erkannt`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (this.pendingPushes.has(id)) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const last = this.lastStateValues[id];
|
|
403
|
+
if (last === state.val) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
this.lastStateValues[id] = state.val;
|
|
408
|
+
const parts = id.split(".");
|
|
409
|
+
const deviceId = parts[2];
|
|
410
|
+
const relPathArr = parts.slice(4);
|
|
411
|
+
|
|
412
|
+
this.pendingPushes.add(id);
|
|
413
|
+
try {
|
|
414
|
+
this.log.info(`✏️ Änderung erkannt: ${id} = ${state.val}`);
|
|
415
|
+
|
|
416
|
+
const payload = this.buildPatchPayloadFromCache(
|
|
417
|
+
deviceId,
|
|
418
|
+
relPathArr,
|
|
419
|
+
state.val,
|
|
420
|
+
);
|
|
421
|
+
this.log.info(`📤 Push ${id}: ${JSON.stringify(payload)}`);
|
|
422
|
+
|
|
423
|
+
await this.refreshAuth();
|
|
424
|
+
const url = `https://api.al-ko.com/v1/iot/things/${encodeURIComponent(deviceId)}/state/desired`;
|
|
425
|
+
await axios.patch(url, payload, {
|
|
426
|
+
headers: {
|
|
427
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
428
|
+
"Content-Type": "application/json",
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
this.log.info(`✅ Push erfolgreich: ${id}`);
|
|
433
|
+
this.updateDeviceStateCache(deviceId, relPathArr, state.val);
|
|
434
|
+
} catch (err) {
|
|
435
|
+
this.log.error(
|
|
436
|
+
`❌ Fehler beim Pushen von ${id}: ${err.response?.status} ${err.response?.data || err.message}`,
|
|
437
|
+
);
|
|
438
|
+
} finally {
|
|
439
|
+
this.pendingPushes.delete(id);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------- Vollständige verschachtelte Payload-Logik (aus Referenz) ----------------
|
|
444
|
+
buildPatchPayloadFromCache(deviceId, relPathArr, value) {
|
|
445
|
+
if (!Array.isArray(relPathArr) || relPathArr.length === 0) {
|
|
446
|
+
throw new Error("Ungültiger relPathArr");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (relPathArr.length === 1) {
|
|
450
|
+
return { [relPathArr[0]]: value };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const parentParts = relPathArr.slice(0, -1);
|
|
454
|
+
const leafKey = relPathArr[relPathArr.length - 1];
|
|
455
|
+
const rootKey = parentParts[0];
|
|
456
|
+
|
|
457
|
+
const deviceRoot = this.deviceStates[deviceId] || {};
|
|
458
|
+
const parentObj = this.getDeep(deviceRoot, parentParts) || {};
|
|
459
|
+
|
|
460
|
+
const parentClone = JSON.parse(JSON.stringify(parentObj));
|
|
461
|
+
this.setDeep(parentClone, [leafKey], value);
|
|
462
|
+
|
|
463
|
+
const parentRelPrefix = parentParts.join(".");
|
|
464
|
+
const filteredParent = this.filterObjectByWhitelist(
|
|
465
|
+
parentClone,
|
|
466
|
+
parentRelPrefix,
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
let nested = filteredParent;
|
|
470
|
+
const nestedParts = parentParts.slice(1);
|
|
471
|
+
for (let i = nestedParts.length - 1; i >= 0; i--) {
|
|
472
|
+
nested = { [nestedParts[i]]: nested };
|
|
473
|
+
}
|
|
474
|
+
return { [rootKey]: nested };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
filterObjectByWhitelist(obj, parentRelPrefix) {
|
|
478
|
+
const relevant = whitelist
|
|
479
|
+
.filter(
|
|
480
|
+
(p) => p === parentRelPrefix || p.startsWith(`${parentRelPrefix}.`),
|
|
481
|
+
)
|
|
482
|
+
.map((p) => p.slice(parentRelPrefix.length + 1))
|
|
483
|
+
.filter((s) => !!s);
|
|
484
|
+
|
|
485
|
+
if (relevant.length === 0) {
|
|
486
|
+
if (whitelist.includes(parentRelPrefix)) {
|
|
487
|
+
return obj;
|
|
488
|
+
}
|
|
489
|
+
return {};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const tree = {};
|
|
493
|
+
for (const rel of relevant) {
|
|
494
|
+
const parts = rel.split(".");
|
|
495
|
+
let cur = tree;
|
|
496
|
+
for (let i = 0; i < parts.length; i++) {
|
|
497
|
+
const part = parts[i];
|
|
498
|
+
if (!cur[part]) {
|
|
499
|
+
cur[part] = {};
|
|
500
|
+
}
|
|
501
|
+
cur = cur[part];
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const copyAllowed = (source, schema) => {
|
|
506
|
+
if (source === null || typeof source !== "object") {
|
|
507
|
+
return source;
|
|
508
|
+
}
|
|
509
|
+
if (Array.isArray(source)) {
|
|
510
|
+
const resArr = [];
|
|
511
|
+
for (let i = 0; i < source.length; i++) {
|
|
512
|
+
const key = String(i);
|
|
513
|
+
if (schema[key]) {
|
|
514
|
+
resArr[i] = copyAllowed(source[i], schema[key]);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return resArr;
|
|
518
|
+
}
|
|
519
|
+
const res = {};
|
|
520
|
+
for (const k of Object.keys(schema)) {
|
|
521
|
+
if (Object.prototype.hasOwnProperty.call(source, k)) {
|
|
522
|
+
res[k] = copyAllowed(source[k], schema[k]);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return res;
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
return copyAllowed(obj, tree);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
getDeep(obj, pathArr) {
|
|
532
|
+
let cur = obj;
|
|
533
|
+
for (const p of pathArr) {
|
|
534
|
+
if (cur == null || typeof cur !== "object") {
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
cur = cur[p];
|
|
538
|
+
}
|
|
539
|
+
return cur;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
setDeep(obj, pathArr, val) {
|
|
543
|
+
let cur = obj;
|
|
544
|
+
for (let i = 0; i < pathArr.length - 1; i++) {
|
|
545
|
+
if (!cur[pathArr[i]] || typeof cur[pathArr[i]] !== "object") {
|
|
546
|
+
cur[pathArr[i]] = {};
|
|
547
|
+
}
|
|
548
|
+
cur = cur[pathArr[i]];
|
|
549
|
+
}
|
|
550
|
+
cur[pathArr[pathArr.length - 1]] = val;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
updateDeviceStateCache(deviceId, relPathArr, value) {
|
|
554
|
+
if (!this.deviceStates[deviceId]) {
|
|
555
|
+
this.deviceStates[deviceId] = {};
|
|
556
|
+
}
|
|
557
|
+
this.setDeep(this.deviceStates[deviceId], relPathArr, value);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---------------- Adapter-Stop ----------------
|
|
561
|
+
onUnload(callback) {
|
|
562
|
+
try {
|
|
563
|
+
this._stopRequested = true;
|
|
564
|
+
if (this.tokenInterval) {
|
|
565
|
+
clearInterval(this.tokenInterval);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// offene Reconnect-Timeouts aufräumen
|
|
569
|
+
for (const t of Object.values(this.reconnectTimeouts)) {
|
|
570
|
+
try {
|
|
571
|
+
clearTimeout(t);
|
|
572
|
+
} catch {
|
|
573
|
+
// intentionally empty
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// offene WebSockets schließen
|
|
578
|
+
for (const ws of Object.values(this.webSockets)) {
|
|
579
|
+
try {
|
|
580
|
+
ws.close();
|
|
581
|
+
} catch {
|
|
582
|
+
// intentionally empty
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
this.log.info("Adapter gestoppt.");
|
|
587
|
+
callback();
|
|
588
|
+
} catch (_e) {
|
|
589
|
+
callback();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ---------------- Export ----------------
|
|
595
|
+
if (require.main !== module) {
|
|
596
|
+
module.exports = (options) => new AlKoAdapter(options);
|
|
597
|
+
} else {
|
|
598
|
+
new AlKoAdapter({});
|
|
599
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "iobroker.al-ko",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Adapter for communication with Al-Ko garden tools",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Hubertiob",
|
|
7
|
+
"email": "hubertiob@posteo.at"
|
|
8
|
+
},
|
|
9
|
+
"contributors": [
|
|
10
|
+
{
|
|
11
|
+
"name": "AL-KO"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/zechnerhubert/ioBroker.al-ko",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"ioBroker",
|
|
18
|
+
"adapter",
|
|
19
|
+
"al-ko",
|
|
20
|
+
"Robolinho",
|
|
21
|
+
"smart-garden",
|
|
22
|
+
"mower"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/zechnerhubert/ioBroker.al-ko.git"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/zechnerhubert/ioBroker.al-ko/issues"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">= 20"
|
|
33
|
+
},
|
|
34
|
+
"main": "main.js",
|
|
35
|
+
"files": [
|
|
36
|
+
"admin{,/!(src)/**}/!(tsconfig|tsconfig.*|.eslintrc).{json,json5}",
|
|
37
|
+
"admin{,/!(src)/**}/*.{html,css,png,svg,jpg,js}",
|
|
38
|
+
"lib/",
|
|
39
|
+
"www/",
|
|
40
|
+
"io-package.json",
|
|
41
|
+
"main.js",
|
|
42
|
+
"whitelist.json",
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"README.md"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test:js": "mocha --config test/mocharc.custom.json \"{!(node_modules|test)/**/*.test.js,*.test.js,test/**/test!(PackageFiles|Startup).js}\"",
|
|
48
|
+
"test:package": "mocha test/package --exit",
|
|
49
|
+
"test:integration": "mocha test/integration --exit",
|
|
50
|
+
"test": "npm run test:js && npm run test:package",
|
|
51
|
+
"check": "tsc --noEmit -p tsconfig.check.json",
|
|
52
|
+
"lint": "eslint main.js lib/ --ext .js",
|
|
53
|
+
"lint:fix": "eslint main.js lib/ --ext .js --fix",
|
|
54
|
+
"translate": "translate-adapter",
|
|
55
|
+
"release": "release-script"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@iobroker/adapter-core": "^3.3.2",
|
|
59
|
+
"axios": "^1.12.2",
|
|
60
|
+
"ws": "^8.18.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@alcalzone/release-script": "^3.8.0",
|
|
64
|
+
"@alcalzone/release-script-plugin-iobroker": "^3.7.2",
|
|
65
|
+
"@alcalzone/release-script-plugin-license": "^3.7.0",
|
|
66
|
+
"@alcalzone/release-script-plugin-manual-review": "^3.7.0",
|
|
67
|
+
"@iobroker/adapter-dev": "^1.5.0",
|
|
68
|
+
"@iobroker/testing": "^5.1.1",
|
|
69
|
+
"@tsconfig/node20": "^20.1.6",
|
|
70
|
+
"@types/chai": "^4.3.20",
|
|
71
|
+
"@types/chai-as-promised": "^7.1.8",
|
|
72
|
+
"@types/mocha": "^10.0.10",
|
|
73
|
+
"@types/node": "^24.6.1",
|
|
74
|
+
"@types/proxyquire": "^1.3.31",
|
|
75
|
+
"@types/sinon": "^17.0.4",
|
|
76
|
+
"@types/sinon-chai": "^4.0.0",
|
|
77
|
+
"@eslint/js": "^9.14.0",
|
|
78
|
+
"@iobroker/eslint-config": "^1.5.0",
|
|
79
|
+
"chai": "^4.5.0",
|
|
80
|
+
"chai-as-promised": "^7.1.2",
|
|
81
|
+
"eslint": "^9.14.0",
|
|
82
|
+
"mocha": "^11.7.2",
|
|
83
|
+
"prettier": "^3.3.3",
|
|
84
|
+
"proxyquire": "^2.1.3",
|
|
85
|
+
"sinon": "^21.0.0",
|
|
86
|
+
"sinon-chai": "^4.0.1",
|
|
87
|
+
"typescript": "~5.0.4",
|
|
88
|
+
"typescript-eslint": "^8.8.0"
|
|
89
|
+
},
|
|
90
|
+
"publishConfig": {
|
|
91
|
+
"access": "public"
|
|
92
|
+
},
|
|
93
|
+
"readmeFilename": "README.md"
|
|
94
|
+
}
|