@camstack/addon-provider-bosch 0.1.26 → 0.1.28
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-DAgg2Cgr.js → account-integration-offline-2MI94wOE.js} +16 -2
- package/dist/{account-integration-offline-iGgTPuq_.mjs → account-integration-offline-DKaazSyb.mjs} +16 -2
- package/dist/home-connect/addon.js +1 -1
- package/dist/home-connect/addon.mjs +1 -1
- package/dist/homecom/addon.js +748 -197
- package/dist/homecom/addon.mjs +748 -197
- package/dist/shc/addon.js +120 -19
- package/dist/shc/addon.mjs +120 -19
- package/i18n/en.json +4 -2
- package/i18n/it.json +4 -2
- package/package.json +1 -1
package/dist/homecom/addon.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_dist = require("../dist-DEZDBpGW.js");
|
|
3
|
-
const require_account_integration_offline = require("../account-integration-offline-
|
|
3
|
+
const require_account_integration_offline = require("../account-integration-offline-2MI94wOE.js");
|
|
4
4
|
let node_crypto = require("node:crypto");
|
|
5
5
|
//#region src/homecom/config.ts
|
|
6
6
|
var HOMECOM_BRANDS = ["bosch", "buderus"];
|
|
@@ -14,8 +14,11 @@ var homeComConfigSchema = require_dist.object({
|
|
|
14
14
|
refresh: require_dist.string().min(1).describe("OAuth refresh token").meta({ secret: true }),
|
|
15
15
|
brand: HomeComBrandSchema.default("bosch"),
|
|
16
16
|
region: HomeComRegionSchema.default("eu"),
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The gateway this integration controls. Chosen from the account list after
|
|
19
|
+
* sign-in. A blank value is the same as absent: the id is not known yet.
|
|
20
|
+
*/
|
|
21
|
+
gatewayId: require_dist.string().trim().optional().transform((value) => value === void 0 || value.length === 0 ? void 0 : value),
|
|
19
22
|
/** Shown on the sign-in field. Not a credential. */
|
|
20
23
|
accountLabel: require_dist.string().optional(),
|
|
21
24
|
/** How many gateways the last sign-in listed. */
|
|
@@ -34,6 +37,8 @@ function sameHomeComConnection(a, b) {
|
|
|
34
37
|
* the same host; `region` is retained for Bacon MQTT (`euc1` / `use1`) when wired.
|
|
35
38
|
*/
|
|
36
39
|
var HOMECOM_POINTT_API_ORIGIN = "https://pointt-api.bosch-thermotechnology.com";
|
|
40
|
+
/** Account gateway list. `deviceId` on each row is the gateway id. */
|
|
41
|
+
var HOMECOM_GATEWAYS_PATH = "/pointt-api/api/v1/gateways/";
|
|
37
42
|
function homeComApiBase(_region) {
|
|
38
43
|
return HOMECOM_POINTT_API_ORIGIN;
|
|
39
44
|
}
|
|
@@ -46,15 +51,29 @@ var HIDDEN_UNTIL_ADVANCED = {
|
|
|
46
51
|
field: "__hidden__",
|
|
47
52
|
equals: "yes"
|
|
48
53
|
};
|
|
54
|
+
var HOMECOM_CODE_HELP = [
|
|
55
|
+
"Before you sign in, open the browser's developer tools, choose the Network tab, and turn on Preserve log.",
|
|
56
|
+
"Sign in with SingleKey. The page will say it has network problems. Ignore that: the browser tried to open the Bosch app and could not.",
|
|
57
|
+
"In the Network list, filter by code= and open the request whose address starts with com.bosch.tt.dashtt.pointt://app/login?code=",
|
|
58
|
+
"Copy that whole address, or only the code, and paste it in the box under the sign-in button.",
|
|
59
|
+
"On a phone the same redirect fails unless the Bosch app is installed. Use a desktop browser to copy the code."
|
|
60
|
+
].join("\n\n");
|
|
49
61
|
function buildHomeComFormSchema() {
|
|
50
62
|
return { sections: [{
|
|
51
63
|
id: "account",
|
|
52
64
|
title: "HomeCom account",
|
|
53
65
|
titleKey: "provider-bosch:homecom.s_account.title",
|
|
54
|
-
description: "Sign in with your SingleKey ID.
|
|
66
|
+
description: "Sign in with your SingleKey ID. Copy the code as described below, then paste it here.",
|
|
55
67
|
descriptionKey: "provider-bosch:homecom.s_account.description",
|
|
56
68
|
columns: 1,
|
|
57
69
|
fields: [
|
|
70
|
+
{
|
|
71
|
+
type: "info",
|
|
72
|
+
key: "__homecom-code-help",
|
|
73
|
+
label: HOMECOM_CODE_HELP,
|
|
74
|
+
labelKey: "provider-bosch:homecom.s_account.__homecom-code-help.label",
|
|
75
|
+
content: ""
|
|
76
|
+
},
|
|
58
77
|
{
|
|
59
78
|
type: "account-sign-in",
|
|
60
79
|
addonId: "bosch-homecom",
|
|
@@ -140,6 +159,8 @@ function buildHomeComFormSchema() {
|
|
|
140
159
|
key: "gatewayId",
|
|
141
160
|
label: "Gateway id",
|
|
142
161
|
labelKey: "provider-bosch:homecom.s_account.gatewayId.label",
|
|
162
|
+
description: "Filled from the account. Type one only when you need to override the choice.",
|
|
163
|
+
descriptionKey: "provider-bosch:homecom.s_account.gatewayId.description",
|
|
143
164
|
required: false
|
|
144
165
|
},
|
|
145
166
|
{
|
|
@@ -213,30 +234,129 @@ var homeComActions = require_dist.defineCustomActions({
|
|
|
213
234
|
})
|
|
214
235
|
});
|
|
215
236
|
//#endregion
|
|
237
|
+
//#region src/homecom/homecom-device-type.ts
|
|
238
|
+
/**
|
|
239
|
+
* Device types the PointT gateway list reports. The set is the one the MIT
|
|
240
|
+
* `homecom_alt` library branches on (`rac`, `k40`, `k30`, `wddw2`, `icom`,
|
|
241
|
+
* `rrc2`). This file records that classification; it is not a copy of that code.
|
|
242
|
+
*/
|
|
243
|
+
var HOMECOM_SERVED_TYPES = [
|
|
244
|
+
"rac",
|
|
245
|
+
"k40",
|
|
246
|
+
"k30",
|
|
247
|
+
"wddw2",
|
|
248
|
+
"icom",
|
|
249
|
+
"rrc2"
|
|
250
|
+
];
|
|
251
|
+
var FAMILIES = {
|
|
252
|
+
rac: "rac",
|
|
253
|
+
k40: "hydronic",
|
|
254
|
+
k30: "hydronic",
|
|
255
|
+
icom: "hydronic",
|
|
256
|
+
rrc2: "hydronic",
|
|
257
|
+
wddw2: "water-heater"
|
|
258
|
+
};
|
|
259
|
+
function familyOf(deviceType) {
|
|
260
|
+
if (deviceType === void 0) return null;
|
|
261
|
+
if (!isServedType(deviceType)) return null;
|
|
262
|
+
return FAMILIES[deviceType];
|
|
263
|
+
}
|
|
264
|
+
function isServedType(deviceType) {
|
|
265
|
+
for (const served of HOMECOM_SERVED_TYPES) if (deviceType === served) return true;
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
/** The account listed a type this addon has no climate or water-heater resources for. */
|
|
269
|
+
var HomeComUnknownDeviceTypeError = class extends Error {
|
|
270
|
+
name = "HomeComUnknownDeviceTypeError";
|
|
271
|
+
deviceType;
|
|
272
|
+
constructor(deviceType) {
|
|
273
|
+
super(`HomeCom does not serve device type "${deviceType}".`);
|
|
274
|
+
this.deviceType = deviceType;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
function unsupportedDeviceTypeMessage(deviceType) {
|
|
278
|
+
if (familyOf(deviceType) !== null) return null;
|
|
279
|
+
return new HomeComUnknownDeviceTypeError(deviceType ?? "(missing)").message;
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/homecom/homecom-oauth.ts
|
|
283
|
+
/**
|
|
284
|
+
* Public OAuth client of the HomeCom Easy app, signed in through SingleKey ID.
|
|
285
|
+
*
|
|
286
|
+
* A PKCE client has no secret. The values are the ones SingleKey accepts for this client id
|
|
287
|
+
* today: the same authorize query the MIT-licensed `homecom_alt` client records
|
|
288
|
+
* (`OAUTH_LOGIN_PARAMS` — parameters, not copied code) and that the Home Assistant integration
|
|
289
|
+
* opens. `pointt.gateway.resource.dopair` is not a scope this client may request; SingleKey
|
|
290
|
+
* answers `misconfigured-application` and never shows the login page. The scope it does accept
|
|
291
|
+
* names `pointt.gateway.resource.dashapp`, and the Bosch app sends `style_id=tt_bsch`.
|
|
292
|
+
*/
|
|
293
|
+
var HOMECOM_CLIENT_ID = "762162C0-FA2D-4540-AE66-6489F189FADC";
|
|
294
|
+
var HOMECOM_REDIRECT_URI = "com.bosch.tt.dashtt.pointt://app/login";
|
|
295
|
+
var HOMECOM_AUTHORIZE_URL = "https://singlekey-id.com/auth/connect/authorize";
|
|
296
|
+
var HOMECOM_TOKEN_URL = "https://singlekey-id.com/auth/connect/token";
|
|
297
|
+
var HOMECOM_STYLE_ID = "tt_bsch";
|
|
298
|
+
var HOMECOM_SCOPE = [
|
|
299
|
+
"openid",
|
|
300
|
+
"email",
|
|
301
|
+
"profile",
|
|
302
|
+
"offline_access",
|
|
303
|
+
"pointt.gateway.claiming",
|
|
304
|
+
"pointt.gateway.removal",
|
|
305
|
+
"pointt.gateway.list",
|
|
306
|
+
"pointt.gateway.users",
|
|
307
|
+
"pointt.gateway.resource.dashapp",
|
|
308
|
+
"pointt.castt.flow.token-exchange",
|
|
309
|
+
"bacon",
|
|
310
|
+
"hcc.tariff.read"
|
|
311
|
+
].join(" ");
|
|
312
|
+
//#endregion
|
|
216
313
|
//#region src/homecom/homecom-domain-mapping.ts
|
|
217
314
|
var MODE_MAP = {
|
|
218
315
|
Off: "off",
|
|
219
316
|
off: "off",
|
|
220
317
|
Heat: "heat",
|
|
221
318
|
heat: "heat",
|
|
319
|
+
manual: "heat",
|
|
222
320
|
Cool: "cool",
|
|
223
321
|
cool: "cool",
|
|
224
322
|
Auto: "auto",
|
|
225
|
-
auto: "auto"
|
|
323
|
+
auto: "auto",
|
|
324
|
+
dry: "dry",
|
|
325
|
+
Dry: "dry",
|
|
326
|
+
fanOnly: "fan_only",
|
|
327
|
+
fan_only: "fan_only"
|
|
226
328
|
};
|
|
227
329
|
function mapHvacMode(raw) {
|
|
228
330
|
return MODE_MAP[raw] ?? "off";
|
|
229
331
|
}
|
|
332
|
+
/** PointT `allowedValues` mapped onto HVAC modes, in the order the device listed them. */
|
|
333
|
+
function hvacModesFromAllowed(allowed) {
|
|
334
|
+
const out = [];
|
|
335
|
+
for (const raw of allowed) {
|
|
336
|
+
const mapped = MODE_MAP[raw];
|
|
337
|
+
if (mapped === void 0 || out.includes(mapped)) continue;
|
|
338
|
+
out.push(mapped);
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
function displayedMode(circuit) {
|
|
343
|
+
const mapped = mapHvacMode(circuit.operationMode);
|
|
344
|
+
if (mapped === "off") return "off";
|
|
345
|
+
if (circuit.season === "cooling") return "cool";
|
|
346
|
+
return mapped;
|
|
347
|
+
}
|
|
230
348
|
function climateSliceFromCircuit(circuit, nowMs) {
|
|
349
|
+
const mode = displayedMode(circuit);
|
|
350
|
+
const available = circuit.availableModes;
|
|
231
351
|
return {
|
|
232
|
-
mode
|
|
233
|
-
availableModes: [
|
|
352
|
+
mode,
|
|
353
|
+
availableModes: available !== void 0 && available.length > 0 ? [...available] : [
|
|
234
354
|
"off",
|
|
235
355
|
"heat",
|
|
236
356
|
"auto"
|
|
237
357
|
],
|
|
238
|
-
fanMode: "",
|
|
239
|
-
availableFanModes: [],
|
|
358
|
+
fanMode: circuit.fanMode ?? "",
|
|
359
|
+
availableFanModes: circuit.availableFanModes !== void 0 ? [...circuit.availableFanModes] : [],
|
|
240
360
|
preset: "",
|
|
241
361
|
availablePresets: [],
|
|
242
362
|
target: circuit.setpoint,
|
|
@@ -244,9 +364,9 @@ function climateSliceFromCircuit(circuit, nowMs) {
|
|
|
244
364
|
targetLow: null,
|
|
245
365
|
targetHumidity: null,
|
|
246
366
|
currentTemp: circuit.currentRoomTemp,
|
|
247
|
-
currentHumidity: null,
|
|
248
|
-
swingVertical: null,
|
|
249
|
-
swingHorizontal: null,
|
|
367
|
+
currentHumidity: circuit.currentHumidity ?? null,
|
|
368
|
+
swingVertical: circuit.swingVertical ?? null,
|
|
369
|
+
swingHorizontal: circuit.swingHorizontal ?? null,
|
|
250
370
|
minTemp: circuit.minTemp,
|
|
251
371
|
maxTemp: circuit.maxTemp,
|
|
252
372
|
tempStep: .5,
|
|
@@ -265,92 +385,364 @@ function waterHeaterSliceFromDhw(dhw, nowMs) {
|
|
|
265
385
|
lastChangedAt: nowMs
|
|
266
386
|
};
|
|
267
387
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
388
|
+
var HOMECOM_PROBE_CLOCK = "/gateway/DateTime";
|
|
389
|
+
var HOMECOM_PROBE_FIRMWARE = "/gateway/versionFirmware";
|
|
390
|
+
var HYDRONIC_DROPPED = "heat-source, ventilation, pool and energy resources are not published on a cap";
|
|
391
|
+
var RAC_DROPPED = "domestic hot water, presets and a humidity target are not published for an air conditioner";
|
|
392
|
+
var WATER_HEATER_DROPPED = "heating circuits are not on a wddw2 water heater";
|
|
393
|
+
var ZONE_DROPPED = "rrc2 zones are not published on a cap";
|
|
394
|
+
var UNSUPPORTED_CONTROLS = "fan mode, swing, presets and a humidity target are not on this device type";
|
|
395
|
+
var HYDRONIC_MODES = [
|
|
396
|
+
"off",
|
|
397
|
+
"heat",
|
|
398
|
+
"auto"
|
|
399
|
+
];
|
|
400
|
+
var TOKEN = /^[A-Za-z0-9_-]+$/;
|
|
401
|
+
function readEnvelope(body) {
|
|
402
|
+
if (!isRecord$2(body) || !("value" in body)) return null;
|
|
403
|
+
const value = body["value"];
|
|
404
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return null;
|
|
405
|
+
return {
|
|
406
|
+
value,
|
|
407
|
+
min: finite(body["minValue"]),
|
|
408
|
+
max: finite(body["maxValue"]),
|
|
409
|
+
allowed: stringList(body["allowedValues"])
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
function probeText(body) {
|
|
413
|
+
const envelope = readEnvelope(body);
|
|
414
|
+
if (envelope === null) return null;
|
|
415
|
+
if (typeof envelope.value === "string" && envelope.value.length > 0) return envelope.value;
|
|
416
|
+
if (typeof envelope.value === "number" || typeof envelope.value === "boolean") return String(envelope.value);
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
async function readPointtSnapshot(input) {
|
|
420
|
+
if (input.family === "rac") return readRac(input);
|
|
421
|
+
if (input.family === "water-heater") return readWaterHeater(input);
|
|
422
|
+
return readHydronic(input);
|
|
423
|
+
}
|
|
424
|
+
function circuitSetpointWrite(snapshot, circuitId, temp) {
|
|
425
|
+
return {
|
|
426
|
+
path: findCircuit(snapshot, circuitId).setpointResource ?? `/heatingCircuits/${circuitId}/manualRoomSetpoint`,
|
|
427
|
+
value: temp
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
function circuitModeWrites(snapshot, circuitId, mode) {
|
|
431
|
+
const token = pointtMode(mode);
|
|
432
|
+
if (snapshot.family === "rac") {
|
|
433
|
+
if (token === "off") return [{
|
|
434
|
+
path: "/airConditioning/acControl",
|
|
435
|
+
value: "off"
|
|
436
|
+
}];
|
|
437
|
+
return [{
|
|
438
|
+
path: "/airConditioning/acControl",
|
|
439
|
+
value: "on"
|
|
440
|
+
}, {
|
|
441
|
+
path: "/airConditioning/operationMode",
|
|
442
|
+
value: token
|
|
443
|
+
}];
|
|
444
|
+
}
|
|
445
|
+
if (token === "cool") return [{
|
|
446
|
+
path: `/heatingCircuits/${circuitId}/heatCoolMode`,
|
|
447
|
+
value: "cooling"
|
|
448
|
+
}];
|
|
449
|
+
return [{
|
|
450
|
+
path: `/heatingCircuits/${circuitId}/operationMode`,
|
|
451
|
+
value: token
|
|
452
|
+
}];
|
|
453
|
+
}
|
|
454
|
+
function dhwTargetWrite(snapshot, temp) {
|
|
455
|
+
const dhw = snapshot.dhw;
|
|
456
|
+
if (dhw?.targetResource === void 0) throw new Error("HomeCom DHW: setpoint not supported");
|
|
457
|
+
return {
|
|
458
|
+
path: dhw.targetResource,
|
|
459
|
+
value: temp
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
function dhwModeWrite(snapshot, mode) {
|
|
463
|
+
const id = snapshot.dhw?.circuitId;
|
|
464
|
+
if (id === void 0) throw new Error("HomeCom DHW: setpoint not supported");
|
|
465
|
+
return {
|
|
466
|
+
path: `/dhwCircuits/${id}/operationMode`,
|
|
467
|
+
value: mode
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
function fanWrite(snapshot, fanMode) {
|
|
471
|
+
if (snapshot.family !== "rac") throw new Error("HomeCom climate: fan mode not supported");
|
|
472
|
+
return {
|
|
473
|
+
path: "/airConditioning/fanSpeed",
|
|
474
|
+
value: fanMode
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function swingWrite(snapshot, axis, on) {
|
|
478
|
+
if (snapshot.family !== "rac") throw new Error("HomeCom climate: swing not supported");
|
|
479
|
+
if (axis === "vertical") return {
|
|
480
|
+
path: "/airConditioning/airFlowVertical",
|
|
481
|
+
value: on ? "swing" : "angle3"
|
|
482
|
+
};
|
|
483
|
+
return {
|
|
484
|
+
path: "/airConditioning/airFlowHorizontal",
|
|
485
|
+
value: on ? "swing" : "center"
|
|
273
486
|
};
|
|
274
|
-
|
|
487
|
+
}
|
|
488
|
+
async function readHydronic(input) {
|
|
489
|
+
input.logDrop(HYDRONIC_DROPPED);
|
|
490
|
+
input.logDrop(UNSUPPORTED_CONTROLS);
|
|
491
|
+
if (input.deviceType === "rrc2") input.logDrop(ZONE_DROPPED);
|
|
492
|
+
const heating = await input.get("/heatingCircuits");
|
|
493
|
+
const dhwBody = await input.get("/dhwCircuits");
|
|
275
494
|
const circuits = [];
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
495
|
+
for (const id of referenceKeys(heating, "hc")) circuits.push(await readCircuit(input.get, id));
|
|
496
|
+
const dhwIds = referenceKeys(dhwBody, "dhw");
|
|
497
|
+
const first = dhwIds[0];
|
|
498
|
+
const dhw = first === void 0 ? null : await readDhw(input.get, first);
|
|
499
|
+
if (dhwIds.length > 1) input.logDrop(`extra DHW circuits are not published: ${dhwIds.slice(1).join(", ")}`);
|
|
500
|
+
return snapshotOf(input, circuits, dhw);
|
|
501
|
+
}
|
|
502
|
+
async function readWaterHeater(input) {
|
|
503
|
+
input.logDrop(WATER_HEATER_DROPPED);
|
|
504
|
+
const first = referenceKeys(await input.get("/dhwCircuits"), "dhw")[0];
|
|
505
|
+
const dhw = first === void 0 ? null : await readDhw(input.get, first);
|
|
506
|
+
if (dhw === null) input.logDrop("this water heater returned no DHW circuit");
|
|
507
|
+
return snapshotOf(input, [], dhw);
|
|
508
|
+
}
|
|
509
|
+
async function readRac(input) {
|
|
510
|
+
input.logDrop(RAC_DROPPED);
|
|
511
|
+
const names = [
|
|
512
|
+
"operationMode",
|
|
513
|
+
"temperatureSetpoint",
|
|
514
|
+
"acControl",
|
|
515
|
+
"fanSpeed",
|
|
516
|
+
"airFlowVertical",
|
|
517
|
+
"airFlowHorizontal",
|
|
518
|
+
"roomTemperature"
|
|
519
|
+
];
|
|
520
|
+
const inline = indexStandard(await input.get("/airConditioning/standardFunctions"));
|
|
521
|
+
const loaded = /* @__PURE__ */ new Map();
|
|
522
|
+
for (const name of names) {
|
|
523
|
+
const direct = readEnvelope(await input.get(`/airConditioning/${name}`));
|
|
524
|
+
loaded.set(name, direct ?? inline.get(name) ?? null);
|
|
525
|
+
}
|
|
526
|
+
const power = stringOf(loaded.get("acControl") ?? null);
|
|
527
|
+
const operation = loaded.get("operationMode") ?? null;
|
|
528
|
+
const setpoint = loaded.get("temperatureSetpoint") ?? null;
|
|
529
|
+
const fan = loaded.get("fanSpeed") ?? null;
|
|
530
|
+
const modes = hvacModesFromAllowed(operation?.allowed ?? []);
|
|
531
|
+
const availableModes = modes.includes("off") ? modes : [...modes, "off"];
|
|
532
|
+
return snapshotOf(input, [{
|
|
533
|
+
id: "ac",
|
|
534
|
+
name: "Air conditioning",
|
|
535
|
+
operationMode: power === "off" ? "off" : stringOf(operation) ?? "off",
|
|
536
|
+
setpoint: numberOf(setpoint),
|
|
537
|
+
currentRoomTemp: numberOf(loaded.get("roomTemperature") ?? null),
|
|
538
|
+
minTemp: setpoint?.min ?? null,
|
|
539
|
+
maxTemp: setpoint?.max ?? null,
|
|
540
|
+
currentHumidity: null,
|
|
541
|
+
season: null,
|
|
542
|
+
fanMode: stringOf(fan) ?? "",
|
|
543
|
+
availableFanModes: fan?.allowed ?? [],
|
|
544
|
+
swingVertical: swingOf(loaded.get("airFlowVertical") ?? null),
|
|
545
|
+
swingHorizontal: swingOf(loaded.get("airFlowHorizontal") ?? null),
|
|
546
|
+
availableModes: availableModes.length > 0 ? availableModes : [
|
|
547
|
+
"off",
|
|
548
|
+
"cool",
|
|
549
|
+
"heat",
|
|
550
|
+
"auto"
|
|
551
|
+
],
|
|
552
|
+
setpointResource: "/airConditioning/temperatureSetpoint"
|
|
553
|
+
}], null);
|
|
554
|
+
}
|
|
555
|
+
async function readCircuit(get, id) {
|
|
556
|
+
const base = `/heatingCircuits/${id}`;
|
|
557
|
+
const loaded = await loadEnvelopes(get, [
|
|
558
|
+
`${base}/operationMode`,
|
|
559
|
+
`${base}/roomtemperature`,
|
|
560
|
+
`${base}/manualRoomSetpoint`,
|
|
561
|
+
`${base}/currentRoomSetpoint`,
|
|
562
|
+
`${base}/currentSuWiMode`,
|
|
563
|
+
`${base}/heatCoolMode`,
|
|
564
|
+
`${base}/actualHumidity`,
|
|
565
|
+
`${base}/cooling/roomTempSetpoint`
|
|
566
|
+
]);
|
|
567
|
+
const operation = loaded.get(`${base}/operationMode`) ?? null;
|
|
568
|
+
const manual = loaded.get(`${base}/manualRoomSetpoint`) ?? null;
|
|
569
|
+
const current = loaded.get(`${base}/currentRoomSetpoint`) ?? null;
|
|
570
|
+
const coolingSet = loaded.get(`${base}/cooling/roomTempSetpoint`) ?? null;
|
|
571
|
+
const season = stringOf(loaded.get(`${base}/currentSuWiMode`) ?? null);
|
|
572
|
+
const heatCool = stringOf(loaded.get(`${base}/heatCoolMode`) ?? null);
|
|
573
|
+
const cooling = season === "cooling" || heatCool === "cooling";
|
|
574
|
+
const modes = hvacModesFromAllowed(operation?.allowed ?? []);
|
|
575
|
+
const humidity = numberOf(loaded.get(`${base}/actualHumidity`) ?? null);
|
|
576
|
+
return {
|
|
577
|
+
id,
|
|
578
|
+
name: id,
|
|
579
|
+
operationMode: stringOf(operation) ?? "off",
|
|
580
|
+
setpoint: cooling ? numberOf(coolingSet) ?? numberOf(current) ?? numberOf(manual) : numberOf(current) ?? numberOf(manual),
|
|
581
|
+
currentRoomTemp: numberOf(loaded.get(`${base}/roomtemperature`) ?? null),
|
|
582
|
+
minTemp: manual?.min ?? current?.min ?? null,
|
|
583
|
+
maxTemp: manual?.max ?? current?.max ?? null,
|
|
584
|
+
currentHumidity: humidity !== null && humidity >= 0 && humidity <= 100 ? humidity : null,
|
|
585
|
+
season: cooling ? "cooling" : season,
|
|
586
|
+
fanMode: "",
|
|
587
|
+
availableFanModes: [],
|
|
588
|
+
swingVertical: null,
|
|
589
|
+
swingHorizontal: null,
|
|
590
|
+
availableModes: modes.length > 0 ? modes : [...HYDRONIC_MODES],
|
|
591
|
+
setpointResource: cooling ? `${base}/cooling/roomTempSetpoint` : `${base}/manualRoomSetpoint`
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
async function readDhw(get, id) {
|
|
595
|
+
const base = `/dhwCircuits/${id}`;
|
|
596
|
+
const loaded = await loadEnvelopes(get, [
|
|
597
|
+
`${base}/operationMode`,
|
|
598
|
+
`${base}/actualTemp`,
|
|
599
|
+
`${base}/outletTemperature`,
|
|
600
|
+
`${base}/currentTemperatureLevel`,
|
|
601
|
+
`${base}/currentSetpoint`,
|
|
602
|
+
`${base}/manualsetpoint`
|
|
603
|
+
]);
|
|
604
|
+
const operation = loaded.get(`${base}/operationMode`) ?? null;
|
|
605
|
+
const chosen = await chooseDhwTarget(get, base, loaded, safeToken(stringOf(loaded.get(`${base}/currentTemperatureLevel`) ?? null)));
|
|
606
|
+
const modes = operation?.allowed ?? [];
|
|
313
607
|
return {
|
|
608
|
+
currentTemp: numberOf(loaded.get(`${base}/actualTemp`) ?? null) ?? numberOf(loaded.get(`${base}/outletTemperature`) ?? null),
|
|
609
|
+
targetTemp: chosen.target,
|
|
610
|
+
operationMode: stringOf(operation),
|
|
611
|
+
availableModes: modes.length > 0 ? [...modes] : [
|
|
612
|
+
"off",
|
|
613
|
+
"eco",
|
|
614
|
+
"performance"
|
|
615
|
+
],
|
|
616
|
+
minTemp: chosen.min,
|
|
617
|
+
maxTemp: chosen.max,
|
|
618
|
+
circuitId: id,
|
|
619
|
+
targetResource: chosen.resource
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
async function chooseDhwTarget(get, base, loaded, level) {
|
|
623
|
+
const current = loaded.get(`${base}/currentSetpoint`) ?? null;
|
|
624
|
+
if (numberOf(current) !== null) return bound(current, numberOf(current), `${base}/currentSetpoint`);
|
|
625
|
+
if (level !== null && level !== "off") {
|
|
626
|
+
const levelEnv = readEnvelope(await get(`${base}/temperatureLevels/${level}`));
|
|
627
|
+
if (numberOf(levelEnv) !== null) return bound(levelEnv, numberOf(levelEnv), `${base}/temperatureLevels/${level}`);
|
|
628
|
+
}
|
|
629
|
+
const manual = loaded.get(`${base}/manualsetpoint`) ?? null;
|
|
630
|
+
return bound(manual, numberOf(manual), `${base}/manualsetpoint`);
|
|
631
|
+
}
|
|
632
|
+
function bound(envelope, target, resource) {
|
|
633
|
+
return {
|
|
634
|
+
target,
|
|
635
|
+
min: envelope?.min ?? null,
|
|
636
|
+
max: envelope?.max ?? null,
|
|
637
|
+
resource
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
async function loadEnvelopes(get, paths) {
|
|
641
|
+
const pairs = await Promise.all(paths.map(async (path) => [path, readEnvelope(await get(path))]));
|
|
642
|
+
return new Map(pairs);
|
|
643
|
+
}
|
|
644
|
+
function snapshotOf(input, circuits, dhw) {
|
|
645
|
+
return {
|
|
646
|
+
gateway: {
|
|
647
|
+
gatewayId: input.gatewayId,
|
|
648
|
+
name: input.gatewayId,
|
|
649
|
+
model: input.deviceType
|
|
650
|
+
},
|
|
314
651
|
circuits,
|
|
315
|
-
dhw
|
|
652
|
+
dhw,
|
|
653
|
+
deviceType: input.deviceType,
|
|
654
|
+
family: input.family
|
|
316
655
|
};
|
|
317
656
|
}
|
|
318
|
-
function
|
|
319
|
-
|
|
320
|
-
|
|
657
|
+
function findCircuit(snapshot, circuitId) {
|
|
658
|
+
const circuit = snapshot.circuits.find((item) => item.id === circuitId);
|
|
659
|
+
if (circuit === void 0) throw new Error(`HomeCom has no heating circuit ${circuitId}`);
|
|
660
|
+
return circuit;
|
|
661
|
+
}
|
|
662
|
+
function pointtMode(mode) {
|
|
663
|
+
switch (mode) {
|
|
664
|
+
case "Heat":
|
|
665
|
+
case "heat":
|
|
666
|
+
case "manual": return "manual";
|
|
667
|
+
case "Auto":
|
|
668
|
+
case "auto": return "auto";
|
|
669
|
+
case "Cool":
|
|
670
|
+
case "cool": return "cool";
|
|
671
|
+
case "Dry":
|
|
672
|
+
case "dry": return "dry";
|
|
673
|
+
case "fan_only":
|
|
674
|
+
case "fanOnly": return "fanOnly";
|
|
675
|
+
case "Off":
|
|
676
|
+
case "off": return "off";
|
|
677
|
+
default: return mode;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
function referenceKeys(body, prefix) {
|
|
681
|
+
if (!isRecord$2(body) || !Array.isArray(body["references"])) return [];
|
|
682
|
+
const pattern = prefix === "hc" ? /^hc\d+$/ : /^dhw\d+$/;
|
|
683
|
+
const out = [];
|
|
684
|
+
for (const ref of body["references"]) {
|
|
685
|
+
if (!isRecord$2(ref) || typeof ref["id"] !== "string") continue;
|
|
686
|
+
const tail = ref["id"].split("/").filter((part) => part.length > 0).at(-1);
|
|
687
|
+
if (tail === void 0 || !pattern.test(tail) || out.includes(tail)) continue;
|
|
688
|
+
out.push(tail);
|
|
689
|
+
}
|
|
690
|
+
return out;
|
|
691
|
+
}
|
|
692
|
+
function indexStandard(body) {
|
|
693
|
+
const map = /* @__PURE__ */ new Map();
|
|
694
|
+
if (!isRecord$2(body) || !Array.isArray(body["references"])) return map;
|
|
695
|
+
for (const ref of body["references"]) {
|
|
696
|
+
if (!isRecord$2(ref) || typeof ref["id"] !== "string") continue;
|
|
697
|
+
const tail = ref["id"].split("/").filter((part) => part.length > 0).at(-1) ?? ref["id"];
|
|
698
|
+
const envelope = readEnvelope(ref);
|
|
699
|
+
if (envelope !== null) map.set(tail, envelope);
|
|
700
|
+
}
|
|
701
|
+
return map;
|
|
702
|
+
}
|
|
703
|
+
function numberOf(envelope) {
|
|
704
|
+
if (envelope === null) return null;
|
|
705
|
+
return typeof envelope.value === "number" && Number.isFinite(envelope.value) ? envelope.value : null;
|
|
706
|
+
}
|
|
707
|
+
function stringOf(envelope) {
|
|
708
|
+
if (envelope === null || typeof envelope.value !== "string" || envelope.value.length === 0) return null;
|
|
709
|
+
return envelope.value;
|
|
710
|
+
}
|
|
711
|
+
function swingOf(envelope) {
|
|
712
|
+
const value = stringOf(envelope);
|
|
713
|
+
if (value === null) return null;
|
|
714
|
+
return value === "swing";
|
|
715
|
+
}
|
|
716
|
+
function safeToken(value) {
|
|
717
|
+
if (value === null || !TOKEN.test(value)) return null;
|
|
718
|
+
return value;
|
|
719
|
+
}
|
|
720
|
+
function finite(raw) {
|
|
721
|
+
return typeof raw === "number" && Number.isFinite(raw) ? raw : null;
|
|
722
|
+
}
|
|
723
|
+
function stringList(raw) {
|
|
724
|
+
if (!Array.isArray(raw)) return [];
|
|
725
|
+
const out = [];
|
|
726
|
+
for (const item of raw) if (typeof item === "string") out.push(item);
|
|
727
|
+
return out;
|
|
728
|
+
}
|
|
729
|
+
function isRecord$2(value) {
|
|
730
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
321
731
|
}
|
|
322
|
-
//#endregion
|
|
323
|
-
//#region src/homecom/homecom-oauth.ts
|
|
324
|
-
/**
|
|
325
|
-
* Public OAuth client of the HomeCom Easy / PointT mobile app, signed in through SingleKey ID.
|
|
326
|
-
*
|
|
327
|
-
* These are the PUBLIC values the app itself ships (a PKCE client has no secret). They are not
|
|
328
|
-
* derivable from this repository; they come from the community PointT client (`homecom_alt`) and
|
|
329
|
-
* have to be confirmed against a live account — see docs/addons/addon-provider-bosch-homecom.md.
|
|
330
|
-
*/
|
|
331
|
-
var HOMECOM_CLIENT_ID = "762162C0-FA2D-4540-AE66-6489F189FADC";
|
|
332
|
-
var HOMECOM_REDIRECT_URI = "com.bosch.tt.dashtt.pointt://app/login";
|
|
333
|
-
var HOMECOM_AUTHORIZE_URL = "https://singlekey-id.com/auth/connect/authorize";
|
|
334
|
-
var HOMECOM_TOKEN_URL = "https://singlekey-id.com/auth/connect/token";
|
|
335
|
-
var HOMECOM_SCOPE = [
|
|
336
|
-
"openid",
|
|
337
|
-
"email",
|
|
338
|
-
"profile",
|
|
339
|
-
"offline_access",
|
|
340
|
-
"pointt.gateway.claiming",
|
|
341
|
-
"pointt.gateway.removal",
|
|
342
|
-
"pointt.gateway.list",
|
|
343
|
-
"pointt.gateway.users",
|
|
344
|
-
"pointt.gateway.resource.dopair",
|
|
345
|
-
"pointt.castt.flow.token-exchange",
|
|
346
|
-
"bacon",
|
|
347
|
-
"hcc.tariff.read"
|
|
348
|
-
].join(" ");
|
|
349
732
|
//#endregion
|
|
350
733
|
//#region src/homecom/homecom-api-client.ts
|
|
351
734
|
var HomeComAuthError = class extends Error {
|
|
352
735
|
name = "HomeComAuthError";
|
|
353
736
|
};
|
|
737
|
+
/** The account gateway list answered with an HTTP error. The status is the point. */
|
|
738
|
+
var HomeComGatewayListError = class extends Error {
|
|
739
|
+
name = "HomeComGatewayListError";
|
|
740
|
+
status;
|
|
741
|
+
constructor(status) {
|
|
742
|
+
super(`The gateway list failed (HTTP ${status}).`);
|
|
743
|
+
this.status = status;
|
|
744
|
+
}
|
|
745
|
+
};
|
|
354
746
|
function isRecord$1(value) {
|
|
355
747
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
356
748
|
}
|
|
@@ -366,6 +758,13 @@ function rowId(row) {
|
|
|
366
758
|
}
|
|
367
759
|
return null;
|
|
368
760
|
}
|
|
761
|
+
function textOf(row, keys) {
|
|
762
|
+
for (const key of keys) {
|
|
763
|
+
const value = row[key];
|
|
764
|
+
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
|
765
|
+
if (typeof value === "number") return String(value);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
369
768
|
/** Gateways from the account listing: a bare array or `{ gateways: [...] }`. */
|
|
370
769
|
function parseHomeComGatewayList(payload) {
|
|
371
770
|
const rows = isRecord$1(payload) ? payload["gateways"] : payload;
|
|
@@ -375,110 +774,237 @@ function parseHomeComGatewayList(payload) {
|
|
|
375
774
|
if (!isRecord$1(row)) continue;
|
|
376
775
|
const gatewayId = rowId(row);
|
|
377
776
|
if (gatewayId === null) continue;
|
|
378
|
-
const name = row["name"];
|
|
379
|
-
const
|
|
380
|
-
const
|
|
777
|
+
const name = textOf(row, ["name", "friendlyName"]);
|
|
778
|
+
const deviceType = textOf(row, ["deviceType"]);
|
|
779
|
+
const model = textOf(row, ["model"]) ?? deviceType;
|
|
780
|
+
const serial = textOf(row, [
|
|
781
|
+
"serial",
|
|
782
|
+
"serialNumber",
|
|
783
|
+
"serialId"
|
|
784
|
+
]);
|
|
785
|
+
const brand = textOf(row, ["brand"]);
|
|
786
|
+
const region = textOf(row, ["region"]);
|
|
381
787
|
out.push({
|
|
382
788
|
gatewayId,
|
|
383
|
-
name:
|
|
384
|
-
...
|
|
385
|
-
...
|
|
789
|
+
name: name ?? gatewayId,
|
|
790
|
+
...model !== void 0 ? { model } : {},
|
|
791
|
+
...serial !== void 0 ? { serial } : {},
|
|
792
|
+
...brand !== void 0 ? { brand } : {},
|
|
793
|
+
...region !== void 0 ? { region } : {},
|
|
794
|
+
...deviceType !== void 0 ? { deviceType } : {}
|
|
386
795
|
});
|
|
387
796
|
}
|
|
388
797
|
return out;
|
|
389
798
|
}
|
|
799
|
+
function homeComResourceUrl(gatewayId, resourcePath) {
|
|
800
|
+
const path = resourcePath.startsWith("/") ? resourcePath : `/${resourcePath}`;
|
|
801
|
+
return `${HOMECOM_POINTT_API_ORIGIN}${HOMECOM_GATEWAYS_PATH}${encodeURIComponent(gatewayId)}/resource${path}`;
|
|
802
|
+
}
|
|
390
803
|
function createHomeComApiClient(config, gatewayId, deps = {}) {
|
|
391
804
|
const fetchFn = deps.fetchFn ?? globalThis.fetch.bind(globalThis);
|
|
805
|
+
const refreshOnUnauthorized = deps.refreshOnUnauthorized ?? true;
|
|
806
|
+
const logger = deps.logger;
|
|
392
807
|
let token = config.token;
|
|
393
808
|
let refresh = config.refresh;
|
|
809
|
+
let deviceType = deps.deviceType;
|
|
810
|
+
let last = null;
|
|
811
|
+
let refreshTail = Promise.resolve();
|
|
812
|
+
const dropped = /* @__PURE__ */ new Set();
|
|
394
813
|
const base = homeComApiBase(config.region);
|
|
395
|
-
|
|
814
|
+
function authHeaders() {
|
|
396
815
|
return {
|
|
397
816
|
Authorization: `Bearer ${token}`,
|
|
398
817
|
Accept: "application/json",
|
|
399
818
|
"Content-Type": "application/json"
|
|
400
819
|
};
|
|
401
820
|
}
|
|
402
|
-
|
|
403
|
-
|
|
821
|
+
function logDrop(reason) {
|
|
822
|
+
if (dropped.has(reason)) return;
|
|
823
|
+
dropped.add(reason);
|
|
824
|
+
logger?.info("HomeCom dropped a resource the caps do not publish", { meta: {
|
|
825
|
+
gatewayId,
|
|
826
|
+
deviceType: deviceType ?? "(missing)",
|
|
827
|
+
reason
|
|
828
|
+
} });
|
|
829
|
+
}
|
|
830
|
+
function logMissing(path, status) {
|
|
831
|
+
const key = `missing:${path}`;
|
|
832
|
+
if (dropped.has(key)) return;
|
|
833
|
+
dropped.add(key);
|
|
834
|
+
logger?.info("HomeCom resource is absent", { meta: {
|
|
835
|
+
gatewayId,
|
|
836
|
+
path,
|
|
837
|
+
status
|
|
838
|
+
} });
|
|
839
|
+
}
|
|
840
|
+
async function refreshIfStale(used) {
|
|
841
|
+
const run = refreshTail.then(async () => {
|
|
842
|
+
if (token !== used) return;
|
|
843
|
+
await refreshTokens();
|
|
844
|
+
});
|
|
845
|
+
refreshTail = run.then(() => void 0, () => void 0);
|
|
846
|
+
await run;
|
|
847
|
+
}
|
|
848
|
+
async function authed(url, init, attempt = "first") {
|
|
849
|
+
const used = token;
|
|
850
|
+
const res = await fetchFn(url, {
|
|
404
851
|
method: init.method ?? "GET",
|
|
405
|
-
headers:
|
|
406
|
-
body: init.body
|
|
852
|
+
headers: authHeaders(),
|
|
853
|
+
body: init.body
|
|
407
854
|
});
|
|
408
|
-
if (res.status === 401)
|
|
855
|
+
if (res.status === 401) {
|
|
856
|
+
if (!((init.allowRefresh ?? true) && refreshOnUnauthorized) || attempt === "retried") throw new HomeComAuthError(attempt === "retried" ? "token expired after refresh" : "token expired");
|
|
857
|
+
await refreshIfStale(used);
|
|
858
|
+
return authed(url, init, "retried");
|
|
859
|
+
}
|
|
860
|
+
let body = null;
|
|
861
|
+
try {
|
|
862
|
+
body = await res.json();
|
|
863
|
+
} catch {
|
|
864
|
+
body = null;
|
|
865
|
+
}
|
|
866
|
+
return {
|
|
867
|
+
ok: res.ok,
|
|
868
|
+
status: res.status,
|
|
869
|
+
body
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
async function getResource(path) {
|
|
873
|
+
const res = await authed(homeComResourceUrl(gatewayId, path), { method: "GET" });
|
|
874
|
+
if (res.status === 404 || res.status === 403) {
|
|
875
|
+
logMissing(path, res.status);
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
409
878
|
if (!res.ok) throw new Error(`HomeCom API ${path} failed: HTTP ${res.status}`);
|
|
410
|
-
return res.
|
|
879
|
+
return res.body;
|
|
880
|
+
}
|
|
881
|
+
async function putWrite(write) {
|
|
882
|
+
const res = await authed(homeComResourceUrl(gatewayId, write.path), {
|
|
883
|
+
method: "PUT",
|
|
884
|
+
body: JSON.stringify({ value: write.value })
|
|
885
|
+
});
|
|
886
|
+
if (res.status === 404 || res.status === 403) throw new Error(`HomeCom write to ${write.path} was refused (HTTP ${res.status}).`);
|
|
887
|
+
if (!res.ok) throw new Error(`HomeCom API ${write.path} failed: HTTP ${res.status}`);
|
|
888
|
+
}
|
|
889
|
+
async function requireSnapshot() {
|
|
890
|
+
if (last !== null) return last;
|
|
891
|
+
return fetchSystem();
|
|
892
|
+
}
|
|
893
|
+
async function resolveDeviceType() {
|
|
894
|
+
if (deviceType === void 0) deviceType = (await listGateways()).find((row) => row.gatewayId === gatewayId)?.deviceType;
|
|
895
|
+
const family = familyOf(deviceType);
|
|
896
|
+
if (family === null || deviceType === void 0) {
|
|
897
|
+
const label = deviceType ?? "(missing)";
|
|
898
|
+
logger?.warn("HomeCom device type is not served", { meta: {
|
|
899
|
+
gatewayId,
|
|
900
|
+
deviceType: label
|
|
901
|
+
} });
|
|
902
|
+
throw new HomeComUnknownDeviceTypeError(label);
|
|
903
|
+
}
|
|
904
|
+
return {
|
|
905
|
+
deviceType,
|
|
906
|
+
family
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
async function refreshTokens() {
|
|
910
|
+
const res = await fetchFn(HOMECOM_TOKEN_URL, {
|
|
911
|
+
method: "POST",
|
|
912
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
913
|
+
body: new URLSearchParams({
|
|
914
|
+
grant_type: "refresh_token",
|
|
915
|
+
refresh_token: refresh,
|
|
916
|
+
client_id: HOMECOM_CLIENT_ID
|
|
917
|
+
}).toString()
|
|
918
|
+
});
|
|
919
|
+
if (!res.ok) {
|
|
920
|
+
if (res.status === 401 || res.status === 403) throw new HomeComAuthError("refresh refused");
|
|
921
|
+
throw new Error(`HomeCom token refresh failed: HTTP ${res.status}`);
|
|
922
|
+
}
|
|
923
|
+
const body = await res.json();
|
|
924
|
+
if (!isRecord$1(body)) throw new Error("HomeCom token refresh: bad body");
|
|
925
|
+
const newToken = body["access_token"];
|
|
926
|
+
const newRefresh = body["refresh_token"];
|
|
927
|
+
if (typeof newToken !== "string" || typeof newRefresh !== "string") throw new Error("HomeCom token refresh: missing tokens");
|
|
928
|
+
token = newToken;
|
|
929
|
+
refresh = newRefresh;
|
|
930
|
+
await deps.onTokensRotated?.({
|
|
931
|
+
token,
|
|
932
|
+
refresh
|
|
933
|
+
});
|
|
934
|
+
return {
|
|
935
|
+
token,
|
|
936
|
+
refresh
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
async function listGateways() {
|
|
940
|
+
const res = await authed(`${base}${HOMECOM_GATEWAYS_PATH}`, { method: "GET" });
|
|
941
|
+
if (!res.ok) throw new HomeComGatewayListError(res.status);
|
|
942
|
+
return parseHomeComGatewayList(res.body);
|
|
943
|
+
}
|
|
944
|
+
async function fetchSystem() {
|
|
945
|
+
const resolved = await resolveDeviceType();
|
|
946
|
+
const snapshot = await readPointtSnapshot({
|
|
947
|
+
deviceType: resolved.deviceType,
|
|
948
|
+
family: resolved.family,
|
|
949
|
+
gatewayId,
|
|
950
|
+
get: getResource,
|
|
951
|
+
logDrop
|
|
952
|
+
});
|
|
953
|
+
last = snapshot;
|
|
954
|
+
return snapshot;
|
|
955
|
+
}
|
|
956
|
+
async function probePath(path) {
|
|
957
|
+
const res = await authed(homeComResourceUrl(gatewayId, path), {
|
|
958
|
+
method: "GET",
|
|
959
|
+
allowRefresh: false
|
|
960
|
+
});
|
|
961
|
+
if (res.status === 404 || res.status === 403) return null;
|
|
962
|
+
if (!res.ok) throw new Error(`HomeCom API ${path} failed: HTTP ${res.status}`);
|
|
963
|
+
return probeText(res.body);
|
|
411
964
|
}
|
|
412
965
|
return {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
}).toString()
|
|
422
|
-
});
|
|
423
|
-
if (!res.ok) {
|
|
424
|
-
if (res.status === 401 || res.status === 403) throw new HomeComAuthError("refresh refused");
|
|
425
|
-
throw new Error(`HomeCom token refresh failed: HTTP ${res.status}`);
|
|
426
|
-
}
|
|
427
|
-
const body = await res.json();
|
|
428
|
-
if (typeof body !== "object" || body === null) throw new Error("HomeCom token refresh: bad body");
|
|
429
|
-
const row = body;
|
|
430
|
-
const newToken = row["access_token"];
|
|
431
|
-
const newRefresh = row["refresh_token"];
|
|
432
|
-
if (typeof newToken !== "string" || typeof newRefresh !== "string") throw new Error("HomeCom token refresh: missing tokens");
|
|
433
|
-
token = newToken;
|
|
434
|
-
refresh = newRefresh;
|
|
435
|
-
await deps.onTokensRotated?.({
|
|
436
|
-
token,
|
|
437
|
-
refresh
|
|
438
|
-
});
|
|
439
|
-
return {
|
|
440
|
-
token,
|
|
441
|
-
refresh
|
|
966
|
+
refreshTokens,
|
|
967
|
+
listGateways,
|
|
968
|
+
fetchSystem,
|
|
969
|
+
async probeGateway() {
|
|
970
|
+
const clock = await probePath(HOMECOM_PROBE_CLOCK);
|
|
971
|
+
if (clock !== null) return {
|
|
972
|
+
resource: HOMECOM_PROBE_CLOCK,
|
|
973
|
+
value: clock
|
|
442
974
|
};
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
async fetchSystem() {
|
|
448
|
-
const parsed = parseHomeComSystemPayload(await request(`/v2/gateways/${gatewayId}/system`));
|
|
449
|
-
return {
|
|
450
|
-
gateway: {
|
|
451
|
-
gatewayId,
|
|
452
|
-
name: "HomeCom heat pump",
|
|
453
|
-
model: "K40"
|
|
454
|
-
},
|
|
455
|
-
circuits: parsed.circuits,
|
|
456
|
-
dhw: parsed.dhw
|
|
975
|
+
const firmware = await probePath(HOMECOM_PROBE_FIRMWARE);
|
|
976
|
+
if (firmware !== null) return {
|
|
977
|
+
resource: HOMECOM_PROBE_FIRMWARE,
|
|
978
|
+
value: firmware
|
|
457
979
|
};
|
|
980
|
+
throw new Error(`HomeCom gateway ${gatewayId} did not answer /gateway/DateTime or /gateway/versionFirmware.`);
|
|
458
981
|
},
|
|
459
982
|
async setCircuitSetpoint(circuitId, temp) {
|
|
460
|
-
await
|
|
461
|
-
method: "PUT",
|
|
462
|
-
body: { value: temp }
|
|
463
|
-
});
|
|
983
|
+
await putWrite(circuitSetpointWrite(await requireSnapshot(), circuitId, temp));
|
|
464
984
|
},
|
|
465
985
|
async setCircuitMode(circuitId, mode) {
|
|
466
|
-
await
|
|
467
|
-
method: "PUT",
|
|
468
|
-
body: { value: mode }
|
|
469
|
-
});
|
|
986
|
+
for (const write of circuitModeWrites(await requireSnapshot(), circuitId, mode)) await putWrite(write);
|
|
470
987
|
},
|
|
471
988
|
async setDhwTarget(temp) {
|
|
472
|
-
await
|
|
473
|
-
method: "PUT",
|
|
474
|
-
body: { value: temp }
|
|
475
|
-
});
|
|
989
|
+
await putWrite(dhwTargetWrite(await requireSnapshot(), temp));
|
|
476
990
|
},
|
|
477
991
|
async setDhwMode(mode) {
|
|
478
|
-
await
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
992
|
+
await putWrite(dhwModeWrite(await requireSnapshot(), mode));
|
|
993
|
+
},
|
|
994
|
+
async setFanMode(_circuitId, fanMode) {
|
|
995
|
+
const snapshot = await requireSnapshot();
|
|
996
|
+
if (snapshot.family !== "rac") logDrop("fan mode is not a resource on this device type");
|
|
997
|
+
await putWrite(fanWrite(snapshot, fanMode));
|
|
998
|
+
},
|
|
999
|
+
async setSwingVertical(_circuitId, on) {
|
|
1000
|
+
const snapshot = await requireSnapshot();
|
|
1001
|
+
if (snapshot.family !== "rac") logDrop("swing is not a resource on this device type");
|
|
1002
|
+
await putWrite(swingWrite(snapshot, "vertical", on));
|
|
1003
|
+
},
|
|
1004
|
+
async setSwingHorizontal(_circuitId, on) {
|
|
1005
|
+
const snapshot = await requireSnapshot();
|
|
1006
|
+
if (snapshot.family !== "rac") logDrop("swing is not a resource on this device type");
|
|
1007
|
+
await putWrite(swingWrite(snapshot, "horizontal", on));
|
|
482
1008
|
}
|
|
483
1009
|
};
|
|
484
1010
|
}
|
|
@@ -492,10 +1018,17 @@ function regionOf(value) {
|
|
|
492
1018
|
for (const region of HOMECOM_REGIONS) if (value === region) return region;
|
|
493
1019
|
return "eu";
|
|
494
1020
|
}
|
|
1021
|
+
function choiceLabel(gateway) {
|
|
1022
|
+
const parts = [];
|
|
1023
|
+
if (gateway.name.length > 0 && gateway.name !== gateway.gatewayId) parts.push(gateway.name);
|
|
1024
|
+
if (gateway.model !== void 0 && gateway.model.length > 0 && gateway.model !== gateway.name) parts.push(gateway.model);
|
|
1025
|
+
if (gateway.serial !== void 0 && gateway.serial.length > 0) parts.push(gateway.serial);
|
|
1026
|
+
return parts.length > 0 ? parts.join(" · ") : gateway.gatewayId;
|
|
1027
|
+
}
|
|
495
1028
|
function choiceOf(gateway) {
|
|
496
1029
|
return {
|
|
497
1030
|
value: gateway.gatewayId,
|
|
498
|
-
label: gateway
|
|
1031
|
+
label: choiceLabel(gateway),
|
|
499
1032
|
brand: brandOf(gateway.brand),
|
|
500
1033
|
region: regionOf(gateway.region)
|
|
501
1034
|
};
|
|
@@ -557,6 +1090,7 @@ function beginHomeComSignIn() {
|
|
|
557
1090
|
url.searchParams.set("state", state);
|
|
558
1091
|
url.searchParams.set("nonce", b64url((0, node_crypto.randomBytes)(16)));
|
|
559
1092
|
url.searchParams.set("prompt", "login");
|
|
1093
|
+
url.searchParams.set("style_id", HOMECOM_STYLE_ID);
|
|
560
1094
|
return {
|
|
561
1095
|
authorizeUrl: url.toString(),
|
|
562
1096
|
verifier,
|
|
@@ -621,11 +1155,12 @@ function publishHomeComSignIn(store, nowMs) {
|
|
|
621
1155
|
};
|
|
622
1156
|
}
|
|
623
1157
|
async function listGatewaysDefault(tokens, fetchFn) {
|
|
624
|
-
const res = await fetchFn(`${HOMECOM_POINTT_API_ORIGIN}
|
|
1158
|
+
const res = await fetchFn(`${HOMECOM_POINTT_API_ORIGIN}${HOMECOM_GATEWAYS_PATH}`, { headers: {
|
|
625
1159
|
Authorization: `Bearer ${tokens.token}`,
|
|
626
1160
|
Accept: "application/json"
|
|
627
1161
|
} });
|
|
628
|
-
if (
|
|
1162
|
+
if (res.status === 401) throw new HomeComGatewayListError(res.status);
|
|
1163
|
+
if (!res.ok) throw new HomeComGatewayListError(res.status);
|
|
629
1164
|
return parseHomeComGatewayList(await res.json());
|
|
630
1165
|
}
|
|
631
1166
|
async function finishHomeComSignIn(input, store, deps = {}) {
|
|
@@ -638,25 +1173,25 @@ async function finishHomeComSignIn(input, store, deps = {}) {
|
|
|
638
1173
|
state: taken.attempt.state
|
|
639
1174
|
}, fetchFn);
|
|
640
1175
|
let gateways = [];
|
|
641
|
-
let
|
|
1176
|
+
let listFailure = null;
|
|
642
1177
|
try {
|
|
643
1178
|
gateways = await (deps.listGateways ?? ((next) => listGatewaysDefault(next, fetchFn)))(tokens);
|
|
644
|
-
} catch {
|
|
645
|
-
|
|
1179
|
+
} catch (err) {
|
|
1180
|
+
listFailure = err instanceof HomeComGatewayListError ? err.message : "The gateway list failed.";
|
|
646
1181
|
gateways = [];
|
|
647
1182
|
}
|
|
648
|
-
return finishedFromGateways(tokens, resolveHomeComGateway(gateways),
|
|
1183
|
+
return finishedFromGateways(tokens, resolveHomeComGateway(gateways), listFailure);
|
|
649
1184
|
}
|
|
650
|
-
function finishedFromGateways(tokens, resolved,
|
|
1185
|
+
function finishedFromGateways(tokens, resolved, listFailure) {
|
|
651
1186
|
const who = tokens.username.length > 0 ? `Signed in as ${tokens.username}` : "Signed in";
|
|
652
1187
|
const base = {
|
|
653
1188
|
...tokens,
|
|
654
1189
|
accountLabel: tokens.username
|
|
655
1190
|
};
|
|
656
|
-
if (
|
|
1191
|
+
if (listFailure !== null) return {
|
|
657
1192
|
...base,
|
|
658
1193
|
gatewayCount: 0,
|
|
659
|
-
summary:
|
|
1194
|
+
summary: listFailure
|
|
660
1195
|
};
|
|
661
1196
|
if (resolved.kind === "one") return {
|
|
662
1197
|
...base,
|
|
@@ -675,7 +1210,7 @@ function finishedFromGateways(tokens, resolved, listFailed) {
|
|
|
675
1210
|
return {
|
|
676
1211
|
...base,
|
|
677
1212
|
gatewayCount: 0,
|
|
678
|
-
summary:
|
|
1213
|
+
summary: "This Bosch account has no HomeCom gateway."
|
|
679
1214
|
};
|
|
680
1215
|
}
|
|
681
1216
|
var homeComSignInAttempts = require_account_integration_offline.createSignInAttemptStore();
|
|
@@ -998,7 +1533,9 @@ var HomeComIntegrationManager = class {
|
|
|
998
1533
|
};
|
|
999
1534
|
this.#makeClient = options.makeClient ?? ((config, gatewayId) => createHomeComApiClient(config, gatewayId, {
|
|
1000
1535
|
fetchFn,
|
|
1001
|
-
onTokensRotated
|
|
1536
|
+
onTokensRotated,
|
|
1537
|
+
logger: this.#logger,
|
|
1538
|
+
deviceType: this.#gateways.find((gateway) => gateway.gatewayId === gatewayId)?.deviceType
|
|
1002
1539
|
}));
|
|
1003
1540
|
this.#pollIntervalMs = options.pollIntervalMs ?? 6e4;
|
|
1004
1541
|
}
|
|
@@ -1030,7 +1567,7 @@ var HomeComIntegrationManager = class {
|
|
|
1030
1567
|
this.#gatewayId = gatewayId;
|
|
1031
1568
|
client = this.#makeClient(this.#connection, gatewayId);
|
|
1032
1569
|
this.#client = client;
|
|
1033
|
-
const snapshot = await
|
|
1570
|
+
const snapshot = await client.fetchSystem();
|
|
1034
1571
|
const surface = {
|
|
1035
1572
|
gatewayId: this.#gatewayId ?? "",
|
|
1036
1573
|
client,
|
|
@@ -1074,7 +1611,7 @@ var HomeComIntegrationManager = class {
|
|
|
1074
1611
|
const client = this.#client;
|
|
1075
1612
|
if (!client) return;
|
|
1076
1613
|
try {
|
|
1077
|
-
const snapshot = await
|
|
1614
|
+
const snapshot = await client.fetchSystem();
|
|
1078
1615
|
this.#surfaceSink.updateSnapshot(this.#id, snapshot);
|
|
1079
1616
|
this.#lastCheckedAt = Date.now();
|
|
1080
1617
|
} catch (err) {
|
|
@@ -1084,18 +1621,6 @@ var HomeComIntegrationManager = class {
|
|
|
1084
1621
|
} });
|
|
1085
1622
|
}
|
|
1086
1623
|
}
|
|
1087
|
-
async fetchWithTokenRefresh(client) {
|
|
1088
|
-
return this.withTokenRefresh(client, () => client.fetchSystem());
|
|
1089
|
-
}
|
|
1090
|
-
async withTokenRefresh(client, call) {
|
|
1091
|
-
try {
|
|
1092
|
-
return await call();
|
|
1093
|
-
} catch (err) {
|
|
1094
|
-
if (!(err instanceof HomeComAuthError)) throw err;
|
|
1095
|
-
await client.refreshTokens();
|
|
1096
|
-
return call();
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
1624
|
/**
|
|
1100
1625
|
* The gateway to poll: the stored choice when the account still has it, otherwise the
|
|
1101
1626
|
* account's first gateway, which is then stored. A stored choice that vanished is an error —
|
|
@@ -1103,7 +1628,7 @@ var HomeComIntegrationManager = class {
|
|
|
1103
1628
|
*/
|
|
1104
1629
|
async resolveGatewayId() {
|
|
1105
1630
|
const lister = this.#makeClient(this.#connection, "");
|
|
1106
|
-
this.#gateways = await
|
|
1631
|
+
this.#gateways = await lister.listGateways();
|
|
1107
1632
|
const stored = this.#connection.gatewayId;
|
|
1108
1633
|
if (stored !== void 0) {
|
|
1109
1634
|
if (!this.#gateways.some((g) => g.gatewayId === stored)) throw new Error(`HomeCom gateway ${stored} is no longer on this account`);
|
|
@@ -1238,17 +1763,28 @@ var HomeComClientRegistry = class {
|
|
|
1238
1763
|
};
|
|
1239
1764
|
//#endregion
|
|
1240
1765
|
//#region src/homecom/homecom-connection-test.ts
|
|
1241
|
-
var TEST_LABEL = "Lists the account gateways and reads the
|
|
1766
|
+
var TEST_LABEL = "Lists the account gateways and reads the gateway clock";
|
|
1767
|
+
var NO_GATEWAY = "This Bosch account has no HomeCom gateway.";
|
|
1768
|
+
function signInIncomplete(paths) {
|
|
1769
|
+
if (paths.includes("token") || paths.includes("refresh")) return "Sign in first. Bosch has not issued an access token yet.";
|
|
1770
|
+
return `HomeCom could not use the saved sign-in (${paths.join(", ")}).`;
|
|
1771
|
+
}
|
|
1772
|
+
function listFailureMessage(err) {
|
|
1773
|
+
if (err instanceof HomeComGatewayListError) return err.message;
|
|
1774
|
+
if (err instanceof Error && err.message.startsWith("The gateway list failed")) return err.message;
|
|
1775
|
+
if (err instanceof Error && err.message.includes("did not answer")) return err.message;
|
|
1776
|
+
return null;
|
|
1777
|
+
}
|
|
1242
1778
|
function buildHomeComConnectionTestProvider(deps) {
|
|
1243
1779
|
const { logger } = deps;
|
|
1244
|
-
const makeClient = deps.makeClient ?? ((config, gatewayId) => createHomeComApiClient(config, gatewayId));
|
|
1780
|
+
const makeClient = deps.makeClient ?? ((config, gatewayId) => createHomeComApiClient(config, gatewayId, { refreshOnUnauthorized: false }));
|
|
1245
1781
|
return {
|
|
1246
1782
|
describeTest: async () => ({ label: TEST_LABEL }),
|
|
1247
1783
|
testSettings: async ({ settings }) => {
|
|
1248
1784
|
const parsed = homeComConfigSchema.safeParse(settings);
|
|
1249
1785
|
if (!parsed.success) return {
|
|
1250
1786
|
outcome: "rejected",
|
|
1251
|
-
error:
|
|
1787
|
+
error: signInIncomplete(parsed.error.issues.map((issue) => issue.path.join(".") || "(root)"))
|
|
1252
1788
|
};
|
|
1253
1789
|
const startedAt = Date.now();
|
|
1254
1790
|
try {
|
|
@@ -1256,18 +1792,30 @@ function buildHomeComConnectionTestProvider(deps) {
|
|
|
1256
1792
|
const gatewayId = parsed.data.gatewayId ?? gateways[0]?.gatewayId;
|
|
1257
1793
|
if (gatewayId === void 0) return {
|
|
1258
1794
|
outcome: "rejected",
|
|
1259
|
-
error:
|
|
1795
|
+
error: NO_GATEWAY
|
|
1260
1796
|
};
|
|
1261
1797
|
if (!gateways.some((g) => g.gatewayId === gatewayId)) return {
|
|
1262
1798
|
outcome: "rejected",
|
|
1263
1799
|
error: `Gateway ${gatewayId} is not on this account.`
|
|
1264
1800
|
};
|
|
1265
|
-
|
|
1801
|
+
const listed = gateways.find((gateway) => gateway.gatewayId === gatewayId);
|
|
1802
|
+
const probe = await makeClient(parsed.data, gatewayId).probeGateway();
|
|
1803
|
+
const refusal = unsupportedDeviceTypeMessage(listed?.deviceType);
|
|
1804
|
+
if (refusal !== null) return {
|
|
1805
|
+
outcome: "rejected",
|
|
1806
|
+
error: refusal
|
|
1807
|
+
};
|
|
1266
1808
|
return {
|
|
1267
1809
|
outcome: "validated",
|
|
1268
|
-
latencyMs: Date.now() - startedAt
|
|
1810
|
+
latencyMs: Date.now() - startedAt,
|
|
1811
|
+
detail: `Gateway ${gatewayId} (${listed?.deviceType}) answered ${probe.resource}.`
|
|
1269
1812
|
};
|
|
1270
1813
|
} catch (err) {
|
|
1814
|
+
const listed = listFailureMessage(err);
|
|
1815
|
+
if (listed !== null) return {
|
|
1816
|
+
outcome: "rejected",
|
|
1817
|
+
error: listed
|
|
1818
|
+
};
|
|
1271
1819
|
if (err instanceof HomeComAuthError) return {
|
|
1272
1820
|
outcome: "rejected",
|
|
1273
1821
|
error: "HomeCom refused the access token — sign in again to get a fresh one."
|
|
@@ -1356,8 +1904,9 @@ var HomeComClimateDevice = class extends require_dist.BaseDevice {
|
|
|
1356
1904
|
await this.requireClient().setCircuitMode(this.#circuitId, apiMode);
|
|
1357
1905
|
this.refreshFromSnapshot();
|
|
1358
1906
|
},
|
|
1359
|
-
setFanMode: async () => {
|
|
1360
|
-
|
|
1907
|
+
setFanMode: async ({ fanMode }) => {
|
|
1908
|
+
await this.requireClient().setFanMode(this.#circuitId, fanMode);
|
|
1909
|
+
this.refreshFromSnapshot();
|
|
1361
1910
|
},
|
|
1362
1911
|
setPreset: async () => {
|
|
1363
1912
|
throw new Error("HomeCom climate: presets not supported");
|
|
@@ -1372,11 +1921,13 @@ var HomeComClimateDevice = class extends require_dist.BaseDevice {
|
|
|
1372
1921
|
setTargetHumidity: async () => {
|
|
1373
1922
|
throw new Error("HomeCom climate: humidity not supported");
|
|
1374
1923
|
},
|
|
1375
|
-
setSwingVertical: async () => {
|
|
1376
|
-
|
|
1924
|
+
setSwingVertical: async ({ on }) => {
|
|
1925
|
+
await this.requireClient().setSwingVertical(this.#circuitId, on);
|
|
1926
|
+
this.refreshFromSnapshot();
|
|
1377
1927
|
},
|
|
1378
|
-
setSwingHorizontal: async () => {
|
|
1379
|
-
|
|
1928
|
+
setSwingHorizontal: async ({ on }) => {
|
|
1929
|
+
await this.requireClient().setSwingHorizontal(this.#circuitId, on);
|
|
1930
|
+
this.refreshFromSnapshot();
|
|
1380
1931
|
}
|
|
1381
1932
|
});
|
|
1382
1933
|
}
|