iobroker.miele-local 0.2.0

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,576 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * ioBroker.miele-local
5
+ * Verbindet moderne Miele@Home-Geräte lokal ohne Internet (MieleH256/DOP2).
6
+ */
7
+
8
+ const utils = require('@iobroker/adapter-core');
9
+ const { MieleCrypto } = require('./lib/crypto');
10
+ const { MieleDeviceApi } = require('./lib/api');
11
+ const { discover } = require('./lib/discovery');
12
+ const cloud = require('./lib/cloud');
13
+ const objdef = require('./lib/objects');
14
+ const { MielePushListener } = require('./lib/push');
15
+ const enroll = require('./lib/enroll');
16
+ const dop2 = require('./lib/dop2');
17
+
18
+ // EcoFeedback: DOP2-Leaf 2/6195 (bislang nur Waschmaschinen liefern ihn).
19
+ // Feldindizes (1-based) empirisch gegen den Cloud-Adapter verifiziert (WCR860):
20
+ // #25 = Energie in Wh (1991 ≈ Cloud 1,9 kWh), #40 = Wasser in 0,1 l (953 ≈ Cloud 96 l).
21
+ // Kann je Modell abweichen.
22
+ const ECO_LEAF = { unit: 2, attr: 6195 };
23
+ const ECO_ENERGY_IDX = 25; // Wh
24
+ const ECO_WATER_IDX = 40; // 0,1 l
25
+
26
+ // Sekundengenaue Zeiten aus DOP2-Leaf 2/256 (verifiziert: #7 Restzeit s, #8 Laufzeit s).
27
+ const SEC_LEAF = { unit: 2, attr: 256 };
28
+ const SEC_REMAINING_IDX = 7;
29
+ const SEC_ELAPSED_IDX = 8;
30
+
31
+ // DOP2 GLOBAL_USER_REQ (Steuerbefehle), Leaf 2/1583
32
+ const USER_REQ_PREFIX = Buffer.from('00100001062f00000000000100010700', 'hex');
33
+ const USER_REQ_UNIT = 2;
34
+ const USER_REQ_LEAF = 1583;
35
+ // Status-Werte, bei denen ein Gerät „aktiv" ist → schnelleres Polling
36
+ const ACTIVE_STATUSES = new Set([3, 4, 5, 6, 7, 9, 13, 14, 15]);
37
+
38
+ function buildUserRequest(opcode) {
39
+ return Buffer.concat([USER_REQ_PREFIX, Buffer.from([opcode & 0xff]), Buffer.alloc(15, 0x20)]);
40
+ }
41
+
42
+ class MieleLocal extends utils.Adapter {
43
+ constructor(options) {
44
+ super({ ...options, name: 'miele-local' });
45
+ /** @type {Object.<string, {ip:string, route:string, deviceType:number, api:MieleDeviceApi, active:boolean}>} */
46
+ this.devices = {};
47
+ this.pollTimer = null;
48
+ this.push = null;
49
+ this.oauth = {}; // messageId → challenge
50
+ this.stopping = false;
51
+
52
+ this.on('ready', this.onReady.bind(this));
53
+ this.on('stateChange', this.onStateChange.bind(this));
54
+ this.on('message', this.onMessage.bind(this));
55
+ this.on('unload', this.onUnload.bind(this));
56
+ }
57
+
58
+ async onReady() {
59
+ await this.setStateAsync('info.connection', { val: false, ack: true });
60
+
61
+ const groupId = this.config.groupId;
62
+ const groupKey = this.config.groupKey;
63
+ if (!groupId || !groupKey) {
64
+ this.log.warn('Keine Zugangsdaten. Bitte in den Instanzeinstellungen anmelden (GroupKey ermitteln).');
65
+ return;
66
+ }
67
+ try {
68
+ this.mc = new MieleCrypto(groupId, groupKey);
69
+ } catch (e) {
70
+ this.log.error(`Ungültiger GroupKey: ${e.message}`);
71
+ return;
72
+ }
73
+
74
+ // Geräte ermitteln: Auto-Discovery und/oder manuelle IP-Liste
75
+ const found = [];
76
+ const seenIp = new Set();
77
+ if (this.config.autoDiscover !== false) {
78
+ try {
79
+ const list = await discover(6000, m => this.log.debug(m), this);
80
+ for (const d of list) {
81
+ if (d.txt.group && d.txt.group.toUpperCase() !== groupId.toUpperCase()) continue;
82
+ found.push({ ip: d.ip, techType: d.techType, deviceType: Number(d.txt.devicetype) });
83
+ seenIp.add(d.ip);
84
+ }
85
+ } catch (e) {
86
+ this.log.warn(`mDNS-Discovery fehlgeschlagen: ${e.message}`);
87
+ }
88
+ }
89
+ for (const entry of this.config.devices || []) {
90
+ const ip = typeof entry === 'string' ? entry : entry && entry.ip;
91
+ if (ip && !seenIp.has(ip)) {
92
+ found.push({ ip, techType: '', deviceType: null });
93
+ seenIp.add(ip);
94
+ }
95
+ }
96
+
97
+ if (!found.length) {
98
+ this.log.warn('Keine Miele-Geräte gefunden (weder per mDNS noch manuell konfiguriert).');
99
+ }
100
+ await this.setStateAsync('info.discoveredDevices', { val: JSON.stringify(found), ack: true });
101
+
102
+ for (const f of found) {
103
+ try {
104
+ await this.initDevice(f);
105
+ } catch (e) {
106
+ this.log.warn(`Gerät ${f.ip} konnte nicht initialisiert werden: ${e.message}`);
107
+ }
108
+ }
109
+
110
+ if (Object.keys(this.devices).length) {
111
+ await this.setStateAsync('info.connection', { val: true, ack: true });
112
+ }
113
+
114
+ await this.subscribeStatesAsync('*.control.*');
115
+
116
+ // Push-Listener + Enrollment
117
+ if (this.config.usePush) {
118
+ this.startPush();
119
+ if (this.push) {
120
+ await this.enrollAll();
121
+ // Subscriptions laufen ab → periodisch erneuern
122
+ this.enrollTimer = this.setInterval(() => this.enrollAll(), 4 * 60 * 1000);
123
+ }
124
+ }
125
+
126
+ // Polling starten
127
+ this.schedulePoll(true);
128
+
129
+ // EcoFeedback (Energie/Wasser) per DOP2 – langsameres, separates Intervall
130
+ if (this.config.ecoFeedback !== false) {
131
+ this.pollEco();
132
+ this.ecoTimer = this.setInterval(() => this.pollEco(), (this.config.ecoInterval || 60) * 1000);
133
+ }
134
+
135
+ // Sekundengenaue Rest-/Laufzeit per DOP2 2/256 – schneller 10s-Poll
136
+ if (this.config.secondsTime !== false) {
137
+ this.pollSeconds();
138
+ this.secTimer = this.setInterval(() => this.pollSeconds(), (this.config.secondsInterval || 30) * 1000);
139
+ }
140
+ }
141
+
142
+ /** Gerät initialisieren: Route (Seriennr.) ermitteln, Objektbaum anlegen, Ident lesen. */
143
+ async initDevice(f) {
144
+ const api = new MieleDeviceApi(f.ip, this.mc, { timeout: 8000 });
145
+ // Seriennummer(n) über signiertes /Devices/ ermitteln
146
+ const list = await api.get('Devices/');
147
+ const routes = Object.keys(list || {});
148
+ if (!routes.length) throw new Error('keine Geräte-Route gefunden');
149
+
150
+ for (const route of routes) {
151
+ const deviceId = route; // Objekt-ID = Seriennummer
152
+ let ident = null;
153
+ try {
154
+ ident = await api.getIdent(route);
155
+ } catch (e) {
156
+ this.log.debug(`Ident ${route} nicht lesbar: ${e.message}`);
157
+ }
158
+ const deviceType = ident ? Number(ident.DeviceType) : f.deviceType;
159
+ const techType = ident ? objdef.pathGet(ident, ['DeviceIdentLabel', 'TechType']) : f.techType;
160
+
161
+ await this.createDeviceTree(deviceId, techType, deviceType);
162
+
163
+ this.devices[deviceId] = { ip: f.ip, route, deviceType, api, active: false };
164
+ if (ident) await this.applyIdent(deviceId, ident);
165
+ const cat = objdef.deviceCategory(deviceType);
166
+ this.log.info(`Gerät erkannt: ${cat ? cat + ' - ' : ''}${techType || 'unbekannt'} (${deviceId}) @ ${f.ip}`);
167
+ }
168
+ }
169
+
170
+ async createDeviceTree(deviceId, techType, deviceType) {
171
+ const cat = objdef.deviceCategory(deviceType);
172
+ const label = techType
173
+ ? `${cat ? cat + ' - ' : ''}${techType} (${deviceId})`
174
+ : (cat ? `${cat} (${deviceId})` : deviceId);
175
+ await this.extendObjectAsync(deviceId, {
176
+ type: 'device',
177
+ common: { name: label },
178
+ native: { serial: deviceId },
179
+ });
180
+ // Kanäle
181
+ for (const ch of ['info', 'state']) {
182
+ await this.setObjectNotExistsAsync(`${deviceId}.${ch}`, {
183
+ type: 'channel',
184
+ common: { name: ch === 'info' ? 'Information' : 'State' },
185
+ native: {},
186
+ });
187
+ }
188
+ const german = this.config.germanNames !== false;
189
+ // Ident-States (extendObject → Namen aktualisieren sich beim Umschalten der Sprache)
190
+ for (const f of objdef.IDENT_FIELDS) {
191
+ await this.extendObjectAsync(`${deviceId}.info.${f.sub}`, {
192
+ type: 'state',
193
+ common: { name: objdef.nameFor('info', f.sub, f.name, german), role: f.role, type: f.type, read: true, write: false },
194
+ native: {},
195
+ });
196
+ }
197
+ // State-States (gerätespezifische Felder nur beim passenden Gerätetyp, siehe fieldAllowed)
198
+ for (const key of Object.keys(objdef.STATE_FIELDS)) {
199
+ if (!objdef.fieldAllowed(key, deviceType)) continue;
200
+ for (const s of objdef.STATE_FIELDS[key].states) {
201
+ await this.extendObjectAsync(`${deviceId}.state.${s.sub}`, {
202
+ type: 'state',
203
+ common: {
204
+ name: objdef.nameFor('state', s.sub, s.name, german),
205
+ role: s.role,
206
+ type: s.type,
207
+ unit: s.unit,
208
+ read: true,
209
+ write: false,
210
+ },
211
+ native: {},
212
+ });
213
+ }
214
+ }
215
+ // Steuer-States (nur wenn erlaubt)
216
+ if (this.config.allowControl) {
217
+ await this.setObjectNotExistsAsync(`${deviceId}.control`, {
218
+ type: 'channel',
219
+ common: { name: 'Control' },
220
+ native: {},
221
+ });
222
+ for (const c of objdef.CONTROL_STATES) {
223
+ await this.extendObjectAsync(`${deviceId}.control.${c.sub}`, {
224
+ type: 'state',
225
+ common: { name: objdef.nameFor('control', c.sub, c.name, german), role: c.role, type: 'boolean', read: false, write: true, def: false },
226
+ native: { opcode: c.opcode },
227
+ });
228
+ }
229
+ }
230
+ }
231
+
232
+ async applyIdent(deviceId, ident) {
233
+ for (const f of objdef.IDENT_FIELDS) {
234
+ const val = objdef.pathGet(ident, f.path);
235
+ if (val !== undefined) {
236
+ await this.setStateAsync(`${deviceId}.info.${f.sub}`, { val: f.type === 'number' ? Number(val) : String(val), ack: true });
237
+ }
238
+ }
239
+ }
240
+
241
+ /** /State-Objekt in ioBroker-States übernehmen. */
242
+ async applyState(deviceId, state) {
243
+ const dev = this.devices[deviceId];
244
+ const ctx = { deviceType: dev ? dev.deviceType : null };
245
+ let statusVal = null;
246
+ for (const [key, def] of Object.entries(objdef.STATE_FIELDS)) {
247
+ if (!(key in state)) continue;
248
+ if (!objdef.fieldAllowed(key, ctx.deviceType)) continue;
249
+ const pairs = def.decode(state[key], ctx);
250
+ for (const p of pairs) {
251
+ await this.setStateAsync(`${deviceId}.state.${p.sub}`, { val: p.val === undefined ? null : p.val, ack: true });
252
+ }
253
+ if (key === 'Status') statusVal = state.Status;
254
+ }
255
+ // Voraussichtliches Programmende (wie mielecloudservice.estimatedEndTime): jetzt + Restzeit.
256
+ // Nur wenn eine Restzeit > 0 vorliegt; sonst leeren (kein laufendes Programm). Rohantwort
257
+ // kennt keine Uhrzeit, daher hier berechnet. Minutengenau (Restzeit-Leaf), das genügt fürs Ende.
258
+ if ('RemainingTime' in state) {
259
+ const remMin = objdef.timeToMinutes(state.RemainingTime);
260
+ if (remMin && remMin > 0) {
261
+ const end = new Date(Date.now() + remMin * 60000);
262
+ await this.setStateAsync(`${deviceId}.state.estimatedEndTime`, { val: end.getTime(), ack: true });
263
+ const hh = String(end.getHours()).padStart(2, '0');
264
+ const mm = String(end.getMinutes()).padStart(2, '0');
265
+ await this.setStateAsync(`${deviceId}.state.estimatedEndTimeText`, { val: `${hh}:${mm}`, ack: true });
266
+ } else {
267
+ await this.setStateAsync(`${deviceId}.state.estimatedEndTime`, { val: null, ack: true });
268
+ await this.setStateAsync(`${deviceId}.state.estimatedEndTimeText`, { val: '', ack: true });
269
+ }
270
+ }
271
+ if (dev && statusVal != null) dev.active = ACTIVE_STATUSES.has(statusVal);
272
+ }
273
+
274
+ schedulePoll(immediate = false) {
275
+ if (this.pollTimer) this.clearTimeout(this.pollTimer);
276
+ const anyActive = Object.values(this.devices).some(d => d.active);
277
+ const interval = (anyActive ? this.config.activePollInterval || 5 : this.config.pollInterval || 15) * 1000;
278
+ const run = async () => {
279
+ if (this.stopping) return;
280
+ await this.pollAll();
281
+ this.schedulePoll(false);
282
+ };
283
+ const nextDelayMs = immediate ? 100 : interval;
284
+ // Nach einem Schreibbefehl kurz pausieren (Gerät bearbeitet nur eine Anfrage,
285
+ // und der neue Zustand steht erst nach kurzer Zeit im /State).
286
+ const effective = Math.max(nextDelayMs, (this.pausePollUntil || 0) - Date.now());
287
+ this.pollTimer = this.setTimeout(run, effective);
288
+ }
289
+
290
+ /** EcoFeedback (Energie/Wasser) aus DOP2-Leaf 2/6195 lesen – nur wo verfügbar. */
291
+ async pollEco() {
292
+ for (const [deviceId, dev] of Object.entries(this.devices)) {
293
+ let plain;
294
+ try {
295
+ const res = await dev.api.readDop2(dev.route, ECO_LEAF.unit, ECO_LEAF.attr);
296
+ if (res.status !== 200 || !res.headers['x-signature']) continue; // Gerät ohne Eco-Leaf
297
+ plain = this.mc.decryptResponse(res.headers['x-signature'], res.body);
298
+ } catch (e) {
299
+ this.log.debug(`Eco ${deviceId}: ${e.message}`);
300
+ continue;
301
+ }
302
+ let fields;
303
+ try {
304
+ ({ fields } = dop2.parseLeaf(plain));
305
+ } catch (e) {
306
+ this.log.debug(`Eco ${deviceId}: Parse-Fehler ${e.message}`);
307
+ continue;
308
+ }
309
+ const energyRaw = dop2.interpValue(fields, ECO_ENERGY_IDX);
310
+ const waterRaw = dop2.interpValue(fields, ECO_WATER_IDX);
311
+ if (energyRaw == null && waterRaw == null) continue;
312
+
313
+ await this.ensureEcoObjects(deviceId);
314
+ if (energyRaw != null) {
315
+ await this.setStateAsync(`${deviceId}.eco.energyWh`, { val: Number(energyRaw), ack: true });
316
+ await this.setStateAsync(`${deviceId}.eco.energy`, { val: Math.round(Number(energyRaw)) / 1000, ack: true });
317
+ }
318
+ if (waterRaw != null) {
319
+ await this.setStateAsync(`${deviceId}.eco.water`, { val: Math.round(Number(waterRaw)) / 10, ack: true });
320
+ }
321
+ }
322
+ }
323
+
324
+ async ensureEcoObjects(deviceId) {
325
+ if (!this._ecoCreated) this._ecoCreated = {};
326
+ if (this._ecoCreated[deviceId]) return;
327
+ const german = this.config.germanNames !== false;
328
+ await this.setObjectNotExistsAsync(`${deviceId}.eco`, {
329
+ type: 'channel', common: { name: german ? 'EcoFeedback' : 'EcoFeedback' }, native: {},
330
+ });
331
+ const defs = [
332
+ { sub: 'energy', name: german ? 'Energieverbrauch' : 'Energy consumption', role: 'value.power.consumption', unit: 'kWh' },
333
+ { sub: 'energyWh', name: german ? 'Energieverbrauch (Rohwert Wh)' : 'Energy consumption (raw Wh)', role: 'value', unit: 'Wh' },
334
+ { sub: 'water', name: german ? 'Wasserverbrauch' : 'Water consumption', role: 'value', unit: 'l' },
335
+ ];
336
+ for (const d of defs) {
337
+ await this.extendObjectAsync(`${deviceId}.eco.${d.sub}`, {
338
+ type: 'state',
339
+ common: { name: d.name, role: d.role, type: 'number', unit: d.unit, read: true, write: false },
340
+ native: {},
341
+ });
342
+ }
343
+ this._ecoCreated[deviceId] = true;
344
+ }
345
+
346
+ /** Sekundengenaue Rest-/Laufzeit aus DOP2 2/256 (#7 Rest s, #8 Lauf s) – nur wo verfügbar. */
347
+ async pollSeconds() {
348
+ for (const [deviceId, dev] of Object.entries(this.devices)) {
349
+ // Läuft kein Programm (Status ≠ In Betrieb/Pause), die Sekundenwerte auf 0 zurücksetzen -
350
+ // sonst blieben nach Programmende die letzten Rest-/Laufzeiten stehen (z. B. 5100 s
351
+ // „Restzeit" bei fertiger Maschine). Spart zugleich den DOP2-Call im Ruhezustand.
352
+ const st = await this.getStateAsync(`${deviceId}.state.status`);
353
+ const running = st && (st.val === 5 || st.val === 6);
354
+ if (!running) {
355
+ await this.ensureSecondsObjects(deviceId);
356
+ await this.setStateAsync(`${deviceId}.state.remainingSeconds`, { val: 0, ack: true });
357
+ await this.setStateAsync(`${deviceId}.state.elapsedSeconds`, { val: 0, ack: true });
358
+ continue;
359
+ }
360
+ let fields;
361
+ try {
362
+ const res = await dev.api.readDop2(dev.route, SEC_LEAF.unit, SEC_LEAF.attr);
363
+ if (res.status !== 200 || !res.headers['x-signature']) continue;
364
+ ({ fields } = dop2.parseLeaf(this.mc.decryptResponse(res.headers['x-signature'], res.body)));
365
+ } catch (e) {
366
+ continue; // Gerät ohne 2/256 oder gerade beschäftigt
367
+ }
368
+ const rem = fields[SEC_REMAINING_IDX] && typeof fields[SEC_REMAINING_IDX].value === 'number' ? fields[SEC_REMAINING_IDX].value : null;
369
+ const ela = fields[SEC_ELAPSED_IDX] && typeof fields[SEC_ELAPSED_IDX].value === 'number' ? fields[SEC_ELAPSED_IDX].value : null;
370
+ if (rem == null && ela == null) continue;
371
+ await this.ensureSecondsObjects(deviceId);
372
+ if (rem != null) await this.setStateAsync(`${deviceId}.state.remainingSeconds`, { val: rem, ack: true });
373
+ if (ela != null) await this.setStateAsync(`${deviceId}.state.elapsedSeconds`, { val: ela, ack: true });
374
+ }
375
+ }
376
+
377
+ async ensureSecondsObjects(deviceId) {
378
+ if (!this._secCreated) this._secCreated = {};
379
+ if (this._secCreated[deviceId]) return;
380
+ const german = this.config.germanNames !== false;
381
+ await this.extendObjectAsync(`${deviceId}.state.remainingSeconds`, {
382
+ type: 'state',
383
+ common: { name: german ? 'Restzeit (Sekunden)' : 'Remaining time (seconds)', role: 'value.interval', type: 'number', unit: 's', read: true, write: false },
384
+ native: {},
385
+ });
386
+ await this.extendObjectAsync(`${deviceId}.state.elapsedSeconds`, {
387
+ type: 'state',
388
+ common: { name: german ? 'Laufzeit (Sekunden)' : 'Elapsed time (seconds)', role: 'value.interval', type: 'number', unit: 's', read: true, write: false },
389
+ native: {},
390
+ });
391
+ this._secCreated[deviceId] = true;
392
+ }
393
+
394
+ /** SuperVision-Enrollment für alle Geräte (Push aktivieren). */
395
+ async enrollAll() {
396
+ if (!this.push) return;
397
+ for (const [deviceId, dev] of Object.entries(this.devices)) {
398
+ try {
399
+ const r = await enroll.enrollDevice(dev.api, {
400
+ route: dev.route,
401
+ ourFab: this.push.ourFab,
402
+ hostIp: this.push.hostIp,
403
+ port: this.config.pushPort || 18082,
404
+ log: this.log,
405
+ });
406
+ this.log.info(
407
+ `Enrollment ${deviceId}: SuperVision=${r.supervisionOk}, Subscriptions=[${r.subscriptions.join(' ')}]`,
408
+ );
409
+ } catch (e) {
410
+ this.log.warn(`Enrollment ${deviceId} fehlgeschlagen: ${e.message}`);
411
+ }
412
+ }
413
+ }
414
+
415
+ async pollAll() {
416
+ let ok = false;
417
+ for (const [deviceId, dev] of Object.entries(this.devices)) {
418
+ try {
419
+ const state = await dev.api.getState(dev.route);
420
+ if (state) {
421
+ await this.applyState(deviceId, state);
422
+ ok = true;
423
+ }
424
+ } catch (e) {
425
+ this.log.debug(`Polling ${deviceId} fehlgeschlagen: ${e.message}`);
426
+ }
427
+ }
428
+ await this.setStateAsync('info.connection', { val: ok, ack: true });
429
+ }
430
+
431
+ async onStateChange(id, state) {
432
+ if (!state || state.ack) return; // nur echte Nutzerbefehle
433
+ const parts = id.split('.'); // miele-local.0.<serial>.control.<sub>
434
+ const idx = parts.indexOf('control');
435
+ if (idx < 0) return;
436
+ const deviceId = parts[idx - 1];
437
+ const sub = parts[idx + 1];
438
+ const dev = this.devices[deviceId];
439
+ if (!dev) return;
440
+ if (!this.config.allowControl) {
441
+ this.log.warn('Steuerung ist in den Instanzeinstellungen deaktiviert.');
442
+ return;
443
+ }
444
+ if (!state.val) return; // nur bei true auslösen
445
+
446
+ const obj = await this.getObjectAsync(id);
447
+ const opcode = obj && obj.native ? obj.native.opcode : null;
448
+ if (opcode == null) return;
449
+
450
+ try {
451
+ const payload = buildUserRequest(opcode);
452
+ const status = await dev.api.put(`Devices/${dev.route}/DOP2/${USER_REQ_UNIT}/${USER_REQ_LEAF}?idx1=0&idx2=0`, payload);
453
+ if (status === 200 || status === 204) {
454
+ this.log.info(`Befehl '${sub}' an ${deviceId} gesendet (opcode 0x${opcode.toString(16)}).`);
455
+ await this.setStateAsync(id, { val: false, ack: true });
456
+ // Polling kurz pausieren, dann gezielt den neuen Zustand holen.
457
+ this.pausePollUntil = Date.now() + 2500;
458
+ if (this.pollTimer) this.clearTimeout(this.pollTimer);
459
+ this.setTimeout(async () => {
460
+ await this.pollAll();
461
+ this.schedulePoll(false);
462
+ }, 2600);
463
+ } else {
464
+ this.log.warn(`Befehl '${sub}' an ${deviceId}: HTTP ${status} (MobileStart am Gerät aktiv?).`);
465
+ }
466
+ } catch (e) {
467
+ this.log.warn(`Befehl '${sub}' an ${deviceId} fehlgeschlagen: ${e.message} (Firmware erlaubt evtl. keine Fernsteuerung).`);
468
+ }
469
+ }
470
+
471
+ startPush() {
472
+ try {
473
+ this.push = new MielePushListener({
474
+ port: this.config.pushPort || 18082,
475
+ crypto: this.mc,
476
+ log: this.log,
477
+ adapter: this,
478
+ onEvent: async ev => {
479
+ // ev = { route, state }
480
+ const deviceId = ev.route;
481
+ if (this.devices[deviceId] && ev.state) {
482
+ await this.applyState(deviceId, ev.state);
483
+ this.schedulePoll(false);
484
+ }
485
+ },
486
+ });
487
+ this.push.start();
488
+ this.log.info(`Push-Listener aktiv auf Port ${this.config.pushPort || 18082}.`);
489
+ } catch (e) {
490
+ this.log.warn(`Push-Listener konnte nicht gestartet werden: ${e.message}. Polling bleibt aktiv.`);
491
+ }
492
+ }
493
+
494
+ // --- Admin-Nachrichten (OAuth-Login) ---
495
+ async onMessage(obj) {
496
+ if (!obj || !obj.command) return;
497
+ try {
498
+ if (obj.command === 'getAuthUrl') {
499
+ const cc = (obj.message && obj.message.country) || this.config.country || 'de';
500
+ const { url, challenge } = cloud.buildAuthorizeUrl(cc);
501
+ this.oauth[challenge.state] = challenge;
502
+ this.log.info(`Login-URL (im Browser öffnen): ${url}`);
503
+ // `openUrl` lässt den Admin die Login-Seite im Browser öffnen.
504
+ this.sendTo(obj.from, obj.command, { openUrl: url, state: challenge.state }, obj.callback);
505
+ return;
506
+ }
507
+ if (obj.command === 'submitRedirect') {
508
+ const redirectUrl = obj.message && obj.message.redirectUrl;
509
+ const region = (obj.message && obj.message.region) || this.config.region || 'EU';
510
+ if (!redirectUrl) throw new Error('Keine Redirect-URL übergeben.');
511
+ // passenden challenge über state finden
512
+ let challenge = null;
513
+ try {
514
+ const q = redirectUrl.slice(redirectUrl.indexOf('?') + 1);
515
+ const st = new URLSearchParams(q).get('state');
516
+ challenge = st ? this.oauth[st] : null;
517
+ } catch {
518
+ /* ignore */
519
+ }
520
+ if (!challenge) challenge = Object.values(this.oauth).pop();
521
+ if (!challenge) throw new Error('Kein aktiver Login-Vorgang. Bitte Login-URL neu erzeugen.');
522
+
523
+ const code = cloud.parseRedirectUrl(redirectUrl, challenge.state);
524
+ const tokens = await cloud.exchangeCode(challenge, code);
525
+ const gk = await cloud.fetchGroupKey(tokens.access_token, region);
526
+ delete this.oauth[challenge.state];
527
+ this.sendTo(
528
+ obj.from,
529
+ obj.command,
530
+ {
531
+ native: {
532
+ groupId: gk.groupId,
533
+ groupKey: gk.groupKey,
534
+ refreshToken: tokens.refresh_token || '',
535
+ country: challenge.cc,
536
+ region,
537
+ },
538
+ saveConfig: true,
539
+ result: `${gk.devices.length} Gerät(e) im Haushalt gefunden. GroupKey gespeichert.`,
540
+ },
541
+ obj.callback,
542
+ );
543
+ return;
544
+ }
545
+ if (obj.command === 'discover') {
546
+ const list = await discover(6000, m => this.log.debug(m), this);
547
+ this.sendTo(obj.from, obj.command, { devices: list.map(d => ({ ip: d.ip, techType: d.techType, deviceType: Number(d.txt.devicetype), group: d.txt.group })) }, obj.callback);
548
+ return;
549
+ }
550
+ } catch (e) {
551
+ this.sendTo(obj.from, obj.command, { error: e.message }, obj.callback);
552
+ }
553
+ }
554
+
555
+ async onUnload(callback) {
556
+ this.stopping = true;
557
+ try {
558
+ if (this.pollTimer) this.clearTimeout(this.pollTimer);
559
+ if (this.enrollTimer) this.clearInterval(this.enrollTimer);
560
+ if (this.ecoTimer) this.clearInterval(this.ecoTimer);
561
+ if (this.secTimer) this.clearInterval(this.secTimer);
562
+ if (this.push) await this.push.stop();
563
+ await this.setStateAsync('info.connection', { val: false, ack: true });
564
+ } catch {
565
+ /* ignore */
566
+ } finally {
567
+ callback();
568
+ }
569
+ }
570
+ }
571
+
572
+ if (require.main !== module) {
573
+ module.exports = options => new MieleLocal(options);
574
+ } else {
575
+ new MieleLocal();
576
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "iobroker.miele-local",
3
+ "version": "0.2.0",
4
+ "description": "Connect modern Miele@Home appliances locally without the internet (MieleH256/DOP2).",
5
+ "author": "Immanuel",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "miele",
9
+ "smarthome",
10
+ "local",
11
+ "dop2",
12
+ "ioBroker"
13
+ ],
14
+ "main": "main.js",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/SmarthomeElektroniker/ioBroker.miele-local.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/SmarthomeElektroniker/ioBroker.miele-local/issues"
21
+ },
22
+ "homepage": "https://github.com/SmarthomeElektroniker/ioBroker.miele-local",
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "dependencies": {
27
+ "@iobroker/adapter-core": "^3.4.1",
28
+ "multicast-dns": "^7.2.5"
29
+ },
30
+ "devDependencies": {
31
+ "@eslint/js": "^9.39.0",
32
+ "@iobroker/adapter-dev": "^1.5.0",
33
+ "@iobroker/testing": "^5.2.2",
34
+ "@types/node": "^22",
35
+ "eslint": "^9.39.0",
36
+ "globals": "^15.15.0"
37
+ },
38
+ "scripts": {
39
+ "test:crypto": "node test/test-crypto.js",
40
+ "test:package": "mocha test/package --exit",
41
+ "test": "npm run test:crypto && npm run test:package",
42
+ "lint": "eslint .",
43
+ "test:integration": "mocha test/integration --exit"
44
+ },
45
+ "files": [
46
+ "admin/",
47
+ "lib/",
48
+ "main.js",
49
+ "io-package.json",
50
+ "LICENSE"
51
+ ]
52
+ }