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