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