vi-api-client 6.1.1__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.
- vi_api_client/__init__.py +74 -0
- vi_api_client/_adapter.py +321 -0
- vi_api_client/_feature_diff.py +250 -0
- vi_api_client/_types.py +12 -0
- vi_api_client/auth.py +365 -0
- vi_api_client/cli.py +1368 -0
- vi_api_client/client.py +1004 -0
- vi_api_client/const.py +20 -0
- vi_api_client/credentials.py +136 -0
- vi_api_client/exceptions.py +112 -0
- vi_api_client/fixture_client.py +169 -0
- vi_api_client/fixtures/Vitocal200S.json +3968 -0
- vi_api_client/fixtures/Vitocal222S.json +6258 -0
- vi_api_client/fixtures/Vitocal250A.json +6909 -0
- vi_api_client/fixtures/Vitocal333G-with-Vitovent300F.json +4833 -0
- vi_api_client/fixtures/Vitocharge03.json +896 -0
- vi_api_client/fixtures/Vitodens200W.json +4640 -0
- vi_api_client/fixtures/Vitopure350.json +1087 -0
- vi_api_client/fixtures/discovery.json +62 -0
- vi_api_client/fixtures/event_history.json +44 -0
- vi_api_client/fixtures/event_history_final_page.json +6 -0
- vi_api_client/models.py +536 -0
- vi_api_client/parsing.py +592 -0
- vi_api_client/privacy.py +172 -0
- vi_api_client/py.typed +1 -0
- vi_api_client/utils.py +138 -0
- vi_api_client/validation.py +52 -0
- vi_api_client-6.1.1.dist-info/METADATA +264 -0
- vi_api_client-6.1.1.dist-info/RECORD +33 -0
- vi_api_client-6.1.1.dist-info/WHEEL +5 -0
- vi_api_client-6.1.1.dist-info/entry_points.txt +2 -0
- vi_api_client-6.1.1.dist-info/licenses/LICENSE +21 -0
- vi_api_client-6.1.1.dist-info/top_level.txt +1 -0
vi_api_client/auth.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Authentication module for Viessmann API."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from math import isfinite
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import TracebackType
|
|
15
|
+
from typing import Any, Self
|
|
16
|
+
from urllib.parse import urlencode
|
|
17
|
+
|
|
18
|
+
import aiohttp
|
|
19
|
+
|
|
20
|
+
from ._types import JsonValue
|
|
21
|
+
from .const import DEFAULT_SCOPES, ENDPOINT_AUTHORIZE, ENDPOINT_TOKEN
|
|
22
|
+
from .credentials import CredentialDocument
|
|
23
|
+
from .exceptions import ViAuthError, ViConnectionError, ViResponseError
|
|
24
|
+
from .validation import is_json_number, validate_json_value
|
|
25
|
+
|
|
26
|
+
_LOGGER = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Renew tokens this long before they expire so they cannot expire mid-request.
|
|
29
|
+
_TOKEN_EXPIRY_MARGIN_SECONDS = 60
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AbstractAuth(ABC):
|
|
33
|
+
"""Abstract class to make authenticated requests."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, websession: aiohttp.ClientSession | None = None) -> None:
|
|
36
|
+
"""Initialize the auth with an optional externally managed session."""
|
|
37
|
+
self._websession = websession
|
|
38
|
+
self._owns_websession = False
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def websession(self) -> aiohttp.ClientSession | None:
|
|
42
|
+
"""Return the session configured at construction or created lazily."""
|
|
43
|
+
return self._websession
|
|
44
|
+
|
|
45
|
+
async def __aenter__(self) -> Self:
|
|
46
|
+
"""Enter the authentication context."""
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
async def __aexit__(
|
|
50
|
+
self,
|
|
51
|
+
exc_type: type[BaseException] | None,
|
|
52
|
+
exc_value: BaseException | None,
|
|
53
|
+
traceback: TracebackType | None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Close resources owned by the authentication provider."""
|
|
56
|
+
await self.async_close()
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
async def async_get_access_token(self) -> str:
|
|
60
|
+
"""Return a valid access token."""
|
|
61
|
+
# Abstract method bodies never execute in concrete subclasses.
|
|
62
|
+
pass # pragma: no cover
|
|
63
|
+
|
|
64
|
+
async def _async_get_websession(self) -> aiohttp.ClientSession:
|
|
65
|
+
"""Return an available session, creating an owned one when needed."""
|
|
66
|
+
if self._websession is None:
|
|
67
|
+
self._websession = aiohttp.ClientSession()
|
|
68
|
+
self._owns_websession = True
|
|
69
|
+
return self._websession
|
|
70
|
+
|
|
71
|
+
async def async_close(self) -> None:
|
|
72
|
+
"""Close the web session only when it was created internally."""
|
|
73
|
+
if not self._owns_websession or self._websession is None:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
if not self._websession.closed:
|
|
77
|
+
await self._websession.close()
|
|
78
|
+
self._websession = None
|
|
79
|
+
self._owns_websession = False
|
|
80
|
+
|
|
81
|
+
async def request(
|
|
82
|
+
self, method: str, url: str, **kwargs: Any
|
|
83
|
+
) -> aiohttp.ClientResponse:
|
|
84
|
+
"""Make an authenticated request.
|
|
85
|
+
|
|
86
|
+
Exceptions raised by the authentication provider propagate unchanged.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ViConnectionError: If the HTTP request cannot be made.
|
|
90
|
+
"""
|
|
91
|
+
access_token = await self.async_get_access_token()
|
|
92
|
+
|
|
93
|
+
headers = kwargs.get("headers", {}).copy()
|
|
94
|
+
headers["Authorization"] = f"Bearer {access_token}"
|
|
95
|
+
kwargs["headers"] = headers
|
|
96
|
+
|
|
97
|
+
websession = await self._async_get_websession()
|
|
98
|
+
try:
|
|
99
|
+
return await websession.request(method, url, **kwargs)
|
|
100
|
+
except (TimeoutError, aiohttp.ClientError) as error:
|
|
101
|
+
raise ViConnectionError(f"Network error: {error}") from error
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class OAuth(AbstractAuth):
|
|
105
|
+
"""OAuth2 implementation for standalone usage."""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
client_id: str,
|
|
110
|
+
redirect_uri: str,
|
|
111
|
+
token_file: Path | str,
|
|
112
|
+
websession: aiohttp.ClientSession | None = None,
|
|
113
|
+
scope: str = DEFAULT_SCOPES,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Initialize OAuth.
|
|
116
|
+
|
|
117
|
+
If websession is None, a session is created lazily. Use the auth provider
|
|
118
|
+
as an async context manager or call `async_close` to release it.
|
|
119
|
+
The token file is not read here; it is loaded in a worker thread on
|
|
120
|
+
the first token request so construction never blocks the event loop.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
client_id: OAuth client ID.
|
|
124
|
+
redirect_uri: Redirect URI for authentication flow.
|
|
125
|
+
token_file: Path to file for storing tokens.
|
|
126
|
+
websession: Optional aiohttp ClientSession.
|
|
127
|
+
scope: OAuth scopes (default: default scopes).
|
|
128
|
+
"""
|
|
129
|
+
super().__init__(websession)
|
|
130
|
+
self.client_id = client_id
|
|
131
|
+
self.redirect_uri = redirect_uri
|
|
132
|
+
self.token_file: Path = Path(token_file)
|
|
133
|
+
self._credential_document = CredentialDocument(self.token_file)
|
|
134
|
+
self.scope = scope
|
|
135
|
+
self._token_info: dict[str, JsonValue] | None = None
|
|
136
|
+
self._token_load_lock = asyncio.Lock()
|
|
137
|
+
self._pkce_verifier: str | None = None
|
|
138
|
+
self._refresh_task: asyncio.Task[None] | None = None
|
|
139
|
+
|
|
140
|
+
def get_authorization_url(self) -> str:
|
|
141
|
+
"""Return the login URL and remember its PKCE verifier.
|
|
142
|
+
|
|
143
|
+
The verifier is required by `async_exchange_code_for_tokens`, so call
|
|
144
|
+
both methods on the same instance.
|
|
145
|
+
"""
|
|
146
|
+
self._pkce_verifier, code_challenge = _generate_pkce_pair()
|
|
147
|
+
|
|
148
|
+
params = {
|
|
149
|
+
"client_id": self.client_id,
|
|
150
|
+
"redirect_uri": self.redirect_uri,
|
|
151
|
+
"response_type": "code",
|
|
152
|
+
"scope": self.scope,
|
|
153
|
+
"code_challenge": code_challenge,
|
|
154
|
+
"code_challenge_method": "S256",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return f"{ENDPOINT_AUTHORIZE}?{urlencode(params)}"
|
|
158
|
+
|
|
159
|
+
async def _async_token_info(self) -> dict[str, JsonValue]:
|
|
160
|
+
"""Return the stored tokens, loading the token file once in a thread.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
ViAuthError: If the token file cannot be read or is malformed.
|
|
164
|
+
"""
|
|
165
|
+
if self._token_info is None:
|
|
166
|
+
async with self._token_load_lock:
|
|
167
|
+
if self._token_info is None:
|
|
168
|
+
self._token_info = await asyncio.to_thread(
|
|
169
|
+
self._credential_document.read
|
|
170
|
+
)
|
|
171
|
+
return self._token_info
|
|
172
|
+
|
|
173
|
+
async def _async_update_tokens(self, token_data: object) -> None:
|
|
174
|
+
"""Update internal token state and save it in a worker thread."""
|
|
175
|
+
validated_token_data = _validate_token_response(token_data)
|
|
176
|
+
token_info = await self._async_token_info()
|
|
177
|
+
token_info.update(validated_token_data)
|
|
178
|
+
|
|
179
|
+
expires_in = validated_token_data.get("expires_in")
|
|
180
|
+
if is_json_number(expires_in):
|
|
181
|
+
token_info["expires_at"] = time.time() + expires_in
|
|
182
|
+
|
|
183
|
+
await asyncio.to_thread(self._credential_document.update, dict(token_info))
|
|
184
|
+
|
|
185
|
+
async def _async_request_tokens(
|
|
186
|
+
self, form_data: Mapping[str, JsonValue], action: str
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Post a token request and store the returned tokens.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
ViAuthError: If the token endpoint rejects the request or returns
|
|
192
|
+
an invalid token response.
|
|
193
|
+
ViConnectionError: If the token request cannot be made.
|
|
194
|
+
"""
|
|
195
|
+
websession = await self._async_get_websession()
|
|
196
|
+
try:
|
|
197
|
+
async with websession.post(ENDPOINT_TOKEN, data=form_data) as response:
|
|
198
|
+
if response.status != 200:
|
|
199
|
+
raise ViAuthError(f"Failed to {action}: {await response.text()}")
|
|
200
|
+
await self._async_update_tokens(await _read_token_response(response))
|
|
201
|
+
except (TimeoutError, aiohttp.ClientError) as error:
|
|
202
|
+
raise ViConnectionError(f"Network error: {error}") from error
|
|
203
|
+
|
|
204
|
+
async def async_exchange_code_for_tokens(self, code: str) -> None:
|
|
205
|
+
"""Exchange an authorization code for tokens and store them.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
ViAuthError: If `get_authorization_url` was not called first or the
|
|
209
|
+
token endpoint rejects the code.
|
|
210
|
+
ViConnectionError: If the token endpoint cannot be reached.
|
|
211
|
+
"""
|
|
212
|
+
if not self._pkce_verifier:
|
|
213
|
+
raise ViAuthError(
|
|
214
|
+
"PKCE Verifier missing. Did you call get_authorization_url()?"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
form_data = {
|
|
218
|
+
"client_id": self.client_id,
|
|
219
|
+
"grant_type": "authorization_code",
|
|
220
|
+
"redirect_uri": self.redirect_uri,
|
|
221
|
+
"code": code,
|
|
222
|
+
"code_verifier": self._pkce_verifier,
|
|
223
|
+
}
|
|
224
|
+
await self._async_request_tokens(form_data, "fetch token")
|
|
225
|
+
_LOGGER.debug("Exchanged authorization code for tokens")
|
|
226
|
+
|
|
227
|
+
async def _async_refresh_access_token(self) -> None:
|
|
228
|
+
"""Refresh the access token through the token endpoint."""
|
|
229
|
+
token_info = await self._async_token_info()
|
|
230
|
+
refresh_token = token_info.get("refresh_token")
|
|
231
|
+
if not refresh_token:
|
|
232
|
+
raise ViAuthError("No refresh token available.")
|
|
233
|
+
|
|
234
|
+
form_data = {
|
|
235
|
+
"client_id": self.client_id,
|
|
236
|
+
"grant_type": "refresh_token",
|
|
237
|
+
"refresh_token": refresh_token,
|
|
238
|
+
}
|
|
239
|
+
_LOGGER.debug("Refreshing access token")
|
|
240
|
+
await self._async_request_tokens(form_data, "refresh token")
|
|
241
|
+
_LOGGER.debug(
|
|
242
|
+
"Access token refreshed (expires_in=%s)", token_info.get("expires_in")
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
async def async_refresh_access_token(self) -> None:
|
|
246
|
+
"""Refresh the access token, sharing an in-flight refresh per instance.
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
ViAuthError: If the refresh token is unavailable or rejected.
|
|
250
|
+
ViConnectionError: If the token endpoint cannot be reached.
|
|
251
|
+
"""
|
|
252
|
+
refresh_task = self._refresh_task
|
|
253
|
+
if refresh_task is None or refresh_task.done():
|
|
254
|
+
refresh_task = asyncio.create_task(self._async_refresh_access_token())
|
|
255
|
+
self._refresh_task = refresh_task
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
await asyncio.shield(refresh_task)
|
|
259
|
+
finally:
|
|
260
|
+
if refresh_task.done() and self._refresh_task is refresh_task:
|
|
261
|
+
self._refresh_task = None
|
|
262
|
+
|
|
263
|
+
async def async_close(self) -> None:
|
|
264
|
+
"""Let an in-flight refresh settle before closing an owned session."""
|
|
265
|
+
refresh_task = self._refresh_task
|
|
266
|
+
if refresh_task is not None:
|
|
267
|
+
try:
|
|
268
|
+
await asyncio.shield(refresh_task)
|
|
269
|
+
except (
|
|
270
|
+
ViAuthError,
|
|
271
|
+
ViConnectionError,
|
|
272
|
+
aiohttp.ClientError,
|
|
273
|
+
OSError,
|
|
274
|
+
TimeoutError,
|
|
275
|
+
ValueError,
|
|
276
|
+
):
|
|
277
|
+
_LOGGER.exception("Token refresh failed while closing authentication")
|
|
278
|
+
finally:
|
|
279
|
+
if refresh_task.done() and self._refresh_task is refresh_task:
|
|
280
|
+
self._refresh_task = None
|
|
281
|
+
|
|
282
|
+
await super().async_close()
|
|
283
|
+
|
|
284
|
+
async def async_get_access_token(self) -> str:
|
|
285
|
+
"""Return a valid access token, refreshing it shortly before expiry.
|
|
286
|
+
|
|
287
|
+
Raises:
|
|
288
|
+
ViAuthError: If no usable token is stored or the refresh fails.
|
|
289
|
+
ViConnectionError: If a needed refresh cannot reach the token
|
|
290
|
+
endpoint.
|
|
291
|
+
"""
|
|
292
|
+
token_info = await self._async_token_info()
|
|
293
|
+
if not token_info:
|
|
294
|
+
raise ViAuthError("No tokens loaded. Please authenticate first.")
|
|
295
|
+
|
|
296
|
+
expires_at = token_info.get("expires_at")
|
|
297
|
+
if (
|
|
298
|
+
is_json_number(expires_at)
|
|
299
|
+
and time.time() < expires_at - _TOKEN_EXPIRY_MARGIN_SECONDS
|
|
300
|
+
):
|
|
301
|
+
return _access_token_value(token_info)
|
|
302
|
+
|
|
303
|
+
if "refresh_token" in token_info:
|
|
304
|
+
await self.async_refresh_access_token()
|
|
305
|
+
return _access_token_value(token_info)
|
|
306
|
+
|
|
307
|
+
# Without the offline_access scope there is no refresh token to renew with.
|
|
308
|
+
_LOGGER.warning(
|
|
309
|
+
"No refresh token available; using possibly expired access token"
|
|
310
|
+
)
|
|
311
|
+
return _access_token_value(token_info)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _generate_pkce_pair() -> tuple[str, str]:
|
|
315
|
+
"""Return a PKCE code verifier and its S256 code challenge (RFC 7636)."""
|
|
316
|
+
# 96 random bytes encode to 128 URL-safe characters, the RFC's maximum.
|
|
317
|
+
verifier = secrets.token_urlsafe(96)
|
|
318
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
319
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
320
|
+
return verifier, challenge
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _access_token_value(token_info: Mapping[str, JsonValue]) -> str:
|
|
324
|
+
"""Return the validated current access token."""
|
|
325
|
+
access_token = token_info.get("access_token")
|
|
326
|
+
if not isinstance(access_token, str) or not access_token:
|
|
327
|
+
raise ViAuthError("No valid access token available.")
|
|
328
|
+
return access_token
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _validate_token_response(token_data: object) -> dict[str, JsonValue]:
|
|
332
|
+
"""Validate a successful OAuth token response before updating state."""
|
|
333
|
+
try:
|
|
334
|
+
validated_token_data = validate_json_value(token_data, path="Token response")
|
|
335
|
+
except ViResponseError as error:
|
|
336
|
+
raise ViAuthError("Token response contains invalid JSON data") from error
|
|
337
|
+
if not isinstance(validated_token_data, dict):
|
|
338
|
+
raise ViAuthError("Token response must be a JSON object")
|
|
339
|
+
|
|
340
|
+
access_token = validated_token_data.get("access_token")
|
|
341
|
+
if not isinstance(access_token, str) or not access_token:
|
|
342
|
+
raise ViAuthError("Token response access_token must be a non-empty string")
|
|
343
|
+
for field_name in ("refresh_token", "token_type"):
|
|
344
|
+
value = validated_token_data.get(field_name)
|
|
345
|
+
if value is not None and not isinstance(value, str):
|
|
346
|
+
raise ViAuthError(f"Token response {field_name} must be a string")
|
|
347
|
+
expires_in = validated_token_data.get("expires_in")
|
|
348
|
+
if expires_in is not None and (
|
|
349
|
+
not is_json_number(expires_in) or not isfinite(expires_in) or expires_in < 0
|
|
350
|
+
):
|
|
351
|
+
raise ViAuthError(
|
|
352
|
+
"Token response expires_in must be a non-negative finite number"
|
|
353
|
+
)
|
|
354
|
+
return validated_token_data
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
async def _read_token_response(response: aiohttp.ClientResponse) -> object:
|
|
358
|
+
"""Read a successful token response as JSON with library-owned errors."""
|
|
359
|
+
try:
|
|
360
|
+
return await response.json()
|
|
361
|
+
except aiohttp.ClientConnectionError, aiohttp.ClientPayloadError:
|
|
362
|
+
# An incomplete body is a network failure, which the caller reports.
|
|
363
|
+
raise
|
|
364
|
+
except (aiohttp.ClientError, json.JSONDecodeError, UnicodeDecodeError) as error:
|
|
365
|
+
raise ViAuthError("Token response contains invalid JSON data") from error
|