@camstack/addon-provider-bosch 0.1.49 → 0.1.51

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,7 +1,11 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_bosch_instances = require("../bosch-instances-BHFcR1TK.js");
3
- const require_account_integration_offline = require("../account-integration-offline-Dcx0sq_v.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
+ let node_child_process = require("node:child_process");
6
+ let node_fs = require("node:fs");
7
+ let node_path = require("node:path");
8
+ let node_url = require("node:url");
5
9
  //#region src/homecom/config.ts
6
10
  var HOMECOM_BRANDS = ["bosch", "buderus"];
7
11
  var HomeComBrandSchema = require_bosch_instances._enum(HOMECOM_BRANDS);
@@ -178,6 +182,280 @@ function buildHomeComFormSchema() {
178
182
  ]
179
183
  }] };
180
184
  }
185
+ var EXPLORER_MAX_NODES_CAP = 1500;
186
+ var VALUE_MAX_CHARS = 200;
187
+ /** Roots walked when `/` does not list its children. */
188
+ var EXPLORER_SEED_ROOTS = [
189
+ "/system",
190
+ "/heatSources",
191
+ "/heatingCircuits",
192
+ "/dhwCircuits",
193
+ "/gateway",
194
+ "/notifications",
195
+ "/recordings",
196
+ "/devices",
197
+ "/solarCircuits",
198
+ "/ventilation"
199
+ ];
200
+ /** Under the 60 s UDS request timeout a forked addon's action answers within. */
201
+ var EXPLORER_MAX_DEADLINE_MS = 5e4;
202
+ var DROPPED_SAMPLE_MAX = 20;
203
+ var UNVISITED_SAMPLE_MAX = 100;
204
+ var PATH_SHAPE = /^\/(?:[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*)?$/;
205
+ /**
206
+ * A resource path this walker will request: absolute, plain segments, and no `.` or `..`
207
+ * segment — URL resolution would otherwise lift the request out of the gateway's tree.
208
+ */
209
+ function isResourcePath(path) {
210
+ if (!PATH_SHAPE.test(path)) return false;
211
+ return path.split("/").slice(1).every((segment) => segment !== "." && segment !== "..");
212
+ }
213
+ var explorerOutcomeSchema = require_bosch_instances._enum([
214
+ "ok",
215
+ "forbidden",
216
+ "not-found",
217
+ "rate-limited",
218
+ "error"
219
+ ]);
220
+ var exploredResourceSchema = require_bosch_instances.object({
221
+ path: require_bosch_instances.string(),
222
+ httpStatus: require_bosch_instances.number(),
223
+ outcome: explorerOutcomeSchema,
224
+ type: require_bosch_instances.string().optional(),
225
+ value: require_bosch_instances.union([
226
+ require_bosch_instances.string(),
227
+ require_bosch_instances.number(),
228
+ require_bosch_instances.boolean(),
229
+ require_bosch_instances._null()
230
+ ]).optional(),
231
+ unit: require_bosch_instances.string().optional(),
232
+ writeable: require_bosch_instances.boolean().optional(),
233
+ recordable: require_bosch_instances.boolean().optional(),
234
+ allowedValues: require_bosch_instances.array(require_bosch_instances.string()).optional(),
235
+ minValue: require_bosch_instances.number().optional(),
236
+ maxValue: require_bosch_instances.number().optional(),
237
+ childCount: require_bosch_instances.number().optional(),
238
+ /** References of this container that were not a walkable path. */
239
+ droppedReferences: require_bosch_instances.number().optional(),
240
+ error: require_bosch_instances.string().optional()
241
+ });
242
+ var explorerSummarySchema = require_bosch_instances.object({
243
+ ok: require_bosch_instances.number(),
244
+ forbidden: require_bosch_instances.number(),
245
+ "not-found": require_bosch_instances.number(),
246
+ "rate-limited": require_bosch_instances.number(),
247
+ error: require_bosch_instances.number()
248
+ });
249
+ var explorerStopReasonSchema = require_bosch_instances._enum([
250
+ "complete",
251
+ "max-nodes",
252
+ "rate-limited",
253
+ "deadline"
254
+ ]);
255
+ function isRecord$3(value) {
256
+ return typeof value === "object" && value !== null && !Array.isArray(value);
257
+ }
258
+ function finite$1(value) {
259
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
260
+ }
261
+ function text$1(value) {
262
+ return typeof value === "string" && value.length > 0 ? value : void 0;
263
+ }
264
+ function truncate(value) {
265
+ return value.length > VALUE_MAX_CHARS ? `${value.slice(0, VALUE_MAX_CHARS - 1)}…` : value;
266
+ }
267
+ function shownValue(value) {
268
+ if (value === null || value === void 0) return null;
269
+ if (typeof value === "number" || typeof value === "boolean") return value;
270
+ if (typeof value === "string") return truncate(value);
271
+ return truncate(JSON.stringify(value));
272
+ }
273
+ function outcomeOf(status) {
274
+ if (status >= 200 && status < 300) return "ok";
275
+ if (status === 403) return "forbidden";
276
+ if (status === 404) return "not-found";
277
+ if (status === 429) return "rate-limited";
278
+ return "error";
279
+ }
280
+ /** What a reference names: its `id`, else the part of its `uri` after `/resource`. */
281
+ function referenceText(ref) {
282
+ if (!isRecord$3(ref)) return null;
283
+ const id = text$1(ref["id"]);
284
+ if (id !== void 0 && id.startsWith("/")) return id;
285
+ const uri = text$1(ref["uri"]);
286
+ if (uri === void 0) return id ?? null;
287
+ const at = uri.indexOf("/resource/");
288
+ if (at < 0) return uri;
289
+ return uri.slice(at + 9);
290
+ }
291
+ /** The walkable resource path a reference names, or null. */
292
+ function referencePath(ref) {
293
+ const path = referenceText(ref);
294
+ return path !== null && isResourcePath(path) ? path : null;
295
+ }
296
+ function referencesOf(body) {
297
+ if (!isRecord$3(body)) return null;
298
+ const refs = body["references"];
299
+ if (!Array.isArray(refs)) return null;
300
+ const paths = [];
301
+ const dropped = [];
302
+ for (const ref of refs) {
303
+ const path = referencePath(ref);
304
+ if (path !== null) paths.push(path);
305
+ else dropped.push(truncate(referenceText(ref) ?? JSON.stringify(ref) ?? "undefined"));
306
+ }
307
+ return {
308
+ total: refs.length,
309
+ paths,
310
+ dropped
311
+ };
312
+ }
313
+ function describeLeaf(path, status, body) {
314
+ if (!isRecord$3(body)) return {
315
+ path,
316
+ httpStatus: status,
317
+ outcome: "ok"
318
+ };
319
+ const allowed = body["allowedValues"];
320
+ const allowedValues = Array.isArray(allowed) ? allowed.map((v) => truncate(String(v))) : void 0;
321
+ const type = text$1(body["type"]);
322
+ const unit = text$1(body["unitOfMeasure"]);
323
+ const minValue = finite$1(body["minValue"]);
324
+ const maxValue = finite$1(body["maxValue"]);
325
+ const writeable = body["writeable"];
326
+ const recordable = body["recordable"];
327
+ return {
328
+ path,
329
+ httpStatus: status,
330
+ outcome: "ok",
331
+ ...type !== void 0 ? { type } : {},
332
+ ..."value" in body ? { value: shownValue(body["value"]) } : {},
333
+ ...unit !== void 0 ? { unit } : {},
334
+ ...typeof writeable === "number" || typeof writeable === "boolean" ? { writeable: Boolean(writeable) } : {},
335
+ ...typeof recordable === "number" || typeof recordable === "boolean" ? { recordable: Boolean(recordable) } : {},
336
+ ...allowedValues !== void 0 ? { allowedValues } : {},
337
+ ...minValue !== void 0 ? { minValue } : {},
338
+ ...maxValue !== void 0 ? { maxValue } : {}
339
+ };
340
+ }
341
+ function emptySummary() {
342
+ return {
343
+ ok: 0,
344
+ forbidden: 0,
345
+ "not-found": 0,
346
+ "rate-limited": 0,
347
+ error: 0
348
+ };
349
+ }
350
+ function clampDeadline(requested) {
351
+ return Math.min(Math.max(Math.floor(requested ?? 45e3), 1), EXPLORER_MAX_DEADLINE_MS);
352
+ }
353
+ function clampNodes(requested) {
354
+ return Math.min(Math.max(Math.floor(requested ?? 400), 1), EXPLORER_MAX_NODES_CAP);
355
+ }
356
+ var defaultSleep = (ms) => new Promise((resolve) => {
357
+ setTimeout(resolve, ms);
358
+ });
359
+ async function exploreHomeComResources(input) {
360
+ const root = input.root ?? "/";
361
+ if (!isResourcePath(root)) throw new Error(`Not a resource path: ${root}`);
362
+ const maxNodes = clampNodes(input.maxNodes);
363
+ const delayMs = Math.max(input.delayMs ?? 150, 150);
364
+ const sleep = input.sleep ?? defaultSleep;
365
+ const now = input.now ?? Date.now;
366
+ const deadlineAt = now() + clampDeadline(input.deadlineMs);
367
+ const queue = [root];
368
+ const queued = new Set([root]);
369
+ const resources = [];
370
+ const summary = emptySummary();
371
+ let calls = 0;
372
+ let stoppedBy = "complete";
373
+ let droppedReferences = 0;
374
+ const droppedSamples = [];
375
+ const enqueue = (paths) => {
376
+ for (const path of paths) {
377
+ if (queued.has(path)) continue;
378
+ queued.add(path);
379
+ queue.push(path);
380
+ }
381
+ };
382
+ while (queue.length > 0) {
383
+ if (calls >= maxNodes) {
384
+ stoppedBy = "max-nodes";
385
+ break;
386
+ }
387
+ if (now() + (calls > 0 ? delayMs : 0) >= deadlineAt) {
388
+ stoppedBy = "deadline";
389
+ break;
390
+ }
391
+ const path = queue.shift();
392
+ if (path === void 0) break;
393
+ if (calls > 0) await sleep(delayMs);
394
+ calls += 1;
395
+ let answer;
396
+ try {
397
+ answer = await input.read(path);
398
+ } catch (err) {
399
+ const error = err instanceof Error ? err.message : String(err);
400
+ resources.push({
401
+ path,
402
+ httpStatus: 0,
403
+ outcome: "error",
404
+ error
405
+ });
406
+ summary.error += 1;
407
+ continue;
408
+ }
409
+ const outcome = outcomeOf(answer.status);
410
+ summary[outcome] += 1;
411
+ if (outcome === "rate-limited") {
412
+ resources.push({
413
+ path,
414
+ httpStatus: answer.status,
415
+ outcome
416
+ });
417
+ stoppedBy = "rate-limited";
418
+ break;
419
+ }
420
+ if (outcome !== "ok") {
421
+ resources.push({
422
+ path,
423
+ httpStatus: answer.status,
424
+ outcome
425
+ });
426
+ if (path === "/" && root === "/") enqueue(EXPLORER_SEED_ROOTS);
427
+ continue;
428
+ }
429
+ const children = referencesOf(answer.body);
430
+ if (children !== null) {
431
+ const type = isRecord$3(answer.body) ? text$1(answer.body["type"]) : void 0;
432
+ resources.push({
433
+ path,
434
+ httpStatus: answer.status,
435
+ outcome,
436
+ type: type ?? "refEnum",
437
+ childCount: children.total,
438
+ ...children.dropped.length > 0 ? { droppedReferences: children.dropped.length } : {}
439
+ });
440
+ droppedReferences += children.dropped.length;
441
+ for (const ref of children.dropped) if (droppedSamples.length < DROPPED_SAMPLE_MAX) droppedSamples.push(`${path} -> ${ref}`);
442
+ enqueue(children.paths);
443
+ continue;
444
+ }
445
+ resources.push(describeLeaf(path, answer.status, answer.body));
446
+ if (path === "/" && root === "/") enqueue(EXPLORER_SEED_ROOTS);
447
+ }
448
+ return {
449
+ root,
450
+ resources,
451
+ summary,
452
+ stoppedBy,
453
+ unvisited: queue.length,
454
+ unvisitedPaths: queue.slice(0, UNVISITED_SAMPLE_MAX),
455
+ droppedReferences,
456
+ droppedSamples
457
+ };
458
+ }
181
459
  //#endregion
182
460
  //#region src/homecom/custom-actions.ts
183
461
  var gatewayChoice = require_bosch_instances.object({
@@ -186,6 +464,27 @@ var gatewayChoice = require_bosch_instances.object({
186
464
  brand: HomeComBrandSchema,
187
465
  region: HomeComRegionSchema
188
466
  });
467
+ var exploreResourcesInput = require_bosch_instances.object({
468
+ /** The HomeCom integration instance; may be omitted when exactly one is connected. */
469
+ integrationId: require_bosch_instances.string().min(1).optional(),
470
+ root: require_bosch_instances.string().refine(isResourcePath, "an absolute resource path such as /heatSources, no . or .. segment").optional(),
471
+ maxNodes: require_bosch_instances.number().int().min(1).max(EXPLORER_MAX_NODES_CAP).optional(),
472
+ /** Wall-clock budget; the walk returns what it has, with `stoppedBy: 'deadline'`. */
473
+ deadlineMs: require_bosch_instances.number().int().min(1e3).max(EXPLORER_MAX_DEADLINE_MS).optional()
474
+ });
475
+ var exploreResourcesOutput = require_bosch_instances.object({
476
+ integrationId: require_bosch_instances.string(),
477
+ /** Null when the integration has no snapshot yet: unknown, not empty. */
478
+ gatewayId: require_bosch_instances.string().nullable(),
479
+ root: require_bosch_instances.string(),
480
+ stoppedBy: explorerStopReasonSchema,
481
+ unvisited: require_bosch_instances.number(),
482
+ unvisitedPaths: require_bosch_instances.array(require_bosch_instances.string()),
483
+ droppedReferences: require_bosch_instances.number(),
484
+ droppedSamples: require_bosch_instances.array(require_bosch_instances.string()),
485
+ summary: explorerSummarySchema,
486
+ resources: require_bosch_instances.array(exploredResourceSchema)
487
+ });
189
488
  var homeComActions = require_bosch_instances.defineCustomActions({
190
489
  beginSignIn: require_bosch_instances.customAction(require_bosch_instances.object({}), require_bosch_instances.object({
191
490
  attemptId: require_bosch_instances.string().min(1),
@@ -219,9 +518,95 @@ var homeComActions = require_bosch_instances.defineCustomActions({
219
518
  }), {
220
519
  kind: "mutation",
221
520
  auth: "admin"
521
+ }),
522
+ /** Read-only walk of the gateway's resource tree, for diagnosing what it serves. */
523
+ exploreResources: require_bosch_instances.customAction(exploreResourcesInput, exploreResourcesOutput, {
524
+ kind: "query",
525
+ auth: "admin"
222
526
  })
223
527
  });
224
528
  //#endregion
529
+ //#region src/homecom/homecom-explore-action.ts
530
+ /** The action refused before it walked anything; `reason` says why. */
531
+ var HomeComExploreRefusedError = class extends Error {
532
+ reason;
533
+ constructor(reason, message) {
534
+ super(message);
535
+ this.name = "HomeComExploreRefusedError";
536
+ this.reason = reason;
537
+ }
538
+ };
539
+ function pickIntegration(input, connected) {
540
+ if (input.integrationId !== void 0) return input.integrationId;
541
+ if (connected.length === 1 && connected[0] !== void 0) return connected[0];
542
+ if (connected.length === 0) throw new HomeComExploreRefusedError("not-connected", "No HomeCom integration is connected.");
543
+ throw new HomeComExploreRefusedError("ambiguous", `Several HomeCom integrations are connected; name one with integrationId (${connected.join(", ")}).`);
544
+ }
545
+ /**
546
+ * The `exploreResources` action: a read-only walk of one integration's gateway. One walk
547
+ * per integration at a time — a second call while one runs is refused, never queued.
548
+ */
549
+ function createHomeComExploreAction(deps) {
550
+ const running = /* @__PURE__ */ new Set();
551
+ return async (input) => {
552
+ const integrationId = pickIntegration(input, deps.listConnected());
553
+ if (running.has(integrationId)) throw new HomeComExploreRefusedError("already-running", `A resource walk is already running for HomeCom integration ${integrationId}.`);
554
+ const client = deps.getClient(integrationId);
555
+ if (client === null) throw new HomeComExploreRefusedError("not-connected", `HomeCom integration ${integrationId} is not connected.`);
556
+ running.add(integrationId);
557
+ try {
558
+ return await walk(input, integrationId, client, deps);
559
+ } finally {
560
+ running.delete(integrationId);
561
+ }
562
+ };
563
+ }
564
+ async function walk(input, integrationId, client, deps) {
565
+ const result = await exploreHomeComResources({
566
+ read: (path) => client.readResource(path),
567
+ ...input.root !== void 0 ? { root: input.root } : {},
568
+ ...input.maxNodes !== void 0 ? { maxNodes: input.maxNodes } : {},
569
+ ...input.deadlineMs !== void 0 ? { deadlineMs: input.deadlineMs } : {},
570
+ ...deps.sleep !== void 0 ? { sleep: deps.sleep } : {},
571
+ ...deps.now !== void 0 ? { now: deps.now } : {}
572
+ });
573
+ const gatewayId = deps.getGatewayId(integrationId);
574
+ if (result.droppedReferences > 0) deps.logger.warn("HomeCom resource walk dropped references that are not a walkable path", {
575
+ tags: { integrationId },
576
+ meta: {
577
+ gatewayId,
578
+ droppedReferences: result.droppedReferences,
579
+ samples: result.droppedSamples
580
+ }
581
+ });
582
+ deps.logger.info("HomeCom resource tree explored", {
583
+ tags: { integrationId },
584
+ meta: {
585
+ gatewayId,
586
+ root: result.root,
587
+ visited: result.resources.length,
588
+ stoppedBy: result.stoppedBy,
589
+ unvisited: result.unvisited,
590
+ droppedReferences: result.droppedReferences,
591
+ ...result.summary
592
+ }
593
+ });
594
+ return {
595
+ integrationId,
596
+ gatewayId,
597
+ ...result
598
+ };
599
+ }
600
+ /** The addon's deps: registered integrations that hold a live client; gateway from the snapshot. */
601
+ function homeComExploreDeps(wiring) {
602
+ return {
603
+ listConnected: () => wiring.registry.list().filter((id) => wiring.connections.getClient(id) !== null),
604
+ getClient: (id) => wiring.connections.getClient(id),
605
+ getGatewayId: (id) => wiring.connections.getSnapshot(id)?.gateway.gatewayId ?? null,
606
+ logger: wiring.logger
607
+ };
608
+ }
609
+ //#endregion
225
610
  //#region src/homecom/homecom-device-type.ts
226
611
  /**
227
612
  * Device types the PointT gateway list reports. The set is the one the MIT
@@ -267,6 +652,210 @@ function unsupportedDeviceTypeMessage(deviceType) {
267
652
  return new HomeComUnknownDeviceTypeError(deviceType ?? "(missing)").message;
268
653
  }
269
654
  //#endregion
655
+ //#region src/homecom/homecom-bridge.ts
656
+ /**
657
+ * The HomeCom Python bridge: one `python/homecom_worker.py` run per poll or write, built on
658
+ * the MIT `homecom_alt` library (pinned in `python/requirements.txt`).
659
+ *
660
+ * The access token travels ONLY in the JSON written to the child's stdin — never in argv,
661
+ * never in the environment — and the worker never prints it. The OAuth refresh stays in
662
+ * TypeScript: the worker answers `auth` and the caller refreshes and runs it again.
663
+ */
664
+ var HOMECOM_BRIDGE_DEVICE_TYPES = [
665
+ "k40",
666
+ "k30",
667
+ "icom"
668
+ ];
669
+ function isBridgeDeviceType(value) {
670
+ return HOMECOM_BRIDGE_DEVICE_TYPES.some((type) => type === value);
671
+ }
672
+ var SettingSchema = require_bosch_instances.object({
673
+ value: require_bosch_instances.union([require_bosch_instances.string(), require_bosch_instances.number()]).nullable(),
674
+ min: require_bosch_instances.number().nullable(),
675
+ max: require_bosch_instances.number().nullable(),
676
+ allowed: require_bosch_instances.array(require_bosch_instances.string()),
677
+ writeable: require_bosch_instances.boolean().nullable()
678
+ });
679
+ var MaybeSetting = SettingSchema.nullable();
680
+ var HeatingCircuitSchema = require_bosch_instances.object({
681
+ id: require_bosch_instances.string(),
682
+ /** The name the gateway gave the circuit in its reference listing, when it gave one. */
683
+ name: require_bosch_instances.string().nullable().default(null),
684
+ operationMode: MaybeSetting,
685
+ heatCoolMode: MaybeSetting,
686
+ currentSuWiMode: MaybeSetting,
687
+ suWiSwitchMode: MaybeSetting,
688
+ roomTemp: require_bosch_instances.number().nullable(),
689
+ humidity: require_bosch_instances.number().nullable(),
690
+ currentRoomSetpoint: MaybeSetting,
691
+ manualRoomSetpoint: MaybeSetting,
692
+ coolingRoomSetpoint: MaybeSetting,
693
+ comfortSetpoint: MaybeSetting,
694
+ ecoSetpoint: MaybeSetting,
695
+ maxFlowTemp: MaybeSetting
696
+ });
697
+ var DhwCircuitSchema = require_bosch_instances.object({
698
+ id: require_bosch_instances.string(),
699
+ operationMode: MaybeSetting,
700
+ actualTemp: require_bosch_instances.number().nullable(),
701
+ currentTemperatureLevel: MaybeSetting,
702
+ charge: MaybeSetting,
703
+ chargeDuration: MaybeSetting,
704
+ singleChargeSetpoint: MaybeSetting,
705
+ currentSetpoint: MaybeSetting,
706
+ temperatureLevels: require_bosch_instances.record(require_bosch_instances.string(), SettingSchema)
707
+ });
708
+ /**
709
+ * One GET of a heat-pump-only heat-source resource: `refused` (403/500) still proves a heat
710
+ * pump the gateway will not let us read, `absent` (404) is a boiler, `transient` proves nothing.
711
+ */
712
+ var ProbeOutcomeSchema = require_bosch_instances._enum([
713
+ "answered",
714
+ "refused",
715
+ "absent",
716
+ "transient"
717
+ ]);
718
+ var ReadingSchema = require_bosch_instances.object({
719
+ heatSource: require_bosch_instances.object({
720
+ heatPumpType: require_bosch_instances.string().nullable(),
721
+ supplyTemp: require_bosch_instances.number().nullable(),
722
+ returnTemp: require_bosch_instances.number().nullable(),
723
+ modulationPct: require_bosch_instances.number().nullable(),
724
+ pressureBar: require_bosch_instances.number().nullable(),
725
+ starts: require_bosch_instances.number().nullable(),
726
+ outdoorTemp: require_bosch_instances.number().nullable(),
727
+ thermalKwh: require_bosch_instances.number().nullable(),
728
+ electricalKwh: require_bosch_instances.number().nullable()
729
+ }),
730
+ system: require_bosch_instances.object({
731
+ away: MaybeSetting,
732
+ silent: MaybeSetting,
733
+ additionalHeater: MaybeSetting,
734
+ holiday: MaybeSetting
735
+ }),
736
+ heatingCircuits: require_bosch_instances.array(HeatingCircuitSchema),
737
+ dhwCircuits: require_bosch_instances.array(DhwCircuitSchema),
738
+ /** Asked only when the bulk read left the heat-pump type unknown; path → outcome. */
739
+ heatSourceProbe: require_bosch_instances.record(require_bosch_instances.string(), ProbeOutcomeSchema).default({})
740
+ });
741
+ var FailureKind = require_bosch_instances._enum([
742
+ "auth",
743
+ "unreachable",
744
+ "refused",
745
+ "api",
746
+ "protocol",
747
+ "bad-request"
748
+ ]);
749
+ var AnswerSchema = require_bosch_instances.union([
750
+ require_bosch_instances.object({
751
+ ok: require_bosch_instances.literal(true),
752
+ reading: ReadingSchema
753
+ }),
754
+ require_bosch_instances.object({
755
+ ok: require_bosch_instances.literal(true),
756
+ written: require_bosch_instances.string()
757
+ }),
758
+ require_bosch_instances.object({
759
+ ok: require_bosch_instances.literal(false),
760
+ kind: FailureKind,
761
+ error: require_bosch_instances.string()
762
+ })
763
+ ]);
764
+ /** A failed bridge run. The message never carries more than a short stderr tail. */
765
+ var HomeComBridgeError = class extends Error {
766
+ name = "HomeComBridgeError";
767
+ kind;
768
+ constructor(kind, message) {
769
+ super(message);
770
+ this.kind = kind;
771
+ }
772
+ };
773
+ /** A K40 bulk read is several bulk POSTs plus per-circuit GETs, 15 s each at most. */
774
+ var DEFAULT_READ_TIMEOUT_MS = 5e4;
775
+ var DEFAULT_WRITE_TIMEOUT_MS = 3e4;
776
+ var MAX_STDOUT_BYTES = 2 * 1024 * 1024;
777
+ function runWorker(deps, request, timeoutMs) {
778
+ const spawnFn = deps.spawnFn ?? node_child_process.spawn;
779
+ return new Promise((resolve, reject) => {
780
+ const child = spawnFn(deps.pythonPath, [deps.scriptPath], { stdio: "pipe" });
781
+ let stdout = "";
782
+ let stderr = "";
783
+ let settled = false;
784
+ const finish = (action) => {
785
+ if (settled) return;
786
+ settled = true;
787
+ clearTimeout(timer);
788
+ action();
789
+ };
790
+ const timer = setTimeout(() => {
791
+ child.kill("SIGKILL");
792
+ finish(() => reject(new HomeComBridgeError("timeout", `the HomeCom bridge did not answer within ${timeoutMs / 1e3}s`)));
793
+ }, timeoutMs);
794
+ child.stdout.on("data", (chunk) => {
795
+ stdout += chunk.toString("utf8");
796
+ if (stdout.length > MAX_STDOUT_BYTES) {
797
+ child.kill("SIGKILL");
798
+ finish(() => reject(new HomeComBridgeError("protocol", "the HomeCom bridge wrote too much output")));
799
+ }
800
+ });
801
+ child.stderr.on("data", (chunk) => {
802
+ stderr = (stderr + chunk.toString("utf8")).slice(-16384);
803
+ });
804
+ child.on("error", (err) => {
805
+ finish(() => reject(new HomeComBridgeError("protocol", `cannot start the HomeCom bridge: ${err.message}`)));
806
+ });
807
+ child.on("close", (code) => {
808
+ finish(() => {
809
+ let json;
810
+ try {
811
+ json = JSON.parse(stdout.trim());
812
+ } catch {
813
+ const tail = stderr.trim().slice(-300) || "no stderr";
814
+ reject(new HomeComBridgeError("protocol", `the HomeCom bridge produced no JSON (exit ${code ?? "null"}): ${tail}`));
815
+ return;
816
+ }
817
+ const parsed = AnswerSchema.safeParse(json);
818
+ if (!parsed.success) {
819
+ reject(new HomeComBridgeError("protocol", "the HomeCom bridge answered an unknown shape"));
820
+ return;
821
+ }
822
+ resolve(parsed.data);
823
+ });
824
+ });
825
+ child.stdin.on("error", () => void 0);
826
+ child.stdin.end(JSON.stringify(request));
827
+ });
828
+ }
829
+ function failure(answer) {
830
+ return answer.ok ? null : new HomeComBridgeError(answer.kind, answer.error);
831
+ }
832
+ function createHomeComBridge(deps) {
833
+ const readTimeoutMs = deps.readTimeoutMs ?? DEFAULT_READ_TIMEOUT_MS;
834
+ const writeTimeoutMs = deps.writeTimeoutMs ?? DEFAULT_WRITE_TIMEOUT_MS;
835
+ return {
836
+ async read(target) {
837
+ const answer = await runWorker(deps, {
838
+ ...target,
839
+ action: "read"
840
+ }, readTimeoutMs);
841
+ const failed = failure(answer);
842
+ if (failed !== null) throw failed;
843
+ if (!("reading" in answer)) throw new HomeComBridgeError("protocol", "the HomeCom bridge answered a read with no reading");
844
+ return answer.reading;
845
+ },
846
+ async write(target, write) {
847
+ const answer = await runWorker(deps, {
848
+ ...target,
849
+ action: "write",
850
+ write
851
+ }, writeTimeoutMs);
852
+ const failed = failure(answer);
853
+ if (failed !== null) throw failed;
854
+ if (!("written" in answer) || answer.written !== write.op) throw new HomeComBridgeError("protocol", "the HomeCom bridge did not confirm the write");
855
+ }
856
+ };
857
+ }
858
+ //#endregion
270
859
  //#region src/homecom/homecom-domain-mapping.ts
271
860
  var MODE_MAP = {
272
861
  Off: "off",
@@ -342,6 +931,280 @@ function waterHeaterSliceFromDhw(dhw, nowMs) {
342
931
  lastChangedAt: nowMs
343
932
  };
344
933
  }
934
+ //#endregion
935
+ //#region src/homecom/homecom-heat-pump.ts
936
+ /** A write the gateway would refuse, caught before it is sent. */
937
+ var HomeComHeatPumpRefusalError = class extends Error {
938
+ name = "HomeComHeatPumpRefusalError";
939
+ };
940
+ var COOLING_VALUES = ["cool", "cooling"];
941
+ /** The current level's own temperature, or the gateway's current setpoint (icom). */
942
+ function dhwTargetSetting(dhw) {
943
+ const level = dhw.currentTemperatureLevel?.value;
944
+ if (typeof level === "string" && level !== "off") {
945
+ const own = dhw.temperatureLevels[level];
946
+ if (own !== void 0) return own;
947
+ }
948
+ return dhw.currentSetpoint;
949
+ }
950
+ /** True when the circuit is cooling now: heat/cool mode or the season says so. */
951
+ function circuitIsCooling(hc) {
952
+ const mode = hc.heatCoolMode?.value;
953
+ const season = hc.currentSuWiMode?.value;
954
+ return typeof mode === "string" && COOLING_VALUES.includes(mode) || typeof season === "string" && COOLING_VALUES.includes(season);
955
+ }
956
+ function numberValue$1(s) {
957
+ return s !== null && typeof s.value === "number" ? s.value : null;
958
+ }
959
+ function hcReading(hc) {
960
+ const target = circuitIsCooling(hc) ? numberValue$1(hc.coolingRoomSetpoint) ?? numberValue$1(hc.currentRoomSetpoint) : numberValue$1(hc.currentRoomSetpoint) ?? numberValue$1(hc.manualRoomSetpoint);
961
+ return {
962
+ id: hc.id,
963
+ kind: "hc",
964
+ currentTemp: hc.roomTemp,
965
+ targetTemp: target,
966
+ operationMode: hc.operationMode,
967
+ heatCoolMode: hc.heatCoolMode,
968
+ summerWinter: hc.suWiSwitchMode,
969
+ comfort: hc.comfortSetpoint,
970
+ eco: hc.ecoSetpoint,
971
+ maxFlow: hc.maxFlowTemp,
972
+ level: null,
973
+ chargeDuration: null,
974
+ charge: null
975
+ };
976
+ }
977
+ function dhwReading(dhw) {
978
+ return {
979
+ id: dhw.id,
980
+ kind: "dhw",
981
+ currentTemp: dhw.actualTemp,
982
+ targetTemp: numberValue$1(dhwTargetSetting(dhw)),
983
+ operationMode: dhw.operationMode,
984
+ heatCoolMode: null,
985
+ summerWinter: null,
986
+ comfort: null,
987
+ eco: null,
988
+ maxFlow: null,
989
+ level: dhw.currentTemperatureLevel,
990
+ chargeDuration: dhw.chargeDuration,
991
+ charge: dhw.charge
992
+ };
993
+ }
994
+ function heatPumpReadingFrom(bridge) {
995
+ const hs = bridge.heatSource;
996
+ return {
997
+ circuits: [...bridge.heatingCircuits.map(hcReading), ...bridge.dhwCircuits.map(dhwReading)],
998
+ heatPumpType: hs.heatPumpType,
999
+ heatSourceRefused: Object.values(bridge.heatSourceProbe).some((outcome) => outcome === "answered" || outcome === "refused"),
1000
+ silent: bridge.system.silent,
1001
+ away: bridge.system.away,
1002
+ additionalHeater: bridge.system.additionalHeater,
1003
+ holiday: bridge.system.holiday,
1004
+ compressorPct: hs.modulationPct,
1005
+ supply: hs.supplyTemp,
1006
+ return: hs.returnTemp,
1007
+ outdoor: hs.outdoorTemp,
1008
+ thermalKwh: hs.thermalKwh,
1009
+ electricalKwh: hs.electricalKwh,
1010
+ pressureBar: hs.pressureBar,
1011
+ starts: hs.starts
1012
+ };
1013
+ }
1014
+ /**
1015
+ * True when the gateway should be offered as a system device (`hp:0`).
1016
+ *
1017
+ * Only evidence a gas boiler on the same controller family cannot give counts: a heat-pump
1018
+ * type on the heat source, a heat-pump-only heat-source resource the gateway refuses (403/500,
1019
+ * never a 404 or a transient failure), the silent mode or the additional heater
1020
+ * (heat-pump-only resources), or a heating circuit that can cool. Away, holiday, flow, return, modulation,
1021
+ * pressure and starts are answered by a boiler too.
1022
+ */
1023
+ function hasSystemDevice(reading) {
1024
+ if (reading.heatPumpType !== null || reading.heatSourceRefused) return true;
1025
+ if (reading.silent !== null || reading.additionalHeater !== null) return true;
1026
+ return reading.circuits.some((c) => c.kind === "hc" && offersCooling(c.heatCoolMode));
1027
+ }
1028
+ function offersCooling(setting) {
1029
+ if (setting === null) return false;
1030
+ const value = setting.value;
1031
+ if (typeof value === "string" && COOLING_VALUES.includes(value)) return true;
1032
+ return setting.allowed.some((allowed) => COOLING_VALUES.includes(allowed));
1033
+ }
1034
+ function heatPumpStatusFrom(reading, now) {
1035
+ return {
1036
+ circuits: reading.circuits.map(circuitOf),
1037
+ system: {
1038
+ silent: optionOf(reading.silent),
1039
+ away: optionOf(reading.away),
1040
+ additionalHeater: optionOf(reading.additionalHeater),
1041
+ holiday: optionOf(reading.holiday)
1042
+ },
1043
+ power: {
1044
+ thermalW: null,
1045
+ electricalW: null,
1046
+ compressorPct: reading.compressorPct
1047
+ },
1048
+ temperatures: {
1049
+ supply: reading.supply,
1050
+ return: reading.return,
1051
+ outdoor: reading.outdoor
1052
+ },
1053
+ energy: {
1054
+ thermalKwh: reading.thermalKwh,
1055
+ electricalKwh: reading.electricalKwh
1056
+ },
1057
+ waterPressureBar: reading.pressureBar,
1058
+ starts: reading.starts,
1059
+ lastFetchedAt: now
1060
+ };
1061
+ }
1062
+ function circuitOf(c) {
1063
+ const kind = kindOf(c);
1064
+ return {
1065
+ id: c.id,
1066
+ kind,
1067
+ label: labelOf(c),
1068
+ icon: kind === "dhw" ? "droplets" : kind === "cooling" ? "snowflake" : "flame",
1069
+ currentTemp: c.currentTemp,
1070
+ targetTemp: c.targetTemp,
1071
+ mode: modeValue(c.operationMode),
1072
+ active: null,
1073
+ operationMode: optionOf(c.operationMode),
1074
+ heatCoolMode: optionOf(c.heatCoolMode),
1075
+ summerWinter: optionOf(c.summerWinter),
1076
+ comfortSetpoint: rangeOf(c.comfort),
1077
+ ecoSetpoint: rangeOf(c.eco),
1078
+ maxFlowTemp: rangeOf(c.maxFlow),
1079
+ temperatureLevel: optionOf(c.level),
1080
+ chargeDuration: rangeOf(c.chargeDuration),
1081
+ extraHotWater: c.charge === null ? null : textOf$1(c.charge) === "start"
1082
+ };
1083
+ }
1084
+ function kindOf(c) {
1085
+ if (c.kind === "dhw") return "dhw";
1086
+ const mode = textOf$1(c.heatCoolMode);
1087
+ return mode !== null && COOLING_VALUES.includes(mode) ? "cooling" : "heating";
1088
+ }
1089
+ function labelOf(c) {
1090
+ const n = c.id.replace(/^\D+/, "");
1091
+ if (c.kind === "dhw") return n === "1" || n === "" ? "Hot water" : `Hot water ${n}`;
1092
+ return n === "" ? "Heating circuit" : `Heating circuit ${n}`;
1093
+ }
1094
+ function textOf$1(s) {
1095
+ return s !== null && typeof s.value === "string" && s.value.length > 0 ? s.value : null;
1096
+ }
1097
+ /**
1098
+ * A mode the gateway answered with an empty value is off (or "none" when the
1099
+ * gateway did not list off). A mode it did not answer at all stays null.
1100
+ */
1101
+ function modeValue(s) {
1102
+ if (s === null || typeof s.value !== "string") return null;
1103
+ if (s.value.length > 0) return s.value;
1104
+ return s.allowed.includes("off") ? "off" : "none";
1105
+ }
1106
+ function optionOf(s) {
1107
+ if (s === null) return null;
1108
+ return {
1109
+ value: modeValue(s),
1110
+ allowed: [...s.allowed]
1111
+ };
1112
+ }
1113
+ function rangeOf(s) {
1114
+ if (s === null) return null;
1115
+ return {
1116
+ value: numberValue$1(s),
1117
+ min: s.min,
1118
+ max: s.max,
1119
+ step: null
1120
+ };
1121
+ }
1122
+ function requireCircuit(reading, circuitId, want) {
1123
+ const found = reading.circuits.find((c) => c.id === circuitId && c.kind === want);
1124
+ if (found === void 0) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump has no ${want} circuit ${circuitId}`);
1125
+ return found;
1126
+ }
1127
+ /** The accepted value of an option setting, or a refusal naming why the gateway would refuse. */
1128
+ function acceptedOption(s, what, mode) {
1129
+ if (s === null) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${what} is not available`);
1130
+ if (s.writeable === false || s.allowed.length === 0) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${what} is read-only on this gateway`);
1131
+ if (!s.allowed.includes(mode)) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: "${mode}" is not an accepted value of ${what} (${s.allowed.join(", ")})`);
1132
+ return mode;
1133
+ }
1134
+ /** The value of a range write, within the limits the gateway gave, or a refusal. */
1135
+ function acceptedRange(s, what, value) {
1136
+ if (s === null) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${what} is not available`);
1137
+ if (s.writeable === false) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${what} is read-only on this gateway`);
1138
+ if (!Number.isFinite(value)) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${what} needs a finite number`);
1139
+ if (s.min === null || s.max === null) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${what} has no limits from the gateway`);
1140
+ if (value < s.min || value > s.max) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${value} is outside ${what} limits ${s.min}..${s.max}`);
1141
+ return value;
1142
+ }
1143
+ var SYSTEM_OPS = {
1144
+ silent: "silentMode",
1145
+ away: "awayMode",
1146
+ additionalHeater: "additionalHeaterMode",
1147
+ holiday: "holidayMode"
1148
+ };
1149
+ function systemModeWrite(reading, which, mode) {
1150
+ return {
1151
+ op: SYSTEM_OPS[which],
1152
+ value: acceptedOption(reading[which], `${which} mode`, mode)
1153
+ };
1154
+ }
1155
+ var CIRCUIT_OPTION_OPS = {
1156
+ operationMode: "hcOperationMode",
1157
+ heatCoolMode: "hcHeatCoolMode",
1158
+ summerWinter: "hcSuWiSwitchMode"
1159
+ };
1160
+ function circuitOptionWrite(reading, circuitId, which, mode) {
1161
+ const circuit = requireCircuit(reading, circuitId, "hc");
1162
+ return {
1163
+ op: CIRCUIT_OPTION_OPS[which],
1164
+ circuitId,
1165
+ value: acceptedOption(circuit[which], `${circuitId} ${which}`, mode)
1166
+ };
1167
+ }
1168
+ var DHW_OPTION_OPS = {
1169
+ operationMode: "dhwOperationMode",
1170
+ level: "dhwTemperatureLevel"
1171
+ };
1172
+ function dhwOptionWrite(reading, circuitId, which, mode) {
1173
+ const circuit = requireCircuit(reading, circuitId, "dhw");
1174
+ return {
1175
+ op: DHW_OPTION_OPS[which],
1176
+ circuitId,
1177
+ value: acceptedOption(circuit[which], `${circuitId} ${which}`, mode)
1178
+ };
1179
+ }
1180
+ var CIRCUIT_RANGE_OPS = {
1181
+ comfort: "hcComfortSetpoint",
1182
+ eco: "hcEcoSetpoint",
1183
+ maxFlow: "hcMaxFlowTemp"
1184
+ };
1185
+ function circuitRangeWrite(reading, circuitId, which, value) {
1186
+ const circuit = requireCircuit(reading, circuitId, "hc");
1187
+ return {
1188
+ op: CIRCUIT_RANGE_OPS[which],
1189
+ circuitId,
1190
+ value: acceptedRange(circuit[which], `${circuitId} ${which}`, value)
1191
+ };
1192
+ }
1193
+ function dhwChargeDurationWrite(reading, circuitId, minutes) {
1194
+ return {
1195
+ op: "dhwChargeDuration",
1196
+ circuitId,
1197
+ value: acceptedRange(requireCircuit(reading, circuitId, "dhw").chargeDuration, `${circuitId} charge duration`, minutes)
1198
+ };
1199
+ }
1200
+ function extraHotWaterWrite(reading, circuitId) {
1201
+ const charge = requireCircuit(reading, circuitId, "dhw").charge;
1202
+ return {
1203
+ op: "dhwCharge",
1204
+ circuitId,
1205
+ value: acceptedOption(charge, `${circuitId} extra hot water`, "start")
1206
+ };
1207
+ }
345
1208
  var HOMECOM_PROBE_CLOCK = "/gateway/DateTime";
346
1209
  var HOMECOM_PROBE_FIRMWARE = "/gateway/versionFirmware";
347
1210
  var HYDRONIC_DROPPED = "heat-source, ventilation, pool and energy resources are not published on a cap";
@@ -659,575 +1522,227 @@ function refName(ref, id) {
659
1522
  const raw = ref["name"];
660
1523
  if (typeof raw !== "string") return null;
661
1524
  const trimmed = raw.trim();
662
- if (trimmed.length === 0 || trimmed === id) return null;
663
- return trimmed;
664
- }
665
- function referenceKeys(body, prefix) {
666
- return circuitRefs(body, prefix).map((ref) => ref.id);
667
- }
668
- function indexStandard(body) {
669
- const map = /* @__PURE__ */ new Map();
670
- if (!isRecord$2(body) || !Array.isArray(body["references"])) return map;
671
- for (const ref of body["references"]) {
672
- if (!isRecord$2(ref) || typeof ref["id"] !== "string") continue;
673
- const tail = ref["id"].split("/").filter((part) => part.length > 0).at(-1) ?? ref["id"];
674
- const envelope = readEnvelope(ref);
675
- if (envelope !== null) map.set(tail, envelope);
676
- }
677
- return map;
678
- }
679
- function numberOf(envelope) {
680
- if (envelope === null) return null;
681
- return typeof envelope.value === "number" && Number.isFinite(envelope.value) ? envelope.value : null;
682
- }
683
- function stringOf(envelope) {
684
- if (envelope === null || typeof envelope.value !== "string" || envelope.value.length === 0) return null;
685
- return envelope.value;
686
- }
687
- function swingOf(envelope) {
688
- const value = stringOf(envelope);
689
- if (value === null) return null;
690
- return value === "swing";
691
- }
692
- function safeToken(value) {
693
- if (value === null || !TOKEN.test(value)) return null;
694
- return value;
695
- }
696
- function finite(raw) {
697
- return typeof raw === "number" && Number.isFinite(raw) ? raw : null;
698
- }
699
- function stringList(raw) {
700
- if (!Array.isArray(raw)) return [];
701
- const out = [];
702
- for (const item of raw) if (typeof item === "string") out.push(item);
703
- return out;
704
- }
705
- function isRecord$2(value) {
706
- return typeof value === "object" && value !== null && !Array.isArray(value);
707
- }
708
- //#endregion
709
- //#region src/homecom/homecom-heat-pump.ts
710
- /**
711
- * Heat-pump resources of a hydronic gateway (D1026).
712
- *
713
- * UNVERIFIED: the paths below come from the layout of the public HomeCom Easy
714
- * tree as the MIT `homecom_alt` library lists it, and were not checked against a
715
- * live heat pump. They live in this one table so a wrong path is a one-line
716
- * fix, and a resource the gateway does not answer reads as `null` (never 0),
717
- * which the hero then simply does not draw.
718
- */
719
- var HEAT_PUMP_PATHS = {
720
- system: {
721
- silent: "/heatSources/hs1/silentMode",
722
- away: "/system/awayMode",
723
- additionalHeater: "/heatSources/hs1/additionalHeaterMode",
724
- holiday: "/system/holidayMode"
725
- },
726
- readings: {
727
- thermalW: "/heatSources/hs1/heatPumpPower",
728
- electricalW: "/heatSources/hs1/electricalPower",
729
- compressorPct: "/heatSources/hs1/compressorModulation",
730
- supply: "/heatSources/hs1/supplyTemperature",
731
- return: "/heatSources/hs1/returnTemperature",
732
- outdoor: "/system/sensors/temperatures/outdoor_t1",
733
- thermalKwh: "/recordings/heatSources/total/thermalEnergy",
734
- electricalKwh: "/recordings/heatSources/total/electricalEnergy",
735
- pressureBar: "/heatSources/hs1/waterPressure",
736
- starts: "/heatSources/hs1/numberOfStarts"
737
- },
738
- /**
739
- * Resources only a heat pump's heat source has. A gateway that answers one of them with
740
- * anything but 404 (a 403 or a 500 included) has a heat pump it will not let us read.
741
- */
742
- identity: {
743
- heatPumpType: "/heatSources/hs1/heatPumpType",
744
- heatPumpPower: "/heatSources/hs1/heatPumpPower"
745
- },
746
- circuit: {
747
- operationMode: "operationMode",
748
- heatCoolMode: "heatCoolMode",
749
- summerWinter: "suWiSwitchMode",
750
- comfort: "temperatureLevels/comfort2",
751
- eco: "temperatureLevels/eco",
752
- maxFlow: "maxSupplyTemperature",
753
- current: "roomtemperature",
754
- target: "currentRoomSetpoint"
755
- },
756
- dhw: {
757
- operationMode: "operationMode",
758
- level: "currentTemperatureLevel",
759
- chargeDuration: "charge/duration",
760
- charge: "charge",
761
- current: "actualTemp",
762
- target: "currentSetpoint"
763
- }
764
- };
765
- /** A write the gateway would refuse, caught before it is sent. */
766
- var HomeComHeatPumpRefusalError = class extends Error {
767
- name = "HomeComHeatPumpRefusalError";
768
- };
769
- async function readHeatPump(input) {
770
- const get = safeGet(input.get, input.onFailure);
771
- const circuits = await Promise.all([...input.circuitIds.hc.map((id) => readHc(get, id)), ...input.circuitIds.dhw.map((id) => readDhwCircuit(get, id))]);
772
- const r = HEAT_PUMP_PATHS.readings;
773
- const s = HEAT_PUMP_PATHS.system;
774
- const heatSourceAnswered = await anyAnswers(input.answers, input.onFailure, [HEAT_PUMP_PATHS.identity.heatPumpType, HEAT_PUMP_PATHS.identity.heatPumpPower]);
775
- const [silent, away, additionalHeater, holiday] = await Promise.all([
776
- setting(get, s.silent),
777
- setting(get, s.away),
778
- setting(get, s.additionalHeater),
779
- setting(get, s.holiday)
780
- ]);
781
- const [thermalW, electricalW, compressorPct, supply, ret, outdoor] = await Promise.all([
782
- num(get, r.thermalW),
783
- num(get, r.electricalW),
784
- num(get, r.compressorPct),
785
- num(get, r.supply),
786
- num(get, r.return),
787
- num(get, r.outdoor)
788
- ]);
789
- const [thermalKwh, electricalKwh, pressureBar, starts] = await Promise.all([
790
- num(get, r.thermalKwh),
791
- num(get, r.electricalKwh),
792
- num(get, r.pressureBar),
793
- num(get, r.starts)
794
- ]);
795
- return {
796
- circuits,
797
- heatSourceAnswered,
798
- silent,
799
- away,
800
- additionalHeater,
801
- holiday,
802
- thermalW,
803
- electricalW,
804
- compressorPct,
805
- supply,
806
- return: ret,
807
- outdoor,
808
- thermalKwh,
809
- electricalKwh,
810
- pressureBar,
811
- starts
812
- };
813
- }
814
- async function anyAnswers(answers, onFailure, paths) {
815
- if (answers === void 0) return false;
816
- return (await Promise.all(paths.map(async (path) => {
817
- try {
818
- return await answers(path);
819
- } catch (error) {
820
- onFailure?.(path, error);
821
- return false;
822
- }
823
- }))).some(Boolean);
824
- }
825
- /** Any failure of a heat-pump read (HTTP 4xx/5xx, a throw) is "not answered", never an error. */
826
- function safeGet(get, onFailure) {
827
- return async (path) => {
828
- try {
829
- return await get(path);
830
- } catch (error) {
831
- onFailure?.(path, error);
832
- return null;
833
- }
834
- };
835
- }
836
- var SYSTEM_MODE_KEYS = [
837
- "silent",
838
- "away",
839
- "additionalHeater",
840
- "holiday"
841
- ];
842
- var HEAT_PUMP_ONLY_READINGS = [
843
- "thermalW",
844
- "electricalW",
845
- "compressorPct"
846
- ];
847
- /**
848
- * True when the gateway should be offered as a system device (`hp:0`).
849
- *
850
- * A system mode counts when the gateway answered it: silent, away, the
851
- * additional heater, or holiday. An empty string is an answer (holiday often
852
- * comes back empty); a path that did not answer is not. A heat-pump-only
853
- * reading counts too (thermal power, electrical power, compressor modulation).
854
- * So does a circuit whose `heatCoolMode` can be cooling, and a heat source that
855
- * answers a heat-pump-only resource with any status but 404: some gateways
856
- * refuse (403) or fail (500) the whole system tree and still serve the
857
- * circuits. Supply, return, outdoor, pressure, starts and the energy meters do
858
- * not count: a gas boiler's heat source answers flow, pressure and starts, and
859
- * that is not a heat pump.
860
- */
861
- function hasSystemDevice(reading) {
862
- if (SYSTEM_MODE_KEYS.some((key) => reading[key] !== null)) return true;
863
- if (HEAT_PUMP_ONLY_READINGS.some((key) => reading[key] !== null)) return true;
864
- if (reading.heatSourceAnswered) return true;
865
- return reading.circuits.some((c) => c.kind === "hc" && offersCooling(c.heatCoolMode));
866
- }
867
- /**
868
- * A boiler circuit can carry a `heatCoolMode` that only ever says heating, so the mode alone
869
- * is no proof. A circuit that can switch to (or already is in) cooling is a heat pump's.
870
- */
871
- function offersCooling(setting) {
872
- if (setting === null) return false;
873
- return setting.value === "cooling" || setting.allowed.includes("cooling");
874
- }
875
- /** Which heat-pump paths this gateway answered. One log per gateway corrects the path table. */
876
- function heatPumpPathCensus(reading) {
877
- const rows = [
878
- ...systemPathRows(reading),
879
- ...readingPathRows(reading),
880
- ...reading.circuits.flatMap(circuitPathRows)
881
- ];
882
- const answered = [];
883
- const unanswered = [];
884
- for (const row of rows) if (row.present) answered.push(row.path);
885
- else unanswered.push(row.path);
886
- return {
887
- answered,
888
- unanswered
889
- };
890
- }
891
- function systemPathRows(reading) {
892
- const s = HEAT_PUMP_PATHS.system;
893
- return [
894
- {
895
- path: s.silent,
896
- present: reading.silent !== null
897
- },
898
- {
899
- path: s.away,
900
- present: reading.away !== null
901
- },
902
- {
903
- path: s.additionalHeater,
904
- present: reading.additionalHeater !== null
905
- },
906
- {
907
- path: s.holiday,
908
- present: reading.holiday !== null
909
- }
910
- ];
911
- }
912
- function readingPathRows(reading) {
913
- const r = HEAT_PUMP_PATHS.readings;
914
- return [
915
- {
916
- path: r.thermalW,
917
- present: reading.thermalW !== null
918
- },
919
- {
920
- path: r.electricalW,
921
- present: reading.electricalW !== null
922
- },
923
- {
924
- path: r.compressorPct,
925
- present: reading.compressorPct !== null
926
- },
927
- {
928
- path: r.supply,
929
- present: reading.supply !== null
930
- },
931
- {
932
- path: r.return,
933
- present: reading.return !== null
934
- },
935
- {
936
- path: r.outdoor,
937
- present: reading.outdoor !== null
938
- },
939
- {
940
- path: r.thermalKwh,
941
- present: reading.thermalKwh !== null
942
- },
943
- {
944
- path: r.electricalKwh,
945
- present: reading.electricalKwh !== null
946
- },
947
- {
948
- path: r.pressureBar,
949
- present: reading.pressureBar !== null
950
- },
951
- {
952
- path: r.starts,
953
- present: reading.starts !== null
954
- }
955
- ];
956
- }
957
- function circuitPathRows(circuit) {
958
- if (circuit.kind === "dhw") return dhwPathRows(circuit);
959
- return hcPathRows(circuit);
960
- }
961
- function hcPathRows(circuit) {
962
- const base = `/heatingCircuits/${circuit.id}`;
963
- const p = HEAT_PUMP_PATHS.circuit;
964
- return [
965
- {
966
- path: `${base}/${p.operationMode}`,
967
- present: circuit.operationMode !== null
968
- },
969
- {
970
- path: `${base}/${p.heatCoolMode}`,
971
- present: circuit.heatCoolMode !== null
972
- },
973
- {
974
- path: `${base}/${p.summerWinter}`,
975
- present: circuit.summerWinter !== null
976
- },
977
- {
978
- path: `${base}/${p.comfort}`,
979
- present: circuit.comfort !== null
980
- },
981
- {
982
- path: `${base}/${p.eco}`,
983
- present: circuit.eco !== null
984
- },
985
- {
986
- path: `${base}/${p.maxFlow}`,
987
- present: circuit.maxFlow !== null
988
- },
989
- {
990
- path: `${base}/${p.current}`,
991
- present: circuit.currentTemp !== null
992
- },
993
- {
994
- path: `${base}/${p.target}`,
995
- present: circuit.targetTemp !== null
996
- }
997
- ];
998
- }
999
- function dhwPathRows(circuit) {
1000
- const base = `/dhwCircuits/${circuit.id}`;
1001
- const p = HEAT_PUMP_PATHS.dhw;
1002
- return [
1003
- {
1004
- path: `${base}/${p.operationMode}`,
1005
- present: circuit.operationMode !== null
1006
- },
1007
- {
1008
- path: `${base}/${p.level}`,
1009
- present: circuit.level !== null
1010
- },
1011
- {
1012
- path: `${base}/${p.chargeDuration}`,
1013
- present: circuit.chargeDuration !== null
1014
- },
1015
- {
1016
- path: `${base}/${p.charge}`,
1017
- present: circuit.charge !== null
1018
- },
1019
- {
1020
- path: `${base}/${p.current}`,
1021
- present: circuit.currentTemp !== null
1022
- },
1023
- {
1024
- path: `${base}/${p.target}`,
1025
- present: circuit.targetTemp !== null
1026
- }
1027
- ];
1525
+ if (trimmed.length === 0 || trimmed === id) return null;
1526
+ return trimmed;
1028
1527
  }
1029
- async function readHc(get, id) {
1030
- const base = `/heatingCircuits/${id}`;
1031
- const p = HEAT_PUMP_PATHS.circuit;
1032
- const [operationMode, heatCoolMode, summerWinter, comfort, eco, maxFlow, current, target] = await Promise.all([
1033
- setting(get, `${base}/${p.operationMode}`),
1034
- setting(get, `${base}/${p.heatCoolMode}`),
1035
- setting(get, `${base}/${p.summerWinter}`),
1036
- setting(get, `${base}/${p.comfort}`),
1037
- setting(get, `${base}/${p.eco}`),
1038
- setting(get, `${base}/${p.maxFlow}`),
1039
- num(get, `${base}/${p.current}`),
1040
- num(get, `${base}/${p.target}`)
1041
- ]);
1042
- return {
1043
- id,
1044
- kind: "hc",
1045
- currentTemp: current,
1046
- targetTemp: target,
1047
- operationMode,
1048
- heatCoolMode,
1049
- summerWinter,
1050
- comfort,
1051
- eco,
1052
- maxFlow,
1053
- level: null,
1054
- chargeDuration: null,
1055
- charge: null
1056
- };
1528
+ function referenceKeys(body, prefix) {
1529
+ return circuitRefs(body, prefix).map((ref) => ref.id);
1057
1530
  }
1058
- async function readDhwCircuit(get, id) {
1059
- const base = `/dhwCircuits/${id}`;
1060
- const p = HEAT_PUMP_PATHS.dhw;
1061
- const [operationMode, level, chargeDuration, charge, current, target] = await Promise.all([
1062
- setting(get, `${base}/${p.operationMode}`),
1063
- setting(get, `${base}/${p.level}`),
1064
- setting(get, `${base}/${p.chargeDuration}`),
1065
- setting(get, `${base}/${p.charge}`),
1066
- num(get, `${base}/${p.current}`),
1067
- num(get, `${base}/${p.target}`)
1068
- ]);
1069
- return {
1070
- id,
1071
- kind: "dhw",
1072
- currentTemp: current,
1073
- targetTemp: target,
1074
- operationMode,
1075
- heatCoolMode: null,
1076
- summerWinter: null,
1077
- comfort: null,
1078
- eco: null,
1079
- maxFlow: null,
1080
- level,
1081
- chargeDuration,
1082
- charge
1083
- };
1531
+ function indexStandard(body) {
1532
+ const map = /* @__PURE__ */ new Map();
1533
+ if (!isRecord$2(body) || !Array.isArray(body["references"])) return map;
1534
+ for (const ref of body["references"]) {
1535
+ if (!isRecord$2(ref) || typeof ref["id"] !== "string") continue;
1536
+ const tail = ref["id"].split("/").filter((part) => part.length > 0).at(-1) ?? ref["id"];
1537
+ const envelope = readEnvelope(ref);
1538
+ if (envelope !== null) map.set(tail, envelope);
1539
+ }
1540
+ return map;
1084
1541
  }
1085
- async function setting(get, path) {
1086
- const envelope = readEnvelope(await get(path));
1087
- return envelope === null ? null : settingOf(path, envelope);
1542
+ function numberOf(envelope) {
1543
+ if (envelope === null) return null;
1544
+ return typeof envelope.value === "number" && Number.isFinite(envelope.value) ? envelope.value : null;
1088
1545
  }
1089
- async function num(get, path) {
1090
- const envelope = readEnvelope(await get(path));
1091
- return envelope !== null && typeof envelope.value === "number" && Number.isFinite(envelope.value) ? envelope.value : null;
1546
+ function stringOf(envelope) {
1547
+ if (envelope === null || typeof envelope.value !== "string" || envelope.value.length === 0) return null;
1548
+ return envelope.value;
1092
1549
  }
1093
- function settingOf(path, envelope) {
1094
- return {
1095
- path,
1096
- value: envelope.value,
1097
- min: envelope.min,
1098
- max: envelope.max,
1099
- allowed: envelope.allowed
1100
- };
1550
+ function swingOf(envelope) {
1551
+ const value = stringOf(envelope);
1552
+ if (value === null) return null;
1553
+ return value === "swing";
1101
1554
  }
1102
- function heatPumpStatusFrom(reading, now) {
1103
- return {
1104
- circuits: reading.circuits.map(circuitOf),
1105
- system: {
1106
- silent: optionOf(reading.silent),
1107
- away: optionOf(reading.away),
1108
- additionalHeater: optionOf(reading.additionalHeater),
1109
- holiday: optionOf(reading.holiday)
1110
- },
1111
- power: {
1112
- thermalW: reading.thermalW,
1113
- electricalW: reading.electricalW,
1114
- compressorPct: reading.compressorPct
1115
- },
1116
- temperatures: {
1117
- supply: reading.supply,
1118
- return: reading.return,
1119
- outdoor: reading.outdoor
1120
- },
1121
- energy: {
1122
- thermalKwh: reading.thermalKwh,
1123
- electricalKwh: reading.electricalKwh
1124
- },
1125
- waterPressureBar: reading.pressureBar,
1126
- starts: reading.starts,
1127
- lastFetchedAt: now
1128
- };
1555
+ function safeToken(value) {
1556
+ if (value === null || !TOKEN.test(value)) return null;
1557
+ return value;
1129
1558
  }
1130
- function circuitOf(c) {
1131
- const kind = kindOf(c);
1132
- return {
1133
- id: c.id,
1134
- kind,
1135
- label: labelOf(c),
1136
- icon: kind === "dhw" ? "droplets" : kind === "cooling" ? "snowflake" : "flame",
1137
- currentTemp: c.currentTemp,
1138
- targetTemp: c.targetTemp,
1139
- mode: modeValue(c.operationMode),
1140
- active: null,
1141
- operationMode: optionOf(c.operationMode),
1142
- heatCoolMode: optionOf(c.heatCoolMode),
1143
- summerWinter: optionOf(c.summerWinter),
1144
- comfortSetpoint: rangeOf(c.comfort),
1145
- ecoSetpoint: rangeOf(c.eco),
1146
- maxFlowTemp: rangeOf(c.maxFlow),
1147
- temperatureLevel: optionOf(c.level),
1148
- chargeDuration: rangeOf(c.chargeDuration),
1149
- extraHotWater: c.charge === null ? null : textOf$1(c.charge) === "start"
1150
- };
1559
+ function finite(raw) {
1560
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : null;
1151
1561
  }
1152
- function kindOf(c) {
1153
- if (c.kind === "dhw") return "dhw";
1154
- return textOf$1(c.heatCoolMode) === "cooling" ? "cooling" : "heating";
1562
+ function stringList(raw) {
1563
+ if (!Array.isArray(raw)) return [];
1564
+ const out = [];
1565
+ for (const item of raw) if (typeof item === "string") out.push(item);
1566
+ return out;
1155
1567
  }
1156
- function labelOf(c) {
1157
- const n = c.id.replace(/^\D+/, "");
1158
- if (c.kind === "dhw") return n === "1" || n === "" ? "Hot water" : `Hot water ${n}`;
1159
- return n === "" ? "Heating circuit" : `Heating circuit ${n}`;
1568
+ function isRecord$2(value) {
1569
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1160
1570
  }
1161
- function textOf$1(s) {
1571
+ //#endregion
1572
+ //#region src/homecom/homecom-bridge-snapshot.ts
1573
+ /**
1574
+ * The climate and water-heater view of a heat-pump gateway (k40, k30, icom), built from the
1575
+ * bridge reading, and the climate writes it accepts. The heat-pump view rides along when
1576
+ * the gateway has one (D1026).
1577
+ */
1578
+ function numberValue(s) {
1579
+ return s !== null && typeof s.value === "number" ? s.value : null;
1580
+ }
1581
+ function textValue(s) {
1162
1582
  return s !== null && typeof s.value === "string" && s.value.length > 0 ? s.value : null;
1163
1583
  }
1164
1584
  /**
1165
- * A mode the gateway answered with an empty value is off (or "none" when the
1166
- * gateway did not list off). A mode it did not answer at all stays null.
1585
+ * Cooling writes the cooling setpoint; manual writes the manual room setpoint; auto writes the
1586
+ * temperature level (comfort or eco) the circuit runs on now. The library reads no active level
1587
+ * for a circuit, so it is the one level whose temperature equals the current room setpoint;
1588
+ * none or both matching is unknown, never a guess.
1167
1589
  */
1168
- function modeValue(s) {
1169
- if (s === null || typeof s.value !== "string") return null;
1170
- if (s.value.length > 0) return s.value;
1171
- return s.allowed.includes("off") ? "off" : "none";
1590
+ function activeSetpoint(hc) {
1591
+ if (circuitIsCooling(hc)) return {
1592
+ kind: "setting",
1593
+ op: "hcCoolingRoomSetpoint",
1594
+ what: `${hc.id} cooling setpoint`,
1595
+ setting: hc.coolingRoomSetpoint
1596
+ };
1597
+ if (textValue(hc.operationMode) !== "auto") return {
1598
+ kind: "setting",
1599
+ op: "hcManualRoomSetpoint",
1600
+ what: `${hc.id} setpoint`,
1601
+ setting: hc.manualRoomSetpoint
1602
+ };
1603
+ const current = numberValue(hc.currentRoomSetpoint);
1604
+ const comfort = current !== null && numberValue(hc.comfortSetpoint) === current;
1605
+ if (comfort === (current !== null && numberValue(hc.ecoSetpoint) === current)) return {
1606
+ kind: "unknown",
1607
+ reason: `HomeCom ${hc.id}: the active level in auto mode cannot be told from the reading`
1608
+ };
1609
+ return comfort ? {
1610
+ kind: "setting",
1611
+ op: "hcComfortSetpoint",
1612
+ what: `${hc.id} comfort level`,
1613
+ setting: hc.comfortSetpoint
1614
+ } : {
1615
+ kind: "setting",
1616
+ op: "hcEcoSetpoint",
1617
+ what: `${hc.id} eco level`,
1618
+ setting: hc.ecoSetpoint
1619
+ };
1172
1620
  }
1173
- function optionOf(s) {
1174
- if (s === null) return null;
1621
+ function climateCircuit(hc) {
1622
+ const cooling = circuitIsCooling(hc);
1623
+ const active = activeSetpoint(hc);
1624
+ const limits = active.kind === "setting" ? active.setting : null;
1625
+ const modes = hvacModesFromAllowed(hc.operationMode?.allowed ?? []);
1175
1626
  return {
1176
- value: modeValue(s),
1177
- allowed: [...s.allowed]
1627
+ id: hc.id,
1628
+ name: heatingCircuitName(hc.id, hc.name),
1629
+ operationMode: textValue(hc.operationMode) ?? "off",
1630
+ setpoint: cooling ? numberValue(hc.coolingRoomSetpoint) ?? numberValue(hc.currentRoomSetpoint) : numberValue(hc.currentRoomSetpoint) ?? numberValue(hc.manualRoomSetpoint),
1631
+ currentRoomTemp: hc.roomTemp,
1632
+ minTemp: limits?.min ?? null,
1633
+ maxTemp: limits?.max ?? null,
1634
+ currentHumidity: hc.humidity,
1635
+ season: cooling ? "cooling" : textValue(hc.currentSuWiMode),
1636
+ fanMode: "",
1637
+ availableFanModes: [],
1638
+ swingVertical: null,
1639
+ swingHorizontal: null,
1640
+ availableModes: modes.length > 0 ? modes : [...HYDRONIC_MODES]
1178
1641
  };
1179
1642
  }
1180
- function rangeOf(s) {
1181
- if (s === null) return null;
1643
+ function waterHeater(dhw) {
1644
+ const target = dhwTargetSetting(dhw);
1645
+ const modes = dhw.operationMode?.allowed ?? [];
1182
1646
  return {
1183
- value: typeof s.value === "number" && Number.isFinite(s.value) ? s.value : null,
1184
- min: s.min,
1185
- max: s.max,
1186
- step: null
1647
+ currentTemp: dhw.actualTemp,
1648
+ targetTemp: numberValue(target),
1649
+ operationMode: textValue(dhw.operationMode),
1650
+ availableModes: [...modes],
1651
+ minTemp: target?.min ?? null,
1652
+ maxTemp: target?.max ?? null,
1653
+ circuitId: dhw.id
1187
1654
  };
1188
1655
  }
1189
- function requireCircuit(reading, circuitId, want) {
1190
- const found = reading.circuits.find((c) => c.id === circuitId && c.kind === want);
1191
- if (found === void 0) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump has no ${want} circuit ${circuitId}`);
1192
- return found;
1193
- }
1194
- function optionWrite(s, what, mode) {
1195
- if (s === null) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${what} is not available`);
1196
- if (s.allowed.length === 0) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${what} is read-only on this gateway`);
1197
- if (!s.allowed.includes(mode)) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: "${mode}" is not an accepted value of ${what} (${s.allowed.join(", ")})`);
1656
+ function bridgeSnapshot(input) {
1657
+ const { reading } = input;
1658
+ const firstDhw = reading.dhwCircuits[0];
1659
+ if (reading.dhwCircuits.length > 1) {
1660
+ const extra = reading.dhwCircuits.slice(1).map((c) => c.id);
1661
+ input.logDrop(`extra DHW circuits are on the heat pump only: ${extra.join(", ")}`);
1662
+ }
1663
+ for (const [path, outcome] of Object.entries(reading.heatSourceProbe)) if (outcome === "transient") input.logTransientProbe(path);
1664
+ const heatPump = heatPumpReadingFrom(reading);
1198
1665
  return {
1199
- path: s.path,
1200
- value: mode
1666
+ gateway: {
1667
+ gatewayId: input.gatewayId,
1668
+ name: input.gatewayId,
1669
+ model: input.deviceType
1670
+ },
1671
+ circuits: reading.heatingCircuits.map(climateCircuit),
1672
+ dhw: firstDhw === void 0 ? null : waterHeater(firstDhw),
1673
+ deviceType: input.deviceType,
1674
+ family: "hydronic",
1675
+ bridge: reading,
1676
+ ...hasSystemDevice(heatPump) ? { heatPump } : {}
1201
1677
  };
1202
1678
  }
1203
- function rangeWrite(s, what, value) {
1204
- if (s === null) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${what} is not available`);
1205
- if (!Number.isFinite(value)) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${what} needs a finite number`);
1206
- if (s.min === null || s.max === null) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${what} has no limits from the gateway`);
1207
- if (value < s.min || value > s.max) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${value} is outside ${what} limits ${s.min}..${s.max}`);
1679
+ function findHc(reading, circuitId) {
1680
+ const found = reading.heatingCircuits.find((c) => c.id === circuitId);
1681
+ if (found === void 0) throw new HomeComHeatPumpRefusalError(`HomeCom has no heating circuit ${circuitId}`);
1682
+ return found;
1683
+ }
1684
+ function firstDhw(reading) {
1685
+ const found = reading.dhwCircuits[0];
1686
+ if (found === void 0) throw new HomeComHeatPumpRefusalError("HomeCom has no DHW circuit");
1687
+ return found;
1688
+ }
1689
+ function bridgeSetpointWrite(reading, circuitId, temp) {
1690
+ const active = activeSetpoint(findHc(reading, circuitId));
1691
+ if (active.kind === "unknown") throw new HomeComHeatPumpRefusalError(active.reason);
1692
+ const value = acceptedRange(active.setting, active.what, temp);
1208
1693
  return {
1209
- path: s.path,
1694
+ op: active.op,
1695
+ circuitId,
1210
1696
  value
1211
1697
  };
1212
1698
  }
1213
- function systemModeWrite(reading, which, mode) {
1214
- return optionWrite(reading[which], `${which} mode`, mode);
1215
- }
1216
- function circuitOptionWrite(reading, circuitId, which, mode) {
1217
- return optionWrite(requireCircuit(reading, circuitId, "hc")[which], `${circuitId} ${which}`, mode);
1218
- }
1219
- function dhwOptionWrite(reading, circuitId, which, mode) {
1220
- return optionWrite(requireCircuit(reading, circuitId, "dhw")[which], `${circuitId} ${which}`, mode);
1699
+ /** The gateway's token for an HVAC mode: heat is `manual`; cool is a heat/cool mode. */
1700
+ function operationToken(mode) {
1701
+ switch (mode) {
1702
+ case "Heat":
1703
+ case "heat": return "manual";
1704
+ case "Auto": return "auto";
1705
+ case "Off": return "off";
1706
+ default: return mode;
1707
+ }
1221
1708
  }
1222
- function circuitRangeWrite(reading, circuitId, which, value) {
1223
- return rangeWrite(requireCircuit(reading, circuitId, "hc")[which], `${circuitId} ${which}`, value);
1709
+ function bridgeModeWrite(reading, circuitId, mode) {
1710
+ const hc = findHc(reading, circuitId);
1711
+ if (mode === "cool" || mode === "Cool") {
1712
+ const token = (hc.heatCoolMode?.allowed ?? []).includes("cool") ? "cool" : "cooling";
1713
+ return {
1714
+ op: "hcHeatCoolMode",
1715
+ circuitId,
1716
+ value: acceptedOption(hc.heatCoolMode, `${circuitId} heat/cool mode`, token)
1717
+ };
1718
+ }
1719
+ return {
1720
+ op: "hcOperationMode",
1721
+ circuitId,
1722
+ value: acceptedOption(hc.operationMode, `${circuitId} mode`, operationToken(mode))
1723
+ };
1224
1724
  }
1225
- function dhwChargeDurationWrite(reading, circuitId, minutes) {
1226
- return rangeWrite(requireCircuit(reading, circuitId, "dhw").chargeDuration, `${circuitId} charge duration`, minutes);
1725
+ /** The hot-water target is the temperature of the level the tank runs on now. */
1726
+ function bridgeDhwTargetWrite(reading, temp) {
1727
+ const dhw = firstDhw(reading);
1728
+ const level = textValue(dhw.currentTemperatureLevel);
1729
+ if (level === null || level === "off" || dhw.temperatureLevels[level] === void 0) throw new HomeComHeatPumpRefusalError("HomeCom DHW: setpoint not supported");
1730
+ const value = acceptedRange(dhw.temperatureLevels[level] ?? null, `${dhw.id} ${level}`, temp);
1731
+ return {
1732
+ op: "dhwLevelTemp",
1733
+ circuitId: dhw.id,
1734
+ level,
1735
+ value
1736
+ };
1227
1737
  }
1228
- function extraHotWaterWrite(reading, circuitId) {
1229
- const charge = requireCircuit(reading, circuitId, "dhw").charge;
1230
- return optionWrite(charge, `${circuitId} extra hot water`, "start");
1738
+ function bridgeDhwModeWrite(reading, mode) {
1739
+ const dhw = firstDhw(reading);
1740
+ const value = acceptedOption(dhw.operationMode, `${dhw.id} mode`, mode);
1741
+ return {
1742
+ op: "dhwOperationMode",
1743
+ circuitId: dhw.id,
1744
+ value
1745
+ };
1231
1746
  }
1232
1747
  //#endregion
1233
1748
  //#region src/homecom/homecom-oauth.ts
@@ -1340,7 +1855,8 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
1340
1855
  let last = null;
1341
1856
  let refreshTail = Promise.resolve();
1342
1857
  const dropped = /* @__PURE__ */ new Set();
1343
- let heatPumpPathsLogged = false;
1858
+ const bridgeFailuresLogged = /* @__PURE__ */ new Set();
1859
+ const bridge = deps.bridge;
1344
1860
  const base = homeComApiBase(config.region);
1345
1861
  function authHeaders() {
1346
1862
  return {
@@ -1409,17 +1925,6 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
1409
1925
  if (!res.ok) throw new Error(`HomeCom API ${path} failed: HTTP ${res.status}`);
1410
1926
  return res.body;
1411
1927
  }
1412
- /**
1413
- * Evidence that the resource exists: a 2xx, or the 403 the gateway gives a resource it
1414
- * serves but will not show. 404 is "absent". Anything else (429, 5xx) is a transient
1415
- * failure, so it is NOT evidence either way: it is logged and counts as unknown.
1416
- */
1417
- async function resourceAnswers(path) {
1418
- const res = await authed(homeComResourceUrl(gatewayId, path), { method: "GET" });
1419
- if (res.ok || res.status === 403) return true;
1420
- if (res.status !== 404) logHeatPumpFailure(path, /* @__PURE__ */ new Error(`HTTP ${res.status}`));
1421
- return false;
1422
- }
1423
1928
  async function putWrite(write) {
1424
1929
  const res = await authed(homeComResourceUrl(gatewayId, write.path), {
1425
1930
  method: "PUT",
@@ -1485,58 +1990,95 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
1485
1990
  }
1486
1991
  async function fetchSystem() {
1487
1992
  const resolved = await resolveDeviceType();
1488
- const withHeatPump = await attachHeatPump(await readPointtSnapshot({
1489
- deviceType: resolved.deviceType,
1490
- family: resolved.family,
1993
+ const snapshot = await readSnapshot(resolved.deviceType, resolved.family);
1994
+ last = snapshot;
1995
+ return snapshot;
1996
+ }
1997
+ async function readSnapshot(type, family) {
1998
+ if (isBridgeDeviceType(type)) if (bridge !== void 0) {
1999
+ const reading = await readThroughBridge(bridge, type);
2000
+ if (reading !== null) return bridgeSnapshot({
2001
+ gatewayId,
2002
+ deviceType: type,
2003
+ reading,
2004
+ logDrop,
2005
+ logTransientProbe
2006
+ });
2007
+ } else logDrop("the HomeCom bridge is not available (no Python): the heat pump is not read");
2008
+ return readPointtSnapshot({
2009
+ deviceType: type,
2010
+ family,
1491
2011
  gatewayId,
1492
2012
  get: getResource,
1493
2013
  logDrop
1494
- }));
1495
- last = withHeatPump;
1496
- return withHeatPump;
1497
- }
1498
- async function attachHeatPump(snapshot) {
1499
- if (snapshot.family !== "hydronic" || snapshot.deviceType === "rrc2") return snapshot;
1500
- const dhwId = snapshot.dhw?.circuitId;
1501
- const heatPump = await readHeatPump({
1502
- get: getResource,
1503
- onFailure: logHeatPumpFailure,
1504
- answers: resourceAnswers,
1505
- circuitIds: {
1506
- hc: snapshot.circuits.map((circuit) => circuit.id),
1507
- dhw: dhwId === void 0 ? [] : [dhwId]
1508
- }
1509
2014
  });
1510
- logHeatPumpPaths(heatPump);
1511
- return hasSystemDevice(heatPump) ? {
1512
- ...snapshot,
1513
- heatPump
1514
- } : snapshot;
1515
- }
1516
- function logHeatPumpPaths(reading) {
1517
- if (heatPumpPathsLogged) return;
1518
- heatPumpPathsLogged = true;
1519
- const census = heatPumpPathCensus(reading);
1520
- logger?.info("HomeCom heat-pump paths", {
1521
- tags: { gatewayId },
1522
- meta: {
1523
- answered: census.answered,
1524
- unanswered: census.unanswered
2015
+ }
2016
+ /**
2017
+ * A failed bridge read must not take the climate and water-heater devices down, nor change
2018
+ * the heat pump's shape: anything but an authentication failure keeps the last bridge
2019
+ * reading (so an adopted heat pump keeps its values and discovery its devices), or — before
2020
+ * any bridge read has succeeded — falls back to the plain climate resources. Named once per
2021
+ * failure kind until a read succeeds again.
2022
+ */
2023
+ async function readThroughBridge(from, type) {
2024
+ try {
2025
+ const reading = await bridgeCall((t) => from.read(bridgeTarget(t, type)));
2026
+ bridgeFailuresLogged.clear();
2027
+ return reading;
2028
+ } catch (error) {
2029
+ if (!(error instanceof HomeComBridgeError)) throw error;
2030
+ const kept = last?.bridge ?? null;
2031
+ if (!bridgeFailuresLogged.has(error.kind)) {
2032
+ bridgeFailuresLogged.add(error.kind);
2033
+ logger?.warn(kept === null ? "HomeCom bridge read failed; the climate resources are read until it answers" : "HomeCom bridge read failed; the last bridge reading is kept until it answers", {
2034
+ tags: { gatewayId },
2035
+ meta: {
2036
+ kind: error.kind,
2037
+ error: error.message
2038
+ }
2039
+ });
1525
2040
  }
1526
- });
2041
+ return kept;
2042
+ }
1527
2043
  }
1528
- function logHeatPumpFailure(path, error) {
1529
- const key = `heat-pump-read:${path}`;
2044
+ function logTransientProbe(path) {
2045
+ const key = `probe:${path}`;
1530
2046
  if (dropped.has(key)) return;
1531
2047
  dropped.add(key);
1532
- logger?.warn("HomeCom heat-pump resource could not be read; it is skipped", {
2048
+ logger?.warn("HomeCom heat-source probe failed transiently; it does not count as evidence", {
1533
2049
  tags: { gatewayId },
1534
- meta: {
1535
- path,
1536
- error: error instanceof Error ? error.message : String(error)
1537
- }
2050
+ meta: { path }
1538
2051
  });
1539
2052
  }
2053
+ function bridgeTarget(t, type) {
2054
+ return {
2055
+ token: t,
2056
+ gatewayId,
2057
+ deviceType: type
2058
+ };
2059
+ }
2060
+ /** One bridge run; an `auth` answer refreshes the token once and runs it again. */
2061
+ async function bridgeCall(run) {
2062
+ const used = token;
2063
+ try {
2064
+ return await run(used);
2065
+ } catch (error) {
2066
+ if (!(error instanceof HomeComBridgeError) || error.kind !== "auth") throw error;
2067
+ if (!refreshOnUnauthorized) throw new HomeComAuthError("token expired");
2068
+ }
2069
+ await refreshIfStale(used);
2070
+ try {
2071
+ return await run(token);
2072
+ } catch (error) {
2073
+ if (error instanceof HomeComBridgeError && error.kind === "auth") throw new HomeComAuthError("token expired after refresh");
2074
+ throw error;
2075
+ }
2076
+ }
2077
+ async function bridgeWrite(snapshot, write) {
2078
+ const type = snapshot.deviceType;
2079
+ if (bridge === void 0 || !isBridgeDeviceType(type)) throw new Error("HomeCom heat-pump writes need the HomeCom bridge (embedded Python).");
2080
+ await bridgeCall((t) => bridge.write(bridgeTarget(t, type), write));
2081
+ }
1540
2082
  async function probePath(path) {
1541
2083
  const res = await authed(homeComResourceUrl(gatewayId, path), {
1542
2084
  method: "GET",
@@ -1564,16 +2106,36 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
1564
2106
  throw new Error(`HomeCom gateway ${gatewayId} did not answer /gateway/DateTime or /gateway/versionFirmware.`);
1565
2107
  },
1566
2108
  async setCircuitSetpoint(circuitId, temp) {
1567
- await putWrite(circuitSetpointWrite(await requireSnapshot(), circuitId, temp));
2109
+ const snapshot = await requireSnapshot();
2110
+ if (snapshot.bridge !== void 0) {
2111
+ await bridgeWrite(snapshot, bridgeSetpointWrite(snapshot.bridge, circuitId, temp));
2112
+ return;
2113
+ }
2114
+ await putWrite(circuitSetpointWrite(snapshot, circuitId, temp));
1568
2115
  },
1569
2116
  async setCircuitMode(circuitId, mode) {
1570
- for (const write of circuitModeWrites(await requireSnapshot(), circuitId, mode)) await putWrite(write);
2117
+ const snapshot = await requireSnapshot();
2118
+ if (snapshot.bridge !== void 0) {
2119
+ await bridgeWrite(snapshot, bridgeModeWrite(snapshot.bridge, circuitId, mode));
2120
+ return;
2121
+ }
2122
+ for (const write of circuitModeWrites(snapshot, circuitId, mode)) await putWrite(write);
1571
2123
  },
1572
2124
  async setDhwTarget(temp) {
1573
- await putWrite(dhwTargetWrite(await requireSnapshot(), temp));
2125
+ const snapshot = await requireSnapshot();
2126
+ if (snapshot.bridge !== void 0) {
2127
+ await bridgeWrite(snapshot, bridgeDhwTargetWrite(snapshot.bridge, temp));
2128
+ return;
2129
+ }
2130
+ await putWrite(dhwTargetWrite(snapshot, temp));
1574
2131
  },
1575
2132
  async setDhwMode(mode) {
1576
- await putWrite(dhwModeWrite(await requireSnapshot(), mode));
2133
+ const snapshot = await requireSnapshot();
2134
+ if (snapshot.bridge !== void 0) {
2135
+ await bridgeWrite(snapshot, bridgeDhwModeWrite(snapshot.bridge, mode));
2136
+ return;
2137
+ }
2138
+ await putWrite(dhwModeWrite(snapshot, mode));
1577
2139
  },
1578
2140
  async setFanMode(_circuitId, fanMode) {
1579
2141
  const snapshot = await requireSnapshot();
@@ -1591,7 +2153,15 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
1591
2153
  await putWrite(swingWrite(snapshot, "horizontal", on));
1592
2154
  },
1593
2155
  async writeHeatPump(writes) {
1594
- for (const write of writes) await putWrite(write);
2156
+ const snapshot = await requireSnapshot();
2157
+ for (const write of writes) await bridgeWrite(snapshot, write);
2158
+ },
2159
+ async readResource(path) {
2160
+ const res = await authed(homeComResourceUrl(gatewayId, path), { method: "GET" });
2161
+ return {
2162
+ status: res.status,
2163
+ body: res.body
2164
+ };
1595
2165
  }
1596
2166
  };
1597
2167
  }
@@ -2244,6 +2814,7 @@ var HomeComIntegrationManager = class {
2244
2814
  this.#onDisconnected = options.onDisconnected;
2245
2815
  this.#surfaceSink = options.surfaceSink;
2246
2816
  const fetchFn = options.fetchFn;
2817
+ const bridge = options.bridge;
2247
2818
  this.#onGatewayChosen = options.onGatewayChosen ?? (() => void 0);
2248
2819
  const onTokensRotated = async (tokens) => {
2249
2820
  this.#connection = {
@@ -2264,6 +2835,7 @@ var HomeComIntegrationManager = class {
2264
2835
  fetchFn,
2265
2836
  onTokensRotated,
2266
2837
  logger: this.#logger,
2838
+ ...bridge !== void 0 ? { bridge } : {},
2267
2839
  deviceType: this.#gateways.find((gateway) => gateway.gatewayId === gatewayId)?.deviceType
2268
2840
  }));
2269
2841
  this.#pollIntervalMs = options.pollIntervalMs ?? 6e4;
@@ -2387,6 +2959,7 @@ var HomeComClientRegistry = class {
2387
2959
  this.#onGatewayChosen = deps.onGatewayChosen ?? (() => void 0);
2388
2960
  const fetchFn = deps.fetchFn;
2389
2961
  const makeClient = deps.makeClient;
2962
+ const bridge = deps.bridge;
2390
2963
  this.#makeManager = deps.makeManager ?? ((opts) => new HomeComIntegrationManager({
2391
2964
  id: opts.integrationId,
2392
2965
  name: opts.name,
@@ -2399,6 +2972,7 @@ var HomeComClientRegistry = class {
2399
2972
  surfaceSink: homeComConnections,
2400
2973
  fetchFn,
2401
2974
  makeClient,
2975
+ ...bridge !== void 0 ? { bridge } : {},
2402
2976
  pollIntervalMs: 6e4
2403
2977
  }));
2404
2978
  }
@@ -2491,6 +3065,27 @@ var HomeComClientRegistry = class {
2491
3065
  }
2492
3066
  };
2493
3067
  //#endregion
3068
+ //#region src/homecom/homecom-python-paths.ts
3069
+ var WORKER = "homecom_worker.py";
3070
+ /**
3071
+ * Absolute path of the bundled `python/homecom_worker.py`: next to `dist/` in a deployed
3072
+ * package (one level up from a `dist/` chunk, two from `dist/homecom/`), two levels up from
3073
+ * `src/homecom/` in the workspace.
3074
+ */
3075
+ function resolveHomeComWorker(fromModuleUrl = require("url").pathToFileURL(__filename).href) {
3076
+ const here = (0, node_path.dirname)((0, node_url.fileURLToPath)(fromModuleUrl));
3077
+ const candidates = [
3078
+ (0, node_path.join)(here, "../python"),
3079
+ (0, node_path.join)(here, "../../python"),
3080
+ (0, node_path.join)(here, "python")
3081
+ ];
3082
+ for (const dir of candidates) {
3083
+ const file = (0, node_path.join)(dir, WORKER);
3084
+ if ((0, node_fs.existsSync)(file)) return file;
3085
+ }
3086
+ throw new Error(`provider-bosch: ${WORKER} not found. Searched:\n${candidates.join("\n")}`);
3087
+ }
3088
+ //#endregion
2494
3089
  //#region src/homecom/homecom-connection-test.ts
2495
3090
  var NO_GATEWAY = "This Bosch account has no HomeCom gateway.";
2496
3091
  function signInIncomplete(paths) {
@@ -3003,7 +3598,7 @@ var HomeComHeatPumpDevice = class extends require_bosch_instances.BaseDevice {
3003
3598
  await client.writeHeatPump([write]);
3004
3599
  this.ctx.logger.info("HomeCom heat pump: value written", {
3005
3600
  tags: { deviceId: this.ctx.id },
3006
- meta: { path: write.path }
3601
+ meta: { op: write.op }
3007
3602
  });
3008
3603
  try {
3009
3604
  const snapshot = await client.fetchSystem();
@@ -3012,7 +3607,7 @@ var HomeComHeatPumpDevice = class extends require_bosch_instances.BaseDevice {
3012
3607
  this.ctx.logger.warn("HomeCom heat pump: value written, the refresh failed", {
3013
3608
  tags: { deviceId: this.ctx.id },
3014
3609
  meta: {
3015
- path: write.path,
3610
+ op: write.op,
3016
3611
  error: error instanceof Error ? error.message : String(error)
3017
3612
  }
3018
3613
  });
@@ -3083,7 +3678,9 @@ var BoschHomeComProviderAddon = class extends require_bosch_instances.BaseDevice
3083
3678
  };
3084
3679
  const persistTokens = buildHomeComTokenPersister(persistDeps);
3085
3680
  const persistGateway = buildHomeComGatewayPersister(persistDeps);
3681
+ const bridge = await this.createBridge();
3086
3682
  this.clients = new HomeComClientRegistry(this.ctx.logger, {
3683
+ ...bridge !== null ? { bridge } : {},
3087
3684
  onTokensRotated: (integrationId, tokens) => persistTokens(integrationId, tokens),
3088
3685
  onGatewayChosen: (integrationId, gatewayId) => void persistGateway(integrationId, gatewayId)
3089
3686
  });
@@ -3095,6 +3692,11 @@ var BoschHomeComProviderAddon = class extends require_bosch_instances.BaseDevice
3095
3692
  });
3096
3693
  await this.reconcileIntegrations();
3097
3694
  this.subscribeIntegrationLifecycle();
3695
+ const explore = createHomeComExploreAction(homeComExploreDeps({
3696
+ registry: this.clients,
3697
+ connections: homeComConnections,
3698
+ logger: this.ctx.logger
3699
+ }));
3098
3700
  return {
3099
3701
  providers: [
3100
3702
  ...regs,
@@ -3121,7 +3723,8 @@ var BoschHomeComProviderAddon = class extends require_bosch_instances.BaseDevice
3121
3723
  signOut: async () => ({
3122
3724
  signedOut: true,
3123
3725
  summary: "Signed out."
3124
- })
3726
+ }),
3727
+ exploreResources: explore
3125
3728
  }
3126
3729
  };
3127
3730
  }
@@ -3135,6 +3738,23 @@ var BoschHomeComProviderAddon = class extends require_bosch_instances.BaseDevice
3135
3738
  this.instances = null;
3136
3739
  await super.onShutdown();
3137
3740
  }
3741
+ /** The `homecom_alt` bridge on the embedded Python; null leaves k40/k30/icom on plain reads. */
3742
+ async createBridge() {
3743
+ try {
3744
+ const pythonPath = await this.ctx.deps.ensurePython();
3745
+ if (pythonPath === null) {
3746
+ this.ctx.logger.warn("HomeCom: embedded Python is unavailable, the heat pump is not read");
3747
+ return null;
3748
+ }
3749
+ return createHomeComBridge({
3750
+ pythonPath,
3751
+ scriptPath: resolveHomeComWorker()
3752
+ });
3753
+ } catch (err) {
3754
+ this.ctx.logger.warn("HomeCom: the Python bridge could not be prepared", { meta: { error: require_bosch_instances.errMsg(err) } });
3755
+ return null;
3756
+ }
3757
+ }
3138
3758
  requireClients() {
3139
3759
  if (!this.clients) throw new Error("HomeCom provider not initialised");
3140
3760
  return this.clients;