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