@impact0815/node-red-contrib-home-connect 0.0.0-stage → 0.2.1

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 (55) hide show
  1. package/BUILD-INFO.json +23 -0
  2. package/CHANGELOG.md +53 -0
  3. package/LICENSE +21 -0
  4. package/NOTICE +19 -0
  5. package/README.de.md +344 -0
  6. package/README.md +343 -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/GITHUB-UPDATE.md +76 -0
  14. package/docs/LOCAL-LAN.de.md +150 -0
  15. package/docs/LOCAL-LAN.md +150 -0
  16. package/docs/MESSAGE-API.md +111 -0
  17. package/docs/RELEASE.md +91 -0
  18. package/docs/TROUBLESHOOTING.de.md +230 -0
  19. package/docs/TROUBLESHOOTING.md +230 -0
  20. package/docs/images/README.md +10 -0
  21. package/docs/images/dashboard-details-en.png +0 -0
  22. package/docs/images/dashboard-overview-en.png +0 -0
  23. package/docs/official-api-contract.json +418 -0
  24. package/examples/01-first-steps-device-list.json +148 -0
  25. package/examples/02-events.json +165 -0
  26. package/examples/03-safe-read-write.json +155 -0
  27. package/examples/04-local-read-only.json +214 -0
  28. package/examples/README.de.md +62 -0
  29. package/examples/README.md +62 -0
  30. package/examples/dashboard-dishwasher-washer-oven.json +628 -0
  31. package/examples/dashboard-screenshot-simulated.json +405 -0
  32. package/examples/demo-home-connect-flowfuse-1.34.1-scriptless.json +390 -0
  33. package/lib/client.js +600 -0
  34. package/lib/constants.js +66 -0
  35. package/lib/dashboard-i18n.js +255 -0
  36. package/lib/device-label.js +39 -0
  37. package/lib/errors.js +51 -0
  38. package/lib/health.js +525 -0
  39. package/lib/local-client.js +1014 -0
  40. package/lib/local-crypto.js +122 -0
  41. package/lib/local-diagnostics.js +115 -0
  42. package/lib/local-profile.js +706 -0
  43. package/lib/local-trace.js +103 -0
  44. package/lib/oauth.js +23 -0
  45. package/lib/rate-limiter.js +186 -0
  46. package/lib/simulator.js +290 -0
  47. package/lib/sse.js +58 -0
  48. package/lib/validation.js +194 -0
  49. package/lib/websocket-transport.js +374 -0
  50. package/nodes/home-connect.html +795 -0
  51. package/nodes/home-connect.js +699 -0
  52. package/nodes/locales/de/home-connect.html +19 -0
  53. package/nodes/locales/de/home-connect.json +10 -0
  54. package/nodes/locales/en-US/home-connect.json +10 -0
  55. package/package.json +76 -4
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const { TextDecoder } = require('node:util');
5
+ const { HomeConnectError } = require('./errors');
6
+
7
+ function decodeBase64Url(value, label) {
8
+ if (typeof value !== 'string' || !value.trim()) {
9
+ throw new HomeConnectError(`${label} is required.`, { code: 'LOCAL_PROFILE_KEY_MISSING' });
10
+ }
11
+ try {
12
+ const text = value.trim();
13
+ if (!/^[A-Za-z0-9_-]+={0,2}$/.test(text) || text.replace(/=/g, '').length % 4 === 1) throw new Error('syntax');
14
+ const buffer = Buffer.from(text.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
15
+ if (!buffer.length) throw new Error('empty');
16
+ if (buffer.toString('base64url') !== text.replace(/=+$/, '')) throw new Error('canonical');
17
+ return buffer;
18
+ } catch (cause) {
19
+ throw new HomeConnectError(`${label} is not valid Base64URL.`, { code: 'LOCAL_PROFILE_KEY_INVALID', cause });
20
+ }
21
+ }
22
+
23
+ function hmac(key, ...parts) {
24
+ const value = crypto.createHmac('sha256', key);
25
+ for (const part of parts) value.update(part);
26
+ return value.digest();
27
+ }
28
+
29
+ class AesMessageCrypto {
30
+ constructor(options = {}) {
31
+ this.psk = Buffer.isBuffer(options.psk) ? Buffer.from(options.psk) : decodeBase64Url(options.psk64, 'PSK');
32
+ this.iv = Buffer.isBuffer(options.iv) ? Buffer.from(options.iv) : decodeBase64Url(options.iv64, 'IV');
33
+ if (this.psk.length !== 32) {
34
+ throw new HomeConnectError('The local Home Connect PSK must contain exactly 32 bytes.', {
35
+ code: 'LOCAL_PROFILE_KEY_LENGTH_INVALID', details: { transport: 'AES' }
36
+ });
37
+ }
38
+ if (this.iv.length !== 16) {
39
+ throw new HomeConnectError('The local AES IV must contain exactly 16 bytes.', { code: 'LOCAL_PROFILE_IV_INVALID' });
40
+ }
41
+ this.encryptionKey = hmac(this.psk, Buffer.from('ENC'));
42
+ this.authenticationKey = hmac(this.psk, Buffer.from('MAC'));
43
+ this.randomBytes = options.randomBytes || crypto.randomBytes;
44
+ this.reset();
45
+ }
46
+
47
+ reset() {
48
+ this.lastTxHmac = Buffer.alloc(16);
49
+ this.lastRxHmac = Buffer.alloc(16);
50
+ this.encryptor = crypto.createCipheriv('aes-256-cbc', this.encryptionKey, this.iv);
51
+ this.decryptor = crypto.createDecipheriv('aes-256-cbc', this.encryptionKey, this.iv);
52
+ this.encryptor.setAutoPadding(false);
53
+ this.decryptor.setAutoPadding(false);
54
+ }
55
+
56
+ _messageHmac(direction, previous, ciphertext) {
57
+ return hmac(this.authenticationKey, this.iv, Buffer.from([direction]), previous, ciphertext).subarray(0, 16);
58
+ }
59
+
60
+ encrypt(value) {
61
+ const clear = Buffer.from(String(value), 'utf8');
62
+ let paddingLength = 16 - (clear.length % 16);
63
+ if (paddingLength === 1) paddingLength += 16;
64
+ const randomLength = paddingLength - 2;
65
+ const padding = Buffer.concat([
66
+ Buffer.from([0]),
67
+ randomLength ? this.randomBytes(randomLength) : Buffer.alloc(0),
68
+ Buffer.from([paddingLength])
69
+ ]);
70
+ const ciphertext = this.encryptor.update(Buffer.concat([clear, padding]));
71
+ this.lastTxHmac = this._messageHmac(0x45, this.lastTxHmac, ciphertext);
72
+ return Buffer.concat([ciphertext, this.lastTxHmac]);
73
+ }
74
+
75
+ decrypt(value) {
76
+ const frame = Buffer.from(value);
77
+ if (frame.length < 32 || frame.length % 16 !== 0) {
78
+ throw new HomeConnectError('Invalid local AES frame length.', { code: 'LOCAL_CRYPTO_FRAME_INVALID' });
79
+ }
80
+ const ciphertext = frame.subarray(0, -16);
81
+ const received = frame.subarray(-16);
82
+ const expected = this._messageHmac(0x43, this.lastRxHmac, ciphertext);
83
+ if (!crypto.timingSafeEqual(received, expected)) {
84
+ throw new HomeConnectError('Local AES frame authentication failed; reconnect is required.', { code: 'LOCAL_CRYPTO_AUTH_FAILED' });
85
+ }
86
+ this.lastRxHmac = Buffer.from(received);
87
+ const clear = this.decryptor.update(ciphertext);
88
+ const paddingLength = clear.at(-1);
89
+ if (paddingLength < 2 || paddingLength > 17 || paddingLength > clear.length) {
90
+ throw new HomeConnectError('Invalid local AES frame padding; reconnect is required.', { code: 'LOCAL_CRYPTO_PADDING_INVALID' });
91
+ }
92
+ if (clear[clear.length - paddingLength] !== 0) {
93
+ throw new HomeConnectError('Invalid local AES frame padding marker; reconnect is required.', { code: 'LOCAL_CRYPTO_PADDING_INVALID' });
94
+ }
95
+ try {
96
+ return new TextDecoder('utf-8', { fatal: true }).decode(clear.subarray(0, -paddingLength));
97
+ } catch (cause) {
98
+ throw new HomeConnectError('Local AES frame is not valid UTF-8; reconnect is required.', { code: 'LOCAL_CRYPTO_TEXT_INVALID', cause });
99
+ }
100
+ }
101
+ }
102
+
103
+ function validateLocalKeyMaterial(profile) {
104
+ const transport = String(profile?.connectionType || '').toUpperCase();
105
+ const psk = decodeBase64Url(profile?.psk64, 'PSK');
106
+ if (psk.length !== 32) {
107
+ throw new HomeConnectError('The local Home Connect PSK must contain exactly 32 bytes.', {
108
+ code: 'LOCAL_PROFILE_KEY_LENGTH_INVALID', details: { transport }
109
+ });
110
+ }
111
+ if (transport === 'AES') {
112
+ const iv = decodeBase64Url(profile?.iv64, 'IV');
113
+ if (iv.length !== 16) {
114
+ throw new HomeConnectError('The local AES IV must contain exactly 16 bytes.', {
115
+ code: 'LOCAL_PROFILE_IV_INVALID', details: { transport }
116
+ });
117
+ }
118
+ }
119
+ return { transport, pskLength: psk.length, ivLength: transport === 'AES' ? 16 : 0 };
120
+ }
121
+
122
+ module.exports = { AesMessageCrypto, decodeBase64Url, hmac, validateLocalKeyMaterial };
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ const LOCAL_TEST_STAGES = Object.freeze([
4
+ Object.freeze({ id: 'ip-reachable', label: 'IP reachable', labelDe: 'IP erreichbar' }),
5
+ Object.freeze({ id: 'profile-loaded', label: 'Profile loaded', labelDe: 'Profil geladen' }),
6
+ Object.freeze({ id: 'transport-recognized', label: 'Transport detected', labelDe: 'Transport erkannt' }),
7
+ Object.freeze({ id: 'websocket-connected', label: 'WebSocket connected', labelDe: 'WebSocket verbunden' }),
8
+ Object.freeze({ id: 'session-initialized', label: 'Session initialized', labelDe: 'Session initialisiert' }),
9
+ Object.freeze({ id: 'services-recognized', label: 'Services detected', labelDe: 'Services erkannt' }),
10
+ Object.freeze({ id: 'readonly-data-received', label: 'Read-only data received', labelDe: 'Read-only-Daten empfangen' })
11
+ ]);
12
+
13
+ const ERROR_STAGE = Object.freeze({
14
+ LOCAL_PROFILE_PATH_REQUIRED: 'profile-loaded',
15
+ LOCAL_PROFILE_REQUIRED: 'profile-loaded',
16
+ LOCAL_PROFILE_KEY_MISSING: 'profile-loaded',
17
+ LOCAL_PROFILE_KEY_INVALID: 'profile-loaded',
18
+ LOCAL_PROFILE_KEY_LENGTH_INVALID: 'profile-loaded',
19
+ LOCAL_PROFILE_IV_MISSING: 'profile-loaded',
20
+ LOCAL_PROFILE_IV_INVALID: 'profile-loaded',
21
+ LOCAL_MAPPING_PROFILE_NOT_FOUND: 'profile-loaded',
22
+ LOCAL_MAPPING_HOST_REQUIRED: 'ip-reachable',
23
+ LOCAL_HOST_INVALID: 'ip-reachable',
24
+ LOCAL_HOST_NOT_PRIVATE: 'ip-reachable',
25
+ LOCAL_NETWORK_ERROR: 'ip-reachable',
26
+ LOCAL_CONNECT_TIMEOUT: 'ip-reachable',
27
+ LOCAL_CONNECT_FAILED: 'ip-reachable',
28
+ LOCAL_TLS_PSK_UNAVAILABLE: 'transport-recognized',
29
+ LOCAL_PROFILE_KEY_REJECTED: 'transport-recognized',
30
+ LOCAL_TRANSPORT_UNSUPPORTED: 'transport-recognized',
31
+ LOCAL_WEBSOCKET_TIMEOUT: 'websocket-connected',
32
+ LOCAL_WEBSOCKET_UPGRADE_FAILED: 'websocket-connected',
33
+ LOCAL_DISCONNECTED: 'session-initialized',
34
+ LOCAL_HANDSHAKE_TIMEOUT: 'session-initialized',
35
+ LOCAL_HANDSHAKE_INVALID: 'session-initialized',
36
+ LOCAL_PROFILE_AUTHENTICATION_FAILED: 'session-initialized',
37
+ LOCAL_SERVICES_INVALID: 'services-recognized',
38
+ LOCAL_PROTOCOL_RESPONSE_MISMATCH: 'readonly-data-received',
39
+ LOCAL_PROTOCOL_REJECTED: 'services-recognized',
40
+ LOCAL_REQUEST_TIMEOUT: 'readonly-data-received',
41
+ LOCAL_HEARTBEAT_TIMEOUT: 'readonly-data-received',
42
+ LOCAL_VALUES_UNAVAILABLE: 'readonly-data-received'
43
+ });
44
+
45
+ function safeDetails(details) {
46
+ if (!details || typeof details !== 'object' || Array.isArray(details)) return undefined;
47
+ const allowed = [
48
+ 'transport', 'port', 'path', 'protocol', 'cipher', 'httpStatus', 'httpStatusText',
49
+ 'services', 'serviceCount', 'valueCount', 'profileTransport', 'declaredTransport',
50
+ 'category', 'causeCode', 'resource', 'expectedResource', 'action', 'version', 'deviceCode',
51
+ 'hintDe', 'hintEn', 'readResponses', 'readSource', 'rejectedResources',
52
+ 'featureCount', 'resolvedItemCount', 'unresolvedItemCount', 'valueResolvedItemCount', 'unresolvedValueCount',
53
+ 'statusCount', 'settingsCount', 'programCount', 'unresolvedValues'
54
+ ];
55
+ const result = {};
56
+ for (const key of allowed) {
57
+ const value = details[key];
58
+ if (value === undefined || value === null || value === '') continue;
59
+ if (Array.isArray(value)) result[key] = value.map(item => String(item)).slice(0, 20);
60
+ else if (['string', 'number', 'boolean'].includes(typeof value)) result[key] = value;
61
+ }
62
+ return Object.keys(result).length ? result : undefined;
63
+ }
64
+
65
+ class LocalConnectionDiagnostics {
66
+ constructor() {
67
+ this.states = new Map(LOCAL_TEST_STAGES.map(stage => [stage.id, {
68
+ ...stage,
69
+ status: 'pending'
70
+ }]));
71
+ }
72
+
73
+ pass(id, details) {
74
+ const stage = this.states.get(id);
75
+ if (!stage || stage.status === 'failed') return;
76
+ stage.status = 'ok';
77
+ const safe = safeDetails(details);
78
+ if (safe) stage.details = safe;
79
+ }
80
+
81
+ fail(id, error) {
82
+ const stage = this.states.get(id);
83
+ if (!stage) return;
84
+ stage.status = 'failed';
85
+ stage.code = error?.code || 'UNEXPECTED_ERROR';
86
+ stage.message = error?.message || String(error);
87
+ const safe = safeDetails(error?.details);
88
+ if (safe) stage.details = safe;
89
+ let afterFailure = false;
90
+ for (const definition of LOCAL_TEST_STAGES) {
91
+ if (definition.id === id) {
92
+ afterFailure = true;
93
+ continue;
94
+ }
95
+ const current = this.states.get(definition.id);
96
+ if (afterFailure && current.status === 'pending') current.status = 'skipped';
97
+ }
98
+ }
99
+
100
+ failFromError(error) {
101
+ const explicit = String(error?.details?.stage || '');
102
+ const resource = String(error?.details?.resource || '');
103
+ const protocolReadFailure = error?.code === 'LOCAL_PROTOCOL_REJECTED' && resource.startsWith('/ro/');
104
+ const id = this.states.has(explicit)
105
+ ? explicit
106
+ : (protocolReadFailure ? 'readonly-data-received' : (ERROR_STAGE[error?.code] || 'session-initialized'));
107
+ this.fail(id, error);
108
+ }
109
+
110
+ snapshot() {
111
+ return LOCAL_TEST_STAGES.map(stage => ({ ...this.states.get(stage.id) }));
112
+ }
113
+ }
114
+
115
+ module.exports = { LOCAL_TEST_STAGES, LocalConnectionDiagnostics, ERROR_STAGE };