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