@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.
- package/assets/bosch-home-connect/icon.png +0 -0
- package/assets/bosch-homecom/icon.png +0 -0
- package/assets/bosch-shc/icon.png +0 -0
- package/dist/account-integration-offline-B_AZ8wAX.js +50 -0
- package/dist/account-integration-offline-l27nXZT3.mjs +39 -0
- package/dist/dist-BoMB3-eL.mjs +79087 -0
- package/dist/dist-DvEvuaoA.js +79278 -0
- package/dist/home-connect/addon.js +3168 -0
- package/dist/home-connect/addon.mjs +3167 -0
- package/dist/homecom/addon.js +1117 -0
- package/dist/homecom/addon.mjs +1116 -0
- package/dist/index.js +7 -0
- package/dist/index.mjs +4 -0
- package/dist/shc/addon.js +23733 -0
- package/dist/shc/addon.mjs +23734 -0
- package/i18n/en.json +5 -0
- package/i18n/it.json +5 -0
- package/package.json +137 -4
- package/README.md +0 -3
|
@@ -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 };
|