pyheltycloud 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,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,94 @@
1
+ # pyheltycloud
2
+
3
+ Async Python client for the **hcloud** backend of [Helty](https://www.heltyair.com/), used by the
4
+ recent control panels of Helty Flow VMC units and by the "Helty Home" app.
5
+
6
+ Born as the foundation for a `helty_cloud` Home Assistant integration, next to
7
+ [`helty`](https://www.home-assistant.io/integrations/helty/) which speaks the local protocol of the
8
+ previous panels. The new panels expose no listening port: control necessarily goes through the
9
+ manufacturer's cloud.
10
+
11
+ > **Unofficial.** The API is undocumented: it has been reconstructed by observing the app and
12
+ > verified on real hardware. Helty may change it at any time.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install pyheltycloud
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```python
23
+ import asyncio
24
+ from pyheltycloud import HeltyCloud, VmcMode
25
+
26
+ async def main() -> None:
27
+ async with HeltyCloud("me@example.com", "password") as helty:
28
+ for device in await helty.get_devices():
29
+ print(device.name, device.model)
30
+
31
+ # update() wakes the board and then reads: the method to use for polling.
32
+ state = await helty.update(device)
33
+ print(state.mode, state.temperature_indoor, state.humidity)
34
+
35
+ # set_mode_verified() re-reads the state and resends if the command is lost.
36
+ await helty.set_mode_verified(device, VmcMode.SPEED_2)
37
+
38
+ asyncio.run(main())
39
+ ```
40
+
41
+ ## How it works
42
+
43
+ Two separate channels. The panel runs no server: it holds an outbound connection to **AWS IoT Core**
44
+ (eu-central-1), and Helty's backend relays to it. Clients never reach the panel directly — the app
45
+ and this library call the REST API at `api.hcloud.heltyair.com`, authenticating against an **AWS
46
+ Cognito** user pool with the account's own email and password (SRP, nothing to provision).
47
+
48
+ That shape explains both surprises below: a read returns the last message the panel pushed, not the
49
+ machine's current state, and a write is a message handed to the relay, so a `200` only means the
50
+ backend took it.
51
+
52
+ ## Things to know before using it
53
+
54
+ **Read, do not poke.** The board reports on its own every 5.5 minutes at the median and every 33
55
+ at the 99th percentile, measured over 5334 spontaneous reports, because it reports on any
56
+ significant change as well as on its timer. Reading the last message costs one call and the board
57
+ never notices, so a poll should use `get_last_telemetry()`. `refresh()` instead wakes the board,
58
+ and `update()` wakes it and waits up to ~15 seconds for the answer: keep those for right after a
59
+ command, when confirmation is worth the round trip. The vendor asks not to solicit on a short
60
+ timer.
61
+
62
+ **Commands are acknowledged, not guaranteed.** The cloud answers `200` before the board has done
63
+ anything. `set_mode()` is a single send; `set_mode_verified()` re-reads the state and retries,
64
+ for callers that would rather pay a round trip than miss a command.
65
+
66
+ **Two different serials.** Reads use `device.serial_number` (the machine), writes use
67
+ `device.board_serial` (the board). They are not interchangeable.
68
+
69
+ ## What is covered
70
+
71
+ | | |
72
+ |---|---|
73
+ | 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 |
74
+ | Writing | all modes (off, speeds 1-4, night, hyperventilation, free cooling), optionally with verification and retries; report request |
75
+ | 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 |
76
+ | 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 |
77
+ | 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 |
78
+ | Not available | the unit of the CO2 and VOC readings, undocumented and untestable on a unit that has neither sensor |
79
+ | 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 |
80
+
81
+ ## Development
82
+
83
+ ```bash
84
+ uv sync --group dev
85
+ uv run pytest --cov=pyheltycloud
86
+ uv run ruff check src tests && uv run ruff format --check src tests
87
+ uv run mypy
88
+ ```
89
+
90
+ Tests never touch the network: they use a fake local HTTP backend.
91
+
92
+ ## License
93
+
94
+ MIT.
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyheltycloud"
7
+ version = "0.1.0"
8
+ description = "Async client for the Helty cloud (hcloud) of Helty Flow VMC units"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ authors = [{ name = "Ermanno Baschiera" }]
13
+ keywords = ["helty", "vmc", "hrv", "home-assistant"]
14
+ dependencies = ["aiohttp>=3.9"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/ebaschiera/pyheltycloud"
18
+
19
+ [dependency-groups]
20
+ dev = ["pytest", "pytest-asyncio", "pytest-cov", "ruff", "mypy"]
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+
25
+ [tool.setuptools.package-data]
26
+ pyheltycloud = ["py.typed"]
27
+
28
+ [tool.pytest.ini_options]
29
+ asyncio_mode = "auto"
30
+ testpaths = ["tests"]
31
+
32
+ [tool.ruff]
33
+ line-length = 100
34
+ target-version = "py312"
35
+
36
+ [tool.ruff.lint]
37
+ select = ["E", "F", "I", "N", "UP", "B", "SIM", "RUF", "D"]
38
+ ignore = ["D203", "D213"]
39
+
40
+ [tool.ruff.lint.per-file-ignores]
41
+ # Tests are documented by their name; docstrings only where the "why" needs explaining.
42
+ "tests/*" = ["D100", "D101", "D102", "D103", "D104"]
43
+
44
+ [tool.mypy]
45
+ strict = true
46
+ packages = ["pyheltycloud"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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
+ )