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