@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,699 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const { HomeConnectClient } = require('../lib/client');
5
+ const { LocalHomeConnectClient } = require('../lib/local-client');
6
+ const {
7
+ exportLocalProfileJson,
8
+ loadLocalProfiles,
9
+ parseLocalDeviceMappings,
10
+ scanLocalProfileDirectory
11
+ } = require('../lib/local-profile');
12
+ const { assertLocalHost } = require('../lib/websocket-transport');
13
+ const { validateLocalKeyMaterial } = require('../lib/local-crypto');
14
+ const { LocalConnectionDiagnostics } = require('../lib/local-diagnostics');
15
+ const { BuiltinSimulator } = require('../lib/simulator');
16
+ const { HomeConnectError } = require('../lib/errors');
17
+ const { formatDeviceLabel } = require('../lib/device-label');
18
+ const { createState, buildAuthorizationUrl } = require('../lib/oauth');
19
+ const { validateWrite } = require('../lib/validation');
20
+ const { HealthMonitor } = require('../lib/health');
21
+ const {
22
+ DEFAULT_MAX_REQUESTS,
23
+ DEFAULT_MINIMUM_INTERVAL,
24
+ SlidingWindowRateLimiter,
25
+ createFileRateLimitStore,
26
+ integerInRange
27
+ } = require('../lib/rate-limiter');
28
+ const { DEFAULT_SCOPE, READ_OPERATIONS, WRITE_ACTIONS } = require('../lib/constants');
29
+ const BUILD_INFO = require('../BUILD-INFO.json');
30
+
31
+ const pendingOAuth = new Map();
32
+ const sharedRateLimiters = new Map();
33
+ const OAUTH_TTL = 10 * 60 * 1000;
34
+
35
+ function useSharedRateLimiter(key, consumerId, options) {
36
+ let entry = sharedRateLimiters.get(key);
37
+ if (!entry) {
38
+ entry = { limiter: new SlidingWindowRateLimiter(options), consumers: new Map() };
39
+ sharedRateLimiters.set(key, entry);
40
+ }
41
+ entry.consumers.set(consumerId, options);
42
+ entry.limiter.configure({
43
+ maxRequests: Math.min(...[...entry.consumers.values()].map(value => value.maxRequests)),
44
+ minimumIntervalMs: Math.max(...[...entry.consumers.values()].map(value => value.minimumIntervalMs))
45
+ });
46
+ return entry.limiter;
47
+ }
48
+
49
+ function releaseSharedRateLimiter(key, consumerId) {
50
+ const entry = sharedRateLimiters.get(key);
51
+ if (!entry) return;
52
+ entry.consumers.delete(consumerId);
53
+ if (!entry.consumers.size) {
54
+ sharedRateLimiters.delete(key);
55
+ return;
56
+ }
57
+ entry.limiter.configure({
58
+ maxRequests: Math.min(...[...entry.consumers.values()].map(value => value.maxRequests)),
59
+ minimumIntervalMs: Math.max(...[...entry.consumers.values()].map(value => value.minimumIntervalMs))
60
+ });
61
+ }
62
+
63
+ function escapeHtml(value) {
64
+ return String(value).replace(/[&<>'"]/g, character => ({
65
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
66
+ })[character]);
67
+ }
68
+
69
+ function errorPayload(error) {
70
+ return error && typeof error.toJSON === 'function'
71
+ ? error.toJSON()
72
+ : { name: error?.name || 'Error', code: error?.code || 'UNEXPECTED_ERROR', message: error?.message || String(error) };
73
+ }
74
+
75
+ function attachHealth(msg, assessment) {
76
+ msg.health = assessment.health;
77
+ msg.warnings = assessment.warnings;
78
+ msg.homeConnect = {
79
+ ...(msg.homeConnect || {}),
80
+ health: assessment.health,
81
+ warnings: assessment.warnings,
82
+ healthSchema: assessment.schema,
83
+ healthAt: assessment.updatedAt
84
+ };
85
+ return msg;
86
+ }
87
+
88
+ function statusForHealth(node, assessment, fallback) {
89
+ const values = {
90
+ ok: { fill: 'green', shape: 'dot', text: fallback || 'ok' },
91
+ running: { fill: 'blue', shape: 'dot', text: 'running' },
92
+ info: { fill: 'blue', shape: 'ring', text: 'info' },
93
+ warning: { fill: 'yellow', shape: 'ring', text: 'warning' },
94
+ action_required: { fill: 'yellow', shape: 'dot', text: 'action required' },
95
+ offline: { fill: 'grey', shape: 'ring', text: 'offline' },
96
+ error: { fill: 'red', shape: 'ring', text: 'error' }
97
+ };
98
+ node.status(values[assessment.health] || values.error);
99
+ }
100
+
101
+ function optionArray(value) {
102
+ if (!value) return [];
103
+ let options;
104
+ if (Array.isArray(value)) {
105
+ options = value.map(entry => ({
106
+ key: entry?.key,
107
+ value: entry?.value,
108
+ ...(entry?.unit ? { unit: entry.unit } : {})
109
+ }));
110
+ } else if (typeof value === 'object') {
111
+ options = Object.entries(value).map(([key, entry]) => {
112
+ if (entry && typeof entry === 'object' && Object.hasOwn(entry, 'value')) {
113
+ return { key, value: entry.value, ...(entry.unit ? { unit: entry.unit } : {}) };
114
+ }
115
+ return { key, value: entry };
116
+ });
117
+ } else {
118
+ throw new HomeConnectError('options must be an array or object.', { code: 'INVALID_OPTIONS' });
119
+ }
120
+ if (options.length > 100) throw new HomeConnectError('A maximum of 100 options is allowed.', { code: 'TOO_MANY_OPTIONS' });
121
+ return options;
122
+ }
123
+
124
+ function mergeLocalTestMappings(value, current) {
125
+ const mappings = parseLocalDeviceMappings(value);
126
+ const [selected] = parseLocalDeviceMappings([current]);
127
+ const byId = new Map(mappings.map(mapping => [mapping.profileId, mapping]));
128
+ // The values from the device card are authoritative, even when the hidden
129
+ // editor field still contains the previous keystroke or deployed value.
130
+ byId.set(selected.profileId, selected);
131
+ return [...byId.values()];
132
+ }
133
+
134
+ function resolveLocalTestProfile(options = {}) {
135
+ const mappings = mergeLocalTestMappings(options.mappings, {
136
+ profileId: options.profileId,
137
+ host: options.host,
138
+ connectionType: options.connectionType || 'auto'
139
+ });
140
+ if (!mappings.find(mapping => mapping.profileId === options.profileId)?.host) {
141
+ throw new HomeConnectError('Enter a Host/IP for this device.', { code: 'LOCAL_MAPPING_HOST_REQUIRED' });
142
+ }
143
+ const profiles = loadLocalProfiles({
144
+ profilePath: options.profilePath,
145
+ mappings,
146
+ requireMapping: false,
147
+ targetProfileId: options.profileId
148
+ });
149
+ return profiles[0];
150
+ }
151
+
152
+ function setSecurityHeaders(res) {
153
+ res.set?.({
154
+ 'cache-control': 'no-store',
155
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'",
156
+ 'referrer-policy': 'no-referrer',
157
+ 'x-home-connect-build': BUILD_INFO.buildId,
158
+ 'x-content-type-options': 'nosniff',
159
+ 'x-frame-options': 'DENY'
160
+ });
161
+ }
162
+
163
+ function oauthPage({ title, heading, message, code, success = false }) {
164
+ const colour = success ? '#16794a' : '#b42318';
165
+ const codeMarkup = code ? `<p><strong>Code:</strong> <code>${escapeHtml(code)}</code></p>` : '';
166
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:48rem;margin:4rem auto;padding:0 1.25rem;color:#222}h1{color:${colour}}code{background:#f3f3f3;padding:.15rem .35rem;border-radius:.2rem}small{color:#666}</style></head><body><h1>${escapeHtml(heading)}</h1><p>${escapeHtml(message)}</p>${codeMarkup}<hr><p><small>Runtime build: ${escapeHtml(BUILD_INFO.buildId)}</small></p></body></html>`;
167
+ }
168
+
169
+ function sendOAuthError(res, error) {
170
+ const payload = errorPayload(error);
171
+ return res.status(error.status || 400).type('html').send(oauthPage({
172
+ title: 'Home Connect authorization failed',
173
+ heading: 'Home Connect authorization failed',
174
+ message: payload.message,
175
+ code: payload.code
176
+ }));
177
+ }
178
+
179
+ function registerRoutes(RED) {
180
+ const readPermission = RED.auth?.needsPermission
181
+ ? RED.auth.needsPermission('home-connect.read')
182
+ : (_req, _res, next) => next();
183
+ const writePermission = RED.auth?.needsPermission
184
+ ? RED.auth.needsPermission('home-connect.write')
185
+ : (_req, _res, next) => next();
186
+
187
+ RED.httpAdmin.get('/home-connect/diagnostics', readPermission, (_req, res) => {
188
+ setSecurityHeaders(res);
189
+ res.json({
190
+ packageName: BUILD_INFO.packageName,
191
+ packageVersion: BUILD_INFO.packageVersion,
192
+ buildId: BUILD_INFO.buildId,
193
+ loadedFrom: __dirname,
194
+ oauth: {
195
+ flow: BUILD_INFO.oauthFlow,
196
+ pkce: BUILD_INFO.pkce,
197
+ clientAuthentication: 'client_secret_post'
198
+ }
199
+ });
200
+ });
201
+
202
+ // Deliberately returns only non-secret setup state. The editor uses this to
203
+ // prevent a first OAuth attempt before the configuration has been deployed.
204
+ RED.httpAdmin.get('/home-connect/config/:id/status', readPermission, async (req, res) => {
205
+ setSecurityHeaders(res);
206
+ const config = RED.nodes.getNode(req.params.id);
207
+ if (!config || config.type !== 'home-connect-config') {
208
+ return res.status(404).json({ ok: false, code: 'CONFIG_NOT_FOUND' });
209
+ }
210
+ return res.json({
211
+ ok: true,
212
+ name: config.name || '',
213
+ environment: config.environment,
214
+ redirectUri: config.redirectUri || '',
215
+ scope: config.scope || '',
216
+ maxRequestsPerDay: config.maxRequestsPerDay,
217
+ minimumRequestInterval: config.minimumRequestInterval,
218
+ rateLimit: config.client.getRateLimitStatus
219
+ ? await config.client.getRateLimitStatus()
220
+ : { simulated: true, blocked: false },
221
+ requiresOAuth: config.client.requiresOAuth,
222
+ localReadOnly: config.environment === 'local',
223
+ localProfilePath: config.environment === 'local' ? (config.localProfilePath || '') : '',
224
+ localDeviceMappings: config.environment === 'local' ? JSON.stringify(config.localDeviceMappings || []) : '[]',
225
+ localProfiles: config.environment === 'local' ? config.client.profiles.length : 0,
226
+ localProtocolTrace: config.environment === 'local' ? config.localProtocolTrace : false,
227
+ localProtocolTracePath: config.environment === 'local' ? (config.localProtocolTracePath || '') : '',
228
+ hasClientId: Boolean(config.credentials?.clientId),
229
+ hasClientSecret: Boolean(config.credentials?.clientSecret),
230
+ hasRefreshToken: Boolean(config.credentials?.refreshToken),
231
+ hasLocalProfileJson: Boolean(config.credentials?.localProfileJson),
232
+ buildId: BUILD_INFO.buildId
233
+ });
234
+ });
235
+
236
+ RED.httpAdmin.get('/home-connect/local-profiles/scan', readPermission, (req, res) => {
237
+ setSecurityHeaders(res);
238
+ try {
239
+ const result = scanLocalProfileDirectory(req.query?.path);
240
+ return res.json({ ok: true, ...result });
241
+ } catch (error) {
242
+ return res.status(error.status || 400).json({ ok: false, error: errorPayload(error) });
243
+ }
244
+ });
245
+
246
+ if (RED.httpAdmin.post) {
247
+ RED.httpAdmin.post('/home-connect/local-profiles/test', readPermission, async (req, res) => {
248
+ setSecurityHeaders(res);
249
+ let client;
250
+ let testedProfileId = '';
251
+ const diagnostics = new LocalConnectionDiagnostics();
252
+ try {
253
+ const profilePath = String(req.body?.path || '').trim();
254
+ const profileId = String(req.body?.profileId || '').trim();
255
+ testedProfileId = profileId;
256
+ if (!profilePath) throw new HomeConnectError('The local profile folder is required.', { code: 'LOCAL_PROFILE_PATH_REQUIRED' });
257
+ if (!profileId) throw new HomeConnectError('Select a detected device profile first.', { code: 'LOCAL_MAPPING_PROFILE_REQUIRED' });
258
+ const profile = resolveLocalTestProfile({
259
+ profilePath,
260
+ profileId,
261
+ host: req.body?.host,
262
+ connectionType: req.body?.connectionType || 'auto',
263
+ mappings: req.body?.mappings
264
+ });
265
+ validateLocalKeyMaterial(profile);
266
+ const traceEnabled = req.body?.traceEnabled === true;
267
+ const tracePath = String(req.body?.tracePath || '').trim();
268
+ if (traceEnabled && tracePath && !path.isAbsolute(tracePath)) {
269
+ throw new HomeConnectError('Protocol trace path must be absolute on the Node-RED host.', { code: 'LOCAL_TRACE_PATH_INVALID' });
270
+ }
271
+ diagnostics.pass('profile-loaded', {
272
+ transport: profile.connectionType,
273
+ profileTransport: profile.automaticConnectionType,
274
+ declaredTransport: profile.declaredConnectionType
275
+ });
276
+ client = new LocalHomeConnectClient({
277
+ profiles: [profile], requestTimeout: req.body?.requestTimeout,
278
+ diagnostics, autoRefresh: false, traceEnabled, tracePath
279
+ });
280
+ const snapshot = await client.testConnection(profileId);
281
+ return res.json({
282
+ ok: true,
283
+ appliance: snapshot.appliance,
284
+ transport: snapshot.transport,
285
+ readOnly: true,
286
+ readValues: snapshot.values?.length || 0,
287
+ services: Object.keys(snapshot.services || {}),
288
+ protocolTrace: traceEnabled ? snapshot.protocolTrace : undefined,
289
+ stages: diagnostics.snapshot()
290
+ });
291
+ } catch (error) {
292
+ diagnostics.failFromError(error);
293
+ return res.status(error.status || 400).json({
294
+ ok: false,
295
+ error: errorPayload(error),
296
+ protocolTrace: client && testedProfileId && req.body?.traceEnabled === true ? client.getProtocolTrace(testedProfileId) : undefined,
297
+ stages: diagnostics.snapshot()
298
+ });
299
+ } finally {
300
+ client?.close();
301
+ }
302
+ });
303
+
304
+ RED.httpAdmin.post('/home-connect/local-profiles/export', writePermission, async (req, res) => {
305
+ setSecurityHeaders(res);
306
+ try {
307
+ const profilePath = String(req.body?.path || '').trim();
308
+ const mappings = parseLocalDeviceMappings(req.body?.mappings);
309
+ if (!profilePath) throw new HomeConnectError('The local profile folder is required.', { code: 'LOCAL_PROFILE_PATH_REQUIRED' });
310
+ if (!mappings.length) throw new HomeConnectError('Scan the folder and complete every Host/IP assignment first.', { code: 'LOCAL_MAPPING_HOST_REQUIRED' });
311
+ await Promise.all(mappings.map(mapping => {
312
+ if (!mapping.host) throw new HomeConnectError(`Host/IP is missing for ${mapping.profileId}.`, { code: 'LOCAL_MAPPING_HOST_REQUIRED' });
313
+ return assertLocalHost(mapping.host);
314
+ }));
315
+ const profileJson = exportLocalProfileJson({ profilePath, mappings });
316
+ return res.json({ ok: true, profileJson, profiles: mappings.length });
317
+ } catch (error) {
318
+ return res.status(error.status || 400).json({ ok: false, error: errorPayload(error) });
319
+ }
320
+ });
321
+ }
322
+
323
+ RED.httpAdmin.get('/home-connect/oauth/:id/start', writePermission, async (req, res) => {
324
+ setSecurityHeaders(res);
325
+ try {
326
+ const config = RED.nodes.getNode(req.params.id);
327
+ if (!config || config.type !== 'home-connect-config') throw new HomeConnectError('Configuration node not found.', { code: 'CONFIG_NOT_FOUND', status: 404 });
328
+ if (!config.client.requiresOAuth) throw new HomeConnectError('OAuth is not required for local or built-in simulator connections.', { code: 'OAUTH_NOT_REQUIRED' });
329
+ if (!config.credentials?.clientId || !config.credentials?.clientSecret || !config.redirectUri) {
330
+ throw new HomeConnectError('Client ID, client secret and redirect URI are required.', { code: 'OAUTH_CONFIGURATION_INCOMPLETE' });
331
+ }
332
+ const state = createState();
333
+ for (const [key, value] of pendingOAuth) if (Date.now() - value.createdAt > OAUTH_TTL) pendingOAuth.delete(key);
334
+ pendingOAuth.set(state, { nodeId: config.id, createdAt: Date.now() });
335
+ const url = buildAuthorizationUrl({
336
+ baseUrl: config.client.baseUrl,
337
+ clientId: config.credentials.clientId,
338
+ redirectUri: config.redirectUri,
339
+ scope: config.scope,
340
+ state
341
+ });
342
+ res.redirect(url);
343
+ } catch (error) {
344
+ sendOAuthError(res, error);
345
+ }
346
+ });
347
+
348
+ RED.httpAdmin.get('/home-connect/config/:id/test', readPermission, async (req, res) => {
349
+ try {
350
+ const config = RED.nodes.getNode(req.params.id);
351
+ if (!config || config.type !== 'home-connect-config') throw new HomeConnectError('Configuration node not found.', { code: 'CONFIG_NOT_FOUND', status: 404 });
352
+ const appliances = config.environment === 'local'
353
+ ? await config.client.discoverAll(true)
354
+ : await config.client.listHomeAppliances();
355
+ res.json({ ok: true, environment: config.environment, appliances });
356
+ } catch (error) {
357
+ res.status(error.status || 400).json({ ok: false, error: errorPayload(error) });
358
+ }
359
+ });
360
+
361
+ RED.httpAdmin.get('/home-connect/config/:id/appliances', readPermission, async (req, res) => {
362
+ try {
363
+ const config = RED.nodes.getNode(req.params.id);
364
+ if (!config || config.type !== 'home-connect-config') throw new HomeConnectError('Configuration node not found.', { code: 'CONFIG_NOT_FOUND', status: 404 });
365
+ const appliances = config.environment === 'local'
366
+ ? (await config.client.listHomeAppliances()).map(appliance => ({ appliance }))
367
+ : await config.client.discoverAll(false);
368
+ res.json({
369
+ appliances: appliances.map(entry => entry?.appliance
370
+ ? { ...entry, appliance: { ...entry.appliance, displayLabel: formatDeviceLabel(entry.appliance, config.language) } }
371
+ : entry)
372
+ });
373
+ } catch (error) {
374
+ res.status(error.status || 400).json({ error: errorPayload(error) });
375
+ }
376
+ });
377
+
378
+ if (RED.httpNode) {
379
+ RED.httpNode.get('/home-connect/oauth/callback', async (req, res) => {
380
+ setSecurityHeaders(res);
381
+ try {
382
+ if (typeof req.query.state !== 'string' || !req.query.state) {
383
+ throw new HomeConnectError('OAuth callback is missing state.', { code: 'OAUTH_STATE_INVALID' });
384
+ }
385
+ const state = req.query.state;
386
+ const pending = pendingOAuth.get(state);
387
+ pendingOAuth.delete(state);
388
+ if (!pending || Date.now() - pending.createdAt > OAUTH_TTL) {
389
+ throw new HomeConnectError('OAuth state is invalid or expired.', { code: 'OAUTH_STATE_INVALID' });
390
+ }
391
+ const config = RED.nodes.getNode(pending.nodeId);
392
+ if (!config || config.type !== 'home-connect-config') throw new HomeConnectError('Configuration node is no longer available.', { code: 'CONFIG_NOT_FOUND', status: 404 });
393
+ if (typeof req.query.error === 'string' && req.query.error) {
394
+ const description = typeof req.query.error_description === 'string' && req.query.error_description
395
+ ? req.query.error_description
396
+ : req.query.error;
397
+ throw new HomeConnectError(`Home Connect rejected the authorization request: ${description}`, {
398
+ code: req.query.error,
399
+ status: 400
400
+ });
401
+ }
402
+ if (typeof req.query.code !== 'string' || !req.query.code) {
403
+ throw new HomeConnectError('OAuth callback is missing the authorization code.', { code: 'OAUTH_CALLBACK_INVALID' });
404
+ }
405
+ await config.client.exchangeCode(req.query.code);
406
+ res.type('html').send(oauthPage({
407
+ title: 'Home Connect authorized',
408
+ heading: 'Home Connect authorized',
409
+ message: 'The refresh token was stored in the Node-RED credential store. You can close this window and return to Node-RED.',
410
+ success: true
411
+ }));
412
+ } catch (error) {
413
+ sendOAuthError(res, error);
414
+ }
415
+ });
416
+ }
417
+ }
418
+
419
+ module.exports = function homeConnectNodes(RED) {
420
+ function HomeConnectConfigNode(config) {
421
+ RED.nodes.createNode(this, config);
422
+ this.name = config.name;
423
+ this.environment = config.environment || 'production';
424
+ this.redirectUri = config.redirectUri;
425
+ this.scope = config.scope || DEFAULT_SCOPE;
426
+ this.language = config.language || 'en-GB';
427
+ this.allowWrite = this.environment !== 'local' && (config.allowWrite === true || config.allowWrite === 'true');
428
+ this.localProfilePath = config.localProfilePath || '';
429
+ this.localDeviceMappings = parseLocalDeviceMappings(config.localDeviceMappings);
430
+ this.localProtocolTrace = config.localProtocolTrace === true || config.localProtocolTrace === 'true';
431
+ this.localProtocolTracePath = String(config.localProtocolTracePath || '').trim();
432
+ if (this.localProtocolTrace && this.localProtocolTracePath && !path.isAbsolute(this.localProtocolTracePath)) {
433
+ throw new HomeConnectError('Protocol trace path must be absolute on the Node-RED host.', { code: 'LOCAL_TRACE_PATH_INVALID' });
434
+ }
435
+ this.requestTimeout = Math.max(1000, Number(config.requestTimeout || 15000));
436
+ this.maxRequestsPerDay = integerInRange(config.maxRequestsPerDay, DEFAULT_MAX_REQUESTS, 1, 1000);
437
+ this.minimumRequestInterval = integerInRange(config.minimumRequestInterval, DEFAULT_MINIMUM_INTERVAL, 100, 60000);
438
+ this.healthMonitor = new HealthMonitor();
439
+
440
+ if (this.environment === 'builtin-simulator') {
441
+ this.client = new BuiltinSimulator({ language: this.language });
442
+ } else if (this.environment === 'local') {
443
+ this.client = new LocalHomeConnectClient({
444
+ // A configured folder is the primary source. The optional credential JSON
445
+ // remains stored but is ignored until the folder field is cleared.
446
+ profileJson: this.localProfilePath ? undefined : this.credentials?.localProfileJson,
447
+ profilePath: this.localProfilePath,
448
+ mappings: this.localDeviceMappings,
449
+ requireMapping: Boolean(this.localProfilePath),
450
+ requestTimeout: this.requestTimeout,
451
+ traceEnabled: this.localProtocolTrace,
452
+ tracePath: this.localProtocolTracePath,
453
+ identity: this.id
454
+ });
455
+ } else {
456
+ const rateLimitKey = `${this.environment}\0${this.credentials?.clientId || this.id}`;
457
+ const rateLimitStore = RED.settings?.userDir
458
+ ? createFileRateLimitStore(
459
+ path.join(RED.settings.userDir, '.home-connect'),
460
+ rateLimitKey
461
+ )
462
+ : undefined;
463
+ const rateLimiter = useSharedRateLimiter(rateLimitKey, this.id, {
464
+ maxRequests: this.maxRequestsPerDay,
465
+ minimumIntervalMs: this.minimumRequestInterval,
466
+ store: rateLimitStore
467
+ });
468
+ this.releaseRateLimiter = () => releaseSharedRateLimiter(rateLimitKey, this.id);
469
+ this.client = new HomeConnectClient({
470
+ environment: this.environment,
471
+ clientId: this.credentials?.clientId,
472
+ clientSecret: this.credentials?.clientSecret,
473
+ redirectUri: this.redirectUri,
474
+ scope: this.scope,
475
+ language: this.language,
476
+ requestTimeout: this.requestTimeout,
477
+ rateLimiter,
478
+ refreshToken: this.credentials?.refreshToken,
479
+ onToken: tokens => {
480
+ const credentials = { ...(this.credentials || {}) };
481
+ if (tokens.refreshToken) credentials.refreshToken = tokens.refreshToken;
482
+ this.credentials = credentials;
483
+ RED.nodes.addCredentials(this.id, credentials);
484
+ }
485
+ });
486
+ }
487
+
488
+ this.on('close', (_removed, done) => {
489
+ this.client.close?.();
490
+ this.releaseRateLimiter?.();
491
+ done?.();
492
+ });
493
+ }
494
+
495
+ RED.nodes.registerType('home-connect-config', HomeConnectConfigNode, {
496
+ credentials: {
497
+ clientId: { type: 'text' },
498
+ clientSecret: { type: 'password' },
499
+ refreshToken: { type: 'password' },
500
+ localProfileJson: { type: 'password' }
501
+ }
502
+ });
503
+
504
+ function requireConfig(node, id) {
505
+ const config = RED.nodes.getNode(id);
506
+ if (!config) {
507
+ node.status({ fill: 'red', shape: 'ring', text: 'configuration missing' });
508
+ throw new HomeConnectError('Home Connect configuration is missing.', { code: 'CONFIG_MISSING' });
509
+ }
510
+ return config;
511
+ }
512
+
513
+ function sendError(node, msg, send, done, error) {
514
+ const output = RED.util.cloneMessage(msg || {});
515
+ delete output.confirm;
516
+ output.payload = errorPayload(error);
517
+ output.homeConnect = { ok: false, error: output.payload };
518
+ const assessment = node.healthMonitor?.assessError(error) || new HealthMonitor().assessError(error);
519
+ attachHealth(output, assessment);
520
+ statusForHealth(node, assessment, output.payload.code || 'error');
521
+ node.error(error);
522
+ send([null, output]);
523
+ done?.();
524
+ }
525
+
526
+ function HomeConnectDiscoverNode(config) {
527
+ RED.nodes.createNode(this, config);
528
+ this.connection = config.connection;
529
+ this.mode = config.mode || 'all';
530
+ this.haId = config.haId;
531
+ const connection = requireConfig(this, this.connection);
532
+ this.healthMonitor = connection.healthMonitor;
533
+ this.on('input', async (msg, send, done) => {
534
+ send = send || this.send.bind(this);
535
+ try {
536
+ this.status({ fill: 'blue', shape: 'dot', text: 'discovering' });
537
+ const haId = msg.haId || this.haId;
538
+ const payload = this.mode === 'all' || !haId
539
+ ? await connection.client.discoverAll(msg.force === true)
540
+ : await connection.client.discoverAppliance(haId, msg.force === true);
541
+ msg.payload = payload;
542
+ msg.homeConnect = { ok: true, operation: 'discover', environment: connection.environment };
543
+ const assessment = connection.healthMonitor.assessPayload(payload, { haId, operation: 'discover' });
544
+ attachHealth(msg, assessment);
545
+ statusForHealth(this, assessment, Array.isArray(payload) ? `${payload.length} appliances` : 'discovered');
546
+ send([msg, null]);
547
+ done?.();
548
+ } catch (error) {
549
+ sendError(this, msg, send, done, error);
550
+ }
551
+ });
552
+ }
553
+ RED.nodes.registerType('home-connect-discover', HomeConnectDiscoverNode);
554
+
555
+ function HomeConnectReadNode(config) {
556
+ RED.nodes.createNode(this, config);
557
+ this.connection = config.connection;
558
+ this.operation = READ_OPERATIONS.includes(config.operation) ? config.operation : 'snapshot';
559
+ this.haId = config.haId;
560
+ this.key = config.key;
561
+ this.subKey = config.subKey;
562
+ const connection = requireConfig(this, this.connection);
563
+ this.healthMonitor = connection.healthMonitor;
564
+ this.on('input', async (msg, send, done) => {
565
+ send = send || this.send.bind(this);
566
+ try {
567
+ const operation = msg.operation || this.operation;
568
+ const haId = msg.haId || this.haId;
569
+ msg.haId = haId;
570
+ const key = msg.key ?? this.key;
571
+ const subKey = msg.subKey ?? this.subKey;
572
+ if (!READ_OPERATIONS.includes(operation)) throw new HomeConnectError(`Unsupported read operation: ${operation}`, { code: 'READ_ACTION_NOT_ALLOWED' });
573
+ this.status({ fill: 'blue', shape: 'dot', text: operation });
574
+ msg.payload = await connection.client.read(haId, operation, key, subKey);
575
+ msg.haId = haId;
576
+ msg.homeConnect = { ok: true, operation, environment: connection.environment, at: new Date().toISOString() };
577
+ const assessment = connection.healthMonitor.assessPayload(msg.payload, { haId, operation });
578
+ attachHealth(msg, assessment);
579
+ statusForHealth(this, assessment, operation);
580
+ send([msg, null]);
581
+ done?.();
582
+ } catch (error) {
583
+ sendError(this, msg, send, done, error);
584
+ }
585
+ });
586
+ }
587
+ RED.nodes.registerType('home-connect-read', HomeConnectReadNode);
588
+
589
+ function HomeConnectEventsNode(config) {
590
+ RED.nodes.createNode(this, config);
591
+ this.connection = config.connection;
592
+ this.haId = config.haId || '*';
593
+ const connection = requireConfig(this, this.connection);
594
+ this.healthMonitor = connection.healthMonitor;
595
+ let closeStream;
596
+ const start = haId => {
597
+ closeStream?.();
598
+ this.status({ fill: 'yellow', shape: 'ring', text: 'connecting' });
599
+ closeStream = connection.client.openEvents(haId, event => {
600
+ const assessment = connection.healthMonitor.assessEvent(event);
601
+ statusForHealth(this, assessment, event.event || 'event');
602
+ const output = attachHealth({
603
+ topic: event.event,
604
+ haId: event.haId,
605
+ payload: event,
606
+ homeConnect: { ok: true, operation: 'event', environment: connection.environment }
607
+ }, assessment);
608
+ this.send([output, null]);
609
+ }, error => {
610
+ const assessment = connection.healthMonitor.assessError(error);
611
+ statusForHealth(this, assessment, 'reconnecting');
612
+ this.send([null, attachHealth({
613
+ haId: error?.haId,
614
+ payload: errorPayload(error),
615
+ homeConnect: { ok: false, operation: 'event', environment: connection.environment }
616
+ }, assessment)]);
617
+ });
618
+ };
619
+ start(this.haId);
620
+ this.on('input', (msg, _send, done) => {
621
+ start(msg.haId || this.haId);
622
+ done?.();
623
+ });
624
+ this.on('close', (_removed, done) => {
625
+ closeStream?.();
626
+ done?.();
627
+ });
628
+ }
629
+ RED.nodes.registerType('home-connect-events', HomeConnectEventsNode);
630
+
631
+ function HomeConnectWriteNode(config) {
632
+ RED.nodes.createNode(this, config);
633
+ this.connection = config.connection;
634
+ this.action = config.action || 'from-message';
635
+ this.haId = config.haId;
636
+ this.dryRun = config.dryRun !== false;
637
+ this.confirmationMode = config.confirmationMode || 'token';
638
+ this.confirmationToken = this.credentials?.confirmationToken || 'CONFIRM';
639
+ this.allowedApplianceTypes = String(config.allowedApplianceTypes || '').split(',').map(value => value.trim()).filter(Boolean);
640
+ const connection = requireConfig(this, this.connection);
641
+ this.healthMonitor = connection.healthMonitor;
642
+
643
+ this.on('input', async (msg, send, done) => {
644
+ send = send || this.send.bind(this);
645
+ try {
646
+ if (connection.environment === 'local') {
647
+ throw new HomeConnectError('Local Home Connect access is deliberately read-only.', { code: 'LOCAL_READ_ONLY', status: 403 });
648
+ }
649
+ if (!connection.allowWrite) throw new HomeConnectError('Write access is disabled in the configuration node.', { code: 'WRITE_DISABLED' });
650
+ const input = msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload) ? msg.payload : {};
651
+ const action = this.action === 'from-message' ? (msg.action || input.action) : this.action;
652
+ if (!WRITE_ACTIONS.includes(action)) throw new HomeConnectError(`Unsupported write action: ${action}`, { code: 'WRITE_ACTION_NOT_ALLOWED' });
653
+ const request = {
654
+ action,
655
+ haId: msg.haId || input.haId || this.haId,
656
+ program: msg.program || input.program,
657
+ key: msg.key || input.key,
658
+ value: msg.value !== undefined ? msg.value : input.value,
659
+ options: optionArray(msg.options || input.options)
660
+ };
661
+ if (!request.haId) throw new HomeConnectError('haId is required.', { code: 'HAID_REQUIRED' });
662
+ if (this.confirmationMode === 'token' && msg.confirm !== this.confirmationToken && input.confirm !== this.confirmationToken) {
663
+ throw new HomeConnectError('Write confirmation token is missing or invalid.', { code: 'CONFIRMATION_REQUIRED' });
664
+ }
665
+ this.status({ fill: 'yellow', shape: 'dot', text: this.dryRun ? 'validating' : 'writing' });
666
+ const capability = await connection.client.discoverAppliance(request.haId, true);
667
+ if (this.allowedApplianceTypes.length && !this.allowedApplianceTypes.includes(capability.appliance.type)) {
668
+ throw new HomeConnectError(`Writes to appliance type ${capability.appliance.type} are not allowed by this node.`, { code: 'APPLIANCE_TYPE_NOT_ALLOWED' });
669
+ }
670
+ validateWrite(capability, request);
671
+ const result = this.dryRun
672
+ ? { accepted: false, dryRun: true, validated: true, request, capabilityTimestamp: capability.discoveredAt }
673
+ : await connection.client.write(request, capability);
674
+ delete msg.confirm;
675
+ msg.payload = result;
676
+ msg.homeConnect = { ok: true, operation: 'write', action, dryRun: this.dryRun, environment: connection.environment };
677
+ const assessment = connection.healthMonitor.current(request.haId);
678
+ attachHealth(msg, assessment);
679
+ statusForHealth(this, assessment, this.dryRun ? 'dry-run valid' : 'written');
680
+ send([msg, null]);
681
+ done?.();
682
+ } catch (error) {
683
+ sendError(this, msg, send, done, error);
684
+ }
685
+ });
686
+ }
687
+ RED.nodes.registerType('home-connect-write', HomeConnectWriteNode, {
688
+ credentials: { confirmationToken: { type: 'password' } }
689
+ });
690
+
691
+ registerRoutes(RED);
692
+ };
693
+
694
+ module.exports.optionArray = optionArray;
695
+ module.exports.mergeLocalTestMappings = mergeLocalTestMappings;
696
+ module.exports.resolveLocalTestProfile = resolveLocalTestProfile;
697
+ module.exports.errorPayload = errorPayload;
698
+ module.exports.attachHealth = attachHealth;
699
+ module.exports.BUILD_INFO = BUILD_INFO;