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