@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
package/lib/health.js ADDED
@@ -0,0 +1,525 @@
1
+ 'use strict';
2
+
3
+ const HEALTH_SCHEMA = 'home-connect-health/v1';
4
+ const HEALTH_ORDER = Object.freeze({
5
+ ok: 0,
6
+ info: 1,
7
+ running: 2,
8
+ warning: 3,
9
+ action_required: 4,
10
+ offline: 5,
11
+ error: 6
12
+ });
13
+
14
+ const STATUS_KEYS = Object.freeze({
15
+ operation: 'BSH.Common.Status.OperationState',
16
+ door: 'BSH.Common.Status.DoorState',
17
+ remote: 'BSH.Common.Status.RemoteControlActive',
18
+ remoteStart: 'BSH.Common.Status.RemoteControlStartAllowed',
19
+ local: 'BSH.Common.Status.LocalControlActive'
20
+ });
21
+
22
+ const STATUS_KEY_SET = new Set(Object.values(STATUS_KEYS));
23
+
24
+ function shortValue(value) {
25
+ if (value === undefined || value === null) return '';
26
+ const text = String(value);
27
+ return text.includes('.') ? text.slice(text.lastIndexOf('.') + 1) : text;
28
+ }
29
+
30
+ function safeCode(value) {
31
+ return String(value || 'UNKNOWN').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toUpperCase() || 'UNKNOWN';
32
+ }
33
+
34
+ function severityRank(value) {
35
+ return ({ info: 0, warning: 1, error: 2, critical: 3 })[value] ?? 0;
36
+ }
37
+
38
+ function warning(source, code, severity, de, en, extra = {}) {
39
+ return {
40
+ source,
41
+ code,
42
+ severity,
43
+ text: { de, en },
44
+ transient: false,
45
+ ...extra
46
+ };
47
+ }
48
+
49
+ function cloneWarning(value) {
50
+ return {
51
+ ...value,
52
+ text: value.text ? { ...value.text } : value.text
53
+ };
54
+ }
55
+
56
+ function healthFromWarnings(warnings, base = 'ok') {
57
+ let health = base;
58
+ for (const item of warnings) {
59
+ const candidate = item.health || (
60
+ item.severity === 'critical' ? 'action_required'
61
+ : item.severity === 'error' ? 'error'
62
+ : item.severity === 'warning' ? 'warning'
63
+ : 'info'
64
+ );
65
+ if ((HEALTH_ORDER[candidate] ?? 0) > (HEALTH_ORDER[health] ?? 0)) health = candidate;
66
+ }
67
+ return health;
68
+ }
69
+
70
+ function publicWarning(value) {
71
+ const result = cloneWarning(value);
72
+ delete result.health;
73
+ return result;
74
+ }
75
+
76
+ function deduplicate(warnings) {
77
+ const result = new Map();
78
+ for (const item of warnings) {
79
+ const id = `${item.source}:${item.code}:${item.key || ''}`;
80
+ const current = result.get(id);
81
+ if (!current || severityRank(item.severity) > severityRank(current.severity)) result.set(id, item);
82
+ }
83
+ return [...result.values()].sort((left, right) => {
84
+ const rank = severityRank(right.severity) - severityRank(left.severity);
85
+ if (rank) return rank;
86
+ return String(left.code).localeCompare(String(right.code));
87
+ });
88
+ }
89
+
90
+ function itemsFrom(value) {
91
+ if (Array.isArray(value)) return value;
92
+ if (Array.isArray(value?.items)) return value.items;
93
+ return [];
94
+ }
95
+
96
+ class HealthMonitor {
97
+ constructor(options = {}) {
98
+ this.now = options.now || (() => Date.now());
99
+ this.transientTtlMs = Math.max(1000, Number(options.transientTtlMs || 5 * 60 * 1000));
100
+ this.states = new Map();
101
+ }
102
+
103
+ _state(haId = '*') {
104
+ const id = haId || '*';
105
+ if (!this.states.has(id)) {
106
+ this.states.set(id, {
107
+ haId: id,
108
+ appliance: {},
109
+ status: new Map(),
110
+ statusMetadata: new Map(),
111
+ transients: new Map(),
112
+ connected: undefined,
113
+ connectionCode: undefined,
114
+ local: false,
115
+ readOnly: false,
116
+ lastOperation: undefined
117
+ });
118
+ }
119
+ return this.states.get(id);
120
+ }
121
+
122
+ _addTransient(state, value, ttlMs = this.transientTtlMs, observedAt) {
123
+ const now = this.now();
124
+ const id = `${value.source}:${value.code}:${value.key || ''}`;
125
+ const previous = state.transients.get(id);
126
+ const firstSeenAt = previous?.firstSeenAt || observedAt || new Date(now).toISOString();
127
+ state.transients.set(id, {
128
+ ...value,
129
+ transient: true,
130
+ firstSeenAt,
131
+ lastSeenAt: observedAt || new Date(now).toISOString(),
132
+ expiresAt: new Date(now + ttlMs).toISOString(),
133
+ expiresAtMs: now + ttlMs
134
+ });
135
+ }
136
+
137
+ _removeTransient(state, code) {
138
+ for (const [id, item] of state.transients) if (item.code === code) state.transients.delete(id);
139
+ }
140
+
141
+ _prune(state) {
142
+ const now = this.now();
143
+ for (const [id, item] of state.transients) if (item.expiresAtMs <= now) state.transients.delete(id);
144
+ }
145
+
146
+ _updateAppliance(state, appliance) {
147
+ if (!appliance || typeof appliance !== 'object') return;
148
+ state.appliance = { ...state.appliance, ...appliance };
149
+ if (typeof appliance.connected === 'boolean') {
150
+ state.connected = appliance.connected;
151
+ state.connectionCode = appliance.connected ? undefined : 'HC_APPLIANCE_OFFLINE';
152
+ if (appliance.connected) this._removeTransient(state, 'HC_STREAM_CONNECTED');
153
+ }
154
+ }
155
+
156
+ _updateConnectionContext(state, payload) {
157
+ if (!payload || typeof payload !== 'object') return;
158
+ const connection = payload.connection && typeof payload.connection === 'object' ? payload.connection : {};
159
+ if (typeof payload.local === 'boolean') state.local = payload.local;
160
+ if (typeof connection.local === 'boolean') state.local = connection.local;
161
+ if (typeof payload.readOnly === 'boolean') state.readOnly = payload.readOnly;
162
+ if (typeof connection.readOnly === 'boolean') state.readOnly = connection.readOnly;
163
+ }
164
+
165
+ _replaceStatus(state, items) {
166
+ const previousOperation = state.status.get(STATUS_KEYS.operation);
167
+ state.status.clear();
168
+ state.statusMetadata.clear();
169
+ for (const item of items) {
170
+ if (!item?.key) continue;
171
+ state.status.set(item.key, item.value);
172
+ state.statusMetadata.set(item.key, { ...item });
173
+ }
174
+ this._operationTransition(
175
+ state,
176
+ previousOperation,
177
+ state.status.get(STATUS_KEYS.operation),
178
+ undefined,
179
+ this._isSemantic(state, STATUS_KEYS.operation)
180
+ );
181
+ }
182
+
183
+ _mergeStatus(state, items) {
184
+ const previousOperation = state.status.get(STATUS_KEYS.operation);
185
+ for (const item of items) {
186
+ if (!item?.key || !STATUS_KEY_SET.has(item.key)) continue;
187
+ state.status.set(item.key, item.value);
188
+ state.statusMetadata.set(item.key, { ...item });
189
+ }
190
+ this._operationTransition(
191
+ state,
192
+ previousOperation,
193
+ state.status.get(STATUS_KEYS.operation),
194
+ undefined,
195
+ this._isSemantic(state, STATUS_KEYS.operation)
196
+ );
197
+ }
198
+
199
+ _isSemantic(state, key) {
200
+ const metadata = state.statusMetadata.get(key);
201
+ return metadata?.valueResolved !== false;
202
+ }
203
+
204
+ _operationTransition(state, previous, current, observedAt, semantic = true) {
205
+ state.lastOperation = current;
206
+ if (!semantic) {
207
+ this._removeTransient(state, 'HC_OPERATION_FINISHED');
208
+ return;
209
+ }
210
+ if (shortValue(current) === 'Finished' && current !== previous) {
211
+ this._addTransient(state, warning(
212
+ 'status',
213
+ 'HC_OPERATION_FINISHED',
214
+ 'info',
215
+ 'Das Programm ist beendet.',
216
+ 'The program has finished.',
217
+ { health: 'info', key: STATUS_KEYS.operation, value: current }
218
+ ), this.transientTtlMs, observedAt);
219
+ }
220
+ if (shortValue(current) !== 'Finished') this._removeTransient(state, 'HC_OPERATION_FINISHED');
221
+ }
222
+
223
+ _statusWarnings(state) {
224
+ const result = [];
225
+ const operationValue = state.status.get(STATUS_KEYS.operation);
226
+ const operation = this._isSemantic(state, STATUS_KEYS.operation) ? shortValue(operationValue) : '';
227
+ const doorValue = state.status.get(STATUS_KEYS.door);
228
+ const door = this._isSemantic(state, STATUS_KEYS.door) ? shortValue(doorValue) : '';
229
+ const remote = this._isSemantic(state, STATUS_KEYS.remote) ? state.status.get(STATUS_KEYS.remote) : undefined;
230
+ const remoteStart = this._isSemantic(state, STATUS_KEYS.remoteStart) ? state.status.get(STATUS_KEYS.remoteStart) : undefined;
231
+ const local = this._isSemantic(state, STATUS_KEYS.local) ? state.status.get(STATUS_KEYS.local) : undefined;
232
+
233
+ if (operation === 'ActionRequired') {
234
+ result.push(warning('status', 'HC_OPERATION_ACTION_REQUIRED', 'critical',
235
+ 'Das Gerät benötigt eine Aktion vor Ort.', 'The appliance requires an action on site.',
236
+ { health: 'action_required', key: STATUS_KEYS.operation, value: operationValue }));
237
+ } else if (operation === 'Pause') {
238
+ result.push(warning('status', 'HC_OPERATION_PAUSED', 'warning',
239
+ 'Das Programm ist pausiert.', 'The program is paused.',
240
+ { health: 'warning', key: STATUS_KEYS.operation, value: operationValue }));
241
+ } else if (operation === 'DelayedStart') {
242
+ result.push(warning('status', 'HC_OPERATION_DELAYED_START', 'info',
243
+ 'Ein verzögerter Start ist geplant.', 'A delayed start is scheduled.',
244
+ { health: 'info', key: STATUS_KEYS.operation, value: operationValue }));
245
+ } else if (operation === 'Error') {
246
+ result.push(warning('status', 'HC_OPERATION_ERROR', 'error',
247
+ 'Das Gerät meldet einen Fehlerzustand.', 'The appliance reports an error state.',
248
+ { health: 'error', key: STATUS_KEYS.operation, value: operationValue }));
249
+ } else if (operation === 'Aborting') {
250
+ result.push(warning('status', 'HC_OPERATION_ABORTING', 'warning',
251
+ 'Das Programm wird abgebrochen.', 'The program is being aborted.',
252
+ { health: 'warning', key: STATUS_KEYS.operation, value: operationValue }));
253
+ } else if (operation && !['Inactive', 'Ready', 'Run', 'Finished'].includes(operation)) {
254
+ result.push(warning('status', 'HC_OPERATION_UNKNOWN', 'info',
255
+ `Unbekannter Betriebszustand „${operation}“; Rohwert bleibt erhalten.`,
256
+ `Unknown operation state “${operation}”; the raw value is preserved.`,
257
+ { health: 'info', key: STATUS_KEYS.operation, value: operationValue }));
258
+ }
259
+
260
+ if (door === 'Open') {
261
+ const requiresAction = ['Run', 'Pause', 'ActionRequired'].includes(operation);
262
+ result.push(warning('status', 'HC_DOOR_OPEN', requiresAction ? 'warning' : 'info',
263
+ requiresAction ? 'Die Gerätetür ist offen; bitte schließen.' : 'Die Gerätetür ist offen.',
264
+ requiresAction ? 'The appliance door is open; please close it.' : 'The appliance door is open.',
265
+ { health: requiresAction ? 'action_required' : 'info', key: STATUS_KEYS.door, value: doorValue }));
266
+ }
267
+
268
+ // Local control is the most useful single message; suppress derivative remote-control noise.
269
+ if (local === true) {
270
+ result.push(warning('status', 'HC_LOCAL_CONTROL_ACTIVE', 'warning',
271
+ 'Das Gerät wird gerade lokal bedient; Fernaktionen sind vorübergehend blockiert.',
272
+ 'The appliance is being operated locally; remote actions are temporarily blocked.',
273
+ { health: 'warning', key: STATUS_KEYS.local, value: local }));
274
+ } else if (remote === false) {
275
+ result.push(warning('status', 'HC_REMOTE_CONTROL_INACTIVE', 'info',
276
+ 'Fernsteuerung ist am Gerät nicht aktiviert.',
277
+ 'Remote control is not enabled on the appliance.',
278
+ { health: 'info', key: STATUS_KEYS.remote, value: remote }));
279
+ } else if (remoteStart === false && operation !== 'Run' && !(
280
+ state.local && state.readOnly && !['Pause', 'DelayedStart', 'ActionRequired'].includes(operation)
281
+ )) {
282
+ result.push(warning('status', 'HC_REMOTE_START_NOT_ALLOWED', 'info',
283
+ 'Fernstart ist am Gerät derzeit nicht erlaubt.',
284
+ 'Remote start is currently not allowed on the appliance.',
285
+ { health: 'info', key: STATUS_KEYS.remoteStart, value: remoteStart }));
286
+ }
287
+
288
+ return result;
289
+ }
290
+
291
+ _connectionWarnings(state) {
292
+ if (state.connected !== false) return [];
293
+ const stream = state.connectionCode === 'HC_STREAM_DISCONNECTED';
294
+ return [warning('connection', stream ? 'HC_STREAM_DISCONNECTED' : 'HC_APPLIANCE_OFFLINE', 'error',
295
+ stream ? 'Die Event-Verbindung wurde getrennt; Wiederverbindung läuft.' : 'Das Gerät ist offline oder nicht mit Home Connect verbunden.',
296
+ stream ? 'The event connection was disconnected; reconnection is in progress.' : 'The appliance is offline or not connected to Home Connect.',
297
+ { health: 'offline', value: false })];
298
+ }
299
+
300
+ _baseHealth(state) {
301
+ if (state.connected === false) return 'offline';
302
+ const operation = this._isSemantic(state, STATUS_KEYS.operation)
303
+ ? shortValue(state.status.get(STATUS_KEYS.operation))
304
+ : '';
305
+ if (operation === 'Run') return 'running';
306
+ if (operation === 'Finished' || operation === 'DelayedStart') return 'info';
307
+ return 'ok';
308
+ }
309
+
310
+ _assessment(state) {
311
+ this._prune(state);
312
+ const internalWarnings = deduplicate([
313
+ ...this._connectionWarnings(state),
314
+ ...this._statusWarnings(state),
315
+ ...state.transients.values()
316
+ ]);
317
+ const health = healthFromWarnings(internalWarnings, this._baseHealth(state));
318
+ return {
319
+ schema: HEALTH_SCHEMA,
320
+ health,
321
+ warnings: internalWarnings.map(item => {
322
+ const result = publicWarning(item);
323
+ delete result.expiresAtMs;
324
+ return result;
325
+ }),
326
+ connected: state.connected,
327
+ operationState: state.status.get(STATUS_KEYS.operation),
328
+ updatedAt: new Date(this.now()).toISOString()
329
+ };
330
+ }
331
+
332
+ _combine(assessments) {
333
+ if (!assessments.length) return {
334
+ schema: HEALTH_SCHEMA,
335
+ health: 'ok',
336
+ warnings: [],
337
+ updatedAt: new Date(this.now()).toISOString()
338
+ };
339
+ let health = 'ok';
340
+ for (const item of assessments) if ((HEALTH_ORDER[item.health] ?? 0) > (HEALTH_ORDER[health] ?? 0)) health = item.health;
341
+ return {
342
+ schema: HEALTH_SCHEMA,
343
+ health,
344
+ warnings: deduplicate(assessments.flatMap(item => item.warnings)).map(publicWarning),
345
+ updatedAt: new Date(this.now()).toISOString(),
346
+ appliances: assessments.length
347
+ };
348
+ }
349
+
350
+ _decorate(payload, assessment) {
351
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return;
352
+ payload.health = assessment.health;
353
+ payload.warnings = assessment.warnings.map(cloneWarning);
354
+ payload.healthSchema = assessment.schema;
355
+ payload.healthAt = assessment.updatedAt;
356
+ }
357
+
358
+ assessPayload(payload, context = {}) {
359
+ if (Array.isArray(payload)) {
360
+ const assessments = payload.map(entry => this.assessPayload(entry, context));
361
+ return this._combine(assessments);
362
+ }
363
+
364
+ const candidateAppliance = payload?.appliance || (
365
+ payload && typeof payload === 'object' && payload.haId && (payload.type || Object.hasOwn(payload, 'connected')) ? payload : null
366
+ );
367
+ const haId = candidateAppliance?.haId || payload?.haId || context.haId || '*';
368
+ const state = this._state(haId);
369
+
370
+ if (payload?.error) {
371
+ const assessment = this.assessError(payload.error);
372
+ this._decorate(payload, assessment);
373
+ return assessment;
374
+ }
375
+
376
+ this._updateAppliance(state, candidateAppliance);
377
+ this._updateConnectionContext(state, payload);
378
+ if (Array.isArray(payload?.status?.items)) this._replaceStatus(state, payload.status.items);
379
+ else if (context.operation === 'status' && Array.isArray(payload?.items)) this._replaceStatus(state, payload.items);
380
+ else if (payload?.key && STATUS_KEY_SET.has(payload.key)) this._mergeStatus(state, [payload]);
381
+
382
+ const assessment = this._assessment(state);
383
+ this._decorate(payload, assessment);
384
+ return assessment;
385
+ }
386
+
387
+ assessEvent(event = {}) {
388
+ const haId = event.haId || '*';
389
+ const state = this._state(haId);
390
+ const eventName = String(event.event || '').toUpperCase();
391
+ const observedAt = event.timestamp || new Date(this.now()).toISOString();
392
+ this._updateConnectionContext(state, event);
393
+
394
+ if (eventName === 'CONNECTED') {
395
+ state.connected = true;
396
+ state.connectionCode = undefined;
397
+ this._removeTransient(state, 'HC_STREAM_DISCONNECTED');
398
+ this._addTransient(state, warning('connection', 'HC_STREAM_CONNECTED', 'info',
399
+ 'Die Event-Verbindung ist hergestellt.', 'The event connection is established.',
400
+ { health: 'info', value: true }), 30 * 1000, observedAt);
401
+ } else if (eventName === 'DISCONNECTED') {
402
+ state.connected = false;
403
+ state.connectionCode = 'HC_STREAM_DISCONNECTED';
404
+ this._removeTransient(state, 'HC_STREAM_CONNECTED');
405
+ }
406
+
407
+ const items = itemsFrom(event.data);
408
+ this._mergeStatus(state, items);
409
+ for (const item of items) {
410
+ if (item?.valueResolved === false) continue;
411
+ const level = shortValue(item?.level).toLowerCase();
412
+ const handling = shortValue(item?.handling).toLowerCase();
413
+ const handlingRequired = handling && !['none', 'optional'].includes(handling);
414
+ let severity;
415
+ if (['critical', 'alert', 'fatal'].includes(level)) severity = 'critical';
416
+ else if (level === 'error') severity = 'error';
417
+ else if (['warning', 'warn'].includes(level)) severity = 'warning';
418
+ else if (eventName === 'EVENT' && ['hint', 'info', 'information'].includes(level)) severity = 'info';
419
+
420
+ if (handlingRequired || severity) {
421
+ const eventWarning = warning('event', `HC_EVENT_${safeCode(item.key)}`, handlingRequired ? 'warning' : severity,
422
+ handlingRequired
423
+ ? `Das Gerät meldet „${shortValue(item.key)}“ und erwartet eine Reaktion.`
424
+ : `Gerätehinweis: ${shortValue(item.key)}.`,
425
+ handlingRequired
426
+ ? `The appliance reports “${shortValue(item.key)}” and expects a response.`
427
+ : `Appliance notice: ${shortValue(item.key)}.`,
428
+ {
429
+ health: handlingRequired ? 'action_required' : undefined,
430
+ key: item.key,
431
+ value: item.value,
432
+ ...(item.level !== undefined ? { level: item.level } : {}),
433
+ ...(item.handling !== undefined ? { handling: item.handling } : {})
434
+ });
435
+ this._addTransient(state, eventWarning, handlingRequired ? 15 * 60 * 1000 : this.transientTtlMs, observedAt);
436
+ } else if (eventName === 'EVENT' && item?.key) {
437
+ this._addTransient(state, warning('event', `HC_EVENT_${safeCode(item.key)}`, 'info',
438
+ `Geräteereignis: ${shortValue(item.key)}.`, `Appliance event: ${shortValue(item.key)}.`,
439
+ { health: 'info', key: item.key, value: item.value }), this.transientTtlMs, observedAt);
440
+ }
441
+ }
442
+
443
+ const assessment = this._assessment(state);
444
+ this._decorate(event, assessment);
445
+ return assessment;
446
+ }
447
+
448
+ assessError(error = {}) {
449
+ const value = typeof error?.toJSON === 'function' ? error.toJSON() : error;
450
+ const originalCode = String(value?.code || error?.code || 'UNEXPECTED_ERROR');
451
+ const code = originalCode.toLowerCase();
452
+ const status = Number(value?.status || error?.status || 0) || undefined;
453
+ const retryAfter = value?.retryAfter ?? error?.retryAfter;
454
+ let item;
455
+
456
+ if (code.includes('insufficient_scope') || code === 'insufficient_scope') {
457
+ item = warning('oauth', 'HC_OAUTH_INSUFFICIENT_SCOPE', 'critical',
458
+ 'Der Zugriffsumfang reicht nicht aus. Scopes korrigieren und neu autorisieren.',
459
+ 'The authorization scope is insufficient. Correct the scopes and authorize again.',
460
+ { health: 'action_required' });
461
+ } else if (['invalid_grant', 'not_authorized', 'authorization_expired', 'token_response_invalid', 'refresh_token_missing'].some(part => code.includes(part)) || status === 401) {
462
+ item = warning('oauth', 'HC_OAUTH_AUTHORIZATION_EXPIRED', 'critical',
463
+ 'Die Autorisierung fehlt, ist abgelaufen oder wurde widerrufen. Bitte neu autorisieren.',
464
+ 'Authorization is missing, expired, or revoked. Authorize again.',
465
+ { health: 'action_required' });
466
+ } else if (status === 429 || code.includes('rate_limit') || code.includes('too_many_requests')) {
467
+ item = warning('api', 'HC_API_RATE_LIMITED', 'warning',
468
+ 'Das API-Limit wurde erreicht. Weniger häufig abfragen und die Wiederholungszeit beachten.',
469
+ 'The API rate limit was reached. Poll less often and observe the retry time.',
470
+ { health: 'warning', ...(retryAfter !== undefined ? { retryAfter } : {}) });
471
+ } else if (['network_error', 'request_timeout', 'request_aborted', 'local_connect', 'local_socket', 'local_disconnected', 'local_heartbeat', 'local_not_connected'].some(part => code.includes(part))) {
472
+ item = warning('connection', 'HC_NETWORK_ERROR', 'error',
473
+ 'Home Connect ist über das Netzwerk derzeit nicht erreichbar.',
474
+ 'Home Connect is currently unreachable over the network.',
475
+ { health: 'offline' });
476
+ } else if (code.includes('local_read_only')) {
477
+ item = warning('security', 'HC_LOCAL_READ_ONLY', 'warning',
478
+ 'Die lokale Home-Connect-Verbindung ist fest schreibgeschützt.',
479
+ 'The local Home Connect connection is deliberately read-only.',
480
+ { health: 'warning' });
481
+ } else if (code.startsWith('oauth_') || ['access_denied', 'unauthorized_client'].some(part => code.includes(part))) {
482
+ item = warning('oauth', `HC_OAUTH_${safeCode(originalCode)}`, 'critical',
483
+ 'Die OAuth-Autorisierung ist fehlgeschlagen und muss korrigiert werden.',
484
+ 'OAuth authorization failed and requires correction.',
485
+ { health: 'action_required' });
486
+ } else {
487
+ item = warning('api', `HC_API_${safeCode(originalCode)}`, 'error',
488
+ 'Die Home-Connect-Anfrage ist fehlgeschlagen.',
489
+ 'The Home Connect request failed.',
490
+ { health: 'error' });
491
+ }
492
+
493
+ const result = publicWarning({
494
+ ...item,
495
+ originalCode,
496
+ ...(status !== undefined ? { status } : {}),
497
+ ...(retryAfter !== undefined ? { retryAfter } : {}),
498
+ transient: true
499
+ });
500
+ return {
501
+ schema: HEALTH_SCHEMA,
502
+ health: item.health,
503
+ warnings: [result],
504
+ updatedAt: new Date(this.now()).toISOString()
505
+ };
506
+ }
507
+
508
+ current(haId = '*') {
509
+ return this._assessment(this._state(haId));
510
+ }
511
+
512
+ clear(haId) {
513
+ if (haId) this.states.delete(haId);
514
+ else this.states.clear();
515
+ }
516
+ }
517
+
518
+ module.exports = {
519
+ HEALTH_SCHEMA,
520
+ HEALTH_ORDER,
521
+ STATUS_KEYS,
522
+ HealthMonitor,
523
+ shortValue,
524
+ healthFromWarnings
525
+ };