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.
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * mDNS-Discovery der Miele-Geräte (_mieleathome._tcp).
5
+ *
6
+ * Record-Struktur (real beobachtet):
7
+ * PTR _mieleathome._tcp.local → "Miele <TechType>._mieleathome._tcp.local"
8
+ * SRV Miele <TechType>._mieleathome... → target "Miele-<MAC>.local", port 80
9
+ * TXT Miele <TechType>._mieleathome... → group=<GroupID>, devicetype, pairing, …
10
+ * A Miele-<MAC>.local → IP
11
+ *
12
+ * Liefert je Gerät: ip, host, techType, txt (group/devicetype/pairing/…).
13
+ * Die Seriennummer steht NICHT im mDNS – sie kommt aus dem signierten /Ident.
14
+ *
15
+ * Nutzt `multicast-dns` (pure JS, keine nativen Abhängigkeiten).
16
+ */
17
+
18
+ // multicast-dns wird bewusst NICHT top-level geladen: mDNS-Discovery ist experimentell und im
19
+ // Bridge-Netz (Docker) ohnehin funktionslos (Multicast wird nicht gebrückt) - der Normalbetrieb
20
+ // nutzt manuelle Geräte-IPs. Fehlt das Modul (z. B. nach einem abgebrochenen npm-Install), darf das
21
+ // NICHT den ganzen Adapter am Start crashen. Daher lazy require erst in discover().
22
+ const SERVICE = '_mieleathome._tcp.local';
23
+
24
+ function parseTxt(buffers) {
25
+ const txt = {};
26
+ for (const b of buffers || []) {
27
+ const s = b.toString('utf8');
28
+ const eq = s.indexOf('=');
29
+ if (eq > 0) {
30
+ txt[s.slice(0, eq)] = s.slice(eq + 1);
31
+ } else if (s) {
32
+ txt[s] = true;
33
+ }
34
+ }
35
+ return txt;
36
+ }
37
+
38
+ /**
39
+ * @param {number} timeoutMs Sammelzeit
40
+ * @param {(msg:string)=>void} [log]
41
+ * @returns {Promise<Array<{ip:string, host:string, port:number, techType:string, txt:object}>>}
42
+ */
43
+ function discover(timeoutMs = 5000, log = () => {}, adapter = null) {
44
+ // Timer über den Adapter, damit sie beim Beenden sauber aufgeräumt werden (ioBroker-Vorgabe)
45
+ const setT = adapter ? adapter.setTimeout.bind(adapter) : global.setTimeout;
46
+ const clearT = adapter ? adapter.clearTimeout.bind(adapter) : global.clearTimeout;
47
+ return new Promise(resolve => {
48
+ let multicastDns;
49
+ try {
50
+ multicastDns = require('multicast-dns');
51
+ } catch (e) {
52
+ log('mDNS-Discovery nicht verfügbar (multicast-dns nicht installiert) - bitte Geräte-IPs manuell eintragen.');
53
+ return resolve([]);
54
+ }
55
+ const mdns = multicastDns();
56
+ const instances = {}; // instanceName → { techType, host, port, txt }
57
+ const hostToIp = {}; // host → ip
58
+
59
+ mdns.on('response', res => {
60
+ for (const a of [...(res.answers || []), ...(res.additionals || [])]) {
61
+ if (a.type === 'SRV' && a.name.endsWith(`.${SERVICE}`)) {
62
+ const inst = (instances[a.name] = instances[a.name] || { techType: '', host: null, port: 80, txt: {} });
63
+ inst.host = a.data.target;
64
+ inst.port = a.data.port;
65
+ inst.techType = a.name.replace(`.${SERVICE}`, '').replace(/^Miele\s*/i, '').trim();
66
+ } else if (a.type === 'TXT' && a.name.endsWith(`.${SERVICE}`)) {
67
+ const inst = (instances[a.name] = instances[a.name] || { techType: '', host: null, port: 80, txt: {} });
68
+ Object.assign(inst.txt, parseTxt(a.data));
69
+ if (!inst.techType) {
70
+ inst.techType = a.name.replace(`.${SERVICE}`, '').replace(/^Miele\s*/i, '').trim();
71
+ }
72
+ } else if (a.type === 'A') {
73
+ hostToIp[a.name] = a.data;
74
+ }
75
+ }
76
+ });
77
+
78
+ const query = () => mdns.query({ questions: [{ name: SERVICE, type: 'PTR' }] });
79
+ query();
80
+ const q2 = setT(query, 1000);
81
+
82
+ setT(() => {
83
+ clearT(q2);
84
+ try {
85
+ mdns.destroy();
86
+ } catch {
87
+ /* egal */
88
+ }
89
+ const list = [];
90
+ for (const inst of Object.values(instances)) {
91
+ const ip = inst.host ? hostToIp[inst.host] : null;
92
+ if (ip) {
93
+ list.push({ ip, host: inst.host, port: inst.port, techType: inst.techType, txt: inst.txt });
94
+ }
95
+ }
96
+ log(`mDNS: ${list.length} Miele-Gerät(e) gefunden`);
97
+ resolve(list);
98
+ }, timeoutMs);
99
+ });
100
+ }
101
+
102
+ module.exports = { discover, SERVICE };
package/lib/dop2.js ADDED
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Minimaler DOP2-Binärparser (portiert aus akappner/MieleRESTServer MieleDop2.py).
5
+ * Dekodiert einen Leaf in eine Map { feldindex(1-based) -> { type, value } }.
6
+ * Struct-Werte sind Arrays von Sub-Attributen; Integer sind Big-Endian.
7
+ */
8
+
9
+ // Feste Byte-Längen je Feldtyp
10
+ const FIXED = { 1: 1, 2: 1, 3: 1, 4: 1, 5: 2, 6: 2, 7: 2, 8: 4, 9: 4, 10: 4, 11: 8, 12: 8, 13: 8, 14: 4, 15: 8 };
11
+ const SIGNED = new Set([3, 6, 9, 12]);
12
+ // Array-Elementtypen: type -> elementByteLength
13
+ const ARRAY_ELEM = { 17: 1, 20: 1, 21: 2, 22: 2, 23: 2, 25: 4, 27: 8 };
14
+
15
+ function readInt(buf, off, len, signed) {
16
+ let v = 0n;
17
+ for (let i = 0; i < len; i++) v = (v << 8n) | BigInt(buf[off + i]);
18
+ if (signed) {
19
+ const bits = BigInt(len * 8);
20
+ if (v >= 1n << (bits - 1n)) v -= 1n << bits;
21
+ }
22
+ return len > 4 ? v : Number(v); // 8-Byte als BigInt, sonst Number
23
+ }
24
+
25
+ /** Dekodiert EIN Feld ab off. Gibt { value, wireLength } zurück. */
26
+ function decodeField(type, buf, off) {
27
+ if (FIXED[type] != null) {
28
+ const len = FIXED[type];
29
+ if (type === 1) return { value: buf[off] === 1, wireLength: 1 };
30
+ if (type === 14) return { value: buf.readFloatBE(off), wireLength: 4 };
31
+ if (type === 15) return { value: buf.readDoubleBE(off), wireLength: 8 };
32
+ return { value: readInt(buf, off, len, SIGNED.has(type)), wireLength: len };
33
+ }
34
+ if (type === 16) return decodeStruct(buf, off);
35
+ if (type === 18 || type === 32) {
36
+ const strLen = (buf[off] << 8) + buf[off + 1];
37
+ return { value: buf.subarray(off + 2, off + 2 + strLen), wireLength: 2 + strLen };
38
+ }
39
+ if (ARRAY_ELEM[type] != null) {
40
+ const n = (buf[off] << 8) + buf[off + 1];
41
+ const el = ARRAY_ELEM[type];
42
+ const vals = [];
43
+ for (let i = 0; i < n; i++) {
44
+ const p = off + 2 + i * el;
45
+ vals.push(type === 17 ? buf[p] === 1 : readInt(buf, p, el, SIGNED.has(type - 10)));
46
+ }
47
+ return { value: vals, wireLength: 2 + n * el };
48
+ }
49
+ throw new Error(`Unbekannter DOP2-Feldtyp ${type} @${off}`);
50
+ }
51
+
52
+ /** Struct: Header [byte0, numberOfFields, byte2], dann Felder [id, type, value] mit 0x00-Padding. */
53
+ function decodeStruct(buf, off) {
54
+ const numberOfFields = buf[off + 1];
55
+ if (numberOfFields === 0 || numberOfFields > 160) throw new Error(`Struct unplausibel (${numberOfFields})`);
56
+ let p = off + 3;
57
+ let fieldLength = 0;
58
+ const fields = [];
59
+ while (true) {
60
+ const dataType = buf[p + 1];
61
+ const f = decodeField(dataType, buf, p + 2);
62
+ const cur = f.wireLength + 2;
63
+ fieldLength += cur;
64
+ fields.push({ type: dataType, value: f.value });
65
+ if (fields.length === numberOfFields) break;
66
+ p += cur;
67
+ if (buf[p] === 0x00) { p += 1; fieldLength += 1; }
68
+ }
69
+ return { value: fields, wireLength: fieldLength + 3 };
70
+ }
71
+
72
+ /**
73
+ * Parst einen ganzen DOP2-Leaf (entschlüsselte Bytes) → { unit, attr, fields{idx:{type,value}} }.
74
+ */
75
+ function parseLeaf(buf) {
76
+ const payloadLength = (buf[0] << 8) + buf[1];
77
+ const unit = (buf[2] << 8) + buf[3];
78
+ const attr = (buf[4] << 8) + buf[5];
79
+ const padding = buf.length - payloadLength - 2;
80
+ const payload = buf.subarray(8, buf.length - (padding > 0 ? padding : 0));
81
+ const fields = {};
82
+ if (payload.length === 0) return { unit, attr, fields };
83
+ const numberOfFields = payload[3] + (payload[4] << 8);
84
+ let rem = payload.subarray(5);
85
+ let count = 0;
86
+ while (count < numberOfFields && rem.length >= 2) {
87
+ const idx = rem[0];
88
+ const type = rem[1];
89
+ const f = decodeField(type, rem, 2);
90
+ fields[idx] = { type, value: f.value };
91
+ count++;
92
+ rem = rem.subarray(3 + f.wireLength);
93
+ }
94
+ return { unit, attr, fields };
95
+ }
96
+
97
+ /** Wert eines "interpretierten" Feldes (Struct [mask, value, interpretation]) → mittleres Sub-Feld. */
98
+ function interpValue(fields, idx) {
99
+ const f = fields[idx];
100
+ if (!f || !Array.isArray(f.value) || f.value.length < 2) return null;
101
+ return f.value[1].value;
102
+ }
103
+
104
+ module.exports = { parseLeaf, interpValue, decodeField };
package/lib/enroll.js ADDED
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SuperVision-Enrollment: bringt ein Gerät dazu, Zustandsänderungen aktiv an
5
+ * unseren Push-Listener zu senden.
6
+ *
7
+ * Ablauf pro Gerät (alles MieleH256-signiert, Body AES-verschlüsselt):
8
+ * 1. PUT /Devices/<route>/SuperVision/<ourFab> {"Show":true,"Signal":true}
9
+ * → registriert uns als SuperVision-Peer (manche Basisgeräte: 404 → übersprungen).
10
+ * 2. POST /Subscriptions je Ressource
11
+ * Body {"Resource":"/Devices/<route><res>","Callback":"http://<ip>:<port>/Devices/<ourFab>/SuperVision/<route><res>"}
12
+ * → das Gerät POSTet künftig an unsere Callback-URL.
13
+ *
14
+ * Subscriptions laufen ab → periodisch erneuern (renew).
15
+ */
16
+
17
+ const PUSH_RESOURCES = ['/State/', '/State/Light/', '/State/Status/', '/Ident/'];
18
+
19
+ /**
20
+ * @param {import('./api').MieleDeviceApi} api
21
+ * @param {object} opts { route, ourFab, hostIp, port, log }
22
+ * @returns {Promise<{supervisionOk:boolean, subscriptions:string[]}>}
23
+ */
24
+ async function enrollDevice(api, opts) {
25
+ const { route, ourFab, hostIp, port, log = console } = opts;
26
+ let supervisionOk = false;
27
+
28
+ // 1. SuperVision-Peer-Eintrag
29
+ try {
30
+ const body = JSON.stringify({ Show: true, Signal: true });
31
+ const status = await api.put(`Devices/${route}/SuperVision/${ourFab}`, body);
32
+ if (status >= 200 && status < 300) {
33
+ supervisionOk = true;
34
+ log.debug(`[${route}] SuperVision Show/Signal akzeptiert (${status})`);
35
+ } else if (status === 404) {
36
+ log.debug(`[${route}] kein /SuperVision-Endpunkt – nur Subscriptions`);
37
+ } else {
38
+ log.debug(`[${route}] SuperVision PUT → ${status}`);
39
+ }
40
+ } catch (e) {
41
+ log.debug(`[${route}] SuperVision PUT Fehler: ${e.message}`);
42
+ }
43
+
44
+ // 2. Subscriptions je Ressource
45
+ const subscriptions = [];
46
+ for (const res of PUSH_RESOURCES) {
47
+ const body = JSON.stringify({
48
+ Resource: `/Devices/${route}${res}`,
49
+ Callback: `http://${hostIp}:${port}/Devices/${ourFab}/SuperVision/${route}${res}`,
50
+ });
51
+ try {
52
+ const { status } = await api.post('Subscriptions', body);
53
+ if (status >= 200 && status < 300) {
54
+ subscriptions.push(res);
55
+ log.debug(`[${route}] Subscription ${res} → ${status}`);
56
+ } else {
57
+ log.debug(`[${route}] Subscription ${res} → ${status}`);
58
+ }
59
+ } catch (e) {
60
+ log.debug(`[${route}] Subscription ${res} Fehler: ${e.message}`);
61
+ }
62
+ }
63
+
64
+ return { supervisionOk, subscriptions };
65
+ }
66
+
67
+ module.exports = { enrollDevice, PUSH_RESOURCES };