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/lib/push.js ADDED
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SuperVision-Push-Listener.
5
+ *
6
+ * Der Adapter gibt sich per mDNS als Haushalts-Peer aus. Cloud-gekoppelte
7
+ * Miele-Geräte melden ihn dann als SuperVision-Empfänger an und POSTen ihre
8
+ * Zustandsänderungen verschlüsselt an unseren HTTP-Server. Wir entschlüsseln
9
+ * mit dem GroupKey (IV = Authorization-Signatur) und liefern das State-Objekt
10
+ * per Callback zurück.
11
+ *
12
+ * Der Handshake (die Geräte fragen vorab unser /Devices, /Ident, /State,
13
+ * /SuperVision ab) wird mit signierten, verschlüsselten Antworten bedient.
14
+ *
15
+ * Hinweis: Push ist eine Best-Effort-Ergänzung; das Polling bleibt der
16
+ * zuverlässige Fallback.
17
+ */
18
+
19
+ const http = require('node:http');
20
+ const os = require('node:os');
21
+ const multicastDns = require('multicast-dns');
22
+
23
+ const SERVICE = '_mieleathome._tcp.local';
24
+ const CONTENT_TYPE = 'application/vnd.miele.v1+json; charset=utf-8';
25
+ // OUI-EUI64-Präfix von Miele für den synthetischen Hostnamen
26
+ const MIELE_OUI_EUI64 = '001D63FFFE';
27
+ const OUR_FAB = '000000000001'; // synthetische Fab-Nummer unseres Peers
28
+
29
+ function httpDate() {
30
+ return new Date().toUTCString();
31
+ }
32
+
33
+ function detectLanIp(_targetIp) {
34
+ // Erste nicht-interne IPv4 als Fallback; genauer wäre routing-basiert.
35
+ const ifaces = os.networkInterfaces();
36
+ for (const name of Object.keys(ifaces)) {
37
+ for (const i of ifaces[name]) {
38
+ if (i.family === 'IPv4' && !i.internal) return i.address;
39
+ }
40
+ }
41
+ return '0.0.0.0';
42
+ }
43
+
44
+ function syntheticHostname(fab) {
45
+ const digits = String(fab).replace(/\D/g, '');
46
+ const fabInt = parseInt(digits.slice(-8) || '0', 10);
47
+ const bottom24 = (fabInt & 0xffffff).toString(16).toUpperCase().padStart(6, '0');
48
+ return `Miele-${MIELE_OUI_EUI64}${bottom24}.local`;
49
+ }
50
+
51
+ class MielePushListener {
52
+ /**
53
+ * @param {object} opts
54
+ * @param {number} opts.port
55
+ * @param {import('./crypto').MieleCrypto} opts.crypto
56
+ * @param {object} opts.log
57
+ * @param {(ev:{route:string, state:object})=>Promise<void>} opts.onEvent
58
+ * @param {string} [opts.hostIp]
59
+ */
60
+ constructor(opts) {
61
+ this.port = opts.port || 18082;
62
+ this.mc = opts.crypto;
63
+ this.log = opts.log || console;
64
+ this.adapter = opts.adapter || null;
65
+ this.onEvent = opts.onEvent;
66
+ this.hostIp = opts.hostIp || detectLanIp('192.168.0.1');
67
+ this.ourFab = OUR_FAB;
68
+ this.hostname = syntheticHostname(OUR_FAB);
69
+ this.instance = `ioBroker ${OUR_FAB}.${SERVICE}`;
70
+ this.server = null;
71
+ this.mdns = null;
72
+ }
73
+
74
+ start() {
75
+ this._startHttp();
76
+ this._advertiseMdns();
77
+ }
78
+
79
+ async stop() {
80
+ try {
81
+ if (this.mdns) this.mdns.destroy();
82
+ } catch {
83
+ /* egal */
84
+ }
85
+ await new Promise(res => {
86
+ if (this.server) this.server.close(() => res());
87
+ else res();
88
+ });
89
+ }
90
+
91
+ _startHttp() {
92
+ this.server = http.createServer((req, res) => this._handle(req, res));
93
+ this.server.on('error', e => this.log.warn(`Push-HTTP-Server-Fehler: ${e.message}`));
94
+ this.server.listen(this.port, '0.0.0.0');
95
+ }
96
+
97
+ _parseAuth(headers) {
98
+ const auth = headers['authorization'] || '';
99
+ if (!auth.startsWith('MieleH256 ')) return { gid: '', sig: '' };
100
+ const rest = auth.slice('MieleH256 '.length);
101
+ const c = rest.indexOf(':');
102
+ if (c < 0) return { gid: '', sig: '' };
103
+ return { gid: rest.slice(0, c), sig: rest.slice(c + 1) };
104
+ }
105
+
106
+ _signedResponse(res, bodyText) {
107
+ const date = httpDate();
108
+ const { body, signature } = this.mc.signResponse(200, date, bodyText);
109
+ res.writeHead(200, {
110
+ 'Content-Type': CONTENT_TYPE,
111
+ 'Content-Length': body.length,
112
+ Date: date,
113
+ 'X-Signature': signature,
114
+ Connection: 'close',
115
+ });
116
+ res.end(body);
117
+ }
118
+
119
+ _handle(req, res) {
120
+ const chunks = [];
121
+ req.on('data', c => chunks.push(c));
122
+ req.on('end', () => {
123
+ const body = Buffer.concat(chunks);
124
+ const path = req.url.split('?')[0];
125
+ try {
126
+ // SuperVision-Push: POST /Devices/<ourfab>/SuperVision/<peerfab>/State/…
127
+ const m = path.match(/^\/Devices\/\d+\/SuperVision\/(\d+)\//);
128
+ if (req.method === 'POST' && m) {
129
+ return this._handlePush(req, res, m[1], body);
130
+ }
131
+ // Subscription-Anlage durch Peer
132
+ if (req.method === 'POST' && /^\/Subscriptions\/?$/.test(path)) {
133
+ res.writeHead(200, { 'Content-Length': 0, Connection: 'close' });
134
+ return res.end();
135
+ }
136
+ // Handshake-GETs → signierte, minimale Antworten
137
+ if (req.method === 'GET' && /^\/Devices\/?$/.test(path)) {
138
+ return this._signedResponse(res, JSON.stringify({ [OUR_FAB]: { href: `${OUR_FAB}/` } }));
139
+ }
140
+ if (req.method === 'GET' && /^\/Devices\/\d+\/Ident\/?$/.test(path)) {
141
+ return this._signedResponse(res, JSON.stringify({ DeviceType: 2, DeviceName: 'ioBroker', ProtocolVersion: 4 }));
142
+ }
143
+ if (req.method === 'GET' && /^\/Devices\/\d+\/(State|SuperVision)/.test(path)) {
144
+ return this._signedResponse(res, JSON.stringify({}));
145
+ }
146
+ // Catch-all → 204, damit Peers nicht endlos retryen
147
+ res.writeHead(204, { Connection: 'close' });
148
+ res.end();
149
+ } catch (e) {
150
+ this.log.debug(`Push-Handler-Fehler (${path}): ${e.message}`);
151
+ try {
152
+ res.writeHead(204);
153
+ res.end();
154
+ } catch {
155
+ /* egal */
156
+ }
157
+ }
158
+ });
159
+ }
160
+
161
+ _handlePush(req, res, peerFab, body) {
162
+ const { gid, sig } = this._parseAuth(req.headers);
163
+ let state = null;
164
+ if (gid && sig && body.length && gid.toUpperCase() === this.mc.groupId) {
165
+ try {
166
+ const plain = this.mc.decryptWithSignature(sig, body);
167
+ const txt = plain.toString('utf8').replace(/[\x00\x20\r\n\t]+$/g, '');
168
+ const parsed = JSON.parse(txt);
169
+ // Struktur: { Host, Resource, Content: {…State…} }
170
+ state = parsed.Content && typeof parsed.Content === 'object' ? parsed.Content : parsed;
171
+ } catch (e) {
172
+ this.log.debug(`Push-Entschlüsselung von ${req.socket.remoteAddress} fehlgeschlagen: ${e.message}`);
173
+ }
174
+ }
175
+ res.writeHead(204, { Connection: 'close' });
176
+ res.end();
177
+ if (state && this.onEvent) {
178
+ this.onEvent({ route: peerFab, state }).catch(e => this.log.debug(`Push-Callback-Fehler: ${e.message}`));
179
+ }
180
+ }
181
+
182
+ _advertiseMdns() {
183
+ this.mdns = multicastDns();
184
+ const props = {
185
+ txtvers: '1',
186
+ group: this.mc.groupId,
187
+ path: '/',
188
+ security: '1',
189
+ pairing: 'false',
190
+ devicetype: '2',
191
+ con: '1',
192
+ subtype: '0',
193
+ s: '0',
194
+ };
195
+ const txtBuffers = Object.entries(props).map(([k, v]) => Buffer.from(`${k}=${v}`));
196
+
197
+ const respond = () => {
198
+ this.mdns.respond({
199
+ answers: [
200
+ { name: SERVICE, type: 'PTR', ttl: 120, data: this.instance },
201
+ { name: this.instance, type: 'SRV', ttl: 120, data: { port: this.port, target: this.hostname } },
202
+ { name: this.instance, type: 'TXT', ttl: 120, data: txtBuffers },
203
+ { name: this.hostname, type: 'A', ttl: 120, data: this.hostIp },
204
+ ],
205
+ });
206
+ };
207
+
208
+ this.mdns.on('query', query => {
209
+ for (const q of query.questions || []) {
210
+ if ((q.name === SERVICE && q.type === 'PTR') || q.name === this.instance || q.name === this.hostname) {
211
+ respond();
212
+ return;
213
+ }
214
+ }
215
+ });
216
+ // initial ankündigen
217
+ respond();
218
+ // Timer über den Adapter (ioBroker-Vorgabe), Fallback auf global für Standalone-Betrieb
219
+ (this.adapter ? this.adapter.setTimeout.bind(this.adapter) : global.setTimeout)(respond, 1000);
220
+ }
221
+ }
222
+
223
+ module.exports = { MielePushListener, syntheticHostname, OUR_FAB };