@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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Normalize a `homecom_alt` K40/icom device reading into the JSON the TypeScript side parses.
|
|
2
|
+
|
|
3
|
+
Pure: no third-party import, so it is tested without the library installed. Every value the
|
|
4
|
+
gateway did not answer, refused (the library turns a 403/404 into `{}`), or flagged as a sensor
|
|
5
|
+
fault is `None`, never `0` (D393).
|
|
6
|
+
|
|
7
|
+
Output shape (camelCase, every key always present):
|
|
8
|
+
|
|
9
|
+
heatSource: { heatPumpType, supplyTemp, returnTemp, modulationPct, pressureBar, starts,
|
|
10
|
+
outdoorTemp, thermalKwh, electricalKwh }
|
|
11
|
+
system: { away, silent, additionalHeater, holiday } # Setting | None
|
|
12
|
+
heatingCircuits: [ { id, name, operationMode, heatCoolMode, currentSuWiMode, suWiSwitchMode,
|
|
13
|
+
roomTemp, humidity, currentRoomSetpoint, manualRoomSetpoint,
|
|
14
|
+
coolingRoomSetpoint, comfortSetpoint, ecoSetpoint, maxFlowTemp } ]
|
|
15
|
+
dhwCircuits: [ { id, operationMode, actualTemp, currentTemperatureLevel, charge,
|
|
16
|
+
chargeDuration, singleChargeSetpoint, currentSetpoint,
|
|
17
|
+
temperatureLevels: { <level>: Setting } } ]
|
|
18
|
+
heatSourceProbe: { <path>: "answered" | "refused" | "absent" | "transient" }
|
|
19
|
+
|
|
20
|
+
Setting = { value: str | number | None, min, max, allowed: [str], writeable: bool | None }
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
from typing import Any, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
ROOM_TEMP_RANGE = (-40.0, 60.0)
|
|
28
|
+
HUMIDITY_RANGE = (0.0, 100.0)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _finite(raw: Any) -> Optional[float]:
|
|
32
|
+
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
|
|
33
|
+
return None
|
|
34
|
+
value = float(raw)
|
|
35
|
+
return value if math.isfinite(value) else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fault_markers(envelope: Dict[str, Any]) -> List[float]:
|
|
39
|
+
"""The sentinel values a sensor reports when it is open or shorted (`state` list)."""
|
|
40
|
+
markers: List[float] = []
|
|
41
|
+
state = envelope.get("state")
|
|
42
|
+
if not isinstance(state, list):
|
|
43
|
+
return markers
|
|
44
|
+
for entry in state:
|
|
45
|
+
if not isinstance(entry, dict):
|
|
46
|
+
continue
|
|
47
|
+
for raw in entry.values():
|
|
48
|
+
number = _finite(raw)
|
|
49
|
+
if number is not None:
|
|
50
|
+
markers.append(number)
|
|
51
|
+
return markers
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def envelope_of(raw: Any) -> Optional[Dict[str, Any]]:
|
|
55
|
+
"""A resource payload, or None for an absent, refused (`{}`) or malformed one."""
|
|
56
|
+
if not isinstance(raw, dict) or len(raw) == 0:
|
|
57
|
+
return None
|
|
58
|
+
if "value" not in raw and "values" not in raw:
|
|
59
|
+
return None
|
|
60
|
+
return raw
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def number_of(raw: Any, bounds: Optional[tuple] = None) -> Optional[float]:
|
|
64
|
+
envelope = envelope_of(raw)
|
|
65
|
+
if envelope is None:
|
|
66
|
+
return None
|
|
67
|
+
value = _finite(envelope.get("value"))
|
|
68
|
+
if value is None or value in _fault_markers(envelope):
|
|
69
|
+
return None
|
|
70
|
+
if bounds is not None and not bounds[0] <= value <= bounds[1]:
|
|
71
|
+
return None
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def text_of(raw: Any) -> Optional[str]:
|
|
76
|
+
envelope = envelope_of(raw)
|
|
77
|
+
if envelope is None:
|
|
78
|
+
return None
|
|
79
|
+
value = envelope.get("value")
|
|
80
|
+
return value if isinstance(value, str) else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _string_list(raw: Any) -> List[str]:
|
|
84
|
+
if not isinstance(raw, list):
|
|
85
|
+
return []
|
|
86
|
+
return [item for item in raw if isinstance(item, str)]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _writeable(raw: Any) -> Optional[bool]:
|
|
90
|
+
if isinstance(raw, bool):
|
|
91
|
+
return raw
|
|
92
|
+
if isinstance(raw, int):
|
|
93
|
+
return raw != 0
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def setting_of(raw: Any) -> Optional[Dict[str, Any]]:
|
|
98
|
+
"""One answered resource as a setting. A `values` list (holiday) answers with its first item."""
|
|
99
|
+
envelope = envelope_of(raw)
|
|
100
|
+
if envelope is None:
|
|
101
|
+
return None
|
|
102
|
+
if "value" in envelope:
|
|
103
|
+
value = envelope.get("value")
|
|
104
|
+
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
|
105
|
+
value = None
|
|
106
|
+
elif not isinstance(value, str):
|
|
107
|
+
value = _finite(value)
|
|
108
|
+
if value is not None and value in _fault_markers(envelope):
|
|
109
|
+
value = None
|
|
110
|
+
else:
|
|
111
|
+
listed = _string_list(envelope.get("values"))
|
|
112
|
+
value = listed[0] if listed else ""
|
|
113
|
+
return {
|
|
114
|
+
"value": value,
|
|
115
|
+
"min": _finite(envelope.get("minValue")),
|
|
116
|
+
"max": _finite(envelope.get("maxValue")),
|
|
117
|
+
"allowed": _string_list(envelope.get("allowedValues")),
|
|
118
|
+
"writeable": _writeable(envelope.get("writeable")),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def emon_part(raw: Any, part: str) -> Optional[float]:
|
|
123
|
+
"""One part of an `emonValue`: the `values` item that is a dict carrying `part`."""
|
|
124
|
+
envelope = envelope_of(raw)
|
|
125
|
+
if envelope is None:
|
|
126
|
+
return None
|
|
127
|
+
values = envelope.get("values")
|
|
128
|
+
if not isinstance(values, list):
|
|
129
|
+
return None
|
|
130
|
+
for item in values:
|
|
131
|
+
if isinstance(item, dict) and part in item:
|
|
132
|
+
return _finite(item[part])
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _first(source: Dict[str, Any], *keys: str) -> Any:
|
|
137
|
+
for key in keys:
|
|
138
|
+
if key in source and source[key] not in (None, {}):
|
|
139
|
+
return source[key]
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _ref_id(ref: Dict[str, Any]) -> Optional[str]:
|
|
144
|
+
raw = ref.get("id")
|
|
145
|
+
if not isinstance(raw, str):
|
|
146
|
+
return None
|
|
147
|
+
tail = [part for part in raw.split("/") if part]
|
|
148
|
+
return tail[-1] if tail else None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _starts(heat_sources: Dict[str, Any], aggregate: Any) -> Optional[float]:
|
|
152
|
+
starts = _first(heat_sources, "starts", "numberOfStarts")
|
|
153
|
+
total = emon_part(starts, "total")
|
|
154
|
+
if total is not None:
|
|
155
|
+
return total
|
|
156
|
+
plain = number_of(starts)
|
|
157
|
+
return plain if plain is not None else number_of(aggregate)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _electrical_kwh(consumption: Any) -> Optional[float]:
|
|
161
|
+
compressor = emon_part(consumption, "compressor")
|
|
162
|
+
eheater = emon_part(consumption, "eheater")
|
|
163
|
+
if compressor is None or eheater is None:
|
|
164
|
+
return None
|
|
165
|
+
return compressor + eheater
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def normalize_heat_source(
|
|
169
|
+
heat_sources: Any, outdoor: Any, aggregate_starts: Any = None
|
|
170
|
+
) -> Dict[str, Any]:
|
|
171
|
+
hs = heat_sources if isinstance(heat_sources, dict) else {}
|
|
172
|
+
consumption = _first(hs, "consumption", "totalConsumption")
|
|
173
|
+
return {
|
|
174
|
+
"heatPumpType": text_of(_first(hs, "pumpType", "type")),
|
|
175
|
+
"supplyTemp": number_of(_first(hs, "actualSupplyTemperature", "supplyTemperature")),
|
|
176
|
+
"returnTemp": number_of(hs.get("returnTemperature")),
|
|
177
|
+
"modulationPct": number_of(_first(hs, "actualModulation", "modulation")),
|
|
178
|
+
"pressureBar": number_of(hs.get("systemPressure")),
|
|
179
|
+
"starts": _starts(hs, aggregate_starts),
|
|
180
|
+
"outdoorTemp": number_of(outdoor if outdoor not in (None, {}) else hs.get("outdoorTemp")),
|
|
181
|
+
"thermalKwh": emon_part(consumption, "outputProduced"),
|
|
182
|
+
"electricalKwh": _electrical_kwh(consumption),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
PROBE_OUTCOMES = ("answered", "refused", "absent", "transient")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def probe_outcome(status: int) -> str:
|
|
190
|
+
"""One GET of a heat-pump-only resource, by HTTP status.
|
|
191
|
+
|
|
192
|
+
The library reads a 403 and a 404 alike as an empty answer, so the identity resources
|
|
193
|
+
are probed on their own: 200 answers; 403 and 500 mean the resource exists but is
|
|
194
|
+
refused (a heat pump the gateway will not let us read); 404 means it is absent (a
|
|
195
|
+
boiler); anything else (429, 502, 503, 504 …) is transient and proves nothing.
|
|
196
|
+
"""
|
|
197
|
+
if status == 200:
|
|
198
|
+
return "answered"
|
|
199
|
+
if status in (403, 500):
|
|
200
|
+
return "refused"
|
|
201
|
+
if status == 404:
|
|
202
|
+
return "absent"
|
|
203
|
+
return "transient"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _probe_map(raw: Any) -> Dict[str, str]:
|
|
207
|
+
if not isinstance(raw, dict):
|
|
208
|
+
return {}
|
|
209
|
+
return {
|
|
210
|
+
path: outcome
|
|
211
|
+
for path, outcome in raw.items()
|
|
212
|
+
if isinstance(path, str) and outcome in PROBE_OUTCOMES
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def normalize_heating_circuit(ref: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
217
|
+
circuit_id = _ref_id(ref)
|
|
218
|
+
if circuit_id is None:
|
|
219
|
+
return None
|
|
220
|
+
name = ref.get("name")
|
|
221
|
+
return {
|
|
222
|
+
"id": circuit_id,
|
|
223
|
+
"name": name if isinstance(name, str) and name.strip() else None,
|
|
224
|
+
"operationMode": setting_of(ref.get("operationMode")),
|
|
225
|
+
"heatCoolMode": setting_of(ref.get("heatCoolMode")),
|
|
226
|
+
"currentSuWiMode": setting_of(ref.get("currentSuWiMode")),
|
|
227
|
+
"suWiSwitchMode": setting_of(ref.get("suWiSwitchMode")),
|
|
228
|
+
"roomTemp": number_of(_first(ref, "roomTemp", "roomtemperature"), ROOM_TEMP_RANGE),
|
|
229
|
+
"humidity": number_of(ref.get("actualHumidity"), HUMIDITY_RANGE),
|
|
230
|
+
"currentRoomSetpoint": setting_of(ref.get("currentRoomSetpoint")),
|
|
231
|
+
"manualRoomSetpoint": setting_of(ref.get("manualRoomSetpoint")),
|
|
232
|
+
"coolingRoomSetpoint": setting_of(ref.get("coolingRoomTempSetpoint")),
|
|
233
|
+
"comfortSetpoint": setting_of(ref.get("temperatureLevelComfort2")),
|
|
234
|
+
"ecoSetpoint": setting_of(ref.get("temperatureLevelEco")),
|
|
235
|
+
"maxFlowTemp": setting_of(ref.get("maxFlowTemp")),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def normalize_dhw_circuit(
|
|
240
|
+
ref: Dict[str, Any], charge_duration: Any = None
|
|
241
|
+
) -> Optional[Dict[str, Any]]:
|
|
242
|
+
circuit_id = _ref_id(ref)
|
|
243
|
+
if circuit_id is None:
|
|
244
|
+
return None
|
|
245
|
+
levels: Dict[str, Any] = {}
|
|
246
|
+
raw_levels = ref.get("tempLevel")
|
|
247
|
+
if isinstance(raw_levels, dict):
|
|
248
|
+
for level, payload in raw_levels.items():
|
|
249
|
+
setting = setting_of(payload)
|
|
250
|
+
if isinstance(level, str) and setting is not None:
|
|
251
|
+
levels[level] = setting
|
|
252
|
+
duration = ref.get("chargeDuration")
|
|
253
|
+
return {
|
|
254
|
+
"id": circuit_id,
|
|
255
|
+
"operationMode": setting_of(ref.get("operationMode")),
|
|
256
|
+
"actualTemp": number_of(ref.get("actualTemp")),
|
|
257
|
+
"currentTemperatureLevel": setting_of(ref.get("currentTemperatureLevel")),
|
|
258
|
+
"charge": setting_of(ref.get("charge")),
|
|
259
|
+
"chargeDuration": setting_of(duration if duration not in (None, {}) else charge_duration),
|
|
260
|
+
"singleChargeSetpoint": setting_of(ref.get("singleChargeSetpoint")),
|
|
261
|
+
"currentSetpoint": setting_of(ref.get("currentSetpoint")),
|
|
262
|
+
"temperatureLevels": levels,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _refs(raw: Any) -> List[Dict[str, Any]]:
|
|
267
|
+
if not isinstance(raw, list):
|
|
268
|
+
return []
|
|
269
|
+
return [ref for ref in raw if isinstance(ref, dict)]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def normalize_reading(
|
|
273
|
+
device: Dict[str, Any], extras: Optional[Dict[str, Any]] = None
|
|
274
|
+
) -> Dict[str, Any]:
|
|
275
|
+
"""`device` is the library's update dataclass as a dict; `extras` the separate reads.
|
|
276
|
+
|
|
277
|
+
extras keys: `silentMode`, `additionalHeaterMode`, `aggregateStarts`,
|
|
278
|
+
`chargeDuration` ({ dhw id: payload }), `heatSourceProbe` ({ path: probe outcome }).
|
|
279
|
+
"""
|
|
280
|
+
more = extras or {}
|
|
281
|
+
durations = more.get("chargeDuration") if isinstance(more.get("chargeDuration"), dict) else {}
|
|
282
|
+
heating = [normalize_heating_circuit(ref) for ref in _refs(device.get("heating_circuits"))]
|
|
283
|
+
dhw = [
|
|
284
|
+
normalize_dhw_circuit(ref, durations.get(_ref_id(ref) or ""))
|
|
285
|
+
for ref in _refs(device.get("dhw_circuits"))
|
|
286
|
+
]
|
|
287
|
+
return {
|
|
288
|
+
"heatSource": normalize_heat_source(
|
|
289
|
+
device.get("heat_sources"), device.get("outdoor_temp"), more.get("aggregateStarts")
|
|
290
|
+
),
|
|
291
|
+
"system": {
|
|
292
|
+
"away": setting_of(device.get("away_mode")),
|
|
293
|
+
"silent": setting_of(more.get("silentMode")),
|
|
294
|
+
"additionalHeater": setting_of(more.get("additionalHeaterMode")),
|
|
295
|
+
"holiday": setting_of(device.get("holiday_mode")),
|
|
296
|
+
},
|
|
297
|
+
"heatingCircuits": [c for c in heating if c is not None],
|
|
298
|
+
"dhwCircuits": [c for c in dhw if c is not None],
|
|
299
|
+
"heatSourceProbe": _probe_map(more.get("heatSourceProbe")),
|
|
300
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""One-shot HomeCom Easy reader/writer for a heat-pump gateway (k40, k30, icom).
|
|
3
|
+
|
|
4
|
+
Runs the MIT `homecom_alt` library (its device classes' own bulk read and write methods),
|
|
5
|
+
one spawn per poll. The OAuth login, the keychain and the token refresh stay in
|
|
6
|
+
TypeScript: this script receives a short-lived access token on stdin and nothing else. The
|
|
7
|
+
library is built with `auth_provider=False`, so it never refreshes, never logs in, and never
|
|
8
|
+
writes a token anywhere; nothing here touches the disk.
|
|
9
|
+
|
|
10
|
+
stdin JSON: { "token": "<access token>", "gatewayId": "101…", "deviceType": "k40",
|
|
11
|
+
"action": "read" }
|
|
12
|
+
or { …, "action": "write", "write": { "op": "<op>", "circuitId"?: "hc1",
|
|
13
|
+
"level"?: "eco", "value": <string | number> } }
|
|
14
|
+
stdout JSON: { "ok": true, "reading": { … } } (read; shape in homecom_normalize.py)
|
|
15
|
+
or { "ok": true, "written": "<op>" } (write)
|
|
16
|
+
or { "ok": false, "kind": "auth|unreachable|refused|api|protocol|bad-request",
|
|
17
|
+
"error": "…" }
|
|
18
|
+
Diagnostics go to stderr. The token is never printed, on either stream.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import sys
|
|
26
|
+
from dataclasses import asdict, is_dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Awaitable, Callable, Dict, Optional
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
31
|
+
|
|
32
|
+
from homecom_normalize import normalize_reading, probe_outcome # noqa: E402
|
|
33
|
+
|
|
34
|
+
DEVICE_TYPES = ("k40", "k30", "icom")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WorkerError(Exception):
|
|
38
|
+
def __init__(self, kind: str, message: str) -> None:
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.kind = kind
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _text(request: Dict[str, Any], key: str) -> str:
|
|
44
|
+
value = request.get(key)
|
|
45
|
+
if not isinstance(value, str) or len(value) == 0:
|
|
46
|
+
raise WorkerError("bad-request", f"{key} must be a non-empty string")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _scalar(write: Dict[str, Any]) -> Any:
|
|
51
|
+
value = write.get("value")
|
|
52
|
+
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
|
53
|
+
raise WorkerError("bad-request", "write.value must be a string or a number")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
WriteCall = Callable[[Any, str, Dict[str, Any]], Awaitable[None]]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _circuit(write: Dict[str, Any]) -> str:
|
|
61
|
+
return _text(write, "circuitId")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
WRITES: Dict[str, WriteCall] = {
|
|
65
|
+
"hcOperationMode": lambda c, d, w: c.async_put_hc_operation_mode(d, _circuit(w), _scalar(w)),
|
|
66
|
+
"hcHeatCoolMode": lambda c, d, w: c.async_put_hc_heatcool_mode(d, _circuit(w), _scalar(w)),
|
|
67
|
+
"hcSuWiSwitchMode": lambda c, d, w: c.async_put_hc_suwi_switch_mode(
|
|
68
|
+
d, _circuit(w), _scalar(w)
|
|
69
|
+
),
|
|
70
|
+
"hcManualRoomSetpoint": lambda c, d, w: c.async_set_hc_manual_room_setpoint(
|
|
71
|
+
d, _circuit(w), _scalar(w)
|
|
72
|
+
),
|
|
73
|
+
"hcCoolingRoomSetpoint": lambda c, d, w: c.async_set_hc_cooling_room_temp_setpoint(
|
|
74
|
+
d, _circuit(w), _scalar(w)
|
|
75
|
+
),
|
|
76
|
+
"hcComfortSetpoint": lambda c, d, w: c.async_set_hc_temp_level_comfort2(
|
|
77
|
+
d, _circuit(w), _scalar(w)
|
|
78
|
+
),
|
|
79
|
+
"hcEcoSetpoint": lambda c, d, w: c.async_set_hc_temp_level_eco(d, _circuit(w), _scalar(w)),
|
|
80
|
+
"hcMaxFlowTemp": lambda c, d, w: c.async_set_hc_max_flow_temp(d, _circuit(w), _scalar(w)),
|
|
81
|
+
"dhwOperationMode": lambda c, d, w: c.async_put_dhw_operation_mode(
|
|
82
|
+
d, _circuit(w), _scalar(w)
|
|
83
|
+
),
|
|
84
|
+
"dhwTemperatureLevel": lambda c, d, w: c.async_put_dhw_current_temp_level(
|
|
85
|
+
d, _circuit(w), _scalar(w)
|
|
86
|
+
),
|
|
87
|
+
"dhwLevelTemp": lambda c, d, w: c.async_set_dhw_temp_level(
|
|
88
|
+
d, _circuit(w), _text(w, "level"), _scalar(w)
|
|
89
|
+
),
|
|
90
|
+
"dhwCharge": lambda c, d, w: c.async_set_dhw_charge(d, _circuit(w), _scalar(w)),
|
|
91
|
+
"dhwChargeDuration": lambda c, d, w: c.async_set_dhw_charge_duration(
|
|
92
|
+
d, _circuit(w), _scalar(w)
|
|
93
|
+
),
|
|
94
|
+
"awayMode": lambda c, d, w: c.async_put_away_mode(d, _scalar(w)),
|
|
95
|
+
"silentMode": lambda c, d, w: c.async_put_silent_mode(d, _scalar(w)),
|
|
96
|
+
"additionalHeaterMode": lambda c, d, w: c.async_put_additional_heater_mode(d, _scalar(w)),
|
|
97
|
+
"holidayMode": lambda c, d, w: c.async_put_holiday_mode(d, _scalar(w)),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_request(raw: str) -> Dict[str, Any]:
|
|
102
|
+
"""Validate stdin before any library import. Raises WorkerError('bad-request')."""
|
|
103
|
+
try:
|
|
104
|
+
request = json.loads(raw)
|
|
105
|
+
except json.JSONDecodeError as err:
|
|
106
|
+
raise WorkerError("bad-request", f"stdin is not JSON: {err.msg}") from None
|
|
107
|
+
if not isinstance(request, dict):
|
|
108
|
+
raise WorkerError("bad-request", "stdin must be a JSON object")
|
|
109
|
+
_text(request, "token")
|
|
110
|
+
_text(request, "gatewayId")
|
|
111
|
+
if request.get("deviceType") not in DEVICE_TYPES:
|
|
112
|
+
raise WorkerError("bad-request", f"deviceType must be one of {', '.join(DEVICE_TYPES)}")
|
|
113
|
+
action = request.get("action")
|
|
114
|
+
if action == "write":
|
|
115
|
+
write = request.get("write")
|
|
116
|
+
if not isinstance(write, dict) or write.get("op") not in WRITES:
|
|
117
|
+
raise WorkerError("bad-request", "write.op is not a known write")
|
|
118
|
+
elif action != "read":
|
|
119
|
+
raise WorkerError("bad-request", "action must be read or write")
|
|
120
|
+
return request
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _as_dict(value: Any) -> Dict[str, Any]:
|
|
124
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
125
|
+
return asdict(value)
|
|
126
|
+
return value if isinstance(value, dict) else {}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _optional(call: Awaitable[Any]) -> Any:
|
|
130
|
+
"""An extra read the device may not have: its failure is an unknown value, never fatal."""
|
|
131
|
+
from homecom_alt import AuthFailedError
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
return await call
|
|
135
|
+
except AuthFailedError:
|
|
136
|
+
raise
|
|
137
|
+
except Exception as err: # noqa: BLE001 - one unanswered extra must not sink the poll
|
|
138
|
+
print(f"[homecom_worker] optional read failed: {type(err).__name__}", file=sys.stderr)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
HEAT_SOURCE_IDENTITY = ("/heatSources/hs1/heatPumpType", "/heatSources/hs1/heatPumpPower")
|
|
143
|
+
PROBE_TIMEOUT_S = 15
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def _probe_heat_source(session: Any, token: str, gateway_id: str) -> Dict[str, str]:
|
|
147
|
+
"""GET each heat-pump-only resource and keep its outcome (see `probe_outcome`).
|
|
148
|
+
|
|
149
|
+
Only asked when the bulk read left the heat-pump type unknown: a gateway that refuses
|
|
150
|
+
its heat source still has a heat pump, and the library cannot tell that from a 404.
|
|
151
|
+
"""
|
|
152
|
+
import aiohttp
|
|
153
|
+
from homecom_alt.const import BOSCHCOM_DOMAIN, BOSCHCOM_ENDPOINT_GATEWAYS
|
|
154
|
+
|
|
155
|
+
outcomes: Dict[str, str] = {}
|
|
156
|
+
for path in HEAT_SOURCE_IDENTITY:
|
|
157
|
+
url = f"{BOSCHCOM_DOMAIN}{BOSCHCOM_ENDPOINT_GATEWAYS}{gateway_id}/resource{path}"
|
|
158
|
+
try:
|
|
159
|
+
async with session.get(
|
|
160
|
+
url,
|
|
161
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
162
|
+
timeout=aiohttp.ClientTimeout(total=PROBE_TIMEOUT_S),
|
|
163
|
+
) as resp:
|
|
164
|
+
outcomes[path] = probe_outcome(resp.status)
|
|
165
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
|
|
166
|
+
print(f"[homecom_worker] probe failed: {type(err).__name__}", file=sys.stderr)
|
|
167
|
+
outcomes[path] = "transient"
|
|
168
|
+
return outcomes
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _read(
|
|
172
|
+
client: Any, session: Any, token: str, gateway_id: str, device_type: str
|
|
173
|
+
) -> Dict[str, Any]:
|
|
174
|
+
device = _as_dict(await client.async_update(gateway_id))
|
|
175
|
+
extras: Dict[str, Any] = {
|
|
176
|
+
"aggregateStarts": await _optional(client.async_get_hs_total_number_of_starts(gateway_id)),
|
|
177
|
+
}
|
|
178
|
+
if device_type != "icom":
|
|
179
|
+
extras["silentMode"] = await _optional(client.async_get_silent_mode(gateway_id))
|
|
180
|
+
extras["additionalHeaterMode"] = await _optional(
|
|
181
|
+
client.async_get_additional_heater_mode(gateway_id)
|
|
182
|
+
)
|
|
183
|
+
durations: Dict[str, Any] = {}
|
|
184
|
+
for ref in device.get("dhw_circuits") or []:
|
|
185
|
+
dhw_id = str(ref.get("id", "")).split("/")[-1] if isinstance(ref, dict) else ""
|
|
186
|
+
if dhw_id:
|
|
187
|
+
durations[dhw_id] = await _optional(
|
|
188
|
+
client.async_get_dhw_charge_duration(gateway_id, dhw_id)
|
|
189
|
+
)
|
|
190
|
+
extras["chargeDuration"] = durations
|
|
191
|
+
if normalize_reading(device)["heatSource"]["heatPumpType"] is None:
|
|
192
|
+
extras["heatSourceProbe"] = await _probe_heat_source(session, token, gateway_id)
|
|
193
|
+
return normalize_reading(device, extras)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def run(request: Dict[str, Any]) -> Dict[str, Any]:
|
|
197
|
+
import aiohttp
|
|
198
|
+
from homecom_alt import (
|
|
199
|
+
ApiError,
|
|
200
|
+
AuthFailedError,
|
|
201
|
+
ConnectionOptions,
|
|
202
|
+
HomeComIcom,
|
|
203
|
+
HomeComK40,
|
|
204
|
+
NotRespondingError,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
gateway_id = request["gatewayId"]
|
|
208
|
+
device_type = request["deviceType"]
|
|
209
|
+
options = ConnectionOptions(token=request["token"], auth_provider=False)
|
|
210
|
+
cls = HomeComIcom if device_type == "icom" else HomeComK40
|
|
211
|
+
try:
|
|
212
|
+
async with aiohttp.ClientSession() as session:
|
|
213
|
+
client = cls(session, options, gateway_id, False)
|
|
214
|
+
if request["action"] == "read":
|
|
215
|
+
reading = await _read(client, session, request["token"], gateway_id, device_type)
|
|
216
|
+
return {"ok": True, "reading": reading}
|
|
217
|
+
write = request["write"]
|
|
218
|
+
await WRITES[write["op"]](client, gateway_id, write)
|
|
219
|
+
return {"ok": True, "written": write["op"]}
|
|
220
|
+
except AuthFailedError:
|
|
221
|
+
raise WorkerError("auth", "HomeCom refused the access token") from None
|
|
222
|
+
except NotRespondingError as err:
|
|
223
|
+
raise WorkerError("unreachable", f"HomeCom did not respond: {err}") from None
|
|
224
|
+
except ApiError as err:
|
|
225
|
+
raise WorkerError("refused", f"HomeCom refused the request: {err}") from None
|
|
226
|
+
except aiohttp.ClientError as err:
|
|
227
|
+
raise WorkerError("unreachable", f"{type(err).__name__}: {err}") from None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _emit(answer: Dict[str, Any]) -> None:
|
|
231
|
+
print(json.dumps(answer), flush=True)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def main() -> int:
|
|
235
|
+
# The library logs request URLs at DEBUG; WARNING on stderr carries no token.
|
|
236
|
+
logging.basicConfig(level=logging.WARNING, stream=sys.stderr)
|
|
237
|
+
try:
|
|
238
|
+
request = parse_request(sys.stdin.read())
|
|
239
|
+
_emit(asyncio.run(run(request)))
|
|
240
|
+
return 0
|
|
241
|
+
except WorkerError as err:
|
|
242
|
+
_emit({"ok": False, "kind": err.kind, "error": str(err)})
|
|
243
|
+
return 1
|
|
244
|
+
except Exception as err: # noqa: BLE001 - the bridge needs one JSON answer, always
|
|
245
|
+
_emit({"ok": False, "kind": "api", "error": f"{type(err).__name__}: {err}"})
|
|
246
|
+
return 1
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
sys.exit(main())
|