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/LICENSE +21 -0
- package/README.md +161 -0
- package/admin/i18n/de/translations.json +44 -0
- package/admin/i18n/en/translations.json +44 -0
- package/admin/i18n/es/translations.json +44 -0
- package/admin/i18n/fr/translations.json +44 -0
- package/admin/i18n/it/translations.json +44 -0
- package/admin/i18n/nl/translations.json +44 -0
- package/admin/i18n/pl/translations.json +44 -0
- package/admin/i18n/pt/translations.json +44 -0
- package/admin/i18n/ru/translations.json +44 -0
- package/admin/i18n/uk/translations.json +44 -0
- package/admin/i18n/zh-cn/translations.json +44 -0
- package/admin/jsonConfig.json +519 -0
- package/admin/miele-local.png +0 -0
- package/io-package.json +181 -0
- package/lib/api.js +106 -0
- package/lib/cloud.js +146 -0
- package/lib/crypto.js +161 -0
- package/lib/discovery.js +102 -0
- package/lib/dop2.js +104 -0
- package/lib/enroll.js +67 -0
- package/lib/enums.js +836 -0
- package/lib/enums_de.js +237 -0
- package/lib/objects.js +285 -0
- package/lib/push.js +223 -0
- package/main.js +576 -0
- package/package.json +52 -0
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 };
|