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