@camstack/addon-provider-bosch 0.1.1 → 0.1.3

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.
@@ -1,7 +1,10 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_dist = require("../dist-QXDYCZIL.js");
3
- const require_account_integration_offline = require("../account-integration-offline-Cbixus9v.js");
2
+ const require_dist = require("../dist-B-Un7bqT.js");
3
+ const require_account_integration_offline = require("../account-integration-offline-BBqEtCgz.js");
4
4
  let node_crypto = require("node:crypto");
5
+ let node_tls = require("node:tls");
6
+ let node_zlib = require("node:zlib");
7
+ let node_dns_promises = require("node:dns/promises");
5
8
  //#region src/home-connect/custom-actions.ts
6
9
  var homeConnectActions = require_dist.defineCustomActions({
7
10
  beginSignIn: require_dist.customAction(require_dist.object({}), require_dist.object({
@@ -18,9 +21,12 @@ var homeConnectActions = require_dist.defineCustomActions({
18
21
  callback: require_dist.string().min(1),
19
22
  state: require_dist.string().min(1)
20
23
  }), require_dist.object({
21
- token: require_dist.string().min(1),
22
- refresh: require_dist.string().optional(),
24
+ token: require_dist.string().min(1).meta({ secret: true }),
25
+ refresh: require_dist.string().optional().meta({ secret: true }),
26
+ /** JSON of the paired appliances — no credential on it. */
23
27
  appliances: require_dist.string().min(1),
28
+ /** JSON of `haId -> { psk, iv64? }`; stored in the keychain, never in `appliances`. */
29
+ applianceSecrets: require_dist.string().min(1).meta({ secret: true }),
24
30
  summary: require_dist.string()
25
31
  }), {
26
32
  kind: "mutation",
@@ -28,9 +34,477 @@ var homeConnectActions = require_dist.defineCustomActions({
28
34
  })
29
35
  });
30
36
  //#endregion
37
+ //#region src/home-connect/home-connect-xml.ts
38
+ var TOKEN_RE = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!\[CDATA\[([\s\S]*?)\]\]>|<!DOCTYPE[^>]*>|<\/([\w:.-]+)\s*>|<([\w:.-]+)((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|([^<]+)/g;
39
+ var ATTR_RE = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
40
+ function decodeEntities(raw) {
41
+ return raw.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&#x([0-9A-Fa-f]+);/g, (_m, hex) => String.fromCodePoint(Number.parseInt(hex, 16))).replace(/&#(\d+);/g, (_m, dec) => String.fromCodePoint(Number.parseInt(dec, 10))).replace(/&amp;/g, "&");
42
+ }
43
+ function parseAttrs(raw) {
44
+ const attrs = {};
45
+ for (const m of raw.matchAll(ATTR_RE)) {
46
+ const key = m[1];
47
+ const value = m[2] ?? m[3] ?? "";
48
+ if (key !== void 0) attrs[key] = decodeEntities(value);
49
+ }
50
+ return attrs;
51
+ }
52
+ /** Parse a document; `null` when it has no root element. Tolerates unbalanced tags (closes to the nearest match). */
53
+ function parseXml(xml) {
54
+ const stack = [];
55
+ let root = null;
56
+ for (const m of xml.matchAll(TOKEN_RE)) {
57
+ const cdata = m[1];
58
+ const closeName = m[2];
59
+ const openName = m[3];
60
+ const text = m[6];
61
+ if (openName !== void 0) {
62
+ const el = {
63
+ name: openName,
64
+ attrs: parseAttrs(m[4] ?? ""),
65
+ children: [],
66
+ text: ""
67
+ };
68
+ const parent = stack[stack.length - 1];
69
+ if (parent) parent.children.push(el);
70
+ else if (root === null) root = el;
71
+ if (m[5] !== "/") stack.push(el);
72
+ continue;
73
+ }
74
+ if (closeName !== void 0) {
75
+ const at = stack.map((e) => e.name).lastIndexOf(closeName);
76
+ if (at >= 0) stack.length = at;
77
+ continue;
78
+ }
79
+ const parent = stack[stack.length - 1];
80
+ if (parent) {
81
+ if (cdata !== void 0) parent.text += cdata;
82
+ else if (text !== void 0) parent.text += decodeEntities(text);
83
+ }
84
+ }
85
+ return root;
86
+ }
87
+ /** Depth-first walk over every descendant element (the root included). */
88
+ function walkXml(root, visit) {
89
+ const go = (el, ancestors) => {
90
+ visit(el, ancestors);
91
+ const next = [...ancestors, el];
92
+ for (const child of el.children) go(child, next);
93
+ };
94
+ go(root, []);
95
+ }
96
+ function isDescriptionRoot(value) {
97
+ return typeof value === "object" && value !== null && !Array.isArray(value);
98
+ }
99
+ function parseHexUid(raw) {
100
+ return Number.parseInt(raw, 16);
101
+ }
102
+ function parseFeatureMappingXml(xml) {
103
+ const features = /* @__PURE__ */ new Map();
104
+ const enumerations = /* @__PURE__ */ new Map();
105
+ const root = parseXml(xml);
106
+ if (root === null) return {
107
+ features,
108
+ enumerations
109
+ };
110
+ walkXml(root, (el) => {
111
+ if (el.name === "feature") {
112
+ const uidHex = el.attrs["refUID"];
113
+ const name = el.text.trim();
114
+ if (uidHex && name) features.set(parseHexUid(uidHex), name);
115
+ return;
116
+ }
117
+ if (el.name === "enumDescription") {
118
+ const enidHex = el.attrs["refENID"];
119
+ if (!enidHex) return;
120
+ const members = {};
121
+ for (const child of el.children) {
122
+ if (child.name !== "enumMember") continue;
123
+ const value = child.attrs["refValue"];
124
+ const label = child.text.trim();
125
+ if (value !== void 0 && label) members[Number.parseInt(value, 10)] = label;
126
+ }
127
+ enumerations.set(parseHexUid(enidHex), members);
128
+ }
129
+ });
130
+ return {
131
+ features,
132
+ enumerations
133
+ };
134
+ }
135
+ var ENTITY_KIND_BY_TAG = {
136
+ status: "status",
137
+ setting: "setting",
138
+ option: "option",
139
+ command: "command",
140
+ event: "event",
141
+ program: "program",
142
+ activeProgram: "activeProgram",
143
+ selectedProgram: "selectedProgram"
144
+ };
145
+ var ACCESS_VALUES = [
146
+ "none",
147
+ "read",
148
+ "readWrite",
149
+ "writeOnly",
150
+ "readStatic"
151
+ ];
152
+ function accessFrom(raw) {
153
+ return ACCESS_VALUES.find((a) => a === raw);
154
+ }
155
+ function boolAttr(raw) {
156
+ if (raw === "true") return true;
157
+ if (raw === "false") return false;
158
+ }
159
+ function numberAttr(raw) {
160
+ if (raw === void 0 || raw.trim() === "") return void 0;
161
+ const n = Number(raw);
162
+ return Number.isFinite(n) ? n : void 0;
163
+ }
164
+ /** A `default="…"` attribute: a number, a boolean, or the text as written. */
165
+ function defaultAttr(raw) {
166
+ if (raw === void 0) return void 0;
167
+ if (raw === "true") return true;
168
+ if (raw === "false") return false;
169
+ const n = numberAttr(raw);
170
+ return n !== void 0 ? n : raw;
171
+ }
172
+ function featureName(features, uid, hexUid) {
173
+ const mapped = features.get(uid);
174
+ if (mapped === void 0) return `Unmapped.${hexUid.toUpperCase()}`;
175
+ return mapped.includes(".") ? mapped : `BSH.Common.${mapped}`;
176
+ }
177
+ function entityFromElement(el, kind, mapping) {
178
+ const uidRaw = el.attrs["uid"];
179
+ if (!uidRaw) return null;
180
+ const uid = parseHexUid(uidRaw);
181
+ if (Number.isNaN(uid)) return null;
182
+ const enType = el.attrs["enumerationType"];
183
+ const enumeration = enType ? { ...mapping.enumerations.get(parseHexUid(enType)) } : void 0;
184
+ const min = numberAttr(el.attrs["min"]);
185
+ const max = numberAttr(el.attrs["max"]);
186
+ const stepSize = numberAttr(el.attrs["stepSize"]);
187
+ const valueType = enumeration !== void 0 ? "enum" : min !== void 0 || max !== void 0 || stepSize !== void 0 ? "number" : "unknown";
188
+ const unit = el.attrs["unit"];
189
+ const defaultValue = defaultAttr(el.attrs["default"]);
190
+ const handling = el.attrs["handling"];
191
+ const level = el.attrs["level"];
192
+ const refCid = el.attrs["refCID"];
193
+ const refDid = el.attrs["refDID"];
194
+ const access = accessFrom(el.attrs["access"]);
195
+ const available = boolAttr(el.attrs["available"]);
196
+ return {
197
+ uid,
198
+ name: featureName(mapping.features, uid, uidRaw),
199
+ kind,
200
+ ...access !== void 0 ? { access } : {},
201
+ ...available !== void 0 ? { available } : {},
202
+ ...enumeration !== void 0 ? { enumeration } : {},
203
+ valueType,
204
+ ...unit !== void 0 && unit.length > 0 ? { unit } : {},
205
+ ...min !== void 0 ? { min } : {},
206
+ ...max !== void 0 ? { max } : {},
207
+ ...stepSize !== void 0 ? { stepSize } : {},
208
+ ...defaultValue !== void 0 ? { defaultValue } : {},
209
+ ...handling !== void 0 ? { handling } : {},
210
+ ...level !== void 0 ? { level } : {},
211
+ ...refCid !== void 0 ? { refCid } : {},
212
+ ...refDid !== void 0 ? { refDid } : {}
213
+ };
214
+ }
215
+ function infoFromDeviceXml(root, deviceIdFallback) {
216
+ const text = (name) => {
217
+ let found;
218
+ walkXml(root, (el) => {
219
+ if (found === void 0 && el.name === name) {
220
+ const t = el.text.trim();
221
+ if (t.length > 0) found = t;
222
+ }
223
+ });
224
+ return found;
225
+ };
226
+ const brand = text("brand");
227
+ return {
228
+ deviceID: deviceIdFallback,
229
+ deviceType: text("type") ?? "Appliance",
230
+ model: text("model") ?? deviceIdFallback,
231
+ ...brand !== void 0 ? { brand } : {}
232
+ };
233
+ }
234
+ function programOptionsOf(program, byUid) {
235
+ const options = [];
236
+ for (const child of program.children) {
237
+ if (child.name !== "option") continue;
238
+ const ref = child.attrs["refUID"];
239
+ if (!ref) continue;
240
+ const def = byUid.get(parseHexUid(ref));
241
+ if (def === void 0) continue;
242
+ const access = accessFrom(child.attrs["access"]);
243
+ const available = boolAttr(child.attrs["available"]);
244
+ const defaultValue = defaultAttr(child.attrs["default"]);
245
+ options.push({
246
+ key: def.name,
247
+ ...access !== void 0 ? { access } : {},
248
+ ...available !== void 0 ? { available } : {},
249
+ ...defaultValue !== void 0 ? { defaultValue } : {}
250
+ });
251
+ }
252
+ return options;
253
+ }
254
+ /** Parse an IDDF XML pair into the compact JSON stored on the appliance connection. */
255
+ function parseHomeConnectIddfXml(input) {
256
+ const mapping = parseFeatureMappingXml(input.featureMappingXml);
257
+ const root = parseXml(input.deviceDescriptionXml);
258
+ const entities = {};
259
+ const byUid = /* @__PURE__ */ new Map();
260
+ const programElements = [];
261
+ if (root !== null) walkXml(root, (el) => {
262
+ const kind = ENTITY_KIND_BY_TAG[el.name];
263
+ if (kind === void 0) return;
264
+ const def = entityFromElement(el, kind, mapping);
265
+ if (def === null) return;
266
+ entities[String(def.uid)] = def;
267
+ byUid.set(def.uid, def);
268
+ if (kind === "program") programElements.push(el);
269
+ });
270
+ const program = [];
271
+ for (const el of programElements) {
272
+ const def = byUid.get(parseHexUid(el.attrs["uid"] ?? ""));
273
+ if (def === void 0) continue;
274
+ const options = programOptionsOf(el, byUid);
275
+ program.push({
276
+ name: def.name.split(".").pop() ?? def.name,
277
+ uid: def.name,
278
+ available: def.available !== false,
279
+ ...options.length > 0 ? { options } : {}
280
+ });
281
+ }
282
+ return {
283
+ version: 2,
284
+ info: root !== null ? infoFromDeviceXml(root, input.haId) : {
285
+ deviceID: input.haId,
286
+ deviceType: "Appliance",
287
+ model: input.haId
288
+ },
289
+ program,
290
+ entities
291
+ };
292
+ }
293
+ function enumerationFrom(raw) {
294
+ if (!isDescriptionRoot(raw)) return void 0;
295
+ const out = {};
296
+ for (const [ek, ev] of Object.entries(raw)) if (typeof ev === "string") out[Number.parseInt(ek, 10)] = ev;
297
+ return out;
298
+ }
299
+ function stringIn(raw, allowed) {
300
+ return allowed.find((a) => a === raw);
301
+ }
302
+ var ENTITY_KINDS = [
303
+ "status",
304
+ "setting",
305
+ "option",
306
+ "command",
307
+ "event",
308
+ "program",
309
+ "activeProgram",
310
+ "selectedProgram"
311
+ ];
312
+ var VALUE_TYPES = [
313
+ "boolean",
314
+ "number",
315
+ "enum",
316
+ "string",
317
+ "unknown"
318
+ ];
319
+ function finiteNumber(raw) {
320
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : void 0;
321
+ }
322
+ function entityFromJson(key, val) {
323
+ const name = val["name"];
324
+ const uid = Number.parseInt(key, 10);
325
+ if (typeof name !== "string" || Number.isNaN(uid)) return null;
326
+ const enumeration = enumerationFrom(val["enumeration"]);
327
+ const kind = stringIn(val["kind"], ENTITY_KINDS);
328
+ const access = stringIn(val["access"], ACCESS_VALUES);
329
+ const valueType = stringIn(val["valueType"], VALUE_TYPES);
330
+ const available = typeof val["available"] === "boolean" ? val["available"] : void 0;
331
+ const unit = typeof val["unit"] === "string" && val["unit"].length > 0 ? val["unit"] : void 0;
332
+ const min = finiteNumber(val["min"]);
333
+ const max = finiteNumber(val["max"]);
334
+ const stepSize = finiteNumber(val["stepSize"]);
335
+ const handling = typeof val["handling"] === "string" ? val["handling"] : void 0;
336
+ const level = typeof val["level"] === "string" ? val["level"] : void 0;
337
+ const refCid = typeof val["refCid"] === "string" ? val["refCid"] : void 0;
338
+ const refDid = typeof val["refDid"] === "string" ? val["refDid"] : void 0;
339
+ const defaultValue = val["defaultValue"];
340
+ return {
341
+ uid,
342
+ name,
343
+ ...kind !== void 0 ? { kind } : {},
344
+ ...access !== void 0 ? { access } : {},
345
+ ...available !== void 0 ? { available } : {},
346
+ ...enumeration !== void 0 ? { enumeration } : {},
347
+ ...valueType !== void 0 ? { valueType } : {},
348
+ ...unit !== void 0 ? { unit } : {},
349
+ ...min !== void 0 ? { min } : {},
350
+ ...max !== void 0 ? { max } : {},
351
+ ...stepSize !== void 0 ? { stepSize } : {},
352
+ ...defaultValue !== void 0 ? { defaultValue } : {},
353
+ ...handling !== void 0 ? { handling } : {},
354
+ ...level !== void 0 ? { level } : {},
355
+ ...refCid !== void 0 ? { refCid } : {},
356
+ ...refDid !== void 0 ? { refDid } : {}
357
+ };
358
+ }
359
+ function rootFromInput(input) {
360
+ if (input === void 0 || input === null) return null;
361
+ if (typeof input === "string") try {
362
+ const parsed = JSON.parse(input.trim());
363
+ return isDescriptionRoot(parsed) ? parsed : null;
364
+ } catch {
365
+ return null;
366
+ }
367
+ return isDescriptionRoot(input) ? input : null;
368
+ }
369
+ function entityIndexFromDescription(input) {
370
+ const index = /* @__PURE__ */ new Map();
371
+ const root = rootFromInput(input);
372
+ if (root === null) return index;
373
+ const entities = root["entities"];
374
+ if (!isDescriptionRoot(entities)) return index;
375
+ for (const [key, val] of Object.entries(entities)) {
376
+ if (!isDescriptionRoot(val)) continue;
377
+ const def = entityFromJson(key, val);
378
+ if (def !== null) index.set(def.uid, def);
379
+ }
380
+ return index;
381
+ }
382
+ /** The programs a stored description lists (empty for the pre-model shape). */
383
+ function programsFromDescription(input) {
384
+ const raw = rootFromInput(input)?.["program"];
385
+ if (!Array.isArray(raw)) return [];
386
+ const out = [];
387
+ for (const row of raw) {
388
+ if (!isDescriptionRoot(row)) continue;
389
+ const uid = row["uid"];
390
+ const name = row["name"];
391
+ if (typeof uid !== "string" || typeof name !== "string") continue;
392
+ const optionsRaw = row["options"];
393
+ const options = [];
394
+ if (Array.isArray(optionsRaw)) for (const o of optionsRaw) {
395
+ if (!isDescriptionRoot(o) || typeof o["key"] !== "string") continue;
396
+ const access = stringIn(o["access"], ACCESS_VALUES);
397
+ options.push({
398
+ key: o["key"],
399
+ ...access !== void 0 ? { access } : {},
400
+ ...typeof o["available"] === "boolean" ? { available: o["available"] } : {},
401
+ ...o["defaultValue"] !== void 0 ? { defaultValue: o["defaultValue"] } : {}
402
+ });
403
+ }
404
+ out.push({
405
+ name,
406
+ uid,
407
+ available: row["available"] !== false,
408
+ ...options.length > 0 ? { options } : {}
409
+ });
410
+ }
411
+ return out;
412
+ }
413
+ /** True when the stored description carries the model this addon reads (kinds + constraints). */
414
+ function isCurrentDescription(input) {
415
+ const root = rootFromInput(input);
416
+ return root?.["version"] === 2 && entityIndexFromDescription(root).size > 0;
417
+ }
418
+ function decodeHomeConnectValue(uid, value, index) {
419
+ const entity = index.get(uid);
420
+ if (!entity) return null;
421
+ if (entity.enumeration && (typeof value === "number" || typeof value === "string" && /^\d+$/.test(value))) {
422
+ const num = typeof value === "number" ? value : Number.parseInt(value, 10);
423
+ const label = entity.enumeration[num];
424
+ return {
425
+ key: entity.name,
426
+ value: label ?? value
427
+ };
428
+ }
429
+ return {
430
+ key: entity.name,
431
+ value
432
+ };
433
+ }
434
+ function encodeHomeConnectValue(featureName, value, index) {
435
+ for (const entity of index.values()) {
436
+ if (entity.name !== featureName) continue;
437
+ if (entity.enumeration && typeof value === "string") {
438
+ for (const [num, label] of Object.entries(entity.enumeration)) if (label === value) return {
439
+ uid: entity.uid,
440
+ value: Number.parseInt(num, 10)
441
+ };
442
+ }
443
+ return {
444
+ uid: entity.uid,
445
+ value
446
+ };
447
+ }
448
+ return null;
449
+ }
450
+ //#endregion
451
+ //#region src/home-connect/home-connect-iddf.ts
452
+ function readUInt16LE(buf, offset) {
453
+ return buf.readUInt16LE(offset);
454
+ }
455
+ function readUInt32LE(buf, offset) {
456
+ return buf.readUInt32LE(offset);
457
+ }
458
+ function decodeZipPayload(method, payload) {
459
+ if (method === 0) return payload;
460
+ if (method === 8) try {
461
+ return (0, node_zlib.inflateRawSync)(payload);
462
+ } catch {
463
+ return null;
464
+ }
465
+ return null;
466
+ }
467
+ /** Extract file bodies from a ZIP (store + deflate). */
468
+ function extractZipEntries(zip) {
469
+ const out = /* @__PURE__ */ new Map();
470
+ let pos = 0;
471
+ while (pos + 30 <= zip.length) {
472
+ const sig = readUInt32LE(zip, pos);
473
+ if (sig === 101010256) break;
474
+ if (sig !== 67324752) break;
475
+ const method = readUInt16LE(zip, pos + 8);
476
+ const compSize = readUInt32LE(zip, pos + 18);
477
+ const nameLen = readUInt16LE(zip, pos + 26);
478
+ const extraLen = readUInt16LE(zip, pos + 28);
479
+ const nameStart = pos + 30;
480
+ const name = zip.subarray(nameStart, nameStart + nameLen).toString("utf8");
481
+ const dataStart = nameStart + nameLen + extraLen;
482
+ const decoded = decodeZipPayload(method, zip.subarray(dataStart, dataStart + compSize));
483
+ if (decoded !== null) out.set(name, decoded);
484
+ pos = dataStart + compSize;
485
+ }
486
+ return out;
487
+ }
488
+ function parseIddfZipBuffer(zip, haId) {
489
+ const entries = extractZipEntries(zip);
490
+ let deviceXml;
491
+ let mappingXml;
492
+ for (const [name, body] of entries) {
493
+ if (name.endsWith("_DeviceDescription.xml")) deviceXml = body.toString("utf8");
494
+ if (name.endsWith("_FeatureMapping.xml")) mappingXml = body.toString("utf8");
495
+ }
496
+ if (!deviceXml || !mappingXml) return null;
497
+ return parseHomeConnectIddfXml({
498
+ deviceDescriptionXml: deviceXml,
499
+ featureMappingXml: mappingXml,
500
+ haId
501
+ });
502
+ }
503
+ var HOME_CONNECT_IDDF_URL = (haId) => `https://eu.services.home-connect.com/api/iddf/v1/iddf/${encodeURIComponent(haId)}`;
504
+ //#endregion
31
505
  //#region src/home-connect/home-connect-login.ts
32
506
  /** Public OAuth client ids from the Home Connect app login (hcpy). */
33
- var APP_ID = "9B75AC9EC512F36C84256AC47D813E2C1DD0D6520DF774B020E1E6E2EB29B1F3";
507
+ var APP_ID$1 = "9B75AC9EC512F36C84256AC47D813E2C1DD0D6520DF774B020E1E6E2EB29B1F3";
34
508
  var OAUTH_BASE = "https://api.home-connect.com/security/oauth/";
35
509
  var ASSET_BASE = "https://eu.services.home-connect.com/";
36
510
  var REDIRECT_URI = "hcauth://auth/prod";
@@ -57,7 +531,7 @@ var CookieJar = class {
57
531
  function headerList(value) {
58
532
  return value ? [value] : [];
59
533
  }
60
- async function defaultFetch(url, init) {
534
+ async function homeConnectLoginFetch(url, init) {
61
535
  const response = await fetch(url, {
62
536
  method: init.method,
63
537
  headers: { ...init.headers },
@@ -67,7 +541,8 @@ async function defaultFetch(url, init) {
67
541
  return {
68
542
  status: response.status,
69
543
  headers: response.headers,
70
- text: () => response.text()
544
+ text: () => response.text(),
545
+ arrayBuffer: () => response.arrayBuffer()
71
546
  };
72
547
  }
73
548
  function b64url(bytes) {
@@ -96,7 +571,7 @@ async function exchange(fetchImpl, jar, url, method, extraHeaders, body) {
96
571
  body: await response.text()
97
572
  };
98
573
  }
99
- function isRecord(value) {
574
+ function isRecord$1(value) {
100
575
  return typeof value === "object" && value !== null && !Array.isArray(value);
101
576
  }
102
577
  function stringField(record, key) {
@@ -105,7 +580,7 @@ function stringField(record, key) {
105
580
  }
106
581
  function tokensFromBody(body) {
107
582
  const tokenJson = JSON.parse(body);
108
- if (!isRecord(tokenJson)) throw new Error("Home Connect sign-in was rejected");
583
+ if (!isRecord$1(tokenJson)) throw new Error("Home Connect sign-in was rejected");
109
584
  const token = tokenJson["access_token"];
110
585
  const refresh = tokenJson["refresh_token"];
111
586
  if (typeof token !== "string" || token.length === 0) throw new Error("Home Connect sign-in was rejected");
@@ -129,7 +604,7 @@ function accountIdFromAccessToken(token) {
129
604
  } catch {
130
605
  throw new Error("Home Connect sign-in was rejected");
131
606
  }
132
- if (!isRecord(parsed) || typeof parsed["sub"] !== "string" || parsed["sub"].length === 0) throw new Error("Home Connect sign-in was rejected");
607
+ if (!isRecord$1(parsed) || typeof parsed["sub"] !== "string" || parsed["sub"].length === 0) throw new Error("Home Connect sign-in was rejected");
133
608
  return parsed["sub"];
134
609
  }
135
610
  function listedFromPaired(row, encryption) {
@@ -138,8 +613,8 @@ function listedFromPaired(row, encryption) {
138
613
  if (!haId) return null;
139
614
  const brand = (stringField(row, "brand") ?? "HomeConnect").toUpperCase();
140
615
  const type = stringField(row, "haType") ?? stringField(row, "type") ?? "Appliance";
141
- const tls = isRecord(encryption["tls"]) ? encryption["tls"] : void 0;
142
- const aes = isRecord(encryption["aes"]) ? encryption["aes"] : void 0;
616
+ const tls = isRecord$1(encryption["tls"]) ? encryption["tls"] : void 0;
617
+ const aes = isRecord$1(encryption["aes"]) ? encryption["aes"] : void 0;
143
618
  const tlsKey = tls ? stringField(tls, "key") : void 0;
144
619
  const aesKey = aes ? stringField(aes, "key") : void 0;
145
620
  const mode = tlsKey ? "TLS" : "AES";
@@ -178,10 +653,10 @@ async function appliancesForToken(token, fetchImpl, jar) {
178
653
  } catch {
179
654
  throw new Error("Home Connect sign-in was rejected");
180
655
  }
181
- if (!isRecord(parsed) || !Array.isArray(parsed["appliances"])) throw new Error("Home Connect sign-in was rejected");
656
+ if (!isRecord$1(parsed) || !Array.isArray(parsed["appliances"])) throw new Error("Home Connect sign-in was rejected");
182
657
  const out = [];
183
658
  for (const row of parsed["appliances"]) {
184
- if (!isRecord(row)) continue;
659
+ if (!isRecord$1(row)) continue;
185
660
  const haId = stringField(row, "haId");
186
661
  if (!haId) continue;
187
662
  const enc = await exchange(fetchImpl, jar, `${ASSET_BASE}api/appliance/v2/appliances/${encodeURIComponent(haId)}/encryption-information`, "GET", {
@@ -195,14 +670,36 @@ async function appliancesForToken(token, fetchImpl, jar) {
195
670
  } catch {
196
671
  continue;
197
672
  }
198
- if (!isRecord(encryption)) continue;
673
+ if (!isRecord$1(encryption)) continue;
199
674
  const appliance = listedFromPaired(row, encryption);
200
- if (appliance) out.push(appliance);
675
+ if (!appliance) continue;
676
+ const iddfResponse = await fetchImpl(HOME_CONNECT_IDDF_URL(haId), {
677
+ method: "GET",
678
+ headers: {
679
+ authorization: `Bearer ${token}`,
680
+ accept: "application/zip"
681
+ },
682
+ redirect: "manual"
683
+ });
684
+ if (iddfResponse.status < 400 && iddfResponse.arrayBuffer) {
685
+ const compact = parseIddfZipBuffer(Buffer.from(await iddfResponse.arrayBuffer()), haId);
686
+ if (compact) {
687
+ out.push({
688
+ ...appliance,
689
+ connection: {
690
+ ...appliance.connection,
691
+ deviceDescription: JSON.stringify(compact)
692
+ }
693
+ });
694
+ continue;
695
+ }
696
+ }
697
+ out.push(appliance);
201
698
  }
202
699
  return out;
203
700
  }
204
701
  /** Load every appliance on the account. A stale access token is refreshed once when a refresh token exists. */
205
- async function fetchHomeConnectAccount(creds, fetchImpl = defaultFetch) {
702
+ async function fetchHomeConnectAccount(creds, fetchImpl = homeConnectLoginFetch) {
206
703
  const jar = new CookieJar();
207
704
  let token = creds.token;
208
705
  let refresh = creds.refresh ?? "";
@@ -220,7 +717,7 @@ async function fetchHomeConnectAccount(creds, fetchImpl = defaultFetch) {
220
717
  const refreshed = await exchange(fetchImpl, jar, `${OAUTH_BASE}token`, "POST", { "content-type": "application/x-www-form-urlencoded" }, new URLSearchParams({
221
718
  grant_type: "refresh_token",
222
719
  refresh_token: refresh,
223
- client_id: APP_ID
720
+ client_id: APP_ID$1
224
721
  }).toString());
225
722
  if (refreshed.status >= 400) throw new Error("Home Connect sign-in was rejected");
226
723
  const next = tokensFromBody(refreshed.body);
@@ -241,7 +738,7 @@ function beginHomeConnectSignIn() {
241
738
  const state = b64url((0, node_crypto.randomBytes)(16));
242
739
  const authorize = new URL("authorize", OAUTH_BASE);
243
740
  authorize.searchParams.set("redirect_url", REDIRECT_URI);
244
- authorize.searchParams.set("client_id", APP_ID);
741
+ authorize.searchParams.set("client_id", APP_ID$1);
245
742
  authorize.searchParams.set("response_type", "code");
246
743
  authorize.searchParams.set("prompt", "login");
247
744
  authorize.searchParams.set("code_challenge_method", "S256");
@@ -272,13 +769,13 @@ function callbackParams(callback) {
272
769
  };
273
770
  }
274
771
  /** Exchange the pasted redirect for the account token and each appliance profile. */
275
- async function completeHomeConnectSignIn(input, fetchImpl = defaultFetch) {
772
+ async function completeHomeConnectSignIn(input, fetchImpl = homeConnectLoginFetch) {
276
773
  const parsed = callbackParams(input.callback);
277
774
  if (parsed.state !== input.state) throw new Error("State mismatch. Create the login link again.");
278
775
  const { code, grantType } = parsed;
279
776
  const token = await exchange(fetchImpl, new CookieJar(), `${OAUTH_BASE}token`, "POST", { "content-type": "application/x-www-form-urlencoded" }, new URLSearchParams({
280
777
  grant_type: grantType,
281
- client_id: APP_ID,
778
+ client_id: APP_ID$1,
282
779
  code,
283
780
  code_verifier: input.verifier,
284
781
  redirect_uri: REDIRECT_URI
@@ -288,29 +785,56 @@ async function completeHomeConnectSignIn(input, fetchImpl = defaultFetch) {
288
785
  }
289
786
  //#endregion
290
787
  //#region src/home-connect/home-connect-discovery.ts
788
+ /** Dishwasher accessory label: `Dishwasher` plus the appliance name. */
789
+ function homeConnectDishwasherEntityName(containerName) {
790
+ const base = containerName.trim();
791
+ return base.length > 0 ? `Dishwasher ${base}` : "Dishwasher";
792
+ }
291
793
  function deviceTypeForApplianceType(type) {
292
- if (type.toLowerCase().includes("dishwasher")) return require_dist.DeviceType.Dishwasher;
794
+ if (type.toLowerCase().includes("dishwasher")) return require_dist.DeviceType.Container;
293
795
  return null;
294
796
  }
797
+ /**
798
+ * The container candidate for one appliance. Its sensor children are not listed here:
799
+ * they are the entities the appliance's own description declares, known once it is
800
+ * connected, and they are attached to the container as that description is read.
801
+ */
802
+ function dishwasherContainerCandidate(app, adoptedId) {
803
+ const name = app.name.length > 0 ? app.name : app.haId;
804
+ const children = [{
805
+ childNativeId: `${app.haId}#dishwasher`,
806
+ name: homeConnectDishwasherEntityName(name),
807
+ type: require_dist.DeviceType.Dishwasher,
808
+ status: "online",
809
+ metadata: {
810
+ serialNumber: app.haId,
811
+ model: app.type
812
+ },
813
+ alreadyAdopted: false,
814
+ adoptedDeviceId: null,
815
+ capabilities: ["dishwasher"]
816
+ }];
817
+ return {
818
+ childNativeId: app.haId,
819
+ name,
820
+ type: require_dist.DeviceType.Container,
821
+ status: "online",
822
+ metadata: {
823
+ serialNumber: app.haId,
824
+ model: app.type
825
+ },
826
+ alreadyAdopted: adoptedId !== null,
827
+ adoptedDeviceId: adoptedId,
828
+ capabilities: [],
829
+ children
830
+ };
831
+ }
295
832
  function buildHomeConnectCandidates(input) {
296
833
  const out = [];
297
834
  for (const app of input.appliances) {
298
- const camType = deviceTypeForApplianceType(app.type);
299
- if (camType === null) continue;
835
+ if (deviceTypeForApplianceType(app.type) === null) continue;
300
836
  const adoptedId = input.adopted.get(app.haId) ?? null;
301
- out.push({
302
- childNativeId: app.haId,
303
- name: app.name.length > 0 ? app.name : app.haId,
304
- type: camType,
305
- status: "online",
306
- metadata: {
307
- serialNumber: app.haId,
308
- model: app.type
309
- },
310
- alreadyAdopted: adoptedId !== null,
311
- adoptedDeviceId: adoptedId,
312
- capabilities: camType === require_dist.DeviceType.Dishwasher ? ["dishwasher"] : []
313
- });
837
+ out.push(dishwasherContainerCandidate(app, adoptedId));
314
838
  }
315
839
  return out;
316
840
  }
@@ -408,8 +932,7 @@ function buildHomeConnectAdoptionProvider(deps) {
408
932
  integrationId,
409
933
  type: candidate.type,
410
934
  name,
411
- applianceType: app.type,
412
- availablePrograms: app.availablePrograms
935
+ applianceType: app.type
413
936
  });
414
937
  adopted.push({
415
938
  childNativeId: haId,
@@ -458,14 +981,45 @@ require_dist.object({
458
981
  iv64: require_dist.string().optional(),
459
982
  deviceDescription: homeConnectDeviceDescriptionSchema
460
983
  });
461
- /** One appliance saved at sign-in, so adopt does not need another cloud call. */
462
- var homeConnectStoredApplianceSchema = require_dist.object({
984
+ /** Setting key of the keychain-stored credentials of every paired appliance. */
985
+ var APPLIANCE_SECRETS_KEY = "applianceSecrets";
986
+ /**
987
+ * The integration-settings keys that hold a credential — what a write from this addon names
988
+ * as `secretKeys` so the hub moves them into the keychain.
989
+ */
990
+ var HOME_CONNECT_SECRET_KEYS = [
991
+ "token",
992
+ "refresh",
993
+ APPLIANCE_SECRETS_KEY
994
+ ];
995
+ /** One appliance as persisted in the integration's `appliances` setting: no credential on it. */
996
+ var homeConnectApplianceRowSchema = require_dist.object({
463
997
  haId: require_dist.string().min(1),
464
998
  name: require_dist.string(),
465
999
  type: require_dist.string(),
466
1000
  host: require_dist.string().min(1),
467
- psk: require_dist.string().min(1),
468
1001
  mode: HomeConnectModeSchema,
1002
+ deviceDescription: homeConnectDeviceDescriptionSchema
1003
+ });
1004
+ /**
1005
+ * A row as it may still be found on disk: before the keychain move the pairing key
1006
+ * (`psk`) and the AES iv (`iv64`) sat on the row, in a JSON textarea, in the clear.
1007
+ * Read once, adopted into the keychain, then never written again.
1008
+ */
1009
+ var legacyApplianceRowSchema = homeConnectApplianceRowSchema.extend({
1010
+ psk: require_dist.string().optional(),
1011
+ iv64: require_dist.string().optional()
1012
+ });
1013
+ /** What the keychain holds per appliance. */
1014
+ var homeConnectApplianceSecretSchema = require_dist.object({
1015
+ psk: require_dist.string().min(1),
1016
+ iv64: require_dist.string().optional()
1017
+ });
1018
+ /** `applianceSecrets`: one string in the keychain, JSON of `haId -> { psk, iv64? }`. */
1019
+ var homeConnectApplianceSecretsSchema = require_dist.record(require_dist.string(), homeConnectApplianceSecretSchema);
1020
+ /** One appliance in memory: its row and its credential together. */
1021
+ var homeConnectStoredApplianceSchema = homeConnectApplianceRowSchema.extend({
1022
+ psk: require_dist.string().min(1),
469
1023
  iv64: require_dist.string().optional()
470
1024
  });
471
1025
  /** The integration is the Home Connect account. Profiles are downloaded once at sign-in. */
@@ -474,10 +1028,74 @@ var homeConnectConfigSchema = require_dist.object({
474
1028
  refresh: require_dist.string().optional().describe("Home Connect refresh token").meta({ secret: true }),
475
1029
  appliances: require_dist.array(homeConnectStoredApplianceSchema).optional()
476
1030
  });
1031
+ function parseJsonValue(raw) {
1032
+ if (typeof raw !== "string") return raw;
1033
+ const trimmed = raw.trim();
1034
+ if (trimmed.length === 0) return void 0;
1035
+ try {
1036
+ return JSON.parse(trimmed);
1037
+ } catch {
1038
+ return raw;
1039
+ }
1040
+ }
1041
+ function legacyRowsOf(raw) {
1042
+ const value = parseJsonValue(raw);
1043
+ if (!Array.isArray(value)) return [];
1044
+ const rows = [];
1045
+ for (const item of value) {
1046
+ const parsed = legacyApplianceRowSchema.safeParse(item);
1047
+ if (parsed.success) rows.push(parsed.data);
1048
+ }
1049
+ return rows;
1050
+ }
1051
+ function readApplianceSecrets(raw) {
1052
+ if (require_dist.isSecretMarker(raw) || require_dist.isSecretMarkerLike(raw)) return { kind: "unresolved" };
1053
+ const value = parseJsonValue(raw);
1054
+ if (value === void 0 || value === null) return { kind: "none" };
1055
+ const parsed = homeConnectApplianceSecretsSchema.safeParse(value);
1056
+ return parsed.success ? {
1057
+ kind: "secrets",
1058
+ secrets: parsed.data
1059
+ } : { kind: "invalid" };
1060
+ }
1061
+ /** Rows plus credentials -> the in-memory appliances. A row with no credential cannot be spoken to and is left out. */
1062
+ function mergeApplianceSecrets(rows, secrets) {
1063
+ const out = [];
1064
+ for (const { psk: inlinePsk, iv64: inlineIv, ...row } of rows) {
1065
+ const keyed = secrets[row.haId];
1066
+ const psk = inlinePsk !== void 0 && inlinePsk.length > 0 ? inlinePsk : keyed?.psk;
1067
+ if (psk === void 0) continue;
1068
+ const iv64 = inlineIv !== void 0 && inlineIv.length > 0 ? inlineIv : keyed?.iv64;
1069
+ out.push({
1070
+ ...row,
1071
+ psk,
1072
+ ...iv64 !== void 0 ? { iv64 } : {}
1073
+ });
1074
+ }
1075
+ return out;
1076
+ }
1077
+ /** In-memory appliances -> the credential-free rows and the secrets map to put in the keychain. */
1078
+ function splitApplianceSecrets(appliances) {
1079
+ const rows = [];
1080
+ const secrets = {};
1081
+ for (const { psk, iv64, ...row } of appliances) {
1082
+ rows.push(row);
1083
+ if (psk !== void 0 && psk.length > 0) secrets[row.haId] = {
1084
+ psk,
1085
+ ...iv64 !== void 0 && iv64.length > 0 ? { iv64 } : {}
1086
+ };
1087
+ }
1088
+ return {
1089
+ rows,
1090
+ secrets
1091
+ };
1092
+ }
477
1093
  /**
478
1094
  * Before `integrations.setSettings`: keep objects as-is (serializes as `json`), parse
479
1095
  * textarea JSON strings into objects so they are not re-serialized as `string` and
480
- * corrupted on read by `deserializeSetting(String(object))`.
1096
+ * corrupted on read by `deserializeSetting(String(object))` — and move any credential
1097
+ * still sitting on an appliance row into `applianceSecrets`, so a row is never written
1098
+ * with a pairing key on it.
481
1099
  */
482
1100
  function homeConnectSettingsForPersist(settings) {
483
1101
  const next = { ...settings };
@@ -493,6 +1111,7 @@ function homeConnectSettingsForPersist(settings) {
493
1111
  const parsedAppliances = JSON.parse(appliancesRaw);
494
1112
  if (Array.isArray(parsedAppliances)) next["appliances"] = parsedAppliances;
495
1113
  } catch {}
1114
+ Object.assign(next, moveInlineSecrets(next));
496
1115
  const raw = next["deviceDescription"];
497
1116
  if (typeof raw !== "string") return next;
498
1117
  const trimmed = raw.trim();
@@ -509,10 +1128,71 @@ function homeConnectSettingsForPersist(settings) {
509
1128
  }
510
1129
  return next;
511
1130
  }
1131
+ /**
1132
+ * The `appliances` / `applianceSecrets` pair with every inline credential moved into the
1133
+ * secrets map. An empty object when no row carries one (nothing to move). When the stored
1134
+ * secrets are unresolved the map is not rewritten: an addon cannot see what it would replace.
1135
+ */
1136
+ function moveInlineSecrets(settings) {
1137
+ const rows = legacyRowsOf(settings["appliances"]);
1138
+ if (rows.filter((r) => (r.psk ?? "").length > 0 || (r.iv64 ?? "").length > 0).length === 0) return {};
1139
+ const existing = readApplianceSecrets(settings[APPLIANCE_SECRETS_KEY]);
1140
+ if (existing.kind === "unresolved" || existing.kind === "invalid") return {};
1141
+ const { rows: stripped, secrets: moved } = splitApplianceSecrets(rows);
1142
+ const merged = {
1143
+ ...existing.kind === "secrets" ? existing.secrets : {},
1144
+ ...moved
1145
+ };
1146
+ return {
1147
+ appliances: stripped,
1148
+ ...Object.keys(merged).length > 0 ? { [APPLIANCE_SECRETS_KEY]: JSON.stringify(merged) } : {}
1149
+ };
1150
+ }
1151
+ /**
1152
+ * One-time adopt: the settings that must be written so a pairing key found on an appliance
1153
+ * row moves into the keychain. `null` when nothing needs moving. The caller writes it with
1154
+ * {@link HOME_CONNECT_SECRET_KEYS} as `secretKeys`.
1155
+ */
1156
+ function legacyApplianceSecretPatch(settings) {
1157
+ const moved = moveInlineSecrets({
1158
+ ...settings,
1159
+ appliances: parseJsonValue(settings["appliances"])
1160
+ });
1161
+ const rows = moved["appliances"];
1162
+ const secrets = moved[APPLIANCE_SECRETS_KEY];
1163
+ if (!Array.isArray(rows) || typeof secrets !== "string") return null;
1164
+ const parsedRows = [];
1165
+ for (const row of rows) {
1166
+ const parsed = homeConnectApplianceRowSchema.safeParse(row);
1167
+ if (parsed.success) parsedRows.push(parsed.data);
1168
+ }
1169
+ return {
1170
+ appliances: parsedRows,
1171
+ applianceSecrets: secrets
1172
+ };
1173
+ }
512
1174
  function connectionFromSettings(settings) {
513
- const parsed = homeConnectConfigSchema.safeParse(homeConnectSettingsForPersist(settings));
1175
+ const normalized = homeConnectSettingsForPersist(settings);
1176
+ const rows = legacyRowsOf(normalized["appliances"]);
1177
+ const secrets = readApplianceSecrets(normalized[APPLIANCE_SECRETS_KEY]);
1178
+ const withoutAppliances = { ...normalized };
1179
+ delete withoutAppliances["appliances"];
1180
+ delete withoutAppliances[APPLIANCE_SECRETS_KEY];
1181
+ const appliances = rows.length > 0 ? mergeApplianceSecrets(rows, secrets.kind === "secrets" ? secrets.secrets : {}) : void 0;
1182
+ const parsed = homeConnectConfigSchema.safeParse({
1183
+ ...withoutAppliances,
1184
+ ...appliances !== void 0 ? { appliances: [...appliances] } : {}
1185
+ });
514
1186
  return parsed.success ? parsed.data : null;
515
1187
  }
1188
+ /** haIds of appliances on the account that have no usable credential (keychain locked, or never stored). */
1189
+ function appliancesWithoutCredential(settings) {
1190
+ const normalized = homeConnectSettingsForPersist(settings);
1191
+ const rows = legacyRowsOf(normalized["appliances"]);
1192
+ const secrets = readApplianceSecrets(normalized[APPLIANCE_SECRETS_KEY]);
1193
+ const have = new Set(mergeApplianceSecrets(rows, secrets.kind === "secrets" ? secrets.secrets : {}).map((a) => a.haId));
1194
+ return rows.filter((r) => !have.has(r.haId)).map((r) => r.haId);
1195
+ }
516
1196
  function hasNonEmptyString(settings, key) {
517
1197
  const value = settings[key];
518
1198
  return typeof value === "string" && value.trim().length > 0;
@@ -618,7 +1298,8 @@ function buildHomeConnectFormSchema() {
618
1298
  assignResult: {
619
1299
  token: "token",
620
1300
  refresh: "refresh",
621
- appliances: "appliances"
1301
+ appliances: "appliances",
1302
+ [APPLIANCE_SECRETS_KEY]: APPLIANCE_SECRETS_KEY
622
1303
  },
623
1304
  successMessage: "{summary}"
624
1305
  },
@@ -642,6 +1323,13 @@ function buildHomeConnectFormSchema() {
642
1323
  label: "Appliances",
643
1324
  required: true,
644
1325
  showWhen: pairedOnly
1326
+ },
1327
+ {
1328
+ type: "password",
1329
+ key: APPLIANCE_SECRETS_KEY,
1330
+ label: "Appliance keys",
1331
+ required: true,
1332
+ showWhen: pairedOnly
645
1333
  }
646
1334
  ]
647
1335
  }] };
@@ -650,16 +1338,38 @@ function buildHomeConnectFormSchema() {
650
1338
  function homeConnectWsUrl(config) {
651
1339
  return `${config.mode === "TLS" ? "wss" : "ws"}://${config.host.replace(/^https?:\/\//, "").replace(/\/$/, "")}/homeconnect`;
652
1340
  }
1341
+ /**
1342
+ * The `appliances` rows to write back after the connection learned something (an IDDF): every
1343
+ * stored row stays — a row with no credential is still a paired appliance — and a row the
1344
+ * connection knows is replaced by its fresher version.
1345
+ */
1346
+ function mergeAppliancesForPersist(storedAppliances, fromConnection) {
1347
+ const stored = legacyRowsOf(storedAppliances);
1348
+ const known = new Map(fromConnection.map((a) => [a.haId, a]));
1349
+ const out = stored.map((row) => ({ ...known.get(row.haId) ?? row }));
1350
+ for (const fresh of fromConnection) if (!stored.some((r) => r.haId === fresh.haId)) out.push({ ...fresh });
1351
+ return out;
1352
+ }
653
1353
  //#endregion
654
1354
  //#region src/home-connect/home-connect-gateway.ts
1355
+ /**
1356
+ * Where devices find the client of their integration. A device that started before
1357
+ * its integration connected, or whose integration was restarted (a NEW client), still
1358
+ * hears about it: `watch` follows the integration, not one client instance.
1359
+ */
655
1360
  var HomeConnectConnectionResolver = class {
656
1361
  #surfaces = /* @__PURE__ */ new Map();
1362
+ #watchers = /* @__PURE__ */ new Map();
657
1363
  set(integrationId, surface) {
658
- if (surface === null) {
659
- this.#surfaces.delete(integrationId);
660
- return;
1364
+ const previous = this.#surfaces.get(integrationId);
1365
+ if (surface === null) this.#surfaces.delete(integrationId);
1366
+ else this.#surfaces.set(integrationId, surface);
1367
+ if (previous?.client === surface?.client) return;
1368
+ for (const watcher of this.#watchers.get(integrationId) ?? []) {
1369
+ watcher.detach?.();
1370
+ watcher.detach = this.#attach(surface?.client ?? null, watcher);
1371
+ watcher.listener({ kind: "client" });
661
1372
  }
662
- this.#surfaces.set(integrationId, surface);
663
1373
  }
664
1374
  appliances(integrationId) {
665
1375
  return this.#surfaces.get(integrationId)?.appliances ?? [];
@@ -673,60 +1383,906 @@ var HomeConnectConnectionResolver = class {
673
1383
  getClient(integrationId) {
674
1384
  return this.#surfaces.get(integrationId)?.client ?? null;
675
1385
  }
1386
+ /**
1387
+ * Hear about everything that can change what `haId` shows: its features, its link,
1388
+ * its description, and the client itself appearing, vanishing or being replaced.
1389
+ */
1390
+ watch(integrationId, haId, listener) {
1391
+ const watcher = {
1392
+ haId,
1393
+ listener,
1394
+ detach: null
1395
+ };
1396
+ watcher.detach = this.#attach(this.getClient(integrationId), watcher);
1397
+ const set = this.#watchers.get(integrationId) ?? /* @__PURE__ */ new Set();
1398
+ set.add(watcher);
1399
+ this.#watchers.set(integrationId, set);
1400
+ return () => {
1401
+ watcher.detach?.();
1402
+ watcher.detach = null;
1403
+ set.delete(watcher);
1404
+ };
1405
+ }
1406
+ #attach(client, watcher) {
1407
+ if (client === null) return null;
1408
+ const unsubs = [client.onFeatureChange((haId, key) => {
1409
+ if (haId === watcher.haId) watcher.listener({
1410
+ kind: "feature",
1411
+ key
1412
+ });
1413
+ }), client.onLinkChange((haId) => {
1414
+ if (haId === watcher.haId) watcher.listener({ kind: "link" });
1415
+ })];
1416
+ const description = client.getDescription(watcher.haId);
1417
+ if (description !== null) unsubs.push(description.onChange(() => watcher.listener({ kind: "description" })));
1418
+ return () => {
1419
+ for (const unsub of unsubs) unsub();
1420
+ };
1421
+ }
676
1422
  clear() {
677
- this.#surfaces.clear();
1423
+ for (const id of [...this.#surfaces.keys()]) this.set(id, null);
678
1424
  }
679
1425
  };
680
1426
  var homeConnectConnections = new HomeConnectConnectionResolver();
681
1427
  //#endregion
682
- //#region src/home-connect/home-connect-ws-client.ts
683
- var WS_OPEN = 1;
684
- function programNamesFromLegacyList(programsRaw) {
685
- const programs = [];
686
- if (!Array.isArray(programsRaw)) return programs;
687
- for (const p of programsRaw) if (typeof p === "string") programs.push(p);
688
- return programs;
1428
+ //#region src/home-connect/home-connect-description-store.ts
1429
+ var ACCESS = [
1430
+ "none",
1431
+ "read",
1432
+ "readWrite",
1433
+ "writeOnly",
1434
+ "readStatic"
1435
+ ];
1436
+ /** Kind of a legacy description entity (stored before kinds were recorded), from its feature name. */
1437
+ function kindFromFeatureName(name) {
1438
+ if (name.endsWith("Root.ActiveProgram")) return "activeProgram";
1439
+ if (name.endsWith("Root.SelectedProgram")) return "selectedProgram";
1440
+ if (name.includes(".Setting.")) return "setting";
1441
+ if (name.includes(".Option.")) return "option";
1442
+ if (name.includes(".Event.")) return "event";
1443
+ if (name.includes(".Command.")) return "command";
1444
+ if (name.includes(".Program.")) return "program";
1445
+ return "status";
689
1446
  }
690
- function programNamesFromHaDeviceDescription(programsRaw) {
691
- const programs = [];
692
- if (!Array.isArray(programsRaw)) return programs;
693
- for (const p of programsRaw) if (typeof p === "object" && p !== null) {
694
- const name = p["name"];
695
- if (typeof name === "string") programs.push(name);
696
- } else if (typeof p === "string") programs.push(p);
697
- return programs;
1447
+ function overlayFromRow(row) {
1448
+ const access = ACCESS.find((a) => a === row["access"]);
1449
+ const num = (key) => {
1450
+ const v = row[key];
1451
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
1452
+ };
1453
+ const min = num("min");
1454
+ const max = num("max");
1455
+ const stepSize = num("stepSize");
1456
+ return {
1457
+ ...typeof row["available"] === "boolean" ? { available: row["available"] } : {},
1458
+ ...access !== void 0 ? { access } : {},
1459
+ ...min !== void 0 ? { min } : {},
1460
+ ...max !== void 0 ? { max } : {},
1461
+ ...stepSize !== void 0 ? { stepSize } : {},
1462
+ ...row["default"] !== void 0 ? { defaultValue: row["default"] } : {}
1463
+ };
698
1464
  }
699
- function parseLegacyAppliancesList(rawList) {
700
- if (!Array.isArray(rawList)) return [];
701
- const appliances = [];
702
- for (const item of rawList) {
703
- if (typeof item !== "object" || item === null) continue;
704
- const row = item;
705
- const haId = row["haId"];
706
- const name = row["name"];
707
- const type = row["type"];
708
- if (typeof haId !== "string") continue;
709
- appliances.push({
710
- haId,
711
- name: typeof name === "string" ? name : haId,
712
- type: typeof type === "string" ? type : "Dishwasher",
713
- availablePrograms: programNamesFromLegacyList(row["programs"])
714
- });
1465
+ /**
1466
+ * The live description of ONE appliance: the stored IDDF plus whatever the
1467
+ * appliance reported since (`/ro/descriptionChange`). The session decodes with it,
1468
+ * the devices read constraints from it; neither keeps a second copy.
1469
+ */
1470
+ var HomeConnectDescriptionStore = class {
1471
+ #base;
1472
+ #programs;
1473
+ #overlay = /* @__PURE__ */ new Map();
1474
+ #listeners = /* @__PURE__ */ new Set();
1475
+ #views = null;
1476
+ constructor(initial) {
1477
+ this.#base = entityIndexFromDescription(initial);
1478
+ this.#programs = programsFromDescription(initial);
715
1479
  }
716
- return appliances;
1480
+ get state() {
1481
+ return this.#base.size > 0 ? "complete" : "missing";
1482
+ }
1483
+ get size() {
1484
+ return this.#base.size;
1485
+ }
1486
+ /** Swap in a freshly fetched description (the live overlay is dropped: it described the old one). */
1487
+ replace(next) {
1488
+ this.#base = entityIndexFromDescription(next);
1489
+ this.#programs = programsFromDescription(next);
1490
+ this.#overlay.clear();
1491
+ this.#views = null;
1492
+ this.#notify();
1493
+ }
1494
+ /** Apply description-change rows; returns the feature names whose constraints moved. */
1495
+ applyChanges(rows) {
1496
+ const touched = [];
1497
+ for (const row of rows) {
1498
+ const uid = row["uid"];
1499
+ if (typeof uid !== "number") continue;
1500
+ const base = this.#base.get(uid);
1501
+ if (base === void 0) continue;
1502
+ const next = overlayFromRow(row);
1503
+ if (Object.keys(next).length === 0) continue;
1504
+ this.#overlay.set(uid, {
1505
+ ...this.#overlay.get(uid),
1506
+ ...next
1507
+ });
1508
+ touched.push(base.name);
1509
+ }
1510
+ if (touched.length > 0) {
1511
+ this.#views = null;
1512
+ this.#notify();
1513
+ }
1514
+ return touched;
1515
+ }
1516
+ onChange(listener) {
1517
+ this.#listeners.add(listener);
1518
+ return () => this.#listeners.delete(listener);
1519
+ }
1520
+ #notify() {
1521
+ for (const fn of this.#listeners) fn();
1522
+ }
1523
+ #resolved() {
1524
+ if (this.#views !== null) return this.#views;
1525
+ const out = /* @__PURE__ */ new Map();
1526
+ for (const [uid, def] of this.#base) {
1527
+ const overlay = this.#overlay.get(uid);
1528
+ out.set(uid, {
1529
+ ...def,
1530
+ ...overlay,
1531
+ kind: def.kind ?? kindFromFeatureName(def.name)
1532
+ });
1533
+ }
1534
+ this.#views = out;
1535
+ return out;
1536
+ }
1537
+ /** The decode / encode index: uid → entity with the overlay applied. */
1538
+ index() {
1539
+ return this.#resolved();
1540
+ }
1541
+ entity(uid) {
1542
+ return this.#resolved().get(uid);
1543
+ }
1544
+ entityByName(name) {
1545
+ for (const view of this.#resolved().values()) if (view.name === name) return view;
1546
+ }
1547
+ entityOfKind(kind) {
1548
+ for (const view of this.#resolved().values()) if (view.kind === kind) return view;
1549
+ }
1550
+ entities() {
1551
+ return [...this.#resolved().values()];
1552
+ }
1553
+ programs() {
1554
+ return this.#programs;
1555
+ }
1556
+ };
1557
+ //#endregion
1558
+ //#region src/home-connect/home-connect-tls-wire.ts
1559
+ function decodePsk64(psk64) {
1560
+ return Buffer.from(`${psk64}===`, "base64url");
717
1561
  }
718
- function parseHaDeviceDescription(record) {
719
- const info = record["info"];
720
- if (typeof info !== "object" || info === null) return [];
721
- const infoRec = info;
722
- const deviceID = infoRec["deviceID"];
723
- if (typeof deviceID !== "string") return [];
724
- const deviceType = infoRec["deviceType"];
725
- const model = infoRec["model"];
726
- return [{
727
- haId: deviceID,
728
- name: typeof model === "string" && model.length > 0 ? model : deviceID,
729
- type: typeof deviceType === "string" ? deviceType : "Dishwasher",
1562
+ function parseHttpHeaders(buffer) {
1563
+ const marker = buffer.indexOf("\r\n\r\n");
1564
+ if (marker < 0) return null;
1565
+ return {
1566
+ headers: buffer.subarray(0, marker + 4),
1567
+ rest: buffer.subarray(marker + 4)
1568
+ };
1569
+ }
1570
+ function readWsFrame(buffer) {
1571
+ if (buffer.length < 2) return null;
1572
+ const b0 = buffer.at(0);
1573
+ const b1 = buffer.at(1);
1574
+ if (b0 === void 0 || b1 === void 0) return null;
1575
+ const opcode = b0 & 15;
1576
+ const masked = (b1 & 128) !== 0;
1577
+ let payloadLen = b1 & 127;
1578
+ let offset = 2;
1579
+ if (payloadLen === 126) {
1580
+ if (buffer.length < 4) return null;
1581
+ payloadLen = buffer.readUInt16BE(2);
1582
+ offset = 4;
1583
+ } else if (payloadLen === 127) {
1584
+ if (buffer.length < 10) return null;
1585
+ payloadLen = Number(buffer.readBigUInt64BE(2));
1586
+ offset = 10;
1587
+ }
1588
+ const maskLen = masked ? 4 : 0;
1589
+ if (buffer.length < offset + maskLen + payloadLen) return null;
1590
+ let payload = buffer.subarray(offset + maskLen, offset + maskLen + payloadLen);
1591
+ if (masked) {
1592
+ const mask = buffer.subarray(offset, offset + 4);
1593
+ const unmasked = Buffer.alloc(payload.length);
1594
+ for (let i = 0; i < payload.length; i += 1) unmasked[i] = (payload.at(i) ?? 0) ^ (mask.at(i % 4) ?? 0);
1595
+ payload = unmasked;
1596
+ }
1597
+ return {
1598
+ opcode,
1599
+ payload,
1600
+ consumed: offset + maskLen + payloadLen
1601
+ };
1602
+ }
1603
+ /** One masked client frame (RFC 6455: every client-to-server frame is masked). */
1604
+ function writeWsFrame(opcode, payload) {
1605
+ const mask = (0, node_crypto.randomBytes)(4);
1606
+ const headerLen = payload.length < 126 ? 2 : payload.length < 65536 ? 4 : 10;
1607
+ const frame = Buffer.alloc(headerLen + 4 + payload.length);
1608
+ frame[0] = 128 | opcode;
1609
+ if (payload.length < 126) {
1610
+ frame[1] = 128 | payload.length;
1611
+ mask.copy(frame, 2);
1612
+ for (let i = 0; i < payload.length; i += 1) {
1613
+ const pb = payload.at(i) ?? 0;
1614
+ const mb = mask.at(i % 4) ?? 0;
1615
+ frame[6 + i] = pb ^ mb;
1616
+ }
1617
+ } else if (payload.length < 65536) {
1618
+ frame[1] = 254;
1619
+ frame.writeUInt16BE(payload.length, 2);
1620
+ mask.copy(frame, 4);
1621
+ for (let i = 0; i < payload.length; i += 1) {
1622
+ const pb = payload.at(i) ?? 0;
1623
+ const mb = mask.at(i % 4) ?? 0;
1624
+ frame[8 + i] = pb ^ mb;
1625
+ }
1626
+ } else {
1627
+ frame[1] = 255;
1628
+ frame.writeBigUInt64BE(BigInt(payload.length), 2);
1629
+ mask.copy(frame, 10);
1630
+ for (let i = 0; i < payload.length; i += 1) {
1631
+ const pb = payload.at(i) ?? 0;
1632
+ const mb = mask.at(i % 4) ?? 0;
1633
+ frame[14 + i] = pb ^ mb;
1634
+ }
1635
+ }
1636
+ return frame;
1637
+ }
1638
+ function createHomeConnectTlsWire(input) {
1639
+ let socket = null;
1640
+ let open = false;
1641
+ const textListeners = /* @__PURE__ */ new Set();
1642
+ const closedListeners = /* @__PURE__ */ new Set();
1643
+ let recvBuffer = Buffer.alloc(0);
1644
+ let upgraded = false;
1645
+ let closeAnnounced = false;
1646
+ function emitText(text) {
1647
+ for (const fn of textListeners) fn(text);
1648
+ }
1649
+ function emitClosed() {
1650
+ if (!open && !closeAnnounced) return;
1651
+ open = false;
1652
+ closeAnnounced = false;
1653
+ for (const fn of closedListeners) fn();
1654
+ }
1655
+ function drainFrames() {
1656
+ while (true) {
1657
+ const parsed = readWsFrame(recvBuffer);
1658
+ if (!parsed) return;
1659
+ recvBuffer = Buffer.from(recvBuffer.subarray(parsed.consumed));
1660
+ switch (parsed.opcode) {
1661
+ case 9:
1662
+ socket?.write(writeWsFrame(10, parsed.payload));
1663
+ break;
1664
+ case 8:
1665
+ socket?.destroy();
1666
+ break;
1667
+ case 10: break;
1668
+ default: if (parsed.payload.length > 0) emitText(parsed.payload.toString("utf8"));
1669
+ }
1670
+ }
1671
+ }
1672
+ return {
1673
+ get open() {
1674
+ return open;
1675
+ },
1676
+ async connect() {
1677
+ if (open) return;
1678
+ const psk = decodePsk64(input.psk);
1679
+ await new Promise((resolve, reject) => {
1680
+ socket = (0, node_tls.connect)({
1681
+ host: input.connectAddress,
1682
+ port: 443,
1683
+ servername: input.host,
1684
+ ciphers: "PSK",
1685
+ minVersion: "TLSv1.2",
1686
+ maxVersion: "TLSv1.2",
1687
+ rejectUnauthorized: false,
1688
+ checkServerIdentity: () => void 0,
1689
+ pskCallback: () => ({
1690
+ psk,
1691
+ identity: ""
1692
+ })
1693
+ }, () => {
1694
+ const key = (0, node_crypto.randomBytes)(16).toString("base64");
1695
+ const req = [
1696
+ "GET /homeconnect HTTP/1.1",
1697
+ `Host: ${input.host}`,
1698
+ "Upgrade: websocket",
1699
+ "Connection: Upgrade",
1700
+ `Sec-WebSocket-Key: ${key}`,
1701
+ "Sec-WebSocket-Version: 13",
1702
+ "",
1703
+ ""
1704
+ ].join("\r\n");
1705
+ socket?.write(req);
1706
+ });
1707
+ socket.on("data", (chunk) => {
1708
+ recvBuffer = Buffer.concat([recvBuffer, chunk]);
1709
+ if (!upgraded) {
1710
+ const parsed = parseHttpHeaders(recvBuffer);
1711
+ if (!parsed) return;
1712
+ if (!(parsed.headers.toString("utf8").split("\r\n")[0] ?? "").includes("101")) {
1713
+ reject(/* @__PURE__ */ new Error("Home Connect WebSocket upgrade failed"));
1714
+ socket?.destroy();
1715
+ return;
1716
+ }
1717
+ upgraded = true;
1718
+ recvBuffer = Buffer.from(parsed.rest);
1719
+ open = true;
1720
+ closeAnnounced = true;
1721
+ resolve();
1722
+ drainFrames();
1723
+ return;
1724
+ }
1725
+ drainFrames();
1726
+ });
1727
+ socket.on("error", (err) => {
1728
+ reject(err);
1729
+ socket?.destroy();
1730
+ });
1731
+ socket.on("close", () => emitClosed());
1732
+ });
1733
+ },
1734
+ send(text) {
1735
+ if (!socket || !open) throw new Error("Home Connect TLS wire not open");
1736
+ socket.write(writeWsFrame(1, Buffer.from(text, "utf8")));
1737
+ },
1738
+ close() {
1739
+ const closing = socket;
1740
+ socket = null;
1741
+ closing?.destroy();
1742
+ emitClosed();
1743
+ },
1744
+ onText(listener) {
1745
+ textListeners.add(listener);
1746
+ return () => textListeners.delete(listener);
1747
+ },
1748
+ onClosed(listener) {
1749
+ closedListeners.add(listener);
1750
+ return () => closedListeners.delete(listener);
1751
+ }
1752
+ };
1753
+ }
1754
+ //#endregion
1755
+ //#region src/home-connect/home-connect-message.ts
1756
+ var HC_ACTION = {
1757
+ GET: "GET",
1758
+ POST: "POST",
1759
+ RESPONSE: "RESPONSE",
1760
+ NOTIFY: "NOTIFY"
1761
+ };
1762
+ function isRecord(value) {
1763
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1764
+ }
1765
+ var ACTIONS = Object.values(HC_ACTION);
1766
+ function actionOf(raw) {
1767
+ return ACTIONS.find((a) => a === raw) ?? null;
1768
+ }
1769
+ function loadHomeConnectMessage(raw) {
1770
+ const row = JSON.parse(raw);
1771
+ if (!isRecord(row)) throw new Error("Invalid Home Connect message");
1772
+ const sID = row["sID"];
1773
+ const msgID = row["msgID"];
1774
+ const resource = row["resource"];
1775
+ const version = row["version"];
1776
+ const action = row["action"];
1777
+ if (typeof sID !== "number" || typeof msgID !== "number" || typeof resource !== "string" || typeof version !== "number" || typeof action !== "string") throw new Error("Invalid Home Connect message fields");
1778
+ const knownAction = actionOf(action);
1779
+ if (knownAction === null) throw new Error(`Unknown Home Connect action: ${action}`);
1780
+ let data;
1781
+ const dataRaw = row["data"];
1782
+ if (dataRaw !== void 0) data = (Array.isArray(dataRaw) ? dataRaw : [dataRaw]).filter(isRecord);
1783
+ const code = row["code"];
1784
+ return {
1785
+ sID,
1786
+ msgID,
1787
+ resource,
1788
+ version,
1789
+ action: knownAction,
1790
+ data,
1791
+ code: typeof code === "number" ? code : void 0
1792
+ };
1793
+ }
1794
+ function dumpHomeConnectMessage(input) {
1795
+ const msg = {
1796
+ sID: input.sID,
1797
+ msgID: input.msgID,
1798
+ resource: input.resource,
1799
+ version: input.version,
1800
+ action: input.action
1801
+ };
1802
+ if (input.data !== void 0) msg["data"] = Array.isArray(input.data) ? input.data : [input.data];
1803
+ if (input.code !== void 0) msg["code"] = input.code;
1804
+ return JSON.stringify(msg);
1805
+ }
1806
+ function responseTo(init, data) {
1807
+ return {
1808
+ sID: init.sID,
1809
+ msgID: init.msgID,
1810
+ resource: init.resource,
1811
+ version: init.version,
1812
+ action: HC_ACTION.RESPONSE,
1813
+ data: [data]
1814
+ };
1815
+ }
1816
+ //#endregion
1817
+ //#region src/home-connect/home-connect-session.ts
1818
+ var APP_NAME = "CamStack";
1819
+ var APP_ID = "camstack-hc";
1820
+ /** Max wait for appliance `/ei/initialValues` after the socket opens. */
1821
+ var HOME_CONNECT_INIT_RESOURCE_TIMEOUT_MS = 12e3;
1822
+ /** A response `code` at or above this is the appliance refusing the request. */
1823
+ var RESPONSE_ERROR_CODE_MIN = 400;
1824
+ function scalarFields(row) {
1825
+ const out = {};
1826
+ for (const [key, value] of Object.entries(row)) if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") out[key] = value;
1827
+ return out;
1828
+ }
1829
+ var HomeConnectSession = class {
1830
+ #wire;
1831
+ #description;
1832
+ #onFeature;
1833
+ #onEmptyEntityIndex;
1834
+ #onClosed;
1835
+ #onInfo;
1836
+ #onDescriptionChange;
1837
+ #logger;
1838
+ #logTags;
1839
+ #keepaliveMs;
1840
+ #requestTimeoutMs;
1841
+ #sid = 0;
1842
+ #nextMsgId = 1;
1843
+ #serviceVersions = {};
1844
+ #pending = /* @__PURE__ */ new Map();
1845
+ #warnedUnknownUids = /* @__PURE__ */ new Set();
1846
+ #warnedEmptyIndex = false;
1847
+ #undecodableCount = 0;
1848
+ #connected = false;
1849
+ #closing = false;
1850
+ #unsubText = null;
1851
+ #unsubClosed = null;
1852
+ #keepalive = null;
1853
+ #missedProbes = 0;
1854
+ constructor(options) {
1855
+ this.#wire = options.wire;
1856
+ this.#description = options.description;
1857
+ this.#onFeature = options.onFeature;
1858
+ this.#onEmptyEntityIndex = options.onEmptyEntityIndex;
1859
+ this.#onClosed = options.onClosed;
1860
+ this.#onInfo = options.onInfo;
1861
+ this.#onDescriptionChange = options.onDescriptionChange;
1862
+ this.#logger = options.logger;
1863
+ this.#logTags = options.logTags ?? {};
1864
+ this.#keepaliveMs = options.keepaliveMs ?? 3e4;
1865
+ this.#requestTimeoutMs = options.requestTimeoutMs ?? 5e3;
1866
+ }
1867
+ get connected() {
1868
+ return this.#connected;
1869
+ }
1870
+ async connect() {
1871
+ if (this.#connected) return;
1872
+ this.#closing = false;
1873
+ this.#unsubText = this.#wire.onText((text) => this.#handleIncoming(text));
1874
+ this.#unsubClosed = this.#wire.onClosed(() => this.#handleWireClosed());
1875
+ try {
1876
+ await this.#handshake();
1877
+ } catch (err) {
1878
+ await this.close();
1879
+ throw err;
1880
+ }
1881
+ this.#connected = true;
1882
+ this.#startKeepalive();
1883
+ }
1884
+ async #handshake() {
1885
+ const initPromise = this.#waitForResource("/ei/initialValues");
1886
+ await this.#wire.connect();
1887
+ const init = loadHomeConnectMessage(await initPromise);
1888
+ this.#sid = init.sID;
1889
+ const edMsgId = (init.data?.[0])?.["edMsgID"];
1890
+ if (typeof edMsgId === "number") this.#nextMsgId = edMsgId;
1891
+ this.#wire.send(dumpHomeConnectMessage(responseTo(init, {
1892
+ deviceType: init.version === 1 ? 2 : "Application",
1893
+ deviceName: APP_NAME,
1894
+ deviceID: APP_ID
1895
+ })));
1896
+ const services = await this.#sendGet("/ci/services", 1);
1897
+ this.#setServiceVersions(services);
1898
+ if (this.#serviceVersions["ei"] === 2) await this.#sendNotify("/ei/deviceReady");
1899
+ if ("iz" in this.#serviceVersions) await this.#sendGet("/iz/info", this.#serviceVersions["iz"] ?? 1).catch(() => void 0);
1900
+ const mandatory = await this.#sendGet("/ro/allMandatoryValues", this.#serviceVersions["ro"] ?? 1);
1901
+ this.#applyValuePayload(mandatory.data);
1902
+ if (this.#description.size === 0) this.#onEmptyEntityIndex?.();
1903
+ await this.#loadOptionalResources();
1904
+ }
1905
+ /**
1906
+ * Description changes and the info services are best-effort: an appliance that
1907
+ * lacks one still serves values, and the failure is logged, not fatal.
1908
+ */
1909
+ async #loadOptionalResources() {
1910
+ const ro = this.#serviceVersions["ro"] ?? 1;
1911
+ await this.#sendGet("/ro/allDescriptionChanges", ro).then((msg) => this.#applyDescriptionChanges(msg.data)).catch((err) => this.#warn("allDescriptionChanges unavailable", err));
1912
+ for (const [service, resource] of [["ci", "/ci/info"], ["ni", "/ni/info"]]) {
1913
+ if (!(service in this.#serviceVersions)) continue;
1914
+ await this.#sendGet(resource, this.#serviceVersions[service] ?? 1).then((msg) => {
1915
+ const row = msg.data?.[0];
1916
+ if (row !== void 0) this.#onInfo?.(service, scalarFields(row));
1917
+ }).catch((err) => this.#warn(`${resource} unavailable`, err));
1918
+ }
1919
+ }
1920
+ /** Re-read every mandatory value (after the description arrived late, or a gap). */
1921
+ async resync() {
1922
+ if (!this.#connected) return;
1923
+ const msg = await this.#sendGet("/ro/allMandatoryValues", this.#serviceVersions["ro"] ?? 1);
1924
+ this.#applyValuePayload(msg.data);
1925
+ }
1926
+ async close() {
1927
+ this.#closing = true;
1928
+ this.#connected = false;
1929
+ this.#stopKeepalive();
1930
+ this.#unsubText?.();
1931
+ this.#unsubText = null;
1932
+ this.#unsubClosed?.();
1933
+ this.#unsubClosed = null;
1934
+ this.#rejectAllPending(/* @__PURE__ */ new Error("Home Connect session closed"));
1935
+ this.#wire.close();
1936
+ }
1937
+ /**
1938
+ * POST one value. The feature changes ONLY when the appliance confirms with a
1939
+ * `/ro/values` notification — nothing is echoed here, so a refused write never
1940
+ * leaves a value the appliance does not hold.
1941
+ */
1942
+ async setFeatureByName(key, value) {
1943
+ const encoded = encodeHomeConnectValue(key, value, this.#description.index());
1944
+ if (!encoded) throw new Error(`Unknown Home Connect feature: ${key}`);
1945
+ await this.#post("/ro/values", [{
1946
+ uid: encoded.uid,
1947
+ value: encoded.value
1948
+ }]);
1949
+ }
1950
+ /** Select (not start) a program, with its option values. */
1951
+ async selectProgram(programName, options = {}) {
1952
+ if (!this.#description.entityOfKind("selectedProgram")) throw new Error("Home Connect appliance has no selected-program entity");
1953
+ await this.#postProgram("/ro/selectedProgram", programName, options);
1954
+ }
1955
+ /** Start a program (the selected one when `programName` is omitted by the caller resolving it). */
1956
+ async startProgram(programName, options = {}) {
1957
+ if (!this.#description.entityOfKind("activeProgram")) throw new Error("Home Connect appliance has no active-program entity");
1958
+ await this.#postProgram("/ro/activeProgram", programName, options);
1959
+ }
1960
+ async #postProgram(resource, programName, options) {
1961
+ const program = this.#description.entityByName(programName);
1962
+ if (!program || program.kind !== "program") throw new Error(`Unknown Home Connect program: ${programName}`);
1963
+ const optionRows = [];
1964
+ for (const [key, value] of Object.entries(options)) {
1965
+ const encoded = encodeHomeConnectValue(key, value, this.#description.index());
1966
+ if (!encoded) throw new Error(`Unknown Home Connect program option: ${key}`);
1967
+ optionRows.push({
1968
+ uid: encoded.uid,
1969
+ value: encoded.value
1970
+ });
1971
+ }
1972
+ await this.#post(resource, [{
1973
+ program: program.uid,
1974
+ ...optionRows.length > 0 ? { options: optionRows } : {}
1975
+ }]);
1976
+ }
1977
+ async #post(resource, data) {
1978
+ if (!this.#connected) throw new Error("Home Connect session not connected");
1979
+ const ro = this.#serviceVersions["ro"] ?? 1;
1980
+ const response = await this.#sendAndWaitResponse({
1981
+ sID: this.#sid,
1982
+ msgID: this.#allocMsgId(),
1983
+ resource,
1984
+ version: ro,
1985
+ action: HC_ACTION.POST,
1986
+ data
1987
+ });
1988
+ if (response.code !== void 0 && response.code >= RESPONSE_ERROR_CODE_MIN) throw new Error(`Home Connect appliance refused ${resource} (code ${response.code})`);
1989
+ }
1990
+ #setServiceVersions(message) {
1991
+ const versions = {};
1992
+ for (const row of message.data ?? []) {
1993
+ const service = row["service"];
1994
+ const version = row["version"];
1995
+ if (typeof service === "string" && typeof version === "number") versions[service] = version;
1996
+ }
1997
+ this.#serviceVersions = versions;
1998
+ }
1999
+ #applyValuePayload(data) {
2000
+ if (!data) return;
2001
+ for (const row of data) this.#applyUidRow(row);
2002
+ }
2003
+ #applyUidRow(row) {
2004
+ const uidRaw = row["uid"];
2005
+ if (typeof uidRaw !== "number") return;
2006
+ const index = this.#description.index();
2007
+ const entity = index.get(uidRaw);
2008
+ if (entity === void 0) {
2009
+ this.#noteUnknownUid(uidRaw, row["value"]);
2010
+ return;
2011
+ }
2012
+ const value = row["value"];
2013
+ if (entity.kind === "activeProgram" || entity.kind === "selectedProgram") {
2014
+ this.#onFeature(entity.name, this.#programNameOf(value));
2015
+ return;
2016
+ }
2017
+ const decoded = decodeHomeConnectValue(uidRaw, value, index);
2018
+ if (decoded) this.#onFeature(decoded.key, decoded.value);
2019
+ }
2020
+ /** A program entity's value is the program's uid; `0` / absent = no program. */
2021
+ #programNameOf(value) {
2022
+ if (typeof value !== "number" || value === 0) return null;
2023
+ return this.#description.entity(value)?.name ?? null;
2024
+ }
2025
+ #noteUnknownUid(uid, value) {
2026
+ if (this.#description.size === 0) {
2027
+ if (!this.#warnedEmptyIndex) {
2028
+ this.#warnedEmptyIndex = true;
2029
+ this.#onEmptyEntityIndex?.();
2030
+ }
2031
+ }
2032
+ if (this.#warnedUnknownUids.has(uid)) return;
2033
+ this.#warnedUnknownUids.add(uid);
2034
+ this.#logger?.warn("Home Connect value for a uid the description does not list — dropped", {
2035
+ tags: this.#logTags,
2036
+ meta: {
2037
+ uid,
2038
+ valueType: typeof value
2039
+ }
2040
+ });
2041
+ }
2042
+ #applyDescriptionChanges(data) {
2043
+ if (!data) return;
2044
+ const touched = this.#description.applyChanges(data);
2045
+ if (touched.length > 0) this.#onDescriptionChange?.(touched);
2046
+ }
2047
+ #applyProgramNotify(data, kind) {
2048
+ const row = data?.[0];
2049
+ if (row === void 0) return;
2050
+ const entity = this.#description.entityOfKind(kind === "active" ? "activeProgram" : "selectedProgram");
2051
+ if (entity !== void 0) this.#onFeature(entity.name, this.#programNameOf(row["program"]));
2052
+ const options = row["options"];
2053
+ if (Array.isArray(options)) {
2054
+ for (const option of options) if (typeof option === "object" && option !== null && !Array.isArray(option)) this.#applyUidRow({ ...option });
2055
+ }
2056
+ }
2057
+ #handleIncoming(text) {
2058
+ let message;
2059
+ try {
2060
+ message = loadHomeConnectMessage(text);
2061
+ } catch (err) {
2062
+ this.#noteUndecodable(err);
2063
+ return;
2064
+ }
2065
+ if (message.action === HC_ACTION.RESPONSE) {
2066
+ const waiter = this.#pending.get(message.msgID);
2067
+ if (waiter) {
2068
+ clearTimeout(waiter.timer);
2069
+ this.#pending.delete(message.msgID);
2070
+ waiter.resolve(message);
2071
+ }
2072
+ return;
2073
+ }
2074
+ if (message.action !== HC_ACTION.NOTIFY) return;
2075
+ switch (message.resource) {
2076
+ case "/ro/values":
2077
+ this.#applyValuePayload(message.data);
2078
+ return;
2079
+ case "/ro/descriptionChange":
2080
+ case "/ro/allDescriptionChanges":
2081
+ this.#applyDescriptionChanges(message.data);
2082
+ return;
2083
+ case "/ro/activeProgram":
2084
+ this.#applyProgramNotify(message.data, "active");
2085
+ return;
2086
+ case "/ro/selectedProgram":
2087
+ this.#applyProgramNotify(message.data, "selected");
2088
+ return;
2089
+ default: return;
2090
+ }
2091
+ }
2092
+ /** Warn on the first undecodable frame, then at powers of ten — a stuck peer must not flood the log. */
2093
+ #noteUndecodable(err) {
2094
+ this.#undecodableCount += 1;
2095
+ const n = this.#undecodableCount;
2096
+ if (n !== 1 && n !== 10 && n !== 100 && n % 1e3 !== 0) return;
2097
+ this.#logger?.warn("Home Connect frame dropped — undecodable", {
2098
+ tags: this.#logTags,
2099
+ meta: {
2100
+ error: require_dist.errMsg(err),
2101
+ droppedSoFar: n
2102
+ }
2103
+ });
2104
+ }
2105
+ #warn(message, err) {
2106
+ this.#logger?.warn(`Home Connect ${message}`, {
2107
+ tags: this.#logTags,
2108
+ meta: { error: require_dist.errMsg(err) }
2109
+ });
2110
+ }
2111
+ #handleWireClosed() {
2112
+ if (this.#closing) return;
2113
+ const wasConnected = this.#connected;
2114
+ this.#connected = false;
2115
+ this.#stopKeepalive();
2116
+ this.#rejectAllPending(/* @__PURE__ */ new Error("Home Connect wire closed"));
2117
+ if (wasConnected) this.#onClosed?.("wire closed");
2118
+ }
2119
+ #startKeepalive() {
2120
+ this.#stopKeepalive();
2121
+ this.#missedProbes = 0;
2122
+ if (this.#keepaliveMs <= 0) return;
2123
+ this.#keepalive = setInterval(() => {
2124
+ this.#probe();
2125
+ }, this.#keepaliveMs);
2126
+ }
2127
+ #stopKeepalive() {
2128
+ if (this.#keepalive !== null) clearInterval(this.#keepalive);
2129
+ this.#keepalive = null;
2130
+ }
2131
+ /**
2132
+ * A cheap request the appliance must answer. A dead socket that never reports
2133
+ * closed (NAT drop, appliance power loss) surfaces here as a timeout, and the
2134
+ * session is torn down so the owner reconnects.
2135
+ */
2136
+ async #probe() {
2137
+ if (!this.#connected) return;
2138
+ try {
2139
+ await this.#sendGet("/ci/services", 1);
2140
+ this.#missedProbes = 0;
2141
+ } catch (err) {
2142
+ if (!this.#connected) return;
2143
+ this.#missedProbes += 1;
2144
+ if (this.#missedProbes < 3) return;
2145
+ this.#warn("keepalive failed repeatedly — dropping the connection", err);
2146
+ this.#wire.close();
2147
+ }
2148
+ }
2149
+ #waitForResource(resource) {
2150
+ return new Promise((resolve, reject) => {
2151
+ let settled = false;
2152
+ const finish = (fn) => {
2153
+ if (settled) return;
2154
+ settled = true;
2155
+ clearTimeout(timer);
2156
+ unsubText();
2157
+ unsubClosed();
2158
+ fn();
2159
+ };
2160
+ const unsubText = this.#wire.onText((text) => {
2161
+ try {
2162
+ if (loadHomeConnectMessage(text).resource === resource) finish(() => resolve(text));
2163
+ } catch {}
2164
+ });
2165
+ const unsubClosed = this.#wire.onClosed(() => {
2166
+ finish(() => reject(/* @__PURE__ */ new Error("Home Connect wire closed before init")));
2167
+ });
2168
+ const timer = setTimeout(() => {
2169
+ finish(() => reject(/* @__PURE__ */ new Error(`Home Connect init timeout waiting for ${resource}`)));
2170
+ }, HOME_CONNECT_INIT_RESOURCE_TIMEOUT_MS);
2171
+ });
2172
+ }
2173
+ async #sendGet(resource, version) {
2174
+ return this.#sendAndWaitResponse({
2175
+ sID: this.#sid,
2176
+ msgID: this.#allocMsgId(),
2177
+ resource,
2178
+ version,
2179
+ action: HC_ACTION.GET
2180
+ });
2181
+ }
2182
+ async #sendNotify(resource) {
2183
+ const msg = {
2184
+ sID: this.#sid,
2185
+ msgID: this.#allocMsgId(),
2186
+ resource,
2187
+ version: this.#serviceVersions[resource.slice(1, 3)] ?? 1,
2188
+ action: HC_ACTION.NOTIFY
2189
+ };
2190
+ this.#wire.send(dumpHomeConnectMessage(msg));
2191
+ }
2192
+ #allocMsgId() {
2193
+ const id = this.#nextMsgId;
2194
+ this.#nextMsgId += 1;
2195
+ return id;
2196
+ }
2197
+ #rejectAllPending(err) {
2198
+ for (const pending of this.#pending.values()) {
2199
+ clearTimeout(pending.timer);
2200
+ pending.reject(err);
2201
+ }
2202
+ this.#pending.clear();
2203
+ }
2204
+ async #sendAndWaitResponse(outgoing) {
2205
+ return new Promise((resolve, reject) => {
2206
+ const timer = setTimeout(() => {
2207
+ if (this.#pending.delete(outgoing.msgID)) reject(/* @__PURE__ */ new Error(`Home Connect response timeout for ${outgoing.resource}`));
2208
+ }, this.#requestTimeoutMs);
2209
+ this.#pending.set(outgoing.msgID, {
2210
+ resource: outgoing.resource,
2211
+ resolve,
2212
+ reject,
2213
+ timer
2214
+ });
2215
+ try {
2216
+ this.#wire.send(dumpHomeConnectMessage(outgoing));
2217
+ } catch (err) {
2218
+ clearTimeout(timer);
2219
+ this.#pending.delete(outgoing.msgID);
2220
+ reject(err instanceof Error ? err : new Error(require_dist.errMsg(err)));
2221
+ }
2222
+ });
2223
+ }
2224
+ };
2225
+ //#endregion
2226
+ //#region src/home-connect/home-connect-ws-client.ts
2227
+ /** Feature-name prefixes for the fields `/ci/info` and `/ni/info` carry (no IDDF entity declares them). */
2228
+ var HOME_CONNECT_INFO_PREFIX = "HomeConnect.Info.";
2229
+ var HOME_CONNECT_NETWORK_PREFIX = "HomeConnect.Network.";
2230
+ /** Reconnect delays after a dropped or refused socket; the last one repeats. */
2231
+ var HOME_CONNECT_RECONNECT_BACKOFF_MS = [
2232
+ 1e3,
2233
+ 2e3,
2234
+ 5e3,
2235
+ 1e4,
2236
+ 3e4,
2237
+ 6e4
2238
+ ];
2239
+ var WS_OPEN = 1;
2240
+ function programNamesFromLegacyList(programsRaw) {
2241
+ const programs = [];
2242
+ if (!Array.isArray(programsRaw)) return programs;
2243
+ for (const p of programsRaw) if (typeof p === "string") programs.push(p);
2244
+ return programs;
2245
+ }
2246
+ function programNamesFromHaDeviceDescription(programsRaw) {
2247
+ const programs = [];
2248
+ if (!Array.isArray(programsRaw)) return programs;
2249
+ for (const p of programsRaw) if (isPlainObject(p)) {
2250
+ const name = p["name"];
2251
+ if (typeof name === "string") programs.push(name);
2252
+ } else if (typeof p === "string") programs.push(p);
2253
+ return programs;
2254
+ }
2255
+ function parseLegacyAppliancesList(rawList) {
2256
+ if (!Array.isArray(rawList)) return [];
2257
+ const appliances = [];
2258
+ for (const item of rawList) {
2259
+ if (!isPlainObject(item)) continue;
2260
+ const row = item;
2261
+ const haId = row["haId"];
2262
+ const name = row["name"];
2263
+ const type = row["type"];
2264
+ if (typeof haId !== "string") continue;
2265
+ appliances.push({
2266
+ haId,
2267
+ name: typeof name === "string" ? name : haId,
2268
+ type: typeof type === "string" ? type : "Dishwasher",
2269
+ availablePrograms: programNamesFromLegacyList(row["programs"])
2270
+ });
2271
+ }
2272
+ return appliances;
2273
+ }
2274
+ function parseHaDeviceDescription(record) {
2275
+ const info = record["info"];
2276
+ if (!isPlainObject(info)) return [];
2277
+ const infoRec = info;
2278
+ const deviceID = infoRec["deviceID"];
2279
+ if (typeof deviceID !== "string") return [];
2280
+ const deviceType = infoRec["deviceType"];
2281
+ const model = infoRec["model"];
2282
+ return [{
2283
+ haId: deviceID,
2284
+ name: typeof model === "string" && model.length > 0 ? model : deviceID,
2285
+ type: typeof deviceType === "string" ? deviceType : "Dishwasher",
730
2286
  availablePrograms: programNamesFromHaDeviceDescription(record["program"])
731
2287
  }];
732
2288
  }
@@ -754,10 +2310,233 @@ function parseDeviceDescription(input) {
754
2310
  return { appliances: [] };
755
2311
  }
756
2312
  }
2313
+ function usesProtocolStack(config, deps) {
2314
+ if (deps.wireFactory) return true;
2315
+ return config.mode === "TLS";
2316
+ }
2317
+ async function resolveConnectAddress(host) {
2318
+ try {
2319
+ const result = await (0, node_dns_promises.lookup)(host, { verbatim: true });
2320
+ return typeof result === "string" ? result : result.address;
2321
+ } catch {
2322
+ return host.replace(/^https?:\/\//, "").split("/")[0] ?? host;
2323
+ }
2324
+ }
2325
+ var defaultWireFactory = (input) => {
2326
+ if (input.mode === "TLS") return createHomeConnectTlsWire({
2327
+ host: input.host,
2328
+ connectAddress: input.connectAddress,
2329
+ psk: input.psk
2330
+ });
2331
+ throw new Error("Home Connect AES local socket is not implemented — use TLS pairing");
2332
+ };
757
2333
  function createHomeConnectWsClient(config, deps = {}) {
758
- const wsFactory = deps.wsFactory ?? defaultWsFactory;
759
- const url = homeConnectWsUrl(config);
760
2334
  const appliances = [...parseDeviceDescription(config.deviceDescription).appliances];
2335
+ const description = new HomeConnectDescriptionStore(config.deviceDescription);
2336
+ const primaryHaId = appliances[0]?.haId ?? config.host;
2337
+ if (usesProtocolStack(config, deps)) return createProtocolHomeConnectClient({
2338
+ config,
2339
+ appliances,
2340
+ description,
2341
+ primaryHaId,
2342
+ wireFactory: deps.wireFactory ?? defaultWireFactory,
2343
+ deps
2344
+ });
2345
+ if (description.size > 0 && config.mode === "AES") deps.onProtocolWarn?.("aes-legacy-fallback-with-entities");
2346
+ return createLegacySubscribeClient(config, deps, appliances);
2347
+ }
2348
+ function infoFeatureName(service, field) {
2349
+ return `${service === "ci" ? HOME_CONNECT_INFO_PREFIX : HOME_CONNECT_NETWORK_PREFIX}${field}`;
2350
+ }
2351
+ function createProtocolHomeConnectClient(input) {
2352
+ const { deps, description } = input;
2353
+ const featureStore = /* @__PURE__ */ new Map();
2354
+ const listeners = /* @__PURE__ */ new Set();
2355
+ const linkListeners = /* @__PURE__ */ new Set();
2356
+ const backoff = deps.backoffMs ?? HOME_CONNECT_RECONNECT_BACKOFF_MS;
2357
+ const jitter = deps.random ?? Math.random;
2358
+ const haId = input.primaryHaId;
2359
+ let session = null;
2360
+ let link = "disconnected";
2361
+ let wanted = false;
2362
+ let attempt = 0;
2363
+ let retryTimer = null;
2364
+ let opening = null;
2365
+ let warnedEmptyIndex = false;
2366
+ function ensureFeatures() {
2367
+ let map = featureStore.get(haId);
2368
+ if (!map) {
2369
+ map = /* @__PURE__ */ new Map();
2370
+ featureStore.set(haId, map);
2371
+ }
2372
+ return map;
2373
+ }
2374
+ function applyFeature(key, value) {
2375
+ ensureFeatures().set(key, value);
2376
+ for (const fn of listeners) fn(haId, key, value);
2377
+ }
2378
+ function setLink(next) {
2379
+ if (link === next) return;
2380
+ link = next;
2381
+ for (const fn of linkListeners) fn(haId, next);
2382
+ }
2383
+ function applyInfo(service, fields) {
2384
+ for (const [field, value] of Object.entries(fields)) applyFeature(infoFeatureName(service, field), value);
2385
+ }
2386
+ function dropSession() {
2387
+ session = null;
2388
+ ensureFeatures().clear();
2389
+ setLink("disconnected");
2390
+ }
2391
+ async function openOnce() {
2392
+ setLink("connecting");
2393
+ const connectAddress = await (deps.resolveAddress ?? resolveConnectAddress)(input.config.host);
2394
+ if (!wanted) throw new Error("Home Connect client closed");
2395
+ const next = new HomeConnectSession({
2396
+ wire: input.wireFactory({
2397
+ host: input.config.host,
2398
+ connectAddress,
2399
+ psk: input.config.psk,
2400
+ mode: input.config.mode,
2401
+ iv64: input.config.iv64
2402
+ }),
2403
+ description,
2404
+ onFeature: applyFeature,
2405
+ onInfo: applyInfo,
2406
+ onDescriptionChange: (names) => {
2407
+ for (const name of names) for (const fn of listeners) fn(haId, name, ensureFeatures().get(name));
2408
+ },
2409
+ onEmptyEntityIndex: () => {
2410
+ if (warnedEmptyIndex) return;
2411
+ warnedEmptyIndex = true;
2412
+ deps.onProtocolWarn?.("empty-entity-index");
2413
+ },
2414
+ onClosed: (reason) => {
2415
+ if (session !== next) return;
2416
+ deps.logger?.warn("Home Connect link dropped — reconnecting", {
2417
+ tags: deps.logTags ?? {},
2418
+ meta: {
2419
+ haId,
2420
+ reason
2421
+ }
2422
+ });
2423
+ dropSession();
2424
+ scheduleReconnect();
2425
+ },
2426
+ logger: deps.logger,
2427
+ logTags: deps.logTags,
2428
+ keepaliveMs: deps.keepaliveMs
2429
+ });
2430
+ session = next;
2431
+ try {
2432
+ await next.connect();
2433
+ } catch (err) {
2434
+ if (session === next) session = null;
2435
+ throw err;
2436
+ }
2437
+ if (!wanted) {
2438
+ if (session === next) session = null;
2439
+ await next.close();
2440
+ throw new Error("Home Connect client closed");
2441
+ }
2442
+ attempt = 0;
2443
+ setLink("connected");
2444
+ }
2445
+ function scheduleReconnect() {
2446
+ if (!wanted || retryTimer !== null) return;
2447
+ const base = backoff[Math.min(attempt, backoff.length - 1)] ?? 6e4;
2448
+ const delay = Math.round(base * (1 + (jitter() - .5) * .4));
2449
+ attempt += 1;
2450
+ retryTimer = setTimeout(() => {
2451
+ retryTimer = null;
2452
+ if (!wanted || opening !== null || link === "connected") return;
2453
+ opening = openOnce().catch((err) => {
2454
+ deps.logger?.warn("Home Connect reconnect failed", {
2455
+ tags: deps.logTags ?? {},
2456
+ meta: {
2457
+ haId,
2458
+ attempt,
2459
+ error: require_dist.errMsg(err)
2460
+ }
2461
+ });
2462
+ setLink("disconnected");
2463
+ scheduleReconnect();
2464
+ }).finally(() => {
2465
+ opening = null;
2466
+ });
2467
+ }, delay);
2468
+ }
2469
+ return {
2470
+ async connect() {
2471
+ wanted = true;
2472
+ if (retryTimer !== null) clearTimeout(retryTimer);
2473
+ retryTimer = null;
2474
+ if (link === "connected") return;
2475
+ if (opening !== null) return opening;
2476
+ const attemptPromise = openOnce();
2477
+ opening = attemptPromise.then(() => void 0, () => void 0);
2478
+ try {
2479
+ await attemptPromise;
2480
+ } catch (err) {
2481
+ setLink("disconnected");
2482
+ scheduleReconnect();
2483
+ throw err;
2484
+ } finally {
2485
+ opening = null;
2486
+ }
2487
+ },
2488
+ close() {
2489
+ wanted = false;
2490
+ if (retryTimer !== null) clearTimeout(retryTimer);
2491
+ retryTimer = null;
2492
+ const closing = session;
2493
+ session = null;
2494
+ ensureFeatures().clear();
2495
+ setLink("disconnected");
2496
+ if (closing) closing.close();
2497
+ },
2498
+ getAppliances() {
2499
+ return input.appliances;
2500
+ },
2501
+ getFeatures(targetHaId) {
2502
+ if (targetHaId !== haId && !featureStore.has(targetHaId)) return /* @__PURE__ */ new Map();
2503
+ return featureStore.get(targetHaId) ?? ensureFeatures();
2504
+ },
2505
+ async setFeature(targetHaId, key, value) {
2506
+ await requireSession(session, targetHaId, haId).setFeatureByName(key, value);
2507
+ },
2508
+ onFeatureChange(listener) {
2509
+ listeners.add(listener);
2510
+ return () => listeners.delete(listener);
2511
+ },
2512
+ getLinkState: () => link,
2513
+ onLinkChange(listener) {
2514
+ linkListeners.add(listener);
2515
+ return () => linkListeners.delete(listener);
2516
+ },
2517
+ getDescription: (targetHaId) => targetHaId === haId ? description : null,
2518
+ async setDescription(targetHaId, next) {
2519
+ if (targetHaId !== haId) throw new Error(`Unknown appliance ${targetHaId}`);
2520
+ description.replace(next);
2521
+ await session?.resync();
2522
+ },
2523
+ async selectProgram(targetHaId, program, options) {
2524
+ await requireSession(session, targetHaId, haId).selectProgram(program, options);
2525
+ },
2526
+ async startProgram(targetHaId, program, options) {
2527
+ await requireSession(session, targetHaId, haId).startProgram(program, options);
2528
+ }
2529
+ };
2530
+ }
2531
+ function requireSession(session, targetHaId, haId) {
2532
+ if (!session?.connected) throw new Error("Home Connect WebSocket not connected");
2533
+ if (targetHaId !== haId) throw new Error(`Unknown appliance ${targetHaId}`);
2534
+ return session;
2535
+ }
2536
+ function createLegacySubscribeClient(config, deps, appliances) {
2537
+ const linkListeners = /* @__PURE__ */ new Set();
2538
+ const wsFactory = deps.wsFactory ?? defaultWsFactoryLegacy;
2539
+ const url = homeConnectWsUrl(config);
761
2540
  const featureStore = /* @__PURE__ */ new Map();
762
2541
  const listeners = /* @__PURE__ */ new Set();
763
2542
  let ws = null;
@@ -782,6 +2561,7 @@ function createHomeConnectWsClient(config, deps = {}) {
782
2561
  ws = socket;
783
2562
  const onOpen = () => {
784
2563
  connected = true;
2564
+ for (const app of appliances) for (const fn of linkListeners) fn(app.haId, "connected");
785
2565
  socket.removeEventListener("open", onOpen);
786
2566
  socket.removeEventListener("error", onError);
787
2567
  for (const app of appliances) socket.send(JSON.stringify({
@@ -798,31 +2578,12 @@ function createHomeConnectWsClient(config, deps = {}) {
798
2578
  socket.addEventListener("message", (ev) => {
799
2579
  if (!ev.data) return;
800
2580
  try {
801
- const msg = JSON.parse(ev.data);
802
- if (typeof msg !== "object" || msg === null) return;
803
- const row = msg;
2581
+ const row = JSON.parse(ev.data);
2582
+ if (!isPlainObject(row)) return;
804
2583
  if (row["type"] === "feature") {
805
- const haId = row["haId"];
2584
+ const msgHaId = row["haId"];
806
2585
  const key = row["key"];
807
- if (typeof haId === "string" && typeof key === "string") applyFeature(haId, key, row["value"]);
808
- }
809
- if (row["type"] === "appliance") {
810
- const haId = row["haId"];
811
- const name = row["name"];
812
- const type = row["type"];
813
- const programs = row["programs"];
814
- if (typeof haId === "string") {
815
- const programList = [];
816
- if (Array.isArray(programs)) {
817
- for (const p of programs) if (typeof p === "string") programList.push(p);
818
- }
819
- appliances.push({
820
- haId,
821
- name: typeof name === "string" ? name : haId,
822
- type: typeof type === "string" ? type : "Dishwasher",
823
- availablePrograms: programList
824
- });
825
- }
2586
+ if (typeof msgHaId === "string" && typeof key === "string") applyFeature(msgHaId, key, row["value"]);
826
2587
  }
827
2588
  } catch {}
828
2589
  });
@@ -847,20 +2608,33 @@ function createHomeConnectWsClient(config, deps = {}) {
847
2608
  key,
848
2609
  value
849
2610
  }));
850
- applyFeature(haId, key, value);
851
2611
  },
852
2612
  onFeatureChange(listener) {
853
2613
  listeners.add(listener);
854
2614
  return () => listeners.delete(listener);
2615
+ },
2616
+ getLinkState: () => connected ? "connected" : "disconnected",
2617
+ onLinkChange(listener) {
2618
+ linkListeners.add(listener);
2619
+ return () => linkListeners.delete(listener);
2620
+ },
2621
+ getDescription: () => null,
2622
+ async setDescription() {
2623
+ throw new Error("Home Connect AES local socket has no description model");
2624
+ },
2625
+ async selectProgram() {
2626
+ throw new Error("Home Connect AES local socket cannot select a program");
2627
+ },
2628
+ async startProgram() {
2629
+ throw new Error("Home Connect AES local socket cannot start a program");
855
2630
  }
856
2631
  };
857
2632
  }
858
2633
  function isWsLike(value) {
859
- if (typeof value !== "object" || value === null) return false;
860
- const ws = value;
861
- return typeof ws["readyState"] === "number" && typeof ws["send"] === "function" && typeof ws["close"] === "function" && typeof ws["addEventListener"] === "function" && typeof ws["removeEventListener"] === "function";
2634
+ if (!isPlainObject(value)) return false;
2635
+ return typeof value["readyState"] === "number" && typeof value["send"] === "function" && typeof value["close"] === "function" && typeof value["addEventListener"] === "function" && typeof value["removeEventListener"] === "function";
862
2636
  }
863
- function defaultWsFactory(url) {
2637
+ function defaultWsFactoryLegacy(url) {
864
2638
  const WS = globalThis.WebSocket;
865
2639
  if (typeof WS !== "function") throw new Error(`WebSocket unavailable — cannot connect to ${url}`);
866
2640
  const socket = new WS(url);
@@ -888,14 +2662,23 @@ function accountClient(appliances, makeGateway) {
888
2662
  const children = /* @__PURE__ */ new Map();
889
2663
  for (const app of appliances) children.set(app.haId, makeGateway(app.connection));
890
2664
  const listeners = /* @__PURE__ */ new Set();
2665
+ const linkListeners = /* @__PURE__ */ new Set();
891
2666
  const unsubs = [];
892
- for (const [haId, child] of children) unsubs.push(child.onFeatureChange((eventHaId, key, value) => {
2667
+ for (const [haId, member] of children) unsubs.push(member.onFeatureChange((eventHaId, key, value) => {
893
2668
  const id = eventHaId.length > 0 ? eventHaId : haId;
894
2669
  for (const fn of listeners) fn(id, key, value);
2670
+ }), member.onLinkChange((eventHaId, state) => {
2671
+ const id = eventHaId.length > 0 ? eventHaId : haId;
2672
+ for (const fn of linkListeners) fn(id, state);
895
2673
  }));
2674
+ function member(haId) {
2675
+ const found = children.get(haId);
2676
+ if (!found) throw new Error(`Home Connect appliance ${haId} is not on this account`);
2677
+ return found;
2678
+ }
896
2679
  return {
897
2680
  async connect() {
898
- const results = await Promise.allSettled([...children.values()].map((child) => child.connect()));
2681
+ const results = await Promise.allSettled([...children.values()].map((c) => c.connect()));
899
2682
  if (results.length > 0 && results.every((result) => result.status === "rejected")) {
900
2683
  const first = results[0];
901
2684
  if (first?.status === "rejected") throw first.reason;
@@ -903,26 +2686,153 @@ function accountClient(appliances, makeGateway) {
903
2686
  },
904
2687
  close() {
905
2688
  for (const unsub of unsubs) unsub();
906
- for (const child of children.values()) child.close();
2689
+ for (const c of children.values()) c.close();
907
2690
  },
908
2691
  getAppliances: () => listed,
909
2692
  getFeatures(haId) {
910
2693
  return children.get(haId)?.getFeatures(haId) ?? /* @__PURE__ */ new Map();
911
2694
  },
912
2695
  async setFeature(haId, key, value) {
913
- const child = children.get(haId);
914
- if (!child) throw new Error(`Home Connect appliance ${haId} is not on this account`);
915
- await child.setFeature(haId, key, value);
2696
+ await member(haId).setFeature(haId, key, value);
916
2697
  },
917
2698
  onFeatureChange(listener) {
918
2699
  listeners.add(listener);
919
2700
  return () => listeners.delete(listener);
2701
+ },
2702
+ getLinkState: (haId) => children.get(haId)?.getLinkState(haId) ?? "disconnected",
2703
+ onLinkChange(listener) {
2704
+ linkListeners.add(listener);
2705
+ return () => linkListeners.delete(listener);
2706
+ },
2707
+ getDescription: (haId) => children.get(haId)?.getDescription(haId) ?? null,
2708
+ async setDescription(haId, description) {
2709
+ await member(haId).setDescription(haId, description);
2710
+ },
2711
+ async selectProgram(haId, program, options) {
2712
+ await member(haId).selectProgram(haId, program, options);
2713
+ },
2714
+ async startProgram(haId, program, options) {
2715
+ await member(haId).startProgram(haId, program, options);
920
2716
  }
921
2717
  };
922
2718
  }
923
2719
  //#endregion
924
- //#region src/home-connect/home-connect-integration-manager.ts
925
- function storedAppliance(row) {
2720
+ //#region src/home-connect/home-connect-iddf-fetch.ts
2721
+ function entityCountInStoredDescription(description) {
2722
+ if (description === void 0) return 0;
2723
+ return entityIndexFromDescription(description).size;
2724
+ }
2725
+ async function fetchHomeConnectIddfCompact(haId, token, fetchImpl) {
2726
+ const response = await fetchImpl(HOME_CONNECT_IDDF_URL(haId), {
2727
+ method: "GET",
2728
+ headers: {
2729
+ authorization: `Bearer ${token}`,
2730
+ accept: "application/zip"
2731
+ },
2732
+ redirect: "manual"
2733
+ });
2734
+ if (response.status >= 400 || !response.arrayBuffer) return null;
2735
+ const bytes = await response.arrayBuffer();
2736
+ return parseIddfZipBuffer(Buffer.from(bytes), haId);
2737
+ }
2738
+ function compactDescriptionToStored(compact) {
2739
+ return JSON.parse(JSON.stringify(compact));
2740
+ }
2741
+ function entityCountForCompact(compact) {
2742
+ if (compact === null) return 0;
2743
+ return entityIndexFromDescription(JSON.parse(JSON.stringify(compact))).size;
2744
+ }
2745
+ /**
2746
+ * Fill missing or outdated IDDF entity maps on stored appliances (TLS first).
2747
+ * An appliance whose IDDF cannot be fetched keeps whatever it had and is listed in
2748
+ * `missing` so the caller can retry — the entity map of ANOTHER model is never
2749
+ * substituted: the UIDs differ per model and a wrong map decodes values into the
2750
+ * wrong features.
2751
+ */
2752
+ async function enrichHomeConnectConnection(connection, fetchImpl, hooks = {}) {
2753
+ const appliances = connection.appliances ?? [];
2754
+ if (appliances.length === 0) return {
2755
+ connection,
2756
+ changed: false,
2757
+ missing: []
2758
+ };
2759
+ const token = connection.token;
2760
+ if (require_dist.isSecretMarker(token) || require_dist.isSecretMarkerLike(token)) return {
2761
+ connection,
2762
+ changed: false,
2763
+ missing: unmapped(appliances)
2764
+ };
2765
+ let changed = false;
2766
+ const nextAppliances = [];
2767
+ for (const row of appliances) {
2768
+ if (!(row.mode === "TLS" && !isCurrentDescription(row.deviceDescription))) {
2769
+ nextAppliances.push(row);
2770
+ continue;
2771
+ }
2772
+ let compact = null;
2773
+ try {
2774
+ compact = await fetchHomeConnectIddfCompact(row.haId, token, fetchImpl);
2775
+ } catch (err) {
2776
+ hooks.onInfo?.("Home Connect IDDF fetch failed", {
2777
+ haId: row.haId,
2778
+ error: require_dist.errMsg(err)
2779
+ });
2780
+ }
2781
+ if (compact === null || entityCountForCompact(compact) === 0) {
2782
+ hooks.onInfo?.("Home Connect IDDF unavailable — appliance stays degraded until it loads", { haId: row.haId });
2783
+ nextAppliances.push(row);
2784
+ continue;
2785
+ }
2786
+ changed = true;
2787
+ nextAppliances.push({
2788
+ ...row,
2789
+ deviceDescription: compactDescriptionToStored(compact)
2790
+ });
2791
+ }
2792
+ return {
2793
+ connection: changed ? {
2794
+ ...connection,
2795
+ appliances: nextAppliances
2796
+ } : connection,
2797
+ changed,
2798
+ missing: unmapped(nextAppliances)
2799
+ };
2800
+ }
2801
+ function unmapped(appliances) {
2802
+ return appliances.filter((a) => a.mode === "TLS" && entityCountInStoredDescription(a.deviceDescription) === 0).map((a) => a.haId);
2803
+ }
2804
+ //#endregion
2805
+ //#region src/home-connect/home-connect-stored-appliance.ts
2806
+ function stubDeviceDescriptionObject(row) {
2807
+ return { info: {
2808
+ deviceID: row.haId,
2809
+ deviceType: row.type,
2810
+ model: row.name
2811
+ } };
2812
+ }
2813
+ function stubDeviceDescription(row) {
2814
+ return JSON.stringify(stubDeviceDescriptionObject(row));
2815
+ }
2816
+ function serializedDeviceDescription(row) {
2817
+ if (row.deviceDescription === void 0) return stubDeviceDescription(row);
2818
+ if (typeof row.deviceDescription === "string") return row.deviceDescription;
2819
+ return JSON.stringify(row.deviceDescription);
2820
+ }
2821
+ function isDescriptionRecord(value) {
2822
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2823
+ }
2824
+ function parseLoginDeviceDescription(raw) {
2825
+ const trimmed = raw.trim();
2826
+ if (trimmed.length === 0) return {};
2827
+ try {
2828
+ const parsed = JSON.parse(trimmed);
2829
+ if (isDescriptionRecord(parsed)) return { deviceDescription: parsed };
2830
+ } catch {
2831
+ return {};
2832
+ }
2833
+ return {};
2834
+ }
2835
+ function listedApplianceFromStored(row) {
926
2836
  return {
927
2837
  haId: row.haId,
928
2838
  name: row.name,
@@ -932,21 +2842,70 @@ function storedAppliance(row) {
932
2842
  psk: row.psk,
933
2843
  mode: row.mode,
934
2844
  iv64: row.iv64 ?? "",
935
- deviceDescription: JSON.stringify({ info: {
936
- deviceID: row.haId,
937
- deviceType: row.type,
938
- model: row.name
939
- } })
2845
+ deviceDescription: serializedDeviceDescription(row)
940
2846
  }
941
2847
  };
942
2848
  }
943
- async function openStoredOrAccount(connection) {
944
- const stored = connection.appliances ?? [];
945
- if (stored.length > 0) return accountClient(stored.map(storedAppliance), defaultHomeConnectWsClientFactory);
946
- return openHomeConnectAccount({
947
- token: connection.token,
948
- refresh: connection.refresh ?? ""
949
- });
2849
+ //#endregion
2850
+ //#region src/home-connect/home-connect-integration-manager.ts
2851
+ /** Delays between IDDF fetch retries for an appliance that has no entity map; the last repeats. */
2852
+ var HOME_CONNECT_IDDF_RETRY_MS = [
2853
+ 3e4,
2854
+ 6e4,
2855
+ 12e4,
2856
+ 3e5,
2857
+ 6e5
2858
+ ];
2859
+ async function openStoredOrAccount(connection, deps) {
2860
+ let working = connection;
2861
+ let missing = [];
2862
+ if ((working.appliances ?? []).length > 0) {
2863
+ const token = working.token;
2864
+ if (require_dist.isSecretMarker(token) || require_dist.isSecretMarkerLike(token)) deps.logger?.warn("Home Connect skipping IDDF enrich — access token not resolved yet", {
2865
+ tags: { integrationId: deps.integrationId },
2866
+ meta: { tokenIsSecretMarker: require_dist.isSecretMarker(token) }
2867
+ });
2868
+ const enriched = await enrichHomeConnectConnection(working, deps.fetchImpl ?? homeConnectLoginFetch, { onInfo: (message, meta) => {
2869
+ deps.logger?.info(message, {
2870
+ tags: { integrationId: deps.integrationId },
2871
+ meta
2872
+ });
2873
+ } });
2874
+ working = enriched.connection;
2875
+ missing = enriched.missing;
2876
+ if (enriched.changed) deps.logger?.info("Home Connect enriched appliance IDDF from cloud", {
2877
+ tags: { integrationId: deps.integrationId },
2878
+ meta: { applianceCount: working.appliances?.length ?? 0 }
2879
+ });
2880
+ for (const haId of missing) deps.logger?.warn("Home Connect appliance has no IDDF entity map — publishing it degraded and retrying the fetch", {
2881
+ tags: { integrationId: deps.integrationId },
2882
+ meta: { haId }
2883
+ });
2884
+ const warnProtocol = (code) => {
2885
+ deps.logger?.warn("Home Connect local protocol warning", {
2886
+ tags: { integrationId: deps.integrationId },
2887
+ meta: { code }
2888
+ });
2889
+ };
2890
+ return {
2891
+ connection: working,
2892
+ missing,
2893
+ client: accountClient((working.appliances ?? []).map(listedApplianceFromStored), (gateway) => createHomeConnectWsClient(gateway, {
2894
+ onProtocolWarn: warnProtocol,
2895
+ logger: deps.logger,
2896
+ logTags: { integrationId: deps.integrationId }
2897
+ }))
2898
+ };
2899
+ }
2900
+ return {
2901
+ connection: working,
2902
+ missing,
2903
+ client: await openHomeConnectAccount({
2904
+ token: working.token,
2905
+ refresh: working.refresh ?? "",
2906
+ fetchImpl: deps.fetchImpl
2907
+ })
2908
+ };
950
2909
  }
951
2910
  var HomeConnectIntegrationManager = class {
952
2911
  #id;
@@ -957,10 +2916,18 @@ var HomeConnectIntegrationManager = class {
957
2916
  #onDisconnected;
958
2917
  #surfaceSink;
959
2918
  #makeClient;
2919
+ #fetchImpl;
2920
+ #onConnectionUpdated;
2921
+ #iddfRetryMs;
960
2922
  #client = null;
2923
+ #unsubLink = null;
2924
+ #links = /* @__PURE__ */ new Map();
961
2925
  #status = "disconnected";
962
2926
  #lastCheckedAt = null;
963
2927
  #error = null;
2928
+ #missing = [];
2929
+ #iddfAttempt = 0;
2930
+ #iddfTimer = null;
964
2931
  constructor(options) {
965
2932
  this.#id = options.id;
966
2933
  this.#name = options.name;
@@ -970,6 +2937,9 @@ var HomeConnectIntegrationManager = class {
970
2937
  this.#onDisconnected = options.onDisconnected;
971
2938
  this.#surfaceSink = options.surfaceSink;
972
2939
  this.#makeClient = options.makeClient ?? null;
2940
+ this.#fetchImpl = options.fetchImpl;
2941
+ this.#onConnectionUpdated = options.onConnectionUpdated;
2942
+ this.#iddfRetryMs = options.iddfRetryMs ?? HOME_CONNECT_IDDF_RETRY_MS;
973
2943
  }
974
2944
  getConnection() {
975
2945
  return this.#connection;
@@ -981,7 +2951,10 @@ var HomeConnectIntegrationManager = class {
981
2951
  name: this.#name,
982
2952
  kind: "home-connect",
983
2953
  status: this.#status,
984
- info: { applianceCount },
2954
+ info: {
2955
+ applianceCount,
2956
+ descriptionMissing: [...this.#missing]
2957
+ },
985
2958
  lastCheckedAt: this.#lastCheckedAt,
986
2959
  error: this.#error
987
2960
  };
@@ -990,8 +2963,23 @@ var HomeConnectIntegrationManager = class {
990
2963
  if (this.#client !== null) return;
991
2964
  this.#status = "connecting";
992
2965
  this.#error = null;
993
- const client = this.#makeClient ? this.#makeClient(this.#connection) : await openStoredOrAccount(this.#connection);
2966
+ const before = this.#connection;
2967
+ let client;
2968
+ if (this.#makeClient) client = this.#makeClient(this.#connection);
2969
+ else {
2970
+ const opened = await openStoredOrAccount(this.#connection, {
2971
+ integrationId: this.#id,
2972
+ fetchImpl: this.#fetchImpl,
2973
+ logger: this.#logger
2974
+ });
2975
+ client = opened.client;
2976
+ this.#connection = opened.connection;
2977
+ this.#missing = opened.missing;
2978
+ if (!sameHomeConnectConnection(before, this.#connection)) this.#onConnectionUpdated?.(this.#id, this.#connection);
2979
+ }
994
2980
  this.#client = client;
2981
+ this.#links.clear();
2982
+ this.#unsubLink = client.onLinkChange((haId, state) => this.#onLink(haId, state));
995
2983
  const seededAppliances = client.getAppliances();
996
2984
  if (seededAppliances.length > 0) this.#surfaceSink.set(this.#id, {
997
2985
  appliances: seededAppliances,
@@ -999,48 +2987,129 @@ var HomeConnectIntegrationManager = class {
999
2987
  });
1000
2988
  try {
1001
2989
  await client.connect();
1002
- const surface = {
2990
+ this.#surfaceSink.set(this.#id, {
1003
2991
  appliances: client.getAppliances(),
1004
2992
  client
1005
- };
1006
- this.#surfaceSink.set(this.#id, surface);
1007
- this.#status = "connected";
2993
+ });
1008
2994
  this.#lastCheckedAt = Date.now();
1009
- this.#onConnected(this.#id);
2995
+ for (const appliance of client.getAppliances()) this.#links.set(appliance.haId, client.getLinkState(appliance.haId));
2996
+ this.#refreshStatus();
2997
+ this.#scheduleIddfRetry();
1010
2998
  } catch (err) {
1011
- this.#status = "error";
1012
2999
  this.#error = require_dist.errMsg(err);
1013
3000
  this.#lastCheckedAt = Date.now();
3001
+ this.#refreshStatus();
1014
3002
  const appliances = client.getAppliances();
1015
3003
  if (appliances.length > 0) {
1016
3004
  this.#surfaceSink.set(this.#id, {
1017
3005
  appliances,
1018
3006
  client
1019
3007
  });
1020
- this.#logger.warn("HomeConnectIntegrationManager: connect failed, keeping appliance inventory from pairing", { meta: {
1021
- integrationId: this.#id,
1022
- error: this.#error,
1023
- applianceCount: appliances.length
1024
- } });
3008
+ this.#scheduleIddfRetry();
3009
+ this.#logger.warn("HomeConnectIntegrationManager: connect failed, keeping appliance inventory from pairing", {
3010
+ tags: { integrationId: this.#id },
3011
+ meta: {
3012
+ error: this.#error,
3013
+ applianceCount: appliances.length
3014
+ }
3015
+ });
1025
3016
  return;
1026
3017
  }
3018
+ this.#unsubLink?.();
3019
+ this.#unsubLink = null;
3020
+ this.#client = null;
3021
+ client.close();
3022
+ this.#status = "error";
1027
3023
  this.#surfaceSink.set(this.#id, null);
1028
3024
  this.#onDisconnected(this.#id);
1029
3025
  throw err;
1030
3026
  }
1031
3027
  }
3028
+ #onLink(haId, state) {
3029
+ this.#links.set(haId, state);
3030
+ this.#lastCheckedAt = Date.now();
3031
+ if (state === "connected") this.#error = null;
3032
+ this.#refreshStatus();
3033
+ }
3034
+ /** `connected` while at least one appliance link is up; the callbacks fire on the edge. */
3035
+ #refreshStatus() {
3036
+ const states = [...this.#links.values()];
3037
+ const wasConnected = this.#status === "connected";
3038
+ let next;
3039
+ if (states.some((s) => s === "connected")) next = "connected";
3040
+ else if (states.some((s) => s === "connecting")) next = "connecting";
3041
+ else next = this.#error !== null ? "error" : "disconnected";
3042
+ this.#status = next;
3043
+ if (next === "connected" && !wasConnected) this.#onConnected(this.#id);
3044
+ if (next !== "connected" && wasConnected) this.#onDisconnected(this.#id);
3045
+ }
3046
+ #scheduleIddfRetry() {
3047
+ if (this.#missing.length === 0 || this.#iddfTimer !== null || this.#client === null) return;
3048
+ const delay = this.#iddfRetryMs[Math.min(this.#iddfAttempt, this.#iddfRetryMs.length - 1)] ?? 6e5;
3049
+ this.#iddfAttempt += 1;
3050
+ this.#iddfTimer = setTimeout(() => {
3051
+ this.#iddfTimer = null;
3052
+ this.#retryIddf();
3053
+ }, delay);
3054
+ }
3055
+ async #retryIddf() {
3056
+ const client = this.#client;
3057
+ if (client === null) return;
3058
+ try {
3059
+ const result = await enrichHomeConnectConnection(this.#connection, this.#fetchImpl ?? homeConnectLoginFetch, { onInfo: (message, meta) => {
3060
+ this.#logger.info(message, {
3061
+ tags: { integrationId: this.#id },
3062
+ meta
3063
+ });
3064
+ } });
3065
+ if (this.#client !== client) return;
3066
+ this.#missing = result.missing;
3067
+ if (result.changed) {
3068
+ this.#connection = result.connection;
3069
+ for (const row of result.connection.appliances ?? []) {
3070
+ if (row.deviceDescription === void 0 || result.missing.includes(row.haId)) continue;
3071
+ await client.setDescription(row.haId, row.deviceDescription).catch((err) => {
3072
+ this.#logger.warn("Home Connect could not apply the fetched IDDF", {
3073
+ tags: { integrationId: this.#id },
3074
+ meta: {
3075
+ haId: row.haId,
3076
+ error: require_dist.errMsg(err)
3077
+ }
3078
+ });
3079
+ });
3080
+ }
3081
+ this.#onConnectionUpdated?.(this.#id, this.#connection);
3082
+ this.#logger.info("Home Connect IDDF loaded after retry", {
3083
+ tags: { integrationId: this.#id },
3084
+ meta: { stillMissing: result.missing.length }
3085
+ });
3086
+ }
3087
+ } catch (err) {
3088
+ this.#logger.warn("Home Connect IDDF retry failed", {
3089
+ tags: { integrationId: this.#id },
3090
+ meta: { error: require_dist.errMsg(err) }
3091
+ });
3092
+ }
3093
+ this.#scheduleIddfRetry();
3094
+ }
1032
3095
  async stop() {
1033
3096
  const client = this.#client;
1034
3097
  this.#client = null;
3098
+ if (this.#iddfTimer !== null) clearTimeout(this.#iddfTimer);
3099
+ this.#iddfTimer = null;
3100
+ this.#iddfAttempt = 0;
3101
+ this.#unsubLink?.();
3102
+ this.#unsubLink = null;
3103
+ this.#links.clear();
1035
3104
  this.#status = "disconnected";
1036
3105
  this.#surfaceSink.set(this.#id, null);
1037
3106
  if (client) try {
1038
3107
  client.close();
1039
3108
  } catch (err) {
1040
- this.#logger.warn("HomeConnectIntegrationManager: client close failed", { meta: {
1041
- integrationId: this.#id,
1042
- error: require_dist.errMsg(err)
1043
- } });
3109
+ this.#logger.warn("HomeConnectIntegrationManager: client close failed", {
3110
+ tags: { integrationId: this.#id },
3111
+ meta: { error: require_dist.errMsg(err) }
3112
+ });
1044
3113
  }
1045
3114
  this.#onDisconnected(this.#id);
1046
3115
  }
@@ -1057,12 +3126,15 @@ var HomeConnectClientRegistry = class {
1057
3126
  #onConnected;
1058
3127
  #onDisconnected;
1059
3128
  #makeManager;
3129
+ #onConnectionUpdated;
1060
3130
  #entries = /* @__PURE__ */ new Map();
1061
3131
  constructor(logger, deps = {}) {
1062
3132
  this.#logger = logger;
1063
3133
  this.#onConnected = deps.onConnected ?? (() => void 0);
1064
3134
  this.#onDisconnected = deps.onDisconnected ?? (() => void 0);
3135
+ this.#onConnectionUpdated = deps.onConnectionUpdated;
1065
3136
  const makeClient = deps.makeClient;
3137
+ const fetchImpl = deps.fetchImpl;
1066
3138
  this.#makeManager = deps.makeManager ?? ((opts) => new HomeConnectIntegrationManager({
1067
3139
  id: opts.integrationId,
1068
3140
  name: opts.name,
@@ -1071,7 +3143,9 @@ var HomeConnectClientRegistry = class {
1071
3143
  onConnected: opts.onConnected,
1072
3144
  onDisconnected: opts.onDisconnected,
1073
3145
  surfaceSink: homeConnectConnections,
1074
- makeClient
3146
+ makeClient,
3147
+ fetchImpl,
3148
+ onConnectionUpdated: this.#onConnectionUpdated
1075
3149
  }));
1076
3150
  }
1077
3151
  setOnConnected(cb) {
@@ -1088,7 +3162,7 @@ var HomeConnectClientRegistry = class {
1088
3162
  await existing.manager.applyConnection(connection);
1089
3163
  this.#entries.set(integrationId, {
1090
3164
  manager: existing.manager,
1091
- connection
3165
+ connection: existing.manager.getConnection()
1092
3166
  });
1093
3167
  } catch (err) {
1094
3168
  this.#logger.warn("HomeConnectClientRegistry: applyConnection failed", { meta: {
@@ -1112,6 +3186,10 @@ var HomeConnectClientRegistry = class {
1112
3186
  });
1113
3187
  try {
1114
3188
  await manager.start();
3189
+ this.#entries.set(integrationId, {
3190
+ manager,
3191
+ connection: manager.getConnection()
3192
+ });
1115
3193
  } catch (err) {
1116
3194
  this.#logger.warn("HomeConnectClientRegistry: manager start failed", { meta: {
1117
3195
  integrationId,
@@ -1175,13 +3253,12 @@ function buildHomeConnectConnectionTestProvider(deps) {
1175
3253
  return {
1176
3254
  describeTest: async () => ({ label: TEST_LABEL }),
1177
3255
  testSettings: async ({ settings }) => {
1178
- const normalized = homeConnectSettingsForPersist(typeof settings === "object" && settings !== null ? settings : {});
1179
- const parsed = homeConnectConfigSchema.safeParse(normalized);
1180
- if (!parsed.success) return {
3256
+ const parsed = connectionFromSettings(typeof settings === "object" && settings !== null ? Object.fromEntries(Object.entries(settings)) : {});
3257
+ if (parsed === null) return {
1181
3258
  outcome: "rejected",
1182
- error: `Home Connect settings incomplete: ${parsed.error.issues.map((issue) => issue.path.join(".") || "(root)").join(", ")}`
3259
+ error: "Home Connect settings incomplete: sign in to read the account token."
1183
3260
  };
1184
- const saved = parsed.data.appliances ?? [];
3261
+ const saved = parsed.appliances ?? [];
1185
3262
  if (saved.length > 0) {
1186
3263
  const names = saved.map((app) => app.name).join(", ");
1187
3264
  return {
@@ -1192,7 +3269,7 @@ function buildHomeConnectConnectionTestProvider(deps) {
1192
3269
  }
1193
3270
  const startedAt = Date.now();
1194
3271
  try {
1195
- const account = await fetchHomeConnectAccount(parsed.data, deps.fetchImpl);
3272
+ const account = await fetchHomeConnectAccount(parsed, deps.fetchImpl);
1196
3273
  return {
1197
3274
  outcome: "validated",
1198
3275
  latencyMs: Date.now() - startedAt,
@@ -1213,124 +3290,828 @@ function buildHomeConnectConnectionTestProvider(deps) {
1213
3290
  }
1214
3291
  };
1215
3292
  }
1216
- var HC_FEATURE_ACTIVE_PROGRAM = "BSH.Common.Setting.Program.Active";
1217
- var HC_FEATURE_REMAINING = "BSH.Common.Option.RemainingProgramTime";
1218
- var HC_FEATURE_PROGRESS = "BSH.Common.Option.ProgramProgress";
1219
- var HC_FEATURE_DOOR = "BSH.Common.Status.DoorState";
1220
- var HC_FEATURE_REMOTE_START = "BSH.Common.Status.RemoteControlActive";
1221
- var HC_FEATURE_POWER = "BSH.Common.Setting.PowerState";
1222
- var HC_FEATURE_SALT = "BSH.Common.Setting.SaltLevel";
1223
- var HC_FEATURE_RINSE = "BSH.Common.Setting.RinseAidLevel";
1224
- var HC_FEATURE_HALF_LOAD = "BSH.Common.Option.HalfLoad";
1225
- var HC_FEATURE_HYGIENE = "BSH.Common.Option.HygienePlus";
1226
- var OPERATION_TO_STATE = {
1227
- Ready: "idle",
1228
- Inactive: "idle",
1229
- Run: "running",
1230
- Pause: "paused",
1231
- Finished: "finished",
1232
- Error: "error"
3293
+ //#endregion
3294
+ //#region src/home-connect/home-connect-units.ts
3295
+ /**
3296
+ * Units are a property of the ENTITY, fixed for its whole series. A value is never
3297
+ * re-labelled by its magnitude (`n >= 10_000` ⇒ "it must be ml" turned one sensor's
3298
+ * history into two incompatible series).
3299
+ *
3300
+ * The unit comes from, in order:
3301
+ * 1. the unit the appliance's own description declares for the entity;
3302
+ * 2. the few features whose unit is part of the Home Connect interface itself
3303
+ * (durations are seconds, a forecast and a progress are percent);
3304
+ * 3. nothing — the series is then published without a unit, never with a guess.
3305
+ */
3306
+ var SECONDS_FEATURES = new Set([
3307
+ "RemainingProgramTime",
3308
+ "StartInRelative",
3309
+ "FinishInRelative",
3310
+ "ElapsedProgramTime",
3311
+ "EstimatedTotalProgramTime"
3312
+ ]);
3313
+ var PERCENT_FEATURES = new Set([
3314
+ "ProgramProgress",
3315
+ "EnergyForecast",
3316
+ "WaterForecast"
3317
+ ]);
3318
+ /** Last dotted segment of a feature name (`BSH.Common.Option.StartInRelative` -> `StartInRelative`). */
3319
+ function featureShortName(featureName) {
3320
+ return featureName.split(".").pop() ?? featureName;
3321
+ }
3322
+ /** The unit of an entity's series, or `null` when neither the description nor the interface fixes one. */
3323
+ function resolveUnit(entity) {
3324
+ if (entity.unit !== void 0) return entity.unit;
3325
+ const short = featureShortName(entity.name);
3326
+ if (SECONDS_FEATURES.has(short)) return "s";
3327
+ if (PERCENT_FEATURES.has(short)) return "%";
3328
+ return null;
3329
+ }
3330
+ var KWH_PER_UNIT = {
3331
+ kWh: 1,
3332
+ Wh: 1 / 1e3,
3333
+ MWh: 1e3,
3334
+ kJ: 1 / 3600,
3335
+ MJ: 1 / 3.6,
3336
+ J: 1 / 36e5
3337
+ };
3338
+ /** Whether a unit is an energy unit this addon can express in kWh. */
3339
+ function isEnergyUnit(unit) {
3340
+ return unit !== null && Object.hasOwn(KWH_PER_UNIT, unit);
3341
+ }
3342
+ /**
3343
+ * Convert an energy reading to kWh using the unit the description declared.
3344
+ * `null` for an undeclared or unknown unit — a counter in the wrong unit is worse than no counter.
3345
+ */
3346
+ function energyToKwh(value, unit) {
3347
+ if (unit === null) return null;
3348
+ const factor = KWH_PER_UNIT[unit];
3349
+ return factor === void 0 ? null : value * factor;
3350
+ }
3351
+ /** A whole number of seconds from a raw reading; `null` when it is not a usable duration. */
3352
+ function secondsFrom(raw) {
3353
+ const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
3354
+ if (!Number.isFinite(n) || n < 0) return null;
3355
+ return Math.round(n);
3356
+ }
3357
+ //#endregion
3358
+ //#region src/home-connect/home-connect-domain-mapping.ts
3359
+ /** `OperationState` values the appliance reports when an event / flag is on. */
3360
+ var EVENT_ACTIVE_VALUES = new Set(["Present", "Confirmed"]);
3361
+ /** An event entity carries its present state; `Present` and `Confirmed` both mean "still raised". */
3362
+ function eventActive(value) {
3363
+ if (typeof value === "string") {
3364
+ if (EVENT_ACTIVE_VALUES.has(value)) return true;
3365
+ if (value === "Off") return false;
3366
+ return null;
3367
+ }
3368
+ if (typeof value === "boolean") return value;
3369
+ return null;
3370
+ }
3371
+ /** First entity of one of `kinds` whose last name segment is one of `shortNames`. */
3372
+ function findEntity(description, kinds, shortNames) {
3373
+ if (description === null) return void 0;
3374
+ return description.entities().find((e) => kinds.includes(e.kind) && shortNames.includes(featureShortName(e.name)));
3375
+ }
3376
+ function valueOf(view, entity) {
3377
+ return entity === void 0 ? void 0 : view.features.get(entity.name);
3378
+ }
3379
+ function boolOf(raw) {
3380
+ return typeof raw === "boolean" ? raw : null;
3381
+ }
3382
+ function stringOf(raw) {
3383
+ return typeof raw === "string" && raw.length > 0 ? raw : null;
3384
+ }
3385
+ function numberOf(raw) {
3386
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : null;
3387
+ }
3388
+ function availabilityOf(view) {
3389
+ if (view.link !== "connected") return "offline";
3390
+ if (view.description === null || view.description.state === "missing") return "description-missing";
3391
+ return "available";
3392
+ }
3393
+ var OPERATION_TO_STATE = {
3394
+ Inactive: "idle",
3395
+ Ready: "idle",
3396
+ DelayedStart: "idle",
3397
+ ActionRequired: "idle",
3398
+ Run: "running",
3399
+ Aborting: "running",
3400
+ Pause: "paused",
3401
+ Finished: "finished",
3402
+ Error: "error"
3403
+ };
3404
+ function operationStateOf(raw) {
3405
+ const parsed = require_dist.DishwasherOperationStateSchema.safeParse(raw);
3406
+ return parsed.success ? parsed.data : null;
3407
+ }
3408
+ function consumableFromEvents(view, nearlyEmpty, empty) {
3409
+ const nearly = findEntity(view.description, ["event"], nearlyEmpty);
3410
+ const gone = findEntity(view.description, ["event"], empty);
3411
+ if (nearly === void 0 && gone === void 0) return null;
3412
+ if (eventActive(valueOf(view, gone)) === true) return "empty";
3413
+ const nearlyActive = eventActive(valueOf(view, nearly));
3414
+ if (nearlyActive === true) return "low";
3415
+ if (nearlyActive === false) return "ok";
3416
+ return null;
3417
+ }
3418
+ function programKey(raw) {
3419
+ return stringOf(raw);
3420
+ }
3421
+ /** Active events (raised right now) as feature keys. */
3422
+ function activeEventsOf(view) {
3423
+ return (view.description?.entities() ?? []).filter((e) => e.kind === "event" && eventActive(valueOf(view, e)) === true);
3424
+ }
3425
+ function optionBool(view, shortNames) {
3426
+ return boolOf(valueOf(view, findEntity(view.description, ["option", "setting"], shortNames)));
3427
+ }
3428
+ /** Programs the appliance currently offers, as feature keys. */
3429
+ function availableProgramKeys(description) {
3430
+ return description.programs().filter((p) => description.entityByName(p.uid)?.available !== false).map((p) => p.uid);
3431
+ }
3432
+ var COLD_DISHWASHER_STATUS = {
3433
+ availability: "offline",
3434
+ state: "unavailable",
3435
+ operationState: null,
3436
+ program: null,
3437
+ selectedProgram: null,
3438
+ availablePrograms: [],
3439
+ remainingSeconds: null,
3440
+ delayedStartSeconds: null,
3441
+ progressPercent: null,
3442
+ doorOpen: null,
3443
+ doorLocked: null,
3444
+ remoteStartAllowed: null,
3445
+ remoteControlActive: null,
3446
+ localControlActive: null,
3447
+ powerOn: null,
3448
+ powerState: null,
3449
+ saltLevel: null,
3450
+ rinseAidLevel: null,
3451
+ halfLoad: null,
3452
+ hygienePlus: null,
3453
+ extraDry: null,
3454
+ intensiveZone: null,
3455
+ varioSpeedPlus: null,
3456
+ childLock: null,
3457
+ activeEvents: [],
3458
+ errorCode: null,
3459
+ errorLabel: null,
3460
+ lastFetchedAt: 0
3461
+ };
3462
+ /**
3463
+ * Map the decoded Home Connect values → {@link DishwasherStatus}. Pure — the only
3464
+ * place the dishwasher slice is assembled. A field the appliance has not reported
3465
+ * (or that the description does not list) is `null`; nothing is defaulted to
3466
+ * `false`, `0` or `Ready`.
3467
+ */
3468
+ function dishwasherStatusFromFeatures(input) {
3469
+ const availability = availabilityOf(input);
3470
+ const description = input.description;
3471
+ if (availability !== "available" || description === null) return {
3472
+ ...COLD_DISHWASHER_STATUS,
3473
+ availability,
3474
+ availablePrograms: description !== null ? [...availableProgramKeys(description)] : [],
3475
+ lastFetchedAt: input.lastValueAt
3476
+ };
3477
+ const op = operationStateOf(valueOf(input, findEntity(description, ["status"], ["OperationState"])));
3478
+ const doorRaw = stringOf(valueOf(input, findEntity(description, ["status"], ["DoorState"])));
3479
+ const powerRaw = stringOf(valueOf(input, findEntity(description, ["setting"], ["PowerState"])));
3480
+ const events = activeEventsOf(input);
3481
+ const alert = events.find((e) => e.level === "alert" || e.level === "critical") ?? events[0];
3482
+ const state = op === null ? "unavailable" : OPERATION_TO_STATE[op];
3483
+ const remaining = valueOf(input, findEntity(description, ["option"], ["RemainingProgramTime"]));
3484
+ const delayed = valueOf(input, findEntity(description, ["option"], ["StartInRelative"]));
3485
+ const progress = numberOf(valueOf(input, findEntity(description, ["option"], ["ProgramProgress"])));
3486
+ const activeEntity = description.entityOfKind("activeProgram");
3487
+ const selectedEntity = description.entityOfKind("selectedProgram");
3488
+ return {
3489
+ availability,
3490
+ state,
3491
+ operationState: op,
3492
+ program: programKey(valueOf(input, activeEntity)),
3493
+ selectedProgram: programKey(valueOf(input, selectedEntity)),
3494
+ availablePrograms: [...availableProgramKeys(description)],
3495
+ remainingSeconds: secondsFrom(remaining),
3496
+ delayedStartSeconds: secondsFrom(delayed),
3497
+ progressPercent: progress !== null && progress >= 0 && progress <= 100 ? progress : null,
3498
+ doorOpen: doorRaw === null ? null : doorRaw === "Open",
3499
+ doorLocked: doorRaw === null ? null : doorRaw === "Locked",
3500
+ remoteStartAllowed: boolOf(valueOf(input, findEntity(description, ["status"], ["RemoteControlStartAllowed"]))),
3501
+ remoteControlActive: boolOf(valueOf(input, findEntity(description, ["status"], ["RemoteControlActive"]))),
3502
+ localControlActive: boolOf(valueOf(input, findEntity(description, ["status"], ["LocalControlActive"]))),
3503
+ powerOn: powerRaw === null ? null : powerRaw === "On",
3504
+ powerState: powerRaw,
3505
+ saltLevel: consumableFromEvents(input, ["SaltNearlyEmpty"], ["SaltEmpty"]),
3506
+ rinseAidLevel: consumableFromEvents(input, ["RinseAidNearlyEmpty"], ["RinseAidEmpty"]),
3507
+ halfLoad: optionBool(input, ["HalfLoad"]),
3508
+ hygienePlus: optionBool(input, ["HygienePlus"]),
3509
+ extraDry: optionBool(input, ["ExtraDry"]),
3510
+ intensiveZone: optionBool(input, ["IntensivZone", "IntensiveZone"]),
3511
+ varioSpeedPlus: optionBool(input, ["VarioSpeedPlus", "VarioSpeed"]),
3512
+ childLock: boolOf(valueOf(input, findEntity(description, ["setting"], ["ChildLock"]))),
3513
+ activeEvents: events.map((e) => e.name),
3514
+ errorCode: null,
3515
+ errorLabel: op === "Error" ? alert?.name ?? "Error" : null,
3516
+ lastFetchedAt: input.lastValueAt
3517
+ };
3518
+ }
3519
+ function primitiveOrNull(raw) {
3520
+ return typeof raw === "boolean" || typeof raw === "string" || typeof raw === "number" ? raw : null;
3521
+ }
3522
+ function descriptorType(entity, current, fallback) {
3523
+ if (entity.enumeration !== void 0) return "enum";
3524
+ if (entity.valueType === "number") return "number";
3525
+ if (entity.valueType === "boolean") return "boolean";
3526
+ for (const sample of [current, fallback]) {
3527
+ if (typeof sample === "boolean") return "boolean";
3528
+ if (typeof sample === "number") return "number";
3529
+ if (typeof sample === "string") return "string";
3530
+ }
3531
+ return entity.min !== void 0 || entity.max !== void 0 ? "number" : "boolean";
3532
+ }
3533
+ function descriptorOf(entity, features, override) {
3534
+ const defaultValue = override?.defaultValue ?? entity.defaultValue;
3535
+ const type = descriptorType(entity, features.get(entity.name), defaultValue);
3536
+ return {
3537
+ key: entity.name,
3538
+ name: featureShortName(entity.name),
3539
+ type,
3540
+ access: override?.access ?? entity.access ?? "read",
3541
+ available: (override?.available ?? entity.available) !== false,
3542
+ unit: type === "number" ? resolveUnit(entity) : null,
3543
+ min: entity.min ?? null,
3544
+ max: entity.max ?? null,
3545
+ step: entity.stepSize ?? null,
3546
+ enumValues: entity.enumeration !== void 0 ? Object.values(entity.enumeration) : null,
3547
+ defaultValue: primitiveOrNull(defaultValue)
3548
+ };
3549
+ }
3550
+ function commandAvailable(description, short) {
3551
+ return findEntity(description, ["command"], [short])?.available === true;
3552
+ }
3553
+ /** The honest descriptor of the appliance: programs with their options, every option / setting, the commands it takes. */
3554
+ function dishwasherOptionsFromDescription(input) {
3555
+ const availability = availabilityOf(input);
3556
+ const description = input.description;
3557
+ if (description === null || description.state === "missing") return {
3558
+ availability,
3559
+ supportsStart: false,
3560
+ supportsPause: false,
3561
+ supportsResume: false,
3562
+ supportsStop: false,
3563
+ supportsPower: false,
3564
+ programs: [],
3565
+ options: []
3566
+ };
3567
+ const settable = description.entities().filter((e) => e.kind === "option" || e.kind === "setting");
3568
+ const power = findEntity(description, ["setting"], ["PowerState"]);
3569
+ const programs = description.programs().map((program) => {
3570
+ const entity = description.entityByName(program.uid);
3571
+ return {
3572
+ key: program.uid,
3573
+ name: program.name,
3574
+ available: entity?.available !== false,
3575
+ options: (program.options ?? []).flatMap((ref) => {
3576
+ const option = description.entityByName(ref.key);
3577
+ return option === void 0 ? [] : [descriptorOf(option, input.features, {
3578
+ ...ref.access !== void 0 ? { access: ref.access } : {},
3579
+ ...ref.available !== void 0 ? { available: ref.available } : {},
3580
+ ...ref.defaultValue !== void 0 ? { defaultValue: ref.defaultValue } : {}
3581
+ })];
3582
+ })
3583
+ };
3584
+ });
3585
+ return {
3586
+ availability,
3587
+ supportsStart: description.entityOfKind("activeProgram") !== void 0,
3588
+ supportsPause: commandAvailable(description, "PauseProgram"),
3589
+ supportsResume: commandAvailable(description, "ResumeProgram"),
3590
+ supportsStop: commandAvailable(description, "AbortProgram"),
3591
+ supportsPower: power !== void 0 && (power.access === "readWrite" || power.access === "writeOnly"),
3592
+ programs,
3593
+ options: settable.map((e) => descriptorOf(e, input.features))
3594
+ };
3595
+ }
3596
+ /** Command entities by their short name — what pause / resume / stop write. */
3597
+ var HC_COMMAND_PAUSE = "PauseProgram";
3598
+ var HC_COMMAND_RESUME = "ResumeProgram";
3599
+ var HC_COMMAND_STOP = "AbortProgram";
3600
+ //#endregion
3601
+ //#region src/home-connect/home-connect-child-bindings.ts
3602
+ /** The capability a bound child registers. */
3603
+ var HomeConnectChildCapSchema = require_dist._enum([
3604
+ "numeric-sensor",
3605
+ "power-meter",
3606
+ "enum-sensor",
3607
+ "binary"
3608
+ ]);
3609
+ /** How the raw value becomes the slice value. */
3610
+ var HomeConnectChildConvertSchema = require_dist._enum([
3611
+ "none",
3612
+ "energy-total-kwh",
3613
+ "event-active"
3614
+ ]);
3615
+ /**
3616
+ * One appliance entity exposed as one child device. Persisted on the container, so a
3617
+ * child's identity (`stableIdSuffix`) survives restarts and description reloads.
3618
+ */
3619
+ var HomeConnectChildBindingSchema = require_dist.object({
3620
+ /** Stable, derived from the feature key — never from a uid, which differs per model. */
3621
+ stableIdSuffix: require_dist.string().min(1),
3622
+ /** Feature key the child reads (`BSH.Common.Status.DoorState`, `HomeConnect.Network.rssi`). */
3623
+ key: require_dist.string().min(1),
3624
+ cap: HomeConnectChildCapSchema,
3625
+ convert: HomeConnectChildConvertSchema,
3626
+ role: require_dist._enum(require_dist.DeviceRole),
3627
+ /** Display name, without the appliance label. */
3628
+ name: require_dist.string().min(1),
3629
+ /** Unit of an info field the description does not declare (`dBm` for a signal strength). */
3630
+ unit: require_dist.string().min(1).optional()
3631
+ });
3632
+ function slugFeatureKey(key) {
3633
+ return key.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3634
+ }
3635
+ /** `OperationState` -> `Operation state`. */
3636
+ function humanizeFeatureName(short) {
3637
+ const spaced = short.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/[-_]+/g, " ").trim();
3638
+ return spaced.length === 0 ? short : spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
3639
+ }
3640
+ var FRIENDLY_NAMES = {
3641
+ StartInRelative: "Delayed start",
3642
+ RemoteControlActive: "Remote control active",
3643
+ RemoteControlStartAllowed: "Remote start allowed",
3644
+ BackendConnected: "Cloud connection",
3645
+ CloudConnected: "Cloud connection"
3646
+ };
3647
+ /** Whether an entity is already carried whole by the dishwasher device and so is not a child. */
3648
+ var NOT_A_CHILD_KINDS = new Set([
3649
+ "command",
3650
+ "program",
3651
+ "activeProgram",
3652
+ "selectedProgram"
3653
+ ]);
3654
+ /** What an entity's values look like — from its description first, its current value second. */
3655
+ function valueClassOf(entity, current) {
3656
+ if (entity.kind === "event") return "binary";
3657
+ if (entity.enumeration !== void 0 || entity.valueType === "enum") return "enum";
3658
+ if (entity.valueType === "number") return "numeric";
3659
+ if (entity.valueType === "boolean") return "binary";
3660
+ if (typeof current === "boolean") return "binary";
3661
+ if (typeof current === "number") return "numeric";
3662
+ if (typeof current === "string") return "enum";
3663
+ return null;
3664
+ }
3665
+ function roleFor(entity, cls, unit) {
3666
+ if (cls === "enum") return require_dist.DeviceRole.EnumSensor;
3667
+ if (cls === "binary") return entity.name.endsWith("Connected") ? require_dist.DeviceRole.ConnectivitySensor : require_dist.DeviceRole.BinarySensor;
3668
+ if (unit === "°C" || unit === "°F") return require_dist.DeviceRole.TemperatureSensor;
3669
+ return require_dist.DeviceRole.NumericSensor;
3670
+ }
3671
+ function isCumulativeEnergy(entity) {
3672
+ return featureShortName(entity.name) === "EnergyConsumption" && entity.name.includes(".Statistic.");
3673
+ }
3674
+ /** A consumption entity is either the running total or the figure of one cycle — the name says which. */
3675
+ function nameFor(entity, short) {
3676
+ if (short === "EnergyConsumption" || short === "WaterConsumption") {
3677
+ const what = short === "EnergyConsumption" ? "Energy used" : "Water used";
3678
+ return entity.name.includes(".Statistic.") ? `${what} (total)` : `${what} (cycle)`;
3679
+ }
3680
+ return FRIENDLY_NAMES[short] ?? humanizeFeatureName(short);
3681
+ }
3682
+ function bindingForEntity(entity, current) {
3683
+ if (NOT_A_CHILD_KINDS.has(entity.kind)) return null;
3684
+ const cls = valueClassOf(entity, current);
3685
+ if (cls === null) return null;
3686
+ const short = featureShortName(entity.name);
3687
+ const unit = cls === "numeric" ? resolveUnit(entity) : null;
3688
+ const name = nameFor(entity, short);
3689
+ const base = {
3690
+ stableIdSuffix: slugFeatureKey(entity.name),
3691
+ key: entity.name,
3692
+ name
3693
+ };
3694
+ if (cls === "binary") return {
3695
+ ...base,
3696
+ cap: "binary",
3697
+ convert: entity.kind === "event" ? "event-active" : "none",
3698
+ role: roleFor(entity, cls, unit)
3699
+ };
3700
+ if (cls === "enum") return {
3701
+ ...base,
3702
+ cap: "enum-sensor",
3703
+ convert: "none",
3704
+ role: roleFor(entity, cls, unit)
3705
+ };
3706
+ if (isCumulativeEnergy(entity) && isEnergyUnit(unit)) return {
3707
+ ...base,
3708
+ name: "Energy total",
3709
+ cap: "power-meter",
3710
+ convert: "energy-total-kwh",
3711
+ role: require_dist.DeviceRole.EnergySensor
3712
+ };
3713
+ return {
3714
+ ...base,
3715
+ cap: "numeric-sensor",
3716
+ convert: "none",
3717
+ role: roleFor(entity, cls, unit)
3718
+ };
3719
+ }
3720
+ function infoLabel(key) {
3721
+ const isNetwork = key.startsWith(HOME_CONNECT_NETWORK_PREFIX);
3722
+ const field = key.slice((isNetwork ? HOME_CONNECT_NETWORK_PREFIX : HOME_CONNECT_INFO_PREFIX).length);
3723
+ return `${isNetwork ? "Network" : "Appliance"} ${humanizeFeatureName(field).toLowerCase()}`;
3724
+ }
3725
+ /** Info fields whose unit is part of the Home Connect interface (a Wi-Fi signal is dBm). */
3726
+ var INFO_UNITS = { rssi: "dBm" };
3727
+ function bindingForInfo(key, current) {
3728
+ const base = {
3729
+ stableIdSuffix: slugFeatureKey(key),
3730
+ key,
3731
+ name: infoLabel(key)
3732
+ };
3733
+ if (typeof current === "number") {
3734
+ const unit = INFO_UNITS[key.split(".").pop() ?? ""];
3735
+ return {
3736
+ ...base,
3737
+ cap: "numeric-sensor",
3738
+ convert: "none",
3739
+ role: require_dist.DeviceRole.NumericSensor,
3740
+ ...unit !== void 0 ? { unit } : {}
3741
+ };
3742
+ }
3743
+ if (typeof current === "boolean") return {
3744
+ ...base,
3745
+ cap: "binary",
3746
+ convert: "none",
3747
+ role: require_dist.DeviceRole.BinarySensor
3748
+ };
3749
+ if (typeof current === "string") return {
3750
+ ...base,
3751
+ cap: "enum-sensor",
3752
+ convert: "none",
3753
+ role: require_dist.DeviceRole.EnumSensor
3754
+ };
3755
+ return null;
3756
+ }
3757
+ /**
3758
+ * Every entity of the appliance's description, plus the `/ci` and `/ni` fields it
3759
+ * sent, as child bindings. Nothing the appliance declares is dropped for not being
3760
+ * in a catalog: a friendlier name is all a catalog adds.
3761
+ *
3762
+ * An entity whose value type cannot be told from its description is bound once a
3763
+ * value shows it; until then it is absent (not guessed).
3764
+ */
3765
+ function deriveChildBindings(input) {
3766
+ const out = /* @__PURE__ */ new Map();
3767
+ for (const entity of input.description.entities()) {
3768
+ const binding = bindingForEntity(entity, input.features.get(entity.name));
3769
+ if (binding !== null) out.set(binding.stableIdSuffix, binding);
3770
+ }
3771
+ for (const [key, value] of input.features) {
3772
+ if (!key.startsWith("HomeConnect.Info.") && !key.startsWith("HomeConnect.Network.")) continue;
3773
+ const binding = bindingForInfo(key, value);
3774
+ if (binding !== null) out.set(binding.stableIdSuffix, binding);
3775
+ }
3776
+ return [...out.values()].toSorted((a, b) => a.stableIdSuffix.localeCompare(b.stableIdSuffix));
3777
+ }
3778
+ /**
3779
+ * The slice a child publishes for one raw value, or `null` when the value is not
3780
+ * known / not usable — in which case the child publishes NOTHING (it goes offline),
3781
+ * rather than a `0` / `false` / `unknown` that reads as a measurement.
3782
+ */
3783
+ function childSliceFromValue(input) {
3784
+ const { binding, raw, nowMs } = input;
3785
+ if (raw === void 0 || raw === null) return null;
3786
+ switch (binding.cap) {
3787
+ case "binary": {
3788
+ const on = binding.convert === "event-active" ? eventActive(raw) : typeof raw === "boolean" ? raw : null;
3789
+ return on === null ? null : {
3790
+ cap: "binary",
3791
+ status: {
3792
+ on,
3793
+ lastChangedAt: nowMs
3794
+ }
3795
+ };
3796
+ }
3797
+ case "enum-sensor": return typeof raw === "string" || typeof raw === "number" ? {
3798
+ cap: "enum-sensor",
3799
+ status: {
3800
+ value: String(raw),
3801
+ lastFetchedAt: nowMs
3802
+ }
3803
+ } : null;
3804
+ case "power-meter": {
3805
+ if (typeof raw !== "number") return null;
3806
+ const kwh = energyToKwh(raw, input.entity === void 0 ? null : resolveUnit(input.entity));
3807
+ return kwh === null ? null : {
3808
+ cap: "power-meter",
3809
+ status: {
3810
+ kwhTotal: kwh,
3811
+ unit: "kWh",
3812
+ lastFetchedAt: nowMs
3813
+ }
3814
+ };
3815
+ }
3816
+ case "numeric-sensor": {
3817
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return null;
3818
+ const unit = input.entity === void 0 ? binding.unit ?? null : resolveUnit(input.entity);
3819
+ return {
3820
+ cap: "numeric-sensor",
3821
+ status: {
3822
+ value: raw,
3823
+ ...unit !== null ? { unit } : {},
3824
+ lastFetchedAt: nowMs
3825
+ }
3826
+ };
3827
+ }
3828
+ }
3829
+ }
3830
+ var LEGACY_CHILD_KEYS = {
3831
+ "energy-forecast": {
3832
+ key: "BSH.Common.Option.EnergyForecast",
3833
+ cap: "numeric-sensor",
3834
+ role: require_dist.DeviceRole.NumericSensor
3835
+ },
3836
+ "water-forecast": {
3837
+ key: "BSH.Common.Option.WaterForecast",
3838
+ cap: "numeric-sensor",
3839
+ role: require_dist.DeviceRole.NumericSensor
3840
+ },
3841
+ "energy-consumed": {
3842
+ key: "BSH.Common.Option.EnergyConsumption",
3843
+ cap: "numeric-sensor",
3844
+ role: require_dist.DeviceRole.NumericSensor
3845
+ },
3846
+ "water-consumed": {
3847
+ key: "BSH.Common.Option.WaterConsumption",
3848
+ cap: "numeric-sensor",
3849
+ role: require_dist.DeviceRole.NumericSensor
3850
+ },
3851
+ "remote-control-level": {
3852
+ key: "BSH.Common.Status.RemoteControlLevel",
3853
+ cap: "enum-sensor",
3854
+ role: require_dist.DeviceRole.EnumSensor
3855
+ },
3856
+ "delayed-start": {
3857
+ key: "BSH.Common.Option.StartInRelative",
3858
+ cap: "numeric-sensor",
3859
+ role: require_dist.DeviceRole.NumericSensor
3860
+ },
3861
+ "interior-light": {
3862
+ key: "BSH.Common.Setting.AmbientLightEnabled",
3863
+ cap: "binary",
3864
+ role: require_dist.DeviceRole.BinarySensor
3865
+ },
3866
+ connectivity: {
3867
+ key: "BSH.Common.Status.BackendConnected",
3868
+ cap: "binary",
3869
+ role: require_dist.DeviceRole.ConnectivitySensor
3870
+ },
3871
+ "program-phase": {
3872
+ key: "Dishcare.Dishwasher.Status.ProgramPhase",
3873
+ cap: "enum-sensor",
3874
+ role: require_dist.DeviceRole.EnumSensor
3875
+ }
1233
3876
  };
1234
- function mapConsumable(raw) {
1235
- if (raw === "Full" || raw === "OK" || raw === "Ok") return "ok";
1236
- if (raw === "Low" || raw === "NearlyEmpty") return "low";
1237
- if (raw === "Empty") return "empty";
1238
- return null;
3877
+ /**
3878
+ * The bindings that keep a container's EXISTING children alive until its description has been
3879
+ * read once: a container saved with the old fixed `children` list has no `entities` yet, and a
3880
+ * spec without those children would make the kernel delete them (removal by absence) before any
3881
+ * replacement exists. They keep their stable suffix; the first successful derive replaces them.
3882
+ */
3883
+ function legacyChildBindings(children) {
3884
+ if (!Array.isArray(children)) return [];
3885
+ const out = [];
3886
+ for (const row of children) {
3887
+ if (typeof row !== "object" || row === null) continue;
3888
+ const suffix = Reflect.get(row, "stableIdSuffix");
3889
+ const name = Reflect.get(row, "name");
3890
+ if (typeof suffix !== "string") continue;
3891
+ const known = LEGACY_CHILD_KEYS[suffix];
3892
+ if (known === void 0) continue;
3893
+ out.push({
3894
+ stableIdSuffix: suffix,
3895
+ key: known.key,
3896
+ cap: known.cap,
3897
+ convert: "none",
3898
+ role: known.role,
3899
+ name: typeof name === "string" && name.length > 0 ? name : suffix
3900
+ });
3901
+ }
3902
+ return out;
1239
3903
  }
1240
- function readBool(raw) {
1241
- return raw === true || raw === "On" || raw === "Open" || raw === "Connected";
3904
+ function isSyntheticKey(key) {
3905
+ return key.startsWith("HomeConnect.Info.") || key.startsWith("HomeConnect.Network.");
1242
3906
  }
1243
- function readString(raw) {
1244
- return typeof raw === "string" && raw.length > 0 ? raw : null;
3907
+ /**
3908
+ * The bindings a container should carry: what the description derives now, plus what it
3909
+ * already had — unless the entity left the description. A value that is unknown right now
3910
+ * (the link dropped) never removes a child; only the appliance dropping the entity does.
3911
+ */
3912
+ function mergeChildBindings(persisted, derived, description) {
3913
+ const out = /* @__PURE__ */ new Map();
3914
+ for (const kept of persisted) if (isSyntheticKey(kept.key) || description.entityByName(kept.key) !== void 0) out.set(kept.stableIdSuffix, kept);
3915
+ for (const binding of derived) out.set(binding.stableIdSuffix, binding);
3916
+ return [...out.values()].toSorted((a, b) => a.stableIdSuffix.localeCompare(b.stableIdSuffix));
1245
3917
  }
1246
- function readNumber(raw) {
1247
- if (typeof raw === "number" && Number.isFinite(raw)) return raw;
1248
- if (typeof raw === "string") {
1249
- const n = Number(raw);
1250
- return Number.isFinite(n) ? n : null;
3918
+ /**
3919
+ * Keeps each adopted appliance's child devices equal to what its description declares.
3920
+ * Runs on the addon, outside the container (rebuilding a container from inside itself
3921
+ * would tear down the code that asked for it).
3922
+ */
3923
+ var HomeConnectChildReconciler = class {
3924
+ #deps;
3925
+ #tracked = /* @__PURE__ */ new Map();
3926
+ #stopped = false;
3927
+ constructor(deps) {
3928
+ this.#deps = deps;
1251
3929
  }
1252
- return null;
3930
+ track(appliance) {
3931
+ if (this.#stopped || this.#tracked.has(appliance.deviceId)) return;
3932
+ const entry = {
3933
+ a: appliance,
3934
+ timer: null,
3935
+ unwatch: this.#deps.watch(appliance.brokerId, appliance.haId, () => this.schedule(appliance.deviceId))
3936
+ };
3937
+ this.#tracked.set(appliance.deviceId, entry);
3938
+ this.schedule(appliance.deviceId);
3939
+ }
3940
+ untrack(deviceId) {
3941
+ const entry = this.#tracked.get(deviceId);
3942
+ if (!entry) return;
3943
+ if (entry.timer !== null) clearTimeout(entry.timer);
3944
+ entry.unwatch();
3945
+ this.#tracked.delete(deviceId);
3946
+ }
3947
+ stop() {
3948
+ this.#stopped = true;
3949
+ for (const id of this.#tracked.keys()) this.untrack(id);
3950
+ }
3951
+ tracks(deviceId) {
3952
+ return this.#tracked.has(deviceId);
3953
+ }
3954
+ schedule(deviceId) {
3955
+ const entry = this.#tracked.get(deviceId);
3956
+ if (!entry) return;
3957
+ if (entry.timer !== null) clearTimeout(entry.timer);
3958
+ entry.timer = setTimeout(() => {
3959
+ entry.timer = null;
3960
+ this.reconcile(deviceId);
3961
+ }, this.#deps.debounceMs ?? 3e3);
3962
+ }
3963
+ /** One pass for one appliance; exposed for the adoption path and tests. */
3964
+ async reconcile(deviceId) {
3965
+ const entry = this.#tracked.get(deviceId);
3966
+ if (!entry) return;
3967
+ const { brokerId, haId } = entry.a;
3968
+ const client = this.#deps.getClient(brokerId);
3969
+ const description = client?.getDescription(haId) ?? null;
3970
+ if (client === null || client.getLinkState(haId) !== "connected") return;
3971
+ if (description === null || description.state === "missing") return;
3972
+ try {
3973
+ const { bindings: persisted, malformed } = await this.#deps.readPersisted(deviceId);
3974
+ if (malformed > 0) {
3975
+ this.#deps.logger.warn("Home Connect child reconcile skipped — persisted rows are unreadable", {
3976
+ tags: { deviceId },
3977
+ meta: { malformed }
3978
+ });
3979
+ return;
3980
+ }
3981
+ const merged = mergeChildBindings(persisted, deriveChildBindings({
3982
+ description,
3983
+ features: client.getFeatures(haId)
3984
+ }), description);
3985
+ if (JSON.stringify(merged) === JSON.stringify(sorted(persisted))) return;
3986
+ await this.#deps.applyEntities(deviceId, merged);
3987
+ this.#deps.logger.info("Home Connect children re-derived from the appliance description", {
3988
+ tags: { deviceId },
3989
+ meta: {
3990
+ before: persisted.length,
3991
+ after: merged.length
3992
+ }
3993
+ });
3994
+ } catch (err) {
3995
+ this.#deps.logger.warn("Home Connect child reconcile failed", {
3996
+ tags: { deviceId },
3997
+ meta: { error: require_dist.errMsg(err) }
3998
+ });
3999
+ }
4000
+ }
4001
+ };
4002
+ function sorted(list) {
4003
+ return list.toSorted((a, b) => a.stableIdSuffix.localeCompare(b.stableIdSuffix));
1253
4004
  }
1254
4005
  /**
1255
- * Map BSH.Common.* feature values → {@link DishwasherStatus}. Pure — the only
1256
- * place dishwasher slice assembly happens for Home Connect local.
4006
+ * Parse a container's persisted `entities`. A row that does not parse is COUNTED, not dropped
4007
+ * silently: the caller must not write a list that would lose it.
1257
4008
  */
1258
- function dishwasherStatusFromFeatures(input) {
1259
- const { features, availablePrograms, nowMs } = input;
1260
- const op = readString(features.get("BSH.Common.Status.OperationState")) ?? "Ready";
1261
- const state = OPERATION_TO_STATE[op] ?? "unavailable";
1262
- const program = readString(features.get(HC_FEATURE_ACTIVE_PROGRAM));
1263
- let remainingMinutes = readNumber(features.get(HC_FEATURE_REMAINING));
1264
- if (remainingMinutes !== null && remainingMinutes > 600) remainingMinutes = Math.round(remainingMinutes / 60);
1265
- const progressPercent = readNumber(features.get(HC_FEATURE_PROGRESS));
1266
- const doorOpen = features.get(HC_FEATURE_DOOR) === "Open";
1267
- const remoteStartAllowed = readBool(features.get(HC_FEATURE_REMOTE_START));
1268
- const powerOn = features.get(HC_FEATURE_POWER) !== "Off";
4009
+ function parsePersistedBindings(raw) {
4010
+ if (!Array.isArray(raw)) return {
4011
+ bindings: [],
4012
+ malformed: 0
4013
+ };
4014
+ const bindings = [];
4015
+ let malformed = 0;
4016
+ for (const row of raw) {
4017
+ const parsed = HomeConnectChildBindingSchema.safeParse(row);
4018
+ if (parsed.success) bindings.push(parsed.data);
4019
+ else malformed += 1;
4020
+ }
1269
4021
  return {
1270
- state,
1271
- program,
1272
- availablePrograms: [...availablePrograms],
1273
- remainingMinutes,
1274
- progressPercent,
1275
- doorOpen,
1276
- remoteStartAllowed,
1277
- powerOn,
1278
- saltLevel: mapConsumable(features.get(HC_FEATURE_SALT)),
1279
- rinseAidLevel: mapConsumable(features.get(HC_FEATURE_RINSE)),
1280
- halfLoad: readBool(features.get(HC_FEATURE_HALF_LOAD)),
1281
- hygienePlus: readBool(features.get(HC_FEATURE_HYGIENE)),
1282
- errorCode: state === "error" ? 1 : null,
1283
- errorLabel: state === "error" ? op : null,
1284
- lastFetchedAt: nowMs
4022
+ bindings,
4023
+ malformed
1285
4024
  };
1286
4025
  }
1287
- /** Command resource keys for WS POST-style mutations. */
1288
- var HC_CMD_START = "BSH.Common.Command.StartProgram";
1289
- var HC_CMD_PAUSE = "BSH.Common.Command.PauseProgram";
1290
- var HC_CMD_STOP = "BSH.Common.Command.AbortProgram";
1291
- var HC_CMD_SET_PROGRAM = "BSH.Common.Setting.Program.Active";
1292
- var HC_CMD_SET_POWER = "BSH.Common.Setting.PowerState";
4026
+ //#endregion
4027
+ //#region src/home-connect/home-connect-option-validation.ts
4028
+ /** A step grid tolerance: `0.1 + 0.2`-style float noise must not reject a valid step. */
4029
+ var STEP_EPSILON = 1e-9;
4030
+ /**
4031
+ * Check a write against what the appliance's own description allows, and answer
4032
+ * the feature key to write. Throws an error that says WHY — a value the appliance
4033
+ * would refuse (or worse, clamp) never leaves this process unexplained.
4034
+ *
4035
+ * The description is the authority: the entity must be listed, a setting or option,
4036
+ * writable, available now, and the value inside its declared range / on its step /
4037
+ * among its members.
4038
+ */
4039
+ function assertWritable(description, key, value) {
4040
+ if (description === null || description.state === "missing") throw new Error(`Cannot set ${key}: the appliance description is not loaded`);
4041
+ const entity = description.entityByName(key);
4042
+ if (entity === void 0) throw new Error(`${key} is not in this appliance's description`);
4043
+ if (entity.kind !== "option" && entity.kind !== "setting") throw new Error(`${key} is a ${entity.kind}, not a setting or option`);
4044
+ if (entity.access !== "readWrite" && entity.access !== "writeOnly") throw new Error(`${key} is not writable (access: ${entity.access ?? "unknown"})`);
4045
+ if (entity.available === false) throw new Error(`${key} is not available right now`);
4046
+ if (entity.enumeration !== void 0) {
4047
+ const members = Object.values(entity.enumeration);
4048
+ if (typeof value !== "string" || !members.includes(value)) throw new Error(`${key} accepts ${members.join(" | ")}, not ${String(value)}`);
4049
+ return key;
4050
+ }
4051
+ if (typeof value === "number") assertNumberInRange(key, value, entity.min, entity.max, entity.stepSize);
4052
+ else if (entity.valueType === "number") throw new Error(`${key} takes a number, not ${typeof value}`);
4053
+ else if (entity.valueType === "boolean" && typeof value !== "boolean") throw new Error(`${key} takes true or false, not ${typeof value}`);
4054
+ return key;
4055
+ }
4056
+ function assertNumberInRange(key, value, min, max, step) {
4057
+ if (!Number.isFinite(value)) throw new Error(`${key} needs a finite number`);
4058
+ if (min !== void 0 && value < min) throw new Error(`${key} must be at least ${min}`);
4059
+ if (max !== void 0 && value > max) throw new Error(`${key} must be at most ${max}`);
4060
+ if (step !== void 0 && step > 0) {
4061
+ const origin = min ?? 0;
4062
+ const steps = (value - origin) / step;
4063
+ if (Math.abs(steps - Math.round(steps)) > STEP_EPSILON) throw new Error(`${key} must be a multiple of ${step}${origin !== 0 ? ` from ${origin}` : ""}`);
4064
+ }
4065
+ }
4066
+ /** The program must be listed and currently offered. */
4067
+ function assertProgramAvailable(description, program) {
4068
+ if (description === null || description.state === "missing") throw new Error("Cannot pick a program: the appliance description is not loaded");
4069
+ if (!description.programs().some((p) => p.uid === program)) throw new Error(`${program} is not a program of this appliance`);
4070
+ if (description.entityByName(program)?.available === false) throw new Error(`${program} is not available right now`);
4071
+ }
4072
+ /**
4073
+ * A program started with options: each must be one the program lists, offered in
4074
+ * that program (not marked unavailable there), and valid as a write.
4075
+ */
4076
+ function assertProgramOptions(description, program, options) {
4077
+ const entries = Object.entries(options);
4078
+ if (entries.length === 0) return;
4079
+ const listed = description?.programs().find((p) => p.uid === program)?.options ?? [];
4080
+ for (const [key, value] of entries) {
4081
+ const ref = listed.find((o) => o.key === key);
4082
+ if (ref === void 0) throw new Error(`${key} is not an option of ${program}`);
4083
+ if (ref.available === false || ref.access === "read" || ref.access === "readStatic") throw new Error(`${key} cannot be set for ${program}`);
4084
+ assertWritable(description, key, value);
4085
+ }
4086
+ }
1293
4087
  //#endregion
1294
4088
  //#region src/home-connect/devices/home-connect-dishwasher-device.ts
1295
4089
  var homeConnectDishwasherSchema = require_dist.object({
1296
4090
  haId: require_dist.string().min(1),
1297
4091
  brokerId: require_dist.string().min(1),
1298
4092
  applianceType: require_dist.string().optional(),
1299
- availablePrograms: require_dist.array(require_dist.string()).default([]),
1300
4093
  system: require_dist.literal("home-connect").optional(),
1301
4094
  integrationId: require_dist.string().optional()
1302
4095
  });
1303
4096
  var DISH_CAP = "dishwasher";
1304
- var COLD_START = {
1305
- state: "unavailable",
1306
- program: null,
1307
- availablePrograms: [],
1308
- remainingMinutes: null,
1309
- progressPercent: null,
1310
- doorOpen: false,
1311
- remoteStartAllowed: false,
1312
- powerOn: false,
1313
- saltLevel: null,
1314
- rinseAidLevel: null,
1315
- halfLoad: false,
1316
- hygienePlus: false,
1317
- errorCode: null,
1318
- errorLabel: null,
1319
- lastFetchedAt: 0
4097
+ /** The option short names of the boolean setters, in the spellings an appliance uses. */
4098
+ var OPTION_NAMES = {
4099
+ halfLoad: ["HalfLoad"],
4100
+ hygienePlus: ["HygienePlus"],
4101
+ extraDry: ["ExtraDry"],
4102
+ intensiveZone: ["IntensivZone", "IntensiveZone"],
4103
+ varioSpeedPlus: ["VarioSpeedPlus", "VarioSpeed"]
1320
4104
  };
1321
4105
  var HomeConnectDishwasherDevice = class extends require_dist.BaseDevice {
1322
4106
  features = [];
1323
4107
  #haId;
1324
4108
  #brokerId;
1325
- #availablePrograms;
1326
- #featureUnsub = null;
4109
+ #unwatch = null;
1327
4110
  constructor(ctx) {
1328
4111
  const persisted = homeConnectDishwasherSchema.parse(ctx.persistedConfig ?? {});
1329
- super(ctx, homeConnectDishwasherSchema, { type: ctx.deviceMeta.type });
4112
+ super(ctx, homeConnectDishwasherSchema, { type: require_dist.DeviceType.Dishwasher });
1330
4113
  this.#haId = persisted.haId;
1331
4114
  this.#brokerId = persisted.brokerId;
1332
- this.#availablePrograms = persisted.availablePrograms;
1333
- this.online = true;
1334
4115
  this.updateSourceInfo({
1335
4116
  id: this.#haId,
1336
4117
  system: "home-connect"
@@ -1339,94 +4120,539 @@ var HomeConnectDishwasherDevice = class extends require_dist.BaseDevice {
1339
4120
  async onActivate() {
1340
4121
  await super.onActivate();
1341
4122
  this.registerDishwasherCap();
1342
- this.runtimeState.setCapState(DISH_CAP, COLD_START);
1343
- this.attachFeatureListener();
1344
- this.recomputeSlice();
4123
+ this.runtimeState.setCapState(DISH_CAP, COLD_DISHWASHER_STATUS);
4124
+ this.markOnline(false);
4125
+ this.#unwatch = homeConnectConnections.watch(this.#brokerId, this.#haId, (event) => this.recomputeSlice(event));
4126
+ this.recomputeSlice({ kind: "client" });
1345
4127
  }
1346
4128
  async removeDevice() {
1347
- this.#featureUnsub?.();
1348
- this.#featureUnsub = null;
4129
+ this.#unwatch?.();
4130
+ this.#unwatch = null;
1349
4131
  await super.removeDevice();
1350
4132
  }
1351
- attachFeatureListener() {
1352
- const client = homeConnectConnections.getClient(this.#brokerId);
1353
- if (!client) return;
1354
- this.#featureUnsub = client.onFeatureChange((haId, _key, _value) => {
1355
- if (haId !== this.#haId) return;
1356
- this.recomputeSlice();
1357
- });
4133
+ client() {
4134
+ return homeConnectConnections.getClient(this.#brokerId);
1358
4135
  }
1359
- recomputeSlice() {
1360
- const client = homeConnectConnections.getClient(this.#brokerId);
1361
- if (!client) {
1362
- this.runtimeState.setCapState(DISH_CAP, {
1363
- ...COLD_START,
1364
- lastFetchedAt: Date.now()
1365
- });
1366
- return;
1367
- }
4136
+ view() {
4137
+ const client = this.client();
4138
+ return {
4139
+ features: client?.getFeatures(this.#haId) ?? /* @__PURE__ */ new Map(),
4140
+ description: client?.getDescription(this.#haId) ?? null,
4141
+ link: client?.getLinkState(this.#haId) ?? "disconnected"
4142
+ };
4143
+ }
4144
+ recomputeSlice(event) {
4145
+ const previous = this.runtimeState.getCapState(DISH_CAP);
4146
+ const lastValueAt = event.kind === "feature" && !event.key.startsWith("HomeConnect.Info.") && !event.key.startsWith("HomeConnect.Network.") ? Date.now() : previous?.lastFetchedAt ?? 0;
1368
4147
  const slice = dishwasherStatusFromFeatures({
1369
- features: client.getFeatures(this.#haId),
1370
- availablePrograms: this.#availablePrograms,
1371
- nowMs: Date.now()
4148
+ ...this.view(),
4149
+ lastValueAt
1372
4150
  });
1373
4151
  this.runtimeState.setCapState(DISH_CAP, slice);
4152
+ this.markOnline(slice.availability !== "offline");
1374
4153
  }
1375
4154
  requireClient() {
1376
- const client = homeConnectConnections.getClient(this.#brokerId);
1377
- if (!client) throw new Error(`Home Connect client unavailable (haId=${this.#haId})`);
4155
+ const client = this.client();
4156
+ if (!client) throw new Error("Home Connect client unavailable — the integration is not connected");
4157
+ if (client.getLinkState(this.#haId) !== "connected") throw new Error("The appliance is not connected right now");
1378
4158
  return client;
1379
4159
  }
4160
+ /** Write a command entity of the description (`PauseProgram`, …); refuses one it does not offer. */
4161
+ async command(shortName) {
4162
+ const client = this.requireClient();
4163
+ const entity = findEntity(client.getDescription(this.#haId), ["command"], [shortName]);
4164
+ if (entity === void 0 || entity.available !== true) throw new Error(`This appliance does not offer ${shortName} right now`);
4165
+ await this.logged(`command ${shortName}`, client.setFeature(this.#haId, entity.name, true));
4166
+ }
4167
+ async setOptionByShortName(shortNames, value) {
4168
+ const client = this.requireClient();
4169
+ const entity = findEntity(client.getDescription(this.#haId), ["option", "setting"], shortNames);
4170
+ if (entity === void 0) throw new Error(`This appliance has no ${shortNames.join(" / ")} option`);
4171
+ await this.writeKey(client, entity.name, value);
4172
+ }
4173
+ async writeKey(client, key, value) {
4174
+ const checked = assertWritable(client.getDescription(this.#haId), key, value);
4175
+ await this.logged(`set ${checked}`, client.setFeature(this.#haId, checked, value));
4176
+ }
4177
+ /** A write that fails says so on the device's own log line, then fails to the caller. */
4178
+ async logged(what, pending) {
4179
+ try {
4180
+ await pending;
4181
+ } catch (err) {
4182
+ this.ctx.logger.warn(`Home Connect ${what} failed`, {
4183
+ tags: { deviceId: this.id },
4184
+ meta: { error: require_dist.errMsg(err) }
4185
+ });
4186
+ throw err;
4187
+ }
4188
+ }
1380
4189
  registerDishwasherCap() {
1381
4190
  this.ctx.registerNativeCap(require_dist.dishwasherCapability, {
1382
- getStatus: async () => this.runtimeState.getCapState(DISH_CAP) ?? COLD_START,
1383
- start: async () => {
1384
- await this.requireClient().setFeature(this.#haId, HC_CMD_START, {});
1385
- },
1386
- pause: async () => {
1387
- await this.requireClient().setFeature(this.#haId, HC_CMD_PAUSE, {});
1388
- },
1389
- stop: async () => {
1390
- await this.requireClient().setFeature(this.#haId, HC_CMD_STOP, {});
4191
+ getStatus: async () => this.runtimeState.getCapState(DISH_CAP) ?? COLD_DISHWASHER_STATUS,
4192
+ getOptions: async () => dishwasherOptionsFromDescription(this.view()),
4193
+ start: async ({ program, options }) => {
4194
+ const client = this.requireClient();
4195
+ const description = client.getDescription(this.#haId);
4196
+ const target = program ?? this.runtimeState.getCapState(DISH_CAP)?.selectedProgram ?? null;
4197
+ if (target === null) throw new Error("No program is selected — pick one to start");
4198
+ assertProgramAvailable(description, target);
4199
+ assertProgramOptions(description, target, options ?? {});
4200
+ await this.logged(`start ${target}`, client.startProgram(this.#haId, target, options));
1391
4201
  },
4202
+ pause: async () => this.command(HC_COMMAND_PAUSE),
4203
+ resume: async () => this.command(HC_COMMAND_RESUME),
4204
+ stop: async () => this.command(HC_COMMAND_STOP),
1392
4205
  setProgram: async ({ program }) => {
1393
- await this.requireClient().setFeature(this.#haId, HC_CMD_SET_PROGRAM, program);
4206
+ const client = this.requireClient();
4207
+ assertProgramAvailable(client.getDescription(this.#haId), program);
4208
+ await this.logged(`select ${program}`, client.selectProgram(this.#haId, program));
1394
4209
  },
1395
4210
  setPower: async ({ on }) => {
1396
- await this.requireClient().setFeature(this.#haId, HC_CMD_SET_POWER, on ? "On" : "Off");
4211
+ const client = this.requireClient();
4212
+ const entity = findEntity(client.getDescription(this.#haId), ["setting"], ["PowerState"]);
4213
+ if (entity === void 0) throw new Error("This appliance has no power state setting");
4214
+ const members = Object.values(entity.enumeration ?? {});
4215
+ const target = on ? "On" : members.includes("Off") ? "Off" : "Standby";
4216
+ await this.writeKey(client, entity.name, target);
1397
4217
  },
1398
- setHalfLoad: async ({ on }) => {
1399
- await this.requireClient().setFeature(this.#haId, HC_FEATURE_HALF_LOAD, on);
1400
- },
1401
- setHygienePlus: async ({ on }) => {
1402
- await this.requireClient().setFeature(this.#haId, HC_FEATURE_HYGIENE, on);
4218
+ setHalfLoad: async ({ on }) => this.setOptionByShortName(OPTION_NAMES.halfLoad, on),
4219
+ setHygienePlus: async ({ on }) => this.setOptionByShortName(OPTION_NAMES.hygienePlus, on),
4220
+ setExtraDry: async ({ on }) => this.setOptionByShortName(OPTION_NAMES.extraDry, on),
4221
+ setIntensiveZone: async ({ on }) => this.setOptionByShortName(OPTION_NAMES.intensiveZone, on),
4222
+ setVarioSpeedPlus: async ({ on }) => this.setOptionByShortName(OPTION_NAMES.varioSpeedPlus, on),
4223
+ setChildLock: async ({ on }) => this.setOptionByShortName(["ChildLock"], on),
4224
+ setOption: async ({ key, value }) => this.writeKey(this.requireClient(), key, value)
4225
+ });
4226
+ }
4227
+ };
4228
+ //#endregion
4229
+ //#region src/home-connect/devices/home-connect-event-device.ts
4230
+ var homeConnectEventSchema = require_dist.object({
4231
+ haId: require_dist.string().min(1),
4232
+ brokerId: require_dist.string().min(1),
4233
+ system: require_dist.literal("home-connect").optional(),
4234
+ integrationId: require_dist.string().optional()
4235
+ });
4236
+ var EVENT_CAP = "event-emitter";
4237
+ /**
4238
+ * The appliance's events (`<event>` entities: salt low, rinse aid low, program finished,
4239
+ * aborted, …) as the generic event emitter: the vocabulary is the event feature keys of
4240
+ * the description, verbatim, and an event fires when it is RAISED.
4241
+ *
4242
+ * A value seen while the link is still being established (the handshake's initial values)
4243
+ * is the baseline, not a new event: a reconnect must not announce "program finished" again.
4244
+ */
4245
+ var HomeConnectEventDevice = class extends require_dist.BaseDevice {
4246
+ features = [];
4247
+ #haId;
4248
+ #brokerId;
4249
+ /** Edge detector: was this event raised the last time we looked? Not part of any slice. */
4250
+ #raised = /* @__PURE__ */ new Map();
4251
+ #seq = 0;
4252
+ #unwatch = null;
4253
+ constructor(ctx) {
4254
+ const persisted = homeConnectEventSchema.parse(ctx.persistedConfig ?? {});
4255
+ super(ctx, homeConnectEventSchema, { type: require_dist.DeviceType.EventEmitter });
4256
+ this.#haId = persisted.haId;
4257
+ this.#brokerId = persisted.brokerId;
4258
+ this.updateSourceInfo({
4259
+ id: `${this.#haId}:events`,
4260
+ system: "home-connect"
4261
+ });
4262
+ }
4263
+ async onActivate() {
4264
+ await super.onActivate();
4265
+ this.registerCap();
4266
+ this.publish(this.eventTypes(), this.runtimeState.getCapState(EVENT_CAP));
4267
+ this.#unwatch = homeConnectConnections.watch(this.#brokerId, this.#haId, (e) => this.onChange(e));
4268
+ }
4269
+ async removeDevice() {
4270
+ this.#unwatch?.();
4271
+ this.#unwatch = null;
4272
+ await super.removeDevice();
4273
+ }
4274
+ eventTypes() {
4275
+ return ((homeConnectConnections.getClient(this.#brokerId)?.getDescription(this.#haId))?.entities() ?? []).filter((e) => e.kind === "event").map((e) => e.name);
4276
+ }
4277
+ publish(eventTypes, previous, lastEvent) {
4278
+ this.runtimeState.setCapState(EVENT_CAP, {
4279
+ eventTypes: [...eventTypes],
4280
+ lastEvent: lastEvent ?? previous?.lastEvent ?? null,
4281
+ eventCountSinceStart: (previous?.eventCountSinceStart ?? 0) + (lastEvent !== void 0 ? 1 : 0)
4282
+ });
4283
+ }
4284
+ onChange(event) {
4285
+ const client = homeConnectConnections.getClient(this.#brokerId);
4286
+ const previous = this.runtimeState.getCapState(EVENT_CAP);
4287
+ const types = this.eventTypes();
4288
+ const connected = client !== null && client.getLinkState(this.#haId) === "connected";
4289
+ if (client !== null && !connected && event.kind === "feature" && types.includes(event.key)) {
4290
+ const baseline = eventActive(client.getFeatures(this.#haId).get(event.key));
4291
+ if (baseline !== null) this.#raised.set(event.key, baseline);
4292
+ return;
4293
+ }
4294
+ if (client === null || !connected) {
4295
+ this.#raised.clear();
4296
+ this.markOnline(false);
4297
+ return;
4298
+ }
4299
+ this.markOnline(true);
4300
+ if (event.kind !== "feature" || !types.includes(event.key)) {
4301
+ this.publish(types, previous);
4302
+ return;
4303
+ }
4304
+ const value = client.getFeatures(this.#haId).get(event.key);
4305
+ const active = eventActive(value);
4306
+ const wasRaised = this.#raised.get(event.key) === true;
4307
+ if (active !== null) this.#raised.set(event.key, active);
4308
+ if (active !== true || wasRaised) {
4309
+ this.publish(types, previous);
4310
+ return;
4311
+ }
4312
+ const entity = client.getDescription(this.#haId)?.entityByName(event.key);
4313
+ const fire = {
4314
+ deviceId: this.id,
4315
+ eventType: event.key,
4316
+ data: {
4317
+ value: typeof value === "string" ? value : null,
4318
+ ...entity?.level !== void 0 ? { level: entity.level } : {},
4319
+ ...entity?.handling !== void 0 ? { handling: entity.handling } : {}
1403
4320
  },
1404
- setOption: async ({ key, value }) => {
1405
- await this.requireClient().setFeature(this.#haId, key, value);
4321
+ timestamp: Date.now(),
4322
+ seq: ++this.#seq
4323
+ };
4324
+ this.ctx.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.EventEmitted, {
4325
+ type: "device",
4326
+ id: this.stableId,
4327
+ deviceId: this.id,
4328
+ addonId: this.ctx.deviceMeta.addonId
4329
+ }, fire));
4330
+ this.publish(types, previous, fire);
4331
+ }
4332
+ registerCap() {
4333
+ this.ctx.registerNativeCap(require_dist.eventEmitterCapability, { getStatus: async () => this.runtimeState.getCapState(EVENT_CAP) ?? {
4334
+ eventTypes: [],
4335
+ lastEvent: null,
4336
+ eventCountSinceStart: 0
4337
+ } });
4338
+ }
4339
+ };
4340
+ //#endregion
4341
+ //#region src/home-connect/devices/home-connect-sensor-device.ts
4342
+ var homeConnectSensorSchema = require_dist.object({
4343
+ haId: require_dist.string().min(1),
4344
+ brokerId: require_dist.string().min(1),
4345
+ binding: HomeConnectChildBindingSchema,
4346
+ system: require_dist.literal("home-connect").optional(),
4347
+ integrationId: require_dist.string().optional()
4348
+ });
4349
+ /**
4350
+ * What `getStatus` answers before the appliance has reported anything. `lastFetchedAt: 0`
4351
+ * is the cap's "never fetched" marker; the number beside it is a placeholder the type
4352
+ * demands, and it is NEVER written to the runtime slice — a sensor with no value is
4353
+ * offline and has no slice, so nothing reads the placeholder as a measurement.
4354
+ */
4355
+ var NEVER_NUMERIC = {
4356
+ value: 0,
4357
+ lastFetchedAt: 0
4358
+ };
4359
+ var NEVER_POWER = { lastFetchedAt: 0 };
4360
+ var NEVER_ENUM = {
4361
+ value: "",
4362
+ lastFetchedAt: 0
4363
+ };
4364
+ var NEVER_BINARY = {
4365
+ on: false,
4366
+ lastChangedAt: 0
4367
+ };
4368
+ /**
4369
+ * One Home Connect entity as a Sensor accessory child of the appliance container. The
4370
+ * entity it reads is its `binding.key` — one key, never "the first of several that
4371
+ * happens to be present".
4372
+ */
4373
+ var HomeConnectSensorDevice = class extends require_dist.BaseDevice {
4374
+ features = [];
4375
+ #haId;
4376
+ #brokerId;
4377
+ #binding;
4378
+ #unwatch = null;
4379
+ constructor(ctx) {
4380
+ const persisted = homeConnectSensorSchema.parse(ctx.persistedConfig ?? {});
4381
+ super(ctx, homeConnectSensorSchema, { type: ctx.deviceMeta.type });
4382
+ this.#haId = persisted.haId;
4383
+ this.#brokerId = persisted.brokerId;
4384
+ this.#binding = persisted.binding;
4385
+ this.updateSourceInfo({
4386
+ id: `${this.#haId}:${this.#binding.stableIdSuffix}`,
4387
+ system: "home-connect"
4388
+ });
4389
+ }
4390
+ async onActivate() {
4391
+ await super.onActivate();
4392
+ this.registerCap();
4393
+ this.markOnline(false);
4394
+ this.#unwatch = homeConnectConnections.watch(this.#brokerId, this.#haId, () => this.recompute());
4395
+ this.recompute();
4396
+ }
4397
+ async removeDevice() {
4398
+ this.#unwatch?.();
4399
+ this.#unwatch = null;
4400
+ await super.removeDevice();
4401
+ }
4402
+ recompute() {
4403
+ const client = homeConnectConnections.getClient(this.#brokerId);
4404
+ if (!client || client.getLinkState(this.#haId) !== "connected") {
4405
+ this.markOnline(false);
4406
+ return;
4407
+ }
4408
+ const slice = childSliceFromValue({
4409
+ binding: this.#binding,
4410
+ entity: client.getDescription(this.#haId)?.entityByName(this.#binding.key),
4411
+ raw: client.getFeatures(this.#haId).get(this.#binding.key),
4412
+ nowMs: Date.now()
4413
+ });
4414
+ if (slice === null) {
4415
+ this.markOnline(false);
4416
+ return;
4417
+ }
4418
+ if (slice.cap === "binary") {
4419
+ const previous = this.runtimeState.getCapState("binary");
4420
+ if (previous !== void 0 && previous.on === slice.status.on && previous.lastChangedAt > 0) {
4421
+ this.markOnline(true);
4422
+ return;
1406
4423
  }
4424
+ }
4425
+ this.runtimeState.setCapState(slice.cap, slice.status);
4426
+ this.markOnline(true);
4427
+ }
4428
+ registerCap() {
4429
+ switch (this.#binding.cap) {
4430
+ case "numeric-sensor":
4431
+ this.ctx.registerNativeCap(require_dist.numericSensorCapability, { getStatus: async () => this.runtimeState.getCapState("numeric-sensor") ?? NEVER_NUMERIC });
4432
+ return;
4433
+ case "power-meter":
4434
+ this.ctx.registerNativeCap(require_dist.powerMeterCapability, { getStatus: async () => this.runtimeState.getCapState("power-meter") ?? NEVER_POWER });
4435
+ return;
4436
+ case "enum-sensor":
4437
+ this.ctx.registerNativeCap(require_dist.enumSensorCapability, { getStatus: async () => this.runtimeState.getCapState("enum-sensor") ?? NEVER_ENUM });
4438
+ return;
4439
+ case "binary":
4440
+ this.ctx.registerNativeCap(require_dist.binaryCapability, { getStatus: async () => this.runtimeState.getCapState("binary") ?? NEVER_BINARY });
4441
+ return;
4442
+ }
4443
+ }
4444
+ };
4445
+ //#endregion
4446
+ //#region src/home-connect/devices/home-connect-appliance-device.ts
4447
+ var homeConnectApplianceSchema = require_dist.object({
4448
+ haId: require_dist.string().min(1),
4449
+ brokerId: require_dist.string().min(1),
4450
+ applianceType: require_dist.string().optional(),
4451
+ /** One entry per appliance entity exposed as a child, maintained from the appliance's description. */
4452
+ entities: require_dist.array(require_dist.unknown()).optional(),
4453
+ /** Children persisted before the description-derived model; read only to keep them alive. */
4454
+ children: require_dist.array(require_dist.unknown()).optional(),
4455
+ system: require_dist.literal("home-connect").optional(),
4456
+ integrationId: require_dist.string().optional()
4457
+ });
4458
+ /** Device metadata keys the `/ci/info` fields map onto (the conventional `DeviceMetadata` keys). */
4459
+ var INFO_TO_METADATA = {
4460
+ brand: "manufacturer",
4461
+ vib: "model",
4462
+ serialNumber: "serialNumber",
4463
+ sn: "serialNumber",
4464
+ softwareVersion: "firmware",
4465
+ swVersion: "firmware",
4466
+ hardwareVersion: "hardware",
4467
+ hwVersion: "hardware",
4468
+ mac: "mac"
4469
+ };
4470
+ /** One Home Connect appliance as a container: the dishwasher, an event emitter, and one child per entity. */
4471
+ var HomeConnectApplianceDevice = class extends require_dist.BaseDevice {
4472
+ features = [];
4473
+ integrationId;
4474
+ brokerId;
4475
+ haId;
4476
+ applianceType;
4477
+ #unwatch = null;
4478
+ #lastInfo = "";
4479
+ constructor(ctx) {
4480
+ const cfg = homeConnectApplianceSchema.parse(ctx.persistedConfig ?? {});
4481
+ super(ctx, homeConnectApplianceSchema, { type: require_dist.DeviceType.Container });
4482
+ this.brokerId = cfg.brokerId;
4483
+ this.haId = cfg.haId;
4484
+ this.integrationId = cfg.integrationId;
4485
+ this.applianceType = cfg.applianceType;
4486
+ this.updateSourceInfo({
4487
+ id: cfg.haId,
4488
+ system: "home-connect"
4489
+ });
4490
+ }
4491
+ async onActivate() {
4492
+ await super.onActivate();
4493
+ this.#unwatch = homeConnectConnections.watch(this.brokerId, this.haId, (event) => this.onChange(event));
4494
+ this.onChange({ kind: "client" });
4495
+ }
4496
+ async removeDevice() {
4497
+ this.#unwatch?.();
4498
+ this.#unwatch = null;
4499
+ await super.removeDevice();
4500
+ }
4501
+ onChange(event) {
4502
+ const client = homeConnectConnections.getClient(this.brokerId);
4503
+ this.markOnline(client?.getLinkState(this.haId) === "connected");
4504
+ if (client === null) return;
4505
+ if (event.kind === "feature" && !event.key.startsWith("HomeConnect.Info.") && !event.key.startsWith("HomeConnect.Network.")) return;
4506
+ this.publishInfo(client.getFeatures(this.haId));
4507
+ }
4508
+ /** `/ci/info` as device metadata (manufacturer, model, serial, firmware, …); pushed only when it changed. */
4509
+ async publishInfo(features) {
4510
+ const patch = {};
4511
+ for (const [field, metadataKey] of Object.entries(INFO_TO_METADATA)) {
4512
+ const value = features.get(`${HOME_CONNECT_INFO_PREFIX}${field}`);
4513
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") patch[metadataKey] = value;
4514
+ }
4515
+ const serialized = JSON.stringify(patch);
4516
+ if (Object.keys(patch).length === 0 || serialized === this.#lastInfo) return;
4517
+ const action = this.ctx.api.deviceManager?.setMetadata;
4518
+ if (!action) return;
4519
+ try {
4520
+ await action.mutate({
4521
+ deviceId: this.id,
4522
+ patch
4523
+ });
4524
+ this.#lastInfo = serialized;
4525
+ } catch (err) {
4526
+ this.ctx.logger.warn("Home Connect could not publish the appliance info", {
4527
+ tags: { deviceId: this.id },
4528
+ meta: { error: require_dist.errMsg(err) }
4529
+ });
4530
+ }
4531
+ }
4532
+ getAccessoryChildren() {
4533
+ const cfg = this.config.values;
4534
+ const bindings = cfg.entities !== void 0 ? parsePersistedBindings(cfg.entities).bindings : legacyChildBindings(cfg.children);
4535
+ const containerName = this.name;
4536
+ const link = {
4537
+ haId: this.haId,
4538
+ brokerId: this.brokerId,
4539
+ system: "home-connect",
4540
+ ...this.integrationId !== void 0 ? { integrationId: this.integrationId } : {}
4541
+ };
4542
+ const integration = this.integrationId !== void 0 ? { integrationId: this.integrationId } : {};
4543
+ const out = [{
4544
+ stableIdSuffix: "dishwasher",
4545
+ meta: {
4546
+ type: require_dist.DeviceType.Dishwasher,
4547
+ name: homeConnectDishwasherEntityName(containerName),
4548
+ linkDeviceId: this.id,
4549
+ ...integration
4550
+ },
4551
+ config: {
4552
+ ...link,
4553
+ ...this.applianceType !== void 0 ? { applianceType: this.applianceType } : {}
4554
+ },
4555
+ factory: (childCtx) => new HomeConnectDishwasherDevice(childCtx)
4556
+ }];
4557
+ if (bindings.some((b) => b.convert === "event-active")) out.push({
4558
+ stableIdSuffix: "events",
4559
+ meta: {
4560
+ type: require_dist.DeviceType.EventEmitter,
4561
+ name: `${containerName} — Events`,
4562
+ linkDeviceId: this.id,
4563
+ ...integration
4564
+ },
4565
+ config: link,
4566
+ factory: (childCtx) => new HomeConnectEventDevice(childCtx)
4567
+ });
4568
+ for (const binding of bindings) out.push({
4569
+ stableIdSuffix: binding.stableIdSuffix,
4570
+ meta: {
4571
+ type: require_dist.DeviceType.Sensor,
4572
+ role: binding.role,
4573
+ name: `${containerName} — ${binding.name}`,
4574
+ linkDeviceId: this.id,
4575
+ ...integration
4576
+ },
4577
+ config: {
4578
+ ...link,
4579
+ binding
4580
+ },
4581
+ factory: (childCtx) => new HomeConnectSensorDevice(childCtx)
1407
4582
  });
4583
+ return out;
1408
4584
  }
1409
4585
  };
1410
4586
  //#endregion
4587
+ //#region src/home-connect/home-connect-secret-adopt.ts
4588
+ /**
4589
+ * One-time adopt: an integration saved before the keychain move carries each appliance's
4590
+ * pairing key (`psk`, `iv64`) on its row, in clear text. Plan the move into `applianceSecrets`
4591
+ * (keychain), with the rows stripped.
4592
+ *
4593
+ * `null` when there is nothing to move — settings already migrated, or the stored keys are
4594
+ * locked (the move could not see what it would replace). Idempotent.
4595
+ */
4596
+ function planLegacyApplianceSecretAdopt(deps, integrationId, settings) {
4597
+ const patch = legacyApplianceSecretPatch(settings);
4598
+ if (patch === null) return null;
4599
+ const next = {
4600
+ ...settings,
4601
+ ...patch
4602
+ };
4603
+ return {
4604
+ settings: next,
4605
+ persist: async () => {
4606
+ try {
4607
+ await deps.setSettings({
4608
+ id: integrationId,
4609
+ settings: next,
4610
+ secretKeys: [...HOME_CONNECT_SECRET_KEYS]
4611
+ });
4612
+ deps.logger.info("Home Connect moved the appliance pairing keys into the keychain", {
4613
+ tags: { integrationId },
4614
+ meta: { appliances: patch.appliances.length }
4615
+ });
4616
+ } catch (err) {
4617
+ deps.logger.warn("Home Connect could not move the appliance pairing keys into the keychain", {
4618
+ tags: { integrationId },
4619
+ meta: { error: require_dist.errMsg(err) }
4620
+ });
4621
+ }
4622
+ }
4623
+ };
4624
+ }
4625
+ //#endregion
1411
4626
  //#region src/home-connect/addon.ts
1412
4627
  var BoschHomeConnectProviderAddon = class extends require_dist.BaseDeviceProvider {
1413
4628
  addonId = "bosch-home-connect";
1414
4629
  providerName = "Bosch Home Connect";
1415
- deviceClasses = { [require_dist.DeviceType.Dishwasher]: HomeConnectDishwasherDevice };
4630
+ deviceClasses = {
4631
+ [require_dist.DeviceType.Container]: HomeConnectApplianceDevice,
4632
+ [require_dist.DeviceType.Dishwasher]: HomeConnectDishwasherDevice,
4633
+ [require_dist.DeviceType.Sensor]: HomeConnectSensorDevice,
4634
+ [require_dist.DeviceType.EventEmitter]: HomeConnectEventDevice
4635
+ };
1416
4636
  clients = null;
4637
+ reconciler = null;
1417
4638
  constructor() {
1418
4639
  super({});
1419
4640
  }
1420
4641
  async onInitialize() {
1421
4642
  const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
1422
- this.clients = new HomeConnectClientRegistry(this.ctx.logger);
4643
+ this.clients = new HomeConnectClientRegistry(this.ctx.logger, { onConnectionUpdated: (integrationId, connection) => {
4644
+ this.persistIntegrationAppliances(integrationId, connection);
4645
+ } });
1423
4646
  this.clients.setOnConnected((integrationId) => {
1424
4647
  this.setIntegrationDevicesOnline(integrationId, true);
4648
+ this.trackAppliances();
1425
4649
  });
1426
4650
  this.clients.setOnDisconnected((integrationId) => {
1427
4651
  this.setIntegrationDevicesOnline(integrationId, false);
1428
4652
  });
4653
+ this.reconciler = this.buildChildReconciler();
1429
4654
  await this.reconcileIntegrations();
4655
+ await this.trackAppliances();
1430
4656
  this.subscribeIntegrationLifecycle();
1431
4657
  return {
1432
4658
  providers: [
@@ -1457,19 +4683,21 @@ var BoschHomeConnectProviderAddon = class extends require_dist.BaseDeviceProvide
1457
4683
  callback: input.callback,
1458
4684
  state: input.state
1459
4685
  });
1460
- const appliances = account.appliances.map((app) => ({
4686
+ const { rows, secrets } = splitApplianceSecrets(account.appliances.map((app) => ({
1461
4687
  haId: app.haId,
1462
4688
  name: app.name,
1463
4689
  type: app.type,
1464
4690
  host: app.connection.host,
1465
4691
  psk: app.connection.psk,
1466
4692
  mode: app.connection.mode,
1467
- ...app.connection.iv64.length > 0 ? { iv64: app.connection.iv64 } : {}
1468
- }));
4693
+ ...app.connection.iv64.length > 0 ? { iv64: app.connection.iv64 } : {},
4694
+ ...parseLoginDeviceDescription(app.connection.deviceDescription)
4695
+ })));
1469
4696
  return {
1470
4697
  token: account.token,
1471
4698
  ...account.refresh.length > 0 ? { refresh: account.refresh } : {},
1472
- appliances: JSON.stringify(appliances),
4699
+ appliances: JSON.stringify(rows),
4700
+ applianceSecrets: JSON.stringify(secrets),
1473
4701
  summary: account.summary
1474
4702
  };
1475
4703
  }
@@ -1477,6 +4705,8 @@ var BoschHomeConnectProviderAddon = class extends require_dist.BaseDeviceProvide
1477
4705
  };
1478
4706
  }
1479
4707
  async onShutdown() {
4708
+ this.reconciler?.stop();
4709
+ this.reconciler = null;
1480
4710
  try {
1481
4711
  await this.clients?.shutdown();
1482
4712
  } catch (err) {
@@ -1489,25 +4719,62 @@ var BoschHomeConnectProviderAddon = class extends require_dist.BaseDeviceProvide
1489
4719
  if (!this.clients) throw new Error("Home Connect provider not initialised");
1490
4720
  return this.clients;
1491
4721
  }
1492
- generateStableId(_type, config) {
4722
+ generateStableId(type, config) {
1493
4723
  const integrationId = config?.["brokerId"];
1494
4724
  if (typeof integrationId !== "string" || integrationId.length === 0) throw new Error("home-connect generateStableId: brokerId required");
1495
4725
  const haId = config?.["haId"];
1496
4726
  if (typeof haId !== "string" || haId.length === 0) throw new Error("home-connect generateStableId: haId required");
4727
+ if (type === require_dist.DeviceType.Container) return `home-connect:${integrationId}:${haId}:appliance`;
1497
4728
  return `home-connect:${integrationId}:${haId}`;
1498
4729
  }
4730
+ async persistIntegrationAppliances(integrationId, connection) {
4731
+ if (!connection.appliances || connection.appliances.length === 0) return;
4732
+ try {
4733
+ const settings = await this.ctx.api.integrations.getSettings.query({ id: integrationId });
4734
+ const stored = readApplianceSecrets(settings[APPLIANCE_SECRETS_KEY]);
4735
+ if (stored.kind === "unresolved" || stored.kind === "invalid") {
4736
+ this.ctx.logger.warn("Home Connect not persisting the enriched IDDF — the stored appliance keys are locked or unreadable", { tags: { integrationId } });
4737
+ return;
4738
+ }
4739
+ const merged = homeConnectSettingsForPersist({
4740
+ ...settings,
4741
+ appliances: mergeAppliancesForPersist(settings["appliances"], connection.appliances)
4742
+ });
4743
+ await this.ctx.api.integrations.setSettings.mutate({
4744
+ id: integrationId,
4745
+ settings: merged,
4746
+ secretKeys: [...HOME_CONNECT_SECRET_KEYS]
4747
+ });
4748
+ } catch (err) {
4749
+ this.ctx.logger.warn("Home Connect failed to persist enriched appliance IDDF", {
4750
+ tags: { integrationId },
4751
+ meta: { error: require_dist.errMsg(err) }
4752
+ });
4753
+ }
4754
+ }
1499
4755
  async reconcileIntegrations() {
1500
4756
  const reg = this.requireClients();
1501
4757
  try {
1502
4758
  const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id && i.enabled);
1503
4759
  const surviving = /* @__PURE__ */ new Set();
4760
+ const secretMoves = [];
1504
4761
  for (const integration of mine) try {
1505
- const settings = await this.ctx.api.integrations.getSettings.query({ id: integration.id });
4762
+ const stored = await this.ctx.api.integrations.getSettings.query({ id: integration.id });
4763
+ const adopt = planLegacyApplianceSecretAdopt({
4764
+ setSettings: (input) => this.ctx.api.integrations.setSettings.mutate(input),
4765
+ logger: this.ctx.logger
4766
+ }, integration.id, stored);
4767
+ if (adopt !== null) secretMoves.push(adopt);
4768
+ const settings = adopt?.settings ?? stored;
1506
4769
  const connection = connectionFromSettings(settings);
1507
4770
  if (!connection) {
1508
4771
  logHomeConnectSettingsGap(this.ctx.logger, integration.id, settings);
1509
4772
  continue;
1510
4773
  }
4774
+ for (const haId of appliancesWithoutCredential(settings)) this.ctx.logger.warn("Home Connect appliance has no usable pairing key — cannot connect to it", {
4775
+ tags: { integrationId: integration.id },
4776
+ meta: { haId }
4777
+ });
1511
4778
  await reg.upsert(integration.id, integration.name, connection);
1512
4779
  const brokerInfo = reg.getInfo(integration.id);
1513
4780
  const applianceCount = typeof brokerInfo?.info["applianceCount"] === "number" ? brokerInfo.info["applianceCount"] : 0;
@@ -1522,6 +4789,7 @@ var BoschHomeConnectProviderAddon = class extends require_dist.BaseDeviceProvide
1522
4789
  error: require_dist.errMsg(err)
1523
4790
  } });
1524
4791
  }
4792
+ for (const move of secretMoves) move.persist();
1525
4793
  const vanished = reg.list().filter((id) => !surviving.has(id));
1526
4794
  await reg.retain(surviving);
1527
4795
  const deviceReg = this.ctx.kernel.deviceRegistry;
@@ -1570,28 +4838,29 @@ var BoschHomeConnectProviderAddon = class extends require_dist.BaseDeviceProvide
1570
4838
  }
1571
4839
  return out;
1572
4840
  },
1573
- adoptDevice: async ({ haId, integrationId, type, name, applianceType, availablePrograms }) => {
4841
+ adoptDevice: async ({ haId, integrationId, type, name, applianceType }) => {
1574
4842
  const devices = this.ctx.kernel.devices;
1575
4843
  if (!devices) throw new Error("home-connect adopt: devices unavailable");
4844
+ if (type !== require_dist.DeviceType.Container) throw new Error(`home-connect adopt: expected Container, got ${type}`);
1576
4845
  const config = {
1577
4846
  haId,
1578
4847
  brokerId: integrationId,
1579
4848
  applianceType,
1580
- availablePrograms: [...availablePrograms],
4849
+ entities: this.initialEntities(integrationId, haId),
1581
4850
  system: "home-connect",
1582
- integrationId,
1583
- name
4851
+ integrationId
1584
4852
  };
1585
4853
  const stableId = this.generateStableId(type, config);
1586
- const DeviceClass = this.deviceClasses[type];
1587
- if (!DeviceClass) throw new Error(`home-connect adopt: unsupported type ${type}`);
1588
- return { deviceId: (await devices.create(stableId, DeviceClass, config, null, {
1589
- type,
4854
+ const device = await devices.create(stableId, HomeConnectApplianceDevice, config, null, {
4855
+ type: require_dist.DeviceType.Container,
1590
4856
  name,
1591
4857
  integrationId
1592
- })).id };
4858
+ });
4859
+ this.trackAppliances();
4860
+ return { deviceId: device.id };
1593
4861
  },
1594
4862
  removeDevice: async (id) => {
4863
+ this.reconciler?.untrack(id);
1595
4864
  await this.ctx.kernel.devices?.remove(id);
1596
4865
  },
1597
4866
  findDeviceConfig: async (id) => {
@@ -1602,6 +4871,51 @@ var BoschHomeConnectProviderAddon = class extends require_dist.BaseDeviceProvide
1602
4871
  logger: this.ctx.logger
1603
4872
  });
1604
4873
  }
4874
+ initialEntities(integrationId, haId) {
4875
+ const client = homeConnectConnections.getClient(integrationId);
4876
+ const description = client?.getDescription(haId) ?? null;
4877
+ if (client === null || description === null || description.state === "missing") return [];
4878
+ return [...deriveChildBindings({
4879
+ description,
4880
+ features: client.getFeatures(haId)
4881
+ })];
4882
+ }
4883
+ buildChildReconciler() {
4884
+ const devices = () => {
4885
+ const api = this.ctx.kernel.devices;
4886
+ if (!api) throw new Error("home-connect: devices unavailable");
4887
+ return api;
4888
+ };
4889
+ return new HomeConnectChildReconciler({
4890
+ watch: (integrationId, haId, listener) => homeConnectConnections.watch(integrationId, haId, listener),
4891
+ getClient: (integrationId) => homeConnectConnections.getClient(integrationId),
4892
+ readPersisted: async (deviceId) => {
4893
+ return parsePersistedBindings((await devices().loadConfig(deviceId))["entities"]);
4894
+ },
4895
+ applyEntities: (deviceId, entities) => devices().applyDerivedSpec(deviceId, { config: { entities: [...entities] } }),
4896
+ logger: this.ctx.logger
4897
+ });
4898
+ }
4899
+ /** Keep a reconciler watch on every adopted appliance container. */
4900
+ async trackAppliances() {
4901
+ const reg = this.ctx.kernel.deviceRegistry;
4902
+ const devices = this.ctx.kernel.devices;
4903
+ const reconciler = this.reconciler;
4904
+ if (!reg || !devices || !reconciler) return;
4905
+ for (const d of reg.getAllForAddon(this.addonId)) {
4906
+ if (d.type !== require_dist.DeviceType.Container || reconciler.tracks(d.id)) continue;
4907
+ const cfg = await devices.loadConfig(d.id).catch(() => ({}));
4908
+ const brokerId = cfg["brokerId"];
4909
+ const haId = cfg["haId"];
4910
+ if (cfg["system"] !== "home-connect" || typeof brokerId !== "string") continue;
4911
+ if (typeof haId !== "string") continue;
4912
+ reconciler.track({
4913
+ deviceId: d.id,
4914
+ brokerId,
4915
+ haId
4916
+ });
4917
+ }
4918
+ }
1605
4919
  globalSettingsSchema() {
1606
4920
  return buildHomeConnectFormSchema();
1607
4921
  }