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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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