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