@camstack/addon-provider-bosch 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,3167 @@
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";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { connect } from "node:tls";
5
+ import { inflateRawSync } from "node:zlib";
6
+ import { lookup } from "node:dns/promises";
7
+ //#region src/home-connect/custom-actions.ts
8
+ var homeConnectActions = defineCustomActions({
9
+ beginSignIn: customAction(object({}), object({
10
+ authorizeUrl: string().min(1),
11
+ verifier: string().min(1),
12
+ state: string().min(1),
13
+ summary: string()
14
+ }), {
15
+ kind: "mutation",
16
+ auth: "admin"
17
+ }),
18
+ finishSignIn: customAction(object({
19
+ verifier: string().min(1),
20
+ callback: string().min(1),
21
+ state: string().min(1)
22
+ }), object({
23
+ token: string().min(1),
24
+ refresh: string().optional(),
25
+ appliances: string().min(1),
26
+ summary: string()
27
+ }), {
28
+ kind: "mutation",
29
+ auth: "admin"
30
+ })
31
+ });
32
+ //#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"
54
+ }
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"
69
+ }
70
+ },
71
+ "539": {
72
+ uid: 539,
73
+ name: "BSH.Common.Setting.PowerState",
74
+ enumeration: {
75
+ 1: "Off",
76
+ 2: "On"
77
+ }
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
+ }
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
117
+ };
118
+ }
119
+ function isDescriptionRoot(value) {
120
+ return typeof value === "object" && value !== null && !Array.isArray(value);
121
+ }
122
+ function parseHexUid(raw) {
123
+ return Number.parseInt(raw, 16);
124
+ }
125
+ function parseFeatureMappingXml(xml) {
126
+ 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
+ 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
+ }
145
+ return {
146
+ features,
147
+ enumerations
148
+ };
149
+ }
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;
156
+ }
157
+ return attrs;
158
+ }
159
+ var ENTITY_TAGS = [
160
+ "status",
161
+ "setting",
162
+ "option",
163
+ "command",
164
+ "event",
165
+ "program",
166
+ "activeProgram",
167
+ "selectedProgram"
168
+ ];
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;
192
+ }
193
+ function infoFromDeviceXml(xml, deviceIdFallback) {
194
+ const type = xml.match(/<type>([^<]+)<\/type>/)?.[1] ?? "Appliance";
195
+ const brand = xml.match(/<brand>([^<]+)<\/brand>/)?.[1];
196
+ return {
197
+ deviceID: deviceIdFallback,
198
+ deviceType: type,
199
+ model: xml.match(/<model>([^<]+)<\/model>/)?.[1] ?? deviceIdFallback,
200
+ brand
201
+ };
202
+ }
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
+ });
218
+ }
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
+ };
232
+ }
233
+ function entityIndexFromDescription(input) {
234
+ 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
261
+ });
262
+ }
263
+ } catch {
264
+ return index;
265
+ }
266
+ return index;
267
+ }
268
+ function decodeHomeConnectValue(uid, value, index) {
269
+ const entity = index.get(uid);
270
+ if (!entity) return null;
271
+ if (entity.enumeration && (typeof value === "number" || typeof value === "string" && /^\d+$/.test(value))) {
272
+ const num = typeof value === "number" ? value : Number.parseInt(value, 10);
273
+ const label = entity.enumeration[num];
274
+ return {
275
+ key: entity.name,
276
+ value: label ?? value
277
+ };
278
+ }
279
+ return {
280
+ key: entity.name,
281
+ value
282
+ };
283
+ }
284
+ function encodeHomeConnectValue(featureName, value, index) {
285
+ for (const entity of index.values()) {
286
+ if (entity.name !== featureName) continue;
287
+ if (entity.enumeration && typeof value === "string") {
288
+ for (const [num, label] of Object.entries(entity.enumeration)) if (label === value) return {
289
+ uid: entity.uid,
290
+ value: Number.parseInt(num, 10)
291
+ };
292
+ }
293
+ return {
294
+ uid: entity.uid,
295
+ value
296
+ };
297
+ }
298
+ return null;
299
+ }
300
+ //#endregion
301
+ //#region src/home-connect/home-connect-iddf.ts
302
+ function readUInt16LE(buf, offset) {
303
+ return buf.readUInt16LE(offset);
304
+ }
305
+ function readUInt32LE(buf, offset) {
306
+ return buf.readUInt32LE(offset);
307
+ }
308
+ function decodeZipPayload(method, payload) {
309
+ if (method === 0) return payload;
310
+ if (method === 8) try {
311
+ return inflateRawSync(payload);
312
+ } catch {
313
+ return null;
314
+ }
315
+ return null;
316
+ }
317
+ /** Extract file bodies from a ZIP (store + deflate). */
318
+ function extractZipEntries(zip) {
319
+ const out = /* @__PURE__ */ new Map();
320
+ let pos = 0;
321
+ while (pos + 30 <= zip.length) {
322
+ const sig = readUInt32LE(zip, pos);
323
+ if (sig === 101010256) break;
324
+ if (sig !== 67324752) break;
325
+ const method = readUInt16LE(zip, pos + 8);
326
+ const compSize = readUInt32LE(zip, pos + 18);
327
+ const nameLen = readUInt16LE(zip, pos + 26);
328
+ const extraLen = readUInt16LE(zip, pos + 28);
329
+ const nameStart = pos + 30;
330
+ const name = zip.subarray(nameStart, nameStart + nameLen).toString("utf8");
331
+ const dataStart = nameStart + nameLen + extraLen;
332
+ const decoded = decodeZipPayload(method, zip.subarray(dataStart, dataStart + compSize));
333
+ if (decoded !== null) out.set(name, decoded);
334
+ pos = dataStart + compSize;
335
+ }
336
+ return out;
337
+ }
338
+ function parseIddfZipBuffer(zip, haId) {
339
+ const entries = extractZipEntries(zip);
340
+ let deviceXml;
341
+ let mappingXml;
342
+ for (const [name, body] of entries) {
343
+ if (name.endsWith("_DeviceDescription.xml")) deviceXml = body.toString("utf8");
344
+ if (name.endsWith("_FeatureMapping.xml")) mappingXml = body.toString("utf8");
345
+ }
346
+ if (!deviceXml || !mappingXml) return null;
347
+ return parseHomeConnectIddfXml({
348
+ deviceDescriptionXml: deviceXml,
349
+ featureMappingXml: mappingXml,
350
+ haId
351
+ });
352
+ }
353
+ var HOME_CONNECT_IDDF_URL = (haId) => `https://eu.services.home-connect.com/api/iddf/v1/iddf/${encodeURIComponent(haId)}`;
354
+ //#endregion
355
+ //#region src/home-connect/home-connect-login.ts
356
+ /** Public OAuth client ids from the Home Connect app login (hcpy). */
357
+ var APP_ID$1 = "9B75AC9EC512F36C84256AC47D813E2C1DD0D6520DF774B020E1E6E2EB29B1F3";
358
+ var OAUTH_BASE = "https://api.home-connect.com/security/oauth/";
359
+ var ASSET_BASE = "https://eu.services.home-connect.com/";
360
+ var REDIRECT_URI = "hcauth://auth/prod";
361
+ var AUTHORIZE_SCOPE = "Control DeleteAppliance IdentifyAppliance Images Monitor ReadAccount ReadOrigApi Settings WriteAppliance WriteOrigApi";
362
+ var CookieJar = class {
363
+ #cookies = /* @__PURE__ */ new Map();
364
+ absorb(headers) {
365
+ const listed = headers.getSetCookie?.() ?? [];
366
+ const lines = listed.length > 0 ? listed : headerList(headers.get("set-cookie"));
367
+ for (const line of lines) {
368
+ const pair = line.split(";")[0] ?? "";
369
+ const eq = pair.indexOf("=");
370
+ if (eq <= 0) continue;
371
+ this.#cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
372
+ }
373
+ }
374
+ header() {
375
+ return [...this.#cookies.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
376
+ }
377
+ get(name) {
378
+ return this.#cookies.get(name);
379
+ }
380
+ };
381
+ function headerList(value) {
382
+ return value ? [value] : [];
383
+ }
384
+ async function homeConnectLoginFetch(url, init) {
385
+ const response = await fetch(url, {
386
+ method: init.method,
387
+ headers: { ...init.headers },
388
+ body: init.body,
389
+ redirect: "manual"
390
+ });
391
+ return {
392
+ status: response.status,
393
+ headers: response.headers,
394
+ text: () => response.text(),
395
+ arrayBuffer: () => response.arrayBuffer()
396
+ };
397
+ }
398
+ function b64url(bytes) {
399
+ return bytes.toString("base64url");
400
+ }
401
+ async function exchange(fetchImpl, jar, url, method, extraHeaders, body) {
402
+ const headers = {
403
+ "user-agent": "hc-login/1.0",
404
+ accept: "application/json, text/html",
405
+ ...extraHeaders
406
+ };
407
+ const cookie = jar.header();
408
+ if (cookie.length > 0) headers["cookie"] = cookie;
409
+ const csrf = jar.get("X-CSRF-FORM-TOKEN");
410
+ if (csrf) headers["RequestVerificationToken"] = csrf;
411
+ const response = await fetchImpl(url, {
412
+ method,
413
+ headers,
414
+ body,
415
+ redirect: "manual"
416
+ });
417
+ jar.absorb(response.headers);
418
+ return {
419
+ status: response.status,
420
+ location: response.headers.get("location"),
421
+ body: await response.text()
422
+ };
423
+ }
424
+ function isRecord(value) {
425
+ return typeof value === "object" && value !== null && !Array.isArray(value);
426
+ }
427
+ function stringField(record, key) {
428
+ const value = record[key];
429
+ return typeof value === "string" ? value : void 0;
430
+ }
431
+ function tokensFromBody(body) {
432
+ const tokenJson = JSON.parse(body);
433
+ if (!isRecord(tokenJson)) throw new Error("Home Connect sign-in was rejected");
434
+ const token = tokenJson["access_token"];
435
+ const refresh = tokenJson["refresh_token"];
436
+ if (typeof token !== "string" || token.length === 0) throw new Error("Home Connect sign-in was rejected");
437
+ return {
438
+ token,
439
+ refresh: typeof refresh === "string" ? refresh : ""
440
+ };
441
+ }
442
+ function accountSummary(appliances) {
443
+ if (appliances.length === 0) return "Signed in. No appliances on this account yet.";
444
+ const names = appliances.map((app) => app.name).join(", ");
445
+ return `Signed in. ${appliances.length} appliance${appliances.length === 1 ? "" : "s"}: ${names}.`;
446
+ }
447
+ function accountIdFromAccessToken(token) {
448
+ const payload = token.split(".")[1];
449
+ if (!payload) throw new Error("Home Connect sign-in was rejected");
450
+ const padded = payload + "=".repeat((4 - payload.length % 4) % 4);
451
+ let parsed;
452
+ try {
453
+ parsed = JSON.parse(Buffer.from(padded, "base64url").toString("utf8"));
454
+ } catch {
455
+ throw new Error("Home Connect sign-in was rejected");
456
+ }
457
+ if (!isRecord(parsed) || typeof parsed["sub"] !== "string" || parsed["sub"].length === 0) throw new Error("Home Connect sign-in was rejected");
458
+ return parsed["sub"];
459
+ }
460
+ function listedFromPaired(row, encryption) {
461
+ if (row["isDemo"] === true) return null;
462
+ const haId = stringField(row, "haId");
463
+ if (!haId) return null;
464
+ const brand = (stringField(row, "brand") ?? "HomeConnect").toUpperCase();
465
+ 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;
468
+ const tlsKey = tls ? stringField(tls, "key") : void 0;
469
+ const aesKey = aes ? stringField(aes, "key") : void 0;
470
+ const mode = tlsKey ? "TLS" : "AES";
471
+ const psk = tlsKey ?? aesKey;
472
+ if (!psk) return null;
473
+ const iv = aes ? stringField(aes, "iv") : void 0;
474
+ if (mode === "AES" && !iv) return null;
475
+ const host = mode === "TLS" ? `${brand}-${type}-${haId}` : haId;
476
+ return {
477
+ haId,
478
+ name: stringField(row, "vib") ?? `${brand} ${type}`,
479
+ type,
480
+ connection: {
481
+ host,
482
+ psk,
483
+ mode,
484
+ iv64: iv ?? "",
485
+ deviceDescription: JSON.stringify({ info: {
486
+ deviceID: haId,
487
+ deviceType: type,
488
+ model: `${brand} ${type}`
489
+ } })
490
+ }
491
+ };
492
+ }
493
+ async function appliancesForToken(token, fetchImpl, jar) {
494
+ const accountId = accountIdFromAccessToken(token);
495
+ const listed = await exchange(fetchImpl, jar, `${ASSET_BASE}api/account/v2/accounts/${encodeURIComponent(accountId)}/paired-appliances`, "GET", {
496
+ authorization: `Bearer ${token}`,
497
+ accept: "application/json"
498
+ });
499
+ if (listed.status >= 400) throw new Error("Home Connect sign-in was rejected");
500
+ let parsed;
501
+ try {
502
+ parsed = JSON.parse(listed.body);
503
+ } catch {
504
+ throw new Error("Home Connect sign-in was rejected");
505
+ }
506
+ if (!isRecord(parsed) || !Array.isArray(parsed["appliances"])) throw new Error("Home Connect sign-in was rejected");
507
+ const out = [];
508
+ for (const row of parsed["appliances"]) {
509
+ if (!isRecord(row)) continue;
510
+ const haId = stringField(row, "haId");
511
+ if (!haId) continue;
512
+ const enc = await exchange(fetchImpl, jar, `${ASSET_BASE}api/appliance/v2/appliances/${encodeURIComponent(haId)}/encryption-information`, "GET", {
513
+ authorization: `Bearer ${token}`,
514
+ accept: "application/json"
515
+ });
516
+ if (enc.status >= 400) continue;
517
+ let encryption;
518
+ try {
519
+ encryption = JSON.parse(enc.body);
520
+ } catch {
521
+ continue;
522
+ }
523
+ if (!isRecord(encryption)) continue;
524
+ const appliance = listedFromPaired(row, encryption);
525
+ if (!appliance) continue;
526
+ const iddfResponse = await fetchImpl(HOME_CONNECT_IDDF_URL(haId), {
527
+ method: "GET",
528
+ headers: {
529
+ authorization: `Bearer ${token}`,
530
+ accept: "application/zip"
531
+ },
532
+ redirect: "manual"
533
+ });
534
+ if (iddfResponse.status < 400 && iddfResponse.arrayBuffer) {
535
+ const compact = parseIddfZipBuffer(Buffer.from(await iddfResponse.arrayBuffer()), haId);
536
+ if (compact) {
537
+ out.push({
538
+ ...appliance,
539
+ connection: {
540
+ ...appliance.connection,
541
+ deviceDescription: JSON.stringify(compact)
542
+ }
543
+ });
544
+ continue;
545
+ }
546
+ }
547
+ out.push(appliance);
548
+ }
549
+ return out;
550
+ }
551
+ /** Load every appliance on the account. A stale access token is refreshed once when a refresh token exists. */
552
+ async function fetchHomeConnectAccount(creds, fetchImpl = homeConnectLoginFetch) {
553
+ const jar = new CookieJar();
554
+ let token = creds.token;
555
+ let refresh = creds.refresh ?? "";
556
+ try {
557
+ const appliances = await appliancesForToken(token, fetchImpl, jar);
558
+ return {
559
+ token,
560
+ refresh,
561
+ appliances,
562
+ summary: accountSummary(appliances)
563
+ };
564
+ } catch (err) {
565
+ if (refresh.length === 0) throw err;
566
+ }
567
+ const refreshed = await exchange(fetchImpl, jar, `${OAUTH_BASE}token`, "POST", { "content-type": "application/x-www-form-urlencoded" }, new URLSearchParams({
568
+ grant_type: "refresh_token",
569
+ refresh_token: refresh,
570
+ client_id: APP_ID$1
571
+ }).toString());
572
+ if (refreshed.status >= 400) throw new Error("Home Connect sign-in was rejected");
573
+ const next = tokensFromBody(refreshed.body);
574
+ token = next.token;
575
+ refresh = next.refresh;
576
+ const appliances = await appliancesForToken(token, fetchImpl, jar);
577
+ return {
578
+ token,
579
+ refresh,
580
+ appliances,
581
+ summary: accountSummary(appliances)
582
+ };
583
+ }
584
+ /** Browser login link, matching Home Connect Local (`redirect_url` + the app scope). */
585
+ function beginHomeConnectSignIn() {
586
+ const verifier = b64url(randomBytes(32));
587
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
588
+ const state = b64url(randomBytes(16));
589
+ const authorize = new URL("authorize", OAUTH_BASE);
590
+ authorize.searchParams.set("redirect_url", REDIRECT_URI);
591
+ authorize.searchParams.set("client_id", APP_ID$1);
592
+ authorize.searchParams.set("response_type", "code");
593
+ authorize.searchParams.set("prompt", "login");
594
+ authorize.searchParams.set("code_challenge_method", "S256");
595
+ authorize.searchParams.set("code_challenge", challenge);
596
+ authorize.searchParams.set("state", state);
597
+ authorize.searchParams.set("nonce", b64url(randomBytes(16)));
598
+ authorize.searchParams.set("scope", AUTHORIZE_SCOPE);
599
+ return {
600
+ authorizeUrl: authorize.toString(),
601
+ verifier,
602
+ state
603
+ };
604
+ }
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");
611
+ }
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");
615
+ return {
616
+ code,
617
+ grantType: url.searchParams.get("grant_type") ?? "authorization_code",
618
+ state
619
+ };
620
+ }
621
+ /** Exchange the pasted redirect for the account token and each appliance profile. */
622
+ 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;
626
+ const token = await exchange(fetchImpl, new CookieJar(), `${OAUTH_BASE}token`, "POST", { "content-type": "application/x-www-form-urlencoded" }, new URLSearchParams({
627
+ grant_type: grantType,
628
+ client_id: APP_ID$1,
629
+ code,
630
+ code_verifier: input.verifier,
631
+ redirect_uri: REDIRECT_URI
632
+ }).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";
886
+ }
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
+ };
907
+ }
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
+ //#endregion
915
+ //#region src/home-connect/home-connect-discovery.ts
916
+ /** Dishwasher accessory label: `Dishwasher` plus the appliance name. */
917
+ function homeConnectDishwasherEntityName(containerName) {
918
+ const base = containerName.trim();
919
+ return base.length > 0 ? `Dishwasher ${base}` : "Dishwasher";
920
+ }
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
+ function deviceTypeForApplianceType(type) {
934
+ if (type.toLowerCase().includes("dishwasher")) return DeviceType.Container;
935
+ return null;
936
+ }
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,
943
+ status: "online",
944
+ metadata: { serialNumber: haId },
945
+ alreadyAdopted: false,
946
+ 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
+ });
968
+ return {
969
+ childNativeId: app.haId,
970
+ name,
971
+ type: DeviceType.Container,
972
+ status: "online",
973
+ metadata: {
974
+ serialNumber: app.haId,
975
+ model: app.type
976
+ },
977
+ alreadyAdopted: adoptedId !== null,
978
+ adoptedDeviceId: adoptedId,
979
+ capabilities: [],
980
+ children
981
+ };
982
+ }
983
+ function buildHomeConnectCandidates(input) {
984
+ const out = [];
985
+ for (const app of input.appliances) {
986
+ if (deviceTypeForApplianceType(app.type) === null) continue;
987
+ const adoptedId = input.adopted.get(app.haId) ?? null;
988
+ out.push(dishwasherContainerCandidate(app, adoptedId));
989
+ }
990
+ return out;
991
+ }
992
+ //#endregion
993
+ //#region src/home-connect/home-connect-adoption-provider.ts
994
+ var DEVICES_FILTER = {
995
+ id: "devices",
996
+ label: "Devices",
997
+ isDefault: true
998
+ };
999
+ async function adoptedMapForIntegration(integrationId, listAdopted) {
1000
+ const all = await listAdopted();
1001
+ const map = /* @__PURE__ */ new Map();
1002
+ for (const device of all) if (device.config["system"] === "home-connect" && device.config["brokerId"] === integrationId) {
1003
+ const haId = device.config["haId"];
1004
+ if (typeof haId === "string") map.set(haId, device.id);
1005
+ }
1006
+ return map;
1007
+ }
1008
+ function buildHomeConnectAdoptionProvider(deps) {
1009
+ const { registry, hasIntegration, listIntegrations, listAdopted, adoptDevice, removeDevice, findDeviceConfig, logger } = deps;
1010
+ async function allCandidatesForIntegration(integrationId) {
1011
+ return buildHomeConnectCandidates({
1012
+ appliances: registry.appliances(integrationId),
1013
+ adopted: await adoptedMapForIntegration(integrationId, listAdopted)
1014
+ });
1015
+ }
1016
+ return {
1017
+ listCandidateFilters: async () => ({ filters: [DEVICES_FILTER] }),
1018
+ listCandidates: async ({ integrationId, page, pageSize }) => {
1019
+ const cands = await allCandidatesForIntegration(integrationId);
1020
+ const start = (page - 1) * pageSize;
1021
+ return {
1022
+ candidates: cands.slice(start, start + pageSize),
1023
+ totalCount: cands.length,
1024
+ page,
1025
+ pageSize
1026
+ };
1027
+ },
1028
+ getCandidate: async ({ integrationId, childNativeId }) => {
1029
+ return (await allCandidatesForIntegration(integrationId)).find((c) => c.childNativeId === childNativeId) ?? null;
1030
+ },
1031
+ getStatus: async () => {
1032
+ try {
1033
+ let candidateCount = 0;
1034
+ for (const integrationId of listIntegrations()) candidateCount += (await allCandidatesForIntegration(integrationId)).length;
1035
+ const adoptedCount = (await listAdopted()).filter((d) => d.config["system"] === "home-connect").length;
1036
+ return {
1037
+ lastDiscoveryAt: Date.now(),
1038
+ candidateCount,
1039
+ adoptedCount,
1040
+ lastError: null
1041
+ };
1042
+ } catch (err) {
1043
+ return {
1044
+ lastDiscoveryAt: null,
1045
+ candidateCount: 0,
1046
+ adoptedCount: 0,
1047
+ lastError: errMsg(err)
1048
+ };
1049
+ }
1050
+ },
1051
+ refresh: async ({ integrationId }) => {
1052
+ const candidateCount = (await allCandidatesForIntegration(integrationId)).length;
1053
+ const adoptedCount = (await adoptedMapForIntegration(integrationId, listAdopted)).size;
1054
+ return {
1055
+ lastDiscoveryAt: Date.now(),
1056
+ candidateCount,
1057
+ adoptedCount,
1058
+ lastError: null
1059
+ };
1060
+ },
1061
+ adopt: async ({ integrationId, childNativeIds, perCandidate }) => {
1062
+ if (!registry.has(integrationId)) throw new Error(`home-connect adopt: integration ${integrationId} not connected`);
1063
+ const appliances = registry.appliances(integrationId);
1064
+ const adopted = [];
1065
+ let failures = 0;
1066
+ for (const haId of childNativeIds) try {
1067
+ const app = appliances.find((a) => a.haId === haId);
1068
+ if (!app) {
1069
+ failures++;
1070
+ continue;
1071
+ }
1072
+ const candidate = buildHomeConnectCandidates({
1073
+ appliances: [app],
1074
+ adopted: /* @__PURE__ */ new Map()
1075
+ })[0];
1076
+ if (!candidate) {
1077
+ failures++;
1078
+ continue;
1079
+ }
1080
+ const name = perCandidate?.[haId]?.name ?? app.name;
1081
+ const { deviceId } = await adoptDevice({
1082
+ haId,
1083
+ integrationId,
1084
+ type: candidate.type,
1085
+ name,
1086
+ applianceType: app.type,
1087
+ availablePrograms: app.availablePrograms
1088
+ });
1089
+ adopted.push({
1090
+ childNativeId: haId,
1091
+ parentDeviceId: deviceId
1092
+ });
1093
+ } catch (err) {
1094
+ logger.warn("home-connect adopt failed", { meta: {
1095
+ haId,
1096
+ integrationId,
1097
+ error: errMsg(err)
1098
+ } });
1099
+ failures++;
1100
+ }
1101
+ if (adopted.length === 0 && failures > 0) throw new Error(`home-connect adopt: all ${failures} adopt(s) failed`);
1102
+ return { adopted: adopted.map((a) => ({
1103
+ ...a,
1104
+ accessoryDeviceIds: []
1105
+ })) };
1106
+ },
1107
+ release: async ({ camDeviceId }) => {
1108
+ await removeDevice(camDeviceId);
1109
+ },
1110
+ resync: async ({ camDeviceId }) => {
1111
+ const cfg = await findDeviceConfig(camDeviceId);
1112
+ if (!cfg || cfg["system"] !== "home-connect") throw new Error(`home-connect resync: device ${camDeviceId} not found`);
1113
+ const integrationId = String(cfg["brokerId"]);
1114
+ const haId = String(cfg["haId"]);
1115
+ if (!hasIntegration(integrationId)) throw new Error(`home-connect resync: integration ${integrationId} not connected`);
1116
+ if (!registry.appliances(integrationId).some((a) => a.haId === haId)) throw new Error(`home-connect resync: appliance ${haId} no longer present`);
1117
+ return {
1118
+ changed: false,
1119
+ rebuiltChildren: 0
1120
+ };
1121
+ }
1122
+ };
1123
+ }
1124
+ var HomeConnectModeSchema = _enum(["AES", "TLS"]);
1125
+ function isPlainObject$1(value) {
1126
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1127
+ }
1128
+ var homeConnectDeviceDescriptionSchema = union([string(), record(string(), unknown())]).optional();
1129
+ object({
1130
+ host: string().min(1),
1131
+ psk: string().min(1).meta({ secret: true }),
1132
+ mode: HomeConnectModeSchema.default("AES"),
1133
+ iv64: string().optional(),
1134
+ deviceDescription: homeConnectDeviceDescriptionSchema
1135
+ });
1136
+ /** One appliance saved at sign-in, so adopt does not need another cloud call. */
1137
+ var homeConnectStoredApplianceSchema = object({
1138
+ haId: string().min(1),
1139
+ name: string(),
1140
+ type: string(),
1141
+ host: string().min(1),
1142
+ psk: string().min(1),
1143
+ mode: HomeConnectModeSchema,
1144
+ iv64: string().optional(),
1145
+ deviceDescription: homeConnectDeviceDescriptionSchema
1146
+ });
1147
+ /** The integration is the Home Connect account. Profiles are downloaded once at sign-in. */
1148
+ var homeConnectConfigSchema = object({
1149
+ token: string().min(1).describe("Home Connect access token").meta({ secret: true }),
1150
+ refresh: string().optional().describe("Home Connect refresh token").meta({ secret: true }),
1151
+ appliances: array(homeConnectStoredApplianceSchema).optional()
1152
+ });
1153
+ /**
1154
+ * Before `integrations.setSettings`: keep objects as-is (serializes as `json`), parse
1155
+ * textarea JSON strings into objects so they are not re-serialized as `string` and
1156
+ * corrupted on read by `deserializeSetting(String(object))`.
1157
+ */
1158
+ function homeConnectSettingsForPersist(settings) {
1159
+ const next = { ...settings };
1160
+ delete next["accountEmail"];
1161
+ delete next["accountPassword"];
1162
+ delete next["authorizeUrl"];
1163
+ delete next["verifier"];
1164
+ delete next["callback"];
1165
+ delete next["state"];
1166
+ if (next["refresh"] === "") delete next["refresh"];
1167
+ const appliancesRaw = next["appliances"];
1168
+ if (typeof appliancesRaw === "string" && appliancesRaw.trim().startsWith("[")) try {
1169
+ const parsedAppliances = JSON.parse(appliancesRaw);
1170
+ if (Array.isArray(parsedAppliances)) next["appliances"] = parsedAppliances;
1171
+ } catch {}
1172
+ const raw = next["deviceDescription"];
1173
+ if (typeof raw !== "string") return next;
1174
+ const trimmed = raw.trim();
1175
+ if (trimmed.length === 0) return next;
1176
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return next;
1177
+ try {
1178
+ const parsed = JSON.parse(trimmed);
1179
+ if (isPlainObject$1(parsed)) return {
1180
+ ...next,
1181
+ deviceDescription: parsed
1182
+ };
1183
+ } catch {
1184
+ return next;
1185
+ }
1186
+ return next;
1187
+ }
1188
+ function connectionFromSettings(settings) {
1189
+ const parsed = homeConnectConfigSchema.safeParse(homeConnectSettingsForPersist(settings));
1190
+ return parsed.success ? parsed.data : null;
1191
+ }
1192
+ function hasNonEmptyString(settings, key) {
1193
+ const value = settings[key];
1194
+ return typeof value === "string" && value.trim().length > 0;
1195
+ }
1196
+ /** Why reconcile cannot build a connection; null when `connectionFromSettings` would succeed. */
1197
+ function homeConnectSettingsGap(settings) {
1198
+ if (connectionFromSettings(settings) !== null) return null;
1199
+ const token = settings["token"];
1200
+ if (isSecretMarker(token) || isSecretMarkerLike(token)) return "token-unresolved-marker";
1201
+ if (!hasNonEmptyString(settings, "token")) return "token-missing";
1202
+ return "schema-invalid";
1203
+ }
1204
+ /** Reconcile path: warn once per incomplete integration (never logs secret values). */
1205
+ function logHomeConnectSettingsGap(logger, integrationId, settings) {
1206
+ const gap = homeConnectSettingsGap(settings);
1207
+ if (gap === null) return;
1208
+ const token = settings["token"];
1209
+ logger.warn("Home Connect integration has incomplete account settings — skipping", { meta: {
1210
+ integrationId,
1211
+ gap,
1212
+ tokenIsSecretMarker: isSecretMarker(token),
1213
+ tokenType: typeof token,
1214
+ hasToken: hasNonEmptyString(settings, "token"),
1215
+ hasRefresh: hasNonEmptyString(settings, "refresh")
1216
+ } });
1217
+ }
1218
+ function sameHomeConnectConnection(a, b) {
1219
+ if (a === null || b === null) return false;
1220
+ return a.token === b.token && (a.refresh ?? "") === (b.refresh ?? "") && JSON.stringify(a.appliances ?? []) === JSON.stringify(b.appliances ?? []);
1221
+ }
1222
+ /** Sentinel so the gateway fields stay filled by sign-in and stay off the form. */
1223
+ var HOME_CONNECT_PAIRED_ONLY = "__paired_only__";
1224
+ function buildHomeConnectFormSchema() {
1225
+ const pairedOnly = {
1226
+ field: "authorizeUrl",
1227
+ equals: HOME_CONNECT_PAIRED_ONLY
1228
+ };
1229
+ return { sections: [{
1230
+ id: "gateway",
1231
+ 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.",
1233
+ columns: 1,
1234
+ fields: [
1235
+ {
1236
+ type: "addon-action-button",
1237
+ addonId: "bosch-home-connect",
1238
+ key: "__home-connect-begin",
1239
+ label: "Home Connect",
1240
+ buttonLabel: "Create login link",
1241
+ action: "beginSignIn",
1242
+ variant: "primary",
1243
+ assignResult: {
1244
+ authorizeUrl: "authorizeUrl",
1245
+ verifier: "verifier",
1246
+ state: "state"
1247
+ },
1248
+ successMessage: "Open the link in Chrome and sign in."
1249
+ },
1250
+ {
1251
+ type: "textarea",
1252
+ key: "authorizeUrl",
1253
+ label: "Login link",
1254
+ required: false,
1255
+ omitFromSettings: true
1256
+ },
1257
+ {
1258
+ type: "text",
1259
+ key: "callback",
1260
+ label: "Redirect address",
1261
+ required: false,
1262
+ placeholder: "hcauth://auth/prod?code=…&state=…",
1263
+ omitFromSettings: true
1264
+ },
1265
+ {
1266
+ type: "password",
1267
+ key: "state",
1268
+ label: "Login state",
1269
+ required: false,
1270
+ omitFromSettings: true,
1271
+ showWhen: pairedOnly
1272
+ },
1273
+ {
1274
+ type: "password",
1275
+ key: "verifier",
1276
+ label: "Login verifier",
1277
+ required: false,
1278
+ omitFromSettings: true,
1279
+ showWhen: pairedOnly
1280
+ },
1281
+ {
1282
+ type: "addon-action-button",
1283
+ addonId: "bosch-home-connect",
1284
+ key: "__home-connect-finish",
1285
+ label: "Finish",
1286
+ buttonLabel: "Complete sign-in",
1287
+ action: "finishSignIn",
1288
+ variant: "primary",
1289
+ paramsFromForm: {
1290
+ verifier: "verifier",
1291
+ callback: "callback",
1292
+ state: "state"
1293
+ },
1294
+ assignResult: {
1295
+ token: "token",
1296
+ refresh: "refresh",
1297
+ appliances: "appliances"
1298
+ },
1299
+ successMessage: "{summary}"
1300
+ },
1301
+ {
1302
+ type: "password",
1303
+ key: "token",
1304
+ label: "Access token",
1305
+ required: true,
1306
+ showWhen: pairedOnly
1307
+ },
1308
+ {
1309
+ type: "password",
1310
+ key: "refresh",
1311
+ label: "Refresh token",
1312
+ required: false,
1313
+ showWhen: pairedOnly
1314
+ },
1315
+ {
1316
+ type: "textarea",
1317
+ key: "appliances",
1318
+ label: "Appliances",
1319
+ required: true,
1320
+ showWhen: pairedOnly
1321
+ }
1322
+ ]
1323
+ }] };
1324
+ }
1325
+ /** WebSocket URL for local Home Connect (plain WS on port 80 path /homeconnect). */
1326
+ function homeConnectWsUrl(config) {
1327
+ return `${config.mode === "TLS" ? "wss" : "ws"}://${config.host.replace(/^https?:\/\//, "").replace(/\/$/, "")}/homeconnect`;
1328
+ }
1329
+ //#endregion
1330
+ //#region src/home-connect/home-connect-gateway.ts
1331
+ var HomeConnectConnectionResolver = class {
1332
+ #surfaces = /* @__PURE__ */ new Map();
1333
+ set(integrationId, surface) {
1334
+ if (surface === null) {
1335
+ this.#surfaces.delete(integrationId);
1336
+ return;
1337
+ }
1338
+ this.#surfaces.set(integrationId, surface);
1339
+ }
1340
+ appliances(integrationId) {
1341
+ return this.#surfaces.get(integrationId)?.appliances ?? [];
1342
+ }
1343
+ has(integrationId) {
1344
+ return this.#surfaces.has(integrationId);
1345
+ }
1346
+ list() {
1347
+ return Array.from(this.#surfaces.keys()).map((id) => ({ id }));
1348
+ }
1349
+ getClient(integrationId) {
1350
+ return this.#surfaces.get(integrationId)?.client ?? null;
1351
+ }
1352
+ clear() {
1353
+ this.#surfaces.clear();
1354
+ }
1355
+ };
1356
+ var homeConnectConnections = new HomeConnectConnectionResolver();
1357
+ //#endregion
1358
+ //#region src/home-connect/home-connect-tls-wire.ts
1359
+ function decodePsk64(psk64) {
1360
+ return Buffer.from(`${psk64}===`, "base64url");
1361
+ }
1362
+ function parseHttpHeaders(buffer) {
1363
+ const marker = buffer.indexOf("\r\n\r\n");
1364
+ if (marker < 0) return null;
1365
+ return {
1366
+ headers: buffer.subarray(0, marker + 4),
1367
+ rest: buffer.subarray(marker + 4)
1368
+ };
1369
+ }
1370
+ function readWsFrame(buffer) {
1371
+ if (buffer.length < 2) return null;
1372
+ const b0 = buffer.at(0);
1373
+ const b1 = buffer.at(1);
1374
+ if (b0 === void 0 || b1 === void 0) return null;
1375
+ const opcode = b0 & 15;
1376
+ const masked = (b1 & 128) !== 0;
1377
+ let payloadLen = b1 & 127;
1378
+ let offset = 2;
1379
+ if (payloadLen === 126) {
1380
+ if (buffer.length < 4) return null;
1381
+ payloadLen = buffer.readUInt16BE(2);
1382
+ offset = 4;
1383
+ } else if (payloadLen === 127) {
1384
+ if (buffer.length < 10) return null;
1385
+ payloadLen = Number(buffer.readBigUInt64BE(2));
1386
+ offset = 10;
1387
+ }
1388
+ const maskLen = masked ? 4 : 0;
1389
+ if (buffer.length < offset + maskLen + payloadLen) return null;
1390
+ let payload = buffer.subarray(offset + maskLen, offset + maskLen + payloadLen);
1391
+ if (masked) {
1392
+ const mask = buffer.subarray(offset, offset + 4);
1393
+ const unmasked = Buffer.alloc(payload.length);
1394
+ for (let i = 0; i < payload.length; i += 1) unmasked[i] = (payload.at(i) ?? 0) ^ (mask.at(i % 4) ?? 0);
1395
+ payload = unmasked;
1396
+ }
1397
+ if (opcode === 8) return {
1398
+ payload: Buffer.alloc(0),
1399
+ consumed: offset + maskLen + payloadLen
1400
+ };
1401
+ return {
1402
+ payload,
1403
+ consumed: offset + maskLen + payloadLen
1404
+ };
1405
+ }
1406
+ function writeWsTextFrame(text) {
1407
+ const payload = Buffer.from(text, "utf8");
1408
+ const mask = randomBytes(4);
1409
+ const headerLen = payload.length < 126 ? 2 : payload.length < 65536 ? 4 : 10;
1410
+ const frame = Buffer.alloc(headerLen + 4 + payload.length);
1411
+ frame[0] = 129;
1412
+ if (payload.length < 126) {
1413
+ frame[1] = 128 | payload.length;
1414
+ mask.copy(frame, 2);
1415
+ for (let i = 0; i < payload.length; i += 1) {
1416
+ const pb = payload.at(i) ?? 0;
1417
+ const mb = mask.at(i % 4) ?? 0;
1418
+ frame[6 + i] = pb ^ mb;
1419
+ }
1420
+ } else if (payload.length < 65536) {
1421
+ frame[1] = 254;
1422
+ frame.writeUInt16BE(payload.length, 2);
1423
+ mask.copy(frame, 4);
1424
+ for (let i = 0; i < payload.length; i += 1) {
1425
+ const pb = payload.at(i) ?? 0;
1426
+ const mb = mask.at(i % 4) ?? 0;
1427
+ frame[8 + i] = pb ^ mb;
1428
+ }
1429
+ } else {
1430
+ frame[1] = 255;
1431
+ frame.writeBigUInt64BE(BigInt(payload.length), 2);
1432
+ mask.copy(frame, 10);
1433
+ for (let i = 0; i < payload.length; i += 1) {
1434
+ const pb = payload.at(i) ?? 0;
1435
+ const mb = mask.at(i % 4) ?? 0;
1436
+ frame[14 + i] = pb ^ mb;
1437
+ }
1438
+ }
1439
+ return frame;
1440
+ }
1441
+ function createHomeConnectTlsWire(input) {
1442
+ let socket = null;
1443
+ let open = false;
1444
+ const textListeners = /* @__PURE__ */ new Set();
1445
+ const closedListeners = /* @__PURE__ */ new Set();
1446
+ let recvBuffer = Buffer.alloc(0);
1447
+ let upgraded = false;
1448
+ function emitText(text) {
1449
+ for (const fn of textListeners) fn(text);
1450
+ }
1451
+ function emitClosed() {
1452
+ open = false;
1453
+ for (const fn of closedListeners) fn();
1454
+ }
1455
+ function drainFrames() {
1456
+ while (true) {
1457
+ const parsed = readWsFrame(recvBuffer);
1458
+ if (!parsed) return;
1459
+ recvBuffer = Buffer.from(recvBuffer.subarray(parsed.consumed));
1460
+ if (parsed.payload.length === 0) continue;
1461
+ emitText(parsed.payload.toString("utf8"));
1462
+ }
1463
+ }
1464
+ return {
1465
+ get open() {
1466
+ return open;
1467
+ },
1468
+ async connect() {
1469
+ if (open) return;
1470
+ const psk = decodePsk64(input.psk);
1471
+ await new Promise((resolve, reject) => {
1472
+ socket = connect({
1473
+ host: input.connectAddress,
1474
+ port: 443,
1475
+ servername: input.host,
1476
+ ciphers: "PSK",
1477
+ minVersion: "TLSv1.2",
1478
+ maxVersion: "TLSv1.2",
1479
+ rejectUnauthorized: false,
1480
+ checkServerIdentity: () => void 0,
1481
+ pskCallback: () => ({
1482
+ psk,
1483
+ identity: ""
1484
+ })
1485
+ }, () => {
1486
+ const key = randomBytes(16).toString("base64");
1487
+ const req = [
1488
+ "GET /homeconnect HTTP/1.1",
1489
+ `Host: ${input.host}`,
1490
+ "Upgrade: websocket",
1491
+ "Connection: Upgrade",
1492
+ `Sec-WebSocket-Key: ${key}`,
1493
+ "Sec-WebSocket-Version: 13",
1494
+ "",
1495
+ ""
1496
+ ].join("\r\n");
1497
+ socket?.write(req);
1498
+ });
1499
+ socket.on("data", (chunk) => {
1500
+ recvBuffer = Buffer.concat([recvBuffer, chunk]);
1501
+ if (!upgraded) {
1502
+ const parsed = parseHttpHeaders(recvBuffer);
1503
+ if (!parsed) return;
1504
+ if (!(parsed.headers.toString("utf8").split("\r\n")[0] ?? "").includes("101")) {
1505
+ reject(/* @__PURE__ */ new Error("Home Connect WebSocket upgrade failed"));
1506
+ return;
1507
+ }
1508
+ upgraded = true;
1509
+ recvBuffer = Buffer.from(parsed.rest);
1510
+ open = true;
1511
+ resolve();
1512
+ drainFrames();
1513
+ return;
1514
+ }
1515
+ drainFrames();
1516
+ });
1517
+ socket.on("error", (err) => reject(err));
1518
+ socket.on("close", () => emitClosed());
1519
+ });
1520
+ },
1521
+ send(text) {
1522
+ if (!socket || !open) throw new Error("Home Connect TLS wire not open");
1523
+ socket.write(writeWsTextFrame(text));
1524
+ },
1525
+ close() {
1526
+ socket?.destroy();
1527
+ socket = null;
1528
+ emitClosed();
1529
+ },
1530
+ onText(listener) {
1531
+ textListeners.add(listener);
1532
+ return () => textListeners.delete(listener);
1533
+ },
1534
+ onClosed(listener) {
1535
+ closedListeners.add(listener);
1536
+ return () => closedListeners.delete(listener);
1537
+ }
1538
+ };
1539
+ }
1540
+ //#endregion
1541
+ //#region src/home-connect/home-connect-message.ts
1542
+ var HC_ACTION = {
1543
+ GET: "GET",
1544
+ POST: "POST",
1545
+ RESPONSE: "RESPONSE",
1546
+ NOTIFY: "NOTIFY"
1547
+ };
1548
+ 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;
1552
+ const sID = row["sID"];
1553
+ const msgID = row["msgID"];
1554
+ const resource = row["resource"];
1555
+ const version = row["version"];
1556
+ const action = row["action"];
1557
+ if (typeof sID !== "number" || typeof msgID !== "number" || typeof resource !== "string" || typeof version !== "number" || typeof action !== "string") throw new Error("Invalid Home Connect message fields");
1558
+ let data;
1559
+ const dataRaw = row["data"];
1560
+ if (dataRaw !== void 0) data = (Array.isArray(dataRaw) ? dataRaw : [dataRaw]).filter((item) => typeof item === "object" && item !== null);
1561
+ const code = row["code"];
1562
+ return {
1563
+ sID,
1564
+ msgID,
1565
+ resource,
1566
+ version,
1567
+ action,
1568
+ data,
1569
+ code: typeof code === "number" ? code : void 0
1570
+ };
1571
+ }
1572
+ function dumpHomeConnectMessage(input) {
1573
+ const msg = {
1574
+ sID: input.sID,
1575
+ msgID: input.msgID,
1576
+ resource: input.resource,
1577
+ version: input.version,
1578
+ action: input.action
1579
+ };
1580
+ if (input.data !== void 0) msg["data"] = Array.isArray(input.data) ? input.data : [input.data];
1581
+ if (input.code !== void 0) msg["code"] = input.code;
1582
+ return JSON.stringify(msg);
1583
+ }
1584
+ function responseTo(init, data) {
1585
+ return {
1586
+ sID: init.sID,
1587
+ msgID: init.msgID,
1588
+ resource: init.resource,
1589
+ version: init.version,
1590
+ action: HC_ACTION.RESPONSE,
1591
+ data: [data]
1592
+ };
1593
+ }
1594
+ //#endregion
1595
+ //#region src/home-connect/home-connect-session.ts
1596
+ var APP_NAME = "CamStack";
1597
+ var APP_ID = "camstack-hc";
1598
+ /** Max wait for appliance `/ei/initialValues` after the socket opens. */
1599
+ var HOME_CONNECT_INIT_RESOURCE_TIMEOUT_MS = 12e3;
1600
+ var HomeConnectSession = class {
1601
+ #wire;
1602
+ #entityIndex;
1603
+ #onFeature;
1604
+ #onEmptyEntityIndex;
1605
+ #sid = 0;
1606
+ #warnedUndecodedUid = false;
1607
+ #nextMsgId = 1;
1608
+ #serviceVersions = {};
1609
+ #pending = /* @__PURE__ */ new Map();
1610
+ #connected = false;
1611
+ #unsubText = null;
1612
+ constructor(options) {
1613
+ this.#wire = options.wire;
1614
+ this.#entityIndex = options.entityIndex;
1615
+ this.#onFeature = options.onFeature;
1616
+ this.#onEmptyEntityIndex = options.onEmptyEntityIndex;
1617
+ }
1618
+ get connected() {
1619
+ return this.#connected;
1620
+ }
1621
+ async connect() {
1622
+ if (this.#connected) return;
1623
+ this.#unsubText = this.#wire.onText((text) => this.#handleIncoming(text));
1624
+ const initPromise = this.#waitForResource("/ei/initialValues");
1625
+ await this.#wire.connect();
1626
+ const init = loadHomeConnectMessage(await initPromise);
1627
+ this.#sid = init.sID;
1628
+ const edMsgId = (init.data?.[0])?.["edMsgID"];
1629
+ if (typeof edMsgId === "number") this.#nextMsgId = edMsgId;
1630
+ this.#wire.send(dumpHomeConnectMessage(responseTo(init, {
1631
+ deviceType: init.version === 1 ? 2 : "Application",
1632
+ deviceName: APP_NAME,
1633
+ deviceID: APP_ID
1634
+ })));
1635
+ const services = await this.#sendGet("/ci/services", 1);
1636
+ this.#setServiceVersions(services);
1637
+ if (this.#serviceVersions["ei"] === 2) await this.#sendNotify("/ei/deviceReady");
1638
+ if ("iz" in this.#serviceVersions) await this.#sendGet("/iz/info", this.#serviceVersions["iz"] ?? 1).catch(() => void 0);
1639
+ const mandatory = await this.#sendGet("/ro/allMandatoryValues", this.#serviceVersions["ro"] ?? 1);
1640
+ this.#applyValuePayload(mandatory.data);
1641
+ if (this.#entityIndex.size === 0) this.#onEmptyEntityIndex?.();
1642
+ this.#connected = true;
1643
+ }
1644
+ close() {
1645
+ this.#connected = false;
1646
+ this.#unsubText?.();
1647
+ this.#unsubText = null;
1648
+ for (const pending of this.#pending.values()) pending.reject(/* @__PURE__ */ new Error("Home Connect session closed"));
1649
+ this.#pending.clear();
1650
+ this.#wire.close();
1651
+ }
1652
+ async setFeatureByName(key, value) {
1653
+ const encoded = encodeHomeConnectValue(key, value, this.#entityIndex);
1654
+ if (!encoded) throw new Error(`Unknown Home Connect feature: ${key}`);
1655
+ await this.#sendPost("/ro/values", this.#serviceVersions["ro"] ?? 1, [{
1656
+ uid: encoded.uid,
1657
+ value: encoded.value
1658
+ }]);
1659
+ this.#onFeature(key, value);
1660
+ }
1661
+ #setServiceVersions(message) {
1662
+ const versions = {};
1663
+ for (const row of message.data ?? []) {
1664
+ const service = row["service"];
1665
+ const version = row["version"];
1666
+ if (typeof service === "string" && typeof version === "number") versions[service] = version;
1667
+ }
1668
+ this.#serviceVersions = versions;
1669
+ }
1670
+ #applyValuePayload(data) {
1671
+ if (!data) return;
1672
+ for (const row of data) this.#applyUidRow(row);
1673
+ }
1674
+ #applyUidRow(row) {
1675
+ const uidRaw = row["uid"];
1676
+ const value = row["value"];
1677
+ if (typeof uidRaw !== "number") return;
1678
+ const decoded = decodeHomeConnectValue(uidRaw, value, this.#entityIndex);
1679
+ if (decoded) {
1680
+ this.#onFeature(decoded.key, decoded.value);
1681
+ return;
1682
+ }
1683
+ if (this.#entityIndex.size === 0 && !this.#warnedUndecodedUid) {
1684
+ this.#warnedUndecodedUid = true;
1685
+ this.#onEmptyEntityIndex?.();
1686
+ }
1687
+ }
1688
+ #handleIncoming(text) {
1689
+ let message;
1690
+ try {
1691
+ message = loadHomeConnectMessage(text);
1692
+ } catch {
1693
+ return;
1694
+ }
1695
+ if (message.action === HC_ACTION.RESPONSE) {
1696
+ const waiter = this.#pending.get(message.msgID);
1697
+ if (waiter) {
1698
+ this.#pending.delete(message.msgID);
1699
+ waiter.resolve(message);
1700
+ }
1701
+ return;
1702
+ }
1703
+ if (message.action === HC_ACTION.NOTIFY && message.resource === "/ro/values") this.#applyValuePayload(message.data);
1704
+ }
1705
+ #waitForResource(resource) {
1706
+ return new Promise((resolve, reject) => {
1707
+ let settled = false;
1708
+ const finish = (fn) => {
1709
+ if (settled) return;
1710
+ settled = true;
1711
+ clearTimeout(timer);
1712
+ unsubText();
1713
+ unsubClosed();
1714
+ fn();
1715
+ };
1716
+ const unsubText = this.#wire.onText((text) => {
1717
+ try {
1718
+ if (loadHomeConnectMessage(text).resource === resource) finish(() => resolve(text));
1719
+ } catch {}
1720
+ });
1721
+ const unsubClosed = this.#wire.onClosed(() => {
1722
+ finish(() => reject(/* @__PURE__ */ new Error("Home Connect wire closed before init")));
1723
+ });
1724
+ const timer = setTimeout(() => {
1725
+ finish(() => reject(/* @__PURE__ */ new Error(`Home Connect init timeout waiting for ${resource}`)));
1726
+ }, HOME_CONNECT_INIT_RESOURCE_TIMEOUT_MS);
1727
+ });
1728
+ }
1729
+ async #sendGet(resource, version) {
1730
+ return this.#sendAndWaitResponse({
1731
+ sID: this.#sid,
1732
+ msgID: this.#allocMsgId(),
1733
+ resource,
1734
+ version,
1735
+ action: HC_ACTION.GET
1736
+ });
1737
+ }
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
+ async #sendNotify(resource) {
1749
+ const msg = {
1750
+ sID: this.#sid,
1751
+ msgID: this.#allocMsgId(),
1752
+ resource,
1753
+ version: this.#serviceVersions[resource.slice(1, 3)] ?? 1,
1754
+ action: HC_ACTION.NOTIFY
1755
+ };
1756
+ this.#wire.send(dumpHomeConnectMessage(msg));
1757
+ }
1758
+ #allocMsgId() {
1759
+ const id = this.#nextMsgId;
1760
+ this.#nextMsgId += 1;
1761
+ return id;
1762
+ }
1763
+ async #sendAndWaitResponse(outgoing) {
1764
+ return new Promise((resolve, reject) => {
1765
+ this.#pending.set(outgoing.msgID, {
1766
+ resolve,
1767
+ reject
1768
+ });
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);
1776
+ });
1777
+ }
1778
+ };
1779
+ //#endregion
1780
+ //#region src/home-connect/home-connect-ws-client.ts
1781
+ var WS_OPEN = 1;
1782
+ function programNamesFromLegacyList(programsRaw) {
1783
+ const programs = [];
1784
+ if (!Array.isArray(programsRaw)) return programs;
1785
+ for (const p of programsRaw) if (typeof p === "string") programs.push(p);
1786
+ return programs;
1787
+ }
1788
+ function programNamesFromHaDeviceDescription(programsRaw) {
1789
+ const programs = [];
1790
+ if (!Array.isArray(programsRaw)) return programs;
1791
+ for (const p of programsRaw) if (typeof p === "object" && p !== null) {
1792
+ const name = p["name"];
1793
+ if (typeof name === "string") programs.push(name);
1794
+ } else if (typeof p === "string") programs.push(p);
1795
+ return programs;
1796
+ }
1797
+ function parseLegacyAppliancesList(rawList) {
1798
+ if (!Array.isArray(rawList)) return [];
1799
+ const appliances = [];
1800
+ for (const item of rawList) {
1801
+ if (typeof item !== "object" || item === null) continue;
1802
+ const row = item;
1803
+ const haId = row["haId"];
1804
+ const name = row["name"];
1805
+ const type = row["type"];
1806
+ if (typeof haId !== "string") continue;
1807
+ appliances.push({
1808
+ haId,
1809
+ name: typeof name === "string" ? name : haId,
1810
+ type: typeof type === "string" ? type : "Dishwasher",
1811
+ availablePrograms: programNamesFromLegacyList(row["programs"])
1812
+ });
1813
+ }
1814
+ return appliances;
1815
+ }
1816
+ function parseHaDeviceDescription(record) {
1817
+ const info = record["info"];
1818
+ if (typeof info !== "object" || info === null) return [];
1819
+ const infoRec = info;
1820
+ const deviceID = infoRec["deviceID"];
1821
+ if (typeof deviceID !== "string") return [];
1822
+ const deviceType = infoRec["deviceType"];
1823
+ const model = infoRec["model"];
1824
+ return [{
1825
+ haId: deviceID,
1826
+ name: typeof model === "string" && model.length > 0 ? model : deviceID,
1827
+ type: typeof deviceType === "string" ? deviceType : "Dishwasher",
1828
+ availablePrograms: programNamesFromHaDeviceDescription(record["program"])
1829
+ }];
1830
+ }
1831
+ function isPlainObject(value) {
1832
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1833
+ }
1834
+ function parseDeviceDescriptionRecord(record) {
1835
+ const rawList = record["appliances"];
1836
+ if (Array.isArray(rawList)) return { appliances: parseLegacyAppliancesList(rawList) };
1837
+ const fromHa = parseHaDeviceDescription(record);
1838
+ if (fromHa.length > 0) return { appliances: fromHa };
1839
+ return { appliances: [] };
1840
+ }
1841
+ function parseDeviceDescription(input) {
1842
+ if (input === void 0) return { appliances: [] };
1843
+ if (isPlainObject(input)) return parseDeviceDescriptionRecord(input);
1844
+ if (typeof input !== "string") return { appliances: [] };
1845
+ const trimmed = input.trim();
1846
+ if (trimmed.length === 0 || trimmed === "[object Object]") return { appliances: [] };
1847
+ try {
1848
+ const root = JSON.parse(trimmed);
1849
+ if (!isPlainObject(root)) return { appliances: [] };
1850
+ return parseDeviceDescriptionRecord(root);
1851
+ } catch {
1852
+ return { appliances: [] };
1853
+ }
1854
+ }
1855
+ function usesProtocolStack(config, deps) {
1856
+ if (deps.wireFactory) return true;
1857
+ return config.mode === "TLS";
1858
+ }
1859
+ async function resolveConnectAddress(host) {
1860
+ try {
1861
+ const result = await lookup(host, { verbatim: true });
1862
+ return typeof result === "string" ? result : result.address;
1863
+ } catch {
1864
+ return host.replace(/^https?:\/\//, "").split("/")[0] ?? host;
1865
+ }
1866
+ }
1867
+ var defaultWireFactory = (input) => {
1868
+ if (input.mode === "TLS") return createHomeConnectTlsWire({
1869
+ host: input.host,
1870
+ connectAddress: input.connectAddress,
1871
+ psk: input.psk
1872
+ });
1873
+ throw new Error("Home Connect AES local socket is not implemented — use TLS pairing");
1874
+ };
1875
+ function createHomeConnectWsClient(config, deps = {}) {
1876
+ const appliances = [...parseDeviceDescription(config.deviceDescription).appliances];
1877
+ const entityIndex = entityIndexFromDescription(config.deviceDescription);
1878
+ const primaryHaId = appliances[0]?.haId ?? config.host;
1879
+ if (usesProtocolStack(config, deps)) return createProtocolHomeConnectClient({
1880
+ config,
1881
+ appliances,
1882
+ entityIndex,
1883
+ primaryHaId,
1884
+ wireFactory: deps.wireFactory ?? defaultWireFactory,
1885
+ onProtocolWarn: deps.onProtocolWarn
1886
+ });
1887
+ if (entityIndex.size > 0 && config.mode === "AES") deps.onProtocolWarn?.("aes-legacy-fallback-with-entities");
1888
+ return createLegacySubscribeClient(config, deps, appliances);
1889
+ }
1890
+ function createProtocolHomeConnectClient(input) {
1891
+ const featureStore = /* @__PURE__ */ new Map();
1892
+ const listeners = /* @__PURE__ */ new Set();
1893
+ let session = null;
1894
+ let connected = false;
1895
+ const haId = input.primaryHaId;
1896
+ function ensureFeatures() {
1897
+ let map = featureStore.get(haId);
1898
+ if (!map) {
1899
+ map = /* @__PURE__ */ new Map();
1900
+ featureStore.set(haId, map);
1901
+ }
1902
+ return map;
1903
+ }
1904
+ function applyFeature(key, value) {
1905
+ ensureFeatures().set(key, value);
1906
+ for (const fn of listeners) fn(haId, key, value);
1907
+ }
1908
+ return {
1909
+ async connect() {
1910
+ if (connected) return;
1911
+ const connectAddress = await resolveConnectAddress(input.config.host);
1912
+ const wire = input.wireFactory({
1913
+ host: input.config.host,
1914
+ connectAddress,
1915
+ psk: input.config.psk,
1916
+ mode: input.config.mode,
1917
+ iv64: input.config.iv64
1918
+ });
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;
1932
+ },
1933
+ close() {
1934
+ connected = false;
1935
+ session?.close();
1936
+ session = null;
1937
+ },
1938
+ getAppliances() {
1939
+ return input.appliances;
1940
+ },
1941
+ getFeatures(targetHaId) {
1942
+ if (targetHaId !== haId && !featureStore.has(targetHaId)) return /* @__PURE__ */ new Map();
1943
+ return featureStore.get(targetHaId) ?? ensureFeatures();
1944
+ },
1945
+ 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);
1949
+ },
1950
+ onFeatureChange(listener) {
1951
+ listeners.add(listener);
1952
+ return () => listeners.delete(listener);
1953
+ }
1954
+ };
1955
+ }
1956
+ function createLegacySubscribeClient(config, deps, appliances) {
1957
+ const wsFactory = deps.wsFactory ?? defaultWsFactoryLegacy;
1958
+ const url = homeConnectWsUrl(config);
1959
+ const featureStore = /* @__PURE__ */ new Map();
1960
+ const listeners = /* @__PURE__ */ new Set();
1961
+ let ws = null;
1962
+ let connected = false;
1963
+ function ensureFeatures(haId) {
1964
+ let map = featureStore.get(haId);
1965
+ if (!map) {
1966
+ map = /* @__PURE__ */ new Map();
1967
+ featureStore.set(haId, map);
1968
+ }
1969
+ return map;
1970
+ }
1971
+ function applyFeature(haId, key, value) {
1972
+ ensureFeatures(haId).set(key, value);
1973
+ for (const fn of listeners) fn(haId, key, value);
1974
+ }
1975
+ return {
1976
+ async connect() {
1977
+ if (connected) return;
1978
+ await new Promise((resolve, reject) => {
1979
+ const socket = wsFactory(url);
1980
+ ws = socket;
1981
+ const onOpen = () => {
1982
+ connected = true;
1983
+ socket.removeEventListener("open", onOpen);
1984
+ socket.removeEventListener("error", onError);
1985
+ for (const app of appliances) socket.send(JSON.stringify({
1986
+ type: "subscribe",
1987
+ haId: app.haId
1988
+ }));
1989
+ resolve();
1990
+ };
1991
+ const onError = () => {
1992
+ reject(/* @__PURE__ */ new Error("Home Connect WebSocket connection failed"));
1993
+ };
1994
+ socket.addEventListener("open", onOpen);
1995
+ socket.addEventListener("error", onError);
1996
+ socket.addEventListener("message", (ev) => {
1997
+ if (!ev.data) return;
1998
+ try {
1999
+ const msg = JSON.parse(ev.data);
2000
+ if (typeof msg !== "object" || msg === null) return;
2001
+ const row = msg;
2002
+ if (row["type"] === "feature") {
2003
+ const msgHaId = row["haId"];
2004
+ const key = row["key"];
2005
+ if (typeof msgHaId === "string" && typeof key === "string") applyFeature(msgHaId, key, row["value"]);
2006
+ }
2007
+ } catch {}
2008
+ });
2009
+ });
2010
+ },
2011
+ close() {
2012
+ connected = false;
2013
+ ws?.close();
2014
+ ws = null;
2015
+ },
2016
+ getAppliances() {
2017
+ return appliances;
2018
+ },
2019
+ getFeatures(haId) {
2020
+ return ensureFeatures(haId);
2021
+ },
2022
+ async setFeature(haId, key, value) {
2023
+ if (!ws || ws.readyState !== WS_OPEN) throw new Error("Home Connect WebSocket not connected");
2024
+ ws.send(JSON.stringify({
2025
+ type: "set",
2026
+ haId,
2027
+ key,
2028
+ value
2029
+ }));
2030
+ applyFeature(haId, key, value);
2031
+ },
2032
+ onFeatureChange(listener) {
2033
+ listeners.add(listener);
2034
+ return () => listeners.delete(listener);
2035
+ }
2036
+ };
2037
+ }
2038
+ 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";
2042
+ }
2043
+ function defaultWsFactoryLegacy(url) {
2044
+ const WS = globalThis.WebSocket;
2045
+ if (typeof WS !== "function") throw new Error(`WebSocket unavailable — cannot connect to ${url}`);
2046
+ const socket = new WS(url);
2047
+ if (!isWsLike(socket)) throw new Error(`WebSocket instance is not WsLike-compatible (${url})`);
2048
+ return socket;
2049
+ }
2050
+ /** Default factory used by the integration manager (overridable in tests). */
2051
+ var defaultHomeConnectWsClientFactory = (config) => createHomeConnectWsClient(config);
2052
+ //#endregion
2053
+ //#region src/home-connect/home-connect-account-client.ts
2054
+ /** One account session: the token lists appliances, each appliance has its own local socket. */
2055
+ async function openHomeConnectAccount(options) {
2056
+ return accountClient((await fetchHomeConnectAccount({
2057
+ token: options.token,
2058
+ refresh: options.refresh
2059
+ }, options.fetchImpl)).appliances, options.makeGateway ?? defaultHomeConnectWsClientFactory);
2060
+ }
2061
+ function accountClient(appliances, makeGateway) {
2062
+ const listed = appliances.map((app) => ({
2063
+ haId: app.haId,
2064
+ name: app.name,
2065
+ type: app.type,
2066
+ availablePrograms: []
2067
+ }));
2068
+ const children = /* @__PURE__ */ new Map();
2069
+ for (const app of appliances) children.set(app.haId, makeGateway(app.connection));
2070
+ const listeners = /* @__PURE__ */ new Set();
2071
+ const unsubs = [];
2072
+ for (const [haId, child] of children) unsubs.push(child.onFeatureChange((eventHaId, key, value) => {
2073
+ const id = eventHaId.length > 0 ? eventHaId : haId;
2074
+ for (const fn of listeners) fn(id, key, value);
2075
+ }));
2076
+ return {
2077
+ async connect() {
2078
+ const results = await Promise.allSettled([...children.values()].map((child) => child.connect()));
2079
+ if (results.length > 0 && results.every((result) => result.status === "rejected")) {
2080
+ const first = results[0];
2081
+ if (first?.status === "rejected") throw first.reason;
2082
+ }
2083
+ },
2084
+ close() {
2085
+ for (const unsub of unsubs) unsub();
2086
+ for (const child of children.values()) child.close();
2087
+ },
2088
+ getAppliances: () => listed,
2089
+ getFeatures(haId) {
2090
+ return children.get(haId)?.getFeatures(haId) ?? /* @__PURE__ */ new Map();
2091
+ },
2092
+ 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);
2096
+ },
2097
+ onFeatureChange(listener) {
2098
+ listeners.add(listener);
2099
+ return () => listeners.delete(listener);
2100
+ }
2101
+ };
2102
+ }
2103
+ //#endregion
2104
+ //#region src/home-connect/home-connect-iddf-fetch.ts
2105
+ function entityCountInStoredDescription(description) {
2106
+ if (description === void 0) return 0;
2107
+ return entityIndexFromDescription(description).size;
2108
+ }
2109
+ async function fetchHomeConnectIddfCompact(haId, token, fetchImpl) {
2110
+ const response = await fetchImpl(HOME_CONNECT_IDDF_URL(haId), {
2111
+ method: "GET",
2112
+ headers: {
2113
+ authorization: `Bearer ${token}`,
2114
+ accept: "application/zip"
2115
+ },
2116
+ redirect: "manual"
2117
+ });
2118
+ if (response.status >= 400 || !response.arrayBuffer) return null;
2119
+ const bytes = await response.arrayBuffer();
2120
+ return parseIddfZipBuffer(Buffer.from(bytes), haId);
2121
+ }
2122
+ function compactDescriptionToStored(compact) {
2123
+ return JSON.parse(JSON.stringify(compact));
2124
+ }
2125
+ function entityCountForCompact(compact) {
2126
+ if (compact === null) return 0;
2127
+ return entityIndexFromDescription(JSON.parse(JSON.stringify(compact))).size;
2128
+ }
2129
+ /** Fill missing IDDF entity maps on stored appliances (TLS first). Returns whether any row changed. */
2130
+ async function enrichHomeConnectConnection(connection, fetchImpl, hooks = {}) {
2131
+ const appliances = connection.appliances ?? [];
2132
+ if (appliances.length === 0) return {
2133
+ connection,
2134
+ changed: false
2135
+ };
2136
+ const token = connection.token;
2137
+ if (isSecretMarker(token) || isSecretMarkerLike(token)) return {
2138
+ connection,
2139
+ changed: false
2140
+ };
2141
+ let changed = false;
2142
+ const nextAppliances = [];
2143
+ for (const row of appliances) {
2144
+ if (!(row.mode === "TLS" && entityCountInStoredDescription(row.deviceDescription) === 0)) {
2145
+ nextAppliances.push(row);
2146
+ continue;
2147
+ }
2148
+ let descriptionToStore = await fetchHomeConnectIddfCompact(row.haId, token, fetchImpl);
2149
+ let entityCount = entityCountForCompact(descriptionToStore);
2150
+ if (entityCount === 0) {
2151
+ descriptionToStore = buildCommonEntityFallbackDescription({
2152
+ haId: row.haId,
2153
+ type: row.type,
2154
+ name: row.name
2155
+ });
2156
+ entityCount = entityCountForCompact(descriptionToStore);
2157
+ hooks.onInfo?.("using common BSH entity fallback", { haId: row.haId });
2158
+ }
2159
+ if (entityCount === 0 || descriptionToStore === null) {
2160
+ nextAppliances.push(row);
2161
+ continue;
2162
+ }
2163
+ changed = true;
2164
+ nextAppliances.push({
2165
+ ...row,
2166
+ deviceDescription: compactDescriptionToStored(descriptionToStore)
2167
+ });
2168
+ }
2169
+ if (!changed) return {
2170
+ connection,
2171
+ changed: false
2172
+ };
2173
+ return {
2174
+ connection: {
2175
+ ...connection,
2176
+ appliances: nextAppliances
2177
+ },
2178
+ changed: true
2179
+ };
2180
+ }
2181
+ //#endregion
2182
+ //#region src/home-connect/home-connect-stored-appliance.ts
2183
+ function stubDeviceDescriptionObject(row) {
2184
+ return { info: {
2185
+ deviceID: row.haId,
2186
+ deviceType: row.type,
2187
+ model: row.name
2188
+ } };
2189
+ }
2190
+ function stubDeviceDescription(row) {
2191
+ return JSON.stringify(stubDeviceDescriptionObject(row));
2192
+ }
2193
+ function serializedDeviceDescription(row) {
2194
+ if (row.deviceDescription === void 0) return stubDeviceDescription(row);
2195
+ if (typeof row.deviceDescription === "string") return row.deviceDescription;
2196
+ return JSON.stringify(row.deviceDescription);
2197
+ }
2198
+ function isDescriptionRecord(value) {
2199
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2200
+ }
2201
+ function parseLoginDeviceDescription(raw) {
2202
+ const trimmed = raw.trim();
2203
+ if (trimmed.length === 0) return {};
2204
+ try {
2205
+ const parsed = JSON.parse(trimmed);
2206
+ if (isDescriptionRecord(parsed)) return { deviceDescription: parsed };
2207
+ } catch {
2208
+ return {};
2209
+ }
2210
+ return {};
2211
+ }
2212
+ function listedApplianceFromStored(row) {
2213
+ return {
2214
+ haId: row.haId,
2215
+ name: row.name,
2216
+ type: row.type,
2217
+ connection: {
2218
+ host: row.host,
2219
+ psk: row.psk,
2220
+ mode: row.mode,
2221
+ iv64: row.iv64 ?? "",
2222
+ deviceDescription: serializedDeviceDescription(row)
2223
+ }
2224
+ };
2225
+ }
2226
+ //#endregion
2227
+ //#region src/home-connect/home-connect-integration-manager.ts
2228
+ async function openStoredOrAccount(connection, deps) {
2229
+ let working = connection;
2230
+ const stored = working.appliances ?? [];
2231
+ if (stored.length > 0) {
2232
+ 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 });
2239
+ } });
2240
+ 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
+ } });
2248
+ const warnProtocol = (code) => {
2249
+ deps.logger?.warn("Home Connect local protocol warning", { meta: { code } });
2250
+ };
2251
+ return {
2252
+ connection: working,
2253
+ client: accountClient(working.appliances.map(listedApplianceFromStored), (gateway) => createHomeConnectWsClient(gateway, { onProtocolWarn: warnProtocol }))
2254
+ };
2255
+ }
2256
+ return {
2257
+ connection: working,
2258
+ client: await openHomeConnectAccount({
2259
+ token: working.token,
2260
+ refresh: working.refresh ?? "",
2261
+ fetchImpl: deps.fetchImpl
2262
+ })
2263
+ };
2264
+ }
2265
+ var HomeConnectIntegrationManager = class {
2266
+ #id;
2267
+ #name;
2268
+ #connection;
2269
+ #logger;
2270
+ #onConnected;
2271
+ #onDisconnected;
2272
+ #surfaceSink;
2273
+ #makeClient;
2274
+ #fetchImpl;
2275
+ #onConnectionUpdated;
2276
+ #client = null;
2277
+ #status = "disconnected";
2278
+ #lastCheckedAt = null;
2279
+ #error = null;
2280
+ constructor(options) {
2281
+ this.#id = options.id;
2282
+ this.#name = options.name;
2283
+ this.#connection = options.connection;
2284
+ this.#logger = options.logger;
2285
+ this.#onConnected = options.onConnected;
2286
+ this.#onDisconnected = options.onDisconnected;
2287
+ this.#surfaceSink = options.surfaceSink;
2288
+ this.#makeClient = options.makeClient ?? null;
2289
+ this.#fetchImpl = options.fetchImpl;
2290
+ this.#onConnectionUpdated = options.onConnectionUpdated;
2291
+ }
2292
+ getConnection() {
2293
+ return this.#connection;
2294
+ }
2295
+ getInfo() {
2296
+ const applianceCount = this.#client?.getAppliances().length ?? 0;
2297
+ return {
2298
+ id: this.#id,
2299
+ name: this.#name,
2300
+ kind: "home-connect",
2301
+ status: this.#status,
2302
+ info: { applianceCount },
2303
+ lastCheckedAt: this.#lastCheckedAt,
2304
+ error: this.#error
2305
+ };
2306
+ }
2307
+ async start() {
2308
+ if (this.#client !== null) return;
2309
+ this.#status = "connecting";
2310
+ this.#error = null;
2311
+ const before = this.#connection;
2312
+ let client;
2313
+ if (this.#makeClient) client = this.#makeClient(this.#connection);
2314
+ else {
2315
+ const opened = await openStoredOrAccount(this.#connection, {
2316
+ fetchImpl: this.#fetchImpl,
2317
+ logger: this.#logger
2318
+ });
2319
+ client = opened.client;
2320
+ this.#connection = opened.connection;
2321
+ if (!sameHomeConnectConnection(before, this.#connection)) this.#onConnectionUpdated?.(this.#id, this.#connection);
2322
+ }
2323
+ this.#client = client;
2324
+ const seededAppliances = client.getAppliances();
2325
+ if (seededAppliances.length > 0) this.#surfaceSink.set(this.#id, {
2326
+ appliances: seededAppliances,
2327
+ client
2328
+ });
2329
+ try {
2330
+ await client.connect();
2331
+ const surface = {
2332
+ appliances: client.getAppliances(),
2333
+ client
2334
+ };
2335
+ this.#surfaceSink.set(this.#id, surface);
2336
+ this.#status = "connected";
2337
+ this.#lastCheckedAt = Date.now();
2338
+ this.#onConnected(this.#id);
2339
+ } catch (err) {
2340
+ this.#status = "error";
2341
+ this.#error = errMsg(err);
2342
+ this.#lastCheckedAt = Date.now();
2343
+ const appliances = client.getAppliances();
2344
+ if (appliances.length > 0) {
2345
+ this.#surfaceSink.set(this.#id, {
2346
+ appliances,
2347
+ client
2348
+ });
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
+ } });
2354
+ return;
2355
+ }
2356
+ this.#surfaceSink.set(this.#id, null);
2357
+ this.#onDisconnected(this.#id);
2358
+ throw err;
2359
+ }
2360
+ }
2361
+ async stop() {
2362
+ const client = this.#client;
2363
+ this.#client = null;
2364
+ this.#status = "disconnected";
2365
+ this.#surfaceSink.set(this.#id, null);
2366
+ if (client) try {
2367
+ client.close();
2368
+ } catch (err) {
2369
+ this.#logger.warn("HomeConnectIntegrationManager: client close failed", { meta: {
2370
+ integrationId: this.#id,
2371
+ error: errMsg(err)
2372
+ } });
2373
+ }
2374
+ this.#onDisconnected(this.#id);
2375
+ }
2376
+ async applyConnection(conn) {
2377
+ await this.stop();
2378
+ this.#connection = conn;
2379
+ await this.start();
2380
+ }
2381
+ };
2382
+ //#endregion
2383
+ //#region src/home-connect/home-connect-client-registry.ts
2384
+ var HomeConnectClientRegistry = class {
2385
+ #logger;
2386
+ #onConnected;
2387
+ #onDisconnected;
2388
+ #makeManager;
2389
+ #onConnectionUpdated;
2390
+ #entries = /* @__PURE__ */ new Map();
2391
+ constructor(logger, deps = {}) {
2392
+ this.#logger = logger;
2393
+ this.#onConnected = deps.onConnected ?? (() => void 0);
2394
+ this.#onDisconnected = deps.onDisconnected ?? (() => void 0);
2395
+ this.#onConnectionUpdated = deps.onConnectionUpdated;
2396
+ const makeClient = deps.makeClient;
2397
+ const fetchImpl = deps.fetchImpl;
2398
+ this.#makeManager = deps.makeManager ?? ((opts) => new HomeConnectIntegrationManager({
2399
+ id: opts.integrationId,
2400
+ name: opts.name,
2401
+ connection: opts.connection,
2402
+ logger: opts.logger,
2403
+ onConnected: opts.onConnected,
2404
+ onDisconnected: opts.onDisconnected,
2405
+ surfaceSink: homeConnectConnections,
2406
+ makeClient,
2407
+ fetchImpl,
2408
+ onConnectionUpdated: this.#onConnectionUpdated
2409
+ }));
2410
+ }
2411
+ setOnConnected(cb) {
2412
+ this.#onConnected = cb;
2413
+ }
2414
+ setOnDisconnected(cb) {
2415
+ this.#onDisconnected = cb;
2416
+ }
2417
+ async upsert(integrationId, name, connection) {
2418
+ const existing = this.#entries.get(integrationId);
2419
+ if (existing) {
2420
+ if (sameHomeConnectConnection(existing.connection, connection)) return;
2421
+ try {
2422
+ await existing.manager.applyConnection(connection);
2423
+ this.#entries.set(integrationId, {
2424
+ manager: existing.manager,
2425
+ connection: existing.manager.getConnection()
2426
+ });
2427
+ } catch (err) {
2428
+ this.#logger.warn("HomeConnectClientRegistry: applyConnection failed", { meta: {
2429
+ integrationId,
2430
+ error: errMsg(err)
2431
+ } });
2432
+ }
2433
+ return;
2434
+ }
2435
+ const manager = this.#makeManager({
2436
+ integrationId,
2437
+ name,
2438
+ connection,
2439
+ logger: this.#logger,
2440
+ onConnected: (id) => this.#onConnected(id),
2441
+ onDisconnected: (id) => this.#onDisconnected(id)
2442
+ });
2443
+ this.#entries.set(integrationId, {
2444
+ manager,
2445
+ connection
2446
+ });
2447
+ try {
2448
+ await manager.start();
2449
+ this.#entries.set(integrationId, {
2450
+ manager,
2451
+ connection: manager.getConnection()
2452
+ });
2453
+ } catch (err) {
2454
+ this.#logger.warn("HomeConnectClientRegistry: manager start failed", { meta: {
2455
+ integrationId,
2456
+ error: errMsg(err)
2457
+ } });
2458
+ }
2459
+ }
2460
+ async remove(integrationId) {
2461
+ const entry = this.#entries.get(integrationId);
2462
+ if (!entry) return;
2463
+ this.#entries.delete(integrationId);
2464
+ try {
2465
+ await entry.manager.stop();
2466
+ } catch (err) {
2467
+ this.#logger.warn("HomeConnectClientRegistry: remove stop failed", { meta: {
2468
+ integrationId,
2469
+ error: errMsg(err)
2470
+ } });
2471
+ }
2472
+ }
2473
+ async retain(keep) {
2474
+ const stale = [...this.#entries.keys()].filter((id) => !keep.has(id));
2475
+ for (const id of stale) await this.remove(id);
2476
+ }
2477
+ async shutdown() {
2478
+ const ids = [...this.#entries.keys()];
2479
+ await Promise.all(ids.map(async (id) => {
2480
+ try {
2481
+ await this.#entries.get(id)?.manager.stop();
2482
+ } catch (err) {
2483
+ this.#logger.warn("HomeConnectClientRegistry: shutdown stop failed", { meta: {
2484
+ integrationId: id,
2485
+ error: errMsg(err)
2486
+ } });
2487
+ }
2488
+ }));
2489
+ this.#entries.clear();
2490
+ homeConnectConnections.clear();
2491
+ }
2492
+ has(integrationId) {
2493
+ return this.#entries.has(integrationId);
2494
+ }
2495
+ list() {
2496
+ return [...this.#entries.keys()];
2497
+ }
2498
+ getConnection(integrationId) {
2499
+ return this.#entries.get(integrationId)?.connection ?? null;
2500
+ }
2501
+ getInfo(integrationId) {
2502
+ return this.#entries.get(integrationId)?.manager.getInfo() ?? null;
2503
+ }
2504
+ size() {
2505
+ return this.#entries.size;
2506
+ }
2507
+ };
2508
+ //#endregion
2509
+ //#region src/home-connect/home-connect-connection-test.ts
2510
+ var TEST_LABEL = "Checks the Home Connect account token and lists appliances";
2511
+ function buildHomeConnectConnectionTestProvider(deps) {
2512
+ const { logger } = deps;
2513
+ return {
2514
+ describeTest: async () => ({ label: TEST_LABEL }),
2515
+ testSettings: async ({ settings }) => {
2516
+ const normalized = homeConnectSettingsForPersist(typeof settings === "object" && settings !== null ? settings : {});
2517
+ const parsed = homeConnectConfigSchema.safeParse(normalized);
2518
+ if (!parsed.success) return {
2519
+ outcome: "rejected",
2520
+ error: `Home Connect settings incomplete: ${parsed.error.issues.map((issue) => issue.path.join(".") || "(root)").join(", ")}`
2521
+ };
2522
+ const saved = parsed.data.appliances ?? [];
2523
+ if (saved.length > 0) {
2524
+ const names = saved.map((app) => app.name).join(", ");
2525
+ return {
2526
+ outcome: "validated",
2527
+ latencyMs: 0,
2528
+ detail: names.length > 0 ? names : `${String(saved.length)} appliance(s) on this account`
2529
+ };
2530
+ }
2531
+ const startedAt = Date.now();
2532
+ try {
2533
+ const account = await fetchHomeConnectAccount(parsed.data, deps.fetchImpl);
2534
+ return {
2535
+ outcome: "validated",
2536
+ latencyMs: Date.now() - startedAt,
2537
+ detail: account.summary
2538
+ };
2539
+ } catch (err) {
2540
+ const message = errMsg(err);
2541
+ if (message.includes("rejected")) return {
2542
+ outcome: "rejected",
2543
+ error: "Home Connect refused this account token. Sign in again."
2544
+ };
2545
+ logger.warn("Home Connect account test inconclusive", { meta: { error: message } });
2546
+ return {
2547
+ outcome: "inconclusive",
2548
+ error: "Could not reach Home Connect to read this account."
2549
+ };
2550
+ }
2551
+ }
2552
+ };
2553
+ }
2554
+ //#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 = {
2566
+ state: "unavailable",
2567
+ program: null,
2568
+ availablePrograms: [],
2569
+ remainingMinutes: null,
2570
+ progressPercent: null,
2571
+ doorOpen: false,
2572
+ remoteStartAllowed: false,
2573
+ powerOn: false,
2574
+ saltLevel: null,
2575
+ rinseAidLevel: null,
2576
+ halfLoad: false,
2577
+ hygienePlus: false,
2578
+ extraDry: false,
2579
+ intensiveZone: false,
2580
+ varioSpeedPlus: false,
2581
+ childLock: false,
2582
+ errorCode: null,
2583
+ errorLabel: null,
2584
+ lastFetchedAt: 0
2585
+ };
2586
+ var HomeConnectDishwasherDevice = class extends BaseDevice {
2587
+ features = [];
2588
+ #haId;
2589
+ #brokerId;
2590
+ #availablePrograms;
2591
+ #featureUnsub = null;
2592
+ #intensiveZoneWriteKey = HC_FEATURE_INTENSIVE_ZONE;
2593
+ constructor(ctx) {
2594
+ const persisted = homeConnectDishwasherSchema.parse(ctx.persistedConfig ?? {});
2595
+ super(ctx, homeConnectDishwasherSchema, { type: DeviceType.Dishwasher });
2596
+ this.#haId = persisted.haId;
2597
+ this.#brokerId = persisted.brokerId;
2598
+ this.#availablePrograms = persisted.availablePrograms;
2599
+ this.online = true;
2600
+ this.updateSourceInfo({
2601
+ id: this.#haId,
2602
+ system: "home-connect"
2603
+ });
2604
+ }
2605
+ async onActivate() {
2606
+ await super.onActivate();
2607
+ this.registerDishwasherCap();
2608
+ this.runtimeState.setCapState(DISH_CAP, COLD_START);
2609
+ this.attachFeatureListener();
2610
+ this.recomputeSlice();
2611
+ }
2612
+ async removeDevice() {
2613
+ this.#featureUnsub?.();
2614
+ this.#featureUnsub = null;
2615
+ await super.removeDevice();
2616
+ }
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
+ });
2625
+ }
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);
2637
+ const slice = dishwasherStatusFromFeatures({
2638
+ features,
2639
+ availablePrograms: this.#availablePrograms,
2640
+ nowMs: Date.now()
2641
+ });
2642
+ this.runtimeState.setCapState(DISH_CAP, slice);
2643
+ }
2644
+ requireClient() {
2645
+ const client = homeConnectConnections.getClient(this.#brokerId);
2646
+ if (!client) throw new Error(`Home Connect client unavailable (haId=${this.#haId})`);
2647
+ return client;
2648
+ }
2649
+ registerDishwasherCap() {
2650
+ 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, {});
2660
+ },
2661
+ setProgram: async ({ program }) => {
2662
+ await this.requireClient().setFeature(this.#haId, HC_CMD_SET_PROGRAM, program);
2663
+ },
2664
+ 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);
2669
+ },
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
+ }
2688
+ });
2689
+ }
2690
+ };
2691
+ //#endregion
2692
+ //#region src/home-connect/devices/home-connect-sensor-device.ts
2693
+ var homeConnectSensorSchema = object({
2694
+ haId: string().min(1),
2695
+ brokerId: string().min(1),
2696
+ sensorKind: string().min(1),
2697
+ system: literal("home-connect").optional(),
2698
+ integrationId: string().optional()
2699
+ });
2700
+ var NUMERIC_COLD = {
2701
+ value: 0,
2702
+ lastFetchedAt: 0
2703
+ };
2704
+ var POWER_COLD = { lastFetchedAt: 0 };
2705
+ var ENUM_COLD = {
2706
+ value: "unknown",
2707
+ lastFetchedAt: 0
2708
+ };
2709
+ var BINARY_COLD = {
2710
+ on: false,
2711
+ lastChangedAt: 0
2712
+ };
2713
+ /**
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, …).
2716
+ */
2717
+ var HomeConnectSensorDevice = class extends BaseDevice {
2718
+ features = [];
2719
+ #haId;
2720
+ #brokerId;
2721
+ #sensorKind;
2722
+ #capName;
2723
+ #featureUnsub = null;
2724
+ constructor(ctx) {
2725
+ const persisted = homeConnectSensorSchema.parse(ctx.persistedConfig ?? {});
2726
+ super(ctx, homeConnectSensorSchema, { type: ctx.deviceMeta.type });
2727
+ this.#haId = persisted.haId;
2728
+ this.#brokerId = persisted.brokerId;
2729
+ this.#sensorKind = persisted.sensorKind;
2730
+ const catalog = homeConnectSensorCatalogEntry(this.#sensorKind);
2731
+ this.#capName = catalog.cap;
2732
+ this.online = true;
2733
+ this.updateSourceInfo({
2734
+ id: `${this.#haId}:${catalog.stableIdSuffix}`,
2735
+ system: "home-connect"
2736
+ });
2737
+ }
2738
+ async onActivate() {
2739
+ await super.onActivate();
2740
+ this.registerCap();
2741
+ this.seedColdStart();
2742
+ this.attachFeatureListener();
2743
+ this.recomputeSlice();
2744
+ }
2745
+ async removeDevice() {
2746
+ this.#featureUnsub?.();
2747
+ this.#featureUnsub = null;
2748
+ await super.removeDevice();
2749
+ }
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() {
2768
+ 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();
2780
+ return;
2781
+ }
2782
+ const parsed = homeConnectSensorSliceFromFeatures({
2783
+ kind: this.#sensorKind,
2784
+ features: client.getFeatures(this.#haId),
2785
+ nowMs
2786
+ });
2787
+ if (parsed.cap === "numeric-sensor") {
2788
+ if (parsed.status === null) return;
2789
+ this.runtimeState.setCapState("numeric-sensor", parsed.status);
2790
+ return;
2791
+ }
2792
+ this.runtimeState.setCapState(parsed.cap, parsed.status);
2793
+ }
2794
+ 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;
2806
+ }
2807
+ this.ctx.registerNativeCap(binaryCapability, { getStatus: async () => this.runtimeState.getCapState("binary") ?? BINARY_COLD });
2808
+ }
2809
+ };
2810
+ //#endregion
2811
+ //#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
+ var homeConnectApplianceSchema = object({
2818
+ haId: string().min(1),
2819
+ brokerId: string().min(1),
2820
+ applianceType: string().optional(),
2821
+ availablePrograms: array(string()).default([]),
2822
+ children: array(homeConnectApplianceChildSchema).min(1),
2823
+ system: literal("home-connect").optional(),
2824
+ integrationId: string().optional()
2825
+ });
2826
+ /** One Home Connect appliance as a container with dishwasher + metric sensor children. */
2827
+ var HomeConnectApplianceDevice = class extends BaseDevice {
2828
+ features = [];
2829
+ integrationId;
2830
+ brokerId;
2831
+ haId;
2832
+ applianceType;
2833
+ availablePrograms;
2834
+ constructor(ctx) {
2835
+ const cfg = homeConnectApplianceSchema.parse(ctx.persistedConfig ?? {});
2836
+ super(ctx, homeConnectApplianceSchema, { type: DeviceType.Container });
2837
+ this.brokerId = cfg.brokerId;
2838
+ this.haId = cfg.haId;
2839
+ this.integrationId = cfg.integrationId;
2840
+ this.applianceType = cfg.applianceType;
2841
+ this.availablePrograms = cfg.availablePrograms;
2842
+ this.online = true;
2843
+ this.updateSourceInfo({
2844
+ id: cfg.haId,
2845
+ system: "home-connect"
2846
+ });
2847
+ }
2848
+ getAccessoryChildren() {
2849
+ const cfg = this.config.values;
2850
+ 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
+ };
2865
+ });
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
+ return out;
2921
+ }
2922
+ };
2923
+ //#endregion
2924
+ //#region src/home-connect/addon.ts
2925
+ var BoschHomeConnectProviderAddon = class extends BaseDeviceProvider {
2926
+ addonId = "bosch-home-connect";
2927
+ providerName = "Bosch Home Connect";
2928
+ deviceClasses = {
2929
+ [DeviceType.Container]: HomeConnectApplianceDevice,
2930
+ [DeviceType.Dishwasher]: HomeConnectDishwasherDevice,
2931
+ [DeviceType.Sensor]: HomeConnectSensorDevice
2932
+ };
2933
+ clients = null;
2934
+ constructor() {
2935
+ super({});
2936
+ }
2937
+ async onInitialize() {
2938
+ const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
2939
+ this.clients = new HomeConnectClientRegistry(this.ctx.logger, { onConnectionUpdated: (integrationId, connection) => {
2940
+ this.persistIntegrationAppliances(integrationId, connection);
2941
+ } });
2942
+ this.clients.setOnConnected((integrationId) => {
2943
+ this.setIntegrationDevicesOnline(integrationId, true);
2944
+ });
2945
+ this.clients.setOnDisconnected((integrationId) => {
2946
+ this.setIntegrationDevicesOnline(integrationId, false);
2947
+ });
2948
+ await this.reconcileIntegrations();
2949
+ this.subscribeIntegrationLifecycle();
2950
+ return {
2951
+ providers: [
2952
+ ...regs,
2953
+ {
2954
+ capability: deviceAdoptionCapability,
2955
+ provider: this.buildAdoptionProvider()
2956
+ },
2957
+ {
2958
+ capability: connectionTestCapability,
2959
+ provider: this.buildConnectionTestProvider()
2960
+ }
2961
+ ],
2962
+ customActions: homeConnectActions,
2963
+ actionHandlers: {
2964
+ beginSignIn: async () => {
2965
+ const started = beginHomeConnectSignIn();
2966
+ return {
2967
+ authorizeUrl: started.authorizeUrl,
2968
+ verifier: started.verifier,
2969
+ state: started.state,
2970
+ summary: "Open the link in the browser and sign in."
2971
+ };
2972
+ },
2973
+ finishSignIn: async (input) => {
2974
+ const account = await completeHomeConnectSignIn({
2975
+ verifier: input.verifier,
2976
+ callback: input.callback,
2977
+ state: input.state
2978
+ });
2979
+ const appliances = account.appliances.map((app) => ({
2980
+ haId: app.haId,
2981
+ name: app.name,
2982
+ type: app.type,
2983
+ host: app.connection.host,
2984
+ psk: app.connection.psk,
2985
+ mode: app.connection.mode,
2986
+ ...app.connection.iv64.length > 0 ? { iv64: app.connection.iv64 } : {},
2987
+ ...parseLoginDeviceDescription(app.connection.deviceDescription)
2988
+ }));
2989
+ return {
2990
+ token: account.token,
2991
+ ...account.refresh.length > 0 ? { refresh: account.refresh } : {},
2992
+ appliances: JSON.stringify(appliances),
2993
+ summary: account.summary
2994
+ };
2995
+ }
2996
+ }
2997
+ };
2998
+ }
2999
+ async onShutdown() {
3000
+ try {
3001
+ await this.clients?.shutdown();
3002
+ } catch (err) {
3003
+ this.ctx.logger.warn("Home Connect shutdown error", { meta: { error: errMsg(err) } });
3004
+ }
3005
+ this.clients = null;
3006
+ await super.onShutdown();
3007
+ }
3008
+ requireClients() {
3009
+ if (!this.clients) throw new Error("Home Connect provider not initialised");
3010
+ return this.clients;
3011
+ }
3012
+ generateStableId(type, config) {
3013
+ const integrationId = config?.["brokerId"];
3014
+ if (typeof integrationId !== "string" || integrationId.length === 0) throw new Error("home-connect generateStableId: brokerId required");
3015
+ const haId = config?.["haId"];
3016
+ if (typeof haId !== "string" || haId.length === 0) throw new Error("home-connect generateStableId: haId required");
3017
+ if (type === DeviceType.Container) return `home-connect:${integrationId}:${haId}:appliance`;
3018
+ return `home-connect:${integrationId}:${haId}`;
3019
+ }
3020
+ async persistIntegrationAppliances(integrationId, connection) {
3021
+ if (!connection.appliances || connection.appliances.length === 0) return;
3022
+ try {
3023
+ const merged = homeConnectSettingsForPersist({
3024
+ ...await this.ctx.api.integrations.getSettings.query({ id: integrationId }),
3025
+ appliances: connection.appliances
3026
+ });
3027
+ await this.ctx.api.integrations.setSettings.mutate({
3028
+ id: integrationId,
3029
+ settings: merged
3030
+ });
3031
+ } catch (err) {
3032
+ this.ctx.logger.warn("Home Connect failed to persist enriched appliance IDDF", { meta: {
3033
+ integrationId,
3034
+ error: errMsg(err)
3035
+ } });
3036
+ }
3037
+ }
3038
+ async reconcileIntegrations() {
3039
+ const reg = this.requireClients();
3040
+ try {
3041
+ const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id && i.enabled);
3042
+ const surviving = /* @__PURE__ */ new Set();
3043
+ for (const integration of mine) try {
3044
+ const settings = await this.ctx.api.integrations.getSettings.query({ id: integration.id });
3045
+ const connection = connectionFromSettings(settings);
3046
+ if (!connection) {
3047
+ logHomeConnectSettingsGap(this.ctx.logger, integration.id, settings);
3048
+ continue;
3049
+ }
3050
+ await reg.upsert(integration.id, integration.name, connection);
3051
+ const brokerInfo = reg.getInfo(integration.id);
3052
+ const applianceCount = typeof brokerInfo?.info["applianceCount"] === "number" ? brokerInfo.info["applianceCount"] : 0;
3053
+ this.ctx.logger.info("Home Connect integration reconciled", { meta: {
3054
+ integrationId: integration.id,
3055
+ applianceCount
3056
+ } });
3057
+ surviving.add(integration.id);
3058
+ } catch (err) {
3059
+ this.ctx.logger.warn("Home Connect reconcile failed for integration", { meta: {
3060
+ integrationId: integration.id,
3061
+ error: errMsg(err)
3062
+ } });
3063
+ }
3064
+ const vanished = reg.list().filter((id) => !surviving.has(id));
3065
+ await reg.retain(surviving);
3066
+ const deviceReg = this.ctx.kernel.deviceRegistry;
3067
+ const deviceApi = this.ctx.kernel.devices;
3068
+ if (deviceReg && deviceApi) for (const integrationId of vanished) await cascadeRemoveDevicesForIntegration({
3069
+ reg: deviceReg,
3070
+ devices: deviceApi,
3071
+ addonId: this.addonId,
3072
+ system: "home-connect",
3073
+ integrationId,
3074
+ logger: this.ctx.logger
3075
+ });
3076
+ } catch (err) {
3077
+ this.ctx.logger.warn("Home Connect integration reconcile failed", { meta: { error: errMsg(err) } });
3078
+ }
3079
+ }
3080
+ subscribeIntegrationLifecycle() {
3081
+ const handler = (event) => {
3082
+ const addonId = event.data["addonId"];
3083
+ if (typeof addonId === "string" && addonId !== this.ctx.id) return;
3084
+ this.reconcileIntegrations();
3085
+ };
3086
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled }, handler);
3087
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, handler);
3088
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, handler);
3089
+ }
3090
+ buildConnectionTestProvider() {
3091
+ return buildHomeConnectConnectionTestProvider({ logger: this.ctx.logger });
3092
+ }
3093
+ buildAdoptionProvider() {
3094
+ return buildHomeConnectAdoptionProvider({
3095
+ registry: homeConnectConnections,
3096
+ hasIntegration: (id) => this.requireClients().has(id),
3097
+ listIntegrations: () => this.requireClients().list(),
3098
+ listAdopted: async () => {
3099
+ const reg = this.ctx.kernel.deviceRegistry;
3100
+ const devices = this.ctx.kernel.devices;
3101
+ if (!reg || !devices) return [];
3102
+ const out = [];
3103
+ for (const d of reg.getAllForAddon(this.addonId)) {
3104
+ const config = await devices.loadConfig(d.id).catch(() => ({}));
3105
+ out.push({
3106
+ id: d.id,
3107
+ config
3108
+ });
3109
+ }
3110
+ return out;
3111
+ },
3112
+ adoptDevice: async ({ haId, integrationId, type, name, applianceType, availablePrograms }) => {
3113
+ const devices = this.ctx.kernel.devices;
3114
+ if (!devices) throw new Error("home-connect adopt: devices unavailable");
3115
+ 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
+ const config = {
3122
+ haId,
3123
+ brokerId: integrationId,
3124
+ applianceType,
3125
+ availablePrograms: [...availablePrograms],
3126
+ children,
3127
+ system: "home-connect",
3128
+ integrationId
3129
+ };
3130
+ const stableId = this.generateStableId(type, config);
3131
+ return { deviceId: (await devices.create(stableId, HomeConnectApplianceDevice, config, null, {
3132
+ type: DeviceType.Container,
3133
+ name,
3134
+ integrationId
3135
+ })).id };
3136
+ },
3137
+ removeDevice: async (id) => {
3138
+ await this.ctx.kernel.devices?.remove(id);
3139
+ },
3140
+ findDeviceConfig: async (id) => {
3141
+ const devices = this.ctx.kernel.devices;
3142
+ if (!devices) return null;
3143
+ return devices.loadConfig(id).catch(() => null);
3144
+ },
3145
+ logger: this.ctx.logger
3146
+ });
3147
+ }
3148
+ globalSettingsSchema() {
3149
+ return buildHomeConnectFormSchema();
3150
+ }
3151
+ async supportsManualCreation() {
3152
+ return false;
3153
+ }
3154
+ async onGetCreationSchema(_type) {
3155
+ return null;
3156
+ }
3157
+ async onCreateDevice(_type, _config) {
3158
+ throw new Error("Home Connect devices are adopted from the gateway");
3159
+ }
3160
+ setIntegrationDevicesOnline(integrationId, online) {
3161
+ const reg = this.ctx.kernel.deviceRegistry;
3162
+ if (!reg) return;
3163
+ setIntegrationDevicesOnline(reg.getAllForAddon(this.addonId), "home-connect", integrationId, online);
3164
+ }
3165
+ };
3166
+ //#endregion
3167
+ export { BoschHomeConnectProviderAddon };