@camstack/addon-provider-bosch 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
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-Cbcvh8mB.mjs";
2
- import { a as parseOAuthCallback, i as loginFailureForAttempt, n as cascadeRemoveDevicesForIntegration, r as createSignInAttemptStore, t as setIntegrationDevicesOnline } from "../account-integration-offline-iGgTPuq_.mjs";
2
+ import { a as parseOAuthCallback, i as loginFailureForAttempt, n as cascadeRemoveDevicesForIntegration, r as createSignInAttemptStore, t as setIntegrationDevicesOnline } from "../account-integration-offline-DKaazSyb.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
- /** The gateway this integration controls; chosen once from the account's gateways. */
17
- gatewayId: string().min(1).optional(),
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. Bosch opens its app address; paste that address here.",
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: mapHvacMode(circuit.operationMode),
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,92 +384,364 @@ function waterHeaterSliceFromDhw(dhw, nowMs) {
264
384
  lastChangedAt: nowMs
265
385
  };
266
386
  }
267
- /** Parse K40-style gateway JSON into heating circuits + DHW. Pure. */
268
- function parseHomeComSystemPayload(payload) {
269
- if (typeof payload !== "object" || payload === null) return {
270
- circuits: [],
271
- dhw: null
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"])
409
+ };
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"
272
485
  };
273
- const root = payload;
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 rawCircuits = root["heatingCircuits"];
276
- if (Array.isArray(rawCircuits)) for (const item of rawCircuits) {
277
- if (typeof item !== "object" || item === null) continue;
278
- const row = item;
279
- const id = row["id"];
280
- if (typeof id !== "string") continue;
281
- circuits.push({
282
- id,
283
- name: typeof row["name"] === "string" ? row["name"] : id,
284
- operationMode: typeof row["operationMode"] === "string" ? row["operationMode"] : "off",
285
- setpoint: readNum(row["setpoint"]),
286
- currentRoomTemp: readNum(row["currentRoomTemperature"]),
287
- minTemp: readNum(row["minTemp"]),
288
- maxTemp: readNum(row["maxTemp"])
289
- });
290
- }
291
- let dhw = null;
292
- const rawDhw = root["domesticHotWater"];
293
- if (typeof rawDhw === "object" && rawDhw !== null) {
294
- const row = rawDhw;
295
- const modes = [];
296
- if (Array.isArray(row["operationModes"])) {
297
- for (const m of row["operationModes"]) if (typeof m === "string") modes.push(m);
298
- }
299
- dhw = {
300
- currentTemp: readNum(row["currentTemperature"]),
301
- targetTemp: readNum(row["setpoint"]),
302
- operationMode: typeof row["operationMode"] === "string" ? row["operationMode"] : null,
303
- availableModes: modes.length > 0 ? modes : [
304
- "off",
305
- "eco",
306
- "performance"
307
- ],
308
- minTemp: readNum(row["minTemp"]),
309
- maxTemp: readNum(row["maxTemp"])
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);
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 ?? [];
312
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 readNum(raw) {
318
- if (typeof raw === "number" && Number.isFinite(raw)) return raw;
319
- return null;
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 / PointT mobile app, signed in through SingleKey ID.
325
- *
326
- * These are the PUBLIC values the app itself ships (a PKCE client has no secret). They are not
327
- * derivable from this repository; they come from the community PointT client (`homecom_alt`) and
328
- * have to be confirmed against a live account — see docs/addons/addon-provider-bosch-homecom.md.
329
- */
330
- var HOMECOM_CLIENT_ID = "762162C0-FA2D-4540-AE66-6489F189FADC";
331
- var HOMECOM_REDIRECT_URI = "com.bosch.tt.dashtt.pointt://app/login";
332
- var HOMECOM_AUTHORIZE_URL = "https://singlekey-id.com/auth/connect/authorize";
333
- var HOMECOM_TOKEN_URL = "https://singlekey-id.com/auth/connect/token";
334
- var HOMECOM_SCOPE = [
335
- "openid",
336
- "email",
337
- "profile",
338
- "offline_access",
339
- "pointt.gateway.claiming",
340
- "pointt.gateway.removal",
341
- "pointt.gateway.list",
342
- "pointt.gateway.users",
343
- "pointt.gateway.resource.dopair",
344
- "pointt.castt.flow.token-exchange",
345
- "bacon",
346
- "hcc.tariff.read"
347
- ].join(" ");
348
731
  //#endregion
349
732
  //#region src/homecom/homecom-api-client.ts
350
733
  var HomeComAuthError = class extends Error {
351
734
  name = "HomeComAuthError";
352
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
+ };
353
745
  function isRecord$1(value) {
354
746
  return typeof value === "object" && value !== null && !Array.isArray(value);
355
747
  }
@@ -365,6 +757,13 @@ function rowId(row) {
365
757
  }
366
758
  return null;
367
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
+ }
368
767
  /** Gateways from the account listing: a bare array or `{ gateways: [...] }`. */
369
768
  function parseHomeComGatewayList(payload) {
370
769
  const rows = isRecord$1(payload) ? payload["gateways"] : payload;
@@ -374,110 +773,237 @@ function parseHomeComGatewayList(payload) {
374
773
  if (!isRecord$1(row)) continue;
375
774
  const gatewayId = rowId(row);
376
775
  if (gatewayId === null) continue;
377
- const name = row["name"];
378
- const brand = row["brand"];
379
- const region = row["region"];
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"]);
380
786
  out.push({
381
787
  gatewayId,
382
- name: typeof name === "string" && name.length > 0 ? name : gatewayId,
383
- ...typeof brand === "string" && brand.length > 0 ? { brand } : {},
384
- ...typeof region === "string" && region.length > 0 ? { region } : {}
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 } : {}
385
794
  });
386
795
  }
387
796
  return out;
388
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
+ }
389
802
  function createHomeComApiClient(config, gatewayId, deps = {}) {
390
803
  const fetchFn = deps.fetchFn ?? globalThis.fetch.bind(globalThis);
804
+ const refreshOnUnauthorized = deps.refreshOnUnauthorized ?? true;
805
+ const logger = deps.logger;
391
806
  let token = config.token;
392
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();
393
812
  const base = homeComApiBase(config.region);
394
- async function authHeaders() {
813
+ function authHeaders() {
395
814
  return {
396
815
  Authorization: `Bearer ${token}`,
397
816
  Accept: "application/json",
398
817
  "Content-Type": "application/json"
399
818
  };
400
819
  }
401
- async function request(path, init = {}) {
402
- const res = await fetchFn(`${base}${path}`, {
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, {
403
850
  method: init.method ?? "GET",
404
- headers: await authHeaders(),
405
- body: init.body !== void 0 ? JSON.stringify(init.body) : void 0
851
+ headers: authHeaders(),
852
+ body: init.body
406
853
  });
407
- if (res.status === 401) throw new HomeComAuthError("token expired");
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
+ }
408
877
  if (!res.ok) throw new Error(`HomeCom API ${path} failed: HTTP ${res.status}`);
409
- return res.json();
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()
917
+ });
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;
961
+ if (!res.ok) throw new Error(`HomeCom API ${path} failed: HTTP ${res.status}`);
962
+ return probeText(res.body);
410
963
  }
411
964
  return {
412
- async refreshTokens() {
413
- const res = await fetchFn(HOMECOM_TOKEN_URL, {
414
- method: "POST",
415
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
416
- body: new URLSearchParams({
417
- grant_type: "refresh_token",
418
- refresh_token: refresh,
419
- client_id: HOMECOM_CLIENT_ID
420
- }).toString()
421
- });
422
- if (!res.ok) {
423
- if (res.status === 401 || res.status === 403) throw new HomeComAuthError("refresh refused");
424
- throw new Error(`HomeCom token refresh failed: HTTP ${res.status}`);
425
- }
426
- const body = await res.json();
427
- if (typeof body !== "object" || body === null) throw new Error("HomeCom token refresh: bad body");
428
- const row = body;
429
- const newToken = row["access_token"];
430
- const newRefresh = row["refresh_token"];
431
- if (typeof newToken !== "string" || typeof newRefresh !== "string") throw new Error("HomeCom token refresh: missing tokens");
432
- token = newToken;
433
- refresh = newRefresh;
434
- await deps.onTokensRotated?.({
435
- token,
436
- refresh
437
- });
438
- return {
439
- token,
440
- 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
441
973
  };
442
- },
443
- async listGateways() {
444
- return parseHomeComGatewayList(await request("/v2/gateways"));
445
- },
446
- async fetchSystem() {
447
- const parsed = parseHomeComSystemPayload(await request(`/v2/gateways/${gatewayId}/system`));
448
- return {
449
- gateway: {
450
- gatewayId,
451
- name: "HomeCom heat pump",
452
- model: "K40"
453
- },
454
- circuits: parsed.circuits,
455
- dhw: parsed.dhw
974
+ const firmware = await probePath(HOMECOM_PROBE_FIRMWARE);
975
+ if (firmware !== null) return {
976
+ resource: HOMECOM_PROBE_FIRMWARE,
977
+ value: firmware
456
978
  };
979
+ throw new Error(`HomeCom gateway ${gatewayId} did not answer /gateway/DateTime or /gateway/versionFirmware.`);
457
980
  },
458
981
  async setCircuitSetpoint(circuitId, temp) {
459
- await request(`/v2/gateways/${gatewayId}/heatingCircuits/${circuitId}/setpoint`, {
460
- method: "PUT",
461
- body: { value: temp }
462
- });
982
+ await putWrite(circuitSetpointWrite(await requireSnapshot(), circuitId, temp));
463
983
  },
464
984
  async setCircuitMode(circuitId, mode) {
465
- await request(`/v2/gateways/${gatewayId}/heatingCircuits/${circuitId}/operationMode`, {
466
- method: "PUT",
467
- body: { value: mode }
468
- });
985
+ for (const write of circuitModeWrites(await requireSnapshot(), circuitId, mode)) await putWrite(write);
469
986
  },
470
987
  async setDhwTarget(temp) {
471
- await request(`/v2/gateways/${gatewayId}/domesticHotWater/setpoint`, {
472
- method: "PUT",
473
- body: { value: temp }
474
- });
988
+ await putWrite(dhwTargetWrite(await requireSnapshot(), temp));
475
989
  },
476
990
  async setDhwMode(mode) {
477
- await request(`/v2/gateways/${gatewayId}/domesticHotWater/operationMode`, {
478
- method: "PUT",
479
- body: { value: mode }
480
- });
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));
481
1007
  }
482
1008
  };
483
1009
  }
@@ -491,10 +1017,17 @@ function regionOf(value) {
491
1017
  for (const region of HOMECOM_REGIONS) if (value === region) return region;
492
1018
  return "eu";
493
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
+ }
494
1027
  function choiceOf(gateway) {
495
1028
  return {
496
1029
  value: gateway.gatewayId,
497
- label: gateway.name,
1030
+ label: choiceLabel(gateway),
498
1031
  brand: brandOf(gateway.brand),
499
1032
  region: regionOf(gateway.region)
500
1033
  };
@@ -556,6 +1089,7 @@ function beginHomeComSignIn() {
556
1089
  url.searchParams.set("state", state);
557
1090
  url.searchParams.set("nonce", b64url(randomBytes(16)));
558
1091
  url.searchParams.set("prompt", "login");
1092
+ url.searchParams.set("style_id", HOMECOM_STYLE_ID);
559
1093
  return {
560
1094
  authorizeUrl: url.toString(),
561
1095
  verifier,
@@ -620,11 +1154,12 @@ function publishHomeComSignIn(store, nowMs) {
620
1154
  };
621
1155
  }
622
1156
  async function listGatewaysDefault(tokens, fetchFn) {
623
- const res = await fetchFn(`${HOMECOM_POINTT_API_ORIGIN}/v2/gateways`, { headers: {
1157
+ const res = await fetchFn(`${HOMECOM_POINTT_API_ORIGIN}${HOMECOM_GATEWAYS_PATH}`, { headers: {
624
1158
  Authorization: `Bearer ${tokens.token}`,
625
1159
  Accept: "application/json"
626
1160
  } });
627
- if (!res.ok) return [];
1161
+ if (res.status === 401) throw new HomeComGatewayListError(res.status);
1162
+ if (!res.ok) throw new HomeComGatewayListError(res.status);
628
1163
  return parseHomeComGatewayList(await res.json());
629
1164
  }
630
1165
  async function finishHomeComSignIn(input, store, deps = {}) {
@@ -637,25 +1172,25 @@ async function finishHomeComSignIn(input, store, deps = {}) {
637
1172
  state: taken.attempt.state
638
1173
  }, fetchFn);
639
1174
  let gateways = [];
640
- let listFailed = false;
1175
+ let listFailure = null;
641
1176
  try {
642
1177
  gateways = await (deps.listGateways ?? ((next) => listGatewaysDefault(next, fetchFn)))(tokens);
643
- } catch {
644
- listFailed = true;
1178
+ } catch (err) {
1179
+ listFailure = err instanceof HomeComGatewayListError ? err.message : "The gateway list failed.";
645
1180
  gateways = [];
646
1181
  }
647
- return finishedFromGateways(tokens, resolveHomeComGateway(gateways), listFailed);
1182
+ return finishedFromGateways(tokens, resolveHomeComGateway(gateways), listFailure);
648
1183
  }
649
- function finishedFromGateways(tokens, resolved, listFailed) {
1184
+ function finishedFromGateways(tokens, resolved, listFailure) {
650
1185
  const who = tokens.username.length > 0 ? `Signed in as ${tokens.username}` : "Signed in";
651
1186
  const base = {
652
1187
  ...tokens,
653
1188
  accountLabel: tokens.username
654
1189
  };
655
- if (listFailed) return {
1190
+ if (listFailure !== null) return {
656
1191
  ...base,
657
1192
  gatewayCount: 0,
658
- summary: `${who}. The gateway list could not be read. Enter the gateway id under Advanced.`
1193
+ summary: listFailure
659
1194
  };
660
1195
  if (resolved.kind === "one") return {
661
1196
  ...base,
@@ -674,7 +1209,7 @@ function finishedFromGateways(tokens, resolved, listFailed) {
674
1209
  return {
675
1210
  ...base,
676
1211
  gatewayCount: 0,
677
- summary: `${who}. No gateway was listed. Enter the gateway id under Advanced.`
1212
+ summary: "This Bosch account has no HomeCom gateway."
678
1213
  };
679
1214
  }
680
1215
  var homeComSignInAttempts = createSignInAttemptStore();
@@ -997,7 +1532,9 @@ var HomeComIntegrationManager = class {
997
1532
  };
998
1533
  this.#makeClient = options.makeClient ?? ((config, gatewayId) => createHomeComApiClient(config, gatewayId, {
999
1534
  fetchFn,
1000
- onTokensRotated
1535
+ onTokensRotated,
1536
+ logger: this.#logger,
1537
+ deviceType: this.#gateways.find((gateway) => gateway.gatewayId === gatewayId)?.deviceType
1001
1538
  }));
1002
1539
  this.#pollIntervalMs = options.pollIntervalMs ?? 6e4;
1003
1540
  }
@@ -1029,7 +1566,7 @@ var HomeComIntegrationManager = class {
1029
1566
  this.#gatewayId = gatewayId;
1030
1567
  client = this.#makeClient(this.#connection, gatewayId);
1031
1568
  this.#client = client;
1032
- const snapshot = await this.fetchWithTokenRefresh(client);
1569
+ const snapshot = await client.fetchSystem();
1033
1570
  const surface = {
1034
1571
  gatewayId: this.#gatewayId ?? "",
1035
1572
  client,
@@ -1073,7 +1610,7 @@ var HomeComIntegrationManager = class {
1073
1610
  const client = this.#client;
1074
1611
  if (!client) return;
1075
1612
  try {
1076
- const snapshot = await this.fetchWithTokenRefresh(client);
1613
+ const snapshot = await client.fetchSystem();
1077
1614
  this.#surfaceSink.updateSnapshot(this.#id, snapshot);
1078
1615
  this.#lastCheckedAt = Date.now();
1079
1616
  } catch (err) {
@@ -1083,18 +1620,6 @@ var HomeComIntegrationManager = class {
1083
1620
  } });
1084
1621
  }
1085
1622
  }
1086
- async fetchWithTokenRefresh(client) {
1087
- return this.withTokenRefresh(client, () => client.fetchSystem());
1088
- }
1089
- async withTokenRefresh(client, call) {
1090
- try {
1091
- return await call();
1092
- } catch (err) {
1093
- if (!(err instanceof HomeComAuthError)) throw err;
1094
- await client.refreshTokens();
1095
- return call();
1096
- }
1097
- }
1098
1623
  /**
1099
1624
  * The gateway to poll: the stored choice when the account still has it, otherwise the
1100
1625
  * account's first gateway, which is then stored. A stored choice that vanished is an error —
@@ -1102,7 +1627,7 @@ var HomeComIntegrationManager = class {
1102
1627
  */
1103
1628
  async resolveGatewayId() {
1104
1629
  const lister = this.#makeClient(this.#connection, "");
1105
- this.#gateways = await this.withTokenRefresh(lister, () => lister.listGateways());
1630
+ this.#gateways = await lister.listGateways();
1106
1631
  const stored = this.#connection.gatewayId;
1107
1632
  if (stored !== void 0) {
1108
1633
  if (!this.#gateways.some((g) => g.gatewayId === stored)) throw new Error(`HomeCom gateway ${stored} is no longer on this account`);
@@ -1237,17 +1762,28 @@ var HomeComClientRegistry = class {
1237
1762
  };
1238
1763
  //#endregion
1239
1764
  //#region src/homecom/homecom-connection-test.ts
1240
- var TEST_LABEL = "Lists the account gateways and reads the heat-pump system snapshot";
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
+ }
1241
1777
  function buildHomeComConnectionTestProvider(deps) {
1242
1778
  const { logger } = deps;
1243
- const makeClient = deps.makeClient ?? ((config, gatewayId) => createHomeComApiClient(config, gatewayId));
1779
+ const makeClient = deps.makeClient ?? ((config, gatewayId) => createHomeComApiClient(config, gatewayId, { refreshOnUnauthorized: false }));
1244
1780
  return {
1245
1781
  describeTest: async () => ({ label: TEST_LABEL }),
1246
1782
  testSettings: async ({ settings }) => {
1247
1783
  const parsed = homeComConfigSchema.safeParse(settings);
1248
1784
  if (!parsed.success) return {
1249
1785
  outcome: "rejected",
1250
- error: `HomeCom settings incomplete: ${parsed.error.issues.map((i) => i.path.join(".") || "(root)").join(", ")}`
1786
+ error: signInIncomplete(parsed.error.issues.map((issue) => issue.path.join(".") || "(root)"))
1251
1787
  };
1252
1788
  const startedAt = Date.now();
1253
1789
  try {
@@ -1255,18 +1791,30 @@ function buildHomeComConnectionTestProvider(deps) {
1255
1791
  const gatewayId = parsed.data.gatewayId ?? gateways[0]?.gatewayId;
1256
1792
  if (gatewayId === void 0) return {
1257
1793
  outcome: "rejected",
1258
- error: "This HomeCom account has no gateway."
1794
+ error: NO_GATEWAY
1259
1795
  };
1260
1796
  if (!gateways.some((g) => g.gatewayId === gatewayId)) return {
1261
1797
  outcome: "rejected",
1262
1798
  error: `Gateway ${gatewayId} is not on this account.`
1263
1799
  };
1264
- await makeClient(parsed.data, gatewayId).fetchSystem();
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
+ };
1265
1807
  return {
1266
1808
  outcome: "validated",
1267
- latencyMs: Date.now() - startedAt
1809
+ latencyMs: Date.now() - startedAt,
1810
+ detail: `Gateway ${gatewayId} (${listed?.deviceType}) answered ${probe.resource}.`
1268
1811
  };
1269
1812
  } catch (err) {
1813
+ const listed = listFailureMessage(err);
1814
+ if (listed !== null) return {
1815
+ outcome: "rejected",
1816
+ error: listed
1817
+ };
1270
1818
  if (err instanceof HomeComAuthError) return {
1271
1819
  outcome: "rejected",
1272
1820
  error: "HomeCom refused the access token — sign in again to get a fresh one."
@@ -1355,8 +1903,9 @@ var HomeComClimateDevice = class extends BaseDevice {
1355
1903
  await this.requireClient().setCircuitMode(this.#circuitId, apiMode);
1356
1904
  this.refreshFromSnapshot();
1357
1905
  },
1358
- setFanMode: async () => {
1359
- throw new Error("HomeCom climate: fan mode not supported");
1906
+ setFanMode: async ({ fanMode }) => {
1907
+ await this.requireClient().setFanMode(this.#circuitId, fanMode);
1908
+ this.refreshFromSnapshot();
1360
1909
  },
1361
1910
  setPreset: async () => {
1362
1911
  throw new Error("HomeCom climate: presets not supported");
@@ -1371,11 +1920,13 @@ var HomeComClimateDevice = class extends BaseDevice {
1371
1920
  setTargetHumidity: async () => {
1372
1921
  throw new Error("HomeCom climate: humidity not supported");
1373
1922
  },
1374
- setSwingVertical: async () => {
1375
- throw new Error("HomeCom climate: swing not supported");
1923
+ setSwingVertical: async ({ on }) => {
1924
+ await this.requireClient().setSwingVertical(this.#circuitId, on);
1925
+ this.refreshFromSnapshot();
1376
1926
  },
1377
- setSwingHorizontal: async () => {
1378
- throw new Error("HomeCom climate: swing not supported");
1927
+ setSwingHorizontal: async ({ on }) => {
1928
+ await this.requireClient().setSwingHorizontal(this.#circuitId, on);
1929
+ this.refreshFromSnapshot();
1379
1930
  }
1380
1931
  });
1381
1932
  }