@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,194 @@
1
+ 'use strict';
2
+
3
+ const { HomeConnectError } = require('./errors');
4
+ const { WRITE_ACTIONS } = require('./constants');
5
+
6
+ function constraintsOf(item) {
7
+ return (item && item.constraints) || {};
8
+ }
9
+
10
+ function assertType(value, type, label) {
11
+ if (!type) return;
12
+ const normalized = String(type).toLowerCase();
13
+ let valid = true;
14
+ if (normalized === 'boolean') valid = typeof value === 'boolean';
15
+ else if (['integer', 'int'].includes(normalized)) valid = Number.isInteger(value);
16
+ else if (['double', 'float', 'number'].includes(normalized)) valid = typeof value === 'number' && Number.isFinite(value);
17
+ else if (normalized === 'string') valid = typeof value === 'string';
18
+ if (!valid) {
19
+ throw new HomeConnectError(`${label} must have type ${type}.`, {
20
+ code: 'VALUE_INVALID_TYPE', details: { value, expectedType: type }
21
+ });
22
+ }
23
+ }
24
+
25
+ function assertValue(value, constraints, label, type) {
26
+ if (value === undefined) {
27
+ throw new HomeConnectError(`${label} requires a value.`, { code: 'VALUE_REQUIRED' });
28
+ }
29
+ assertType(value, type, label);
30
+ const c = constraints || {};
31
+ const allowed = c.allowedvalues || c.allowedValues;
32
+ if (Array.isArray(allowed) && !allowed.includes(value)) {
33
+ throw new HomeConnectError(`${label} must be one of: ${allowed.join(', ')}`, {
34
+ code: 'VALUE_NOT_ALLOWED', details: { value, allowed }
35
+ });
36
+ }
37
+ if (typeof value === 'number') {
38
+ if (!Number.isFinite(value)) {
39
+ throw new HomeConnectError(`${label} must be a finite number.`, { code: 'VALUE_INVALID_TYPE' });
40
+ }
41
+ if (Number.isFinite(c.min) && value < c.min) {
42
+ throw new HomeConnectError(`${label} must be >= ${c.min}`, { code: 'VALUE_BELOW_MINIMUM' });
43
+ }
44
+ if (Number.isFinite(c.max) && value > c.max) {
45
+ throw new HomeConnectError(`${label} must be <= ${c.max}`, { code: 'VALUE_ABOVE_MAXIMUM' });
46
+ }
47
+ const step = c.stepsize ?? c.stepSize;
48
+ if (Number.isFinite(step) && step > 0) {
49
+ const origin = Number.isFinite(c.min) ? c.min : 0;
50
+ const quotient = (value - origin) / step;
51
+ if (Math.abs(quotient - Math.round(quotient)) > 1e-8) {
52
+ throw new HomeConnectError(`${label} must follow step size ${step}`, { code: 'VALUE_INVALID_STEP' });
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ function assertWritable(item, label) {
59
+ const access = constraintsOf(item).access;
60
+ if (access && String(access).toLowerCase() !== 'readwrite' && String(access).toLowerCase() !== 'write') {
61
+ throw new HomeConnectError(`${label} is read-only.`, {
62
+ code: 'RESOURCE_READ_ONLY', details: { access }
63
+ });
64
+ }
65
+ }
66
+
67
+ function mapItems(section) {
68
+ const items = section && Array.isArray(section.items) ? section.items : [];
69
+ return new Map(items.map(item => [item.key, item]));
70
+ }
71
+
72
+ function statusValue(capability, key) {
73
+ return mapItems(capability.status).get(key)?.value;
74
+ }
75
+
76
+ function findProgram(capability, key) {
77
+ const programs = capability.programs && Array.isArray(capability.programs.available)
78
+ ? capability.programs.available : [];
79
+ return programs.find(program => program.key === key);
80
+ }
81
+
82
+ function assertConnected(capability) {
83
+ if (capability.appliance?.connected === false) {
84
+ throw new HomeConnectError('The appliance is offline.', { code: 'APPLIANCE_OFFLINE' });
85
+ }
86
+ }
87
+
88
+ function assertStartPreconditions(capability) {
89
+ assertConnected(capability);
90
+ const remote = statusValue(capability, 'BSH.Common.Status.RemoteControlActive');
91
+ const startAllowed = statusValue(capability, 'BSH.Common.Status.RemoteControlStartAllowed');
92
+ const local = statusValue(capability, 'BSH.Common.Status.LocalControlActive');
93
+ const operation = statusValue(capability, 'BSH.Common.Status.OperationState');
94
+ if (remote === false) throw new HomeConnectError('Remote control is disabled on the appliance.', { code: 'REMOTE_CONTROL_DISABLED' });
95
+ if (startAllowed === false) throw new HomeConnectError('Remote start is not allowed by the appliance.', { code: 'REMOTE_START_DISABLED' });
96
+ if (local === true) throw new HomeConnectError('Local control is currently active.', { code: 'LOCAL_CONTROL_ACTIVE' });
97
+ const readyStates = [
98
+ 'BSH.Common.EnumType.OperationState.Ready',
99
+ 'BSH.Common.EnumType.OperationState.Inactive',
100
+ 'BSH.Common.EnumType.OperationState.Finished'
101
+ ];
102
+ if (operation && !readyStates.includes(operation)) {
103
+ throw new HomeConnectError(`Appliance is not ready (state: ${operation}).`, { code: 'APPLIANCE_NOT_READY' });
104
+ }
105
+ if (capability.programs?.active) {
106
+ throw new HomeConnectError('Another program is already active.', { code: 'PROGRAM_ALREADY_ACTIVE' });
107
+ }
108
+ }
109
+
110
+ function validateOptions(definitions, supplied, context) {
111
+ if (!Array.isArray(supplied)) {
112
+ throw new HomeConnectError('options must be an array.', { code: 'INVALID_OPTIONS' });
113
+ }
114
+ const available = new Map((definitions || []).map(option => [option.key, option]));
115
+ const seen = new Set();
116
+ for (const option of supplied) {
117
+ if (!option || typeof option.key !== 'string' || !option.key) {
118
+ throw new HomeConnectError('Every option requires a non-empty key.', { code: 'OPTION_KEY_REQUIRED' });
119
+ }
120
+ if (seen.has(option.key)) {
121
+ throw new HomeConnectError(`Option occurs more than once: ${option.key}`, { code: 'OPTION_DUPLICATE' });
122
+ }
123
+ seen.add(option.key);
124
+ const definition = available.get(option.key);
125
+ if (!definition) {
126
+ throw new HomeConnectError(`Option is not available for ${context}: ${option.key}`, { code: 'OPTION_NOT_AVAILABLE' });
127
+ }
128
+ assertWritable(definition, option.key);
129
+ assertValue(option.value, constraintsOf(definition), option.key, definition.type);
130
+ }
131
+ }
132
+
133
+ function validateWrite(capability, request) {
134
+ if (!capability || !capability.appliance) {
135
+ throw new HomeConnectError('No capability information is available for this appliance.', { code: 'CAPABILITIES_MISSING' });
136
+ }
137
+ if (!request || !WRITE_ACTIONS.includes(request.action)) {
138
+ throw new HomeConnectError(`Unsupported write action: ${request?.action}`, { code: 'WRITE_ACTION_NOT_ALLOWED' });
139
+ }
140
+
141
+ if (request.action === 'start-program' || request.action === 'select-program') {
142
+ if (request.action === 'start-program') assertStartPreconditions(capability);
143
+ else assertConnected(capability);
144
+ const program = findProgram(capability, request.program);
145
+ if (!program) throw new HomeConnectError(`Program is not available: ${request.program}`, { code: 'PROGRAM_NOT_AVAILABLE' });
146
+ assertWritable(program, request.program);
147
+ validateOptions(program.options, request.options || [], request.program);
148
+ return;
149
+ }
150
+
151
+ assertConnected(capability);
152
+
153
+ if (request.action === 'stop-program') {
154
+ if (!capability.programs?.active) {
155
+ throw new HomeConnectError('No program is active.', { code: 'NO_ACTIVE_PROGRAM' });
156
+ }
157
+ return;
158
+ }
159
+
160
+ if (request.action === 'set-setting') {
161
+ const setting = mapItems(capability.settings).get(request.key);
162
+ if (!setting) throw new HomeConnectError(`Setting is not available: ${request.key}`, { code: 'SETTING_NOT_AVAILABLE' });
163
+ assertWritable(setting, request.key);
164
+ assertValue(request.value, constraintsOf(setting), request.key, setting.type);
165
+ return;
166
+ }
167
+
168
+ if (request.action === 'command') {
169
+ const command = mapItems(capability.commands).get(request.key);
170
+ if (!command) throw new HomeConnectError(`Command is not available: ${request.key}`, { code: 'COMMAND_NOT_AVAILABLE' });
171
+ assertWritable(command, request.key);
172
+ assertValue(request.value, constraintsOf(command), request.key, command.type);
173
+ return;
174
+ }
175
+
176
+ const selected = request.action === 'set-selected-option';
177
+ const program = selected ? capability.programs?.selected : capability.programs?.active;
178
+ if (!program) {
179
+ throw new HomeConnectError(`No ${selected ? 'selected' : 'active'} program is available.`, {
180
+ code: selected ? 'NO_SELECTED_PROGRAM' : 'NO_ACTIVE_PROGRAM'
181
+ });
182
+ }
183
+ validateOptions(program.options, [{ key: request.key, value: request.value }], program.key);
184
+ }
185
+
186
+ module.exports = {
187
+ assertType,
188
+ assertValue,
189
+ assertWritable,
190
+ validateOptions,
191
+ validateWrite,
192
+ statusValue,
193
+ findProgram
194
+ };
@@ -0,0 +1,374 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const dns = require('node:dns').promises;
5
+ const net = require('node:net');
6
+ const tls = require('node:tls');
7
+ const { EventEmitter } = require('node:events');
8
+ const { HomeConnectError } = require('./errors');
9
+ const { decodeBase64Url } = require('./local-crypto');
10
+
11
+ const WEBSOCKET_PATH = '/homeconnect';
12
+ const TLS_PSK_CIPHERS = 'ECDHE-PSK-CHACHA20-POLY1305:PSK-AES128-CBC-SHA256:PSK-AES256-CBC-SHA:PSK';
13
+
14
+ function cleanHost(value) {
15
+ const host = String(value || '').trim().replace(/^\[|\]$/g, '');
16
+ if (!host || /[\s/@?#]/.test(host) || /^[a-z]+:\/\//i.test(host)) {
17
+ throw new HomeConnectError('A plain local hostname or IP address is required.', { code: 'LOCAL_HOST_INVALID' });
18
+ }
19
+ return host;
20
+ }
21
+
22
+ function privateIp(address) {
23
+ const plain = String(address).split('%')[0].toLowerCase();
24
+ if (net.isIPv4(plain)) {
25
+ const parts = plain.split('.').map(Number);
26
+ return parts[0] === 10 || (parts[0] === 169 && parts[1] === 254)
27
+ || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
28
+ || (parts[0] === 192 && parts[1] === 168);
29
+ }
30
+ if (net.isIPv6(plain)) {
31
+ return plain.startsWith('fe8') || plain.startsWith('fe9')
32
+ || plain.startsWith('fea') || plain.startsWith('feb') || plain.startsWith('fc') || plain.startsWith('fd');
33
+ }
34
+ return false;
35
+ }
36
+
37
+ async function resolveLocalHost(host, lookup = dns.lookup) {
38
+ const plain = cleanHost(host);
39
+ const ip = net.isIP(plain.split('%')[0]);
40
+ const addresses = ip ? [{ address: plain.split('%')[0] }] : await lookup(plain, { all: true, verbatim: true });
41
+ if (!addresses.length || addresses.some(entry => !privateIp(entry.address))) {
42
+ throw new HomeConnectError('The configured appliance host does not resolve exclusively to a private or link-local address.', {
43
+ code: 'LOCAL_HOST_NOT_PRIVATE'
44
+ });
45
+ }
46
+ return { host: plain, address: ip ? plain : addresses[0].address };
47
+ }
48
+
49
+ async function assertLocalHost(host, lookup = dns.lookup) { return (await resolveLocalHost(host, lookup)).host; }
50
+
51
+ function transportError(cause, mode = 'TLS') {
52
+ if (cause instanceof HomeConnectError) return cause;
53
+ const code = String(cause?.code || '');
54
+ const message = String(cause?.message || cause || '');
55
+ const combined = `${code} ${message}`.toLowerCase();
56
+ const details = { transport: String(mode).toUpperCase(), causeCode: code || undefined };
57
+ if (['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'ENETDOWN', 'EAI_AGAIN', 'ENOTFOUND', 'ETIMEDOUT'].includes(code)) {
58
+ return new HomeConnectError('Could not reach the local appliance over the network.', {
59
+ code: 'LOCAL_NETWORK_ERROR',
60
+ details: {
61
+ ...details,
62
+ category: 'network',
63
+ hintDe: code === 'EHOSTUNREACH'
64
+ ? 'Kein Netzwerkpfad zum Gerät. Geräte-Strom/Standby, IP, WLAN, VLAN-Routing und Firewall prüfen.'
65
+ : 'Geräte-Strom/Standby, IP/Hostname, WLAN, VLAN-Routing, Firewall und Zielport prüfen.',
66
+ hintEn: code === 'EHOSTUNREACH'
67
+ ? 'No network path to the appliance. Check power/standby, IP, Wi-Fi, VLAN routing, and firewall.'
68
+ : 'Check appliance power/standby, IP/hostname, Wi-Fi, VLAN routing, firewall, and target port.'
69
+ },
70
+ cause
71
+ });
72
+ }
73
+ if (/psk|pre.?shared|bad record mac|decrypt|unknown identity|identity unknown|handshake failure/.test(combined)) {
74
+ return new HomeConnectError('The local appliance rejected the configured profile key.', {
75
+ code: 'LOCAL_PROFILE_KEY_REJECTED',
76
+ details: {
77
+ ...details, category: 'profile',
78
+ hintDe: 'Profil gehört nicht zu diesem Gerät oder PSK/IV ist unvollständig. Profil neu herunterladen; Schlüssel nie bearbeiten.',
79
+ hintEn: 'The profile does not match this appliance or its PSK/IV is incomplete. Download it again; never edit key material.'
80
+ },
81
+ cause
82
+ });
83
+ }
84
+ if (/no shared cipher|unsupported protocol|protocol version|wrong version|cipher.*unsupported|legacy sigalg/.test(combined)) {
85
+ return new HomeConnectError('The appliance does not support the selected local transport or TLS profile.', {
86
+ code: 'LOCAL_TRANSPORT_UNSUPPORTED',
87
+ details: {
88
+ ...details, category: 'transport',
89
+ hintDe: 'Auto verwenden und die im Profil deklarierte Verbindungsart prüfen; Node.js/OpenSSL muss TLS-PSK unterstützen.',
90
+ hintEn: 'Use Auto and verify the transport declared by the profile; Node.js/OpenSSL must support TLS-PSK.'
91
+ },
92
+ cause
93
+ });
94
+ }
95
+ return new HomeConnectError('Could not connect to the local appliance.', {
96
+ code: 'LOCAL_CONNECT_FAILED',
97
+ details: { ...details, category: 'transport' },
98
+ cause
99
+ });
100
+ }
101
+
102
+ function frame(opcode, payload = Buffer.alloc(0), masked = true) {
103
+ const data = Buffer.from(payload);
104
+ let lengthBytes;
105
+ if (data.length < 126) lengthBytes = Buffer.from([data.length]);
106
+ else if (data.length <= 0xffff) {
107
+ lengthBytes = Buffer.alloc(3);
108
+ lengthBytes[0] = 126;
109
+ lengthBytes.writeUInt16BE(data.length, 1);
110
+ } else {
111
+ lengthBytes = Buffer.alloc(9);
112
+ lengthBytes[0] = 127;
113
+ lengthBytes.writeBigUInt64BE(BigInt(data.length), 1);
114
+ }
115
+ lengthBytes[0] |= masked ? 0x80 : 0;
116
+ const head = Buffer.from([0x80 | (opcode & 0x0f)]);
117
+ if (!masked) return Buffer.concat([head, lengthBytes, data]);
118
+ const mask = crypto.randomBytes(4);
119
+ const output = Buffer.alloc(data.length);
120
+ for (let index = 0; index < data.length; index++) output[index] = data[index] ^ mask[index % 4];
121
+ return Buffer.concat([head, lengthBytes, mask, output]);
122
+ }
123
+
124
+ function assertTlsPskSupport(ciphers = TLS_PSK_CIPHERS) {
125
+ const available = new Set(tls.getCiphers().map(value => value.toUpperCase()));
126
+ const requested = String(ciphers).split(':').map(value => value.trim()).filter(Boolean);
127
+ if (!requested.some(value => available.has(value.toUpperCase()))) {
128
+ throw new HomeConnectError('This Node.js/OpenSSL runtime does not provide a requested TLS-PSK cipher.', {
129
+ code: 'LOCAL_TLS_PSK_UNAVAILABLE', details: { transport: 'TLS', protocol: 'TLSv1.2-PSK' }
130
+ });
131
+ }
132
+ }
133
+
134
+ class FrameDecoder extends EventEmitter {
135
+ constructor() {
136
+ super();
137
+ this.buffer = Buffer.alloc(0);
138
+ this.fragments = [];
139
+ this.fragmentOpcode = null;
140
+ }
141
+
142
+ push(chunk) {
143
+ this.buffer = Buffer.concat([this.buffer, Buffer.from(chunk)]);
144
+ while (this.buffer.length >= 2) {
145
+ const first = this.buffer[0];
146
+ const second = this.buffer[1];
147
+ const final = Boolean(first & 0x80);
148
+ const opcode = first & 0x0f;
149
+ const masked = Boolean(second & 0x80);
150
+ let length = second & 0x7f;
151
+ let offset = 2;
152
+ if (length === 126) {
153
+ if (this.buffer.length < 4) return;
154
+ length = this.buffer.readUInt16BE(2); offset = 4;
155
+ } else if (length === 127) {
156
+ if (this.buffer.length < 10) return;
157
+ const big = this.buffer.readBigUInt64BE(2);
158
+ if (big > BigInt(Number.MAX_SAFE_INTEGER)) throw new HomeConnectError('WebSocket frame is too large.', { code: 'LOCAL_WEBSOCKET_FRAME_TOO_LARGE' });
159
+ length = Number(big); offset = 10;
160
+ }
161
+ const maskOffset = masked ? 4 : 0;
162
+ if (this.buffer.length < offset + maskOffset + length) return;
163
+ const mask = masked ? this.buffer.subarray(offset, offset + 4) : null;
164
+ offset += maskOffset;
165
+ const payload = Buffer.from(this.buffer.subarray(offset, offset + length));
166
+ this.buffer = this.buffer.subarray(offset + length);
167
+ if (mask) for (let index = 0; index < payload.length; index++) payload[index] ^= mask[index % 4];
168
+ this._emitFrame(opcode, final, payload);
169
+ }
170
+ }
171
+
172
+ _emitFrame(opcode, final, payload) {
173
+ if (opcode === 0x8) return this.emit('close', payload);
174
+ if (opcode === 0x9) return this.emit('ping', payload);
175
+ if (opcode === 0xA) return this.emit('pong', payload);
176
+ if (opcode === 0x0) {
177
+ if (this.fragmentOpcode == null) return this.emit('error', new HomeConnectError('Unexpected WebSocket continuation frame.', { code: 'LOCAL_WEBSOCKET_PROTOCOL_ERROR' }));
178
+ this.fragments.push(payload);
179
+ if (final) {
180
+ const type = this.fragmentOpcode;
181
+ const value = Buffer.concat(this.fragments);
182
+ this.fragmentOpcode = null; this.fragments = [];
183
+ this.emit('message', value, type === 0x2);
184
+ }
185
+ return;
186
+ }
187
+ if (![0x1, 0x2].includes(opcode)) return this.emit('error', new HomeConnectError('Unsupported WebSocket opcode.', { code: 'LOCAL_WEBSOCKET_PROTOCOL_ERROR' }));
188
+ if (final) return this.emit('message', payload, opcode === 0x2);
189
+ this.fragmentOpcode = opcode;
190
+ this.fragments = [payload];
191
+ }
192
+ }
193
+
194
+ class HomeConnectWebSocketTransport extends EventEmitter {
195
+ constructor(options = {}) {
196
+ super();
197
+ this.host = cleanHost(options.host);
198
+ this.mode = String(options.mode || (options.iv64 ? 'AES' : 'TLS')).toUpperCase();
199
+ this.psk = decodeBase64Url(options.psk64, 'PSK');
200
+ this.timeoutMs = Math.max(1000, Number(options.timeoutMs || 15000));
201
+ this.heartbeatMs = Math.max(5000, Number(options.heartbeatMs || 20000));
202
+ this.pskIdentity = String(options.pskIdentity || 'HCCOM_Local_App');
203
+ this.tlsCiphers = String(options.tlsCiphers || TLS_PSK_CIPHERS);
204
+ this.lookup = options.lookup;
205
+ this.onStage = typeof options.onStage === 'function' ? options.onStage : () => {};
206
+ this.closed = false;
207
+ }
208
+
209
+ async connect() {
210
+ const resolved = await resolveLocalHost(this.host, this.lookup || dns.lookup);
211
+ const useTls = this.mode === 'TLS';
212
+ if (!['AES', 'TLS'].includes(this.mode)) throw new HomeConnectError(`Unsupported local connection type: ${this.mode}`, { code: 'LOCAL_CONNECTION_TYPE_INVALID' });
213
+ if (useTls) assertTlsPskSupport(this.tlsCiphers);
214
+ const port = useTls ? 443 : 80;
215
+ this.onStage('transport-recognized', {
216
+ transport: this.mode, port, path: WEBSOCKET_PATH,
217
+ protocol: useTls ? 'TLSv1.2-PSK' : 'AES-256-CBC/HMAC-SHA256'
218
+ });
219
+ // Connect to the address that was checked, avoiding a second DNS lookup/rebinding window.
220
+ const socketOptions = { host: resolved.address, port };
221
+ this.socket = useTls
222
+ ? tls.connect({
223
+ ...socketOptions,
224
+ minVersion: 'TLSv1.2', maxVersion: 'TLSv1.2', rejectUnauthorized: false,
225
+ ciphers: this.tlsCiphers,
226
+ pskCallback: () => ({ psk: this.psk, identity: this.pskIdentity })
227
+ })
228
+ : net.connect(socketOptions);
229
+ let reachableReported = false;
230
+ const reportReachable = () => {
231
+ if (reachableReported) return;
232
+ reachableReported = true;
233
+ this.onStage('ip-reachable', { transport: this.mode, port, path: WEBSOCKET_PATH });
234
+ };
235
+ // For TLS, TCP reachability is distinct from the subsequent PSK handshake.
236
+ this.socket.once('connect', reportReachable);
237
+ await new Promise((resolve, reject) => {
238
+ const event = useTls ? 'secureConnect' : 'connect';
239
+ const timer = setTimeout(() => {
240
+ this.socket.destroy();
241
+ reject(new HomeConnectError('Local appliance connection timed out.', {
242
+ code: 'LOCAL_CONNECT_TIMEOUT',
243
+ details: {
244
+ transport: this.mode, category: 'network', port,
245
+ hintDe: 'Gerät kann im Standby/ausgeschaltet sein oder VLAN/Firewall blockiert den Zielport.',
246
+ hintEn: 'The appliance may be asleep/offline, or a VLAN/firewall may block the target port.'
247
+ }
248
+ }));
249
+ }, this.timeoutMs);
250
+ const fail = cause => { clearTimeout(timer); reject(transportError(cause, this.mode)); };
251
+ this.socket.once(event, () => {
252
+ clearTimeout(timer);
253
+ this.socket.off('error', fail);
254
+ reportReachable();
255
+ resolve();
256
+ });
257
+ this.socket.once('error', fail);
258
+ });
259
+ await this._upgrade();
260
+ this.onStage('websocket-connected', {
261
+ transport: this.mode, port, path: WEBSOCKET_PATH,
262
+ protocol: 'RFC6455', cipher: useTls ? this.socket.getCipher?.()?.name : undefined
263
+ });
264
+ this._startFrames();
265
+ }
266
+
267
+ _upgrade() {
268
+ return new Promise((resolve, reject) => {
269
+ const key = crypto.randomBytes(16).toString('base64');
270
+ const displayHost = this.host.includes(':') ? `[${this.host}]` : this.host;
271
+ const originScheme = this.mode === 'TLS' ? 'https' : 'http';
272
+ // Match websocket-client/homeconnect-websocket exactly: /homeconnect,
273
+ // HTTP/1.1 Upgrade and an Origin derived from the ws/wss URL.
274
+ const request = [
275
+ `GET ${WEBSOCKET_PATH} HTTP/1.1`, 'Upgrade: websocket', `Host: ${displayHost}`,
276
+ `Origin: ${originScheme}://${displayHost}`,
277
+ `Sec-WebSocket-Key: ${key}`, 'Sec-WebSocket-Version: 13', 'Connection: Upgrade', '', ''
278
+ ].join('\r\n');
279
+ let response = Buffer.alloc(0);
280
+ const timer = setTimeout(() => {
281
+ cleanup();
282
+ this.socket.destroy();
283
+ reject(new HomeConnectError('Local WebSocket upgrade timed out.', {
284
+ code: 'LOCAL_WEBSOCKET_TIMEOUT', details: { transport: this.mode, path: WEBSOCKET_PATH }
285
+ }));
286
+ }, this.timeoutMs);
287
+ const onData = chunk => {
288
+ response = Buffer.concat([response, chunk]);
289
+ const marker = response.indexOf('\r\n\r\n');
290
+ if (marker < 0) return;
291
+ cleanup();
292
+ const header = response.subarray(0, marker).toString('latin1');
293
+ const lines = header.split('\r\n');
294
+ const headers = Object.fromEntries(lines.slice(1).map(line => {
295
+ const index = line.indexOf(':'); return [line.slice(0, index).trim().toLowerCase(), line.slice(index + 1).trim()];
296
+ }));
297
+ const expected = crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
298
+ if (!/^HTTP\/1\.[01] 101\b/.test(lines[0]) || headers['sec-websocket-accept'] !== expected) {
299
+ const status = lines[0].match(/^HTTP\/1\.[01]\s+(\d{3})(?:\s+(.+))?$/);
300
+ this.socket.destroy();
301
+ reject(new HomeConnectError('The appliance rejected the WebSocket upgrade.', {
302
+ code: 'LOCAL_WEBSOCKET_UPGRADE_FAILED',
303
+ details: {
304
+ transport: this.mode, path: WEBSOCKET_PATH, category: 'transport',
305
+ httpStatus: status ? Number(status[1]) : undefined,
306
+ httpStatusText: status?.[2],
307
+ hintDe: 'Host/IP, Profilzuordnung, AES/TLS-Port und den exakten Pfad /homeconnect prüfen.',
308
+ hintEn: 'Verify Host/IP, profile assignment, AES/TLS port, and the exact /homeconnect path.'
309
+ }
310
+ }));
311
+ return;
312
+ }
313
+ this.leftover = response.subarray(marker + 4);
314
+ resolve();
315
+ };
316
+ const onError = cause => {
317
+ cleanup();
318
+ reject(new HomeConnectError('Local WebSocket upgrade failed.', {
319
+ code: 'LOCAL_WEBSOCKET_UPGRADE_FAILED', details: { transport: this.mode, path: WEBSOCKET_PATH }, cause
320
+ }));
321
+ };
322
+ const cleanup = () => { clearTimeout(timer); this.socket.off('data', onData); this.socket.off('error', onError); };
323
+ this.socket.on('data', onData); this.socket.once('error', onError); this.socket.write(request);
324
+ });
325
+ }
326
+
327
+ _startFrames() {
328
+ this.decoder = new FrameDecoder();
329
+ this.decoder.on('message', (payload, binary) => this.emit('message', payload, binary));
330
+ this.decoder.on('ping', payload => this.socket.write(frame(0xA, payload)));
331
+ this.decoder.on('pong', () => { this.awaitingPong = false; });
332
+ this.decoder.on('close', payload => { if (!this.closed) this.socket.write(frame(0x8, payload)); this.close(); });
333
+ this.decoder.on('error', error => { this.emit('error', error); this.close(); });
334
+ this.socket.on('data', chunk => {
335
+ try { this.decoder.push(chunk); } catch (error) { this.emit('error', error); this.close(); }
336
+ });
337
+ this.socket.on('error', cause => this.emit('error', transportError(cause, this.mode)));
338
+ this.socket.on('close', () => { clearInterval(this.heartbeat); this.emit('close'); });
339
+ if (this.leftover?.length) this.decoder.push(this.leftover);
340
+ this.heartbeat = setInterval(() => {
341
+ if (this.awaitingPong) {
342
+ this.emit('error', new HomeConnectError('Local appliance heartbeat timed out.', {
343
+ code: 'LOCAL_HEARTBEAT_TIMEOUT',
344
+ details: {
345
+ category: 'network', transport: this.mode,
346
+ hintDe: 'Die WebSocket-Verbindung antwortet nicht mehr; Session wird geschlossen und neu aufgebaut.',
347
+ hintEn: 'The WebSocket stopped responding; the session will be closed and rebuilt.'
348
+ }
349
+ }));
350
+ return this.close();
351
+ }
352
+ this.awaitingPong = true;
353
+ this.socket.write(frame(0x9, Buffer.alloc(0)));
354
+ }, this.heartbeatMs);
355
+ this.heartbeat.unref?.();
356
+ }
357
+
358
+ send(payload, binary = false) {
359
+ if (!this.socket || this.socket.destroyed) throw new HomeConnectError('Local appliance socket is not connected.', { code: 'LOCAL_NOT_CONNECTED' });
360
+ this.socket.write(frame(binary ? 0x2 : 0x1, payload));
361
+ }
362
+
363
+ close() {
364
+ if (this.closed) return;
365
+ this.closed = true;
366
+ clearInterval(this.heartbeat);
367
+ try { if (this.socket && !this.socket.destroyed) this.socket.end(frame(0x8)); } catch { this.socket?.destroy(); }
368
+ }
369
+ }
370
+
371
+ module.exports = {
372
+ HomeConnectWebSocketTransport, FrameDecoder, frame, cleanHost, privateIp, resolveLocalHost, assertLocalHost,
373
+ transportError, assertTlsPskSupport, WEBSOCKET_PATH, TLS_PSK_CIPHERS
374
+ };