@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,255 @@
1
+ 'use strict';
2
+
3
+ function createDashboardI18n(environment = {}, options = {}) {
4
+ const STORAGE_KEY = options.storageKey || 'home-connect-dashboard-language';
5
+ const EVENT_NAME = options.eventName || 'home-connect-language-changed';
6
+ const supported = ['de', 'en'];
7
+
8
+ const labels = {
9
+ de: {
10
+ overview: 'Übersicht', applianceDetails: 'Gerätedetails', connection: 'Verbindung', status: 'Status', door: 'Tür', active: 'Aktiv',
11
+ selected: 'Ausgewählt', remaining: 'Restzeit', localProfileReadOnly: 'Lokale Profilwerte · nur lesend', remoteControl: 'Fernsteuerung',
12
+ remoteStart: 'Fernstart', localControl: 'Lokale Bedienung', currentOptions: 'Aktuelle Optionen', availablePrograms: 'Verfügbare Programme',
13
+ program: 'Programm', targetTemperature: 'Solltemperatur', temperature: 'Temperatur', refresh: 'Aktualisieren', start: 'Start', stop: 'Stopp',
14
+ openDetails: 'Details öffnen', backToOverview: 'Zur Übersicht', lastKnownState: 'letzter bekannter Stand', remoteStartUnavailable: 'Fernstart ist aktuell nicht verfügbar.',
15
+ language: 'Sprache', german: 'Deutsch', english: 'Englisch', online: 'online', offline: 'offline', yes: 'Ja', no: 'Nein',
16
+ unavailable: '—', unknownValue: 'Unbekannter Wert', rawValue: 'Rohwert', unknownWarning: 'Unbekannter Hinweis',
17
+ readOnly: 'nur lesend', simulated: 'SIMULIERT', simulatedValues: 'Simulierte Beispielwerte · nur Anzeige'
18
+ },
19
+ en: {
20
+ overview: 'Overview', applianceDetails: 'Appliance details', connection: 'Connection', status: 'Status', door: 'Door', active: 'Active',
21
+ selected: 'Selected', remaining: 'Remaining', localProfileReadOnly: 'Local profile values · read-only', remoteControl: 'Remote control',
22
+ remoteStart: 'Remote start', localControl: 'Local control', currentOptions: 'Current options', availablePrograms: 'Available programs',
23
+ program: 'Program', targetTemperature: 'Target temperature', temperature: 'Temperature', refresh: 'Refresh', start: 'Start', stop: 'Stop',
24
+ openDetails: 'Open details', backToOverview: 'Back to overview', lastKnownState: 'last known state', remoteStartUnavailable: 'Remote start is currently unavailable.',
25
+ language: 'Language', german: 'German', english: 'English', online: 'online', offline: 'offline', yes: 'Yes', no: 'No',
26
+ unavailable: '—', unknownValue: 'Unknown value', rawValue: 'raw value', unknownWarning: 'Unknown notice',
27
+ readOnly: 'read-only', simulated: 'SIMULATED', simulatedValues: 'Simulated example values · display only'
28
+ }
29
+ };
30
+
31
+ const healthNames = {
32
+ ok: { de: 'OK', en: 'OK' }, running: { de: 'Läuft', en: 'Running' }, info: { de: 'Info', en: 'Info' },
33
+ warning: { de: 'Warnung', en: 'Warning' }, action_required: { de: 'Aktion nötig', en: 'Action required' },
34
+ offline: { de: 'Offline', en: 'Offline' }, error: { de: 'Fehler', en: 'Error' }
35
+ };
36
+
37
+ const deviceTypes = {
38
+ Dishwasher: { de: 'Geschirrspüler', en: 'Dishwasher' }, Washer: { de: 'Waschmaschine', en: 'Washer' },
39
+ Oven: { de: 'Backofen', en: 'Oven' }, Dryer: { de: 'Trockner', en: 'Dryer' },
40
+ WasherDryer: { de: 'Waschtrockner', en: 'Washer-dryer' }, Refrigerator: { de: 'Kühlschrank', en: 'Refrigerator' },
41
+ Freezer: { de: 'Gefrierschrank', en: 'Freezer' }, FridgeFreezer: { de: 'Kühl-Gefrier-Kombination', en: 'Fridge-freezer' },
42
+ CoffeeMaker: { de: 'Kaffeevollautomat', en: 'Coffee maker' }, Hood: { de: 'Dunstabzugshaube', en: 'Hood' },
43
+ Hob: { de: 'Kochfeld', en: 'Hob' }
44
+ };
45
+
46
+ const valueNames = {
47
+ Inactive: { de: 'Inaktiv', en: 'Inactive' }, Ready: { de: 'Bereit', en: 'Ready' }, Run: { de: 'Läuft', en: 'Running' },
48
+ Finished: { de: 'Beendet', en: 'Finished' }, DelayedStart: { de: 'Startzeitvorwahl', en: 'Delayed start' },
49
+ Pause: { de: 'Pausiert', en: 'Paused' }, Paused: { de: 'Pausiert', en: 'Paused' },
50
+ ActionRequired: { de: 'Aktion erforderlich', en: 'Action required' }, Aborting: { de: 'Wird abgebrochen', en: 'Aborting' },
51
+ Error: { de: 'Fehler', en: 'Error' }, Open: { de: 'Offen', en: 'Open' }, Closed: { de: 'Geschlossen', en: 'Closed' },
52
+ Locked: { de: 'Verriegelt', en: 'Locked' }, On: { de: 'Ein', en: 'On' }, Off: { de: 'Aus', en: 'Off' },
53
+ Standby: { de: 'Standby', en: 'Standby' }, Present: { de: 'Vorhanden', en: 'Present' }, Absent: { de: 'Nicht vorhanden', en: 'Absent' },
54
+ Confirmed: { de: 'Bestätigt', en: 'Confirmed' }, Enabled: { de: 'Aktiviert', en: 'Enabled' }, Disabled: { de: 'Deaktiviert', en: 'Disabled' },
55
+ Cleaning: { de: 'Reinigen', en: 'Cleaning' }, Rinsing: { de: 'Spülen', en: 'Rinsing' }, Drying: { de: 'Trocknen', en: 'Drying' },
56
+ PreWash: { de: 'Vorspülen', en: 'Pre-wash' }, MainWash: { de: 'Hauptspülen', en: 'Main wash' },
57
+ Heating: { de: 'Aufheizen', en: 'Heating' }, Cooling: { de: 'Abkühlen', en: 'Cooling' },
58
+ DE: { de: 'Deutsch', en: 'German' }, EN: { de: 'Englisch', en: 'English' }
59
+ };
60
+
61
+ const displayNames = {
62
+ ProgramProgress: { de: 'Programmfortschritt', en: 'Program progress' }, RemainingProgramTime: { de: 'Restzeit', en: 'Remaining program time' },
63
+ EnergyForecast: { de: 'Energieprognose', en: 'Energy forecast' }, WaterForecast: { de: 'Wasserprognose', en: 'Water forecast' },
64
+ StartInRelative: { de: 'Start in', en: 'Start in' }, Duration: { de: 'Dauer', en: 'Duration' },
65
+ RemoteControlActive: { de: 'Fernsteuerung aktiv', en: 'Remote control active' },
66
+ RemoteControlStartAllowed: { de: 'Fernstart erlaubt', en: 'Remote start allowed' }, LocalControlActive: { de: 'Lokale Bedienung aktiv', en: 'Local control active' },
67
+ OperationState: { de: 'Betriebszustand', en: 'Operation state' }, DoorState: { de: 'Türzustand', en: 'Door state' },
68
+ PowerState: { de: 'Betriebsart', en: 'Power state' }, ProgramPhase: { de: 'Programmphase', en: 'Program phase' },
69
+ Language: { de: 'Sprache', en: 'Language' }, SaltNearlyEmpty: { de: 'Salz fast leer', en: 'Salt nearly empty' },
70
+ RinseAidNearlyEmpty: { de: 'Klarspüler fast leer', en: 'Rinse aid nearly empty' },
71
+ HygienePlus: { de: 'Hygiene Plus', en: 'Hygiene Plus' }, IntensivZone: { de: 'Intensivzone', en: 'Intensive zone' },
72
+ VarioSpeedPlus: { de: 'varioSpeed Plus', en: 'varioSpeed Plus' }, BrillianceDry: { de: 'Glanztrocknen', en: 'Brilliance dry' },
73
+ ExtraDry: { de: 'Extra trocknen', en: 'Extra dry' }, HalfLoad: { de: 'Halbe Beladung', en: 'Half load' },
74
+ SilenceOnDemand: { de: 'Leisebetrieb', en: 'Silence on demand' }, Temperature: { de: 'Temperatur', en: 'Temperature' },
75
+ SpinSpeed: { de: 'Schleuderdrehzahl', en: 'Spin speed' }, Stains: { de: 'Flecken', en: 'Stains' },
76
+ SetpointTemperature: { de: 'Solltemperatur', en: 'Target temperature' }, FastPreHeat: { de: 'Schnellaufheizen', en: 'Fast preheat' },
77
+ AddedSteam: { de: 'Dampfzugabe', en: 'Added steam' },
78
+
79
+ Intensiv70: { de: 'Intensiv 70 °C', en: 'Intensive 70 °C' }, Auto2: { de: 'Automatik 45–65 °C', en: 'Auto 45–65 °C' },
80
+ Eco50: { de: 'Eco 50 °C', en: 'Eco 50 °C' }, Glas40: { de: 'Glas 40 °C', en: 'Glass 40 °C' },
81
+ Quick45: { de: 'Kurz 45 °C', en: 'Quick 45 °C' }, PreRinse: { de: 'Vorspülen', en: 'Pre-rinse' },
82
+ MachineCare: { de: 'Maschinenpflege', en: 'Machine care' },
83
+ Cotton: { de: 'Baumwolle', en: 'Cotton' }, EasyCare: { de: 'Pflegeleicht', en: 'Easy-care' }, Mix: { de: 'Mix', en: 'Mix' },
84
+ DelicatesSilk: { de: 'Fein/Seide', en: 'Delicates/Silk' }, Wool: { de: 'Wolle', en: 'Wool' }, DarkWash: { de: 'Dunkle Wäsche', en: 'Dark wash' },
85
+ ShirtsBlouses: { de: 'Hemden/Blusen', en: 'Shirts/Blouses' }, Curtains: { de: 'Gardinen', en: 'Curtains' }, Super15: { de: 'Super 15', en: 'Super 15' },
86
+ Dessous: { de: 'Dessous', en: 'Lingerie' }, Outdoor: { de: 'Outdoor', en: 'Outdoor' }, Sensitive: { de: 'Sensitiv', en: 'Sensitive' },
87
+ DrumClean: { de: 'Trommel reinigen', en: 'Drum clean' }, Rinse: { de: 'Spülen', en: 'Rinse' }, Spin: { de: 'Schleudern', en: 'Spin' },
88
+ Drain: { de: 'Abpumpen', en: 'Drain' }, NightWash: { de: 'Nachtwäsche', en: 'Night wash' }, CottonEco: { de: 'Baumwolle Eco', en: 'Cotton Eco' },
89
+ Memory1: { de: 'Favorit 1', en: 'Memory 1' }, JustStart: { de: 'Nur Start', en: 'Just start' },
90
+ HotAir: { de: 'Heißluft', en: 'Hot air' }, TopBottomHeating: { de: 'Ober-/Unterhitze', en: 'Top/bottom heat' },
91
+ PreHeating: { de: 'Vorheizen', en: 'Preheating' }, HotAirEco: { de: 'Heißluft Eco', en: 'Hot air Eco' },
92
+ TopBottomHeatingEco: { de: 'Ober-/Unterhitze Eco', en: 'Top/bottom heat Eco' }, HotAirGrilling: { de: 'Umluftgrill', en: 'Hot-air grilling' },
93
+ PizzaSetting: { de: 'Pizzastufe', en: 'Pizza setting' }, IntensiveHeat: { de: 'Intensivhitze', en: 'Intensive heat' },
94
+ GrillLargeArea: { de: 'Großflächengrill', en: 'Large-area grill' }, GrillSmallArea: { de: 'Kleinflächengrill', en: 'Small-area grill' },
95
+ SlowCook: { de: 'Sanftgaren', en: 'Slow cook' }, BottomHeating: { de: 'Unterhitze', en: 'Bottom heat' },
96
+ KeepWarm: { de: 'Warmhalten', en: 'Keep warm' }, PreheatOvenware: { de: 'Geschirr vorwärmen', en: 'Preheat ovenware' },
97
+ FrozenHeatupSpecial: { de: 'Tiefkühl-Spezial', en: 'Frozen food special' }, SabbathProgramme: { de: 'Sabbatprogramm', en: 'Sabbath program' },
98
+ Steam: { de: 'Dampf', en: 'Steam' }, Reheat: { de: 'Aufwärmen', en: 'Reheat' }, DoughProving: { de: 'Gärstufe', en: 'Dough proving' },
99
+ Defrosting: { de: 'Auftauen', en: 'Defrosting' }, Ecolysis: { de: 'Ecolyse', en: 'Ecolysis' }, Descale: { de: 'Entkalken', en: 'Descale' },
100
+ Flushing: { de: 'Spülen', en: 'Flushing' }, Draining: { de: 'Abpumpen', en: 'Draining' }, SubsequentCooking: { de: 'Nachgaren', en: 'Subsequent cooking' },
101
+ KuchenInFormen: { de: 'Kuchen in Formen', en: 'Cakes in tins' }, KuchenAufBackblech: { de: 'Kuchen auf Backblech', en: 'Tray-baked cakes' },
102
+ Strudel: { de: 'Strudel', en: 'Strudel' }, Pavlova: { de: 'Pavlova', en: 'Pavlova' }, WaeheOderTart: { de: 'Wähe/Tarte', en: 'Flan/Tart' },
103
+ Blaetterteiggebaeck: { de: 'Blätterteiggebäck', en: 'Puff pastry' }, Brandteiggebaeck: { de: 'Brandteiggebäck', en: 'Choux pastry' },
104
+ Muffins: { de: 'Muffins', en: 'Muffins' }, Plundergebaeck: { de: 'Plundergebäck', en: 'Danish pastry' },
105
+ Hefeteiggebaeck: { de: 'Hefeteiggebäck', en: 'Yeast pastries' }, Muerbeteiggebaeck: { de: 'Mürbeteiggebäck', en: 'Shortcrust pastry' },
106
+ Baiser: { de: 'Baiser', en: 'Meringue' }, Makronen: { de: 'Makronen', en: 'Macaroons' }, Spritzgebaeck: { de: 'Spritzgebäck', en: 'Spritz biscuits' },
107
+ Plaetzchen: { de: 'Plätzchen', en: 'Biscuits' }, Vollkornbrot: { de: 'Vollkornbrot', en: 'Wholegrain bread' },
108
+ WeizenbrotWeizenmischbrotAufBackblech: { de: 'Weizen-/Weizenmischbrot auf Backblech', en: 'Wheat/mixed-wheat bread on tray' }
109
+ };
110
+
111
+ function unwrap(value) {
112
+ if (value && typeof value === 'object' && Object.hasOwn(value, 'value')) return value.value;
113
+ return value;
114
+ }
115
+
116
+ function normalizeLocale(value) {
117
+ const text = String(unwrap(value) || '').trim().toLowerCase().replace('_', '-');
118
+ if (text === 'de' || text.startsWith('de-')) return 'de';
119
+ if (text === 'en' || text.startsWith('en-')) return 'en';
120
+ return '';
121
+ }
122
+
123
+ function readStoredLocale() {
124
+ try { return normalizeLocale(environment.localStorage?.getItem(STORAGE_KEY)); }
125
+ catch { return ''; }
126
+ }
127
+
128
+ function detectLocale(component, message) {
129
+ const msg = message || component?.msg || {};
130
+ const candidates = [
131
+ msg.uiLocale, msg.locale, readStoredLocale(), component?.$i18n?.locale, component?.$vuetify?.locale?.current,
132
+ environment.document?.documentElement?.lang, ...(environment.navigator?.languages || []), environment.navigator?.language
133
+ ];
134
+ for (const candidate of candidates) {
135
+ const locale = normalizeLocale(candidate);
136
+ if (locale) return locale;
137
+ }
138
+ return 'en';
139
+ }
140
+
141
+ function setLocale(value, notify = true) {
142
+ const locale = normalizeLocale(value) || 'en';
143
+ try { environment.localStorage?.setItem(STORAGE_KEY, locale); }
144
+ catch { /* Storage can be blocked by browser policy. */ }
145
+ if (notify) {
146
+ try { environment.dispatchEvent?.(new environment.CustomEvent(EVENT_NAME, { detail: { locale } })); }
147
+ catch { /* CustomEvent may be unavailable in non-browser tests. */ }
148
+ }
149
+ return locale;
150
+ }
151
+
152
+ function t(key, locale) {
153
+ const lang = normalizeLocale(locale) || 'en';
154
+ return labels[lang][key] ?? labels.en[key] ?? key;
155
+ }
156
+
157
+ function token(value) {
158
+ return String(value ?? '').split('.').pop();
159
+ }
160
+
161
+ function humanize(value) {
162
+ const leaf = token(value)
163
+ .replace(/[_-]+/g, ' ')
164
+ .replace(/([a-zäöüß])([A-Z])/g, '$1 $2')
165
+ .replace(/([A-Za-z])([0-9])/g, '$1 $2')
166
+ .replace(/([0-9])([A-Za-z])/g, '$1 $2')
167
+ .replace(/\s+/g, ' ')
168
+ .trim();
169
+ return leaf || '—';
170
+ }
171
+
172
+ function localized(entry, locale) {
173
+ const lang = normalizeLocale(locale) || 'en';
174
+ return entry?.[lang] ?? entry?.en;
175
+ }
176
+
177
+ function deviceType(value, locale) {
178
+ const leaf = token(value);
179
+ const known = localized(deviceTypes[leaf], locale);
180
+ if (known) return known;
181
+ if (!leaf) return t('unavailable', locale);
182
+ const prefix = normalizeLocale(locale) === 'de' ? 'Gerätetyp' : 'Appliance type';
183
+ return `${prefix}: ${humanize(leaf)}`;
184
+ }
185
+
186
+ function displayKey(value, locale) {
187
+ const leaf = token(value);
188
+ return localized(displayNames[leaf], locale) || localized(valueNames[leaf], locale) || humanize(leaf);
189
+ }
190
+
191
+ function displayName(value, locale) {
192
+ const source = value && typeof value === 'object' ? (value.key || value.name) : value;
193
+ if (source === undefined || source === null || source === '' || source === '—') return t('unavailable', locale);
194
+ const leaf = token(source);
195
+ const known = localized(displayNames[leaf], locale) || localized(valueNames[leaf], locale);
196
+ if (known) return known;
197
+ const fallbackSource = value && typeof value === 'object' && value.name && !String(value.name).includes('.') ? value.name : leaf;
198
+ return humanize(fallbackSource);
199
+ }
200
+
201
+ function unknown(rawValue, locale) {
202
+ const printable = rawValue === undefined ? '?' : String(rawValue);
203
+ return `${t('unknownValue', locale)} (${t('rawValue', locale)}: ${printable})`;
204
+ }
205
+
206
+ function semantic(item, locale) {
207
+ if (!item) return t('unavailable', locale);
208
+ if (item.valueResolved === false) return unknown(item.rawValue, locale);
209
+ const value = item.value;
210
+ if (value === undefined || value === null || value === '') return t('unavailable', locale);
211
+ if (value === true) return t('yes', locale);
212
+ if (value === false) return t('no', locale);
213
+ if (typeof value === 'number') return new Intl.NumberFormat(normalizeLocale(locale) === 'de' ? 'de-DE' : 'en-US').format(value);
214
+ return displayName(value, locale);
215
+ }
216
+
217
+ function booleanText(item, locale) {
218
+ return semantic(item, locale);
219
+ }
220
+
221
+ function healthLabel(value, locale) {
222
+ return localized(healthNames[value], locale) || humanize(value);
223
+ }
224
+
225
+ function warningText(value, locale) {
226
+ if (!value) return '';
227
+ const lang = normalizeLocale(locale) || 'en';
228
+ return value.text?.[lang] || value.text?.en || value.text?.de || `${t('unknownWarning', lang)} (${value.code || '?'})`;
229
+ }
230
+
231
+ function remaining(model, locale) {
232
+ const value = model?.remaining;
233
+ const item = model?.remainingItem;
234
+ if (value === undefined || value === null || value === '') return t('unavailable', locale);
235
+ if (item?.valueResolved === false) return unknown(item.rawValue, locale);
236
+ const number = Number(value);
237
+ if (!Number.isFinite(number)) return String(value);
238
+ const unit = String(item?.unit || '').toLowerCase();
239
+ if (unit.includes('min')) return `${Math.ceil(number)} min`;
240
+ if (unit.includes('sec') || !model?.connection?.local) return `${Math.ceil(number / 60)} min`;
241
+ const formatted = new Intl.NumberFormat(normalizeLocale(locale) === 'de' ? 'de-DE' : 'en-US').format(number);
242
+ return item?.unit ? `${formatted} ${item.unit}` : formatted;
243
+ }
244
+
245
+ function programItems(programs, locale) {
246
+ return (Array.isArray(programs) ? programs : []).map(item => ({ ...item, title: displayName(item, locale) }));
247
+ }
248
+
249
+ return {
250
+ STORAGE_KEY, EVENT_NAME, supported, normalizeLocale, detectLocale, setLocale, t, token, humanize,
251
+ deviceType, displayKey, displayName, semantic, booleanText, healthLabel, warningText, remaining, programItems
252
+ };
253
+ }
254
+
255
+ module.exports = { createDashboardI18n };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ const TYPE_NAMES = Object.freeze({
4
+ Dishwasher: { de: 'Geschirrspüler', en: 'Dishwasher' },
5
+ Washer: { de: 'Waschmaschine', en: 'Washer' },
6
+ Oven: { de: 'Backofen', en: 'Oven' }
7
+ });
8
+
9
+ function text(value) {
10
+ return value === undefined || value === null ? '' : String(value).trim();
11
+ }
12
+
13
+ function shortTechnicalId(value, visible = 4) {
14
+ const id = text(value);
15
+ if (!id) return '';
16
+ return `…${id.slice(-Math.max(4, Number(visible) || 4))}`;
17
+ }
18
+
19
+ function applianceModel(appliance = {}) {
20
+ const value = text(appliance.model || appliance.vib || appliance.eNumber || appliance.enumber);
21
+ return value ? value.split('/')[0] : '';
22
+ }
23
+
24
+ function formatDeviceLabel(appliance = {}, language = 'de') {
25
+ const type = text(appliance.type || appliance.applianceType || appliance.deviceType) || 'Appliance';
26
+ const localizedType = TYPE_NAMES[type]?.[String(language).toLowerCase().startsWith('de') ? 'de' : 'en'] || '';
27
+ const name = text(appliance.name || appliance.displayName);
28
+ const brand = text(appliance.brand);
29
+ const model = applianceModel(appliance);
30
+ const brandModel = [brand, model].filter(Boolean).join(' ');
31
+ const id = shortTechnicalId(appliance.haId || appliance.id || appliance.identifier);
32
+ const parts = [type];
33
+ for (const value of [name || localizedType, brandModel, id]) {
34
+ if (value && !parts.some(part => part.toLowerCase() === value.toLowerCase())) parts.push(value);
35
+ }
36
+ return parts.join(' — ');
37
+ }
38
+
39
+ module.exports = { TYPE_NAMES, applianceModel, formatDeviceLabel, shortTechnicalId };
package/lib/errors.js ADDED
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ class HomeConnectError extends Error {
4
+ constructor(message, options = {}) {
5
+ super(message);
6
+ this.name = 'HomeConnectError';
7
+ this.code = options.code || 'HOME_CONNECT_ERROR';
8
+ this.status = options.status;
9
+ this.details = options.details;
10
+ this.retryAfter = options.retryAfter;
11
+ if (options.cause) this.cause = options.cause;
12
+ }
13
+
14
+ toJSON() {
15
+ return {
16
+ name: this.name,
17
+ code: this.code,
18
+ message: this.message,
19
+ status: this.status,
20
+ details: this.details,
21
+ retryAfter: this.retryAfter
22
+ };
23
+ }
24
+ }
25
+
26
+ function retryAfterSeconds(value, now = Date.now()) {
27
+ if (value === undefined || value === null || value === '') return undefined;
28
+ const text = String(value);
29
+ const seconds = Number(text);
30
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds);
31
+ const date = Date.parse(text);
32
+ if (Number.isFinite(date)) return Math.max(0, Math.ceil((date - now) / 1000));
33
+ const match = text.match(/(?:remaining\s+period\s+of|retry(?:\s+after|\s+in)?|blocked(?:\s+for|\s+during)?)[^0-9]{0,24}(\d+)\s*seconds?/i)
34
+ || text.match(/(\d+)\s*seconds?[^.]{0,40}(?:remaining|retry|blocked)/i);
35
+ return match ? Number(match[1]) : undefined;
36
+ }
37
+
38
+ function apiError(response, payload) {
39
+ const raw = payload && payload.error !== undefined ? payload.error : payload;
40
+ const source = typeof raw === 'string' ? { key: raw } : (raw || {});
41
+ const description = source.description || source.message || payload?.error_description;
42
+ const retryAfter = retryAfterSeconds(response.headers?.get?.('retry-after') || description);
43
+ return new HomeConnectError(description || `Home Connect API returned HTTP ${response.status}`, {
44
+ code: source.key || source.code || (typeof raw === 'string' ? raw : `HTTP_${response.status}`),
45
+ status: response.status,
46
+ details: source,
47
+ retryAfter: retryAfter === undefined ? undefined : String(retryAfter)
48
+ });
49
+ }
50
+
51
+ module.exports = { HomeConnectError, apiError, retryAfterSeconds };