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

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,1624 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_dist = require("../dist-QXDYCZIL.js");
3
+ const require_account_integration_offline = require("../account-integration-offline-Cbixus9v.js");
4
+ let node_crypto = require("node:crypto");
5
+ //#region src/home-connect/custom-actions.ts
6
+ var homeConnectActions = require_dist.defineCustomActions({
7
+ beginSignIn: require_dist.customAction(require_dist.object({}), require_dist.object({
8
+ authorizeUrl: require_dist.string().min(1),
9
+ verifier: require_dist.string().min(1),
10
+ state: require_dist.string().min(1),
11
+ summary: require_dist.string()
12
+ }), {
13
+ kind: "mutation",
14
+ auth: "admin"
15
+ }),
16
+ finishSignIn: require_dist.customAction(require_dist.object({
17
+ verifier: require_dist.string().min(1),
18
+ callback: require_dist.string().min(1),
19
+ state: require_dist.string().min(1)
20
+ }), require_dist.object({
21
+ token: require_dist.string().min(1),
22
+ refresh: require_dist.string().optional(),
23
+ appliances: require_dist.string().min(1),
24
+ summary: require_dist.string()
25
+ }), {
26
+ kind: "mutation",
27
+ auth: "admin"
28
+ })
29
+ });
30
+ //#endregion
31
+ //#region src/home-connect/home-connect-login.ts
32
+ /** Public OAuth client ids from the Home Connect app login (hcpy). */
33
+ var APP_ID = "9B75AC9EC512F36C84256AC47D813E2C1DD0D6520DF774B020E1E6E2EB29B1F3";
34
+ var OAUTH_BASE = "https://api.home-connect.com/security/oauth/";
35
+ var ASSET_BASE = "https://eu.services.home-connect.com/";
36
+ var REDIRECT_URI = "hcauth://auth/prod";
37
+ var AUTHORIZE_SCOPE = "Control DeleteAppliance IdentifyAppliance Images Monitor ReadAccount ReadOrigApi Settings WriteAppliance WriteOrigApi";
38
+ var CookieJar = class {
39
+ #cookies = /* @__PURE__ */ new Map();
40
+ absorb(headers) {
41
+ const listed = headers.getSetCookie?.() ?? [];
42
+ const lines = listed.length > 0 ? listed : headerList(headers.get("set-cookie"));
43
+ for (const line of lines) {
44
+ const pair = line.split(";")[0] ?? "";
45
+ const eq = pair.indexOf("=");
46
+ if (eq <= 0) continue;
47
+ this.#cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
48
+ }
49
+ }
50
+ header() {
51
+ return [...this.#cookies.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
52
+ }
53
+ get(name) {
54
+ return this.#cookies.get(name);
55
+ }
56
+ };
57
+ function headerList(value) {
58
+ return value ? [value] : [];
59
+ }
60
+ async function defaultFetch(url, init) {
61
+ const response = await fetch(url, {
62
+ method: init.method,
63
+ headers: { ...init.headers },
64
+ body: init.body,
65
+ redirect: "manual"
66
+ });
67
+ return {
68
+ status: response.status,
69
+ headers: response.headers,
70
+ text: () => response.text()
71
+ };
72
+ }
73
+ function b64url(bytes) {
74
+ return bytes.toString("base64url");
75
+ }
76
+ async function exchange(fetchImpl, jar, url, method, extraHeaders, body) {
77
+ const headers = {
78
+ "user-agent": "hc-login/1.0",
79
+ accept: "application/json, text/html",
80
+ ...extraHeaders
81
+ };
82
+ const cookie = jar.header();
83
+ if (cookie.length > 0) headers["cookie"] = cookie;
84
+ const csrf = jar.get("X-CSRF-FORM-TOKEN");
85
+ if (csrf) headers["RequestVerificationToken"] = csrf;
86
+ const response = await fetchImpl(url, {
87
+ method,
88
+ headers,
89
+ body,
90
+ redirect: "manual"
91
+ });
92
+ jar.absorb(response.headers);
93
+ return {
94
+ status: response.status,
95
+ location: response.headers.get("location"),
96
+ body: await response.text()
97
+ };
98
+ }
99
+ function isRecord(value) {
100
+ return typeof value === "object" && value !== null && !Array.isArray(value);
101
+ }
102
+ function stringField(record, key) {
103
+ const value = record[key];
104
+ return typeof value === "string" ? value : void 0;
105
+ }
106
+ function tokensFromBody(body) {
107
+ const tokenJson = JSON.parse(body);
108
+ if (!isRecord(tokenJson)) throw new Error("Home Connect sign-in was rejected");
109
+ const token = tokenJson["access_token"];
110
+ const refresh = tokenJson["refresh_token"];
111
+ if (typeof token !== "string" || token.length === 0) throw new Error("Home Connect sign-in was rejected");
112
+ return {
113
+ token,
114
+ refresh: typeof refresh === "string" ? refresh : ""
115
+ };
116
+ }
117
+ function accountSummary(appliances) {
118
+ if (appliances.length === 0) return "Signed in. No appliances on this account yet.";
119
+ const names = appliances.map((app) => app.name).join(", ");
120
+ return `Signed in. ${appliances.length} appliance${appliances.length === 1 ? "" : "s"}: ${names}.`;
121
+ }
122
+ function accountIdFromAccessToken(token) {
123
+ const payload = token.split(".")[1];
124
+ if (!payload) throw new Error("Home Connect sign-in was rejected");
125
+ const padded = payload + "=".repeat((4 - payload.length % 4) % 4);
126
+ let parsed;
127
+ try {
128
+ parsed = JSON.parse(Buffer.from(padded, "base64url").toString("utf8"));
129
+ } catch {
130
+ throw new Error("Home Connect sign-in was rejected");
131
+ }
132
+ if (!isRecord(parsed) || typeof parsed["sub"] !== "string" || parsed["sub"].length === 0) throw new Error("Home Connect sign-in was rejected");
133
+ return parsed["sub"];
134
+ }
135
+ function listedFromPaired(row, encryption) {
136
+ if (row["isDemo"] === true) return null;
137
+ const haId = stringField(row, "haId");
138
+ if (!haId) return null;
139
+ const brand = (stringField(row, "brand") ?? "HomeConnect").toUpperCase();
140
+ const type = stringField(row, "haType") ?? stringField(row, "type") ?? "Appliance";
141
+ const tls = isRecord(encryption["tls"]) ? encryption["tls"] : void 0;
142
+ const aes = isRecord(encryption["aes"]) ? encryption["aes"] : void 0;
143
+ const tlsKey = tls ? stringField(tls, "key") : void 0;
144
+ const aesKey = aes ? stringField(aes, "key") : void 0;
145
+ const mode = tlsKey ? "TLS" : "AES";
146
+ const psk = tlsKey ?? aesKey;
147
+ if (!psk) return null;
148
+ const iv = aes ? stringField(aes, "iv") : void 0;
149
+ if (mode === "AES" && !iv) return null;
150
+ const host = mode === "TLS" ? `${brand}-${type}-${haId}` : haId;
151
+ return {
152
+ haId,
153
+ name: stringField(row, "vib") ?? `${brand} ${type}`,
154
+ type,
155
+ connection: {
156
+ host,
157
+ psk,
158
+ mode,
159
+ iv64: iv ?? "",
160
+ deviceDescription: JSON.stringify({ info: {
161
+ deviceID: haId,
162
+ deviceType: type,
163
+ model: `${brand} ${type}`
164
+ } })
165
+ }
166
+ };
167
+ }
168
+ async function appliancesForToken(token, fetchImpl, jar) {
169
+ const accountId = accountIdFromAccessToken(token);
170
+ const listed = await exchange(fetchImpl, jar, `${ASSET_BASE}api/account/v2/accounts/${encodeURIComponent(accountId)}/paired-appliances`, "GET", {
171
+ authorization: `Bearer ${token}`,
172
+ accept: "application/json"
173
+ });
174
+ if (listed.status >= 400) throw new Error("Home Connect sign-in was rejected");
175
+ let parsed;
176
+ try {
177
+ parsed = JSON.parse(listed.body);
178
+ } catch {
179
+ throw new Error("Home Connect sign-in was rejected");
180
+ }
181
+ if (!isRecord(parsed) || !Array.isArray(parsed["appliances"])) throw new Error("Home Connect sign-in was rejected");
182
+ const out = [];
183
+ for (const row of parsed["appliances"]) {
184
+ if (!isRecord(row)) continue;
185
+ const haId = stringField(row, "haId");
186
+ if (!haId) continue;
187
+ const enc = await exchange(fetchImpl, jar, `${ASSET_BASE}api/appliance/v2/appliances/${encodeURIComponent(haId)}/encryption-information`, "GET", {
188
+ authorization: `Bearer ${token}`,
189
+ accept: "application/json"
190
+ });
191
+ if (enc.status >= 400) continue;
192
+ let encryption;
193
+ try {
194
+ encryption = JSON.parse(enc.body);
195
+ } catch {
196
+ continue;
197
+ }
198
+ if (!isRecord(encryption)) continue;
199
+ const appliance = listedFromPaired(row, encryption);
200
+ if (appliance) out.push(appliance);
201
+ }
202
+ return out;
203
+ }
204
+ /** Load every appliance on the account. A stale access token is refreshed once when a refresh token exists. */
205
+ async function fetchHomeConnectAccount(creds, fetchImpl = defaultFetch) {
206
+ const jar = new CookieJar();
207
+ let token = creds.token;
208
+ let refresh = creds.refresh ?? "";
209
+ try {
210
+ const appliances = await appliancesForToken(token, fetchImpl, jar);
211
+ return {
212
+ token,
213
+ refresh,
214
+ appliances,
215
+ summary: accountSummary(appliances)
216
+ };
217
+ } catch (err) {
218
+ if (refresh.length === 0) throw err;
219
+ }
220
+ const refreshed = await exchange(fetchImpl, jar, `${OAUTH_BASE}token`, "POST", { "content-type": "application/x-www-form-urlencoded" }, new URLSearchParams({
221
+ grant_type: "refresh_token",
222
+ refresh_token: refresh,
223
+ client_id: APP_ID
224
+ }).toString());
225
+ if (refreshed.status >= 400) throw new Error("Home Connect sign-in was rejected");
226
+ const next = tokensFromBody(refreshed.body);
227
+ token = next.token;
228
+ refresh = next.refresh;
229
+ const appliances = await appliancesForToken(token, fetchImpl, jar);
230
+ return {
231
+ token,
232
+ refresh,
233
+ appliances,
234
+ summary: accountSummary(appliances)
235
+ };
236
+ }
237
+ /** Browser login link, matching Home Connect Local (`redirect_url` + the app scope). */
238
+ function beginHomeConnectSignIn() {
239
+ const verifier = b64url((0, node_crypto.randomBytes)(32));
240
+ const challenge = b64url((0, node_crypto.createHash)("sha256").update(verifier).digest());
241
+ const state = b64url((0, node_crypto.randomBytes)(16));
242
+ const authorize = new URL("authorize", OAUTH_BASE);
243
+ authorize.searchParams.set("redirect_url", REDIRECT_URI);
244
+ authorize.searchParams.set("client_id", APP_ID);
245
+ authorize.searchParams.set("response_type", "code");
246
+ authorize.searchParams.set("prompt", "login");
247
+ authorize.searchParams.set("code_challenge_method", "S256");
248
+ authorize.searchParams.set("code_challenge", challenge);
249
+ authorize.searchParams.set("state", state);
250
+ authorize.searchParams.set("nonce", b64url((0, node_crypto.randomBytes)(16)));
251
+ authorize.searchParams.set("scope", AUTHORIZE_SCOPE);
252
+ return {
253
+ authorizeUrl: authorize.toString(),
254
+ verifier,
255
+ state
256
+ };
257
+ }
258
+ function callbackParams(callback) {
259
+ let url;
260
+ try {
261
+ url = new URL(callback.trim());
262
+ } catch {
263
+ throw new Error("Paste the full redirect address from the browser");
264
+ }
265
+ const code = url.searchParams.get("code") ?? url.hash.replace(/^#/, "").match(/(?:^|&)code=([^&]+)/)?.[1];
266
+ const state = url.searchParams.get("state") ?? "";
267
+ if (!code) throw new Error("Paste the full redirect address from the browser");
268
+ return {
269
+ code,
270
+ grantType: url.searchParams.get("grant_type") ?? "authorization_code",
271
+ state
272
+ };
273
+ }
274
+ /** Exchange the pasted redirect for the account token and each appliance profile. */
275
+ async function completeHomeConnectSignIn(input, fetchImpl = defaultFetch) {
276
+ const parsed = callbackParams(input.callback);
277
+ if (parsed.state !== input.state) throw new Error("State mismatch. Create the login link again.");
278
+ const { code, grantType } = parsed;
279
+ const token = await exchange(fetchImpl, new CookieJar(), `${OAUTH_BASE}token`, "POST", { "content-type": "application/x-www-form-urlencoded" }, new URLSearchParams({
280
+ grant_type: grantType,
281
+ client_id: APP_ID,
282
+ code,
283
+ code_verifier: input.verifier,
284
+ redirect_uri: REDIRECT_URI
285
+ }).toString());
286
+ if (token.status >= 400) throw new Error("Home Connect sign-in was rejected");
287
+ return fetchHomeConnectAccount(tokensFromBody(token.body), fetchImpl);
288
+ }
289
+ //#endregion
290
+ //#region src/home-connect/home-connect-discovery.ts
291
+ function deviceTypeForApplianceType(type) {
292
+ if (type.toLowerCase().includes("dishwasher")) return require_dist.DeviceType.Dishwasher;
293
+ return null;
294
+ }
295
+ function buildHomeConnectCandidates(input) {
296
+ const out = [];
297
+ for (const app of input.appliances) {
298
+ const camType = deviceTypeForApplianceType(app.type);
299
+ if (camType === null) continue;
300
+ const adoptedId = input.adopted.get(app.haId) ?? null;
301
+ out.push({
302
+ childNativeId: app.haId,
303
+ name: app.name.length > 0 ? app.name : app.haId,
304
+ type: camType,
305
+ status: "online",
306
+ metadata: {
307
+ serialNumber: app.haId,
308
+ model: app.type
309
+ },
310
+ alreadyAdopted: adoptedId !== null,
311
+ adoptedDeviceId: adoptedId,
312
+ capabilities: camType === require_dist.DeviceType.Dishwasher ? ["dishwasher"] : []
313
+ });
314
+ }
315
+ return out;
316
+ }
317
+ //#endregion
318
+ //#region src/home-connect/home-connect-adoption-provider.ts
319
+ var DEVICES_FILTER = {
320
+ id: "devices",
321
+ label: "Devices",
322
+ isDefault: true
323
+ };
324
+ async function adoptedMapForIntegration(integrationId, listAdopted) {
325
+ const all = await listAdopted();
326
+ const map = /* @__PURE__ */ new Map();
327
+ for (const device of all) if (device.config["system"] === "home-connect" && device.config["brokerId"] === integrationId) {
328
+ const haId = device.config["haId"];
329
+ if (typeof haId === "string") map.set(haId, device.id);
330
+ }
331
+ return map;
332
+ }
333
+ function buildHomeConnectAdoptionProvider(deps) {
334
+ const { registry, hasIntegration, listIntegrations, listAdopted, adoptDevice, removeDevice, findDeviceConfig, logger } = deps;
335
+ async function allCandidatesForIntegration(integrationId) {
336
+ return buildHomeConnectCandidates({
337
+ appliances: registry.appliances(integrationId),
338
+ adopted: await adoptedMapForIntegration(integrationId, listAdopted)
339
+ });
340
+ }
341
+ return {
342
+ listCandidateFilters: async () => ({ filters: [DEVICES_FILTER] }),
343
+ listCandidates: async ({ integrationId, page, pageSize }) => {
344
+ const cands = await allCandidatesForIntegration(integrationId);
345
+ const start = (page - 1) * pageSize;
346
+ return {
347
+ candidates: cands.slice(start, start + pageSize),
348
+ totalCount: cands.length,
349
+ page,
350
+ pageSize
351
+ };
352
+ },
353
+ getCandidate: async ({ integrationId, childNativeId }) => {
354
+ return (await allCandidatesForIntegration(integrationId)).find((c) => c.childNativeId === childNativeId) ?? null;
355
+ },
356
+ getStatus: async () => {
357
+ try {
358
+ let candidateCount = 0;
359
+ for (const integrationId of listIntegrations()) candidateCount += (await allCandidatesForIntegration(integrationId)).length;
360
+ const adoptedCount = (await listAdopted()).filter((d) => d.config["system"] === "home-connect").length;
361
+ return {
362
+ lastDiscoveryAt: Date.now(),
363
+ candidateCount,
364
+ adoptedCount,
365
+ lastError: null
366
+ };
367
+ } catch (err) {
368
+ return {
369
+ lastDiscoveryAt: null,
370
+ candidateCount: 0,
371
+ adoptedCount: 0,
372
+ lastError: require_dist.errMsg(err)
373
+ };
374
+ }
375
+ },
376
+ refresh: async ({ integrationId }) => {
377
+ const candidateCount = (await allCandidatesForIntegration(integrationId)).length;
378
+ const adoptedCount = (await adoptedMapForIntegration(integrationId, listAdopted)).size;
379
+ return {
380
+ lastDiscoveryAt: Date.now(),
381
+ candidateCount,
382
+ adoptedCount,
383
+ lastError: null
384
+ };
385
+ },
386
+ adopt: async ({ integrationId, childNativeIds, perCandidate }) => {
387
+ if (!registry.has(integrationId)) throw new Error(`home-connect adopt: integration ${integrationId} not connected`);
388
+ const appliances = registry.appliances(integrationId);
389
+ const adopted = [];
390
+ let failures = 0;
391
+ for (const haId of childNativeIds) try {
392
+ const app = appliances.find((a) => a.haId === haId);
393
+ if (!app) {
394
+ failures++;
395
+ continue;
396
+ }
397
+ const candidate = buildHomeConnectCandidates({
398
+ appliances: [app],
399
+ adopted: /* @__PURE__ */ new Map()
400
+ })[0];
401
+ if (!candidate) {
402
+ failures++;
403
+ continue;
404
+ }
405
+ const name = perCandidate?.[haId]?.name ?? app.name;
406
+ const { deviceId } = await adoptDevice({
407
+ haId,
408
+ integrationId,
409
+ type: candidate.type,
410
+ name,
411
+ applianceType: app.type,
412
+ availablePrograms: app.availablePrograms
413
+ });
414
+ adopted.push({
415
+ childNativeId: haId,
416
+ parentDeviceId: deviceId
417
+ });
418
+ } catch (err) {
419
+ logger.warn("home-connect adopt failed", { meta: {
420
+ haId,
421
+ integrationId,
422
+ error: require_dist.errMsg(err)
423
+ } });
424
+ failures++;
425
+ }
426
+ if (adopted.length === 0 && failures > 0) throw new Error(`home-connect adopt: all ${failures} adopt(s) failed`);
427
+ return { adopted: adopted.map((a) => ({
428
+ ...a,
429
+ accessoryDeviceIds: []
430
+ })) };
431
+ },
432
+ release: async ({ camDeviceId }) => {
433
+ await removeDevice(camDeviceId);
434
+ },
435
+ resync: async ({ camDeviceId }) => {
436
+ const cfg = await findDeviceConfig(camDeviceId);
437
+ if (!cfg || cfg["system"] !== "home-connect") throw new Error(`home-connect resync: device ${camDeviceId} not found`);
438
+ const integrationId = String(cfg["brokerId"]);
439
+ const haId = String(cfg["haId"]);
440
+ if (!hasIntegration(integrationId)) throw new Error(`home-connect resync: integration ${integrationId} not connected`);
441
+ if (!registry.appliances(integrationId).some((a) => a.haId === haId)) throw new Error(`home-connect resync: appliance ${haId} no longer present`);
442
+ return {
443
+ changed: false,
444
+ rebuiltChildren: 0
445
+ };
446
+ }
447
+ };
448
+ }
449
+ var HomeConnectModeSchema = require_dist._enum(["AES", "TLS"]);
450
+ function isPlainObject$1(value) {
451
+ return value !== null && typeof value === "object" && !Array.isArray(value);
452
+ }
453
+ var homeConnectDeviceDescriptionSchema = require_dist.union([require_dist.string(), require_dist.record(require_dist.string(), require_dist.unknown())]).optional();
454
+ require_dist.object({
455
+ host: require_dist.string().min(1),
456
+ psk: require_dist.string().min(1).meta({ secret: true }),
457
+ mode: HomeConnectModeSchema.default("AES"),
458
+ iv64: require_dist.string().optional(),
459
+ deviceDescription: homeConnectDeviceDescriptionSchema
460
+ });
461
+ /** One appliance saved at sign-in, so adopt does not need another cloud call. */
462
+ var homeConnectStoredApplianceSchema = require_dist.object({
463
+ haId: require_dist.string().min(1),
464
+ name: require_dist.string(),
465
+ type: require_dist.string(),
466
+ host: require_dist.string().min(1),
467
+ psk: require_dist.string().min(1),
468
+ mode: HomeConnectModeSchema,
469
+ iv64: require_dist.string().optional()
470
+ });
471
+ /** The integration is the Home Connect account. Profiles are downloaded once at sign-in. */
472
+ var homeConnectConfigSchema = require_dist.object({
473
+ token: require_dist.string().min(1).describe("Home Connect access token").meta({ secret: true }),
474
+ refresh: require_dist.string().optional().describe("Home Connect refresh token").meta({ secret: true }),
475
+ appliances: require_dist.array(homeConnectStoredApplianceSchema).optional()
476
+ });
477
+ /**
478
+ * Before `integrations.setSettings`: keep objects as-is (serializes as `json`), parse
479
+ * textarea JSON strings into objects so they are not re-serialized as `string` and
480
+ * corrupted on read by `deserializeSetting(String(object))`.
481
+ */
482
+ function homeConnectSettingsForPersist(settings) {
483
+ const next = { ...settings };
484
+ delete next["accountEmail"];
485
+ delete next["accountPassword"];
486
+ delete next["authorizeUrl"];
487
+ delete next["verifier"];
488
+ delete next["callback"];
489
+ delete next["state"];
490
+ if (next["refresh"] === "") delete next["refresh"];
491
+ const appliancesRaw = next["appliances"];
492
+ if (typeof appliancesRaw === "string" && appliancesRaw.trim().startsWith("[")) try {
493
+ const parsedAppliances = JSON.parse(appliancesRaw);
494
+ if (Array.isArray(parsedAppliances)) next["appliances"] = parsedAppliances;
495
+ } catch {}
496
+ const raw = next["deviceDescription"];
497
+ if (typeof raw !== "string") return next;
498
+ const trimmed = raw.trim();
499
+ if (trimmed.length === 0) return next;
500
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return next;
501
+ try {
502
+ const parsed = JSON.parse(trimmed);
503
+ if (isPlainObject$1(parsed)) return {
504
+ ...next,
505
+ deviceDescription: parsed
506
+ };
507
+ } catch {
508
+ return next;
509
+ }
510
+ return next;
511
+ }
512
+ function connectionFromSettings(settings) {
513
+ const parsed = homeConnectConfigSchema.safeParse(homeConnectSettingsForPersist(settings));
514
+ return parsed.success ? parsed.data : null;
515
+ }
516
+ function hasNonEmptyString(settings, key) {
517
+ const value = settings[key];
518
+ return typeof value === "string" && value.trim().length > 0;
519
+ }
520
+ /** Why reconcile cannot build a connection; null when `connectionFromSettings` would succeed. */
521
+ function homeConnectSettingsGap(settings) {
522
+ if (connectionFromSettings(settings) !== null) return null;
523
+ const token = settings["token"];
524
+ if (require_dist.isSecretMarker(token) || require_dist.isSecretMarkerLike(token)) return "token-unresolved-marker";
525
+ if (!hasNonEmptyString(settings, "token")) return "token-missing";
526
+ return "schema-invalid";
527
+ }
528
+ /** Reconcile path: warn once per incomplete integration (never logs secret values). */
529
+ function logHomeConnectSettingsGap(logger, integrationId, settings) {
530
+ const gap = homeConnectSettingsGap(settings);
531
+ if (gap === null) return;
532
+ const token = settings["token"];
533
+ logger.warn("Home Connect integration has incomplete account settings — skipping", { meta: {
534
+ integrationId,
535
+ gap,
536
+ tokenIsSecretMarker: require_dist.isSecretMarker(token),
537
+ tokenType: typeof token,
538
+ hasToken: hasNonEmptyString(settings, "token"),
539
+ hasRefresh: hasNonEmptyString(settings, "refresh")
540
+ } });
541
+ }
542
+ function sameHomeConnectConnection(a, b) {
543
+ if (a === null || b === null) return false;
544
+ return a.token === b.token && (a.refresh ?? "") === (b.refresh ?? "") && JSON.stringify(a.appliances ?? []) === JSON.stringify(b.appliances ?? []);
545
+ }
546
+ /** Sentinel so the gateway fields stay filled by sign-in and stay off the form. */
547
+ var HOME_CONNECT_PAIRED_ONLY = "__paired_only__";
548
+ function buildHomeConnectFormSchema() {
549
+ const pairedOnly = {
550
+ field: "authorizeUrl",
551
+ equals: HOME_CONNECT_PAIRED_ONLY
552
+ };
553
+ return { sections: [{
554
+ id: "gateway",
555
+ title: "Home Connect",
556
+ 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.",
557
+ columns: 1,
558
+ fields: [
559
+ {
560
+ type: "addon-action-button",
561
+ addonId: "bosch-home-connect",
562
+ key: "__home-connect-begin",
563
+ label: "Home Connect",
564
+ buttonLabel: "Create login link",
565
+ action: "beginSignIn",
566
+ variant: "primary",
567
+ assignResult: {
568
+ authorizeUrl: "authorizeUrl",
569
+ verifier: "verifier",
570
+ state: "state"
571
+ },
572
+ successMessage: "Open the link in Chrome and sign in."
573
+ },
574
+ {
575
+ type: "textarea",
576
+ key: "authorizeUrl",
577
+ label: "Login link",
578
+ required: false,
579
+ omitFromSettings: true
580
+ },
581
+ {
582
+ type: "text",
583
+ key: "callback",
584
+ label: "Redirect address",
585
+ required: false,
586
+ placeholder: "hcauth://auth/prod?code=…&state=…",
587
+ omitFromSettings: true
588
+ },
589
+ {
590
+ type: "password",
591
+ key: "state",
592
+ label: "Login state",
593
+ required: false,
594
+ omitFromSettings: true,
595
+ showWhen: pairedOnly
596
+ },
597
+ {
598
+ type: "password",
599
+ key: "verifier",
600
+ label: "Login verifier",
601
+ required: false,
602
+ omitFromSettings: true,
603
+ showWhen: pairedOnly
604
+ },
605
+ {
606
+ type: "addon-action-button",
607
+ addonId: "bosch-home-connect",
608
+ key: "__home-connect-finish",
609
+ label: "Finish",
610
+ buttonLabel: "Complete sign-in",
611
+ action: "finishSignIn",
612
+ variant: "primary",
613
+ paramsFromForm: {
614
+ verifier: "verifier",
615
+ callback: "callback",
616
+ state: "state"
617
+ },
618
+ assignResult: {
619
+ token: "token",
620
+ refresh: "refresh",
621
+ appliances: "appliances"
622
+ },
623
+ successMessage: "{summary}"
624
+ },
625
+ {
626
+ type: "password",
627
+ key: "token",
628
+ label: "Access token",
629
+ required: true,
630
+ showWhen: pairedOnly
631
+ },
632
+ {
633
+ type: "password",
634
+ key: "refresh",
635
+ label: "Refresh token",
636
+ required: false,
637
+ showWhen: pairedOnly
638
+ },
639
+ {
640
+ type: "textarea",
641
+ key: "appliances",
642
+ label: "Appliances",
643
+ required: true,
644
+ showWhen: pairedOnly
645
+ }
646
+ ]
647
+ }] };
648
+ }
649
+ /** WebSocket URL for local Home Connect (plain WS on port 80 path /homeconnect). */
650
+ function homeConnectWsUrl(config) {
651
+ return `${config.mode === "TLS" ? "wss" : "ws"}://${config.host.replace(/^https?:\/\//, "").replace(/\/$/, "")}/homeconnect`;
652
+ }
653
+ //#endregion
654
+ //#region src/home-connect/home-connect-gateway.ts
655
+ var HomeConnectConnectionResolver = class {
656
+ #surfaces = /* @__PURE__ */ new Map();
657
+ set(integrationId, surface) {
658
+ if (surface === null) {
659
+ this.#surfaces.delete(integrationId);
660
+ return;
661
+ }
662
+ this.#surfaces.set(integrationId, surface);
663
+ }
664
+ appliances(integrationId) {
665
+ return this.#surfaces.get(integrationId)?.appliances ?? [];
666
+ }
667
+ has(integrationId) {
668
+ return this.#surfaces.has(integrationId);
669
+ }
670
+ list() {
671
+ return Array.from(this.#surfaces.keys()).map((id) => ({ id }));
672
+ }
673
+ getClient(integrationId) {
674
+ return this.#surfaces.get(integrationId)?.client ?? null;
675
+ }
676
+ clear() {
677
+ this.#surfaces.clear();
678
+ }
679
+ };
680
+ var homeConnectConnections = new HomeConnectConnectionResolver();
681
+ //#endregion
682
+ //#region src/home-connect/home-connect-ws-client.ts
683
+ var WS_OPEN = 1;
684
+ function programNamesFromLegacyList(programsRaw) {
685
+ const programs = [];
686
+ if (!Array.isArray(programsRaw)) return programs;
687
+ for (const p of programsRaw) if (typeof p === "string") programs.push(p);
688
+ return programs;
689
+ }
690
+ function programNamesFromHaDeviceDescription(programsRaw) {
691
+ const programs = [];
692
+ if (!Array.isArray(programsRaw)) return programs;
693
+ for (const p of programsRaw) if (typeof p === "object" && p !== null) {
694
+ const name = p["name"];
695
+ if (typeof name === "string") programs.push(name);
696
+ } else if (typeof p === "string") programs.push(p);
697
+ return programs;
698
+ }
699
+ function parseLegacyAppliancesList(rawList) {
700
+ if (!Array.isArray(rawList)) return [];
701
+ const appliances = [];
702
+ for (const item of rawList) {
703
+ if (typeof item !== "object" || item === null) continue;
704
+ const row = item;
705
+ const haId = row["haId"];
706
+ const name = row["name"];
707
+ const type = row["type"];
708
+ if (typeof haId !== "string") continue;
709
+ appliances.push({
710
+ haId,
711
+ name: typeof name === "string" ? name : haId,
712
+ type: typeof type === "string" ? type : "Dishwasher",
713
+ availablePrograms: programNamesFromLegacyList(row["programs"])
714
+ });
715
+ }
716
+ return appliances;
717
+ }
718
+ function parseHaDeviceDescription(record) {
719
+ const info = record["info"];
720
+ if (typeof info !== "object" || info === null) return [];
721
+ const infoRec = info;
722
+ const deviceID = infoRec["deviceID"];
723
+ if (typeof deviceID !== "string") return [];
724
+ const deviceType = infoRec["deviceType"];
725
+ const model = infoRec["model"];
726
+ return [{
727
+ haId: deviceID,
728
+ name: typeof model === "string" && model.length > 0 ? model : deviceID,
729
+ type: typeof deviceType === "string" ? deviceType : "Dishwasher",
730
+ availablePrograms: programNamesFromHaDeviceDescription(record["program"])
731
+ }];
732
+ }
733
+ function isPlainObject(value) {
734
+ return value !== null && typeof value === "object" && !Array.isArray(value);
735
+ }
736
+ function parseDeviceDescriptionRecord(record) {
737
+ const rawList = record["appliances"];
738
+ if (Array.isArray(rawList)) return { appliances: parseLegacyAppliancesList(rawList) };
739
+ const fromHa = parseHaDeviceDescription(record);
740
+ if (fromHa.length > 0) return { appliances: fromHa };
741
+ return { appliances: [] };
742
+ }
743
+ function parseDeviceDescription(input) {
744
+ if (input === void 0) return { appliances: [] };
745
+ if (isPlainObject(input)) return parseDeviceDescriptionRecord(input);
746
+ if (typeof input !== "string") return { appliances: [] };
747
+ const trimmed = input.trim();
748
+ if (trimmed.length === 0 || trimmed === "[object Object]") return { appliances: [] };
749
+ try {
750
+ const root = JSON.parse(trimmed);
751
+ if (!isPlainObject(root)) return { appliances: [] };
752
+ return parseDeviceDescriptionRecord(root);
753
+ } catch {
754
+ return { appliances: [] };
755
+ }
756
+ }
757
+ function createHomeConnectWsClient(config, deps = {}) {
758
+ const wsFactory = deps.wsFactory ?? defaultWsFactory;
759
+ const url = homeConnectWsUrl(config);
760
+ const appliances = [...parseDeviceDescription(config.deviceDescription).appliances];
761
+ const featureStore = /* @__PURE__ */ new Map();
762
+ const listeners = /* @__PURE__ */ new Set();
763
+ let ws = null;
764
+ let connected = false;
765
+ function ensureFeatures(haId) {
766
+ let map = featureStore.get(haId);
767
+ if (!map) {
768
+ map = /* @__PURE__ */ new Map();
769
+ featureStore.set(haId, map);
770
+ }
771
+ return map;
772
+ }
773
+ function applyFeature(haId, key, value) {
774
+ ensureFeatures(haId).set(key, value);
775
+ for (const fn of listeners) fn(haId, key, value);
776
+ }
777
+ return {
778
+ async connect() {
779
+ if (connected) return;
780
+ await new Promise((resolve, reject) => {
781
+ const socket = wsFactory(url);
782
+ ws = socket;
783
+ const onOpen = () => {
784
+ connected = true;
785
+ socket.removeEventListener("open", onOpen);
786
+ socket.removeEventListener("error", onError);
787
+ for (const app of appliances) socket.send(JSON.stringify({
788
+ type: "subscribe",
789
+ haId: app.haId
790
+ }));
791
+ resolve();
792
+ };
793
+ const onError = () => {
794
+ reject(/* @__PURE__ */ new Error("Home Connect WebSocket connection failed"));
795
+ };
796
+ socket.addEventListener("open", onOpen);
797
+ socket.addEventListener("error", onError);
798
+ socket.addEventListener("message", (ev) => {
799
+ if (!ev.data) return;
800
+ try {
801
+ const msg = JSON.parse(ev.data);
802
+ if (typeof msg !== "object" || msg === null) return;
803
+ const row = msg;
804
+ if (row["type"] === "feature") {
805
+ const haId = row["haId"];
806
+ const key = row["key"];
807
+ if (typeof haId === "string" && typeof key === "string") applyFeature(haId, key, row["value"]);
808
+ }
809
+ if (row["type"] === "appliance") {
810
+ const haId = row["haId"];
811
+ const name = row["name"];
812
+ const type = row["type"];
813
+ const programs = row["programs"];
814
+ if (typeof haId === "string") {
815
+ const programList = [];
816
+ if (Array.isArray(programs)) {
817
+ for (const p of programs) if (typeof p === "string") programList.push(p);
818
+ }
819
+ appliances.push({
820
+ haId,
821
+ name: typeof name === "string" ? name : haId,
822
+ type: typeof type === "string" ? type : "Dishwasher",
823
+ availablePrograms: programList
824
+ });
825
+ }
826
+ }
827
+ } catch {}
828
+ });
829
+ });
830
+ },
831
+ close() {
832
+ connected = false;
833
+ ws?.close();
834
+ ws = null;
835
+ },
836
+ getAppliances() {
837
+ return appliances;
838
+ },
839
+ getFeatures(haId) {
840
+ return ensureFeatures(haId);
841
+ },
842
+ async setFeature(haId, key, value) {
843
+ if (!ws || ws.readyState !== WS_OPEN) throw new Error("Home Connect WebSocket not connected");
844
+ ws.send(JSON.stringify({
845
+ type: "set",
846
+ haId,
847
+ key,
848
+ value
849
+ }));
850
+ applyFeature(haId, key, value);
851
+ },
852
+ onFeatureChange(listener) {
853
+ listeners.add(listener);
854
+ return () => listeners.delete(listener);
855
+ }
856
+ };
857
+ }
858
+ function isWsLike(value) {
859
+ if (typeof value !== "object" || value === null) return false;
860
+ const ws = value;
861
+ return typeof ws["readyState"] === "number" && typeof ws["send"] === "function" && typeof ws["close"] === "function" && typeof ws["addEventListener"] === "function" && typeof ws["removeEventListener"] === "function";
862
+ }
863
+ function defaultWsFactory(url) {
864
+ const WS = globalThis.WebSocket;
865
+ if (typeof WS !== "function") throw new Error(`WebSocket unavailable — cannot connect to ${url}`);
866
+ const socket = new WS(url);
867
+ if (!isWsLike(socket)) throw new Error(`WebSocket instance is not WsLike-compatible (${url})`);
868
+ return socket;
869
+ }
870
+ /** Default factory used by the integration manager (overridable in tests). */
871
+ var defaultHomeConnectWsClientFactory = (config) => createHomeConnectWsClient(config);
872
+ //#endregion
873
+ //#region src/home-connect/home-connect-account-client.ts
874
+ /** One account session: the token lists appliances, each appliance has its own local socket. */
875
+ async function openHomeConnectAccount(options) {
876
+ return accountClient((await fetchHomeConnectAccount({
877
+ token: options.token,
878
+ refresh: options.refresh
879
+ }, options.fetchImpl)).appliances, options.makeGateway ?? defaultHomeConnectWsClientFactory);
880
+ }
881
+ function accountClient(appliances, makeGateway) {
882
+ const listed = appliances.map((app) => ({
883
+ haId: app.haId,
884
+ name: app.name,
885
+ type: app.type,
886
+ availablePrograms: []
887
+ }));
888
+ const children = /* @__PURE__ */ new Map();
889
+ for (const app of appliances) children.set(app.haId, makeGateway(app.connection));
890
+ const listeners = /* @__PURE__ */ new Set();
891
+ const unsubs = [];
892
+ for (const [haId, child] of children) unsubs.push(child.onFeatureChange((eventHaId, key, value) => {
893
+ const id = eventHaId.length > 0 ? eventHaId : haId;
894
+ for (const fn of listeners) fn(id, key, value);
895
+ }));
896
+ return {
897
+ async connect() {
898
+ const results = await Promise.allSettled([...children.values()].map((child) => child.connect()));
899
+ if (results.length > 0 && results.every((result) => result.status === "rejected")) {
900
+ const first = results[0];
901
+ if (first?.status === "rejected") throw first.reason;
902
+ }
903
+ },
904
+ close() {
905
+ for (const unsub of unsubs) unsub();
906
+ for (const child of children.values()) child.close();
907
+ },
908
+ getAppliances: () => listed,
909
+ getFeatures(haId) {
910
+ return children.get(haId)?.getFeatures(haId) ?? /* @__PURE__ */ new Map();
911
+ },
912
+ async setFeature(haId, key, value) {
913
+ const child = children.get(haId);
914
+ if (!child) throw new Error(`Home Connect appliance ${haId} is not on this account`);
915
+ await child.setFeature(haId, key, value);
916
+ },
917
+ onFeatureChange(listener) {
918
+ listeners.add(listener);
919
+ return () => listeners.delete(listener);
920
+ }
921
+ };
922
+ }
923
+ //#endregion
924
+ //#region src/home-connect/home-connect-integration-manager.ts
925
+ function storedAppliance(row) {
926
+ return {
927
+ haId: row.haId,
928
+ name: row.name,
929
+ type: row.type,
930
+ connection: {
931
+ host: row.host,
932
+ psk: row.psk,
933
+ mode: row.mode,
934
+ iv64: row.iv64 ?? "",
935
+ deviceDescription: JSON.stringify({ info: {
936
+ deviceID: row.haId,
937
+ deviceType: row.type,
938
+ model: row.name
939
+ } })
940
+ }
941
+ };
942
+ }
943
+ async function openStoredOrAccount(connection) {
944
+ const stored = connection.appliances ?? [];
945
+ if (stored.length > 0) return accountClient(stored.map(storedAppliance), defaultHomeConnectWsClientFactory);
946
+ return openHomeConnectAccount({
947
+ token: connection.token,
948
+ refresh: connection.refresh ?? ""
949
+ });
950
+ }
951
+ var HomeConnectIntegrationManager = class {
952
+ #id;
953
+ #name;
954
+ #connection;
955
+ #logger;
956
+ #onConnected;
957
+ #onDisconnected;
958
+ #surfaceSink;
959
+ #makeClient;
960
+ #client = null;
961
+ #status = "disconnected";
962
+ #lastCheckedAt = null;
963
+ #error = null;
964
+ constructor(options) {
965
+ this.#id = options.id;
966
+ this.#name = options.name;
967
+ this.#connection = options.connection;
968
+ this.#logger = options.logger;
969
+ this.#onConnected = options.onConnected;
970
+ this.#onDisconnected = options.onDisconnected;
971
+ this.#surfaceSink = options.surfaceSink;
972
+ this.#makeClient = options.makeClient ?? null;
973
+ }
974
+ getConnection() {
975
+ return this.#connection;
976
+ }
977
+ getInfo() {
978
+ const applianceCount = this.#client?.getAppliances().length ?? 0;
979
+ return {
980
+ id: this.#id,
981
+ name: this.#name,
982
+ kind: "home-connect",
983
+ status: this.#status,
984
+ info: { applianceCount },
985
+ lastCheckedAt: this.#lastCheckedAt,
986
+ error: this.#error
987
+ };
988
+ }
989
+ async start() {
990
+ if (this.#client !== null) return;
991
+ this.#status = "connecting";
992
+ this.#error = null;
993
+ const client = this.#makeClient ? this.#makeClient(this.#connection) : await openStoredOrAccount(this.#connection);
994
+ this.#client = client;
995
+ const seededAppliances = client.getAppliances();
996
+ if (seededAppliances.length > 0) this.#surfaceSink.set(this.#id, {
997
+ appliances: seededAppliances,
998
+ client
999
+ });
1000
+ try {
1001
+ await client.connect();
1002
+ const surface = {
1003
+ appliances: client.getAppliances(),
1004
+ client
1005
+ };
1006
+ this.#surfaceSink.set(this.#id, surface);
1007
+ this.#status = "connected";
1008
+ this.#lastCheckedAt = Date.now();
1009
+ this.#onConnected(this.#id);
1010
+ } catch (err) {
1011
+ this.#status = "error";
1012
+ this.#error = require_dist.errMsg(err);
1013
+ this.#lastCheckedAt = Date.now();
1014
+ const appliances = client.getAppliances();
1015
+ if (appliances.length > 0) {
1016
+ this.#surfaceSink.set(this.#id, {
1017
+ appliances,
1018
+ client
1019
+ });
1020
+ this.#logger.warn("HomeConnectIntegrationManager: connect failed, keeping appliance inventory from pairing", { meta: {
1021
+ integrationId: this.#id,
1022
+ error: this.#error,
1023
+ applianceCount: appliances.length
1024
+ } });
1025
+ return;
1026
+ }
1027
+ this.#surfaceSink.set(this.#id, null);
1028
+ this.#onDisconnected(this.#id);
1029
+ throw err;
1030
+ }
1031
+ }
1032
+ async stop() {
1033
+ const client = this.#client;
1034
+ this.#client = null;
1035
+ this.#status = "disconnected";
1036
+ this.#surfaceSink.set(this.#id, null);
1037
+ if (client) try {
1038
+ client.close();
1039
+ } catch (err) {
1040
+ this.#logger.warn("HomeConnectIntegrationManager: client close failed", { meta: {
1041
+ integrationId: this.#id,
1042
+ error: require_dist.errMsg(err)
1043
+ } });
1044
+ }
1045
+ this.#onDisconnected(this.#id);
1046
+ }
1047
+ async applyConnection(conn) {
1048
+ await this.stop();
1049
+ this.#connection = conn;
1050
+ await this.start();
1051
+ }
1052
+ };
1053
+ //#endregion
1054
+ //#region src/home-connect/home-connect-client-registry.ts
1055
+ var HomeConnectClientRegistry = class {
1056
+ #logger;
1057
+ #onConnected;
1058
+ #onDisconnected;
1059
+ #makeManager;
1060
+ #entries = /* @__PURE__ */ new Map();
1061
+ constructor(logger, deps = {}) {
1062
+ this.#logger = logger;
1063
+ this.#onConnected = deps.onConnected ?? (() => void 0);
1064
+ this.#onDisconnected = deps.onDisconnected ?? (() => void 0);
1065
+ const makeClient = deps.makeClient;
1066
+ this.#makeManager = deps.makeManager ?? ((opts) => new HomeConnectIntegrationManager({
1067
+ id: opts.integrationId,
1068
+ name: opts.name,
1069
+ connection: opts.connection,
1070
+ logger: opts.logger,
1071
+ onConnected: opts.onConnected,
1072
+ onDisconnected: opts.onDisconnected,
1073
+ surfaceSink: homeConnectConnections,
1074
+ makeClient
1075
+ }));
1076
+ }
1077
+ setOnConnected(cb) {
1078
+ this.#onConnected = cb;
1079
+ }
1080
+ setOnDisconnected(cb) {
1081
+ this.#onDisconnected = cb;
1082
+ }
1083
+ async upsert(integrationId, name, connection) {
1084
+ const existing = this.#entries.get(integrationId);
1085
+ if (existing) {
1086
+ if (sameHomeConnectConnection(existing.connection, connection)) return;
1087
+ try {
1088
+ await existing.manager.applyConnection(connection);
1089
+ this.#entries.set(integrationId, {
1090
+ manager: existing.manager,
1091
+ connection
1092
+ });
1093
+ } catch (err) {
1094
+ this.#logger.warn("HomeConnectClientRegistry: applyConnection failed", { meta: {
1095
+ integrationId,
1096
+ error: require_dist.errMsg(err)
1097
+ } });
1098
+ }
1099
+ return;
1100
+ }
1101
+ const manager = this.#makeManager({
1102
+ integrationId,
1103
+ name,
1104
+ connection,
1105
+ logger: this.#logger,
1106
+ onConnected: (id) => this.#onConnected(id),
1107
+ onDisconnected: (id) => this.#onDisconnected(id)
1108
+ });
1109
+ this.#entries.set(integrationId, {
1110
+ manager,
1111
+ connection
1112
+ });
1113
+ try {
1114
+ await manager.start();
1115
+ } catch (err) {
1116
+ this.#logger.warn("HomeConnectClientRegistry: manager start failed", { meta: {
1117
+ integrationId,
1118
+ error: require_dist.errMsg(err)
1119
+ } });
1120
+ }
1121
+ }
1122
+ async remove(integrationId) {
1123
+ const entry = this.#entries.get(integrationId);
1124
+ if (!entry) return;
1125
+ this.#entries.delete(integrationId);
1126
+ try {
1127
+ await entry.manager.stop();
1128
+ } catch (err) {
1129
+ this.#logger.warn("HomeConnectClientRegistry: remove stop failed", { meta: {
1130
+ integrationId,
1131
+ error: require_dist.errMsg(err)
1132
+ } });
1133
+ }
1134
+ }
1135
+ async retain(keep) {
1136
+ const stale = [...this.#entries.keys()].filter((id) => !keep.has(id));
1137
+ for (const id of stale) await this.remove(id);
1138
+ }
1139
+ async shutdown() {
1140
+ const ids = [...this.#entries.keys()];
1141
+ await Promise.all(ids.map(async (id) => {
1142
+ try {
1143
+ await this.#entries.get(id)?.manager.stop();
1144
+ } catch (err) {
1145
+ this.#logger.warn("HomeConnectClientRegistry: shutdown stop failed", { meta: {
1146
+ integrationId: id,
1147
+ error: require_dist.errMsg(err)
1148
+ } });
1149
+ }
1150
+ }));
1151
+ this.#entries.clear();
1152
+ homeConnectConnections.clear();
1153
+ }
1154
+ has(integrationId) {
1155
+ return this.#entries.has(integrationId);
1156
+ }
1157
+ list() {
1158
+ return [...this.#entries.keys()];
1159
+ }
1160
+ getConnection(integrationId) {
1161
+ return this.#entries.get(integrationId)?.connection ?? null;
1162
+ }
1163
+ getInfo(integrationId) {
1164
+ return this.#entries.get(integrationId)?.manager.getInfo() ?? null;
1165
+ }
1166
+ size() {
1167
+ return this.#entries.size;
1168
+ }
1169
+ };
1170
+ //#endregion
1171
+ //#region src/home-connect/home-connect-connection-test.ts
1172
+ var TEST_LABEL = "Checks the Home Connect account token and lists appliances";
1173
+ function buildHomeConnectConnectionTestProvider(deps) {
1174
+ const { logger } = deps;
1175
+ return {
1176
+ describeTest: async () => ({ label: TEST_LABEL }),
1177
+ testSettings: async ({ settings }) => {
1178
+ const normalized = homeConnectSettingsForPersist(typeof settings === "object" && settings !== null ? settings : {});
1179
+ const parsed = homeConnectConfigSchema.safeParse(normalized);
1180
+ if (!parsed.success) return {
1181
+ outcome: "rejected",
1182
+ error: `Home Connect settings incomplete: ${parsed.error.issues.map((issue) => issue.path.join(".") || "(root)").join(", ")}`
1183
+ };
1184
+ const saved = parsed.data.appliances ?? [];
1185
+ if (saved.length > 0) {
1186
+ const names = saved.map((app) => app.name).join(", ");
1187
+ return {
1188
+ outcome: "validated",
1189
+ latencyMs: 0,
1190
+ detail: names.length > 0 ? names : `${String(saved.length)} appliance(s) on this account`
1191
+ };
1192
+ }
1193
+ const startedAt = Date.now();
1194
+ try {
1195
+ const account = await fetchHomeConnectAccount(parsed.data, deps.fetchImpl);
1196
+ return {
1197
+ outcome: "validated",
1198
+ latencyMs: Date.now() - startedAt,
1199
+ detail: account.summary
1200
+ };
1201
+ } catch (err) {
1202
+ const message = require_dist.errMsg(err);
1203
+ if (message.includes("rejected")) return {
1204
+ outcome: "rejected",
1205
+ error: "Home Connect refused this account token. Sign in again."
1206
+ };
1207
+ logger.warn("Home Connect account test inconclusive", { meta: { error: message } });
1208
+ return {
1209
+ outcome: "inconclusive",
1210
+ error: "Could not reach Home Connect to read this account."
1211
+ };
1212
+ }
1213
+ }
1214
+ };
1215
+ }
1216
+ var HC_FEATURE_ACTIVE_PROGRAM = "BSH.Common.Setting.Program.Active";
1217
+ var HC_FEATURE_REMAINING = "BSH.Common.Option.RemainingProgramTime";
1218
+ var HC_FEATURE_PROGRESS = "BSH.Common.Option.ProgramProgress";
1219
+ var HC_FEATURE_DOOR = "BSH.Common.Status.DoorState";
1220
+ var HC_FEATURE_REMOTE_START = "BSH.Common.Status.RemoteControlActive";
1221
+ var HC_FEATURE_POWER = "BSH.Common.Setting.PowerState";
1222
+ var HC_FEATURE_SALT = "BSH.Common.Setting.SaltLevel";
1223
+ var HC_FEATURE_RINSE = "BSH.Common.Setting.RinseAidLevel";
1224
+ var HC_FEATURE_HALF_LOAD = "BSH.Common.Option.HalfLoad";
1225
+ var HC_FEATURE_HYGIENE = "BSH.Common.Option.HygienePlus";
1226
+ var OPERATION_TO_STATE = {
1227
+ Ready: "idle",
1228
+ Inactive: "idle",
1229
+ Run: "running",
1230
+ Pause: "paused",
1231
+ Finished: "finished",
1232
+ Error: "error"
1233
+ };
1234
+ function mapConsumable(raw) {
1235
+ if (raw === "Full" || raw === "OK" || raw === "Ok") return "ok";
1236
+ if (raw === "Low" || raw === "NearlyEmpty") return "low";
1237
+ if (raw === "Empty") return "empty";
1238
+ return null;
1239
+ }
1240
+ function readBool(raw) {
1241
+ return raw === true || raw === "On" || raw === "Open" || raw === "Connected";
1242
+ }
1243
+ function readString(raw) {
1244
+ return typeof raw === "string" && raw.length > 0 ? raw : null;
1245
+ }
1246
+ function readNumber(raw) {
1247
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
1248
+ if (typeof raw === "string") {
1249
+ const n = Number(raw);
1250
+ return Number.isFinite(n) ? n : null;
1251
+ }
1252
+ return null;
1253
+ }
1254
+ /**
1255
+ * Map BSH.Common.* feature values → {@link DishwasherStatus}. Pure — the only
1256
+ * place dishwasher slice assembly happens for Home Connect local.
1257
+ */
1258
+ function dishwasherStatusFromFeatures(input) {
1259
+ const { features, availablePrograms, nowMs } = input;
1260
+ const op = readString(features.get("BSH.Common.Status.OperationState")) ?? "Ready";
1261
+ const state = OPERATION_TO_STATE[op] ?? "unavailable";
1262
+ const program = readString(features.get(HC_FEATURE_ACTIVE_PROGRAM));
1263
+ let remainingMinutes = readNumber(features.get(HC_FEATURE_REMAINING));
1264
+ if (remainingMinutes !== null && remainingMinutes > 600) remainingMinutes = Math.round(remainingMinutes / 60);
1265
+ const progressPercent = readNumber(features.get(HC_FEATURE_PROGRESS));
1266
+ const doorOpen = features.get(HC_FEATURE_DOOR) === "Open";
1267
+ const remoteStartAllowed = readBool(features.get(HC_FEATURE_REMOTE_START));
1268
+ const powerOn = features.get(HC_FEATURE_POWER) !== "Off";
1269
+ return {
1270
+ state,
1271
+ program,
1272
+ availablePrograms: [...availablePrograms],
1273
+ remainingMinutes,
1274
+ progressPercent,
1275
+ doorOpen,
1276
+ remoteStartAllowed,
1277
+ powerOn,
1278
+ saltLevel: mapConsumable(features.get(HC_FEATURE_SALT)),
1279
+ rinseAidLevel: mapConsumable(features.get(HC_FEATURE_RINSE)),
1280
+ halfLoad: readBool(features.get(HC_FEATURE_HALF_LOAD)),
1281
+ hygienePlus: readBool(features.get(HC_FEATURE_HYGIENE)),
1282
+ errorCode: state === "error" ? 1 : null,
1283
+ errorLabel: state === "error" ? op : null,
1284
+ lastFetchedAt: nowMs
1285
+ };
1286
+ }
1287
+ /** Command resource keys for WS POST-style mutations. */
1288
+ var HC_CMD_START = "BSH.Common.Command.StartProgram";
1289
+ var HC_CMD_PAUSE = "BSH.Common.Command.PauseProgram";
1290
+ var HC_CMD_STOP = "BSH.Common.Command.AbortProgram";
1291
+ var HC_CMD_SET_PROGRAM = "BSH.Common.Setting.Program.Active";
1292
+ var HC_CMD_SET_POWER = "BSH.Common.Setting.PowerState";
1293
+ //#endregion
1294
+ //#region src/home-connect/devices/home-connect-dishwasher-device.ts
1295
+ var homeConnectDishwasherSchema = require_dist.object({
1296
+ haId: require_dist.string().min(1),
1297
+ brokerId: require_dist.string().min(1),
1298
+ applianceType: require_dist.string().optional(),
1299
+ availablePrograms: require_dist.array(require_dist.string()).default([]),
1300
+ system: require_dist.literal("home-connect").optional(),
1301
+ integrationId: require_dist.string().optional()
1302
+ });
1303
+ var DISH_CAP = "dishwasher";
1304
+ var COLD_START = {
1305
+ state: "unavailable",
1306
+ program: null,
1307
+ availablePrograms: [],
1308
+ remainingMinutes: null,
1309
+ progressPercent: null,
1310
+ doorOpen: false,
1311
+ remoteStartAllowed: false,
1312
+ powerOn: false,
1313
+ saltLevel: null,
1314
+ rinseAidLevel: null,
1315
+ halfLoad: false,
1316
+ hygienePlus: false,
1317
+ errorCode: null,
1318
+ errorLabel: null,
1319
+ lastFetchedAt: 0
1320
+ };
1321
+ var HomeConnectDishwasherDevice = class extends require_dist.BaseDevice {
1322
+ features = [];
1323
+ #haId;
1324
+ #brokerId;
1325
+ #availablePrograms;
1326
+ #featureUnsub = null;
1327
+ constructor(ctx) {
1328
+ const persisted = homeConnectDishwasherSchema.parse(ctx.persistedConfig ?? {});
1329
+ super(ctx, homeConnectDishwasherSchema, { type: ctx.deviceMeta.type });
1330
+ this.#haId = persisted.haId;
1331
+ this.#brokerId = persisted.brokerId;
1332
+ this.#availablePrograms = persisted.availablePrograms;
1333
+ this.online = true;
1334
+ this.updateSourceInfo({
1335
+ id: this.#haId,
1336
+ system: "home-connect"
1337
+ });
1338
+ }
1339
+ async onActivate() {
1340
+ await super.onActivate();
1341
+ this.registerDishwasherCap();
1342
+ this.runtimeState.setCapState(DISH_CAP, COLD_START);
1343
+ this.attachFeatureListener();
1344
+ this.recomputeSlice();
1345
+ }
1346
+ async removeDevice() {
1347
+ this.#featureUnsub?.();
1348
+ this.#featureUnsub = null;
1349
+ await super.removeDevice();
1350
+ }
1351
+ attachFeatureListener() {
1352
+ const client = homeConnectConnections.getClient(this.#brokerId);
1353
+ if (!client) return;
1354
+ this.#featureUnsub = client.onFeatureChange((haId, _key, _value) => {
1355
+ if (haId !== this.#haId) return;
1356
+ this.recomputeSlice();
1357
+ });
1358
+ }
1359
+ recomputeSlice() {
1360
+ const client = homeConnectConnections.getClient(this.#brokerId);
1361
+ if (!client) {
1362
+ this.runtimeState.setCapState(DISH_CAP, {
1363
+ ...COLD_START,
1364
+ lastFetchedAt: Date.now()
1365
+ });
1366
+ return;
1367
+ }
1368
+ const slice = dishwasherStatusFromFeatures({
1369
+ features: client.getFeatures(this.#haId),
1370
+ availablePrograms: this.#availablePrograms,
1371
+ nowMs: Date.now()
1372
+ });
1373
+ this.runtimeState.setCapState(DISH_CAP, slice);
1374
+ }
1375
+ requireClient() {
1376
+ const client = homeConnectConnections.getClient(this.#brokerId);
1377
+ if (!client) throw new Error(`Home Connect client unavailable (haId=${this.#haId})`);
1378
+ return client;
1379
+ }
1380
+ registerDishwasherCap() {
1381
+ this.ctx.registerNativeCap(require_dist.dishwasherCapability, {
1382
+ getStatus: async () => this.runtimeState.getCapState(DISH_CAP) ?? COLD_START,
1383
+ start: async () => {
1384
+ await this.requireClient().setFeature(this.#haId, HC_CMD_START, {});
1385
+ },
1386
+ pause: async () => {
1387
+ await this.requireClient().setFeature(this.#haId, HC_CMD_PAUSE, {});
1388
+ },
1389
+ stop: async () => {
1390
+ await this.requireClient().setFeature(this.#haId, HC_CMD_STOP, {});
1391
+ },
1392
+ setProgram: async ({ program }) => {
1393
+ await this.requireClient().setFeature(this.#haId, HC_CMD_SET_PROGRAM, program);
1394
+ },
1395
+ setPower: async ({ on }) => {
1396
+ await this.requireClient().setFeature(this.#haId, HC_CMD_SET_POWER, on ? "On" : "Off");
1397
+ },
1398
+ setHalfLoad: async ({ on }) => {
1399
+ await this.requireClient().setFeature(this.#haId, HC_FEATURE_HALF_LOAD, on);
1400
+ },
1401
+ setHygienePlus: async ({ on }) => {
1402
+ await this.requireClient().setFeature(this.#haId, HC_FEATURE_HYGIENE, on);
1403
+ },
1404
+ setOption: async ({ key, value }) => {
1405
+ await this.requireClient().setFeature(this.#haId, key, value);
1406
+ }
1407
+ });
1408
+ }
1409
+ };
1410
+ //#endregion
1411
+ //#region src/home-connect/addon.ts
1412
+ var BoschHomeConnectProviderAddon = class extends require_dist.BaseDeviceProvider {
1413
+ addonId = "bosch-home-connect";
1414
+ providerName = "Bosch Home Connect";
1415
+ deviceClasses = { [require_dist.DeviceType.Dishwasher]: HomeConnectDishwasherDevice };
1416
+ clients = null;
1417
+ constructor() {
1418
+ super({});
1419
+ }
1420
+ async onInitialize() {
1421
+ const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
1422
+ this.clients = new HomeConnectClientRegistry(this.ctx.logger);
1423
+ this.clients.setOnConnected((integrationId) => {
1424
+ this.setIntegrationDevicesOnline(integrationId, true);
1425
+ });
1426
+ this.clients.setOnDisconnected((integrationId) => {
1427
+ this.setIntegrationDevicesOnline(integrationId, false);
1428
+ });
1429
+ await this.reconcileIntegrations();
1430
+ this.subscribeIntegrationLifecycle();
1431
+ return {
1432
+ providers: [
1433
+ ...regs,
1434
+ {
1435
+ capability: require_dist.deviceAdoptionCapability,
1436
+ provider: this.buildAdoptionProvider()
1437
+ },
1438
+ {
1439
+ capability: require_dist.connectionTestCapability,
1440
+ provider: this.buildConnectionTestProvider()
1441
+ }
1442
+ ],
1443
+ customActions: homeConnectActions,
1444
+ actionHandlers: {
1445
+ beginSignIn: async () => {
1446
+ const started = beginHomeConnectSignIn();
1447
+ return {
1448
+ authorizeUrl: started.authorizeUrl,
1449
+ verifier: started.verifier,
1450
+ state: started.state,
1451
+ summary: "Open the link in the browser and sign in."
1452
+ };
1453
+ },
1454
+ finishSignIn: async (input) => {
1455
+ const account = await completeHomeConnectSignIn({
1456
+ verifier: input.verifier,
1457
+ callback: input.callback,
1458
+ state: input.state
1459
+ });
1460
+ const appliances = account.appliances.map((app) => ({
1461
+ haId: app.haId,
1462
+ name: app.name,
1463
+ type: app.type,
1464
+ host: app.connection.host,
1465
+ psk: app.connection.psk,
1466
+ mode: app.connection.mode,
1467
+ ...app.connection.iv64.length > 0 ? { iv64: app.connection.iv64 } : {}
1468
+ }));
1469
+ return {
1470
+ token: account.token,
1471
+ ...account.refresh.length > 0 ? { refresh: account.refresh } : {},
1472
+ appliances: JSON.stringify(appliances),
1473
+ summary: account.summary
1474
+ };
1475
+ }
1476
+ }
1477
+ };
1478
+ }
1479
+ async onShutdown() {
1480
+ try {
1481
+ await this.clients?.shutdown();
1482
+ } catch (err) {
1483
+ this.ctx.logger.warn("Home Connect shutdown error", { meta: { error: require_dist.errMsg(err) } });
1484
+ }
1485
+ this.clients = null;
1486
+ await super.onShutdown();
1487
+ }
1488
+ requireClients() {
1489
+ if (!this.clients) throw new Error("Home Connect provider not initialised");
1490
+ return this.clients;
1491
+ }
1492
+ generateStableId(_type, config) {
1493
+ const integrationId = config?.["brokerId"];
1494
+ if (typeof integrationId !== "string" || integrationId.length === 0) throw new Error("home-connect generateStableId: brokerId required");
1495
+ const haId = config?.["haId"];
1496
+ if (typeof haId !== "string" || haId.length === 0) throw new Error("home-connect generateStableId: haId required");
1497
+ return `home-connect:${integrationId}:${haId}`;
1498
+ }
1499
+ async reconcileIntegrations() {
1500
+ const reg = this.requireClients();
1501
+ try {
1502
+ const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id && i.enabled);
1503
+ const surviving = /* @__PURE__ */ new Set();
1504
+ for (const integration of mine) try {
1505
+ const settings = await this.ctx.api.integrations.getSettings.query({ id: integration.id });
1506
+ const connection = connectionFromSettings(settings);
1507
+ if (!connection) {
1508
+ logHomeConnectSettingsGap(this.ctx.logger, integration.id, settings);
1509
+ continue;
1510
+ }
1511
+ await reg.upsert(integration.id, integration.name, connection);
1512
+ const brokerInfo = reg.getInfo(integration.id);
1513
+ const applianceCount = typeof brokerInfo?.info["applianceCount"] === "number" ? brokerInfo.info["applianceCount"] : 0;
1514
+ this.ctx.logger.info("Home Connect integration reconciled", { meta: {
1515
+ integrationId: integration.id,
1516
+ applianceCount
1517
+ } });
1518
+ surviving.add(integration.id);
1519
+ } catch (err) {
1520
+ this.ctx.logger.warn("Home Connect reconcile failed for integration", { meta: {
1521
+ integrationId: integration.id,
1522
+ error: require_dist.errMsg(err)
1523
+ } });
1524
+ }
1525
+ const vanished = reg.list().filter((id) => !surviving.has(id));
1526
+ await reg.retain(surviving);
1527
+ const deviceReg = this.ctx.kernel.deviceRegistry;
1528
+ const deviceApi = this.ctx.kernel.devices;
1529
+ if (deviceReg && deviceApi) for (const integrationId of vanished) await require_account_integration_offline.cascadeRemoveDevicesForIntegration({
1530
+ reg: deviceReg,
1531
+ devices: deviceApi,
1532
+ addonId: this.addonId,
1533
+ system: "home-connect",
1534
+ integrationId,
1535
+ logger: this.ctx.logger
1536
+ });
1537
+ } catch (err) {
1538
+ this.ctx.logger.warn("Home Connect integration reconcile failed", { meta: { error: require_dist.errMsg(err) } });
1539
+ }
1540
+ }
1541
+ subscribeIntegrationLifecycle() {
1542
+ const handler = (event) => {
1543
+ const addonId = event.data["addonId"];
1544
+ if (typeof addonId === "string" && addonId !== this.ctx.id) return;
1545
+ this.reconcileIntegrations();
1546
+ };
1547
+ this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.IntegrationEnabled }, handler);
1548
+ this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.IntegrationDisabled }, handler);
1549
+ this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.IntegrationDeleted }, handler);
1550
+ }
1551
+ buildConnectionTestProvider() {
1552
+ return buildHomeConnectConnectionTestProvider({ logger: this.ctx.logger });
1553
+ }
1554
+ buildAdoptionProvider() {
1555
+ return buildHomeConnectAdoptionProvider({
1556
+ registry: homeConnectConnections,
1557
+ hasIntegration: (id) => this.requireClients().has(id),
1558
+ listIntegrations: () => this.requireClients().list(),
1559
+ listAdopted: async () => {
1560
+ const reg = this.ctx.kernel.deviceRegistry;
1561
+ const devices = this.ctx.kernel.devices;
1562
+ if (!reg || !devices) return [];
1563
+ const out = [];
1564
+ for (const d of reg.getAllForAddon(this.addonId)) {
1565
+ const config = await devices.loadConfig(d.id).catch(() => ({}));
1566
+ out.push({
1567
+ id: d.id,
1568
+ config
1569
+ });
1570
+ }
1571
+ return out;
1572
+ },
1573
+ adoptDevice: async ({ haId, integrationId, type, name, applianceType, availablePrograms }) => {
1574
+ const devices = this.ctx.kernel.devices;
1575
+ if (!devices) throw new Error("home-connect adopt: devices unavailable");
1576
+ const config = {
1577
+ haId,
1578
+ brokerId: integrationId,
1579
+ applianceType,
1580
+ availablePrograms: [...availablePrograms],
1581
+ system: "home-connect",
1582
+ integrationId,
1583
+ name
1584
+ };
1585
+ const stableId = this.generateStableId(type, config);
1586
+ const DeviceClass = this.deviceClasses[type];
1587
+ if (!DeviceClass) throw new Error(`home-connect adopt: unsupported type ${type}`);
1588
+ return { deviceId: (await devices.create(stableId, DeviceClass, config, null, {
1589
+ type,
1590
+ name,
1591
+ integrationId
1592
+ })).id };
1593
+ },
1594
+ removeDevice: async (id) => {
1595
+ await this.ctx.kernel.devices?.remove(id);
1596
+ },
1597
+ findDeviceConfig: async (id) => {
1598
+ const devices = this.ctx.kernel.devices;
1599
+ if (!devices) return null;
1600
+ return devices.loadConfig(id).catch(() => null);
1601
+ },
1602
+ logger: this.ctx.logger
1603
+ });
1604
+ }
1605
+ globalSettingsSchema() {
1606
+ return buildHomeConnectFormSchema();
1607
+ }
1608
+ async supportsManualCreation() {
1609
+ return false;
1610
+ }
1611
+ async onGetCreationSchema(_type) {
1612
+ return null;
1613
+ }
1614
+ async onCreateDevice(_type, _config) {
1615
+ throw new Error("Home Connect devices are adopted from the gateway");
1616
+ }
1617
+ setIntegrationDevicesOnline(integrationId, online) {
1618
+ const reg = this.ctx.kernel.deviceRegistry;
1619
+ if (!reg) return;
1620
+ require_account_integration_offline.setIntegrationDevicesOnline(reg.getAllForAddon(this.addonId), "home-connect", integrationId, online);
1621
+ }
1622
+ };
1623
+ //#endregion
1624
+ exports.BoschHomeConnectProviderAddon = BoschHomeConnectProviderAddon;