@camstack/addon-provider-bosch 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/bosch-home-connect/icon.png +0 -0
- package/assets/bosch-homecom/icon.png +0 -0
- package/assets/bosch-shc/icon.png +0 -0
- package/dist/account-integration-offline-B_AZ8wAX.js +50 -0
- package/dist/account-integration-offline-l27nXZT3.mjs +39 -0
- package/dist/dist-BoMB3-eL.mjs +79087 -0
- package/dist/dist-DvEvuaoA.js +79278 -0
- package/dist/home-connect/addon.js +3168 -0
- package/dist/home-connect/addon.mjs +3167 -0
- package/dist/homecom/addon.js +1117 -0
- package/dist/homecom/addon.mjs +1116 -0
- package/dist/index.js +7 -0
- package/dist/index.mjs +4 -0
- package/dist/shc/addon.js +23733 -0
- package/dist/shc/addon.mjs +23734 -0
- package/i18n/en.json +5 -0
- package/i18n/it.json +5 -0
- package/package.json +137 -4
- package/README.md +0 -3
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
import { C as _enum, D as object, M as EventCategory, T as literal, _ as DeviceType, a as climateControlCapability, h as waterHeaterCapability, k as string, n as BaseDeviceProvider, o as connectionTestCapability, s as deviceAdoptionCapability, t as BaseDevice, v as errMsg } from "../dist-BoMB3-eL.mjs";
|
|
2
|
+
import { n as cascadeRemoveDevicesForIntegration, t as setIntegrationDevicesOnline } from "../account-integration-offline-l27nXZT3.mjs";
|
|
3
|
+
//#region src/homecom/homecom-discovery.ts
|
|
4
|
+
function buildHomeComCandidates(input) {
|
|
5
|
+
const out = [];
|
|
6
|
+
for (const dev of input.devices) {
|
|
7
|
+
const type = dev.kind === "heating-circuit" ? DeviceType.Climate : DeviceType.WaterHeater;
|
|
8
|
+
const caps = dev.kind === "heating-circuit" ? ["climate-control"] : ["water-heater"];
|
|
9
|
+
const adoptedId = input.adopted.get(dev.nativeId) ?? null;
|
|
10
|
+
out.push({
|
|
11
|
+
childNativeId: dev.nativeId,
|
|
12
|
+
name: dev.name,
|
|
13
|
+
type,
|
|
14
|
+
status: "online",
|
|
15
|
+
metadata: { serialNumber: dev.nativeId },
|
|
16
|
+
alreadyAdopted: adoptedId !== null,
|
|
17
|
+
adoptedDeviceId: adoptedId,
|
|
18
|
+
capabilities: caps
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/homecom/homecom-adoption-provider.ts
|
|
25
|
+
var DEVICES_FILTER = {
|
|
26
|
+
id: "devices",
|
|
27
|
+
label: "Devices",
|
|
28
|
+
isDefault: true
|
|
29
|
+
};
|
|
30
|
+
async function adoptedMapForIntegration(integrationId, listAdopted) {
|
|
31
|
+
const all = await listAdopted();
|
|
32
|
+
const map = /* @__PURE__ */ new Map();
|
|
33
|
+
for (const device of all) if (device.config["system"] === "homecom" && device.config["brokerId"] === integrationId) {
|
|
34
|
+
const nativeId = device.config["nativeId"];
|
|
35
|
+
if (typeof nativeId === "string") map.set(nativeId, device.id);
|
|
36
|
+
}
|
|
37
|
+
return map;
|
|
38
|
+
}
|
|
39
|
+
function buildHomeComAdoptionProvider(deps) {
|
|
40
|
+
const { registry, hasIntegration, listIntegrations, listAdopted, adoptDevice, removeDevice, findDeviceConfig, logger } = deps;
|
|
41
|
+
async function allCandidatesForIntegration(integrationId) {
|
|
42
|
+
return buildHomeComCandidates({
|
|
43
|
+
devices: registry.discoverables(integrationId),
|
|
44
|
+
adopted: await adoptedMapForIntegration(integrationId, listAdopted)
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
listCandidateFilters: async () => ({ filters: [DEVICES_FILTER] }),
|
|
49
|
+
listCandidates: async ({ integrationId, page, pageSize }) => {
|
|
50
|
+
const cands = await allCandidatesForIntegration(integrationId);
|
|
51
|
+
const start = (page - 1) * pageSize;
|
|
52
|
+
return {
|
|
53
|
+
candidates: cands.slice(start, start + pageSize),
|
|
54
|
+
totalCount: cands.length,
|
|
55
|
+
page,
|
|
56
|
+
pageSize
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
getCandidate: async ({ integrationId, childNativeId }) => {
|
|
60
|
+
return (await allCandidatesForIntegration(integrationId)).find((c) => c.childNativeId === childNativeId) ?? null;
|
|
61
|
+
},
|
|
62
|
+
getStatus: async () => {
|
|
63
|
+
try {
|
|
64
|
+
let candidateCount = 0;
|
|
65
|
+
for (const integrationId of listIntegrations()) candidateCount += (await allCandidatesForIntegration(integrationId)).length;
|
|
66
|
+
const adoptedCount = (await listAdopted()).filter((d) => d.config["system"] === "homecom").length;
|
|
67
|
+
return {
|
|
68
|
+
lastDiscoveryAt: Date.now(),
|
|
69
|
+
candidateCount,
|
|
70
|
+
adoptedCount,
|
|
71
|
+
lastError: null
|
|
72
|
+
};
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return {
|
|
75
|
+
lastDiscoveryAt: null,
|
|
76
|
+
candidateCount: 0,
|
|
77
|
+
adoptedCount: 0,
|
|
78
|
+
lastError: errMsg(err)
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
refresh: async ({ integrationId }) => {
|
|
83
|
+
const candidateCount = (await allCandidatesForIntegration(integrationId)).length;
|
|
84
|
+
const adoptedCount = (await adoptedMapForIntegration(integrationId, listAdopted)).size;
|
|
85
|
+
return {
|
|
86
|
+
lastDiscoveryAt: Date.now(),
|
|
87
|
+
candidateCount,
|
|
88
|
+
adoptedCount,
|
|
89
|
+
lastError: null
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
adopt: async ({ integrationId, childNativeIds, perCandidate }) => {
|
|
93
|
+
if (!registry.has(integrationId)) throw new Error(`homecom adopt: integration ${integrationId} not connected`);
|
|
94
|
+
const devices = registry.discoverables(integrationId);
|
|
95
|
+
const adopted = [];
|
|
96
|
+
let failures = 0;
|
|
97
|
+
for (const nativeId of childNativeIds) try {
|
|
98
|
+
const dev = devices.find((d) => d.nativeId === nativeId);
|
|
99
|
+
if (!dev) {
|
|
100
|
+
failures++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const candidate = buildHomeComCandidates({
|
|
104
|
+
devices: [dev],
|
|
105
|
+
adopted: /* @__PURE__ */ new Map()
|
|
106
|
+
})[0];
|
|
107
|
+
if (!candidate) {
|
|
108
|
+
failures++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const name = perCandidate?.[nativeId]?.name ?? dev.name;
|
|
112
|
+
const { deviceId } = await adoptDevice({
|
|
113
|
+
nativeId,
|
|
114
|
+
integrationId,
|
|
115
|
+
type: candidate.type,
|
|
116
|
+
name,
|
|
117
|
+
kind: dev.kind,
|
|
118
|
+
circuitId: dev.circuitId
|
|
119
|
+
});
|
|
120
|
+
adopted.push({
|
|
121
|
+
childNativeId: nativeId,
|
|
122
|
+
parentDeviceId: deviceId
|
|
123
|
+
});
|
|
124
|
+
} catch (err) {
|
|
125
|
+
logger.warn("homecom adopt failed", { meta: {
|
|
126
|
+
nativeId,
|
|
127
|
+
integrationId,
|
|
128
|
+
error: errMsg(err)
|
|
129
|
+
} });
|
|
130
|
+
failures++;
|
|
131
|
+
}
|
|
132
|
+
if (adopted.length === 0 && failures > 0) throw new Error(`homecom adopt: all ${failures} adopt(s) failed`);
|
|
133
|
+
return { adopted: adopted.map((a) => ({
|
|
134
|
+
...a,
|
|
135
|
+
accessoryDeviceIds: []
|
|
136
|
+
})) };
|
|
137
|
+
},
|
|
138
|
+
release: async ({ camDeviceId }) => {
|
|
139
|
+
await removeDevice(camDeviceId);
|
|
140
|
+
},
|
|
141
|
+
resync: async ({ camDeviceId }) => {
|
|
142
|
+
const cfg = await findDeviceConfig(camDeviceId);
|
|
143
|
+
if (!cfg || cfg["system"] !== "homecom") throw new Error(`homecom resync: device ${camDeviceId} not found`);
|
|
144
|
+
const integrationId = String(cfg["brokerId"]);
|
|
145
|
+
const nativeId = String(cfg["nativeId"]);
|
|
146
|
+
if (!hasIntegration(integrationId)) throw new Error(`homecom resync: integration ${integrationId} not connected`);
|
|
147
|
+
if (!registry.discoverables(integrationId).some((d) => d.nativeId === nativeId)) throw new Error(`homecom resync: device ${nativeId} no longer present`);
|
|
148
|
+
return {
|
|
149
|
+
changed: false,
|
|
150
|
+
rebuiltChildren: 0
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/homecom/config.ts
|
|
157
|
+
var HOMECOM_BRANDS = ["bosch", "buderus"];
|
|
158
|
+
var HomeComBrandSchema = _enum(HOMECOM_BRANDS);
|
|
159
|
+
var HOMECOM_REGIONS = ["eu", "us"];
|
|
160
|
+
var HomeComRegionSchema = _enum(HOMECOM_REGIONS);
|
|
161
|
+
var homeComConfigSchema = object({
|
|
162
|
+
username: string().min(1).describe("HomeCom / PointT account email").meta({ secret: true }),
|
|
163
|
+
token: string().min(1).describe("OAuth access token").meta({ secret: true }),
|
|
164
|
+
refresh: string().min(1).describe("OAuth refresh token").meta({ secret: true }),
|
|
165
|
+
brand: HomeComBrandSchema.default("bosch"),
|
|
166
|
+
region: HomeComRegionSchema.default("eu")
|
|
167
|
+
});
|
|
168
|
+
function connectionFromSettings(settings) {
|
|
169
|
+
const parsed = homeComConfigSchema.safeParse(settings);
|
|
170
|
+
return parsed.success ? parsed.data : null;
|
|
171
|
+
}
|
|
172
|
+
function sameHomeComConnection(a, b) {
|
|
173
|
+
if (a === null || b === null) return false;
|
|
174
|
+
return a.username === b.username && a.token === b.token && a.refresh === b.refresh && a.brand === b.brand && a.region === b.region;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* PointT REST origin (matches homecom_alt / HomeCom Easy). US and EU accounts use
|
|
178
|
+
* the same host; `region` is retained for Bacon MQTT (`euc1` / `use1`) when wired.
|
|
179
|
+
*/
|
|
180
|
+
var HOMECOM_POINTT_API_ORIGIN = "https://pointt-api.bosch-thermotechnology.com";
|
|
181
|
+
function homeComApiBase(_region) {
|
|
182
|
+
return HOMECOM_POINTT_API_ORIGIN;
|
|
183
|
+
}
|
|
184
|
+
function buildHomeComFormSchema() {
|
|
185
|
+
return { sections: [{
|
|
186
|
+
id: "account",
|
|
187
|
+
title: "HomeCom account",
|
|
188
|
+
columns: 1,
|
|
189
|
+
fields: [
|
|
190
|
+
{
|
|
191
|
+
type: "text",
|
|
192
|
+
key: "username",
|
|
193
|
+
label: "Username",
|
|
194
|
+
required: true
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
type: "password",
|
|
198
|
+
key: "token",
|
|
199
|
+
label: "Access token",
|
|
200
|
+
required: true,
|
|
201
|
+
showToggle: true
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
type: "password",
|
|
205
|
+
key: "refresh",
|
|
206
|
+
label: "Refresh token",
|
|
207
|
+
required: true,
|
|
208
|
+
showToggle: true
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
type: "select",
|
|
212
|
+
key: "brand",
|
|
213
|
+
label: "Brand",
|
|
214
|
+
default: "bosch",
|
|
215
|
+
options: HOMECOM_BRANDS.map((b) => ({
|
|
216
|
+
value: b,
|
|
217
|
+
label: b
|
|
218
|
+
}))
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
type: "select",
|
|
222
|
+
key: "region",
|
|
223
|
+
label: "Region",
|
|
224
|
+
default: "eu",
|
|
225
|
+
options: HOMECOM_REGIONS.map((r) => ({
|
|
226
|
+
value: r,
|
|
227
|
+
label: r.toUpperCase()
|
|
228
|
+
}))
|
|
229
|
+
}
|
|
230
|
+
]
|
|
231
|
+
}] };
|
|
232
|
+
}
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/homecom/homecom-gateway.ts
|
|
235
|
+
var HomeComConnectionResolver = class {
|
|
236
|
+
#surfaces = /* @__PURE__ */ new Map();
|
|
237
|
+
set(integrationId, surface) {
|
|
238
|
+
if (surface === null) {
|
|
239
|
+
this.#surfaces.delete(integrationId);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.#surfaces.set(integrationId, surface);
|
|
243
|
+
}
|
|
244
|
+
has(integrationId) {
|
|
245
|
+
return this.#surfaces.has(integrationId);
|
|
246
|
+
}
|
|
247
|
+
list() {
|
|
248
|
+
return Array.from(this.#surfaces.keys()).map((id) => ({ id }));
|
|
249
|
+
}
|
|
250
|
+
discoverables(integrationId) {
|
|
251
|
+
return this.#surfaces.get(integrationId)?.discoverables ?? [];
|
|
252
|
+
}
|
|
253
|
+
getClient(integrationId) {
|
|
254
|
+
return this.#surfaces.get(integrationId)?.client ?? null;
|
|
255
|
+
}
|
|
256
|
+
getSnapshot(integrationId) {
|
|
257
|
+
return this.#surfaces.get(integrationId)?.snapshot ?? null;
|
|
258
|
+
}
|
|
259
|
+
updateSnapshot(integrationId, snapshot) {
|
|
260
|
+
const existing = this.#surfaces.get(integrationId);
|
|
261
|
+
if (!existing) return;
|
|
262
|
+
this.#surfaces.set(integrationId, {
|
|
263
|
+
...existing,
|
|
264
|
+
snapshot,
|
|
265
|
+
discoverables: buildDiscoverables(snapshot)
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
clear() {
|
|
269
|
+
this.#surfaces.clear();
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
function buildDiscoverables(snapshot) {
|
|
273
|
+
const out = [];
|
|
274
|
+
for (const circuit of snapshot.circuits) out.push({
|
|
275
|
+
nativeId: `hc:${circuit.id}`,
|
|
276
|
+
name: circuit.name,
|
|
277
|
+
kind: "heating-circuit",
|
|
278
|
+
circuitId: circuit.id
|
|
279
|
+
});
|
|
280
|
+
if (snapshot.dhw) out.push({
|
|
281
|
+
nativeId: "dhw:0",
|
|
282
|
+
name: "Domestic hot water",
|
|
283
|
+
kind: "dhw"
|
|
284
|
+
});
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
var homeComConnections = new HomeComConnectionResolver();
|
|
288
|
+
//#endregion
|
|
289
|
+
//#region src/homecom/homecom-domain-mapping.ts
|
|
290
|
+
var MODE_MAP = {
|
|
291
|
+
Off: "off",
|
|
292
|
+
off: "off",
|
|
293
|
+
Heat: "heat",
|
|
294
|
+
heat: "heat",
|
|
295
|
+
Cool: "cool",
|
|
296
|
+
cool: "cool",
|
|
297
|
+
Auto: "auto",
|
|
298
|
+
auto: "auto"
|
|
299
|
+
};
|
|
300
|
+
function mapHvacMode(raw) {
|
|
301
|
+
return MODE_MAP[raw] ?? "off";
|
|
302
|
+
}
|
|
303
|
+
function climateSliceFromCircuit(circuit, nowMs) {
|
|
304
|
+
return {
|
|
305
|
+
mode: mapHvacMode(circuit.operationMode),
|
|
306
|
+
availableModes: [
|
|
307
|
+
"off",
|
|
308
|
+
"heat",
|
|
309
|
+
"auto"
|
|
310
|
+
],
|
|
311
|
+
fanMode: "",
|
|
312
|
+
availableFanModes: [],
|
|
313
|
+
preset: "",
|
|
314
|
+
availablePresets: [],
|
|
315
|
+
target: circuit.setpoint,
|
|
316
|
+
targetHigh: null,
|
|
317
|
+
targetLow: null,
|
|
318
|
+
targetHumidity: null,
|
|
319
|
+
currentTemp: circuit.currentRoomTemp,
|
|
320
|
+
currentHumidity: null,
|
|
321
|
+
swingVertical: null,
|
|
322
|
+
swingHorizontal: null,
|
|
323
|
+
minTemp: circuit.minTemp,
|
|
324
|
+
maxTemp: circuit.maxTemp,
|
|
325
|
+
tempStep: .5,
|
|
326
|
+
lastFetchedAt: nowMs
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function waterHeaterSliceFromDhw(dhw, nowMs) {
|
|
330
|
+
return {
|
|
331
|
+
currentTemp: dhw.currentTemp,
|
|
332
|
+
targetTemp: dhw.targetTemp,
|
|
333
|
+
operationMode: dhw.operationMode,
|
|
334
|
+
availableModes: [...dhw.availableModes],
|
|
335
|
+
away: null,
|
|
336
|
+
minTemp: dhw.minTemp,
|
|
337
|
+
maxTemp: dhw.maxTemp,
|
|
338
|
+
lastChangedAt: nowMs
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/** Parse K40-style gateway JSON into heating circuits + DHW. Pure. */
|
|
342
|
+
function parseHomeComSystemPayload(payload) {
|
|
343
|
+
if (typeof payload !== "object" || payload === null) return {
|
|
344
|
+
circuits: [],
|
|
345
|
+
dhw: null
|
|
346
|
+
};
|
|
347
|
+
const root = payload;
|
|
348
|
+
const circuits = [];
|
|
349
|
+
const rawCircuits = root["heatingCircuits"];
|
|
350
|
+
if (Array.isArray(rawCircuits)) for (const item of rawCircuits) {
|
|
351
|
+
if (typeof item !== "object" || item === null) continue;
|
|
352
|
+
const row = item;
|
|
353
|
+
const id = row["id"];
|
|
354
|
+
if (typeof id !== "string") continue;
|
|
355
|
+
circuits.push({
|
|
356
|
+
id,
|
|
357
|
+
name: typeof row["name"] === "string" ? row["name"] : id,
|
|
358
|
+
operationMode: typeof row["operationMode"] === "string" ? row["operationMode"] : "off",
|
|
359
|
+
setpoint: readNum(row["setpoint"]),
|
|
360
|
+
currentRoomTemp: readNum(row["currentRoomTemperature"]),
|
|
361
|
+
minTemp: readNum(row["minTemp"]),
|
|
362
|
+
maxTemp: readNum(row["maxTemp"])
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
let dhw = null;
|
|
366
|
+
const rawDhw = root["domesticHotWater"];
|
|
367
|
+
if (typeof rawDhw === "object" && rawDhw !== null) {
|
|
368
|
+
const row = rawDhw;
|
|
369
|
+
const modes = [];
|
|
370
|
+
if (Array.isArray(row["operationModes"])) {
|
|
371
|
+
for (const m of row["operationModes"]) if (typeof m === "string") modes.push(m);
|
|
372
|
+
}
|
|
373
|
+
dhw = {
|
|
374
|
+
currentTemp: readNum(row["currentTemperature"]),
|
|
375
|
+
targetTemp: readNum(row["setpoint"]),
|
|
376
|
+
operationMode: typeof row["operationMode"] === "string" ? row["operationMode"] : null,
|
|
377
|
+
availableModes: modes.length > 0 ? modes : [
|
|
378
|
+
"off",
|
|
379
|
+
"eco",
|
|
380
|
+
"performance"
|
|
381
|
+
],
|
|
382
|
+
minTemp: readNum(row["minTemp"]),
|
|
383
|
+
maxTemp: readNum(row["maxTemp"])
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
circuits,
|
|
388
|
+
dhw
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
function readNum(raw) {
|
|
392
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
//#endregion
|
|
396
|
+
//#region src/homecom/homecom-api-client.ts
|
|
397
|
+
var HomeComAuthError = class extends Error {
|
|
398
|
+
name = "HomeComAuthError";
|
|
399
|
+
};
|
|
400
|
+
function createHomeComApiClient(config, gatewayId, deps = {}) {
|
|
401
|
+
const fetchFn = deps.fetchFn ?? globalThis.fetch.bind(globalThis);
|
|
402
|
+
let token = config.token;
|
|
403
|
+
let refresh = config.refresh;
|
|
404
|
+
const base = homeComApiBase(config.region);
|
|
405
|
+
async function authHeaders() {
|
|
406
|
+
return {
|
|
407
|
+
Authorization: `Bearer ${token}`,
|
|
408
|
+
Accept: "application/json",
|
|
409
|
+
"Content-Type": "application/json"
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
async function request(path, init = {}) {
|
|
413
|
+
const res = await fetchFn(`${base}${path}`, {
|
|
414
|
+
method: init.method ?? "GET",
|
|
415
|
+
headers: await authHeaders(),
|
|
416
|
+
body: init.body !== void 0 ? JSON.stringify(init.body) : void 0
|
|
417
|
+
});
|
|
418
|
+
if (res.status === 401) throw new HomeComAuthError("token expired");
|
|
419
|
+
if (!res.ok) throw new Error(`HomeCom API ${path} failed: HTTP ${res.status}`);
|
|
420
|
+
return res.json();
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
async refreshTokens() {
|
|
424
|
+
const res = await fetchFn(`${base}/token/oauth2`, {
|
|
425
|
+
method: "POST",
|
|
426
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
427
|
+
body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(refresh)}&client_id=homecom`
|
|
428
|
+
});
|
|
429
|
+
if (!res.ok) {
|
|
430
|
+
if (res.status === 401 || res.status === 403) throw new HomeComAuthError("refresh refused");
|
|
431
|
+
throw new Error(`HomeCom token refresh failed: HTTP ${res.status}`);
|
|
432
|
+
}
|
|
433
|
+
const body = await res.json();
|
|
434
|
+
if (typeof body !== "object" || body === null) throw new Error("HomeCom token refresh: bad body");
|
|
435
|
+
const row = body;
|
|
436
|
+
const newToken = row["access_token"];
|
|
437
|
+
const newRefresh = row["refresh_token"];
|
|
438
|
+
if (typeof newToken !== "string" || typeof newRefresh !== "string") throw new Error("HomeCom token refresh: missing tokens");
|
|
439
|
+
token = newToken;
|
|
440
|
+
refresh = newRefresh;
|
|
441
|
+
return {
|
|
442
|
+
token,
|
|
443
|
+
refresh
|
|
444
|
+
};
|
|
445
|
+
},
|
|
446
|
+
async fetchSystem() {
|
|
447
|
+
const parsed = parseHomeComSystemPayload(await request(`/v2/gateways/${gatewayId}/system`));
|
|
448
|
+
return {
|
|
449
|
+
gateway: {
|
|
450
|
+
gatewayId,
|
|
451
|
+
name: "HomeCom heat pump",
|
|
452
|
+
model: "K40"
|
|
453
|
+
},
|
|
454
|
+
circuits: parsed.circuits,
|
|
455
|
+
dhw: parsed.dhw
|
|
456
|
+
};
|
|
457
|
+
},
|
|
458
|
+
async setCircuitSetpoint(circuitId, temp) {
|
|
459
|
+
await request(`/v2/gateways/${gatewayId}/heatingCircuits/${circuitId}/setpoint`, {
|
|
460
|
+
method: "PUT",
|
|
461
|
+
body: { value: temp }
|
|
462
|
+
});
|
|
463
|
+
},
|
|
464
|
+
async setCircuitMode(circuitId, mode) {
|
|
465
|
+
await request(`/v2/gateways/${gatewayId}/heatingCircuits/${circuitId}/operationMode`, {
|
|
466
|
+
method: "PUT",
|
|
467
|
+
body: { value: mode }
|
|
468
|
+
});
|
|
469
|
+
},
|
|
470
|
+
async setDhwTarget(temp) {
|
|
471
|
+
await request(`/v2/gateways/${gatewayId}/domesticHotWater/setpoint`, {
|
|
472
|
+
method: "PUT",
|
|
473
|
+
body: { value: temp }
|
|
474
|
+
});
|
|
475
|
+
},
|
|
476
|
+
async setDhwMode(mode) {
|
|
477
|
+
await request(`/v2/gateways/${gatewayId}/domesticHotWater/operationMode`, {
|
|
478
|
+
method: "PUT",
|
|
479
|
+
body: { value: mode }
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
//#endregion
|
|
485
|
+
//#region src/homecom/homecom-integration-manager.ts
|
|
486
|
+
var HomeComIntegrationManager = class {
|
|
487
|
+
#id;
|
|
488
|
+
#name;
|
|
489
|
+
#connection;
|
|
490
|
+
#logger;
|
|
491
|
+
#onConnected;
|
|
492
|
+
#onDisconnected;
|
|
493
|
+
#surfaceSink;
|
|
494
|
+
#makeClient;
|
|
495
|
+
#pollIntervalMs;
|
|
496
|
+
#client = null;
|
|
497
|
+
#gatewayId = null;
|
|
498
|
+
#pollTimer = null;
|
|
499
|
+
#status = "disconnected";
|
|
500
|
+
#error = null;
|
|
501
|
+
#lastCheckedAt = null;
|
|
502
|
+
constructor(options) {
|
|
503
|
+
this.#id = options.id;
|
|
504
|
+
this.#name = options.name;
|
|
505
|
+
this.#connection = options.connection;
|
|
506
|
+
this.#logger = options.logger;
|
|
507
|
+
this.#onConnected = options.onConnected;
|
|
508
|
+
this.#onDisconnected = options.onDisconnected;
|
|
509
|
+
this.#surfaceSink = options.surfaceSink;
|
|
510
|
+
const fetchFn = options.fetchFn;
|
|
511
|
+
this.#makeClient = options.makeClient ?? ((config, gatewayId) => createHomeComApiClient(config, gatewayId, { fetchFn }));
|
|
512
|
+
this.#pollIntervalMs = options.pollIntervalMs ?? 6e4;
|
|
513
|
+
}
|
|
514
|
+
getConnection() {
|
|
515
|
+
return this.#connection;
|
|
516
|
+
}
|
|
517
|
+
getInfo() {
|
|
518
|
+
return {
|
|
519
|
+
id: this.#id,
|
|
520
|
+
name: this.#name,
|
|
521
|
+
kind: "homecom",
|
|
522
|
+
status: this.#status,
|
|
523
|
+
info: {
|
|
524
|
+
username: this.#connection.username,
|
|
525
|
+
gatewayId: this.#gatewayId
|
|
526
|
+
},
|
|
527
|
+
lastCheckedAt: this.#lastCheckedAt,
|
|
528
|
+
error: this.#error
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
async start() {
|
|
532
|
+
if (this.#client !== null) return;
|
|
533
|
+
this.#status = "connecting";
|
|
534
|
+
this.#error = null;
|
|
535
|
+
const gatewayId = await this.resolveGatewayId(this.#connection);
|
|
536
|
+
this.#gatewayId = gatewayId;
|
|
537
|
+
const client = this.#makeClient(this.#connection, gatewayId);
|
|
538
|
+
this.#client = client;
|
|
539
|
+
try {
|
|
540
|
+
const snapshot = await this.fetchWithTokenRefresh(client);
|
|
541
|
+
const surface = {
|
|
542
|
+
gatewayId,
|
|
543
|
+
client,
|
|
544
|
+
snapshot,
|
|
545
|
+
discoverables: buildDiscoverables(snapshot)
|
|
546
|
+
};
|
|
547
|
+
this.#surfaceSink.set(this.#id, surface);
|
|
548
|
+
this.#status = "connected";
|
|
549
|
+
this.#lastCheckedAt = Date.now();
|
|
550
|
+
this.#onConnected(this.#id);
|
|
551
|
+
this.#pollTimer = setInterval(() => {
|
|
552
|
+
this.pollOnce();
|
|
553
|
+
}, this.#pollIntervalMs);
|
|
554
|
+
} catch (err) {
|
|
555
|
+
this.#status = "error";
|
|
556
|
+
this.#error = err instanceof HomeComAuthError ? "authentication failed — check tokens" : errMsg(err);
|
|
557
|
+
this.#surfaceSink.set(this.#id, null);
|
|
558
|
+
this.#onDisconnected(this.#id);
|
|
559
|
+
throw err;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
async stop() {
|
|
563
|
+
if (this.#pollTimer) {
|
|
564
|
+
clearInterval(this.#pollTimer);
|
|
565
|
+
this.#pollTimer = null;
|
|
566
|
+
}
|
|
567
|
+
this.#client = null;
|
|
568
|
+
this.#gatewayId = null;
|
|
569
|
+
this.#status = "disconnected";
|
|
570
|
+
this.#surfaceSink.set(this.#id, null);
|
|
571
|
+
this.#onDisconnected(this.#id);
|
|
572
|
+
}
|
|
573
|
+
async applyConnection(conn) {
|
|
574
|
+
await this.stop();
|
|
575
|
+
this.#connection = conn;
|
|
576
|
+
await this.start();
|
|
577
|
+
}
|
|
578
|
+
async pollOnce() {
|
|
579
|
+
const client = this.#client;
|
|
580
|
+
if (!client) return;
|
|
581
|
+
try {
|
|
582
|
+
const snapshot = await this.fetchWithTokenRefresh(client);
|
|
583
|
+
this.#surfaceSink.updateSnapshot(this.#id, snapshot);
|
|
584
|
+
this.#lastCheckedAt = Date.now();
|
|
585
|
+
} catch (err) {
|
|
586
|
+
this.#logger.warn("HomeCom poll failed", { meta: {
|
|
587
|
+
integrationId: this.#id,
|
|
588
|
+
error: errMsg(err)
|
|
589
|
+
} });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
async fetchWithTokenRefresh(client) {
|
|
593
|
+
try {
|
|
594
|
+
return await client.fetchSystem();
|
|
595
|
+
} catch (err) {
|
|
596
|
+
if (!(err instanceof HomeComAuthError)) throw err;
|
|
597
|
+
await client.refreshTokens();
|
|
598
|
+
return client.fetchSystem();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/** Discover K40 gateway id via account listing (simplified: username hash stub). */
|
|
602
|
+
async resolveGatewayId(config) {
|
|
603
|
+
return "gateway-k40";
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region src/homecom/homecom-client-registry.ts
|
|
608
|
+
var HomeComClientRegistry = class {
|
|
609
|
+
#logger;
|
|
610
|
+
#onConnected;
|
|
611
|
+
#onDisconnected;
|
|
612
|
+
#makeManager;
|
|
613
|
+
#entries = /* @__PURE__ */ new Map();
|
|
614
|
+
constructor(logger, deps = {}) {
|
|
615
|
+
this.#logger = logger;
|
|
616
|
+
this.#onConnected = deps.onConnected ?? (() => void 0);
|
|
617
|
+
this.#onDisconnected = deps.onDisconnected ?? (() => void 0);
|
|
618
|
+
const fetchFn = deps.fetchFn;
|
|
619
|
+
const makeClient = deps.makeClient;
|
|
620
|
+
this.#makeManager = deps.makeManager ?? ((opts) => new HomeComIntegrationManager({
|
|
621
|
+
id: opts.integrationId,
|
|
622
|
+
name: opts.name,
|
|
623
|
+
connection: opts.connection,
|
|
624
|
+
logger: opts.logger,
|
|
625
|
+
onConnected: opts.onConnected,
|
|
626
|
+
onDisconnected: opts.onDisconnected,
|
|
627
|
+
surfaceSink: homeComConnections,
|
|
628
|
+
fetchFn,
|
|
629
|
+
makeClient,
|
|
630
|
+
pollIntervalMs: 6e4
|
|
631
|
+
}));
|
|
632
|
+
}
|
|
633
|
+
setOnConnected(cb) {
|
|
634
|
+
this.#onConnected = cb;
|
|
635
|
+
}
|
|
636
|
+
setOnDisconnected(cb) {
|
|
637
|
+
this.#onDisconnected = cb;
|
|
638
|
+
}
|
|
639
|
+
async upsert(integrationId, name, connection) {
|
|
640
|
+
const existing = this.#entries.get(integrationId);
|
|
641
|
+
if (existing) {
|
|
642
|
+
if (sameHomeComConnection(existing.connection, connection)) return;
|
|
643
|
+
try {
|
|
644
|
+
await existing.manager.applyConnection(connection);
|
|
645
|
+
this.#entries.set(integrationId, {
|
|
646
|
+
manager: existing.manager,
|
|
647
|
+
connection
|
|
648
|
+
});
|
|
649
|
+
} catch (err) {
|
|
650
|
+
this.#logger.warn("HomeComClientRegistry: applyConnection failed", { meta: {
|
|
651
|
+
integrationId,
|
|
652
|
+
error: errMsg(err)
|
|
653
|
+
} });
|
|
654
|
+
}
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const manager = this.#makeManager({
|
|
658
|
+
integrationId,
|
|
659
|
+
name,
|
|
660
|
+
connection,
|
|
661
|
+
logger: this.#logger,
|
|
662
|
+
onConnected: (id) => this.#onConnected(id),
|
|
663
|
+
onDisconnected: (id) => this.#onDisconnected(id)
|
|
664
|
+
});
|
|
665
|
+
this.#entries.set(integrationId, {
|
|
666
|
+
manager,
|
|
667
|
+
connection
|
|
668
|
+
});
|
|
669
|
+
try {
|
|
670
|
+
await manager.start();
|
|
671
|
+
} catch (err) {
|
|
672
|
+
this.#logger.warn("HomeComClientRegistry: manager start failed", { meta: {
|
|
673
|
+
integrationId,
|
|
674
|
+
error: errMsg(err)
|
|
675
|
+
} });
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
async remove(integrationId) {
|
|
679
|
+
const entry = this.#entries.get(integrationId);
|
|
680
|
+
if (!entry) return;
|
|
681
|
+
this.#entries.delete(integrationId);
|
|
682
|
+
try {
|
|
683
|
+
await entry.manager.stop();
|
|
684
|
+
} catch (err) {
|
|
685
|
+
this.#logger.warn("HomeComClientRegistry: remove stop failed", { meta: {
|
|
686
|
+
integrationId,
|
|
687
|
+
error: errMsg(err)
|
|
688
|
+
} });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
async retain(keep) {
|
|
692
|
+
for (const id of [...this.#entries.keys()].filter((k) => !keep.has(k))) await this.remove(id);
|
|
693
|
+
}
|
|
694
|
+
async shutdown() {
|
|
695
|
+
for (const id of [...this.#entries.keys()]) try {
|
|
696
|
+
await this.#entries.get(id)?.manager.stop();
|
|
697
|
+
} catch (err) {
|
|
698
|
+
this.#logger.warn("HomeComClientRegistry: shutdown stop failed", { meta: {
|
|
699
|
+
integrationId: id,
|
|
700
|
+
error: errMsg(err)
|
|
701
|
+
} });
|
|
702
|
+
}
|
|
703
|
+
this.#entries.clear();
|
|
704
|
+
homeComConnections.clear();
|
|
705
|
+
}
|
|
706
|
+
has(integrationId) {
|
|
707
|
+
return this.#entries.has(integrationId);
|
|
708
|
+
}
|
|
709
|
+
list() {
|
|
710
|
+
return [...this.#entries.keys()];
|
|
711
|
+
}
|
|
712
|
+
getInfo(integrationId) {
|
|
713
|
+
return this.#entries.get(integrationId)?.manager.getInfo() ?? null;
|
|
714
|
+
}
|
|
715
|
+
size() {
|
|
716
|
+
return this.#entries.size;
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
//#endregion
|
|
720
|
+
//#region src/homecom/homecom-connection-test.ts
|
|
721
|
+
async function defaultResolveGatewayId(_config) {
|
|
722
|
+
return "gateway-k40";
|
|
723
|
+
}
|
|
724
|
+
var TEST_LABEL = "Refreshes HomeCom OAuth tokens and reads the heat-pump system snapshot";
|
|
725
|
+
function buildHomeComConnectionTestProvider(deps) {
|
|
726
|
+
const { logger } = deps;
|
|
727
|
+
const makeClient = deps.makeClient ?? ((config, gatewayId) => createHomeComApiClient(config, gatewayId));
|
|
728
|
+
const resolveGatewayId = deps.resolveGatewayId ?? defaultResolveGatewayId;
|
|
729
|
+
return {
|
|
730
|
+
describeTest: async () => ({ label: TEST_LABEL }),
|
|
731
|
+
testSettings: async ({ settings }) => {
|
|
732
|
+
const parsed = homeComConfigSchema.safeParse(settings);
|
|
733
|
+
if (!parsed.success) return {
|
|
734
|
+
outcome: "rejected",
|
|
735
|
+
error: `HomeCom settings incomplete: ${parsed.error.issues.map((i) => i.path.join(".") || "(root)").join(", ")}`
|
|
736
|
+
};
|
|
737
|
+
const startedAt = Date.now();
|
|
738
|
+
const gatewayId = await resolveGatewayId(parsed.data);
|
|
739
|
+
const client = makeClient(parsed.data, gatewayId);
|
|
740
|
+
try {
|
|
741
|
+
await client.refreshTokens();
|
|
742
|
+
await client.fetchSystem();
|
|
743
|
+
return {
|
|
744
|
+
outcome: "validated",
|
|
745
|
+
latencyMs: Date.now() - startedAt
|
|
746
|
+
};
|
|
747
|
+
} catch (err) {
|
|
748
|
+
if (err instanceof HomeComAuthError) return {
|
|
749
|
+
outcome: "rejected",
|
|
750
|
+
error: "HomeCom refused these tokens — re-authenticate the PointT account."
|
|
751
|
+
};
|
|
752
|
+
logger.warn("HomeCom connection test inconclusive", { meta: {
|
|
753
|
+
username: parsed.data.username,
|
|
754
|
+
error: errMsg(err)
|
|
755
|
+
} });
|
|
756
|
+
return {
|
|
757
|
+
outcome: "inconclusive",
|
|
758
|
+
error: "Could not reach the HomeCom cloud API."
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
//#endregion
|
|
765
|
+
//#region src/homecom/devices/homecom-climate-device.ts
|
|
766
|
+
var homeComClimateSchema = object({
|
|
767
|
+
nativeId: string().min(1),
|
|
768
|
+
brokerId: string().min(1),
|
|
769
|
+
circuitId: string().min(1),
|
|
770
|
+
system: literal("homecom").optional(),
|
|
771
|
+
integrationId: string().optional()
|
|
772
|
+
});
|
|
773
|
+
var CLIMATE_CAP = "climate-control";
|
|
774
|
+
var COLD_START$1 = {
|
|
775
|
+
mode: "off",
|
|
776
|
+
availableModes: [],
|
|
777
|
+
fanMode: "",
|
|
778
|
+
availableFanModes: [],
|
|
779
|
+
preset: "",
|
|
780
|
+
availablePresets: [],
|
|
781
|
+
target: null,
|
|
782
|
+
targetHigh: null,
|
|
783
|
+
targetLow: null,
|
|
784
|
+
targetHumidity: null,
|
|
785
|
+
currentTemp: null,
|
|
786
|
+
currentHumidity: null,
|
|
787
|
+
swingVertical: null,
|
|
788
|
+
swingHorizontal: null,
|
|
789
|
+
lastFetchedAt: 0
|
|
790
|
+
};
|
|
791
|
+
var HomeComClimateDevice = class extends BaseDevice {
|
|
792
|
+
features = [];
|
|
793
|
+
#brokerId;
|
|
794
|
+
#circuitId;
|
|
795
|
+
constructor(ctx) {
|
|
796
|
+
const persisted = homeComClimateSchema.parse(ctx.persistedConfig ?? {});
|
|
797
|
+
super(ctx, homeComClimateSchema, { type: ctx.deviceMeta.type });
|
|
798
|
+
this.#brokerId = persisted.brokerId;
|
|
799
|
+
this.#circuitId = persisted.circuitId;
|
|
800
|
+
this.online = true;
|
|
801
|
+
this.updateSourceInfo({
|
|
802
|
+
id: persisted.nativeId,
|
|
803
|
+
system: "homecom"
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
async onActivate() {
|
|
807
|
+
await super.onActivate();
|
|
808
|
+
this.registerClimateCap();
|
|
809
|
+
this.runtimeState.setCapState(CLIMATE_CAP, COLD_START$1);
|
|
810
|
+
this.refreshFromSnapshot();
|
|
811
|
+
}
|
|
812
|
+
refreshFromSnapshot() {
|
|
813
|
+
const circuit = homeComConnections.getSnapshot(this.#brokerId)?.circuits.find((c) => c.id === this.#circuitId);
|
|
814
|
+
if (!circuit) {
|
|
815
|
+
this.runtimeState.setCapState(CLIMATE_CAP, {
|
|
816
|
+
...COLD_START$1,
|
|
817
|
+
lastFetchedAt: Date.now()
|
|
818
|
+
});
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
this.runtimeState.setCapState(CLIMATE_CAP, climateSliceFromCircuit(circuit, Date.now()));
|
|
822
|
+
}
|
|
823
|
+
requireClient() {
|
|
824
|
+
const client = homeComConnections.getClient(this.#brokerId);
|
|
825
|
+
if (!client) throw new Error(`HomeCom client unavailable (circuit=${this.#circuitId})`);
|
|
826
|
+
return client;
|
|
827
|
+
}
|
|
828
|
+
registerClimateCap() {
|
|
829
|
+
this.ctx.registerNativeCap(climateControlCapability, {
|
|
830
|
+
getStatus: async () => {
|
|
831
|
+
this.refreshFromSnapshot();
|
|
832
|
+
return this.runtimeState.getCapState(CLIMATE_CAP) ?? COLD_START$1;
|
|
833
|
+
},
|
|
834
|
+
setMode: async ({ mode }) => {
|
|
835
|
+
const apiMode = hvacModeToHomeCom(mode);
|
|
836
|
+
await this.requireClient().setCircuitMode(this.#circuitId, apiMode);
|
|
837
|
+
this.refreshFromSnapshot();
|
|
838
|
+
},
|
|
839
|
+
setFanMode: async () => {
|
|
840
|
+
throw new Error("HomeCom climate: fan mode not supported");
|
|
841
|
+
},
|
|
842
|
+
setPreset: async () => {
|
|
843
|
+
throw new Error("HomeCom climate: presets not supported");
|
|
844
|
+
},
|
|
845
|
+
setTarget: async ({ target }) => {
|
|
846
|
+
await this.requireClient().setCircuitSetpoint(this.#circuitId, target);
|
|
847
|
+
this.refreshFromSnapshot();
|
|
848
|
+
},
|
|
849
|
+
setTargetRange: async () => {
|
|
850
|
+
throw new Error("HomeCom climate: dual setpoint not supported");
|
|
851
|
+
},
|
|
852
|
+
setTargetHumidity: async () => {
|
|
853
|
+
throw new Error("HomeCom climate: humidity not supported");
|
|
854
|
+
},
|
|
855
|
+
setSwingVertical: async () => {
|
|
856
|
+
throw new Error("HomeCom climate: swing not supported");
|
|
857
|
+
},
|
|
858
|
+
setSwingHorizontal: async () => {
|
|
859
|
+
throw new Error("HomeCom climate: swing not supported");
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
function hvacModeToHomeCom(mode) {
|
|
865
|
+
switch (mode) {
|
|
866
|
+
case "heat": return "Heat";
|
|
867
|
+
case "cool": return "Cool";
|
|
868
|
+
case "auto": return "Auto";
|
|
869
|
+
default: return "Off";
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
//#endregion
|
|
873
|
+
//#region src/homecom/devices/homecom-water-heater-device.ts
|
|
874
|
+
var homeComWaterHeaterSchema = object({
|
|
875
|
+
nativeId: string().min(1),
|
|
876
|
+
brokerId: string().min(1),
|
|
877
|
+
system: literal("homecom").optional(),
|
|
878
|
+
integrationId: string().optional()
|
|
879
|
+
});
|
|
880
|
+
var WH_CAP = "water-heater";
|
|
881
|
+
var COLD_START = {
|
|
882
|
+
currentTemp: null,
|
|
883
|
+
targetTemp: null,
|
|
884
|
+
operationMode: null,
|
|
885
|
+
availableModes: [],
|
|
886
|
+
away: null,
|
|
887
|
+
minTemp: null,
|
|
888
|
+
maxTemp: null,
|
|
889
|
+
lastChangedAt: 0
|
|
890
|
+
};
|
|
891
|
+
var HomeComWaterHeaterDevice = class extends BaseDevice {
|
|
892
|
+
features = [];
|
|
893
|
+
#brokerId;
|
|
894
|
+
constructor(ctx) {
|
|
895
|
+
const persisted = homeComWaterHeaterSchema.parse(ctx.persistedConfig ?? {});
|
|
896
|
+
super(ctx, homeComWaterHeaterSchema, { type: ctx.deviceMeta.type });
|
|
897
|
+
this.#brokerId = persisted.brokerId;
|
|
898
|
+
this.online = true;
|
|
899
|
+
this.updateSourceInfo({
|
|
900
|
+
id: persisted.nativeId,
|
|
901
|
+
system: "homecom"
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
async onActivate() {
|
|
905
|
+
await super.onActivate();
|
|
906
|
+
this.registerWaterHeaterCap();
|
|
907
|
+
this.runtimeState.setCapState(WH_CAP, COLD_START);
|
|
908
|
+
this.refreshFromSnapshot();
|
|
909
|
+
}
|
|
910
|
+
refreshFromSnapshot() {
|
|
911
|
+
const dhw = homeComConnections.getSnapshot(this.#brokerId)?.dhw;
|
|
912
|
+
if (!dhw) {
|
|
913
|
+
this.runtimeState.setCapState(WH_CAP, {
|
|
914
|
+
...COLD_START,
|
|
915
|
+
lastChangedAt: Date.now()
|
|
916
|
+
});
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
this.runtimeState.setCapState(WH_CAP, waterHeaterSliceFromDhw(dhw, Date.now()));
|
|
920
|
+
}
|
|
921
|
+
requireClient() {
|
|
922
|
+
const client = homeComConnections.getClient(this.#brokerId);
|
|
923
|
+
if (!client) throw new Error("HomeCom client unavailable (DHW)");
|
|
924
|
+
return client;
|
|
925
|
+
}
|
|
926
|
+
registerWaterHeaterCap() {
|
|
927
|
+
this.ctx.registerNativeCap(waterHeaterCapability, {
|
|
928
|
+
getStatus: async () => {
|
|
929
|
+
this.refreshFromSnapshot();
|
|
930
|
+
return this.runtimeState.getCapState(WH_CAP) ?? COLD_START;
|
|
931
|
+
},
|
|
932
|
+
setTargetTemp: async ({ temp }) => {
|
|
933
|
+
await this.requireClient().setDhwTarget(temp);
|
|
934
|
+
this.refreshFromSnapshot();
|
|
935
|
+
},
|
|
936
|
+
setOperationMode: async ({ mode }) => {
|
|
937
|
+
await this.requireClient().setDhwMode(mode);
|
|
938
|
+
this.refreshFromSnapshot();
|
|
939
|
+
},
|
|
940
|
+
setAway: async () => {
|
|
941
|
+
throw new Error("HomeCom DHW: away mode not supported");
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
//#endregion
|
|
947
|
+
//#region src/homecom/addon.ts
|
|
948
|
+
var BoschHomeComProviderAddon = class extends BaseDeviceProvider {
|
|
949
|
+
addonId = "bosch-homecom";
|
|
950
|
+
providerName = "Bosch HomeCom";
|
|
951
|
+
deviceClasses = {
|
|
952
|
+
[DeviceType.Climate]: HomeComClimateDevice,
|
|
953
|
+
[DeviceType.WaterHeater]: HomeComWaterHeaterDevice
|
|
954
|
+
};
|
|
955
|
+
clients = null;
|
|
956
|
+
constructor() {
|
|
957
|
+
super({});
|
|
958
|
+
}
|
|
959
|
+
async onInitialize() {
|
|
960
|
+
const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
|
|
961
|
+
this.clients = new HomeComClientRegistry(this.ctx.logger);
|
|
962
|
+
this.clients.setOnConnected((integrationId) => {
|
|
963
|
+
this.setIntegrationDevicesOnline(integrationId, true);
|
|
964
|
+
});
|
|
965
|
+
this.clients.setOnDisconnected((integrationId) => {
|
|
966
|
+
this.setIntegrationDevicesOnline(integrationId, false);
|
|
967
|
+
});
|
|
968
|
+
await this.reconcileIntegrations();
|
|
969
|
+
this.subscribeIntegrationLifecycle();
|
|
970
|
+
return [
|
|
971
|
+
...regs,
|
|
972
|
+
{
|
|
973
|
+
capability: deviceAdoptionCapability,
|
|
974
|
+
provider: this.buildAdoptionProvider()
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
capability: connectionTestCapability,
|
|
978
|
+
provider: this.buildConnectionTestProvider()
|
|
979
|
+
}
|
|
980
|
+
];
|
|
981
|
+
}
|
|
982
|
+
async onShutdown() {
|
|
983
|
+
try {
|
|
984
|
+
await this.clients?.shutdown();
|
|
985
|
+
} catch (err) {
|
|
986
|
+
this.ctx.logger.warn("HomeCom shutdown error", { meta: { error: errMsg(err) } });
|
|
987
|
+
}
|
|
988
|
+
this.clients = null;
|
|
989
|
+
await super.onShutdown();
|
|
990
|
+
}
|
|
991
|
+
requireClients() {
|
|
992
|
+
if (!this.clients) throw new Error("HomeCom provider not initialised");
|
|
993
|
+
return this.clients;
|
|
994
|
+
}
|
|
995
|
+
generateStableId(_type, config) {
|
|
996
|
+
const integrationId = config?.["brokerId"];
|
|
997
|
+
if (typeof integrationId !== "string" || integrationId.length === 0) throw new Error("homecom generateStableId: brokerId required");
|
|
998
|
+
const nativeId = config?.["nativeId"];
|
|
999
|
+
if (typeof nativeId !== "string" || nativeId.length === 0) throw new Error("homecom generateStableId: nativeId required");
|
|
1000
|
+
return `homecom:${integrationId}:${nativeId}`;
|
|
1001
|
+
}
|
|
1002
|
+
async reconcileIntegrations() {
|
|
1003
|
+
const reg = this.requireClients();
|
|
1004
|
+
try {
|
|
1005
|
+
const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id && i.enabled);
|
|
1006
|
+
const surviving = /* @__PURE__ */ new Set();
|
|
1007
|
+
for (const integration of mine) try {
|
|
1008
|
+
const connection = connectionFromSettings(await this.ctx.api.integrations.getSettings.query({ id: integration.id }));
|
|
1009
|
+
if (!connection) continue;
|
|
1010
|
+
await reg.upsert(integration.id, integration.name, connection);
|
|
1011
|
+
surviving.add(integration.id);
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
this.ctx.logger.warn("HomeCom reconcile failed for integration", { meta: {
|
|
1014
|
+
integrationId: integration.id,
|
|
1015
|
+
error: errMsg(err)
|
|
1016
|
+
} });
|
|
1017
|
+
}
|
|
1018
|
+
const vanished = reg.list().filter((id) => !surviving.has(id));
|
|
1019
|
+
await reg.retain(surviving);
|
|
1020
|
+
const deviceReg = this.ctx.kernel.deviceRegistry;
|
|
1021
|
+
const deviceApi = this.ctx.kernel.devices;
|
|
1022
|
+
if (deviceReg && deviceApi) for (const integrationId of vanished) await cascadeRemoveDevicesForIntegration({
|
|
1023
|
+
reg: deviceReg,
|
|
1024
|
+
devices: deviceApi,
|
|
1025
|
+
addonId: this.addonId,
|
|
1026
|
+
system: "homecom",
|
|
1027
|
+
integrationId,
|
|
1028
|
+
logger: this.ctx.logger
|
|
1029
|
+
});
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
this.ctx.logger.warn("HomeCom integration reconcile failed", { meta: { error: errMsg(err) } });
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
subscribeIntegrationLifecycle() {
|
|
1035
|
+
const handler = (event) => {
|
|
1036
|
+
const addonId = event.data["addonId"];
|
|
1037
|
+
if (typeof addonId === "string" && addonId !== this.ctx.id) return;
|
|
1038
|
+
this.reconcileIntegrations();
|
|
1039
|
+
};
|
|
1040
|
+
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled }, handler);
|
|
1041
|
+
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, handler);
|
|
1042
|
+
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, handler);
|
|
1043
|
+
}
|
|
1044
|
+
buildConnectionTestProvider() {
|
|
1045
|
+
return buildHomeComConnectionTestProvider({ logger: this.ctx.logger });
|
|
1046
|
+
}
|
|
1047
|
+
buildAdoptionProvider() {
|
|
1048
|
+
return buildHomeComAdoptionProvider({
|
|
1049
|
+
registry: homeComConnections,
|
|
1050
|
+
hasIntegration: (id) => this.requireClients().has(id),
|
|
1051
|
+
listIntegrations: () => this.requireClients().list(),
|
|
1052
|
+
listAdopted: async () => {
|
|
1053
|
+
const reg = this.ctx.kernel.deviceRegistry;
|
|
1054
|
+
const devices = this.ctx.kernel.devices;
|
|
1055
|
+
if (!reg || !devices) return [];
|
|
1056
|
+
const out = [];
|
|
1057
|
+
for (const d of reg.getAllForAddon(this.addonId)) {
|
|
1058
|
+
const config = await devices.loadConfig(d.id).catch(() => ({}));
|
|
1059
|
+
out.push({
|
|
1060
|
+
id: d.id,
|
|
1061
|
+
config
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
return out;
|
|
1065
|
+
},
|
|
1066
|
+
adoptDevice: async ({ nativeId, integrationId, type, name, kind, circuitId }) => {
|
|
1067
|
+
const devices = this.ctx.kernel.devices;
|
|
1068
|
+
if (!devices) throw new Error("homecom adopt: devices unavailable");
|
|
1069
|
+
const config = {
|
|
1070
|
+
nativeId,
|
|
1071
|
+
brokerId: integrationId,
|
|
1072
|
+
system: "homecom",
|
|
1073
|
+
integrationId,
|
|
1074
|
+
name
|
|
1075
|
+
};
|
|
1076
|
+
if (kind === "heating-circuit" && circuitId) config["circuitId"] = circuitId;
|
|
1077
|
+
const stableId = this.generateStableId(type, config);
|
|
1078
|
+
const DeviceClass = this.deviceClasses[type];
|
|
1079
|
+
if (!DeviceClass) throw new Error(`homecom adopt: unsupported type ${type}`);
|
|
1080
|
+
return { deviceId: (await devices.create(stableId, DeviceClass, config, null, {
|
|
1081
|
+
type,
|
|
1082
|
+
name,
|
|
1083
|
+
integrationId
|
|
1084
|
+
})).id };
|
|
1085
|
+
},
|
|
1086
|
+
removeDevice: async (id) => {
|
|
1087
|
+
await this.ctx.kernel.devices?.remove(id);
|
|
1088
|
+
},
|
|
1089
|
+
findDeviceConfig: async (id) => {
|
|
1090
|
+
const devices = this.ctx.kernel.devices;
|
|
1091
|
+
if (!devices) return null;
|
|
1092
|
+
return devices.loadConfig(id).catch(() => null);
|
|
1093
|
+
},
|
|
1094
|
+
logger: this.ctx.logger
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
globalSettingsSchema() {
|
|
1098
|
+
return buildHomeComFormSchema();
|
|
1099
|
+
}
|
|
1100
|
+
async supportsManualCreation() {
|
|
1101
|
+
return false;
|
|
1102
|
+
}
|
|
1103
|
+
async onGetCreationSchema(_type) {
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
async onCreateDevice(_type, _config) {
|
|
1107
|
+
throw new Error("HomeCom devices are adopted from the cloud");
|
|
1108
|
+
}
|
|
1109
|
+
setIntegrationDevicesOnline(integrationId, online) {
|
|
1110
|
+
const reg = this.ctx.kernel.deviceRegistry;
|
|
1111
|
+
if (!reg) return;
|
|
1112
|
+
setIntegrationDevicesOnline(reg.getAllForAddon(this.addonId), "homecom", integrationId, online);
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
//#endregion
|
|
1116
|
+
export { BoschHomeComProviderAddon };
|