@camstack/addon-provider-bosch 0.1.2 → 0.1.4

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