@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,1014 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const { EventEmitter } = require('node:events');
5
+ const { HomeConnectError } = require('./errors');
6
+ const { AesMessageCrypto } = require('./local-crypto');
7
+ const { loadLocalProfiles, publicProfile } = require('./local-profile');
8
+ const { HomeConnectWebSocketTransport } = require('./websocket-transport');
9
+ const { LocalProtocolTrace } = require('./local-trace');
10
+ const { READ_OPERATIONS } = require('./constants');
11
+
12
+ // Community implementations initialize mandatory/current values before the
13
+ // optional description/program resources. Every request remains a GET.
14
+ const READ_RESOURCES = Object.freeze([
15
+ '/ro/allMandatoryValues',
16
+ '/ro/values',
17
+ '/ro/allDescriptionChanges',
18
+ '/ro/activeProgram',
19
+ '/ro/selectedProgram'
20
+ ]);
21
+
22
+ function withTimeout(promise, timeoutMs, code, message) {
23
+ let timer;
24
+ return Promise.race([
25
+ promise,
26
+ new Promise((_resolve, reject) => {
27
+ timer = setTimeout(() => reject(new HomeConnectError(message, { code })), timeoutMs);
28
+ timer.unref?.();
29
+ })
30
+ ]).finally(() => clearTimeout(timer));
31
+ }
32
+
33
+ function compactMessage(value) { return JSON.stringify(value); }
34
+
35
+ function parseMessage(value) {
36
+ try { return JSON.parse(value); }
37
+ catch (cause) {
38
+ try { return JSON.parse(String(value).replace(/\]"/g, ']')); }
39
+ catch { throw new HomeConnectError('The local appliance sent invalid JSON.', { code: 'LOCAL_MESSAGE_INVALID', cause }); }
40
+ }
41
+ }
42
+
43
+ function serviceName(resource) {
44
+ return String(resource || '').split('/').filter(Boolean)[0] || 'ci';
45
+ }
46
+
47
+ function messageData(message) {
48
+ if (Array.isArray(message?.data)) return message.data;
49
+ if (!message?.data || typeof message.data !== 'object') return [];
50
+ for (const key of ['services', 'items', 'values']) {
51
+ if (Array.isArray(message.data[key])) return message.data[key];
52
+ }
53
+ return [message.data];
54
+ }
55
+
56
+ function serviceVersions(message) {
57
+ const result = {};
58
+ for (const entry of messageData(message)) {
59
+ const service = String(entry?.service || entry?.name || '').replace(/^\/+|\/+$/g, '').toLowerCase();
60
+ const version = Number(entry?.version ?? entry?.serviceVersion ?? 1);
61
+ if (service && Number.isFinite(version) && version > 0) result[service] = version;
62
+ }
63
+ return result;
64
+ }
65
+
66
+ function acceptedResponseCode(code) {
67
+ return code === undefined || code === null || code === '' || Number(code) === 0 || Number(code) === 200;
68
+ }
69
+
70
+ function protocolResponseError(message, pending = {}) {
71
+ const deviceCode = Number(message?.code);
72
+ const resource = message?.resource || pending.resource || 'unknown resource';
73
+ const action = pending.action || 'request';
74
+ const version = pending.version ?? message?.version;
75
+ return new HomeConnectError(`Local appliance rejected ${action} ${resource} (version ${version}) with code ${message?.code}.`, {
76
+ code: 'LOCAL_PROTOCOL_REJECTED',
77
+ status: Number.isFinite(deviceCode) ? deviceCode : undefined,
78
+ details: {
79
+ category: 'protocol',
80
+ resource,
81
+ action,
82
+ version,
83
+ deviceCode: message?.code,
84
+ hintDe: 'Ressource, Aktion und ausgehandelte Service-Version mit dem Geräteprofil bzw. Mitschnitt vergleichen.',
85
+ hintEn: 'Compare the resource, action, and negotiated service version with the appliance profile or protocol trace.'
86
+ }
87
+ });
88
+ }
89
+
90
+ function shortName(name) {
91
+ const text = String(name || '');
92
+ return text.includes('.') ? text.slice(text.lastIndexOf('.') + 1) : text;
93
+ }
94
+
95
+ function itemCategory(feature = {}, key = '') {
96
+ const declared = String(feature.category || '').toLowerCase().replace(/s$/, '');
97
+ if (['status', 'setting', 'event', 'option', 'program', 'command'].includes(declared)) return declared;
98
+ if (/\.Status\./.test(key)) return 'status';
99
+ if (/\.Setting\./.test(key)) return 'setting';
100
+ if (/\.Event\./.test(key)) return 'event';
101
+ if (/\.Option\./.test(key)) return 'option';
102
+ if (/\.Program\./.test(key)) return 'program';
103
+ if (/\.Command\./.test(key)) return 'command';
104
+ return 'value';
105
+ }
106
+
107
+ function isOfflineError(error) {
108
+ if (error?.details?.category === 'network') return true;
109
+ return new Set([
110
+ 'LOCAL_NETWORK_ERROR', 'LOCAL_CONNECT_TIMEOUT', 'LOCAL_WEBSOCKET_TIMEOUT',
111
+ 'LOCAL_DISCONNECTED', 'LOCAL_HANDSHAKE_TIMEOUT', 'LOCAL_REQUEST_TIMEOUT',
112
+ 'LOCAL_HEARTBEAT_TIMEOUT', 'ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH',
113
+ 'ENETUNREACH', 'ENETDOWN', 'EAI_AGAIN', 'ENOTFOUND', 'ETIMEDOUT'
114
+ ]).has(error?.code);
115
+ }
116
+
117
+ function safeOfflineReason(error) {
118
+ if (!error) return undefined;
119
+ return {
120
+ code: String(error.code || '').startsWith('LOCAL_') ? error.code : 'LOCAL_NETWORK_ERROR',
121
+ category: 'network',
122
+ ...(error.details?.causeCode ? { causeCode: error.details.causeCode } : {}),
123
+ ...(error.details?.hintDe ? { hintDe: error.details.hintDe } : {}),
124
+ ...(error.details?.hintEn ? { hintEn: error.details.hintEn } : {})
125
+ };
126
+ }
127
+
128
+ function profileBoolean(rawValue) {
129
+ if (rawValue === true || rawValue === false) return { matched: true, value: rawValue };
130
+ const text = String(rawValue).toLowerCase();
131
+ if (rawValue === 1 || rawValue === '1' || text === 'true' || text.endsWith('.true')) return { matched: true, value: true };
132
+ if (rawValue === 0 || rawValue === '0' || text === 'false' || text.endsWith('.false')) return { matched: true, value: false };
133
+ return { matched: false, value: rawValue };
134
+ }
135
+
136
+ function safeRawDiagnostic(rawValue) {
137
+ if (rawValue === null) return 'null';
138
+ if (['number', 'boolean'].includes(typeof rawValue)) return String(rawValue);
139
+ if (typeof rawValue === 'string' && /^-?\d+(?:\.\d+)?$/.test(rawValue)) return rawValue;
140
+ return `[${typeof rawValue}]`;
141
+ }
142
+
143
+ function findEdMessageId(value) {
144
+ if (!value || typeof value !== 'object') return undefined;
145
+ if (Object.hasOwn(value, 'edMsgID') && Number.isFinite(Number(value.edMsgID))) return Number(value.edMsgID);
146
+ for (const child of Array.isArray(value) ? value : Object.values(value)) {
147
+ const found = findEdMessageId(child);
148
+ if (found !== undefined) return found;
149
+ }
150
+ return undefined;
151
+ }
152
+
153
+ function valueEntries(value, result = [], seen = new WeakSet()) {
154
+ if (!value || typeof value !== 'object' || seen.has(value)) return result;
155
+ seen.add(value);
156
+ if (Array.isArray(value)) {
157
+ for (const item of value) valueEntries(item, result, seen);
158
+ return result;
159
+ }
160
+ if (Object.hasOwn(value, 'uid') && Object.hasOwn(value, 'value')) result.push(value);
161
+ for (const [key, child] of Object.entries(value)) {
162
+ if (key === 'uid' || key === 'value') continue;
163
+ if (child && typeof child === 'object') valueEntries(child, result, seen);
164
+ }
165
+ return result;
166
+ }
167
+
168
+ function aggregateProtocolRejection(failures) {
169
+ const rejected = failures.filter(item => item.error?.code === 'LOCAL_PROTOCOL_REJECTED');
170
+ const first = rejected[0]?.error;
171
+ if (!first) return failures[0]?.error;
172
+ const rejections = rejected.map(item => ({
173
+ resource: item.resource,
174
+ action: item.error.details?.action || 'GET',
175
+ version: item.error.details?.version,
176
+ deviceCode: item.error.details?.deviceCode
177
+ }));
178
+ return new HomeConnectError(
179
+ `The appliance rejected the local read sequence. First rejection: ${rejections[0].action} ${rejections[0].resource} (version ${rejections[0].version}), code ${rejections[0].deviceCode}.`,
180
+ {
181
+ code: 'LOCAL_PROTOCOL_REJECTED',
182
+ status: first.status,
183
+ cause: first,
184
+ details: {
185
+ ...first.details,
186
+ stage: 'readonly-data-received',
187
+ rejections,
188
+ rejectedResources: rejections.map(item => `${item.action} ${item.resource} v${item.version}: ${item.deviceCode}`)
189
+ }
190
+ }
191
+ );
192
+ }
193
+
194
+ class LocalApplianceSession extends EventEmitter {
195
+ constructor(profile, options = {}) {
196
+ super();
197
+ this.profile = profile;
198
+ this.timeoutMs = Math.max(1000, Number(options.timeoutMs || 15000));
199
+ this.appName = options.appName || 'node-red-home-connect';
200
+ this.appId = options.appId || crypto.randomBytes(8).toString('hex');
201
+ this.transportFactory = options.transportFactory || (transportOptions => new HomeConnectWebSocketTransport(transportOptions));
202
+ this.diagnostics = options.diagnostics;
203
+ this.autoRefresh = options.autoRefresh !== false;
204
+ this.trace = options.trace || new LocalProtocolTrace({
205
+ enabled: options.traceEnabled === true,
206
+ path: options.tracePath,
207
+ profile: profile.haId
208
+ });
209
+ this.services = {};
210
+ this.values = new Map();
211
+ this.pending = new Map();
212
+ this.connected = false;
213
+ this.connecting = null;
214
+ this.closed = false;
215
+ this.watchers = 0;
216
+ this.reconnectBaseDelay = 1000;
217
+ this.reconnectDelay = this.reconnectBaseDelay;
218
+ this.reconnectAttempt = 0;
219
+ this.transportGeneration = 0;
220
+ this.readEvidence = { messages: 0, responses: 0, notifications: 0, values: 0, lastResource: undefined, lastAction: undefined };
221
+ this.optionalFailures = [];
222
+ this.lastValueAt = undefined;
223
+ }
224
+
225
+ get versionInfo() { return { ...this.services }; }
226
+ get protocolTrace() { return this.trace.snapshot(); }
227
+
228
+ ensureConnected() {
229
+ if (this.connected) return Promise.resolve();
230
+ if (this.connecting) return this.connecting;
231
+ this.connecting = this._connect().finally(() => { this.connecting = null; });
232
+ return this.connecting;
233
+ }
234
+
235
+ async _connect() {
236
+ if (this.closed) throw new HomeConnectError('The local appliance session is closed.', { code: 'LOCAL_SESSION_CLOSED' });
237
+ const generation = ++this.transportGeneration;
238
+ this._clearSessionState();
239
+ this.trace.event('connect-start', { transport: this.profile.connectionType, attempt: this.reconnectAttempt + 1 });
240
+ try {
241
+ this.crypto = this.profile.connectionType === 'AES'
242
+ ? new AesMessageCrypto({ psk64: this.profile.psk64, iv64: this.profile.iv64 })
243
+ : null;
244
+ const transport = this.transportFactory({
245
+ host: this.profile.host,
246
+ mode: this.profile.connectionType,
247
+ psk64: this.profile.psk64,
248
+ pskIdentity: this.profile.pskIdentity,
249
+ timeoutMs: this.timeoutMs,
250
+ onStage: (stage, details) => this.diagnostics?.pass(stage, details)
251
+ });
252
+ this.transport = transport;
253
+ transport.on('message', (payload, binary) => {
254
+ if (generation === this.transportGeneration) this._receive(payload, binary);
255
+ });
256
+ transport.on('error', error => {
257
+ if (generation === this.transportGeneration) this._transportError(error);
258
+ });
259
+ transport.on('close', () => {
260
+ if (generation === this.transportGeneration) this._transportClosed();
261
+ });
262
+ const initialized = new Promise((resolve, reject) => { this.initialized = { resolve, reject, settled: false }; });
263
+ await transport.connect();
264
+ await withTimeout(initialized, this.timeoutMs, 'LOCAL_HANDSHAKE_TIMEOUT', 'The local Home Connect handshake timed out.');
265
+ this.connected = true;
266
+ this.reconnectDelay = this.reconnectBaseDelay;
267
+ this.reconnectAttempt = 0;
268
+ this.emit('event', this._event('CONNECTED', { items: [] }, { synthetic: true, transport: this.profile.connectionType }));
269
+ if (this.autoRefresh) await this.refresh().catch(error => this.emit('warning', error));
270
+ } catch (error) {
271
+ this.trace.event('connect-failed', { code: error.code, details: error.details });
272
+ this.diagnostics?.failFromError(error);
273
+ if (generation === this.transportGeneration) {
274
+ try { this.transport?.close(); } catch { /* no-op */ }
275
+ }
276
+ throw error;
277
+ }
278
+ }
279
+
280
+ _clearSessionState() {
281
+ this.connected = false;
282
+ this.sessionId = null;
283
+ this.nextMessageId = null;
284
+ this.services = {};
285
+ this.optionalFailures = [];
286
+ }
287
+
288
+ _receive(payload, binary) {
289
+ try {
290
+ let text;
291
+ if (this.crypto) {
292
+ if (!binary) throw new HomeConnectError('AES appliance sent a non-binary WebSocket frame.', { code: 'LOCAL_CRYPTO_FRAME_INVALID' });
293
+ text = this.crypto.decrypt(payload);
294
+ } else {
295
+ text = Buffer.from(payload).toString('utf8');
296
+ }
297
+ const message = parseMessage(text);
298
+ this.trace.record('rx', message, { binary: Boolean(binary) });
299
+ this._handleMessage(message);
300
+ } catch (error) {
301
+ this.trace.event('receive-error', { code: error.code, message: error.message });
302
+ this._emitError(error);
303
+ if (String(error.code || '').startsWith('LOCAL_CRYPTO_') || error.code === 'LOCAL_MESSAGE_INVALID') this.transport?.close();
304
+ }
305
+ }
306
+
307
+ _handleMessage(message) {
308
+ message.sID = Number(message.sID);
309
+ message.msgID = Number(message.msgID);
310
+ message.version = Number(message.version || 1);
311
+ message.action = String(message.action || '').toUpperCase();
312
+ if (!Number.isFinite(message.sID) || !Number.isFinite(message.msgID)) {
313
+ throw new HomeConnectError('The local appliance message has invalid identifiers.', { code: 'LOCAL_MESSAGE_INVALID' });
314
+ }
315
+
316
+ if (message.resource === '/ei/initialValues' && ['POST', 'GET', 'NOTIFY'].includes(message.action)) {
317
+ this._startHandshake(message);
318
+ return;
319
+ }
320
+
321
+ if (this.sessionId != null && message.sID !== this.sessionId) {
322
+ this.trace.event('stale-session-message', { expectedSID: this.sessionId, receivedSID: message.sID, resource: message.resource });
323
+ return;
324
+ }
325
+
326
+ if (['GET', 'POST'].includes(message.action)) {
327
+ this._respondToDeviceRequest(message);
328
+ return;
329
+ }
330
+
331
+ if (message.resource === '/ei/keepAlive' || message.resource === '/ci/keepAlive') {
332
+ this.trace.event('protocol-keepalive', { action: message.action, resource: message.resource });
333
+ }
334
+
335
+ const carriesReadData = String(message.resource || '').startsWith('/ro/') || valueEntries(message.data).length;
336
+ const acceptedReadMessage = message.action !== 'RESPONSE' || acceptedResponseCode(message.code);
337
+ if (carriesReadData && acceptedReadMessage) {
338
+ this._recordReadEvidence(message);
339
+ this._applyResource(message);
340
+ }
341
+
342
+ if (message.action === 'RESPONSE' && this.pending.has(message.msgID)) {
343
+ const pending = this.pending.get(message.msgID);
344
+ this.pending.delete(message.msgID);
345
+ clearTimeout(pending.timer);
346
+ if (message.resource && pending.resource !== message.resource) {
347
+ pending.reject(new HomeConnectError(`Local response resource mismatch: expected ${pending.resource}, received ${message.resource}.`, {
348
+ code: 'LOCAL_PROTOCOL_RESPONSE_MISMATCH',
349
+ details: { category: 'protocol', expectedResource: pending.resource, resource: message.resource, action: pending.action, version: pending.version }
350
+ }));
351
+ } else if (!acceptedResponseCode(message.code)) {
352
+ pending.reject(protocolResponseError(message, pending));
353
+ } else {
354
+ pending.resolve(message);
355
+ }
356
+ }
357
+ }
358
+
359
+ _startHandshake(initial) {
360
+ this._handshake(initial).catch(error => {
361
+ if (this.initialized && !this.initialized.settled) {
362
+ this.initialized.settled = true;
363
+ this.initialized.reject(error);
364
+ }
365
+ this._emitError(error);
366
+ this.transport?.close();
367
+ });
368
+ }
369
+
370
+ async _handshake(initial) {
371
+ const startId = findEdMessageId(initial.data);
372
+ if (!Number.isFinite(startId)) throw new HomeConnectError('The local appliance handshake did not provide edMsgID.', { code: 'LOCAL_HANDSHAKE_INVALID' });
373
+
374
+ if (this.sessionId != null && this.sessionId !== initial.sID) {
375
+ this._rejectPending(new HomeConnectError('The appliance started a new local session.', { code: 'LOCAL_SESSION_REPLACED' }));
376
+ this.services = {};
377
+ }
378
+ this.sessionId = initial.sID;
379
+ this.nextMessageId = startId;
380
+ const initialValues = valueEntries(initial.data);
381
+ if (initialValues.length) {
382
+ this._recordReadEvidence(initial);
383
+ this._applyResource(initial);
384
+ }
385
+ this._sendResponse(initial, [{
386
+ deviceType: initial.version === 1 ? 2 : 'Application',
387
+ deviceName: this.appName,
388
+ deviceID: this.appId
389
+ }]);
390
+ this.diagnostics?.pass('session-initialized', { transport: this.profile.connectionType });
391
+
392
+ const services = await this.request('/ci/services', 'GET', undefined, 1);
393
+ this.services = serviceVersions(services);
394
+ if (!Object.keys(this.services).length) {
395
+ throw new HomeConnectError('The appliance returned no usable service versions.', {
396
+ code: 'LOCAL_SERVICES_INVALID',
397
+ details: { category: 'protocol', resource: '/ci/services', action: 'GET', version: 1 }
398
+ });
399
+ }
400
+ this.diagnostics?.pass('services-recognized', {
401
+ services: Object.entries(this.services).map(([name, version]) => `${name}:${version}`),
402
+ serviceCount: Object.keys(this.services).length
403
+ });
404
+
405
+ const ciVersion = this.services.ci || 1;
406
+ if (ciVersion < 3) await this._authenticate(ciVersion);
407
+ const eiVersion = Number(this.services.ei || initial.version || 1);
408
+ this.notify('/ei/deviceReady', undefined, eiVersion);
409
+ await this._readOptionalInfo();
410
+
411
+ if (this.initialized && !this.initialized.settled) {
412
+ this.initialized.settled = true;
413
+ this.initialized.resolve();
414
+ }
415
+ }
416
+
417
+ async _authenticate(ciVersion) {
418
+ const nonce = crypto.randomBytes(32).toString('base64url');
419
+ const attempts = [];
420
+ // hcpy sends POST. Some firmware documented/implemented GET, so retry GET
421
+ // only after a concrete protocol rejection.
422
+ for (const version of [...new Set([ciVersion, 2])]) {
423
+ attempts.push({ action: 'POST', version }, { action: 'GET', version });
424
+ }
425
+ let lastError;
426
+ for (const attempt of attempts) {
427
+ try {
428
+ await this.request('/ci/authentication', attempt.action, { nonce }, attempt.version);
429
+ return;
430
+ } catch (error) {
431
+ if (error.code !== 'LOCAL_PROTOCOL_REJECTED') throw error;
432
+ lastError = error;
433
+ }
434
+ }
435
+ throw new HomeConnectError('The appliance rejected local profile authentication.', {
436
+ code: 'LOCAL_PROFILE_AUTHENTICATION_FAILED',
437
+ status: lastError?.status,
438
+ details: { ...lastError?.details, category: 'profile' },
439
+ cause: lastError
440
+ });
441
+ }
442
+
443
+ async _readOptionalInfo() {
444
+ const requests = [];
445
+ if (this.services.iz) requests.push(['/iz/info', this.services.iz]);
446
+ else if (this.services.ci) requests.push(['/ci/info', this.services.ci]);
447
+ if (this.services.ni) requests.push(['/ni/info', this.services.ni]);
448
+ for (const [resource, version] of requests) {
449
+ try { await this.request(resource, 'GET', undefined, version); }
450
+ catch (error) {
451
+ this.optionalFailures.push({ resource, action: 'GET', version, code: error.code, deviceCode: error.details?.deviceCode });
452
+ this.trace.event('optional-request-failed', { resource, action: 'GET', version, code: error.code, deviceCode: error.details?.deviceCode });
453
+ }
454
+ }
455
+ }
456
+
457
+ _respondToDeviceRequest(message) {
458
+ // Device-originated requests are protocol housekeeping, not appliance
459
+ // control. Acknowledge them without issuing any write resource.
460
+ this._sendResponse(message, undefined);
461
+ this.trace.event('device-request-acknowledged', {
462
+ resource: message.resource,
463
+ action: message.action,
464
+ version: message.version,
465
+ msgID: message.msgID
466
+ });
467
+ }
468
+
469
+ _sendResponse(request, data, code) {
470
+ const response = {
471
+ sID: request.sID,
472
+ msgID: request.msgID,
473
+ resource: request.resource,
474
+ version: request.version,
475
+ action: 'RESPONSE'
476
+ };
477
+ if (data !== undefined) response.data = Array.isArray(data) ? data : [data];
478
+ if (code !== undefined) response.code = code;
479
+ this._send(response);
480
+ }
481
+
482
+ _send(message) {
483
+ const text = compactMessage(message);
484
+ this.trace.record('tx', message, { binary: Boolean(this.crypto) });
485
+ if (this.crypto) this.transport.send(this.crypto.encrypt(text), true);
486
+ else this.transport.send(Buffer.from(text), false);
487
+ }
488
+
489
+ request(resource, action = 'GET', data, version) {
490
+ if (!this.transport || this.sessionId == null || !Number.isFinite(this.nextMessageId)) {
491
+ return Promise.reject(new HomeConnectError('Local appliance is not connected.', { code: 'LOCAL_NOT_CONNECTED' }));
492
+ }
493
+ const msgID = this.nextMessageId++;
494
+ const requestVersion = Number(version || this.services[serviceName(resource)] || 1);
495
+ const message = { sID: this.sessionId, msgID, resource, version: requestVersion, action: String(action).toUpperCase() };
496
+ if (data !== undefined) message.data = Array.isArray(data) ? data : [data];
497
+ return new Promise((resolve, reject) => {
498
+ const timer = setTimeout(() => {
499
+ this.pending.delete(msgID);
500
+ reject(new HomeConnectError(`Local request timed out: ${message.action} ${resource} (version ${requestVersion}).`, {
501
+ code: 'LOCAL_REQUEST_TIMEOUT',
502
+ details: {
503
+ category: 'network', resource, action: message.action, version: requestVersion,
504
+ hintDe: 'Gerät kann im Standby/ausgeschaltet sein oder VLAN/Firewall blockiert die bestehende Verbindung.',
505
+ hintEn: 'The appliance may be asleep/offline, or a VLAN/firewall may be interrupting the established connection.'
506
+ }
507
+ }));
508
+ }, this.timeoutMs);
509
+ timer.unref?.();
510
+ this.pending.set(msgID, { resolve, reject, timer, resource, action: message.action, version: requestVersion });
511
+ try { this._send(message); }
512
+ catch (error) { clearTimeout(timer); this.pending.delete(msgID); reject(error); }
513
+ });
514
+ }
515
+
516
+ notify(resource, data, version) {
517
+ if (this.sessionId == null || !Number.isFinite(this.nextMessageId)) throw new HomeConnectError('Local session is not initialized.', { code: 'LOCAL_NOT_CONNECTED' });
518
+ const message = {
519
+ sID: this.sessionId,
520
+ msgID: this.nextMessageId++,
521
+ resource,
522
+ version: Number(version || this.services[serviceName(resource)] || 1),
523
+ action: 'NOTIFY'
524
+ };
525
+ if (data !== undefined) message.data = Array.isArray(data) ? data : [data];
526
+ this._send(message);
527
+ }
528
+
529
+ async refresh() {
530
+ if (!this.connected) await this.ensureConnected();
531
+ const results = [];
532
+ const failures = [];
533
+ const before = this.readEvidence.messages;
534
+ const roVersion = Number(this.services.ro || 1);
535
+
536
+ for (const resource of READ_RESOURCES) {
537
+ try {
538
+ const response = await this.request(resource, 'GET', undefined, roVersion);
539
+ results.push({ resource, action: 'GET', version: roVersion, ok: true, itemCount: valueEntries(response.data).length });
540
+ } catch (error) {
541
+ const failure = { resource, action: 'GET', version: roVersion, ok: false, code: error.code, status: error.status, deviceCode: error.details?.deviceCode };
542
+ results.push(failure);
543
+ failures.push({ resource, error });
544
+ }
545
+ }
546
+
547
+ const newEvidence = this.readEvidence.messages > before;
548
+ const existingEvidence = this.readEvidence.messages > 0 || this.values.size > 0;
549
+ const successfulResponse = results.some(item => item.ok);
550
+ if (!newEvidence && !existingEvidence && !successfulResponse) {
551
+ const nonProtocol = failures.find(item => item.error?.code !== 'LOCAL_PROTOCOL_REJECTED')?.error;
552
+ if (nonProtocol) throw nonProtocol;
553
+ throw aggregateProtocolRejection(failures) || new HomeConnectError('The appliance returned no read-only data.', {
554
+ code: 'LOCAL_VALUES_UNAVAILABLE', details: { stage: 'readonly-data-received', category: 'protocol' }
555
+ });
556
+ }
557
+
558
+ const readSource = this.readEvidence.notifications > 0 && !successfulResponse
559
+ ? 'device-notify'
560
+ : (this.readEvidence.values > 0 ? 'values' : 'successful-read-response');
561
+ const resolution = this.resolutionSummary();
562
+ this.diagnostics?.pass('readonly-data-received', {
563
+ valueCount: this.values.size,
564
+ readResponses: results.filter(item => item.ok).length,
565
+ readSource,
566
+ featureCount: resolution.featureCount,
567
+ resolvedItemCount: resolution.resolvedItemCount,
568
+ unresolvedItemCount: resolution.unresolvedItemCount,
569
+ valueResolvedItemCount: resolution.valueResolvedItemCount,
570
+ unresolvedValueCount: resolution.unresolvedValueCount,
571
+ statusCount: resolution.statusCount,
572
+ settingsCount: resolution.settingsCount,
573
+ programCount: resolution.programCount,
574
+ unresolvedValues: resolution.unresolvedValues,
575
+ rejectedResources: results.filter(item => !item.ok).map(item => `${item.action} ${item.resource} v${item.version}: ${item.deviceCode ?? item.code}`)
576
+ });
577
+ return results;
578
+ }
579
+
580
+ _recordReadEvidence(message) {
581
+ this.readEvidence.messages++;
582
+ if (message.action === 'NOTIFY') this.readEvidence.notifications++;
583
+ if (message.action === 'RESPONSE') this.readEvidence.responses++;
584
+ this.readEvidence.values += valueEntries(message.data).length;
585
+ this.readEvidence.lastResource = message.resource;
586
+ this.readEvidence.lastAction = message.action;
587
+ }
588
+
589
+ _applyResource(message) {
590
+ const data = Array.isArray(message.data) ? message.data : (message.data && typeof message.data === 'object' ? [message.data] : []);
591
+ if (message.resource === '/ro/activeProgram') this.activeProgram = this._programFromResource(data);
592
+ if (message.resource === '/ro/selectedProgram') this.selectedProgram = this._programFromResource(data);
593
+ const changed = [];
594
+ for (const entry of valueEntries(data)) {
595
+ const uid = String(entry.uid);
596
+ this.values.set(uid, entry.value);
597
+ const item = this.item(uid, entry.value);
598
+ changed.push(item);
599
+ if (item.key === 'BSH.Common.Root.ActiveProgram') this.activeProgram = this._program(item.rawValue);
600
+ if (item.key === 'BSH.Common.Root.SelectedProgram') this.selectedProgram = this._program(item.rawValue);
601
+ }
602
+ if (changed.length) {
603
+ this.lastValueAt = new Date().toISOString();
604
+ const programs = this.programState();
605
+ this.emit('event', this._event('STATUS', {
606
+ items: changed,
607
+ programs
608
+ }, { resource: message.resource, action: message.action }));
609
+ }
610
+ }
611
+
612
+ item(uid, rawValue = this.values.get(String(uid))) {
613
+ const feature = this.profile.features[String(uid)] || {};
614
+ const values = feature.values || {};
615
+ const key = feature.name || String(uid);
616
+ const rawKey = String(rawValue);
617
+ const keyResolved = Boolean(feature.name);
618
+ let value = rawValue;
619
+ let valueResolved = false;
620
+ let resolution = keyResolved ? 'unresolved-profile-value' : 'raw-uid';
621
+ const booleanType = /bool(?:ean)?/i.test(String(feature.type || ''));
622
+ const enumDeclared = Boolean(feature.enumDeclared || feature.enumerationType || feature.subsetOf || Object.keys(values).length);
623
+ const scalarDeclared = Boolean(keyResolved && !enumDeclared && (
624
+ feature.type || feature.unit || feature.min !== undefined || feature.max !== undefined || feature.stepSize !== undefined
625
+ ));
626
+ if (Object.hasOwn(values, rawKey)) {
627
+ value = values[rawKey];
628
+ valueResolved = true;
629
+ resolution = 'profile-enum';
630
+ if (booleanType) {
631
+ const boolean = profileBoolean(value);
632
+ if (boolean.matched) {
633
+ value = boolean.value;
634
+ resolution = 'profile-enum-boolean';
635
+ }
636
+ }
637
+ } else if (key === 'BSH.Common.Root.ActiveProgram' || key === 'BSH.Common.Root.SelectedProgram') {
638
+ const program = this.profile.features[rawKey];
639
+ if (program?.name) {
640
+ value = program.name;
641
+ valueResolved = true;
642
+ resolution = 'profile-program';
643
+ } else if (rawValue === undefined || rawValue === null || rawValue === '' || Number(rawValue) === 0) {
644
+ valueResolved = true;
645
+ resolution = 'profile-no-program';
646
+ } else if (typeof rawValue === 'string' && /\.Program\./.test(rawValue)) {
647
+ valueResolved = true;
648
+ resolution = 'semantic-program';
649
+ } else {
650
+ valueResolved = false;
651
+ resolution = 'unresolved-program';
652
+ }
653
+ } else if (enumDeclared) {
654
+ valueResolved = false;
655
+ resolution = 'unresolved-enum';
656
+ } else if (booleanType) {
657
+ const boolean = profileBoolean(rawValue);
658
+ value = boolean.value;
659
+ valueResolved = keyResolved && boolean.matched;
660
+ resolution = boolean.matched && keyResolved ? 'profile-boolean' : 'unresolved-boolean';
661
+ } else if (scalarDeclared) {
662
+ valueResolved = true;
663
+ resolution = 'profile-scalar';
664
+ }
665
+ return {
666
+ key, value, rawValue, uid: Number.isFinite(Number(uid)) ? Number(uid) : uid,
667
+ category: itemCategory(feature, key), resolved: keyResolved, keyResolved, valueResolved, resolution,
668
+ ...(feature.access ? { access: feature.access } : {}),
669
+ ...(feature.unit ? { unit: feature.unit } : {}),
670
+ ...(feature.type ? { type: feature.type } : {}),
671
+ ...(feature.enumName ? { enumName: feature.enumName } : {})
672
+ };
673
+ }
674
+
675
+ items() { return [...this.values.entries()].map(([uid, value]) => this.item(uid, value)); }
676
+
677
+ _program(value, options = []) {
678
+ if (value === undefined || value === null || value === '' || Number(value) === 0) return null;
679
+ const feature = this.profile.features[String(value)] || {};
680
+ const key = feature.name || (typeof value === 'string' && value.includes('.') ? value : String(value));
681
+ return {
682
+ key,
683
+ uid: Number.isFinite(Number(value)) ? Number(value) : value,
684
+ name: shortName(key),
685
+ options: (Array.isArray(options) ? options : []).map(entry => {
686
+ if (entry && Object.hasOwn(entry, 'uid') && Object.hasOwn(entry, 'value')) return this.item(entry.uid, entry.value);
687
+ return structuredClone(entry);
688
+ })
689
+ };
690
+ }
691
+
692
+ _programFromResource(data) {
693
+ const entry = Array.isArray(data) ? data[0] : data;
694
+ if (!entry || typeof entry !== 'object') return null;
695
+ return this._program(entry.program ?? entry.value ?? entry.uid ?? entry.key, entry.options);
696
+ }
697
+
698
+ programState(items = this.items()) {
699
+ const options = items.filter(item => item.category === 'option');
700
+ const attach = program => program ? { ...program, options: options.map(item => ({ ...item })) } : null;
701
+ return { active: attach(this.activeProgram), selected: attach(this.selectedProgram), options };
702
+ }
703
+
704
+ resolutionSummary(items = this.items()) {
705
+ const resolved = items.filter(item => item.resolved);
706
+ const valueResolved = items.filter(item => item.valueResolved);
707
+ return {
708
+ featureCount: Object.keys(this.profile.features || {}).length,
709
+ itemCount: items.length,
710
+ resolvedItemCount: resolved.length,
711
+ unresolvedItemCount: items.length - resolved.length,
712
+ valueResolvedItemCount: valueResolved.length,
713
+ unresolvedValueCount: items.length - valueResolved.length,
714
+ statusCount: items.filter(item => ['status', 'event'].includes(item.category)).length,
715
+ settingsCount: items.filter(item => item.category === 'setting').length,
716
+ programCount: Object.values(this.profile.features || {}).filter(feature => itemCategory(feature, feature?.name) === 'program').length,
717
+ unresolvedUids: items.filter(item => !item.resolved).map(item => item.uid).slice(0, 20),
718
+ unresolvedValues: items.filter(item => !item.valueResolved).map(item => `${item.uid}:${safeRawDiagnostic(item.rawValue)}`).slice(0, 20)
719
+ };
720
+ }
721
+
722
+ _event(event, data, connection = {}) {
723
+ return {
724
+ haId: this.profile.haId,
725
+ event,
726
+ data,
727
+ timestamp: new Date().toISOString(),
728
+ connection: { local: true, readOnly: true, ...connection },
729
+ simulated: false
730
+ };
731
+ }
732
+
733
+ _transportError(error) {
734
+ this.trace.event('transport-error', { code: error.code, details: error.details });
735
+ this._emitError(error);
736
+ }
737
+
738
+ _emitError(error) {
739
+ if (this.listenerCount('error') > 0) this.emit('error', error);
740
+ else this.emit('warning', error);
741
+ }
742
+
743
+ _rejectPending(error) {
744
+ for (const pending of this.pending.values()) {
745
+ clearTimeout(pending.timer);
746
+ pending.reject(error);
747
+ }
748
+ this.pending.clear();
749
+ }
750
+
751
+ _transportClosed() {
752
+ const wasConnected = this.connected;
753
+ this.connected = false;
754
+ this.sessionId = null;
755
+ this.nextMessageId = null;
756
+ this.trace.event('transport-closed', { wasConnected });
757
+ const disconnected = new HomeConnectError('Local appliance disconnected.', {
758
+ code: 'LOCAL_DISCONNECTED',
759
+ details: {
760
+ category: 'network',
761
+ hintDe: 'Gerätestrom/Standby, WLAN, VLAN und Firewall prüfen; die Session wird mit Backoff neu aufgebaut.',
762
+ hintEn: 'Check appliance power/standby, Wi-Fi, VLAN, and firewall; the session will be rebuilt with backoff.'
763
+ }
764
+ });
765
+ if (!wasConnected && this.initialized && !this.initialized.settled) {
766
+ this.initialized.settled = true;
767
+ this.initialized.reject(new HomeConnectError('Local appliance disconnected during the handshake.', { code: 'LOCAL_DISCONNECTED', details: disconnected.details }));
768
+ }
769
+ this._rejectPending(disconnected);
770
+ if (wasConnected) this.emit('event', this._event('DISCONNECTED', { items: [] }, { synthetic: true }));
771
+ if (!this.closed && this.watchers > 0) this._scheduleReconnect();
772
+ }
773
+
774
+ _scheduleReconnect() {
775
+ if (this.closed || this.watchers <= 0 || this.connecting) return;
776
+ clearTimeout(this.reconnectTimer);
777
+ this.reconnectAttempt++;
778
+ const jitter = 0.8 + (Math.random() * 0.4);
779
+ const delay = Math.round(this.reconnectDelay * jitter);
780
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, 60000);
781
+ this.trace.event('reconnect-scheduled', { attempt: this.reconnectAttempt, delayMs: delay });
782
+ this.reconnectTimer = setTimeout(() => {
783
+ this.reconnectTimer = null;
784
+ this.ensureConnected().catch(error => {
785
+ this._emitError(error);
786
+ this._scheduleReconnect();
787
+ });
788
+ }, delay);
789
+ this.reconnectTimer.unref?.();
790
+ }
791
+
792
+ watch() {
793
+ this.watchers++;
794
+ this.ensureConnected().catch(error => {
795
+ this._emitError(error);
796
+ this._scheduleReconnect();
797
+ });
798
+ }
799
+
800
+ unwatch() {
801
+ this.watchers = Math.max(0, this.watchers - 1);
802
+ if (!this.watchers) clearTimeout(this.reconnectTimer);
803
+ }
804
+
805
+ close() {
806
+ this.closed = true;
807
+ clearTimeout(this.reconnectTimer);
808
+ this._rejectPending(new HomeConnectError('Local appliance session closed.', { code: 'LOCAL_SESSION_CLOSED' }));
809
+ this.transport?.close();
810
+ }
811
+ }
812
+
813
+ class LocalHomeConnectClient {
814
+ constructor(options = {}) {
815
+ this.environment = 'local';
816
+ this.profiles = options.profiles || loadLocalProfiles({
817
+ profileJson: options.profileJson,
818
+ profilePath: options.profilePath,
819
+ mappings: options.mappings,
820
+ requireMapping: options.requireMapping === true
821
+ });
822
+ this.appName = options.appName || 'node-red-home-connect';
823
+ this.appId = options.appId || crypto.createHash('sha256').update(String(options.identity || 'node-red')).digest('hex').slice(0, 16);
824
+ this.sessionFactory = options.sessionFactory || ((profile, sessionOptions) => new LocalApplianceSession(profile, sessionOptions));
825
+ this.sessions = new Map(this.profiles.map(profile => [profile.haId, this.sessionFactory(profile, {
826
+ timeoutMs: options.requestTimeout,
827
+ appName: this.appName,
828
+ appId: this.appId,
829
+ transportFactory: options.transportFactory,
830
+ diagnostics: options.diagnostics,
831
+ autoRefresh: options.autoRefresh,
832
+ traceEnabled: options.traceEnabled,
833
+ tracePath: options.tracePath
834
+ })]));
835
+ this.eventClosers = new Set();
836
+ }
837
+
838
+ get requiresOAuth() { return false; }
839
+ get readOnly() { return true; }
840
+ async getRateLimitStatus() { return { local: true, simulated: false, blocked: false, usedRequests: 0 }; }
841
+
842
+ _session(haId) {
843
+ const session = this.sessions.get(haId);
844
+ if (!session) throw new HomeConnectError(`Unknown local appliance: ${haId}`, { code: 'LOCAL_APPLIANCE_NOT_FOUND', status: 404 });
845
+ return session;
846
+ }
847
+
848
+ async listHomeAppliances() {
849
+ return this.profiles.map(profile => ({ ...publicProfile(profile), connected: this.sessions.get(profile.haId).connected }));
850
+ }
851
+
852
+ async discoverAppliance(haId, force = false) {
853
+ const profile = this.profiles.find(item => item.haId === haId);
854
+ const session = this._session(haId);
855
+ let refresh = [];
856
+ let offlineReason;
857
+ try {
858
+ await session.ensureConnected();
859
+ refresh = force ? await session.refresh() : [];
860
+ } catch (error) {
861
+ if (!isOfflineError(error)) throw error;
862
+ offlineReason = safeOfflineReason(error);
863
+ }
864
+ const items = session.items();
865
+ const settings = items.filter(item => item.category === 'setting');
866
+ const status = items.filter(item => ['status', 'event'].includes(item.category));
867
+ const programs = Object.entries(profile.features)
868
+ .filter(([, feature]) => itemCategory(feature, feature?.name) === 'program')
869
+ .map(([uid, feature]) => ({ key: feature.name, name: shortName(feature.name), uid: Number.isFinite(Number(uid)) ? Number(uid) : uid }));
870
+ const resolution = session.resolutionSummary(items);
871
+ const currentPrograms = session.programState(items);
872
+ return {
873
+ appliance: { ...publicProfile(profile), connected: session.connected },
874
+ status: { items: status }, settings: { items: settings }, values: { items },
875
+ programs: { available: programs, ...currentPrograms },
876
+ commands: { items: [], unsupported: true, readOnly: true },
877
+ services: session.versionInfo, local: true, readOnly: true, transport: profile.connectionType,
878
+ refresh, profileResolution: resolution,
879
+ connection: {
880
+ local: true, readOnly: true, state: session.connected ? 'online' : 'offline',
881
+ ...(offlineReason ? { offlineReason } : {}),
882
+ ...(session.lastValueAt ? { lastValueAt: session.lastValueAt } : {})
883
+ },
884
+ stale: !session.connected && items.length > 0,
885
+ discoveredAt: new Date().toISOString(), simulated: false
886
+ };
887
+ }
888
+
889
+ async discoverAll(force = false) {
890
+ const result = [];
891
+ for (const profile of this.profiles) {
892
+ try { result.push(await this.discoverAppliance(profile.haId, force)); }
893
+ catch (error) { result.push({ appliance: { ...publicProfile(profile), connected: false }, error: error.toJSON ? error.toJSON() : { code: error.code, message: error.message }, local: true, readOnly: true }); }
894
+ }
895
+ return result;
896
+ }
897
+
898
+ async testConnection(haId) {
899
+ try {
900
+ const session = this._session(haId);
901
+ await session.ensureConnected();
902
+ const refresh = await session.refresh();
903
+ return {
904
+ appliance: { ...publicProfile(this.profiles.find(item => item.haId === haId)), connected: true },
905
+ transport: session.profile.connectionType,
906
+ services: session.versionInfo,
907
+ values: session.items(),
908
+ refresh,
909
+ protocolTrace: session.protocolTrace,
910
+ local: true,
911
+ readOnly: true
912
+ };
913
+ } catch (error) {
914
+ this._session(haId).diagnostics?.failFromError(error);
915
+ throw error;
916
+ }
917
+ }
918
+
919
+ getProtocolTrace(haId) { return this._session(haId).protocolTrace; }
920
+
921
+ async read(haId, operation = 'snapshot', key) {
922
+ if (!READ_OPERATIONS.includes(operation)) throw new HomeConnectError(`Unsupported read operation: ${operation}`, { code: 'READ_ACTION_NOT_ALLOWED' });
923
+ if (operation === 'appliances') return this.listHomeAppliances();
924
+ const capability = await this.discoverAppliance(haId, ['snapshot', 'capabilities', 'status', 'settings'].includes(operation));
925
+ const byKey = items => items.find(item => item.key === key || String(item.uid) === String(key));
926
+ const operations = {
927
+ snapshot: () => ({ ...capability, snapshotAt: new Date().toISOString() }),
928
+ capabilities: () => capability,
929
+ appliance: () => capability.appliance,
930
+ status: () => capability.status,
931
+ 'status-item': () => byKey(capability.status.items),
932
+ settings: () => capability.settings,
933
+ setting: () => byKey(capability.settings.items),
934
+ 'all-programs': () => capability.programs,
935
+ programs: () => ({ programs: capability.programs.available }),
936
+ program: () => capability.programs.available.find(item => item.key === key || String(item.uid) === String(key)),
937
+ 'active-program': () => capability.programs.active,
938
+ 'selected-program': () => capability.programs.selected,
939
+ 'active-options': () => ({ items: capability.programs.active?.options || [] }),
940
+ 'selected-options': () => ({ items: capability.programs.selected?.options || [] }),
941
+ 'active-option': () => (capability.programs.active?.options || []).find(item => item.key === key || String(item.uid) === String(key)),
942
+ 'selected-option': () => (capability.programs.selected?.options || []).find(item => item.key === key || String(item.uid) === String(key)),
943
+ commands: () => capability.commands
944
+ };
945
+ if (!operations[operation]) throw new HomeConnectError(`Read operation ${operation} is not available for local profiles.`, { code: 'LOCAL_READ_UNSUPPORTED' });
946
+ return operations[operation]();
947
+ }
948
+
949
+ async write() {
950
+ throw new HomeConnectError('Local Home Connect access is deliberately read-only.', { code: 'LOCAL_READ_ONLY', status: 403 });
951
+ }
952
+
953
+ openEvents(haId, onEvent, onError = () => {}) {
954
+ const selected = haId && haId !== '*' ? [this._session(haId)] : [...this.sessions.values()];
955
+ const bindings = [];
956
+ for (const session of selected) {
957
+ const event = value => onEvent(value);
958
+ const error = value => {
959
+ if (value && !value.haId) value.haId = session.profile.haId;
960
+ if (isOfflineError(value)) {
961
+ onEvent({
962
+ haId: session.profile.haId,
963
+ event: 'DISCONNECTED',
964
+ data: { items: [] },
965
+ timestamp: new Date().toISOString(),
966
+ connection: { local: true, readOnly: true, synthetic: true, reconnecting: true, offlineReason: safeOfflineReason(value) },
967
+ simulated: false
968
+ });
969
+ }
970
+ onError(value);
971
+ };
972
+ session.on('event', event);
973
+ session.on('error', error);
974
+ session.on('warning', error);
975
+ session.watch();
976
+ bindings.push({ session, event, error });
977
+ }
978
+ const close = () => {
979
+ for (const binding of bindings) {
980
+ binding.session.off('event', binding.event);
981
+ binding.session.off('error', binding.error);
982
+ binding.session.off('warning', binding.error);
983
+ binding.session.unwatch();
984
+ }
985
+ this.eventClosers.delete(close);
986
+ };
987
+ this.eventClosers.add(close);
988
+ return close;
989
+ }
990
+
991
+ close() {
992
+ for (const close of [...this.eventClosers]) close();
993
+ for (const session of this.sessions.values()) session.close();
994
+ }
995
+ }
996
+
997
+ module.exports = {
998
+ LocalHomeConnectClient,
999
+ LocalApplianceSession,
1000
+ READ_RESOURCES,
1001
+ parseMessage,
1002
+ shortName,
1003
+ messageData,
1004
+ serviceVersions,
1005
+ protocolResponseError,
1006
+ acceptedResponseCode,
1007
+ valueEntries,
1008
+ aggregateProtocolRejection,
1009
+ itemCategory,
1010
+ profileBoolean,
1011
+ safeRawDiagnostic,
1012
+ isOfflineError,
1013
+ safeOfflineReason
1014
+ };