@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.
- package/BUILD-INFO.json +23 -0
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/NOTICE +19 -0
- package/README.de.md +326 -0
- package/README.md +325 -2
- package/SECURITY.md +47 -0
- package/docs/API-COVERAGE.md +59 -0
- package/docs/ARCHITECTURE.md +95 -0
- package/docs/DASHBOARD.de.md +121 -0
- package/docs/DASHBOARD.md +121 -0
- package/docs/FIRST-PUSH.md +42 -0
- package/docs/LOCAL-LAN.de.md +150 -0
- package/docs/LOCAL-LAN.md +150 -0
- package/docs/MESSAGE-API.md +111 -0
- package/docs/RELEASE.md +78 -0
- package/docs/TROUBLESHOOTING.de.md +230 -0
- package/docs/TROUBLESHOOTING.md +230 -0
- package/docs/images/README.md +10 -0
- package/docs/images/dashboard-details-en.png +0 -0
- package/docs/images/dashboard-overview-en.png +0 -0
- package/docs/official-api-contract.json +418 -0
- package/examples/01-first-steps-device-list.json +148 -0
- package/examples/02-events.json +165 -0
- package/examples/03-safe-read-write.json +155 -0
- package/examples/04-local-read-only.json +214 -0
- package/examples/dashboard-dishwasher-washer-oven.json +628 -0
- package/examples/dashboard-screenshot-simulated.json +405 -0
- package/examples/demo-home-connect-flowfuse-1.34.1-scriptless.json +390 -0
- package/lib/client.js +600 -0
- package/lib/constants.js +66 -0
- package/lib/dashboard-i18n.js +255 -0
- package/lib/device-label.js +39 -0
- package/lib/errors.js +51 -0
- package/lib/health.js +525 -0
- package/lib/local-client.js +1014 -0
- package/lib/local-crypto.js +122 -0
- package/lib/local-diagnostics.js +115 -0
- package/lib/local-profile.js +706 -0
- package/lib/local-trace.js +103 -0
- package/lib/oauth.js +23 -0
- package/lib/rate-limiter.js +186 -0
- package/lib/simulator.js +290 -0
- package/lib/sse.js +58 -0
- package/lib/validation.js +194 -0
- package/lib/websocket-transport.js +374 -0
- package/nodes/home-connect.html +795 -0
- package/nodes/home-connect.js +699 -0
- package/nodes/locales/de/home-connect.html +19 -0
- package/nodes/locales/de/home-connect.json +10 -0
- package/nodes/locales/en-US/home-connect.json +10 -0
- 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
|
+
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
|
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;
|