pyheltycloud 0.1.0__py3-none-any.whl
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.
- pyheltycloud/__init__.py +24 -0
- pyheltycloud/auth.py +284 -0
- pyheltycloud/client.py +304 -0
- pyheltycloud/const.py +180 -0
- pyheltycloud/exceptions.py +24 -0
- pyheltycloud/models.py +214 -0
- pyheltycloud/py.typed +0 -0
- pyheltycloud-0.1.0.dist-info/METADATA +108 -0
- pyheltycloud-0.1.0.dist-info/RECORD +12 -0
- pyheltycloud-0.1.0.dist-info/WHEEL +5 -0
- pyheltycloud-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyheltycloud-0.1.0.dist-info/top_level.txt +1 -0
pyheltycloud/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Async client for the Helty cloud (hcloud) of Helty Flow VMC units."""
|
|
2
|
+
|
|
3
|
+
from .client import HeltyCloud
|
|
4
|
+
from .const import Alarm, Command, VmcMode
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
HeltyCloudAuthError,
|
|
7
|
+
HeltyCloudConnectionError,
|
|
8
|
+
HeltyCloudError,
|
|
9
|
+
HeltyCloudNoDataError,
|
|
10
|
+
)
|
|
11
|
+
from .models import HeltyDevice, HeltyState
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Alarm",
|
|
15
|
+
"Command",
|
|
16
|
+
"HeltyCloud",
|
|
17
|
+
"HeltyCloudAuthError",
|
|
18
|
+
"HeltyCloudConnectionError",
|
|
19
|
+
"HeltyCloudError",
|
|
20
|
+
"HeltyCloudNoDataError",
|
|
21
|
+
"HeltyDevice",
|
|
22
|
+
"HeltyState",
|
|
23
|
+
"VmcMode",
|
|
24
|
+
]
|
pyheltycloud/auth.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""AWS Cognito authentication (USER_SRP_AUTH) on aiohttp.
|
|
2
|
+
|
|
3
|
+
The Helty pool only accepts the SRP flow, so the password never travels:
|
|
4
|
+
knowledge of it is proven by signing a challenge. Implemented here instead
|
|
5
|
+
of using pycognito/boto3 because those libraries are synchronous and Home
|
|
6
|
+
Assistant wants async I/O.
|
|
7
|
+
|
|
8
|
+
Reference: SRP-6a with Cognito's specifics (key derived via HKDF and HMAC
|
|
9
|
+
signature of the challenge block).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import binascii
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import aiohttp
|
|
26
|
+
|
|
27
|
+
from .exceptions import HeltyCloudAuthError, HeltyCloudConnectionError
|
|
28
|
+
|
|
29
|
+
# 3072-bit prime and generator, fixed by the Cognito protocol.
|
|
30
|
+
N_HEX = (
|
|
31
|
+
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74"
|
|
32
|
+
"020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437"
|
|
33
|
+
"4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED"
|
|
34
|
+
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05"
|
|
35
|
+
"98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB"
|
|
36
|
+
"9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B"
|
|
37
|
+
"E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718"
|
|
38
|
+
"3995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33"
|
|
39
|
+
"A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7"
|
|
40
|
+
"ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864"
|
|
41
|
+
"D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E2"
|
|
42
|
+
"08E24FA074E5AB3143DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF"
|
|
43
|
+
)
|
|
44
|
+
G_HEX = "2"
|
|
45
|
+
INFO_BITS = b"Caldera Derived Key"
|
|
46
|
+
|
|
47
|
+
# strftime is locale-dependent: Cognito wants the English names, so we spell
|
|
48
|
+
# them out.
|
|
49
|
+
_WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
|
50
|
+
_MONTHS = (
|
|
51
|
+
"Jan",
|
|
52
|
+
"Feb",
|
|
53
|
+
"Mar",
|
|
54
|
+
"Apr",
|
|
55
|
+
"May",
|
|
56
|
+
"Jun",
|
|
57
|
+
"Jul",
|
|
58
|
+
"Aug",
|
|
59
|
+
"Sep",
|
|
60
|
+
"Oct",
|
|
61
|
+
"Nov",
|
|
62
|
+
"Dec",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Renew the token a bit before it expires, to avoid being caught mid-call.
|
|
66
|
+
_EXPIRY_MARGIN = 60.0
|
|
67
|
+
|
|
68
|
+
# Overall timeout for each call to Cognito.
|
|
69
|
+
_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _hash_sha256(buf: bytes | bytearray) -> str:
|
|
73
|
+
digest = hashlib.sha256(buf).hexdigest()
|
|
74
|
+
return digest.rjust(64, "0")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _hex_hash(hex_string: str) -> str:
|
|
78
|
+
return _hash_sha256(bytearray.fromhex(hex_string))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _pad_hex(value: int | str) -> str:
|
|
82
|
+
"""Normalize a hex string the way SRP expects.
|
|
83
|
+
|
|
84
|
+
Even length, and a leading zero byte if the first nibble has the high
|
|
85
|
+
bit set (it would otherwise be read as a negative number).
|
|
86
|
+
"""
|
|
87
|
+
hex_str = value if isinstance(value, str) else f"{value:x}"
|
|
88
|
+
if len(hex_str) % 2 == 1:
|
|
89
|
+
return f"0{hex_str}"
|
|
90
|
+
if hex_str[0] in "89ABCDEFabcdef":
|
|
91
|
+
return f"00{hex_str}"
|
|
92
|
+
return hex_str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _compute_hkdf(ikm: bytes | bytearray, salt: bytes | bytearray) -> bytes:
|
|
96
|
+
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
|
|
97
|
+
return hmac.new(prk, INFO_BITS + b"\x01", hashlib.sha256).digest()[:16]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _cognito_timestamp() -> str:
|
|
101
|
+
now = datetime.now(UTC)
|
|
102
|
+
return (
|
|
103
|
+
f"{_WEEKDAYS[now.weekday()]} {_MONTHS[now.month - 1]} {now.day} "
|
|
104
|
+
f"{now:%H:%M:%S} UTC {now.year}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(slots=True)
|
|
109
|
+
class Tokens:
|
|
110
|
+
"""Cognito session tokens."""
|
|
111
|
+
|
|
112
|
+
id_token: str
|
|
113
|
+
access_token: str
|
|
114
|
+
refresh_token: str
|
|
115
|
+
expires_at: float
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def expired(self) -> bool:
|
|
119
|
+
"""True when it is time to renew before the next call."""
|
|
120
|
+
return time.time() >= self.expires_at - _EXPIRY_MARGIN
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class CognitoSrpAuth:
|
|
124
|
+
"""SRP login and token renewal against a Cognito user pool."""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
session: aiohttp.ClientSession,
|
|
129
|
+
user_pool_id: str,
|
|
130
|
+
client_id: str,
|
|
131
|
+
region: str,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Prepare the fixed SRP parameters of the pool."""
|
|
134
|
+
self._session = session
|
|
135
|
+
self._user_pool_id = user_pool_id
|
|
136
|
+
self._client_id = client_id
|
|
137
|
+
self._url = f"https://cognito-idp.{region}.amazonaws.com/"
|
|
138
|
+
self._big_n = int(N_HEX, 16)
|
|
139
|
+
self._g = int(G_HEX, 16)
|
|
140
|
+
self._k = int(_hex_hash(f"00{N_HEX}0{G_HEX}"), 16)
|
|
141
|
+
self._new_ephemeral()
|
|
142
|
+
|
|
143
|
+
def _new_ephemeral(self) -> None:
|
|
144
|
+
"""Draw a fresh SRP secret and its public value.
|
|
145
|
+
|
|
146
|
+
One per login: reusing the pair across authentications is outside
|
|
147
|
+
what the protocol assumes of the ephemeral keys.
|
|
148
|
+
"""
|
|
149
|
+
self._small_a = int(binascii.hexlify(os.urandom(128)), 16) % self._big_n
|
|
150
|
+
self._big_a = pow(self._g, self._small_a, self._big_n)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def _pool_name(self) -> str:
|
|
154
|
+
"""The pool id without the region prefix (needed in the signature)."""
|
|
155
|
+
return self._user_pool_id.split("_", 1)[1]
|
|
156
|
+
|
|
157
|
+
async def _call(self, target: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
158
|
+
headers = {
|
|
159
|
+
"Content-Type": "application/x-amz-json-1.1",
|
|
160
|
+
"X-Amz-Target": f"AWSCognitoIdentityProviderService.{target}",
|
|
161
|
+
}
|
|
162
|
+
try:
|
|
163
|
+
async with self._session.post(
|
|
164
|
+
self._url, headers=headers, json=payload, timeout=_TIMEOUT
|
|
165
|
+
) as response:
|
|
166
|
+
status = response.status
|
|
167
|
+
text = await response.text()
|
|
168
|
+
except (TimeoutError, aiohttp.ClientError) as err:
|
|
169
|
+
raise HeltyCloudConnectionError(f"Cognito is unreachable: {err}") from err
|
|
170
|
+
|
|
171
|
+
# A proxy in front of Cognito answers with its own HTML error page,
|
|
172
|
+
# which json() would blow up on with a ValueError: not one of ours,
|
|
173
|
+
# and callers only guard against HeltyCloudError.
|
|
174
|
+
try:
|
|
175
|
+
body = json.loads(text)
|
|
176
|
+
except ValueError as err:
|
|
177
|
+
raise HeltyCloudConnectionError(
|
|
178
|
+
f"{target}: Cognito answered HTTP {status} with a non-JSON body: {text[:200]}"
|
|
179
|
+
) from err
|
|
180
|
+
if not isinstance(body, dict):
|
|
181
|
+
raise HeltyCloudConnectionError(f"{target}: unexpected answer {text[:200]}")
|
|
182
|
+
|
|
183
|
+
if status != 200:
|
|
184
|
+
message = body.get("message") or body.get("__type") or str(body)
|
|
185
|
+
raise HeltyCloudAuthError(f"{target}: {message}")
|
|
186
|
+
return body
|
|
187
|
+
|
|
188
|
+
def _password_key(self, username: str, password: str, srp_b_hex: str, salt_hex: str) -> bytes:
|
|
189
|
+
big_b = int(srp_b_hex, 16)
|
|
190
|
+
if big_b % self._big_n == 0:
|
|
191
|
+
raise HeltyCloudAuthError("Invalid SRP_B returned by Cognito")
|
|
192
|
+
|
|
193
|
+
u_value = int(_hex_hash(_pad_hex(self._big_a) + _pad_hex(big_b)), 16)
|
|
194
|
+
if u_value == 0:
|
|
195
|
+
raise HeltyCloudAuthError("U parameter is zero in the SRP negotiation")
|
|
196
|
+
|
|
197
|
+
id_hash = _hash_sha256(f"{self._pool_name}{username}:{password}".encode())
|
|
198
|
+
x_value = int(_hex_hash(_pad_hex(salt_hex) + id_hash), 16)
|
|
199
|
+
|
|
200
|
+
g_mod_pow_xn = pow(self._g, x_value, self._big_n)
|
|
201
|
+
base = (big_b - self._k * g_mod_pow_xn) % self._big_n
|
|
202
|
+
s_value = pow(base, self._small_a + u_value * x_value, self._big_n)
|
|
203
|
+
|
|
204
|
+
return _compute_hkdf(
|
|
205
|
+
bytearray.fromhex(_pad_hex(s_value)),
|
|
206
|
+
bytearray.fromhex(_pad_hex(f"{u_value:x}")),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
async def authenticate(self, username: str, password: str) -> Tokens:
|
|
210
|
+
"""Run the full SRP login."""
|
|
211
|
+
self._new_ephemeral()
|
|
212
|
+
init = await self._call(
|
|
213
|
+
"InitiateAuth",
|
|
214
|
+
{
|
|
215
|
+
"AuthFlow": "USER_SRP_AUTH",
|
|
216
|
+
"ClientId": self._client_id,
|
|
217
|
+
"AuthParameters": {
|
|
218
|
+
"USERNAME": username,
|
|
219
|
+
"SRP_A": f"{self._big_a:x}",
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
challenge = init.get("ChallengeParameters", {})
|
|
224
|
+
if init.get("ChallengeName") != "PASSWORD_VERIFIER":
|
|
225
|
+
raise HeltyCloudAuthError(
|
|
226
|
+
f"Unexpected challenge from Cognito: {init.get('ChallengeName')}"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Cognito may answer with an internal identifier different from the
|
|
230
|
+
# one we logged in with: from here on, that one must be used.
|
|
231
|
+
user_id = challenge["USER_ID_FOR_SRP"]
|
|
232
|
+
secret_block = challenge["SECRET_BLOCK"]
|
|
233
|
+
timestamp = _cognito_timestamp()
|
|
234
|
+
|
|
235
|
+
hkdf = self._password_key(user_id, password, challenge["SRP_B"], challenge["SALT"])
|
|
236
|
+
message = (
|
|
237
|
+
self._pool_name.encode()
|
|
238
|
+
+ user_id.encode()
|
|
239
|
+
+ base64.standard_b64decode(secret_block)
|
|
240
|
+
+ timestamp.encode()
|
|
241
|
+
)
|
|
242
|
+
signature = base64.standard_b64encode(
|
|
243
|
+
hmac.new(hkdf, message, hashlib.sha256).digest()
|
|
244
|
+
).decode()
|
|
245
|
+
|
|
246
|
+
result = await self._call(
|
|
247
|
+
"RespondToAuthChallenge",
|
|
248
|
+
{
|
|
249
|
+
"ChallengeName": "PASSWORD_VERIFIER",
|
|
250
|
+
"ClientId": self._client_id,
|
|
251
|
+
"ChallengeResponses": {
|
|
252
|
+
"USERNAME": user_id,
|
|
253
|
+
"TIMESTAMP": timestamp,
|
|
254
|
+
"PASSWORD_CLAIM_SECRET_BLOCK": secret_block,
|
|
255
|
+
"PASSWORD_CLAIM_SIGNATURE": signature,
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
return self._tokens_from(result)
|
|
260
|
+
|
|
261
|
+
async def refresh(self, refresh_token: str) -> Tokens:
|
|
262
|
+
"""Renew the tokens without redoing the whole SRP dance."""
|
|
263
|
+
result = await self._call(
|
|
264
|
+
"InitiateAuth",
|
|
265
|
+
{
|
|
266
|
+
"AuthFlow": "REFRESH_TOKEN_AUTH",
|
|
267
|
+
"ClientId": self._client_id,
|
|
268
|
+
"AuthParameters": {"REFRESH_TOKEN": refresh_token},
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
# The refresh response does not repeat the refresh token: reuse it.
|
|
272
|
+
return self._tokens_from(result, refresh_token=refresh_token)
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _tokens_from(payload: dict[str, Any], refresh_token: str = "") -> Tokens:
|
|
276
|
+
auth = payload.get("AuthenticationResult")
|
|
277
|
+
if not auth:
|
|
278
|
+
raise HeltyCloudAuthError("Cognito did not return the tokens")
|
|
279
|
+
return Tokens(
|
|
280
|
+
id_token=auth["IdToken"],
|
|
281
|
+
access_token=auth["AccessToken"],
|
|
282
|
+
refresh_token=auth.get("RefreshToken", refresh_token),
|
|
283
|
+
expires_at=time.time() + auth.get("ExpiresIn", 3600),
|
|
284
|
+
)
|
pyheltycloud/client.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Async client for the Helty cloud (hcloud)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Any, Self
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
|
|
14
|
+
from .auth import CognitoSrpAuth, Tokens
|
|
15
|
+
from .const import (
|
|
16
|
+
API_BASE,
|
|
17
|
+
CLIENT_ID,
|
|
18
|
+
EP_COMMAND_LOGS,
|
|
19
|
+
EP_INSTALLATIONS,
|
|
20
|
+
EP_LAST_STATUS,
|
|
21
|
+
EP_SEND_COMMAND,
|
|
22
|
+
MODE_COMMANDS,
|
|
23
|
+
REGION,
|
|
24
|
+
USER_POOL_ID,
|
|
25
|
+
Command,
|
|
26
|
+
VmcMode,
|
|
27
|
+
)
|
|
28
|
+
from .exceptions import (
|
|
29
|
+
HeltyCloudAuthError,
|
|
30
|
+
HeltyCloudConnectionError,
|
|
31
|
+
HeltyCloudError,
|
|
32
|
+
HeltyCloudNoDataError,
|
|
33
|
+
)
|
|
34
|
+
from .models import HeltyDevice, HeltyState, is_telemetry
|
|
35
|
+
|
|
36
|
+
_LOGGER = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
#: Overall timeout for each HTTP call to the API.
|
|
39
|
+
TIMEOUT = aiohttp.ClientTimeout(total=15)
|
|
40
|
+
|
|
41
|
+
#: How often to re-check the history while waiting for the board to answer.
|
|
42
|
+
REFRESH_POLL_INTERVAL = 2.0
|
|
43
|
+
|
|
44
|
+
#: How long to wait overall for the board to answer before falling back to
|
|
45
|
+
#: the last available data.
|
|
46
|
+
REFRESH_TIMEOUT = 15.0
|
|
47
|
+
|
|
48
|
+
#: History search window when reading raw telemetry. Measured on 5334
|
|
49
|
+
#: spontaneous reports: the board reports every 5.5 minutes at the median
|
|
50
|
+
#: and every 33 at the 99th percentile, so two hours is wide enough to
|
|
51
|
+
#: always find something without asking the cloud to scan a long window.
|
|
52
|
+
DEFAULT_HISTORY_HOURS = 2
|
|
53
|
+
|
|
54
|
+
#: Rows to ask for. Only the newest is used; a few spare ones cover the
|
|
55
|
+
#: acks and other commands that share the history.
|
|
56
|
+
HISTORY_PAGE_SIZE = 5
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HeltyCloud:
|
|
60
|
+
"""Session towards api.hcloud.heltyair.com.
|
|
61
|
+
|
|
62
|
+
The cloud keeps no device state: it returns the last message received
|
|
63
|
+
from the board, which pushes over its own AWS IoT connection. To get
|
|
64
|
+
current data the board must be asked to report (refresh()), otherwise
|
|
65
|
+
you read whatever is there.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
email: str,
|
|
71
|
+
password: str,
|
|
72
|
+
session: aiohttp.ClientSession | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Without a session the client creates and closes its own.
|
|
75
|
+
|
|
76
|
+
The owned session is created on the first request, not here: aiohttp
|
|
77
|
+
needs a running event loop to create one.
|
|
78
|
+
"""
|
|
79
|
+
self._email = email
|
|
80
|
+
self._password = password
|
|
81
|
+
self._session = session
|
|
82
|
+
self._owns_session = session is None
|
|
83
|
+
self._auth = (
|
|
84
|
+
None if session is None else CognitoSrpAuth(session, USER_POOL_ID, CLIENT_ID, REGION)
|
|
85
|
+
)
|
|
86
|
+
self._tokens: Tokens | None = None
|
|
87
|
+
self._lock = asyncio.Lock()
|
|
88
|
+
|
|
89
|
+
def _ensure_started(self) -> tuple[aiohttp.ClientSession, CognitoSrpAuth]:
|
|
90
|
+
"""Session and auth, created lazily inside the current event loop."""
|
|
91
|
+
if self._session is None:
|
|
92
|
+
self._session = aiohttp.ClientSession()
|
|
93
|
+
if self._auth is None:
|
|
94
|
+
self._auth = CognitoSrpAuth(self._session, USER_POOL_ID, CLIENT_ID, REGION)
|
|
95
|
+
return self._session, self._auth
|
|
96
|
+
|
|
97
|
+
async def __aenter__(self) -> Self:
|
|
98
|
+
"""Enter the async context."""
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
async def __aexit__(
|
|
102
|
+
self,
|
|
103
|
+
exc_type: type[BaseException] | None,
|
|
104
|
+
exc: BaseException | None,
|
|
105
|
+
tb: TracebackType | None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Exit the context, closing the session."""
|
|
108
|
+
await self.close()
|
|
109
|
+
|
|
110
|
+
async def close(self) -> None:
|
|
111
|
+
"""Close the HTTP session, if we created it ourselves."""
|
|
112
|
+
if self._owns_session and self._session is not None:
|
|
113
|
+
await self._session.close()
|
|
114
|
+
|
|
115
|
+
# -- authentication ----------------------------------------------------
|
|
116
|
+
|
|
117
|
+
async def login(self) -> None:
|
|
118
|
+
"""Authenticate and store the tokens. Useful to validate credentials."""
|
|
119
|
+
_, auth = self._ensure_started()
|
|
120
|
+
self._tokens = await auth.authenticate(self._email, self._password)
|
|
121
|
+
|
|
122
|
+
async def _id_token(self) -> str:
|
|
123
|
+
"""Return a valid token, renewing it or logging in again if needed."""
|
|
124
|
+
_, auth = self._ensure_started()
|
|
125
|
+
async with self._lock:
|
|
126
|
+
if self._tokens is None:
|
|
127
|
+
self._tokens = await auth.authenticate(self._email, self._password)
|
|
128
|
+
elif self._tokens.expired:
|
|
129
|
+
try:
|
|
130
|
+
self._tokens = await auth.refresh(self._tokens.refresh_token)
|
|
131
|
+
except HeltyCloudAuthError:
|
|
132
|
+
# The refresh token expired or was revoked: start over.
|
|
133
|
+
_LOGGER.debug("Refresh failed, retrying the full SRP login")
|
|
134
|
+
self._tokens = await auth.authenticate(self._email, self._password)
|
|
135
|
+
return self._tokens.id_token
|
|
136
|
+
|
|
137
|
+
# -- transport ---------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
async def _post(self, path: str, payload: dict[str, Any], *, allow_404: bool = False) -> Any:
|
|
140
|
+
session, _ = self._ensure_started()
|
|
141
|
+
headers = {
|
|
142
|
+
# The API validates the ID token: the access token gets a 401.
|
|
143
|
+
"Authorization": f"Bearer {await self._id_token()}",
|
|
144
|
+
"Accept": "application/json",
|
|
145
|
+
}
|
|
146
|
+
try:
|
|
147
|
+
async with session.post(
|
|
148
|
+
API_BASE + path, headers=headers, json=payload, timeout=TIMEOUT
|
|
149
|
+
) as response:
|
|
150
|
+
status = response.status
|
|
151
|
+
text = await response.text()
|
|
152
|
+
except (TimeoutError, aiohttp.ClientError) as err:
|
|
153
|
+
raise HeltyCloudConnectionError(f"{path}: {err}") from err
|
|
154
|
+
|
|
155
|
+
if status == 401:
|
|
156
|
+
raise HeltyCloudAuthError(text)
|
|
157
|
+
if status == 404 and allow_404:
|
|
158
|
+
return None
|
|
159
|
+
if status >= 400:
|
|
160
|
+
raise HeltyCloudError(f"HTTP {status} on {path}: {text[:200]}")
|
|
161
|
+
|
|
162
|
+
# nginx sits in front of the API and answers with its own HTML pages,
|
|
163
|
+
# so a 200 is not a promise of JSON. Parsing here keeps a ValueError
|
|
164
|
+
# from escaping past the exceptions this library documents.
|
|
165
|
+
try:
|
|
166
|
+
return json.loads(text)
|
|
167
|
+
except ValueError as err:
|
|
168
|
+
raise HeltyCloudConnectionError(
|
|
169
|
+
f"{path}: the cloud answered HTTP {status} with a non-JSON body: {text[:200]}"
|
|
170
|
+
) from err
|
|
171
|
+
|
|
172
|
+
# -- reading -----------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
async def get_devices(self) -> list[HeltyDevice]:
|
|
175
|
+
"""List the VMC units associated with the account."""
|
|
176
|
+
data = await self._post(EP_INSTALLATIONS, {})
|
|
177
|
+
return [HeltyDevice.from_json(item) for item in data]
|
|
178
|
+
|
|
179
|
+
async def get_state(self, serial_number: str) -> HeltyState:
|
|
180
|
+
"""Last known state, from the fields already normalized by the cloud.
|
|
181
|
+
|
|
182
|
+
Raises HeltyCloudNoDataError if the board has not reported for over
|
|
183
|
+
an hour: in that case use refresh() or get_last_telemetry().
|
|
184
|
+
"""
|
|
185
|
+
rows = await self._post(EP_LAST_STATUS, {"serialNumber": serial_number}, allow_404=True)
|
|
186
|
+
if rows is None:
|
|
187
|
+
raise HeltyCloudNoDataError(f"No recent telemetry for {serial_number}")
|
|
188
|
+
return HeltyState.from_last_status(rows)
|
|
189
|
+
|
|
190
|
+
async def get_last_telemetry(
|
|
191
|
+
self, serial_number: str, hours: int = DEFAULT_HISTORY_HOURS
|
|
192
|
+
) -> HeltyState:
|
|
193
|
+
"""Last state from the raw history: includes fans and alarms."""
|
|
194
|
+
now = int(time.time() * 1000)
|
|
195
|
+
page = await self._post(
|
|
196
|
+
EP_COMMAND_LOGS,
|
|
197
|
+
{
|
|
198
|
+
"serialNumber": serial_number,
|
|
199
|
+
"pageSize": HISTORY_PAGE_SIZE,
|
|
200
|
+
"pageNumber": 0,
|
|
201
|
+
"fromTs": now - hours * 3600 * 1000,
|
|
202
|
+
"toTs": now,
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
rows = [
|
|
206
|
+
row
|
|
207
|
+
for row in page.get("data", [])
|
|
208
|
+
if row.get("commandId") == Command.GET_STATUS and is_telemetry(row.get("data") or [])
|
|
209
|
+
]
|
|
210
|
+
if not rows:
|
|
211
|
+
raise HeltyCloudNoDataError(
|
|
212
|
+
f"No telemetry for {serial_number} in the last {hours} hours"
|
|
213
|
+
)
|
|
214
|
+
# The cloud happens to return the newest first, but nothing documents
|
|
215
|
+
# it, and update() decides whether the board answered by comparing
|
|
216
|
+
# timestamps: pick the newest explicitly.
|
|
217
|
+
newest = max(rows, key=lambda row: row.get("timestamp") or 0)
|
|
218
|
+
return HeltyState.from_telemetry(newest["data"], newest.get("timestamp"))
|
|
219
|
+
|
|
220
|
+
async def update(self, device: HeltyDevice) -> HeltyState:
|
|
221
|
+
"""Fetch the current state: ask the board to report, then read.
|
|
222
|
+
|
|
223
|
+
This is the method to use for polling. It waits for a telemetry
|
|
224
|
+
newer than the one already known (up to REFRESH_TIMEOUT seconds);
|
|
225
|
+
if the board does not answer it falls back to the last available
|
|
226
|
+
data, and raises HeltyCloudNoDataError only if there is nothing
|
|
227
|
+
to read at all.
|
|
228
|
+
"""
|
|
229
|
+
try:
|
|
230
|
+
last = await self.get_last_telemetry(device.serial_number)
|
|
231
|
+
except HeltyCloudNoDataError:
|
|
232
|
+
last = None
|
|
233
|
+
|
|
234
|
+
await self.refresh(device.board_serial)
|
|
235
|
+
|
|
236
|
+
loop = asyncio.get_running_loop()
|
|
237
|
+
deadline = loop.time() + REFRESH_TIMEOUT
|
|
238
|
+
while loop.time() < deadline:
|
|
239
|
+
await asyncio.sleep(REFRESH_POLL_INTERVAL)
|
|
240
|
+
try:
|
|
241
|
+
state = await self.get_last_telemetry(device.serial_number)
|
|
242
|
+
except HeltyCloudNoDataError:
|
|
243
|
+
continue
|
|
244
|
+
if last is None or (state.timestamp or 0) > (last.timestamp or 0):
|
|
245
|
+
return state
|
|
246
|
+
|
|
247
|
+
if last is not None:
|
|
248
|
+
_LOGGER.debug(
|
|
249
|
+
"Board %s did not answer the refresh: using the last known data",
|
|
250
|
+
device.serial_number,
|
|
251
|
+
)
|
|
252
|
+
return last
|
|
253
|
+
raise HeltyCloudNoDataError(
|
|
254
|
+
f"No telemetry for {device.serial_number}, even after a refresh"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# -- writing -----------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
async def send_command(self, board_serial: str, command: Command) -> None:
|
|
260
|
+
"""Send an argument-less command to the board.
|
|
261
|
+
|
|
262
|
+
The cloud answers 200 as soon as it takes the command, before the
|
|
263
|
+
board has acted on it, and the answer says nothing about delivery.
|
|
264
|
+
Callers who need certainty should read the state back.
|
|
265
|
+
|
|
266
|
+
Commands that carry an argument are not supported: "values" has to
|
|
267
|
+
hold objects with "id" and "value" (the keys are in the app's own
|
|
268
|
+
code), but every combination tried against the live API was refused
|
|
269
|
+
with 400 "Malformed Command". The vendor places the only argument we
|
|
270
|
+
went looking for, the brightness level, on the lights of the Elite
|
|
271
|
+
version, so there may be nothing here to support on a FlowPLUS.
|
|
272
|
+
"""
|
|
273
|
+
body: dict[str, Any] = {"commandId": int(command), "values": []}
|
|
274
|
+
await self._post(EP_SEND_COMMAND.format(board_serial=board_serial), body)
|
|
275
|
+
|
|
276
|
+
async def refresh(self, board_serial: str) -> None:
|
|
277
|
+
"""Ask the board to report its state right away."""
|
|
278
|
+
await self.send_command(board_serial, Command.GET_STATUS)
|
|
279
|
+
|
|
280
|
+
async def set_mode(self, board_serial: str, mode: VmcMode) -> None:
|
|
281
|
+
"""Set the VMC mode. Single send, no verification."""
|
|
282
|
+
await self.send_command(board_serial, MODE_COMMANDS[mode])
|
|
283
|
+
|
|
284
|
+
async def set_mode_verified(
|
|
285
|
+
self, device: HeltyDevice, mode: VmcMode, attempts: int = 3
|
|
286
|
+
) -> HeltyState:
|
|
287
|
+
"""set_mode() with verification: re-read the state and resend if lost.
|
|
288
|
+
|
|
289
|
+
Returns the state reflecting the requested mode. Raises
|
|
290
|
+
HeltyCloudError if after `attempts` sends the VMC has not applied it.
|
|
291
|
+
"""
|
|
292
|
+
for attempt in range(1, attempts + 1):
|
|
293
|
+
await self.set_mode(device.board_serial, mode)
|
|
294
|
+
state = await self.update(device)
|
|
295
|
+
if state.mode is mode:
|
|
296
|
+
return state
|
|
297
|
+
_LOGGER.debug(
|
|
298
|
+
"Command %s not applied by %s (attempt %d/%d)",
|
|
299
|
+
mode.name,
|
|
300
|
+
device.serial_number,
|
|
301
|
+
attempt,
|
|
302
|
+
attempts,
|
|
303
|
+
)
|
|
304
|
+
raise HeltyCloudError(f"The VMC did not apply mode {mode.name} after {attempts} attempts")
|
pyheltycloud/const.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Constants of the Helty cloud (hcloud).
|
|
2
|
+
|
|
3
|
+
Extracted from the "Helty Home" app 1.2.2 (Flutter + AWS Amplify) and
|
|
4
|
+
verified against the real API. No official documentation exists.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import IntEnum, IntFlag
|
|
10
|
+
|
|
11
|
+
USER_POOL_ID = "eu-central-1_lejYSlqKZ"
|
|
12
|
+
CLIENT_ID = "7k0c21g92bk3413frij8rso6rk"
|
|
13
|
+
REGION = "eu-central-1"
|
|
14
|
+
API_BASE = "https://api.hcloud.heltyair.com"
|
|
15
|
+
|
|
16
|
+
EP_INSTALLATIONS = "/board/installation/all"
|
|
17
|
+
EP_LAST_STATUS = "/log/commandlogs/laststatus"
|
|
18
|
+
EP_COMMAND_LOGS = "/log/commandlogs/search"
|
|
19
|
+
# The service prefix is doubled (like /user/user/...), and the parameter is
|
|
20
|
+
# the serial of the BOARD, not the one of the VMC.
|
|
21
|
+
EP_SEND_COMMAND = "/board/board/sendcommand/{board_serial}"
|
|
22
|
+
|
|
23
|
+
# Physical quantities arrive multiplied by 10, as on the local protocol.
|
|
24
|
+
SCALE = 10
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class VmcMode(IntEnum):
|
|
28
|
+
"""VMC mode: same enum as the local protocol (port 5001)."""
|
|
29
|
+
|
|
30
|
+
OFF = 0
|
|
31
|
+
SPEED_1 = 1
|
|
32
|
+
SPEED_2 = 2
|
|
33
|
+
SPEED_3 = 3
|
|
34
|
+
SPEED_4 = 4
|
|
35
|
+
HYPERVENTILATION = 5
|
|
36
|
+
NIGHT = 6
|
|
37
|
+
FREE_COOLING = 7
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Alarm(IntFlag):
|
|
41
|
+
"""Bits of the alarm bitmap, from the table the vendor supplied.
|
|
42
|
+
|
|
43
|
+
The table stops at bit 8; every higher bit is undocumented. IntFlag
|
|
44
|
+
keeps unknown bits rather than rejecting them, so the raw value stays
|
|
45
|
+
readable through the flags.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
ICE_RISK = 0x001
|
|
49
|
+
#: The vendor lists two humidity alarms (codes 7 and 8) and gives both
|
|
50
|
+
#: the same description, without saying what tells them apart.
|
|
51
|
+
HUMIDITY_1 = 0x002
|
|
52
|
+
HUMIDITY_2 = 0x004
|
|
53
|
+
TEMPERATURE_DELTA = 0x008
|
|
54
|
+
CO2_OVER_LIMIT = 0x010
|
|
55
|
+
FILTER = 0x020
|
|
56
|
+
#: Code 12 in the vendor's table, whose description column is empty.
|
|
57
|
+
UNDOCUMENTED_12 = 0x040
|
|
58
|
+
#: Not a fault: the machine reports that it went into free cooling by
|
|
59
|
+
#: itself. It sits in the alarm bitmap all the same.
|
|
60
|
+
FREE_COOLING_AUTO = 0x080
|
|
61
|
+
VOC_OVER_LIMIT = 0x100
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
#: Bits that mean something is wrong. Free cooling is a state, not a fault,
|
|
65
|
+
#: and the undocumented bits are left out on purpose: the only one ever seen
|
|
66
|
+
#: on a real unit, 0x0000000C00000000, lasted two seconds while the fans were
|
|
67
|
+
#: spinning up, so treating unknown bits as faults would fire a notification
|
|
68
|
+
#: at every start.
|
|
69
|
+
FAULT_MASK = (
|
|
70
|
+
Alarm.ICE_RISK
|
|
71
|
+
| Alarm.HUMIDITY_1
|
|
72
|
+
| Alarm.HUMIDITY_2
|
|
73
|
+
| Alarm.TEMPERATURE_DELTA
|
|
74
|
+
| Alarm.CO2_OVER_LIMIT
|
|
75
|
+
| Alarm.FILTER
|
|
76
|
+
| Alarm.VOC_OVER_LIMIT
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Command(IntEnum):
|
|
81
|
+
"""commandId values accepted by EP_SEND_COMMAND.
|
|
82
|
+
|
|
83
|
+
Derived by watching the command log while operating the app: verified
|
|
84
|
+
by observation, not documented.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
GET_STATUS = 0
|
|
88
|
+
GET_INFO = 1
|
|
89
|
+
OFF = 20
|
|
90
|
+
FREE_COOLING = 21
|
|
91
|
+
NIGHT = 22
|
|
92
|
+
HYPERVENTILATION = 23
|
|
93
|
+
SPEED_STEP_UP = 24
|
|
94
|
+
GET_PARAMETERS = 33
|
|
95
|
+
# Zeroes the partial filter counter after a filter change. Given by the
|
|
96
|
+
# vendor, who is not certain of it either ("dovrebbe essere il 37"), and
|
|
97
|
+
# never sent: trying it would destroy the only record of how old the
|
|
98
|
+
# filter is.
|
|
99
|
+
RESET_FILTER = 37
|
|
100
|
+
# The "Auto" toggle of the app ("auto_group" in its command switch).
|
|
101
|
+
# Write only: the vendor confirmed the commands take effect but that the
|
|
102
|
+
# setting cannot be read back from anywhere, which is why the app shows
|
|
103
|
+
# its own local idea of the toggle. In Auto the unit picks the speed from
|
|
104
|
+
# its humidity, VOC and CO2 sensors, but only while the outdoor
|
|
105
|
+
# temperature is at least 5 °C below the indoor one and indoor humidity
|
|
106
|
+
# is above 65%, so on a unit with humidity only nothing happens in summer.
|
|
107
|
+
AUTO_ON = 38
|
|
108
|
+
AUTO_OFF = 39
|
|
109
|
+
# Verified live: 40 switches the whole panel off, display and speed LEDs
|
|
110
|
+
# together, and 41 switches it back on. Neither takes an argument, and
|
|
111
|
+
# nothing in the telemetry reflects which of the two is in force.
|
|
112
|
+
PANEL_OFF = 40
|
|
113
|
+
PANEL_ON = 41
|
|
114
|
+
SPEED_1 = 44
|
|
115
|
+
SPEED_2 = 45
|
|
116
|
+
SPEED_3 = 46
|
|
117
|
+
SPEED_4 = 47
|
|
118
|
+
# Answers with [level]. The vendor says the level belongs to the lights
|
|
119
|
+
# of the Elite version, not to this panel, which would explain why it
|
|
120
|
+
# reads 100 whether the panel is lit or dark.
|
|
121
|
+
GET_PANEL_LEVEL = 48
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
#: Command that sets each mode. All modes are covered.
|
|
125
|
+
MODE_COMMANDS: dict[VmcMode, Command] = {
|
|
126
|
+
VmcMode.OFF: Command.OFF,
|
|
127
|
+
VmcMode.SPEED_1: Command.SPEED_1,
|
|
128
|
+
VmcMode.SPEED_2: Command.SPEED_2,
|
|
129
|
+
VmcMode.SPEED_3: Command.SPEED_3,
|
|
130
|
+
VmcMode.SPEED_4: Command.SPEED_4,
|
|
131
|
+
VmcMode.HYPERVENTILATION: Command.HYPERVENTILATION,
|
|
132
|
+
VmcMode.NIGHT: Command.NIGHT,
|
|
133
|
+
VmcMode.FREE_COOLING: Command.FREE_COOLING,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# Positions in the telemetry payload of Command.GET_STATUS.
|
|
137
|
+
IDX_ALARMS = 0
|
|
138
|
+
IDX_MODE = 1
|
|
139
|
+
IDX_TEMP_INDOOR = 2
|
|
140
|
+
IDX_TEMP_OUTDOOR = 3
|
|
141
|
+
IDX_FAN_SUPPLY = 4
|
|
142
|
+
IDX_FAN_EXHAUST = 5
|
|
143
|
+
IDX_HUMIDITY = 10
|
|
144
|
+
TELEMETRY_MIN_LEN = IDX_HUMIDITY + 1
|
|
145
|
+
|
|
146
|
+
# Optional sensors, absent on most units: they read 0 when not installed.
|
|
147
|
+
# The vendor calls them "A: anidride CO2" and "I: isobutene VOC" and gives
|
|
148
|
+
# no unit, and this panel has neither, so the values are passed on as they
|
|
149
|
+
# arrive rather than scaled on a guess.
|
|
150
|
+
IDX_CO2 = 14
|
|
151
|
+
IDX_VOC = 15
|
|
152
|
+
SENSOR_MIN_LEN = IDX_VOC + 1
|
|
153
|
+
|
|
154
|
+
# The two counters of treated air. The vendor confirmed that 16 is the
|
|
155
|
+
# partial one, zeroed by the filter reset, and that 18 is the lifetime one:
|
|
156
|
+
# their difference is constant and is what the machine had treated when the
|
|
157
|
+
# filter was last changed.
|
|
158
|
+
IDX_FILTER_PARTIAL = 16
|
|
159
|
+
IDX_FILTER_TOTAL = 18
|
|
160
|
+
FILTER_MIN_LEN = IDX_FILTER_TOTAL + 1
|
|
161
|
+
|
|
162
|
+
#: Cubic metres per count. The vendor wrote "10e-1 mc", but the measured
|
|
163
|
+
#: rates only make sense at ten: over 475 hours at speed 1 the counter grew
|
|
164
|
+
#: 0.73 per hour, that is 7.3 m3/h, while a tenth of a cubic metre per count
|
|
165
|
+
#: would mean 0.073 m3/h out of a machine rated for tens.
|
|
166
|
+
FILTER_M3_PER_COUNT = 10
|
|
167
|
+
|
|
168
|
+
#: Cubic metres after which the panel shows the filter alarm. The vendor
|
|
169
|
+
#: gives this for the current firmware and warns that it can change with
|
|
170
|
+
#: later ones, and it is in none of the 27 values of GET_PARAMETERS: it is a
|
|
171
|
+
#: default to start from, not a reading.
|
|
172
|
+
FILTER_ALARM_M3 = 90_000
|
|
173
|
+
|
|
174
|
+
# EP_LAST_STATUS fields and the corresponding HeltyState attribute.
|
|
175
|
+
LAST_STATUS_MAP = {
|
|
176
|
+
"VMCStatus": "mode",
|
|
177
|
+
"TemperaturaInterna": "temperature_indoor",
|
|
178
|
+
"TemperaturaEsterna": "temperature_outdoor",
|
|
179
|
+
"Humidity": "humidity",
|
|
180
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Exceptions of pyheltycloud."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HeltyCloudError(Exception):
|
|
7
|
+
"""Generic Helty cloud error."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HeltyCloudAuthError(HeltyCloudError):
|
|
11
|
+
"""Credentials rejected, or session no longer renewable."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HeltyCloudConnectionError(HeltyCloudError):
|
|
15
|
+
"""The cloud is unreachable or misbehaved."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HeltyCloudNoDataError(HeltyCloudError):
|
|
19
|
+
"""The panel has not sent telemetry recently.
|
|
20
|
+
|
|
21
|
+
The cloud keeps no state: it only answers with the last message it
|
|
22
|
+
received from the board. If the board has been silent for a while there
|
|
23
|
+
is nothing to read until it is asked to report (see HeltyCloud.refresh).
|
|
24
|
+
"""
|
pyheltycloud/models.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Data models of pyheltycloud."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .const import (
|
|
9
|
+
FAULT_MASK,
|
|
10
|
+
FILTER_M3_PER_COUNT,
|
|
11
|
+
FILTER_MIN_LEN,
|
|
12
|
+
IDX_ALARMS,
|
|
13
|
+
IDX_CO2,
|
|
14
|
+
IDX_FAN_EXHAUST,
|
|
15
|
+
IDX_FAN_SUPPLY,
|
|
16
|
+
IDX_FILTER_PARTIAL,
|
|
17
|
+
IDX_FILTER_TOTAL,
|
|
18
|
+
IDX_HUMIDITY,
|
|
19
|
+
IDX_MODE,
|
|
20
|
+
IDX_TEMP_INDOOR,
|
|
21
|
+
IDX_TEMP_OUTDOOR,
|
|
22
|
+
IDX_VOC,
|
|
23
|
+
LAST_STATUS_MAP,
|
|
24
|
+
SCALE,
|
|
25
|
+
SENSOR_MIN_LEN,
|
|
26
|
+
TELEMETRY_MIN_LEN,
|
|
27
|
+
Alarm,
|
|
28
|
+
VmcMode,
|
|
29
|
+
)
|
|
30
|
+
from .exceptions import HeltyCloudError, HeltyCloudNoDataError
|
|
31
|
+
|
|
32
|
+
#: Value the cloud uses when an optional sensor is not installed.
|
|
33
|
+
NO_SENSOR = 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class HeltyDevice:
|
|
38
|
+
"""A VMC unit as the cloud knows it."""
|
|
39
|
+
|
|
40
|
+
serial_number: str
|
|
41
|
+
"""Serial of the machine: identifies the device in reads."""
|
|
42
|
+
|
|
43
|
+
board_serial: str
|
|
44
|
+
"""Serial of the board (= MAC of the panel): needed to send commands."""
|
|
45
|
+
|
|
46
|
+
name: str
|
|
47
|
+
model: str
|
|
48
|
+
firmware: str
|
|
49
|
+
raw: dict[str, Any] = field(repr=False, default_factory=dict)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_json(cls, data: dict[str, Any]) -> HeltyDevice:
|
|
53
|
+
"""Build the device from the EP_INSTALLATIONS response."""
|
|
54
|
+
board = data.get("cloudBoard") or {}
|
|
55
|
+
installation = data.get("currentInstallation") or {}
|
|
56
|
+
product = data.get("productType") or {}
|
|
57
|
+
serial = data["serialNumber"]
|
|
58
|
+
return cls(
|
|
59
|
+
serial_number=serial,
|
|
60
|
+
board_serial=data.get("boardSerialNumber") or board.get("serialNumber", ""),
|
|
61
|
+
name=installation.get("name") or serial,
|
|
62
|
+
model=product.get("model", ""),
|
|
63
|
+
firmware=board.get("firmware", ""),
|
|
64
|
+
raw=data,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class HeltyState:
|
|
70
|
+
"""State of the VMC at a point in time."""
|
|
71
|
+
|
|
72
|
+
mode: VmcMode
|
|
73
|
+
temperature_indoor: float | None = None
|
|
74
|
+
temperature_outdoor: float | None = None
|
|
75
|
+
humidity: float | None = None
|
|
76
|
+
fan_supply: int | None = None
|
|
77
|
+
"""Supply fan percentage (only from raw telemetry)."""
|
|
78
|
+
|
|
79
|
+
fan_exhaust: int | None = None
|
|
80
|
+
"""Exhaust fan percentage (only from raw telemetry)."""
|
|
81
|
+
|
|
82
|
+
alarms: str | None = None
|
|
83
|
+
"""Hex alarm bitmap; "0000000000000000" means no alarms."""
|
|
84
|
+
|
|
85
|
+
co2: int | None = None
|
|
86
|
+
"""CO2 reading, None when the sensor is not installed. Unit unknown."""
|
|
87
|
+
|
|
88
|
+
voc: int | None = None
|
|
89
|
+
"""VOC reading, None when the sensor is not installed. Unit unknown."""
|
|
90
|
+
|
|
91
|
+
filter_partial_m3: int | None = None
|
|
92
|
+
"""Cubic metres treated since the last filter reset."""
|
|
93
|
+
|
|
94
|
+
filter_total_m3: int | None = None
|
|
95
|
+
"""Cubic metres treated over the life of the machine."""
|
|
96
|
+
|
|
97
|
+
timestamp: int | None = None
|
|
98
|
+
"""Epoch milliseconds of the message this state derives from."""
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def is_on(self) -> bool:
|
|
102
|
+
"""True when the machine is ventilating, in any mode."""
|
|
103
|
+
return self.mode is not VmcMode.OFF
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def alarm_flags(self) -> Alarm | None:
|
|
107
|
+
"""Decode the alarm bitmap, or None when there is no raw telemetry."""
|
|
108
|
+
if not self.alarms:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
return Alarm(int(self.alarms, 16))
|
|
112
|
+
except ValueError:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def has_alarm(self) -> bool:
|
|
117
|
+
"""True when the machine reports a fault.
|
|
118
|
+
|
|
119
|
+
Only the documented fault bits count: free cooling reports itself in
|
|
120
|
+
the same bitmap without anything being wrong, and the undocumented
|
|
121
|
+
bits carry at least one start-up transient.
|
|
122
|
+
"""
|
|
123
|
+
flags = self.alarm_flags
|
|
124
|
+
return flags is not None and bool(flags & FAULT_MASK)
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_last_status(cls, rows: list[dict[str, Any]]) -> HeltyState:
|
|
128
|
+
"""Build the state from the EP_LAST_STATUS response.
|
|
129
|
+
|
|
130
|
+
Each row is {"field": …, "value": …}; values are scaled integers.
|
|
131
|
+
Raises HeltyCloudNoDataError when VMCStatus is missing: "no data"
|
|
132
|
+
must not be mistaken for "machine off".
|
|
133
|
+
"""
|
|
134
|
+
values: dict[str, float] = {}
|
|
135
|
+
for row in rows:
|
|
136
|
+
attribute = LAST_STATUS_MAP.get(row.get("field", ""))
|
|
137
|
+
if attribute is not None:
|
|
138
|
+
values[attribute] = row["value"]
|
|
139
|
+
if "mode" not in values:
|
|
140
|
+
raise HeltyCloudNoDataError("laststatus response without VMCStatus")
|
|
141
|
+
return cls(
|
|
142
|
+
mode=_mode(values["mode"]),
|
|
143
|
+
temperature_indoor=_scaled(values.get("temperature_indoor")),
|
|
144
|
+
temperature_outdoor=_scaled(values.get("temperature_outdoor")),
|
|
145
|
+
humidity=_humidity(values.get("humidity")),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def from_telemetry(cls, data: list[Any], timestamp: int | None = None) -> HeltyState:
|
|
150
|
+
"""Build the state from the raw Command.GET_STATUS payload.
|
|
151
|
+
|
|
152
|
+
Richer than from_last_status(): adds fans and alarms.
|
|
153
|
+
"""
|
|
154
|
+
return cls(
|
|
155
|
+
mode=_mode(data[IDX_MODE]),
|
|
156
|
+
temperature_indoor=_scaled(data[IDX_TEMP_INDOOR]),
|
|
157
|
+
temperature_outdoor=_scaled(data[IDX_TEMP_OUTDOOR]),
|
|
158
|
+
humidity=_humidity(data[IDX_HUMIDITY]),
|
|
159
|
+
fan_supply=int(data[IDX_FAN_SUPPLY]),
|
|
160
|
+
fan_exhaust=int(data[IDX_FAN_EXHAUST]),
|
|
161
|
+
alarms=str(data[IDX_ALARMS]),
|
|
162
|
+
timestamp=timestamp,
|
|
163
|
+
co2=_optional(data, IDX_CO2, SENSOR_MIN_LEN),
|
|
164
|
+
voc=_optional(data, IDX_VOC, SENSOR_MIN_LEN),
|
|
165
|
+
filter_partial_m3=_counter(data, IDX_FILTER_PARTIAL),
|
|
166
|
+
filter_total_m3=_counter(data, IDX_FILTER_TOTAL),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def is_telemetry(data: list[Any]) -> bool:
|
|
171
|
+
"""Return True when the payload is a full telemetry and not an empty ack."""
|
|
172
|
+
return len(data) >= TELEMETRY_MIN_LEN
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _optional(data: list[Any], index: int, min_len: int) -> int | None:
|
|
176
|
+
"""Read a field that may be absent, or zero because it is not installed.
|
|
177
|
+
|
|
178
|
+
Short payloads do not carry it, and a zero is not trusted: for the first
|
|
179
|
+
day it was online this panel returned telemetry with a whole block of
|
|
180
|
+
fields zeroed while the machine was running.
|
|
181
|
+
"""
|
|
182
|
+
if len(data) < min_len or not data[index]:
|
|
183
|
+
return None
|
|
184
|
+
return int(data[index])
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _counter(data: list[Any], index: int) -> int | None:
|
|
188
|
+
"""Read a filter counter and convert it to cubic metres."""
|
|
189
|
+
counts = _optional(data, index, FILTER_MIN_LEN)
|
|
190
|
+
return None if counts is None else counts * FILTER_M3_PER_COUNT
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _mode(value: Any) -> VmcMode:
|
|
194
|
+
"""Decode the mode, refusing to guess at a value we do not know.
|
|
195
|
+
|
|
196
|
+
The vendor has no way of warning us before the API changes, so a mode
|
|
197
|
+
added by a future firmware must surface as a cloud error the caller
|
|
198
|
+
already handles, not as a ValueError out of a dataclass.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
return VmcMode(int(value))
|
|
202
|
+
except ValueError as err:
|
|
203
|
+
raise HeltyCloudError(f"Unknown VMC mode {value!r}") from err
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _scaled(value: float | None) -> float | None:
|
|
207
|
+
return None if value is None else value / SCALE
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _humidity(value: float | None) -> float | None:
|
|
211
|
+
"""Like _scaled, but 0 becomes None: 0% is not a real humidity."""
|
|
212
|
+
if value is None or value == NO_SENSOR:
|
|
213
|
+
return None
|
|
214
|
+
return value / SCALE
|
pyheltycloud/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyheltycloud
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async client for the Helty cloud (hcloud) of Helty Flow VMC units
|
|
5
|
+
Author: Ermanno Baschiera
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ebaschiera/pyheltycloud
|
|
8
|
+
Keywords: helty,vmc,hrv,home-assistant
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: aiohttp>=3.9
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# pyheltycloud
|
|
16
|
+
|
|
17
|
+
Async Python client for the **hcloud** backend of [Helty](https://www.heltyair.com/), used by the
|
|
18
|
+
recent control panels of Helty Flow VMC units and by the "Helty Home" app.
|
|
19
|
+
|
|
20
|
+
Born as the foundation for a `helty_cloud` Home Assistant integration, next to
|
|
21
|
+
[`helty`](https://www.home-assistant.io/integrations/helty/) which speaks the local protocol of the
|
|
22
|
+
previous panels. The new panels expose no listening port: control necessarily goes through the
|
|
23
|
+
manufacturer's cloud.
|
|
24
|
+
|
|
25
|
+
> **Unofficial.** The API is undocumented: it has been reconstructed by observing the app and
|
|
26
|
+
> verified on real hardware. Helty may change it at any time.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install pyheltycloud
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import asyncio
|
|
38
|
+
from pyheltycloud import HeltyCloud, VmcMode
|
|
39
|
+
|
|
40
|
+
async def main() -> None:
|
|
41
|
+
async with HeltyCloud("me@example.com", "password") as helty:
|
|
42
|
+
for device in await helty.get_devices():
|
|
43
|
+
print(device.name, device.model)
|
|
44
|
+
|
|
45
|
+
# update() wakes the board and then reads: the method to use for polling.
|
|
46
|
+
state = await helty.update(device)
|
|
47
|
+
print(state.mode, state.temperature_indoor, state.humidity)
|
|
48
|
+
|
|
49
|
+
# set_mode_verified() re-reads the state and resends if the command is lost.
|
|
50
|
+
await helty.set_mode_verified(device, VmcMode.SPEED_2)
|
|
51
|
+
|
|
52
|
+
asyncio.run(main())
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## How it works
|
|
56
|
+
|
|
57
|
+
Two separate channels. The panel runs no server: it holds an outbound connection to **AWS IoT Core**
|
|
58
|
+
(eu-central-1), and Helty's backend relays to it. Clients never reach the panel directly — the app
|
|
59
|
+
and this library call the REST API at `api.hcloud.heltyair.com`, authenticating against an **AWS
|
|
60
|
+
Cognito** user pool with the account's own email and password (SRP, nothing to provision).
|
|
61
|
+
|
|
62
|
+
That shape explains both surprises below: a read returns the last message the panel pushed, not the
|
|
63
|
+
machine's current state, and a write is a message handed to the relay, so a `200` only means the
|
|
64
|
+
backend took it.
|
|
65
|
+
|
|
66
|
+
## Things to know before using it
|
|
67
|
+
|
|
68
|
+
**Read, do not poke.** The board reports on its own every 5.5 minutes at the median and every 33
|
|
69
|
+
at the 99th percentile, measured over 5334 spontaneous reports, because it reports on any
|
|
70
|
+
significant change as well as on its timer. Reading the last message costs one call and the board
|
|
71
|
+
never notices, so a poll should use `get_last_telemetry()`. `refresh()` instead wakes the board,
|
|
72
|
+
and `update()` wakes it and waits up to ~15 seconds for the answer: keep those for right after a
|
|
73
|
+
command, when confirmation is worth the round trip. The vendor asks not to solicit on a short
|
|
74
|
+
timer.
|
|
75
|
+
|
|
76
|
+
**Commands are acknowledged, not guaranteed.** The cloud answers `200` before the board has done
|
|
77
|
+
anything. `set_mode()` is a single send; `set_mode_verified()` re-reads the state and retries,
|
|
78
|
+
for callers that would rather pay a round trip than miss a command.
|
|
79
|
+
|
|
80
|
+
**Two different serials.** Reads use `device.serial_number` (the machine), writes use
|
|
81
|
+
`device.board_serial` (the board). They are not interchangeable.
|
|
82
|
+
|
|
83
|
+
## What is covered
|
|
84
|
+
|
|
85
|
+
| | |
|
|
86
|
+
|---|---|
|
|
87
|
+
| Reading | devices, mode, indoor and outdoor temperature, humidity, CO2 and VOC where installed, both fan percentages, alarms as named flags, the two counters of treated air |
|
|
88
|
+
| Writing | all modes (off, speeds 1-4, night, hyperventilation, free cooling), optionally with verification and retries; report request |
|
|
89
|
+
| Not mapped yet | commands that carry an argument: the API refuses every shape tried. The brightness level, the one argument worth having, belongs to the Elite version's lights, so a FlowPLUS may have nothing here |
|
|
90
|
+
| Not mapped yet | the filter reset: the vendor gives command 37, unverified, and sending it would destroy the only record of how old the filter is |
|
|
91
|
+
| Not available | whether the panel is lit, and whether Auto is on: 40, 41, 38 and 39 take effect, but the vendor states this generation of machines cannot report either state |
|
|
92
|
+
| Not available | the unit of the CO2 and VOC readings, undocumented and untestable on a unit that has neither sensor |
|
|
93
|
+
| Not available | the alarm threshold of the filter — 90000 m³ on the current firmware, by the vendor's word, and in none of the values the board reports |
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
uv sync --group dev
|
|
99
|
+
uv run pytest --cov=pyheltycloud
|
|
100
|
+
uv run ruff check src tests && uv run ruff format --check src tests
|
|
101
|
+
uv run mypy
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Tests never touch the network: they use a fake local HTTP backend.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pyheltycloud/__init__.py,sha256=soLNiIrzmIsgf7G66GEG__E4oxlAl_ztkAJKlLfJ4oY,549
|
|
2
|
+
pyheltycloud/auth.py,sha256=HBhJRV-xkema-5btF1itjGTAzwbNQh1jkM1HL69Bezg,10130
|
|
3
|
+
pyheltycloud/client.py,sha256=n5e2llqor_45wMn4MVblwenAUBw2BAMFFHUm_G5Yb_U,11962
|
|
4
|
+
pyheltycloud/const.py,sha256=5incKG14-oLJYMtfYHRDsInUMqX6NsI-O4LxQXAZ__o,6289
|
|
5
|
+
pyheltycloud/exceptions.py,sha256=nyhzPgbN2PhzB9OaaiDMXsxfBagjrHqi3_lHOxwtcZY,686
|
|
6
|
+
pyheltycloud/models.py,sha256=rSLCYVLFJtpwBgGf2j_4DvythO_rup3_esci4teDmrQ,7152
|
|
7
|
+
pyheltycloud/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
pyheltycloud-0.1.0.dist-info/licenses/LICENSE,sha256=32Lok91VHOSzOcH_3yzT7o-OybwTYMAxHqyBzZZNh_8,1074
|
|
9
|
+
pyheltycloud-0.1.0.dist-info/METADATA,sha256=wdevyfVd5lhCMuQyY7vk8dCD8V6WeCRMYMqzIkl3IV0,4854
|
|
10
|
+
pyheltycloud-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
pyheltycloud-0.1.0.dist-info/top_level.txt,sha256=MODR7Lrix1kgpeerR0hil7NyujOh2wgEHiiW7RVjcE4,13
|
|
12
|
+
pyheltycloud-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ermanno Baschiera
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyheltycloud
|