@camstack/addon-provider-bosch 0.1.50 → 0.1.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13554,7 +13554,7 @@ var require_snapshot_utils = /* @__PURE__ */ __commonJSMin(((exports, module) =>
13554
13554
  //#region ../../node_modules/undici/lib/mock/snapshot-recorder.js
13555
13555
  var require_snapshot_recorder = /* @__PURE__ */ __commonJSMin(((exports, module) => {
13556
13556
  var { writeFile, readFile, mkdir } = __require("node:fs/promises");
13557
- var { dirname, resolve } = __require("node:path");
13557
+ var { dirname: dirname$1, resolve } = __require("node:path");
13558
13558
  var { setTimeout: setTimeout$1, clearTimeout: clearTimeout$1 } = __require("node:timers");
13559
13559
  var { InvalidArgumentError, UndiciError } = require_errors();
13560
13560
  var { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = require_snapshot_utils();
@@ -13840,7 +13840,7 @@ var require_snapshot_recorder = /* @__PURE__ */ __commonJSMin(((exports, module)
13840
13840
  const path = filePath || this.#snapshotPath;
13841
13841
  if (!path) throw new InvalidArgumentError("Snapshot path is required");
13842
13842
  const resolvedPath = resolve(path);
13843
- await mkdir(dirname(resolvedPath), { recursive: true });
13843
+ await mkdir(dirname$1(resolvedPath), { recursive: true });
13844
13844
  const data = Array.from(this.#snapshots.entries()).map(([hash, snapshot]) => ({
13845
13845
  hash,
13846
13846
  snapshot
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-bosch",
3
- "version": "0.1.50",
3
+ "version": "0.1.51",
4
4
  "description": "Bosch smart-home device-provider addons for CamStack — SHC, Home Connect, and HomeCom cloud integrations (scaffold)",
5
5
  "keywords": [
6
6
  "camstack",
@@ -112,6 +112,9 @@
112
112
  "execution": {
113
113
  "placement": "hub-only"
114
114
  },
115
+ "python": {
116
+ "requirements": "./python/requirements.txt"
117
+ },
115
118
  "capabilities": [
116
119
  {
117
120
  "name": "device-provider"
@@ -138,11 +141,12 @@
138
141
  }
139
142
  ],
140
143
  "requires": {
141
- "server": ">=1.2.834"
144
+ "server": ">=1.2.835"
142
145
  }
143
146
  },
144
147
  "files": [
145
148
  "dist",
149
+ "python",
146
150
  "i18n",
147
151
  "assets",
148
152
  "README.md",
@@ -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())
@@ -0,0 +1,2 @@
1
+ # HomeCom Easy cloud client (MIT). Pinned exactly: the worker reads its payload shapes.
2
+ homecom-alt==1.9.2