upgrait-heatcontrol-api 0.1.0.dev0__tar.gz

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.
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: upgrait-heatcontrol-api
3
+ Version: 0.1.0.dev0
4
+ Summary: Async Python API client for the UPGRAIT HeatControl 3rd-party connector.
5
+ Author: UPGRAIT GmbH
6
+ License-Expression: LicenseRef-UPGRAIT-Proprietary
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: aiohttp<4,>=3.12
10
+ Requires-Dist: PyNaCl<2,>=1.5
11
+ Requires-Dist: zeroconf<1,>=0.136
12
+
13
+ # upgrait-heatcontrol-api
14
+
15
+ Async Python API package for the UPGRAIT HeatControl 3rd-party connector.
16
+
17
+ The package is intended to be the external dependency consumed by the Home Assistant integration and by other 3rd-party clients.
18
+
19
+ Current feature set:
20
+
21
+ - HTTP probing via `/api/ping`
22
+ - device metadata parsing
23
+ - pairing start via `/api/pair/start`
24
+ - pairing confirmation via `/api/pair/confirm`
25
+ - websocket bind/session transport
26
+ - encrypted request/response handling over the websocket session
27
+ - initial snapshot handling and live event subscription
28
+ - Zeroconf service constants and discovery metadata parsing helpers
29
+
30
+ Installation:
31
+
32
+ ```bash
33
+ pip install upgrait-heatcontrol-api
34
+ ```
35
+
36
+ Example:
37
+
38
+ ```python
39
+ import asyncio
40
+
41
+ from upgrait_heatcontrol_api import HeatControlApiClient, generate_keypair
42
+
43
+
44
+ async def main() -> None:
45
+ async with HeatControlApiClient(host="192.168.2.116", port=8001) as client:
46
+ device = await client.async_get_device_info()
47
+ print(device.serial, device.version)
48
+
49
+ private_key, public_key = generate_keypair()
50
+
51
+ pairing = await client.async_start_pairing(
52
+ ha_instance_id="example-ha-instance",
53
+ display_name="Example Client",
54
+ integration_version="0.1.0.dev0",
55
+ )
56
+ print(pairing.expires_at)
57
+
58
+ # The 6-digit PIN must be entered by the user after it is shown on the UHC. (Only available in new local interface, version >1610)
59
+ # confirm = await client.async_confirm_pairing(...)
60
+ # connection = await client.async_connect_and_bind(...)
61
+
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ Notes:
67
+
68
+ - `HeatControlApiClient` can manage its own `aiohttp` session. In that case, either use `async with HeatControlApiClient(...)` or call `await client.close()`.
69
+ - `HeatControlConnection.subscribe()` registers callbacks for live websocket events and returns an unsubscribe function.
70
+ - Discovery helpers cover both the `/api/ping` `discovery` payload and Zeroconf TXT-record property normalization.
@@ -0,0 +1,58 @@
1
+ # upgrait-heatcontrol-api
2
+
3
+ Async Python API package for the UPGRAIT HeatControl 3rd-party connector.
4
+
5
+ The package is intended to be the external dependency consumed by the Home Assistant integration and by other 3rd-party clients.
6
+
7
+ Current feature set:
8
+
9
+ - HTTP probing via `/api/ping`
10
+ - device metadata parsing
11
+ - pairing start via `/api/pair/start`
12
+ - pairing confirmation via `/api/pair/confirm`
13
+ - websocket bind/session transport
14
+ - encrypted request/response handling over the websocket session
15
+ - initial snapshot handling and live event subscription
16
+ - Zeroconf service constants and discovery metadata parsing helpers
17
+
18
+ Installation:
19
+
20
+ ```bash
21
+ pip install upgrait-heatcontrol-api
22
+ ```
23
+
24
+ Example:
25
+
26
+ ```python
27
+ import asyncio
28
+
29
+ from upgrait_heatcontrol_api import HeatControlApiClient, generate_keypair
30
+
31
+
32
+ async def main() -> None:
33
+ async with HeatControlApiClient(host="192.168.2.116", port=8001) as client:
34
+ device = await client.async_get_device_info()
35
+ print(device.serial, device.version)
36
+
37
+ private_key, public_key = generate_keypair()
38
+
39
+ pairing = await client.async_start_pairing(
40
+ ha_instance_id="example-ha-instance",
41
+ display_name="Example Client",
42
+ integration_version="0.1.0.dev0",
43
+ )
44
+ print(pairing.expires_at)
45
+
46
+ # The 6-digit PIN must be entered by the user after it is shown on the UHC. (Only available in new local interface, version >1610)
47
+ # confirm = await client.async_confirm_pairing(...)
48
+ # connection = await client.async_connect_and_bind(...)
49
+
50
+
51
+ asyncio.run(main())
52
+ ```
53
+
54
+ Notes:
55
+
56
+ - `HeatControlApiClient` can manage its own `aiohttp` session. In that case, either use `async with HeatControlApiClient(...)` or call `await client.close()`.
57
+ - `HeatControlConnection.subscribe()` registers callbacks for live websocket events and returns an unsubscribe function.
58
+ - Discovery helpers cover both the `/api/ping` `discovery` payload and Zeroconf TXT-record property normalization.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=78", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "upgrait-heatcontrol-api"
7
+ version = "0.1.0.dev0"
8
+ description = "Async Python API client for the UPGRAIT HeatControl 3rd-party connector."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "LicenseRef-UPGRAIT-Proprietary"
12
+ authors = [
13
+ {name = "UPGRAIT GmbH"}
14
+ ]
15
+ dependencies = [
16
+ "aiohttp>=3.12,<4",
17
+ "PyNaCl>=1.5,<2",
18
+ "zeroconf>=0.136,<1"
19
+ ]
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "pytest>=8.3,<9",
24
+ "pytest-asyncio>=0.24,<1",
25
+ "twine>=6,<7"
26
+ ]
27
+
28
+ [tool.setuptools]
29
+ package-dir = {"" = "src"}
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,41 @@
1
+ """Public exports for the UPGRAIT HeatControl API package."""
2
+
3
+ from .client import HeatControlApiClient
4
+ from .connection import HeatControlConnection
5
+ from .crypto import generate_keypair
6
+ from .discovery import (
7
+ DEFAULT_PORT,
8
+ MANUFACTURER,
9
+ MODEL,
10
+ SERVICE_TYPE,
11
+ DiscoveryAdvertisement,
12
+ ZeroconfDiscoveryInfo,
13
+ )
14
+ from .exceptions import (
15
+ HeatControlApiAuthError,
16
+ HeatControlApiConnectionError,
17
+ HeatControlApiError,
18
+ HeatControlApiInvalidPinError,
19
+ HeatControlApiProtocolError,
20
+ )
21
+ from .models import DeviceInfo, PairingConfirmResult, PairingStartResult
22
+
23
+ __all__ = [
24
+ "DEFAULT_PORT",
25
+ "DeviceInfo",
26
+ "DiscoveryAdvertisement",
27
+ "HeatControlApiClient",
28
+ "HeatControlApiAuthError",
29
+ "HeatControlApiConnectionError",
30
+ "HeatControlApiError",
31
+ "HeatControlApiInvalidPinError",
32
+ "HeatControlApiProtocolError",
33
+ "HeatControlConnection",
34
+ "MANUFACTURER",
35
+ "MODEL",
36
+ "PairingConfirmResult",
37
+ "PairingStartResult",
38
+ "SERVICE_TYPE",
39
+ "ZeroconfDiscoveryInfo",
40
+ "generate_keypair",
41
+ ]
@@ -0,0 +1,183 @@
1
+ """Async client for the UHC 3rd-party connector."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ import aiohttp
9
+
10
+ from .connection import HeatControlConnection
11
+ from .discovery import DEFAULT_PORT
12
+ from .exceptions import (
13
+ HeatControlApiAuthError,
14
+ HeatControlApiConnectionError,
15
+ HeatControlApiError,
16
+ HeatControlApiInvalidPinError,
17
+ )
18
+ from .models import DeviceInfo, PairingConfirmResult, PairingStartResult
19
+
20
+
21
+ class HeatControlApiClient:
22
+ """Async HTTP + websocket client for UPGRAIT HeatControl."""
23
+
24
+ def __init__(
25
+ self,
26
+ *,
27
+ host: str,
28
+ port: int = DEFAULT_PORT,
29
+ session: aiohttp.ClientSession | None = None,
30
+ ) -> None:
31
+ self.host = host
32
+ self.port = port
33
+ self._session = session
34
+ self._owns_session = session is None
35
+
36
+ async def __aenter__(self) -> "HeatControlApiClient":
37
+ return self
38
+
39
+ async def __aexit__(self, *_exc_info: object) -> None:
40
+ await self.close()
41
+
42
+ @property
43
+ def base_url(self) -> str:
44
+ return f"http://{self._network_location}"
45
+
46
+ @property
47
+ def ws_url(self) -> str:
48
+ return f"ws://{self._network_location}/ws"
49
+
50
+ @property
51
+ def _network_location(self) -> str:
52
+ host = self.host.strip()
53
+ if ":" in host and not host.startswith("["):
54
+ host = f"[{host}]"
55
+ return f"{host}:{self.port}"
56
+
57
+ async def async_ping(self) -> dict[str, Any]:
58
+ return await self._async_request("GET", "/api/ping")
59
+
60
+ async def async_get_device_info(self) -> DeviceInfo:
61
+ payload = await self.async_ping()
62
+ return DeviceInfo.from_ping_payload(payload)
63
+
64
+ async def async_start_pairing(
65
+ self,
66
+ *,
67
+ ha_instance_id: str,
68
+ display_name: str,
69
+ integration_version: str,
70
+ ) -> PairingStartResult:
71
+ payload = await self._async_request(
72
+ "POST",
73
+ "/api/pair/start",
74
+ json={
75
+ "ha_instance_id": ha_instance_id,
76
+ "display_name": display_name,
77
+ "integration_version": integration_version,
78
+ },
79
+ )
80
+ return PairingStartResult.from_payload(payload)
81
+
82
+ async def async_confirm_pairing(
83
+ self,
84
+ *,
85
+ pin: str,
86
+ ha_instance_id: str,
87
+ display_name: str,
88
+ integration_version: str,
89
+ ha_public_key: str,
90
+ ) -> PairingConfirmResult:
91
+ payload = await self._async_request(
92
+ "POST",
93
+ "/api/pair/confirm",
94
+ json={
95
+ "pin": pin,
96
+ "ha_instance_id": ha_instance_id,
97
+ "display_name": display_name,
98
+ "integration_version": integration_version,
99
+ "ha_public_key": ha_public_key,
100
+ },
101
+ )
102
+ return PairingConfirmResult.from_payload(payload)
103
+
104
+ async def async_connect_and_bind(
105
+ self,
106
+ *,
107
+ ha_instance_id: str,
108
+ ha_private_key: str,
109
+ server_public_key: str,
110
+ ) -> HeatControlConnection:
111
+ session = await self._get_session()
112
+ try:
113
+ ws = await session.ws_connect(self.ws_url, heartbeat=30)
114
+ except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
115
+ raise HeatControlApiConnectionError(
116
+ f"failed to open websocket at {self.ws_url}: {exc}"
117
+ ) from exc
118
+
119
+ connection = HeatControlConnection(
120
+ session=session,
121
+ ws=ws,
122
+ ha_instance_id=ha_instance_id,
123
+ ha_private_key=ha_private_key,
124
+ server_public_key=server_public_key,
125
+ )
126
+ try:
127
+ await connection.start()
128
+ except Exception:
129
+ await connection.close()
130
+ raise
131
+ return connection
132
+
133
+ async def close(self) -> None:
134
+ if not self._owns_session or self._session is None:
135
+ return
136
+ if not self._session.closed:
137
+ await self._session.close()
138
+ self._session = None
139
+
140
+ async def _async_request(
141
+ self,
142
+ method: str,
143
+ path: str,
144
+ *,
145
+ json: dict[str, Any] | None = None,
146
+ ) -> dict[str, Any]:
147
+ session = await self._get_session()
148
+ try:
149
+ async with session.request(method, f"{self.base_url}{path}", json=json) as resp:
150
+ payload = await resp.json()
151
+ except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
152
+ raise HeatControlApiConnectionError(
153
+ f"failed to reach HeatControl at {self.base_url}: {exc}"
154
+ ) from exc
155
+ except Exception as exc:
156
+ raise HeatControlApiError(f"{method} {path} returned invalid JSON: {exc}") from exc
157
+
158
+ if not isinstance(payload, dict):
159
+ raise HeatControlApiError(f"{method} {path} returned invalid JSON")
160
+
161
+ if resp.status >= 400 or payload.get("ok") is False:
162
+ error = payload.get("error")
163
+ code = str(error.get("code") if isinstance(error, dict) else f"http_{resp.status}")
164
+ message = str(
165
+ error.get("message") if isinstance(error, dict) else f"{method} {path} failed"
166
+ )
167
+ if code == "invalid_pin":
168
+ raise HeatControlApiInvalidPinError(message)
169
+ if code in {"pairing_not_active", "pairing_mismatch"}:
170
+ raise HeatControlApiAuthError(message)
171
+ raise HeatControlApiError(f"{code}: {message}")
172
+ return payload
173
+
174
+ async def _get_session(self) -> aiohttp.ClientSession:
175
+ if self._session is not None:
176
+ if not self._session.closed:
177
+ return self._session
178
+ if not self._owns_session:
179
+ raise HeatControlApiConnectionError("provided aiohttp session is closed")
180
+ self._session = None
181
+ self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
182
+ self._owns_session = True
183
+ return self._session
@@ -0,0 +1,226 @@
1
+ """WebSocket connection handling for the UHC 3rd-party connector."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import inspect
8
+ import json
9
+ import time
10
+ from collections.abc import Awaitable, Callable
11
+ from typing import Any
12
+
13
+ import aiohttp
14
+ from nacl.public import PrivateKey, PublicKey
15
+
16
+ from .crypto import (
17
+ box_decrypt_json,
18
+ box_encrypt_json,
19
+ load_private_key,
20
+ load_public_key,
21
+ uuid4_hex,
22
+ )
23
+ from .exceptions import (
24
+ HeatControlApiAuthError,
25
+ HeatControlApiConnectionError,
26
+ HeatControlApiProtocolError,
27
+ )
28
+
29
+
30
+ EventCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
31
+
32
+
33
+ class HeatControlConnection:
34
+ """Bound websocket connection to a HeatControl device."""
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ session: aiohttp.ClientSession,
40
+ ws: aiohttp.ClientWebSocketResponse,
41
+ ha_instance_id: str,
42
+ ha_private_key: str,
43
+ server_public_key: str,
44
+ ) -> None:
45
+ self._session = session
46
+ self._ws = ws
47
+ self._ha_instance_id = ha_instance_id
48
+ self._ha_private_key = ha_private_key
49
+ self._server_public_key = server_public_key
50
+ self._ha_private_key_obj: PrivateKey = load_private_key(ha_private_key)
51
+ self._server_public_key_obj: PublicKey = load_public_key(server_public_key)
52
+ self._event_callbacks: list[EventCallback] = []
53
+ self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {}
54
+ self._reader_task: asyncio.Task[Any] | None = None
55
+ self._initial_snapshot: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
56
+ self._closed = False
57
+ self._reader_exception: Exception | None = None
58
+ self.snapshot: dict[str, Any] = {}
59
+
60
+ async def start(self) -> None:
61
+ bind_payload = box_encrypt_json(
62
+ self._ha_private_key_obj,
63
+ self._server_public_key_obj,
64
+ {
65
+ "ha_instance_id": self._ha_instance_id,
66
+ "ts_ms": int(time.time() * 1000),
67
+ "nonce": uuid4_hex(),
68
+ },
69
+ )
70
+ try:
71
+ await self._ws.send_json({"type": "bind", "payload": bind_payload})
72
+ ack = await asyncio.wait_for(self._ws.receive(), timeout=15)
73
+ except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
74
+ raise HeatControlApiConnectionError(f"websocket bind failed: {exc}") from exc
75
+ if ack.type in {
76
+ aiohttp.WSMsgType.CLOSE,
77
+ aiohttp.WSMsgType.CLOSED,
78
+ aiohttp.WSMsgType.CLOSING,
79
+ }:
80
+ raise HeatControlApiConnectionError("websocket closed during bind")
81
+ if ack.type != aiohttp.WSMsgType.TEXT:
82
+ raise HeatControlApiAuthError("websocket bind failed")
83
+ try:
84
+ payload = json.loads(ack.data)
85
+ except json.JSONDecodeError as exc:
86
+ raise HeatControlApiProtocolError("bind acknowledgement was not valid JSON") from exc
87
+ if not isinstance(payload, dict):
88
+ raise HeatControlApiProtocolError("bind acknowledgement was not a JSON object")
89
+ if payload.get("type") == "error":
90
+ raise HeatControlApiAuthError(str(payload.get("error") or "websocket bind failed"))
91
+ if payload.get("type") != "bind_ack":
92
+ raise HeatControlApiAuthError(f"unexpected bind response: {payload}")
93
+ self._reader_task = asyncio.create_task(self._reader())
94
+ self.snapshot = await asyncio.wait_for(self._initial_snapshot, timeout=10)
95
+
96
+ def subscribe(self, callback: EventCallback) -> Callable[[], None]:
97
+ self._event_callbacks.append(callback)
98
+
99
+ def _unsubscribe() -> None:
100
+ with contextlib.suppress(ValueError):
101
+ self._event_callbacks.remove(callback)
102
+
103
+ return _unsubscribe
104
+
105
+ async def request(self, method: str, params: dict[str, Any] | None = None) -> Any:
106
+ self._raise_if_unavailable()
107
+ request_id = uuid4_hex()
108
+ future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
109
+ self._pending[request_id] = future
110
+ try:
111
+ await self._send_encrypted(
112
+ {
113
+ "type": "req",
114
+ "request_id": request_id,
115
+ "method": method,
116
+ "params": params or {},
117
+ }
118
+ )
119
+ response = await asyncio.wait_for(future, timeout=10)
120
+ finally:
121
+ self._pending.pop(request_id, None)
122
+
123
+ if response.get("ok") is not True:
124
+ error = response.get("error")
125
+ raise HeatControlApiProtocolError(str(error))
126
+ return response.get("result")
127
+
128
+ async def close(self) -> None:
129
+ self._closed = True
130
+ close_exc = HeatControlApiConnectionError("connection closed")
131
+ if not self._initial_snapshot.done():
132
+ self._initial_snapshot.set_exception(close_exc)
133
+ for future in list(self._pending.values()):
134
+ if not future.done():
135
+ future.set_exception(close_exc)
136
+ if self._reader_task is not None:
137
+ self._reader_task.cancel()
138
+ with contextlib.suppress(asyncio.CancelledError, Exception):
139
+ await self._reader_task
140
+ await self._ws.close()
141
+
142
+ async def _reader(self) -> None:
143
+ try:
144
+ async for msg in self._ws:
145
+ if msg.type != aiohttp.WSMsgType.TEXT:
146
+ continue
147
+ outer = json.loads(msg.data)
148
+ msg_type = outer.get("type")
149
+ if msg_type == "close":
150
+ raise HeatControlApiAuthError(str(outer.get("reason") or "connection closed"))
151
+ if msg_type != "msg":
152
+ continue
153
+ payload_b64 = outer.get("payload")
154
+ if not isinstance(payload_b64, str):
155
+ continue
156
+ inner = box_decrypt_json(
157
+ self._server_public_key_obj,
158
+ self._ha_private_key_obj,
159
+ payload_b64,
160
+ )
161
+ inner_type = inner.get("type")
162
+ if inner_type == "res":
163
+ request_id = str(inner.get("request_id") or "")
164
+ future = self._pending.get(request_id)
165
+ if future is not None and not future.done():
166
+ future.set_result(inner)
167
+ elif inner_type == "evt":
168
+ if isinstance(inner.get("topics"), dict):
169
+ self.snapshot = dict(inner["topics"])
170
+ if not self._initial_snapshot.done():
171
+ self._initial_snapshot.set_result(dict(self.snapshot))
172
+ elif isinstance(inner.get("topic"), str):
173
+ self.snapshot[inner["topic"]] = inner.get("value")
174
+ for callback in list(self._event_callbacks):
175
+ result = callback(inner)
176
+ if inspect.isawaitable(result):
177
+ await result
178
+ except asyncio.CancelledError:
179
+ raise
180
+ except Exception as exc:
181
+ self._set_reader_exception(self._normalize_reader_exception(exc))
182
+ else:
183
+ if not self._closed:
184
+ self._set_reader_exception(
185
+ HeatControlApiConnectionError("websocket connection closed")
186
+ )
187
+
188
+ async def _send_encrypted(self, payload: dict[str, Any]) -> None:
189
+ self._raise_if_unavailable()
190
+ encrypted = box_encrypt_json(
191
+ self._ha_private_key_obj,
192
+ self._server_public_key_obj,
193
+ payload,
194
+ )
195
+ await self._ws.send_json({"type": "msg", "payload": encrypted})
196
+
197
+ def _normalize_reader_exception(self, exc: Exception) -> Exception:
198
+ if isinstance(
199
+ exc,
200
+ (
201
+ HeatControlApiAuthError,
202
+ HeatControlApiConnectionError,
203
+ HeatControlApiProtocolError,
204
+ ),
205
+ ):
206
+ return exc
207
+ return HeatControlApiConnectionError(f"websocket reader failed: {exc}")
208
+
209
+ def _raise_if_unavailable(self) -> None:
210
+ if self._reader_exception is not None:
211
+ raise self._reader_exception
212
+ if self._closed or self._ws.closed:
213
+ raise HeatControlApiConnectionError("websocket connection is closed")
214
+ if self._reader_task is None:
215
+ raise HeatControlApiProtocolError("connection has not been started")
216
+ if self._reader_task.done():
217
+ raise HeatControlApiConnectionError("websocket reader is not running")
218
+
219
+ def _set_reader_exception(self, exc: Exception) -> None:
220
+ if self._reader_exception is None:
221
+ self._reader_exception = exc
222
+ if not self._initial_snapshot.done():
223
+ self._initial_snapshot.set_exception(self._reader_exception)
224
+ for future in list(self._pending.values()):
225
+ if not future.done():
226
+ future.set_exception(self._reader_exception)
@@ -0,0 +1,72 @@
1
+ """PyNaCl helpers shared by the API client connection stack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import uuid
8
+ from typing import Any
9
+
10
+ import nacl.utils
11
+ from nacl.public import Box, PrivateKey, PublicKey
12
+
13
+
14
+ def b64e(value: bytes) -> str:
15
+ return base64.b64encode(value).decode("ascii")
16
+
17
+
18
+ def b64d(value: str) -> bytes:
19
+ padded = (value or "").strip()
20
+ padded += "=" * ((-len(padded)) % 4)
21
+ return base64.b64decode(padded.encode("ascii"), validate=True)
22
+
23
+
24
+ def generate_keypair() -> tuple[str, str]:
25
+ private_key = PrivateKey.generate()
26
+ return b64e(bytes(private_key)), b64e(bytes(private_key.public_key))
27
+
28
+
29
+ def load_private_key(value: str) -> PrivateKey:
30
+ return PrivateKey(b64d(value))
31
+
32
+
33
+ def load_public_key(value: str) -> PublicKey:
34
+ return PublicKey(b64d(value))
35
+
36
+
37
+ def uuid4_hex() -> str:
38
+ return uuid.uuid4().hex
39
+
40
+
41
+ def box_encrypt_json(
42
+ sender_private_key: PrivateKey,
43
+ recipient_public_key: PublicKey,
44
+ payload: dict[str, Any],
45
+ ) -> str:
46
+ nonce = nacl.utils.random(Box.NONCE_SIZE)
47
+ plaintext = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode(
48
+ "utf-8"
49
+ )
50
+ ciphertext = Box(sender_private_key, recipient_public_key).encrypt(
51
+ plaintext, nonce
52
+ ).ciphertext
53
+ return b64e(nonce + ciphertext)
54
+
55
+
56
+ def box_decrypt_json(
57
+ sender_public_key: PublicKey,
58
+ recipient_private_key: PrivateKey,
59
+ payload_b64: str,
60
+ ) -> dict[str, Any]:
61
+ raw = b64d(payload_b64)
62
+ if len(raw) <= Box.NONCE_SIZE:
63
+ raise ValueError("payload too short")
64
+ nonce = raw[: Box.NONCE_SIZE]
65
+ ciphertext = raw[Box.NONCE_SIZE :]
66
+ plaintext = Box(recipient_private_key, sender_public_key).decrypt(
67
+ ciphertext, nonce
68
+ )
69
+ decoded = json.loads(plaintext.decode("utf-8"))
70
+ if not isinstance(decoded, dict):
71
+ raise ValueError("payload must decode to object")
72
+ return decoded
@@ -0,0 +1,87 @@
1
+ """Discovery constants and metadata helpers shared by the API package and integrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from zeroconf import ServiceInfo
9
+
10
+ DEFAULT_PORT = 8001
11
+ SERVICE_TYPE = "_upgrait-hc._tcp.local."
12
+ MANUFACTURER = "UPGRAIT GmbH"
13
+ MODEL = "HeatControl"
14
+
15
+
16
+ def _decode_discovery_value(value: object) -> str | None:
17
+ if value is None:
18
+ return None
19
+ if isinstance(value, bytes):
20
+ decoded = value.decode("utf-8", errors="strict").strip()
21
+ return decoded or None
22
+ if isinstance(value, str):
23
+ stripped = value.strip()
24
+ return stripped or None
25
+ raise ValueError(f"unsupported discovery value type: {type(value).__name__}")
26
+
27
+
28
+ @dataclass(slots=True, frozen=True)
29
+ class DiscoveryAdvertisement:
30
+ """Metadata returned by the connector ping endpoint for discovery state."""
31
+
32
+ service_type: str
33
+ advertised: bool
34
+ ip: str | None = None
35
+ service_name: str | None = None
36
+ last_error: str | None = None
37
+
38
+ @classmethod
39
+ def from_payload(cls, payload: dict[str, Any]) -> "DiscoveryAdvertisement":
40
+ if not isinstance(payload, dict):
41
+ raise ValueError("discovery payload must be an object")
42
+ advertised = payload.get("advertised")
43
+ if not isinstance(advertised, bool):
44
+ raise ValueError("discovery advertised must be a boolean")
45
+ service_type = payload.get("service_type")
46
+ if not isinstance(service_type, str) or not service_type.strip():
47
+ raise ValueError("discovery service_type must be a non-empty string")
48
+ return cls(
49
+ service_type=service_type.strip(),
50
+ advertised=advertised,
51
+ ip=_decode_discovery_value(payload.get("ip")),
52
+ service_name=_decode_discovery_value(payload.get("service_name")),
53
+ last_error=_decode_discovery_value(payload.get("last_error")),
54
+ )
55
+
56
+
57
+ @dataclass(slots=True, frozen=True)
58
+ class ZeroconfDiscoveryInfo:
59
+ """Normalized Zeroconf TXT-record properties for HeatControl discovery."""
60
+
61
+ name: str | None = None
62
+ serial: str | None = None
63
+ vendor: str | None = None
64
+ model: str | None = None
65
+ version: str | None = None
66
+
67
+ @classmethod
68
+ def from_properties(
69
+ cls, properties: dict[str | bytes, str | bytes]
70
+ ) -> "ZeroconfDiscoveryInfo":
71
+ normalized: dict[str, str | None] = {}
72
+ for key, value in properties.items():
73
+ normalized_key = _decode_discovery_value(key)
74
+ if normalized_key is None:
75
+ continue
76
+ normalized[normalized_key.lower()] = _decode_discovery_value(value)
77
+ return cls(
78
+ name=normalized.get("name"),
79
+ serial=normalized.get("serial"),
80
+ vendor=normalized.get("vendor"),
81
+ model=normalized.get("model"),
82
+ version=normalized.get("version"),
83
+ )
84
+
85
+ @classmethod
86
+ def from_service_info(cls, service_info: ServiceInfo) -> "ZeroconfDiscoveryInfo":
87
+ return cls.from_properties(service_info.properties)
@@ -0,0 +1,21 @@
1
+ """Exception hierarchy for the HeatControl API package."""
2
+
3
+
4
+ class HeatControlApiError(RuntimeError):
5
+ """Base API error."""
6
+
7
+
8
+ class HeatControlApiConnectionError(HeatControlApiError):
9
+ """Raised when the device cannot be reached."""
10
+
11
+
12
+ class HeatControlApiAuthError(HeatControlApiError):
13
+ """Raised when pairing or websocket binding is rejected."""
14
+
15
+
16
+ class HeatControlApiInvalidPinError(HeatControlApiAuthError):
17
+ """Raised when the pairing pin is invalid."""
18
+
19
+
20
+ class HeatControlApiProtocolError(HeatControlApiError):
21
+ """Raised when the connector speaks an unexpected protocol."""
@@ -0,0 +1,145 @@
1
+ """Value objects used by the HeatControl API package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from .discovery import MANUFACTURER, MODEL
9
+
10
+
11
+ def _require_object(payload: dict[str, Any], field: str) -> dict[str, Any]:
12
+ value = payload.get(field)
13
+ if not isinstance(value, dict):
14
+ raise ValueError(f"{field} payload missing object")
15
+ return value
16
+
17
+
18
+ def _require_str(payload: dict[str, Any], field: str) -> str:
19
+ value = payload.get(field)
20
+ if not isinstance(value, str):
21
+ raise ValueError(f"{field} must be a string")
22
+ stripped = value.strip()
23
+ if not stripped:
24
+ raise ValueError(f"{field} must not be empty")
25
+ return stripped
26
+
27
+
28
+ def _optional_str(payload: dict[str, Any], field: str) -> str | None:
29
+ value = payload.get(field)
30
+ if value is None:
31
+ return None
32
+ if not isinstance(value, str):
33
+ raise ValueError(f"{field} must be a string when provided")
34
+ stripped = value.strip()
35
+ return stripped or None
36
+
37
+
38
+ def _require_int(payload: dict[str, Any], field: str) -> int:
39
+ value = payload.get(field)
40
+ if isinstance(value, bool):
41
+ raise ValueError(f"{field} must be an integer")
42
+ if isinstance(value, int):
43
+ return value
44
+ if isinstance(value, str):
45
+ stripped = value.strip()
46
+ if stripped:
47
+ try:
48
+ return int(stripped, 10)
49
+ except ValueError as exc:
50
+ raise ValueError(f"{field} must be an integer") from exc
51
+ raise ValueError(f"{field} must be an integer")
52
+
53
+
54
+ def _require_bool(payload: dict[str, Any], field: str) -> bool:
55
+ value = payload.get(field)
56
+ if isinstance(value, bool):
57
+ return value
58
+ if isinstance(value, int) and value in {0, 1}:
59
+ return bool(value)
60
+ if isinstance(value, str):
61
+ normalized = value.strip().lower()
62
+ if normalized in {"true", "1"}:
63
+ return True
64
+ if normalized in {"false", "0"}:
65
+ return False
66
+ raise ValueError(f"{field} must be a boolean")
67
+
68
+
69
+ @dataclass(slots=True, frozen=True)
70
+ class DeviceInfo:
71
+ """Device information returned by the connector ping endpoint."""
72
+
73
+ serial: str
74
+ version: str
75
+ server_public_key: str
76
+ manufacturer: str = MANUFACTURER
77
+ model: str = MODEL
78
+ name: str | None = None
79
+
80
+ @classmethod
81
+ def from_ping_payload(cls, payload: dict[str, Any]) -> "DeviceInfo":
82
+ device = _require_object(payload, "device")
83
+ protocol = _require_object(payload, "protocol")
84
+ serial = _require_str(device, "serial")
85
+ version = _require_str(device, "version")
86
+ server_public_key = _require_str(protocol, "server_public_key")
87
+ manufacturer = _optional_str(device, "manufacturer") or MANUFACTURER
88
+ model = _optional_str(device, "model") or MODEL
89
+ name = _optional_str(device, "name")
90
+ return cls(
91
+ serial=serial,
92
+ version=version,
93
+ server_public_key=server_public_key,
94
+ manufacturer=manufacturer,
95
+ model=model,
96
+ name=name,
97
+ )
98
+
99
+
100
+ @dataclass(slots=True, frozen=True)
101
+ class PairingStartResult:
102
+ """Metadata returned after starting pairing."""
103
+
104
+ expires_at: int
105
+ pairing_active: bool
106
+ replaces_existing_binding: bool
107
+ existing_binding_display_name: str | None = None
108
+ server_public_key: str | None = None
109
+
110
+ @classmethod
111
+ def from_payload(cls, payload: dict[str, Any]) -> "PairingStartResult":
112
+ result = _require_object(payload, "result")
113
+ return cls(
114
+ expires_at=_require_int(result, "expires_at"),
115
+ pairing_active=_require_bool(result, "pairing_active"),
116
+ replaces_existing_binding=_require_bool(result, "replaces_existing_binding"),
117
+ existing_binding_display_name=_optional_str(
118
+ result, "existing_binding_display_name"
119
+ ),
120
+ server_public_key=_optional_str(result, "server_public_key"),
121
+ )
122
+
123
+
124
+ @dataclass(slots=True, frozen=True)
125
+ class PairingConfirmResult:
126
+ """Metadata returned after confirming pairing."""
127
+
128
+ ha_instance_id: str
129
+ display_name: str
130
+ integration_version: str
131
+ revision: int
132
+ confirmed_at: int
133
+ server_public_key: str
134
+
135
+ @classmethod
136
+ def from_payload(cls, payload: dict[str, Any]) -> "PairingConfirmResult":
137
+ result = _require_object(payload, "result")
138
+ return cls(
139
+ ha_instance_id=_require_str(result, "ha_instance_id"),
140
+ display_name=_require_str(result, "display_name"),
141
+ integration_version=_require_str(result, "integration_version"),
142
+ revision=_require_int(result, "revision"),
143
+ confirmed_at=_require_int(result, "confirmed_at"),
144
+ server_public_key=_require_str(result, "server_public_key"),
145
+ )
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: upgrait-heatcontrol-api
3
+ Version: 0.1.0.dev0
4
+ Summary: Async Python API client for the UPGRAIT HeatControl 3rd-party connector.
5
+ Author: UPGRAIT GmbH
6
+ License-Expression: LicenseRef-UPGRAIT-Proprietary
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: aiohttp<4,>=3.12
10
+ Requires-Dist: PyNaCl<2,>=1.5
11
+ Requires-Dist: zeroconf<1,>=0.136
12
+
13
+ # upgrait-heatcontrol-api
14
+
15
+ Async Python API package for the UPGRAIT HeatControl 3rd-party connector.
16
+
17
+ The package is intended to be the external dependency consumed by the Home Assistant integration and by other 3rd-party clients.
18
+
19
+ Current feature set:
20
+
21
+ - HTTP probing via `/api/ping`
22
+ - device metadata parsing
23
+ - pairing start via `/api/pair/start`
24
+ - pairing confirmation via `/api/pair/confirm`
25
+ - websocket bind/session transport
26
+ - encrypted request/response handling over the websocket session
27
+ - initial snapshot handling and live event subscription
28
+ - Zeroconf service constants and discovery metadata parsing helpers
29
+
30
+ Installation:
31
+
32
+ ```bash
33
+ pip install upgrait-heatcontrol-api
34
+ ```
35
+
36
+ Example:
37
+
38
+ ```python
39
+ import asyncio
40
+
41
+ from upgrait_heatcontrol_api import HeatControlApiClient, generate_keypair
42
+
43
+
44
+ async def main() -> None:
45
+ async with HeatControlApiClient(host="192.168.2.116", port=8001) as client:
46
+ device = await client.async_get_device_info()
47
+ print(device.serial, device.version)
48
+
49
+ private_key, public_key = generate_keypair()
50
+
51
+ pairing = await client.async_start_pairing(
52
+ ha_instance_id="example-ha-instance",
53
+ display_name="Example Client",
54
+ integration_version="0.1.0.dev0",
55
+ )
56
+ print(pairing.expires_at)
57
+
58
+ # The 6-digit PIN must be entered by the user after it is shown on the UHC. (Only available in new local interface, version >1610)
59
+ # confirm = await client.async_confirm_pairing(...)
60
+ # connection = await client.async_connect_and_bind(...)
61
+
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ Notes:
67
+
68
+ - `HeatControlApiClient` can manage its own `aiohttp` session. In that case, either use `async with HeatControlApiClient(...)` or call `await client.close()`.
69
+ - `HeatControlConnection.subscribe()` registers callbacks for live websocket events and returns an unsubscribe function.
70
+ - Discovery helpers cover both the `/api/ping` `discovery` payload and Zeroconf TXT-record property normalization.
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/upgrait_heatcontrol_api/__init__.py
4
+ src/upgrait_heatcontrol_api/client.py
5
+ src/upgrait_heatcontrol_api/connection.py
6
+ src/upgrait_heatcontrol_api/crypto.py
7
+ src/upgrait_heatcontrol_api/discovery.py
8
+ src/upgrait_heatcontrol_api/exceptions.py
9
+ src/upgrait_heatcontrol_api/models.py
10
+ src/upgrait_heatcontrol_api.egg-info/PKG-INFO
11
+ src/upgrait_heatcontrol_api.egg-info/SOURCES.txt
12
+ src/upgrait_heatcontrol_api.egg-info/dependency_links.txt
13
+ src/upgrait_heatcontrol_api.egg-info/requires.txt
14
+ src/upgrait_heatcontrol_api.egg-info/top_level.txt
15
+ tests/test_client.py
@@ -0,0 +1,3 @@
1
+ aiohttp<4,>=3.12
2
+ PyNaCl<2,>=1.5
3
+ zeroconf<1,>=0.136
@@ -0,0 +1,222 @@
1
+ import asyncio
2
+
3
+ import pytest
4
+
5
+ from upgrait_heatcontrol_api import DiscoveryAdvertisement, ZeroconfDiscoveryInfo
6
+ from upgrait_heatcontrol_api.crypto import (
7
+ box_decrypt_json,
8
+ box_encrypt_json,
9
+ generate_keypair,
10
+ load_private_key,
11
+ load_public_key,
12
+ )
13
+ from upgrait_heatcontrol_api.client import HeatControlApiClient
14
+ from upgrait_heatcontrol_api.connection import HeatControlConnection
15
+ from upgrait_heatcontrol_api.exceptions import (
16
+ HeatControlApiConnectionError,
17
+ HeatControlApiProtocolError,
18
+ )
19
+ from upgrait_heatcontrol_api.models import DeviceInfo, PairingConfirmResult, PairingStartResult
20
+
21
+
22
+ def test_base_url_uses_default_port() -> None:
23
+ client = HeatControlApiClient(host="192.0.2.10")
24
+ assert client.base_url == "http://192.0.2.10:8001"
25
+
26
+
27
+ def test_base_url_wraps_ipv6_host() -> None:
28
+ client = HeatControlApiClient(host="2001:db8::1")
29
+ assert client.base_url == "http://[2001:db8::1]:8001"
30
+ assert client.ws_url == "ws://[2001:db8::1]:8001/ws"
31
+
32
+
33
+ def test_device_info_from_ping_payload() -> None:
34
+ device = DeviceInfo.from_ping_payload(
35
+ {
36
+ "ok": True,
37
+ "protocol": {"server_public_key": "Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYg=="},
38
+ "device": {
39
+ "serial": "UHC123456",
40
+ "version": "2026.03",
41
+ "manufacturer": "UPGRAIT GmbH",
42
+ "model": "HeatControl",
43
+ },
44
+ }
45
+ )
46
+ assert device.serial == "UHC123456"
47
+ assert device.version == "2026.03"
48
+ assert device.server_public_key
49
+
50
+
51
+ def test_pairing_models_from_payloads() -> None:
52
+ start = PairingStartResult.from_payload(
53
+ {
54
+ "ok": True,
55
+ "result": {
56
+ "pairing_active": True,
57
+ "expires_at": 123,
58
+ "replaces_existing_binding": False,
59
+ "server_public_key": "server_key",
60
+ },
61
+ }
62
+ )
63
+ confirm = PairingConfirmResult.from_payload(
64
+ {
65
+ "ok": True,
66
+ "result": {
67
+ "ha_instance_id": "ha-id",
68
+ "display_name": "Home Assistant",
69
+ "integration_version": "0.1.0-dev",
70
+ "revision": 2,
71
+ "confirmed_at": 456,
72
+ "server_public_key": "server_key",
73
+ },
74
+ }
75
+ )
76
+ assert start.pairing_active is True
77
+ assert confirm.revision == 2
78
+
79
+
80
+ def test_pairing_start_model_rejects_invalid_boolean_strings() -> None:
81
+ with pytest.raises(ValueError, match="pairing_active must be a boolean"):
82
+ PairingStartResult.from_payload(
83
+ {
84
+ "ok": True,
85
+ "result": {
86
+ "pairing_active": "yes",
87
+ "expires_at": 123,
88
+ "replaces_existing_binding": False,
89
+ },
90
+ }
91
+ )
92
+
93
+
94
+ def test_pairing_confirm_model_requires_non_empty_strings() -> None:
95
+ with pytest.raises(ValueError, match="server_public_key must not be empty"):
96
+ PairingConfirmResult.from_payload(
97
+ {
98
+ "ok": True,
99
+ "result": {
100
+ "ha_instance_id": "ha-id",
101
+ "display_name": "Home Assistant",
102
+ "integration_version": "0.1.0-dev",
103
+ "revision": 2,
104
+ "confirmed_at": 456,
105
+ "server_public_key": " ",
106
+ },
107
+ }
108
+ )
109
+
110
+
111
+ def test_discovery_helpers_parse_payloads() -> None:
112
+ advertisement = DiscoveryAdvertisement.from_payload(
113
+ {
114
+ "service_type": "_upgrait-hc._tcp.local.",
115
+ "advertised": True,
116
+ "ip": "192.0.2.10",
117
+ "service_name": "UPGRAIT HeatControl UHC123._upgrait-hc._tcp.local.",
118
+ "last_error": None,
119
+ }
120
+ )
121
+ zeroconf_info = ZeroconfDiscoveryInfo.from_properties(
122
+ {
123
+ b"name": b"UPGRAIT HeatControl",
124
+ b"serial": b"UHC123456",
125
+ b"vendor": b"UPGRAIT GmbH",
126
+ b"model": b"HeatControl",
127
+ b"version": b"2026.03",
128
+ }
129
+ )
130
+ assert advertisement.advertised is True
131
+ assert zeroconf_info.serial == "UHC123456"
132
+
133
+
134
+ def test_crypto_roundtrip() -> None:
135
+ sender_private_b64, sender_public_b64 = generate_keypair()
136
+ recipient_private_b64, recipient_public_b64 = generate_keypair()
137
+ payload = {"type": "req", "method": "ping"}
138
+ encrypted = box_encrypt_json(
139
+ load_private_key(sender_private_b64),
140
+ load_public_key(recipient_public_b64),
141
+ payload,
142
+ )
143
+ decrypted = box_decrypt_json(
144
+ load_public_key(sender_public_b64),
145
+ load_private_key(recipient_private_b64),
146
+ encrypted,
147
+ )
148
+ assert decrypted == payload
149
+
150
+
151
+ @pytest.mark.asyncio
152
+ async def test_client_closes_only_owned_session() -> None:
153
+ external_session = object()
154
+ client = HeatControlApiClient(host="192.0.2.10", session=external_session) # type: ignore[arg-type]
155
+ await client.close()
156
+ assert client._session is external_session
157
+
158
+
159
+ @pytest.mark.asyncio
160
+ async def test_client_close_releases_owned_session() -> None:
161
+ client = HeatControlApiClient(host="192.0.2.10")
162
+ session = await client._get_session()
163
+ await client.close()
164
+ assert session.closed is True
165
+ assert client._session is None
166
+
167
+
168
+ class _FakeWebSocket:
169
+ def __init__(self) -> None:
170
+ self.closed = False
171
+
172
+ async def close(self) -> None:
173
+ self.closed = True
174
+
175
+ async def send_json(self, _payload: object) -> None:
176
+ return None
177
+
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_connection_request_raises_reader_failure_immediately() -> None:
181
+ connection = HeatControlConnection(
182
+ session=object(), # type: ignore[arg-type]
183
+ ws=_FakeWebSocket(), # type: ignore[arg-type]
184
+ ha_instance_id="ha-id",
185
+ ha_private_key=generate_keypair()[0],
186
+ server_public_key=generate_keypair()[1],
187
+ )
188
+ connection._reader_exception = HeatControlApiConnectionError("reader failed")
189
+ with pytest.raises(HeatControlApiConnectionError, match="reader failed"):
190
+ await connection.request("cfg_get")
191
+
192
+
193
+ @pytest.mark.asyncio
194
+ async def test_connection_request_requires_started_connection() -> None:
195
+ private_key, _public_key = generate_keypair()
196
+ _server_private_key, server_public_key = generate_keypair()
197
+ connection = HeatControlConnection(
198
+ session=object(), # type: ignore[arg-type]
199
+ ws=_FakeWebSocket(), # type: ignore[arg-type]
200
+ ha_instance_id="ha-id",
201
+ ha_private_key=private_key,
202
+ server_public_key=server_public_key,
203
+ )
204
+ with pytest.raises(HeatControlApiProtocolError, match="has not been started"):
205
+ await connection.request("cfg_get")
206
+
207
+
208
+ @pytest.mark.asyncio
209
+ async def test_connection_request_raises_if_reader_task_stopped() -> None:
210
+ private_key, _public_key = generate_keypair()
211
+ _server_private_key, server_public_key = generate_keypair()
212
+ connection = HeatControlConnection(
213
+ session=object(), # type: ignore[arg-type]
214
+ ws=_FakeWebSocket(), # type: ignore[arg-type]
215
+ ha_instance_id="ha-id",
216
+ ha_private_key=private_key,
217
+ server_public_key=server_public_key,
218
+ )
219
+ connection._reader_task = asyncio.create_task(asyncio.sleep(0))
220
+ await connection._reader_task
221
+ with pytest.raises(HeatControlApiConnectionError, match="reader is not running"):
222
+ await connection.request("cfg_get")