@camstack/addon-provider-bosch 0.1.50 → 0.1.52
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 +13 -0
- package/dist/{account-integration-offline-7CH3r5jI.js → account-integration-offline-BEzGeJcl.js} +1 -1
- package/dist/{account-integration-offline-B1Wgr_rT.mjs → account-integration-offline-D5vjmfia.mjs} +1 -1
- package/dist/{bosch-instances-CdvcBYye.mjs → bosch-instances-DSE6IXdU.mjs} +775 -213
- package/dist/{bosch-instances-CYBnIcHg.js → bosch-instances-LWbVnsaD.js} +780 -212
- package/dist/home-connect/addon.js +2 -2
- package/dist/home-connect/addon.mjs +2 -2
- package/dist/homecom/addon.js +789 -556
- package/dist/homecom/addon.mjs +789 -556
- package/dist/shc/addon.js +163 -133
- package/dist/shc/addon.mjs +165 -135
- 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 { $ 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 errMsg, A as waterHeaterCapability, B as _enum, F as defineEntryKind, G as number, H as array, I as entryBody, J as string, K as object, N as customAction, P as defineCustomActions, Q as DeviceType, T as registerPowerSwitch, U as boolean, V as _null, W as literal, Y as union, Z as DeviceRole, _ as heatPumpControlCapability, a as BaseDevice, d as climateControlCapability, et as EventCategory, i as testBoschInstance, j as RecordIntegrationInstances, n as boschRowsPorts, o as BaseDeviceProvider, p as deviceAdoptionCapability, q as record, r as keepBodyKeys, t as boschRowsMigration, v as heatPumpStatusCapability, x as integrationInstancesCapability, z as rowsDepsOver } from "../bosch-instances-DSE6IXdU.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-D5vjmfia.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);
|
|
@@ -647,6 +651,210 @@ function unsupportedDeviceTypeMessage(deviceType) {
|
|
|
647
651
|
return new HomeComUnknownDeviceTypeError(deviceType ?? "(missing)").message;
|
|
648
652
|
}
|
|
649
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
|
|
650
858
|
//#region src/homecom/homecom-domain-mapping.ts
|
|
651
859
|
var MODE_MAP = {
|
|
652
860
|
Off: "off",
|
|
@@ -722,6 +930,280 @@ function waterHeaterSliceFromDhw(dhw, nowMs) {
|
|
|
722
930
|
lastChangedAt: nowMs
|
|
723
931
|
};
|
|
724
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
|
+
}
|
|
725
1207
|
var HOMECOM_PROBE_CLOCK = "/gateway/DateTime";
|
|
726
1208
|
var HOMECOM_PROBE_FIRMWARE = "/gateway/versionFirmware";
|
|
727
1209
|
var HYDRONIC_DROPPED = "heat-source, ventilation, pool and energy resources are not published on a cap";
|
|
@@ -1086,529 +1568,181 @@ function isRecord$2(value) {
|
|
|
1086
1568
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1087
1569
|
}
|
|
1088
1570
|
//#endregion
|
|
1089
|
-
//#region src/homecom/homecom-
|
|
1571
|
+
//#region src/homecom/homecom-bridge-snapshot.ts
|
|
1090
1572
|
/**
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
* tree as the MIT `homecom_alt` library lists it, and were not checked against a
|
|
1095
|
-
* live heat pump. They live in this one table so a wrong path is a one-line
|
|
1096
|
-
* fix, and a resource the gateway does not answer reads as `null` (never 0),
|
|
1097
|
-
* which the hero then simply does not draw.
|
|
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).
|
|
1098
1576
|
*/
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
silent: "/heatSources/hs1/silentMode",
|
|
1102
|
-
away: "/system/awayMode",
|
|
1103
|
-
additionalHeater: "/heatSources/hs1/additionalHeaterMode",
|
|
1104
|
-
holiday: "/system/holidayMode"
|
|
1105
|
-
},
|
|
1106
|
-
readings: {
|
|
1107
|
-
thermalW: "/heatSources/hs1/heatPumpPower",
|
|
1108
|
-
electricalW: "/heatSources/hs1/electricalPower",
|
|
1109
|
-
compressorPct: "/heatSources/hs1/compressorModulation",
|
|
1110
|
-
supply: "/heatSources/hs1/supplyTemperature",
|
|
1111
|
-
return: "/heatSources/hs1/returnTemperature",
|
|
1112
|
-
outdoor: "/system/sensors/temperatures/outdoor_t1",
|
|
1113
|
-
thermalKwh: "/recordings/heatSources/total/thermalEnergy",
|
|
1114
|
-
electricalKwh: "/recordings/heatSources/total/electricalEnergy",
|
|
1115
|
-
pressureBar: "/heatSources/hs1/waterPressure",
|
|
1116
|
-
starts: "/heatSources/hs1/numberOfStarts"
|
|
1117
|
-
},
|
|
1118
|
-
/**
|
|
1119
|
-
* Resources only a heat pump's heat source has. A gateway that answers one of them with
|
|
1120
|
-
* anything but 404 (a 403 or a 500 included) has a heat pump it will not let us read.
|
|
1121
|
-
*/
|
|
1122
|
-
identity: {
|
|
1123
|
-
heatPumpType: "/heatSources/hs1/heatPumpType",
|
|
1124
|
-
heatPumpPower: "/heatSources/hs1/heatPumpPower"
|
|
1125
|
-
},
|
|
1126
|
-
circuit: {
|
|
1127
|
-
operationMode: "operationMode",
|
|
1128
|
-
heatCoolMode: "heatCoolMode",
|
|
1129
|
-
summerWinter: "suWiSwitchMode",
|
|
1130
|
-
comfort: "temperatureLevels/comfort2",
|
|
1131
|
-
eco: "temperatureLevels/eco",
|
|
1132
|
-
maxFlow: "maxSupplyTemperature",
|
|
1133
|
-
current: "roomtemperature",
|
|
1134
|
-
target: "currentRoomSetpoint"
|
|
1135
|
-
},
|
|
1136
|
-
dhw: {
|
|
1137
|
-
operationMode: "operationMode",
|
|
1138
|
-
level: "currentTemperatureLevel",
|
|
1139
|
-
chargeDuration: "charge/duration",
|
|
1140
|
-
charge: "charge",
|
|
1141
|
-
current: "actualTemp",
|
|
1142
|
-
target: "currentSetpoint"
|
|
1143
|
-
}
|
|
1144
|
-
};
|
|
1145
|
-
/** A write the gateway would refuse, caught before it is sent. */
|
|
1146
|
-
var HomeComHeatPumpRefusalError = class extends Error {
|
|
1147
|
-
name = "HomeComHeatPumpRefusalError";
|
|
1148
|
-
};
|
|
1149
|
-
async function readHeatPump(input) {
|
|
1150
|
-
const get = safeGet(input.get, input.onFailure);
|
|
1151
|
-
const circuits = await Promise.all([...input.circuitIds.hc.map((id) => readHc(get, id)), ...input.circuitIds.dhw.map((id) => readDhwCircuit(get, id))]);
|
|
1152
|
-
const r = HEAT_PUMP_PATHS.readings;
|
|
1153
|
-
const s = HEAT_PUMP_PATHS.system;
|
|
1154
|
-
const heatSourceAnswered = await anyAnswers(input.answers, input.onFailure, [HEAT_PUMP_PATHS.identity.heatPumpType, HEAT_PUMP_PATHS.identity.heatPumpPower]);
|
|
1155
|
-
const [silent, away, additionalHeater, holiday] = await Promise.all([
|
|
1156
|
-
setting(get, s.silent),
|
|
1157
|
-
setting(get, s.away),
|
|
1158
|
-
setting(get, s.additionalHeater),
|
|
1159
|
-
setting(get, s.holiday)
|
|
1160
|
-
]);
|
|
1161
|
-
const [thermalW, electricalW, compressorPct, supply, ret, outdoor] = await Promise.all([
|
|
1162
|
-
num(get, r.thermalW),
|
|
1163
|
-
num(get, r.electricalW),
|
|
1164
|
-
num(get, r.compressorPct),
|
|
1165
|
-
num(get, r.supply),
|
|
1166
|
-
num(get, r.return),
|
|
1167
|
-
num(get, r.outdoor)
|
|
1168
|
-
]);
|
|
1169
|
-
const [thermalKwh, electricalKwh, pressureBar, starts] = await Promise.all([
|
|
1170
|
-
num(get, r.thermalKwh),
|
|
1171
|
-
num(get, r.electricalKwh),
|
|
1172
|
-
num(get, r.pressureBar),
|
|
1173
|
-
num(get, r.starts)
|
|
1174
|
-
]);
|
|
1175
|
-
return {
|
|
1176
|
-
circuits,
|
|
1177
|
-
heatSourceAnswered,
|
|
1178
|
-
silent,
|
|
1179
|
-
away,
|
|
1180
|
-
additionalHeater,
|
|
1181
|
-
holiday,
|
|
1182
|
-
thermalW,
|
|
1183
|
-
electricalW,
|
|
1184
|
-
compressorPct,
|
|
1185
|
-
supply,
|
|
1186
|
-
return: ret,
|
|
1187
|
-
outdoor,
|
|
1188
|
-
thermalKwh,
|
|
1189
|
-
electricalKwh,
|
|
1190
|
-
pressureBar,
|
|
1191
|
-
starts
|
|
1192
|
-
};
|
|
1193
|
-
}
|
|
1194
|
-
async function anyAnswers(answers, onFailure, paths) {
|
|
1195
|
-
if (answers === void 0) return false;
|
|
1196
|
-
return (await Promise.all(paths.map(async (path) => {
|
|
1197
|
-
try {
|
|
1198
|
-
return await answers(path);
|
|
1199
|
-
} catch (error) {
|
|
1200
|
-
onFailure?.(path, error);
|
|
1201
|
-
return false;
|
|
1202
|
-
}
|
|
1203
|
-
}))).some(Boolean);
|
|
1204
|
-
}
|
|
1205
|
-
/** Any failure of a heat-pump read (HTTP 4xx/5xx, a throw) is "not answered", never an error. */
|
|
1206
|
-
function safeGet(get, onFailure) {
|
|
1207
|
-
return async (path) => {
|
|
1208
|
-
try {
|
|
1209
|
-
return await get(path);
|
|
1210
|
-
} catch (error) {
|
|
1211
|
-
onFailure?.(path, error);
|
|
1212
|
-
return null;
|
|
1213
|
-
}
|
|
1214
|
-
};
|
|
1577
|
+
function numberValue(s) {
|
|
1578
|
+
return s !== null && typeof s.value === "number" ? s.value : null;
|
|
1215
1579
|
}
|
|
1216
|
-
|
|
1217
|
-
"
|
|
1218
|
-
"away",
|
|
1219
|
-
"additionalHeater",
|
|
1220
|
-
"holiday"
|
|
1221
|
-
];
|
|
1222
|
-
var HEAT_PUMP_ONLY_READINGS = [
|
|
1223
|
-
"thermalW",
|
|
1224
|
-
"electricalW",
|
|
1225
|
-
"compressorPct"
|
|
1226
|
-
];
|
|
1227
|
-
/**
|
|
1228
|
-
* True when the gateway should be offered as a system device (`hp:0`).
|
|
1229
|
-
*
|
|
1230
|
-
* A system mode counts when the gateway answered it: silent, away, the
|
|
1231
|
-
* additional heater, or holiday. An empty string is an answer (holiday often
|
|
1232
|
-
* comes back empty); a path that did not answer is not. A heat-pump-only
|
|
1233
|
-
* reading counts too (thermal power, electrical power, compressor modulation).
|
|
1234
|
-
* So does a circuit whose `heatCoolMode` can be cooling, and a heat source that
|
|
1235
|
-
* answers a heat-pump-only resource with any status but 404: some gateways
|
|
1236
|
-
* refuse (403) or fail (500) the whole system tree and still serve the
|
|
1237
|
-
* circuits. Supply, return, outdoor, pressure, starts and the energy meters do
|
|
1238
|
-
* not count: a gas boiler's heat source answers flow, pressure and starts, and
|
|
1239
|
-
* that is not a heat pump.
|
|
1240
|
-
*/
|
|
1241
|
-
function hasSystemDevice(reading) {
|
|
1242
|
-
if (SYSTEM_MODE_KEYS.some((key) => reading[key] !== null)) return true;
|
|
1243
|
-
if (HEAT_PUMP_ONLY_READINGS.some((key) => reading[key] !== null)) return true;
|
|
1244
|
-
if (reading.heatSourceAnswered) return true;
|
|
1245
|
-
return reading.circuits.some((c) => c.kind === "hc" && offersCooling(c.heatCoolMode));
|
|
1580
|
+
function textValue(s) {
|
|
1581
|
+
return s !== null && typeof s.value === "string" && s.value.length > 0 ? s.value : null;
|
|
1246
1582
|
}
|
|
1247
1583
|
/**
|
|
1248
|
-
*
|
|
1249
|
-
*
|
|
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.
|
|
1250
1588
|
*/
|
|
1251
|
-
function
|
|
1252
|
-
if (
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
const rows = [
|
|
1258
|
-
...systemPathRows(reading),
|
|
1259
|
-
...readingPathRows(reading),
|
|
1260
|
-
...reading.circuits.flatMap(circuitPathRows)
|
|
1261
|
-
];
|
|
1262
|
-
const answered = [];
|
|
1263
|
-
const unanswered = [];
|
|
1264
|
-
for (const row of rows) if (row.present) answered.push(row.path);
|
|
1265
|
-
else unanswered.push(row.path);
|
|
1266
|
-
return {
|
|
1267
|
-
answered,
|
|
1268
|
-
unanswered
|
|
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
|
|
1269
1595
|
};
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
path: s.silent,
|
|
1276
|
-
present: reading.silent !== null
|
|
1277
|
-
},
|
|
1278
|
-
{
|
|
1279
|
-
path: s.away,
|
|
1280
|
-
present: reading.away !== null
|
|
1281
|
-
},
|
|
1282
|
-
{
|
|
1283
|
-
path: s.additionalHeater,
|
|
1284
|
-
present: reading.additionalHeater !== null
|
|
1285
|
-
},
|
|
1286
|
-
{
|
|
1287
|
-
path: s.holiday,
|
|
1288
|
-
present: reading.holiday !== null
|
|
1289
|
-
}
|
|
1290
|
-
];
|
|
1291
|
-
}
|
|
1292
|
-
function readingPathRows(reading) {
|
|
1293
|
-
const r = HEAT_PUMP_PATHS.readings;
|
|
1294
|
-
return [
|
|
1295
|
-
{
|
|
1296
|
-
path: r.thermalW,
|
|
1297
|
-
present: reading.thermalW !== null
|
|
1298
|
-
},
|
|
1299
|
-
{
|
|
1300
|
-
path: r.electricalW,
|
|
1301
|
-
present: reading.electricalW !== null
|
|
1302
|
-
},
|
|
1303
|
-
{
|
|
1304
|
-
path: r.compressorPct,
|
|
1305
|
-
present: reading.compressorPct !== null
|
|
1306
|
-
},
|
|
1307
|
-
{
|
|
1308
|
-
path: r.supply,
|
|
1309
|
-
present: reading.supply !== null
|
|
1310
|
-
},
|
|
1311
|
-
{
|
|
1312
|
-
path: r.return,
|
|
1313
|
-
present: reading.return !== null
|
|
1314
|
-
},
|
|
1315
|
-
{
|
|
1316
|
-
path: r.outdoor,
|
|
1317
|
-
present: reading.outdoor !== null
|
|
1318
|
-
},
|
|
1319
|
-
{
|
|
1320
|
-
path: r.thermalKwh,
|
|
1321
|
-
present: reading.thermalKwh !== null
|
|
1322
|
-
},
|
|
1323
|
-
{
|
|
1324
|
-
path: r.electricalKwh,
|
|
1325
|
-
present: reading.electricalKwh !== null
|
|
1326
|
-
},
|
|
1327
|
-
{
|
|
1328
|
-
path: r.pressureBar,
|
|
1329
|
-
present: reading.pressureBar !== null
|
|
1330
|
-
},
|
|
1331
|
-
{
|
|
1332
|
-
path: r.starts,
|
|
1333
|
-
present: reading.starts !== null
|
|
1334
|
-
}
|
|
1335
|
-
];
|
|
1336
|
-
}
|
|
1337
|
-
function circuitPathRows(circuit) {
|
|
1338
|
-
if (circuit.kind === "dhw") return dhwPathRows(circuit);
|
|
1339
|
-
return hcPathRows(circuit);
|
|
1340
|
-
}
|
|
1341
|
-
function hcPathRows(circuit) {
|
|
1342
|
-
const base = `/heatingCircuits/${circuit.id}`;
|
|
1343
|
-
const p = HEAT_PUMP_PATHS.circuit;
|
|
1344
|
-
return [
|
|
1345
|
-
{
|
|
1346
|
-
path: `${base}/${p.operationMode}`,
|
|
1347
|
-
present: circuit.operationMode !== null
|
|
1348
|
-
},
|
|
1349
|
-
{
|
|
1350
|
-
path: `${base}/${p.heatCoolMode}`,
|
|
1351
|
-
present: circuit.heatCoolMode !== null
|
|
1352
|
-
},
|
|
1353
|
-
{
|
|
1354
|
-
path: `${base}/${p.summerWinter}`,
|
|
1355
|
-
present: circuit.summerWinter !== null
|
|
1356
|
-
},
|
|
1357
|
-
{
|
|
1358
|
-
path: `${base}/${p.comfort}`,
|
|
1359
|
-
present: circuit.comfort !== null
|
|
1360
|
-
},
|
|
1361
|
-
{
|
|
1362
|
-
path: `${base}/${p.eco}`,
|
|
1363
|
-
present: circuit.eco !== null
|
|
1364
|
-
},
|
|
1365
|
-
{
|
|
1366
|
-
path: `${base}/${p.maxFlow}`,
|
|
1367
|
-
present: circuit.maxFlow !== null
|
|
1368
|
-
},
|
|
1369
|
-
{
|
|
1370
|
-
path: `${base}/${p.current}`,
|
|
1371
|
-
present: circuit.currentTemp !== null
|
|
1372
|
-
},
|
|
1373
|
-
{
|
|
1374
|
-
path: `${base}/${p.target}`,
|
|
1375
|
-
present: circuit.targetTemp !== null
|
|
1376
|
-
}
|
|
1377
|
-
];
|
|
1378
|
-
}
|
|
1379
|
-
function dhwPathRows(circuit) {
|
|
1380
|
-
const base = `/dhwCircuits/${circuit.id}`;
|
|
1381
|
-
const p = HEAT_PUMP_PATHS.dhw;
|
|
1382
|
-
return [
|
|
1383
|
-
{
|
|
1384
|
-
path: `${base}/${p.operationMode}`,
|
|
1385
|
-
present: circuit.operationMode !== null
|
|
1386
|
-
},
|
|
1387
|
-
{
|
|
1388
|
-
path: `${base}/${p.level}`,
|
|
1389
|
-
present: circuit.level !== null
|
|
1390
|
-
},
|
|
1391
|
-
{
|
|
1392
|
-
path: `${base}/${p.chargeDuration}`,
|
|
1393
|
-
present: circuit.chargeDuration !== null
|
|
1394
|
-
},
|
|
1395
|
-
{
|
|
1396
|
-
path: `${base}/${p.charge}`,
|
|
1397
|
-
present: circuit.charge !== null
|
|
1398
|
-
},
|
|
1399
|
-
{
|
|
1400
|
-
path: `${base}/${p.current}`,
|
|
1401
|
-
present: circuit.currentTemp !== null
|
|
1402
|
-
},
|
|
1403
|
-
{
|
|
1404
|
-
path: `${base}/${p.target}`,
|
|
1405
|
-
present: circuit.targetTemp !== null
|
|
1406
|
-
}
|
|
1407
|
-
];
|
|
1408
|
-
}
|
|
1409
|
-
async function readHc(get, id) {
|
|
1410
|
-
const base = `/heatingCircuits/${id}`;
|
|
1411
|
-
const p = HEAT_PUMP_PATHS.circuit;
|
|
1412
|
-
const [operationMode, heatCoolMode, summerWinter, comfort, eco, maxFlow, current, target] = await Promise.all([
|
|
1413
|
-
setting(get, `${base}/${p.operationMode}`),
|
|
1414
|
-
setting(get, `${base}/${p.heatCoolMode}`),
|
|
1415
|
-
setting(get, `${base}/${p.summerWinter}`),
|
|
1416
|
-
setting(get, `${base}/${p.comfort}`),
|
|
1417
|
-
setting(get, `${base}/${p.eco}`),
|
|
1418
|
-
setting(get, `${base}/${p.maxFlow}`),
|
|
1419
|
-
num(get, `${base}/${p.current}`),
|
|
1420
|
-
num(get, `${base}/${p.target}`)
|
|
1421
|
-
]);
|
|
1422
|
-
return {
|
|
1423
|
-
id,
|
|
1424
|
-
kind: "hc",
|
|
1425
|
-
currentTemp: current,
|
|
1426
|
-
targetTemp: target,
|
|
1427
|
-
operationMode,
|
|
1428
|
-
heatCoolMode,
|
|
1429
|
-
summerWinter,
|
|
1430
|
-
comfort,
|
|
1431
|
-
eco,
|
|
1432
|
-
maxFlow,
|
|
1433
|
-
level: null,
|
|
1434
|
-
chargeDuration: null,
|
|
1435
|
-
charge: null
|
|
1596
|
+
if (textValue(hc.operationMode) !== "auto") return {
|
|
1597
|
+
kind: "setting",
|
|
1598
|
+
op: "hcManualRoomSetpoint",
|
|
1599
|
+
what: `${hc.id} setpoint`,
|
|
1600
|
+
setting: hc.manualRoomSetpoint
|
|
1436
1601
|
};
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
setting
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
targetTemp: target,
|
|
1454
|
-
operationMode,
|
|
1455
|
-
heatCoolMode: null,
|
|
1456
|
-
summerWinter: null,
|
|
1457
|
-
comfort: null,
|
|
1458
|
-
eco: null,
|
|
1459
|
-
maxFlow: null,
|
|
1460
|
-
level,
|
|
1461
|
-
chargeDuration,
|
|
1462
|
-
charge
|
|
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
|
|
1463
1618
|
};
|
|
1464
1619
|
}
|
|
1465
|
-
|
|
1466
|
-
const
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
const envelope = readEnvelope(await get(path));
|
|
1471
|
-
return envelope !== null && typeof envelope.value === "number" && Number.isFinite(envelope.value) ? envelope.value : null;
|
|
1472
|
-
}
|
|
1473
|
-
function settingOf(path, envelope) {
|
|
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 ?? []);
|
|
1474
1625
|
return {
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
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]
|
|
1480
1640
|
};
|
|
1481
1641
|
}
|
|
1482
|
-
function
|
|
1642
|
+
function waterHeater(dhw) {
|
|
1643
|
+
const target = dhwTargetSetting(dhw);
|
|
1644
|
+
const modes = dhw.operationMode?.allowed ?? [];
|
|
1483
1645
|
return {
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
power: {
|
|
1492
|
-
thermalW: reading.thermalW,
|
|
1493
|
-
electricalW: reading.electricalW,
|
|
1494
|
-
compressorPct: reading.compressorPct
|
|
1495
|
-
},
|
|
1496
|
-
temperatures: {
|
|
1497
|
-
supply: reading.supply,
|
|
1498
|
-
return: reading.return,
|
|
1499
|
-
outdoor: reading.outdoor
|
|
1500
|
-
},
|
|
1501
|
-
energy: {
|
|
1502
|
-
thermalKwh: reading.thermalKwh,
|
|
1503
|
-
electricalKwh: reading.electricalKwh
|
|
1504
|
-
},
|
|
1505
|
-
waterPressureBar: reading.pressureBar,
|
|
1506
|
-
starts: reading.starts,
|
|
1507
|
-
lastFetchedAt: now
|
|
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
|
|
1508
1653
|
};
|
|
1509
1654
|
}
|
|
1510
|
-
function
|
|
1511
|
-
const
|
|
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);
|
|
1512
1664
|
return {
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
comfortSetpoint: rangeOf(c.comfort),
|
|
1525
|
-
ecoSetpoint: rangeOf(c.eco),
|
|
1526
|
-
maxFlowTemp: rangeOf(c.maxFlow),
|
|
1527
|
-
temperatureLevel: optionOf(c.level),
|
|
1528
|
-
chargeDuration: rangeOf(c.chargeDuration),
|
|
1529
|
-
extraHotWater: c.charge === null ? null : textOf$1(c.charge) === "start"
|
|
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 } : {}
|
|
1530
1676
|
};
|
|
1531
1677
|
}
|
|
1532
|
-
function
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
function labelOf(c) {
|
|
1537
|
-
const n = c.id.replace(/^\D+/, "");
|
|
1538
|
-
if (c.kind === "dhw") return n === "1" || n === "" ? "Hot water" : `Hot water ${n}`;
|
|
1539
|
-
return n === "" ? "Heating circuit" : `Heating circuit ${n}`;
|
|
1540
|
-
}
|
|
1541
|
-
function textOf$1(s) {
|
|
1542
|
-
return s !== null && typeof s.value === "string" && s.value.length > 0 ? s.value : null;
|
|
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;
|
|
1543
1682
|
}
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
function modeValue(s) {
|
|
1549
|
-
if (s === null || typeof s.value !== "string") return null;
|
|
1550
|
-
if (s.value.length > 0) return s.value;
|
|
1551
|
-
return s.allowed.includes("off") ? "off" : "none";
|
|
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;
|
|
1552
1687
|
}
|
|
1553
|
-
function
|
|
1554
|
-
|
|
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);
|
|
1555
1692
|
return {
|
|
1556
|
-
|
|
1557
|
-
|
|
1693
|
+
op: active.op,
|
|
1694
|
+
circuitId,
|
|
1695
|
+
value
|
|
1558
1696
|
};
|
|
1559
1697
|
}
|
|
1560
|
-
|
|
1561
|
-
|
|
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
|
+
}
|
|
1707
|
+
}
|
|
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
|
+
}
|
|
1562
1718
|
return {
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
step: null
|
|
1719
|
+
op: "hcOperationMode",
|
|
1720
|
+
circuitId,
|
|
1721
|
+
value: acceptedOption(hc.operationMode, `${circuitId} mode`, operationToken(mode))
|
|
1567
1722
|
};
|
|
1568
1723
|
}
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
if (s === null) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${what} is not available`);
|
|
1576
|
-
if (s.allowed.length === 0) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${what} is read-only on this gateway`);
|
|
1577
|
-
if (!s.allowed.includes(mode)) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: "${mode}" is not an accepted value of ${what} (${s.allowed.join(", ")})`);
|
|
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);
|
|
1578
1730
|
return {
|
|
1579
|
-
|
|
1580
|
-
|
|
1731
|
+
op: "dhwLevelTemp",
|
|
1732
|
+
circuitId: dhw.id,
|
|
1733
|
+
level,
|
|
1734
|
+
value
|
|
1581
1735
|
};
|
|
1582
1736
|
}
|
|
1583
|
-
function
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
if (s.min === null || s.max === null) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${what} has no limits from the gateway`);
|
|
1587
|
-
if (value < s.min || value > s.max) throw new HomeComHeatPumpRefusalError(`HomeCom heat pump: ${value} is outside ${what} limits ${s.min}..${s.max}`);
|
|
1737
|
+
function bridgeDhwModeWrite(reading, mode) {
|
|
1738
|
+
const dhw = firstDhw(reading);
|
|
1739
|
+
const value = acceptedOption(dhw.operationMode, `${dhw.id} mode`, mode);
|
|
1588
1740
|
return {
|
|
1589
|
-
|
|
1741
|
+
op: "dhwOperationMode",
|
|
1742
|
+
circuitId: dhw.id,
|
|
1590
1743
|
value
|
|
1591
1744
|
};
|
|
1592
1745
|
}
|
|
1593
|
-
function systemModeWrite(reading, which, mode) {
|
|
1594
|
-
return optionWrite(reading[which], `${which} mode`, mode);
|
|
1595
|
-
}
|
|
1596
|
-
function circuitOptionWrite(reading, circuitId, which, mode) {
|
|
1597
|
-
return optionWrite(requireCircuit(reading, circuitId, "hc")[which], `${circuitId} ${which}`, mode);
|
|
1598
|
-
}
|
|
1599
|
-
function dhwOptionWrite(reading, circuitId, which, mode) {
|
|
1600
|
-
return optionWrite(requireCircuit(reading, circuitId, "dhw")[which], `${circuitId} ${which}`, mode);
|
|
1601
|
-
}
|
|
1602
|
-
function circuitRangeWrite(reading, circuitId, which, value) {
|
|
1603
|
-
return rangeWrite(requireCircuit(reading, circuitId, "hc")[which], `${circuitId} ${which}`, value);
|
|
1604
|
-
}
|
|
1605
|
-
function dhwChargeDurationWrite(reading, circuitId, minutes) {
|
|
1606
|
-
return rangeWrite(requireCircuit(reading, circuitId, "dhw").chargeDuration, `${circuitId} charge duration`, minutes);
|
|
1607
|
-
}
|
|
1608
|
-
function extraHotWaterWrite(reading, circuitId) {
|
|
1609
|
-
const charge = requireCircuit(reading, circuitId, "dhw").charge;
|
|
1610
|
-
return optionWrite(charge, `${circuitId} extra hot water`, "start");
|
|
1611
|
-
}
|
|
1612
1746
|
//#endregion
|
|
1613
1747
|
//#region src/homecom/homecom-oauth.ts
|
|
1614
1748
|
/**
|
|
@@ -1720,7 +1854,8 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
|
|
|
1720
1854
|
let last = null;
|
|
1721
1855
|
let refreshTail = Promise.resolve();
|
|
1722
1856
|
const dropped = /* @__PURE__ */ new Set();
|
|
1723
|
-
|
|
1857
|
+
const bridgeFailuresLogged = /* @__PURE__ */ new Set();
|
|
1858
|
+
const bridge = deps.bridge;
|
|
1724
1859
|
const base = homeComApiBase(config.region);
|
|
1725
1860
|
function authHeaders() {
|
|
1726
1861
|
return {
|
|
@@ -1789,17 +1924,6 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
|
|
|
1789
1924
|
if (!res.ok) throw new Error(`HomeCom API ${path} failed: HTTP ${res.status}`);
|
|
1790
1925
|
return res.body;
|
|
1791
1926
|
}
|
|
1792
|
-
/**
|
|
1793
|
-
* Evidence that the resource exists: a 2xx, or the 403 the gateway gives a resource it
|
|
1794
|
-
* serves but will not show. 404 is "absent". Anything else (429, 5xx) is a transient
|
|
1795
|
-
* failure, so it is NOT evidence either way: it is logged and counts as unknown.
|
|
1796
|
-
*/
|
|
1797
|
-
async function resourceAnswers(path) {
|
|
1798
|
-
const res = await authed(homeComResourceUrl(gatewayId, path), { method: "GET" });
|
|
1799
|
-
if (res.ok || res.status === 403) return true;
|
|
1800
|
-
if (res.status !== 404) logHeatPumpFailure(path, /* @__PURE__ */ new Error(`HTTP ${res.status}`));
|
|
1801
|
-
return false;
|
|
1802
|
-
}
|
|
1803
1927
|
async function putWrite(write) {
|
|
1804
1928
|
const res = await authed(homeComResourceUrl(gatewayId, write.path), {
|
|
1805
1929
|
method: "PUT",
|
|
@@ -1865,58 +1989,95 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
|
|
|
1865
1989
|
}
|
|
1866
1990
|
async function fetchSystem() {
|
|
1867
1991
|
const resolved = await resolveDeviceType();
|
|
1868
|
-
const
|
|
1869
|
-
|
|
1870
|
-
|
|
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,
|
|
1871
2010
|
gatewayId,
|
|
1872
2011
|
get: getResource,
|
|
1873
2012
|
logDrop
|
|
1874
|
-
}));
|
|
1875
|
-
last = withHeatPump;
|
|
1876
|
-
return withHeatPump;
|
|
1877
|
-
}
|
|
1878
|
-
async function attachHeatPump(snapshot) {
|
|
1879
|
-
if (snapshot.family !== "hydronic" || snapshot.deviceType === "rrc2") return snapshot;
|
|
1880
|
-
const dhwId = snapshot.dhw?.circuitId;
|
|
1881
|
-
const heatPump = await readHeatPump({
|
|
1882
|
-
get: getResource,
|
|
1883
|
-
onFailure: logHeatPumpFailure,
|
|
1884
|
-
answers: resourceAnswers,
|
|
1885
|
-
circuitIds: {
|
|
1886
|
-
hc: snapshot.circuits.map((circuit) => circuit.id),
|
|
1887
|
-
dhw: dhwId === void 0 ? [] : [dhwId]
|
|
1888
|
-
}
|
|
1889
2013
|
});
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
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
|
+
});
|
|
1905
2039
|
}
|
|
1906
|
-
|
|
2040
|
+
return kept;
|
|
2041
|
+
}
|
|
1907
2042
|
}
|
|
1908
|
-
function
|
|
1909
|
-
const key = `
|
|
2043
|
+
function logTransientProbe(path) {
|
|
2044
|
+
const key = `probe:${path}`;
|
|
1910
2045
|
if (dropped.has(key)) return;
|
|
1911
2046
|
dropped.add(key);
|
|
1912
|
-
logger?.warn("HomeCom heat-
|
|
2047
|
+
logger?.warn("HomeCom heat-source probe failed transiently; it does not count as evidence", {
|
|
1913
2048
|
tags: { gatewayId },
|
|
1914
|
-
meta: {
|
|
1915
|
-
path,
|
|
1916
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1917
|
-
}
|
|
2049
|
+
meta: { path }
|
|
1918
2050
|
});
|
|
1919
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
|
+
}
|
|
1920
2081
|
async function probePath(path) {
|
|
1921
2082
|
const res = await authed(homeComResourceUrl(gatewayId, path), {
|
|
1922
2083
|
method: "GET",
|
|
@@ -1944,16 +2105,36 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
|
|
|
1944
2105
|
throw new Error(`HomeCom gateway ${gatewayId} did not answer /gateway/DateTime or /gateway/versionFirmware.`);
|
|
1945
2106
|
},
|
|
1946
2107
|
async setCircuitSetpoint(circuitId, temp) {
|
|
1947
|
-
|
|
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));
|
|
1948
2114
|
},
|
|
1949
2115
|
async setCircuitMode(circuitId, mode) {
|
|
1950
|
-
|
|
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);
|
|
1951
2122
|
},
|
|
1952
2123
|
async setDhwTarget(temp) {
|
|
1953
|
-
|
|
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));
|
|
1954
2130
|
},
|
|
1955
2131
|
async setDhwMode(mode) {
|
|
1956
|
-
|
|
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));
|
|
1957
2138
|
},
|
|
1958
2139
|
async setFanMode(_circuitId, fanMode) {
|
|
1959
2140
|
const snapshot = await requireSnapshot();
|
|
@@ -1971,7 +2152,8 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
|
|
|
1971
2152
|
await putWrite(swingWrite(snapshot, "horizontal", on));
|
|
1972
2153
|
},
|
|
1973
2154
|
async writeHeatPump(writes) {
|
|
1974
|
-
|
|
2155
|
+
const snapshot = await requireSnapshot();
|
|
2156
|
+
for (const write of writes) await bridgeWrite(snapshot, write);
|
|
1975
2157
|
},
|
|
1976
2158
|
async readResource(path) {
|
|
1977
2159
|
const res = await authed(homeComResourceUrl(gatewayId, path), { method: "GET" });
|
|
@@ -2631,6 +2813,7 @@ var HomeComIntegrationManager = class {
|
|
|
2631
2813
|
this.#onDisconnected = options.onDisconnected;
|
|
2632
2814
|
this.#surfaceSink = options.surfaceSink;
|
|
2633
2815
|
const fetchFn = options.fetchFn;
|
|
2816
|
+
const bridge = options.bridge;
|
|
2634
2817
|
this.#onGatewayChosen = options.onGatewayChosen ?? (() => void 0);
|
|
2635
2818
|
const onTokensRotated = async (tokens) => {
|
|
2636
2819
|
this.#connection = {
|
|
@@ -2651,6 +2834,7 @@ var HomeComIntegrationManager = class {
|
|
|
2651
2834
|
fetchFn,
|
|
2652
2835
|
onTokensRotated,
|
|
2653
2836
|
logger: this.#logger,
|
|
2837
|
+
...bridge !== void 0 ? { bridge } : {},
|
|
2654
2838
|
deviceType: this.#gateways.find((gateway) => gateway.gatewayId === gatewayId)?.deviceType
|
|
2655
2839
|
}));
|
|
2656
2840
|
this.#pollIntervalMs = options.pollIntervalMs ?? 6e4;
|
|
@@ -2774,6 +2958,7 @@ var HomeComClientRegistry = class {
|
|
|
2774
2958
|
this.#onGatewayChosen = deps.onGatewayChosen ?? (() => void 0);
|
|
2775
2959
|
const fetchFn = deps.fetchFn;
|
|
2776
2960
|
const makeClient = deps.makeClient;
|
|
2961
|
+
const bridge = deps.bridge;
|
|
2777
2962
|
this.#makeManager = deps.makeManager ?? ((opts) => new HomeComIntegrationManager({
|
|
2778
2963
|
id: opts.integrationId,
|
|
2779
2964
|
name: opts.name,
|
|
@@ -2786,6 +2971,7 @@ var HomeComClientRegistry = class {
|
|
|
2786
2971
|
surfaceSink: homeComConnections,
|
|
2787
2972
|
fetchFn,
|
|
2788
2973
|
makeClient,
|
|
2974
|
+
...bridge !== void 0 ? { bridge } : {},
|
|
2789
2975
|
pollIntervalMs: 6e4
|
|
2790
2976
|
}));
|
|
2791
2977
|
}
|
|
@@ -2878,6 +3064,27 @@ var HomeComClientRegistry = class {
|
|
|
2878
3064
|
}
|
|
2879
3065
|
};
|
|
2880
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
|
|
2881
3088
|
//#region src/homecom/homecom-connection-test.ts
|
|
2882
3089
|
var NO_GATEWAY = "This Bosch account has no HomeCom gateway.";
|
|
2883
3090
|
function signInIncomplete(paths) {
|
|
@@ -3108,7 +3315,10 @@ var HomeComClimateDevice = class extends BaseDevice {
|
|
|
3108
3315
|
#circuitId;
|
|
3109
3316
|
constructor(ctx) {
|
|
3110
3317
|
const persisted = homeComClimateSchema.parse(ctx.persistedConfig ?? {});
|
|
3111
|
-
super(ctx, homeComClimateSchema, {
|
|
3318
|
+
super(ctx, homeComClimateSchema, {
|
|
3319
|
+
type: ctx.deviceMeta.type,
|
|
3320
|
+
role: DeviceRole.ClimateHeatPumpCircuit
|
|
3321
|
+
});
|
|
3112
3322
|
this.#brokerId = persisted.brokerId;
|
|
3113
3323
|
this.#circuitId = persisted.circuitId;
|
|
3114
3324
|
this.online = true;
|
|
@@ -3140,7 +3350,7 @@ var HomeComClimateDevice = class extends BaseDevice {
|
|
|
3140
3350
|
return client;
|
|
3141
3351
|
}
|
|
3142
3352
|
registerClimateCap() {
|
|
3143
|
-
|
|
3353
|
+
const provider = {
|
|
3144
3354
|
getStatus: async () => {
|
|
3145
3355
|
this.refreshFromSnapshot();
|
|
3146
3356
|
return this.runtimeState.getCapState(CLIMATE_CAP) ?? COLD_START$2;
|
|
@@ -3175,7 +3385,9 @@ var HomeComClimateDevice = class extends BaseDevice {
|
|
|
3175
3385
|
await this.requireClient().setSwingHorizontal(this.#circuitId, on);
|
|
3176
3386
|
this.refreshFromSnapshot();
|
|
3177
3387
|
}
|
|
3178
|
-
}
|
|
3388
|
+
};
|
|
3389
|
+
this.ctx.registerNativeCap(climateControlCapability, provider);
|
|
3390
|
+
registerPowerSwitch(this, climateControlCapability, provider);
|
|
3179
3391
|
}
|
|
3180
3392
|
};
|
|
3181
3393
|
function hvacModeToHomeCom(mode) {
|
|
@@ -3241,7 +3453,7 @@ var HomeComWaterHeaterDevice = class extends BaseDevice {
|
|
|
3241
3453
|
return client;
|
|
3242
3454
|
}
|
|
3243
3455
|
registerWaterHeaterCap() {
|
|
3244
|
-
|
|
3456
|
+
const provider = {
|
|
3245
3457
|
getStatus: async () => {
|
|
3246
3458
|
this.refreshFromSnapshot();
|
|
3247
3459
|
return this.runtimeState.getCapState(WH_CAP) ?? COLD_START$1;
|
|
@@ -3257,7 +3469,9 @@ var HomeComWaterHeaterDevice = class extends BaseDevice {
|
|
|
3257
3469
|
setAway: async () => {
|
|
3258
3470
|
throw new Error("HomeCom DHW: away mode not supported");
|
|
3259
3471
|
}
|
|
3260
|
-
}
|
|
3472
|
+
};
|
|
3473
|
+
this.ctx.registerNativeCap(waterHeaterCapability, provider);
|
|
3474
|
+
registerPowerSwitch(this, waterHeaterCapability, provider);
|
|
3261
3475
|
}
|
|
3262
3476
|
};
|
|
3263
3477
|
//#endregion
|
|
@@ -3390,7 +3604,7 @@ var HomeComHeatPumpDevice = class extends BaseDevice {
|
|
|
3390
3604
|
await client.writeHeatPump([write]);
|
|
3391
3605
|
this.ctx.logger.info("HomeCom heat pump: value written", {
|
|
3392
3606
|
tags: { deviceId: this.ctx.id },
|
|
3393
|
-
meta: {
|
|
3607
|
+
meta: { op: write.op }
|
|
3394
3608
|
});
|
|
3395
3609
|
try {
|
|
3396
3610
|
const snapshot = await client.fetchSystem();
|
|
@@ -3399,7 +3613,7 @@ var HomeComHeatPumpDevice = class extends BaseDevice {
|
|
|
3399
3613
|
this.ctx.logger.warn("HomeCom heat pump: value written, the refresh failed", {
|
|
3400
3614
|
tags: { deviceId: this.ctx.id },
|
|
3401
3615
|
meta: {
|
|
3402
|
-
|
|
3616
|
+
op: write.op,
|
|
3403
3617
|
error: error instanceof Error ? error.message : String(error)
|
|
3404
3618
|
}
|
|
3405
3619
|
});
|
|
@@ -3470,7 +3684,9 @@ var BoschHomeComProviderAddon = class extends BaseDeviceProvider {
|
|
|
3470
3684
|
};
|
|
3471
3685
|
const persistTokens = buildHomeComTokenPersister(persistDeps);
|
|
3472
3686
|
const persistGateway = buildHomeComGatewayPersister(persistDeps);
|
|
3687
|
+
const bridge = await this.createBridge();
|
|
3473
3688
|
this.clients = new HomeComClientRegistry(this.ctx.logger, {
|
|
3689
|
+
...bridge !== null ? { bridge } : {},
|
|
3474
3690
|
onTokensRotated: (integrationId, tokens) => persistTokens(integrationId, tokens),
|
|
3475
3691
|
onGatewayChosen: (integrationId, gatewayId) => void persistGateway(integrationId, gatewayId)
|
|
3476
3692
|
});
|
|
@@ -3528,6 +3744,23 @@ var BoschHomeComProviderAddon = class extends BaseDeviceProvider {
|
|
|
3528
3744
|
this.instances = null;
|
|
3529
3745
|
await super.onShutdown();
|
|
3530
3746
|
}
|
|
3747
|
+
/** The `homecom_alt` bridge on the embedded Python; null leaves k40/k30/icom on plain reads. */
|
|
3748
|
+
async createBridge() {
|
|
3749
|
+
try {
|
|
3750
|
+
const pythonPath = await this.ctx.deps.ensurePython();
|
|
3751
|
+
if (pythonPath === null) {
|
|
3752
|
+
this.ctx.logger.warn("HomeCom: embedded Python is unavailable, the heat pump is not read");
|
|
3753
|
+
return null;
|
|
3754
|
+
}
|
|
3755
|
+
return createHomeComBridge({
|
|
3756
|
+
pythonPath,
|
|
3757
|
+
scriptPath: resolveHomeComWorker()
|
|
3758
|
+
});
|
|
3759
|
+
} catch (err) {
|
|
3760
|
+
this.ctx.logger.warn("HomeCom: the Python bridge could not be prepared", { meta: { error: errMsg(err) } });
|
|
3761
|
+
return null;
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3531
3764
|
requireClients() {
|
|
3532
3765
|
if (!this.clients) throw new Error("HomeCom provider not initialised");
|
|
3533
3766
|
return this.clients;
|