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,181 @@
1
+ {
2
+ "common": {
3
+ "name": "miele-local",
4
+ "version": "0.2.0",
5
+ "titleLang": {
6
+ "en": "Miele@local",
7
+ "de": "Miele@local",
8
+ "ru": "Miele@local",
9
+ "pt": "Miele@local",
10
+ "nl": "Miele@local",
11
+ "fr": "Miele@local",
12
+ "it": "Miele@local",
13
+ "es": "Miele@local",
14
+ "pl": "Miele@local",
15
+ "uk": "Miele@local",
16
+ "zh-cn": "Miele@local"
17
+ },
18
+ "desc": {
19
+ "en": "Connect modern Miele@Home appliances locally without the internet.",
20
+ "de": "Verbindet moderne Miele@Home-Geräte lokal ohne Internet.",
21
+ "ru": "Локальное подключение современных приборов Miele@Home без интернета.",
22
+ "pt": "Liga eletrodomésticos Miele@Home modernos localmente, sem internet.",
23
+ "nl": "Verbindt moderne Miele@Home-apparaten lokaal zonder internet.",
24
+ "fr": "Connecte localement les appareils Miele@Home modernes, sans internet.",
25
+ "it": "Collega localmente i moderni elettrodomestici Miele@Home senza internet.",
26
+ "es": "Conecta electrodomésticos Miele@Home modernos localmente, sin internet.",
27
+ "pl": "Łączy nowoczesne urządzenia Miele@Home lokalnie, bez internetu.",
28
+ "uk": "Локальне підключення сучасних приладів Miele@Home без інтернету.",
29
+ "zh-cn": "无需互联网即可在本地连接现代 Miele@Home 家电。"
30
+ },
31
+ "authors": [
32
+ "Immanuel"
33
+ ],
34
+ "keywords": [
35
+ "miele",
36
+ "local",
37
+ "dop2",
38
+ "smarthome",
39
+ "washing machine",
40
+ "dishwasher",
41
+ "oven"
42
+ ],
43
+ "licenseInformation": {
44
+ "type": "free",
45
+ "license": "MIT"
46
+ },
47
+ "tier": 3,
48
+ "platform": "Javascript/Node.js",
49
+ "icon": "miele-local.png",
50
+ "enabled": true,
51
+ "extIcon": "https://raw.githubusercontent.com/SmarthomeElektroniker/ioBroker.miele-local/main/admin/miele-local.png",
52
+ "readme": "https://github.com/SmarthomeElektroniker/ioBroker.miele-local/blob/main/README.md",
53
+ "loglevel": "info",
54
+ "news": {
55
+ "0.2.0": {
56
+ "en": "Renamed from miele-lokal to miele-local: English adapter name and title.",
57
+ "de": "Umbenennung von miele-lokal in miele-local: englischer Adaptername und Titel.",
58
+ "ru": "Переименование miele-lokal в miele-local: английское имя и заголовок адаптера.",
59
+ "pt": "Renomeado de miele-lokal para miele-local: nome e título do adaptador em inglês.",
60
+ "nl": "Hernoemd van miele-lokal naar miele-local: Engelse adapternaam en titel.",
61
+ "fr": "Renommé de miele-lokal en miele-local : nom et titre de l'adaptateur en anglais.",
62
+ "it": "Rinominato da miele-lokal a miele-local: nome e titolo adattatore in inglese.",
63
+ "es": "Renombrado de miele-lokal a miele-local: nombre y título del adaptador en inglés.",
64
+ "pl": "Zmiana nazwy z miele-lokal na miele-local: angielska nazwa i tytuł adaptera.",
65
+ "uk": "Перейменовано з miele-lokal на miele-local: англійська назва та заголовок адаптера.",
66
+ "zh-cn": "从 miele-lokal 重命名为 miele-local:英文适配器名称和标题。"
67
+ },
68
+ "0.1.1": {
69
+ "en": "Released via GitHub Actions with npm provenance (trusted publishing). No functional changes.",
70
+ "de": "Veröffentlichung über GitHub Actions mit npm-Provenance (Trusted Publishing). Keine funktionalen Änderungen.",
71
+ "ru": "Выпуск через GitHub Actions с npm-provenance (trusted publishing). Функциональных изменений нет.",
72
+ "pt": "Publicado via GitHub Actions com proveniência npm (trusted publishing). Sem alterações funcionais.",
73
+ "nl": "Uitgebracht via GitHub Actions met npm-provenance (trusted publishing). Geen functionele wijzigingen.",
74
+ "fr": "Publié via GitHub Actions avec la provenance npm (trusted publishing). Aucun changement fonctionnel.",
75
+ "it": "Pubblicato tramite GitHub Actions con provenance npm (trusted publishing). Nessuna modifica funzionale.",
76
+ "es": "Publicado mediante GitHub Actions con procedencia npm (trusted publishing). Sin cambios funcionales.",
77
+ "pl": "Opublikowano przez GitHub Actions z npm provenance (trusted publishing). Bez zmian funkcjonalnych.",
78
+ "uk": "Випущено через GitHub Actions з npm-provenance (trusted publishing). Функціональних змін немає.",
79
+ "zh-cn": "通过 GitHub Actions 发布并附带 npm provenance(可信发布)。无功能变更。"
80
+ },
81
+ "0.1.0": {
82
+ "en": "Initial release: login/GroupKey retrieval, mDNS discovery, state polling with enum decoding, optional SuperVision push, optional control.",
83
+ "de": "Erste Version: Login/GroupKey-Ermittlung, mDNS-Discovery, State-Polling mit Enum-Dekodierung, optionaler SuperVision-Push, optionale Steuerung.",
84
+ "ru": "Первый выпуск: получение логина/GroupKey, mDNS-обнаружение, опрос состояний с декодированием enum, опциональный SuperVision-push, опциональное управление.",
85
+ "pt": "Primeira versão: obtenção de login/GroupKey, deteção mDNS, sondagem de estados com descodificação de enums, push SuperVision opcional, controlo opcional.",
86
+ "nl": "Eerste versie: login/GroupKey ophalen, mDNS-detectie, statuspolling met enum-decodering, optionele SuperVision-push, optionele besturing.",
87
+ "fr": "Première version : récupération login/GroupKey, découverte mDNS, interrogation des états avec décodage enum, push SuperVision optionnel, commande optionnelle.",
88
+ "it": "Prima versione: recupero login/GroupKey, rilevamento mDNS, polling degli stati con decodifica enum, push SuperVision opzionale, controllo opzionale.",
89
+ "es": "Primera versión: obtención de login/GroupKey, detección mDNS, sondeo de estados con decodificación de enums, push SuperVision opcional, control opcional.",
90
+ "pl": "Pierwsza wersja: pobieranie login/GroupKey, wykrywanie mDNS, odpytywanie stanów z dekodowaniem enum, opcjonalny push SuperVision, opcjonalne sterowanie.",
91
+ "uk": "Перший випуск: отримання логіна/GroupKey, виявлення mDNS, опитування станів з декодуванням enum, опціональний SuperVision-push, опціональне керування.",
92
+ "zh-cn": "首个版本:登录/GroupKey 获取、mDNS 发现、带枚举解码的状态轮询、可选 SuperVision 推送、可选控制。"
93
+ }
94
+ },
95
+ "mode": "daemon",
96
+ "type": "household",
97
+ "compact": true,
98
+ "connectionType": "local",
99
+ "dataSource": "push",
100
+ "adminUI": {
101
+ "config": "json"
102
+ },
103
+ "dependencies": [
104
+ {
105
+ "js-controller": ">=6.0.11"
106
+ }
107
+ ],
108
+ "globalDependencies": [
109
+ {
110
+ "admin": ">=7.6.20"
111
+ }
112
+ ],
113
+ "messagebox": true,
114
+ "supportCustoms": false
115
+ },
116
+ "encryptedNative": [
117
+ "groupKey",
118
+ "refreshToken"
119
+ ],
120
+ "protectedNative": [
121
+ "groupKey",
122
+ "refreshToken",
123
+ "groupId"
124
+ ],
125
+ "native": {
126
+ "country": "de",
127
+ "region": "EU",
128
+ "groupId": "",
129
+ "groupKey": "",
130
+ "refreshToken": "",
131
+ "autoDiscover": true,
132
+ "devices": [],
133
+ "pollInterval": 30,
134
+ "activePollInterval": 30,
135
+ "usePush": false,
136
+ "pushPort": 18082,
137
+ "allowControl": false,
138
+ "germanNames": true,
139
+ "ecoFeedback": true,
140
+ "ecoInterval": 60,
141
+ "secondsTime": true,
142
+ "secondsInterval": 30
143
+ },
144
+ "objects": [],
145
+ "instanceObjects": [
146
+ {
147
+ "_id": "info",
148
+ "type": "channel",
149
+ "common": {
150
+ "name": "Information"
151
+ },
152
+ "native": {}
153
+ },
154
+ {
155
+ "_id": "info.connection",
156
+ "type": "state",
157
+ "common": {
158
+ "role": "indicator.connected",
159
+ "name": "Device or service connected",
160
+ "type": "boolean",
161
+ "read": true,
162
+ "write": false,
163
+ "def": false
164
+ },
165
+ "native": {}
166
+ },
167
+ {
168
+ "_id": "info.discoveredDevices",
169
+ "type": "state",
170
+ "common": {
171
+ "role": "json",
172
+ "name": "Discovered devices (mDNS)",
173
+ "type": "string",
174
+ "read": true,
175
+ "write": false,
176
+ "def": "[]"
177
+ },
178
+ "native": {}
179
+ }
180
+ ]
181
+ }
package/lib/api.js ADDED
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+ const { MieleCrypto } = require('./crypto');
5
+
6
+ /**
7
+ * Lokaler Miele-Geräte-Client: signierte GET-Reads (entschlüsselt JSON) und
8
+ * signierte PUT-Writes (verschlüsselter Body) über HTTP Port 80.
9
+ */
10
+ class MieleDeviceApi {
11
+ /**
12
+ * @param {string} host Geräte-IP
13
+ * @param {MieleCrypto} mc Krypto-Provider (GroupID/GroupKey)
14
+ * @param {object} [opts] { timeout }
15
+ */
16
+ constructor(host, mc, opts = {}) {
17
+ this.host = host;
18
+ this.mc = mc;
19
+ this.timeout = opts.timeout || 8000;
20
+ }
21
+
22
+ _request(method, resource, bodyPlain) {
23
+ return new Promise((resolve, reject) => {
24
+ const bodyMethod = method === 'PUT' || method === 'POST';
25
+ let body = Buffer.alloc(0);
26
+ let signature;
27
+ if (bodyMethod && bodyPlain != null) {
28
+ body = MieleCrypto.padBody(bodyPlain);
29
+ }
30
+ const { headers, signature: sig } = this.mc.headers(method, this.host, resource, body);
31
+ signature = sig;
32
+
33
+ let sendBuf = null;
34
+ if (bodyMethod && body.length > 0) {
35
+ sendBuf = this.mc.encryptBody(body, signature);
36
+ headers['Content-Length'] = sendBuf.length;
37
+ }
38
+
39
+ const req = http.request(
40
+ { host: this.host, port: 80, method, path: `/${resource}`, headers, timeout: this.timeout },
41
+ res => {
42
+ const chunks = [];
43
+ res.on('data', c => chunks.push(c));
44
+ res.on('end', () => {
45
+ resolve({
46
+ status: res.statusCode,
47
+ headers: res.headers,
48
+ body: Buffer.concat(chunks),
49
+ });
50
+ });
51
+ },
52
+ );
53
+ req.on('error', reject);
54
+ req.on('timeout', () => req.destroy(new Error(`Timeout ${this.host}/${resource}`)));
55
+ if (sendBuf) req.write(sendBuf);
56
+ req.end();
57
+ });
58
+ }
59
+
60
+ /** Signierter GET, entschlüsselt & als JSON geparst. */
61
+ async get(resource) {
62
+ const res = await this._request('GET', resource);
63
+ if (res.status === 204) return null;
64
+ if (res.status !== 200) {
65
+ const err = new Error(`GET /${resource} → HTTP ${res.status}`);
66
+ err.status = res.status;
67
+ throw err;
68
+ }
69
+ const xsig = res.headers['x-signature'];
70
+ if (!xsig) throw new Error(`GET /${resource}: kein X-Signature-Header`);
71
+ const plain = this.mc.decryptResponse(xsig, res.body);
72
+ const txt = plain.toString('utf8').replace(/[\x00\x20]+$/g, '');
73
+ try {
74
+ return JSON.parse(txt);
75
+ } catch (e) {
76
+ const err = new Error(`GET /${resource}: JSON-Fehler (${e.message})`);
77
+ err.raw = txt;
78
+ throw err;
79
+ }
80
+ }
81
+
82
+ /** Signierter PUT mit verschlüsseltem Body. Gibt Statuscode zurück. */
83
+ async put(resource, bodyPlain) {
84
+ const res = await this._request('PUT', resource, bodyPlain);
85
+ return res.status;
86
+ }
87
+
88
+ /** Signierter POST mit verschlüsseltem Body. Gibt {status, location} zurück. */
89
+ async post(resource, bodyPlain) {
90
+ const res = await this._request('POST', resource, bodyPlain);
91
+ return { status: res.status, location: res.headers['location'] || '' };
92
+ }
93
+
94
+ // --- Convenience ---
95
+ getIdent(route) {
96
+ return this.get(`Devices/${route}/Ident`);
97
+ }
98
+ getState(route) {
99
+ return this.get(`Devices/${route}/State`);
100
+ }
101
+ readDop2(route, unit, attr, idx1 = 0, idx2 = 0) {
102
+ return this._request('GET', `Devices/${route}/DOP2/${unit}/${attr}?idx1=${idx1}&idx2=${idx2}`);
103
+ }
104
+ }
105
+
106
+ module.exports = { MieleDeviceApi };
package/lib/cloud.js ADDED
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Miele MAP/Gigya-OAuth (PKCE) + Haushalts-GroupKey-Abruf.
5
+ *
6
+ * Ablauf ohne lokale Neu-Provisionierung (die Miele-App bleibt gekoppelt):
7
+ * 1) build_authorize_url() → Nutzer öffnet URL im Browser, meldet sich an.
8
+ * 2) Browser wird auf miele://oauth2-code/?code=...&state=... umgeleitet
9
+ * (blockiert) → Nutzer kopiert diese URL zurück.
10
+ * 3) exchangeCode() tauscht code → {access_token, refresh_token}.
11
+ * 4) fetchGroupKey() holt {groupId, groupKey, devices} von
12
+ * rest-<region>.domestic.miele-iot.com/V2/GroupKeyId/.
13
+ */
14
+
15
+ const crypto = require('node:crypto');
16
+ const https = require('node:https');
17
+ const { URL, URLSearchParams } = require('node:url');
18
+
19
+ // Länderspezifische MAP-Consumer-client_ids (aus offizieller Android-APK).
20
+ const CONSUMER_CLIENT_IDS = {
21
+ at: 'wNv9HJ3ZcFKH4bxvz0LExQuw', ch: 'V52nWiniHyVotglJKplSXnX8',
22
+ cz: 'npoAzuJP6okjvJ0NqUq9i5Rv', de: 'UJgKOxacIul2BcPJAzrQE6p0',
23
+ dk: 'xWgykqRQSa9THqOXWfzZbxsH', es: 'D0Q4NPBR9dwP2EjX4E0_CtHE',
24
+ fr: 'SOiiE3R4tSD0VxYYBvB8Pi_J', gb: 'WigtLzKGJE1Wg6yeZUECV8-P',
25
+ hr: 'HD4OUUQYAw_5DtVFSe4-rYzR', hu: '2mm2yscHPGJ4tJCVjd6mp-to',
26
+ it: 'ARQyaYB0ZxLxJ1SJcjJgctuV', nl: '7ItTbQXQ1wthDOue9jvBQ7Iz',
27
+ pl: 'jWbgLScpvIuqjUoYvf1jS-Is', pt: '5ZVD-CuJvpG4YpCO9pQhtrGQ',
28
+ se: '3Mm7m1gD1eU_sUh8yxmShL6S', si: 'UTyhG21RchpI8FPbNeb1vFg1',
29
+ sk: 'pGeafLwcC1_BCLr8DRTCVxSt', us: 'HpsWh2gzgKqRBduPpkZ4Yui9',
30
+ };
31
+ const REDIRECT_URI = 'miele://oauth2-code/';
32
+ const OAUTH_SCOPE = 'openid mcs bpdata zuora'; // `mcs` ist Pflicht für /V2/GroupKeyId/
33
+ const REST_HOST_BY_REGION = {
34
+ EU: 'rest-eu.domestic.miele-iot.com',
35
+ AS: 'rest-as.domestic.miele-iot.com',
36
+ EU2: 'rest-eu2.domestic.miele-iot.com',
37
+ };
38
+
39
+ function b64url(buf) {
40
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
41
+ }
42
+
43
+ function httpsRequest(method, urlStr, { headers = {}, body = null } = {}) {
44
+ return new Promise((resolve, reject) => {
45
+ const u = new URL(urlStr);
46
+ const req = https.request(
47
+ { method, hostname: u.hostname, path: u.pathname + u.search, headers, timeout: 20000 },
48
+ res => {
49
+ const chunks = [];
50
+ res.on('data', c => chunks.push(c));
51
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }));
52
+ },
53
+ );
54
+ req.on('error', reject);
55
+ req.on('timeout', () => req.destroy(new Error('HTTPS Timeout')));
56
+ if (body) req.write(body);
57
+ req.end();
58
+ });
59
+ }
60
+
61
+ /** Autorisierungs-URL + PKCE-Challenge erzeugen. */
62
+ function buildAuthorizeUrl(cc) {
63
+ cc = String(cc).toLowerCase();
64
+ const clientId = CONSUMER_CLIENT_IDS[cc];
65
+ if (!clientId) throw new Error(`Unbekanntes Land "${cc}". Unterstützt: ${Object.keys(CONSUMER_CLIENT_IDS).join(', ')}`);
66
+ const verifier = b64url(crypto.randomBytes(64));
67
+ const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
68
+ const state = b64url(crypto.randomBytes(16));
69
+ const nonce = b64url(crypto.randomBytes(16));
70
+ const params = new URLSearchParams({
71
+ client_id: clientId, response_type: 'code', redirect_uri: REDIRECT_URI,
72
+ scope: OAUTH_SCOPE, state, nonce,
73
+ code_challenge: challenge, code_challenge_method: 'S256',
74
+ });
75
+ return {
76
+ url: `https://prod.map.miele-iot.com/${cc}/authorize?${params}`,
77
+ challenge: { verifier, state, nonce, cc, clientId },
78
+ };
79
+ }
80
+
81
+ /** `code` aus miele://oauth2-code/?code=...&state=... extrahieren (mit state-Prüfung). */
82
+ function parseRedirectUrl(redirectUrl, expectedState) {
83
+ // miele:// ist kein von URL() parsebares Schema in allen Node-Versionen → manuell.
84
+ const q = redirectUrl.includes('?') ? redirectUrl.slice(redirectUrl.indexOf('?') + 1) : '';
85
+ const parsed = new URLSearchParams(q);
86
+ if (parsed.get('error')) throw new Error(`OAuth-Fehler: ${parsed.get('error')} ${parsed.get('error_description') || ''}`);
87
+ const code = parsed.get('code');
88
+ if (!code) throw new Error('Kein "code" in der eingefügten Redirect-URL.');
89
+ const state = parsed.get('state');
90
+ if (expectedState && state !== expectedState) throw new Error('state stimmt nicht überein (CSRF-Schutz) – bitte Login-URL neu erzeugen.');
91
+ return code;
92
+ }
93
+
94
+ /** code → Token. */
95
+ async function exchangeCode(challenge, code) {
96
+ const body = new URLSearchParams({
97
+ grant_type: 'authorization_code', code, client_id: challenge.clientId,
98
+ redirect_uri: REDIRECT_URI, code_verifier: challenge.verifier,
99
+ }).toString();
100
+ const res = await httpsRequest('POST', `https://prod.map.miele-iot.com/${challenge.cc}/token`, {
101
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
102
+ body,
103
+ });
104
+ let tok;
105
+ try { tok = JSON.parse(res.body); } catch { throw new Error(`Nicht-JSON-Token-Antwort: ${res.body.slice(0, 200)}`); }
106
+ if (tok.error) throw new Error(`Token-Endpoint: ${tok.error} ${tok.error_description || ''}`);
107
+ return tok;
108
+ }
109
+
110
+ /** Access-Token erneuern (refresh_token kann rotieren). */
111
+ async function refreshAccessToken(cc, refreshToken) {
112
+ cc = String(cc).toLowerCase();
113
+ const body = new URLSearchParams({
114
+ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CONSUMER_CLIENT_IDS[cc],
115
+ }).toString();
116
+ const res = await httpsRequest('POST', `https://prod.map.miele-iot.com/${cc}/token`, {
117
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
118
+ body,
119
+ });
120
+ const tok = JSON.parse(res.body);
121
+ if (tok.error) throw new Error(`Refresh fehlgeschlagen: ${tok.error} ${tok.error_description || ''}`);
122
+ return tok;
123
+ }
124
+
125
+ /** GET /V2/GroupKeyId/ → { groupId, groupKey, devices }. */
126
+ async function fetchGroupKey(accessToken, region = 'EU') {
127
+ const host = REST_HOST_BY_REGION[String(region).toUpperCase()];
128
+ if (!host) throw new Error(`Unbekannte Region "${region}"`);
129
+ const res = await httpsRequest('GET', `https://${host}/V2/GroupKeyId/`, {
130
+ headers: {
131
+ Authorization: `Bearer ${accessToken}`, Accept: 'application/json',
132
+ 'Accept-Language': 'de-DE', 'User-Agent': 'Miele@mobile 2.3.3 Android',
133
+ },
134
+ });
135
+ if (res.status === 403) throw new Error(`GroupKeyId → 403 (Token ohne mcs-Scope?). ${res.body.slice(0, 150)}`);
136
+ if (res.status !== 200) throw new Error(`GroupKeyId → HTTP ${res.status}: ${res.body.slice(0, 150)}`);
137
+ const groups = JSON.parse(res.body);
138
+ if (!groups || !groups.length) throw new Error('Kein Haushalt zurückgegeben – Konto ohne gekoppelte Geräte?');
139
+ const g = groups[0];
140
+ return { groupId: g.groupId, groupKey: g.groupKey, devices: g.devices || [] };
141
+ }
142
+
143
+ module.exports = {
144
+ CONSUMER_CLIENT_IDS, REDIRECT_URI, OAUTH_SCOPE, REST_HOST_BY_REGION,
145
+ buildAuthorizeUrl, parseRedirectUrl, exchangeCode, refreshAccessToken, fetchGroupKey,
146
+ };
package/lib/crypto.js ADDED
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * MieleH256 – lokale Signierung & Verschlüsselung für Miele@Home-Geräte (DOP2).
5
+ *
6
+ * Verfahren (reverse-engineered, verifiziert gegen WCR860 / G5840 / H2469BP):
7
+ * GroupKey = 64 Byte. AES-Key = erste 32 Byte, HMAC-Key = alle 64 Byte.
8
+ * Signatur = HMAC-SHA256(GroupKey) über
9
+ * "METHOD\nHOST/resource\nContent-Type\nAccept\nDate\n" + Body
10
+ * Header: Authorization: MieleH256 <GroupID>:<SIG_HEX_UPPER>
11
+ * Antwort: AES-256-CBC, Key = erste 32 Byte, IV = erste 16 Byte der
12
+ * Signatur aus dem X-Signature-Antwortheader.
13
+ * PUT-Body: auf 16 Byte gepaddet, AES-256-CBC, IV = erste 16 Byte der
14
+ * Request-Signatur.
15
+ */
16
+
17
+ const crypto = require('node:crypto');
18
+
19
+ const ACCEPT = 'application/vnd.miele.v1+json';
20
+ const CONTENT_TYPE = 'application/vnd.miele.v1+json; charset=utf-8';
21
+ // Das Gerät wertet das Datum nicht aus – fester Wert wie in der App.
22
+ const DATE = 'Thu, 01 Jan 1970 02:09:22 GMT';
23
+
24
+ class MieleCrypto {
25
+ /**
26
+ * @param {string} groupId Hex-String (16 Zeichen / 8 Byte)
27
+ * @param {string} groupKey Hex-String (128 Zeichen / 64 Byte)
28
+ */
29
+ constructor(groupId, groupKey) {
30
+ this.groupId = String(groupId).toUpperCase();
31
+ this.groupKey = Buffer.from(groupKey, 'hex');
32
+ if (this.groupKey.length !== 64) {
33
+ throw new Error(`GroupKey muss 64 Byte sein, ist ${this.groupKey.length}`);
34
+ }
35
+ this.aesKey = this.groupKey.subarray(0, 32);
36
+ }
37
+
38
+ /**
39
+ * HMAC-SHA256-Signatur über den kanonischen String + Body.
40
+ * @param {string} method GET/PUT
41
+ * @param {string} host z.B. "192.168.10.127"
42
+ * @param {string} resource Pfad ohne führenden Slash, z.B. "Devices/000149933556/State"
43
+ * @param {Buffer} [body] Klartext-Body (bei PUT), sonst leer
44
+ * @returns {string} Signatur als Großbuchstaben-Hex
45
+ */
46
+ sign(method, host, resource, body = Buffer.alloc(0)) {
47
+ const header = [method, `${host}/${resource}`, CONTENT_TYPE, ACCEPT, DATE].join('\n') + '\n';
48
+ const payload = Buffer.concat([Buffer.from(header, 'utf8'), body]);
49
+ return crypto.createHmac('sha256', this.groupKey).update(payload).digest('hex').toUpperCase();
50
+ }
51
+
52
+ /** Header-Objekt inkl. Authorization für einen Request. */
53
+ headers(method, host, resource, body) {
54
+ const signature = this.sign(method, host, resource, body);
55
+ return {
56
+ headers: {
57
+ 'Content-Type': CONTENT_TYPE,
58
+ Host: host,
59
+ 'User-Agent': 'Miele@mobile 2.3.3 iOS',
60
+ Authorization: `MieleH256 ${this.groupId}:${signature}`,
61
+ Date: DATE,
62
+ Accept: ACCEPT,
63
+ },
64
+ signature,
65
+ };
66
+ }
67
+
68
+ /** IV = erste 16 Byte einer Hex-Signatur. */
69
+ static ivFromSignature(sigHex) {
70
+ return Buffer.from(sigHex, 'hex').subarray(0, 16);
71
+ }
72
+
73
+ /** IV aus dem X-Signature-Antwortheader ("MieleH256 gid:sig"). */
74
+ static ivFromAuthHeader(authHeader) {
75
+ const sig = authHeader.slice('MieleH256 '.length).split(':')[1];
76
+ return MieleCrypto.ivFromSignature(sig);
77
+ }
78
+
79
+ /** Antwortkörper entschlüsseln. */
80
+ decryptResponse(xSignatureHeader, cipherBuf) {
81
+ const iv = MieleCrypto.ivFromAuthHeader(xSignatureHeader);
82
+ const decipher = crypto.createDecipheriv('aes-256-cbc', this.aesKey, iv);
83
+ decipher.setAutoPadding(false);
84
+ return Buffer.concat([decipher.update(cipherBuf), decipher.final()]);
85
+ }
86
+
87
+ /** Eingehenden Push-Body entschlüsseln (IV = erste 16 Byte der Authorization-Signatur). */
88
+ decryptWithSignature(sigHex, cipherBuf) {
89
+ if (sigHex.length % 2) sigHex = '0' + sigHex;
90
+ const iv = MieleCrypto.ivFromSignature(sigHex);
91
+ const decipher = crypto.createDecipheriv('aes-256-cbc', this.aesKey, iv);
92
+ decipher.setAutoPadding(false);
93
+ return Buffer.concat([decipher.update(cipherBuf), decipher.final()]);
94
+ }
95
+
96
+ /**
97
+ * Peer-Antwort signieren & verschlüsseln (für den SuperVision-Handshake).
98
+ * canonical = "status\nContent-Type\nDate\n" + gepaddeter Body.
99
+ * @returns {{ body: Buffer, signature: string }}
100
+ */
101
+ signResponse(status, date, bodyPlain) {
102
+ const bodyBuf = MieleCrypto.padResponseBody(Buffer.isBuffer(bodyPlain) ? bodyPlain : Buffer.from(bodyPlain, 'utf8'));
103
+ const canonical = Buffer.concat([Buffer.from(`${status}\n${CONTENT_TYPE}\n${date}\n`, 'utf8'), bodyBuf]);
104
+ const sig = crypto.createHmac('sha256', this.groupKey).update(canonical).digest();
105
+ const iv = sig.subarray(0, 16);
106
+ let body = Buffer.alloc(0);
107
+ if (bodyBuf.length) {
108
+ const cipher = crypto.createCipheriv('aes-256-cbc', this.aesKey, iv);
109
+ cipher.setAutoPadding(false);
110
+ body = Buffer.concat([cipher.update(bodyBuf), cipher.final()]);
111
+ }
112
+ return { body, signature: `MieleH256 ${this.groupId}:${sig.toString('hex').toUpperCase()}` };
113
+ }
114
+
115
+ /** Response-Padding (JSON auf min. 64 Byte, sonst auf 16 ausrichten). */
116
+ static padResponseBody(buf) {
117
+ if (!buf.length) return buf;
118
+ const isJson = buf[0] === 0x7b && buf[buf.length - 1] === 0x7d; // { … }
119
+ if (isJson && buf.length < 64) {
120
+ return Buffer.concat([buf.subarray(0, buf.length - 1), Buffer.alloc(64 - buf.length, 0x20), Buffer.from('}')]);
121
+ }
122
+ const rem = buf.length % 16;
123
+ if (rem === 0 && buf.length >= 64) return buf;
124
+ const needed = Math.max(64 - buf.length, 0) || (16 - rem);
125
+ return Buffer.concat([buf, Buffer.alloc(needed, 0x20)]);
126
+ }
127
+
128
+ /**
129
+ * Klartext-Body für PUT/POST padden.
130
+ * JSON-Bodys ({ … }) werden auf mind. 64 Byte und 16-Byte-Ausrichtung gebracht,
131
+ * indem Leerzeichen VOR dem schließenden '}' eingefügt werden (bleibt gültiges JSON).
132
+ * Andere Bodys werden nur auf die nächste 16-Byte-Grenze aufgefüllt.
133
+ */
134
+ static padBody(plain) {
135
+ let buf = Buffer.isBuffer(plain) ? plain : Buffer.from(plain, 'utf8');
136
+ if (buf.length === 0) return buf;
137
+ const isJson = buf[0] === 0x7b && buf[buf.length - 1] === 0x7d; // { … }
138
+ if (isJson) {
139
+ let target = Math.max(64, buf.length);
140
+ if (target % 16) target += 16 - (target % 16);
141
+ const spaces = target - buf.length;
142
+ if (spaces > 0) {
143
+ return Buffer.concat([buf.subarray(0, buf.length - 1), Buffer.alloc(spaces, 0x20), Buffer.from('}')]);
144
+ }
145
+ return buf;
146
+ }
147
+ const rem = buf.length % 16;
148
+ if (rem === 0) return buf;
149
+ return Buffer.concat([buf, Buffer.alloc(16 - rem, 0x20)]);
150
+ }
151
+
152
+ /** PUT-Body verschlüsseln mit IV = erste 16 Byte der Request-Signatur. */
153
+ encryptBody(paddedBody, signature) {
154
+ const iv = MieleCrypto.ivFromSignature(signature);
155
+ const cipher = crypto.createCipheriv('aes-256-cbc', this.aesKey, iv);
156
+ cipher.setAutoPadding(false);
157
+ return Buffer.concat([cipher.update(paddedBody), cipher.final()]);
158
+ }
159
+ }
160
+
161
+ module.exports = { MieleCrypto, ACCEPT, CONTENT_TYPE, DATE };