@camstack/addon-provider-bosch 0.1.48 → 0.1.50

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,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_bosch_instances = require("../bosch-instances-CRvxRbDk.js");
3
- const require_account_integration_offline = require("../account-integration-offline-CQ7UB6U_.js");
2
+ const require_bosch_instances = require("../bosch-instances-CYBnIcHg.js");
3
+ const require_account_integration_offline = require("../account-integration-offline-7CH3r5jI.js");
4
4
  let node_crypto = require("node:crypto");
5
5
  let node_tls = require("node:tls");
6
6
  let node_zlib = require("node:zlib");
@@ -1,5 +1,5 @@
1
- import { A as RecordIntegrationInstances, B as array, C as powerMeterCapability, E as runningStateCapability, F as entryBody, G as string, I as isSecretMarker, J as DeviceRole, K as union, L as isSecretMarkerLike, M as customAction, N as defineCustomActions, P as defineEntryKind, R as rowsDepsOver, S as numericSensorCapability, T as runningFromDishwasherOperation, U as object, V as literal, W as record, X as errMsg, Y as DeviceType, Z as EventCategory, a as BaseDevice, b as ingestReading, c as RUNNING_STATE_COLD, f as consumablesCapability, g as eventEmitterCapability, h as enumSensorCapability, i as testBoschInstance, j as createEvent, m as dishwasherCapability, n as boschRowsPorts, o as BaseDeviceProvider, p as deviceAdoptionCapability, q as unknown, r as keepBodyKeys, s as DishwasherOperationStateSchema, t as boschRowsMigration, u as binaryCapability, w as quantityFromUnit, x as integrationInstancesCapability, z as _enum } from "../bosch-instances-t9x1fGVW.mjs";
2
- import { a as loginFailureForAttempt, c as HOME_CONNECT_REDIRECT_URI, i as createSignInAttemptStore, n as cascadeRemoveDevicesForIntegration, o as parseOAuthCallback, r as buildSignInGuideFields, t as setIntegrationDevicesOnline } from "../account-integration-offline-DLCUxCxS.mjs";
1
+ import { $ as EventCategory, A as RecordIntegrationInstances, C as powerMeterCapability, E as runningStateCapability, F as entryBody, G as object, I as isSecretMarker, J as union, K as record, L as isSecretMarkerLike, M as customAction, N as defineCustomActions, P as defineEntryKind, Q as errMsg, R as rowsDepsOver, S as numericSensorCapability, T as runningFromDishwasherOperation, U as literal, V as array, X as DeviceRole, Y as unknown, Z as DeviceType, a as BaseDevice, b as ingestReading, c as RUNNING_STATE_COLD, f as consumablesCapability, g as eventEmitterCapability, h as enumSensorCapability, i as testBoschInstance, j as createEvent, m as dishwasherCapability, n as boschRowsPorts, o as BaseDeviceProvider, p as deviceAdoptionCapability, q as string, r as keepBodyKeys, s as DishwasherOperationStateSchema, t as boschRowsMigration, u as binaryCapability, w as quantityFromUnit, x as integrationInstancesCapability, z as _enum } from "../bosch-instances-CdvcBYye.mjs";
2
+ import { a as loginFailureForAttempt, c as HOME_CONNECT_REDIRECT_URI, i as createSignInAttemptStore, n as cascadeRemoveDevicesForIntegration, o as parseOAuthCallback, r as buildSignInGuideFields, t as setIntegrationDevicesOnline } from "../account-integration-offline-B1Wgr_rT.mjs";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { connect } from "node:tls";
5
5
  import { inflateRawSync } from "node:zlib";
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_bosch_instances = require("../bosch-instances-CRvxRbDk.js");
3
- const require_account_integration_offline = require("../account-integration-offline-CQ7UB6U_.js");
2
+ const require_bosch_instances = require("../bosch-instances-CYBnIcHg.js");
3
+ const require_account_integration_offline = require("../account-integration-offline-7CH3r5jI.js");
4
4
  let node_crypto = require("node:crypto");
5
5
  //#region src/homecom/config.ts
6
6
  var HOMECOM_BRANDS = ["bosch", "buderus"];
@@ -178,6 +178,280 @@ function buildHomeComFormSchema() {
178
178
  ]
179
179
  }] };
180
180
  }
181
+ var EXPLORER_MAX_NODES_CAP = 1500;
182
+ var VALUE_MAX_CHARS = 200;
183
+ /** Roots walked when `/` does not list its children. */
184
+ var EXPLORER_SEED_ROOTS = [
185
+ "/system",
186
+ "/heatSources",
187
+ "/heatingCircuits",
188
+ "/dhwCircuits",
189
+ "/gateway",
190
+ "/notifications",
191
+ "/recordings",
192
+ "/devices",
193
+ "/solarCircuits",
194
+ "/ventilation"
195
+ ];
196
+ /** Under the 60 s UDS request timeout a forked addon's action answers within. */
197
+ var EXPLORER_MAX_DEADLINE_MS = 5e4;
198
+ var DROPPED_SAMPLE_MAX = 20;
199
+ var UNVISITED_SAMPLE_MAX = 100;
200
+ var PATH_SHAPE = /^\/(?:[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*)?$/;
201
+ /**
202
+ * A resource path this walker will request: absolute, plain segments, and no `.` or `..`
203
+ * segment — URL resolution would otherwise lift the request out of the gateway's tree.
204
+ */
205
+ function isResourcePath(path) {
206
+ if (!PATH_SHAPE.test(path)) return false;
207
+ return path.split("/").slice(1).every((segment) => segment !== "." && segment !== "..");
208
+ }
209
+ var explorerOutcomeSchema = require_bosch_instances._enum([
210
+ "ok",
211
+ "forbidden",
212
+ "not-found",
213
+ "rate-limited",
214
+ "error"
215
+ ]);
216
+ var exploredResourceSchema = require_bosch_instances.object({
217
+ path: require_bosch_instances.string(),
218
+ httpStatus: require_bosch_instances.number(),
219
+ outcome: explorerOutcomeSchema,
220
+ type: require_bosch_instances.string().optional(),
221
+ value: require_bosch_instances.union([
222
+ require_bosch_instances.string(),
223
+ require_bosch_instances.number(),
224
+ require_bosch_instances.boolean(),
225
+ require_bosch_instances._null()
226
+ ]).optional(),
227
+ unit: require_bosch_instances.string().optional(),
228
+ writeable: require_bosch_instances.boolean().optional(),
229
+ recordable: require_bosch_instances.boolean().optional(),
230
+ allowedValues: require_bosch_instances.array(require_bosch_instances.string()).optional(),
231
+ minValue: require_bosch_instances.number().optional(),
232
+ maxValue: require_bosch_instances.number().optional(),
233
+ childCount: require_bosch_instances.number().optional(),
234
+ /** References of this container that were not a walkable path. */
235
+ droppedReferences: require_bosch_instances.number().optional(),
236
+ error: require_bosch_instances.string().optional()
237
+ });
238
+ var explorerSummarySchema = require_bosch_instances.object({
239
+ ok: require_bosch_instances.number(),
240
+ forbidden: require_bosch_instances.number(),
241
+ "not-found": require_bosch_instances.number(),
242
+ "rate-limited": require_bosch_instances.number(),
243
+ error: require_bosch_instances.number()
244
+ });
245
+ var explorerStopReasonSchema = require_bosch_instances._enum([
246
+ "complete",
247
+ "max-nodes",
248
+ "rate-limited",
249
+ "deadline"
250
+ ]);
251
+ function isRecord$3(value) {
252
+ return typeof value === "object" && value !== null && !Array.isArray(value);
253
+ }
254
+ function finite$1(value) {
255
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
256
+ }
257
+ function text$1(value) {
258
+ return typeof value === "string" && value.length > 0 ? value : void 0;
259
+ }
260
+ function truncate(value) {
261
+ return value.length > VALUE_MAX_CHARS ? `${value.slice(0, VALUE_MAX_CHARS - 1)}…` : value;
262
+ }
263
+ function shownValue(value) {
264
+ if (value === null || value === void 0) return null;
265
+ if (typeof value === "number" || typeof value === "boolean") return value;
266
+ if (typeof value === "string") return truncate(value);
267
+ return truncate(JSON.stringify(value));
268
+ }
269
+ function outcomeOf(status) {
270
+ if (status >= 200 && status < 300) return "ok";
271
+ if (status === 403) return "forbidden";
272
+ if (status === 404) return "not-found";
273
+ if (status === 429) return "rate-limited";
274
+ return "error";
275
+ }
276
+ /** What a reference names: its `id`, else the part of its `uri` after `/resource`. */
277
+ function referenceText(ref) {
278
+ if (!isRecord$3(ref)) return null;
279
+ const id = text$1(ref["id"]);
280
+ if (id !== void 0 && id.startsWith("/")) return id;
281
+ const uri = text$1(ref["uri"]);
282
+ if (uri === void 0) return id ?? null;
283
+ const at = uri.indexOf("/resource/");
284
+ if (at < 0) return uri;
285
+ return uri.slice(at + 9);
286
+ }
287
+ /** The walkable resource path a reference names, or null. */
288
+ function referencePath(ref) {
289
+ const path = referenceText(ref);
290
+ return path !== null && isResourcePath(path) ? path : null;
291
+ }
292
+ function referencesOf(body) {
293
+ if (!isRecord$3(body)) return null;
294
+ const refs = body["references"];
295
+ if (!Array.isArray(refs)) return null;
296
+ const paths = [];
297
+ const dropped = [];
298
+ for (const ref of refs) {
299
+ const path = referencePath(ref);
300
+ if (path !== null) paths.push(path);
301
+ else dropped.push(truncate(referenceText(ref) ?? JSON.stringify(ref) ?? "undefined"));
302
+ }
303
+ return {
304
+ total: refs.length,
305
+ paths,
306
+ dropped
307
+ };
308
+ }
309
+ function describeLeaf(path, status, body) {
310
+ if (!isRecord$3(body)) return {
311
+ path,
312
+ httpStatus: status,
313
+ outcome: "ok"
314
+ };
315
+ const allowed = body["allowedValues"];
316
+ const allowedValues = Array.isArray(allowed) ? allowed.map((v) => truncate(String(v))) : void 0;
317
+ const type = text$1(body["type"]);
318
+ const unit = text$1(body["unitOfMeasure"]);
319
+ const minValue = finite$1(body["minValue"]);
320
+ const maxValue = finite$1(body["maxValue"]);
321
+ const writeable = body["writeable"];
322
+ const recordable = body["recordable"];
323
+ return {
324
+ path,
325
+ httpStatus: status,
326
+ outcome: "ok",
327
+ ...type !== void 0 ? { type } : {},
328
+ ..."value" in body ? { value: shownValue(body["value"]) } : {},
329
+ ...unit !== void 0 ? { unit } : {},
330
+ ...typeof writeable === "number" || typeof writeable === "boolean" ? { writeable: Boolean(writeable) } : {},
331
+ ...typeof recordable === "number" || typeof recordable === "boolean" ? { recordable: Boolean(recordable) } : {},
332
+ ...allowedValues !== void 0 ? { allowedValues } : {},
333
+ ...minValue !== void 0 ? { minValue } : {},
334
+ ...maxValue !== void 0 ? { maxValue } : {}
335
+ };
336
+ }
337
+ function emptySummary() {
338
+ return {
339
+ ok: 0,
340
+ forbidden: 0,
341
+ "not-found": 0,
342
+ "rate-limited": 0,
343
+ error: 0
344
+ };
345
+ }
346
+ function clampDeadline(requested) {
347
+ return Math.min(Math.max(Math.floor(requested ?? 45e3), 1), EXPLORER_MAX_DEADLINE_MS);
348
+ }
349
+ function clampNodes(requested) {
350
+ return Math.min(Math.max(Math.floor(requested ?? 400), 1), EXPLORER_MAX_NODES_CAP);
351
+ }
352
+ var defaultSleep = (ms) => new Promise((resolve) => {
353
+ setTimeout(resolve, ms);
354
+ });
355
+ async function exploreHomeComResources(input) {
356
+ const root = input.root ?? "/";
357
+ if (!isResourcePath(root)) throw new Error(`Not a resource path: ${root}`);
358
+ const maxNodes = clampNodes(input.maxNodes);
359
+ const delayMs = Math.max(input.delayMs ?? 150, 150);
360
+ const sleep = input.sleep ?? defaultSleep;
361
+ const now = input.now ?? Date.now;
362
+ const deadlineAt = now() + clampDeadline(input.deadlineMs);
363
+ const queue = [root];
364
+ const queued = new Set([root]);
365
+ const resources = [];
366
+ const summary = emptySummary();
367
+ let calls = 0;
368
+ let stoppedBy = "complete";
369
+ let droppedReferences = 0;
370
+ const droppedSamples = [];
371
+ const enqueue = (paths) => {
372
+ for (const path of paths) {
373
+ if (queued.has(path)) continue;
374
+ queued.add(path);
375
+ queue.push(path);
376
+ }
377
+ };
378
+ while (queue.length > 0) {
379
+ if (calls >= maxNodes) {
380
+ stoppedBy = "max-nodes";
381
+ break;
382
+ }
383
+ if (now() + (calls > 0 ? delayMs : 0) >= deadlineAt) {
384
+ stoppedBy = "deadline";
385
+ break;
386
+ }
387
+ const path = queue.shift();
388
+ if (path === void 0) break;
389
+ if (calls > 0) await sleep(delayMs);
390
+ calls += 1;
391
+ let answer;
392
+ try {
393
+ answer = await input.read(path);
394
+ } catch (err) {
395
+ const error = err instanceof Error ? err.message : String(err);
396
+ resources.push({
397
+ path,
398
+ httpStatus: 0,
399
+ outcome: "error",
400
+ error
401
+ });
402
+ summary.error += 1;
403
+ continue;
404
+ }
405
+ const outcome = outcomeOf(answer.status);
406
+ summary[outcome] += 1;
407
+ if (outcome === "rate-limited") {
408
+ resources.push({
409
+ path,
410
+ httpStatus: answer.status,
411
+ outcome
412
+ });
413
+ stoppedBy = "rate-limited";
414
+ break;
415
+ }
416
+ if (outcome !== "ok") {
417
+ resources.push({
418
+ path,
419
+ httpStatus: answer.status,
420
+ outcome
421
+ });
422
+ if (path === "/" && root === "/") enqueue(EXPLORER_SEED_ROOTS);
423
+ continue;
424
+ }
425
+ const children = referencesOf(answer.body);
426
+ if (children !== null) {
427
+ const type = isRecord$3(answer.body) ? text$1(answer.body["type"]) : void 0;
428
+ resources.push({
429
+ path,
430
+ httpStatus: answer.status,
431
+ outcome,
432
+ type: type ?? "refEnum",
433
+ childCount: children.total,
434
+ ...children.dropped.length > 0 ? { droppedReferences: children.dropped.length } : {}
435
+ });
436
+ droppedReferences += children.dropped.length;
437
+ for (const ref of children.dropped) if (droppedSamples.length < DROPPED_SAMPLE_MAX) droppedSamples.push(`${path} -> ${ref}`);
438
+ enqueue(children.paths);
439
+ continue;
440
+ }
441
+ resources.push(describeLeaf(path, answer.status, answer.body));
442
+ if (path === "/" && root === "/") enqueue(EXPLORER_SEED_ROOTS);
443
+ }
444
+ return {
445
+ root,
446
+ resources,
447
+ summary,
448
+ stoppedBy,
449
+ unvisited: queue.length,
450
+ unvisitedPaths: queue.slice(0, UNVISITED_SAMPLE_MAX),
451
+ droppedReferences,
452
+ droppedSamples
453
+ };
454
+ }
181
455
  //#endregion
182
456
  //#region src/homecom/custom-actions.ts
183
457
  var gatewayChoice = require_bosch_instances.object({
@@ -186,6 +460,27 @@ var gatewayChoice = require_bosch_instances.object({
186
460
  brand: HomeComBrandSchema,
187
461
  region: HomeComRegionSchema
188
462
  });
463
+ var exploreResourcesInput = require_bosch_instances.object({
464
+ /** The HomeCom integration instance; may be omitted when exactly one is connected. */
465
+ integrationId: require_bosch_instances.string().min(1).optional(),
466
+ root: require_bosch_instances.string().refine(isResourcePath, "an absolute resource path such as /heatSources, no . or .. segment").optional(),
467
+ maxNodes: require_bosch_instances.number().int().min(1).max(EXPLORER_MAX_NODES_CAP).optional(),
468
+ /** Wall-clock budget; the walk returns what it has, with `stoppedBy: 'deadline'`. */
469
+ deadlineMs: require_bosch_instances.number().int().min(1e3).max(EXPLORER_MAX_DEADLINE_MS).optional()
470
+ });
471
+ var exploreResourcesOutput = require_bosch_instances.object({
472
+ integrationId: require_bosch_instances.string(),
473
+ /** Null when the integration has no snapshot yet: unknown, not empty. */
474
+ gatewayId: require_bosch_instances.string().nullable(),
475
+ root: require_bosch_instances.string(),
476
+ stoppedBy: explorerStopReasonSchema,
477
+ unvisited: require_bosch_instances.number(),
478
+ unvisitedPaths: require_bosch_instances.array(require_bosch_instances.string()),
479
+ droppedReferences: require_bosch_instances.number(),
480
+ droppedSamples: require_bosch_instances.array(require_bosch_instances.string()),
481
+ summary: explorerSummarySchema,
482
+ resources: require_bosch_instances.array(exploredResourceSchema)
483
+ });
189
484
  var homeComActions = require_bosch_instances.defineCustomActions({
190
485
  beginSignIn: require_bosch_instances.customAction(require_bosch_instances.object({}), require_bosch_instances.object({
191
486
  attemptId: require_bosch_instances.string().min(1),
@@ -219,9 +514,95 @@ var homeComActions = require_bosch_instances.defineCustomActions({
219
514
  }), {
220
515
  kind: "mutation",
221
516
  auth: "admin"
517
+ }),
518
+ /** Read-only walk of the gateway's resource tree, for diagnosing what it serves. */
519
+ exploreResources: require_bosch_instances.customAction(exploreResourcesInput, exploreResourcesOutput, {
520
+ kind: "query",
521
+ auth: "admin"
222
522
  })
223
523
  });
224
524
  //#endregion
525
+ //#region src/homecom/homecom-explore-action.ts
526
+ /** The action refused before it walked anything; `reason` says why. */
527
+ var HomeComExploreRefusedError = class extends Error {
528
+ reason;
529
+ constructor(reason, message) {
530
+ super(message);
531
+ this.name = "HomeComExploreRefusedError";
532
+ this.reason = reason;
533
+ }
534
+ };
535
+ function pickIntegration(input, connected) {
536
+ if (input.integrationId !== void 0) return input.integrationId;
537
+ if (connected.length === 1 && connected[0] !== void 0) return connected[0];
538
+ if (connected.length === 0) throw new HomeComExploreRefusedError("not-connected", "No HomeCom integration is connected.");
539
+ throw new HomeComExploreRefusedError("ambiguous", `Several HomeCom integrations are connected; name one with integrationId (${connected.join(", ")}).`);
540
+ }
541
+ /**
542
+ * The `exploreResources` action: a read-only walk of one integration's gateway. One walk
543
+ * per integration at a time — a second call while one runs is refused, never queued.
544
+ */
545
+ function createHomeComExploreAction(deps) {
546
+ const running = /* @__PURE__ */ new Set();
547
+ return async (input) => {
548
+ const integrationId = pickIntegration(input, deps.listConnected());
549
+ if (running.has(integrationId)) throw new HomeComExploreRefusedError("already-running", `A resource walk is already running for HomeCom integration ${integrationId}.`);
550
+ const client = deps.getClient(integrationId);
551
+ if (client === null) throw new HomeComExploreRefusedError("not-connected", `HomeCom integration ${integrationId} is not connected.`);
552
+ running.add(integrationId);
553
+ try {
554
+ return await walk(input, integrationId, client, deps);
555
+ } finally {
556
+ running.delete(integrationId);
557
+ }
558
+ };
559
+ }
560
+ async function walk(input, integrationId, client, deps) {
561
+ const result = await exploreHomeComResources({
562
+ read: (path) => client.readResource(path),
563
+ ...input.root !== void 0 ? { root: input.root } : {},
564
+ ...input.maxNodes !== void 0 ? { maxNodes: input.maxNodes } : {},
565
+ ...input.deadlineMs !== void 0 ? { deadlineMs: input.deadlineMs } : {},
566
+ ...deps.sleep !== void 0 ? { sleep: deps.sleep } : {},
567
+ ...deps.now !== void 0 ? { now: deps.now } : {}
568
+ });
569
+ const gatewayId = deps.getGatewayId(integrationId);
570
+ if (result.droppedReferences > 0) deps.logger.warn("HomeCom resource walk dropped references that are not a walkable path", {
571
+ tags: { integrationId },
572
+ meta: {
573
+ gatewayId,
574
+ droppedReferences: result.droppedReferences,
575
+ samples: result.droppedSamples
576
+ }
577
+ });
578
+ deps.logger.info("HomeCom resource tree explored", {
579
+ tags: { integrationId },
580
+ meta: {
581
+ gatewayId,
582
+ root: result.root,
583
+ visited: result.resources.length,
584
+ stoppedBy: result.stoppedBy,
585
+ unvisited: result.unvisited,
586
+ droppedReferences: result.droppedReferences,
587
+ ...result.summary
588
+ }
589
+ });
590
+ return {
591
+ integrationId,
592
+ gatewayId,
593
+ ...result
594
+ };
595
+ }
596
+ /** The addon's deps: registered integrations that hold a live client; gateway from the snapshot. */
597
+ function homeComExploreDeps(wiring) {
598
+ return {
599
+ listConnected: () => wiring.registry.list().filter((id) => wiring.connections.getClient(id) !== null),
600
+ getClient: (id) => wiring.connections.getClient(id),
601
+ getGatewayId: (id) => wiring.connections.getSnapshot(id)?.gateway.gatewayId ?? null,
602
+ logger: wiring.logger
603
+ };
604
+ }
605
+ //#endregion
225
606
  //#region src/homecom/homecom-device-type.ts
226
607
  /**
227
608
  * Device types the PointT gateway list reports. The set is the one the MIT
@@ -735,6 +1116,14 @@ var HEAT_PUMP_PATHS = {
735
1116
  pressureBar: "/heatSources/hs1/waterPressure",
736
1117
  starts: "/heatSources/hs1/numberOfStarts"
737
1118
  },
1119
+ /**
1120
+ * Resources only a heat pump's heat source has. A gateway that answers one of them with
1121
+ * anything but 404 (a 403 or a 500 included) has a heat pump it will not let us read.
1122
+ */
1123
+ identity: {
1124
+ heatPumpType: "/heatSources/hs1/heatPumpType",
1125
+ heatPumpPower: "/heatSources/hs1/heatPumpPower"
1126
+ },
738
1127
  circuit: {
739
1128
  operationMode: "operationMode",
740
1129
  heatCoolMode: "heatCoolMode",
@@ -763,6 +1152,7 @@ async function readHeatPump(input) {
763
1152
  const circuits = await Promise.all([...input.circuitIds.hc.map((id) => readHc(get, id)), ...input.circuitIds.dhw.map((id) => readDhwCircuit(get, id))]);
764
1153
  const r = HEAT_PUMP_PATHS.readings;
765
1154
  const s = HEAT_PUMP_PATHS.system;
1155
+ const heatSourceAnswered = await anyAnswers(input.answers, input.onFailure, [HEAT_PUMP_PATHS.identity.heatPumpType, HEAT_PUMP_PATHS.identity.heatPumpPower]);
766
1156
  const [silent, away, additionalHeater, holiday] = await Promise.all([
767
1157
  setting(get, s.silent),
768
1158
  setting(get, s.away),
@@ -785,6 +1175,7 @@ async function readHeatPump(input) {
785
1175
  ]);
786
1176
  return {
787
1177
  circuits,
1178
+ heatSourceAnswered,
788
1179
  silent,
789
1180
  away,
790
1181
  additionalHeater,
@@ -801,6 +1192,17 @@ async function readHeatPump(input) {
801
1192
  starts
802
1193
  };
803
1194
  }
1195
+ async function anyAnswers(answers, onFailure, paths) {
1196
+ if (answers === void 0) return false;
1197
+ return (await Promise.all(paths.map(async (path) => {
1198
+ try {
1199
+ return await answers(path);
1200
+ } catch (error) {
1201
+ onFailure?.(path, error);
1202
+ return false;
1203
+ }
1204
+ }))).some(Boolean);
1205
+ }
804
1206
  /** Any failure of a heat-pump read (HTTP 4xx/5xx, a throw) is "not answered", never an error. */
805
1207
  function safeGet(get, onFailure) {
806
1208
  return async (path) => {
@@ -828,16 +1230,28 @@ var HEAT_PUMP_ONLY_READINGS = [
828
1230
  *
829
1231
  * A system mode counts when the gateway answered it: silent, away, the
830
1232
  * additional heater, or holiday. An empty string is an answer (holiday often
831
- * comes back empty); a path that did not answer is not. HomeCom returns no
832
- * value for silent mode and the auxiliary heater on some heat-pump firmware,
833
- * so away and holiday alone are enough. A heat-pump-only reading counts too
834
- * (thermal power, electrical power, compressor modulation). Supply, return,
835
- * outdoor, pressure, starts and the energy meters do not: a gas boiler's heat
836
- * source answers flow, pressure and starts, and that is not a heat pump.
1233
+ * comes back empty); a path that did not answer is not. A heat-pump-only
1234
+ * reading counts too (thermal power, electrical power, compressor modulation).
1235
+ * So does a circuit whose `heatCoolMode` can be cooling, and a heat source that
1236
+ * answers a heat-pump-only resource with any status but 404: some gateways
1237
+ * refuse (403) or fail (500) the whole system tree and still serve the
1238
+ * circuits. Supply, return, outdoor, pressure, starts and the energy meters do
1239
+ * not count: a gas boiler's heat source answers flow, pressure and starts, and
1240
+ * that is not a heat pump.
837
1241
  */
838
1242
  function hasSystemDevice(reading) {
839
1243
  if (SYSTEM_MODE_KEYS.some((key) => reading[key] !== null)) return true;
840
- return HEAT_PUMP_ONLY_READINGS.some((key) => reading[key] !== null);
1244
+ if (HEAT_PUMP_ONLY_READINGS.some((key) => reading[key] !== null)) return true;
1245
+ if (reading.heatSourceAnswered) return true;
1246
+ return reading.circuits.some((c) => c.kind === "hc" && offersCooling(c.heatCoolMode));
1247
+ }
1248
+ /**
1249
+ * A boiler circuit can carry a `heatCoolMode` that only ever says heating, so the mode alone
1250
+ * is no proof. A circuit that can switch to (or already is in) cooling is a heat pump's.
1251
+ */
1252
+ function offersCooling(setting) {
1253
+ if (setting === null) return false;
1254
+ return setting.value === "cooling" || setting.allowed.includes("cooling");
841
1255
  }
842
1256
  /** Which heat-pump paths this gateway answered. One log per gateway corrects the path table. */
843
1257
  function heatPumpPathCensus(reading) {
@@ -1376,6 +1790,17 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
1376
1790
  if (!res.ok) throw new Error(`HomeCom API ${path} failed: HTTP ${res.status}`);
1377
1791
  return res.body;
1378
1792
  }
1793
+ /**
1794
+ * Evidence that the resource exists: a 2xx, or the 403 the gateway gives a resource it
1795
+ * serves but will not show. 404 is "absent". Anything else (429, 5xx) is a transient
1796
+ * failure, so it is NOT evidence either way: it is logged and counts as unknown.
1797
+ */
1798
+ async function resourceAnswers(path) {
1799
+ const res = await authed(homeComResourceUrl(gatewayId, path), { method: "GET" });
1800
+ if (res.ok || res.status === 403) return true;
1801
+ if (res.status !== 404) logHeatPumpFailure(path, /* @__PURE__ */ new Error(`HTTP ${res.status}`));
1802
+ return false;
1803
+ }
1379
1804
  async function putWrite(write) {
1380
1805
  const res = await authed(homeComResourceUrl(gatewayId, write.path), {
1381
1806
  method: "PUT",
@@ -1457,6 +1882,7 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
1457
1882
  const heatPump = await readHeatPump({
1458
1883
  get: getResource,
1459
1884
  onFailure: logHeatPumpFailure,
1885
+ answers: resourceAnswers,
1460
1886
  circuitIds: {
1461
1887
  hc: snapshot.circuits.map((circuit) => circuit.id),
1462
1888
  dhw: dhwId === void 0 ? [] : [dhwId]
@@ -1547,6 +1973,13 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
1547
1973
  },
1548
1974
  async writeHeatPump(writes) {
1549
1975
  for (const write of writes) await putWrite(write);
1976
+ },
1977
+ async readResource(path) {
1978
+ const res = await authed(homeComResourceUrl(gatewayId, path), { method: "GET" });
1979
+ return {
1980
+ status: res.status,
1981
+ body: res.body
1982
+ };
1550
1983
  }
1551
1984
  };
1552
1985
  }
@@ -1851,6 +2284,15 @@ var HomeComConnectionResolver = class {
1851
2284
  this.#surfaces.clear();
1852
2285
  }
1853
2286
  };
2287
+ /** Native id of the gateway pick: a Gateway device, sibling of the circuits. */
2288
+ var HOMECOM_GATEWAY_NATIVE_ID = "gateway:0";
2289
+ function gatewayDiscoverable(snapshot) {
2290
+ return {
2291
+ nativeId: HOMECOM_GATEWAY_NATIVE_ID,
2292
+ name: snapshot.gateway.name.length > 0 ? snapshot.gateway.name : "HomeCom gateway",
2293
+ kind: "gateway"
2294
+ };
2295
+ }
1854
2296
  var BOILER_DHW_NAME = "Domestic hot water";
1855
2297
  function circuitDiscoverable(circuit) {
1856
2298
  return {
@@ -1868,18 +2310,25 @@ function dhwDiscoverable(name) {
1868
2310
  };
1869
2311
  }
1870
2312
  /**
1871
- * A gateway with a heat pump is one pick. A boiler still offers each circuit
2313
+ * The gateway itself is always the first pick. A gateway with a heat pump is one more pick. A boiler still offers each circuit
1872
2314
  * and the hot-water row on its own.
1873
2315
  */
1874
2316
  function buildDiscoverables(snapshot) {
1875
2317
  const circuits = snapshot.circuits.map(circuitDiscoverable);
1876
- if (snapshot.heatPump !== void 0) return [{
1877
- nativeId: "hp:0",
1878
- name: "Heat pump",
1879
- kind: "heat-pump",
1880
- children: [...circuits, ...snapshot.dhw === null ? [] : [dhwDiscoverable(HOMECOM_HOT_WATER_CHILD_NAME)]]
1881
- }];
1882
- return [...circuits, ...snapshot.dhw === null ? [] : [dhwDiscoverable(BOILER_DHW_NAME)]];
2318
+ if (snapshot.heatPump !== void 0) {
2319
+ const children = [...circuits, ...snapshot.dhw === null ? [] : [dhwDiscoverable(HOMECOM_HOT_WATER_CHILD_NAME)]];
2320
+ return [gatewayDiscoverable(snapshot), {
2321
+ nativeId: "hp:0",
2322
+ name: "Heat pump",
2323
+ kind: "heat-pump",
2324
+ children
2325
+ }];
2326
+ }
2327
+ return [
2328
+ gatewayDiscoverable(snapshot),
2329
+ ...circuits,
2330
+ ...snapshot.dhw === null ? [] : [dhwDiscoverable(BOILER_DHW_NAME)]
2331
+ ];
1883
2332
  }
1884
2333
  var homeComConnections = new HomeComConnectionResolver();
1885
2334
  //#endregion
@@ -1962,6 +2411,10 @@ function shapeOf(kind) {
1962
2411
  type: require_bosch_instances.DeviceType.WaterHeater,
1963
2412
  caps: ["water-heater"]
1964
2413
  };
2414
+ case "gateway": return {
2415
+ type: require_bosch_instances.DeviceType.Gateway,
2416
+ caps: []
2417
+ };
1965
2418
  case "heat-pump": return {
1966
2419
  type: require_bosch_instances.DeviceType.HeatPump,
1967
2420
  caps: ["heat-pump-status", "heat-pump-control"]
@@ -2809,6 +3262,35 @@ var HomeComWaterHeaterDevice = class extends require_bosch_instances.BaseDevice
2809
3262
  }
2810
3263
  };
2811
3264
  //#endregion
3265
+ //#region src/homecom/devices/homecom-gateway-device.ts
3266
+ var homeComGatewaySchema = require_bosch_instances.object({
3267
+ nativeId: require_bosch_instances.string().min(1),
3268
+ brokerId: require_bosch_instances.string().min(1),
3269
+ system: require_bosch_instances.literal("homecom").optional(),
3270
+ integrationId: require_bosch_instances.string().optional()
3271
+ });
3272
+ /**
3273
+ * The HomeCom gateway, as a {@link DeviceType.Gateway}. Its connection lives on the
3274
+ * INTEGRATION; this device is the gateway's presence in the device list and follows the
3275
+ * integration's online state (its connect and disconnect callbacks flip it).
3276
+ *
3277
+ * It registers no action: the cloud account offers no reboot, no update and no local page,
3278
+ * so there is nothing real to expose. The circuits, the hot water and the heat pump are NOT
3279
+ * children of it; each is adopted as its own top-level device of the same integration.
3280
+ */
3281
+ var HomeComGatewayDevice = class extends require_bosch_instances.BaseDevice {
3282
+ features = [];
3283
+ constructor(ctx) {
3284
+ const cfg = homeComGatewaySchema.parse(ctx.persistedConfig ?? {});
3285
+ super(ctx, homeComGatewaySchema, { type: require_bosch_instances.DeviceType.Gateway });
3286
+ this.online = homeComConnections.has(cfg.brokerId);
3287
+ this.updateSourceInfo({
3288
+ id: cfg.nativeId,
3289
+ system: "homecom"
3290
+ });
3291
+ }
3292
+ };
3293
+ //#endregion
2812
3294
  //#region src/homecom/devices/homecom-heat-pump-device.ts
2813
3295
  var homeComHeatPumpSchema = require_bosch_instances.object({
2814
3296
  nativeId: require_bosch_instances.string().min(1),
@@ -2959,7 +3441,8 @@ var BoschHomeComProviderAddon = class extends require_bosch_instances.BaseDevice
2959
3441
  deviceClasses = {
2960
3442
  [require_bosch_instances.DeviceType.Climate]: HomeComClimateDevice,
2961
3443
  [require_bosch_instances.DeviceType.WaterHeater]: HomeComWaterHeaterDevice,
2962
- [require_bosch_instances.DeviceType.HeatPump]: HomeComHeatPumpDevice
3444
+ [require_bosch_instances.DeviceType.HeatPump]: HomeComHeatPumpDevice,
3445
+ [require_bosch_instances.DeviceType.Gateway]: HomeComGatewayDevice
2963
3446
  };
2964
3447
  clients = null;
2965
3448
  instances = null;
@@ -3000,6 +3483,11 @@ var BoschHomeComProviderAddon = class extends require_bosch_instances.BaseDevice
3000
3483
  });
3001
3484
  await this.reconcileIntegrations();
3002
3485
  this.subscribeIntegrationLifecycle();
3486
+ const explore = createHomeComExploreAction(homeComExploreDeps({
3487
+ registry: this.clients,
3488
+ connections: homeComConnections,
3489
+ logger: this.ctx.logger
3490
+ }));
3003
3491
  return {
3004
3492
  providers: [
3005
3493
  ...regs,
@@ -3026,7 +3514,8 @@ var BoschHomeComProviderAddon = class extends require_bosch_instances.BaseDevice
3026
3514
  signOut: async () => ({
3027
3515
  signedOut: true,
3028
3516
  summary: "Signed out."
3029
- })
3517
+ }),
3518
+ exploreResources: explore
3030
3519
  }
3031
3520
  };
3032
3521
  }