@camstack/addon-provider-bosch 0.1.27 → 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.
@@ -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
- /** The gateway this integration controls; chosen once from the account's gateways. */
18
- gatewayId: require_dist.string().min(1).optional(),
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. Bosch opens its app address; paste that address here.",
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: mapHvacMode(circuit.operationMode),
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,96 +385,364 @@ function waterHeaterSliceFromDhw(dhw, nowMs) {
265
385
  lastChangedAt: nowMs
266
386
  };
267
387
  }
268
- /** Parse K40-style gateway JSON into heating circuits + DHW. Pure. */
269
- function parseHomeComSystemPayload(payload) {
270
- if (typeof payload !== "object" || payload === null) return {
271
- circuits: [],
272
- dhw: null
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"])
273
410
  };
274
- const root = payload;
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"
486
+ };
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 rawCircuits = root["heatingCircuits"];
277
- if (Array.isArray(rawCircuits)) for (const item of rawCircuits) {
278
- if (typeof item !== "object" || item === null) continue;
279
- const row = item;
280
- const id = row["id"];
281
- if (typeof id !== "string") continue;
282
- circuits.push({
283
- id,
284
- name: typeof row["name"] === "string" ? row["name"] : id,
285
- operationMode: typeof row["operationMode"] === "string" ? row["operationMode"] : "off",
286
- setpoint: readNum(row["setpoint"]),
287
- currentRoomTemp: readNum(row["currentRoomTemperature"]),
288
- minTemp: readNum(row["minTemp"]),
289
- maxTemp: readNum(row["maxTemp"])
290
- });
291
- }
292
- let dhw = null;
293
- const rawDhw = root["domesticHotWater"];
294
- if (typeof rawDhw === "object" && rawDhw !== null) {
295
- const row = rawDhw;
296
- const modes = [];
297
- if (Array.isArray(row["operationModes"])) {
298
- for (const m of row["operationModes"]) if (typeof m === "string") modes.push(m);
299
- }
300
- dhw = {
301
- currentTemp: readNum(row["currentTemperature"]),
302
- targetTemp: readNum(row["setpoint"]),
303
- operationMode: typeof row["operationMode"] === "string" ? row["operationMode"] : null,
304
- availableModes: modes.length > 0 ? modes : [
305
- "off",
306
- "eco",
307
- "performance"
308
- ],
309
- minTemp: readNum(row["minTemp"]),
310
- maxTemp: readNum(row["maxTemp"])
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);
313
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 ?? [];
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 readNum(raw) {
319
- if (typeof raw === "number" && Number.isFinite(raw)) return raw;
320
- return null;
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 app, signed in through SingleKey ID.
326
- *
327
- * A PKCE client has no secret. The values are the ones SingleKey accepts for this client id
328
- * today: the same authorize query the MIT-licensed `homecom_alt` client records
329
- * (`OAUTH_LOGIN_PARAMS` — parameters, not copied code) and that the Home Assistant integration
330
- * opens. `pointt.gateway.resource.dopair` is not a scope this client may request; SingleKey
331
- * answers `misconfigured-application` and never shows the login page. The scope it does accept
332
- * names `pointt.gateway.resource.dashapp`, and the Bosch app sends `style_id=tt_bsch`.
333
- */
334
- var HOMECOM_CLIENT_ID = "762162C0-FA2D-4540-AE66-6489F189FADC";
335
- var HOMECOM_REDIRECT_URI = "com.bosch.tt.dashtt.pointt://app/login";
336
- var HOMECOM_AUTHORIZE_URL = "https://singlekey-id.com/auth/connect/authorize";
337
- var HOMECOM_TOKEN_URL = "https://singlekey-id.com/auth/connect/token";
338
- var HOMECOM_STYLE_ID = "tt_bsch";
339
- var HOMECOM_SCOPE = [
340
- "openid",
341
- "email",
342
- "profile",
343
- "offline_access",
344
- "pointt.gateway.claiming",
345
- "pointt.gateway.removal",
346
- "pointt.gateway.list",
347
- "pointt.gateway.users",
348
- "pointt.gateway.resource.dashapp",
349
- "pointt.castt.flow.token-exchange",
350
- "bacon",
351
- "hcc.tariff.read"
352
- ].join(" ");
353
732
  //#endregion
354
733
  //#region src/homecom/homecom-api-client.ts
355
734
  var HomeComAuthError = class extends Error {
356
735
  name = "HomeComAuthError";
357
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
+ };
358
746
  function isRecord$1(value) {
359
747
  return typeof value === "object" && value !== null && !Array.isArray(value);
360
748
  }
@@ -370,6 +758,13 @@ function rowId(row) {
370
758
  }
371
759
  return null;
372
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
+ }
373
768
  /** Gateways from the account listing: a bare array or `{ gateways: [...] }`. */
374
769
  function parseHomeComGatewayList(payload) {
375
770
  const rows = isRecord$1(payload) ? payload["gateways"] : payload;
@@ -379,110 +774,237 @@ function parseHomeComGatewayList(payload) {
379
774
  if (!isRecord$1(row)) continue;
380
775
  const gatewayId = rowId(row);
381
776
  if (gatewayId === null) continue;
382
- const name = row["name"];
383
- const brand = row["brand"];
384
- const region = row["region"];
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"]);
385
787
  out.push({
386
788
  gatewayId,
387
- name: typeof name === "string" && name.length > 0 ? name : gatewayId,
388
- ...typeof brand === "string" && brand.length > 0 ? { brand } : {},
389
- ...typeof region === "string" && region.length > 0 ? { region } : {}
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 } : {}
390
795
  });
391
796
  }
392
797
  return out;
393
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
+ }
394
803
  function createHomeComApiClient(config, gatewayId, deps = {}) {
395
804
  const fetchFn = deps.fetchFn ?? globalThis.fetch.bind(globalThis);
805
+ const refreshOnUnauthorized = deps.refreshOnUnauthorized ?? true;
806
+ const logger = deps.logger;
396
807
  let token = config.token;
397
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();
398
813
  const base = homeComApiBase(config.region);
399
- async function authHeaders() {
814
+ function authHeaders() {
400
815
  return {
401
816
  Authorization: `Bearer ${token}`,
402
817
  Accept: "application/json",
403
818
  "Content-Type": "application/json"
404
819
  };
405
820
  }
406
- async function request(path, init = {}) {
407
- const res = await fetchFn(`${base}${path}`, {
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, {
408
851
  method: init.method ?? "GET",
409
- headers: await authHeaders(),
410
- body: init.body !== void 0 ? JSON.stringify(init.body) : void 0
852
+ headers: authHeaders(),
853
+ body: init.body
854
+ });
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
+ }
878
+ if (!res.ok) throw new Error(`HomeCom API ${path} failed: HTTP ${res.status}`);
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()
411
918
  });
412
- if (res.status === 401) throw new HomeComAuthError("token expired");
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;
413
962
  if (!res.ok) throw new Error(`HomeCom API ${path} failed: HTTP ${res.status}`);
414
- return res.json();
963
+ return probeText(res.body);
415
964
  }
416
965
  return {
417
- async refreshTokens() {
418
- const res = await fetchFn(HOMECOM_TOKEN_URL, {
419
- method: "POST",
420
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
421
- body: new URLSearchParams({
422
- grant_type: "refresh_token",
423
- refresh_token: refresh,
424
- client_id: HOMECOM_CLIENT_ID
425
- }).toString()
426
- });
427
- if (!res.ok) {
428
- if (res.status === 401 || res.status === 403) throw new HomeComAuthError("refresh refused");
429
- throw new Error(`HomeCom token refresh failed: HTTP ${res.status}`);
430
- }
431
- const body = await res.json();
432
- if (typeof body !== "object" || body === null) throw new Error("HomeCom token refresh: bad body");
433
- const row = body;
434
- const newToken = row["access_token"];
435
- const newRefresh = row["refresh_token"];
436
- if (typeof newToken !== "string" || typeof newRefresh !== "string") throw new Error("HomeCom token refresh: missing tokens");
437
- token = newToken;
438
- refresh = newRefresh;
439
- await deps.onTokensRotated?.({
440
- token,
441
- refresh
442
- });
443
- return {
444
- token,
445
- 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
446
974
  };
447
- },
448
- async listGateways() {
449
- return parseHomeComGatewayList(await request("/v2/gateways"));
450
- },
451
- async fetchSystem() {
452
- const parsed = parseHomeComSystemPayload(await request(`/v2/gateways/${gatewayId}/system`));
453
- return {
454
- gateway: {
455
- gatewayId,
456
- name: "HomeCom heat pump",
457
- model: "K40"
458
- },
459
- circuits: parsed.circuits,
460
- dhw: parsed.dhw
975
+ const firmware = await probePath(HOMECOM_PROBE_FIRMWARE);
976
+ if (firmware !== null) return {
977
+ resource: HOMECOM_PROBE_FIRMWARE,
978
+ value: firmware
461
979
  };
980
+ throw new Error(`HomeCom gateway ${gatewayId} did not answer /gateway/DateTime or /gateway/versionFirmware.`);
462
981
  },
463
982
  async setCircuitSetpoint(circuitId, temp) {
464
- await request(`/v2/gateways/${gatewayId}/heatingCircuits/${circuitId}/setpoint`, {
465
- method: "PUT",
466
- body: { value: temp }
467
- });
983
+ await putWrite(circuitSetpointWrite(await requireSnapshot(), circuitId, temp));
468
984
  },
469
985
  async setCircuitMode(circuitId, mode) {
470
- await request(`/v2/gateways/${gatewayId}/heatingCircuits/${circuitId}/operationMode`, {
471
- method: "PUT",
472
- body: { value: mode }
473
- });
986
+ for (const write of circuitModeWrites(await requireSnapshot(), circuitId, mode)) await putWrite(write);
474
987
  },
475
988
  async setDhwTarget(temp) {
476
- await request(`/v2/gateways/${gatewayId}/domesticHotWater/setpoint`, {
477
- method: "PUT",
478
- body: { value: temp }
479
- });
989
+ await putWrite(dhwTargetWrite(await requireSnapshot(), temp));
480
990
  },
481
991
  async setDhwMode(mode) {
482
- await request(`/v2/gateways/${gatewayId}/domesticHotWater/operationMode`, {
483
- method: "PUT",
484
- body: { value: mode }
485
- });
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));
486
1008
  }
487
1009
  };
488
1010
  }
@@ -496,10 +1018,17 @@ function regionOf(value) {
496
1018
  for (const region of HOMECOM_REGIONS) if (value === region) return region;
497
1019
  return "eu";
498
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
+ }
499
1028
  function choiceOf(gateway) {
500
1029
  return {
501
1030
  value: gateway.gatewayId,
502
- label: gateway.name,
1031
+ label: choiceLabel(gateway),
503
1032
  brand: brandOf(gateway.brand),
504
1033
  region: regionOf(gateway.region)
505
1034
  };
@@ -626,11 +1155,12 @@ function publishHomeComSignIn(store, nowMs) {
626
1155
  };
627
1156
  }
628
1157
  async function listGatewaysDefault(tokens, fetchFn) {
629
- const res = await fetchFn(`${HOMECOM_POINTT_API_ORIGIN}/v2/gateways`, { headers: {
1158
+ const res = await fetchFn(`${HOMECOM_POINTT_API_ORIGIN}${HOMECOM_GATEWAYS_PATH}`, { headers: {
630
1159
  Authorization: `Bearer ${tokens.token}`,
631
1160
  Accept: "application/json"
632
1161
  } });
633
- if (!res.ok) return [];
1162
+ if (res.status === 401) throw new HomeComGatewayListError(res.status);
1163
+ if (!res.ok) throw new HomeComGatewayListError(res.status);
634
1164
  return parseHomeComGatewayList(await res.json());
635
1165
  }
636
1166
  async function finishHomeComSignIn(input, store, deps = {}) {
@@ -643,25 +1173,25 @@ async function finishHomeComSignIn(input, store, deps = {}) {
643
1173
  state: taken.attempt.state
644
1174
  }, fetchFn);
645
1175
  let gateways = [];
646
- let listFailed = false;
1176
+ let listFailure = null;
647
1177
  try {
648
1178
  gateways = await (deps.listGateways ?? ((next) => listGatewaysDefault(next, fetchFn)))(tokens);
649
- } catch {
650
- listFailed = true;
1179
+ } catch (err) {
1180
+ listFailure = err instanceof HomeComGatewayListError ? err.message : "The gateway list failed.";
651
1181
  gateways = [];
652
1182
  }
653
- return finishedFromGateways(tokens, resolveHomeComGateway(gateways), listFailed);
1183
+ return finishedFromGateways(tokens, resolveHomeComGateway(gateways), listFailure);
654
1184
  }
655
- function finishedFromGateways(tokens, resolved, listFailed) {
1185
+ function finishedFromGateways(tokens, resolved, listFailure) {
656
1186
  const who = tokens.username.length > 0 ? `Signed in as ${tokens.username}` : "Signed in";
657
1187
  const base = {
658
1188
  ...tokens,
659
1189
  accountLabel: tokens.username
660
1190
  };
661
- if (listFailed) return {
1191
+ if (listFailure !== null) return {
662
1192
  ...base,
663
1193
  gatewayCount: 0,
664
- summary: `${who}. The gateway list could not be read. Enter the gateway id under Advanced.`
1194
+ summary: listFailure
665
1195
  };
666
1196
  if (resolved.kind === "one") return {
667
1197
  ...base,
@@ -680,7 +1210,7 @@ function finishedFromGateways(tokens, resolved, listFailed) {
680
1210
  return {
681
1211
  ...base,
682
1212
  gatewayCount: 0,
683
- summary: `${who}. No gateway was listed. Enter the gateway id under Advanced.`
1213
+ summary: "This Bosch account has no HomeCom gateway."
684
1214
  };
685
1215
  }
686
1216
  var homeComSignInAttempts = require_account_integration_offline.createSignInAttemptStore();
@@ -1003,7 +1533,9 @@ var HomeComIntegrationManager = class {
1003
1533
  };
1004
1534
  this.#makeClient = options.makeClient ?? ((config, gatewayId) => createHomeComApiClient(config, gatewayId, {
1005
1535
  fetchFn,
1006
- onTokensRotated
1536
+ onTokensRotated,
1537
+ logger: this.#logger,
1538
+ deviceType: this.#gateways.find((gateway) => gateway.gatewayId === gatewayId)?.deviceType
1007
1539
  }));
1008
1540
  this.#pollIntervalMs = options.pollIntervalMs ?? 6e4;
1009
1541
  }
@@ -1035,7 +1567,7 @@ var HomeComIntegrationManager = class {
1035
1567
  this.#gatewayId = gatewayId;
1036
1568
  client = this.#makeClient(this.#connection, gatewayId);
1037
1569
  this.#client = client;
1038
- const snapshot = await this.fetchWithTokenRefresh(client);
1570
+ const snapshot = await client.fetchSystem();
1039
1571
  const surface = {
1040
1572
  gatewayId: this.#gatewayId ?? "",
1041
1573
  client,
@@ -1079,7 +1611,7 @@ var HomeComIntegrationManager = class {
1079
1611
  const client = this.#client;
1080
1612
  if (!client) return;
1081
1613
  try {
1082
- const snapshot = await this.fetchWithTokenRefresh(client);
1614
+ const snapshot = await client.fetchSystem();
1083
1615
  this.#surfaceSink.updateSnapshot(this.#id, snapshot);
1084
1616
  this.#lastCheckedAt = Date.now();
1085
1617
  } catch (err) {
@@ -1089,18 +1621,6 @@ var HomeComIntegrationManager = class {
1089
1621
  } });
1090
1622
  }
1091
1623
  }
1092
- async fetchWithTokenRefresh(client) {
1093
- return this.withTokenRefresh(client, () => client.fetchSystem());
1094
- }
1095
- async withTokenRefresh(client, call) {
1096
- try {
1097
- return await call();
1098
- } catch (err) {
1099
- if (!(err instanceof HomeComAuthError)) throw err;
1100
- await client.refreshTokens();
1101
- return call();
1102
- }
1103
- }
1104
1624
  /**
1105
1625
  * The gateway to poll: the stored choice when the account still has it, otherwise the
1106
1626
  * account's first gateway, which is then stored. A stored choice that vanished is an error —
@@ -1108,7 +1628,7 @@ var HomeComIntegrationManager = class {
1108
1628
  */
1109
1629
  async resolveGatewayId() {
1110
1630
  const lister = this.#makeClient(this.#connection, "");
1111
- this.#gateways = await this.withTokenRefresh(lister, () => lister.listGateways());
1631
+ this.#gateways = await lister.listGateways();
1112
1632
  const stored = this.#connection.gatewayId;
1113
1633
  if (stored !== void 0) {
1114
1634
  if (!this.#gateways.some((g) => g.gatewayId === stored)) throw new Error(`HomeCom gateway ${stored} is no longer on this account`);
@@ -1243,17 +1763,28 @@ var HomeComClientRegistry = class {
1243
1763
  };
1244
1764
  //#endregion
1245
1765
  //#region src/homecom/homecom-connection-test.ts
1246
- var TEST_LABEL = "Lists the account gateways and reads the heat-pump system snapshot";
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
+ }
1247
1778
  function buildHomeComConnectionTestProvider(deps) {
1248
1779
  const { logger } = deps;
1249
- const makeClient = deps.makeClient ?? ((config, gatewayId) => createHomeComApiClient(config, gatewayId));
1780
+ const makeClient = deps.makeClient ?? ((config, gatewayId) => createHomeComApiClient(config, gatewayId, { refreshOnUnauthorized: false }));
1250
1781
  return {
1251
1782
  describeTest: async () => ({ label: TEST_LABEL }),
1252
1783
  testSettings: async ({ settings }) => {
1253
1784
  const parsed = homeComConfigSchema.safeParse(settings);
1254
1785
  if (!parsed.success) return {
1255
1786
  outcome: "rejected",
1256
- error: `HomeCom settings incomplete: ${parsed.error.issues.map((i) => i.path.join(".") || "(root)").join(", ")}`
1787
+ error: signInIncomplete(parsed.error.issues.map((issue) => issue.path.join(".") || "(root)"))
1257
1788
  };
1258
1789
  const startedAt = Date.now();
1259
1790
  try {
@@ -1261,18 +1792,30 @@ function buildHomeComConnectionTestProvider(deps) {
1261
1792
  const gatewayId = parsed.data.gatewayId ?? gateways[0]?.gatewayId;
1262
1793
  if (gatewayId === void 0) return {
1263
1794
  outcome: "rejected",
1264
- error: "This HomeCom account has no gateway."
1795
+ error: NO_GATEWAY
1265
1796
  };
1266
1797
  if (!gateways.some((g) => g.gatewayId === gatewayId)) return {
1267
1798
  outcome: "rejected",
1268
1799
  error: `Gateway ${gatewayId} is not on this account.`
1269
1800
  };
1270
- await makeClient(parsed.data, gatewayId).fetchSystem();
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
+ };
1271
1808
  return {
1272
1809
  outcome: "validated",
1273
- latencyMs: Date.now() - startedAt
1810
+ latencyMs: Date.now() - startedAt,
1811
+ detail: `Gateway ${gatewayId} (${listed?.deviceType}) answered ${probe.resource}.`
1274
1812
  };
1275
1813
  } catch (err) {
1814
+ const listed = listFailureMessage(err);
1815
+ if (listed !== null) return {
1816
+ outcome: "rejected",
1817
+ error: listed
1818
+ };
1276
1819
  if (err instanceof HomeComAuthError) return {
1277
1820
  outcome: "rejected",
1278
1821
  error: "HomeCom refused the access token — sign in again to get a fresh one."
@@ -1361,8 +1904,9 @@ var HomeComClimateDevice = class extends require_dist.BaseDevice {
1361
1904
  await this.requireClient().setCircuitMode(this.#circuitId, apiMode);
1362
1905
  this.refreshFromSnapshot();
1363
1906
  },
1364
- setFanMode: async () => {
1365
- throw new Error("HomeCom climate: fan mode not supported");
1907
+ setFanMode: async ({ fanMode }) => {
1908
+ await this.requireClient().setFanMode(this.#circuitId, fanMode);
1909
+ this.refreshFromSnapshot();
1366
1910
  },
1367
1911
  setPreset: async () => {
1368
1912
  throw new Error("HomeCom climate: presets not supported");
@@ -1377,11 +1921,13 @@ var HomeComClimateDevice = class extends require_dist.BaseDevice {
1377
1921
  setTargetHumidity: async () => {
1378
1922
  throw new Error("HomeCom climate: humidity not supported");
1379
1923
  },
1380
- setSwingVertical: async () => {
1381
- throw new Error("HomeCom climate: swing not supported");
1924
+ setSwingVertical: async ({ on }) => {
1925
+ await this.requireClient().setSwingVertical(this.#circuitId, on);
1926
+ this.refreshFromSnapshot();
1382
1927
  },
1383
- setSwingHorizontal: async () => {
1384
- throw new Error("HomeCom climate: swing not supported");
1928
+ setSwingHorizontal: async ({ on }) => {
1929
+ await this.requireClient().setSwingHorizontal(this.#circuitId, on);
1930
+ this.refreshFromSnapshot();
1385
1931
  }
1386
1932
  });
1387
1933
  }