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