@impact0815/node-red-contrib-home-connect 0.0.0-stage → 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.
Files changed (52) hide show
  1. package/BUILD-INFO.json +23 -0
  2. package/CHANGELOG.md +52 -0
  3. package/LICENSE +21 -0
  4. package/NOTICE +19 -0
  5. package/README.de.md +326 -0
  6. package/README.md +325 -2
  7. package/SECURITY.md +47 -0
  8. package/docs/API-COVERAGE.md +59 -0
  9. package/docs/ARCHITECTURE.md +95 -0
  10. package/docs/DASHBOARD.de.md +121 -0
  11. package/docs/DASHBOARD.md +121 -0
  12. package/docs/FIRST-PUSH.md +42 -0
  13. package/docs/LOCAL-LAN.de.md +150 -0
  14. package/docs/LOCAL-LAN.md +150 -0
  15. package/docs/MESSAGE-API.md +111 -0
  16. package/docs/RELEASE.md +78 -0
  17. package/docs/TROUBLESHOOTING.de.md +230 -0
  18. package/docs/TROUBLESHOOTING.md +230 -0
  19. package/docs/images/README.md +10 -0
  20. package/docs/images/dashboard-details-en.png +0 -0
  21. package/docs/images/dashboard-overview-en.png +0 -0
  22. package/docs/official-api-contract.json +418 -0
  23. package/examples/01-first-steps-device-list.json +148 -0
  24. package/examples/02-events.json +165 -0
  25. package/examples/03-safe-read-write.json +155 -0
  26. package/examples/04-local-read-only.json +214 -0
  27. package/examples/dashboard-dishwasher-washer-oven.json +628 -0
  28. package/examples/dashboard-screenshot-simulated.json +405 -0
  29. package/examples/demo-home-connect-flowfuse-1.34.1-scriptless.json +390 -0
  30. package/lib/client.js +600 -0
  31. package/lib/constants.js +66 -0
  32. package/lib/dashboard-i18n.js +255 -0
  33. package/lib/device-label.js +39 -0
  34. package/lib/errors.js +51 -0
  35. package/lib/health.js +525 -0
  36. package/lib/local-client.js +1014 -0
  37. package/lib/local-crypto.js +122 -0
  38. package/lib/local-diagnostics.js +115 -0
  39. package/lib/local-profile.js +706 -0
  40. package/lib/local-trace.js +103 -0
  41. package/lib/oauth.js +23 -0
  42. package/lib/rate-limiter.js +186 -0
  43. package/lib/simulator.js +290 -0
  44. package/lib/sse.js +58 -0
  45. package/lib/validation.js +194 -0
  46. package/lib/websocket-transport.js +374 -0
  47. package/nodes/home-connect.html +795 -0
  48. package/nodes/home-connect.js +699 -0
  49. package/nodes/locales/de/home-connect.html +19 -0
  50. package/nodes/locales/de/home-connect.json +10 -0
  51. package/nodes/locales/en-US/home-connect.json +10 -0
  52. package/package.json +71 -4
@@ -0,0 +1,795 @@
1
+ <script type="text/javascript">
2
+ (function() {
3
+ const readOperations = [
4
+ ['snapshot', 'Snapshot + capabilities'],
5
+ ['appliances', 'Appliances'],
6
+ ['appliance', 'Appliance details'],
7
+ ['status', 'All status values'],
8
+ ['status-item', 'One status value'],
9
+ ['settings', 'All settings'],
10
+ ['setting', 'One setting'],
11
+ ['all-programs', 'All programs'],
12
+ ['programs', 'Available programs'],
13
+ ['program', 'Program constraints'],
14
+ ['active-program', 'Active program'],
15
+ ['active-options', 'Active program options'],
16
+ ['active-option', 'One active option'],
17
+ ['selected-program', 'Selected program'],
18
+ ['selected-options', 'Selected program options'],
19
+ ['selected-option', 'One selected option'],
20
+ ['commands', 'Commands'],
21
+ ['images', 'Image metadata'],
22
+ ['image', 'Image (JPEG buffer)'],
23
+ ['maps', 'Cleaning-robot maps'],
24
+ ['map', 'One cleaning-robot map'],
25
+ ['map-occupancy-grid', 'Map occupancy grid'],
26
+ ['map-layout', 'Map layout'],
27
+ ['map-cleaning-status', 'Map cleaning status'],
28
+ ['map-detected-objects', 'Map detected objects'],
29
+ ['map-detected-object', 'One detected object'],
30
+ ['map-rooms', 'Map rooms'],
31
+ ['map-room', 'One map room'],
32
+ ['map-zones', 'Map zones'],
33
+ ['map-zone', 'One map zone'],
34
+ ['map-robot-position', 'Robot position'],
35
+ ['map-config-restrictions', 'Map restrictions'],
36
+ ['map-cleaning-summaries', 'Cleaning summaries'],
37
+ ['map-cleaning-summary', 'One cleaning summary'],
38
+ ['capabilities', 'Force capability refresh']
39
+ ];
40
+
41
+ const localReadOperations = new Set([
42
+ 'snapshot', 'appliances', 'appliance', 'status', 'status-item', 'settings', 'setting',
43
+ 'all-programs', 'programs', 'program', 'active-program', 'active-options', 'active-option',
44
+ 'selected-program', 'selected-options', 'selected-option', 'commands', 'capabilities'
45
+ ]);
46
+
47
+ function shortTechnicalId(value) {
48
+ const id = String(value || '').trim();
49
+ return id ? '…' + id.slice(-4) : '';
50
+ }
51
+
52
+ function deviceLabel(appliance) {
53
+ if (appliance?.displayLabel) return appliance.displayLabel;
54
+ const type = appliance?.type || 'Appliance';
55
+ const names = { Dishwasher: 'Geschirrspüler', Washer: 'Waschmaschine', Oven: 'Backofen' };
56
+ const name = appliance?.name || names[type] || '';
57
+ const model = String(appliance?.model || appliance?.vib || appliance?.eNumber || '').split('/')[0];
58
+ const brandModel = [appliance?.brand, model].filter(Boolean).join(' ');
59
+ return [type, name, brandModel, shortTechnicalId(appliance?.haId)].filter((value, index, values) => value && values.findIndex(other => String(other).toLowerCase() === String(value).toLowerCase()) === index).join(' — ');
60
+ }
61
+
62
+ function fillDeviceSelector(items, allowAll, preferred) {
63
+ const selector = $('#node-input-haId');
64
+ const current = selector.val() || preferred;
65
+ if (!selector.is('select')) {
66
+ selector.autocomplete({ source: items, minLength: 0 }).autocomplete('search', '');
67
+ return;
68
+ }
69
+ selector.empty();
70
+ if (allowAll) selector.append($('<option></option>').val('*').text('All configured appliances / Alle konfigurierten Geräte'));
71
+ items.forEach(item => selector.append($('<option></option>').val(item.value).text(item.label)));
72
+ if (current && selector.find('option').filter(function() { return $(this).val() === current; }).length) selector.val(current);
73
+ else if (!allowAll && items.length) selector.val(items[0].value);
74
+ }
75
+
76
+ function prepareApplianceDiscovery(options = {}) {
77
+ const load = function(event) {
78
+ event?.preventDefault();
79
+ const connection = $('#node-input-connection').val();
80
+ const result = $('.hc-device-result').text('…');
81
+ if (!connection) { result.text('Select a connection first'); return; }
82
+ $.getJSON('home-connect/config/' + encodeURIComponent(connection) + '/appliances')
83
+ .done(data => {
84
+ const items = (data.appliances || []).filter(entry => entry.appliance).map(entry => ({
85
+ label: deviceLabel(entry.appliance),
86
+ value: entry.appliance.haId
87
+ }));
88
+ fillDeviceSelector(items, options.allowAll === true, options.current);
89
+ result.text('✓ ' + items.length + ' appliance(s)');
90
+ })
91
+ .fail(xhr => result.text('✗ ' + (xhr.responseJSON?.error?.message || xhr.statusText)));
92
+ };
93
+ $('.hc-load-devices').on('click', load);
94
+ $('#node-input-connection').on('change', () => setTimeout(load, 0));
95
+ if ($('#node-input-connection').val()) setTimeout(load, 0);
96
+ return load;
97
+ }
98
+
99
+ function localHostCheck(value) {
100
+ const host = String(value || '').trim().replace(/^\[|\]$/g, '');
101
+ if (!host) return { valid: false, message: 'Host/IP fehlt / Host/IP is missing.' };
102
+ if (/[\s/@?#]/.test(host) || /^[a-z]+:\/\//i.test(host)) return { valid: false, message: 'Nur Hostname oder IP ohne http://, Port oder Pfad eingeben.' };
103
+ const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
104
+ if (ipv4) {
105
+ const parts = ipv4.slice(1).map(Number);
106
+ if (parts.some(part => part > 255)) return { valid: false, message: 'Ungültige IPv4-Adresse / invalid IPv4 address.' };
107
+ const local = parts[0] === 10 || (parts[0] === 169 && parts[1] === 254)
108
+ || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
109
+ || (parts[0] === 192 && parts[1] === 168);
110
+ return local
111
+ ? { valid: true, message: '✓ Private/link-lokale IP / private or link-local IP.' }
112
+ : { valid: false, message: 'Nur private oder link-lokale IP-Adressen sind erlaubt.' };
113
+ }
114
+ if (host.includes(':')) {
115
+ const plain = host.toLowerCase().split('%')[0];
116
+ const local = /^(?:fe[89ab]|f[cd])/.test(plain);
117
+ return local
118
+ ? { valid: true, message: '✓ Private/link-lokale IPv6-Adresse.' }
119
+ : { valid: false, message: 'Nur private oder link-lokale IPv6-Adressen sind erlaubt.' };
120
+ }
121
+ if (!/^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/i.test(host)) return { valid: false, message: 'Ungültiger Hostname / invalid hostname.' };
122
+ return { valid: true, pending: true, message: 'Hostname wird beim Verbindungstest auf eine private/link-lokale IP geprüft.' };
123
+ }
124
+
125
+ function validLocalMappings(value) {
126
+ try {
127
+ const parsed = JSON.parse(value || '[]');
128
+ const ids = new Set();
129
+ return Array.isArray(parsed) && parsed.length <= 100 && parsed.every(entry => {
130
+ const id = String(entry?.profileId || '').trim();
131
+ const connection = entry?.connectionType || 'auto';
132
+ if (!id || ids.has(id) || !['auto', 'AES', 'TLS'].includes(connection)) return false;
133
+ ids.add(id);
134
+ return localHostCheck(entry?.host).valid;
135
+ });
136
+ } catch (_error) { return false; }
137
+ }
138
+
139
+ function friendlyLocalError(error) {
140
+ const code = error?.code || 'UNEXPECTED_ERROR';
141
+ const details = error?.details || {};
142
+ const messages = {
143
+ LOCAL_PROFILE_PATH_REQUIRED: 'Profilordner fehlt / profile folder is missing.',
144
+ LOCAL_PROFILE_PATH_UNREADABLE: 'Profilordner ist für Node-RED nicht lesbar. Mount/Pfad und Rechte prüfen.',
145
+ LOCAL_PROFILE_FOLDER_REQUIRED: 'Bitte einen Ordner statt einer einzelnen Datei auswählen.',
146
+ LOCAL_PROFILE_FILES_INCOMPLETE: 'Profil ist unvollständig: Geräte-JSON, DeviceDescription.xml und FeatureMapping.xml zusammenlassen.',
147
+ LOCAL_PROFILE_KEY_MISSING: 'Geräteprofil enthält keinen PSK. Profil vollständig neu herunterladen.',
148
+ LOCAL_PROFILE_DUPLICATE: 'Profil-ID ist doppelt. Jede Profil-ID darf nur einmal vorkommen.',
149
+ LOCAL_MAPPING_HOST_REQUIRED: 'Host/IP fehlt. Für dieses Gerät eine private oder link-lokale Adresse eintragen.',
150
+ LOCAL_MAPPING_PROFILE_NOT_FOUND: 'Profil wurde im Ordner nicht mehr gefunden. Erneut Profile suchen.',
151
+ LOCAL_HOST_INVALID: 'Host/IP ist ungültig. Nur Hostname oder IP ohne URL, Port oder Pfad eingeben.',
152
+ LOCAL_HOST_NOT_PRIVATE: 'Ziel ist nicht ausschließlich privat/link-lokal und wurde blockiert.',
153
+ LOCAL_CONNECT_TIMEOUT: 'Zeitüberschreitung. Geräte-IP, WLAN, VLAN/Firewall und Port prüfen.',
154
+ LOCAL_HANDSHAKE_TIMEOUT: 'Lokaler Protokoll-Handshake hat nicht rechtzeitig geantwortet. Profil, Transport und Netzwerk prüfen.',
155
+ LOCAL_REQUEST_TIMEOUT: 'Das Gerät hat auf die lokale Leseanfrage nicht rechtzeitig geantwortet.',
156
+ LOCAL_NETWORK_ERROR: 'Netzwerkfehler. IP/Hostname, WLAN, VLAN, Firewall und Erreichbarkeit prüfen.',
157
+ LOCAL_CONNECT_FAILED: 'Lokales Gerät nicht erreichbar. IP, WLAN, VLAN/Firewall und Verbindungsart prüfen.',
158
+ LOCAL_DISCONNECTED: 'Lokale Verbindung wurde getrennt. Gerätestrom/Standby, WLAN, VLAN und Firewall prüfen; die Session wird neu aufgebaut.',
159
+ LOCAL_HEARTBEAT_TIMEOUT: 'Das Gerät antwortet nicht mehr auf WebSocket-Ping. Verbindung und Session werden neu aufgebaut.',
160
+ LOCAL_TRANSPORT_UNSUPPORTED: 'Die gewählte Verbindungsart wird vom Gerät nicht unterstützt. Auto bzw. AES/TLS prüfen.',
161
+ LOCAL_TLS_PSK_UNAVAILABLE: 'TLS-PSK ist in dieser Node.js/OpenSSL-Laufzeit nicht verfügbar.',
162
+ LOCAL_WEBSOCKET_UPGRADE_FAILED: 'Das Gerät hat das WebSocket-Upgrade auf /homeconnect abgelehnt. Profilzuordnung, Auto/AES/TLS und Geräte-IP prüfen.',
163
+ LOCAL_CRYPTO_AUTH_FAILED: 'Profil-Schlüssel passt nicht zum Gerät. Profile neu herunterladen.',
164
+ LOCAL_CRYPTO_PADDING_INVALID: 'AES-CBC wurde entschlüsselt, aber IV/PSK oder der Nachrichtenstrom passen nicht zum Gerät.',
165
+ LOCAL_CRYPTO_TEXT_INVALID: 'AES-CBC wurde entschlüsselt, lieferte aber keinen gültigen Home-Connect-Text.',
166
+ LOCAL_PROFILE_KEY_LENGTH_INVALID: 'Der Profil-PSK hat nicht die erwarteten 32 Byte.',
167
+ LOCAL_PROFILE_IV_INVALID: 'Der AES-IV hat nicht die erwarteten 16 Byte.',
168
+ LOCAL_PROFILE_KEY_REJECTED: 'PSK/Profil passt nicht zum Gerät. Richtiges Geräteprofil neu herunterladen.',
169
+ LOCAL_PROFILE_AUTHENTICATION_FAILED: 'Lokale Profil-Authentifizierung wurde abgelehnt. Profil und Gerätezuordnung prüfen.',
170
+ LOCAL_SERVICES_INVALID: 'Das Gerät hat keine auswertbaren Service-Versionen geliefert.',
171
+ LOCAL_PROTOCOL_RESPONSE_MISMATCH: 'Die Geräteantwort gehört nicht zur erwarteten Ressource; sicheren Mitschnitt prüfen.',
172
+ LOCAL_PROTOCOL_REJECTED: 'Das Gerät hat die lokale Protokollanfrage abgelehnt.',
173
+ LOCAL_TRACE_PATH_INVALID: 'Mitschnittpfad muss ein absoluter Pfad auf dem Node-RED-Host sein.'
174
+ };
175
+ let extra = '';
176
+ if (code === 'LOCAL_PROTOCOL_REJECTED' && details.resource) {
177
+ extra = ' Abgelehnt: ' + (details.action || 'GET') + ' ' + details.resource
178
+ + ' · Version ' + (details.version ?? '?') + ' · Gerätecode ' + (details.deviceCode ?? error?.status ?? '?') + '.';
179
+ } else if (code === 'LOCAL_NETWORK_ERROR' && details.causeCode) {
180
+ extra = ' Ursache: ' + details.causeCode + '.';
181
+ }
182
+ if (details.hintDe) extra += ' ' + details.hintDe;
183
+ if (details.hintEn) extra += ' / ' + details.hintEn;
184
+ return (messages[code] || error?.message || code) + extra + ' [' + code + ']';
185
+ }
186
+
187
+ function postJson(url, body) {
188
+ return $.ajax({
189
+ url,
190
+ method: 'POST',
191
+ contentType: 'application/json; charset=utf-8',
192
+ dataType: 'json',
193
+ data: JSON.stringify(body)
194
+ });
195
+ }
196
+
197
+ RED.nodes.registerType('home-connect-config', {
198
+ category: 'config',
199
+ defaults: {
200
+ name: { value: '' },
201
+ environment: { value: 'production', required: true },
202
+ redirectUri: {
203
+ value: 'http://localhost:1880/home-connect/oauth/callback',
204
+ required: true,
205
+ validate: function(value) {
206
+ try {
207
+ const uri = new URL(value);
208
+ return ['http:', 'https:'].includes(uri.protocol)
209
+ && uri.pathname.endsWith('/home-connect/oauth/callback')
210
+ && !uri.pathname.endsWith('/home-connect/oauth/callback/');
211
+ } catch (_error) {
212
+ return false;
213
+ }
214
+ }
215
+ },
216
+ scope: { value: 'IdentifyAppliance', required: true },
217
+ language: { value: 'en-GB', required: true },
218
+ requestTimeout: { value: 15000, required: true, validate: RED.validators.number() },
219
+ maxRequestsPerDay: { value: 900, required: true, validate: RED.validators.number() },
220
+ minimumRequestInterval: { value: 250, required: true, validate: RED.validators.number() },
221
+ localProfilePath: { value: '' },
222
+ localDeviceMappings: { value: '[]', validate: validLocalMappings },
223
+ localProtocolTrace: { value: false },
224
+ localProtocolTracePath: { value: '' },
225
+ allowWrite: { value: false }
226
+ },
227
+ credentials: {
228
+ clientId: { type: 'text' },
229
+ clientSecret: { type: 'password' },
230
+ refreshToken: { type: 'password' },
231
+ localProfileJson: { type: 'password' }
232
+ },
233
+ label: function() { return this.name || 'Home Connect'; },
234
+ oneditprepare: function() {
235
+ const id = this.id;
236
+ let runtimeStatus = null;
237
+ let credentialsEdited = false;
238
+ let detectedProfiles = [];
239
+ const runtimeFields = '#node-config-input-name,#node-config-input-environment,#node-config-input-redirectUri,#node-config-input-scope,#node-config-input-maxRequestsPerDay,#node-config-input-minimumRequestInterval,#node-config-input-localProfilePath,#node-config-input-localDeviceMappings,#node-config-input-localProtocolTrace,#node-config-input-localProtocolTracePath';
240
+ const credentialField = $('#node-config-input-localProfileJson');
241
+ const manualEditor = $('#hc-manual-json-editor');
242
+ const storedCredential = credentialField.val() === ('__' + 'PWRD__');
243
+ manualEditor.val(storedCredential ? '' : (credentialField.val() || ''));
244
+
245
+ function currentSetup() {
246
+ return {
247
+ name: $('#node-config-input-name').val().trim(),
248
+ environment: $('#node-config-input-environment').val(),
249
+ redirectUri: $('#node-config-input-redirectUri').val().trim(),
250
+ scope: $('#node-config-input-scope').val().trim().replace(/\s+/g, ' '),
251
+ maxRequestsPerDay: Number($('#node-config-input-maxRequestsPerDay').val()),
252
+ minimumRequestInterval: Number($('#node-config-input-minimumRequestInterval').val()),
253
+ localProfilePath: $('#node-config-input-localProfilePath').val().trim(),
254
+ localDeviceMappings: $('#node-config-input-localDeviceMappings').val() || '[]',
255
+ localProtocolTrace: $('#node-config-input-localProtocolTrace').prop('checked'),
256
+ localProtocolTracePath: $('#node-config-input-localProtocolTracePath').val().trim()
257
+ };
258
+ }
259
+
260
+ function environmentNameWarning(setup) {
261
+ const name = setup.name.toLowerCase();
262
+ const simulatorName = /simulator|simulation|test/.test(name);
263
+ const productionName = /production|produktiv|live/.test(name);
264
+ if (setup.environment === 'production' && simulatorName) return '⚠ Der Name klingt nach Simulator, ausgewählt ist aber Production. Bitte eindeutig benennen.';
265
+ if (setup.environment.includes('simulator') && productionName) return '⚠ The name sounds like Production, but a simulator is selected. Rename it clearly.';
266
+ return '';
267
+ }
268
+
269
+ function redirectWarning(uriText) {
270
+ if (!uriText) return '⚠ Redirect URI fehlt / is missing.';
271
+ let uri;
272
+ try { uri = new URL(uriText); }
273
+ catch (_error) { return '⚠ Keine gültige URL / not a valid URL.'; }
274
+ if (!['http:', 'https:'].includes(uri.protocol)) return '⚠ Nur http:// oder https:// verwenden.';
275
+ if (!uri.pathname.endsWith('/home-connect/oauth/callback') || uri.pathname.endsWith('/')) return '⚠ Der Pfad muss exakt mit /home-connect/oauth/callback enden – ohne Slash danach.';
276
+ if (['localhost', '127.0.0.1', '::1'].includes(uri.hostname)) return '⚠ localhost funktioniert nur, wenn dieser Browser auf demselben Rechner wie Node-RED läuft.';
277
+ return '✓ Format plausibel. Im Developer Portal Zeichen für Zeichen identisch eintragen.';
278
+ }
279
+
280
+ function scopeWarning(scopeText) {
281
+ const scopes = new Set(scopeText.split(/\s+/).filter(Boolean));
282
+ if (!scopes.has('IdentifyAppliance')) return '⚠ IdentifyAppliance fehlt; die Geräteliste kann sonst nicht geladen werden.';
283
+ if (scopes.has('Monitor') && ![...scopes].some(value => /-(Monitor|Settings|Control)$/.test(value))) return '⚠ Der allgemeine Scope Monitor reicht nicht für jedes Konto. Bei insufficient_scope gerätespezifische Scopes verwenden.';
284
+ return '✓ IdentifyAppliance ist enthalten. Nach jeder Scope-Änderung neu autorisieren.';
285
+ }
286
+
287
+ function savedMappings() {
288
+ try { return JSON.parse($('#node-config-input-localDeviceMappings').val() || '[]'); }
289
+ catch (_error) { return []; }
290
+ }
291
+
292
+ function validateCard(row) {
293
+ const state = localHostCheck(row.find('.hc-local-host').val());
294
+ row.toggleClass('hc-device-invalid', !state.valid);
295
+ row.find('.hc-local-host').toggleClass('input-error', !state.valid);
296
+ row.find('.hc-local-host-message')
297
+ .toggleClass('hc-warn', !state.valid || state.pending)
298
+ .toggleClass('hc-ok', state.valid && !state.pending)
299
+ .text(state.message);
300
+ row.find('.hc-test-local-device').prop('disabled', !state.valid);
301
+ return state.valid;
302
+ }
303
+
304
+ function storeMappings() {
305
+ const mappings = [];
306
+ $('#hc-local-devices .hc-local-device').each(function() {
307
+ mappings.push({
308
+ profileId: String($(this).data('profile-id')),
309
+ host: $(this).find('.hc-local-host').val().trim(),
310
+ connectionType: $(this).find('.hc-local-connection').val() || 'auto'
311
+ });
312
+ });
313
+ $('#node-config-input-localDeviceMappings').val(JSON.stringify(mappings)).trigger('change');
314
+ return mappings;
315
+ }
316
+
317
+ function testLocalDevice(event) {
318
+ event.preventDefault();
319
+ const button = $(event.currentTarget);
320
+ const row = button.closest('.hc-local-device');
321
+ const result = row.find('.hc-local-test-result');
322
+ if (!validateCard(row)) { result.removeClass('hc-ok').addClass('hc-warn').text('✗ Host/IP zuerst korrigieren.'); return; }
323
+ button.prop('disabled', true);
324
+ const stageBox = row.find('.hc-local-test-stages');
325
+ const traceBox = row.find('.hc-local-protocol-trace');
326
+ const stageNames = {
327
+ 'ip-reachable': 'IP erreichbar / IP reachable',
328
+ 'profile-loaded': 'Profil geladen / Profile loaded',
329
+ 'transport-recognized': 'Transport erkannt / Transport detected',
330
+ 'websocket-connected': 'WebSocket verbunden / WebSocket connected',
331
+ 'session-initialized': 'Session initialisiert / Session initialized',
332
+ 'services-recognized': 'Services erkannt / Services detected',
333
+ 'readonly-data-received': 'Read-only-Daten empfangen / Read-only data received'
334
+ };
335
+ const stageOrder = Object.keys(stageNames);
336
+ function renderStages(stages) {
337
+ const values = new Map((stages || []).map(stage => [stage.id, stage]));
338
+ const list = $('<ol></ol>');
339
+ stageOrder.forEach(id => {
340
+ const stage = values.get(id) || { status: 'pending' };
341
+ const icons = { ok: '✓', failed: '✗', skipped: '–', pending: '…' };
342
+ const item = $('<li></li>')
343
+ .addClass('hc-stage-' + stage.status)
344
+ .text((icons[stage.status] || '…') + ' ' + stageNames[id]);
345
+ if (stage.status === 'failed') item.append($('<div class="hc-stage-detail"></div>').text(friendlyLocalError(stage)));
346
+ if (stage.status === 'ok' && id === 'transport-recognized' && stage.details?.transport) {
347
+ item.append($('<span class="hc-stage-meta"></span>').text(' · ' + stage.details.transport + ' · Port ' + stage.details.port + ' · ' + stage.details.path));
348
+ }
349
+ list.append(item);
350
+ });
351
+ stageBox.empty().append(list).show();
352
+ }
353
+ function renderTrace(trace) {
354
+ if (!trace?.enabled) { traceBox.empty().hide(); return; }
355
+ const summary = $('<summary></summary>').text('Bereinigter Protokoll-Mitschnitt / sanitized protocol trace (' + (trace.entries?.length || 0) + ')');
356
+ const text = JSON.stringify(trace, null, 2);
357
+ traceBox.empty().append(summary, $('<pre></pre>').text(text)).show();
358
+ }
359
+ renderStages([]);
360
+ renderTrace(null);
361
+ result.removeClass('hc-ok hc-warn').text('… gestufter lokaler Read-only-Test läuft …');
362
+ const mappings = storeMappings();
363
+ postJson('home-connect/local-profiles/test', {
364
+ path: $('#node-config-input-localProfilePath').val().trim(),
365
+ profileId: String(row.data('profile-id')),
366
+ host: row.find('.hc-local-host').val().trim(),
367
+ connectionType: row.find('.hc-local-connection').val() || 'auto',
368
+ mappings,
369
+ requestTimeout: Number($('#node-config-input-requestTimeout').val()) || 15000,
370
+ traceEnabled: $('#node-config-input-localProtocolTrace').prop('checked'),
371
+ tracePath: $('#node-config-input-localProtocolTracePath').val().trim()
372
+ }).done(data => {
373
+ renderStages(data.stages);
374
+ renderTrace(data.protocolTrace);
375
+ result.removeClass('hc-warn').addClass('hc-ok').text('✓ Lokal verbunden, nur gelesen · ' + data.transport + ' · ' + data.readValues + ' Werte / values');
376
+ }).fail(xhr => {
377
+ const data = xhr.responseJSON || {};
378
+ renderStages(data.stages);
379
+ renderTrace(data.protocolTrace);
380
+ const failed = (data.stages || []).find(stage => stage.status === 'failed');
381
+ result.removeClass('hc-ok').addClass('hc-warn').text('✗ Test gestoppt bei: ' + (stageNames[failed?.id] || 'lokaler Verbindung') + '.');
382
+ }).always(() => button.prop('disabled', !validateCard(row)));
383
+ }
384
+
385
+ function renderLocalProfiles(profiles) {
386
+ detectedProfiles = profiles;
387
+ const previous = new Map(savedMappings().map(entry => [entry.profileId, entry]));
388
+ const container = $('#hc-local-devices').empty();
389
+ profiles.forEach(profile => {
390
+ const existing = previous.get(profile.profileId) || {};
391
+ const row = $('<div class="hc-local-device"></div>').data('profile-id', profile.profileId);
392
+ const title = $('<div class="hc-local-device-title"></div>').text(profile.displayLabel || deviceLabel({ ...profile, haId: profile.profileId }));
393
+ const files = profile.files || {};
394
+ const fileSummary = [files.deviceJson, ...(files.deviceDescription || []), ...(files.featureMapping || [])].filter(Boolean).join(' · ');
395
+ const source = $('<div class="hc-local-device-source"></div>').text((profile.type || 'Unknown') + ' · ' + (fileSummary || profile.profileSource) + ' · erkannt/detected: ' + profile.detectedConnectionType);
396
+ const host = $('<input type="text" class="hc-local-host" autocomplete="off" placeholder="z. B. geraet.local">').val(existing.host || profile.detectedHost || '');
397
+ const hostMessage = $('<div class="hc-local-host-message hc-mini-card"></div>');
398
+ const connection = $('<select class="hc-local-connection"><option value="auto">Auto (empfohlen / recommended)</option><option value="AES">AES (Port 80)</option><option value="TLS">TLS (Port 443)</option></select>').val(existing.connectionType || 'auto');
399
+ const test = $('<button type="button" class="red-ui-button hc-test-local-device"><i class="fa fa-check-circle"></i> Verbindung testen / Test connection</button>');
400
+ const testResult = $('<span class="hc-local-test-result"></span>');
401
+ const testStages = $('<div class="hc-local-test-stages" style="display:none"></div>');
402
+ const protocolTrace = $('<details class="hc-local-protocol-trace" style="display:none"></details>');
403
+ row.append(
404
+ title,
405
+ source,
406
+ $('<div class="hc-card-field"><label>Host/IP</label></div>').append(host),
407
+ hostMessage,
408
+ $('<div class="hc-card-field"><label>Verbindungsart</label></div>').append(connection),
409
+ $('<div class="hc-card-actions"></div>').append(test, ' ', testResult, testStages, protocolTrace)
410
+ );
411
+ container.append(row);
412
+ host.on('input change', () => { validateCard(row); storeMappings(); });
413
+ connection.on('change', storeMappings);
414
+ test.on('click', testLocalDevice);
415
+ validateCard(row);
416
+ });
417
+ storeMappings();
418
+ }
419
+
420
+ function renderScanErrors(errors) {
421
+ const box = $('#hc-local-scan-errors').empty();
422
+ if (!errors.length) { box.hide(); return; }
423
+ const list = $('<ul></ul>');
424
+ errors.forEach(error => list.append($('<li></li>').text((error.messageDe || error.message || error.code) + ' [' + error.code + ']')));
425
+ box.append($('<b></b>').text('Hinweise zu unvollständigen oder doppelten Profilen:'), list).show();
426
+ }
427
+
428
+ function scanLocalProfiles(event) {
429
+ event?.preventDefault();
430
+ const profilePath = $('#node-config-input-localProfilePath').val().trim();
431
+ const result = $('#hc-local-scan-result');
432
+ if (!profilePath) {
433
+ detectedProfiles = [];
434
+ $('#hc-local-devices').empty();
435
+ $('#node-config-input-localDeviceMappings').val('[]').trigger('change');
436
+ renderScanErrors([]);
437
+ result.removeClass('hc-ok').addClass('hc-warn').text('Profilordner angeben / enter a profile folder.');
438
+ return;
439
+ }
440
+ result.removeClass('hc-ok hc-warn').text('… Profile werden rekursiv gesucht / scanning recursively …');
441
+ $.getJSON('home-connect/local-profiles/scan', { path: profilePath })
442
+ .done(data => {
443
+ renderLocalProfiles(data.profiles || []);
444
+ renderScanErrors(data.errors || []);
445
+ const count = (data.profiles || []).length;
446
+ const text = count === 1 ? '1 Geräteprofil gefunden' : count + ' Geräteprofile gefunden';
447
+ const english = count === 1 ? '1 device profile found' : count + ' device profiles found';
448
+ const warnings = (data.errors || []).length ? ' · ' + data.errors.length + ' Hinweis(e) / issue(s)' : '';
449
+ result.removeClass('hc-warn').addClass('hc-ok').text('✓ ' + text + ' / ' + english + warnings);
450
+ $('#hc-local-scan-help').text(count
451
+ ? 'Nächster Schritt: Für jedes Gerät Host/IP prüfen, Auto beibehalten und „Verbindung testen“ ausführen. Der Zugriff bleibt vollständig read-only.'
452
+ : 'Keine vollständigen Profile gefunden. Geräte-JSON, DeviceDescription.xml und FeatureMapping.xml je Gerät zusammen in den Profilordner legen.');
453
+ })
454
+ .fail(xhr => {
455
+ detectedProfiles = [];
456
+ $('#hc-local-devices').empty();
457
+ $('#node-config-input-localDeviceMappings').val('[]').trigger('change');
458
+ renderScanErrors([]);
459
+ result.removeClass('hc-ok').addClass('hc-warn').text('✗ ' + friendlyLocalError(xhr.responseJSON?.error));
460
+ });
461
+ }
462
+
463
+ function updateManualState() {
464
+ const folderActive = Boolean($('#node-config-input-localProfilePath').val().trim());
465
+ manualEditor.prop('disabled', folderActive);
466
+ $('#hc-manual-json-note').text(folderActive
467
+ ? 'Der Profilordner ist aktiv. Manuelles JSON bleibt sicher gespeichert, wird aber erst verwendet, wenn der Ordnerpfad geleert wird.'
468
+ : 'Kein Profilordner aktiv: Optional kann hier vollständiges Profil-JSON eingefügt werden.');
469
+ }
470
+
471
+ const update = () => {
472
+ const builtin = $('#node-config-input-environment').val() === 'builtin-simulator';
473
+ const local = $('#node-config-input-environment').val() === 'local';
474
+ const cloud = !builtin && !local;
475
+ $('.hc-oauth-row,.hc-cloud-row').toggle(cloud);
476
+ $('.hc-local-row').toggle(local);
477
+ $('.hc-nonlocal-row').toggle(!local);
478
+ $('.hc-write-row').toggle(!local);
479
+ const setup = currentSetup();
480
+ const runtimeMatches = runtimeStatus && runtimeStatus.ok
481
+ && !credentialsEdited
482
+ && runtimeStatus.name === setup.name
483
+ && runtimeStatus.environment === setup.environment
484
+ && runtimeStatus.redirectUri === setup.redirectUri
485
+ && String(runtimeStatus.scope || '').trim().replace(/\s+/g, ' ') === setup.scope
486
+ && runtimeStatus.maxRequestsPerDay === setup.maxRequestsPerDay
487
+ && runtimeStatus.minimumRequestInterval === setup.minimumRequestInterval
488
+ && runtimeStatus.localProfilePath === setup.localProfilePath
489
+ && runtimeStatus.localDeviceMappings === setup.localDeviceMappings
490
+ && runtimeStatus.localProtocolTrace === setup.localProtocolTrace
491
+ && runtimeStatus.localProtocolTracePath === setup.localProtocolTracePath;
492
+ const runtimeCredentialsReady = local
493
+ ? (runtimeStatus?.hasLocalProfileJson || Boolean(setup.localProfilePath))
494
+ : (runtimeStatus?.hasClientId && runtimeStatus?.hasClientSecret);
495
+ $('#hc-oauth').prop('disabled', !id || builtin || local || !runtimeMatches || !runtimeCredentialsReady);
496
+ $('#hc-test').prop('disabled', !id || !runtimeMatches);
497
+ if (cloud) {
498
+ $('#hc-redirect-check').text(redirectWarning(setup.redirectUri));
499
+ $('#hc-scope-check').text(scopeWarning(setup.scope));
500
+ const rate = runtimeStatus?.rateLimit;
501
+ if (rate?.blocked) $('#hc-rate-status').text('⚠ API-Aufrufe lokal pausiert: ' + rate.usedRequests + '/' + rate.maxRequests + '; erneut in ' + rate.retryAfter + ' s.');
502
+ else if (rate) $('#hc-rate-status').text('Lokales Tagesbudget: ' + rate.usedRequests + '/' + rate.maxRequests + ' API-Aufrufe verwendet.');
503
+ else $('#hc-rate-status').text('Tagesbudget wird nach dem Deploy angezeigt.');
504
+ }
505
+ $('#hc-name-check').text(environmentNameWarning(setup));
506
+ $('#node-config-input-localProtocolTracePath').prop('disabled', !setup.localProtocolTrace);
507
+ if (!id || !runtimeStatus) {
508
+ $('#hc-runtime-status').removeClass('hc-ok').addClass('hc-warn').text(local
509
+ ? 'Zuordnung speichern: Fertig → Deployen. Danach Read-/Events-Nodes verwenden.'
510
+ : '1. Fertig klicken → 2. Deployen → 3. Verbindung erneut öffnen.');
511
+ } else if (!runtimeMatches) {
512
+ $('#hc-runtime-status').removeClass('hc-ok').addClass('hc-warn').text('Änderungen sind noch nicht deployt / changes are not deployed. Fertig → Deploy → erneut öffnen.');
513
+ } else if (local && runtimeCredentialsReady) {
514
+ $('#hc-runtime-status').removeClass('hc-warn').addClass('hc-ok').text('✓ Lokale Profile sind deployt; Zugriff ist fest schreibgeschützt (' + (runtimeStatus?.localProfiles || 0) + ' Gerät(e)).');
515
+ } else if (local) {
516
+ $('#hc-runtime-status').removeClass('hc-ok').addClass('hc-warn').text('Profilordner oder optionales manuelles Profil-JSON fehlt.');
517
+ } else if (builtin) {
518
+ $('#hc-runtime-status').removeClass('hc-warn').addClass('hc-ok').text('✓ Integrierter Simulator ist deployt.');
519
+ } else if (!runtimeCredentialsReady) {
520
+ $('#hc-runtime-status').removeClass('hc-ok').addClass('hc-warn').text('Client ID oder Client Secret fehlt in der deployten Verbindung.');
521
+ } else {
522
+ const auth = runtimeStatus.hasRefreshToken ? 'Refresh Token vorhanden / stored' : 'noch nicht autorisiert / not authorized yet';
523
+ $('#hc-runtime-status').removeClass('hc-warn').addClass('hc-ok').text('✓ Konfiguration ist deployt (' + auth + ').');
524
+ }
525
+ updateManualState();
526
+ };
527
+
528
+ $('#hc-scan-local-profiles').on('click', scanLocalProfiles);
529
+ $('#node-config-input-localProfilePath').on('input', function() {
530
+ detectedProfiles = [];
531
+ $('#hc-local-devices').empty();
532
+ $('#node-config-input-localDeviceMappings').val('[]').trigger('change');
533
+ renderScanErrors([]);
534
+ $('#hc-local-scan-result').removeClass('hc-ok').addClass('hc-warn').text('Pfad geändert: „Profile suchen / Geräte erkennen“ klicken.');
535
+ updateManualState();
536
+ });
537
+ manualEditor.on('input', function() {
538
+ credentialField.val($(this).val());
539
+ credentialsEdited = true;
540
+ $('#hc-manual-json-status').text($(this).val().trim() ? 'Manuelles JSON wurde geändert; speichern und deployen.' : '');
541
+ update();
542
+ });
543
+ $('#hc-generate-manual-json').on('click', function(event) {
544
+ event.preventDefault();
545
+ const result = $('#hc-manual-json-status').removeClass('hc-ok hc-warn').text('… JSON wird sicher aus den erkannten Profilen erzeugt …');
546
+ const rows = $('#hc-local-devices .hc-local-device');
547
+ let valid = rows.length > 0;
548
+ rows.each(function() { valid = validateCard($(this)) && valid; });
549
+ if (!valid || detectedProfiles.length !== rows.length) {
550
+ result.addClass('hc-warn').text('✗ Erst Profile suchen und alle Host-/IP-Felder korrigieren.');
551
+ return;
552
+ }
553
+ const mappings = storeMappings();
554
+ postJson('home-connect/local-profiles/export', {
555
+ path: $('#node-config-input-localProfilePath').val().trim(),
556
+ mappings
557
+ }).done(data => {
558
+ credentialField.val(data.profileJson);
559
+ manualEditor.val(data.profileJson);
560
+ credentialsEdited = true;
561
+ $('#hc-local-advanced').prop('open', true);
562
+ result.removeClass('hc-warn').addClass('hc-ok').text('✓ Optionales manuelles Profil-JSON erzeugt. Zum Verwenden den Profilordner leeren; sonst bleibt der Ordner aktiv.');
563
+ update();
564
+ }).fail(xhr => result.removeClass('hc-ok').addClass('hc-warn').text('✗ ' + friendlyLocalError(xhr.responseJSON?.error)));
565
+ });
566
+
567
+ $('#node-config-input-environment').on('change', update);
568
+ $(runtimeFields).on('input change', update);
569
+ $('#node-config-input-clientId,#node-config-input-clientSecret').on('input', function() { credentialsEdited = true; update(); });
570
+ $('#hc-use-browser-address').on('click', function(event) {
571
+ event.preventDefault();
572
+ $('#node-config-input-redirectUri').val(window.location.origin + '/home-connect/oauth/callback').trigger('input');
573
+ });
574
+ $('#hc-copy-redirect').on('click', async function(event) {
575
+ event.preventDefault();
576
+ const value = $('#node-config-input-redirectUri').val();
577
+ try { await navigator.clipboard.writeText(value); $('#hc-copy-result').text('✓ kopiert / copied'); }
578
+ catch (_error) { $('#hc-copy-result').text('Bitte manuell kopieren / copy manually'); }
579
+ });
580
+ $('.hc-scope-preset').on('click', function(event) {
581
+ event.preventDefault();
582
+ $('#node-config-input-scope').val($(this).data('scope')).trigger('input');
583
+ });
584
+ if (storedCredential) $('#hc-manual-json-status').text('Optionales manuelles Profil-JSON ist sicher im Credential Store gespeichert; der Platzhalter wird nicht angezeigt.');
585
+ if (id) {
586
+ $.getJSON('home-connect/config/' + encodeURIComponent(id) + '/status')
587
+ .done(data => { runtimeStatus = data; update(); })
588
+ .fail(() => { runtimeStatus = null; update(); });
589
+ }
590
+ if ($('#node-config-input-localProfilePath').val().trim()) setTimeout(scanLocalProfiles, 0);
591
+ update();
592
+ $('#hc-oauth').on('click', function(event) {
593
+ event.preventDefault();
594
+ window.open('home-connect/oauth/' + encodeURIComponent(id) + '/start', '_blank', 'noopener');
595
+ });
596
+ $('#hc-test').on('click', function(event) {
597
+ event.preventDefault();
598
+ const result = $('#hc-test-result').text('…');
599
+ $.getJSON('home-connect/config/' + encodeURIComponent(id) + '/test')
600
+ .done(data => result.text('✓ ' + data.appliances.length + ' appliance(s)'))
601
+ .fail(xhr => result.text('✗ ' + (xhr.responseJSON?.error?.message || xhr.statusText)));
602
+ });
603
+ },
604
+ oneditsave: function() {
605
+ const rows = $('#hc-local-devices .hc-local-device');
606
+ if (!rows.length) return;
607
+ const mappings = [];
608
+ rows.each(function() {
609
+ mappings.push({
610
+ profileId: String($(this).data('profile-id')),
611
+ host: $(this).find('.hc-local-host').val().trim(),
612
+ connectionType: $(this).find('.hc-local-connection').val() || 'auto'
613
+ });
614
+ });
615
+ $('#node-config-input-localDeviceMappings').val(JSON.stringify(mappings));
616
+ }
617
+ });
618
+
619
+ RED.nodes.registerType('home-connect-discover', {
620
+ category: 'Home Connect', color: '#6fa8dc', icon: 'font-awesome/fa-search',
621
+ defaults: { name: {value:''}, connection: {value:'', type:'home-connect-config', required:true}, mode:{value:'all'}, haId:{value:''} },
622
+ inputs: 1, outputs: 2, outputLabels: ['data', 'error'],
623
+ oneditprepare: prepareApplianceDiscovery,
624
+ label: function() { return this.name || 'Home Connect discover'; }
625
+ });
626
+
627
+ RED.nodes.registerType('home-connect-read', {
628
+ category: 'Home Connect', color: '#6fa8dc', icon: 'font-awesome/fa-download',
629
+ defaults: { name:{value:''}, connection:{value:'', type:'home-connect-config', required:true}, operation:{value:'snapshot'}, haId:{value:''}, key:{value:''}, subKey:{value:''} },
630
+ inputs: 1, outputs: 2, outputLabels: ['data', 'error'],
631
+ oneditprepare: function() {
632
+ prepareApplianceDiscovery({ current: this.haId });
633
+ const select = $('#node-input-operation').empty();
634
+ const selectedOperation = this.operation || 'snapshot';
635
+ const renderOperations = local => {
636
+ select.empty();
637
+ readOperations.filter(([value]) => !local || localReadOperations.has(value))
638
+ .forEach(([value, label]) => select.append($('<option></option>').val(value).text(label)));
639
+ select.val(select.find('option[value="' + selectedOperation + '"]').length ? selectedOperation : 'snapshot');
640
+ };
641
+ const detectEnvironment = () => {
642
+ const connection = $('#node-input-connection').val();
643
+ if (!connection) { renderOperations(false); return; }
644
+ $.getJSON('home-connect/config/' + encodeURIComponent(connection) + '/status')
645
+ .done(data => renderOperations(data.environment === 'local'))
646
+ .fail(() => renderOperations(false));
647
+ };
648
+ $('#node-input-connection').on('change', detectEnvironment);
649
+ detectEnvironment();
650
+ },
651
+ label: function() { return this.name || 'Home Connect read'; }
652
+ });
653
+
654
+ RED.nodes.registerType('home-connect-events', {
655
+ category: 'Home Connect', color: '#f6b26b', icon: 'font-awesome/fa-bolt',
656
+ defaults: { name:{value:''}, connection:{value:'', type:'home-connect-config', required:true}, haId:{value:'*'} },
657
+ inputs: 1, outputs: 2, outputLabels: ['event', 'error'],
658
+ oneditprepare: function() { prepareApplianceDiscovery({ allowAll: true, current: this.haId || '*' }); },
659
+ label: function() { return this.name || 'Home Connect events'; }
660
+ });
661
+
662
+ RED.nodes.registerType('home-connect-write', {
663
+ category: 'Home Connect', color: '#e06666', icon: 'font-awesome/fa-shield',
664
+ defaults: {
665
+ name:{value:''}, connection:{value:'', type:'home-connect-config', required:true}, action:{value:'from-message'}, haId:{value:''},
666
+ dryRun:{value:true}, confirmationMode:{value:'token'}, allowedApplianceTypes:{value:''}
667
+ },
668
+ credentials: { confirmationToken: { type: 'password' } },
669
+ inputs: 1, outputs: 2, outputLabels: ['result', 'error'],
670
+ oneditprepare: prepareApplianceDiscovery,
671
+ label: function() { return this.name || 'Home Connect write'; }
672
+ });
673
+ })();
674
+ </script>
675
+
676
+ <script type="text/html" data-template-name="home-connect-config">
677
+ <style>
678
+ .hc-guide{border-left:4px solid #00629b;padding:9px 12px;margin:8px 0;background:#f3f8fb}
679
+ .hc-warn{color:#9a6700}.hc-ok{color:#16794a}.hc-mini{display:block;margin:4px 0 0 105px;font-size:.9em}
680
+ .hc-actions{margin-left:105px}.hc-actions .red-ui-button{margin:3px 3px 3px 0}
681
+ .hc-local-device{border:1px solid #aaa;border-radius:6px;padding:12px;margin:10px 0 10px 105px;background:#fafafa}
682
+ .hc-local-device.hc-device-invalid{border-color:#b42318}.hc-local-device-title{font-weight:700;font-size:1.05em}
683
+ .hc-local-device-source{font-size:.82em;color:#666;margin:4px 0 9px;overflow-wrap:anywhere}
684
+ .hc-card-field{display:flex;align-items:center;margin:7px 0}.hc-card-field label{width:145px;margin:0}
685
+ .hc-card-field input,.hc-card-field select{flex:1;min-width:0}.hc-mini-card{font-size:.85em;margin:2px 0 4px 145px}
686
+ .hc-card-actions{margin:9px 0 0 145px}.hc-local-test-result{display:block;margin-top:5px;font-size:.9em}
687
+ .hc-local-test-stages{margin-top:7px;background:#fff;padding:7px 10px;border:1px solid #ccc;border-radius:4px}
688
+ .hc-local-test-stages ol{margin:0;padding-left:22px}.hc-local-test-stages li{margin:3px 0}
689
+ .hc-local-protocol-trace{margin-top:7px;background:#f7f7f7;border:1px solid #ccc;border-radius:4px;padding:6px 8px}
690
+ .hc-local-protocol-trace summary{cursor:pointer;font-weight:600}.hc-local-protocol-trace pre{max-height:22rem;overflow:auto;white-space:pre-wrap;font-size:.82em}
691
+ .hc-stage-ok{color:#16794a}.hc-stage-failed{color:#b42318}.hc-stage-skipped{color:#777}.hc-stage-detail{margin-left:4px}.hc-stage-meta{color:#555;font-size:.9em}
692
+ .hc-local-scan-help{margin:7px 0 8px 105px;background:#fff4ce;border-left:4px solid #d6b656;padding:8px 12px;box-sizing:border-box}
693
+ .hc-scan-errors{border-left:4px solid #9a6700;background:#fff8e6;padding:8px 12px;margin:8px 0 8px 105px}
694
+ .hc-scan-errors ul{margin:6px 0 0 18px}.hc-advanced{margin:12px 0 12px 105px;border:1px solid #bbb;border-radius:5px;padding:8px 10px}
695
+ .hc-advanced summary{cursor:pointer;font-weight:600}.hc-advanced textarea{width:100%;box-sizing:border-box;margin-top:8px}
696
+ .hc-advanced .red-ui-button{margin-top:7px}.input-error{border-color:#b42318!important}
697
+ </style>
698
+ <div class="hc-guide hc-local-row"><b>Lokales Netz – nur lesen:</b> Profilordner eintragen → <b>Profile suchen / Geräte erkennen</b> → pro Gerät Host/IP eintragen → <b>Auto</b> lassen → <b>Verbindung testen</b> → <b>Fertig</b> → <b>Deployen</b>.</div>
699
+ <div class="hc-guide hc-cloud-row"><b>Cloud:</b> Developer-Portal-Application eintragen → <b>Fertig</b> → <b>Deploy</b> → erneut öffnen → <b>Autorisieren</b>.</div>
700
+ <div class="form-row"><label for="node-config-input-name"><i class="fa fa-tag"></i> <span data-i18n="home-connect.label.name"></span></label><input id="node-config-input-name" type="text" placeholder="z. B. Home Connect Zuhause"></div>
701
+ <span id="hc-name-check" class="hc-mini hc-warn"></span>
702
+ <div class="form-row"><label for="node-config-input-environment"><i class="fa fa-server"></i> <span data-i18n="home-connect.label.environment"></span></label><select id="node-config-input-environment"><option value="local">Local LAN (read-only) / Lokales Netz (nur lesen)</option><option value="production">Production (api.home-connect.com)</option><option value="china">China (api.home-connect.cn)</option><option value="official-simulator">Official simulator</option><option value="builtin-simulator">Built-in simulator / Integrierter Simulator</option></select></div>
703
+
704
+ <div class="form-row hc-local-row"><label for="node-config-input-localProfilePath"><i class="fa fa-folder-open"></i> Profilordner</label><input id="node-config-input-localProfilePath" type="text" placeholder="/data/homeconnect-profiles (Pfad auf dem Node-RED-Server)"></div>
705
+ <input id="node-config-input-localDeviceMappings" type="hidden">
706
+ <div class="hc-actions hc-local-row"><button type="button" id="hc-scan-local-profiles" class="red-ui-button"><i class="fa fa-search"></i> Profile suchen / Geräte erkennen</button> <span id="hc-local-scan-result"></span></div>
707
+ <div id="hc-local-scan-help" class="hc-local-scan-help hc-local-row">Profilordner wählen und Profile suchen. Danach pro Gerät Host/IP prüfen und den gestuften Read-only-Verbindungstest starten.</div>
708
+ <div id="hc-local-scan-errors" class="hc-scan-errors hc-local-row" style="display:none"></div>
709
+ <div id="hc-local-devices" class="hc-local-row"></div>
710
+ <div class="form-tips hc-local-row"><b>Zentrale Zuordnung:</b> Jede Karte erzeugt automatisch <code>localDeviceMappings</code>. Sie schreiben kein Mapping-JSON. Erlaubt sind nur private/link-lokale Ziele. Read-Nodes wählen ausschließlich Gerät und Lese-Ressource; Events wählt ein Gerät oder alle. Lokales Schreiben bleibt technisch gesperrt.</div>
711
+
712
+ <input id="node-config-input-localProfileJson" type="password" style="display:none" autocomplete="new-password">
713
+ <details id="hc-local-advanced" class="hc-advanced hc-local-row">
714
+ <summary>Erweitert / Advanced</summary>
715
+ <p><b>Profil-JSON manuell einfügen (optional)</b></p>
716
+ <p>Verwenden Sie entweder den Profilordner oben oder vollständiges manuelles JSON. Solange ein Profilordner gesetzt ist, ist dieser Editor deaktiviert und der Ordner hat Vorrang. Schlüssel niemals veröffentlichen.</p>
717
+ <textarea id="hc-manual-json-editor" rows="8" placeholder="Optionales vollständiges Profil-JSON; kein Beispiel und kein Credential-Platzhalter"></textarea>
718
+ <div id="hc-manual-json-note" class="hc-mini-card"></div>
719
+ <button type="button" id="hc-generate-manual-json" class="red-ui-button"><i class="fa fa-magic"></i> Aus erkannten Profilen und Zuordnungen erzeugen</button>
720
+ <div id="hc-manual-json-status" class="hc-mini-card"></div>
721
+ <hr>
722
+ <p><b>Sicherer Protokoll-Mitschnitt (optional)</b></p>
723
+ <div class="form-row"><label for="node-config-input-localProtocolTrace"><i class="fa fa-file-text-o"></i> Mitschnitt</label><input id="node-config-input-localProtocolTrace" type="checkbox" style="width:auto"> entschlüsselte, bereinigte RX/TX-Nachrichten erfassen</div>
724
+ <div class="form-row"><label for="node-config-input-localProtocolTracePath">JSONL-Datei</label><input id="node-config-input-localProtocolTracePath" type="text" placeholder="optional: /data/homeconnect-local-protocol.jsonl"></div>
725
+ <p class="form-tips">Standardmäßig aus. Ohne Pfad bleibt ein begrenzter Mitschnitt nur im Speicher und erscheint beim Verbindungstest. Mit absolutem Pfad schreibt Node-RED unter POSIX JSONL mit Dateirecht 0600; unter Windows den Pfad per NTFS-ACL auf das Dienstkonto beschränken. PSK, IV, Schlüssel, Tokens, Nonces sowie Geräte-/Netzwerkkennungen werden entfernt. Nach der Diagnose wieder ausschalten.</p>
726
+ </details>
727
+
728
+ <div class="form-row hc-oauth-row"><label for="node-config-input-clientId">Client ID</label><input id="node-config-input-clientId" type="text" placeholder="aus der eigenen Developer-Portal-Application"></div>
729
+ <div class="form-row hc-oauth-row"><label for="node-config-input-clientSecret">Client Secret</label><input id="node-config-input-clientSecret" type="password" placeholder="niemals veröffentlichen / never share"></div>
730
+ <div class="form-tips hc-oauth-row"><b>Nicht aus der Home-Connect-App:</b> Client ID und Client Secret stammen aus Ihrer eigenen Application im Developer Portal (Authorization Code Grant). Secret nie in Screenshots, Foren oder Flows zeigen; bei Offenlegung sofort regenerieren.</div>
731
+ <input id="node-config-input-refreshToken" type="password" style="display:none">
732
+ <div class="form-row hc-oauth-row"><label for="node-config-input-redirectUri">Redirect URI</label><input id="node-config-input-redirectUri" type="text"></div>
733
+ <div class="hc-actions hc-oauth-row"><button type="button" id="hc-use-browser-address" class="red-ui-button"><i class="fa fa-magic"></i> Diese Browser-Adresse verwenden</button><button type="button" id="hc-copy-redirect" class="red-ui-button"><i class="fa fa-copy"></i> Kopieren</button> <span id="hc-copy-result"></span></div>
734
+ <span id="hc-redirect-check" class="hc-mini hc-oauth-row"></span>
735
+ <div class="form-tips hc-oauth-row">Im Developer Portal und hier <b>Zeichen für Zeichen identisch</b>: http/https, Hostname, Port und Pfad <code>/home-connect/oauth/callback</code>; kein Slash am Ende.</div>
736
+ <div class="form-row hc-oauth-row"><label for="node-config-input-scope">OAuth scopes</label><input id="node-config-input-scope" type="text"></div>
737
+ <div class="hc-actions hc-oauth-row"><button type="button" class="red-ui-button hc-scope-preset" data-scope="IdentifyAppliance">Nur Geräteliste</button><button type="button" class="red-ui-button hc-scope-preset" data-scope="IdentifyAppliance Dishwasher-Monitor Washer-Monitor Oven-Monitor">3 Geräte lesen</button><button type="button" class="red-ui-button hc-scope-preset" data-scope="IdentifyAppliance Dishwasher-Monitor Dishwasher-Control Dishwasher-Settings Washer-Monitor Washer-Control Washer-Settings Oven-Monitor Oven-Control Oven-Settings">Dashboard lesen &amp; steuern</button></div>
738
+ <span id="hc-scope-check" class="hc-mini hc-oauth-row"></span>
739
+ <div class="form-row hc-cloud-row"><label for="node-config-input-language"><i class="fa fa-language"></i> API language</label><input id="node-config-input-language" type="text" placeholder="de-DE / en-GB"></div>
740
+ <div class="form-row"><label for="node-config-input-requestTimeout"><i class="fa fa-clock-o"></i> Timeout (ms)</label><input id="node-config-input-requestTimeout" type="number" min="1000" step="1000"></div>
741
+ <div class="form-row hc-cloud-row"><label for="node-config-input-maxRequestsPerDay"><i class="fa fa-tachometer"></i> API calls/day</label><input id="node-config-input-maxRequestsPerDay" type="number" min="1" max="1000" step="1"></div>
742
+ <div class="form-row hc-cloud-row"><label for="node-config-input-minimumRequestInterval"><i class="fa fa-hourglass-half"></i> Min. interval (ms)</label><input id="node-config-input-minimumRequestInterval" type="number" min="100" max="60000" step="50"></div>
743
+ <div class="form-tips hc-cloud-row">Der Cloud-Sliding-Window-Limiter zählt jeden REST- und SSE-Verbindungsversuch. Standard: 900 Aufrufe je 24 Stunden und mindestens 250 ms Abstand. HTTP 429 aktiviert den lokalen Circuit Breaker.</div>
744
+ <span id="hc-rate-status" class="hc-mini hc-cloud-row"></span>
745
+ <div class="form-row hc-write-row"><label for="node-config-input-allowWrite"><i class="fa fa-shield"></i> <span data-i18n="home-connect.label.allowWrite"></span></label><input id="node-config-input-allowWrite" type="checkbox" style="width:auto"></div>
746
+ <div id="hc-runtime-status" class="hc-guide hc-warn"></div>
747
+ <div class="form-row hc-oauth-row"><label>&nbsp;</label><button type="button" id="hc-oauth" class="red-ui-button"><i class="fa fa-sign-in"></i> Authorize / Autorisieren</button></div>
748
+ <div class="form-row hc-nonlocal-row"><label>&nbsp;</label><button type="button" id="hc-test" class="red-ui-button"><i class="fa fa-check"></i> Test</button> <span id="hc-test-result"></span></div>
749
+ <div class="form-tips hc-cloud-row">Diese Version verwendet den vertraulichen Authorization Code Grant mit Client Secret und <b>ohne PKCE</b>. Nach „Home Connect authorized“ kann <b>Test</b> die Geräte laden. Ohne Refresh Token OAuth vollständig neu starten.</div>
750
+ </script>
751
+
752
+ <script type="text/html" data-template-name="home-connect-discover">
753
+ <div class="form-row"><label for="node-input-name"><i class="fa fa-tag"></i> Name</label><input id="node-input-name" type="text"></div>
754
+ <div class="form-row"><label for="node-input-connection"><i class="fa fa-plug"></i> Connection</label><input id="node-input-connection" type="text"></div>
755
+ <div class="form-row"><label for="node-input-mode">Mode</label><select id="node-input-mode"><option value="all">All appliances</option><option value="single">Single appliance</option></select></div>
756
+ <div class="form-row"><label for="node-input-haId">Appliance / Gerät</label><input id="node-input-haId" type="text" placeholder="optional when mode=all"></div>
757
+ <div class="form-row"><label>&nbsp;</label><button class="red-ui-button hc-load-devices">Load devices</button> <span class="hc-device-result"></span></div>
758
+ </script>
759
+
760
+ <script type="text/html" data-template-name="home-connect-read">
761
+ <div class="form-row"><label for="node-input-name"><i class="fa fa-tag"></i> Name</label><input id="node-input-name" type="text"></div>
762
+ <div class="form-row"><label for="node-input-connection"><i class="fa fa-plug"></i> Connection</label><input id="node-input-connection" type="text"></div>
763
+ <div class="form-row"><label for="node-input-operation">Operation</label><select id="node-input-operation"></select></div>
764
+ <div class="form-row"><label for="node-input-haId">Appliance / Gerät</label><select id="node-input-haId"></select></div>
765
+ <div class="form-row"><label>&nbsp;</label><button class="red-ui-button hc-load-devices">Load devices</button> <span class="hc-device-result"></span></div>
766
+ <div class="form-row"><label for="node-input-key">Resource key / map ID</label><input id="node-input-key" type="text"></div>
767
+ <div class="form-row"><label for="node-input-subKey">Nested resource ID</label><input id="node-input-subKey" type="text" placeholder="object, room or zone ID"></div>
768
+ </script>
769
+
770
+ <script type="text/html" data-template-name="home-connect-events">
771
+ <div class="form-row"><label for="node-input-name"><i class="fa fa-tag"></i> Name</label><input id="node-input-name" type="text"></div>
772
+ <div class="form-row"><label for="node-input-connection"><i class="fa fa-plug"></i> Connection</label><input id="node-input-connection" type="text"></div>
773
+ <div class="form-row"><label for="node-input-haId">Appliance / Gerät</label><select id="node-input-haId"><option value="*">All configured appliances / Alle konfigurierten Geräte</option></select></div>
774
+ <div class="form-row"><label>&nbsp;</label><button class="red-ui-button hc-load-devices">Load devices</button> <span class="hc-device-result"></span></div>
775
+ <div class="form-tips">The stream reconnects automatically, resumes with Last-Event-ID when available and uses exponential backoff. Prefer one all-appliance stream.</div>
776
+ </script>
777
+
778
+ <script type="text/html" data-template-name="home-connect-write">
779
+ <div class="form-row"><label for="node-input-name"><i class="fa fa-tag"></i> Name</label><input id="node-input-name" type="text"></div>
780
+ <div class="form-row"><label for="node-input-connection"><i class="fa fa-plug"></i> Connection</label><input id="node-input-connection" type="text"></div>
781
+ <div class="form-row"><label for="node-input-action">Allowed action</label><select id="node-input-action"><option value="from-message">From message</option><option value="start-program">Start program</option><option value="stop-program">Stop program</option><option value="select-program">Select program</option><option value="set-setting">Set setting</option><option value="command">Command</option><option value="set-active-option">Set active option</option><option value="set-selected-option">Set selected option</option></select></div>
782
+ <div class="form-row"><label for="node-input-haId">Appliance / Gerät</label><input id="node-input-haId" type="text"></div>
783
+ <div class="form-row"><label>&nbsp;</label><button class="red-ui-button hc-load-devices">Load devices</button> <span class="hc-device-result"></span></div>
784
+ <div class="form-row"><label for="node-input-dryRun">Dry run</label><input id="node-input-dryRun" type="checkbox" style="width:auto"></div>
785
+ <div class="form-row"><label for="node-input-confirmationMode">Confirmation</label><select id="node-input-confirmationMode"><option value="token">Require token</option><option value="none">None</option></select></div>
786
+ <div class="form-row"><label for="node-input-confirmationToken">Token</label><input id="node-input-confirmationToken" type="password" placeholder="default: CONFIRM"></div>
787
+ <div class="form-row"><label for="node-input-allowedApplianceTypes">Allowed types</label><input id="node-input-allowedApplianceTypes" type="text" placeholder="for example: Dishwasher,Washer,Oven"></div>
788
+ <div class="form-tips">Writes require global enablement and are validated against fresh capabilities, access flags, types, ranges and appliance state. The token is stored as a Node-RED credential.</div>
789
+ </script>
790
+
791
+ <script type="text/html" data-help-name="home-connect-config"><h3>Local LAN (read-only)</h3><ol><li>Mount or copy the protected Profile Downloader folder so the Node-RED server/container can read it.</li><li>Select <b>Local LAN (read-only)</b> and enter that server-side folder.</li><li>Click <b>Scan profiles / Detect devices</b>. The scanner searches subfolders and accepts a device only when its appliance JSON, <code>DeviceDescription.xml</code>, and <code>FeatureMapping.xml</code> belong together.</li><li>In every device card, enter a private/link-local Host/IP and leave <b>Auto (recommended)</b> selected.</li><li>Click <b>Test connection</b> on every card. The test immediately uses the visible Host/IP and transport, even before deploy, and performs local reads only.</li><li>Click <b>Done</b>, deploy, and then select the descriptive device label/resource in Read or one/all devices in Events. The full technical ID remains internal.</li></ol><p>The Config node updates <code>localDeviceMappings</code> on every card change and serializes all cards again on Done; users never write mapping JSON. Missing/public addresses, incomplete sets, and duplicate profile IDs are rejected clearly. Local writes are always blocked with <code>LOCAL_READ_ONLY</code>.</p><p><b>Advanced:</b> use either the profile folder or optional manually pasted profile JSON. With a folder set, the manual editor is collapsed/disabled and ignored. It can be generated from detected profiles and mappings; keys stay in the credential store and never enter Read/Events nodes.</p><p>The optional safe protocol trace records decrypted but sanitized RX/TX messages in a bounded memory buffer or, on POSIX, a mode-0600 JSONL file. On Windows, restrict the trace path to the Node-RED service account with an NTFS ACL. PSK, IV, keys, authentication data, nonces, and appliance/network identifiers are removed. Enable it only while diagnosing. Protocol rejections report resource, action, negotiated version, and device code; network, key/profile, transport, crypto, and heartbeat errors remain separate.</p><h3>Cloud</h3><p>Cloud configurations keep the confidential Authorization Code flow with Client Secret and no PKCE, the persistent rate limiter, OAuth refresh tokens, health/warnings, and guarded writes. Save and deploy before authorizing. The Redirect URI must match the Developer Portal exactly.</p></script>
792
+ <script type="text/html" data-help-name="home-connect-discover"><p>Discovers real appliances dynamically. No user appliance names, IDs, programs or capabilities are hard-coded. Unsupported resources are reported in <code>discoveryErrors</code> without discarding successful data.</p></script>
793
+ <script type="text/html" data-help-name="home-connect-read"><p>Reads a predefined resource. With a local configuration, select only a detected/configured appliance and the desired read resource; Host/IP remains exclusively in the Config node. <code>msg.haId</code>, <code>msg.operation</code> and <code>msg.key</code> can override editor fields; arbitrary URLs are not accepted.</p></script>
794
+ <script type="text/html" data-help-name="home-connect-events"><p>Opens a local WebSocket or cloud SSE stream and reconnects automatically. Select one appliance or all configured appliances; Host/IP remains exclusively in the Config node. Output contains <code>msg.topic</code>, <code>msg.haId</code>, <code>msg.payload</code>, normalized <code>msg.health</code>, and bilingual <code>msg.warnings</code>. Stream lifecycle is also emitted as <code>CONNECTED</code>/<code>DISCONNECTED</code>.</p></script>
795
+ <script type="text/html" data-help-name="home-connect-write"><p>Safeguarded writer for program, setting, command and active/selected option endpoints from the official <b>cloud</b> API. Local LAN connections always reject writes with <code>LOCAL_READ_ONLY</code>.</p><p>Example: <code>msg.payload={action:'start-program',haId:'...',program:'...',options:[{key:'...',value:180}],confirm:'CONFIRM'}</code>.</p></script>